diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1dad215f5..ee6910b3b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,12 +13,14 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + with: + fetch-depth: 0 - name: Install clang-format run: pip install clang-format==22.1.3 - name: Run format check - run: bash tools/format.sh --check + run: bash tools/format.sh --check-changed "${{ github.event.pull_request.base.sha }}" commitlint: name: Validate Commits @@ -50,3 +52,26 @@ jobs: - name: Build run: bash tools/build.sh shell: bash + + hle-tests: + name: HLE Tests + runs-on: ubuntu-latest + continue-on-error: true + steps: + - uses: actions/checkout@v4 + + - name: Install dependencies + run: | + sudo apt-get update + sudo apt-get install -y libsdl2-dev + + - name: Configure + run: cmake -B build -S tools/hle + env: + SDL_VIDEODRIVER: dummy + + - name: Build tests + run: cmake --build build --target hle_tests -j$(nproc) + + - name: Run tests + run: ./build/hle_tests diff --git a/.releaserc.js b/.releaserc.js index fd18ea8db..4b3355cde 100644 --- a/.releaserc.js +++ b/.releaserc.js @@ -78,19 +78,13 @@ module.exports = { [ "@semantic-release/exec", { - prepareCmd: - "sed -i 's/\"version\": *\"[^\"]*\"/\"version\": \"${nextRelease.version}\"/' firmware_p4/assets/config/OTA/firmware.json firmware_c5/assets/config/OTA/firmware.json && sed -i 's/#define FIRMWARE_VERSION \"[^\"]*\"/#define FIRMWARE_VERSION \"${nextRelease.version}\"/' firmware_p4/components/Service/ota/include/ota_version.h", + prepareCmd: "printf '%s\\n' '${nextRelease.version}' > common/metadata/version_info.txt", }, ], [ "@semantic-release/git", { - assets: [ - "CHANGELOG.md", - "firmware_p4/assets/config/OTA/firmware.json", - "firmware_c5/assets/config/OTA/firmware.json", - "firmware_p4/components/Service/ota/include/ota_version.h", - ], + assets: ["CHANGELOG.md", "common/metadata/version_info.txt"], message: "chore(release): v${nextRelease.version}", }, ], diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..d2f416f06 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,120 @@ +# TentacleOS — Agent Instructions + +## Project Overview + +Embedded firmware for the High Boy platform (ESP32-S3/P4/C5). Dual-firmware architecture: ESP32-C5 is the co-processor, ESP32-P4 is the master that embeds the C5 binary at build time. + +## Build & Flash + +ESP-IDF v5.5.3 is the pinned version. Source the environment before any build command: + +```bash +. $HOME/esp/v5.5.3/esp-idf/export.sh # or rely on $IDF_PATH +``` + +Full build (C5 then P4, in order — P4 embeds the C5 binary): + +```bash +./tools/build.sh +``` + +Build a single target: + +```bash +cd firmware_c5 && idf.py -DIDF_TARGET=esp32c5 build +cd firmware_p4 && idf.py -DIDF_TARGET=esp32p4 build +``` + +Flash (P4 only, which also programs C5): + +```bash +./tools/flash.sh +``` + +## Formatting + +Formatting is enforced by CI and the pre-commit hook. Run manually before committing: + +```bash +./tools/format.sh # fix all firmware sources +./tools/format.sh --check # verify all firmware sources +./tools/format.sh --changed # fix files changed from a base +./tools/format.sh --check-changed # verify changed files (CI uses this) +``` + +Config: `.clang-format` — LLVM base, 2-space indent, 100-column limit, pointers right-aligned, no sorted includes. Only `.c` and `.h` files under `firmware_*/` are formatted. + +## Commit Messages + +Conventional Commits enforced by git hook and CI. Format: `(): ` + +Types: `feat`, `fix`, `docs`, `style`, `refactor`, `perf`, `test`, `chore`, `ci`, `build`, `revert`, `delete`, `deleted`, `remove`, `removed` + +Breaking changes use `!` before the colon. Header max length: 200 chars. + +## Repo Structure + +``` +firmware_c5/ # ESP32-C5 co-processor firmware +firmware_p4/ # ESP32-P4 master firmware (embeds C5 binary) + components/ + Drivers/ # Hardware drivers (SPI, buttons, display, radio, USB) + Service/ # Support services (OTA, WiFi, console, storage, SPI bridge) + Core/ # System core and main managers + Applications/ # User-facing apps (UI, bad_usb, SubGhz) + Drivers/spi_bridge_phy/ # SPI bridge physical layer (P4 side) + main/main.c # Entry point +common/metadata/ # Shared metadata +tools/ # Build, flash, format, setup scripts +``` + +Each target is a standalone ESP-IDF project with its own `CMakeLists.txt`, `partitions.csv`, and `sdkconfig.defaults`. + +## Coding Standards + +All rules are in `CODING_STANDARDS.md`. Key points an agent must not miss: + +- **Public functions** are prefixed with module name (`cc1101_set_frequency()`). **Static functions** drop the prefix (`process_pulse()`). +- **Variables**: local `snake_case`, static `s_` prefix, global `g_` prefix, bools `is_`/`has_`/`can_`, output params `out_`. +- **Types**: `module_name_t` with `_cb_t` for callbacks. +- **Constants**: `UPPER_SNAKE_CASE`. Every literal with domain meaning must be a named `#define` (except `0`, `1`, `NULL`, `true`, `false`). +- **Enums**: `UPPER_SNAKE_CASE` with module prefix, include `_COUNT` sentinel when iterable. +- **Fixed-width types** from `` for all hardware code. Never rely on implicit `int`. +- **Error handling**: public functions return `esp_err_t`. Every `malloc` must be checked for `NULL`, logged with `ESP_LOGE`, and handled via `goto cleanup`. +- **Logging**: every `.c` file defines `static const char *TAG = "MODULE_NAME";`. Use `ESP_LOGx` macros, never `printf`. +- **File layout order**: license, own header, C stdlib, ESP-IDF/FreeRTOS, project headers, defines, static types, static vars, forward decls, public funcs, static funcs. +- **Headers**: `#ifndef` guards, `extern "C"` blocks, include order separated by blank lines. +- **Source file layout**: `^[0-9a-z_]+\.[ch]$`, filename is a prefix for its content. + +## Hook Setup + +Run once after cloning: + +```bash +./tools/setup.sh +``` + +This sets `core.hooksPath` to `.githooks/` (pre-commit: clang-format, commit-msg: conventional commits). + +## CI + +GitHub Actions runs on PRs to `main`/`dev`: +1. **Format check** — `./tools/format.sh --check` +2. **Commitlint** — validates all commits in PR +3. **Build** — both targets inside `espressif/idf:v5.5.3` container via `./tools/build.sh` + +All must pass before merge. + +## Generated Files (do not edit) + +- `managed_components/` — ESP-IDF component manager (gitignored) +- `sdkconfig` / `sdkconfig.old` — ESP-IDF build config (gitignored); use `sdkconfig.defaults` instead +- `dependencies.lock` — ESP-IDF component manager lockfile (gitignored) +- Build outputs in `build/` directories (gitignored) + +## Versioning + +Automated via semantic-release on `main`. Do not manage versions manually — commit messages determine bumps. Version is written to: +- `firmware_p4/assets/config/OTA/firmware.json` +- `firmware_c5/assets/config/OTA/firmware.json` +- `firmware_p4/components/Service/ota/include/ota_version.h` diff --git a/CODING_STANDARDS.md b/CODING_STANDARDS.md index b830eaf8e..a9371896c 100644 --- a/CODING_STANDARDS.md +++ b/CODING_STANDARDS.md @@ -188,6 +188,30 @@ cleanup: - All shared state must be initialized before `xTaskCreate`. - Tasks must release all resources before `vTaskDelete`. Set handles to `NULL` after deletion. +### Task priority and core affinity + +- Every task priority and core comes from `sys_prio.h` (in `Drivers/sys_prio/include`, the + one component every task-creating module already depends on). Never pass a raw number or a + private `#define` for a task priority or core. +- Always pin tasks with `xTaskCreatePinnedToCore` (or `xTaskCreateStaticPinnedToCore`). + Do not use the unpinned `xTaskCreate` / `xTaskCreateStatic`. +- Priority bands (higher number is higher priority): + + | Macro | Prio | Use | + |-------|------|-----| + | `SYS_PRIO_REALTIME` | 10 | Deferred ISR / hard real-time (radio IRQ) | + | `SYS_PRIO_RENDER` | 6 | LVGL renderer only | + | `SYS_PRIO_SERVICE_HI` | 5 | Latency-sensitive services (host link, radio rx/tx, streaming) | + | `SYS_PRIO_SERVICE_LO` | 4 | Regular services (media playback, capture, UI helpers) | + | `SYS_PRIO_BACKGROUND` | 3 | Periodic polling, logging, telemetry | + | `SYS_PRIO_BACKGROUND_LO` | 2 | Lowest non-idle background work | + | `SYS_PRIO_MONITOR` | 1 | Health monitor | + +- Core affinity: `SYS_CORE_UI` (core 1) runs the renderer plus everything that feeds the + screen (media, capture, UI helper tasks). `SYS_CORE_RADIO` (core 0) runs radios, host + link, bridge, storage and background services. The renderer must never share a core with + a radio or USB stream. + ## Headers diff --git a/README.md b/README.md index cd610862f..36f6cb9b8 100644 --- a/README.md +++ b/README.md @@ -22,9 +22,8 @@ We are expanding support for the latest Espressif chips: | Target | Status | | :--- | :--- | -| **ESP32-S3** | Main Development | -| **ESP32-P4** | Experimental (firmware_p4) | -| **ESP32-C5** | Experimental (firmware_c5) | +| **ESP32-P4** | Main Development | +| **ESP32-C5** | Main Development | ## Firmware Structure @@ -70,6 +69,140 @@ Example layout: ``` +## Native HLE simulator + +The host-level emulation (HLE) target runs the P4 UI, LVGL, host-backed storage, +and a simulated C5 SPI bridge on Linux. It is intended for UI and firmware-flow +development without a connected High Boy. + +

+ TentacleOS HLE emulator boot screen +
+ Boot screen rendered by the native SDL simulator. +

+ +### Requirements + +- Linux +- CMake 3.16 or newer +- A C11/C++17 toolchain +- Git and the SDL2 development headers +- Internet access during the first configure, which downloads LVGL, cJSON, and + GoogleTest + +On Ubuntu or Debian: + +```bash +sudo apt update +sudo apt install build-essential cmake git libsdl2-dev +``` + +ESP-IDF, an ESP32 toolchain, and connected High Boy hardware are not required +for the native simulator. + +### Build and run + +Run these commands from the repository root: + +```bash +cmake -S tools/hle -B build +cmake --build build --target hle_interactive -j +./build/hle_interactive +``` + +The first build also converts the assets under `firmware_p4/assets`. After UI +or firmware changes, rerun the `cmake --build` command and restart the +simulator; reconfiguration is only needed after CMake or source-layout changes. + +### Controls + +| High Boy input | Keyboard | +| :--- | :--- | +| Directional buttons | Arrow keys or W/A/S/D | +| OK | Enter, keypad Enter, or Space | +| Back | Backspace or Escape | +| Exit simulator | Ctrl+Q or close the window | + +### Storage + +The simulator stores `/sdcard` data under `/tmp/hle_storage` by default. +Override the location with `HLE_STORAGE_PATH`: + +```bash +HLE_STORAGE_PATH="$HOME/.local/state/tentacleos-hle" ./build/hle_interactive +``` + +Point `HLE_STORAGE_PATH` at a new empty directory to exercise the firmware's +first-boot flow again. + +### Headless snapshots + +For deterministic, headless UI snapshots: + +```bash +SDL_VIDEODRIVER=dummy \ +HLE_SNAPSHOT_PATH=/tmp/high-boy.ppm \ +HLE_SNAPSHOT_MS=6500 \ +./build/hle_interactive +``` + +The snapshot example renders for 6500 ms, writes a PPM image, and exits. It is +also suitable for CI or SSH sessions without a display server. + +### Tests + +Run the native regression suite with: + +```bash +cmake --build build --target hle_tests -j +ctest --test-dir build --output-on-failure +``` + +#### Example: testing display output + +Every `*.cpp` file under `tools/hle/tests` is compiled into `hle_tests` and +automatically registered with GoogleTest. For example, create +`tools/hle/tests/test_my_ui.cpp`: + +```cpp +#include +#include + +#include + +#include "hle/hle_display.h" + +TEST(MyUIScreen, DrawsExpectedPixel) { + auto &display = hle::Display::instance(); + display.fill_screen(0); + + constexpr uint16_t expected_color = 0xF81F; + display.draw_bitmap(12, 20, 13, 21, &expected_color); + + std::array framebuffer{}; + ASSERT_TRUE(display.copy_pixels_if_dirty( + framebuffer.data(), hle::LCD_H_RES * sizeof(uint16_t))); + EXPECT_EQ(framebuffer[(20 * hle::LCD_H_RES) + 12], expected_color); +} +``` + +Build and run only that test: + +```bash +cmake --build build --target hle_tests -j +./build/hle_tests --gtest_filter=MyUIScreen.DrawsExpectedPixel +``` + +Use the same pattern for NVS, SPI bridge, input, and other host-emulated +contracts. Tests that include C firmware headers should place those includes +inside an `extern "C"` block. + +### Scope and limitations + +The HLE covers UI and host-emulated firmware flows. Wi-Fi, Bluetooth, radio, +and other physical-hardware behavior still require target testing. + + ## How to Contribute Contributions are what make the open-source community such an amazing place to learn, inspire, and create. Any contributions you make are **greatly appreciated**. diff --git a/README.pt.md b/README.pt.md index f0b46d84c..a98a519b0 100644 --- a/README.pt.md +++ b/README.pt.md @@ -23,9 +23,8 @@ Estamos expandindo o suporte para os chips mais recentes da Espressif: | Alvo | Status | | :--- | :--- | -| **ESP32-S3** | Desenvolvimento Principal | -| **ESP32-P4** | Experimental (firmware_p4) | -| **ESP32-C5** | Experimental (firmware_c5) | +| **ESP32-P4** | Desenvolvimento Principal | +| **ESP32-C5** | Desenvolvimento Principal | --- @@ -71,6 +70,142 @@ Exemplo de layout: └── README.md ``` +## Simulador HLE nativo + +O alvo de emulação de alto nível (HLE) executa a interface do P4, o LVGL, o +armazenamento no host e uma ponte SPI simulada para o C5 no Linux. Ele permite +desenvolver a interface e os fluxos do firmware sem conectar um High Boy. + +

+ Tela de inicialização do emulador HLE do TentacleOS +
+ Tela de inicialização renderizada pelo simulador SDL nativo. +

+ +### Requisitos + +- Linux +- CMake 3.16 ou mais recente +- Um compilador compatível com C11/C++17 +- Git e os cabeçalhos de desenvolvimento do SDL2 +- Acesso à internet durante a primeira configuração, que baixa LVGL, cJSON e + GoogleTest + +No Ubuntu ou Debian: + +```bash +sudo apt update +sudo apt install build-essential cmake git libsdl2-dev +``` + +O simulador nativo não exige ESP-IDF, um toolchain ESP32 ou um High Boy +conectado. + +### Compilar e executar + +Execute estes comandos a partir da raiz do repositório: + +```bash +cmake -S tools/hle -B build +cmake --build build --target hle_interactive -j +./build/hle_interactive +``` + +A primeira compilação também converte os assets em `firmware_p4/assets`. Após +alterações na interface ou no firmware, execute novamente o comando +`cmake --build` e reinicie o simulador. Só é necessário reconfigurar após +alterações no CMake ou na estrutura dos arquivos-fonte. + +### Controles + +| Entrada do High Boy | Teclado | +| :--- | :--- | +| Botões direcionais | Setas ou W/A/S/D | +| OK | Enter, Enter do teclado numérico ou Espaço | +| Voltar | Backspace ou Escape | +| Sair do simulador | Ctrl+Q ou fechar a janela | + +### Armazenamento + +Por padrão, o simulador armazena os dados de `/sdcard` em `/tmp/hle_storage`. +Use `HLE_STORAGE_PATH` para escolher outro local: + +```bash +HLE_STORAGE_PATH="$HOME/.local/state/tentacleos-hle" ./build/hle_interactive +``` + +Use um diretório novo e vazio em `HLE_STORAGE_PATH` para executar novamente o +fluxo de primeira inicialização do firmware. + +### Capturas sem interface gráfica + +Para gerar capturas determinísticas da interface sem abrir uma janela: + +```bash +SDL_VIDEODRIVER=dummy \ +HLE_SNAPSHOT_PATH=/tmp/high-boy.ppm \ +HLE_SNAPSHOT_MS=6500 \ +./build/hle_interactive +``` + +O exemplo gera a interface por 6500 ms, grava uma imagem PPM e encerra. Ele +também pode ser usado em CI ou em sessões SSH sem servidor gráfico. + +### Testes + +Execute os testes nativos com: + +```bash +cmake --build build --target hle_tests -j +ctest --test-dir build --output-on-failure +``` + +#### Exemplo: testar a saída do display + +Cada arquivo `*.cpp` em `tools/hle/tests` é compilado no executável `hle_tests` +e registrado automaticamente no GoogleTest. Por exemplo, crie +`tools/hle/tests/test_my_ui.cpp`: + +```cpp +#include +#include + +#include + +#include "hle/hle_display.h" + +TEST(MyUIScreen, DrawsExpectedPixel) { + auto &display = hle::Display::instance(); + display.fill_screen(0); + + constexpr uint16_t expected_color = 0xF81F; + display.draw_bitmap(12, 20, 13, 21, &expected_color); + + std::array framebuffer{}; + ASSERT_TRUE(display.copy_pixels_if_dirty( + framebuffer.data(), hle::LCD_H_RES * sizeof(uint16_t))); + EXPECT_EQ(framebuffer[(20 * hle::LCD_H_RES) + 12], expected_color); +} +``` + +Compile e execute somente esse teste: + +```bash +cmake --build build --target hle_tests -j +./build/hle_tests --gtest_filter=MyUIScreen.DrawsExpectedPixel +``` + +Use o mesmo padrão para NVS, ponte SPI, entrada e outros contratos emulados no +host. Testes que incluem cabeçalhos C do firmware devem colocar essas inclusões +dentro de um bloco `extern "C"`. + +### Escopo e limitações + +O HLE cobre a interface e os fluxos emulados do firmware. Wi-Fi, Bluetooth, +rádio e outros comportamentos de hardware físico ainda exigem testes no +dispositivo. + + --- ## Como Contribuir diff --git a/commitlint.config.js b/commitlint.config.js index 7da37b223..672f0d4d1 100644 --- a/commitlint.config.js +++ b/commitlint.config.js @@ -4,7 +4,8 @@ module.exports = { 'type-enum': [2, 'always', [ 'feat', 'fix', 'docs', 'style', 'refactor', 'perf', 'test', 'chore', 'ci', 'build', 'revert', - 'delete', 'deleted', 'remove', 'removed' + 'delete', 'deleted', 'remove', 'removed', + 'diag', 'tweak' ]], 'scope-case': [0], 'subject-case': [0], diff --git a/common/metadata/ota_version.h.in b/common/metadata/ota_version.h.in new file mode 100644 index 000000000..6a896ae64 --- /dev/null +++ b/common/metadata/ota_version.h.in @@ -0,0 +1,16 @@ +// Generated at build time from common/metadata/version_info.txt. Do not edit. + +#ifndef OTA_VERSION_H +#define OTA_VERSION_H + +#ifdef __cplusplus +extern "C" { +#endif + +#define FIRMWARE_VERSION "@FW_VERSION@" + +#ifdef __cplusplus +} +#endif + +#endif // OTA_VERSION_H diff --git a/common/metadata/version_info.txt b/common/metadata/version_info.txt index 3eefcb9dd..3a3cd8cc8 100644 --- a/common/metadata/version_info.txt +++ b/common/metadata/version_info.txt @@ -1 +1 @@ -1.0.0 +1.3.1 diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 000000000..4a7f1569d --- /dev/null +++ b/docs/README.md @@ -0,0 +1,45 @@ +# Documentation hub + +Aggregated, canonical copies of the project documentation, one directory per +component. Each component's in-tree README points back to its copy here. +Components present in both firmwares keep `# P4` and `# C5` sections in one +README.md, separated by `---`. + +## Featured + +- [host_link/](host_link/README.md) - companion app link: overview, [app implementation guide](host_link/app-guide.md), [protocol spec](host_link/protocol.md), per-firmware sections +- [spi_bridge/](spi_bridge/README.md) - P4<->C5 SPI bridge: architecture + per-firmware sections + +## All components + +| Component | Docs | +|-----------|------| +| `bad_usb` | [README.md](bad_usb/README.md) | +| `bluetooth` | [README.md](bluetooth/README.md) | +| `boot_report` | [README.md](boot_report/README.md) | +| `buttons_gpio` | [README.md](buttons_gpio/README.md) | +| `c5_flasher` | [README.md](c5_flasher/README.md) | +| `cc1101` | [README.md](cc1101/README.md) | +| `console` | [README.md](console/README.md) | +| `dns_server` | [README.md](dns_server/README.md) | +| `esp_now` | [README.md](esp_now/README.md) | +| `espnow_chat` | [README.md](espnow_chat/README.md) | +| `host_link` | [app-guide.md](host_link/app-guide.md) [protocol.md](host_link/protocol.md) [README.md](host_link/README.md) | +| `http_server` | [README.md](http_server/README.md) | +| `input_manager` | [README.md](input_manager/README.md) | +| `lvgl_port` | [README.md](lvgl_port/README.md) | +| `ota` | [README.md](ota/README.md) | +| `recovery` | [README.md](recovery/README.md) | +| `sd_card` | [README.md](sd_card/README.md) | +| `spi` | [README.md](spi/README.md) | +| `spi_bridge` | [README.md](spi_bridge/README.md) | +| `st7789` | [README.md](st7789/README.md) | +| `storage_api` | [README.md](storage_api/README.md) | +| `storage_assets` | [README.md](storage_assets/README.md) | +| `storage_vfs` | [README.md](storage_vfs/README.md) | +| `SubGhz` | [README.md](SubGhz/README.md) | +| `sys_monitor` | [README.md](sys_monitor/README.md) | +| `sys_prio` | [README.md](sys_prio/README.md) | +| `tusb_desc` | [README.md](tusb_desc/README.md) | +| `ui` | [README.md](ui/README.md) [input-migration.md](ui/input-migration.md) | +| `wifi` | [README.md](wifi/README.md) | diff --git a/docs/SubGhz/README.md b/docs/SubGhz/README.md new file mode 100644 index 000000000..e8b2c7376 --- /dev/null +++ b/docs/SubGhz/README.md @@ -0,0 +1,279 @@ +# SubGhz Application + +This component implements the complete Sub-GHz RF application layer: signal reception (with protocol decoding and frequency hopping), raw/encoded transmission, spectrum analysis, signal analysis, and file serialization. It sits on top of the `cc1101` driver and uses the ESP-IDF RMT peripheral for precise pulse timing. + +## Overview + +- **Location:** `components/Applications/SubGhz/` +- **Dependencies:** `cc1101`, `driver/rmt_rx`, `driver/rmt_tx`, `freertos`, `pin_def` +- **RMT Resolution:** 1 MHz (1 us per tick) +- **RX GPIO:** GPIO 8 (GDO0 via `GPIO_SDA_PIN`) +- **TX GPIO:** GDO2 (via `GPIO_SCL_PIN`) + +## Architecture + +``` +┌─────────────────────────────────────────────────────┐ +│ SubGhz App │ +│ │ +│ ┌──────────┐ ┌──────────────┐ ┌───────────────┐ │ +│ │ Receiver │ │ Transmitter │ │ Spectrum │ │ +│ │ (RMT RX) │ │ (RMT TX) │ │ Analyzer │ │ +│ └────┬─────┘ └──────┬───────┘ └───────┬───────┘ │ +│ │ │ │ │ +│ ┌────┴─────┐ ┌────┴─────┐ ┌──────┴───────┐ │ +│ │ Protocol │ │ Queue │ │ RSSI Sweep │ │ +│ │ Registry │ │ Worker │ │ (80 bins) │ │ +│ └────┬─────┘ └──────────┘ └──────────────┘ │ +│ │ │ +│ ┌────┴─────┐ ┌──────────────┐ ┌───────────────┐ │ +│ │ Analyzer │ │ Serializer │ │ Storage │ │ +│ │(Histogram)│ │ (.sub files) │ │ (SD Card) │ │ +│ └──────────┘ └──────────────┘ └───────────────┘ │ +└─────────────────────────────────────────────────────┘ + │ + ┌─────────┴─────────┐ + │ CC1101 Driver │ + │ (SPI Bus) │ + └───────────────────┘ +``` + +## Modules + +### Receiver (`subghz_receiver`) + +Captures RF signals via the CC1101 GDO0 pin routed to the ESP32 RMT RX peripheral. Runs as a FreeRTOS task pinned to Core 1. + +**Operating Modes:** + +| Mode | Behavior | +|------|----------| +| `SUBGHZ_MODE_SCAN` | Decodes signals via protocol registry. Unknown signals are analyzed and saved as RAW. | +| `SUBGHZ_MODE_RAW` | Captures and saves all raw pulse data without decoding. | + +**Frequency Hopping:** When `freq == 0` is passed to `subghz_receiver_start`, the receiver cycles through 12 predefined frequencies (433.92, 868.35, 315, 300, 390, 418, 915 MHz, etc.) every 5 seconds. + +**Signal Processing Pipeline:** +1. RMT hardware captures pulse timings (min 1 us, idle timeout 10 ms) +2. Software filter removes pulses < 15 us +3. Pulses converted to signed int32 buffer (positive = HIGH, negative = LOW) +4. **SCAN mode:** Protocol registry tries all decoders -> Analyzer for unknowns +5. **RAW mode:** Direct save to storage + +#### API + +```c +esp_err_t subghz_receiver_start(subghz_mode_t mode, cc1101_preset_t preset, uint32_t freq); +void subghz_receiver_stop(void); +bool subghz_receiver_is_running(void); +``` +- `freq = 0` enables frequency hopping mode. +- Returns `ESP_OK` on success, `ESP_ERR_INVALID_STATE` if already running, `ESP_ERR_NO_MEM` on task creation failure. +- Task stack: 8192 bytes, priority 5, Core 1. + +### Transmitter (`subghz_transmitter`) + +Asynchronous queue-based transmitter. Converts signed pulse timings to RMT symbols and transmits via CC1101 GDO2 in async mode. + +**Flow:** `subghz_tx_send_raw()` -> FreeRTOS Queue -> TX Task -> RMT TX -> CC1101 + +#### API + +```c +esp_err_t subghz_tx_init(void); +void subghz_tx_stop(void); +esp_err_t subghz_tx_send_raw(const int32_t *timings, size_t count); +``` +- `subghz_tx_init` returns `ESP_OK` on success, `ESP_ERR_NO_MEM` on queue creation failure. +- `subghz_tx_send_raw` returns `ESP_OK` on success, `ESP_ERR_INVALID_ARG` if not running or invalid params, `ESP_ERR_NO_MEM` on allocation failure, `ESP_ERR_TIMEOUT` if queue is full. +- Queue depth: 10 items. Drops packets if full. +- Timing data is copied internally; caller retains ownership of the original buffer. +- Max RMT symbol duration: 32767 us per pulse. +- Task stack: 4096 bytes, priority 5, Core 1. + +### Spectrum Analyzer (`subghz_spectrum`) + +Sweeps across a frequency span by stepping the CC1101 through discrete frequencies and reading RSSI values. Produces 80-sample spectral lines. + +**Sweep Process:** +1. Divides the span into 80 frequency steps +2. For each step: tune CC1101, wait 400 us stabilization, take 3 RSSI peak samples +3. Updates a mutex-protected global `subghz_spectrum_line_t` structure + +#### Data Structure + +```c +typedef struct { + uint32_t center_freq; + uint32_t span_hz; + uint32_t start_freq; + uint32_t step_hz; + float dbm_values[SPECTRUM_SAMPLES]; + uint64_t timestamp; +} subghz_spectrum_line_t; +``` + +#### API + +```c +void subghz_spectrum_start(uint32_t center_freq, uint32_t span_hz); +void subghz_spectrum_stop(void); +bool subghz_spectrum_get_line(subghz_spectrum_line_t *out_line); +``` +- Task stack: 4096 bytes, priority 1, Core 1. +- Thread-safe reads via `subghz_spectrum_get_line`. + +### Signal Analyzer (`subghz_analyzer`) + +Analyzes unknown signals by building a pulse duration histogram to estimate modulation parameters and recover bitstreams. + +**Analysis Steps:** +1. **Histogram:** Builds 50 us bins (up to 5000 us) from absolute pulse durations +2. **TE Estimation:** First significant histogram peak = estimated Time Element +3. **Modulation Heuristic:** 2 peaks = Manchester/Biphase, 3+ peaks = PWM/Tri-state +4. **Bitstream Recovery:** Slices pulses into TE-sized bits using edge-to-edge detection + +#### Data Structure + +```c +typedef struct { + uint32_t estimated_te; + uint32_t pulse_min; + uint32_t pulse_max; + size_t pulse_count; + const char *modulation_hint; + uint8_t bitstream[128]; + size_t bitstream_len; +} subghz_analyzer_result_t; +``` + +#### API + +```c +bool subghz_analyzer_process(const int32_t *pulses, size_t count, subghz_analyzer_result_t *out_result); +``` +- Requires minimum 10 pulses. Filters durations < 50 us as noise. + +### Protocol Serializer (`subghz_protocol_serializer`) + +Serializes and parses `.sub` file format for decoded and raw signals. + +**File Format:** +``` +Filetype: High Boy SubGhz File +Version 1 +Frequency: 433920000 +Preset: 6 +Protocol: Princeton +Bit: 24 +Key: 00 00 00 00 XX XX XX XX +TE: 350 +``` + +RAW variant replaces Protocol/Bit/Key/TE with: +``` +Protocol: RAW +RAW_Data: 350 -700 350 -350 700 -350 ... +``` + +#### API + +```c +uint8_t subghz_protocol_get_preset_id(void); +size_t subghz_protocol_serialize_decoded(const subghz_data_t *data, uint32_t frequency, uint32_t te, char *out_buf, size_t out_size); +size_t subghz_protocol_serialize_raw(const int32_t *pulses, size_t count, uint32_t frequency, char *out_buf, size_t out_size); +size_t subghz_protocol_parse_raw(const char *content, int32_t *out_pulses, size_t max_count, uint32_t *out_frequency, uint8_t *out_preset); +``` + +### Storage (`subghz_storage`) + +Saves captured signals to persistent storage using the serializer. Currently operates in placeholder mode (outputs to log). + +#### API + +```c +esp_err_t subghz_storage_init(void); +esp_err_t subghz_storage_save_decoded(const char *name, const subghz_data_t *data, uint32_t frequency, uint32_t te); +esp_err_t subghz_storage_save_raw(const char *name, const int32_t *pulses, size_t count, uint32_t frequency); +``` +- Returns `ESP_OK` on success, `ESP_ERR_INVALID_ARG` on null arguments, `ESP_ERR_NO_MEM` on allocation failure. + +## Protocol Plugins (`protocols/`) + +The protocol system follows a **plugin architecture**. Each protocol is a self-contained module (e.g., `protocol_princeton.c`) that implements a common interface and is registered in a central registry. This design allows adding support for new protocols without modifying existing code - just create a new `protocol_*.c` file, implement the `subghz_protocol_t` interface, and register it in `subghz_protocol_registry.c`. + +### Plugin Interface + +Every protocol plugin must export a `subghz_protocol_t` struct with two function pointers: + +```c +typedef struct { + const char *name; + bool (*decode)(const int32_t *pulses, size_t count, subghz_data_t *out_data); + size_t (*encode)(const subghz_data_t *data, int32_t *pulses, size_t max_count); +} subghz_protocol_t; +``` + +- **`decode`**: Receives raw pulse timings and attempts to recognize the protocol. Returns `true` if the signal matches, filling `out_data` with serial, button, bit count, and raw value. +- **`encode`**: Converts structured data back into pulse timings for retransmission. + +### How It Works + +1. Each plugin file declares a global `subghz_protocol_t` (e.g., `protocol_princeton`) +2. The registry (`subghz_protocol_registry.c`) holds an array of pointers to all registered plugins +3. On signal reception, `subghz_protocol_registry_decode_all()` iterates through all plugins in order, calling each `decode()` until one claims the signal +4. If no plugin matches, the signal falls through to the `subghz_analyzer` for heuristic analysis + +### Adding a New Protocol Plugin + +1. Create `protocols/protocol_mydevice.c` +2. Implement `decode()` and optionally `encode()` +3. Export: `subghz_protocol_t protocol_mydevice = { .name = "MyDevice", .decode = ..., .encode = ... };` +4. Register in `subghz_protocol_registry.c`: + - Add `extern subghz_protocol_t protocol_mydevice;` + - Add `&protocol_mydevice` to the `s_protocols[]` array + +### Registered Plugins + +| Plugin | Modulation | Typical Use | +|--------------|------------|------------------------------| +| RCSwitch | OOK/PWM | Generic remote switches | +| Princeton | OOK/PWM | Fixed-code remotes | +| CAME | OOK/PWM | Gate/garage remotes | +| Nice FLO | OOK/PWM | Gate/garage remotes | +| Ansonic | OOK/PWM | Gate remotes | +| Chamberlain | OOK/PWM | Garage door openers | +| Holtek | OOK/PWM | Remote controls | +| LiftMaster | OOK/PWM | Garage door openers | +| Linear | OOK/PWM | Gate/access control | +| Rossi | OOK/PWM | Gate remotes | + +### Utility Functions (`subghz_protocol_utils.h`) + +```c +uint32_t subghz_abs_diff(uint32_t a, uint32_t b); +bool subghz_check_pulse(int32_t raw_len, uint32_t target_len, uint8_t tolerance_pct); +``` +Helper functions available to all plugins for pulse timing validation with percentage-based tolerance. + +### Registry API + +```c +void subghz_protocol_registry_init(void); +bool subghz_protocol_registry_decode_all(const int32_t *pulses, size_t count, subghz_data_t *out_data); +const subghz_protocol_t *subghz_protocol_registry_get_by_name(const char *name); +``` + +## Common Types (`subghz_types.h`) + +```c +typedef struct { + const char *protocol_name; + uint32_t serial; + uint8_t btn; + uint8_t bit_count; + uint32_t raw_value; +} subghz_data_t; +``` + +Shared data structure used across decoder, serializer, storage, and UI layers. diff --git a/docs/bad_usb/README.md b/docs/bad_usb/README.md new file mode 100644 index 000000000..b89fc7086 --- /dev/null +++ b/docs/bad_usb/README.md @@ -0,0 +1,134 @@ +# BadUSB Application + +This component implements a modular HID injection tool capable of emulating keyboard and mouse input to execute automated payloads. It features a 3-layer architecture that decouples script parsing, keyboard layouts, and hardware transport. + +## Overview + +- **Location:** `components/Applications/bad_usb/` +- **Dependencies:** `tinyusb`, `tusb_desc`, `storage_api`, `freertos` +- **Transport:** USB HID via TinyUSB (Bluetooth planned) + +## Architecture + +``` +┌─────────────────────────────────────────────────┐ +│ BadUSB Application │ +│ │ +│ ┌─────────────────────────────────────────┐ │ +│ │ DuckyScript Parser │ │ +│ │ (ducky_parser.c) │ │ +│ │ Parses scripts, dispatches commands │ │ +│ └────────┬──────────────┬─────────────────┘ │ +│ │ │ │ +│ ┌────────┴────────┐ ┌─┴──────────────────┐ │ +│ │ HID Layouts │ │ HID HAL │ │ +│ │ (hid_layouts) │ │ (hid_hal) │ │ +│ │ US / ABNT2 │ │ Callback-based │ │ +│ │ char -> HID │ │ abstraction │ │ +│ └────────┬────────┘ └─┬──────────────────┘ │ +│ │ │ │ +│ └──────┬───────┘ │ +│ │ │ +│ ┌───────────────┴─────────────────────────┐ │ +│ │ Transport Backend │ │ +│ │ USB: bad_usb.c (TinyUSB) │ │ +│ │ BLE: (planned) │ │ +│ └─────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────┘ +``` + +**Layer 1 - HAL (`hid_hal`):** Manages the registration of transport drivers and provides a common interface for sending key reports, mouse movements, and waiting for connections. The parser never calls USB directly. + +**Layer 2 - Layouts (`hid_layouts`):** Translates characters and strings into HID keycodes. Hardware-independent and reusable by any transport registered in the HAL. + +**Layer 3 - Parser (`ducky_parser`):** Processes DuckyScript files and calls the HAL/Layout functions to execute commands. + +## API Reference + +### BadUSB Driver (`bad_usb.h`) + +```c +esp_err_t bad_usb_init(void); +esp_err_t bad_usb_deinit(void); +void bad_usb_wait_for_connection(void); +``` +- `bad_usb_init` initializes TinyUSB and registers USB HID callbacks into the HAL. +- `bad_usb_deinit` unregisters callbacks and uninstalls the TinyUSB driver. +- `bad_usb_wait_for_connection` blocks until the USB host mounts the device, then waits 2 seconds for enumeration. + +### HID HAL (`hid_hal.h`) + +```c +void hid_hal_register_callback(hid_send_cb_t send_cb, + hid_mouse_cb_t mouse_cb, + hid_wait_cb_t wait_cb); +void hid_hal_press_key(uint8_t keycode, uint8_t modifiers); +void hid_hal_mouse_move(int8_t x, int8_t y); +void hid_hal_mouse_click(uint8_t buttons); +void hid_hal_mouse_scroll(int8_t wheel); +void hid_hal_wait_for_connection(void); +``` +- `hid_hal_press_key` sends a key-down + key-up report with ~5 ms per phase. +- Mouse functions use ~2 ms delay for moves and ~5 ms for clicks. +- All functions yield to the scheduler (`vTaskDelay(0)`) to prevent WDT starvation. + +### Keyboard Layouts (`hid_layouts.h`) + +```c +void hid_layouts_type_string_us(const char *str); +void hid_layouts_type_string_abnt2(const char *str); +``` +- `hid_layouts_type_string_us` maps ASCII characters to US keyboard HID keycodes. +- `hid_layouts_type_string_abnt2` handles Brazilian Portuguese layout including UTF-8 dead-key sequences for accented characters (e.g. a, e, c, a, o). + +### DuckyScript Parser (`ducky_parser.h`) + +```c +void ducky_set_output_mode(ducky_output_mode_t mode); +void ducky_set_layout(ducky_layout_t layout); +void ducky_set_progress_callback(ducky_progress_cb_t cb); +void ducky_parse_and_run(const char *script); +esp_err_t ducky_run_from_assets(const char *filename); +esp_err_t ducky_run_from_sdcard(const char *path); +void ducky_abort(void); +``` +- `ducky_parse_and_run` executes a script line-by-line with 20 ms inter-line delay. +- `ducky_run_from_assets` loads a script from the internal flash asset partition. +- `ducky_run_from_sdcard` loads a script from the SD card (max 8 KB). +- `ducky_abort` sets a flag that stops execution at the next line boundary. +- Progress callback is invoked after each line with current/total counts. + +## Supported DuckyScript Commands + +| Command | Arguments | Description | +|---------|-----------|-------------| +| `REM` | [comment] | Comment line (ignored) | +| `DELAY` | [ms] | Pause execution for N milliseconds | +| `STRING` | [text] | Type text using the active keyboard layout | +| `ENTER` / `RETURN` | - | Press Enter | +| `GUI` / `WINDOWS` / `COMMAND` | [key] | Windows/Command key (optionally with a key) | +| `CTRL` / `CONTROL` | [key] | Control + key | +| `SHIFT` | [key] | Shift + key | +| `ALT` | [key] | Alt + key | +| `TAB` | - | Tab key | +| `ESC` / `ESCAPE` | - | Escape key | +| `F1` - `F12` | - | Function keys | +| `UP` / `DOWN` / `LEFT` / `RIGHT` | - | Arrow keys | +| `HOME` / `END` / `INSERT` / `DELETE` | - | Navigation keys | +| `PAGEUP` / `PAGEDOWN` | - | Page navigation | +| `CAPSLOCK` / `NUMLOCK` / `SCROLLLOCK` | - | Lock keys | +| `PRINTSCREEN` / `PAUSE` / `APP` / `MENU` | - | Special system keys | +| `MOUSE_MOVE` | [x] [y] | Move mouse relative (-127 to 127) | +| `MOUSE_CLICK` / `LCLICK` | - | Left mouse click | +| `MOUSE_RIGHT_CLICK` / `RCLICK` | - | Right mouse click | +| `MOUSE_SCROLL` | [amount] | Scroll mouse wheel | + +Modifier keys can be combined: `CTRL SHIFT ESC`, `GUI r`, `ALT F4`. + +## Supported Layouts + +| Layout | Enum | Notes | +|--------|------|-------| +| US (QWERTY) | `DUCKY_LAYOUT_US` | Default. Standard ASCII mapping. | +| ABNT2 (Brazil) | `DUCKY_LAYOUT_ABNT2` | Dead-key accent support, remapped punctuation. | + diff --git a/docs/bluetooth/README.md b/docs/bluetooth/README.md new file mode 100644 index 000000000..249e7f91c --- /dev/null +++ b/docs/bluetooth/README.md @@ -0,0 +1,145 @@ +# Bluetooth Service Component Documentation + +This component manages the Bluetooth Low Energy (BLE) functionality of the device using the Apache NimBLE stack. It provides a high-level API for initialization, lifecycle management, scanning, advertising, connection handling, and address randomization. + +## Overview + +- **Location:** `components/Service/bluetooth/` +- **Main Header:** `include/bluetooth_service.h` +- **Stack:** Apache NimBLE (via `nimble_port`) +- **Dependencies:** `nvs_flash`, `storage_assets`, `cJSON`, `esp_random` + +## API Functions + +### Initialization & Lifecycle + +The service lifecycle is split into initialization (resource allocation) and start (execution). + +#### `bluetooth_service_init` +```c +esp_err_t bluetooth_service_init(void); +``` +Allocates resources and prepares the BLE stack. +- Initializes NVS. +- Initializes the NimBLE port. +- Configures GAP callbacks and loads persistent device configuration. +- Does **not** start the background task. + +#### `bluetooth_service_start` +```c +esp_err_t bluetooth_service_start(void); +``` +Spawns the NimBLE host task and waits (up to 10s) for the controller to synchronize. + +#### `bluetooth_service_stop` +```c +esp_err_t bluetooth_service_stop(void); +``` +Stops the NimBLE host task. The service is "paused", but resources remain allocated in memory. + +#### `bluetooth_service_deinit` +```c +esp_err_t bluetooth_service_deinit(void); +``` +Completely shuts down the stack and frees all allocated memory and semaphores. + +#### `Status Checks` +- `bluetooth_service_is_initialized()`: Returns `true` if resources are allocated. +- `bluetooth_service_is_running()`: Returns `true` if the host task is active. + +### Scanning + +#### `bluetooth_service_scan` +```c +void bluetooth_service_scan(uint32_t duration_ms); +``` +Performs a blocking discovery procedure for the specified duration. Results are stored in an internal cache. + +#### `Scan Results` +- `bluetooth_service_get_scan_count()`: Returns the number of unique devices found. +- `bluetooth_service_get_scan_result(uint16_t index)`: Returns a pointer to a `bluetooth_service_scan_result_t` structure containing name, RSSI, and MAC address. + +### Advertising Management + +#### `bluetooth_service_start_advertising` / `stop_advertising` +Standard connectable advertising using the configured device name. Advertising automatically restarts on disconnection. + +### Connection Management + +#### `bluetooth_service_disconnect_all` +```c +void bluetooth_service_disconnect_all(void); +``` +Terminates all active GAP connections. + +#### `bluetooth_service_get_connected_count` +```c +int bluetooth_service_get_connected_count(void); +``` +Returns the number of currently connected peers (tracked internally). + +### Address Management + +#### `bluetooth_service_get_mac` +```c +void bluetooth_service_get_mac(uint8_t *mac); +``` +Copies the 6-byte current identity address into the provided buffer. + +#### `bluetooth_service_get_own_addr_type` +```c +uint8_t bluetooth_service_get_own_addr_type(void); +``` +Returns the current address type (e.g., Public, Random Static) used by the stack. + +#### `bluetooth_service_set_random_mac` +```c +esp_err_t bluetooth_service_set_random_mac(void); +``` +Generates and sets a new **Random Static Address**. This stops active advertising and switches the address type to `BLE_OWN_ADDR_RANDOM`. + +### Power Management + +#### `bluetooth_service_set_max_power` +Sets TX power to `ESP_PWR_LVL_P9` (+9dBm) for advertising and connections. + +### Configuration & Persistence + +#### `bluetooth_service_save_announce_config` +```c +esp_err_t bluetooth_service_save_announce_config(const char *name, uint8_t max_conn); +``` +Saves the main device announcement settings (Device Name) to `/assets/config/bluetooth/ble_announce.conf`. + +#### `bluetooth_service_load_spam_list` +```c +esp_err_t bluetooth_service_load_spam_list(char ***list, size_t *count); +``` +Loads a list of beacon names/payloads from `/assets/config/bluetooth/beacon_list.conf` used for specific application logic (e.g., spam functions). +- **Memory:** Allocates an array of strings. The caller **must** free this memory using `bluetooth_service_free_spam_list`. + +#### `bluetooth_service_save_spam_list` +```c +esp_err_t bluetooth_service_save_spam_list(const char * const *list, size_t count); +``` +Saves a list of strings to the beacon configuration file. + +#### `bluetooth_service_free_spam_list` +```c +void bluetooth_service_free_spam_list(char **list, size_t count); +``` +Helper function to safely free the memory allocated by `bluetooth_service_load_spam_list`. + +## Internal Implementation Details + +### Connection Tracking +The service maintains an internal array (`connection_handles`) of active peers. This is updated via `BLE_GAP_EVENT_CONNECT` and `BLE_GAP_EVENT_DISCONNECT` in the GAP event handler to allow mass disconnection and status reporting without relying on private NimBLE headers. + +### Event Handling +- `BLE_GAP_EVENT_DISC`: Parsed advertisement data to populate the scan results cache. +- `BLE_GAP_EVENT_DISC_COMPLETE`: Signals the completion of the scan via a semaphore. +- `BLE_GAP_EVENT_CONNECT/DISCONNECT`: Logs events and manages the connection tracking list. + +### Configuration Files +- `assets/config/bluetooth/ble_announce.conf`: Device name and connection limits. +- `assets/config/bluetooth/beacon_list.conf`: Payload list for BLE spam functions. diff --git a/docs/boot_report/README.md b/docs/boot_report/README.md new file mode 100644 index 000000000..a7ee0c0d4 --- /dev/null +++ b/docs/boot_report/README.md @@ -0,0 +1,123 @@ +# Boot Report: Boot Map & Crash Forensics + +On-device diagnostics for the ESP32-P4 covering two things: **why the last run +ended** (crash forensics, item 22) and **how this boot came up** (the boot map, +item 8). Lives in `components/Service/boot_report` so both the kernel (Core, via +the transitive Applications -> Service require) and the UI (Applications -> +Service) can reach it. + +- **Header:** `components/Service/boot_report/include/boot_report.h` + +## Boot map (item 8) + +`kernel_init` used to discard every init return code, so a failed subsystem was +invisible. It now returns `esp_err_t` and records each subsystem into a map: + +```c +typedef struct { + const char *name; // short label + bool required; // a required stage failing aborts to safe mode + esp_err_t result; // ESP_OK, an error, or ESP_ERR_NOT_FOUND when skipped +} boot_stage_t; + +void boot_report_reset(void); // at the top of kernel_init +void boot_report_record(const char *name, bool required, esp_err_t result); +const boot_stage_t *boot_report_stages(int *out_count); +bool boot_report_all_required_ok(void); +``` + +Recorded stages with their real return codes: `sd-storage` (optional, absent SD +is fine), `assets` (required), `battery` (optional), `display` (required), +`nvs` (required). A **required** failure makes `kernel_init` drop into +[safe mode](../recovery/README.md) instead of booting blind, and the function +returns `ESP_FAIL`. + +Viewer: **View boot map** in the developer menu (`SCREEN_BOOT_MAP`) and in the +safe-mode menu. Each row shows the stage name (`*` marks required) and its state +(`OK` / an `ESP_ERR_*` name / `skip`). + +## Crash forensics (item 22) + +### Flash layout + +Core dump is enabled to a dedicated flash partition (previously +`ESP_COREDUMP_ENABLE_TO_NONE`, so a field crash left nothing behind). + +- `partitions.csv`: a 64K `coredump` partition, carved from the OTA slots + (`ota_0`/`ota_1` shrunk `0x280000` -> `0x270000`, ~60% used so plenty of + headroom). The `assets` partition is 100% full and is **not** touched; it just + moves from `0x520000` to `0x510000`. +- `sdkconfig.defaults`: `CONFIG_ESP_COREDUMP_ENABLE_TO_FLASH`, + `..._DATA_FORMAT_ELF`, `..._CHECKSUM_CRC32`. + +> Because the partition table changed, the next update must be a **full flash** +> (`idf.py flash`: bootloader + partition table + app + assets). OTA from the old +> layout will not work, and the coredump partition starts blank (so "no crash +> recorded" is expected until a real panic). + +### Capture API + +```c +typedef struct { + esp_reset_reason_t reason; + bool has_coredump; + bool crash; // reason or dump indicates an abnormal end + char task[16]; // faulting task (coredump only) + uint32_t pc, mcause, mtval, ra, sp; // RISC-V fault context (coredump only) +} crash_info_t; + +void boot_report_capture_crash(void); // early in kernel_init +bool boot_report_has_crash(void); +const crash_info_t *boot_report_crash(void); +esp_err_t boot_report_clear_crash(void); +const char *boot_report_reason_str(esp_reset_reason_t reason); +``` + +`boot_report_capture_crash` runs early in `kernel_init` (right after NVS, before +anything can overwrite the reason). It reads `esp_reset_reason()` and, if +`esp_core_dump_image_check()` passes, `esp_core_dump_get_summary()` for the +faulting task and RISC-V registers. RISC-V has no on-device backtrace +symbolication (that needs GDB/the ELF on a host), so the viewer shows the raw +`pc`/`ra`/`sp`/`mcause`/`mtval` for offline decoding. + +Viewer: **View last crash** in the developer menu (`SCREEN_CRASH_REPORT`) and in +the safe-mode menu. Shows the reset reason and, when a dump exists, the fault +context; **OK** clears the dump (`esp_core_dump_image_erase`). + +## Boot-loop detection (item 4) + +Directly addresses the "reboots by itself" symptom: a marginal-hardware panic +(display FFC, bad SD, an I2C peripheral not ACKing) used to loop forever with no +trace and no reduced-boot attempt. + +```c +void boot_report_track_bootloop(void); // FIRST thing in app_main +bool boot_report_in_bootloop(void); +uint32_t boot_report_abnormal_boots(void); +uint32_t boot_report_panic_total(void); +void boot_report_mark_stable(void); +``` + +- `boot_report_track_bootloop` runs **first in `app_main`** (before + `kernel_init`). It reads `esp_reset_reason()` and keeps a counter in + **`RTC_NOINIT_ATTR`** memory: it survives a reset but not a power cycle and + never writes flash - exactly the semantics for loop detection. A magic word + distinguishes a real count from the garbage RTC RAM holds after power-on. An + abnormal reason (panic / task-WDT / int-WDT / WDT / lockup / brownout) + increments it; a clean reset or power-on clears it. +- It arms a one-shot `esp_timer` (20 s) that calls `boot_report_mark_stable` to + zero the counter once the device has been up long enough to be considered + stable, so a single crash never accumulates toward the threshold. +- At **3 consecutive abnormal boots** (`BOOT_REPORT_BOOTLOOP_THRESHOLD`), + `boot_report_in_bootloop()` is true and `kernel_init` drops into + [safe mode](../recovery/README.md) - the degraded target (no radios, no SD + theme, minimal UI). The safe-mode footer shows the reset reason, and the crash + viewer shows `Panics total` and `Abnormal boots N/3`. +- Only a **summary** is persisted to NVS (namespace `boot_report`: last reason + + running panic total), written on abnormal boots only, so flash wear is + negligible. The RTC counter carries the loop state itself. + +## Follow-ups + +- Exposing the map and crash summary over `host_link` for the companion app is + not wired yet; the on-device viewers are the current surface. diff --git a/docs/buttons_gpio/README.md b/docs/buttons_gpio/README.md new file mode 100644 index 000000000..65e0f8602 --- /dev/null +++ b/docs/buttons_gpio/README.md @@ -0,0 +1,141 @@ +# P4 + +> **Now a compatibility shim.** As of the input rework, `buttons_gpio` no longer +> touches GPIO directly - it forwards to [`input_manager`](../input_manager/README.md), +> which owns sampling, debounce, long-press/repeat, the event queue, activity +> tracking and the wake source. These functions still work exactly as before, so +> existing call sites need no changes. New code should consume `input_manager` +> events instead. + +This component exposes the physical input buttons of the Highboy device: poll +button states with both "is pressed" (continuous) and "was pressed" (one-shot) +logic. + +## Overview + +- **Location:** `components/Drivers/buttons_gpio/` +- **Header:** `include/buttons_gpio.h` +- **Backed by:** [`input_manager`](../input_manager/README.md) + +## Configuration + +- **Input Mode:** `GPIO_MODE_INPUT` with internal Pull-Up enabled (in `input_manager`). +- **Active Level:** Low (`0`). Buttons connect to ground when pressed. +- **Debounce:** Time-based (~20 ms), in `input_manager`. `buttons_task()` is now a + no-op: sampling runs in the `input_manager` timer. + +## Key Mapping + +| Button | Function | +| :--- | :--- | +| **BTN_UP** | Up Navigation | +| **BTN_DOWN** | Down Navigation | +| **BTN_LEFT** | Left / Decrease | +| **BTN_RIGHT** | Right / Increase | +| **BTN_OK** | Enter / Select | +| **BTN_BACK** | Back / Escape | + +## API Reference + +### Initialization + +#### `buttons_init` +```c +void buttons_init(void); +``` +Configures the GPIO pins defined in `pin_def.h` as inputs with pull-ups. Initializes the state of all buttons. + +### State Checking (One-shot) +These functions return `true` **only once** per press. They rely on the `buttons_task` or interrupt logic (conceptually) setting a flag, and these functions reading/clearing it atomically. + +- `bool up_button_pressed(void)` +- `bool down_button_pressed(void)` +- `bool left_button_pressed(void)` +- `bool right_button_pressed(void)` +- `bool ok_button_pressed(void)` +- `bool back_button_pressed(void)` + +### State Checking (Continuous) +These functions return the **current raw state** of the button. Returns `true` as long as the button is held down. + +- `bool up_button_is_down(void)` +- `bool down_button_is_down(void)` +- `bool left_button_is_down(void)` +- `bool right_button_is_down(void)` +- `bool ok_button_is_down(void)` +- `bool back_button_is_down(void)` + +### Tasks + +#### `buttons_task` +```c +void buttons_task(void); +``` +Updates the internal state of the buttons. This should be called periodically (e.g., in a FreeRTOS task or timer callback) to detect state changes (edges) and set the `pressed_flag`. + +--- + +# C5 + +This component handles the physical input buttons of the Highboy device. It provides functions to initialize GPIOs and poll button states, supporting both "is pressed" (continuous) and "was pressed" (one-shot/flag) logic. + +## Overview + +- **Location:** `components/Drivers/buttons_gpio/` +- **Header:** `include/buttons_gpio.h` +- **Dependencies:** `driver/gpio`, `pin_def.h` + +## Configuration + +- **Input Mode:** `GPIO_MODE_INPUT` with internal Pull-Up enabled. +- **Active Level:** Low (`0`). Buttons connect to ground when pressed. +- **Debounce/Polling:** Handled via `buttons_task` or direct atomic flag checks. + +## Key Mapping + +| Button | Function | +| :--- | :--- | +| **BTN_UP** | Up Navigation | +| **BTN_DOWN** | Down Navigation | +| **BTN_LEFT** | Left / Decrease | +| **BTN_RIGHT** | Right / Increase | +| **BTN_OK** | Enter / Select | +| **BTN_BACK** | Back / Escape | + +## API Reference + +### Initialization + +#### `buttons_init` +```c +void buttons_init(void); +``` +Configures the GPIO pins defined in `pin_def.h` as inputs with pull-ups. Initializes the state of all buttons. + +### State Checking (One-shot) +These functions return `true` **only once** per press. They rely on the `buttons_task` or interrupt logic (conceptually) setting a flag, and these functions reading/clearing it atomically. + +- `bool up_button_pressed(void)` +- `bool down_button_pressed(void)` +- `bool left_button_pressed(void)` +- `bool right_button_pressed(void)` +- `bool ok_button_pressed(void)` +- `bool back_button_pressed(void)` + +### State Checking (Continuous) +These functions return the **current raw state** of the button. Returns `true` as long as the button is held down. + +- `bool up_button_is_down(void)` +- `bool down_button_is_down(void)` +- `bool left_button_is_down(void)` +- `bool right_button_is_down(void)` +- `bool ok_button_is_down(void)` +- `bool back_button_is_down(void)` + +### Tasks + +#### `buttons_task` +```c +void buttons_task(void); +``` +Updates the internal state of the buttons. This should be called periodically (e.g., in a FreeRTOS task or timer callback) to detect state changes (edges) and set the `pressed_flag`. diff --git a/docs/c5_flasher/README.md b/docs/c5_flasher/README.md new file mode 100644 index 000000000..dfa9fc968 --- /dev/null +++ b/docs/c5_flasher/README.md @@ -0,0 +1,21 @@ +# C5 Flasher Service - P4 Master + +This service allows the ESP32-P4 to update the firmware of the ESP32-C5 using an embedded binary image. + +## Features +- **Embedded Binary**: The C5 firmware is embedded directly into the P4 executable during the build process. +- **Bootloader Control**: Automatically puts the C5 into serial bootloader mode using the BOOT and RESET pins. +- **Serial Protocol**: Implements the Espressif Serial Protocol (SLIP framing) to write blocks to the C5 flash. + +## Usage +1. **Initial Sync**: On boot, the `bridge_manager` checks the C5 version. +2. **Auto-Update**: If the C5 is unresponsive or outdated, `c5_flasher_update(NULL, 0)` is called. +3. **Execution**: The P4 stops the SPI bridge, initializes the Flasher UART, pulses the Reset pin with Boot LOW, and starts streaming the binary. + +## Symbols +The embedded binary is accessed via: +- `_binary_firmware_c5_bin_start` +- `_binary_firmware_c5_bin_end` + +## Build Automation +Use the `./tools/build_and_flash.sh` script to ensure the C5 binary is updated and embedded correctly before flashing the P4. diff --git a/docs/cc1101/README.md b/docs/cc1101/README.md new file mode 100644 index 000000000..f1f3d9239 --- /dev/null +++ b/docs/cc1101/README.md @@ -0,0 +1,187 @@ +# CC1101 Sub-GHz Radio Driver + +This component provides a full driver for the Texas Instruments CC1101 low-power sub-GHz RF transceiver. It handles SPI communication, frequency configuration, modulation presets, and TX/RX operations. + +## Overview + +- **Location:** `components/Drivers/cc1101/` +- **Header:** `include/cc1101.h` +- **Dependencies:** `spi`, `pin_def`, `driver/gpio`, `freertos` +- **Interface:** SPI (via `spi` component, device `SPI_DEVICE_CC1101`) +- **Crystal:** 26 MHz (used for frequency calculations) + +## Supported Frequency Bands + +| Band | Range (MHz) | PA Table | +|------------|---------------|----------| +| 315 MHz | 300 - 348 | `PA_TABLE_315` | +| 433 MHz | 387 - 464 | `PA_TABLE_433` | +| 868 MHz | 779 - 899 | `PA_TABLE_868` | +| 915 MHz | 900 - 928 | `PA_TABLE_915` | + +## Modulation Presets (`cc1101_preset_t`) + +| Preset | Mode | RX Bandwidth | +|----------------------------|---------|--------------| +| `CC1101_PRESET_IDLE` | Idle | - | +| `CC1101_PRESET_OOK_270KHZ`| ASK/OOK | 270 kHz | +| `CC1101_PRESET_OOK_650KHZ`| ASK/OOK | 650 kHz | +| `CC1101_PRESET_OOK_800KHZ`| ASK/OOK | 812 kHz | +| `CC1101_PRESET_2FSK_2KHZ` | 2-FSK | 58 kHz | +| `CC1101_PRESET_2FSK_47KHZ`| 2-FSK | 270 kHz | +| `CC1101_PRESET_2FSK_95KHZ`| 2-FSK | 540 kHz | + +## API Reference + +### Initialization + +#### `cc1101_init` +```c +void cc1101_init(void); +``` +Adds the CC1101 to the SPI bus (SPI3_HOST, 4 MHz), performs a hardware reset, verifies chip presence via version register, and sets the default frequency to **433.92 MHz**. + +### Frequency & Calibration + +#### `cc1101_set_frequency` +```c +void cc1101_set_frequency(uint32_t freq_hz); +``` +Sets the carrier frequency in Hz. Calculates FREQ2/FREQ1/FREQ0 registers from a 26 MHz crystal reference and triggers automatic calibration. + +#### `cc1101_calibrate` +```c +void cc1101_calibrate(void); +``` +Performs frequency synthesizer calibration with band-specific FSCTRL0, TEST0, and FSCAL2 adjustments. + +### Preset Management + +#### `cc1101_set_preset` +```c +void cc1101_set_preset(cc1101_preset_t preset, uint32_t freq_hz); +``` +Configures the radio with a predefined modulation/bandwidth combination. Internally calls `cc1101_enable_async_mode` (OOK presets) or `cc1101_enable_fsk_mode` (FSK presets) and then applies preset-specific tuning. + +#### `cc1101_get_active_preset_id` +```c +uint8_t cc1101_get_active_preset_id(void); +``` +Returns the ID of the currently active preset. + +### Operating Modes + +#### `cc1101_enable_async_mode` +```c +void cc1101_enable_async_mode(uint32_t freq_hz); +``` +Configures the CC1101 for **ASK/OOK async serial output** on GDO0 (for RMT-based sniffing). Sets infinite packet length, max sensitivity AGC, 812 kHz RX bandwidth, and enters RX. + +#### `cc1101_enable_fsk_mode` +```c +void cc1101_enable_fsk_mode(uint32_t freq_hz); +``` +Configures the CC1101 for **2-FSK async serial output** on GDO0. Same async architecture as OOK mode but with FSK modulation. + +#### `cc1101_enter_rx_mode` / `cc1101_enter_tx_mode` +```c +void cc1101_enter_rx_mode(void); +void cc1101_enter_tx_mode(void); +``` +Transitions the radio to RX or TX state (via IDLE first). + +### Data Transmission + +#### `cc1101_send_data` +```c +void cc1101_send_data(const uint8_t *data, size_t len); +``` +Sends a packet (max 61 bytes) via the TX FIFO. Flushes the FIFO, writes length + payload, strobes TX, and blocks until transmission completes (polls MARCSTATE). + +### Modem Tuning + +#### `cc1101_set_rx_bandwidth` +```c +void cc1101_set_rx_bandwidth(float khz); +``` +Sets the RX filter bandwidth in kHz by calculating the MDMCFG4 register fields. + +#### `cc1101_set_data_rate` +```c +void cc1101_set_data_rate(float baud); +``` +Sets the data rate in kBaud (range: ~0.025 - 1621.83). Writes MDMCFG4 (exponent) and MDMCFG3 (mantissa). + +#### `cc1101_set_deviation` +```c +void cc1101_set_deviation(float dev); +``` +Sets frequency deviation in kHz (range: 1.59 - 380.86) for FSK modulation. + +#### `cc1101_set_modulation` +```c +void cc1101_set_modulation(uint8_t modulation); +``` +Sets the modulation format: `0` = 2-FSK, `1` = GFSK, `2` = ASK/OOK, `3` = 4-FSK, `4` = MSK. Automatically adjusts FREND0 and reapplies PA settings. + +#### `cc1101_set_pa` +```c +void cc1101_set_pa(int dbm); +``` +Sets the output power in dBm. Automatically selects the correct PA table for the current frequency band. Handles ASK/OOK PATABLE indexing (index 0 = 0x00, index 1 = power). + +#### `cc1101_set_channel` +```c +void cc1101_set_channel(uint8_t channel); +``` +Sets the channel number (CHANNR register). + +#### `cc1101_set_chsp` +```c +void cc1101_set_chsp(float khz); +``` +Sets channel spacing in kHz (range: 25.39 - 405.46). + +#### `cc1101_set_sync_mode` +```c +void cc1101_set_sync_mode(uint8_t mode); +``` +Configures sync word detection mode (0-7). See CC1101 datasheet for mode descriptions. + +#### `cc1101_set_fec` +```c +void cc1101_set_fec(bool enable); +``` +Enables or disables Forward Error Correction. + +#### `cc1101_set_preamble` +```c +void cc1101_set_preamble(uint8_t preamble_bytes); +``` +Sets the number of preamble bytes (2-24, mapped to register encoding). + +#### `cc1101_set_dc_filter_off` / `cc1101_set_manchester` +```c +void cc1101_set_dc_filter_off(bool disable); +void cc1101_set_manchester(bool enable); +``` +Toggles DC blocking filter and Manchester encoding respectively. + +### Utilities + +#### `cc1101_convert_rssi` +```c +float cc1101_convert_rssi(uint8_t rssi_raw); +``` +Converts a raw RSSI register value to dBm. + +### Low-Level SPI Access + +```c +void cc1101_strobe(uint8_t cmd); +void cc1101_write_reg(uint8_t reg, uint8_t val); +uint8_t cc1101_read_reg(uint8_t reg); +void cc1101_write_burst(uint8_t reg, const uint8_t *buf, uint8_t len); +void cc1101_read_burst(uint8_t reg, uint8_t *buf, uint8_t len); +``` +Direct SPI register access: single read/write, burst read/write, and strobe commands. diff --git a/firmware_c5/components/Service/console/README.md b/docs/console/README.md similarity index 100% rename from firmware_c5/components/Service/console/README.md rename to docs/console/README.md diff --git a/docs/dns_server/README.md b/docs/dns_server/README.md new file mode 100644 index 000000000..6ef23bc1f --- /dev/null +++ b/docs/dns_server/README.md @@ -0,0 +1,53 @@ +# DNS Server Service Component + +This component implements a lightweight DNS server optimized for "Evil Twin" and Captive Portal applications. It intercepts all DNS queries and responds authoritatively with the device's own IP address, effectively redirecting all traffic to the local web server. + +## Overview + +- **Location:** `components/Service/dns_server/` +- **Main Header:** `include/dns_server.h` +- **Socket Type:** UDP Port 53 +- **Response Strategy:** Authoritative (AA=1), Recursive (RA=0), No Error. +- **Dependencies:** `lwip/sockets`, `esp_netif` + +## Key Features + +- **Dynamic IP Resolution:** Automatically detects the current Access Point IP address using `esp_netif_get_ip_info`, ensuring correct redirection even if the network configuration changes. +- **Robust Parsing:** Implements a safe DNS name parser (`parse_dns_name`) to validate queries and prevent buffer overflows. +- **Evil Twin Optimization:** Uses specific DNS flags (`0x8500`) to mark responses as "Authoritative". This forces client devices (especially modern Android/iOS) to accept the redirection faster, improving Captive Portal detection. +- **IPv4 Focus:** Optimized for stability and simplicity, handling standard A-record queries. +- **Task Management:** Runs in a dedicated FreeRTOS task with an increased stack size (4096 bytes) to handle high loads and logging without overflow. + +## API Reference + +### `start_dns_server` +```c +void start_dns_server(void); +``` +Starts the DNS server task. +- Creates a UDP socket bound to port 53. +- Listens for incoming queries. +- Spawns the `dns_server` task with 4KB stack. + +### `stop_dns_server` +```c +void stop_dns_server(void); +``` +Stops the DNS server and frees resources. +- Deletes the FreeRTOS task. +- Closes the UDP socket (handled within the task loop upon deletion). + +## Internal Implementation Details + +### Packet Handling +1. **Validation:** Incoming packets are checked for minimum size (header length) and valid query flags. +2. **Parsing:** The domain name is extracted using `parse_dns_name` for logging and validation purposes. +3. **Response Construction:** + - Copies the transaction ID from the request. + - Sets Flags to `0x8500` (Response + Authoritative). + - Appends the original Question section. + - Appends an Answer section pointing to the AP's IP address (TTL 60s). + +### Configuration +- **Stack Size:** 4096 bytes (Safe for logging and network operations). +- **Socket Timeout:** 1 second (allows graceful shutdown checks). diff --git a/docs/esp_now/README.md b/docs/esp_now/README.md new file mode 100644 index 000000000..bd800ba66 --- /dev/null +++ b/docs/esp_now/README.md @@ -0,0 +1,102 @@ +# ESP-NOW Service + +The **ESP-NOW Service** is the low-level communication backbone for the Highboy project. It abstracts the ESP-IDF `esp_now` driver, providing a robust, connectionless messaging layer with auto-discovery, persistent peer management, and software-based security. + +## Features + +- **Connectionless Communication**: Uses ESP-NOW (WiFi Vendor Specific Elements) to send small packets instantly without WiFi association. +- **Auto-Discovery**: "Hello" broadcast packets allow devices to find each other. +- **Auto-Pairing (The "Cat Jump" Logic)**: Automatically registers any device from which a packet is received, allowing immediate reply without manual pairing. +- **Smart Peer Management**: + - **Volatile (Session)**: Stores discovered peers in PSRAM (or RAM) to show who is currently online. + - **Permanent**: Saves trusted peers to `addresses.conf` (JSON). +- **Software Security**: + - Implements a Vigenère Cipher for message payloads to bypass ESP-NOW hardware limits (6-20 peers) while keeping packets ASCII-compatible. + - **Secure Handshake**: Special `KEY_SHARE` packet type to exchange keys automatically. +- **Configuration Persistence**: Saves Nickname, Online Status, and Encryption Keys to `chat.conf`. + +## Architecture + +### Packet Structure +The service uses a packed struct to ensure consistent data alignment over the air. + +| Field | Type | Size | Description | +|-------|------|------|-------------| +| `type` | `uint8_t` | 1 byte | Packet intent (see below). | +| `nick` | `char[]` | 16 bytes | Sender's nickname. | +| `text` | `char[]` | 201 bytes | Message content or Key payload. | + +### Message Types +1. **`HELLO` (0x01)**: Broadcast packet. Sent to `FF:FF:FF:FF:FF:FF`. Used for discovery. +2. **`MSG` (0x02)**: Direct message (Unicast). Encrypted if a key is set. +3. **`KEY_SHARE` (0x03)**: Handshake packet. Sent unencrypted containing the generated session key in the `text` field. + +### File System Integration +The service relies on the **Assets Partition** for configuration: + +1. **`/assets/config/chat/chat.conf`**: + ```json + { + "nick": "Highboy_User", + "online": true, + "key": "SecretKey123" + } + ``` +2. **`/assets/config/chat/addresses.conf`**: + ```json + [ + { "mac": "AA:BB:CC:DD:EE:FF", "name": "Friend_Device" } + ] + ``` + +## API Reference + +### Initialization +```c +esp_err_t service_esp_now_init(void); +void service_esp_now_deinit(void); +``` +Initializes ESP-NOW, registers callbacks, loads configuration, and allocates memory for the session list. + +### Configuration +```c +esp_err_t service_esp_now_set_nick(const char *nick); +const char* service_esp_now_get_nick(void); +esp_err_t service_esp_now_set_online(bool online); // Toggle TX/RX +bool service_esp_now_is_online(void); +esp_err_t service_esp_now_set_key(const char *key); // Sets encryption key +``` + +### Messaging +```c +// Send HELLO to Broadcast (Discovery) +esp_err_t service_esp_now_broadcast_hello(void); + +// Send Text Message (Auto-encrypts if key is set) +esp_err_t service_esp_now_send_msg(const uint8_t *target_mac, const char *text); + +// Initiate Secure Handshake (Generates key if missing, sends KEY_SHARE) +esp_err_t service_esp_now_secure_pair(const uint8_t *target_mac); +``` + +### Peer Management +```c +// Get list of currently visible devices (from RAM/PSRAM) +int service_esp_now_get_session_peers(service_esp_now_peer_info_t *out_peers, int max_peers); + +// Save a peer permanently to addresses.conf +esp_err_t service_esp_now_save_peer_to_conf(const uint8_t *mac_addr, const char *name); +``` + +### Callbacks +```c +typedef void (*service_esp_now_recv_cb_t)(const uint8_t *mac_addr, const service_esp_now_packet_t *data, int8_t rssi); +typedef void (*service_esp_now_send_cb_t)(const uint8_t *mac_addr, esp_now_send_status_t status); + +void service_esp_now_register_recv_cb(service_esp_now_recv_cb_t cb); +void service_esp_now_register_send_cb(service_esp_now_send_cb_t cb); +``` + +## Security Note regarding `peer.encrypt` +We explicitly set `peer.encrypt = false` in the hardware driver. +**Reason**: ESP32 hardware encryption limits the peer list drastically (approx. 10 devices). By implementing software encryption (Vigenère) on the payload, we allow **unlimited peers** while maintaining confidentiality and enabling instant "fire-and-forget" messaging without complex hardware handshake requirements. diff --git a/docs/espnow_chat/README.md b/docs/espnow_chat/README.md new file mode 100644 index 000000000..2af69383e --- /dev/null +++ b/docs/espnow_chat/README.md @@ -0,0 +1,98 @@ +# ESP-NOW Chat Application + +The **ESP-NOW Chat Application** is the high-level logic layer that bridges the raw `Service` capabilities with the User Interface (UI). It handles business logic, event notification, and data formatting for the display. + +## Overview + +This component sits between the **UI Manager** (LVGL) and the **ESP-NOW Service**. It ensures that the UI doesn't need to know about raw bytes, MAC addresses, or packet types, providing a clean API for "sending messages" and "listing users". + +## Features + +- **Event-Driven UI Updates**: Provides a callback mechanism so the UI only updates when necessary (new message, new device found). +- **System Notifications**: automatically injects system messages (e.g., "Secure Pair with User!") into the chat stream. +- **Simplified API**: Wraps complex service calls into single-line functions for the UI. +- **Data Abstraction**: Converts service-level structs into UI-friendly structs. + +## Integration Guide + +### 1. Initialization +In your `main.c` or `ui_manager.c`: + +```c +#include "espnow_chat.h" + +void app_main() { + // ... WiFi Init ... + + // Initialize the Chat App + espnow_chat_init(); + + // Register UI Callbacks + espnow_chat_register_msg_cb(my_ui_message_handler); + espnow_chat_register_refresh_cb(my_ui_device_list_refresh); +} +``` + +### 2. Handling Messages in UI +The UI should implement a callback to receive messages: + +```c +void my_ui_message_handler(const char *sender_nick, const char *message, bool is_system_msg) { + if (is_system_msg) { + // Render in yellow/red + ui_chat_add_bubble_system(message); + } else { + // Render in bubble + ui_chat_add_bubble(sender_nick, message); + } +} +``` + +### 3. Listing Devices +When the user opens the "Scan" tab, the UI calls: + +```c +espnow_chat_peer_t peers[10]; +int count = espnow_chat_get_peer_list(peers, 10); + +for(int i=0; i UI calls `espnow_chat_broadcast_discovery()`. + - Service sends HELLO. + - Other devices receive HELLO -> Service auto-adds to list -> App triggers `refresh_cb` -> UI updates list. + +2. **Chatting**: + - User taps a device -> UI enters Chat Screen. + - User types "Hi" -> UI calls `espnow_chat_send_message()`. + - Service encrypts & sends. + +3. **Secure Pairing**: + - User taps "Secure Pair" -> UI calls `espnow_chat_secure_pair()`. + - Service generates Key (if none) -> Sends `KEY_SHARE` packet. + - Target receives `KEY_SHARE` -> App triggers `msg_cb` ("Secure Pair with X!") -> Service saves key. + - Future messages are now secure. + diff --git a/docs/host_link/README.md b/docs/host_link/README.md new file mode 100644 index 000000000..5bcd53616 --- /dev/null +++ b/docs/host_link/README.md @@ -0,0 +1,258 @@ +# Host Link - unified overview + +End-to-end companion-app link, spanning **both firmwares**. This document is the +single cross-firmware view: how the pieces fit, who owns what, and where to look. +It deliberately does **not** repeat the per-file reference tables - those live in +the component READMEs, and the byte-level wire format lives in the protocol spec. + +- Companion app implementation guide: [`app-guide.md`](./app-guide.md) +- Wire spec: [`protocol.md`](./protocol.md) +- SPI bridge (P4↔C5 transport this rides on): [`../spi_bridge/README.md`](../spi_bridge/README.md) +- P4 component reference: the [`# P4`](#p4) section below. +- C5 component reference: the [`# C5`](#c5) section below. + +## The model + +``` + USB CDC-ACM (P4-native) + ┌──────────────┐ ◄───────────────────────────────► ┌─────────────┐ + │ Companion app│ │ ESP32-P4 │ + │ (PC/phone) │ ◄───────────────────────────────► │ (the brain)│ + └──────────────┘ BLE ┌─────────────┐ relay └─────────────┘ + ◄───────────►│ ESP32-C5 │◄────────────────┘ SPI bridge + │ (BLE radio) │ + └─────────────┘ +``` + +- **The P4 is the single brain.** It terminates the security envelope, dispatches + every command (locally or relayed to the C5 over SPI), and owns SD/flash and + device state. Identical behavior on both transports - one place for crypto. +- **USB** terminates on the P4 (CDC-ACM in the TinyUSB composite, alongside the + BadUSB HID). +- **BLE** terminates on the **C5** (it owns the radio). The C5 is a transparent + byte relay - it never parses companion payloads; all auth is on the P4. +- **One companion session at a time.** The first transport to attach owns the + session; a second attach is rejected until it releases. + +## Frame envelope (summary) + +``` +[MAGIC 'H''B'][VER][FLAGS][COUNTER u32][LEN u16][BODY][MAC 16 if FLAGS.auth] +BODY = [type][category][op][payload] +``` + +`category`/`op` reuse the `spi_protocol.h` ids (`SPI_CMD(cat, op)`) - one HAL +shared by app, P4 and C5. Types: `CMD`, `RESP`, `STREAM`, `LOG`, `HELLO`, +`HELLO_ACK`. Full field semantics: see the wire spec. + +## Security (P4 only) + +- PSK (32 B) in NVS, auto-generated on first boot. Provisioned out-of-band: QR + + hex on the P4 pairing screen (Settings → PAIRING) or the `hostlink psk` console + command. +- `HELLO`/`HELLO_ACK` handshake → per-direction HKDF keys (`a2d`/`d2a`) + counter + reset. Per-frame HMAC-SHA256 (truncated 16 B, mbedTLS) verified before any body + parse; monotonic counter rejects replays. Only `HELLO` is accepted unauthenticated. +- BLE bonding is "just works" (LE Secure Connections, no MITM) on top of the PSK + envelope - the PSK is the real trust boundary. + +## Module map + +**P4 (`firmware_p4/components/Service/host_link/`)** - core + both transports + +all local handlers: framing/dispatch/session arbitration, USB CDC, BLE relay, +security, the P4 log tee, the C5 log relay, file ops, device state/settings/ +console-exec, and the streaming/heartbeat proxy. + +**C5 (`firmware_c5/components/Service/host_link/`)** - BLE GATT server (NimBLE +NUS-style), the chunking transport to/from the P4, and the C5 log tee. + +New SPI ids backing all this live in `spi_protocol.h` under `SPI_CAT_HOST = 0x06` +(BLE relay) plus P4-local `SPI_CAT_SYSTEM` ops (`SYSTEM_LOG`, `FILE_*`, +`DEVICE_STATE`, settings, console-exec). Per-file detail: the component READMEs. + +## Command routing (P4) + +After auth, each `CMD` is routed by id: file ops → local; device-state/settings/ +console-exec → local; `SPI_CAT_SESSION` (heartbeat/stop) → stream proxy (local, +**not** relayed - the P4 keeps heartbeating the C5 itself); session-start ops +(sniffer) → `spi_session`; everything else → relayed to the C5. + +## Logs & two consoles + +Both chips tee their `ESP_LOGx` (without losing the local dev console). P4 logs +are emitted directly; C5 logs stream to the P4 (`SPI_ID_SYSTEM_LOG`) and are +re-emitted. Each `LOG` frame carries a `source` byte (P4 / C5) so the app renders +two separate consoles. Console-exec output is delivered as console LOG frames. + +## Toggles (NVS, default on) + +| Setting | Off behavior | +|---------|--------------| +| `console_exec` | app can't run raw console lines (structured `CMD`s still work) | +| `log_over_ble` | no background logs over BLE; **USB always carries logs**; console-exec output always delivered | + +## Boot order + +**P4 (`kernel.c`):** `host_link_state_init` → `host_link_stream_init` → +`host_link_init` → `host_link_cdc_init` → `host_link_log_init` → +`host_link_c5log_init` → `host_link_ble_init` (BLE advertising starts on demand: +`hostlink ble on`). + +**C5 (`kernel.c`):** `c5_log_init` right after `spi_bridge_slave_init`. The GATT +server is started on demand by the P4, not at boot. + +## Phase status + +All 8 phases (core+CDC, P4 log tee, security, BLE relay, C5 log forward, file ops, +device state+toggles+console-exec, streaming+heartbeat proxy) are **implemented +and build-validated on both firmwares**. See §15 of the wire spec for the +per-phase breakdown. + +## Caveats (not yet hardware-tested) + +- The transport layer is unexercised: the dev board's native USB pads are + unsoldered and BLE hasn't been run. Everything is build-validated only. +- **NimBLE is single-owner**: host-link BLE, MeshCore and Meshtastic are mutually + exclusive. +- **One `spi_session`**: the on-device UI sniffer and the companion sniffer are + mutually exclusive (a start preempts the other). +- Device→app frames larger than the BLE MTU are split across notifications and + reassembled by the app via `LEN`. + +--- + +# P4 + +Terminates the companion-app protocol on the **ESP32-P4**. The P4 is the single +brain: it owns the security envelope, dispatches commands (locally or relayed to +the C5 over the SPI bridge), and owns SD/flash storage and device state. The same +behavior is exposed over **two transports** - USB CDC-ACM (P4-native) and BLE +(terminated on the C5, relayed here). Only **one** companion session is active at +a time. + +- Unified cross-firmware overview: [`README.md`](./README.md) +- Wire format (envelope, types, ids): [`protocol.md`](./protocol.md) + +This README is the **P4 component reference** - the file map and P4-side wiring. +The frame envelope, BODY types and the `SPI_CMD(cat, op)` id scheme are defined in +the wire spec; the end-to-end (app↔P4↔C5) picture is in the unified overview. + +## Files + +| File | Role | +|------|------| +| `host_link.c` | Core: reassembly, frame encode/decode, dispatch, single-session arbitration, `emit_frame` (RESP/LOG/STREAM). | +| `host_link_cdc.c` | USB CDC-ACM transport (TinyUSB composite). Claims the session on DTR; drops bytes when no app is attached. | +| `host_link_ble.c` | BLE transport relay: chunks frames to the C5 (`SPI_ID_HOST_TX`), reassembles inbound (`SPI_ID_HOST_RX` stream), drives the C5 GATT on/off and connection status. | +| `host_link_sec.c` | Security: PSK in NVS (auto-generated), `HELLO`/`HELLO_ACK` handshake, HKDF per-direction keys, per-frame MAC verify/sign, counter replay rejection. mbedTLS. | +| `host_link_log.c` | P4 log tee (`esp_log_set_vprintf`): ANSI strip, level, drop-oldest ring, worker → `LOG` frames `source=P4`. | +| `host_link_c5log.c` | Consumes the `SPI_ID_SYSTEM_LOG` stream from the C5 → `LOG` frames `source=C5`. | +| `host_link_files.c` | P4-local `FILE_*` ops over `/assets`, `/littlefs`, `/sdcard` (POSIX VFS), path-sandboxed, chunked. | +| `host_link_state.c` | Device state (battery/versions), the two settings toggles (NVS), and raw console exec (captured stdout → console LOG frames). | +| `host_link_stream.c` | Streaming + heartbeat proxy: starts session ops via `spi_session`, pushes records as `STREAM` frames, app-liveness watchdog, link-loss teardown. | + +## Command routing (in `host_link.c`) + +After authentication, `process_frame` routes each `CMD` by id: + +1. `host_files_is_file_op` → local file ops (bypass the 256 B relay cap). +2. `host_state_is_local_op` → device state / settings / console exec. +3. `category == SPI_CAT_SESSION` → heartbeat/stop handled by the stream proxy + (**not** relayed; the P4 keeps heartbeating the C5 itself). +4. `host_stream_is_session_op` → start a session-based stream (sniffer). +5. otherwise → relayed to the C5 via `spi_bridge_send_command`. + +## Security model + +- Only `HELLO` is accepted before keys exist. Every other inbound frame must be + authenticated (valid MAC, fresh counter) or it is dropped + logged. +- Per-direction HKDF keys (`a2d`/`d2a`) prevent reflection; fresh nonces per + handshake prevent cross-session replay. +- The PSK is provisioned out-of-band: shown as a QR + hex on the P4 pairing + screen (Settings → PAIRING) and via the `hostlink psk` console command. +- BLE bonding is "just works" (LE Secure Connections, no MITM) on top of the PSK + envelope, which is the real trust boundary. + +## Toggles (NVS, default on) + +| Setting | Effect when off | +|---------|-----------------| +| `console_exec` | the app cannot run raw console lines (structured `CMD`s still work) | +| `log_over_ble` | background logs are not sent over BLE; **USB always carries logs**, and console-exec output is always delivered | + +## Boot wiring (`kernel.c`) + +``` +host_link_state_init(); // load toggles +host_link_stream_init(); // streaming proxy +host_link_init(); // core + PSK +host_link_cdc_init(); // USB transport +host_link_log_init(); // P4 log tee +host_link_c5log_init(); // C5 log relay +host_link_ble_init(); // BLE relay infra (advertising on demand: `hostlink ble on`) +``` + +## Status + +All phases implemented and build-validated. **Not yet hardware-tested** - the +dev board's native USB pads are unsoldered and BLE is unexercised. Known runtime +caveats: NimBLE is single-owner (host-link BLE / MeshCore / Meshtastic are +mutually exclusive); the UI sniffer and the companion sniffer share one +`spi_session` (mutually exclusive); large device→app frames split across BLE +notifications and are reassembled by the app via `LEN`. + +--- + +# C5 + +The companion app's **BLE transport terminates on the ESP32-C5** (it owns the BLE +radio). The C5 is a **transparent byte relay**: it ferries opaque host-link frames +to/from the P4 over the SPI bridge and forwards its own logs up. **All +crypto/auth lives on the P4** - the C5 never parses companion payloads. + +Mirrors the proven Meshtastic/MeshCore phone-bridge pattern. + +- Unified cross-firmware overview: [`README.md`](./README.md) +- Wire format: [`protocol.md`](./protocol.md) + +This README is the **C5 component reference** (BLE relay + log tee). + +## Files + +| File | Role | +|------|------| +| `host_link_gatt.c` | NimBLE GATT server (NUS-style): a **write** char (app→device) and a **notify** char (device→app). "Just works" LE Secure Connections (no MITM). Splits notifications by ATT MTU; the app reassembles by frame `LEN`. | +| `host_transport.c` | Chunk/reassembly between BLE and SPI. BLE write → `SPI_ID_HOST_RX` stream (C5→P4). `SPI_ID_HOST_TX` chunks (P4→C5) → reassemble → BLE notify. Reuses `spi_mesh_chunk_hdr_t`. | +| `c5_log.c` | C5 log tee (`esp_log_set_vprintf`): keeps the local dev console, ANSI strip + level, drop-oldest ring, worker → `SPI_ID_SYSTEM_LOG` stream (C5→P4) as `[level u8][utf-8 text]`. | + +## SPI ops (category `SPI_CAT_HOST = 0x06`, in `spi_protocol.h`) + +| Op | Id | Direction | Purpose | +|----|----|-----------|---------| +| `SPI_ID_HOST_BLE_INIT` | `0x06A0` | P4→C5 cmd | start GATT + advertise (`spi_host_init_t { name_prefix }`) | +| `SPI_ID_HOST_BLE_STOP` | `0x06A1` | P4→C5 cmd | stop GATT | +| `SPI_ID_HOST_TX` | `0x06A2` | P4→C5 cmd (push) | device→app bytes → BLE notify | +| `SPI_ID_HOST_RX` | `0x06A3` | C5→P4 stream | app→device bytes (BLE write) | +| `SPI_ID_HOST_STATUS` | `0x06A4` | P4→C5 cmd | poll `spi_host_status_t { ble_connected, ble_subscribed }` | + +`SPI_ID_SYSTEM_LOG` (`0x0007`, C5→P4 stream) carries the forwarded log lines. + +## Dispatch + +`SPI_CAT_HOST` is routed to `bt_dispatcher_execute` (alongside `SPI_CAT_BT` / +`SPI_CAT_MCORE`) in `spi_bridge.c`. The handlers call into `host_transport` / +`host_link_gatt`. + +## Boot wiring (`kernel.c`) + +`c5_log_init()` runs right after `spi_bridge_slave_init()` (it pushes to the SPI +stream). The GATT server is started on demand by the P4 (`SPI_ID_HOST_BLE_INIT`), +not at boot, so it doesn't hog NimBLE from the BLE attack features. + +## Caveats + +- **NimBLE is single-owner**: host-link BLE, MeshCore, and Meshtastic each refuse + to init while another holds NimBLE. +- The C5 log stream is always enabled on this side; the P4 drops the resulting + `LOG` frames when no companion session is active, and the **log-over-BLE** + toggle (P4) gates BLE delivery. Build-validated; **not yet hardware-tested**. diff --git a/docs/host_link/app-guide.md b/docs/host_link/app-guide.md new file mode 100644 index 000000000..383438766 --- /dev/null +++ b/docs/host_link/app-guide.md @@ -0,0 +1,282 @@ +# Companion app implementation guide + +How a desktop/mobile companion app talks to a TentacleOS device. This is the +practical, app-side recipe: transports, the byte-level frame, the security +handshake, and how to issue commands / read streams / logs / files. + +The firmware owns the protocol; the app follows it. Pair this guide with: + +- [`protocol.md`](./protocol.md) - the formal wire contract. +- [`../spi_bridge/README.md`](../spi_bridge/README.md) - the full `category`/`op` command table. +- [`README.md`](./README.md) - cross-firmware overview. + +All multi-byte integers are **little-endian**. + +--- + +## 1. Transports + +The app speaks the **same framed byte protocol** over either transport. Pick one +connection; the device allows **only one companion session at a time**. + +### 1.1 USB (CDC-ACM) + +- The device enumerates as a composite USB device, **VID `0xCAFE` / PID `0x4001`**, + with a CDC-ACM interface labelled **"TentacleOS Companion"**. +- On Linux it shows up as `/dev/ttyACM*`; on macOS `/dev/cu.usbmodem*`; on Windows + a COM port. Open it **raw** (no line discipline, no echo, no newline translation): + it is a transparent binary pipe, not a text console. +- Baud rate is irrelevant (USB CDC ignores it). Write whole frames; read a byte + stream and reassemble (see §3). +- Note: this is the device's **native USB** port, separate from the USB-Serial-JTAG + used for `idf.py monitor`. + +### 1.2 BLE (GATT) + +The C5 advertises as **`Tentacle-XXXX`** (last 4 hex of its MAC). GATT service +(NUS-style), UUIDs (current values; treat as the contract for now): + +| Role | UUID | Properties | +|------|------|------------| +| Service | `6e540001-b5a3-f393-e0a9-e50e24dcca9e` | primary | +| RX (app → device) | `6e540002-b5a3-f393-e0a9-e50e24dcca9e` | write / write-no-response | +| TX (device → app) | `6e540003-b5a3-f393-e0a9-e50e24dcca9e` | notify (subscribe via CCCD) | + +- Negotiate the largest MTU you can (the device prefers 512). +- **App → device:** write frames to RX. A single write must not exceed + `min(MTU-3, 512)` bytes; split larger frames across consecutive writes (order is + preserved, the device reassembles the byte stream). +- **Device → app:** subscribe to TX notifications. A frame larger than `MTU-3` is + split across multiple notifications; concatenate notification payloads and + reassemble by frame length (see §3). +- Bonding is "just works" (LE Secure Connections, no passkey). BLE encryption is + defense-in-depth; the real auth is the host-link PSK envelope below. + +--- + +## 2. Frame envelope + +Every frame on either transport: + +| Offset | Size | Field | Notes | +|-------:|-----:|-------|-------| +| 0 | 2 | `MAGIC` | `0x48 0x42` ("HB") | +| 2 | 1 | `VER` | `1` | +| 3 | 1 | `FLAGS` | bit0 = authenticated; other bits 0 | +| 4 | 4 | `COUNTER` | u32 LE, per-direction monotonic | +| 8 | 2 | `LEN` | u16 LE, length of `BODY` | +| 10 | `LEN` | `BODY` | `[type u8][category u8][op u8][payload...]` | +| 10+LEN | 16 | `MAC` | present only if `FLAGS.bit0 == 1` | + +`MAC = HMAC-SHA256(K_dir, frame[2 .. 10+LEN])[:16]` - i.e. over `VER`, `FLAGS`, +`COUNTER`, `LEN`, and the whole `BODY` (everything except the 2 MAGIC bytes and +the MAC itself), truncated to the first 16 bytes. + +`BODY` types: + +| type | name | direction | payload | +|------|------|-----------|---------| +| `0x01` | `CMD` | app → device | command args | +| `0x02` | `RESP` | device → app | `[status u8][data...]` | +| `0x03` | `STREAM` | device → app | live data (see §6) | +| `0x04` | `LOG` | device → app | `[source u8][level u8][utf-8 text]` | +| `0x10` | `HELLO` | app → device | handshake (unauthenticated) | +| `0x11` | `HELLO_ACK` | device → app | handshake (unauthenticated) | + +`category`/`op` are the same ids the firmware uses internally +(`spi_id_t = (category << 8) | op`). Full table: [`../spi_bridge/README.md`](../spi_bridge/README.md). + +--- + +## 3. Reassembly (RX byte stream) + +Both transports deliver bytes that may split or coalesce frames. Buffer and parse: + +``` +loop: + resync: drop bytes until buffer starts with 48 42 + if buffered < 10: wait for more + LEN = u16le(buf[8:10]) + auth = buf[3] & 1 + total = 10 + LEN + (auth ? 16 : 0) + if buffered < total: wait for more + handle(buf[0:total]); remove those bytes +``` + +--- + +## 4. Pairing & handshake (do this on every connect) + +### 4.1 Get the PSK (once per device) + +The device shows a **32-byte PSK** as a QR code + hex on its screen +(Settings -> PAIRING), or prints it on the dev console with `hostlink psk`. The +app reads/types it once and stores it in the OS keystore (Keychain / Credential +Manager / libsecret). The QR/hex encodes the 64-char lowercase hex of the PSK. + +### 4.2 Handshake frames + +1. **App -> device `HELLO`** (unauthenticated, `FLAGS=0`, no MAC). BODY: + `[type=0x10][cat=0x00][op=0x00][host_ver=0x01][client_nonce[16]]` + (`client_nonce` = 16 random bytes). `LEN = 20`. + +2. **Device -> app `HELLO_ACK`** (unauthenticated). BODY: + `[type=0x11][cat=0x00][op=0x00][host_ver=0x01][server_nonce[16]][device_id[6]][mac_psk[16]]`. + - `device_id` = the device's 6-byte base MAC. + - Verify `mac_psk == HMAC-SHA256(PSK, client_nonce || server_nonce)[:16]`. If it + doesn't match, the device doesn't hold your PSK - abort. + +3. **Both derive per-direction keys** (HKDF-SHA256, standard extract+expand): + ``` + salt = client_nonce || server_nonce # 32 bytes + K_a2d = HKDF-SHA256(ikm=PSK, salt=salt, info="tos-host-a2d", L=32) # app -> device + K_d2a = HKDF-SHA256(ikm=PSK, salt=salt, info="tos-host-d2a", L=32) # device -> app + ``` + The `info` labels are exactly those 12 ASCII bytes (no NUL terminator). + +4. **Reset counters.** Use a fresh monotonic counter per direction for this + session. The app signs every app->device frame with `K_a2d`; it verifies every + device->app frame with `K_d2a`. + +After the handshake, **all** frames are authenticated (`FLAGS.bit0 = 1`, MAC +appended). The device rejects (drops + logs) any non-`HELLO` frame that fails the +MAC or counter check. + +--- + +## 5. Authenticated frames, counters, replay + +- Set `FLAGS = 0x01`, fill `COUNTER`, build `BODY`, then append + `MAC = HMAC-SHA256(K_dir, frame[2 .. 10+LEN])[:16]`. +- **App -> device:** sign with `K_a2d`. Use a counter that **strictly increases** + every frame. Starting at `0` (and incrementing) is fine; the device accepts the + first authenticated frame at any value and then requires each next one to be + greater. +- **Device -> app:** verify with `K_d2a` and check the counter strictly increases. + The first authenticated device frame uses `COUNTER = 1` (the `HELLO_ACK` consumed + `0`). Drop any frame whose MAC fails or whose counter is `<=` the last accepted. +- Reconnecting (or the link dropping) invalidates the session: redo the handshake. + +--- + +## 6. Commands and responses + +``` +app -> CMD : BODY = [0x01][category][op][args...] (authenticated) +device -> RESP: BODY = [0x02][category][op][status u8][data...] +``` + +`status` (`spi_status_t`): `0` OK, `1` BUSY, `2` ERROR, `3` UNSUPPORTED, +`4` INVALID_ARG. The device echoes the same `category`/`op` in the `RESP`. + +Example - **WiFi scan** (`category=0x01`, `op=0x10`), no args, authenticated, app +counter `5`: + +``` +48 42 01 01 05 00 00 00 03 00 header: MAGIC,VER,FLAGS=auth,COUNTER=5,LEN=3 +01 01 10 body: type=CMD, cat=0x01, op=0x10 +<16-byte MAC over bytes [2..13)> +``` + +List results (scan tables, etc.) are pulled with the generic data pipe +`SPI_ID_SYSTEM_DATA` (`category=0x00`, `op=0x05`): index `0xFFFF` returns the +count, `0..N-1` returns one item. See [`../spi_bridge/README.md`](../spi_bridge/README.md). + +--- + +## 7. Streaming (sniffers, monitors) + +Long-running ops push data instead of being polled: + +``` +app -> CMD category/op of the op (e.g. WiFi sniffer 0x01/0x25), args +device -> RESP status=OK + data = [session_id u32] +device -> STREAM (pushed) BODY = [0x03][category][op][record bytes] # repeated +app -> CMD SESSION_HEARTBEAT (0xFF/0xF0) every ~2 s -> RESP [alive u8] +app -> CMD SESSION_STOP (0xFF/0xF2) to end +``` + +- Keep sending the heartbeat: if the app goes silent for ~6 s (or the link drops), + the device tears the session down. If the device ends it first (error/timeout), + it pushes a `STREAM` with `category=0xFF op=0xF1` (session lost) and an empty + payload. +- For the WiFi sniffer the `STREAM` record payload is + `[rssi i8][channel u8][len u8][802.11 frame bytes]` - build your pcap/pcapng + from `frame` (use `rssi`/`channel` for the radiotap header). +- Backpressure is handled device-side; just drain notifications/reads promptly. + +--- + +## 8. Logs and the two consoles + +The device pushes `LOG` frames: `BODY = [0x04][cat=0][op=0][source u8][level u8][utf-8 text]`. + +- `source`: `0` = P4, `1` = C5 -> render two separate consoles. +- `level`: `0` ERROR, `1` WARN, `2` INFO, `3` DEBUG, `4` VERBOSE (colorize/filter). +- ANSI codes are already stripped. Logs always flow over USB; over BLE they are + gated by the `log_over_ble` toggle (§10). + +**Run a console line:** `CMD category=0x00 op=0x47` (`SYSTEM_CONSOLE_EXEC`) with +the raw command line as the payload. The command's stdout comes back as `LOG` +frames (`source=P4`); the `RESP` just confirms acceptance. Gated by the +`console_exec` toggle. + +--- + +## 9. File transfer (P4-local) + +All file ops are `category=0x00`; they run on the P4 and never touch the C5. +Paths are sandboxed to `/assets`, `/littlefs`, `/sdcard` (no `..`). Chunk size cap +is 1024 bytes. + +| op | id | request payload | response data | +|----|----|-----------------|---------------| +| `FILE_LIST` | `0x40` | `` | `[count u16]` then entries `[is_dir u8][size u32][nlen u8][name]` | +| `FILE_STAT` | `0x41` | `` | `[exists u8][is_dir u8][size u32]` | +| `FILE_READ` | `0x42` | `[offset u32][len u16]` | file bytes (0 bytes = EOF) | +| `FILE_WRITE` | `0x43` | `[offset u32][flags u8][path_len u16]` | `[written u32]` | +| `FILE_DELETE` | `0x44` | `` | (empty) | +| `FILE_MKDIR` | `0x45` | `` | (empty) | + +`FILE_WRITE` `flags` bit0 = create/truncate (start a fresh file); otherwise the +data is written in place at `offset` (file created if absent). Download = repeated +`FILE_READ` with advancing `offset` until a short/empty read; upload = repeated +`FILE_WRITE`. + +--- + +## 10. Device state and settings + +- **Device state:** `CMD category=0x00 op=0x46` (`SYSTEM_DEVICE_STATE`) -> + `[battery_pct u8][charging u8][app_connected u8][p4_len u8][p4_ver][c5_len u8][c5_ver]`. +- **Read settings:** `op=0x48` (`GET_SETTINGS`) -> `[console_exec u8][log_over_ble u8]`. +- **Write settings:** `op=0x49` (`SET_SETTINGS`) with `[console_exec u8][log_over_ble u8]`. + Both default to on. `console_exec=0` disables raw console exec (structured + commands still work); `log_over_ble=0` stops background logs over BLE (USB always + carries logs; console-exec output is always delivered). +- Version check: `op=0x04` (`SYSTEM_VERSION`) returns the C5 version string; the + device-state frame carries both P4 and C5 versions. + +--- + +## 11. Connect sequence (summary) + +1. Open the transport (USB serial or BLE GATT + subscribe to TX notify). +2. `HELLO` -> `HELLO_ACK`; verify `mac_psk`; derive `K_a2d`/`K_d2a`; reset counters. +3. Read `SYSTEM_DEVICE_STATE` / `SYSTEM_VERSION`; check firmware compatibility. +4. Issue authenticated `CMD`s; handle `RESP`, `STREAM`, and `LOG` frames as they + arrive. Heartbeat any active streaming session every ~2 s. +5. On disconnect, discard the session keys; a reconnect starts a fresh handshake. + +--- + +## 12. Crypto checklist (must match the firmware exactly) + +- HMAC-SHA256, truncated to the **first 16 bytes**. +- HKDF-SHA256 (RFC 5869 extract+expand), `ikm = PSK`, + `salt = client_nonce || server_nonce`, `info` = `"tos-host-a2d"` / `"tos-host-d2a"`, + output length 32. +- `mac_psk` and per-frame `MAC` are both HMAC-SHA256 truncated to 16 B; the + per-frame MAC input is `frame[2 .. 10+LEN]` (header-after-MAGIC plus BODY). +- Verify MACs in constant time. Never log or persist the PSK or session keys in + plaintext. diff --git a/docs/host_link/protocol.md b/docs/host_link/protocol.md new file mode 100644 index 000000000..63083e9e6 --- /dev/null +++ b/docs/host_link/protocol.md @@ -0,0 +1,370 @@ +# Host Link Protocol - Companion App ↔ TentacleOS + +**Status: CONFIRMED v1 (firmware-owned).** +The **firmware is the source of truth** for the wire protocol; the desktop/web +companion app only follows it. This document is the agreed contract - the +`[FW]` decisions from the original proposal are resolved below. A few hardware +identifiers (USB VID/PID, BLE UUIDs) are marked **TBD** and assigned during +implementation; they don't affect the protocol shape. + +Related docs: +- [`README.md`](./README.md) - unified host-link overview +- [`../spi_bridge/README.md`](../spi_bridge/README.md) - P4 ↔ C5 architecture overview +- `firmware_*/components/Service/spi_bridge/README.md` - command reference, session lifecycle, stream transport +- `firmware_*/components/Service/spi_bridge/spi_protocol.h` - shared command table (`spi_id_t`) + +--- + +## 1. Goal + +Let the companion app drive the device over **USB and BLE** using the **same +command set the firmware speaks internally** (`spi_id_t` = `Category`+`Op`). We +do not invent a parallel command protocol - the app reuses the existing +commands, stream format, and session lifecycle. The host link only adds what an +external, untrusted connection needs that the internal SPI trace does not: +framing, authentication, push delivery, file transfer, and log/console access. + +--- + +## 2. Architecture - Model A (P4 is the single hub) - CONFIRMED + +``` + USB ┌─────────────┐ SPI (existing bridge) ┌─────────────┐ + Companion app ─────►│ ESP32-P4 │◄───────────────────────►│ ESP32-C5 │ + (desktop / web) │ brain/OS │ │ radios │ + BLE │ SD · USB │ │ WiFi·BT·LoRa│ + ┌────────────│ SPI master │ │ SPI slave │ + │ (relay) └─────────────┘ └─────────────┘ + │ ▲ + └───────────────────┘ BLE terminates on the C5 (it owns the radio); + the C5 RELAYS framed host bytes to the P4. +``` + +- **USB** terminates on the **P4** (P4 owns USB). +- **BLE** terminates on the **C5** (C5 owns the BLE radio); the C5 is a + **transparent byte relay** that ferries companion frames to/from the P4 over + the existing SPI bridge. +- **The P4 is the one brain:** it terminates the security envelope, dispatches + commands (locally or to the C5 over SPI, exactly as today), owns SD storage + and device state. Identical behavior on both transports; one place for crypto. + +**C5 ⇄ P4 relay mechanism:** reuses the **proven Meshtastic/MeshCore phone-bridge +pattern** (BLE-on-C5 → SPI → P4 already ships today). Two SPI ops carry opaque +host bytes: `SPI_ID_HOST_RX` (C5→P4, inbound from app; C5 buffers, raises IRQ, +P4 pulls via the stream path) and `SPI_ID_HOST_TX` (P4→C5, outbound to app; C5 +notifies over BLE). Host frames larger than one SPI frame are chunked by the +firmware. The C5 never parses companion payloads - it only moves bytes; **all +crypto/auth is on the P4.** The app never sees this internal hop. + +--- + +## 3. Transports + +### 3.1 USB - CDC-ACM (dedicated) +- A dedicated **CDC-ACM** interface in the P4's TinyUSB composite (alongside the + existing BadUSB HID). The raw developer console (`idf.py monitor`) stays on the + **USB-Serial-JTAG**, so dev logs and the companion link don't collide. +- Bidirectional, framed: app→device = `CMD`; device→app = `RESP`/`STREAM`/`LOG`. +- **TBD:** VID/PID for auto-detect. + +### 3.2 BLE - GATT companion service (on the C5) +- A GATT service with a **write** characteristic (app→device) and a **notify** + characteristic (device→app). Frames larger than the MTU span multiple + notifications and are reassembled by `LEN`. +- **TBD:** service/characteristic UUIDs, advertised name / scan-match, target + MTU, bonding requirement (LE Secure Connections recommended on top of the PSK). + +### 3.3 Single companion session +Only **one** companion connection is active at a time. While one app is +connected (and authenticated), the device **rejects** a second connection on +either transport. + +--- + +## 4. What we REUSE from the SPI bridge (unchanged) + +- **Command identity:** `Category` + `Op` → `spi_id_t` (`SPI_CMD(cat, op)`). Same + IDs as `spi_protocol.h`. +- **Message types:** `CMD 0x01`, `RESP 0x02`, `STREAM 0x03` (host link adds `LOG`). +- **Response status:** `RESP` payload byte 0 = `spi_status_t` (`OK 0`, `BUSY 1`, + `ERROR 2`, `UNSUPPORTED 3`, `INVALID_ARG 4`). +- **Stream record layout:** `[u16 batch_len]` then records `[u16 op][u8 len][payload]`, + payload carrying `spi_stream_meta_t { session_id, seq }` + op data. +- **Generic data pipe** for list results: `SPI_ID_SYSTEM_DATA` (`0xFFFF` count, + `0..N-1` item, `0xEEEE` stats, `0xDDDD` deauth counter). +- **Session lifecycle:** random 32-bit `session_id`, heartbeat (2 s) + watchdog + (5 s) → `SPI_ID_SESSION_LOST`, backpressure window (64), `SPI_ID_SESSION_STOP`. +- **Version check:** `SPI_ID_SYSTEM_VERSION`. + +**NOT reused:** SPI physical artifacts - fixed 264 B / 2048 B frames, 4-byte DMA +alignment, master-poll. The host link uses variable length-prefixed frames and +**push** delivery. + +--- + +## 5. Host frame format (the envelope) + +Every byte on the USB/BLE link is one host frame. **Little-endian.** + +| Offset | Size | Field | Notes | +|-------:|-----:|-------|-------| +| 0 | 2 | `MAGIC` | `0x48 0x42` ("HB") - frame sync / resync anchor | +| 2 | 1 | `VER` | host-link protocol version (separate from firmware version) | +| 3 | 1 | `FLAGS` | bit0 = authenticated; rest reserved | +| 4 | 4 | `COUNTER` | u32, per-direction monotonic - replay protection | +| 8 | 2 | `LEN` | u16, length of `BODY` | +| 10 | `LEN` | `BODY` | see below | +| 10+LEN | 16 | `MAC` | HMAC-SHA256(`K_dir`, bytes `[2 .. 10+LEN)`) truncated to 128 bits | + +`MAC` is **fixed 16 B**. The P4 **verifies the MAC and checks the counter before +parsing `BODY`**; on failure it drops the frame and logs a security event. The +P4 has hardware SHA acceleration, so per-frame HMAC is cheap even on pcap streams. + +``` +BODY = | type (1B) | category (1B) | op (1B) | payload (...) | + CMD (0x01) payload = command args + RESP (0x02) payload = [status u8][data...] + STREAM (0x03) payload = [u16 batch_len][record]... (record = [u16 op][u8 len][meta+data]) + LOG (0x04) payload = [source u8][level u8][utf-8 text] (see §7) +``` + +Pre-auth handshake frames (`HELLO` / `HELLO_ACK`, §6) travel with +`FLAGS.authenticated = 0` and are the only frames accepted before keys exist. + +**Reassembly:** sync on `MAGIC`, read the 10-byte header for `LEN`, accumulate +`LEN + 16` more bytes. + +--- + +## 6. Security + +The internal SPI trace is trusted; **USB and especially BLE are not.** The device +drives real RF/USB attack hardware, so command authenticity + replay protection +are mandatory. + +### 6.1 Handshake (on every connect) +Dedicated pre-auth frames (unauthenticated): +1. App → device: `HELLO { host_ver, client_nonce[16] }`. +2. Device → app: `HELLO_ACK { host_ver, server_nonce[16], device_id, mac_psk }` + where `mac_psk = HMAC(PSK, client_nonce || server_nonce)` (proves the device + holds the PSK - mutual auth). +3. Both derive per-direction session keys and reset counters: + - `K_a2d = HKDF(PSK, client_nonce || server_nonce, "a2d")` + - `K_d2a = HKDF(PSK, client_nonce || server_nonce, "d2a")` +4. All later frames carry `FLAGS.authenticated = 1`, the sender's direction key, + and a monotonic counter. + +Per-direction keys prevent reflection; fresh nonces prevent cross-session replay. + +### 6.2 PSK provisioning - QR/code on the P4 display +First-time pairing: the user authorizes a new app; the **P4 shows a QR/code on +its display**, the app reads it (or the user types it), and both derive the PSK. +Works identically for USB and BLE. App-side, the PSK is stored in the OS keystore +(Keychain / Credential Manager / libsecret) - never plaintext, never logged. + +### 6.3 Alignment +OWASP 2021: A02 (HMAC-SHA256/HKDF, BLE LE Secure Connections), A07 (session keys, +re-auth on reconnect), A08 (per-frame integrity), A01 (only the paired app +commands the device). + +--- + +## 7. Logs & console (two separate consoles) + +Both chips emit their own `ESP_LOGx`. Each tees its output via +`esp_log_set_vprintf` (without losing the local dev console). The P4 forwards +both streams to the app as `LOG` frames tagged with a **`source`** byte so the +app can render **two consoles** (P4 / C5): + +``` +LOG payload = [source u8: 0=P4, 1=C5][level u8: E=0,W=1,I=2,D=3,V=4][utf-8 text] +``` + +- **P4 logs:** teed locally on the P4. +- **C5 logs:** teed on the C5 → pushed to the P4 over SPI via `SPI_ID_SYSTEM_LOG` + (same mechanism as the existing `SPI_ID_MESH_LOG_PUSH`) → relayed out as `LOG` + with `source=C5`. +- ANSI color codes are stripped; the app colorizes/filters by `level` and `source`. +- Each log channel has a small **ring + drop-oldest** buffer; only the boot burst + is heavy (runtime logging is low-rate), so drops are rare and counted. + +**Console command execution:** the app may send a raw console line; the P4 runs it +through `esp_console` and the output flows back through the `LOG` channel. + +### Toggles (device settings) +| Setting | Default | Effect when off | +|---------|---------|-----------------| +| **Console exec** (app→device) | on, BLE + USB | app cannot run raw console lines (structured `CMD`s still work) | +| **Log over BLE** (global) | on | **no** logs (P4 or C5) are sent over BLE; USB always carries logs | + +Structured commands (scan, capture, file ops, …) and log *reading* over USB are +always available; the toggles only gate raw console exec and BLE log delivery. + +--- + +## 8. Command / response flow + +``` +app → CMD (category, op, args) authenticated, counter++ +P4 → RESP (status, data) authenticated, counter++ +``` +- Reliable + ordered (USB CDC / BLE ACL) → no app-level retransmit; the counter + detects gaps/replays. +- The P4 dispatches by `category`/`op` exactly as it dispatches its own commands + today (local handler or relay to C5 over SPI). +- List results pulled via the generic data pipe (`SPI_ID_SYSTEM_DATA`). +- **Full command set on both transports** (no USB-only restriction), gated only + by the console-exec toggle above. + +--- + +## 9. Streaming & sessions (push-based) + +Long-running ops (sniffers, monitors) reuse the firmware session model; the +device **pushes** `STREAM` frames instead of the host polling: + +``` +app → CMD SPI_ID_WIFI_APP_SNIFFER { params } +P4 → RESP status OK + spi_session_resp_t { session_id } +P4 → STREAM batched records { session_id, seq, payload } … (pushed) +app → CMD SPI_ID_SESSION_HEARTBEAT { session_id, last_acked_seq } (every 2 s) +app → CMD SPI_ID_SESSION_STOP { session_id } +``` +- **Liveness is two-level:** the app heartbeats the P4 over the host link; the P4 + keeps heartbeating the C5 over SPI (existing). If the app disappears, the P4 + tears down and stops heartbeating the C5 → the C5 watchdog kills the session. +- **Backpressure / anti-zombie** unchanged (window 64; 5 s watchdog → + `SPI_ID_SESSION_LOST`). Matters more on BLE/USB (links drop/unplug). +- The app builds a real pcap/pcapng from the raw 802.11 bytes in the stream + records → full Wireshark-level dissection in real time. + +--- + +## 10. File transfer (download + edit) + +The app has a file viewer/editor, so it can **download and write** files over +both transports. The P4 exposes **two separate filesystems, both physically on +the P4** - the app browses/edits each independently: + +- **Internal flash** - the `assets` / `littlefs` partitions (config, defaults, + captures saved to flash, …). +- **micro-SD** - via SDMMC (`/sdcard`), the larger removable storage. + +The **path root selects the filesystem** (e.g. `/assets/…`, `/littlefs/…`, +`/sdcard/…`); ops are sandboxed to the mounted roots (no escaping them). Large +files (pcap, MBs) are transferred in **chunks with offsets**; big reads reuse the +batched stream transport. + +Proposed `SYSTEM`-category ops (final ids assigned in `spi_protocol.h`): +- `FILE_LIST { path }` → directory entries (name, size, is_dir) via the data pipe. +- `FILE_STAT { path }` → size, flags. +- `FILE_READ { path, offset, len }` → chunk (streamed for large files). +- `FILE_WRITE { path, offset, data }` → write/edit a chunk (create/truncate flags). +- `FILE_DELETE { path }`, `FILE_MKDIR { path }`. + +Writes are bounded/validated by the P4 (path sandbox to the storage mount; no +escaping it). All file ops require an authenticated session. + +--- + +## 11. Device state (pushed) + +The device pushes a status frame on connect, on change, and periodically: +``` +DeviceStatus = { battery_pct u8, charging u8, app_connected u8, + fw_version_p4[..], fw_version_c5[..] } +``` +Carried as a `SYSTEM` op (`SPI_ID_SYSTEM_STATUS` extended, or a dedicated +`SPI_ID_SYSTEM_DEVICE_STATE`). Battery comes from the BQ25896 gauge; versions +reuse the existing version contract. + +--- + +## 12. Versioning & compatibility + +- Host-link `VER` is exchanged in the handshake; mismatch → app refuses to + proceed with a clear message. +- The app also reads `SPI_ID_SYSTEM_VERSION` on connect and checks the firmware + version. **Min firmware version:** the first build that ships host-link support + (TBD once it lands; bump from the current `1.3.0`). + +--- + +## 13. Firmware components to build + +- **P4 host-link core:** frame envelope + HMAC/HKDF + counter + handshake + PSK + store (NVS) + dispatch (reuses SPI dispatch) + push. +- **P4 CDC-ACM** interface (TinyUSB composite) + auto-detect VID/PID. +- **C5 BLE companion GATT** service + `SPI_ID_HOST_RX`/`HOST_TX` relay (mesh + bridge pattern). +- **Log tee** on both chips + `SPI_ID_SYSTEM_LOG` forward (C5→P4) + `LOG` frames + with `source`. +- **Console-exec** command + the two settings toggles. +- **File ops** (`FILE_*`) over both P4 filesystems (internal flash + micro-SD), + path-rooted and sandboxed, chunked. +- **Device-state** push (battery + versions + connection). + +--- + +## 14. Open hardware identifiers (TBD - don't block the protocol) +- USB VID/PID. +- BLE service/characteristic UUIDs, advertised name, target MTU, bonding policy. +- Final `SPI_ID_*` op numbers for the new commands (`HOST_RX/TX`, `SYSTEM_LOG`, + `FILE_*`, `DEVICE_STATE`). +- Minimum firmware version once host-link ships. + +--- + +## 15. Implementation plan (phased build order) + +Built in small, independently testable phases. **All 8 phases are implemented and +build-validated on both firmwares.** They have **not** been exercised on hardware +yet (the dev board's native USB pads are unsoldered and BLE is untested), so each +phase still lists its concrete on-device check for when that's possible. + +Status legend: ✅ implemented (build-validated). + +1. ✅ **P4 host-link core + CDC-ACM (no crypto).** Frame envelope encode/decode + + dispatch reusing the existing SPI dispatcher, over USB CDC. + (`host_link.c`, `host_link_cdc.c`.) *Test:* a serial tool sends + `PING`/`VERSION`, gets `RESP`. - note: now requires a handshake first (phase 3). +2. ✅ **Log tee on P4 + `LOG` frames** (`source=P4`). vprintf hook → ANSI strip → + drop-oldest ring → worker. (`host_link_log.c`.) *Test:* app sees P4 logs. +3. ✅ **Security envelope** - HMAC-SHA256/HKDF (mbedTLS), per-direction keys, + monotonic counter, `HELLO`/`HELLO_ACK` handshake, PSK in NVS, QR/hex on the P4 + display. (`host_link_sec.c`; UI `companion_pairing`; `cmd_hostlink`.) *Test:* + unauthenticated frames rejected; paired app works; replay rejected. +4. ✅ **BLE companion (C5 GATT) + relay** `SPI_ID_HOST_RX`/`HOST_TX` (mesh-bridge + pattern, NimBLE NUS-style, "just works" SC). C5 `host_link_gatt.c` + + `host_transport.c`; P4 `host_link_ble.c`; single-session arbitration in the + core. *Test:* same command set over BLE; single-app enforcement. +5. ✅ **C5 log forward** `SPI_ID_SYSTEM_LOG` (C5→P4 stream) → `LOG` frames with + `source=C5`. C5 `c5_log.c`; P4 `host_link_c5log.c`. *Test:* both consoles + populate. +6. ✅ **File ops** `FILE_*` over both filesystems (flash + micro-SD), chunked, + path-sandboxed; BLE notify split by MTU. (`host_link_files.c`.) *Test:* + download a pcap, edit + write back a config file. +7. ✅ **Device state** (battery/charging/versions) + the two settings toggles + (console-exec, log-over-BLE) + raw console exec (captures stdout → console + LOG frames). (`host_link_state.c`.) *Test:* state read; toggles persist. +8. ✅ **Streaming push + heartbeat proxy** (reuses the existing `spi_session`). + Sniffer records → `STREAM` frames; app heartbeat refreshes liveness; app + silence / link loss tears the session down. (`host_link_stream.c`.) *Test:* + live sniffer pcap streams to the app; pulling the link triggers + `SPI_ID_SESSION_LOST`. + +Each new command (`HOST_RX/TX`, `SYSTEM_LOG`, `FILE_*`, `DEVICE_STATE`, +settings, console-exec) lives in `spi_protocol.h` so it stays part of the +single-source-of-truth HAL. Component-level docs: `firmware_p4/components/ +Service/host_link/README.md` and `firmware_c5/components/Service/host_link/README.md`. + +## 16. What the app implements + +- A transport-agnostic backend with two implementations (**USB serial** / **BLE**) + behind one interface. +- Host-frame encode/decode + HMAC envelope + counter + handshake. +- Reuse of `spi_id_t` IDs derived from `spi_protocol.h` (single source of truth). +- Mapping device→app messages onto app state (commands, streams, the two log + consoles, file viewer/editor, device-status indicators). + +The backend is the trust boundary; the UI layer only ever sees validated state. diff --git a/docs/http_server/README.md b/docs/http_server/README.md new file mode 100644 index 000000000..89e3a47ac --- /dev/null +++ b/docs/http_server/README.md @@ -0,0 +1,116 @@ +# HTTP Server Service Component Documentation + +This component provides an abstraction layer over ESP-IDF's native `esp_http_server`, facilitating initialization, request handling, response sending, and file system (SD Card) integration for the Highboy project. + +## Overview + +- **Location:** `components/Service/http_server/` +- **Main Header:** `include/http_server_service.h` +- **Implementation:** `http_server_service.c` + +The service manages the web server lifecycle (start/stop), route registration (URIs), and offers utilities for reading HTML files from storage and handling standard HTTP errors. + +## API Functions + +### Server Management + +#### `start_web_server` +```c +esp_err_t start_web_server(void); +``` +Starts the HTTP server with default configurations, enabling `lru_purge_enable` to manage old connections. + +#### `stop_http_server` +```c +esp_err_t stop_http_server(void); +``` +Stops the HTTP server if it is running and frees associated resources. + +#### `http_service_register_uri` +```c +esp_err_t http_service_register_uri(const httpd_uri_t *uri_handler); +``` +Registers a URI handler (route) on the active server. Returns an error if the server is not started. + +### Request and Response Handling + +#### `http_service_req_recv` +```c +esp_err_t http_service_req_recv(httpd_req_t *req, char *buffer, size_t buffer_size); +``` +Receives the content (body) of a request with safety checks for buffer size. +- Returns `ESP_ERR_INVALID_SIZE` if the content is larger than the buffer. +- Automatically handles timeouts. + +#### `http_service_query_key_value` +```c +esp_err_t http_service_query_key_value(const char *data_buffer, const char *key, char *out_val, size_t out_size); +``` +Extracts the value of a specific key from a query string (URL encoded). Handles cases where the key is not found or the value is truncated. + +#### `http_service_send_response` +```c +esp_err_t http_service_send_response(httpd_req_t *req, const char *buffer, ssize_t length); +``` +Sends a generic HTTP response. +- If `buffer` is `NULL`, it automatically sends a 500 error. + +#### `http_service_send_error` +```c +esp_err_t http_service_send_error(httpd_req_t *req, http_status_t status_code, const char *msg); +``` +Sends a standardized HTTP error response, mapping the internal `http_status_t` enum to ESP-IDF error codes (`httpd_err_code_t`). + +### Storage Integration (SD Card) + +#### `get_html_buffer` +```c +const char *get_html_buffer(const char *path); +``` +Reads an entire file from the specified path (usually from the SD Card) and returns a dynamically allocated buffer containing the data, null-terminated (`\0`). +- **Note:** The caller is responsible for freeing the returned memory (see Casting note below). + +#### `http_service_send_file_from_sd` +```c +esp_err_t http_service_send_file_from_sd(httpd_req_t *req, const char *filepath); +``` +Combines `get_html_buffer` and `http_service_send_response` to read a file and send it directly as a response to the request. Automatically frees the buffer memory after sending. + +--- + +## Castings and Implementation Details + +Below are listed all explicit "castings" (type conversions) performed in the source code `http_server_service.c`, which are fundamental for memory allocation and opaque type manipulation. + +### 1. File Buffer Allocation +**Location:** Function `get_html_buffer` +```c +char *buffer = (char *)malloc(file_size + 1); +``` +- **From:** `void *` (generic return from `malloc`) +- **To:** `char *` +- **Reason:** The pointer returned by `malloc` needs to be treated as a character string to store the file content and the null terminator. + +### 2. Constant Memory Deallocation +**Location:** Function `http_service_send_file_from_sd` +```c +free((void*)html_content); +``` +- **From:** `const char *` (type of `html_content` variable) +- **To:** `void *` +- **Reason:** The `get_html_buffer` function returns a `const char *` to semantically indicate that the receiver should not alter its content. However, to free this memory with `free()`, it is necessary to remove the `const` qualifier via a cast to `void *`; otherwise, the compiler would emit a warning or error, since `free` expects a pointer to mutable memory (even though it only frees it). + +--- + +## Auxiliary Data Structures + +### `http_status_t` +Enumeration defined in `http_server_service.h` to abstract HTTP status codes and facilitate internal mapping: +- `HTTP_STATUS_OK_200` +- `HTTP_STATUS_CREATED_201` +- `HTTP_STATUS_BAD_REQUEST_400` +- `HTTP_STATUS_UNAUTHORIZED_401` +- `HTTP_STATUS_FORBIDDEN_403` +- `HTTP_STATUS_NOT_FOUND_404` +- `HTTP_STATUS_REQUEST_TIMEOUT_408` +- `HTTP_STATUS_INTERNAL_ERROR_500` diff --git a/docs/input_manager/README.md b/docs/input_manager/README.md new file mode 100644 index 000000000..9b7834135 --- /dev/null +++ b/docs/input_manager/README.md @@ -0,0 +1,115 @@ +# P4 + +Central button input for the P4. One periodic sampler debounces all six buttons, +drives a per-button state machine (press / release / long-press / auto-repeat), +pushes events onto a queue, tracks the last interaction for the power policy, and +registers the button GPIOs as a wake source. + +## Overview + +- **Location:** `components/Drivers/input_manager/` +- **Header:** `include/input_manager.h` +- **Dependencies:** `driver/gpio`, `esp_timer`, `esp_sleep`, `pin_def.h` + +It replaces the pattern where ~110 screens each ran their own `lv_timer` polling +raw GPIO with per-screen edge detection. `buttons_gpio` is now a thin shim over +this module (see [buttons_gpio](../buttons_gpio/README.md)), so existing call +sites keep working unchanged while gaining debounce, activity tracking and wake. + +## Why it exists + +- **Single "user interacted" point.** `input_last_activity_ms()` gives the power + policy one timestamp to watch, instead of editing every screen. +- **Long-press / repeat.** Enables hold-to-power-off and boot key-combos, and + gives menus auto-repeat, without per-screen timers. +- **Time-based debounce.** Consistent regardless of any screen's poll period. +- **Wake source.** The button GPIOs are registered so light sleep can be woken by + a button. +- **Fewer always-on timers.** Migrated screens drop their polling `lv_timer`. + +## Sampling and timing + +A periodic `esp_timer` ticks every `SAMPLE_PERIOD_MS` (5 ms), independent of the +LVGL render loop, so input is sampled reliably even when rendering is busy. + +| Constant | Default | Meaning | +|----------|---------|---------| +| `SAMPLE_PERIOD_MS` | 5 | Sampler tick | +| `DEBOUNCE_SAMPLES` | 4 | Stable ticks to accept a level (~20 ms) | +| `LONG_PRESS_MS` | 800 | Held this long emits `LONG_PRESS` once | +| `REPEAT_DELAY_MS` | 400 | First `REPEAT` after a press | +| `REPEAT_PERIOD_MS` | 120 | Subsequent `REPEAT` interval | +| `EVENT_QUEUE_LEN` | 16 | Event queue depth | + +A button held at boot is seeded as "pressed at boot" (no phantom `PRESS`), so a +boot key-combo times its long-press from boot rather than firing instantly. + +## Events + +```c +typedef struct { + input_button_t button; // INPUT_BTN_UP..INPUT_BTN_BACK + input_action_t action; // PRESS | RELEASE | LONG_PRESS | REPEAT + uint32_t timestamp_ms; // ms since boot +} input_event_t; +``` + +`PRESS`/`RELEASE` are debounced edges; `LONG_PRESS` fires once at the threshold; +`REPEAT` auto-repeats while held. A held button therefore emits `PRESS`, then +`REPEAT`s, and `LONG_PRESS` once at 800 ms - consumers use whichever they need. + +## API + +```c +esp_err_t input_manager_init(void); // sampler + queue + wake source +bool input_get_event(input_event_t *out, uint32_t timeout_ms); // consume events +bool input_is_down(input_button_t button); // debounced held state +bool input_consume_press(input_button_t button); // latched edge (backs the shim) +uint32_t input_last_activity_ms(void); // timestamp of last activity +void input_sim_press(input_button_t button, uint32_t ms); // headless / console `key` +esp_err_t input_configure_wake_source(void); // re-arm GPIO light-sleep wake +``` + +Idle time for the power policy is `now_ms - input_last_activity_ms()`. + +## Event-driven screens (the router) + +Screens do not call `input_get_event()` themselves. The UI manager runs **one** +pump (`ui_input_pump`, a single `lv_timer`) that drains the queue and dispatches +each event to the active screen's handler. This replaces the ~110 per-screen +polling `lv_timer`s with one shared timer. + +- A screen registers its handler in its open function: + `ui_input_set_screen_handler(my_input, NULL)` (see `ui_manager.h`). +- The handler is cleared automatically on the next screen switch + (`clear_current_screen`), so screens never leak handlers or fight for input. +- The pump swallows input while `ui_input_is_locked() || msgbox_is_open() || + keyboard_is_open()`, so the old per-screen guards are gone and screens freeze + correctly under overlays. The dropdown refreshes the input lock while open, so + it is covered too. +- Migrated and not-yet-migrated screens coexist: a screen that has not been + migrated simply leaves the handler NULL and keeps its own polling timer. + +Handler pattern (from `nfc_menu_ui.c`): + +```c +static void nfc_menu_input(const input_event_t *ev, void *ctx) { + const bool press = (ev->action == INPUT_ACTION_PRESS); + const bool nav = press || (ev->action == INPUT_ACTION_REPEAT); // held auto-scroll + switch (ev->button) { + case INPUT_BTN_BACK: case INPUT_BTN_LEFT: if (press) ui_switch_screen(SCREEN_MENU); break; + case INPUT_BTN_OK: case INPUT_BTN_RIGHT: if (press) { /* open selected */ } break; + case INPUT_BTN_DOWN: if (nav) menu_component_next(&s_menu); break; + case INPUT_BTN_UP: if (nav) menu_component_prev(&s_menu); break; + default: break; + } +} +``` + +The `PRESS` event *is* the debounced edge, so the per-screen `s_*_last` edge +statics disappear. Use `INPUT_ACTION_REPEAT` for held auto-repeat (menu scroll), +and `input_is_down()` for screens that need a continuous held state (e.g. games). + +**Migrating the existing screens:** 7 are done; ~91 still poll. The step-by-step +recipe, gotchas and reference examples are in +[ui/input-migration.md](../ui/input-migration.md). diff --git a/docs/lvgl/README.md b/docs/lvgl/README.md new file mode 100644 index 000000000..b5c7a68a3 --- /dev/null +++ b/docs/lvgl/README.md @@ -0,0 +1,136 @@ +# LVGL Service Documentation + +This component integrates the **LVGL v9** graphics library with the Highboy +hardware: the ST7789 display (via ESP-LCD) and the GPIO buttons. It is the +single home for all LVGL setup on the P4. + +## Overview + +- **Location:** `components/Service/lvgl/` +- **Main headers:** + - `include/lvgl_glue.h` (core + display + lock) + - `include/lv_port_indev.h` (input device) +- **Dependencies:** `lvgl`, `esp_lvgl_port`, `esp_lcd`, `st7789`, `buttons_gpio` + +The service has two parts: +1. **Core + display (`lvgl_glue`):** brings LVGL up over Espressif's + `esp_lvgl_port` and registers the ST7789 display. +2. **Input (`lv_port_indev`):** maps the physical GPIO buttons to LVGL logical + keys (Keypad) for UI navigation. + +> **Note:** the old hand-written display port (`lv_port_disp`) was removed. The +> display is now driven through `esp_lvgl_port` via `lvgl_glue`, so there is a +> single display path. `esp_lvgl_port` owns the LVGL task, the tick source and +> the thread-safety lock; we no longer manage those by hand. + +> **Scheduling:** the LVGL render task runs at `SYS_PRIO_RENDER` (6) pinned to +> `SYS_CORE_UI` (core 1), set in `lvgl_glue_init` from +> [`sys_prio.h`](../sys_prio/README.md). Radios and services live on core 0 so +> they cannot stall rendering. + +### Bring-up order + +```c +st7789_init(); // creates the esp_lcd panel handles (io_handle/panel_handle) +lvgl_glue_init(); // lv_init + LVGL task + tick + display registration +lv_port_indev_init(); // keypad indev + navigation group (under the glue lock) +ui_init(); // build the UI (all LVGL access goes through the glue lock) +``` + +--- + +## Core + Display (`lvgl_glue`) + +Thin wrapper over `esp_lvgl_port`. It configures the port for this board and +exposes a small API the rest of the firmware uses. + +### `lvgl_glue_init` +```c +esp_err_t lvgl_glue_init(void); +``` +1. **Requires `st7789_init()` first** - it reads the panel handles (`io_handle`, + `panel_handle`) the driver publishes; returns `ESP_ERR_INVALID_STATE` if they + are not ready. +2. **`lvgl_port_init`:** starts the managed LVGL task (calls `lv_init` + internally), the periodic tick, and a recursive mutex (the lock). +3. **`lvgl_port_add_disp`:** registers the display - DMA double buffering (20 + lines), RGB565 with byte swap, internal RAM (no PSRAM). + +### Thread safety +```c +bool lvgl_glue_lock(int timeout_ms); // -1 = wait forever +void lvgl_glue_unlock(void); +``` +Any task that touches LVGL (UI, screen capture, etc.) must hold this lock. It is +the `esp_lvgl_port` recursive mutex; `ui_manager`'s `ui_acquire`/`ui_release` +wrap it. + +> **Do not pass `-1` from application code.** `ui_acquire()` uses a finite +> 1000 ms timeout on purpose: a task that grabs the lock and never returns can no +> longer freeze every other UI caller forever. `ui_acquire()` returns `false` on +> timeout (and logs a warning); **every callsite must check the return** and skip +> its work when it is `false`. + +> **Never hold the lock across long or blocking work, and never run a busy loop +> on the UI thread.** A held lock stops the LVGL task from rendering, which +> stalls the render-progress beat (`ui_render_beat()`) that `sys_monitor` polls; +> a stalled beat triggers a controlled restart. A busy loop that spins a core +> past 5 s also trips the Task Watchdog panic (`CONFIG_ESP_TASK_WDT_PANIC=y`). +> Offload long work to its own task (the SubGhz receiver is the reference) and +> push results back to the screen with an `lv_timer` or `lv_async_call`, which +> run inside the LVGL task already under the lock. See +> [ui: long-running work](../ui/README.md#long-running-work-and-the-watchdog). + +### Rotation +```c +bool lvgl_glue_toggle_rotation(void); // returns true if now landscape +bool lvgl_glue_is_landscape(void); +``` +Toggles between portrait (0deg) and landscape (270deg) and invalidates the +active screen. The input port reads `lvgl_glue_is_landscape()` to remap the +arrow keys accordingly. + +### Status +```c +bool lvgl_glue_is_ready(void); // true once lvgl_glue_init succeeded +``` + +--- + +## Input (`lv_port_indev`) + +Integrates the physical buttons as an LVGL "Keypad" input device, enabling +navigation through groups and widgets. + +### `lv_port_indev_init` +```c +void lv_port_indev_init(void); +``` +1. Acquires the LVGL lock via `lvgl_glue_lock` (so it is safe to call after + `lvgl_glue_init`). +2. Creates the default navigation group (`main_group`) and sets it as default. +3. Creates an `LV_INDEV_TYPE_KEYPAD` device with `keypad_read` as its read + callback, bound to `main_group`. + +### Global variables +- `indev_keypad`: the created input device. +- `main_group`: the main navigation group. Widgets added to it are button-driven. + +### Key mapping + +Physical button states (from `buttons_gpio.h`) map to LVGL logical keys. In +landscape the up/down/left/right are remapped to match the rotated screen. + +| Physical Button | LVGL Key | Function | +| :--- | :--- | :--- | +| **Up** | `LV_KEY_PREV` | Focus previous item | +| **Down** | `LV_KEY_NEXT` | Focus next item | +| **OK** | `LV_KEY_ENTER` | Click/Select | +| **Back** | `LV_KEY_ESC` | Back/Close | +| **Left** | `LV_KEY_LEFT` | Decrease value / move left | +| **Right** | `LV_KEY_RIGHT` | Increase value / move right | + +### Internal logic +`keypad_read` is polled periodically by the LVGL task. It reads the hardware +buttons and updates `data->state`/`data->key`, remembering the last pressed key +until all keys are released. diff --git a/docs/ota/README.md b/docs/ota/README.md new file mode 100644 index 000000000..8c7438411 --- /dev/null +++ b/docs/ota/README.md @@ -0,0 +1,110 @@ +# OTA Update Service + +Handles firmware updates for TentacleOS via MicroSD card. Uses A/B OTA partitions with automatic rollback and dual-chip synchronization (ESP32-P4 + ESP32-C5). + +## How It Works + +The C5 firmware is embedded inside the P4 binary at build time. A single `.bin` file updates both chips. + +### Update Flow + +1. Place firmware at `/sdcard/update/tentacleos.bin` +2. Trigger `ota_start_update()` from UI or console +3. P4 validates the file and writes it to the inactive OTA partition +4. P4 reboots into new firmware +5. After `kernel_init`, `ota_post_boot_check()` confirms the image using **local** + health: the assets LittleFS is mounted and the LVGL renderer is advancing +6. If healthy, the update is confirmed (`esp_ota_mark_app_valid_cancel_rollback`) + and `firmware.json` is synced to the running version +7. If the local check fails, the image is left unconfirmed and the bootloader + rolls back to the previous good image on the next reboot + +> **Watchdog:** the write loop (`fread` from LittleFS + `esp_ota_write`, both +> cache-disabling) yields with `vTaskDelay(1)` per chunk so the idle task keeps +> feeding the Task Watchdog. This is required because `CONFIG_ESP_TASK_WDT_PANIC=y` +> is enabled: without the yield, writing a multi-MB image would starve the idle +> task and reboot the device mid-write. The C5 UART flash loop in `c5_flasher` +> yields the same way. + +### Rollback + +The system uses two app partitions (`ota_0` / `ota_1`). After OTA, the new +firmware must call `esp_ota_mark_app_valid_cancel_rollback()` to confirm. +Confirmation is gated on **local, mandatory** criteria only (assets partition +mounted + LVGL renderer alive), **never** on an optional peripheral. + +> Previously confirmation was gated on `bridge_manager_init()` (the C5 bridge), +> which is disabled on purpose - so every update stayed in `PENDING_VERIFY` and +> the device rolled back forever. The C5 sync is no longer part of validation. A +> C5 version sync, if reintroduced, must be an optional step that only logs a +> warning and never blocks confirmation. + +Scenarios: +- **P4 crashes before confirmation** - automatic rollback to previous firmware +- **New image cannot mount assets or the renderer is frozen** - local check + fails, P4 does not confirm, bootloader rolls back +- **Healthy new image** - confirmed within a few seconds of boot + +### Partition Table + +See [boot_report](../boot_report/README.md) for the full current layout (the OTA +slots were resized to `0x270000` to make room for a `coredump` partition). + +| Name | Type | Size | +|---|---|---| +| ota_0 | app | 0x270000 | +| ota_1 | app | 0x270000 | +| coredump | data | 64K | +| otadata | data | 8K | + +### Versioning + +Version is read from `assets/config/OTA/firmware.json`. Both P4 and C5 share the same version string. The C5 responds its version via `SPI_ID_SYSTEM_VERSION` (0x04). + +## API + +```c +bool ota_update_available(void); +esp_err_t ota_start_update(ota_progress_cb_t progress_cb); +esp_err_t ota_post_boot_check(void); +const char* ota_get_current_version(void); +ota_state_t ota_get_state(void); +``` + +### Progress Callback + +```c +void on_progress(int percent, const char *message) { + // 0-5%: Validating + // 5-90%: Writing to flash + // 90-95%: Finalizing + // 95%: Rebooting +} + +ota_start_update(on_progress); +``` + +### Post Boot Check + +Must be called from `main.c` **after** `kernel_init()`, so the assets partition +and LVGL are up for the local health check (it briefly waits on the render beat): + +```c +kernel_init(); +ota_post_boot_check(); +``` + +## sdkconfig + +Required: +``` +CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE=y +``` + +## Dependencies + +- `app_update` (esp_ota_ops) +- `storage_assets` (firmware.json + local health: assets mounted) +- `ui_liveness` (local health: LVGL render beat) +- `sd_card_init` (SD mount status) +- `cJSON` (JSON parsing) diff --git a/docs/recovery/README.md b/docs/recovery/README.md new file mode 100644 index 000000000..87c8111da --- /dev/null +++ b/docs/recovery/README.md @@ -0,0 +1,70 @@ +# Recovery: Safe Mode & Factory Reset + +Field-recovery path for the ESP32-P4 when the normal UI cannot be trusted (a +broken theme, inconsistent config, or a required subsystem that failed to come +up). Reachable without any host tooling. + +## Entering safe mode + +Safe mode is entered at power-on in two ways, both decided in `kernel_init` +before radios, custom themes and services start, so recovery always comes up +minimal: + +1. **Button combo** - hold **OK + BACK** together while powering on. Detection + (`detect_safe_mode_combo`) waits ~250 ms for the input sampler to debounce, + then requires the combo to stay held across a ~500 ms confirm window so a + stray press never triggers it. +2. **Required-subsystem failure** - if a required boot stage failed (see + [boot_report](../boot_report/README.md)), `kernel_init` drops into safe mode + automatically instead of booting blind. This is the degraded-mode target. +3. **Boot loop** - after 3 consecutive abnormal boots (panic / watchdog / + brownout), boot-loop detection (see + [boot_report](../boot_report/README.md#boot-loop-detection-item-4)) forces + safe mode and the footer shows the reset reason. This breaks the endless + reboot cycle from marginal hardware and gives a factory-reset path out. + +In safe mode the kernel brings up only LED, battery, display, LVGL and the +recovery UI. Radios (CC1101, the C5 bridge, RFID), Wi-Fi, host link, console and +the SD custom theme are **not** started. `kernel_init` returns `esp_err_t` +(`ESP_FAIL` when it fell back due to a required failure); `main.c` logs the +degraded boot. + +## The recovery menu + +`ui_init_safe_mode` builds a minimal UI (theme + input pump + render heartbeat, +no boot animation, no power policy) and loads the safe-mode screen. Menu: + +| Item | Action | +|------|--------| +| Reset config | Confirm, then `tos_factory_reset_config` + reboot | +| Reset all | Confirm, then `tos_factory_reset_all` + reboot | +| View last crash | Opens the [crash viewer](../boot_report/README.md) | +| View boot map | Opens the [boot-map viewer](../boot_report/README.md) | +| Reboot | `esp_restart` | + +The reset actions run a two-step confirm inside the screen's own input handler +(not `msgbox`, which polls buttons directly and would double-fire against the +central input router). The wipe itself runs in a separate task so file deletion +never stalls the LVGL renderer, then the device reboots. + +## Factory reset semantics + +Implemented in `tos_factory_reset.{c,h}` (see +[storage_api](../storage_api/README.md#factory-reset-tos_factory_reseth)): + +- **Reset config** - deletes config files on both storages + the first-boot + marker; defaults are re-seeded on the next boot. Keeps NVS, loot and assets. +- **Reset all** - config + user captures/loot/themes/scripts on both storages + + `nvs_flash_erase`. Never formats the `assets` partition (the shipped + icons/html/fonts are exactly 100% of the partition and would need a re-flash) + and never deletes the on-SD C5 firmware image. + +Deletion is file-only (directory skeleton preserved) so the first config save +after a reset still finds its folder. + +## Notes + +- If the `assets` partition itself failed to mount, safe mode still comes up but + may render without icons; the boot map and crash text still show. +- `power_policy_is_asleep()` gates the input router, so the wake press only wakes + the screen - this is unrelated to safe mode but shares the input path. diff --git a/docs/sd_card/README.md b/docs/sd_card/README.md new file mode 100644 index 000000000..df41393bd --- /dev/null +++ b/docs/sd_card/README.md @@ -0,0 +1,1913 @@ +# P4 + +Component for managing directories on SD card storage. + +## Overview + +- **Location:** `components/storage/sd_dir/` +- **Main Header:** `include/sd_dir.h` +- **Dependencies:** `esp_vfs`, `esp_vfs_fat`, `sdmmc`, `storage_sd` + +## Key Features + +- **Directory Operations:** Create, delete, list, and check existence +- **Recursive Operations:** Remove trees, copy directories, calculate sizes +- **Predefined Paths:** System-wide constants for organizing data +- **Callback System:** Efficient iteration with custom callbacks +- **Statistics:** Count files/directories, calculate storage usage + +## Path Constants + +All path constants have been centralized in `tos_storage_paths.h` using `TOS_PATH_*` macros. +The sd_card component uses `VFS_MOUNT_POINT` (from `vfs_config.h`) as the mount point prefix. + +See `storage_api/include/tos_storage_paths.h` for the full list of available paths. + +## API Reference + +### Directory Creation & Deletion + +#### `sd_dir_create` +```c +esp_err_t sd_dir_create(const char *path); +``` +Creates directory with automatic parent creation (like `mkdir -p`). + +**Returns:** `ESP_OK` on success, `ESP_FAIL` on failure. + +--- + +#### `sd_dir_remove_recursive` +```c +esp_err_t sd_dir_remove_recursive(const char *path); +``` +Recursively deletes directory and all contents. **Use with caution.** + +**Returns:** `ESP_OK` on success, `ESP_FAIL` on failure. + +--- + +### Directory Information + +#### `sd_dir_exists` +```c +bool sd_dir_exists(const char *path); +``` +Checks if directory exists. + +**Returns:** `true` if exists, `false` otherwise. + +--- + +#### `sd_dir_list` +```c +typedef void (*sd_dir_callback_t)(const char *name, bool is_dir, void *user_data); +esp_err_t sd_dir_list(const char *path, sd_dir_callback_t callback, void *user_data); +``` +Iterates through directory entries, calling callback for each item. + +**Example:** +```c +void print_entry(const char *name, bool is_dir, void *user_data) { + printf("%s %s\n", is_dir ? "[DIR]" : "[FILE]", name); +} +sd_dir_list("/sdcard/badusb", print_entry, NULL); +``` + +--- + +#### `sd_dir_count` +```c +esp_err_t sd_dir_count(const char *path, uint32_t *file_count, uint32_t *dir_count); +``` +Counts files and subdirectories (non-recursive). + +**Returns:** `ESP_OK` on success. + +--- + +#### `sd_dir_get_size` +```c +esp_err_t sd_dir_get_size(const char *path, uint64_t *total_size); +``` +Calculates total size of all files in directory tree (recursive). + +**Returns:** `ESP_OK` on success. + +--- + +### Directory Operations + +#### `sd_dir_copy_recursive` +```c +esp_err_t sd_dir_copy_recursive(const char *src, const char *dst); +``` +Copies entire directory tree, preserving structure. + +**Returns:** `ESP_OK` on success. + +--- + +## Implementation Details + +- All functions require full paths including `VFS_MOUNT_POINT` +- Functions are not thread-safe - use mutexes for concurrent access +- Recursive operations may fail on deeply nested directories + +## Usage Example + +```c +#include "tos_storage_paths.h" + +void example(void) { + sd_dir_create(TOS_PATH_NFC); + sd_dir_create(TOS_PATH_BADUSB); +} +``` + +--- + +# SD Card Information Component + +Component for querying SD card hardware and filesystem statistics. + +## Overview + +- **Location:** `components/storage/sd_card_info/` +- **Main Header:** `include/sd_card_info.h` +- **Dependencies:** `esp_vfs_fat`, `sdmmc_cmd`, `ff`, `storage_sd` + +## Key Features + +- **Hardware Info:** Card name, capacity, speed, type +- **Filesystem Stats:** Total, used, free space with percentages +- **Mount Status:** Check if card is accessible +- **Debug Output:** Console logging of card information + +## Data Structures + +### `sd_card_info_t` +```c +typedef struct { + char name[16]; // Card manufacturer name + uint32_t capacity_mb; // Total capacity in MB + uint32_t sector_size; // Sector size in bytes + uint32_t num_sectors; // Total number of sectors + uint32_t speed_khz; // Max speed in kHz + uint8_t card_type; // Card type identifier + bool is_mounted; // Mount status +} sd_card_info_t; +``` + +### `sd_fs_stats_t` +```c +typedef struct { + uint64_t total_bytes; // Total capacity + uint64_t used_bytes; // Space in use + uint64_t free_bytes; // Available space +} sd_fs_stats_t; +``` + +## API Reference + +### Card Information + +#### `sd_get_card_info` +```c +esp_err_t sd_get_card_info(sd_card_info_t *info); +``` +Retrieves complete hardware information. + +**Returns:** `ESP_OK`, `ESP_ERR_INVALID_STATE`, or `ESP_ERR_INVALID_ARG`. + +--- + +#### `sd_print_card_info` +```c +void sd_print_card_info(void); +``` +Prints formatted card information to console. + +--- + +### Filesystem Statistics + +#### `sd_get_fs_stats` +```c +esp_err_t sd_get_fs_stats(sd_fs_stats_t *stats); +``` +Retrieves complete filesystem statistics. + +**Returns:** `ESP_OK`, `ESP_ERR_INVALID_STATE`, `ESP_ERR_INVALID_ARG`, or `ESP_FAIL`. + +--- + +#### `sd_get_free_space` +```c +esp_err_t sd_get_free_space(uint64_t *free_bytes); +``` +Gets available free space. + +--- + +#### `sd_get_total_space` +```c +esp_err_t sd_get_total_space(uint64_t *total_bytes); +``` +Gets total filesystem capacity. + +--- + +#### `sd_get_used_space` +```c +esp_err_t sd_get_used_space(uint64_t *used_bytes); +``` +Gets space currently in use. + +--- + +#### `sd_get_usage_percent` +```c +esp_err_t sd_get_usage_percent(float *percentage); +``` +Calculates usage percentage (0.0 to 100.0). + +--- + +### Individual Attributes + +#### `sd_get_card_name` +```c +esp_err_t sd_get_card_name(char *name, size_t size); +``` +Gets manufacturer name. + +--- + +#### `sd_get_capacity` +```c +esp_err_t sd_get_capacity(uint32_t *capacity_mb); +``` +Gets total capacity in MB. + +--- + +#### `sd_get_speed` +```c +esp_err_t sd_get_speed(uint32_t *speed_khz); +``` +Gets maximum communication speed. + +--- + +#### `sd_get_card_type` +```c +esp_err_t sd_get_card_type(uint8_t *type); +``` +Gets raw card type identifier. + +--- + +#### `sd_get_card_type_name` +```c +esp_err_t sd_get_card_type_name(char *type_name, size_t size); +``` +Gets human-readable card type string. + +--- + +## Implementation Details + +- Uses FatFS `f_getfree()` for filesystem stats +- Accesses SDMMC layer for hardware information +- All functions verify mount status before access +- Thread-safe for read operations + +## Usage Example + +```c +void check_storage_health(void) { + sd_card_info_t info; + float usage; + + if (sd_get_card_info(&info) == ESP_OK && + sd_get_usage_percent(&usage) == ESP_OK) { + + printf("Card: %s (%lu MB)\n", info.name, info.capacity_mb); + printf("Usage: %.1f%%\n", usage); + + if (usage > 90.0f) { + printf("WARNING: Low disk space!\n"); + } + } +} +``` + +--- + +# SD Card Initialization Component + +Component for SD card initialization, mounting, and lifecycle management. + +## Overview + +- **Location:** `components/storage/sd_card_init/` +- **Main Header:** `include/sd_card_init.h` +- **Dependencies:** `esp_vfs_fat`, `driver/sdspi_host`, `sdmmc_cmd`, `spi`, `pin_def` + +## Key Features + +- **Simple Initialization:** One-function setup with defaults +- **Custom Configuration:** Control max files, auto-format, allocation size +- **Mount Management:** Mount, unmount, remount, check status +- **Shared SPI Bus:** Integration with centralized SPI driver +- **Health Monitoring:** Basic health checks +- **Card Handle Access:** Low-level SDMMC handle for advanced use + +## Configuration + +```c +// VFS_MOUNT_POINT is defined in vfs_config.h (e.g. "/sdcard") +#define SD_MAX_FILES 10 // Max open files +#define SD_ALLOCATION_UNIT 16 * 1024 // 16KB cluster size +``` + +## API Reference + +### Initialization + +#### `sd_init` +```c +esp_err_t sd_init(void); +``` +Initializes SD card with default settings. + +**Returns:** `ESP_OK`, `ESP_ERR_INVALID_STATE`, or `ESP_FAIL`. + +--- + +#### `sd_init_custom` +```c +esp_err_t sd_init_custom(uint8_t max_files, bool format_if_failed); +``` +Initializes with custom parameters. + +**Warning:** `format_if_failed=true` erases all data on mount failure. + +--- + +#### `sd_init_custom_pins` +```c +esp_err_t sd_init_custom_pins(int mosi, int miso, int clk, int cs); +``` +**Deprecated:** Custom pins not supported with shared SPI driver. + +--- + +### Deinitialization + +#### `sd_deinit` +```c +esp_err_t sd_deinit(void); +``` +Unmounts SD card and releases resources. Close all files first. + +**Returns:** `ESP_OK`, `ESP_ERR_INVALID_STATE`, or `ESP_FAIL`. + +--- + +### Status & Maintenance + +#### `sd_is_mounted` +```c +bool sd_is_mounted(void); +``` +Checks if SD card is mounted. + +--- + +#### `sd_remount` +```c +esp_err_t sd_remount(void); +``` +Unmounts and remounts SD card (useful for error recovery). + +--- + +#### `sd_check_health` +```c +esp_err_t sd_check_health(void); +``` +Performs basic health check. + +--- + +#### `sd_reset_bus` +```c +esp_err_t sd_reset_bus(void); +``` +**Not Supported:** Returns `ESP_ERR_NOT_SUPPORTED`. Use `sd_remount()` instead. + +--- + +### Advanced Access + +#### `sd_get_card_handle` +```c +sdmmc_card_t* sd_get_card_handle(void); +``` +Returns pointer to internal SDMMC card structure. Returns `NULL` if not mounted. + +**Warning:** Direct manipulation can interfere with VFS operations. + +--- + +## Implementation Details + +### SPI Configuration +```c +spi_device_config_t sd_cfg = { + .cs_pin = SD_CARD_CS_PIN, + .clock_speed_hz = 20000 * 1000, + .mode = 0, + .queue_size = 4, +}; +``` + +### Mount Configuration +```c +esp_vfs_fat_sdmmc_mount_config_t mount_config = { + .format_if_mount_failed = false, + .max_files = 5, + .allocation_unit_size = 16 * 1024, +}; +``` + +## Troubleshooting + +| Problem | Solutions | +|---------|-----------| +| `sd_init()` returns `ESP_FAIL` | Check card insertion, verify pins, try different card, enable debug logs | +| File operations fail | Check filesystem corruption, verify max_files limit, close file handles, try remount | +| Random disconnects | Check power supply, verify connections, reduce clock speed, add pull-ups | +| `sd_deinit()` fails | Close all file handles first, check for active tasks | + +## Usage Example + +```c +void storage_init(void) { + if (sd_init() == ESP_OK) { + ESP_LOGI(TAG, "SD card mounted"); + sd_dir_create("/sdcard/config"); + } else { + ESP_LOGE(TAG, "SD card mount failed"); + } +} +``` + +--- + +# SD Card Read Component + +Component for comprehensive SD card file reading operations. + +## Overview + +- **Location:** `components/storage/sd_card_read/` +- **Main Header:** `include/sd_card_read.h` +- **Dependencies:** `esp_vfs_fat`, `storage_sd` + +## Key Features + +- **Text Reading:** Entire files, specific lines, line-by-line processing +- **Binary Reading:** Raw data, chunks, individual bytes +- **Type Conversion:** Direct reading of integers, floats +- **Content Search:** String search and occurrence counting +- **Flexible Paths:** Automatic `/sdcard` prefix for relative paths + +## Configuration + +```c +#define MAX_PATH_LEN 256 // Maximum path length +#define MAX_LINE_LEN 512 // Maximum line length +``` + +## API Reference + +### Text Reading + +#### `sd_read_string` +```c +esp_err_t sd_read_string(const char *path, char *buffer, size_t buffer_size); +``` +Reads entire file as null-terminated string. + +--- + +#### `sd_read_line` +```c +esp_err_t sd_read_line(const char *path, char *buffer, size_t buffer_size, uint32_t line_number); +``` +Reads specific line (1-based index). + +--- + +#### `sd_read_first_line` +```c +esp_err_t sd_read_first_line(const char *path, char *buffer, size_t buffer_size); +``` +Reads first line. Equivalent to `sd_read_line(path, buffer, size, 1)`. + +--- + +#### `sd_read_last_line` +```c +esp_err_t sd_read_last_line(const char *path, char *buffer, size_t buffer_size); +``` +Reads last line. + +--- + +#### `sd_read_lines` +```c +typedef void (*sd_line_callback_t)(const char *line, void *user_data); +esp_err_t sd_read_lines(const char *path, sd_line_callback_t callback, void *user_data); +``` +Processes each line via callback. Memory-efficient for large files. + +--- + +#### `sd_count_lines` +```c +esp_err_t sd_count_lines(const char *path, uint32_t *line_count); +``` +Counts total lines in file. + +--- + +### Binary Reading + +#### `sd_read_binary` +```c +esp_err_t sd_read_binary(const char *path, void *buffer, size_t size, size_t *bytes_read); +``` +Reads raw binary data. + +--- + +#### `sd_read_chunk` +```c +esp_err_t sd_read_chunk(const char *path, size_t offset, void *buffer, size_t size, size_t *bytes_read); +``` +Reads data chunk from specific offset. + +--- + +#### `sd_read_bytes` +```c +esp_err_t sd_read_bytes(const char *path, uint8_t *bytes, size_t max_count, size_t *count); +``` +Alias for `sd_read_binary` with byte array typing. + +--- + +#### `sd_read_byte` +```c +esp_err_t sd_read_byte(const char *path, uint8_t *byte); +``` +Reads single byte. + +--- + +### Type Conversion + +#### `sd_read_int` +```c +esp_err_t sd_read_int(const char *path, int32_t *value); +``` +Reads and converts to 32-bit integer. + +--- + +#### `sd_read_float` +```c +esp_err_t sd_read_float(const char *path, float *value); +``` +Reads and converts to float. + +--- + +### Content Search + +#### `sd_file_contains` +```c +esp_err_t sd_file_contains(const char *path, const char *search, bool *found); +``` +Checks if string exists in file. + +--- + +#### `sd_count_occurrences` +```c +esp_err_t sd_count_occurrences(const char *path, const char *search, uint32_t *count); +``` +Counts string occurrences in file. + +--- + +## Implementation Details + +- Line functions allocate 512-byte stack buffers +- Use `sd_read_lines()` callback for large files +- Thread-safe for different files +- Automatic path formatting (relative → absolute) + +## Usage Example + +```c +void process_config(void) { + char buffer[256]; + + // Read entire file + if (sd_read_string("/config/settings.txt", buffer, sizeof(buffer)) == ESP_OK) { + printf("Config: %s\n", buffer); + } + + // Process line-by-line + sd_read_lines("/logs/system.log", [](const char *line, void *ctx) { + printf("Log: %s\n", line); + }, NULL); +} +``` + +--- + +# SD Card Write Component + +Component for comprehensive SD card file writing operations. + +## Overview + +- **Location:** `components/storage/sd_card_write/` +- **Main Header:** `include/sd_card_write.h` +- **Dependencies:** `esp_vfs_fat`, `storage_sd` + +## Key Features + +- **Text Writing:** Strings, lines, formatted text +- **Binary Writing:** Raw data, buffers, individual bytes +- **Append Operations:** Add to existing files +- **Formatted Output:** Printf-style writing +- **CSV Support:** Simplified row writing + +## API Reference + +### Text Writing + +#### `sd_write_string` / `sd_append_string` +```c +esp_err_t sd_write_string(const char *path, const char *data); +esp_err_t sd_append_string(const char *path, const char *data); +``` +Writes or appends string. + +--- + +#### `sd_write_line` / `sd_append_line` +```c +esp_err_t sd_write_line(const char *path, const char *line); +esp_err_t sd_append_line(const char *path, const char *line); +``` +Writes or appends line with automatic newline. + +--- + +#### `sd_write_formatted` / `sd_append_formatted` +```c +esp_err_t sd_write_formatted(const char *path, const char *format, ...); +esp_err_t sd_append_formatted(const char *path, const char *format, ...); +``` +Printf-style formatted writing. + +--- + +### Binary Writing + +#### `sd_write_binary` / `sd_append_binary` +```c +esp_err_t sd_write_binary(const char *path, const void *data, size_t size); +esp_err_t sd_append_binary(const char *path, const void *data, size_t size); +``` +Writes or appends binary data. + +--- + +#### `sd_write_buffer` +```c +esp_err_t sd_write_buffer(const char *path, const void *buffer, size_t size); +``` +Alias for `sd_write_binary`. + +--- + +#### `sd_write_bytes` +```c +esp_err_t sd_write_bytes(const char *path, const uint8_t *bytes, size_t count); +``` +Writes byte array. + +--- + +#### `sd_write_byte` +```c +esp_err_t sd_write_byte(const char *path, uint8_t byte); +``` +Writes single byte. + +--- + +### Type Helpers + +#### `sd_write_int` +```c +esp_err_t sd_write_int(const char *path, int32_t value); +``` +Writes integer as decimal text. + +--- + +#### `sd_write_float` +```c +esp_err_t sd_write_float(const char *path, float value); +``` +Writes float with 6 decimal places. + +--- + +### CSV Support + +#### `sd_write_csv_row` / `sd_append_csv_row` +```c +esp_err_t sd_write_csv_row(const char *path, const char **columns, size_t num_columns); +esp_err_t sd_append_csv_row(const char *path, const char **columns, size_t num_columns); +``` +Writes or appends CSV row (comma-separated with newline). + +--- + +## Implementation Details + +- All writes verify byte count matches expected size +- Automatic `/sdcard` prefix for relative paths +- Buffers flushed automatically on file close + +## Usage Example + +```c +void log_event(const char *type, const char *msg) { + time_t now = time(NULL); + sd_append_formatted("/logs/events.log", "[%ld] %s: %s\n", now, type, msg); +} + +void save_sensor_data(float temp, float humidity) { + const char *row[] = { + "Temperature", "Humidity" + }; + sd_write_csv_row("/data/sensors.csv", row, 2); + + char temp_str[16], hum_str[16]; + snprintf(temp_str, sizeof(temp_str), "%.2f", temp); + snprintf(hum_str, sizeof(hum_str), "%.2f", humidity); + + const char *data[] = {temp_str, hum_str}; + sd_append_csv_row("/data/sensors.csv", data, 2); +} +``` + +--- + +# SD Card File Management Component + +Component for comprehensive SD card file operations. + +## Overview + +- **Location:** `components/storage/sd_card_file/` +- **Main Header:** `include/sd_card_file.h` +- **Dependencies:** `esp_vfs`, `esp_vfs_fat`, `sdmmc`, `storage_sd` + +## Key Features + +- **File Operations:** Create, delete, rename, move, copy +- **Metadata Access:** Size, modification time, attributes +- **File Comparison:** Byte-by-byte comparison +- **File Truncation:** Resize to specific length +- **Utilities:** Check existence, get extensions, clear contents + +## Data Structures + +### `sd_file_info_t` +```c +typedef struct { + char path[256]; // Full path + size_t size; // File size in bytes + time_t modified_time; // Last modification time + bool is_directory; // Directory flag +} sd_file_info_t; +``` + +## API Reference + +### File Information + +#### `sd_file_exists` +```c +bool sd_file_exists(const char *path); +``` +Checks if file exists. + +--- + +#### `sd_file_get_info` +```c +esp_err_t sd_file_get_info(const char *path, sd_file_info_t *info); +``` +Retrieves complete file information. + +--- + +#### `sd_file_get_size` +```c +esp_err_t sd_file_get_size(const char *path, size_t *size); +``` +Gets file size in bytes. + +--- + +#### `sd_file_is_empty` +```c +esp_err_t sd_file_is_empty(const char *path, bool *is_empty); +``` +Checks if file has zero bytes. + +--- + +### File Manipulation + +#### `sd_file_delete` +```c +esp_err_t sd_file_delete(const char *path); +``` +Permanently deletes file. + +--- + +#### `sd_file_rename` +```c +esp_err_t sd_file_rename(const char *old_path, const char *new_path); +``` +Renames or moves file (same filesystem). + +--- + +#### `sd_file_move` +```c +esp_err_t sd_file_move(const char *src_path, const char *dst_path); +``` +Moves file (alias for rename). + +--- + +#### `sd_file_copy` +```c +esp_err_t sd_file_copy(const char *src_path, const char *dst_path); +``` +Copies file (source unchanged). + +--- + +#### `sd_file_truncate` +```c +esp_err_t sd_file_truncate(const char *path, size_t size); +``` +Resizes file to specified size. + +--- + +#### `sd_file_clear` +```c +esp_err_t sd_file_clear(const char *path); +``` +Clears all content (makes empty). + +--- + +### File Comparison + +#### `sd_file_compare` +```c +esp_err_t sd_file_compare(const char *path1, const char *path2, bool *are_equal); +``` +Byte-by-byte comparison. + +--- + +### Utilities + +#### `sd_file_get_extension` +```c +esp_err_t sd_file_get_extension(const char *path, char *extension, size_t size); +``` +Extracts file extension (without dot). + +--- + +## Implementation Details + +- Rename/move are atomic, copy is not +- Path buffer in `sd_file_info_t` is 256 bytes +- Not thread-safe - use mutexes for concurrent access + +## Usage Example + +```c +esp_err_t backup_config(void) { + const char *config = "/sdcard/config/settings.json"; + const char *backup = "/sdcard/backups/settings.json"; + + // Create backup + if (sd_file_copy(config, backup) != ESP_OK) { + return ESP_FAIL; + } + + // Verify backup + bool equal; + sd_file_compare(config, backup, &equal); + + return equal ? ESP_OK : ESP_FAIL; +} +``` +--- + +# C5 + +Component for managing directories on SD card storage. + +## Overview + +- **Location:** `components/storage/sd_dir/` +- **Main Header:** `include/sd_dir.h` +- **Dependencies:** `esp_vfs`, `esp_vfs_fat`, `sdmmc`, `storage_sd` + +## Key Features + +- **Directory Operations:** Create, delete, list, and check existence +- **Recursive Operations:** Remove trees, copy directories, calculate sizes +- **Predefined Paths:** System-wide constants for organizing data +- **Callback System:** Efficient iteration with custom callbacks +- **Statistics:** Count files/directories, calculate storage usage + +## Predefined System Directories + +| Constant | Path | Purpose | +|----------|------|---------| +| `SD_BASE_PATH` | `/sdcard` | Root mount point | +| `SD_DIR_IR` | `/ir` | Infrared signal files | +| `SD_DIR_BADUSB` | `/badusb` | DuckyScript payloads | +| `SD_DIR_NFC` | `/nfc` | NFC tag data | +| `SD_DIR_RFID` | `/rfid` | RFID card data | +| `SD_DIR_SUBGHZ` | `/subghz` | Sub-GHz captures | +| `SD_DIR_CONFIG` | `/config` | Configuration files | +| `SD_DIR_LOGS` | `/logs` | Application logs | +| `SD_DIR_BACKUP` | `/backups` | System backups | + +**Note:** Paths are relative to `SD_BASE_PATH`. Use `SD_BASE_PATH SD_DIR_BADUSB` → `/sdcard/badusb` + +## API Reference + +### Directory Creation & Deletion + +#### `sd_dir_create` +```c +esp_err_t sd_dir_create(const char *path); +``` +Creates directory with automatic parent creation (like `mkdir -p`). + +**Returns:** `ESP_OK` on success, `ESP_FAIL` on failure. + +--- + +#### `sd_dir_remove_recursive` +```c +esp_err_t sd_dir_remove_recursive(const char *path); +``` +Recursively deletes directory and all contents. **Use with caution.** + +**Returns:** `ESP_OK` on success, `ESP_FAIL` on failure. + +--- + +### Directory Information + +#### `sd_dir_exists` +```c +bool sd_dir_exists(const char *path); +``` +Checks if directory exists. + +**Returns:** `true` if exists, `false` otherwise. + +--- + +#### `sd_dir_list` +```c +typedef void (*sd_dir_callback_t)(const char *name, bool is_dir, void *user_data); +esp_err_t sd_dir_list(const char *path, sd_dir_callback_t callback, void *user_data); +``` +Iterates through directory entries, calling callback for each item. + +**Example:** +```c +void print_entry(const char *name, bool is_dir, void *user_data) { + printf("%s %s\n", is_dir ? "[DIR]" : "[FILE]", name); +} +sd_dir_list("/sdcard/badusb", print_entry, NULL); +``` + +--- + +#### `sd_dir_count` +```c +esp_err_t sd_dir_count(const char *path, uint32_t *file_count, uint32_t *dir_count); +``` +Counts files and subdirectories (non-recursive). + +**Returns:** `ESP_OK` on success. + +--- + +#### `sd_dir_get_size` +```c +esp_err_t sd_dir_get_size(const char *path, uint64_t *total_size); +``` +Calculates total size of all files in directory tree (recursive). + +**Returns:** `ESP_OK` on success. + +--- + +### Directory Operations + +#### `sd_dir_copy_recursive` +```c +esp_err_t sd_dir_copy_recursive(const char *src, const char *dst); +``` +Copies entire directory tree, preserving structure. + +**Returns:** `ESP_OK` on success. + +--- + +## Implementation Details + +- All functions require full paths including `SD_BASE_PATH` +- Functions are not thread-safe - use mutexes for concurrent access +- Recursive operations may fail on deeply nested directories + +## Usage Example + +```c +void init_storage_structure(void) { + const char *dirs[] = {SD_DIR_IR, SD_DIR_BADUSB, SD_DIR_CONFIG, SD_DIR_LOGS}; + + for (int i = 0; i < 4; i++) { + char path[64]; + snprintf(path, sizeof(path), "%s%s", SD_BASE_PATH, dirs[i]); + sd_dir_create(path); + } +} +``` + +--- + +# SD Card Information Component + +Component for querying SD card hardware and filesystem statistics. + +## Overview + +- **Location:** `components/storage/sd_card_info/` +- **Main Header:** `include/sd_card_info.h` +- **Dependencies:** `esp_vfs_fat`, `sdmmc_cmd`, `ff`, `storage_sd` + +## Key Features + +- **Hardware Info:** Card name, capacity, speed, type +- **Filesystem Stats:** Total, used, free space with percentages +- **Mount Status:** Check if card is accessible +- **Debug Output:** Console logging of card information + +## Data Structures + +### `sd_card_info_t` +```c +typedef struct { + char name[16]; // Card manufacturer name + uint32_t capacity_mb; // Total capacity in MB + uint32_t sector_size; // Sector size in bytes + uint32_t num_sectors; // Total number of sectors + uint32_t speed_khz; // Max speed in kHz + uint8_t card_type; // Card type identifier + bool is_mounted; // Mount status +} sd_card_info_t; +``` + +### `sd_fs_stats_t` +```c +typedef struct { + uint64_t total_bytes; // Total capacity + uint64_t used_bytes; // Space in use + uint64_t free_bytes; // Available space +} sd_fs_stats_t; +``` + +## API Reference + +### Card Information + +#### `sd_get_card_info` +```c +esp_err_t sd_get_card_info(sd_card_info_t *info); +``` +Retrieves complete hardware information. + +**Returns:** `ESP_OK`, `ESP_ERR_INVALID_STATE`, or `ESP_ERR_INVALID_ARG`. + +--- + +#### `sd_print_card_info` +```c +void sd_print_card_info(void); +``` +Prints formatted card information to console. + +--- + +### Filesystem Statistics + +#### `sd_get_fs_stats` +```c +esp_err_t sd_get_fs_stats(sd_fs_stats_t *stats); +``` +Retrieves complete filesystem statistics. + +**Returns:** `ESP_OK`, `ESP_ERR_INVALID_STATE`, `ESP_ERR_INVALID_ARG`, or `ESP_FAIL`. + +--- + +#### `sd_get_free_space` +```c +esp_err_t sd_get_free_space(uint64_t *free_bytes); +``` +Gets available free space. + +--- + +#### `sd_get_total_space` +```c +esp_err_t sd_get_total_space(uint64_t *total_bytes); +``` +Gets total filesystem capacity. + +--- + +#### `sd_get_used_space` +```c +esp_err_t sd_get_used_space(uint64_t *used_bytes); +``` +Gets space currently in use. + +--- + +#### `sd_get_usage_percent` +```c +esp_err_t sd_get_usage_percent(float *percentage); +``` +Calculates usage percentage (0.0 to 100.0). + +--- + +### Individual Attributes + +#### `sd_get_card_name` +```c +esp_err_t sd_get_card_name(char *name, size_t size); +``` +Gets manufacturer name. + +--- + +#### `sd_get_capacity` +```c +esp_err_t sd_get_capacity(uint32_t *capacity_mb); +``` +Gets total capacity in MB. + +--- + +#### `sd_get_speed` +```c +esp_err_t sd_get_speed(uint32_t *speed_khz); +``` +Gets maximum communication speed. + +--- + +#### `sd_get_card_type` +```c +esp_err_t sd_get_card_type(uint8_t *type); +``` +Gets raw card type identifier. + +--- + +#### `sd_get_card_type_name` +```c +esp_err_t sd_get_card_type_name(char *type_name, size_t size); +``` +Gets human-readable card type string. + +--- + +## Implementation Details + +- Uses FatFS `f_getfree()` for filesystem stats +- Accesses SDMMC layer for hardware information +- All functions verify mount status before access +- Thread-safe for read operations + +## Usage Example + +```c +void check_storage_health(void) { + sd_card_info_t info; + float usage; + + if (sd_get_card_info(&info) == ESP_OK && + sd_get_usage_percent(&usage) == ESP_OK) { + + printf("Card: %s (%lu MB)\n", info.name, info.capacity_mb); + printf("Usage: %.1f%%\n", usage); + + if (usage > 90.0f) { + printf("WARNING: Low disk space!\n"); + } + } +} +``` + +--- + +# SD Card Initialization Component + +Component for SD card initialization, mounting, and lifecycle management. + +## Overview + +- **Location:** `components/storage/sd_card_init/` +- **Main Header:** `include/sd_card_init.h` +- **Dependencies:** `esp_vfs_fat`, `driver/sdspi_host`, `sdmmc_cmd`, `spi`, `pin_def` + +## Key Features + +- **Simple Initialization:** One-function setup with defaults +- **Custom Configuration:** Control max files, auto-format, allocation size +- **Mount Management:** Mount, unmount, remount, check status +- **Shared SPI Bus:** Integration with centralized SPI driver +- **Health Monitoring:** Basic health checks +- **Card Handle Access:** Low-level SDMMC handle for advanced use + +## Configuration + +```c +#define SD_MOUNT_POINT "/sdcard" // VFS mount point +#define SD_MAX_FILES 5 // Max open files +#define SD_ALLOCATION_UNIT 16 * 1024 // 16KB cluster size +#define SDMMC_FREQ_DEFAULT 20000 // 20MHz speed +``` + +## API Reference + +### Initialization + +#### `sd_init` +```c +esp_err_t sd_init(void); +``` +Initializes SD card with default settings. + +**Returns:** `ESP_OK`, `ESP_ERR_INVALID_STATE`, or `ESP_FAIL`. + +--- + +#### `sd_init_custom` +```c +esp_err_t sd_init_custom(uint8_t max_files, bool format_if_failed); +``` +Initializes with custom parameters. + +**Warning:** `format_if_failed=true` erases all data on mount failure. + +--- + +#### `sd_init_custom_pins` +```c +esp_err_t sd_init_custom_pins(int mosi, int miso, int clk, int cs); +``` +**Deprecated:** Custom pins not supported with shared SPI driver. + +--- + +### Deinitialization + +#### `sd_deinit` +```c +esp_err_t sd_deinit(void); +``` +Unmounts SD card and releases resources. Close all files first. + +**Returns:** `ESP_OK`, `ESP_ERR_INVALID_STATE`, or `ESP_FAIL`. + +--- + +### Status & Maintenance + +#### `sd_is_mounted` +```c +bool sd_is_mounted(void); +``` +Checks if SD card is mounted. + +--- + +#### `sd_remount` +```c +esp_err_t sd_remount(void); +``` +Unmounts and remounts SD card (useful for error recovery). + +--- + +#### `sd_check_health` +```c +esp_err_t sd_check_health(void); +``` +Performs basic health check. + +--- + +#### `sd_reset_bus` +```c +esp_err_t sd_reset_bus(void); +``` +**Not Supported:** Returns `ESP_ERR_NOT_SUPPORTED`. Use `sd_remount()` instead. + +--- + +### Advanced Access + +#### `sd_get_card_handle` +```c +sdmmc_card_t* sd_get_card_handle(void); +``` +Returns pointer to internal SDMMC card structure. Returns `NULL` if not mounted. + +**Warning:** Direct manipulation can interfere with VFS operations. + +--- + +## Implementation Details + +### SPI Configuration +```c +spi_device_config_t sd_cfg = { + .cs_pin = SD_CARD_CS_PIN, + .clock_speed_hz = 20000 * 1000, + .mode = 0, + .queue_size = 4, +}; +``` + +### Mount Configuration +```c +esp_vfs_fat_sdmmc_mount_config_t mount_config = { + .format_if_mount_failed = false, + .max_files = 5, + .allocation_unit_size = 16 * 1024, +}; +``` + +## Troubleshooting + +| Problem | Solutions | +|---------|-----------| +| `sd_init()` returns `ESP_FAIL` | Check card insertion, verify pins, try different card, enable debug logs | +| File operations fail | Check filesystem corruption, verify max_files limit, close file handles, try remount | +| Random disconnects | Check power supply, verify connections, reduce clock speed, add pull-ups | +| `sd_deinit()` fails | Close all file handles first, check for active tasks | + +## Usage Example + +```c +void storage_init(void) { + if (sd_init() == ESP_OK) { + ESP_LOGI(TAG, "SD card mounted"); + sd_dir_create("/sdcard/config"); + } else { + ESP_LOGE(TAG, "SD card mount failed"); + } +} +``` + +--- + +# SD Card Read Component + +Component for comprehensive SD card file reading operations. + +## Overview + +- **Location:** `components/storage/sd_card_read/` +- **Main Header:** `include/sd_card_read.h` +- **Dependencies:** `esp_vfs_fat`, `storage_sd` + +## Key Features + +- **Text Reading:** Entire files, specific lines, line-by-line processing +- **Binary Reading:** Raw data, chunks, individual bytes +- **Type Conversion:** Direct reading of integers, floats +- **Content Search:** String search and occurrence counting +- **Flexible Paths:** Automatic `/sdcard` prefix for relative paths + +## Configuration + +```c +#define MAX_PATH_LEN 256 // Maximum path length +#define MAX_LINE_LEN 512 // Maximum line length +``` + +## API Reference + +### Text Reading + +#### `sd_read_string` +```c +esp_err_t sd_read_string(const char *path, char *buffer, size_t buffer_size); +``` +Reads entire file as null-terminated string. + +--- + +#### `sd_read_line` +```c +esp_err_t sd_read_line(const char *path, char *buffer, size_t buffer_size, uint32_t line_number); +``` +Reads specific line (1-based index). + +--- + +#### `sd_read_first_line` +```c +esp_err_t sd_read_first_line(const char *path, char *buffer, size_t buffer_size); +``` +Reads first line. Equivalent to `sd_read_line(path, buffer, size, 1)`. + +--- + +#### `sd_read_last_line` +```c +esp_err_t sd_read_last_line(const char *path, char *buffer, size_t buffer_size); +``` +Reads last line. + +--- + +#### `sd_read_lines` +```c +typedef void (*sd_line_callback_t)(const char *line, void *user_data); +esp_err_t sd_read_lines(const char *path, sd_line_callback_t callback, void *user_data); +``` +Processes each line via callback. Memory-efficient for large files. + +--- + +#### `sd_count_lines` +```c +esp_err_t sd_count_lines(const char *path, uint32_t *line_count); +``` +Counts total lines in file. + +--- + +### Binary Reading + +#### `sd_read_binary` +```c +esp_err_t sd_read_binary(const char *path, void *buffer, size_t size, size_t *bytes_read); +``` +Reads raw binary data. + +--- + +#### `sd_read_chunk` +```c +esp_err_t sd_read_chunk(const char *path, size_t offset, void *buffer, size_t size, size_t *bytes_read); +``` +Reads data chunk from specific offset. + +--- + +#### `sd_read_bytes` +```c +esp_err_t sd_read_bytes(const char *path, uint8_t *bytes, size_t max_count, size_t *count); +``` +Alias for `sd_read_binary` with byte array typing. + +--- + +#### `sd_read_byte` +```c +esp_err_t sd_read_byte(const char *path, uint8_t *byte); +``` +Reads single byte. + +--- + +### Type Conversion + +#### `sd_read_int` +```c +esp_err_t sd_read_int(const char *path, int32_t *value); +``` +Reads and converts to 32-bit integer. + +--- + +#### `sd_read_float` +```c +esp_err_t sd_read_float(const char *path, float *value); +``` +Reads and converts to float. + +--- + +### Content Search + +#### `sd_file_contains` +```c +esp_err_t sd_file_contains(const char *path, const char *search, bool *found); +``` +Checks if string exists in file. + +--- + +#### `sd_count_occurrences` +```c +esp_err_t sd_count_occurrences(const char *path, const char *search, uint32_t *count); +``` +Counts string occurrences in file. + +--- + +## Implementation Details + +- Line functions allocate 512-byte stack buffers +- Use `sd_read_lines()` callback for large files +- Thread-safe for different files +- Automatic path formatting (relative → absolute) + +## Usage Example + +```c +void process_config(void) { + char buffer[256]; + + // Read entire file + if (sd_read_string("/config/settings.txt", buffer, sizeof(buffer)) == ESP_OK) { + printf("Config: %s\n", buffer); + } + + // Process line-by-line + sd_read_lines("/logs/system.log", [](const char *line, void *ctx) { + printf("Log: %s\n", line); + }, NULL); +} +``` + +--- + +# SD Card Write Component + +Component for comprehensive SD card file writing operations. + +## Overview + +- **Location:** `components/storage/sd_card_write/` +- **Main Header:** `include/sd_card_write.h` +- **Dependencies:** `esp_vfs_fat`, `storage_sd` + +## Key Features + +- **Text Writing:** Strings, lines, formatted text +- **Binary Writing:** Raw data, buffers, individual bytes +- **Append Operations:** Add to existing files +- **Formatted Output:** Printf-style writing +- **CSV Support:** Simplified row writing + +## API Reference + +### Text Writing + +#### `sd_write_string` / `sd_append_string` +```c +esp_err_t sd_write_string(const char *path, const char *data); +esp_err_t sd_append_string(const char *path, const char *data); +``` +Writes or appends string. + +--- + +#### `sd_write_line` / `sd_append_line` +```c +esp_err_t sd_write_line(const char *path, const char *line); +esp_err_t sd_append_line(const char *path, const char *line); +``` +Writes or appends line with automatic newline. + +--- + +#### `sd_write_formatted` / `sd_append_formatted` +```c +esp_err_t sd_write_formatted(const char *path, const char *format, ...); +esp_err_t sd_append_formatted(const char *path, const char *format, ...); +``` +Printf-style formatted writing. + +--- + +### Binary Writing + +#### `sd_write_binary` / `sd_append_binary` +```c +esp_err_t sd_write_binary(const char *path, const void *data, size_t size); +esp_err_t sd_append_binary(const char *path, const void *data, size_t size); +``` +Writes or appends binary data. + +--- + +#### `sd_write_buffer` +```c +esp_err_t sd_write_buffer(const char *path, const void *buffer, size_t size); +``` +Alias for `sd_write_binary`. + +--- + +#### `sd_write_bytes` +```c +esp_err_t sd_write_bytes(const char *path, const uint8_t *bytes, size_t count); +``` +Writes byte array. + +--- + +#### `sd_write_byte` +```c +esp_err_t sd_write_byte(const char *path, uint8_t byte); +``` +Writes single byte. + +--- + +### Type Helpers + +#### `sd_write_int` +```c +esp_err_t sd_write_int(const char *path, int32_t value); +``` +Writes integer as decimal text. + +--- + +#### `sd_write_float` +```c +esp_err_t sd_write_float(const char *path, float value); +``` +Writes float with 6 decimal places. + +--- + +### CSV Support + +#### `sd_write_csv_row` / `sd_append_csv_row` +```c +esp_err_t sd_write_csv_row(const char *path, const char **columns, size_t num_columns); +esp_err_t sd_append_csv_row(const char *path, const char **columns, size_t num_columns); +``` +Writes or appends CSV row (comma-separated with newline). + +--- + +## Implementation Details + +- All writes verify byte count matches expected size +- Automatic `/sdcard` prefix for relative paths +- Buffers flushed automatically on file close + +## Usage Example + +```c +void log_event(const char *type, const char *msg) { + time_t now = time(NULL); + sd_append_formatted("/logs/events.log", "[%ld] %s: %s\n", now, type, msg); +} + +void save_sensor_data(float temp, float humidity) { + const char *row[] = { + "Temperature", "Humidity" + }; + sd_write_csv_row("/data/sensors.csv", row, 2); + + char temp_str[16], hum_str[16]; + snprintf(temp_str, sizeof(temp_str), "%.2f", temp); + snprintf(hum_str, sizeof(hum_str), "%.2f", humidity); + + const char *data[] = {temp_str, hum_str}; + sd_append_csv_row("/data/sensors.csv", data, 2); +} +``` + +--- + +# SD Card File Management Component + +Component for comprehensive SD card file operations. + +## Overview + +- **Location:** `components/storage/sd_card_file/` +- **Main Header:** `include/sd_card_file.h` +- **Dependencies:** `esp_vfs`, `esp_vfs_fat`, `sdmmc`, `storage_sd` + +## Key Features + +- **File Operations:** Create, delete, rename, move, copy +- **Metadata Access:** Size, modification time, attributes +- **File Comparison:** Byte-by-byte comparison +- **File Truncation:** Resize to specific length +- **Utilities:** Check existence, get extensions, clear contents + +## Data Structures + +### `sd_file_info_t` +```c +typedef struct { + char path[256]; // Full path + size_t size; // File size in bytes + time_t modified_time; // Last modification time + bool is_directory; // Directory flag +} sd_file_info_t; +``` + +## API Reference + +### File Information + +#### `sd_file_exists` +```c +bool sd_file_exists(const char *path); +``` +Checks if file exists. + +--- + +#### `sd_file_get_info` +```c +esp_err_t sd_file_get_info(const char *path, sd_file_info_t *info); +``` +Retrieves complete file information. + +--- + +#### `sd_file_get_size` +```c +esp_err_t sd_file_get_size(const char *path, size_t *size); +``` +Gets file size in bytes. + +--- + +#### `sd_file_is_empty` +```c +esp_err_t sd_file_is_empty(const char *path, bool *is_empty); +``` +Checks if file has zero bytes. + +--- + +### File Manipulation + +#### `sd_file_delete` +```c +esp_err_t sd_file_delete(const char *path); +``` +Permanently deletes file. + +--- + +#### `sd_file_rename` +```c +esp_err_t sd_file_rename(const char *old_path, const char *new_path); +``` +Renames or moves file (same filesystem). + +--- + +#### `sd_file_move` +```c +esp_err_t sd_file_move(const char *src_path, const char *dst_path); +``` +Moves file (alias for rename). + +--- + +#### `sd_file_copy` +```c +esp_err_t sd_file_copy(const char *src_path, const char *dst_path); +``` +Copies file (source unchanged). + +--- + +#### `sd_file_truncate` +```c +esp_err_t sd_file_truncate(const char *path, size_t size); +``` +Resizes file to specified size. + +--- + +#### `sd_file_clear` +```c +esp_err_t sd_file_clear(const char *path); +``` +Clears all content (makes empty). + +--- + +### File Comparison + +#### `sd_file_compare` +```c +esp_err_t sd_file_compare(const char *path1, const char *path2, bool *are_equal); +``` +Byte-by-byte comparison. + +--- + +### Utilities + +#### `sd_file_get_extension` +```c +esp_err_t sd_file_get_extension(const char *path, char *extension, size_t size); +``` +Extracts file extension (without dot). + +--- + +## Implementation Details + +- Rename/move are atomic, copy is not +- Path buffer in `sd_file_info_t` is 256 bytes +- Not thread-safe - use mutexes for concurrent access + +## Usage Example + +```c +esp_err_t backup_config(void) { + const char *config = "/sdcard/config/settings.json"; + const char *backup = "/sdcard/backups/settings.json"; + + // Create backup + if (sd_file_copy(config, backup) != ESP_OK) { + return ESP_FAIL; + } + + // Verify backup + bool equal; + sd_file_compare(config, backup, &equal); + + return equal ? ESP_OK : ESP_FAIL; +} +``` \ No newline at end of file diff --git a/docs/spi/README.md b/docs/spi/README.md new file mode 100644 index 000000000..d234b6341 --- /dev/null +++ b/docs/spi/README.md @@ -0,0 +1,110 @@ +# P4 + +This component acts as a central manager for the SPI bus, allowing multiple devices (Display, Radio, SD Card) to share the same SPI host safely and efficiently. + +## Overview + +- **Location:** `components/Drivers/spi/` +- **Header:** `include/spi.h` +- **Dependencies:** `driver/spi_master` +- **Host:** `SPI3_HOST` + +## Supported Devices (`spi_device_id_t`) + +1. **SPI_DEVICE_ST7789:** Display Driver +2. **SPI_DEVICE_CC1101:** Sub-GHz Radio +3. **SPI_DEVICE_SD_CARD:** Storage + +## API Reference + +### `spi_init` +```c +esp_err_t spi_init(void); +``` +Initializes the SPI bus (MOSI, MISO, SCLK) on `SPI3_HOST` using DMA Channel `Auto`. +- **Pins:** Defined in `pin_def.h`. +- **Max Transfer Size:** 32768 bytes. + +### `spi_add_device` +```c +esp_err_t spi_add_device(spi_host_device_t host, spi_device_id_t id, const spi_device_config_t *config); +``` +Adds a specific device to the initialized bus. +- **host:** SPI host device (SPI2_HOST, SPI3_HOST). +- **id:** Device identifier enum. +- **config:** Struct containing CS pin, clock speed, SPI mode, and queue size. + +### `spi_get_handle` +```c +spi_device_handle_t spi_get_handle(spi_device_id_t id); +``` +Retrieves the ESP-IDF `spi_device_handle_t` for a registered device ID. Useful for calling native ESP-IDF SPI functions. + +### `spi_transmit` +```c +esp_err_t spi_transmit(spi_device_id_t id, const uint8_t *data, size_t len); +``` +Performs a simple polling/blocking transmission to the specified device. +- **Note:** For high-performance display flushing, specific drivers (like `esp_lcd`) typically use their own transmission logic using the handle obtained via `spi_get_handle`. + +### `spi_deinit` +```c +esp_err_t spi_deinit(void); +``` +Removes all devices and frees the SPI bus resources. + +--- + +# C5 + +This component acts as a central manager for the SPI bus, allowing multiple devices (Display, Radio, SD Card) to share the same SPI host safely and efficiently. + +## Overview + +- **Location:** `components/Drivers/spi/` +- **Header:** `include/spi.h` +- **Dependencies:** `driver/spi_master` +- **Host:** `SPI3_HOST` + +## Supported Devices (`spi_device_id_t`) + +1. **SPI_DEVICE_ST7789:** Display Driver +2. **SPI_DEVICE_CC1101:** Sub-GHz Radio +3. **SPI_DEVICE_SD_CARD:** Storage + +## API Reference + +### `spi_init` +```c +esp_err_t spi_init(void); +``` +Initializes the SPI bus (MOSI, MISO, SCLK) on `SPI3_HOST` using DMA Channel `Auto`. +- **Pins:** Defined in `pin_def.h`. +- **Max Transfer Size:** 32768 bytes. + +### `spi_add_device` +```c +esp_err_t spi_add_device(spi_device_id_t id, const spi_device_config_t *config); +``` +Adds a specific device to the initialized bus. +- **id:** Device identifier enum. +- **config:** Struct containing CS pin, clock speed, SPI mode, and queue size. + +### `spi_get_handle` +```c +spi_device_handle_t spi_get_handle(spi_device_id_t id); +``` +Retrieves the ESP-IDF `spi_device_handle_t` for a registered device ID. Useful for calling native ESP-IDF SPI functions. + +### `spi_transmit` +```c +esp_err_t spi_transmit(spi_device_id_t id, const uint8_t *data, size_t len); +``` +Performs a simple polling/blocking transmission to the specified device. +- **Note:** For high-performance display flushing, specific drivers (like `esp_lcd`) typically use their own transmission logic using the handle obtained via `spi_get_handle`. + +### `spi_deinit` +```c +esp_err_t spi_deinit(void); +``` +Removes all devices and frees the SPI bus resources. diff --git a/docs/spi_bridge/README.md b/docs/spi_bridge/README.md new file mode 100644 index 000000000..f2daa92b6 --- /dev/null +++ b/docs/spi_bridge/README.md @@ -0,0 +1,875 @@ +# TentacleOS - P4 ↔ C5 SPI Bridge + +How the two microcontrollers in TentacleOS talk to each other. + +This is the **architecture overview** that ties both sides together. For +side-specific detail and migration recipes see the component READMEs: +- `firmware_p4/components/Service/spi_bridge/README.md` (master, protocol spec, + command reference, session lifecycle, stream transport) +- `firmware_c5/components/Service/spi_bridge/README.md` (slave) + +--- + +## 1. Roles + +TentacleOS runs on two chips with a clean split of responsibilities: + +| Chip | Role | Owns | +|------|------|------| +| **ESP32-P4** | Main OS / "brain" | UI (LVGL display), apps, storage (micro-SD via SDMMC), USB, the SPI **master** | +| **ESP32-C5** | Radio co-processor | WiFi, Bluetooth (NimBLE), LoRa, the SPI **slave** | + +The P4 has no native WiFi/BT radio, so every radio action (scan, connect, +sniff, attack, mesh, …) is a **command sent to the C5** over SPI. The C5 +executes it on the radio and returns results / streams data back. Anything that +needs the micro-SD is routed from the C5 to the P4 over this same bridge - the +C5 stores only on its internal flash (LittleFS). + +``` + ┌────────────────────┐ SPI (10 MHz, mode 0, DMA) ┌────────────────────┐ + │ ESP32-P4 │ ── SCLK / MOSI / MISO / CS ──►│ ESP32-C5 │ + │ (master / OS) │ ◄──────── IRQ ───────────────│ (slave / radio) │ + │ │ ── UART1 + RESET/BOOT ───────►│ (firmware flash) │ + └────────────────────┘ └────────────────────┘ +``` + +--- + +## 2. Physical layer + +Two independent links connect the chips: + +### 2.1 SPI bridge (runtime data path) + +Standard **4-wire SPI, 1-bit, full-duplex, mode 0, 10 MHz, DMA-driven** +(`SPI_DMA_CH_AUTO` on both sides). The P4 is master, the C5 is slave. A separate +GPIO line (**IRQ**) lets the slave signal "response ready" to the master. + +| Signal | P4 GPIO | C5 GPIO | +|--------|---------|---------| +| SCLK | 20 | 6 | +| MOSI | 21 | 7 | +| MISO | 22 | 2 | +| CS | 23 | 10 | +| IRQ | 2 | 3 | + +- **DMA** is mandatory: frames are 264 B (and stream frames 2 KB), far above the + SPI hardware FIFO (~64 B). DMA also frees the CPU during transfers. +- Because of DMA, **every transfer length must be a multiple of 4 bytes** - see + the frame sizing notes below. + +### 2.2 UART + control (firmware flashing only) + +The P4 flashes the C5's firmware over a separate UART link using the official +`esp-serial-flasher` component. Not used at runtime. + +| Signal | P4 GPIO | C5 | +|--------|---------|----| +| UART TX (P4→C5) | 46 | GPIO12 (U0RXD) | +| UART RX (C5→P4) | 47 | GPIO11 (U0TXD) | +| RESET | 48 | EN | +| BOOT | 33 | IO0 (GPIO0 strapping) | + +--- + +## 3. Frame format + +Every packet on the SPI bus starts with a fixed **5-byte header**: + +```c +typedef struct { + uint8_t sync; // 0xAA + uint8_t type; // 0x01 CMD, 0x02 RESP, 0x03 STREAM + uint8_t category; // spi_cat_t - subsystem + uint8_t op; // operation within the category + uint8_t length; // payload bytes that follow (0-255) +} spi_header_t; +``` + +`SPI_FRAME_SIZE` = header + 256 B payload, **rounded up to a multiple of 4** for +DMA = **264 B**. The command/response path always transfers `SPI_FRAME_SIZE`. + +### Command identifier = `category` + `op` + +A command is identified by two header bytes that pack into a single 16-bit value +in code via `SPI_CMD(cat, op)`. The C5 routes to a dispatcher by `category` +alone; `op` selects the operation within it. + +| Category | Value | Routed to | +|----------|-------|-----------| +| `SPI_CAT_SYSTEM` | `0x00` | inline system handlers | +| `SPI_CAT_WIFI` | `0x01` | `wifi_dispatcher` | +| `SPI_CAT_BT` | `0x02` | `bt_dispatcher` | +| `SPI_CAT_LORA` | `0x03` | (lora) | +| `SPI_CAT_MESH` | `0x04` | meshtastic (split BLE/WiFi transport) | +| `SPI_CAT_MCORE` | `0x05` | meshcore → `bt_dispatcher` | +| `SPI_CAT_HOST` | `0x06` | companion host-link BLE relay → `bt_dispatcher` | +| `SPI_CAT_SESSION` | `0xFF` | inline session handlers | + +In C, the `SPI_ID_*` constants stay single named values (e.g. +`SPI_ID_WIFI_SCAN = SPI_CMD(SPI_CAT_WIFI, 0x10) = 0x0110`), so call sites and +dispatcher `case` labels are unchanged - only the wire carries the two bytes. +The full command table lives in the P4 component README. + +### Response status + +A `RESP` frame's **payload byte 0 is the status** (`spi_status_t`): `OK (0)`, +`BUSY (1)`, `ERROR (2)`, `UNSUPPORTED (3)`, `INVALID_ARG (4)`; the rest of the +payload is the response data. + +--- + +## 4. Command / response flow + +The bridge is a master-driven request/response protocol with an IRQ handshake: + +``` +P4 (master) C5 (slave) + │ clock CMD frame (264 B) ───────────► receive into armed RX buffer + │ route by category → dispatcher + │ build RESP, arm TX buffer + │ ◄────────── IRQ rising edge ────────── pulse IRQ (~10 µs) + │ clock again to read RESP (264 B) ───► transmit RESP + │ parse status + payload +``` + +- The P4 catches the IRQ via a **GPIO rising-edge interrupt** (ISR → semaphore), + so the C5 only needs a short (~10 µs) pulse - no held level, no millisecond + delay. +- The C5's `bridge_task` keeps a **receive transaction always armed in hardware** + (it queues the next RX before the current response finishes), so a command is + never missed in the gap between transfers, even under task preemption. +- A per-command **mutex** on the P4 serialises commands; long radio ops get + longer timeouts (`SPI_TIMEOUT_WIFI_MS = 20 s`, default `1 s`). + +--- + +## 5. Generic data pipe (pulling lists) + +Operations that produce lists (scan results, etc.) don't push everything at +once. The C5 points the bridge at its result array via +`spi_bridge_provide_results(ptr, count, item_size)`, and the P4 pulls items with +`SPI_ID_SYSTEM_DATA` using special indices: + +| Index | Meaning | +|-------|---------| +| `0xFFFF` | item count | +| `0..N-1` | one item | +| `0xEEEE` | live `spi_sniffer_stats_t` | +| `0xDDDD` | deauth counter | + +This is also how the **Packet Monitor** works: it's a counter-only sniffer mode +that just polls the stats - it does not stream frames. + +--- + +## 6. Streaming (live data, e.g. pcap) + +Long-running ops that emit a continuous feed (WiFi/BLE sniffers, mesh phone +bridge) use a stream path. The C5 buffers records in a 64-deep ring; the P4 +drains them by polling `SPI_ID_SYSTEM_STREAM`. + +To keep throughput high, the transport **batches many records into one large +transfer** (`SPI_STREAM_FRAME_SIZE = 2048 B`) instead of one record per +round-trip: + +``` +STREAM frame payload (after the 5-byte header, type = STREAM): + [u16 batch_len][record][record]... record = [u16 op][u8 len][len bytes] +``` + +- `batch_len = 0` ⇒ no data pending ⇒ the P4 backs off and polls later. +- The P4 unpacks and dispatches **each record to its op's callback**, exactly as + if it had arrived in its own frame - so session/`seq`/backpressure semantics + stay **per record**. +- The command/response path is untouched (still `SPI_FRAME_SIZE`). + +**Throughput:** the original one-record-per-frame + 1 ms IRQ pulse capped streams +at ~120 KB/s. Shortening the IRQ pulse (~3×) plus batching lifts the ceiling to +roughly ~1 MB/s at 10 MHz - enough for dense-AP / targeted capture. A saturated +data channel can still overrun it (physics on a 1-bit link), in which case +records are **dropped and counted** (capture is never blocked) - the right tool +there is a capture filter. + +--- + +## 7. Session lifecycle (anti-zombie + backpressure) + +Streaming/long-running ops are wrapped in a **session** so the C5 never keeps +running into the void if the P4 crashes or stops listening: + +1. **Session ID** - the C5 returns a random 32-bit `session_id` on START; both + sides track it, and stream records carry it so stale data is discarded. +2. **Heartbeat** - the P4 sends `SPI_ID_SESSION_HEARTBEAT` every **2 s** with its + `last_acked_seq`. A C5 watchdog (1 s tick) kills any session whose last + heartbeat is older than **5 s** and emits `SPI_ID_SESSION_LOST`. +3. **Backpressure window** - each record carries `{session_id, seq}`. The C5 + refuses to emit when `seq - last_acked_seq >= SPI_SESSION_WINDOW (64)`, + preventing overflow when the radio produces faster than the bridge drains. + +| Direction | When | Packet | +|-----------|------|--------| +| P4→C5 | START | `op_id` + params | +| C5→P4 | START reply | status + `spi_session_resp_t { session_id }` | +| P4→C5 | every 2 s | `SPI_ID_SESSION_HEARTBEAT` + `{ session_id, last_acked_seq }` | +| C5→P4 | data | batched STREAM frame (§6); each record = `op` + meta + payload | +| P4→C5 | STOP | `SPI_ID_SESSION_STOP` + `{ session_id }` | +| C5→P4 | watchdog kill | `SPI_ID_SESSION_LOST` + `{ session_id, cmd }` | + +--- + +## 8. Firmware versioning & flashing + +The C5 firmware is **embedded in the P4 firmware** at build time (bootloader + +partition table + app). On boot, `bridge_manager` queries the C5's version +(`SPI_ID_SYSTEM_VERSION`) and compares it against the P4's expected version +(`FIRMWARE_VERSION`, currently **1.3.0**). On mismatch (or no response) the P4 +re-flashes the C5 over the UART link using `esp-serial-flasher`, writing the +full image: + +| Image | C5 flash offset | +|-------|-----------------| +| bootloader | `0x2000` | +| partition table | `0x8000` | +| app | `0x10000` | + +Any breaking change to the wire format must bump **both** versions +(`FIRMWARE_VERSION` on the P4 and `SPI_FW_VERSION_STRING` on the C5) to the same +new value, forcing a re-sync. + +--- + +## 9. Key source files + +**P4 (master)** +- `components/Service/spi_bridge/` - `spi_bridge.c` (send command, stream task), + `spi_session.c` (session/heartbeat), `spi_protocol.h` (shared contract) +- `components/Drivers/spi_bridge_phy/` - SPI master PHY + IRQ edge ISR +- `components/Service/bridge_manager/` - version check + C5 recovery +- `components/Service/c5_flasher/` - `esp-serial-flasher` wrapper + +**C5 (slave)** +- `components/Service/spi_bridge/` - `spi_bridge.c` (`bridge_task` routing + + always-armed RX + stream batching), `wifi_dispatcher.c`, `bt_dispatcher.c`, + `session_manager.c`, `spi_protocol.h` +- `components/Drivers/spi_slave/` - SPI slave driver (queued transactions) + +`spi_protocol.h` is kept in sync between the two firmwares (the P4 copy is a +superset - it has port-scan commands the C5 doesn't implement). + +--- + +## 10. Design constraints & limits + +- **1-bit SPI** - dual/quad isn't wired, so the raw ceiling is the clock + (~1.25 MB/s at 10 MHz). Higher clocks (20/40 MHz) are possible but limited by + the SPI slave timing and trace integrity. +- **264 B / 2 KB frames must stay 4-byte aligned** for DMA. +- The two `spi_protocol.h` copies are maintained by hand - keep them in sync. +- Command `op` values currently reuse the legacy single-byte ids (e.g. WiFi ops + start at `0x10`); renumbering to `0x01`-based per category is a safe cosmetic + follow-up. + +--- + +# P4 + +This component manages the high-speed communication link between the **ESP32-P4 (Main OS)** and the **ESP32-C5 (Radio Co-processor)**. + +## Architecture +The P4 acts as the **SPI Master**. It is responsible for: +1. Generating the SCLK and managing the CS line. +2. Initiating all command transfers. +3. Handling the **IRQ (Handshake)** signal from the C5 to know when response data is ready. +4. Managing the C5 lifecycle (Reset, Boot mode, and Firmware Updates via UART). + +## Protocol Specification +Every packet follows a 5-byte fixed header: +- `Sync (0xAA)`: Packet synchronization. +- `Type`: `0x01` (Command), `0x02` (Response), `0x03` (Stream). +- `Category`: Subsystem selector (`spi_cat_t`: WiFi `0x01`, BT `0x02`, …). The C5 + routes a command to a dispatcher by this byte alone. +- `Op`: Operation within the category. +- `Length`: Size of the following payload (0-255 bytes). + +`Category` + `Op` together form the packed command identifier (`spi_id_t`), +built via `SPI_CMD(cat, op)`. Use `spi_header_cmd()` / `spi_header_set_cmd()` to +read/write the pair as a single 16-bit value. + +## Command Reference + +Every command's `spi_id_t` packs `Category` (high byte) and `Op` (low byte) via `SPI_CMD(cat, op)`. On the wire those are the 3rd and 4th header bytes; in code use the single 16-bit `SPI_ID_*` constant. + +### System (`0x00`) + +| Command | Op | `spi_id_t` | +|---------|----|------------| +| `SPI_ID_SYSTEM_PING` | `0x01` | `0x0001` | +| `SPI_ID_SYSTEM_STATUS` | `0x02` | `0x0002` | +| `SPI_ID_SYSTEM_REBOOT` | `0x03` | `0x0003` | +| `SPI_ID_SYSTEM_VERSION` | `0x04` | `0x0004` | +| `SPI_ID_SYSTEM_DATA` | `0x05` | `0x0005` | +| `SPI_ID_SYSTEM_STREAM` | `0x06` | `0x0006` | +| `SPI_ID_SYSTEM_LOG` | `0x07` | `0x0007` | + +`SPI_ID_SYSTEM_LOG` is a C5→P4 stream carrying log lines (`[level u8][utf-8]`) for +the companion's C5 console (see the host-link docs). + +System ops `0x40`-`0x49` (`FILE_*`, `SYSTEM_DEVICE_STATE`, `SYSTEM_CONSOLE_EXEC`, +`SYSTEM_GET_SETTINGS`, `SYSTEM_SET_SETTINGS`) are **P4-local host-link commands**: +they share the `spi_id_t` space so the companion app and P4 agree, but they are +handled on the P4 and **never travel over this SPI bridge**. They are documented +in [`../host_link/protocol.md`](../host_link/protocol.md). + +### WiFi (`0x01`) + +| Command | Op | `spi_id_t` | +|---------|----|------------| +| `SPI_ID_WIFI_SCAN` | `0x10` | `0x0110` | +| `SPI_ID_WIFI_CONNECT` | `0x11` | `0x0111` | +| `SPI_ID_WIFI_DISCONNECT` | `0x12` | `0x0112` | +| `SPI_ID_WIFI_GET_STA_INFO` | `0x13` | `0x0113` | +| `SPI_ID_WIFI_SET_AP` | `0x14` | `0x0114` | +| `SPI_ID_WIFI_START` | `0x15` | `0x0115` | +| `SPI_ID_WIFI_STOP` | `0x16` | `0x0116` | +| `SPI_ID_WIFI_SAVE_AP_CONFIG` | `0x17` | `0x0117` | +| `SPI_ID_WIFI_SET_ENABLED` | `0x18` | `0x0118` | +| `SPI_ID_WIFI_SET_AP_PASSWORD` | `0x19` | `0x0119` | +| `SPI_ID_WIFI_SET_AP_MAX_CONN` | `0x1A` | `0x011A` | +| `SPI_ID_WIFI_SET_AP_IP` | `0x1B` | `0x011B` | +| `SPI_ID_WIFI_PROMISC_START` | `0x1C` | `0x011C` | +| `SPI_ID_WIFI_PROMISC_STOP` | `0x1D` | `0x011D` | +| `SPI_ID_WIFI_CH_HOP_START` | `0x1E` | `0x011E` | +| `SPI_ID_WIFI_CH_HOP_STOP` | `0x1F` | `0x011F` | +| `SPI_ID_WIFI_APP_SCAN_AP` | `0x20` | `0x0120` | +| `SPI_ID_WIFI_APP_SCAN_CLIENT` | `0x21` | `0x0121` | +| `SPI_ID_WIFI_APP_BEACON_SPAM` | `0x22` | `0x0122` | +| `SPI_ID_WIFI_APP_DEAUTHER` | `0x23` | `0x0123` | +| `SPI_ID_WIFI_APP_FLOOD` | `0x24` | `0x0124` | +| `SPI_ID_WIFI_APP_SNIFFER` | `0x25` | `0x0125` | +| `SPI_ID_WIFI_APP_EVIL_TWIN` | `0x26` | `0x0126` | +| `SPI_ID_WIFI_APP_DEAUTH_DET` | `0x27` | `0x0127` | +| `SPI_ID_WIFI_APP_PROBE_MON` | `0x28` | `0x0128` | +| `SPI_ID_WIFI_APP_SIGNAL_MON` | `0x29` | `0x0129` | +| `SPI_ID_WIFI_SNIFFER_SET_SNAPLEN` | `0x2B` | `0x012B` | +| `SPI_ID_WIFI_SNIFFER_SET_VERBOSE` | `0x2C` | `0x012C` | +| `SPI_ID_WIFI_SNIFFER_SAVE_FLASH` | `0x2D` | `0x012D` | +| `SPI_ID_WIFI_SNIFFER_SAVE_SD` | `0x2E` | `0x012E` | +| `SPI_ID_WIFI_SNIFFER_FREE_BUFFER` | `0x2F` | `0x012F` | +| `SPI_ID_WIFI_SNIFFER_STREAM_SD` | `0x30` | `0x0130` | +| `SPI_ID_WIFI_SNIFFER_CLEAR_PMKID` | `0x31` | `0x0131` | +| `SPI_ID_WIFI_SNIFFER_GET_PMKID_BSSID` | `0x32` | `0x0132` | +| `SPI_ID_WIFI_SNIFFER_CLEAR_HANDSHAKE` | `0x33` | `0x0133` | +| `SPI_ID_WIFI_SNIFFER_GET_HANDSHAKE_BSSID` | `0x34` | `0x0134` | +| `SPI_ID_WIFI_DEAUTH_STATUS` | `0x35` | `0x0135` | +| `SPI_ID_WIFI_DEAUTH_SEND_RAW` | `0x36` | `0x0136` | +| `SPI_ID_WIFI_ASSOC_REQUEST` | `0x37` | `0x0137` | +| `SPI_ID_WIFI_DEAUTH_SEND_FRAME` | `0x38` | `0x0138` | +| `SPI_ID_WIFI_DEAUTH_SEND_BROADCAST` | `0x39` | `0x0139` | +| `SPI_ID_WIFI_TARGET_SCAN_START` | `0x3A` | `0x013A` | +| `SPI_ID_WIFI_TARGET_SCAN_STATUS` | `0x3B` | `0x013B` | +| `SPI_ID_WIFI_TARGET_SAVE_FLASH` | `0x3C` | `0x013C` | +| `SPI_ID_WIFI_TARGET_SAVE_SD` | `0x3D` | `0x013D` | +| `SPI_ID_WIFI_TARGET_FREE` | `0x3E` | `0x013E` | +| `SPI_ID_WIFI_PROBE_SAVE_FLASH` | `0x3F` | `0x013F` | +| `SPI_ID_WIFI_PROBE_SAVE_SD` | `0x40` | `0x0140` | +| `SPI_ID_WIFI_EVIL_TWIN_TEMPLATE` | `0x41` | `0x0141` | +| `SPI_ID_WIFI_EVIL_TWIN_HAS_PASSWORD` | `0x42` | `0x0142` | +| `SPI_ID_WIFI_EVIL_TWIN_GET_PASSWORD` | `0x43` | `0x0143` | +| `SPI_ID_WIFI_EVIL_TWIN_RESET_CAPTURE` | `0x44` | `0x0144` | +| `SPI_ID_WIFI_CLIENT_SAVE_FLASH` | `0x45` | `0x0145` | +| `SPI_ID_WIFI_CLIENT_SAVE_SD` | `0x46` | `0x0146` | +| `SPI_ID_WIFI_AP_SAVE_FLASH` | `0x47` | `0x0147` | +| `SPI_ID_WIFI_AP_SAVE_SD` | `0x48` | `0x0148` | +| `SPI_ID_WIFI_PORT_SCAN_TARGET_RANGE` | `0x49` | `0x0149` | +| `SPI_ID_WIFI_PORT_SCAN_TARGET_LIST` | `0x4A` | `0x014A` | +| `SPI_ID_WIFI_PORT_SCAN_NETWORK` | `0x4B` | `0x014B` | +| `SPI_ID_WIFI_PORT_SCAN_CIDR` | `0x4C` | `0x014C` | +| `SPI_ID_WIFI_PORT_SCAN_STOP` | `0x4D` | `0x014D` | +| `SPI_ID_WIFI_GET_MAC` | `0x4E` | `0x014E` | +| `SPI_ID_WIFI_GET_IP_INFO` | `0x4F` | `0x014F` | +| `SPI_ID_WIFI_EVIL_TWIN_TMPL_BEGIN` | `0xA0` | `0x01A0` | +| `SPI_ID_WIFI_EVIL_TWIN_TMPL_CHUNK` | `0xA1` | `0x01A1` | + +### Bluetooth (`0x02`) + +| Command | Op | `spi_id_t` | +|---------|----|------------| +| `SPI_ID_BT_SCAN` | `0x50` | `0x0250` | +| `SPI_ID_BT_CONNECT` | `0x51` | `0x0251` | +| `SPI_ID_BT_DISCONNECT` | `0x52` | `0x0252` | +| `SPI_ID_BT_GET_INFO` | `0x53` | `0x0253` | +| `SPI_ID_BT_INIT` | `0x54` | `0x0254` | +| `SPI_ID_BT_DEINIT` | `0x55` | `0x0255` | +| `SPI_ID_BT_START` | `0x56` | `0x0256` | +| `SPI_ID_BT_STOP` | `0x57` | `0x0257` | +| `SPI_ID_BT_SET_RANDOM_MAC` | `0x58` | `0x0258` | +| `SPI_ID_BT_START_ADV` | `0x59` | `0x0259` | +| `SPI_ID_BT_STOP_ADV` | `0x5A` | `0x025A` | +| `SPI_ID_BT_SET_MAX_POWER` | `0x5B` | `0x025B` | +| `SPI_ID_BT_TRACKER_START` | `0x5C` | `0x025C` | +| `SPI_ID_BT_TRACKER_STOP` | `0x5D` | `0x025D` | +| `SPI_ID_BT_GET_ADDR_TYPE` | `0x5E` | `0x025E` | +| `SPI_ID_BT_SAVE_ANNOUNCE_CFG` | `0x5F` | `0x025F` | +| `SPI_ID_BT_APP_SCANNER` | `0x60` | `0x0260` | +| `SPI_ID_BT_APP_SNIFFER` | `0x61` | `0x0261` | +| `SPI_ID_BT_APP_SPAM` | `0x62` | `0x0262` | +| `SPI_ID_BT_APP_FLOOD` | `0x63` | `0x0263` | +| `SPI_ID_BT_APP_SKIMMER` | `0x64` | `0x0264` | +| `SPI_ID_BT_APP_TRACKER` | `0x65` | `0x0265` | +| `SPI_ID_BT_APP_GATT_EXP` | `0x66` | `0x0266` | +| `SPI_ID_BT_SPAM_LIST_LOAD` | `0x68` | `0x0268` | +| `SPI_ID_BT_SPAM_LIST_BEGIN` | `0x69` | `0x0269` | +| `SPI_ID_BT_SPAM_LIST_ITEM` | `0x6A` | `0x026A` | +| `SPI_ID_BT_SPAM_LIST_COMMIT` | `0x6B` | `0x026B` | +| `SPI_ID_BT_SCREEN_INIT` | `0x6C` | `0x026C` | +| `SPI_ID_BT_SCREEN_DEINIT` | `0x6D` | `0x026D` | +| `SPI_ID_BT_SCREEN_IS_ACTIVE` | `0x6E` | `0x026E` | +| `SPI_ID_BT_SCREEN_SEND_PARTIAL` | `0x6F` | `0x026F` | +| `SPI_ID_BT_L2CAP_STATUS` | `0x70` | `0x0270` | +| `SPI_ID_BT_HID_INIT` | `0x71` | `0x0271` | +| `SPI_ID_BT_HID_DEINIT` | `0x72` | `0x0272` | +| `SPI_ID_BT_HID_IS_CONNECTED` | `0x73` | `0x0273` | +| `SPI_ID_BT_HID_SEND_KEY` | `0x74` | `0x0274` | + +### LoRa (`0x03`) + +| Command | Op | `spi_id_t` | +|---------|----|------------| +| `SPI_ID_LORA_RX` | `0x80` | `0x0380` | +| `SPI_ID_LORA_TX` | `0x81` | `0x0381` | + +### Meshtastic (`0x04`) + +| Command | Op | `spi_id_t` | +|---------|----|------------| +| `SPI_ID_MESH_BLE_INIT` | `0x90` | `0x0490` | +| `SPI_ID_MESH_BLE_STOP` | `0x91` | `0x0491` | +| `SPI_ID_MESH_WIFI_INIT` | `0x92` | `0x0492` | +| `SPI_ID_MESH_WIFI_STOP` | `0x93` | `0x0493` | +| `SPI_ID_MESH_FROMRADIO_PUSH` | `0x94` | `0x0494` | +| `SPI_ID_MESH_LOG_PUSH` | `0x95` | `0x0495` | +| `SPI_ID_MESH_STATUS` | `0x96` | `0x0496` | +| `SPI_ID_MESH_TORADIO_STREAM` | `0x97` | `0x0497` | + +### MeshCore (`0x05`) + +| Command | Op | `spi_id_t` | +|---------|----|------------| +| `SPI_ID_MCORE_BLE_INIT` | `0x98` | `0x0598` | +| `SPI_ID_MCORE_BLE_STOP` | `0x99` | `0x0599` | +| `SPI_ID_MCORE_TX_PUSH` | `0x9A` | `0x059A` | +| `SPI_ID_MCORE_RX_STREAM` | `0x9B` | `0x059B` | +| `SPI_ID_MCORE_STATUS` | `0x9C` | `0x059C` | + +### Host Link (`0x06`) + +Companion BLE relay (the C5 owns the radio; the P4 owns crypto). The C5 routes +this category to `bt_dispatcher`. See [`../host_link/`](../host_link/README.md). + +| Command | Op | `spi_id_t` | Direction | +|---------|----|------------|-----------| +| `SPI_ID_HOST_BLE_INIT` | `0xA0` | `0x06A0` | P4→C5 cmd: start GATT + advertise | +| `SPI_ID_HOST_BLE_STOP` | `0xA1` | `0x06A1` | P4→C5 cmd: stop GATT | +| `SPI_ID_HOST_TX` | `0xA2` | `0x06A2` | P4→C5 push: device→app (BLE notify) | +| `SPI_ID_HOST_RX` | `0xA3` | `0x06A3` | C5→P4 stream: app→device (BLE write) | +| `SPI_ID_HOST_STATUS` | `0xA4` | `0x06A4` | P4→C5 cmd: poll BLE connection state | + +### Session (`0xFF`) + +| Command | Op | `spi_id_t` | +|---------|----|------------| +| `SPI_ID_SESSION_HEARTBEAT` | `0xF0` | `0xFFF0` | +| `SPI_ID_SESSION_LOST` | `0xF1` | `0xFFF1` | +| `SPI_ID_SESSION_STOP` | `0xF2` | `0xFFF2` | + +## Frame Example + +The 5-byte header maps directly to `spi_header_t`: + +```c +typedef struct { + uint8_t sync; // 0xAA + uint8_t type; // spi_type_t: CMD 0x01 / RESP 0x02 / STREAM 0x03 + uint8_t category; // spi_cat_t + uint8_t op; // operation within the category + uint8_t length; // payload bytes that follow (0-255) +} spi_header_t; +``` + +**Example - WiFi scan** (`SPI_ID_WIFI_SCAN` = `SPI_CMD(SPI_CAT_WIFI, 0x10)` = `0x0110`), no payload: + +``` +P4 -> C5 (command) + AA 01 01 10 00 + ^ ^ ^ ^ ^ + | | | | +-- length = 0 + | | | +----- op = 0x10 + | | +-------- category = 0x01 (WiFi) + | +----------- type = 0x01 (CMD) + +-------------- sync = 0xAA + +C5 -> P4 (response, after raising IRQ) - payload byte 0 is the status + AA 02 01 10 01 00 + ^ ^ ^ ^ ^ ^ + | | | | | +-- status = 0x00 (SPI_STATUS_OK) + | | | | +----- length = 1 + | | | +-------- op = 0x10 + | | +----------- category = 0x01 + | +-------------- type = 0x02 (RESP) + +----------------- sync = 0xAA +``` + +Scan results are then pulled item-by-item through the **Generic Data Pipe** (`SPI_ID_SYSTEM_DATA`) described below. + +## Generic Data Pipe +To keep the bridge simple, we use a "Dumb Pipe" approach for large data sets (like Scan results): +1. **Pull Count**: Call `SPI_ID_SYSTEM_DATA` with index `0xFFFF`. +2. **Pull Item**: Call `SPI_ID_SYSTEM_DATA` with index `0 to N`. +3. **Real-time Stats**: Call `SPI_ID_SYSTEM_DATA` with index `0xEEEE` to get a `sniffer_stats_t` structure. + +## Stream Transport (batched) + +Long-running ops (sniffers, mesh bridge) emit a continuous stream of records. +The P4 drains them by polling `SPI_ID_SYSTEM_STREAM`. To keep throughput high, +the transport **batches many records into one transfer** instead of one record +per round-trip: + +- The C5 buffers records in a ring (depth `SPI_STREAM_QUEUE_LEN = 64`). On a + `SPI_ID_SYSTEM_STREAM` poll it packs as many as fit into a single large frame + of `SPI_STREAM_FRAME_SIZE` (2048 B) and the P4 always clocks that fixed size. +- Stream frame layout (after the 5-byte header, `type = STREAM`): + `[u16 batch_len]` then `batch_len` bytes of records, each + `[u16 op][u8 len][len bytes]`. `batch_len = 0` means "no data" → the P4 backs + off and polls again later. +- The P4 unpacks and dispatches **each record to its `op`'s stream callback**, + exactly as if it had arrived in its own frame - so session/`seq`/backpressure + semantics stay **per record** (see Session Lifecycle). The command/response + path is unaffected and still uses `SPI_FRAME_SIZE`. + +Two related tunables: the C5 signals readiness with a short rising-edge IRQ +pulse (~10 µs - the P4 catches it via a GPIO edge interrupt, so no held level +or millisecond delay is needed), and bursts are absorbed by the 64-deep ring; +when it overflows, records are dropped and counted (never block capture). + +### Stream Example (WiFi sniffer) + +**Producer - C5** (each captured 802.11 frame becomes one record; the session +layer adds the `{session_id, seq}` meta and applies backpressure): +```c +spi_wifi_sniffer_frame_t f = { .rssi = -42, .channel = 6, .len = n, /* data */ }; +session_manager_try_emit(session_id, (const uint8_t *)&f, 3 + n); +``` + +**On the wire** - the P4 polls `SYSTEM_STREAM` and the C5 returns one 2 KB frame +batching the queued records: +``` +P4 -> C5: AA 01 00 06 00 poll: SYSTEM_STREAM (cat 0x00, op 0x06) +C5 -> P4: AA 03 00 00 00 | + ^ header, type=STREAM (cat/op/length unused for the batch) + payload: + 20 00 batch_len = 0x0020 (32 bytes of records) + ── record 1 ─────────────────────── + 25 01 op = 0x0125 (SPI_ID_WIFI_APP_SNIFFER) + 0D rec_len = 13 + 34 12 00 00 01 00 00 00 spi_stream_meta_t { session_id=0x1234, seq=1 } + D6 06 02 AA BB frame: rssi=-42, ch=6, len=2, data=AA BB + ── record 2 (same op, seq=2) ────── + 25 01 0D 34 12 00 00 02 00 00 00 D6 06 02 CC DD + ── remaining bytes up to 2048 = padding, ignored (batch_len bounds it) ── +``` + +**Consumer - P4** (each record is dispatched to the op's callback; the meta is +stripped by the session layer, so the consumer sees only the frame): +```c +// registered via spi_session_start(SPI_ID_WIFI_APP_SNIFFER, …, on_stream, …) +static void on_stream(const uint8_t *payload, uint8_t len) { + const spi_wifi_sniffer_frame_t *f = (const void *)payload; // one captured frame + storage_stream_write(pcap, f->data, f->len); +} +``` +See `wifi_sniffer.c` (both firmwares) for the full reference implementation. + +## Adding a New Command +To add a new feature (e.g., "GPS Get Location"): + +1. **Protocol**: Add `SPI_ID_GPS_GET` to `spi_protocol.h`. +2. **C5 Dispatcher**: + - Open `wifi_dispatcher.c` (or a new `gps_dispatcher.c`). + - Add the case for `SPI_ID_GPS_GET`. + - Call the actual hardware driver. + - If it returns a list, call `spi_bridge_provide_results(pointer, count, size)`. +3. **P4 Wrapper**: + - Create a wrapper in `Applications` or `Service`. + - Use `spi_bridge_send_command(SPI_ID_GPS_GET, ...)` to trigger the action. + - Use the generic `SPI_ID_SYSTEM_DATA` to pull results if necessary. + +## Session Lifecycle (Long-Running Operations) + +For operations that run for an extended period (sniffers, monitors, attacks +that emit a stream of events), the basic request-response model is unsafe: +if the master dies or stops listening, the slave keeps running indefinitely +and sends data into the void. The session protocol fixes this with three +mechanisms working together: + +### 1. Session ID +Every long-running operation is tagged with a 32-bit `session_id` chosen +randomly by the C5 when the operation starts. Both sides track the active +session; stream packets carry the id so stale data can be discarded after +a restart. + +### 2. Heartbeat (anti-zombie) +The P4 sends `SPI_ID_SESSION_HEARTBEAT { session_id, last_acked_seq }` +every **2 seconds** while a session is active. The C5 has a watchdog task +that runs every second and kills any session whose last heartbeat is older +than **5 seconds**. When killed, the C5 emits `SPI_ID_SESSION_LOST` as a +stream so the master can react (e.g., restart, show error UI). + +If the master detects 3 consecutive heartbeat failures, it assumes the +session is gone and fires its local `on_lost` callback. + +### 3. Backpressure window +Stream packets carry `{ session_id, seq }`. The master accumulates +`last_acked_seq` and reports it via heartbeat. The C5 refuses to emit if +`seq - last_acked_seq >= SPI_SESSION_WINDOW (64)` - protects against +buffer overflow when the slave produces faster than the master drains. +Drops are counted and logged. + +### Wire shapes + +| Direction | When | Packet | +|-----------|------|--------| +| P4 → C5 | START | `op_id` + op-specific params | +| C5 → P4 | START reply | status byte + `spi_session_resp_t { session_id }` | +| P4 → C5 | every 2s | `SPI_ID_SESSION_HEARTBEAT` + `spi_heartbeat_req_t` | +| C5 → P4 | heartbeat reply | status + `spi_heartbeat_resp_t { alive }` | +| C5 → P4 | data | batched STREAM frame (see "Stream Transport"); each record = `op` + `spi_stream_meta_t { session_id, seq }` + payload | +| P4 → C5 | STOP | `SPI_ID_SESSION_STOP` + `spi_session_stop_req_t { session_id }` | +| C5 → P4 | watchdog kill | `SPI_ID_SESSION_LOST` STREAM + `spi_session_lost_t { session_id, cmd }` | + +### Master API + +```c +// Start a long-running operation. Spawns heartbeat task internally. +uint32_t spi_session_start(spi_id_t op_id, + const uint8_t *params, uint8_t params_len, + spi_session_stream_cb_t on_stream, // peeled meta + spi_session_lost_cb_t on_lost); + +// Clean teardown. Kills heartbeat, sends STOP. +esp_err_t spi_session_stop(uint32_t session_id); +``` + +Returns `SPI_SESSION_INVALID_ID` (0) on START failure. The `on_stream` +callback receives the **operation payload only** - the meta header is +stripped and ack tracking is invisible to the consumer. + +### Slave API (C5) + +```c +// Open a session for the op_id. Closes any prior session first. +uint32_t session_manager_start(spi_id_t op_id, session_kill_cb_t kill_cb); + +// Emit a stream packet (prefixes meta, applies backpressure). +esp_err_t session_manager_try_emit(uint32_t session_id, + const uint8_t *data, uint8_t len); +``` + +The op implementation stores the returned `session_id` and uses it for +every emit. The `kill_cb` is invoked by the watchdog if heartbeats stop - +the op should call its own `_stop()` from there. + +### Migrating a New Operation (recipe) + +There are two patterns depending on whether the op emits streams. Both +are used in the codebase - see `wifi_sniffer` (streaming) and +`wifi_deauther` (non-streaming) as references. + +#### Pattern A - Non-streaming op (deauther, flood, evil_twin, …) + +The op runs in background but does NOT emit packets to the master. The +master polls for results via `SPI_ID_SYSTEM_DATA` if it needs data. + +**C5 side (only the dispatcher changes - op .c/.h untouched):** +```c +// In wifi_dispatcher.c (or bt_dispatcher.c): +static void killed_my_op(spi_id_t id) { (void)id; my_op_stop(); } + +case SPI_ID_MY_OP: + if (!my_op_start(...)) return SPI_STATUS_ERROR; + return open_session(SPI_ID_MY_OP, killed_my_op, + out_resp_payload, out_resp_len, my_op_stop); +``` + +**P4 side (wrapper):** +```c +static uint32_t s_session_id = SPI_SESSION_INVALID_ID; + +bool my_op_start(...) { + s_session_id = spi_session_start(SPI_ID_MY_OP, params, len, NULL, NULL); + return s_session_id != SPI_SESSION_INVALID_ID; +} + +void my_op_stop(void) { + if (s_session_id != SPI_SESSION_INVALID_ID) { + spi_session_stop(s_session_id); + s_session_id = SPI_SESSION_INVALID_ID; + } +} +``` + +#### Pattern B - Streaming op (sniffer, ble_sniffer, …) + +The op emits a continuous stream of packets to the master. + +**C5 side:** +1. Add `static uint32_t s_session_id = SPI_SESSION_INVALID_ID;` to the + op's `.c`. +2. Add public `_bind_session(uint32_t)` setter and + `_session_killed(spi_id_t)` kill callback (the latter calls `_stop()`). +3. Replace `spi_bridge_stream_push(SPI_ID_OP, data, len)` with + `session_manager_try_emit(s_session_id, data, len)`. +4. In the dispatcher, replace the START handler with: call + `op_start(...)`, then `session_manager_start(SPI_ID_OP, op_session_killed)`, + then `op_bind_session(sid)`, then return + `spi_session_resp_t { sid }` as response payload. + +**P4 side:** +1. Replace `spi_bridge_send_command(SPI_ID_OP, …)` + + `spi_bridge_register_stream_cb(SPI_ID_OP, raw_cb)` with a single + `spi_session_start(SPI_ID_OP, params, …, on_stream, on_lost)`. +2. Store the returned `session_id`. +3. Change STOP to `spi_session_stop(session_id)`. +4. The `on_stream` callback signature is + `void(const uint8_t *payload, uint8_t len)` - the meta header is + already stripped. + +### Tunables +Defined in `session_manager.c` (slave) and `spi_session.c` (master): +- `SESSION_TIMEOUT_MS` = 5000 - slave watchdog timeout +- `WATCHDOG_PERIOD_MS` = 1000 - slave watchdog tick +- `HEARTBEAT_INTERVAL_MS` = 2000 - master ping period +- `HEARTBEAT_FAIL_LIMIT` = 3 - master fails before declaring lost +- `SPI_SESSION_WINDOW` = 64 - backpressure window (in `spi_protocol.h`) + +### Migrated operations + +All long-running ops now use the session lifecycle. Each one: +- Returns `spi_session_resp_t { session_id }` on START. +- Has a kill_cb registered with the session manager that calls its `_stop()`. +- Is closed by the master via `SPI_ID_SESSION_STOP { session_id }` (sent + internally by `spi_session_stop`). +- Is auto-killed by the C5 watchdog if the master stops sending heartbeats + for 5s (master crash, screen freeze, etc.). + +| Op | C5 module | P4 wrapper | Streams? | +|----|-----------|-----------|----------| +| `WIFI_APP_SNIFFER` | wifi_sniffer.c | wifi_sniffer.c | ✓ stream | +| `BT_APP_SNIFFER` | ble_sniffer.c | bluetooth_service.c | ✓ stream | +| `WIFI_APP_DEAUTHER` | wifi_deauther.c | wifi_deauther.c | - | +| `WIFI_APP_FLOOD` | wifi_flood.c | wifi_flood.c | - | +| `WIFI_APP_EVIL_TWIN` | evil_twin.c | evil_twin.c | - | +| `WIFI_APP_BEACON_SPAM` | beacon_spam.c | beacon_spam.c | - | +| `WIFI_APP_DEAUTH_DET` | deauther_detector.c | deauther_detector.c | - | +| `WIFI_APP_PROBE_MON` | probe_monitor.c | probe_monitor.c | - | +| `WIFI_APP_SIGNAL_MON` | signal_monitor.c | signal_monitor.c | - | +| `BT_APP_FLOOD` | ble_connect_flood.c | ble_connect_flood.c | - | +| `BT_APP_SKIMMER` | skimmer_detector.c | skimmer_detector.c | - | +| `BT_APP_TRACKER` | tracker_detector.c | tracker_detector.c | - | +| `BT_APP_SPAM` | (handler pending) | canned_spam.c | - | +| `BT_APP_FLOOD` (L2CAP variant) | ble_connect_flood.c | ble_l2cap_flood.c | - | + +The legacy `SPI_ID_WIFI_APP_ATTACK_STOP` and `SPI_ID_BT_APP_STOP` shotgun +commands have been removed entirely. Every op now stops via its own +session via `SPI_ID_SESSION_STOP { session_id }`. + +## Hardware Hookup +| Signal | P4 Pin | C5 Pin | +|--------|--------|--------| +| SCLK | 20 | 6 | +| MOSI | 21 | 7 | +| MISO | 22 | 2 | +| CS | 23 | 10 | +| IRQ | 2 | 3 | +| RESET | 48 | EN | +| BOOT | 33 | IO0 | +| UART TX| 46 | RX | +| UART RX| 47 | TX | + +--- + +# C5 + +This component transforms the **ESP32-C5** into a high-performance radio co-processor for the ESP32-P4. + +## How it Works +The C5 runs a background task (`spi_bridge_task`) that stays in a blocked state waiting for the P4 to send SPI bytes. + +1. **Reception**: When bytes arrive, the task validates the `0xAA` sync byte. +2. **Routing**: It switches on the `Category` byte and routes the payload to the appropriate **Dispatcher** (WiFi or Bluetooth); the `Op` byte selects the operation within that dispatcher. +3. **Execution**: The Dispatcher executes the radio command (e.g., starts a scan). +4. **Notification**: Once the command is done (or results are ready), the C5 raises the **IRQ (Handshake)** pin. +5. **Response**: The P4 sees the IRQ, sends a dummy SPI clock, and the C5 "pushes" the response packet back. + +## Memory Mapping (Zero-Copy Results) +The C5 uses a `current_data_source` pointer system. Instead of copying large scan lists into a bridge buffer, the Dispatcher simply points the bridge to the existing result array in memory: +```c +spi_bridge_provide_results(wifi_records, count, sizeof(wifi_ap_record_t)); +``` +The bridge then serves these items one by one when the P4 asks for them via the generic `SPI_ID_SYSTEM_DATA` command. + +## Key Files +- `spi_bridge.c`: Main task and generic data provider logic. +- `wifi_dispatcher.c`: Logic to translate SPI IDs to WiFi driver calls. +- `bt_dispatcher.c`: Logic to translate SPI IDs to NimBLE/BT calls. +- `spi_slave_driver.c`: Low-level peripheral configuration. +- `session_manager.c`: Session lifecycle for long-running operations + (heartbeat watchdog + backpressure). See "Session Lifecycle" below. + +## Command Categories +The `Category` header byte (`spi_cat_t`) selects the subsystem; the `Op` byte +selects the operation within it. Together they pack into `spi_id_t` via +`SPI_CMD(cat, op)`. +- `0x00`: System/Bridge management (ping, status, version, data, stream, log). +- `0x01`: WiFi operations. +- `0x02`: Bluetooth operations. +- `0x03`: LoRa operations. +- `0x04`: Meshtastic phone bridge. +- `0x05`: MeshCore phone bridge. +- `0x06`: Companion host-link BLE relay (routed to `bt_dispatcher`). +- `0xFF`: Session lifecycle (heartbeat, lost, stop). + +`SPI_ID_SYSTEM_LOG` (`0x0007`) is a C5→P4 stream that forwards this chip's log +lines to the companion's C5 console. + +## Session Lifecycle (Long-Running Operations) + +For full design and migration recipe, see the +[P4 README "Session Lifecycle" section](../../../../firmware_p4/components/Service/spi_bridge/README.md#session-lifecycle-long-running-operations). +The two sides share `spi_protocol.h` so the wire format is identical. + +### Slave responsibilities (this side) + +The `session_manager` runs a background watchdog that auto-kills sessions +when the master stops sending heartbeats (5s timeout). Each long-running +operation must: + +1. Call `session_manager_start(op_id, kill_cb)` from its dispatcher case + to obtain a `session_id`. The dispatcher returns this id to the master + inside an `spi_session_resp_t` response payload. +2. Provide a `kill_cb(spi_id_t)` that calls the op's `_stop()` - invoked + by the watchdog when the master goes quiet, and also when the master + sends `SPI_ID_SESSION_STOP`. +3. **Streaming ops only**: store the id in the op (e.g. via a + `_bind_session(uint32_t)` setter) and emit packets via + `session_manager_try_emit(s_session_id, data, len)` instead of raw + `spi_bridge_stream_push` - this prefixes meta and applies backpressure. + +For non-streaming ops (deauther, flood, evil_twin, beacon_spam, etc.), +the `kill_cb` lives in the dispatcher itself - the op's `.c` file does +not need to know about sessions at all. + +References: +- Streaming pattern: `wifi_sniffer.c`, `ble_sniffer.c`. +- Non-streaming pattern: see the `killed_*` static functions plus the + `open_session()` / `bt_open_session()` helpers in the dispatchers. diff --git a/docs/st7789/README.md b/docs/st7789/README.md new file mode 100644 index 000000000..3e3af18c3 --- /dev/null +++ b/docs/st7789/README.md @@ -0,0 +1,76 @@ +# ST7789 Display Driver + +This component initializes and manages the ST7789 LCD controller using the ESP-IDF `esp_lcd` component. It handles the SPI interface configuration and the display initialization sequence. + +## Overview + +- **Location:** `components/Drivers/st7789/` +- **Header:** `include/st7789.h` +- **Dependencies:** `esp_lcd`, `driver/gpio`, `driver/ledc`, `spi` + +## Hardware Configuration +- **Resolution:** 240x240 +- **Color Depth:** 16-bit (RGB565) +- **Interface:** SPI (via `spi` component driver) + +## Internal Backlight Control +Although a separate `backlight` component exists, this driver currently includes its own internal PWM initialization (`init_backlight_pwm`) and control logic using `LEDC_TIMER_0` / `LEDC_CHANNEL_0`. +*Note: This overlaps with the standalone `backlight` component. Verify project integration to avoid timer conflicts.* + +## API Reference + +### `st7789_init` +```c +void st7789_init(void); +``` +Initializes the display. +1. Creates the SPI device interface on `SPI3_HOST`. +2. Configures the ST7789 panel (Reset pin, RGB order, etc.). +3. Resets and initializes the panel. +4. Inverts colors (standard for many ST7789 IPS panels). +5. Turns the display ON. +6. Initializes the backlight PWM and **applies** (does not re-save) the saved + brightness/rotation. + +### `lcd_apply_brightness` +```c +void lcd_apply_brightness(uint8_t percent); +``` +Applies a backlight duty (0-100%) **without persisting**. Use for transient +changes such as the auto-dim fade in `power_policy`, and for the live preview +while the user drags the brightness bar. Uses LEDC Timer 0, Channel 0, 13-bit. + +### `lcd_set_brightness` +```c +void lcd_set_brightness(uint8_t percent); +``` +Applies the brightness **and persists** it. Kept for callers that want the old +apply+save behaviour in one call; the display settings screen instead saves +through `tos_config` (see below). + +### `lcd_get_brightness` +```c +uint8_t lcd_get_brightness(void); +``` +Reads the persisted brightness back from the config file. + +### `lcd_display_sleep` +```c +void lcd_display_sleep(bool sleep); +``` +Turns the panel off (`sleep = true`, sleep-in / display-off) or on. Pair with +the backlight: the panel command cuts the pixels, the backlight cuts the light. +`power_policy` calls this once the auto-dim fade reaches zero. + +## Config file ownership + +`st7789` and `tos_config` both address the same file (`FLASH_CONFIG_SCREEN`). +To avoid one clobbering the other's fields, **`tos_config` is the sole writer** +of the `screen` schema (brightness, rotation, theme, `auto_lock_seconds`, +`auto_dim`). `st7789_init` only **reads and applies** it; it never re-saves, so +the auto-lock / auto-dim / theme fields survive a display init. The display +settings screen writes via `tos_config_save(FLASH_CONFIG_SCREEN, "screen")`. + +## Global Handles +- `panel_handle`: Handle to the abstract LCD panel. +- `io_handle`: Handle to the underlying IO interface. diff --git a/docs/storage_api/README.md b/docs/storage_api/README.md new file mode 100644 index 000000000..4bfe5897d --- /dev/null +++ b/docs/storage_api/README.md @@ -0,0 +1,959 @@ +# P4 + +The **Storage API** provides a unified, backend-agnostic interface for file system operations in the Highboy project. It abstracts the underlying storage mechanism (LittleFS, SD Card, etc.), allowing developers to perform file and directory operations using a consistent set of functions without worrying about low-level details or mount points. + +## Features + +- **Unified Interface**: Same API for internal flash (LittleFS) and external SD cards. +- **Backend Abstraction**: Uses VFS layer underneath, works with any configured backend. +- **Automatic Path Resolution**: Automatically handles mount points - use relative paths. +- **Robustness**: Includes safety checks, recursive directory creation, and error handling. +- **High-Level Helpers**: Easy reading/writing of strings, lines, formatted text, and CSV data. + +--- + +## Architecture + +``` +Application Code + ↓ + Storage API ← You are here (recommended layer) + ↓ + VFS Core ← Backend abstraction + ↓ + SD Card / LittleFS / SPIFFS +``` + +**Dependencies:** +- Requires `vfs_core` to be initialized +- Backend selection is done in `vfs_config.h` + +--- + +## Initialization + +Before performing any operations, the storage system must be initialized. + +```c +#include "storage_init.h" + +// Initialize the storage system +// This calls vfs_init_auto() internally +esp_err_t ret = storage_init(); +if (ret != ESP_OK) { + // Handle error +} + +// Check if mounted +if (storage_is_mounted()) { + // Ready to use +} + +// Deinitialize when done (rarely needed for main application) +storage_deinit(); +``` + +### Default Directory Structure + +On first boot, `tos_first_boot_setup()` creates the full directory tree on the SD card: + +``` +/ +├── config/ - Modular .conf files (screen, wifi, ble, lora, system) +├── nfc/assets/ - NFC card data + protocol databases +├── rfid/assets/ - RFID key data + protocol databases +├── subghz/assets/ - Sub-GHz captures + frequency lists +├── ir/assets/ - IR remote files + universal remotes DB +├── wifi/ +│ ├── assets/ - OUI DB, wordlists +│ ├── loot/ - handshakes/, pcaps/, deauth_logs/ +│ └── captive_portal/templates/ +├── ble/ +│ ├── assets/ - Company ID DB +│ └── loot/ - Scan results +├── lora/ +│ ├── assets/ - Frequency plans +│ ├── loot/ - Device scans +│ └── messages/ - LoRa messages +├── badusb/assets/ - DuckyScript payloads + keyboard layouts +├── themes/ - Custom themes (*/theme.conf) +├── ringtones/ - Custom sounds +├── apps/ - External apps (.tap) +├── apps_data/ - App persistence +├── scripts/ - User scripts +├── logs/ - System logs +├── backup/ - Backups +├── cache/ - Temporary cache +└── update/ - Firmware update via SD +``` + +All paths are defined in `tos_storage_paths.h` and accessed via `TOS_PATH_*` macros: + +```c +#include "tos_storage_paths.h" + +// Macros automatically include VFS_MOUNT_POINT +storage_write_string(TOS_PATH_CONFIG_SCREEN, json_data); +storage_append_formatted(TOS_PATH_LOGS "/system.log", "[%lu] Event\n", timestamp); +storage_file_copy(TOS_PATH_WIFI_LOOT_HS "/capture.hccapx", TOS_PATH_BACKUP "/capture.hccapx"); +``` + +--- + +## File Operations + +Header: `storage_impl.h` + +### Basic Management + +| Function | Description | +|----------|-------------| +| `bool storage_file_exists(const char *path)` | Checks if a file exists. | +| `esp_err_t storage_file_delete(const char *path)` | Deletes a file. | +| `esp_err_t storage_file_rename(const char *old, const char *new)` | Renames or moves a file. | +| `esp_err_t storage_file_copy(const char *src, const char *dst)` | Copies a file. | +| `esp_err_t storage_file_move(const char *src, const char *dst)` | Moves a file (same as rename). | +| `esp_err_t storage_file_clear(const char *path)` | Clears file content (truncates to 0). | +| `esp_err_t storage_file_truncate(const char *path, size_t size)` | Truncates file to specified size. | +| `esp_err_t storage_file_compare(const char *p1, const char *p2, bool *equal)` | Compares two files for equality. | + +### Information + +```c +// File information structure +typedef struct { + char path[256]; // Full path to file + size_t size; // File size in bytes + time_t modified_time; // Last modification time (Unix timestamp) + time_t created_time; // Creation time (Unix timestamp) + bool is_directory; // True if this is a directory + bool is_hidden; // True if hidden file + bool is_readonly; // True if read-only +} storage_file_info_t; +``` + +| Function | Description | +|----------|-------------| +| `esp_err_t storage_file_get_size(const char *path, size_t *size)` | Gets file size in bytes. | +| `esp_err_t storage_file_is_empty(const char *path, bool *empty)` | Checks if a file is empty. | +| `esp_err_t storage_file_get_info(const char *path, storage_file_info_t *info)` | Gets detailed info (size, times, attributes). | +| `esp_err_t storage_file_get_extension(const char *path, char *ext, size_t size)` | Extracts file extension. | + +--- + +## Reading Data + +Header: `storage_read.h` + +The API provides various ways to read data depending on your needs. + +### Strings & Binary + +```c +// Read entire file into a string buffer (null-terminated) +char buffer[128]; +storage_read_string("/config/settings.txt", buffer, sizeof(buffer)); + +// Read binary data +uint8_t data[64]; +size_t bytes_read; +storage_read_binary("/data/image.bin", data, sizeof(data), &bytes_read); + +// Read chunk from specific offset +storage_read_chunk("/data/large.bin", 1024, data, sizeof(data), &bytes_read); +``` + +### Line-by-Line + +```c +// Read specific line (1-based index) +char line[64]; +storage_read_line("/logs/system.log", line, sizeof(line), 5); + +// Read first/last line helpers +storage_read_first_line("/logs/system.log", line, sizeof(line)); +storage_read_last_line("/logs/system.log", line, sizeof(line)); + +// Iterate over all lines using a callback +void my_line_callback(const char *line, void *user_data) { + printf("Read line: %s\n", line); +} +storage_read_lines("/data/list.txt", my_line_callback, NULL); + +// Count lines in file +uint32_t count; +storage_count_lines("/data/list.txt", &count); +``` + +### Typed Data + +```c +int32_t count; +storage_read_int("/config/boot_count", &count); + +float temperature; +storage_read_float("/config/temp_threshold", &temperature); + +uint8_t byte; +storage_read_byte("/data/flag", &byte); + +uint8_t bytes[16]; +size_t num_bytes; +storage_read_bytes("/data/raw", bytes, sizeof(bytes), &num_bytes); +``` + +### Search Operations + +```c +// Check if file contains a string +bool found; +storage_file_contains("/logs/events.log", "ERROR", &found); + +// Count occurrences of a string +uint32_t count; +storage_count_occurrences("/logs/events.log", "WARNING", &count); +``` + +--- + +## Writing Data + +Header: `storage_write.h` + +All write functions automatically create parent directories if they don't exist (recursive mkdir). + +### Strings & Binary + +```c +// Write (overwrite) a string to a file +storage_write_string("/data/status.txt", "System Ready"); + +// Append to a file +storage_append_string("/logs/app.log", "Event occurred"); + +// Write binary data +uint8_t raw_data[] = {0x01, 0x02, 0x03}; +storage_write_binary("/data/blob.bin", raw_data, sizeof(raw_data)); + +// Append binary data +storage_append_binary("/data/stream.bin", raw_data, sizeof(raw_data)); +``` + +### Line-Based Writing + +```c +// Write single line with newline +storage_write_line("/data/entry.txt", "First entry"); + +// Append line with newline +storage_append_line("/logs/events.log", "Event occurred at 12:00"); +``` + +### Formatted Output + +Similar to `printf`, useful for logs or human-readable data. + +```c +storage_write_formatted("/logs/info.txt", "Boot count: %d\nTime: %u", count, timestamp); +storage_append_formatted("/logs/events.log", "[INFO] Sensor %s: %.2f\n", sensor_name, value); +``` + +### Typed Data + +```c +// Write integer +storage_write_int("/config/counter", 42); + +// Write float +storage_write_float("/config/threshold", 3.14159); + +// Write single byte +storage_write_byte("/data/flag", 0xFF); + +// Write byte array +uint8_t data[] = {0xDE, 0xAD, 0xBE, 0xEF}; +storage_write_bytes("/data/magic", data, sizeof(data)); +``` + +### CSV Support + +Helper for writing structured data. + +```c +const char *header[] = {"Timestamp", "Value", "Unit"}; +storage_write_csv_row("/data/sensors.csv", header, 3); +// Writes: Timestamp,Value,Unit\n + +const char *row[] = {"1234567890", "23.5", "°C"}; +storage_append_csv_row("/data/sensors.csv", row, 3); +// Appends: 1234567890,23.5,°C\n +``` + +--- + +## Stream I/O + +Header: `storage_stream.h` + +For high-throughput scenarios where the file must stay open across multiple writes (e.g., SPI bridge callbacks, packet capture, continuous logging). + +```c +#include "storage_stream.h" + +// Open a stream (file stays open until explicitly closed) +storage_stream_t stream = storage_stream_open(TOS_PATH_WIFI_LOOT_PCAPS "/capture.pcap", "wb"); + +// Write chunks as they arrive (e.g., inside a SPI stream callback) +storage_stream_write(stream, packet_data, packet_len); + +// Periodic flush to prevent data loss on crash +storage_stream_flush(stream); + +// Check state +if (storage_stream_is_open(stream)) { + size_t total = storage_stream_bytes_written(stream); +} + +// Read mode works too +storage_stream_t reader = storage_stream_open(TOS_PATH_LOGS "/system.log", "r"); +char buf[256]; +size_t read; +storage_stream_read(reader, buf, sizeof(buf), &read); +storage_stream_close(reader); + +// Close and free resources +storage_stream_close(stream); +``` + +| Function | Description | +|----------|-------------| +| `storage_stream_open(path, mode)` | Opens file, returns opaque handle | +| `storage_stream_write(stream, data, size)` | Writes chunk without closing | +| `storage_stream_read(stream, buf, size, *read)` | Reads chunk without closing | +| `storage_stream_flush(stream)` | Forces write to SD | +| `storage_stream_close(stream)` | Closes file and frees handle | +| `storage_stream_is_open(stream)` | Checks if handle is valid | +| `storage_stream_bytes_written(stream)` | Total bytes written in session | + +--- + +## Directory Operations + +Header: `storage_impl.h` + +| Function | Description | +|----------|-------------| +| `esp_err_t storage_dir_create(const char *path)` | Creates a directory. | +| `esp_err_t storage_dir_remove(const char *path)` | Removes an empty directory. | +| `esp_err_t storage_dir_remove_recursive(const char *path)` | Removes a directory and all contents. | +| `bool storage_dir_exists(const char *path)` | Checks if directory exists. | +| `esp_err_t storage_dir_is_empty(const char *path, bool *empty)` | Checks if directory is empty. | +| `esp_err_t storage_dir_list(const char *path, storage_dir_callback_t cb, void *user_data)` | Lists directory contents via callback. | +| `esp_err_t storage_dir_count(const char *path, uint32_t *file_count, uint32_t *dir_count)` | Counts files and subdirectories. | + +**Note**: `storage_dir_copy_recursive()` and `storage_dir_get_size()` return `ESP_ERR_NOT_SUPPORTED` (not yet implemented). + +### Directory Listing Example + +```c +void list_callback(const char *name, bool is_dir, void *user_data) { + printf("%s %s\n", is_dir ? "[DIR]" : "[FILE]", name); +} + +storage_dir_list("/data", list_callback, NULL); +``` + +--- + +## Storage Information + +Header: `storage_impl.h` + +Monitor storage usage and health. + +```c +// Print detailed usage report to log +storage_print_info_detailed(); + +// Get complete storage information +storage_info_t info; +storage_get_info(&info); +printf("Backend: %s\n", info.backend_name); +printf("Mount: %s\n", info.mount_point); +printf("Total: %llu bytes\n", info.total_bytes); + +// Get individual values +uint64_t total, free, used; +storage_get_total_space(&total); +storage_get_free_space(&free); +storage_get_used_space(&used); + +// Get usage percentage +float percent; +storage_get_usage_percent(&percent); + +// Get backend information +const char *backend = storage_get_backend_type(); +const char *mount = storage_get_mount_point_str(); +``` + +--- + +## Helper Functions + +Header: `storage_mkdir.h` + +```c +// Create directory path recursively (used internally by write functions) +esp_err_t storage_mkdir_recursive(const char *path); +``` + +This function creates all parent directories as needed. It's automatically called by write operations, but can be used directly when needed. + +--- + +## Example Usage + +```c +#include "storage_init.h" +#include "storage_impl.h" +#include "storage_read.h" +#include "storage_write.h" +#include "storage_stream.h" +#include "tos_storage_paths.h" + +void app_main() { + if (storage_init() != ESP_OK) { + printf("Storage init failed!\n"); + return; + } + + // Read config + char config[1024]; + storage_read_string(TOS_PATH_CONFIG_SCREEN, config, sizeof(config)); + + // Log startup + storage_append_formatted(TOS_PATH_LOGS "/boot.log", + "System started at %lu\n", xTaskGetTickCount()); + + // Stream write (for high-throughput capture) + storage_stream_t stream = storage_stream_open(TOS_PATH_WIFI_LOOT_PCAPS "/capture.pcap", "wb"); + storage_stream_write(stream, some_data, data_len); + storage_stream_close(stream); + + // Check storage health + float usage; + storage_get_usage_percent(&usage); + printf("Storage usage: %.1f%%\n", usage); +} +``` + +--- + +## Best Practices + +1. **Use `TOS_PATH_*` macros** - Never hardcode `"/sdcard/"` or mount points +2. **Check return values** - All functions return `esp_err_t` for error handling +3. **Use stream for high-throughput** - SPI callbacks, packet capture, continuous logging +4. **Monitor storage** - Use `storage_get_usage_percent()` to prevent full disk +5. **Use appropriate read functions** - Line-by-line for logs, binary for images +6. **Automatic directory creation** - Write functions create parent directories automatically +7. **Close streams** - Always call `storage_stream_close()` to prevent FAT32 corruption + +--- + +## Error Handling + +All functions return `esp_err_t` values. Common return codes: + +- `ESP_OK` - Operation successful +- `ESP_ERR_INVALID_ARG` - Invalid argument (NULL pointer, invalid size) +- `ESP_ERR_INVALID_STATE` - Storage not mounted +- `ESP_FAIL` - General failure (file not found, I/O error, etc.) +- `ESP_ERR_NOT_FOUND` - Item not found (used by some search functions) +- `ESP_ERR_NOT_SUPPORTED` - Feature not implemented + +Always check return values: + +```c +esp_err_t ret = storage_write_string("/config/test.txt", "data"); +if (ret != ESP_OK) { + ESP_LOGE(TAG, "Write failed: %s", esp_err_to_name(ret)); +} +``` + +## Factory reset (`tos_factory_reset.h`) + +System-wide reset used by safe mode (see [recovery](../recovery/README.md)). +Both functions delete only **file contents** and leave the directory skeleton +intact, because `write_string_to_file` does not create parent directories: a +config save right after a reset must still find its folder. + +```c +esp_err_t tos_factory_reset_config(void); // config only +esp_err_t tos_factory_reset_all(void); // config + user data + NVS +``` + +- **`tos_factory_reset_config`** - deletes every file under the config + directories on both storages (`/assets/config` and `/sdcard/config`) and + removes the first-boot marker so `tos_first_boot_setup` re-seeds defaults on + the next boot. NVS, captures/loot and shipped assets are untouched. +- **`tos_factory_reset_all`** - runs the config reset, then deletes user + captures/loot/themes/scripts on both storages and erases NVS + (`nvs_flash_erase`). It **never** formats the `assets` partition (shipped + icons/html/fonts survive) and **never** touches the on-SD C5 firmware image + (`/sdcard/c5`), so the device and its C5-recovery path stay bootable. + +Callers are expected to reboot afterwards. + +--- + +# C5 + +The **Storage API** provides a unified, backend-agnostic interface for file system operations in the Highboy project. It abstracts the underlying storage mechanism (LittleFS, SD Card, etc.), allowing developers to perform file and directory operations using a consistent set of functions without worrying about low-level details or mount points. + +## Features + +- **Unified Interface**: Same API for internal flash (LittleFS) and external SD cards. +- **Backend Abstraction**: Uses VFS layer underneath, works with any configured backend. +- **Automatic Path Resolution**: Automatically handles mount points - use relative paths. +- **Robustness**: Includes safety checks, recursive directory creation, and error handling. +- **High-Level Helpers**: Easy reading/writing of strings, lines, formatted text, and CSV data. + +--- + +## Architecture + +``` +Application Code + ↓ + Storage API ← You are here (recommended layer) + ↓ + VFS Core ← Backend abstraction + ↓ + SD Card / LittleFS / SPIFFS +``` + +**Dependencies:** +- Requires `vfs_core` to be initialized +- Backend selection is done in `vfs_config.h` + +--- + +## Initialization + +Before performing any operations, the storage system must be initialized. + +```c +#include "storage_init.h" + +// Initialize the storage system +// This calls vfs_init_auto() internally +esp_err_t ret = storage_init(); +if (ret != ESP_OK) { + // Handle error +} + +// Check if mounted +if (storage_is_mounted()) { + // Ready to use +} + +// Deinitialize when done (rarely needed for main application) +storage_deinit(); +``` + +### Default Directory Structure + +The storage system automatically creates a standard directory tree on initialization: + +``` +/ (e.g., /sdcard or /littlefs) +├── config/ - Configuration files +├── data/ - Application data +├── logs/ - Log files +├── cache/ - Temporary cache +├── temp/ - Temporary files +├── backup/ - Backup files +├── certs/ - SSL/TLS certificates +├── scripts/ - Script files +└── captive_portal/ - Captive portal files +``` + +These directories are defined in `storage_dirs.h` and can be accessed via macros: + +```c +#include "storage_dirs.h" + +// Macros automatically include the mount point +// Example: STORAGE_DIR_CONFIG expands to "/sdcard/config" or "/littlefs/config" + +// Write to config directory +storage_write_string(STORAGE_DIR_CONFIG "/settings.json", json_data); + +// Append to logs +storage_append_formatted(STORAGE_DIR_LOGS "/system.log", "[%lu] Event\n", timestamp); + +// Save backup +storage_file_copy(STORAGE_DIR_DATA "/important.dat", STORAGE_DIR_BACKUP "/important.dat"); +``` + +**Path Handling:** +- All Storage API functions accept **relative paths** (e.g., `/config/file.txt`) +- Mount point is automatically prepended internally +- You can use either `"/config/file.txt"` or `STORAGE_DIR_CONFIG "/file.txt"` +- Paths starting with `/` are treated as relative to mount point +- Paths already containing the mount point are used as-is + +**Note**: Directory creation is non-critical. If any directory fails to create, initialization continues successfully, and you can create directories manually later as needed. + +--- + +## File Operations + +Header: `storage_impl.h` + +### Basic Management + +| Function | Description | +|----------|-------------| +| `bool storage_file_exists(const char *path)` | Checks if a file exists. | +| `esp_err_t storage_file_delete(const char *path)` | Deletes a file. | +| `esp_err_t storage_file_rename(const char *old, const char *new)` | Renames or moves a file. | +| `esp_err_t storage_file_copy(const char *src, const char *dst)` | Copies a file. | +| `esp_err_t storage_file_move(const char *src, const char *dst)` | Moves a file (same as rename). | +| `esp_err_t storage_file_clear(const char *path)` | Clears file content (truncates to 0). | +| `esp_err_t storage_file_truncate(const char *path, size_t size)` | Truncates file to specified size. | +| `esp_err_t storage_file_compare(const char *p1, const char *p2, bool *equal)` | Compares two files for equality. | + +### Information + +```c +// File information structure +typedef struct { + char path[256]; // Full path to file + size_t size; // File size in bytes + time_t modified_time; // Last modification time (Unix timestamp) + time_t created_time; // Creation time (Unix timestamp) + bool is_directory; // True if this is a directory + bool is_hidden; // True if hidden file + bool is_readonly; // True if read-only +} storage_file_info_t; +``` + +| Function | Description | +|----------|-------------| +| `esp_err_t storage_file_get_size(const char *path, size_t *size)` | Gets file size in bytes. | +| `esp_err_t storage_file_is_empty(const char *path, bool *empty)` | Checks if a file is empty. | +| `esp_err_t storage_file_get_info(const char *path, storage_file_info_t *info)` | Gets detailed info (size, times, attributes). | +| `esp_err_t storage_file_get_extension(const char *path, char *ext, size_t size)` | Extracts file extension. | + +--- + +## Reading Data + +Header: `storage_read.h` + +The API provides various ways to read data depending on your needs. + +### Strings & Binary + +```c +// Read entire file into a string buffer (null-terminated) +char buffer[128]; +storage_read_string("/config/settings.txt", buffer, sizeof(buffer)); + +// Read binary data +uint8_t data[64]; +size_t bytes_read; +storage_read_binary("/data/image.bin", data, sizeof(data), &bytes_read); + +// Read chunk from specific offset +storage_read_chunk("/data/large.bin", 1024, data, sizeof(data), &bytes_read); +``` + +### Line-by-Line + +```c +// Read specific line (1-based index) +char line[64]; +storage_read_line("/logs/system.log", line, sizeof(line), 5); + +// Read first/last line helpers +storage_read_first_line("/logs/system.log", line, sizeof(line)); +storage_read_last_line("/logs/system.log", line, sizeof(line)); + +// Iterate over all lines using a callback +void my_line_callback(const char *line, void *user_data) { + printf("Read line: %s\n", line); +} +storage_read_lines("/data/list.txt", my_line_callback, NULL); + +// Count lines in file +uint32_t count; +storage_count_lines("/data/list.txt", &count); +``` + +### Typed Data + +```c +int32_t count; +storage_read_int("/config/boot_count", &count); + +float temperature; +storage_read_float("/config/temp_threshold", &temperature); + +uint8_t byte; +storage_read_byte("/data/flag", &byte); + +uint8_t bytes[16]; +size_t num_bytes; +storage_read_bytes("/data/raw", bytes, sizeof(bytes), &num_bytes); +``` + +### Search Operations + +```c +// Check if file contains a string +bool found; +storage_file_contains("/logs/events.log", "ERROR", &found); + +// Count occurrences of a string +uint32_t count; +storage_count_occurrences("/logs/events.log", "WARNING", &count); +``` + +--- + +## Writing Data + +Header: `storage_write.h` + +All write functions automatically create parent directories if they don't exist (recursive mkdir). + +### Strings & Binary + +```c +// Write (overwrite) a string to a file +storage_write_string("/data/status.txt", "System Ready"); + +// Append to a file +storage_append_string("/logs/app.log", "Event occurred"); + +// Write binary data +uint8_t raw_data[] = {0x01, 0x02, 0x03}; +storage_write_binary("/data/blob.bin", raw_data, sizeof(raw_data)); + +// Append binary data +storage_append_binary("/data/stream.bin", raw_data, sizeof(raw_data)); +``` + +### Line-Based Writing + +```c +// Write single line with newline +storage_write_line("/data/entry.txt", "First entry"); + +// Append line with newline +storage_append_line("/logs/events.log", "Event occurred at 12:00"); +``` + +### Formatted Output + +Similar to `printf`, useful for logs or human-readable data. + +```c +storage_write_formatted("/logs/info.txt", "Boot count: %d\nTime: %u", count, timestamp); +storage_append_formatted("/logs/events.log", "[INFO] Sensor %s: %.2f\n", sensor_name, value); +``` + +### Typed Data + +```c +// Write integer +storage_write_int("/config/counter", 42); + +// Write float +storage_write_float("/config/threshold", 3.14159); + +// Write single byte +storage_write_byte("/data/flag", 0xFF); + +// Write byte array +uint8_t data[] = {0xDE, 0xAD, 0xBE, 0xEF}; +storage_write_bytes("/data/magic", data, sizeof(data)); +``` + +### CSV Support + +Helper for writing structured data. + +```c +const char *header[] = {"Timestamp", "Value", "Unit"}; +storage_write_csv_row("/data/sensors.csv", header, 3); +// Writes: Timestamp,Value,Unit\n + +const char *row[] = {"1234567890", "23.5", "°C"}; +storage_append_csv_row("/data/sensors.csv", row, 3); +// Appends: 1234567890,23.5,°C\n +``` + +--- + +## Directory Operations + +Header: `storage_impl.h` + +| Function | Description | +|----------|-------------| +| `esp_err_t storage_dir_create(const char *path)` | Creates a directory. | +| `esp_err_t storage_dir_remove(const char *path)` | Removes an empty directory. | +| `esp_err_t storage_dir_remove_recursive(const char *path)` | Removes a directory and all contents. | +| `bool storage_dir_exists(const char *path)` | Checks if directory exists. | +| `esp_err_t storage_dir_is_empty(const char *path, bool *empty)` | Checks if directory is empty. | +| `esp_err_t storage_dir_list(const char *path, storage_dir_callback_t cb, void *user_data)` | Lists directory contents via callback. | +| `esp_err_t storage_dir_count(const char *path, uint32_t *file_count, uint32_t *dir_count)` | Counts files and subdirectories. | + +**Note**: `storage_dir_copy_recursive()` and `storage_dir_get_size()` return `ESP_ERR_NOT_SUPPORTED` (not yet implemented). + +### Directory Listing Example + +```c +void list_callback(const char *name, bool is_dir, void *user_data) { + printf("%s %s\n", is_dir ? "[DIR]" : "[FILE]", name); +} + +storage_dir_list("/data", list_callback, NULL); +``` + +--- + +## Storage Information + +Header: `storage_impl.h` + +Monitor storage usage and health. + +```c +// Print detailed usage report to log +storage_print_info_detailed(); + +// Get complete storage information +storage_info_t info; +storage_get_info(&info); +printf("Backend: %s\n", info.backend_name); +printf("Mount: %s\n", info.mount_point); +printf("Total: %llu bytes\n", info.total_bytes); + +// Get individual values +uint64_t total, free, used; +storage_get_total_space(&total); +storage_get_free_space(&free); +storage_get_used_space(&used); + +// Get usage percentage +float percent; +storage_get_usage_percent(&percent); + +// Get backend information +const char *backend = storage_get_backend_type(); +const char *mount = storage_get_mount_point_str(); +``` + +--- + +## Helper Functions + +Header: `storage_mkdir.h` + +```c +// Create directory path recursively (used internally by write functions) +esp_err_t storage_mkdir_recursive(const char *path); +``` + +This function creates all parent directories as needed. It's automatically called by write operations, but can be used directly when needed. + +--- + +## Example Usage + +```c +#include "storage_init.h" +#include "storage_impl.h" +#include "storage_read.h" +#include "storage_write.h" +#include "storage_dirs.h" + +void app_main() { + // Initialize storage (calls vfs_init_auto internally) + if (storage_init() != ESP_OK) { + printf("Storage init failed!\n"); + return; + } + + // Check for config file + if (storage_file_exists(STORAGE_DIR_CONFIG "/settings.json")) { + char config[1024]; + storage_read_string(STORAGE_DIR_CONFIG "/settings.json", config, sizeof(config)); + // Process config... + } else { + // Create default config + storage_write_string(STORAGE_DIR_CONFIG "/settings.json", "{ \"defaults\": true }"); + } + + // Log startup event with timestamp + storage_append_formatted(STORAGE_DIR_LOGS "/boot.log", + "System started at %lu\n", xTaskGetTickCount()); + + // Write sensor data to CSV + const char *header[] = {"Time", "Temp", "Humidity"}; + storage_write_csv_row(STORAGE_DIR_DATA "/sensors.csv", header, 3); + + const char *data[] = {"12:00", "23.5", "65"}; + storage_append_csv_row(STORAGE_DIR_DATA "/sensors.csv", data, 3); + + // Check storage health + float usage; + storage_get_usage_percent(&usage); + printf("Storage usage: %.1f%%\n", usage); + + // List directory contents + uint32_t files, dirs; + storage_dir_count(STORAGE_DIR_DATA, &files, &dirs); + printf("Data directory: %lu files, %lu subdirectories\n", files, dirs); +} +``` + +--- + +## Best Practices + +1. **Always use relative paths** - Let the API handle mount points +2. **Use directory macros** - `STORAGE_DIR_CONFIG` instead of hardcoded `"/config"` +3. **Check return values** - All functions return `esp_err_t` for error handling +4. **Monitor storage** - Use `storage_get_usage_percent()` to prevent full disk +5. **Use appropriate read functions** - Line-by-line for logs, binary for images +6. **Automatic directory creation** - Write functions create parent directories automatically +7. **Path flexibility** - Relative paths (`/config/file.txt`) or full mount paths both work + +--- + +## Error Handling + +All functions return `esp_err_t` values. Common return codes: + +- `ESP_OK` - Operation successful +- `ESP_ERR_INVALID_ARG` - Invalid argument (NULL pointer, invalid size) +- `ESP_ERR_INVALID_STATE` - Storage not mounted +- `ESP_FAIL` - General failure (file not found, I/O error, etc.) +- `ESP_ERR_NOT_FOUND` - Item not found (used by some search functions) +- `ESP_ERR_NOT_SUPPORTED` - Feature not implemented + +Always check return values: + +```c +esp_err_t ret = storage_write_string("/config/test.txt", "data"); +if (ret != ESP_OK) { + ESP_LOGE(TAG, "Write failed: %s", esp_err_to_name(ret)); +} +``` \ No newline at end of file diff --git a/docs/storage_assets/README.md b/docs/storage_assets/README.md new file mode 100644 index 000000000..0d84294fc --- /dev/null +++ b/docs/storage_assets/README.md @@ -0,0 +1,1244 @@ +# P4 + +This component provides read-only access to a dedicated LittleFS partition for storing static application assets like images, fonts, configuration files, and other resources that are flashed with the firmware. + +## Overview + +- **Location:** `components/Service/storage_assets/` +- **Main Header:** `include/storage_assets.h` +- **Implementation:** `storage_assets.c` +- **Dependencies:** `esp_littlefs`, `esp_vfs` +- **Partition:** `assets` (LittleFS, read-only in production) + +## Key Features + +- **Dedicated Partition:** Separate from application code and main storage. +- **LittleFS Backend:** Efficient wear-leveling filesystem optimized for flash. +- **Read-Only Access:** Assets are flashed once and cannot be modified at runtime. +- **Auto-Discovery:** Automatically lists all files in partition on initialization. +- **Memory Management:** Helper function to load entire files with automatic allocation. +- **Directory Traversal:** Recursive directory listing for debugging. + +## Typical Use Cases + +- **Graphical Assets:** Logos, icons, sprites, bitmaps for displays. +- **Fonts:** Pre-compiled font files for text rendering. +- **Configuration Templates:** Default configuration files. +- **Audio Samples:** Short sound effects or melodies. +- **IR/RF Databases:** Preloaded signal databases. +- **Firmware Resources:** Any read-only data needed by the application. + +## Configuration + +### Partition Table + +The assets partition must be defined in your partition table (`partitions.csv`): + +```csv +# Name, Type, SubType, Offset, Size, Flags +nvs, data, nvs, 0x9000, 0x6000, +phy_init, data, phy, 0xf000, 0x1000, +factory, app, factory, 0x10000, 1M, +assets, data, spiffs, 0x110000, 512K, +storage, data, spiffs, 0x190000, 1M, +``` + +**Important Notes:** +- The SubType must be `spiffs` (even though we use LittleFS - this is an ESP-IDF quirk). +- Size should be sufficient for all your assets (adjust as needed). +- The partition must be flashed before use. + +### Constants + +```c +#define ASSETS_MOUNT_POINT "/assets" +#define ASSETS_PARTITION_LABEL "assets" +``` + +These are defined internally and cannot be changed without modifying the source. + +--- + +## API Reference + +### Initialization + +#### `storage_assets_init` + +```c +esp_err_t storage_assets_init(void); +``` + +Initializes and mounts the assets partition. Must be called before any other asset operations. + +**Behavior:** +- Mounts the LittleFS partition at `/assets`. +- Formats the partition if mounting fails (useful for first flash). +- Lists all files in the partition for debugging. +- Displays partition size and usage statistics. + +**Returns:** +- `ESP_OK` - Assets partition mounted successfully. +- `ESP_ERR_NOT_FOUND` - Partition 'assets' not found in partition table. +- `ESP_FAIL` - Mount or format failed. +- `ESP_ERR_INVALID_STATE` - Already initialized. + +**Example:** +```c +void app_main(void) { + esp_err_t ret = storage_assets_init(); + if (ret == ESP_OK) { + printf("Assets ready!\n"); + } else if (ret == ESP_ERR_NOT_FOUND) { + printf("ERROR: 'assets' partition not found!\n"); + printf("Check your partition table.\n"); + } else { + printf("Assets init failed: %s\n", esp_err_to_name(ret)); + } +} +``` + +**Console Output Example:** +``` +I (1234) storage_assets: Initializing LittleFS for assets partition +I (1245) storage_assets: Assets ready at /assets +I (1246) storage_assets: Partition size: 524288 bytes, used: 12345 bytes +I (1247) storage_assets: === Files in assets partition === +I (1248) storage_assets: [1] logo.bin (1200 bytes) +I (1249) storage_assets: [DIR] fonts/ +I (1250) storage_assets: [2] arial.ttf (45000 bytes) +I (1251) storage_assets: [3] config_template.json (567 bytes) +I (1252) storage_assets: Total: 3 file(s), 1 dir(s) +I (1253) storage_assets: ================================ +``` + +--- + +#### `storage_assets_deinit` + +```c +esp_err_t storage_assets_deinit(void); +``` + +Unmounts the assets partition and releases resources. + +**Returns:** +- `ESP_OK` - Unmounted successfully. +- `ESP_ERR_INVALID_STATE` - Not initialized. + +**Example:** +```c +// Before system shutdown +storage_assets_deinit(); +``` + +--- + +#### `storage_assets_is_mounted` + +```c +bool storage_assets_is_mounted(void); +``` + +Checks if the assets partition is currently mounted. + +**Returns:** +- `true` - Partition is mounted and ready. +- `false` - Partition is not mounted. + +**Example:** +```c +if (!storage_assets_is_mounted()) { + storage_assets_init(); +} +``` + +--- + +### File Access + +#### `storage_assets_get_file_size` + +```c +esp_err_t storage_assets_get_file_size(const char *filename, size_t *out_size); +``` + +Gets the size of a file in the assets partition without reading it. + +**Parameters:** +- `filename` - Name of the file (e.g., "logo.bin", "fonts/arial.ttf"). +- `out_size` - Pointer to store file size in bytes. + +**Returns:** +- `ESP_OK` - Size retrieved successfully. +- `ESP_ERR_INVALID_STATE` - Assets not initialized. +- `ESP_ERR_INVALID_ARG` - NULL parameters. +- `ESP_ERR_NOT_FOUND` - File doesn't exist. + +**Example:** +```c +size_t logo_size; +if (storage_assets_get_file_size("logo.bin", &logo_size) == ESP_OK) { + printf("Logo is %zu bytes\n", logo_size); + + // Allocate buffer of exact size + uint8_t *buffer = malloc(logo_size); +} +``` + +--- + +#### `storage_assets_read_file` + +```c +esp_err_t storage_assets_read_file(const char *filename, uint8_t *buffer, size_t size, size_t *out_read); +``` + +Reads file content into a pre-allocated buffer. + +**Parameters:** +- `filename` - Name of the file. +- `buffer` - Pre-allocated buffer to receive data. +- `size` - Maximum bytes to read (buffer size). +- `out_read` - Pointer to store actual bytes read (can be NULL). + +**Returns:** +- `ESP_OK` - File read successfully. +- `ESP_ERR_INVALID_STATE` - Assets not initialized. +- `ESP_ERR_INVALID_ARG` - Invalid parameters. +- `ESP_ERR_NOT_FOUND` - File doesn't exist. + +**Example:** +```c +uint8_t buffer[2048]; +size_t bytes_read; + +esp_err_t ret = storage_assets_read_file("config.json", buffer, sizeof(buffer), &bytes_read); +if (ret == ESP_OK) { + buffer[bytes_read] = '\0'; // Null-terminate if text + printf("Config: %s\n", (char *)buffer); +} else { + printf("Failed to read config: %s\n", esp_err_to_name(ret)); +} +``` + +--- + +#### `storage_assets_load_file` + +```c +uint8_t* storage_assets_load_file(const char *filename, size_t *out_size); +``` + +Loads an entire file into dynamically allocated memory. **Caller must free() the returned pointer.** + +**Parameters:** +- `filename` - Name of the file. +- `out_size` - Pointer to store file size (can be NULL). + +**Returns:** +- Pointer to allocated buffer containing file data. +- `NULL` on error (allocation failure, file not found, etc.). + +**Example:** +```c +size_t image_size; +uint8_t *image_data = storage_assets_load_file("splash_screen.bin", &image_size); + +if (image_data != NULL) { + // Use the image data + display_draw_bitmap(image_data, image_size); + + // IMPORTANT: Free when done! + free(image_data); +} else { + printf("Failed to load splash screen\n"); +} +``` + +**Memory Warning:** This function allocates heap memory. Ensure sufficient heap is available before loading large files. + +--- + +### Utility Functions + +#### `storage_assets_get_mount_point` + +```c +const char* storage_assets_get_mount_point(void); +``` + +Returns the mount point path for the assets partition. + +**Returns:** +- Constant string "/assets". + +**Example:** +```c +const char *mount = storage_assets_get_mount_point(); + +// Construct full path +char full_path[128]; +snprintf(full_path, sizeof(full_path), "%s/%s", mount, "config.json"); + +// Use with standard file operations +FILE *f = fopen(full_path, "r"); +``` + +--- + +#### `storage_assets_print_info` + +```c +void storage_assets_print_info(void); +``` + +Prints detailed information about the assets partition to the console. + +**Parameters:** None + +**Returns:** Nothing (void) + +**Example Output:** +``` +I (1234) storage_assets: === Assets Partition Info === +I (1235) storage_assets: Mount point: /assets +I (1236) storage_assets: Partition: assets +I (1237) storage_assets: Total size: 524288 bytes (512.00 KB) +I (1238) storage_assets: Used: 98765 bytes (96.45 KB) +I (1239) storage_assets: Free: 425523 bytes (415.55 KB) +I (1240) storage_assets: Usage: 18.8% +``` + +**Usage:** +```c +// During debugging or diagnostics +storage_assets_print_info(); +``` + +--- + +## Implementation Details + +### Directory Listing + +The component includes a recursive directory listing function that runs automatically during initialization: + +```c +static void list_directory_recursive(const char *path, const char *prefix, + int *file_count, int *dir_count); +``` + +This helps during development to verify that assets were flashed correctly. + +### Path Handling + +All file operations internally prepend the mount point: + +```c +// User provides: "logo.bin" +// Internally becomes: "/assets/logo.bin" +``` + +Subdirectories are supported: +```c +// User provides: "fonts/arial.ttf" +// Internally becomes: "/assets/fonts/arial.ttf" +``` + +### Error Handling + +All functions validate: +- Initialization state +- Parameter validity +- File existence +- Memory allocation success + +Always check return values to ensure robust operation. + +--- + +## Usage Patterns + +### Loading a Bitmap for Display + +```c +void display_splash_screen(void) { + size_t image_size; + uint8_t *image = storage_assets_load_file("splash.bin", &image_size); + + if (image == NULL) { + ESP_LOGE(TAG, "Failed to load splash screen"); + return; + } + + // Expected format: 128x64 monochrome bitmap + if (image_size != (128 * 64) / 8) { + ESP_LOGW(TAG, "Unexpected image size: %zu", image_size); + } + + // Send to display + oled_draw_bitmap(0, 0, image, 128, 64); + + // Clean up + free(image); +} +``` + +--- + +### Loading Configuration Template + +```c +cJSON* load_default_config(void) { + uint8_t *json_data = storage_assets_load_file("config_template.json", NULL); + if (json_data == NULL) { + return NULL; + } + + cJSON *config = cJSON_Parse((const char *)json_data); + free(json_data); + + return config; +} +``` + +--- + +### Preloading Assets at Boot + +```c +typedef struct { + uint8_t *logo_data; + size_t logo_size; + uint8_t *font_data; + size_t font_size; +} app_assets_t; + +app_assets_t g_assets = {0}; + +esp_err_t preload_assets(void) { + // Load logo + g_assets.logo_data = storage_assets_load_file("logo.bin", &g_assets.logo_size); + if (g_assets.logo_data == NULL) { + return ESP_FAIL; + } + + // Load font + g_assets.font_data = storage_assets_load_file("font.bin", &g_assets.font_size); + if (g_assets.font_data == NULL) { + free(g_assets.logo_data); + return ESP_FAIL; + } + + ESP_LOGI(TAG, "Assets preloaded (%zu + %zu bytes)", + g_assets.logo_size, g_assets.font_size); + + return ESP_OK; +} + +void cleanup_assets(void) { + free(g_assets.logo_data); + free(g_assets.font_data); + memset(&g_assets, 0, sizeof(g_assets)); +} +``` + +--- + +### Chunked Reading for Large Files + +```c +esp_err_t process_large_asset(const char *filename) { + FILE *f = fopen("/assets/large_file.dat", "rb"); + if (!f) { + return ESP_FAIL; + } + + uint8_t chunk[512]; + size_t bytes_read; + + while ((bytes_read = fread(chunk, 1, sizeof(chunk), f)) > 0) { + // Process chunk + process_data(chunk, bytes_read); + } + + fclose(f); + return ESP_OK; +} +``` + +--- + +### Conditional Asset Loading + +```c +void load_language_assets(const char *language) { + char filename[64]; + snprintf(filename, sizeof(filename), "strings_%s.json", language); + + uint8_t *strings = storage_assets_load_file(filename, NULL); + if (strings == NULL) { + ESP_LOGW(TAG, "Language '%s' not found, using default", language); + strings = storage_assets_load_file("strings_en.json", NULL); + } + + if (strings != NULL) { + parse_language_strings((const char *)strings); + free(strings); + } +} +``` + +--- + +## Flashing Assets + +### Option 1: Automatic (Recommended) + +Add to your `CMakeLists.txt`: + +```cmake +# Create assets partition image from 'assets' folder +littlefs_create_partition_image(assets assets FLASH_IN_PROJECT) +``` + +This automatically flashes the `assets/` folder content when running `idf.py flash`. + +### Option 2: Manual Flash + +```bash +# Build the assets partition image +idf.py build + +# Flash everything including assets +idf.py flash + +# Or flash only assets partition +esptool.py write_flash 0x110000 build/assets.bin +``` + +**Note:** Replace `0x110000` with the actual offset from your partition table. + +### Asset Folder Structure + +``` +project/ +├── assets/ +│ ├── logo.bin +│ ├── config_template.json +│ ├── fonts/ +│ │ ├── arial.ttf +│ │ └── mono.ttf +│ └── images/ +│ ├── icon_wifi.bin +│ └── icon_battery.bin +└── main/ + └── main.c +``` + +--- + +## Troubleshooting + +### "Partition 'assets' not found" + +**Problem:** The assets partition is not defined in the partition table. + +**Solution:** +1. Add partition to `partitions.csv`: + ```csv + assets, data, spiffs, 0x110000, 512K, + ``` +2. Set partition table in `sdkconfig`: + ``` + CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions.csv" + CONFIG_PARTITION_TABLE_CUSTOM=y + ``` +3. Rebuild: `idf.py fullclean && idf.py build` + +--- + +### "(empty - partition has no files!)" + +**Problem:** Assets partition exists but contains no files. + +**Solution:** +1. Create `assets/` folder in project root +2. Add files to the folder +3. Enable automatic flash in `CMakeLists.txt`: + ```cmake + littlefs_create_partition_image(assets assets FLASH_IN_PROJECT) + ``` +4. Rebuild and flash: `idf.py flash` + +--- + +### "Failed to allocate memory" + +**Problem:** Insufficient heap for large asset file. + +**Solutions:** +- Use `storage_assets_read_file()` with pre-allocated buffer instead of `load_file()` +- Read file in chunks instead of loading entirely +- Increase heap size in `sdkconfig`: + ``` + CONFIG_ESP_SYSTEM_EVENT_TASK_STACK_SIZE=4096 + CONFIG_FREERTOS_HZ=1000 + ``` + +--- + +### File Not Found at Runtime + +**Problem:** File exists in assets folder but not found at runtime. + +**Checklist:** +- [ ] Is partition flashed? (`idf.py flash`) +- [ ] Is filename correct? (case-sensitive!) +- [ ] Is `storage_assets_init()` called before reading? +- [ ] Check `storage_assets_print_info()` output - does it list your file? + +--- + +## Performance Considerations + +- **Initialization:** Takes 100-500ms depending on partition size and file count. +- **File Reading:** LittleFS is optimized for small files (< 1MB). +- **Memory:** `load_file()` allocates heap - monitor with `esp_get_free_heap_size()`. +- **Large Files:** For files > 100KB, consider chunked reading instead of full load. + +--- + +## Best Practices + +1. **Keep Assets Small:** LittleFS works best with many small files rather than few large ones. +2. **Compress When Possible:** Pre-compress assets (e.g., PNG → binary bitmap) before flashing. +3. **Validate Sizes:** Always check file sizes match expected values. +4. **Free Memory:** Always `free()` pointers returned by `load_file()`. +5. **Handle Errors:** Never assume assets are present - always validate return codes. +6. **Use Subdirectories:** Organize assets logically (fonts/, images/, sounds/). +7. **Version Assets:** Include version info in filenames or metadata for updates. +--- + +# C5 + +This component provides read-only access to a dedicated LittleFS partition for storing static application assets like images, fonts, configuration files, and other resources that are flashed with the firmware. + +## Overview + +- **Location:** `components/storage/storage_assets/` +- **Main Header:** `include/storage_assets.h` +- **Implementation:** `storage_assets.c` +- **Dependencies:** `esp_littlefs`, `esp_vfs` +- **Partition:** `assets` (LittleFS, read-only in production) + +## Key Features + +- **Dedicated Partition:** Separate from application code and main storage. +- **LittleFS Backend:** Efficient wear-leveling filesystem optimized for flash. +- **Read-Only Access:** Assets are flashed once and cannot be modified at runtime. +- **Auto-Discovery:** Automatically lists all files in partition on initialization. +- **Memory Management:** Helper function to load entire files with automatic allocation. +- **Directory Traversal:** Recursive directory listing for debugging. + +## Typical Use Cases + +- **Graphical Assets:** Logos, icons, sprites, bitmaps for displays. +- **Fonts:** Pre-compiled font files for text rendering. +- **Configuration Templates:** Default configuration files. +- **Audio Samples:** Short sound effects or melodies. +- **IR/RF Databases:** Preloaded signal databases. +- **Firmware Resources:** Any read-only data needed by the application. + +## Configuration + +### Partition Table + +The assets partition must be defined in your partition table (`partitions.csv`): + +```csv +# Name, Type, SubType, Offset, Size, Flags +nvs, data, nvs, 0x9000, 0x6000, +phy_init, data, phy, 0xf000, 0x1000, +factory, app, factory, 0x10000, 1M, +assets, data, spiffs, 0x110000, 512K, +storage, data, spiffs, 0x190000, 1M, +``` + +**Important Notes:** +- The SubType must be `spiffs` (even though we use LittleFS - this is an ESP-IDF quirk). +- Size should be sufficient for all your assets (adjust as needed). +- The partition must be flashed before use. + +### Constants + +```c +#define ASSETS_MOUNT_POINT "/assets" +#define ASSETS_PARTITION_LABEL "assets" +``` + +These are defined internally and cannot be changed without modifying the source. + +--- + +## API Reference + +### Initialization + +#### `storage_assets_init` + +```c +esp_err_t storage_assets_init(void); +``` + +Initializes and mounts the assets partition. Must be called before any other asset operations. + +**Behavior:** +- Mounts the LittleFS partition at `/assets`. +- Formats the partition if mounting fails (useful for first flash). +- Lists all files in the partition for debugging. +- Displays partition size and usage statistics. + +**Returns:** +- `ESP_OK` - Assets partition mounted successfully. +- `ESP_ERR_NOT_FOUND` - Partition 'assets' not found in partition table. +- `ESP_FAIL` - Mount or format failed. +- `ESP_ERR_INVALID_STATE` - Already initialized. + +**Example:** +```c +void app_main(void) { + esp_err_t ret = storage_assets_init(); + if (ret == ESP_OK) { + printf("Assets ready!\n"); + } else if (ret == ESP_ERR_NOT_FOUND) { + printf("ERROR: 'assets' partition not found!\n"); + printf("Check your partition table.\n"); + } else { + printf("Assets init failed: %s\n", esp_err_to_name(ret)); + } +} +``` + +**Console Output Example:** +``` +I (1234) storage_assets: Initializing LittleFS for assets partition +I (1245) storage_assets: Assets ready at /assets +I (1246) storage_assets: Partition size: 524288 bytes, used: 12345 bytes +I (1247) storage_assets: === Files in assets partition === +I (1248) storage_assets: [1] logo.bin (1200 bytes) +I (1249) storage_assets: [DIR] fonts/ +I (1250) storage_assets: [2] arial.ttf (45000 bytes) +I (1251) storage_assets: [3] config_template.json (567 bytes) +I (1252) storage_assets: Total: 3 file(s), 1 dir(s) +I (1253) storage_assets: ================================ +``` + +--- + +#### `storage_assets_deinit` + +```c +esp_err_t storage_assets_deinit(void); +``` + +Unmounts the assets partition and releases resources. + +**Returns:** +- `ESP_OK` - Unmounted successfully. +- `ESP_ERR_INVALID_STATE` - Not initialized. + +**Example:** +```c +// Before system shutdown +storage_assets_deinit(); +``` + +--- + +#### `storage_assets_is_mounted` + +```c +bool storage_assets_is_mounted(void); +``` + +Checks if the assets partition is currently mounted. + +**Returns:** +- `true` - Partition is mounted and ready. +- `false` - Partition is not mounted. + +**Example:** +```c +if (!storage_assets_is_mounted()) { + storage_assets_init(); +} +``` + +--- + +### File Access + +#### `storage_assets_get_file_size` + +```c +esp_err_t storage_assets_get_file_size(const char *filename, size_t *out_size); +``` + +Gets the size of a file in the assets partition without reading it. + +**Parameters:** +- `filename` - Name of the file (e.g., "logo.bin", "fonts/arial.ttf"). +- `out_size` - Pointer to store file size in bytes. + +**Returns:** +- `ESP_OK` - Size retrieved successfully. +- `ESP_ERR_INVALID_STATE` - Assets not initialized. +- `ESP_ERR_INVALID_ARG` - NULL parameters. +- `ESP_ERR_NOT_FOUND` - File doesn't exist. + +**Example:** +```c +size_t logo_size; +if (storage_assets_get_file_size("logo.bin", &logo_size) == ESP_OK) { + printf("Logo is %zu bytes\n", logo_size); + + // Allocate buffer of exact size + uint8_t *buffer = malloc(logo_size); +} +``` + +--- + +#### `storage_assets_read_file` + +```c +esp_err_t storage_assets_read_file(const char *filename, uint8_t *buffer, size_t size, size_t *out_read); +``` + +Reads file content into a pre-allocated buffer. + +**Parameters:** +- `filename` - Name of the file. +- `buffer` - Pre-allocated buffer to receive data. +- `size` - Maximum bytes to read (buffer size). +- `out_read` - Pointer to store actual bytes read (can be NULL). + +**Returns:** +- `ESP_OK` - File read successfully. +- `ESP_ERR_INVALID_STATE` - Assets not initialized. +- `ESP_ERR_INVALID_ARG` - Invalid parameters. +- `ESP_ERR_NOT_FOUND` - File doesn't exist. + +**Example:** +```c +uint8_t buffer[2048]; +size_t bytes_read; + +esp_err_t ret = storage_assets_read_file("config.json", buffer, sizeof(buffer), &bytes_read); +if (ret == ESP_OK) { + buffer[bytes_read] = '\0'; // Null-terminate if text + printf("Config: %s\n", (char *)buffer); +} else { + printf("Failed to read config: %s\n", esp_err_to_name(ret)); +} +``` + +--- + +#### `storage_assets_load_file` + +```c +uint8_t* storage_assets_load_file(const char *filename, size_t *out_size); +``` + +Loads an entire file into dynamically allocated memory. **Caller must free() the returned pointer.** + +**Parameters:** +- `filename` - Name of the file. +- `out_size` - Pointer to store file size (can be NULL). + +**Returns:** +- Pointer to allocated buffer containing file data. +- `NULL` on error (allocation failure, file not found, etc.). + +**Example:** +```c +size_t image_size; +uint8_t *image_data = storage_assets_load_file("splash_screen.bin", &image_size); + +if (image_data != NULL) { + // Use the image data + display_draw_bitmap(image_data, image_size); + + // IMPORTANT: Free when done! + free(image_data); +} else { + printf("Failed to load splash screen\n"); +} +``` + +**Memory Warning:** This function allocates heap memory. Ensure sufficient heap is available before loading large files. + +--- + +### Utility Functions + +#### `storage_assets_get_mount_point` + +```c +const char* storage_assets_get_mount_point(void); +``` + +Returns the mount point path for the assets partition. + +**Returns:** +- Constant string "/assets". + +**Example:** +```c +const char *mount = storage_assets_get_mount_point(); + +// Construct full path +char full_path[128]; +snprintf(full_path, sizeof(full_path), "%s/%s", mount, "config.json"); + +// Use with standard file operations +FILE *f = fopen(full_path, "r"); +``` + +--- + +#### `storage_assets_print_info` + +```c +void storage_assets_print_info(void); +``` + +Prints detailed information about the assets partition to the console. + +**Parameters:** None + +**Returns:** Nothing (void) + +**Example Output:** +``` +I (1234) storage_assets: === Assets Partition Info === +I (1235) storage_assets: Mount point: /assets +I (1236) storage_assets: Partition: assets +I (1237) storage_assets: Total size: 524288 bytes (512.00 KB) +I (1238) storage_assets: Used: 98765 bytes (96.45 KB) +I (1239) storage_assets: Free: 425523 bytes (415.55 KB) +I (1240) storage_assets: Usage: 18.8% +``` + +**Usage:** +```c +// During debugging or diagnostics +storage_assets_print_info(); +``` + +--- + +## Implementation Details + +### Directory Listing + +The component includes a recursive directory listing function that runs automatically during initialization: + +```c +static void list_directory_recursive(const char *path, const char *prefix, + int *file_count, int *dir_count); +``` + +This helps during development to verify that assets were flashed correctly. + +### Path Handling + +All file operations internally prepend the mount point: + +```c +// User provides: "logo.bin" +// Internally becomes: "/assets/logo.bin" +``` + +Subdirectories are supported: +```c +// User provides: "fonts/arial.ttf" +// Internally becomes: "/assets/fonts/arial.ttf" +``` + +### Error Handling + +All functions validate: +- Initialization state +- Parameter validity +- File existence +- Memory allocation success + +Always check return values to ensure robust operation. + +--- + +## Usage Patterns + +### Loading a Bitmap for Display + +```c +void display_splash_screen(void) { + size_t image_size; + uint8_t *image = storage_assets_load_file("splash.bin", &image_size); + + if (image == NULL) { + ESP_LOGE(TAG, "Failed to load splash screen"); + return; + } + + // Expected format: 128x64 monochrome bitmap + if (image_size != (128 * 64) / 8) { + ESP_LOGW(TAG, "Unexpected image size: %zu", image_size); + } + + // Send to display + oled_draw_bitmap(0, 0, image, 128, 64); + + // Clean up + free(image); +} +``` + +--- + +### Loading Configuration Template + +```c +cJSON* load_default_config(void) { + uint8_t *json_data = storage_assets_load_file("config_template.json", NULL); + if (json_data == NULL) { + return NULL; + } + + cJSON *config = cJSON_Parse((const char *)json_data); + free(json_data); + + return config; +} +``` + +--- + +### Preloading Assets at Boot + +```c +typedef struct { + uint8_t *logo_data; + size_t logo_size; + uint8_t *font_data; + size_t font_size; +} app_assets_t; + +app_assets_t g_assets = {0}; + +esp_err_t preload_assets(void) { + // Load logo + g_assets.logo_data = storage_assets_load_file("logo.bin", &g_assets.logo_size); + if (g_assets.logo_data == NULL) { + return ESP_FAIL; + } + + // Load font + g_assets.font_data = storage_assets_load_file("font.bin", &g_assets.font_size); + if (g_assets.font_data == NULL) { + free(g_assets.logo_data); + return ESP_FAIL; + } + + ESP_LOGI(TAG, "Assets preloaded (%zu + %zu bytes)", + g_assets.logo_size, g_assets.font_size); + + return ESP_OK; +} + +void cleanup_assets(void) { + free(g_assets.logo_data); + free(g_assets.font_data); + memset(&g_assets, 0, sizeof(g_assets)); +} +``` + +--- + +### Chunked Reading for Large Files + +```c +esp_err_t process_large_asset(const char *filename) { + FILE *f = fopen("/assets/large_file.dat", "rb"); + if (!f) { + return ESP_FAIL; + } + + uint8_t chunk[512]; + size_t bytes_read; + + while ((bytes_read = fread(chunk, 1, sizeof(chunk), f)) > 0) { + // Process chunk + process_data(chunk, bytes_read); + } + + fclose(f); + return ESP_OK; +} +``` + +--- + +### Conditional Asset Loading + +```c +void load_language_assets(const char *language) { + char filename[64]; + snprintf(filename, sizeof(filename), "strings_%s.json", language); + + uint8_t *strings = storage_assets_load_file(filename, NULL); + if (strings == NULL) { + ESP_LOGW(TAG, "Language '%s' not found, using default", language); + strings = storage_assets_load_file("strings_en.json", NULL); + } + + if (strings != NULL) { + parse_language_strings((const char *)strings); + free(strings); + } +} +``` + +--- + +## Flashing Assets + +### Option 1: Automatic (Recommended) + +Add to your `CMakeLists.txt`: + +```cmake +# Create assets partition image from 'assets' folder +littlefs_create_partition_image(assets assets FLASH_IN_PROJECT) +``` + +This automatically flashes the `assets/` folder content when running `idf.py flash`. + +### Option 2: Manual Flash + +```bash +# Build the assets partition image +idf.py build + +# Flash everything including assets +idf.py flash + +# Or flash only assets partition +esptool.py write_flash 0x110000 build/assets.bin +``` + +**Note:** Replace `0x110000` with the actual offset from your partition table. + +### Asset Folder Structure + +``` +project/ +├── assets/ +│ ├── logo.bin +│ ├── config_template.json +│ ├── fonts/ +│ │ ├── arial.ttf +│ │ └── mono.ttf +│ └── images/ +│ ├── icon_wifi.bin +│ └── icon_battery.bin +└── main/ + └── main.c +``` + +--- + +## Troubleshooting + +### "Partition 'assets' not found" + +**Problem:** The assets partition is not defined in the partition table. + +**Solution:** +1. Add partition to `partitions.csv`: + ```csv + assets, data, spiffs, 0x110000, 512K, + ``` +2. Set partition table in `sdkconfig`: + ``` + CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions.csv" + CONFIG_PARTITION_TABLE_CUSTOM=y + ``` +3. Rebuild: `idf.py fullclean && idf.py build` + +--- + +### "(empty - partition has no files!)" + +**Problem:** Assets partition exists but contains no files. + +**Solution:** +1. Create `assets/` folder in project root +2. Add files to the folder +3. Enable automatic flash in `CMakeLists.txt`: + ```cmake + littlefs_create_partition_image(assets assets FLASH_IN_PROJECT) + ``` +4. Rebuild and flash: `idf.py flash` + +--- + +### "Failed to allocate memory" + +**Problem:** Insufficient heap for large asset file. + +**Solutions:** +- Use `storage_assets_read_file()` with pre-allocated buffer instead of `load_file()` +- Read file in chunks instead of loading entirely +- Increase heap size in `sdkconfig`: + ``` + CONFIG_ESP_SYSTEM_EVENT_TASK_STACK_SIZE=4096 + CONFIG_FREERTOS_HZ=1000 + ``` + +--- + +### File Not Found at Runtime + +**Problem:** File exists in assets folder but not found at runtime. + +**Checklist:** +- [ ] Is partition flashed? (`idf.py flash`) +- [ ] Is filename correct? (case-sensitive!) +- [ ] Is `storage_assets_init()` called before reading? +- [ ] Check `storage_assets_print_info()` output - does it list your file? + +--- + +## Performance Considerations + +- **Initialization:** Takes 100-500ms depending on partition size and file count. +- **File Reading:** LittleFS is optimized for small files (< 1MB). +- **Memory:** `load_file()` allocates heap - monitor with `esp_get_free_heap_size()`. +- **Large Files:** For files > 100KB, consider chunked reading instead of full load. + +--- + +## Best Practices + +1. **Keep Assets Small:** LittleFS works best with many small files rather than few large ones. +2. **Compress When Possible:** Pre-compress assets (e.g., PNG → binary bitmap) before flashing. +3. **Validate Sizes:** Always check file sizes match expected values. +4. **Free Memory:** Always `free()` pointers returned by `load_file()`. +5. **Handle Errors:** Never assume assets are present - always validate return codes. +6. **Use Subdirectories:** Organize assets logically (fonts/, images/, sounds/). +7. **Version Assets:** Include version info in filenames or metadata for updates. \ No newline at end of file diff --git a/docs/storage_vfs/README.md b/docs/storage_vfs/README.md new file mode 100644 index 000000000..68faedef5 --- /dev/null +++ b/docs/storage_vfs/README.md @@ -0,0 +1,1096 @@ +# P4 + +The VFS system provides a unified, low-level abstraction layer for multiple storage backends, allowing applications to work with files using a consistent API regardless of the underlying storage medium (SD Card, SPIFFS, LittleFS, or RAM). + +## Overview + +- **Location:** `components/Service/storage_vfs/` +- **Main Headers:** + - `include/vfs_core.h` (Core API) + - `include/vfs_config.h` (Backend selection) + - `include/vfs_sdcard.h` (SD Card backend) + - `include/vfs_littlefs.h` (LittleFS backend) +- **Dependencies:** `esp_vfs`, `esp_vfs_fat`, `esp_littlefs`, `sdmmc`, `spi` + +## Architecture Position + +``` +Application Code + ↓ + Storage API ← Recommended for most applications + ↓ + VFS Core ← You are here (low-level abstraction) + ↓ +Backend-Specific Drivers (SD/LittleFS/SPIFFS/RAM) +``` + +**When to use VFS directly:** +- You need POSIX-like file descriptor operations +- You want manual control over open/read/write/close +- Storage API doesn't provide what you need +- You're building your own storage abstraction + +**When NOT to use VFS:** +- For simple file operations → Use **Storage API** instead +- For read-only assets → Use **Storage Assets** instead + +--- + +## Key Features + +- **Multiple Backends:** Support for SD Card (FAT), SPIFFS, LittleFS, and RAM filesystem +- **Single Backend Selection:** Compile-time selection ensures only one backend is active +- **POSIX-Like API:** Familiar file operations (open, read, write, close, lseek) +- **Directory Operations:** Full directory tree manipulation +- **Backend Abstraction:** Switch storage backends by changing configuration + +--- + +## Backend Selection (Compile-Time) + +The VFS system uses **compile-time backend selection** to ensure only one storage backend is active. + +Edit `vfs_config.h`: + +```c +// Only ONE backend can be uncommented at a time + +#define VFS_USE_SD_CARD // ← Active backend +// #define VFS_USE_SPIFFS +// #define VFS_USE_LITTLEFS +// #define VFS_USE_RAMFS +``` + +**Important:** The system validates this at compile time and will error if multiple backends are selected. + +### Backend Configurations + +Each backend has specific configuration in `vfs_config.h`: + +#### SD Card Backend +```c +#define VFS_MOUNT_POINT "/sdcard" +#define VFS_MAX_FILES 10 +#define VFS_FORMAT_ON_FAIL false +#define VFS_BACKEND_NAME "SD Card" +``` + +#### LittleFS Backend +```c +#define VFS_MOUNT_POINT "/littlefs" +#define VFS_MAX_FILES 10 +#define VFS_FORMAT_ON_FAIL true +#define VFS_PARTITION_LABEL "storage" +#define VFS_BACKEND_NAME "LittleFS" +``` + +--- + +## Data Structures + +### File Descriptor + +```c +typedef int vfs_fd_t; +#define VFS_INVALID_FD -1 +``` + +File descriptor for open files. Similar to POSIX file descriptors. + +--- + +### File/Directory Information + +```c +typedef struct { + char name[VFS_MAX_NAME]; // Entry name (64 chars max) + vfs_entry_type_t type; // VFS_TYPE_FILE or VFS_TYPE_DIR + size_t size; // File size in bytes + time_t mtime; // Last modification time + time_t ctime; // Creation time + bool is_hidden; // Hidden attribute + bool is_readonly; // Read-only attribute +} vfs_stat_t; +``` + +--- + +### Filesystem Statistics + +```c +typedef struct { + uint64_t total_bytes; // Total filesystem capacity + uint64_t free_bytes; // Available free space + uint64_t used_bytes; // Space currently in use + uint32_t block_size; // Filesystem block size + uint32_t total_blocks; // Total number of blocks + uint32_t free_blocks; // Available free blocks +} vfs_statvfs_t; +``` + +--- + +## Core API Reference + +### Initialization + +#### `vfs_init_auto` + +```c +esp_err_t vfs_init_auto(void); +``` + +Initializes the VFS backend selected in `vfs_config.h`. + +**Returns:** +- `ESP_OK` - Backend initialized and mounted successfully +- `ESP_FAIL` - Initialization failed (check logs) + +--- + +#### `vfs_deinit_auto` + +```c +esp_err_t vfs_deinit_auto(void); +``` + +Unmounts and deinitializes the active VFS backend. + +**Returns:** +- `ESP_OK` - Backend deinitialized successfully +- `ESP_FAIL` - Deinitialization failed + +--- + +#### `vfs_is_mounted_auto` + +```c +bool vfs_is_mounted_auto(void); +``` + +Checks if the active backend is currently mounted. + +--- + +#### `vfs_get_mount_point` + +```c +const char* vfs_get_mount_point(void); +``` + +Returns the mount point path for the active backend (e.g., "/sdcard", "/littlefs"). + +--- + +#### `vfs_get_backend_name` + +```c +const char* vfs_get_backend_name(void); +``` + +Returns the human-readable name of the active backend (e.g., "SD Card", "LittleFS"). + +--- + +#### `vfs_print_info` + +```c +void vfs_print_info(void); +``` + +Prints detailed information about the active VFS backend to the console, including mount point, capacity, and usage statistics. + +--- + +### File Operations (POSIX-like) + +#### `vfs_open` + +```c +vfs_fd_t vfs_open(const char *path, int flags, int mode); +``` + +Opens a file with specified flags and permissions. + +**Parameters:** +- `path` - Full path to file (e.g., "/sdcard/data.txt") +- `flags` - Opening mode flags (bitwise OR): + - `VFS_O_RDONLY` - Read-only + - `VFS_O_WRONLY` - Write-only + - `VFS_O_RDWR` - Read and write + - `VFS_O_CREAT` - Create if doesn't exist + - `VFS_O_TRUNC` - Truncate to zero length + - `VFS_O_APPEND` - Append to end of file + - `VFS_O_EXCL` - Fail if file exists (with O_CREAT) +- `mode` - File permissions (POSIX mode, e.g., 0644) + +**Returns:** +- Valid file descriptor (>= 0) on success +- `VFS_INVALID_FD` on failure + +--- + +#### `vfs_read` + +```c +ssize_t vfs_read(vfs_fd_t fd, void *buf, size_t size); +``` + +Reads data from an open file. + +**Returns:** +- Number of bytes read (>= 0) +- -1 on error + +--- + +#### `vfs_write` + +```c +ssize_t vfs_write(vfs_fd_t fd, const void *buf, size_t size); +``` + +Writes data to an open file. + +**Returns:** +- Number of bytes written (>= 0) +- -1 on error + +--- + +#### `vfs_lseek` + +```c +off_t vfs_lseek(vfs_fd_t fd, off_t offset, int whence); +``` + +Moves the file position pointer. + +**Parameters:** +- `whence` - Reference point: + - `VFS_SEEK_SET` - From beginning of file + - `VFS_SEEK_CUR` - From current position + - `VFS_SEEK_END` - From end of file + +**Returns:** +- New file position on success +- -1 on error + +--- + +#### `vfs_close` + +```c +esp_err_t vfs_close(vfs_fd_t fd); +``` + +Closes an open file descriptor. + +--- + +#### `vfs_fsync` + +```c +esp_err_t vfs_fsync(vfs_fd_t fd); +``` + +Flushes file buffers to storage, ensuring data is physically written. + +--- + +### File Metadata + +#### `vfs_stat` + +```c +esp_err_t vfs_stat(const char *path, vfs_stat_t *st); +``` + +Gets information about a file or directory. + +--- + +#### `vfs_exists` + +```c +bool vfs_exists(const char *path); +``` + +Checks if a file or directory exists. + +--- + +#### `vfs_get_size` + +```c +esp_err_t vfs_get_size(const char *path, size_t *size); +``` + +Gets the size of a file in bytes. + +--- + +### File Management + +#### `vfs_rename` + +```c +esp_err_t vfs_rename(const char *old_path, const char *new_path); +``` + +Renames or moves a file. + +--- + +#### `vfs_unlink` + +```c +esp_err_t vfs_unlink(const char *path); +``` + +Deletes a file. + +--- + +#### `vfs_truncate` + +```c +esp_err_t vfs_truncate(const char *path, off_t length); +``` + +Resizes a file to the specified length. + +--- + +### Directory Operations + +#### `vfs_mkdir` + +```c +esp_err_t vfs_mkdir(const char *path, int mode); +``` + +Creates a new directory. + +--- + +#### `vfs_rmdir` + +```c +esp_err_t vfs_rmdir(const char *path); +``` + +Removes an empty directory. + +--- + +#### `vfs_rmdir_recursive` + +```c +esp_err_t vfs_rmdir_recursive(const char *path); +``` + +Recursively removes a directory and all its contents. + +--- + +#### `vfs_opendir` / `vfs_readdir` / `vfs_closedir` + +```c +vfs_dir_t vfs_opendir(const char *path); +esp_err_t vfs_readdir(vfs_dir_t dir, vfs_stat_t *entry); +esp_err_t vfs_closedir(vfs_dir_t dir); +``` + +Directory traversal using iterator pattern. + +--- + +#### `vfs_list_dir` + +```c +typedef void (*vfs_dir_callback_t)(const vfs_stat_t *entry, void *user_data); +esp_err_t vfs_list_dir(const char *path, vfs_dir_callback_t callback, void *user_data); +``` + +Lists directory contents using callback. + +--- + +### Filesystem Information + +#### `vfs_statvfs` + +```c +esp_err_t vfs_statvfs(const char *path, vfs_statvfs_t *stat); +``` + +Gets filesystem statistics. + +--- + +#### `vfs_get_free_space` + +```c +esp_err_t vfs_get_free_space(const char *path, uint64_t *free_bytes); +``` + +Gets available free space. + +--- + +#### `vfs_get_usage_percent` + +```c +esp_err_t vfs_get_usage_percent(const char *path, float *percentage); +``` + +Calculates filesystem usage percentage. + +--- + +### High-Level Helpers + +These functions simplify common operations by handling open/close internally. + +#### `vfs_read_file` + +```c +esp_err_t vfs_read_file(const char *path, void *buf, size_t size, size_t *bytes_read); +``` + +Reads entire file content in one operation. + +--- + +#### `vfs_write_file` + +```c +esp_err_t vfs_write_file(const char *path, const void *buf, size_t size); +``` + +Writes data to file, creating or overwriting it. + +--- + +#### `vfs_append_file` + +```c +esp_err_t vfs_append_file(const char *path, const void *buf, size_t size); +``` + +Appends data to end of file. + +--- + +#### `vfs_copy_file` + +```c +esp_err_t vfs_copy_file(const char *src, const char *dst); +``` + +Copies a file. + +--- + +## Backend-Specific APIs + +### SD Card Backend + +```c +#include "vfs_sdcard.h" + +esp_err_t vfs_sdcard_init(void); +esp_err_t vfs_sdcard_deinit(void); +bool vfs_sdcard_is_mounted(void); +void vfs_sdcard_print_info(void); +esp_err_t vfs_sdcard_format(void); +``` + +### LittleFS Backend + +```c +#include "vfs_littlefs.h" + +esp_err_t vfs_littlefs_init(void); +esp_err_t vfs_littlefs_deinit(void); +bool vfs_littlefs_is_mounted(void); +void vfs_littlefs_print_info(void); +esp_err_t vfs_littlefs_format(void); +``` + +--- + +## Switching Backends + +To switch between storage backends, edit `vfs_config.h`: + +```c +// From SD Card: +#define VFS_USE_SD_CARD + +// To LittleFS: +// #define VFS_USE_SD_CARD +#define VFS_USE_LITTLEFS +``` + +Rebuild your project. All `vfs_*` function calls remain the same. + +--- + +## Best Practices + +1. **Consider Storage API first** - Use VFS only when you need low-level control +2. **Always check return values** - Especially for `vfs_open()` and `vfs_init_auto()` +3. **Close file descriptors** - Always call `vfs_close()` when done +4. **Use absolute paths** - Include mount point (e.g., "/sdcard/file.txt") +5. **Single backend only** - Never uncomment multiple backends in `vfs_config.h` +--- + +# C5 + +The VFS system provides a unified, low-level abstraction layer for multiple storage backends, allowing applications to work with files using a consistent API regardless of the underlying storage medium (SD Card, SPIFFS, LittleFS, or RAM). + +## Overview + +- **Location:** `components/storage/vfs/` +- **Main Headers:** + - `include/vfs_core.h` (Core API) + - `include/vfs_config.h` (Backend selection) + - `include/vfs_sdcard.h` (SD Card backend) + - `include/vfs_littlefs.h` (LittleFS backend) +- **Dependencies:** `esp_vfs`, `esp_vfs_fat`, `esp_littlefs`, `sdmmc`, `spi` + +## Architecture Position + +``` +Application Code + ↓ + Storage API ← Recommended for most applications + ↓ + VFS Core ← You are here (low-level abstraction) + ↓ +Backend-Specific Drivers (SD/LittleFS/SPIFFS/RAM) +``` + +**When to use VFS directly:** +- You need POSIX-like file descriptor operations +- You want manual control over open/read/write/close +- Storage API doesn't provide what you need +- You're building your own storage abstraction + +**When NOT to use VFS:** +- For simple file operations → Use **Storage API** instead +- For read-only assets → Use **Storage Assets** instead + +--- + +## Key Features + +- **Multiple Backends:** Support for SD Card (FAT), SPIFFS, LittleFS, and RAM filesystem +- **Single Backend Selection:** Compile-time selection ensures only one backend is active +- **POSIX-Like API:** Familiar file operations (open, read, write, close, lseek) +- **Directory Operations:** Full directory tree manipulation +- **Backend Abstraction:** Switch storage backends by changing configuration + +--- + +## Backend Selection (Compile-Time) + +The VFS system uses **compile-time backend selection** to ensure only one storage backend is active. + +Edit `vfs_config.h`: + +```c +// Only ONE backend can be uncommented at a time + +#define VFS_USE_SD_CARD // ← Active backend +// #define VFS_USE_SPIFFS +// #define VFS_USE_LITTLEFS +// #define VFS_USE_RAMFS +``` + +**Important:** The system validates this at compile time and will error if multiple backends are selected. + +### Backend Configurations + +Each backend has specific configuration in `vfs_config.h`: + +#### SD Card Backend +```c +#define VFS_MOUNT_POINT "/sdcard" +#define VFS_MAX_FILES 10 +#define VFS_FORMAT_ON_FAIL false +#define VFS_BACKEND_NAME "SD Card" +``` + +#### LittleFS Backend +```c +#define VFS_MOUNT_POINT "/littlefs" +#define VFS_MAX_FILES 10 +#define VFS_FORMAT_ON_FAIL true +#define VFS_PARTITION_LABEL "storage" +#define VFS_BACKEND_NAME "LittleFS" +``` + +--- + +## Data Structures + +### File Descriptor + +```c +typedef int vfs_fd_t; +#define VFS_INVALID_FD -1 +``` + +File descriptor for open files. Similar to POSIX file descriptors. + +--- + +### File/Directory Information + +```c +typedef struct { + char name[VFS_MAX_NAME]; // Entry name (64 chars max) + vfs_entry_type_t type; // VFS_TYPE_FILE or VFS_TYPE_DIR + size_t size; // File size in bytes + time_t mtime; // Last modification time + time_t ctime; // Creation time + bool is_hidden; // Hidden attribute + bool is_readonly; // Read-only attribute +} vfs_stat_t; +``` + +--- + +### Filesystem Statistics + +```c +typedef struct { + uint64_t total_bytes; // Total filesystem capacity + uint64_t free_bytes; // Available free space + uint64_t used_bytes; // Space currently in use + uint32_t block_size; // Filesystem block size + uint32_t total_blocks; // Total number of blocks + uint32_t free_blocks; // Available free blocks +} vfs_statvfs_t; +``` + +--- + +## Core API Reference + +### Initialization + +#### `vfs_init_auto` + +```c +esp_err_t vfs_init_auto(void); +``` + +Initializes the VFS backend selected in `vfs_config.h`. + +**Returns:** +- `ESP_OK` - Backend initialized and mounted successfully +- `ESP_FAIL` - Initialization failed (check logs) + +--- + +#### `vfs_deinit_auto` + +```c +esp_err_t vfs_deinit_auto(void); +``` + +Unmounts and deinitializes the active VFS backend. + +**Returns:** +- `ESP_OK` - Backend deinitialized successfully +- `ESP_FAIL` - Deinitialization failed + +--- + +#### `vfs_is_mounted_auto` + +```c +bool vfs_is_mounted_auto(void); +``` + +Checks if the active backend is currently mounted. + +--- + +#### `vfs_get_mount_point` + +```c +const char* vfs_get_mount_point(void); +``` + +Returns the mount point path for the active backend (e.g., "/sdcard", "/littlefs"). + +--- + +#### `vfs_get_backend_name` + +```c +const char* vfs_get_backend_name(void); +``` + +Returns the human-readable name of the active backend (e.g., "SD Card", "LittleFS"). + +--- + +#### `vfs_print_info` + +```c +void vfs_print_info(void); +``` + +Prints detailed information about the active VFS backend to the console, including mount point, capacity, and usage statistics. + +--- + +### File Operations (POSIX-like) + +#### `vfs_open` + +```c +vfs_fd_t vfs_open(const char *path, int flags, int mode); +``` + +Opens a file with specified flags and permissions. + +**Parameters:** +- `path` - Full path to file (e.g., "/sdcard/data.txt") +- `flags` - Opening mode flags (bitwise OR): + - `VFS_O_RDONLY` - Read-only + - `VFS_O_WRONLY` - Write-only + - `VFS_O_RDWR` - Read and write + - `VFS_O_CREAT` - Create if doesn't exist + - `VFS_O_TRUNC` - Truncate to zero length + - `VFS_O_APPEND` - Append to end of file + - `VFS_O_EXCL` - Fail if file exists (with O_CREAT) +- `mode` - File permissions (POSIX mode, e.g., 0644) + +**Returns:** +- Valid file descriptor (>= 0) on success +- `VFS_INVALID_FD` on failure + +--- + +#### `vfs_read` + +```c +ssize_t vfs_read(vfs_fd_t fd, void *buf, size_t size); +``` + +Reads data from an open file. + +**Returns:** +- Number of bytes read (>= 0) +- -1 on error + +--- + +#### `vfs_write` + +```c +ssize_t vfs_write(vfs_fd_t fd, const void *buf, size_t size); +``` + +Writes data to an open file. + +**Returns:** +- Number of bytes written (>= 0) +- -1 on error + +--- + +#### `vfs_lseek` + +```c +off_t vfs_lseek(vfs_fd_t fd, off_t offset, int whence); +``` + +Moves the file position pointer. + +**Parameters:** +- `whence` - Reference point: + - `VFS_SEEK_SET` - From beginning of file + - `VFS_SEEK_CUR` - From current position + - `VFS_SEEK_END` - From end of file + +**Returns:** +- New file position on success +- -1 on error + +--- + +#### `vfs_close` + +```c +esp_err_t vfs_close(vfs_fd_t fd); +``` + +Closes an open file descriptor. + +--- + +#### `vfs_fsync` + +```c +esp_err_t vfs_fsync(vfs_fd_t fd); +``` + +Flushes file buffers to storage, ensuring data is physically written. + +--- + +### File Metadata + +#### `vfs_stat` + +```c +esp_err_t vfs_stat(const char *path, vfs_stat_t *st); +``` + +Gets information about a file or directory. + +--- + +#### `vfs_exists` + +```c +bool vfs_exists(const char *path); +``` + +Checks if a file or directory exists. + +--- + +#### `vfs_get_size` + +```c +esp_err_t vfs_get_size(const char *path, size_t *size); +``` + +Gets the size of a file in bytes. + +--- + +### File Management + +#### `vfs_rename` + +```c +esp_err_t vfs_rename(const char *old_path, const char *new_path); +``` + +Renames or moves a file. + +--- + +#### `vfs_unlink` + +```c +esp_err_t vfs_unlink(const char *path); +``` + +Deletes a file. + +--- + +#### `vfs_truncate` + +```c +esp_err_t vfs_truncate(const char *path, off_t length); +``` + +Resizes a file to the specified length. + +--- + +### Directory Operations + +#### `vfs_mkdir` + +```c +esp_err_t vfs_mkdir(const char *path, int mode); +``` + +Creates a new directory. + +--- + +#### `vfs_rmdir` + +```c +esp_err_t vfs_rmdir(const char *path); +``` + +Removes an empty directory. + +--- + +#### `vfs_rmdir_recursive` + +```c +esp_err_t vfs_rmdir_recursive(const char *path); +``` + +Recursively removes a directory and all its contents. + +--- + +#### `vfs_opendir` / `vfs_readdir` / `vfs_closedir` + +```c +vfs_dir_t vfs_opendir(const char *path); +esp_err_t vfs_readdir(vfs_dir_t dir, vfs_stat_t *entry); +esp_err_t vfs_closedir(vfs_dir_t dir); +``` + +Directory traversal using iterator pattern. + +--- + +#### `vfs_list_dir` + +```c +typedef void (*vfs_dir_callback_t)(const vfs_stat_t *entry, void *user_data); +esp_err_t vfs_list_dir(const char *path, vfs_dir_callback_t callback, void *user_data); +``` + +Lists directory contents using callback. + +--- + +### Filesystem Information + +#### `vfs_statvfs` + +```c +esp_err_t vfs_statvfs(const char *path, vfs_statvfs_t *stat); +``` + +Gets filesystem statistics. + +--- + +#### `vfs_get_free_space` + +```c +esp_err_t vfs_get_free_space(const char *path, uint64_t *free_bytes); +``` + +Gets available free space. + +--- + +#### `vfs_get_usage_percent` + +```c +esp_err_t vfs_get_usage_percent(const char *path, float *percentage); +``` + +Calculates filesystem usage percentage. + +--- + +### High-Level Helpers + +These functions simplify common operations by handling open/close internally. + +#### `vfs_read_file` + +```c +esp_err_t vfs_read_file(const char *path, void *buf, size_t size, size_t *bytes_read); +``` + +Reads entire file content in one operation. + +--- + +#### `vfs_write_file` + +```c +esp_err_t vfs_write_file(const char *path, const void *buf, size_t size); +``` + +Writes data to file, creating or overwriting it. + +--- + +#### `vfs_append_file` + +```c +esp_err_t vfs_append_file(const char *path, const void *buf, size_t size); +``` + +Appends data to end of file. + +--- + +#### `vfs_copy_file` + +```c +esp_err_t vfs_copy_file(const char *src, const char *dst); +``` + +Copies a file. + +--- + +## Backend-Specific APIs + +### SD Card Backend + +```c +#include "vfs_sdcard.h" + +esp_err_t vfs_sdcard_init(void); +esp_err_t vfs_sdcard_deinit(void); +bool vfs_sdcard_is_mounted(void); +void vfs_sdcard_print_info(void); +esp_err_t vfs_sdcard_format(void); +``` + +### LittleFS Backend + +```c +#include "vfs_littlefs.h" + +esp_err_t vfs_littlefs_init(void); +esp_err_t vfs_littlefs_deinit(void); +bool vfs_littlefs_is_mounted(void); +void vfs_littlefs_print_info(void); +esp_err_t vfs_littlefs_format(void); +``` + +--- + +## Switching Backends + +To switch between storage backends, edit `vfs_config.h`: + +```c +// From SD Card: +#define VFS_USE_SD_CARD + +// To LittleFS: +// #define VFS_USE_SD_CARD +#define VFS_USE_LITTLEFS +``` + +Rebuild your project. All `vfs_*` function calls remain the same. + +--- + +## Best Practices + +1. **Consider Storage API first** - Use VFS only when you need low-level control +2. **Always check return values** - Especially for `vfs_open()` and `vfs_init_auto()` +3. **Close file descriptors** - Always call `vfs_close()` when done +4. **Use absolute paths** - Include mount point (e.g., "/sdcard/file.txt") +5. **Single backend only** - Never uncomment multiple backends in `vfs_config.h` \ No newline at end of file diff --git a/docs/sys_monitor/README.md b/docs/sys_monitor/README.md new file mode 100644 index 000000000..bd813037e --- /dev/null +++ b/docs/sys_monitor/README.md @@ -0,0 +1,85 @@ +# System Monitor (`sys_monitor`) + +Background health task for `firmware_p4`. It samples every running task's stack +headroom on a fixed interval and **observes and reports** problems. It never +kills tasks. + +- **Location:** `components/Core/sys_monitor.c` +- **Task:** `SysMonitor`, priority `SYS_PRIO_MONITOR` (1), pinned to + `SYS_CORE_RADIO` (core 0). See [sys_prio](../sys_prio/README.md). +- **Start:** `sys_monitor_start(bool is_verbose)` from `kernel_init`. + +## What it does each cycle (2 s) + +1. Optionally logs heap stats (internal / PSRAM / free) when verbose. +2. Walks `uxTaskGetSystemState()` and flags any task whose stack high-water mark + is below `CRITICAL_STACK_THRESHOLD` (256 B free). +3. For each flagged task: logs a warning, and shows **one** UI warning per streak + via `safeguard_alert()` (which takes the LVGL lock - it never touches LVGL + without it). +4. Tracks how many **consecutive** cycles each task stays critical. Only after + `STACK_ESCALATE_CYCLES` (5, so ~10 s) does it perform a **controlled restart** + (`esp_restart()`), preceded by a log, a UI alert and a short grace delay. +5. Tasks that recover or exit drop out of the table, so a fresh dip starts a new + streak instead of inheriting a stale one. +6. It is also the **Task Watchdog heartbeat** (`esp_task_wdt_add`/`reset` each + cycle) and the **UI render supervisor**: it polls `ui_render_beat()` (a beat + bumped by an `lv_timer` inside the LVGL task) and, if the beat stalls for a + couple of cycles, does the same controlled restart. This replaces the old + "watch a task called `UI Task` by name" logic - it watches the renderer's + actual progress, not a task name. + +`usStackHighWaterMark` is monotonic (it records the lowest free stack ever seen, +and never rises again), so a sustained streak means the task is alive and running +with dangerously little headroom - not a transient spike. A long-lived task that +legitimately sits under the threshold will eventually trigger the restart; that +is the correct signal that its stack is too small and needs fixing. + +## What it must never do + +**It never calls `vTaskDelete` on another task.** Deleting a task in the middle +of an I2C or SPI transaction leaks the peripheral's bus mutex: the driver stays +locked forever and no other task can use that bus until reboot. On SPI3 (shared +with the display) that can take the screen down too. A tight stack must not +become a dead peripheral. + +If a subsystem genuinely needs to be force-torn-down, its owner must expose a +`*_abort()` that releases its own bus/DMA/mutex/handles first; the monitor calls +that, it does not reach into the task. Until such an API exists, the only +escalation is the controlled restart above. + +## Tunables (`sys_monitor.c`) + +| Macro | Default | Meaning | +|-------|---------|---------| +| `MONITOR_INTERVAL_MS` | 2000 | Sampling period | +| `CRITICAL_STACK_THRESHOLD` | 256 | Free-stack floor (bytes) that flags a task | +| `STACK_ESCALATE_CYCLES` | 5 | Consecutive critical cycles before a controlled restart | +| `STACK_WATCH_MAX` | 8 | Distinct critical tasks tracked for persistence | +| `REBOOT_GRACE_MS` | 1500 | Delay after the alert so it renders / logs flush | + +> The controlled restart has a `TODO(item 31)` hook: once the graceful shutdown +> path exists it should run there so the filesystem and radios flush their state +> before `esp_restart()`. + +--- + +## ESP32-C5 (`firmware_c5`) + +The C5 runs the same observe / report / escalate monitor +(`firmware_c5/components/Core/sys_monitor.c`), ported from the P4. Differences: + +- **Headless, so "report" is log-only.** The C5 `safeguard_alert()` just logs + (there is no UI), so low-stack warnings and the recovery notice go to the log + (which the host link forwards to the P4). Everything else - the per-task + persistence table, `STACK_ESCALATE_CYCLES`, the controlled `esp_restart()` - is + identical. +- **Entry point is `sys_monitor(bool show_ram_logs)`**, started from + `kernel_init`, and the task runs at `SYS_PRIO_MONITOR` on the single core. +- The bus most dangerous to strand with a stray `vTaskDelete` here is the **SPI + bridge to the P4**, which is exactly why the C5 monitor no longer kills tasks. +- **The monitor loop is the C5 watchdog heartbeat** (the P4 uses its UI task for + this). It subscribes with `esp_task_wdt_add(NULL)` and calls + `esp_task_wdt_reset()` each cycle, so with `CONFIG_ESP_TASK_WDT_PANIC=y` a + stuck monitor - or a task that starves the single core past the 5 s timeout - + reboots instead of only logging a warning. diff --git a/docs/sys_prio/README.md b/docs/sys_prio/README.md new file mode 100644 index 000000000..e6e5eedb9 --- /dev/null +++ b/docs/sys_prio/README.md @@ -0,0 +1,70 @@ +# Task Priority & Core Affinity (`sys_prio.h`) + +Single source of truth for FreeRTOS task priority and core assignment on the +dual-core ESP32-P4 (`firmware_p4`). Every `xTaskCreate*` site draws its priority +and core from here instead of hard-coded numbers, so the whole scheduling policy +lives in one file. + +- **Location:** `components/Drivers/sys_prio/include/sys_prio.h` +- **Why `Drivers`:** it is the one component every task-creating module already + depends on (Applications, Service and Core all require Drivers), so the header + is visible everywhere without adding a dependency edge. Putting it in `Core` + would add a REQUIRES edge that reorders ESP-IDF's cyclic component link (there + is no `--start-group`; archives are repeated) and breaks Service->Applications + symbol resolution. + +## Priority bands + +Higher number = higher priority (FreeRTOS convention). + +| Macro | Prio | Use | +|-------|------|-----| +| `SYS_PRIO_REALTIME` | 10 | Deferred ISR / hard real-time (radio IRQ) | +| `SYS_PRIO_RENDER` | 6 | LVGL renderer only | +| `SYS_PRIO_SERVICE_HI` | 5 | Latency-sensitive services (host link, radio rx/tx, streaming) | +| `SYS_PRIO_SERVICE_LO` | 4 | Regular services (media playback, capture, UI helpers) | +| `SYS_PRIO_BACKGROUND` | 3 | Periodic polling, logging, telemetry | +| `SYS_PRIO_BACKGROUND_LO` | 2 | Lowest non-idle background work | +| `SYS_PRIO_MONITOR` | 1 | Health monitor | + +## Core affinity + +| Macro | Core | Runs | +|-------|------|------| +| `SYS_CORE_UI` | 1 | LVGL renderer + everything that feeds the screen (media, capture, UI helpers) | +| `SYS_CORE_RADIO` | 0 | Radios, host link, bridge, storage, monitor | +| `SYS_CORE_ANY` | - | `tskNO_AFFINITY` (let the scheduler place it) | + +The renderer runs at priority 6 pinned to core 1; radios and USB streaming run +on core 0. Keeping the two apart removes the UI jank that used to happen when a +radio scan or USB stream landed on the render core. + +## Rules + +- Always pin: use `xTaskCreatePinnedToCore` (or `xTaskCreateStaticPinnedToCore`), + never the unpinned `xTaskCreate` / `xTaskCreateStatic`. +- Never pass a raw priority number or a private `#define`; use a `SYS_PRIO_*` / + `SYS_CORE_*` macro. +- Screen background work (scans, captures) goes in its own task pinned per this + policy, not on the UI thread. See [ui](../ui/README.md#long-running-work-and-the-watchdog). + +See [Coding Standards - Concurrency](../../CODING_STANDARDS.md) for the full rule. + +--- + +## ESP32-C5 (`firmware_c5`) + +The C5 has its own `sys_prio.h` at +`firmware_c5/components/Drivers/sys_prio/include/` (same placement rationale as +the P4). It is single-core (`CONFIG_FREERTOS_UNICORE=y`) and headless, so it +keeps the same priority bands but **drops the render band and the UI/radio core +split** - every task runs on the one core. + +- `SYS_PRIO_REALTIME` (10): the SPI bridge to the P4. +- `SYS_PRIO_SERVICE_HI` (5): radio and app tasks (Wi-Fi/BLE ops, scanners, DNS, + OTA, session watchdog, mesh TCP, channel hopper). +- `SYS_PRIO_SERVICE_LO` (4): host-link logging (`c5_log`). +- `SYS_PRIO_MONITOR` (1): the system monitor. +- Core macros are `SYS_CORE_MAIN` (0, the only core) and `SYS_CORE_ANY`. + +The port centralized every C5 task priority without changing any numeric value. diff --git a/docs/tusb_desc/README.md b/docs/tusb_desc/README.md new file mode 100644 index 000000000..580cd0735 --- /dev/null +++ b/docs/tusb_desc/README.md @@ -0,0 +1,123 @@ +# TinyUSB Descriptors (HID Composite) + +This component defines the USB descriptors required to enumerate the ESP32-P4 as a USB HID Composite Device (Keyboard + Mouse) and provides the initialization routine for the TinyUSB driver. + +## Overview + +- **Location:** `components/Drivers/tusb_desc/` +- **Header:** `include/tusb_desc.h` +- **Dependencies:** `tinyusb`, `esp_tinyusb`, `driver/gpio` +- **USB Port:** High Speed (ESP32-P4) + +## USB Descriptors + +### Device Descriptor + +| Field | Value | +|-------|-------| +| USB Version | 2.0 | +| Vendor ID | `0xCAFE` | +| Product ID | `0x4001` | +| Device Class | Defined at interface level | +| Configurations | 1 | + +### Configuration Descriptor + +| Field | Value | +|-------|-------| +| Interfaces | 1 (HID) | +| Max Power | 100 mA | +| Attributes | Remote Wakeup | + +### HID Report Descriptor + +Single HID interface with two reports using Report IDs: + +| Report ID | Type | Usage | +|-----------|------|-------| +| 1 | Keyboard | Generic Desktop Keyboard | +| 2 | Mouse | Generic Desktop Mouse (buttons + XY + wheel) | + +### String Descriptors + +| Index | Value | +|-------|-------| +| 0 | Language ID (English US) | +| 1 | Manufacturer: "HighCode" | +| 2 | Product: "BadUSB Device" | +| 3 | Serial: "123456" | + +## API Reference + +### `busb_init` +```c +esp_err_t busb_init(void); +``` +Initializes the TinyUSB driver with the defined descriptors. +1. Installs the GPIO ISR service (required for ESP32-P4 High Speed USB). +2. Configures device, configuration, and HID report descriptors. +3. Installs the TinyUSB driver on the High Speed port. + +Must be called before any HID report transmission. It does **not** touch the +USB-C data mux (see below) - installing the driver and routing the connector are +separate steps. + +### `usb_mux_init` +```c +void usb_mux_init(void); +``` +Configures the mux select pin as an output and defaults it to the UART bridge. +Called once at boot from `kernel_init`. + +### `usb_mux_set_native` +```c +esp_err_t usb_mux_set_native(bool native); +``` +Switches the USB-C data lines at runtime (no reset). `true` = P4 native USB +(brings the TinyUSB composite up first, then routes the connector); `false` = +CP2105 UART bridge. + +### `usb_mux_is_native` +```c +bool usb_mux_is_native(void); +``` +Current mux state. + +## USB-C data mux (TS3USB221) + +The HighBoy V2 has a **single Type-C connector** whose `D+/D-` are muxed by a +**TS3USB221 (U13)** between two destinations, selected by +`GPIO_USB_MUX_SEL_PIN` (GPIO19): + +| Select (GPIO19) | Route | Use | +|-----------------|-------|-----| +| LOW (default, 10k pulldown) | CP2105 USB-UART bridge | serial console + flashing | +| HIGH | ESP32-P4 native USB PHY | TinyUSB composite (BadUSB HID + companion CDC) | + +Only one path is live at a time - they share the connector. Because the pin has +a hardware pulldown, the board powers up on the **UART bridge**, so the serial +console and flashing work by default. Flashing is unaffected either way: the ROM +download mode runs before the app drives the pin. + +**Runtime toggle:** Settings -> Connection -> **USB NATIVE**. Turning it on calls +`usb_mux_set_native(true)`; the switch is immediate and needs no reset, but the +USB-serial console over that connector goes away until it is turned off (or the +device resets, which returns to the UART default). BadUSB and the companion CDC +only enumerate while the mux is on native. + +> Earlier the firmware defined `GPIO_USB_MUX_SEL_PIN` but never drove it, so the +> mux stayed on the UART bridge and native USB never enumerated. That was the +> root cause of "only USB-UART works". + +## TinyUSB Callbacks + +The component implements the required TinyUSB callbacks to serve descriptors to the USB host: + +| Callback | Purpose | +|----------|---------| +| `tud_descriptor_device_cb` | Returns the device descriptor | +| `tud_descriptor_configuration_cb` | Returns the configuration descriptor | +| `tud_descriptor_string_cb` | Returns string descriptors (manufacturer, product, serial) | +| `tud_hid_descriptor_report_cb` | Returns the HID report descriptor | +| `tud_hid_get_report_cb` | Handles GET_REPORT requests (stub) | +| `tud_hid_set_report_cb` | Handles SET_REPORT requests (stub) | diff --git a/docs/ui/README.md b/docs/ui/README.md new file mode 100644 index 000000000..eb3b0d408 --- /dev/null +++ b/docs/ui/README.md @@ -0,0 +1,264 @@ +# ui_manager +step-by-step process for adding a new screen (feature) to the HighBoy system using the ui_manager architecture. + +**Example** used: We'll create a fictional **Bluetooth (BLE)** screen. + +### 1. Register the screen in the UI ui_manager +The `ui_manager` needs to know about the new screen to handle navigation. + +**File:** `ui/ui_manager.h` +1. Add a new identifier to the `enum`: +```c +typedef enum { + SCREEN_NONE, + SCREEN_HOME, + SCREEN_MENU, + SCREEN_WIFI_MENU, + // ... + SCREEN_BLE_MENU, // <--- NEW ID ADDED +} screen_id_t; +``` + +### 2. Configure routing and Power Management +Define how the ui_manager should open the screen and handle any required hardware power states. + +**File:** `ui/ui_manager.c` +1. Include de header for the new screen (created in Step 3): +```c +#include "screens/bluetooth/ui_ble_menu.h" +``` + +2. (Optional) Power Management: If the screen uses a radio (Wi-Fi, BLE, RF), add logic to automatically enable/disable the hardware. +```c +static bool is_ble_screen(screen_id_t screen) { + switch (screen) { + case SCREEN_BLE_MENU: + case SCREEN_BLE_SCAN: // Future sub-screens + return true; + default: + return false; + } +} +``` + +Update `ui_switch_screen` to call `ble_init()` / `ble_deinit()` based on this flag (similar to how Wi-Fi is handled). + +3. Add the case to the main switch statement: +```c +void ui_switch_screen(screen_id_t new_screen) { + if (ui_acquire()) { + // ... init/deinit logic ... + clear_current_screen(); + + switch (new_screen) { + // ... other cases ... + + case SCREEN_BLE_MENU: // <--- NEW ROUTE + ui_ble_menu_open(); + break; + } + // ... + } +} +``` + +### 3. Create the New Screen UI +Create the folder and files for the new feature: `ui/screens/bluetooth/` + +**Header File:** `ui_ble_menu.h` +```c +#ifndef UI_BLE_MENU_H +#define UI_BLE_MENU_H +#include "lvgl.h" +void ui_ble_menu_open(void); // Public function +#endif +``` + +**Source File:** `ui_ble_menu.c` +Standard template from any Highboy screen: + +```c +#include "ui_ble_menu.h" +#include "ui_manager.h" +#include "lv_port_indev.h" // Access to main_group +#include "esp_log.h" + +static const char *TAG = "UI_BLE"; +static lv_obj_t * screen_ble = NULL; + +// 1. Event Callback (Navigation) +static void ble_event_cb(lv_event_t * e) { + lv_event_code_t code = lv_event_get_code(e); + + if (code == LV_EVENT_KEY) { + uint32_t key = lv_event_get_key(e); + // BACK BUTTON (ESC/LEFT) + if (key == LV_KEY_ESC || key == LV_KEY_LEFT) { + ESP_LOGI(TAG, "Returning to Main Menu"); + // Destroy current screen and open Menu + ui_switch_screen(SCREEN_MENU); + } + } +} + +// 2. Screen Build Function +void ui_ble_menu_open(void) { + // Safety cleanup + if (screen_ble) { + lv_obj_del(screen_ble); + screen_ble = NULL; + } + + // A. Create Base Screen + screen_ble = lv_obj_create(NULL); + lv_obj_set_style_bg_color(screen_ble, lv_color_black(), 0); + + // B. Add Content (e.g., Title) + lv_obj_t * label = lv_label_create(screen_ble); + lv_label_set_text(label, "Bluetooth Menu"); + lv_obj_set_style_text_color(label, lv_color_white(), 0); + lv_obj_align(label, LV_ALIGN_CENTER, 0, 0); + + // C. Setup Navigation + lv_obj_add_event_cb(screen_ble, ble_event_cb, LV_EVENT_KEY, NULL); + + // Add to Input Group (Essential!) + if (main_group) { + lv_group_add_obj(main_group, screen_ble); + lv_group_focus_obj(screen_ble); + } + + // D. Load Screen + lv_screen_load(screen_ble); +} +``` + +### 4. Link from the main Menu +Add a button/entru in the main menu to access the new screen + +**File:** `ui/screens/menu/ui_menu.c` +1. In the `menu_event_cb` callback, locate the corresponding item ID case and add/uncomment the call: +```c +case MENU_ID_BLUETOOTH: + ui_switch_screen(SCREEN_BLE_MENU); // <--- Routes to the new screen + break; +``` +(Note: If the MENU_ID_BLUETOOTH entry doesn't exist yet in menu_item_id_t, create it.) + +### 5. Update Build System (CMake) +Commom error: forgettint to register the new source files. + +**File:** `CMakeLists.txt` (UI component) +1. Add the new sources files and include directory: +```cmake +file(GLOB_RECURSE HOME_UI_SRCS "ui/screens/home/*.c") +file(GLOB_RECURSE MENU_UI_SRCS "ui/screens/menu/*.c") +file(GLOB_RECURSE WIFI_UI_SRCS "ui/screens/wifi/*.c") +file(GLOB_RECURSE BLE_UI_SRCS "ui/screens/ble/*.c") # <---- Add srcs here + +idf_component_register(SRCS + "ui/ui_manager.c" + ${HOME_UI_SRCS} + ${MENU_UI_SRCS} + ${WIFI_UI_SRCS} + ${BLE_UI_SRCS} # <----- and call it here + + INCLUDE_DIRS + "ui/include" + "ui/screens/home/include" + "ui/screens/menu/include" + "ui/screens/wifi/include" + "ui/screens/ble/include" # <----- dont forget include files +) +``` +2. Recommended: Run `idf.py reconfigure` in the terminal after saving + +--- + +## Long-running work and the watchdog + +Screens run on the UI thread under the LVGL lock (`ui_acquire` / `ui_release`). +Two hard rules keep the device from freezing or rebooting: + +1. **Always check `ui_acquire()`.** It uses a finite 1000 ms timeout and returns + `false` if the lock is held elsewhere. The screen template already guards + every access with `if (ui_acquire()) { ... ui_release(); }` - keep it that + way. Never assume the lock was taken. + +2. **Never block, sleep or loop while holding the lock, and never run a busy + loop on the UI thread.** Rendering liveness is supervised by `sys_monitor`: a + lightweight `lv_timer` bumps a render-progress beat (`ui_render_beat()`), and + if the beat stalls - a lock held too long, a runaway callback, or a frozen + LVGL task - the monitor does a controlled restart. Separately, a task that + spins a core past 5 s trips the idle Task Watchdog and panics + (`CONFIG_ESP_TASK_WDT_PANIC=y`). Either way the board recovers instead of + freezing. + +**Pattern for scans / captures / anything that takes more than a frame:** run it +in its own FreeRTOS task, not on the UI thread. The SubGhz receiver is the +reference - `subghz_rx_task` blocks on a queue (so it yields the CPU), never +touches the LVGL lock, and the screen (`subghz_read_ui`) only uses `lv_timer` +callbacks to refresh. Push results from the worker task back to the screen with +`lv_async_call` or an `lv_timer`; both run inside the LVGL task, already under +the lock. Create the worker with `xTaskCreatePinnedToCore` using a priority and +core from [`sys_prio.h`](../sys_prio/README.md) (radios / IO on core 0, UI-side +helpers on core 1). + +## Input: event-driven handlers + +Screens no longer poll the buttons with their own `lv_timer`. Input comes from +[`input_manager`](../input_manager/README.md) as debounced events, dispatched by +a single central pump to the active screen's handler. + +To add input to a screen: + +1. Write a handler `static void my_input(const input_event_t *ev, void *ctx)`. + The event carries `ev->button` (`INPUT_BTN_UP..BACK`) and `ev->action` + (`PRESS` / `RELEASE` / `LONG_PRESS` / `REPEAT`). `PRESS` is the debounced edge, + so there is no `s_*_last` bookkeeping. +2. Register it at the end of your `*_open()` with + `ui_input_set_screen_handler(my_input, NULL)`. + +That is all: no timer to create or delete, no `ui_input_is_locked()` / +`msgbox_is_open()` guards (the pump handles them), and the handler is cleared for +you on the next screen switch. Use `REPEAT` for held auto-scroll and +`input_is_down(button)` when you need a continuous held state (games). + +See `nfc_menu_ui.c` for the reference migration, and +[input-migration.md](input-migration.md) for the step-by-step guide to convert +the remaining ~91 screens off their polling timers (with the gotchas). + +## Screen power policy (auto-dim / sleep) + +`components/ui/components/power_policy` owns the always-on display power +behaviour in a single `lv_timer`, started once from `ui_init` under the LVGL +lock. It reads `input_last_activity_ms()` (from `input_manager`) and the +`screen` config (`g_config_screen.auto_lock_seconds`, `auto_dim`): + +- After `auto_lock_seconds` of no input it **fades** the backlight (a ramp, not + a step) and, when faded to zero, calls `lcd_display_sleep(true)` to cut the + panel. When `auto_dim` is set it first dims to a low level a few seconds + before sleeping. +- The user's chosen brightness is captured before dimming and restored on the + next input, so auto-dim never overwrites it. Transient changes use + `lcd_apply_brightness` (no persist); see [st7789](../st7789/README.md). +- Any input wakes the screen. `power_policy_is_asleep()` gates the central input + router so the wake press only wakes the display instead of also acting on the + underlying screen. + +The display settings screen writes `auto_lock_seconds` / `auto_dim` / brightness +through `tos_config`, which is the sole writer of the `screen` config file. + +## Execution Flow Sumamary +1. User selects **Bluetooth** from the Main Menu. +2. Menu callback calls `ui_switch_screen(SCREEN_BLE_MENU)`. +3. `ui_manager`: + - Handles hardware power (enables BLE if needed). + - Clears previous screen. + - Calls `ui_ble_menu_open()`. +4. `ui_ble_menu_open`: + - Creates visual objects. + - Adds objects to `main_group`. + - Loads the screen. + +**Done! The new screen is fully integrated, safe and navigable.** diff --git a/docs/ui/input-migration.md b/docs/ui/input-migration.md new file mode 100644 index 000000000..cdd29a624 --- /dev/null +++ b/docs/ui/input-migration.md @@ -0,0 +1,129 @@ +# Screen input migration guide + +How to convert one screen from the old per-screen polling `lv_timer` to the +central event-driven input. Background: [input_manager](../input_manager/README.md), +[ui: event-driven handlers](README.md#input-event-driven-handlers). + +**Status:** 7 screens migrated (see [Reference examples](#reference-examples)); +~91 still use `nav_timer_cb`. Find the remaining ones with: + +```sh +grep -rln "nav_timer_cb\|nav_cb\b" firmware_p4/components/Applications/ui/screens --include='*.c' +``` + +Nothing is broken in the meantime: unmigrated screens keep working through the +`buttons_gpio` shim. Migrate opportunistically, build after each batch. + +## What you are replacing + +Old pattern (every screen has its own copy): + +```c +#include "buttons_gpio.h" +#define NAV_TIMER_MS 50 +static lv_timer_t *s_nav_timer = NULL; +static bool s_btn_up_last = false; /* ... one per button ... */ + +static void nav_timer_cb(lv_timer_t *t) { + if (lv_screen_active() != s_screen) { lv_timer_delete(t); s_nav_timer = NULL; return; } + if (ui_input_is_locked()) return; + bool up = ui_btn_up(); bool down = ui_btn_down(); /* ...read all buttons... */ + if (down && !s_btn_down_last) menu_component_next(&s_menu); // edge detection + /* ... */ + s_btn_up_last = up; /* ...store last state... */ +} +/* in the open function: */ +if (s_nav_timer == NULL) s_nav_timer = lv_timer_create(nav_timer_cb, NAV_TIMER_MS, NULL); +``` + +New pattern: a handler that receives one debounced event at a time. + +```c +static void my_screen_input(const input_event_t *ev, void *ctx) { + (void)ctx; + const bool press = (ev->action == INPUT_ACTION_PRESS); + const bool nav = press || (ev->action == INPUT_ACTION_REPEAT); // held auto-scroll + switch (ev->button) { + case INPUT_BTN_DOWN: if (nav) menu_component_next(&s_menu); break; + case INPUT_BTN_UP: if (nav) menu_component_prev(&s_menu); break; + case INPUT_BTN_OK: if (press) { /* select */ } break; + case INPUT_BTN_BACK: if (press) ui_switch_screen(SCREEN_MENU); break; + default: break; + } +} +/* in the open function, instead of the lv_timer_create: */ +ui_input_set_screen_handler(my_screen_input, NULL); +``` + +## The recipe + +1. **Delete** the polling scaffolding: the `#include "buttons_gpio.h"`, the + `#define NAV_TIMER*`, the `static lv_timer_t *s_nav_timer`, and all the + `static bool s_*_last` edge statics. Keep `s_screen` and `s_menu`. +2. **Rewrite** `nav_timer_cb`'s body as the handler above: + - `if (X && !s_X_last)` (a fresh press) becomes `case INPUT_BTN_X: if (press)`. + - Navigation (UP/DOWN, or LEFT/RIGHT carousels) should use `nav` (PRESS + + REPEAT) so holding auto-scrolls. Actions (OK/BACK/select) use `press` only. + - Drop the `lv_screen_active()` guard, the `ui_input_is_locked()` guard, and + any `msgbox_is_open()` / `keyboard_is_open()` guards - the central pump + already handles all of them. +3. **Register** it: replace the `lv_timer_create(nav_timer_cb, ...)` line (and + the `if (s_nav_timer == NULL)` around it) with + `ui_input_set_screen_handler(my_screen_input, NULL);`. +4. **Build** and fix (see gotchas). Do not add a handler-clear on close; the UI + manager clears it on the next screen switch. + +## Gotchas (these bit me - check each) + +- **Definition order / forward declaration.** If the handler is defined *after* + the open function (common when `nav_timer_cb` was forward-declared and its body + sat at the bottom), the open function can't see it: add a forward declaration + near the top, e.g. `static void my_screen_input(const input_event_t *ev, void *ctx);`. +- **Handler references data defined lower in the file.** If your handler uses a + `MENU_ITEMS[]` array that is declared *below* where you put the handler, you'll + get "undeclared identifier". Put the handler *after* that array (where the old + `nav_timer_cb` body was), not up where the statics were. +- **State reset in the open function.** Some screens reset the edge statics on + open (`s_up_last = s_down_last = ... = false;`). Delete that line too - the + statics are gone. +- **Screens that need a continuous held state** (games: hold a direction to + move) should read `input_is_down(INPUT_BTN_X)` from their own render/tick + timer instead of edge events, or act on `INPUT_ACTION_REPEAT`. Do not force + these into a pure press-handler. +- **Multi-view screens** (a menu plus sub-screens driven by `switch (s_view)`) + keep the same structure: the handler switches on `s_view` first, then on + `ev->button`. Convert each `if (X && !s_X_last)` branch in place. +- **`TAG` may become unused** once the polling code is gone. The coding standard + keeps `TAG` in every `.c`; the unused-variable warning is not an error. + +## Reference examples + +Copy the closest match: + +| Screen | Pattern it shows | +|--------|------------------| +| `nfc/nfc_menu_ui.c` | Plainest menu: UP/DOWN nav, OK/RIGHT enter, BACK/LEFT out | +| `wifi/wifi_ui.c` | Same, but handler sits after the `MENU_ITEMS[]` array (ordering) | +| `connect_bluetooth/connect_bt_ui.c` | Any of OK/RIGHT/BACK/LEFT does one action | +| `infrared/ir_menu_ui.c` | Forward-declared handler (defined after `open`) | +| `dev/dev_menu_ui.c` | Forward decl **and** an edge-state reset removed from `open` | +| `bluetooth/ui_ble_menu.c` | Forward decl + `MENU_ITEMS` target dispatch | +| `games/games_menu_ui.c` | Carousel: LEFT/UP and RIGHT/DOWN cycle an index with REPEAT | + +## Remaining work (~91 screens) + +By area (run the grep above for the live list): bluetooth 15, wifi 14, nfc 13, +settings 11, lora 8, infrared 7, subghz 5, audio 4, dev 3, games 2, badusb 2, +and one each in theme, rfid, haptic, gpio, files, connect_wifi, +connection_settings. The simple area menus (settings, submenus) are quickest; +the games and multi-view NFC/RFID screens need the continuous-state / multi-view +handling above. + +## Verify + +```sh +cd firmware_p4 && idf.py build +``` + +A migrated screen should navigate exactly as before, plus hold-to-scroll on +UP/DOWN, and it no longer runs a per-screen timer. diff --git a/docs/wifi/README.md b/docs/wifi/README.md new file mode 100644 index 000000000..26fa45a1e --- /dev/null +++ b/docs/wifi/README.md @@ -0,0 +1,371 @@ +# P4 + +This component manages Wi-Fi functionalities including Access Point (AP) mode, Station (STA) mode, scanning, and configuration persistence using JSON files. + +## Functionality Overview + +The service handles: +- **Initialization/Deinitialization:** Setup of NVS, Netif, Event Loops, and Wi-Fi drivers. +- **Access Point (AP):** Configurable SSID, password, max connections, and custom IP address. +- **Scanning:** Active scanning for nearby networks. +- **Station (STA):** Connecting to external Wi-Fi networks. +- **Hotspot Management:** Dynamic switching of AP configuration. +- **Promiscuous Mode:** Low-level packet sniffing and environment monitoring. +- **Channel Hopping:** Automated cycling through Wi-Fi channels for environment monitoring. +- **Configuration Persistence:** AP/client settings loaded via `tos_config_load_all()` from SD (`config/wifi.conf`) with flash fallback (`/assets/config/wifi/wifi_ap.conf`). +- **Known Networks:** Automatically saves connected network credentials to `wifi/` on SD card. + +## API Functions + +### Initialization & Lifecycle + +#### `wifi_service_init` +```c +void wifi_service_init(void); +``` +Initializes the Wi-Fi stack in `APSTA` mode. +- Initializes NVS (performing erase if necessary). +- Sets up the default event loop and registers handlers. +- Loads AP configuration from storage (or uses defaults "Darth Maul"/"MyPassword123"). +- Configures the static IP (default: 192.168.4.1) and starts the DHCP server. + +#### `wifi_service_deinit` +```c +void wifi_service_deinit(void); +``` +Completely shuts down the Wi-Fi service. +- Stops the Wi-Fi driver. +- Unregisters event handlers. +- Deinitializes the driver. +- Frees synchronization primitives (mutexes) and clears static data. + +#### `wifi_service_start` / `wifi_service_stop` +```c +void wifi_service_start(void); +void wifi_service_stop(void); +``` +Simple wrappers to start or stop the Wi-Fi driver without full deinitialization. `wifi_service_stop` also clears stored scan results. + +### Scanning + +#### `wifi_service_scan` +```c +void wifi_service_scan(void); +``` +Performs an active Wi-Fi scan. +- Uses a mutex to ensure thread safety. +- Stores up to `WIFI_SCAN_LIST_SIZE` results internally. +- Provides visual feedback via LEDs (Green for AP connection, Red for failures, Blue for scan success). + +#### `wifi_service_get_ap_count` +```c +uint16_t wifi_service_get_ap_count(void); +``` +Returns the number of networks found in the last scan. + +#### `wifi_service_get_ap_record` +```c +wifi_ap_record_t* wifi_service_get_ap_record(uint16_t index); +``` +Retrieves a pointer to a specific scan result record. Returns `NULL` if the index is invalid. + +### Connection & Management + +#### `wifi_service_connect_to_ap` +```c +esp_err_t wifi_service_connect_to_ap(const char *ssid, const char *password); +``` +Connects the device (as a station) to an external Access Point. +- Configures authentication mode based on the presence of a password (WPA2_PSK or OPEN). +- Disconnects any existing connection before attempting a new one. +- **Persistence:** Automatically saves the SSID and password to `assets/storage/wifi/know_networks.json`. If the network already exists, the password is updated. + +#### `wifi_service_is_connected` +```c +bool wifi_service_is_connected(void); +``` +Returns `true` if the device is currently connected to an external Wi-Fi network and has an IP address. + +#### `wifi_service_is_active` +```c +bool wifi_service_is_active(void); +``` +Returns `true` if the Wi-Fi service is started (driver initialized and interface up). + +#### `wifi_service_get_connected_ssid` +```c +const char* wifi_service_get_connected_ssid(void); +``` +Returns the SSID of the currently connected network. Returns `NULL` if not connected. + +#### `wifi_service_change_to_hotspot` +```c +void wifi_service_change_to_hotspot(const char *new_ssid); +``` +Dynamically reconfigures the device's Access Point to an **Open** network with the specified SSID. +- Stops the Wi-Fi driver briefly to apply changes. +- Sets `authmode` to `WIFI_AUTH_OPEN`. +- Restarts Wi-Fi with the new configuration. + +### Promiscuous Mode + +#### `wifi_service_promiscuous_start` +```c +void wifi_service_promiscuous_start(wifi_promiscuous_cb_t cb, wifi_promiscuous_filter_t *filter); +``` +Enables promiscuous mode (sniffer) with a custom callback and filter. +- `cb`: Function to handle captured packets. +- `filter`: Filter mask (e.g., `WIFI_PROMIS_FILTER_MASK_MGMT`). + +#### `wifi_service_promiscuous_stop` +```c +void wifi_service_promiscuous_stop(void); +``` +Disables promiscuous mode and clears the callback. + +### Channel Hopping + +#### `wifi_service_start_channel_hopping` +```c +void wifi_service_start_channel_hopping(void); +``` +Starts a background task that cycles the Wi-Fi interface through channels 1 to 13. +- Useful for promiscuous mode applications (e.g., deauth detection). +- Task memory is allocated in PSRAM if available. + +#### `wifi_service_stop_channel_hopping` +```c +void wifi_service_stop_channel_hopping(void); +``` +Stops the channel hopping task and frees associated memory resources. + +### Configuration Storage + +#### `wifi_service_save_ap_config` +```c +esp_err_t wifi_service_save_ap_config(const char *ssid, const char *password, uint8_t max_conn, const char *ip_addr, bool enabled); +``` +Saves the AP configuration to a JSON file (`/assets/config/wifi/wifi_ap.conf`). +- Uses `cJSON` to serialize settings. +- Persists data using the storage API. +- **State Management:** If `enabled` is `true` and Wi-Fi is inactive, it calls `wifi_service_start()`. If `enabled` is `false` and Wi-Fi is active, it calls `wifi_service_stop()`. + +#### Individual Setters +Helper functions to update a single configuration parameter while preserving others. They automatically save the config and trigger state changes if `enabled` is toggled. + +```c +esp_err_t wifi_service_set_enabled(bool enabled); +esp_err_t wifi_service_set_ap_ssid(const char *ssid); +esp_err_t wifi_service_set_ap_password(const char *password); +esp_err_t wifi_service_set_ap_max_conn(uint8_t max_conn); +esp_err_t wifi_service_set_ap_ip(const char *ip_addr); +``` + +**Internal Loader:** `wifi_service_load_ap_config` is called during initialization to read these settings. If `enabled` is found to be `false` in the config, `wifi_service_init` will initialize the driver but **not** start the radio. + +## Internal Implementation Details + +### Event Handling +A static `wifi_event_handler` manages Wi-Fi and IP events: +- **WIFI_EVENT_AP_STACONNECTED:** Logs the MAC of the connected station and blinks Green. +- **WIFI_EVENT_AP_STADISCONNECTED:** Blinks Red. +- **IP_EVENT_AP_STAIPASSIGNED:** Logs IP assignment and blinks Green. + +### Thread Safety +A `wifi_mutex` (Semaphore) is used to protect the scanning process (`wifi_service_scan`), preventing concurrent scan requests which could lead to resource conflicts. + +### Channel Hopping Task +The channel hopping feature runs as a static FreeRTOS task. It uses `esp_wifi_set_channel` to switch channels every 250ms. To optimize internal RAM usage, both the task stack and the Task Control Block (TCB) are allocated in **PSRAM** using the `SPIRAM` capability. + +### Castings & Memory Management +- **cJSON:** Used extensively for parsing and generating configuration files. +- **PSRAM Allocation:** Critical tasks and large buffers are allocated in PSRAM to preserve internal memory. +- **Type Casting:** `event_data` is cast to specific event structures (e.g., `wifi_event_ap_staconnected_t*`) within handlers. +- **String Handling:** `strncpy` is used safely with explicit null-termination to prevent buffer overflows when handling SSIDs and passwords. + +--- + +# C5 + +This component manages Wi-Fi functionalities including Access Point (AP) mode, Station (STA) mode, scanning, and configuration persistence using JSON files. + +## Functionality Overview + +The service handles: +- **Initialization/Deinitialization:** Setup of NVS, Netif, Event Loops, and Wi-Fi drivers. +- **Access Point (AP):** Configurable SSID, password, max connections, and custom IP address. +- **Scanning:** Active scanning for nearby networks. +- **Station (STA):** Connecting to external Wi-Fi networks. +- **Hotspot Management:** Dynamic switching of AP configuration. +- **Promiscuous Mode:** Low-level packet sniffing and environment monitoring. +- **Channel Hopping:** Automated cycling through Wi-Fi channels for environment monitoring. +- **Configuration Persistence:** Loading and saving AP settings to/from `assets/config/wifi/wifi_ap.conf`. +- **Known Networks:** Automatically saves connected network credentials to `assets/storage/wifi/know_networks.json`. + +## API Functions + +### Initialization & Lifecycle + +#### `wifi_service_init` +```c +void wifi_service_init(void); +``` +Initializes the Wi-Fi stack in `APSTA` mode. +- Initializes NVS (performing erase if necessary). +- Sets up the default event loop and registers handlers. +- Loads AP configuration from storage (or uses defaults "Darth Maul"/"MyPassword123"). +- Configures the static IP (default: 192.168.4.1) and starts the DHCP server. + +#### `wifi_service_deinit` +```c +void wifi_service_deinit(void); +``` +Completely shuts down the Wi-Fi service. +- Stops the Wi-Fi driver. +- Unregisters event handlers. +- Deinitializes the driver. +- Frees synchronization primitives (mutexes) and clears static data. + +#### `wifi_service_start` / `wifi_service_stop` +```c +void wifi_service_start(void); +void wifi_service_stop(void); +``` +Simple wrappers to start or stop the Wi-Fi driver without full deinitialization. `wifi_service_stop` also clears stored scan results. + +### Scanning + +#### `wifi_service_scan` +```c +void wifi_service_scan(void); +``` +Performs an active Wi-Fi scan. +- Uses a mutex to ensure thread safety. +- Stores up to `WIFI_SCAN_LIST_SIZE` results internally. +- Provides visual feedback via LEDs (Green for AP connection, Red for failures, Blue for scan success). + +#### `wifi_service_get_ap_count` +```c +uint16_t wifi_service_get_ap_count(void); +``` +Returns the number of networks found in the last scan. + +#### `wifi_service_get_ap_record` +```c +wifi_ap_record_t* wifi_service_get_ap_record(uint16_t index); +``` +Retrieves a pointer to a specific scan result record. Returns `NULL` if the index is invalid. + +### Connection & Management + +#### `wifi_service_connect_to_ap` +```c +esp_err_t wifi_service_connect_to_ap(const char *ssid, const char *password); +``` +Connects the device (as a station) to an external Access Point. +- Configures authentication mode based on the presence of a password (WPA2_PSK or OPEN). +- Disconnects any existing connection before attempting a new one. +- **Persistence:** Automatically saves the SSID and password to `assets/storage/wifi/know_networks.json`. If the network already exists, the password is updated. + +#### `wifi_service_is_connected` +```c +bool wifi_service_is_connected(void); +``` +Returns `true` if the device is currently connected to an external Wi-Fi network and has an IP address. + +#### `wifi_service_is_active` +```c +bool wifi_service_is_active(void); +``` +Returns `true` if the Wi-Fi service is started (driver initialized and interface up). + +#### `wifi_service_get_connected_ssid` +```c +const char* wifi_service_get_connected_ssid(void); +``` +Returns the SSID of the currently connected network. Returns `NULL` if not connected. + +#### `wifi_service_change_to_hotspot` +```c +void wifi_service_change_to_hotspot(const char *new_ssid); +``` +Dynamically reconfigures the device's Access Point to an **Open** network with the specified SSID. +- Stops the Wi-Fi driver briefly to apply changes. +- Sets `authmode` to `WIFI_AUTH_OPEN`. +- Restarts Wi-Fi with the new configuration. + +### Promiscuous Mode + +#### `wifi_service_promiscuous_start` +```c +void wifi_service_promiscuous_start(wifi_promiscuous_cb_t cb, wifi_promiscuous_filter_t *filter); +``` +Enables promiscuous mode (sniffer) with a custom callback and filter. +- `cb`: Function to handle captured packets. +- `filter`: Filter mask (e.g., `WIFI_PROMIS_FILTER_MASK_MGMT`). + +#### `wifi_service_promiscuous_stop` +```c +void wifi_service_promiscuous_stop(void); +``` +Disables promiscuous mode and clears the callback. + +### Channel Hopping + +#### `wifi_service_start_channel_hopping` +```c +void wifi_service_start_channel_hopping(void); +``` +Starts a background task that cycles the Wi-Fi interface through channels 1 to 13. +- Useful for promiscuous mode applications (e.g., deauth detection). +- Task memory is allocated in PSRAM if available. + +#### `wifi_service_stop_channel_hopping` +```c +void wifi_service_stop_channel_hopping(void); +``` +Stops the channel hopping task and frees associated memory resources. + +### Configuration Storage + +#### `wifi_service_save_ap_config` +```c +esp_err_t wifi_service_save_ap_config(const char *ssid, const char *password, uint8_t max_conn, const char *ip_addr, bool enabled); +``` +Saves the AP configuration to a JSON file (`/assets/config/wifi/wifi_ap.conf`). +- Uses `cJSON` to serialize settings. +- Persists data using the storage API. +- **State Management:** If `enabled` is `true` and Wi-Fi is inactive, it calls `wifi_service_start()`. If `enabled` is `false` and Wi-Fi is active, it calls `wifi_service_stop()`. + +#### Individual Setters +Helper functions to update a single configuration parameter while preserving others. They automatically save the config and trigger state changes if `enabled` is toggled. + +```c +esp_err_t wifi_service_set_enabled(bool enabled); +esp_err_t wifi_service_set_ap_ssid(const char *ssid); +esp_err_t wifi_service_set_ap_password(const char *password); +esp_err_t wifi_service_set_ap_max_conn(uint8_t max_conn); +esp_err_t wifi_service_set_ap_ip(const char *ip_addr); +``` + +**Internal Loader:** `wifi_service_load_ap_config` is called during initialization to read these settings. If `enabled` is found to be `false` in the config, `wifi_service_init` will initialize the driver but **not** start the radio. + +## Internal Implementation Details + +### Event Handling +A static `wifi_event_handler` manages Wi-Fi and IP events: +- **WIFI_EVENT_AP_STACONNECTED:** Logs the MAC of the connected station and blinks Green. +- **WIFI_EVENT_AP_STADISCONNECTED:** Blinks Red. +- **IP_EVENT_AP_STAIPASSIGNED:** Logs IP assignment and blinks Green. + +### Thread Safety +A `wifi_mutex` (Semaphore) is used to protect the scanning process (`wifi_service_scan`), preventing concurrent scan requests which could lead to resource conflicts. + +### Channel Hopping Task +The channel hopping feature runs as a static FreeRTOS task. It uses `esp_wifi_set_channel` to switch channels every 250ms. To optimize internal RAM usage, both the task stack and the Task Control Block (TCB) are allocated in **PSRAM** using the `SPIRAM` capability. + +### Castings & Memory Management +- **cJSON:** Used extensively for parsing and generating configuration files. +- **PSRAM Allocation:** Critical tasks and large buffers are allocated in PSRAM to preserve internal memory. +- **Type Casting:** `event_data` is cast to specific event structures (e.g., `wifi_event_ap_staconnected_t*`) within handlers. +- **String Handling:** `strncpy` is used safely with explicit null-termination to prevent buffer overflows when handling SSIDs and passwords. diff --git a/firmware_c5/CMakeLists.txt b/firmware_c5/CMakeLists.txt index ab4f82484..37fbd4a49 100644 --- a/firmware_c5/CMakeLists.txt +++ b/firmware_c5/CMakeLists.txt @@ -12,33 +12,24 @@ include($ENV{IDF_PATH}/tools/cmake/project.cmake) project(TentacleOS_C5) -if(CMAKE_HOST_SYSTEM_NAME STREQUAL "Windows") - # Windows Detection - message(STATUS "Host System: Windows. Using PowerShell script.") - set(CONVERT_SCRIPT "${CMAKE_SOURCE_DIR}/../tools/png_to_bin/png_conversor_to_bin.ps1") - set(CONVERT_COMMAND powershell.exe -ExecutionPolicy Bypass -File "${CONVERT_SCRIPT}") - -elseif(CMAKE_HOST_SYSTEM_NAME STREQUAL "Linux" OR CMAKE_HOST_SYSTEM_NAME STREQUAL "Darwin") - # Linux or macOS Detection - message(STATUS "Host System: ${CMAKE_HOST_SYSTEM_NAME}. Using Bash script.") - set(CONVERT_SCRIPT "${CMAKE_SOURCE_DIR}/../tools/png_to_bin/png_conversor_to_bin.sh") - set(CONVERT_COMMAND bash "${CONVERT_SCRIPT}") - -else() - message(FATAL_ERROR "Unsupported Operating System: ${CMAKE_HOST_SYSTEM_NAME}") -endif() - -# Execute the conversion process +# The C5 is headless: its assets are plain config/html/storage files (no PNG +# icons or fonts), so there is no png->bin conversion step like the P4 has. The +# assets/ tree is staged into temp/ so the build can stamp the current version +# into firmware.json before imaging, without dirtying the source tree. +# The `storage` partition holds runtime captures (evil twin passwords, pcaps, +# GATT dumps, espnow chat) and is formatted on first boot (VFS_FORMAT_ON_FAIL). execute_process( - COMMAND ${CONVERT_COMMAND} - WORKING_DIRECTORY "${CMAKE_SOURCE_DIR}" - RESULT_VARIABLE convert_result + COMMAND ${CMAKE_COMMAND} -E copy_directory + "${CMAKE_CURRENT_LIST_DIR}/assets" "${CMAKE_CURRENT_LIST_DIR}/temp" ) -if(NOT convert_result EQUAL 0) - message(FATAL_ERROR "Failed to convert assets. Check the logs above for details.") +file(STRINGS "${CMAKE_CURRENT_LIST_DIR}/../common/metadata/version_info.txt" FW_VERSION LIMIT_COUNT 1) +string(STRIP "${FW_VERSION}" FW_VERSION) +set(FW_JSON "${CMAKE_CURRENT_LIST_DIR}/temp/config/OTA/firmware.json") +if(EXISTS "${FW_JSON}") + file(READ "${FW_JSON}" _fw_json) + string(REGEX REPLACE "\"version\"[ \t]*:[ \t]*\"[^\"]*\"" "\"version\": \"${FW_VERSION}\"" _fw_json "${_fw_json}") + file(WRITE "${FW_JSON}" "${_fw_json}") endif() -# ============================================================================== - littlefs_create_partition_image(assets temp FLASH_IN_PROJECT) \ No newline at end of file diff --git a/firmware_c5/components/Applications/bluetooth/ble_connect_flood.c b/firmware_c5/components/Applications/bluetooth/ble_connect_flood.c index c1d47e701..05402dac1 100644 --- a/firmware_c5/components/Applications/bluetooth/ble_connect_flood.c +++ b/firmware_c5/components/Applications/bluetooth/ble_connect_flood.c @@ -22,6 +22,7 @@ #include "freertos/FreeRTOS.h" #include "freertos/semphr.h" #include "freertos/task.h" +#include "sys_prio.h" #include "host/ble_gap.h" #include "bluetooth_service.h" @@ -29,7 +30,7 @@ static const char *TAG = "BLE_CONNECT_FLOOD"; #define FLOOD_STACK_SIZE 4096 -#define FLOOD_TASK_PRIORITY 5 +#define FLOOD_TASK_PRIORITY SYS_PRIO_SERVICE_HI #define FLOOD_CONNECT_TIMEOUT 5000 #define FLOOD_RETRY_DELAY_MS 100 #define FLOOD_CYCLE_DELAY_MS 20 diff --git a/firmware_c5/components/Applications/bluetooth/ble_l2cap_flood.c b/firmware_c5/components/Applications/bluetooth/ble_l2cap_flood.c index e6882e3a7..d5c11e2da 100644 --- a/firmware_c5/components/Applications/bluetooth/ble_l2cap_flood.c +++ b/firmware_c5/components/Applications/bluetooth/ble_l2cap_flood.c @@ -22,6 +22,7 @@ #include "freertos/FreeRTOS.h" #include "freertos/semphr.h" #include "freertos/task.h" +#include "sys_prio.h" #include "host/ble_gap.h" #include "bluetooth_service.h" @@ -29,7 +30,7 @@ static const char *TAG = "BLE_L2CAP_FLOOD"; #define L2CAP_STACK_SIZE 4096 -#define L2CAP_TASK_PRIORITY 5 +#define L2CAP_TASK_PRIORITY SYS_PRIO_SERVICE_HI #define L2CAP_CONNECT_TIMEOUT 10000 #define L2CAP_RETRY_DELAY_MS 100 #define L2CAP_RECONNECT_DELAY_MS 1000 diff --git a/firmware_c5/components/Applications/bluetooth/ble_scanner.c b/firmware_c5/components/Applications/bluetooth/ble_scanner.c index c6ed9667d..0b576a90a 100644 --- a/firmware_c5/components/Applications/bluetooth/ble_scanner.c +++ b/firmware_c5/components/Applications/bluetooth/ble_scanner.c @@ -22,6 +22,7 @@ #include "esp_log.h" #include "freertos/FreeRTOS.h" #include "freertos/task.h" +#include "sys_prio.h" #include "sys/stat.h" #include "bluetooth_service.h" @@ -34,7 +35,7 @@ static const char *TAG = "BLE_SCANNER"; #define SCANNER_STACK_SIZE 4096 -#define SCANNER_TASK_PRIORITY 5 +#define SCANNER_TASK_PRIORITY SYS_PRIO_SERVICE_HI #define SCAN_DURATION_MS 10000 #define DIR_PERMISSIONS 0777 diff --git a/firmware_c5/components/Applications/bluetooth/canned_spam.c b/firmware_c5/components/Applications/bluetooth/canned_spam.c index 478d3dcb8..1f2efd483 100644 --- a/firmware_c5/components/Applications/bluetooth/canned_spam.c +++ b/firmware_c5/components/Applications/bluetooth/canned_spam.c @@ -28,6 +28,7 @@ #include "esp_log.h" #include "freertos/FreeRTOS.h" #include "freertos/task.h" +#include "sys_prio.h" #include "host/ble_gap.h" #include "host/ble_hs.h" #include "host/ble_hs_id.h" @@ -43,7 +44,7 @@ static const char *TAG = "CANNED_SPAM"; #define SPAM_TASK_STACK_SIZE 4096 -#define SPAM_TASK_PRIORITY 5 +#define SPAM_TASK_PRIORITY SYS_PRIO_SERVICE_HI #define SPAM_ADV_ITVL_MIN 32 #define SPAM_ADV_ITVL_MAX 48 #define SPAM_PAYLOAD_BUF_SIZE 32 diff --git a/firmware_c5/components/Applications/bluetooth/gatt_explorer.c b/firmware_c5/components/Applications/bluetooth/gatt_explorer.c index 2df5028da..ee8c7e627 100644 --- a/firmware_c5/components/Applications/bluetooth/gatt_explorer.c +++ b/firmware_c5/components/Applications/bluetooth/gatt_explorer.c @@ -23,6 +23,7 @@ #include "freertos/FreeRTOS.h" #include "freertos/semphr.h" #include "freertos/task.h" +#include "sys_prio.h" #include "host/ble_gatt.h" #include "host/ble_hs.h" @@ -35,7 +36,7 @@ static const char *TAG = "GATT_EXPLORER"; #define MAX_DISCOVERED_SVCS 16 #define EXPLORER_STACK_SIZE 6144 -#define EXPLORER_TASK_PRIORITY 5 +#define EXPLORER_TASK_PRIORITY SYS_PRIO_SERVICE_HI #define EXPLORER_TIMEOUT_MS 60000 #define ADDR_STR_LEN 18 #define SVC_NAME_MAX_LEN 64 diff --git a/firmware_c5/components/Applications/bluetooth/skimmer_detector.c b/firmware_c5/components/Applications/bluetooth/skimmer_detector.c index 3e5254992..f6b9f7a79 100644 --- a/firmware_c5/components/Applications/bluetooth/skimmer_detector.c +++ b/firmware_c5/components/Applications/bluetooth/skimmer_detector.c @@ -24,6 +24,7 @@ #include "freertos/FreeRTOS.h" #include "freertos/semphr.h" #include "freertos/task.h" +#include "sys_prio.h" #include "host/ble_hs.h" #include "bluetooth_service.h" @@ -31,7 +32,7 @@ static const char *TAG = "SKIMMER_DETECTOR"; #define SKIMMER_TASK_STACK_SIZE 4096 -#define SKIMMER_TASK_PRIORITY 5 +#define SKIMMER_TASK_PRIORITY SYS_PRIO_SERVICE_HI #define SKIMMER_TIMEOUT_MS 30000 #define SKIMMER_POLL_DELAY_MS 1000 #define SKIMMER_MUTEX_TIMEOUT_MS 100 diff --git a/firmware_c5/components/Applications/bluetooth/tracker_detector.c b/firmware_c5/components/Applications/bluetooth/tracker_detector.c index 780873e89..572ebdfac 100644 --- a/firmware_c5/components/Applications/bluetooth/tracker_detector.c +++ b/firmware_c5/components/Applications/bluetooth/tracker_detector.c @@ -23,6 +23,7 @@ #include "freertos/FreeRTOS.h" #include "freertos/semphr.h" #include "freertos/task.h" +#include "sys_prio.h" #include "host/ble_hs.h" #include "bluetooth_service.h" @@ -30,7 +31,7 @@ static const char *TAG = "TRACKER_DETECTOR"; #define TRACKER_TASK_STACK_SIZE 4096 -#define TRACKER_TASK_PRIORITY 5 +#define TRACKER_TASK_PRIORITY SYS_PRIO_SERVICE_HI #define TRACKER_TIMEOUT_MS 30000 #define TRACKER_POLL_DELAY_MS 1000 #define TRACKER_MUTEX_TIMEOUT_MS 100 diff --git a/firmware_c5/components/Applications/espnow_chat/README.md b/firmware_c5/components/Applications/espnow_chat/README.md index 2af69383e..3416d427d 100644 --- a/firmware_c5/components/Applications/espnow_chat/README.md +++ b/firmware_c5/components/Applications/espnow_chat/README.md @@ -1,98 +1,7 @@ # ESP-NOW Chat Application -The **ESP-NOW Chat Application** is the high-level logic layer that bridges the raw `Service` capabilities with the User Interface (UI). It handles business logic, event notification, and data formatting for the display. +Documentation for this component lives in the project docs hub (single source of truth): -## Overview - -This component sits between the **UI Manager** (LVGL) and the **ESP-NOW Service**. It ensures that the UI doesn't need to know about raw bytes, MAC addresses, or packet types, providing a clean API for "sending messages" and "listing users". - -## Features - -- **Event-Driven UI Updates**: Provides a callback mechanism so the UI only updates when necessary (new message, new device found). -- **System Notifications**: automatically injects system messages (e.g., "Secure Pair with User!") into the chat stream. -- **Simplified API**: Wraps complex service calls into single-line functions for the UI. -- **Data Abstraction**: Converts service-level structs into UI-friendly structs. - -## Integration Guide - -### 1. Initialization -In your `main.c` or `ui_manager.c`: - -```c -#include "espnow_chat.h" - -void app_main() { - // ... WiFi Init ... - - // Initialize the Chat App - espnow_chat_init(); - - // Register UI Callbacks - espnow_chat_register_msg_cb(my_ui_message_handler); - espnow_chat_register_refresh_cb(my_ui_device_list_refresh); -} -``` - -### 2. Handling Messages in UI -The UI should implement a callback to receive messages: - -```c -void my_ui_message_handler(const char *sender_nick, const char *message, bool is_system_msg) { - if (is_system_msg) { - // Render in yellow/red - ui_chat_add_bubble_system(message); - } else { - // Render in bubble - ui_chat_add_bubble(sender_nick, message); - } -} -``` - -### 3. Listing Devices -When the user opens the "Scan" tab, the UI calls: - -```c -espnow_chat_peer_t peers[10]; -int count = espnow_chat_get_peer_list(peers, 10); - -for(int i=0; i UI calls `espnow_chat_broadcast_discovery()`. - - Service sends HELLO. - - Other devices receive HELLO -> Service auto-adds to list -> App triggers `refresh_cb` -> UI updates list. - -2. **Chatting**: - - User taps a device -> UI enters Chat Screen. - - User types "Hi" -> UI calls `espnow_chat_send_message()`. - - Service encrypts & sends. - -3. **Secure Pairing**: - - User taps "Secure Pair" -> UI calls `espnow_chat_secure_pair()`. - - Service generates Key (if none) -> Sends `KEY_SHARE` packet. - - Target receives `KEY_SHARE` -> App triggers `msg_cb` ("Secure Pair with X!") -> Service saves key. - - Future messages are now secure. +- [docs/espnow_chat/README.md](../../../../docs/espnow_chat/README.md) +This file is only a pointer: edit the docs there to keep things from drifting. diff --git a/firmware_c5/components/Applications/wifi/ap_scanner.c b/firmware_c5/components/Applications/wifi/ap_scanner.c index 37883d333..9c3c35a9e 100644 --- a/firmware_c5/components/Applications/wifi/ap_scanner.c +++ b/firmware_c5/components/Applications/wifi/ap_scanner.c @@ -22,18 +22,14 @@ #include "esp_wifi.h" #include "freertos/FreeRTOS.h" #include "freertos/task.h" +#include "sys_prio.h" -#include "cJSON.h" -#include "sd_card_init.h" -#include "sd_card_write.h" -#include "storage_write.h" -#include "tos_flash_paths.h" #include "wifi_service.h" static const char *TAG = "AP_SCANNER"; #define SCANNER_STACK_SIZE 4096 -#define SCANNER_TASK_PRIORITY 5 +#define SCANNER_TASK_PRIORITY SYS_PRIO_SERVICE_HI static TaskHandle_t s_scanner_task_handle = NULL; static StackType_t *s_scanner_task_stack = NULL; @@ -43,8 +39,6 @@ static wifi_ap_record_t *s_scan_results = NULL; static uint16_t s_scan_count = 0; static bool s_is_scanning = false; -static const char *get_auth_mode_name(wifi_auth_mode_t auth_mode); -static bool save_results_to_path(const char *path, bool use_sd_driver); static void scanner_task(void *pvParameters); bool ap_scanner_start(void) { @@ -114,106 +108,14 @@ void ap_scanner_free_results(void) { } } +// Scan persistence lives on the P4 (it pulls results over SPI and writes the SD). +// The C5 keeps results only in PSRAM for that pull, so these are no-ops. bool ap_scanner_save_results_to_internal_flash(void) { - return save_results_to_path(FLASH_STORAGE_WIFI_APS, false); + return false; } bool ap_scanner_save_results_to_sd_card(void) { - return save_results_to_path("/scanned_aps.json", true); -} - -static const char *get_auth_mode_name(wifi_auth_mode_t auth_mode) { - switch (auth_mode) { - case WIFI_AUTH_OPEN: - return "OPEN"; - case WIFI_AUTH_WEP: - return "WEP"; - case WIFI_AUTH_WPA_PSK: - return "WPA-PSK"; - case WIFI_AUTH_WPA2_PSK: - return "WPA2-PSK"; - case WIFI_AUTH_WPA_WPA2_PSK: - return "WPA/WPA2-PSK"; - case WIFI_AUTH_WPA2_ENTERPRISE: - return "WPA2-ENT"; - case WIFI_AUTH_WPA3_PSK: - return "WPA3-PSK"; - case WIFI_AUTH_WPA2_WPA3_PSK: - return "WPA2/WPA3-PSK"; - default: - return "Unknown"; - } -} - -static bool save_results_to_path(const char *path, bool use_sd_driver) { - if (s_scan_results == NULL || s_scan_count == 0) { - ESP_LOGW(TAG, "No results to save."); - return false; - } - - cJSON *root = cJSON_CreateArray(); - if (root == NULL) { - ESP_LOGE(TAG, "Failed to create JSON array."); - return false; - } - - for (int i = 0; i < s_scan_count; i++) { - wifi_ap_record_t *ap = &s_scan_results[i]; - cJSON *entry = cJSON_CreateObject(); - - cJSON_AddStringToObject(entry, "ssid", (char *)ap->ssid); - - char bssid_str[18]; - snprintf(bssid_str, - sizeof(bssid_str), - "%02x:%02x:%02x:%02x:%02x:%02x", - ap->bssid[0], - ap->bssid[1], - ap->bssid[2], - ap->bssid[3], - ap->bssid[4], - ap->bssid[5]); - cJSON_AddStringToObject(entry, "bssid", bssid_str); - - cJSON_AddNumberToObject(entry, "rssi", ap->rssi); - cJSON_AddNumberToObject(entry, "channel", ap->primary); - cJSON_AddNumberToObject(entry, "authmode", ap->authmode); - cJSON_AddStringToObject(entry, "auth_str", get_auth_mode_name(ap->authmode)); - cJSON_AddBoolToObject(entry, "wps", ap->wps); - - cJSON_AddItemToArray(root, entry); - } - - char *json_string = cJSON_PrintUnformatted(root); - if (json_string == NULL) { - ESP_LOGE(TAG, "Failed to print JSON."); - cJSON_Delete(root); - return false; - } - - esp_err_t err; - if (use_sd_driver) { - if (!sd_is_mounted()) { - ESP_LOGE(TAG, "SD Card not mounted."); - free(json_string); - cJSON_Delete(root); - return false; - } - err = sd_write_string(path, json_string); - } else { - err = storage_write_string(path, json_string); - } - - free(json_string); - cJSON_Delete(root); - - if (err != ESP_OK) { - ESP_LOGE(TAG, "Failed to write results to %s: %s", path, esp_err_to_name(err)); - return false; - } - - ESP_LOGI(TAG, "Scan results saved to %s", path); - return true; + return false; } static void scanner_task(void *pvParameters) { @@ -249,9 +151,8 @@ static void scanner_task(void *pvParameters) { } } ESP_LOGI(TAG, "Results copied to PSRAM."); - - ap_scanner_save_results_to_internal_flash(); - + // Results are pulled by the P4 over SPI (SYSTEM_DATA) and persisted there. + // The C5 does not save scans locally. } else { ESP_LOGE(TAG, "Failed to allocate memory for results in PSRAM!"); } diff --git a/firmware_c5/components/Applications/wifi/beacon_spam.c b/firmware_c5/components/Applications/wifi/beacon_spam.c index 2eef46278..f9ef3b723 100644 --- a/firmware_c5/components/Applications/wifi/beacon_spam.c +++ b/firmware_c5/components/Applications/wifi/beacon_spam.c @@ -25,6 +25,7 @@ #include "esp_wifi.h" #include "freertos/FreeRTOS.h" #include "freertos/task.h" +#include "sys_prio.h" #include "cJSON.h" #include "storage_impl.h" @@ -35,7 +36,7 @@ static const char *TAG = "BEACON_SPAM"; #define BEACON_SPAM_MAX_SSIDS 100 #define BEACON_SPAM_INTERVAL_MS 100 #define BEACON_SPAM_STACK_SIZE 4096 -#define BEACON_SPAM_TASK_PRIORITY 5 +#define BEACON_SPAM_TASK_PRIORITY SYS_PRIO_SERVICE_HI #define BEACON_SPAM_TX_DELAY_MS 10 #define BEACON_SPAM_MAX_SSID_LEN 32 #define BEACON_SPAM_CHANNEL_COUNT 11 diff --git a/firmware_c5/components/Applications/wifi/client_scanner.c b/firmware_c5/components/Applications/wifi/client_scanner.c index 35feba757..56f98ef65 100644 --- a/firmware_c5/components/Applications/wifi/client_scanner.c +++ b/firmware_c5/components/Applications/wifi/client_scanner.c @@ -22,20 +22,15 @@ #include "esp_wifi.h" #include "freertos/FreeRTOS.h" #include "freertos/task.h" +#include "sys_prio.h" -#include "cJSON.h" -#include "mac_vendor.h" -#include "sd_card_init.h" -#include "sd_card_write.h" -#include "storage_write.h" -#include "tos_flash_paths.h" #include "wifi_80211.h" #include "wifi_service.h" static const char *TAG = "CLIENT_SCANNER"; #define SCANNER_STACK_SIZE 4096 -#define SCANNER_TASK_PRIORITY 5 +#define SCANNER_TASK_PRIORITY SYS_PRIO_SERVICE_HI #define SCAN_DURATION_MS 15000 #define MAX_SCAN_RESULTS 200 #define BSSID_LEN 6 @@ -52,7 +47,6 @@ static bool s_is_scanning = false; static void add_or_update_client(const uint8_t *bssid, const uint8_t *client_mac, int8_t rssi, uint8_t channel); static void sniffer_callback(void *buf, wifi_promiscuous_pkt_type_t type); -static bool save_results_to_path(const char *path, bool use_sd_driver); static void scanner_task(void *pvParameters); bool client_scanner_start(void) { @@ -122,12 +116,14 @@ void client_scanner_free_results(void) { } } +// Scan persistence lives on the P4 (it pulls results over SPI and writes the SD). +// The C5 keeps results only in PSRAM for that pull, so these are no-ops. bool client_scanner_save_results_to_internal_flash(void) { - return save_results_to_path(FLASH_STORAGE_WIFI_CLIENTS, false); + return false; } bool client_scanner_save_results_to_sd_card(void) { - return save_results_to_path("/scanned_clients.json", true); + return false; } static void add_or_update_client(const uint8_t *bssid, @@ -203,102 +199,6 @@ static void sniffer_callback(void *buf, wifi_promiscuous_pkt_type_t type) { add_or_update_client(bssid, client, ppkt->rx_ctrl.rssi, channel); } -static bool save_results_to_path(const char *path, bool use_sd_driver) { - if (s_scan_results == NULL || s_scan_count == 0) { - ESP_LOGW(TAG, "No results to save."); - return false; - } - - cJSON *root = cJSON_CreateArray(); - if (root == NULL) { - ESP_LOGE(TAG, "Failed to create JSON array."); - return false; - } - - for (int i = 0; i < s_scan_count; i++) { - client_scanner_record_t *rec = &s_scan_results[i]; - - cJSON *ap_entry = NULL; - char bssid_str[18]; - snprintf(bssid_str, - sizeof(bssid_str), - "%02x:%02x:%02x:%02x:%02x:%02x", - rec->bssid[0], - rec->bssid[1], - rec->bssid[2], - rec->bssid[3], - rec->bssid[4], - rec->bssid[5]); - - int array_size = cJSON_GetArraySize(root); - for (int j = 0; j < array_size; j++) { - cJSON *item = cJSON_GetArrayItem(root, j); - cJSON *bssid_obj = cJSON_GetObjectItem(item, "bssid"); - if (bssid_obj != NULL && strcmp(bssid_obj->valuestring, bssid_str) == 0) { - ap_entry = item; - break; - } - } - - if (ap_entry == NULL) { - ap_entry = cJSON_CreateObject(); - cJSON_AddStringToObject(ap_entry, "bssid", bssid_str); - cJSON_AddStringToObject(ap_entry, "ssid", ""); - cJSON_AddNumberToObject(ap_entry, "channel", rec->channel); - cJSON_AddItemToObject(ap_entry, "clients", cJSON_CreateArray()); - cJSON_AddItemToArray(root, ap_entry); - } - - cJSON *clients_array = cJSON_GetObjectItem(ap_entry, "clients"); - cJSON *client_obj = cJSON_CreateObject(); - char client_mac_str[18]; - snprintf(client_mac_str, - sizeof(client_mac_str), - "%02x:%02x:%02x:%02x:%02x:%02x", - rec->client_mac[0], - rec->client_mac[1], - rec->client_mac[2], - rec->client_mac[3], - rec->client_mac[4], - rec->client_mac[5]); - cJSON_AddStringToObject(client_obj, "mac", client_mac_str); - cJSON_AddStringToObject(client_obj, "vendor", mac_vendor_get_name(rec->client_mac)); - cJSON_AddNumberToObject(client_obj, "rssi", rec->rssi); - cJSON_AddItemToArray(clients_array, client_obj); - } - - char *json_string = cJSON_PrintUnformatted(root); - if (json_string == NULL) { - ESP_LOGE(TAG, "Failed to print JSON."); - cJSON_Delete(root); - return false; - } - - esp_err_t err; - if (use_sd_driver) { - if (!sd_is_mounted()) { - ESP_LOGE(TAG, "SD Card not mounted."); - free(json_string); - cJSON_Delete(root); - return false; - } - err = sd_write_string(path, json_string); - } else { - err = storage_write_string(path, json_string); - } - - free(json_string); - cJSON_Delete(root); - - if (err != ESP_OK) { - ESP_LOGE(TAG, "Failed to write results to %s: %s", path, esp_err_to_name(err)); - return false; - } - - ESP_LOGI(TAG, "Scan results saved to %s", path); - return true; -} - static void scanner_task(void *pvParameters) { ESP_LOGI(TAG, "Starting Client Scan Task (PSRAM)..."); diff --git a/firmware_c5/components/Applications/wifi/evil_twin.c b/firmware_c5/components/Applications/wifi/evil_twin.c index 86ae1403c..7c7f9a04d 100644 --- a/firmware_c5/components/Applications/wifi/evil_twin.c +++ b/firmware_c5/components/Applications/wifi/evil_twin.c @@ -158,44 +158,13 @@ static esp_err_t submit_post_handler(httpd_req_t *req) { char password[MAX_PASSWORD_LEN] = {0}; if (http_service_query_key_value(buf, "password", password, sizeof(password)) == ESP_OK) { + // Keep the captured password in RAM only. The P4 pulls it over SPI + // (SPI_ID_WIFI_EVIL_TWIN_GET_PASSWORD) and persists it on its SD; the C5 + // does not write captures to its own storage. strncpy(s_last_password, password, sizeof(s_last_password) - 1); s_last_password[sizeof(s_last_password) - 1] = '\0'; s_has_password = true; - - if (xSemaphoreTake(s_storage_mutex, pdMS_TO_TICKS(STORAGE_MUTEX_TIMEOUT_MS)) == pdTRUE) { - cJSON *root_array = NULL; - - size_t size = 0; - char *existing_json = (char *)storage_assets_load_file(PATH_PASSWORDS_REL, &size); - - if (existing_json != NULL) { - root_array = cJSON_Parse(existing_json); - free(existing_json); - } - - if (root_array == NULL) { - root_array = cJSON_CreateArray(); - } - - cJSON *entry = cJSON_CreateObject(); - cJSON_AddStringToObject(entry, "user", ""); - cJSON_AddStringToObject(entry, "password", password); - cJSON_AddStringToObject(entry, "2fa", ""); - cJSON_AddStringToObject(entry, "token", ""); - cJSON_AddItemToArray(root_array, entry); - - char *output = cJSON_PrintUnformatted(root_array); - if (output != NULL) { - storage_write_string(PATH_PASSWORDS_ABS, output); - free(output); - } - cJSON_Delete(root_array); - - xSemaphoreGive(s_storage_mutex); - ESP_LOGI(TAG, "Password successfully saved to JSON"); - } else { - ESP_LOGE(TAG, "Timeout when attempting to obtain Mutex to save password"); - } + ESP_LOGI(TAG, "Password captured (held in RAM for P4 pull)"); } size_t size = 0; diff --git a/firmware_c5/components/Applications/wifi/include/wifi_sniffer.h b/firmware_c5/components/Applications/wifi/include/wifi_sniffer.h index 5dc79e9c9..5ed431267 100644 --- a/firmware_c5/components/Applications/wifi/include/wifi_sniffer.h +++ b/firmware_c5/components/Applications/wifi/include/wifi_sniffer.h @@ -113,6 +113,15 @@ uint32_t wifi_sniffer_get_deauth_count(void); */ uint32_t wifi_sniffer_get_buffer_usage(void); +/** + * @brief Fill the extended monitor fields (per-type/per-band tallies, unique + * APs, last channel) of a sniffer stats payload. + * + * @param out Stats struct whose extended tail is populated. The base fields + * (packets/deauths/...) are left untouched. + */ +void wifi_sniffer_fill_ext_stats(spi_sniffer_stats_t *out); + /** * @brief Set the maximum snapshot length for captured packets. * diff --git a/firmware_c5/components/Applications/wifi/signal_monitor.c b/firmware_c5/components/Applications/wifi/signal_monitor.c index da470367a..44161d428 100644 --- a/firmware_c5/components/Applications/wifi/signal_monitor.c +++ b/firmware_c5/components/Applications/wifi/signal_monitor.c @@ -23,6 +23,7 @@ #include "esp_wifi.h" #include "freertos/FreeRTOS.h" #include "freertos/task.h" +#include "sys_prio.h" #include "wifi_80211.h" #include "wifi_service.h" @@ -30,7 +31,7 @@ static const char *TAG = "SIGNAL_MONITOR"; #define MONITOR_STACK_SIZE 4096 -#define MONITOR_TASK_PRIORITY 5 +#define MONITOR_TASK_PRIORITY SYS_PRIO_SERVICE_HI #define SIGNAL_TIMEOUT_MS 5000 #define SIGNAL_CHECK_INTERVAL_MS 500 #define SIGNAL_STOP_WAIT_MS 600 diff --git a/firmware_c5/components/Applications/wifi/target_scanner.c b/firmware_c5/components/Applications/wifi/target_scanner.c index ee6b7323a..dd0af107b 100644 --- a/firmware_c5/components/Applications/wifi/target_scanner.c +++ b/firmware_c5/components/Applications/wifi/target_scanner.c @@ -22,6 +22,7 @@ #include "esp_wifi.h" #include "freertos/FreeRTOS.h" #include "freertos/task.h" +#include "sys_prio.h" #include "cJSON.h" #include "mac_vendor.h" @@ -35,7 +36,7 @@ static const char *TAG = "TARGET_SCANNER"; #define SCANNER_STACK_SIZE 4096 -#define SCANNER_TASK_PRIORITY 5 +#define SCANNER_TASK_PRIORITY SYS_PRIO_SERVICE_HI #define SCAN_DURATION_MS 30000 #define MAX_SCAN_RESULTS 200 #define BSSID_LEN 6 diff --git a/firmware_c5/components/Applications/wifi/wifi_deauther.c b/firmware_c5/components/Applications/wifi/wifi_deauther.c index d46ab0e43..dd1906df4 100644 --- a/firmware_c5/components/Applications/wifi/wifi_deauther.c +++ b/firmware_c5/components/Applications/wifi/wifi_deauther.c @@ -22,13 +22,14 @@ #include "esp_wifi.h" #include "freertos/FreeRTOS.h" #include "freertos/task.h" +#include "sys_prio.h" #include "led_control.h" static const char *TAG = "WIFI_DEAUTHER"; #define DEAUTHER_STACK_SIZE 4096 -#define DEAUTHER_TASK_PRIORITY 5 +#define DEAUTHER_TASK_PRIORITY SYS_PRIO_SERVICE_HI #define DEAUTHER_DELAY_MS 100 #define DEAUTHER_STOP_MARGIN_MS 50 #define DEAUTH_FRAME_LEN 26 diff --git a/firmware_c5/components/Applications/wifi/wifi_flood.c b/firmware_c5/components/Applications/wifi/wifi_flood.c index cad8c7f9a..415d8326c 100644 --- a/firmware_c5/components/Applications/wifi/wifi_flood.c +++ b/firmware_c5/components/Applications/wifi/wifi_flood.c @@ -23,11 +23,12 @@ #include "esp_wifi.h" #include "freertos/FreeRTOS.h" #include "freertos/task.h" +#include "sys_prio.h" static const char *TAG = "WIFI_FLOOD"; #define FLOOD_STACK_SIZE 4096 -#define FLOOD_TASK_PRIORITY 5 +#define FLOOD_TASK_PRIORITY SYS_PRIO_SERVICE_HI #define FLOOD_DELAY_MS 10 #define FLOOD_STOP_WAIT_MS 100 #define BSSID_LEN 6 diff --git a/firmware_c5/components/Applications/wifi/wifi_sniffer.c b/firmware_c5/components/Applications/wifi/wifi_sniffer.c index 1642522d8..ad3b4b9b4 100644 --- a/firmware_c5/components/Applications/wifi/wifi_sniffer.c +++ b/firmware_c5/components/Applications/wifi/wifi_sniffer.c @@ -25,6 +25,7 @@ #include "esp_wifi.h" #include "freertos/FreeRTOS.h" #include "freertos/task.h" +#include "sys_prio.h" #include "pcap_serializer.h" #include "sd_card_init.h" @@ -45,9 +46,9 @@ static const char *TAG = "WIFI_SNIFFER"; #define SNIFFER_STREAM_DELAY_MS 50 #define SNIFFER_STOP_DELAY_MS 200 #define SNIFFER_TASK_STACK_SIZE 4096 -#define SNIFFER_TASK_PRIORITY 5 +#define SNIFFER_TASK_PRIORITY SYS_PRIO_SERVICE_HI #define MAX_TRACKED_SESSIONS 16 -#define MAX_KNOWN_APS 32 +#define MAX_KNOWN_APS 128 #define DEFAULT_SNAPLEN 65535 #define BSSID_LEN 6 #define MAC_LEN 6 @@ -63,6 +64,8 @@ static const char *TAG = "WIFI_SNIFFER"; #define MGMT_FRAME_TYPE 0 #define DATA_FRAME_TYPE 2 #define CTRL_FRAME_TYPE 1 +// Channels 1-14 are 2.4 GHz; anything above is a 5 GHz channel. +#define FIRST_5GHZ_CHANNEL 15 #define EAPOL_DESCRIPTOR_TYPE 3 #define EAPOL_KEY_DESC_OFFSET 4 #define EAPOL_KEY_DATA_LEN_OFFSET 93 @@ -90,6 +93,21 @@ static uint32_t s_buffer_offset = 0; static uint32_t s_packet_count = 0; static uint32_t s_session_id = SPI_SESSION_INVALID_ID; static uint32_t s_deauth_count = 0; + +// Per-type / per-band tallies, surfaced to the companion app via the stats poll. +// Counted for every frame seen (like s_deauth_count), independent of the save +// filter, so they reflect what is actually on the air. +static uint32_t s_beacon_count = 0; +static uint32_t s_probe_req_count = 0; +static uint32_t s_probe_resp_count = 0; +static uint32_t s_data_count = 0; +static uint32_t s_ctrl_count = 0; +static uint32_t s_mgmt_count = 0; +static uint32_t s_pkts_2ghz = 0; +static uint32_t s_pkts_5ghz = 0; +static uint32_t s_unique_aps = 0; +static uint8_t s_last_channel = 0; +static int8_t s_last_rssi = -127; // RSSI of the last captured frame (-127 = none yet) static bool s_is_monitor_mode = false; // packet monitor: counts forever, buffer recycles static bool s_is_sniffing = false; static bool s_is_pcap_enabled = false; @@ -121,6 +139,20 @@ static void sniffer_callback(void *buf, wifi_promiscuous_pkt_type_t type); static void stream_task(void *arg); static bool save_to_file(const char *path, bool use_sd); +static void reset_monitor_counters(void) { + s_beacon_count = 0; + s_probe_req_count = 0; + s_probe_resp_count = 0; + s_data_count = 0; + s_ctrl_count = 0; + s_mgmt_count = 0; + s_pkts_2ghz = 0; + s_pkts_5ghz = 0; + s_unique_aps = 0; + s_last_channel = 0; + s_last_rssi = -127; +} + void wifi_sniffer_set_snaplen(uint16_t len) { s_snaplen = len; } @@ -157,6 +189,7 @@ bool wifi_sniffer_start(wifi_sniffer_type_t type, uint8_t channel) { s_is_pmkid_captured = false; s_is_handshake_captured = false; s_current_type = type; + reset_monitor_counters(); memset(s_sessions, 0, sizeof(s_sessions)); memset(s_known_aps, 0, sizeof(s_known_aps)); @@ -218,6 +251,7 @@ bool wifi_sniffer_start_stream_sd(wifi_sniffer_type_t type, uint8_t channel, con s_rb_read_offset = 0; s_packet_count = 0; s_current_type = type; + reset_monitor_counters(); memset(s_sessions, 0, sizeof(s_sessions)); memset(s_known_aps, 0, sizeof(s_known_aps)); @@ -351,6 +385,22 @@ uint32_t wifi_sniffer_get_buffer_usage(void) { return s_buffer_offset; } +void wifi_sniffer_fill_ext_stats(spi_sniffer_stats_t *out) { + if (out == NULL) + return; + out->beacons = s_beacon_count; + out->probe_reqs = s_probe_req_count; + out->probe_resps = s_probe_resp_count; + out->data_frames = s_data_count; + out->ctrl_frames = s_ctrl_count; + out->mgmt_frames = s_mgmt_count; + out->pkts_2ghz = s_pkts_2ghz; + out->pkts_5ghz = s_pkts_5ghz; + out->unique_aps = s_unique_aps; + out->channel = s_last_channel; + out->last_rssi = s_last_rssi; +} + bool wifi_sniffer_pmkid_captured(void) { return s_is_pmkid_captured; } @@ -438,15 +488,21 @@ static void inject_unicast_probe_req(const uint8_t *target_bssid) { } static void register_known_ap(const uint8_t *bssid) { + static const uint8_t empty_bssid[BSSID_LEN] = {0}; + int free_idx = -1; for (int i = 0; i < MAX_KNOWN_APS; i++) { if (memcmp(s_known_aps[i].bssid, bssid, BSSID_LEN) == 0) { s_known_aps[i].has_ssid = true; - return; + return; // already seen: a repeat AP stays, it is not counted again } - } - int idx = s_packet_count % MAX_KNOWN_APS; - memcpy(s_known_aps[idx].bssid, bssid, BSSID_LEN); - s_known_aps[idx].has_ssid = true; + if (free_idx < 0 && memcmp(s_known_aps[i].bssid, empty_bssid, BSSID_LEN) == 0) + free_idx = i; + } + if (free_idx < 0) + return; // table full: stop counting instead of evicting and recounting + memcpy(s_known_aps[free_idx].bssid, bssid, BSSID_LEN); + s_known_aps[free_idx].has_ssid = true; + s_unique_aps++; } static bool is_ap_ssid_known(const uint8_t *bssid) { @@ -651,6 +707,27 @@ static void sniffer_callback(void *buf, wifi_promiscuous_pkt_type_t type) { ESP_LOGW(TAG, "Deauth detected!"); } + if (fc->type == MGMT_FRAME_TYPE) { + s_mgmt_count++; + if (fc->subtype == BEACON_SUBTYPE) + s_beacon_count++; + else if (fc->subtype == PROBE_REQ_SUBTYPE) + s_probe_req_count++; + else if (fc->subtype == PROBE_RESP_SUBTYPE) + s_probe_resp_count++; + } else if (fc->type == DATA_FRAME_TYPE) { + s_data_count++; + } else if (fc->type == CTRL_FRAME_TYPE) { + s_ctrl_count++; + } + + s_last_channel = ppkt->rx_ctrl.channel; + s_last_rssi = ppkt->rx_ctrl.rssi; + if (ppkt->rx_ctrl.channel >= FIRST_5GHZ_CHANNEL) + s_pkts_5ghz++; + else + s_pkts_2ghz++; + if (s_is_verbose) { if (fc->type == MGMT_FRAME_TYPE && fc->subtype == BEACON_SUBTYPE) printf("B"); @@ -781,27 +858,9 @@ static void sniffer_callback(void *buf, wifi_promiscuous_pkt_type_t type) { } static bool save_to_file(const char *path, bool use_sd) { - if (s_pcap_buffer == NULL || s_buffer_offset == 0) - return false; - - if (!use_sd) { - storage_mkdir_recursive(FLASH_STORAGE_WIFI_PCAP); - } - - esp_err_t err; - if (use_sd) { - if (!sd_is_mounted()) - return false; - err = sd_write_binary(path, s_pcap_buffer, s_buffer_offset); - } else { - err = storage_write_binary(path, s_pcap_buffer, s_buffer_offset); - } - - if (err == ESP_OK) { - ESP_LOGI(TAG, "Saved PCAP to %s (%lu bytes)", path, s_buffer_offset); - return true; - } else { - ESP_LOGE(TAG, "Failed to save PCAP: %s", esp_err_to_name(err)); - return false; - } + (void)path; + (void)use_sd; + // Pcaps are heavy: the C5 never persists them. Frames stream to the P4 over SPI + // and the P4 writes the .pcap to its SD card. Without a card nothing is saved. + return false; } diff --git a/firmware_c5/components/Core/include/sys_monitor.h b/firmware_c5/components/Core/include/sys_monitor.h index 7e8c7ebaa..9531da80e 100644 --- a/firmware_c5/components/Core/include/sys_monitor.h +++ b/firmware_c5/components/Core/include/sys_monitor.h @@ -23,13 +23,15 @@ extern "C" { #include /** - * @brief Start the system monitor task. + * @brief Start the background system monitor task. * - * Monitors stack usage across all tasks and optionally logs RAM statistics. + * Feeds the Task Watchdog, watches every task's stack high-watermark, and does a + * controlled restart (never a vTaskDelete) when a task stays critically low for + * several consecutive cycles. * - * @param show_ram_logs Enable verbose RAM logging when true. + * @param is_verbose Log RAM usage every cycle when true. */ -void sys_monitor(bool show_ram_logs); +void sys_monitor_start(bool is_verbose); #ifdef __cplusplus } diff --git a/firmware_c5/components/Core/kernel.c b/firmware_c5/components/Core/kernel.c index b2df4a00e..c0a61672c 100644 --- a/firmware_c5/components/Core/kernel.c +++ b/firmware_c5/components/Core/kernel.c @@ -19,36 +19,40 @@ #include "driver/gpio.h" #include "driver/i2c.h" +#include "esp_heap_caps.h" #include "esp_log.h" #include "freertos/FreeRTOS.h" #include "freertos/task.h" #include "nvs_flash.h" -#include "bq25896.h" #include "buttons_gpio.h" -#include "console_service.h" +#include "c5_log.h" #include "i2c_init.h" #include "led_control.h" +#include "ota_service.h" #include "pin_def.h" -#include "spi.h" #include "spi_bridge.h" #include "storage_assets.h" -#include "storage_init.h" #include "sys_monitor.h" #include "wifi_service.h" static const char *TAG = "SAFEGUARD"; -#define CONSOLE_TASK_STACK 4096 -#define CONSOLE_TASK_PRIO 5 -#define BOOT_SETTLE_MS 1500 +#define BOOT_SETTLE_MS 1500 -static void console_task(void *pvParameters) { - console_service_init(); - vTaskDelete(NULL); +// Fires on any heap_caps allocation failure with the size, caps and caller - +// context the generic vApplicationMallocFailedHook lacks. +static void heap_alloc_failed_cb(size_t size, uint32_t caps, const char *function_name) { + ESP_LOGE(TAG, + "alloc failed: %u B, caps 0x%lx, in %s", + (unsigned)size, + (unsigned long)caps, + function_name ? function_name : "?"); } void kernel_init(void) { + heap_caps_register_failed_alloc_callback(heap_alloc_failed_cb); + esp_err_t ret = nvs_flash_init(); if (ret == ESP_ERR_NVS_NO_FREE_PAGES || ret == ESP_ERR_NVS_NEW_VERSION_FOUND) { ESP_ERROR_CHECK(nvs_flash_erase()); @@ -56,23 +60,27 @@ void kernel_init(void) { } ESP_ERROR_CHECK(ret); - spi_init(); init_i2c(); - // Storage Init - storage_init(); + // Only the assets partition is mounted: it holds P4-editable config (AP name, + // password, captive-portal HTML, chat config, known networks). Captured data + // (passwords, pcaps, scans) is NOT persisted on the C5 - it streams to the P4 + // over SPI, which owns storage. So there is no runtime `storage` partition and + // no storage_init() here. storage_assets_init(); storage_assets_print_info(); - led_rgb_init(); - bq25896_init(); - spi_bridge_slave_init(); + // The BQ25896 charger and RGB LED live on the P4 and are managed there; the C5 + // has no battery/charger driver at all (it is not on the C5's I2C bus). + // V2 PCB has no bridge IRQ trace: run the slave in POLL mode (matches the P4). + spi_bridge_slave_init_mode(SPI_BRIDGE_MODE_POLL); + c5_log_init(); // tee C5 logs to the P4 over SPI for the companion console + // OTA is triggered on demand over SPI (SPI_ID_SYSTEM_START_UART_OTA); UART0 is + // the console until then. No always-on UART receiver here anymore. - sys_monitor(false); + sys_monitor_start(false); wifi_service_init(); - xTaskCreate(console_task, "console_task", CONSOLE_TASK_STACK, NULL, CONSOLE_TASK_PRIO, NULL); - vTaskDelay(pdMS_TO_TICKS(BOOT_SETTLE_MS)); } diff --git a/firmware_c5/components/Core/sys_monitor.c b/firmware_c5/components/Core/sys_monitor.c index 5ed72f49f..b83b71672 100644 --- a/firmware_c5/components/Core/sys_monitor.c +++ b/firmware_c5/components/Core/sys_monitor.c @@ -1,16 +1,17 @@ // Copyright (c) 2025 HIGH CODE LLC // -// 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 +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. // -// http://www.apache.org/licenses/LICENSE-2.0 +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. // -// 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. +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . #include "sys_monitor.h" @@ -19,78 +20,247 @@ #include "esp_heap_caps.h" #include "esp_log.h" #include "esp_system.h" +#include "esp_task_wdt.h" #include "freertos/FreeRTOS.h" #include "freertos/task.h" +#include "sys_prio.h" + #include "kernel.h" static const char *TAG = "SYS_MONITOR"; #define MONITOR_INTERVAL_MS 2000 -#define STACK_SIZE_BYTES 4096 -#define CRITICAL_STACK_THRESHOLD 256 +#define MONITOR_STACK_SIZE 4096 +#define MONITOR_PRIORITY SYS_PRIO_MONITOR +#define MONITOR_CORE SYS_CORE_MAIN +#define CRITICAL_STACK_THRESHOLD 256 // free stack (bytes); below this a task is at risk +#define STACK_ESCALATE_CYCLES 5 // consecutive critical cycles before a controlled restart +#define STACK_WATCH_MAX 8 // distinct critical tasks tracked for persistence +#define REBOOT_GRACE_MS 1500 // let the alert log flush before restart +#define ALERT_MSG_SIZE 128 + +// Internal-RAM heap policy (the tight pool; wifi/bt buffers + task stacks live +// here). The largest contiguous block is tracked alongside total free because +// fragmentation can fail an allocation before the total runs out. +#define HEAP_WARN_FREE_B 24576 // total internal free below this -> warn once +#define HEAP_WARN_LARGEST_B 12288 // largest contiguous block below this -> warn once +#define HEAP_CRIT_FREE_B 8192 // sustained below this -> controlled restart +#define HEAP_CRIT_CYCLES 3 typedef struct { - bool verbose_logging; + bool is_verbose; } sys_monitor_params_t; +// The monitor observes and reports; it never deletes tasks. Deleting a task +// mid-transaction leaks the peripheral bus mutex (I2C/SPI) and wedges the driver +// until reboot, turning a tight stack into a dead peripheral. On the C5 the SPI +// bridge to the P4 is the worst thing to strand this way. When a task stays +// critically low for STACK_ESCALATE_CYCLES consecutive cycles we do a controlled +// restart instead. usStackHighWaterMark is monotonic (it records the lowest free +// stack ever seen), so a persistent streak means the task is alive and running +// with dangerously little headroom, not a past transient. +// +// The C5 is headless, so "report" means logging: safeguard_alert() just logs. +typedef struct { + char name[configMAX_TASK_NAME_LEN]; + uint32_t streak; // consecutive monitor cycles this task has been critical + bool alerted; // alert already emitted for the current streak + bool seen; // matched in the cycle currently being processed +} stack_watch_t; + +static stack_watch_t s_watch[STACK_WATCH_MAX]; +static uint32_t s_watch_count; + +static stack_watch_t *watch_find(const char *name) { + for (uint32_t i = 0; i < s_watch_count; i++) { + if (strcmp(s_watch[i].name, name) == 0) { + return &s_watch[i]; + } + } + return NULL; +} + +// TODO(item 31): flush the filesystem and radios via a graceful shutdown hook +// here once it exists, before the restart. +static void controlled_restart(const char *title, const char *message) { + safeguard_alert(title, message); + vTaskDelay(pdMS_TO_TICKS(REBOOT_GRACE_MS)); + esp_restart(); +} + +static void escalate_stack_restart(const char *name, uint32_t watermark) { + ESP_LOGE(TAG, + "Task [%s] critically low on stack (%lu B) for %d cycles; controlled restart", + name, + (unsigned long)watermark, + STACK_ESCALATE_CYCLES); + + char msg_buf[ALERT_MSG_SIZE]; + snprintf(msg_buf, sizeof(msg_buf), "Low stack in '%s' persisted; restarting to recover", name); + controlled_restart("SYSTEM RECOVERY", msg_buf); +} + +static void check_task_stacks(const TaskStatus_t *tasks, uint32_t count) { + for (uint32_t i = 0; i < s_watch_count; i++) { + s_watch[i].seen = false; + } + + for (uint32_t i = 0; i < count; i++) { + uint32_t watermark = tasks[i].usStackHighWaterMark; + if (watermark >= CRITICAL_STACK_THRESHOLD) { + continue; + } + + const char *name = tasks[i].pcTaskName; + ESP_LOGW(TAG, + "Low stack in task [%s]: %lu B free (threshold %d B)", + name, + (unsigned long)watermark, + CRITICAL_STACK_THRESHOLD); + + stack_watch_t *w = watch_find(name); + if (w == NULL) { + if (s_watch_count >= STACK_WATCH_MAX) { + continue; // table full; the condition is still logged above + } + w = &s_watch[s_watch_count++]; + strncpy(w->name, name, sizeof(w->name) - 1); + w->name[sizeof(w->name) - 1] = '\0'; + w->streak = 0; + w->alerted = false; + } + + w->seen = true; + w->streak++; + + // Report once per streak (log-only on the headless C5). + if (!w->alerted) { + w->alerted = true; + char msg_buf[ALERT_MSG_SIZE]; + snprintf(msg_buf, + sizeof(msg_buf), + "Low stack in '%s' (%lu B free)", + name, + (unsigned long)watermark); + safeguard_alert("LOW STACK", msg_buf); + } + + if (w->streak >= STACK_ESCALATE_CYCLES) { + escalate_stack_restart(name, watermark); // does not return + } + } + + // Forget tasks that are no longer critical or have exited, so a fresh dip + // starts a new streak instead of inheriting a stale one. + uint32_t kept = 0; + for (uint32_t i = 0; i < s_watch_count; i++) { + if (s_watch[i].seen) { + s_watch[kept++] = s_watch[i]; + } + } + s_watch_count = kept; +} + +static void check_heap(void) { + static bool warned = false; + static uint32_t crit_streak = 0; + + uint32_t free_int = heap_caps_get_free_size(MALLOC_CAP_INTERNAL); + uint32_t largest = heap_caps_get_largest_free_block(MALLOC_CAP_INTERNAL); + + if (free_int < HEAP_WARN_FREE_B || largest < HEAP_WARN_LARGEST_B) { + if (!warned) { + warned = true; + ESP_LOGW(TAG, + "Low heap: %lu B free, largest block %lu B (min ever %lu B)", + (unsigned long)free_int, + (unsigned long)largest, + (unsigned long)esp_get_minimum_free_heap_size()); + char msg[ALERT_MSG_SIZE]; + snprintf(msg, + sizeof(msg), + "Low memory: %lu KB free, %lu KB block", + (unsigned long)(free_int / 1024), + (unsigned long)(largest / 1024)); + safeguard_alert("LOW MEMORY", msg); + } + } else { + warned = false; + } + + if (free_int < HEAP_CRIT_FREE_B) { + if (++crit_streak >= HEAP_CRIT_CYCLES) { + ESP_LOGE(TAG, + "Heap critically low (%lu B) for %d cycles; controlled restart", + (unsigned long)free_int, + HEAP_CRIT_CYCLES); + controlled_restart("SYSTEM RECOVERY", "Out of memory; restarting to recover"); + } + } else { + crit_streak = 0; + } +} + static void sys_monitor_task(void *pvParameters) { sys_monitor_params_t *params = (sys_monitor_params_t *)pvParameters; - bool verbose = params->verbose_logging; + bool is_verbose = params->is_verbose; vPortFree(params); - ESP_LOGI( - TAG, "System Monitor (RAM & Stack) started. Verbose: %s", verbose ? "ENABLED" : "DISABLED"); + ESP_LOGI(TAG, "System monitor started (verbose: %s)", is_verbose ? "enabled" : "disabled"); + + // The monitor loop is the system health heartbeat (mirrors the P4 UI task): + // it subscribes to the Task Watchdog and feeds it each cycle. With + // CONFIG_ESP_TASK_WDT_PANIC=y a stuck monitor, or a task that starves the + // single core for longer than the timeout, reboots instead of only warning. + esp_task_wdt_add(NULL); while (1) { - if (verbose) { + esp_task_wdt_reset(); + + if (is_verbose) { uint32_t free_heap = esp_get_free_heap_size(); uint32_t internal_free = heap_caps_get_free_size(MALLOC_CAP_INTERNAL); uint32_t spiram_free = heap_caps_get_free_size(MALLOC_CAP_SPIRAM); ESP_LOGI(TAG, - "RAM Status - Total Free: %lu, Internal Free: %lu, PSRAM Free: %lu", + "RAM — Free: %lu, Internal: %lu, PSRAM: %lu", (unsigned long)free_heap, (unsigned long)internal_free, (unsigned long)spiram_free); } uint32_t task_count = uxTaskGetNumberOfTasks(); - TaskStatus_t *pxTaskStatusArray = pvPortMalloc(task_count * sizeof(TaskStatus_t)); - - if (pxTaskStatusArray != NULL) { - task_count = uxTaskGetSystemState(pxTaskStatusArray, task_count, NULL); - - for (uint32_t i = 0; i < task_count; i++) { - uint32_t watermark = pxTaskStatusArray[i].usStackHighWaterMark; - - if (watermark < CRITICAL_STACK_THRESHOLD) { - ESP_LOGE(TAG, - "!!! SECURITY ALERT !!! Task [%s] has CRITICAL STACK: %lu bytes free. " - "TERMINATING TASK.", - pxTaskStatusArray[i].pcTaskName, - (unsigned long)watermark); + TaskStatus_t *task_array = pvPortMalloc(task_count * sizeof(TaskStatus_t)); - if (pxTaskStatusArray[i].xHandle != xTaskGetCurrentTaskHandle()) { - vTaskDelete(pxTaskStatusArray[i].xHandle); - } - } - } - vPortFree(pxTaskStatusArray); + if (task_array != NULL) { + task_count = uxTaskGetSystemState(task_array, task_count, NULL); + check_task_stacks(task_array, task_count); + vPortFree(task_array); + } else { + ESP_LOGE(TAG, "Failed to allocate task status array"); } + check_heap(); + vTaskDelay(pdMS_TO_TICKS(MONITOR_INTERVAL_MS)); } } -void sys_monitor(bool show_ram_logs) { +void sys_monitor_start(bool is_verbose) { sys_monitor_params_t *params = pvPortMalloc(sizeof(sys_monitor_params_t)); - if (params) { - params->verbose_logging = show_ram_logs; - - xTaskCreatePinnedToCore( - sys_monitor_task, "SysMonitor", STACK_SIZE_BYTES, (void *)params, 1, NULL, 0); - } else { - ESP_LOGE(TAG, "Failed to allocate memory for SysMonitor parameters."); + if (params == NULL) { + ESP_LOGE(TAG, "Failed to allocate monitor parameters"); + return; } + + params->is_verbose = is_verbose; + + xTaskCreatePinnedToCore(sys_monitor_task, + "SysMonitor", + MONITOR_STACK_SIZE, + (void *)params, + MONITOR_PRIORITY, + NULL, + MONITOR_CORE); } diff --git a/firmware_c5/components/Drivers/CMakeLists.txt b/firmware_c5/components/Drivers/CMakeLists.txt index 9666655f3..b298b4603 100644 --- a/firmware_c5/components/Drivers/CMakeLists.txt +++ b/firmware_c5/components/Drivers/CMakeLists.txt @@ -14,16 +14,15 @@ # along with TentacleOS. If not, see . -idf_component_register(SRCS - "bq25896/bq25896.c" +idf_component_register(SRCS "led/led_control.c" "spi/spi.c" "spi_slave/spi_slave_driver.c" "i2c_init/i2c_init.c" "buttons_gpio/buttons_gpio.c" - INCLUDE_DIRS - "bq25896/include" + INCLUDE_DIRS + "sys_prio/include" "led/include" "pins/include" "spi/include" diff --git a/firmware_c5/components/Drivers/bq25896/bq25896.c b/firmware_c5/components/Drivers/bq25896/bq25896.c deleted file mode 100644 index 48b7d2653..000000000 --- a/firmware_c5/components/Drivers/bq25896/bq25896.c +++ /dev/null @@ -1,163 +0,0 @@ -// Copyright (c) 2025 HIGH CODE LLC -// -// TentacleOS is free software: you can redistribute it and/or modify -// it under the terms of the GNU General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// TentacleOS is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU General Public License for more details. -// -// You should have received a copy of the GNU General Public License -// along with TentacleOS. If not, see . - -#include "bq25896.h" - -#include - -#include "driver/i2c.h" -#include "esp_log.h" - -static const char *TAG = "BQ25896"; - -#define I2C_PORT I2C_NUM_0 -#define I2C_TIMEOUT_MS 100 -#define BATV_BASE_MV 2304 -#define BATV_STEP_MV 20 -#define BATTERY_MIN_VOLTAGE_MV 3200 -#define BATTERY_MAX_VOLTAGE_MV 4200 -#define BATTERY_PERCENT_MAX 100 - -// BQ25896 register addresses -#define REG_ILIM 0x00 -#define REG_VINDPM 0x01 -#define REG_ADC_CTRL 0x02 -#define REG_CHG_CTRL_0 0x03 -#define REG_ICHG 0x04 -#define REG_IPRE_ITERM 0x05 -#define REG_VREG 0x06 -#define REG_CHG_CTRL_1 0x07 -#define REG_CHG_TIMER 0x08 -#define REG_BAT_COMP 0x09 -#define REG_CHG_CTRL_2 0x0A -#define REG_STATUS 0x0B -#define REG_FAULT 0x0C -#define REG_VINDPM_OS 0x0D -#define REG_BAT_VOLT 0x0E -#define REG_SYS_VOLT 0x0F -#define REG_TS_ADC 0x10 -#define REG_VBUS_ADC 0x11 -#define REG_ICHG_ADC 0x12 -#define REG_IDPM_ADC 0x13 -#define REG_CTRL_3 0x14 - -// Status register masks (REG_STATUS 0x0B) -#define STATUS_VBUS_STAT_MASK 0b11100000 -#define STATUS_VBUS_STAT_SHIFT 5 -#define STATUS_CHG_STAT_MASK 0b00011000 -#define STATUS_CHG_STAT_SHIFT 3 -#define STATUS_PG_STAT_MASK 0b00000100 -#define STATUS_PG_STAT_SHIFT 2 -#define STATUS_VSYS_STAT_MASK 0b00000001 - -// ADC control register masks (REG_ADC_CTRL 0x02) -#define ADC_CTRL_CONV_RATE_MASK 0b10000000 -#define ADC_CTRL_ADC_EN_MASK 0b01000000 - -// Battery voltage register mask -#define BATV_MASK 0b01111111 - -static esp_err_t bq25896_read_reg(uint8_t reg, uint8_t *data) { - i2c_cmd_handle_t cmd = i2c_cmd_link_create(); - i2c_master_start(cmd); - i2c_master_write_byte(cmd, (BQ25896_I2C_ADDR << 1) | I2C_MASTER_WRITE, true); - i2c_master_write_byte(cmd, reg, true); - i2c_master_start(cmd); - i2c_master_write_byte(cmd, (BQ25896_I2C_ADDR << 1) | I2C_MASTER_READ, true); - i2c_master_read_byte(cmd, data, I2C_MASTER_LAST_NACK); - i2c_master_stop(cmd); - esp_err_t ret = i2c_master_cmd_begin(I2C_PORT, cmd, I2C_TIMEOUT_MS / portTICK_PERIOD_MS); - i2c_cmd_link_delete(cmd); - return ret; -} - -static esp_err_t bq25896_write_reg(uint8_t reg, uint8_t data) { - i2c_cmd_handle_t cmd = i2c_cmd_link_create(); - i2c_master_start(cmd); - i2c_master_write_byte(cmd, (BQ25896_I2C_ADDR << 1) | I2C_MASTER_WRITE, true); - i2c_master_write_byte(cmd, reg, true); - i2c_master_write_byte(cmd, data, true); - i2c_master_stop(cmd); - esp_err_t ret = i2c_master_cmd_begin(I2C_PORT, cmd, I2C_TIMEOUT_MS / portTICK_PERIOD_MS); - i2c_cmd_link_delete(cmd); - return ret; -} - -esp_err_t bq25896_init(void) { - uint8_t data; - esp_err_t ret = bq25896_read_reg(REG_CTRL_3, &data); - if (ret != ESP_OK) { - ESP_LOGE(TAG, "Falha ao comunicar com o BQ25896."); - return ret; - } - - ret = bq25896_read_reg(REG_ADC_CTRL, &data); - if (ret != ESP_OK) - return ret; - - data |= ADC_CTRL_ADC_EN_MASK; - data &= ~ADC_CTRL_CONV_RATE_MASK; - - ret = bq25896_write_reg(REG_ADC_CTRL, data); - - if (ret == ESP_OK) { - ESP_LOGI(TAG, "BQ25896 inicializado com sucesso."); - } - - return ret; -} - -bq25896_charge_status_t bq25896_get_charge_status(void) { - uint8_t data = 0; - if (bq25896_read_reg(REG_STATUS, &data) == ESP_OK) { - uint8_t status = (data & STATUS_CHG_STAT_MASK) >> STATUS_CHG_STAT_SHIFT; - return (bq25896_charge_status_t)status; - } - return CHARGE_STATUS_NOT_CHARGING; -} - -bq25896_vbus_status_t bq25896_get_vbus_status(void) { - uint8_t data = 0; - if (bq25896_read_reg(REG_STATUS, &data) == ESP_OK) { - uint8_t status = (data & STATUS_VBUS_STAT_MASK) >> STATUS_VBUS_STAT_SHIFT; - return (bq25896_vbus_status_t)status; - } - return VBUS_STATUS_UNKNOWN; -} - -bool bq25896_is_charging(void) { - bq25896_charge_status_t status = bq25896_get_charge_status(); - return (status == CHARGE_STATUS_PRECHARGE || status == CHARGE_STATUS_FAST_CHARGE); -} - -int bq25896_get_battery_percentage(uint16_t voltage_mv) { - if (voltage_mv <= BATTERY_MIN_VOLTAGE_MV) - return 0; - if (voltage_mv >= BATTERY_MAX_VOLTAGE_MV) - return BATTERY_PERCENT_MAX; - - int percentage = ((voltage_mv - BATTERY_MIN_VOLTAGE_MV) * BATTERY_PERCENT_MAX) / - (BATTERY_MAX_VOLTAGE_MV - BATTERY_MIN_VOLTAGE_MV); - return percentage > BATTERY_PERCENT_MAX ? BATTERY_PERCENT_MAX : percentage; -} - -uint16_t bq25896_get_battery_voltage(void) { - uint8_t data = 0; - if (bq25896_read_reg(REG_BAT_VOLT, &data) == ESP_OK) { - uint16_t voltage = BATV_BASE_MV + ((data & BATV_MASK) * BATV_STEP_MV); - return voltage; - } - return 0; -} diff --git a/firmware_c5/components/Drivers/bq25896/include/bq25896.h b/firmware_c5/components/Drivers/bq25896/include/bq25896.h deleted file mode 100644 index 12b5602f4..000000000 --- a/firmware_c5/components/Drivers/bq25896/include/bq25896.h +++ /dev/null @@ -1,92 +0,0 @@ -// Copyright (c) 2025 HIGH CODE LLC -// -// TentacleOS is free software: you can redistribute it and/or modify -// it under the terms of the GNU General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// TentacleOS is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU General Public License for more details. -// -// You should have received a copy of the GNU General Public License -// along with TentacleOS. If not, see . - -#ifndef BQ25896_H -#define BQ25896_H - -#ifdef __cplusplus -extern "C" { -#endif - -#include "driver/i2c.h" -#include -#include - -#define BQ25896_I2C_ADDR 0x6B - -typedef enum { - CHARGE_STATUS_NOT_CHARGING = 0, - CHARGE_STATUS_PRECHARGE = 1, - CHARGE_STATUS_FAST_CHARGE = 2, - CHARGE_STATUS_CHARGE_DONE = 3 -} bq25896_charge_status_t; - -typedef enum { - VBUS_STATUS_UNKNOWN = 0, - VBUS_STATUS_USB_HOST = 1, - VBUS_STATUS_ADAPTER_PORT = 2, - VBUS_STATUS_OTG = 3 -} bq25896_vbus_status_t; - -/** - * @brief Initialize the BQ25896 charger IC. - * - * @return - * - ESP_OK on success - * - ESP_FAIL on communication error - */ -esp_err_t bq25896_init(void); - -/** - * @brief Get the current charge status. - * - * @return Charge status enum value. - */ -bq25896_charge_status_t bq25896_get_charge_status(void); - -/** - * @brief Get the VBUS status (charger connection state). - * - * @return VBUS status enum value. - */ -bq25896_vbus_status_t bq25896_get_vbus_status(void); - -/** - * @brief Get the battery voltage in millivolts. - * - * @return Battery voltage in mV, or 0 on read error. - */ -uint16_t bq25896_get_battery_voltage(void); - -/** - * @brief Check if the battery is currently charging. - * - * @return true if pre-charging or fast-charging. - */ -bool bq25896_is_charging(void); - -/** - * @brief Estimate battery percentage from voltage. - * - * @param voltage_mv Battery voltage in millivolts. - * @return Estimated percentage (0-100). - */ -int bq25896_get_battery_percentage(uint16_t voltage_mv); - -#ifdef __cplusplus -} -#endif - -#endif // BQ25896_H diff --git a/firmware_c5/components/Drivers/buttons_gpio/README.md b/firmware_c5/components/Drivers/buttons_gpio/README.md index 7820397e8..d29c90eca 100644 --- a/firmware_c5/components/Drivers/buttons_gpio/README.md +++ b/firmware_c5/components/Drivers/buttons_gpio/README.md @@ -1,64 +1,7 @@ # GPIO Buttons Driver -This component handles the physical input buttons of the Highboy device. It provides functions to initialize GPIOs and poll button states, supporting both "is pressed" (continuous) and "was pressed" (one-shot/flag) logic. +Documentation for this component lives in the project docs hub (single source of truth): -## Overview +- [docs/buttons_gpio/README.md#c5](../../../../docs/buttons_gpio/README.md#c5) -- **Location:** `components/Drivers/buttons_gpio/` -- **Header:** `include/buttons_gpio.h` -- **Dependencies:** `driver/gpio`, `pin_def.h` - -## Configuration - -- **Input Mode:** `GPIO_MODE_INPUT` with internal Pull-Up enabled. -- **Active Level:** Low (`0`). Buttons connect to ground when pressed. -- **Debounce/Polling:** Handled via `buttons_task` or direct atomic flag checks. - -## Key Mapping - -| Button | Function | -| :--- | :--- | -| **BTN_UP** | Up Navigation | -| **BTN_DOWN** | Down Navigation | -| **BTN_LEFT** | Left / Decrease | -| **BTN_RIGHT** | Right / Increase | -| **BTN_OK** | Enter / Select | -| **BTN_BACK** | Back / Escape | - -## API Reference - -### Initialization - -#### `buttons_init` -```c -void buttons_init(void); -``` -Configures the GPIO pins defined in `pin_def.h` as inputs with pull-ups. Initializes the state of all buttons. - -### State Checking (One-shot) -These functions return `true` **only once** per press. They rely on the `buttons_task` or interrupt logic (conceptually) setting a flag, and these functions reading/clearing it atomically. - -- `bool up_button_pressed(void)` -- `bool down_button_pressed(void)` -- `bool left_button_pressed(void)` -- `bool right_button_pressed(void)` -- `bool ok_button_pressed(void)` -- `bool back_button_pressed(void)` - -### State Checking (Continuous) -These functions return the **current raw state** of the button. Returns `true` as long as the button is held down. - -- `bool up_button_is_down(void)` -- `bool down_button_is_down(void)` -- `bool left_button_is_down(void)` -- `bool right_button_is_down(void)` -- `bool ok_button_is_down(void)` -- `bool back_button_is_down(void)` - -### Tasks - -#### `buttons_task` -```c -void buttons_task(void); -``` -Updates the internal state of the buttons. This should be called periodically (e.g., in a FreeRTOS task or timer callback) to detect state changes (edges) and set the `pressed_flag`. +This file is only a pointer: edit the docs there to keep things from drifting. diff --git a/firmware_c5/components/Drivers/pins/include/pin_def.h b/firmware_c5/components/Drivers/pins/include/pin_def.h index 690022f5a..ab272ab39 100644 --- a/firmware_c5/components/Drivers/pins/include/pin_def.h +++ b/firmware_c5/components/Drivers/pins/include/pin_def.h @@ -60,14 +60,14 @@ extern "C" { #define GPIO_I2C_SCL_PIN 9 // RGB LED (WS2812 / SK6812) -#define GPIO_LED_RGB_PIN 45 +#define GPIO_LED_RGB_PIN 27 #define LED_COUNT 1 // P4-C5 Bridge SPI (Slave) -#define GPIO_BRIDGE_SCLK_PIN 6 -#define GPIO_BRIDGE_MOSI_PIN 7 -#define GPIO_BRIDGE_MISO_PIN 2 -#define GPIO_BRIDGE_CS_PIN 10 +#define GPIO_BRIDGE_SCLK_PIN 26 +#define GPIO_BRIDGE_MOSI_PIN 25 +#define GPIO_BRIDGE_MISO_PIN 24 +#define GPIO_BRIDGE_CS_PIN 23 #define GPIO_BRIDGE_IRQ_PIN 3 #ifdef __cplusplus diff --git a/firmware_c5/components/Drivers/spi/README.md b/firmware_c5/components/Drivers/spi/README.md index f4dc129d6..f9530c282 100644 --- a/firmware_c5/components/Drivers/spi/README.md +++ b/firmware_c5/components/Drivers/spi/README.md @@ -1,53 +1,7 @@ # SPI Bus Driver -This component acts as a central manager for the SPI bus, allowing multiple devices (Display, Radio, SD Card) to share the same SPI host safely and efficiently. +Documentation for this component lives in the project docs hub (single source of truth): -## Overview +- [docs/spi/README.md#c5](../../../../docs/spi/README.md#c5) -- **Location:** `components/Drivers/spi/` -- **Header:** `include/spi.h` -- **Dependencies:** `driver/spi_master` -- **Host:** `SPI3_HOST` - -## Supported Devices (`spi_device_id_t`) - -1. **SPI_DEVICE_ST7789:** Display Driver -2. **SPI_DEVICE_CC1101:** Sub-GHz Radio -3. **SPI_DEVICE_SD_CARD:** Storage - -## API Reference - -### `spi_init` -```c -esp_err_t spi_init(void); -``` -Initializes the SPI bus (MOSI, MISO, SCLK) on `SPI3_HOST` using DMA Channel `Auto`. -- **Pins:** Defined in `pin_def.h`. -- **Max Transfer Size:** 32768 bytes. - -### `spi_add_device` -```c -esp_err_t spi_add_device(spi_device_id_t id, const spi_device_config_t *config); -``` -Adds a specific device to the initialized bus. -- **id:** Device identifier enum. -- **config:** Struct containing CS pin, clock speed, SPI mode, and queue size. - -### `spi_get_handle` -```c -spi_device_handle_t spi_get_handle(spi_device_id_t id); -``` -Retrieves the ESP-IDF `spi_device_handle_t` for a registered device ID. Useful for calling native ESP-IDF SPI functions. - -### `spi_transmit` -```c -esp_err_t spi_transmit(spi_device_id_t id, const uint8_t *data, size_t len); -``` -Performs a simple polling/blocking transmission to the specified device. -- **Note:** For high-performance display flushing, specific drivers (like `esp_lcd`) typically use their own transmission logic using the handle obtained via `spi_get_handle`. - -### `spi_deinit` -```c -esp_err_t spi_deinit(void); -``` -Removes all devices and frees the SPI bus resources. +This file is only a pointer: edit the docs there to keep things from drifting. diff --git a/firmware_c5/components/Drivers/spi_slave/include/spi_slave_driver.h b/firmware_c5/components/Drivers/spi_slave/include/spi_slave_driver.h index b46f3c9fb..7503fce51 100644 --- a/firmware_c5/components/Drivers/spi_slave/include/spi_slave_driver.h +++ b/firmware_c5/components/Drivers/spi_slave/include/spi_slave_driver.h @@ -23,6 +23,7 @@ extern "C" { #include #include +#include "driver/spi_slave.h" #include "esp_err.h" /** @@ -44,6 +45,33 @@ esp_err_t spi_slave_driver_init(void); */ esp_err_t spi_slave_driver_transmit(const uint8_t *tx_data, uint8_t *rx_data, size_t len); +/** + * @brief Queue an SPI slave transaction without blocking. + * + * Lets the caller keep a transaction armed in hardware at all times so a + * transfer from the master is never missed. The @p trans descriptor must stay + * valid until reaped by spi_slave_driver_wait(). + * + * @param trans Caller-owned transaction descriptor (filled in by this call). + * @param tx_data Transmit buffer (may be NULL). + * @param rx_data Receive buffer (may be NULL). + * @param len Number of bytes to transfer. + * @return ESP_OK on success, or an error code. + */ +esp_err_t spi_slave_driver_queue(spi_slave_transaction_t *trans, + const uint8_t *tx_data, + uint8_t *rx_data, + size_t len); + +/** + * @brief Wait for the next queued SPI slave transaction to complete. + * + * Transactions complete in the order they were queued (FIFO). + * + * @return ESP_OK on success, or an error code. + */ +esp_err_t spi_slave_driver_wait(void); + /** * @brief Set the IRQ output level to signal the master. * diff --git a/firmware_c5/components/Drivers/spi_slave/spi_slave_driver.c b/firmware_c5/components/Drivers/spi_slave/spi_slave_driver.c index 605151a91..c2fa23f49 100644 --- a/firmware_c5/components/Drivers/spi_slave/spi_slave_driver.c +++ b/firmware_c5/components/Drivers/spi_slave/spi_slave_driver.c @@ -20,6 +20,7 @@ #include "driver/gpio.h" #include "driver/spi_slave.h" #include "esp_log.h" +#include "freertos/FreeRTOS.h" #include "pin_def.h" @@ -70,6 +71,22 @@ esp_err_t spi_slave_driver_transmit(const uint8_t *tx_data, uint8_t *rx_data, si return spi_slave_transmit(SPI2_HOST, &t, portMAX_DELAY); } +esp_err_t spi_slave_driver_queue(spi_slave_transaction_t *trans, + const uint8_t *tx_data, + uint8_t *rx_data, + size_t len) { + memset(trans, 0, sizeof(*trans)); + trans->length = len * 8; + trans->tx_buffer = tx_data; + trans->rx_buffer = rx_data; + return spi_slave_queue_trans(SPI2_HOST, trans, portMAX_DELAY); +} + +esp_err_t spi_slave_driver_wait(void) { + spi_slave_transaction_t *result = NULL; + return spi_slave_get_trans_result(SPI2_HOST, &result, portMAX_DELAY); +} + void spi_slave_driver_set_irq(int level) { gpio_set_level(GPIO_BRIDGE_IRQ_PIN, level); } diff --git a/firmware_c5/components/Drivers/sys_prio/include/sys_prio.h b/firmware_c5/components/Drivers/sys_prio/include/sys_prio.h new file mode 100644 index 000000000..071e1be1c --- /dev/null +++ b/firmware_c5/components/Drivers/sys_prio/include/sys_prio.h @@ -0,0 +1,49 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#ifndef SYS_PRIO_H +#define SYS_PRIO_H + +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" + +// Central task scheduling policy for TentacleOS on the ESP32-C5 radio +// co-processor. Every xTaskCreate site draws its priority from here instead of a +// scattered magic number, so the whole policy lives in one file. +// +// The C5 is single-core (CONFIG_FREERTOS_UNICORE=y) and headless, so unlike the +// P4 there is no render band and no UI/radio core split: every task runs on the +// one core. The core macros exist only so the few pinned creates stay readable. + +#define SYS_CORE_MAIN 0 // the only core on the C5 +#define SYS_CORE_ANY tskNO_AFFINITY // let the scheduler place the task + +// Priority bands. Higher number is higher priority (FreeRTOS convention). +// +// SYS_PRIO_REALTIME deferred ISR / hard real-time (the SPI bridge to the P4) +// SYS_PRIO_SERVICE_HI radio services and app tasks (Wi-Fi/BLE ops, scanners, DNS, OTA) +// SYS_PRIO_SERVICE_LO lower-priority services (host-link logging) +// SYS_PRIO_BACKGROUND periodic polling, telemetry +// SYS_PRIO_BACKGROUND_LO lowest non-idle background work +// SYS_PRIO_MONITOR health monitor + +#define SYS_PRIO_REALTIME 10 +#define SYS_PRIO_SERVICE_HI 5 +#define SYS_PRIO_SERVICE_LO 4 +#define SYS_PRIO_BACKGROUND 3 +#define SYS_PRIO_BACKGROUND_LO 2 +#define SYS_PRIO_MONITOR 1 + +#endif // SYS_PRIO_H diff --git a/firmware_c5/components/Service/CMakeLists.txt b/firmware_c5/components/Service/CMakeLists.txt index dcdb9761b..59c555ffd 100644 --- a/firmware_c5/components/Service/CMakeLists.txt +++ b/firmware_c5/components/Service/CMakeLists.txt @@ -13,12 +13,20 @@ # You should have received a copy of the GNU General Public License # along with TentacleOS. If not, see . -file(GLOB_RECURSE CONSOLE_SERVICE_SRCS "console/*.c") -file(GLOB_RECURSE CONSOLE_COMMANDS_SRCS "console/commands/*.c") +set(VERSION_META_DIR "${CMAKE_CURRENT_LIST_DIR}/../../../common/metadata") +set(VERSION_INFO_FILE "${VERSION_META_DIR}/version_info.txt") +file(STRINGS "${VERSION_INFO_FILE}" FW_VERSION LIMIT_COUNT 1) +string(STRIP "${FW_VERSION}" FW_VERSION) +set(GENERATED_VERSION_DIR "${CMAKE_CURRENT_BINARY_DIR}/generated") +configure_file("${VERSION_META_DIR}/ota_version.h.in" + "${GENERATED_VERSION_DIR}/ota_version.h" @ONLY) +set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS "${VERSION_INFO_FILE}") + file(GLOB_RECURSE SPI_BRIDGE_SRCS "spi_bridge/*.c") file(GLOB_RECURSE SD_CARD_SRCS "sd_card/*.c") file(GLOB_RECURSE MESHTASTIC_SRCS "meshtastic/*.c") file(GLOB_RECURSE MESHCORE_SRCS "meshcore/*.c") +file(GLOB_RECURSE HOST_LINK_SRCS "host_link/*.c") idf_component_register(SRCS @@ -40,13 +48,13 @@ idf_component_register(SRCS "storage_assets/storage_assets.c" "esp_now/service_esp_now.c" + "ota/ota_service.c" ${SPI_BRIDGE_SRCS} - ${CONSOLE_SERVICE_SRCS} - ${CONSOLE_COMMANDS_SRCS} ${SD_CARD_SRCS} ${MESHTASTIC_SRCS} ${MESHCORE_SRCS} - INCLUDE_DIRS + ${HOST_LINK_SRCS} + INCLUDE_DIRS "wifi/include" "http_server/include" "dns_server/include" @@ -56,10 +64,12 @@ idf_component_register(SRCS "storage_assets/include" "esp_now/include" "spi_bridge/include" - "console/include" "sd_card/include" "meshtastic/include" "meshcore/include" + "host_link/include" + "ota/include" + "${GENERATED_VERSION_DIR}" @@ -73,6 +83,7 @@ idf_component_register(SRCS esp_common esp_netif esp_app_format + app_update Drivers Applications esp_http_server @@ -80,7 +91,5 @@ idf_component_register(SRCS lvgl littlefs cjson - console - argtable3 mdns ) diff --git a/firmware_c5/components/Service/bluetooth/README.md b/firmware_c5/components/Service/bluetooth/README.md index 249e7f91c..2027a6624 100644 --- a/firmware_c5/components/Service/bluetooth/README.md +++ b/firmware_c5/components/Service/bluetooth/README.md @@ -1,145 +1,7 @@ # Bluetooth Service Component Documentation -This component manages the Bluetooth Low Energy (BLE) functionality of the device using the Apache NimBLE stack. It provides a high-level API for initialization, lifecycle management, scanning, advertising, connection handling, and address randomization. +Documentation for this component lives in the project docs hub (single source of truth): -## Overview +- [docs/bluetooth/README.md](../../../../docs/bluetooth/README.md) -- **Location:** `components/Service/bluetooth/` -- **Main Header:** `include/bluetooth_service.h` -- **Stack:** Apache NimBLE (via `nimble_port`) -- **Dependencies:** `nvs_flash`, `storage_assets`, `cJSON`, `esp_random` - -## API Functions - -### Initialization & Lifecycle - -The service lifecycle is split into initialization (resource allocation) and start (execution). - -#### `bluetooth_service_init` -```c -esp_err_t bluetooth_service_init(void); -``` -Allocates resources and prepares the BLE stack. -- Initializes NVS. -- Initializes the NimBLE port. -- Configures GAP callbacks and loads persistent device configuration. -- Does **not** start the background task. - -#### `bluetooth_service_start` -```c -esp_err_t bluetooth_service_start(void); -``` -Spawns the NimBLE host task and waits (up to 10s) for the controller to synchronize. - -#### `bluetooth_service_stop` -```c -esp_err_t bluetooth_service_stop(void); -``` -Stops the NimBLE host task. The service is "paused", but resources remain allocated in memory. - -#### `bluetooth_service_deinit` -```c -esp_err_t bluetooth_service_deinit(void); -``` -Completely shuts down the stack and frees all allocated memory and semaphores. - -#### `Status Checks` -- `bluetooth_service_is_initialized()`: Returns `true` if resources are allocated. -- `bluetooth_service_is_running()`: Returns `true` if the host task is active. - -### Scanning - -#### `bluetooth_service_scan` -```c -void bluetooth_service_scan(uint32_t duration_ms); -``` -Performs a blocking discovery procedure for the specified duration. Results are stored in an internal cache. - -#### `Scan Results` -- `bluetooth_service_get_scan_count()`: Returns the number of unique devices found. -- `bluetooth_service_get_scan_result(uint16_t index)`: Returns a pointer to a `bluetooth_service_scan_result_t` structure containing name, RSSI, and MAC address. - -### Advertising Management - -#### `bluetooth_service_start_advertising` / `stop_advertising` -Standard connectable advertising using the configured device name. Advertising automatically restarts on disconnection. - -### Connection Management - -#### `bluetooth_service_disconnect_all` -```c -void bluetooth_service_disconnect_all(void); -``` -Terminates all active GAP connections. - -#### `bluetooth_service_get_connected_count` -```c -int bluetooth_service_get_connected_count(void); -``` -Returns the number of currently connected peers (tracked internally). - -### Address Management - -#### `bluetooth_service_get_mac` -```c -void bluetooth_service_get_mac(uint8_t *mac); -``` -Copies the 6-byte current identity address into the provided buffer. - -#### `bluetooth_service_get_own_addr_type` -```c -uint8_t bluetooth_service_get_own_addr_type(void); -``` -Returns the current address type (e.g., Public, Random Static) used by the stack. - -#### `bluetooth_service_set_random_mac` -```c -esp_err_t bluetooth_service_set_random_mac(void); -``` -Generates and sets a new **Random Static Address**. This stops active advertising and switches the address type to `BLE_OWN_ADDR_RANDOM`. - -### Power Management - -#### `bluetooth_service_set_max_power` -Sets TX power to `ESP_PWR_LVL_P9` (+9dBm) for advertising and connections. - -### Configuration & Persistence - -#### `bluetooth_service_save_announce_config` -```c -esp_err_t bluetooth_service_save_announce_config(const char *name, uint8_t max_conn); -``` -Saves the main device announcement settings (Device Name) to `/assets/config/bluetooth/ble_announce.conf`. - -#### `bluetooth_service_load_spam_list` -```c -esp_err_t bluetooth_service_load_spam_list(char ***list, size_t *count); -``` -Loads a list of beacon names/payloads from `/assets/config/bluetooth/beacon_list.conf` used for specific application logic (e.g., spam functions). -- **Memory:** Allocates an array of strings. The caller **must** free this memory using `bluetooth_service_free_spam_list`. - -#### `bluetooth_service_save_spam_list` -```c -esp_err_t bluetooth_service_save_spam_list(const char * const *list, size_t count); -``` -Saves a list of strings to the beacon configuration file. - -#### `bluetooth_service_free_spam_list` -```c -void bluetooth_service_free_spam_list(char **list, size_t count); -``` -Helper function to safely free the memory allocated by `bluetooth_service_load_spam_list`. - -## Internal Implementation Details - -### Connection Tracking -The service maintains an internal array (`connection_handles`) of active peers. This is updated via `BLE_GAP_EVENT_CONNECT` and `BLE_GAP_EVENT_DISCONNECT` in the GAP event handler to allow mass disconnection and status reporting without relying on private NimBLE headers. - -### Event Handling -- `BLE_GAP_EVENT_DISC`: Parsed advertisement data to populate the scan results cache. -- `BLE_GAP_EVENT_DISC_COMPLETE`: Signals the completion of the scan via a semaphore. -- `BLE_GAP_EVENT_CONNECT/DISCONNECT`: Logs events and manages the connection tracking list. - -### Configuration Files -- `assets/config/bluetooth/ble_announce.conf`: Device name and connection limits. -- `assets/config/bluetooth/beacon_list.conf`: Payload list for BLE spam functions. +This file is only a pointer: edit the docs there to keep things from drifting. diff --git a/firmware_c5/components/Service/bluetooth/bluetooth_service.c b/firmware_c5/components/Service/bluetooth/bluetooth_service.c index 7b90df727..56e829a1b 100644 --- a/firmware_c5/components/Service/bluetooth/bluetooth_service.c +++ b/firmware_c5/components/Service/bluetooth/bluetooth_service.c @@ -44,7 +44,7 @@ static const char *TAG = "BLE_SERVICE"; #define BLE_SPAM_LIST_FILE "config/bluetooth/beacon_list.conf" #define BLE_SPAM_LIST_PATH "/assets/" BLE_SPAM_LIST_FILE #define MAX_BLE_CONNECTIONS 8 -#define BLE_SYNC_TIMEOUT_MS 10000 +#define BLE_SYNC_TIMEOUT_MS 2000 #define BLE_CONNECT_TIMEOUT_MS 30000 #define BLE_NAME_MAX_LEN 31 #define BLE_MAC_LEN 6 @@ -177,6 +177,7 @@ esp_err_t bluetooth_service_deinit(void) { } s_is_initialized = false; + s_is_running = false; ESP_LOGI(TAG, "BLE deinitialized"); return ESP_OK; } diff --git a/firmware_c5/components/Service/bluetooth/include/bluetooth_service.h b/firmware_c5/components/Service/bluetooth/include/bluetooth_service.h index 1ebe58f7a..f8de27cd2 100644 --- a/firmware_c5/components/Service/bluetooth/include/bluetooth_service.h +++ b/firmware_c5/components/Service/bluetooth/include/bluetooth_service.h @@ -27,7 +27,7 @@ extern "C" { #include "nimble/ble.h" #include "host/ble_gap.h" -#define BLE_SCAN_LIST_SIZE 50 +#define BLE_SCAN_LIST_SIZE 30 /** * @brief BLE scan result entry. diff --git a/firmware_c5/components/Service/console/commands/cmd_fs.c b/firmware_c5/components/Service/console/commands/cmd_fs.c deleted file mode 100644 index 7b41f57d7..000000000 --- a/firmware_c5/components/Service/console/commands/cmd_fs.c +++ /dev/null @@ -1,216 +0,0 @@ -// Copyright (c) 2025 HIGH CODE LLC -// -// TentacleOS is free software: you can redistribute it and/or modify -// it under the terms of the GNU General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// TentacleOS is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU General Public License for more details. -// -// You should have received a copy of the GNU General Public License -// along with TentacleOS. If not, see . - -#include "console_service.h" - -#include -#include -#include -#include - -#include "esp_console.h" -#include "esp_log.h" -#include "argtable3/argtable3.h" - -#include "cJSON.h" - -static const char *TAG = "CMD_FS"; - -#define PATH_BUF_SIZE 512 - -static char s_cwd[PATH_BUF_SIZE] = "/assets"; - -static void resolve_path(const char *input, char *output, size_t max_len) { - if (input == NULL || strlen(input) == 0) { - strncpy(output, s_cwd, max_len); - return; - } - - if (input[0] == '/') { - strncpy(output, input, max_len); - } else { - snprintf(output, max_len, "%s/%s", s_cwd, input); - } -} - -static struct { - struct arg_str *path; - struct arg_lit *json; - struct arg_end *end; -} s_ls_args; - -static struct { - struct arg_str *path; - struct arg_end *end; -} s_cd_args; - -static struct { - struct arg_str *path; - struct arg_end *end; -} s_cat_args; - -static int cmd_pwd(int argc, char **argv) { - printf("%s\n", s_cwd); - return 0; -} - -static int cmd_cd(int argc, char **argv) { - int nerrors = arg_parse(argc, argv, (void **)&s_cd_args); - if (nerrors != 0) { - arg_print_errors(stderr, s_cd_args.end, "cd"); - printf("Usage: cd \n"); - return 1; - } - const char *target = s_cd_args.path->sval[0]; - char new_path[PATH_BUF_SIZE]; - - if (strcmp(target, "..") == 0) { - strncpy(new_path, s_cwd, sizeof(new_path)); - char *last_slash = strrchr(new_path, '/'); - if (last_slash && last_slash != new_path) { - *last_slash = '\0'; - } else if (last_slash == new_path) { - new_path[1] = '\0'; - } - } else if (strcmp(target, ".") == 0) { - return 0; - } else { - resolve_path(target, new_path, sizeof(new_path)); - } - - struct stat st; - if (stat(new_path, &st) == 0 && S_ISDIR(st.st_mode)) { - strncpy(s_cwd, new_path, sizeof(s_cwd)); - size_t len = strlen(s_cwd); - if (len > 1 && s_cwd[len - 1] == '/') { - s_cwd[len - 1] = '\0'; - } - } else { - printf("Error: Not a directory or path does not exist: %s\n", new_path); - return 1; - } - return 0; -} - -static int cmd_ls(int argc, char **argv) { - int nerrors = arg_parse(argc, argv, (void **)&s_ls_args); - if (nerrors != 0) { - arg_print_errors(stderr, s_ls_args.end, "ls"); - printf("Usage: ls [-j] [path]\n"); - return 1; - } - char path[PATH_BUF_SIZE]; - const char *input_path = (s_ls_args.path->count > 0) ? s_ls_args.path->sval[0] : NULL; - resolve_path(input_path, path, sizeof(path)); - - bool use_json = (s_ls_args.json->count > 0); - - DIR *dir = opendir(path); - if (dir == NULL) { - printf("Error: Cannot open directory '%s'\n", path); - return 1; - } - - struct dirent *entry; - cJSON *root = use_json ? cJSON_CreateArray() : NULL; - - if (!use_json) - printf("Directory: %s\n", path); - - while ((entry = readdir(dir)) != NULL) { - char full_path[1024]; - snprintf(full_path, sizeof(full_path), "%s/%s", path, entry->d_name); - - struct stat st; - stat(full_path, &st); - - if (use_json) { - cJSON *item = cJSON_CreateObject(); - cJSON_AddStringToObject(item, "name", entry->d_name); - cJSON_AddStringToObject(item, "type", (entry->d_type == DT_DIR) ? "dir" : "file"); - cJSON_AddNumberToObject(item, "size", (double)st.st_size); - cJSON_AddItemToArray(root, item); - } else { - printf("%-20s %s (%ld bytes)\n", - entry->d_name, - (entry->d_type == DT_DIR) ? "[DIR]" : "", - (long)st.st_size); - } - } - closedir(dir); - - if (use_json) { - char *json_str = cJSON_PrintUnformatted(root); - printf("%s\n", json_str); - free(json_str); - cJSON_Delete(root); - } - - return 0; -} - -static int cmd_cat(int argc, char **argv) { - int nerrors = arg_parse(argc, argv, (void **)&s_cat_args); - if (nerrors != 0) { - arg_print_errors(stderr, s_cat_args.end, "cat"); - printf("Usage: cat \n"); - return 1; - } - char path[PATH_BUF_SIZE]; - resolve_path(s_cat_args.path->sval[0], path, sizeof(path)); - - FILE *f = fopen(path, "r"); - if (f == NULL) { - printf("Error: Cannot open file '%s'\n", path); - return 1; - } - - char buf[128]; - while (fgets(buf, sizeof(buf), f) != NULL) { - printf("%s", buf); - } - printf("\n"); - fclose(f); - return 0; -} - -void register_fs_commands(void) { - // LS - s_ls_args.path = arg_str0(NULL, NULL, "", "Directory path"); - s_ls_args.json = arg_lit0("j", "json", "Output in JSON format"); - s_ls_args.end = arg_end(1); - const esp_console_cmd_t ls_cmd = { - .command = "ls", .help = "List directory", .func = &cmd_ls, .argtable = &s_ls_args}; - ESP_ERROR_CHECK(esp_console_cmd_register(&ls_cmd)); - - // CD - s_cd_args.path = arg_str1(NULL, NULL, "", "Target directory"); - s_cd_args.end = arg_end(1); - const esp_console_cmd_t cd_cmd = { - .command = "cd", .help = "Change directory", .func = &cmd_cd, .argtable = &s_cd_args}; - ESP_ERROR_CHECK(esp_console_cmd_register(&cd_cmd)); - - // PWD - const esp_console_cmd_t pwd_cmd = { - .command = "pwd", .help = "Print working directory", .func = &cmd_pwd}; - ESP_ERROR_CHECK(esp_console_cmd_register(&pwd_cmd)); - - // CAT - s_cat_args.path = arg_str1(NULL, NULL, "", "File path"); - s_cat_args.end = arg_end(1); - const esp_console_cmd_t cat_cmd = { - .command = "cat", .help = "Print file content", .func = &cmd_cat, .argtable = &s_cat_args}; - ESP_ERROR_CHECK(esp_console_cmd_register(&cat_cmd)); -} diff --git a/firmware_c5/components/Service/console/commands/cmd_system.c b/firmware_c5/components/Service/console/commands/cmd_system.c deleted file mode 100644 index 91a588942..000000000 --- a/firmware_c5/components/Service/console/commands/cmd_system.c +++ /dev/null @@ -1,134 +0,0 @@ -// Copyright (c) 2025 HIGH CODE LLC -// -// TentacleOS is free software: you can redistribute it and/or modify -// it under the terms of the GNU General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// TentacleOS is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU General Public License for more details. -// -// You should have received a copy of the GNU General Public License -// along with TentacleOS. If not, see . - -#include "console_service.h" - -#include - -#include "esp_console.h" -#include "esp_log.h" -#include "esp_system.h" -#include "esp_heap_caps.h" -#include "esp_wifi.h" -#include "esp_netif.h" -#include "esp_mac.h" -#include "freertos/FreeRTOS.h" -#include "freertos/task.h" - -static const char *TAG = "CMD_SYSTEM"; - -static int cmd_free(int argc, char **argv) { - printf("Internal RAM:\n"); - printf(" Free: %lu bytes\n", (unsigned long)heap_caps_get_free_size(MALLOC_CAP_INTERNAL)); - printf(" Min Free: %lu bytes\n", - (unsigned long)heap_caps_get_minimum_free_size(MALLOC_CAP_INTERNAL)); - - printf("SPIRAM (PSRAM):\n"); - printf(" Free: %lu bytes\n", (unsigned long)heap_caps_get_free_size(MALLOC_CAP_SPIRAM)); - printf(" Min Free: %lu bytes\n", - (unsigned long)heap_caps_get_minimum_free_size(MALLOC_CAP_SPIRAM)); - return 0; -} - -static int cmd_restart(int argc, char **argv) { - printf("Restarting system...\n"); - esp_restart(); - return 0; -} - -static int cmd_ip(int argc, char **argv) { - esp_netif_t *netif_sta = esp_netif_get_handle_from_ifkey("WIFI_STA_DEF"); - esp_netif_t *netif_ap = esp_netif_get_handle_from_ifkey("WIFI_AP_DEF"); - - if (netif_sta) { - esp_netif_ip_info_t ip_info; - esp_netif_get_ip_info(netif_sta, &ip_info); - printf("STA Interface:\n"); - printf(" IP: " IPSTR "\n", IP2STR(&ip_info.ip)); - printf(" Mask: " IPSTR "\n", IP2STR(&ip_info.netmask)); - printf(" GW: " IPSTR "\n", IP2STR(&ip_info.gw)); - - uint8_t mac[6]; - esp_wifi_get_mac(WIFI_IF_STA, mac); - printf(" MAC: " MACSTR "\n", MAC2STR(mac)); - } - - if (netif_ap) { - esp_netif_ip_info_t ip_info; - esp_netif_get_ip_info(netif_ap, &ip_info); - printf("AP Interface:\n"); - printf(" IP: " IPSTR "\n", IP2STR(&ip_info.ip)); - printf(" Mask: " IPSTR "\n", IP2STR(&ip_info.netmask)); - printf(" GW: " IPSTR "\n", IP2STR(&ip_info.gw)); - - uint8_t mac[6]; - esp_wifi_get_mac(WIFI_IF_AP, mac); - printf(" MAC: " MACSTR "\n", MAC2STR(mac)); - } - return 0; -} - -static int cmd_tasks(int argc, char **argv) { - const size_t bytes_per_task = 40; /* See vTaskList description */ - char *task_list_buffer = malloc(uxTaskGetNumberOfTasks() * bytes_per_task); - - if (task_list_buffer == NULL) { - printf("Error: Failed to allocate memory for task list.\n"); - return 1; - } - - printf("Task Name State Prio Stack Num\n"); - printf("-------------------------------------------\n"); - vTaskList(task_list_buffer); - printf("%s", task_list_buffer); - printf("-------------------------------------------\n"); - - free(task_list_buffer); - return 0; -} - -void register_system_commands(void) { - const esp_console_cmd_t cmd_tasks_def = { - .command = "tasks", - .help = "List running FreeRTOS tasks", - .hint = NULL, - .func = &cmd_tasks, - }; - ESP_ERROR_CHECK(esp_console_cmd_register(&cmd_tasks_def)); - - const esp_console_cmd_t cmd_ip_def = { - .command = "ip", - .help = "Show network interfaces", - .hint = NULL, - .func = &cmd_ip, - }; - ESP_ERROR_CHECK(esp_console_cmd_register(&cmd_ip_def)); - - const esp_console_cmd_t cmd_free_def = { - .command = "free", - .help = "Show remaining memory", - .hint = NULL, - .func = &cmd_free, - }; - ESP_ERROR_CHECK(esp_console_cmd_register(&cmd_free_def)); - - const esp_console_cmd_t cmd_restart_def = { - .command = "restart", - .help = "Reboot the Highboy", - .hint = NULL, - .func = &cmd_restart, - }; - ESP_ERROR_CHECK(esp_console_cmd_register(&cmd_restart_def)); -} diff --git a/firmware_c5/components/Service/console/commands/cmd_wifi.c b/firmware_c5/components/Service/console/commands/cmd_wifi.c deleted file mode 100644 index fbcce54c6..000000000 --- a/firmware_c5/components/Service/console/commands/cmd_wifi.c +++ /dev/null @@ -1,655 +0,0 @@ -// Copyright (c) 2025 HIGH CODE LLC -// -// TentacleOS is free software: you can redistribute it and/or modify -// it under the terms of the GNU General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// TentacleOS is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU General Public License for more details. -// -// You should have received a copy of the GNU General Public License -// along with TentacleOS. If not, see . - -#include "console_service.h" - -#include - -#include "esp_console.h" -#include "esp_log.h" -#include "esp_wifi.h" -#include "esp_mac.h" -#include "argtable3/argtable3.h" - -#include "tos_flash_paths.h" -#include "wifi_service.h" -#include "ap_scanner.h" -#include "wifi_deauther.h" -#include "beacon_spam.h" -#include "wifi_sniffer.h" -#include "probe_monitor.h" -#include "client_scanner.h" -#include "target_scanner.h" -#include "signal_monitor.h" -#include "deauther_detector.h" -#include "evil_twin.h" -#include "port_scan.h" - -static const char *TAG = "CMD_WIFI"; - -#define PATH_BUF_SIZE 512 -#define MAX_PORT_RESULTS 20 - -// SCAN -static struct { - struct arg_end *end; -} s_scan_args; - -static int subcmd_scan(int argc, char **argv) { - int nerrors = arg_parse(argc, argv, (void **)&s_scan_args); - if (nerrors != 0) { - arg_print_errors(stderr, s_scan_args.end, "wifi scan"); - return 1; - } - - printf("Starting Wi-Fi Scan...\n"); - wifi_service_scan(); - - uint16_t count = wifi_service_get_ap_count(); - printf("Found %d networks:\n", count); - printf("%-32s | %-17s | %s | %s | %s\n", "SSID", "BSSID", "CH", "RSSI", "WPS"); - printf("--------------------------------------------------------------------------------\n"); - - for (int i = 0; i < count; i++) { - wifi_ap_record_t *rec = wifi_service_get_ap_record(i); - if (rec) { - printf("%-32s | %02x:%02x:%02x:%02x:%02x:%02x | %2d | %4d | %s\n", - rec->ssid, - rec->bssid[0], - rec->bssid[1], - rec->bssid[2], - rec->bssid[3], - rec->bssid[4], - rec->bssid[5], - rec->primary, - rec->rssi, - rec->wps ? "Yes" : "No "); - } - } - return 0; -} - -// CONNECT -static struct { - struct arg_str *ssid; - struct arg_str *password; - struct arg_end *end; -} s_connect_args; - -static int subcmd_connect(int argc, char **argv) { - int nerrors = arg_parse(argc, argv, (void **)&s_connect_args); - if (nerrors != 0) { - arg_print_errors(stderr, s_connect_args.end, "wifi connect"); - return 1; - } - - const char *ssid = s_connect_args.ssid->sval[0]; - const char *pass = (s_connect_args.password->count > 0) ? s_connect_args.password->sval[0] : NULL; - - printf("Connecting to '%s'...\n", ssid); - esp_err_t err = wifi_service_connect_to_ap(ssid, pass); - if (err == ESP_OK) { - printf("Connection request sent.\n"); - } else { - printf("Error initiating connection: %s\n", esp_err_to_name(err)); - } - return 0; -} - -// AP CONFIG -static struct { - struct arg_str *ssid; - struct arg_str *password; - struct arg_end *end; -} s_ap_args; - -static int subcmd_ap(int argc, char **argv) { - int nerrors = arg_parse(argc, argv, (void **)&s_ap_args); - if (nerrors != 0) { - arg_print_errors(stderr, s_ap_args.end, "wifi ap"); - return 1; - } - - const char *ssid = s_ap_args.ssid->sval[0]; - const char *pass = (s_ap_args.password->count > 0) ? s_ap_args.password->sval[0] : ""; - - printf("Configuring AP: SSID='%s', Pass='%s'\n", ssid, pass); - wifi_service_set_ap_ssid(ssid); - wifi_service_set_ap_password(pass); - printf("AP Configuration updated.\n"); - return 0; -} - -// CONFIG -static struct { - struct arg_int *enabled; - struct arg_str *ip; - struct arg_int *max_conn; - struct arg_end *end; -} s_config_args; - -static int subcmd_config(int argc, char **argv) { - int nerrors = arg_parse(argc, argv, (void **)&s_config_args); - if (nerrors != 0) { - arg_print_errors(stderr, s_config_args.end, "wifi config"); - return 1; - } - - if (s_config_args.enabled->count > 0) { - bool en = (s_config_args.enabled->ival[0] != 0); - printf("Setting Wi-Fi Enabled: %s\n", en ? "True" : "False"); - wifi_service_set_enabled(en); - } - - if (s_config_args.ip->count > 0) { - const char *ip = s_config_args.ip->sval[0]; - printf("Setting AP IP: %s\n", ip); - wifi_service_set_ap_ip(ip); - } - - if (s_config_args.max_conn->count > 0) { - int max = s_config_args.max_conn->ival[0]; - printf("Setting Max Connections: %d\n", max); - wifi_service_set_ap_max_conn((uint8_t)max); - } - - return 0; -} - -// SPAM -static struct { - struct arg_lit *random; - struct arg_lit *list; - struct arg_lit *stop; - struct arg_end *end; -} s_spam_args; - -static int subcmd_spam(int argc, char **argv) { - int nerrors = arg_parse(argc, argv, (void **)&s_spam_args); - if (nerrors != 0) { - arg_print_errors(stderr, s_spam_args.end, "wifi spam"); - return 1; - } - - if (s_spam_args.stop->count > 0) { - beacon_spam_stop(); - printf("Beacon spam stopped.\n"); - return 0; - } - - if (s_spam_args.random->count > 0) { - if (beacon_spam_start_random()) { - printf("Random Beacon Spam started.\n"); - } else { - printf("Failed to start Random Beacon Spam.\n"); - } - return 0; - } - - if (s_spam_args.list->count > 0) { - if (beacon_spam_start_custom(FLASH_CONFIG_WIFI_BEACONS)) { - printf("Custom List Beacon Spam started.\n"); - } else { - printf("Failed to start Custom Beacon Spam (Check " FLASH_CONFIG_WIFI_BEACONS ").\n"); - } - return 0; - } - - printf("Usage: wifi spam -r (random) | -l (list) | -s (stop)\n"); - return 0; -} - -// DEAUTH -static struct { - struct arg_str *mac; - struct arg_int *channel; - struct arg_lit *stop; - struct arg_end *end; -} s_deauth_args; - -static int subcmd_deauth(int argc, char **argv) { - int nerrors = arg_parse(argc, argv, (void **)&s_deauth_args); - if (nerrors != 0) { - arg_print_errors(stderr, s_deauth_args.end, "wifi deauth"); - return 1; - } - - if (s_deauth_args.stop->count > 0) { - wifi_deauther_stop(); - printf("Deauther stopped.\n"); - return 0; - } - - if (s_deauth_args.mac->count == 0) { - printf("Error: Target MAC required.\n"); - return 1; - } - - const char *mac_str = s_deauth_args.mac->sval[0]; - uint8_t mac[6]; - int parsed = sscanf(mac_str, - "%hhx:%hhx:%hhx:%hhx:%hhx:%hhx", - &mac[0], - &mac[1], - &mac[2], - &mac[3], - &mac[4], - &mac[5]); - - if (parsed != 6) { - printf("Error: Invalid MAC format. Use XX:XX:XX:XX:XX:XX\n"); - return 1; - } - - int channel = (s_deauth_args.channel->count > 0) ? s_deauth_args.channel->ival[0] : 1; - - wifi_ap_record_t target_ap; - memset(&target_ap, 0, sizeof(wifi_ap_record_t)); - memcpy(target_ap.bssid, mac, 6); - target_ap.primary = channel; - - if (wifi_deauther_start(&target_ap, WIFI_DEAUTHER_TYPE_INVALID_AUTH, true)) { - printf("Deauth Attack Started on %s (Ch %d)\n", mac_str, channel); - } else { - printf("Failed to start Deauth (Is Wi-Fi running?).\n"); - } - - return 0; -} - -// SNIFFER -static struct { - struct arg_str *type; - struct arg_int *channel; - struct arg_str *file; - struct arg_lit *verbose; - struct arg_lit *stop; - struct arg_end *end; -} s_sniff_args; - -static int subcmd_sniff(int argc, char **argv) { - int nerrors = arg_parse(argc, argv, (void **)&s_sniff_args); - if (nerrors != 0) { - arg_print_errors(stderr, s_sniff_args.end, "wifi sniff"); - return 1; - } - - if (s_sniff_args.stop->count > 0) { - wifi_sniffer_stop(); - printf("Sniffer stopped.\n"); - return 0; - } - - wifi_sniffer_type_t type = WIFI_SNIFFER_TYPE_RAW; - if (s_sniff_args.type->count > 0) { - const char *t = s_sniff_args.type->sval[0]; - if (strcmp(t, "beacon") == 0) - type = WIFI_SNIFFER_TYPE_BEACON; - else if (strcmp(t, "probe") == 0) - type = WIFI_SNIFFER_TYPE_PROBE; - else if (strcmp(t, "pwn") == 0) - type = WIFI_SNIFFER_TYPE_EAPOL; - } - - uint8_t ch = (s_sniff_args.channel->count > 0) ? (uint8_t)s_sniff_args.channel->ival[0] : 0; - - wifi_sniffer_set_verbose(s_sniff_args.verbose->count > 0); - - if (s_sniff_args.file->count > 0) { - if (wifi_sniffer_start_stream_sd(type, ch, s_sniff_args.file->sval[0])) { - printf("Sniffer started (streaming to %s)\n", s_sniff_args.file->sval[0]); - } else { - printf("Failed to start sniffer stream.\n"); - } - } else { - if (wifi_sniffer_start(type, ch)) { - printf("Sniffer started (RAM buffer).\n"); - } else { - printf("Failed to start sniffer.\n"); - } - } - return 0; -} - -// PROBE MONITOR -static struct { - struct arg_lit *start; - struct arg_lit *stop; - struct arg_end *end; -} s_probe_args; - -static int subcmd_probe(int argc, char **argv) { - int nerrors = arg_parse(argc, argv, (void **)&s_probe_args); - if (nerrors != 0) { - arg_print_errors(stderr, s_probe_args.end, "wifi probe"); - return 1; - } - - if (s_probe_args.stop->count > 0) { - probe_monitor_stop(); - printf("Probe monitor stopped.\n"); - return 0; - } - - if (probe_monitor_start()) { - printf("Probe monitor started. Use 'wifi status' to see results count.\n"); - } else { - printf("Failed to start probe monitor.\n"); - } - return 0; -} - -// CLIENT SCAN -static struct { - struct arg_lit *start; - struct arg_lit *stop; - struct arg_end *end; -} s_clients_args; - -static int subcmd_clients(int argc, char **argv) { - int nerrors = arg_parse(argc, argv, (void **)&s_clients_args); - if (nerrors != 0) { - arg_print_errors(stderr, s_clients_args.end, "wifi clients"); - return 1; - } - - if (s_clients_args.stop->count > 0) { - wifi_service_promiscuous_stop(); - printf("Client scanner stopped.\n"); - return 0; - } - - if (client_scanner_start()) { - printf("Client scanner started (15s duration).\n"); - } else { - printf("Failed to start client scanner.\n"); - } - return 0; -} - -// TARGET SCAN -static struct { - struct arg_str *mac; - struct arg_int *channel; - struct arg_lit *stop; - struct arg_end *end; -} s_target_args; - -static int subcmd_target(int argc, char **argv) { - int nerrors = arg_parse(argc, argv, (void **)&s_target_args); - if (nerrors != 0) { - arg_print_errors(stderr, s_target_args.end, "wifi target"); - return 1; - } - - if (s_target_args.stop->count > 0) { - wifi_service_promiscuous_stop(); - printf("Target scanner stopped.\n"); - return 0; - } - - if (s_target_args.mac->count == 0 || s_target_args.channel->count == 0) { - printf("Error: Target MAC and Channel required.\n"); - return 1; - } - - const char *mac_str = s_target_args.mac->sval[0]; - uint8_t mac[6]; - sscanf(mac_str, - "%hhx:%hhx:%hhx:%hhx:%hhx:%hhx", - &mac[0], - &mac[1], - &mac[2], - &mac[3], - &mac[4], - &mac[5]); - uint8_t ch = (uint8_t)s_target_args.channel->ival[0]; - - if (target_scanner_start(mac, ch)) { - printf("Target scanner started for %s on Ch %d.\n", mac_str, ch); - } else { - printf("Failed to start target scanner.\n"); - } - return 0; -} - -// EVIL TWIN -static struct { - struct arg_str *ssid; - struct arg_lit *stop; - struct arg_end *end; -} s_evil_args; - -static int subcmd_evil(int argc, char **argv) { - int nerrors = arg_parse(argc, argv, (void **)&s_evil_args); - if (nerrors != 0) { - arg_print_errors(stderr, s_evil_args.end, "wifi evil"); - return 1; - } - - if (s_evil_args.stop->count > 0) { - evil_twin_stop_attack(); - printf("Evil Twin stopped.\n"); - return 0; - } - - if (s_evil_args.ssid->count > 0) { - const char *ssid = s_evil_args.ssid->sval[0]; - evil_twin_start_attack(ssid); - printf("Evil Twin started with SSID: %s\n", ssid); - return 0; - } - - printf("Usage: wifi evil -ssid | --stop (stop)\n"); - return 0; -} - -// PORT SCAN -static struct { - struct arg_str *ip; - struct arg_int *min; - struct arg_int *max; - struct arg_end *end; -} s_port_args; - -static int subcmd_portscan(int argc, char **argv) { - int nerrors = arg_parse(argc, argv, (void **)&s_port_args); - if (nerrors != 0) { - arg_print_errors(stderr, s_port_args.end, "wifi portscan"); - return 1; - } - - if (s_port_args.ip->count == 0) { - printf("Error: IP required.\n"); - return 1; - } - - const char *ip = s_port_args.ip->sval[0]; - int min = (s_port_args.min->count > 0) ? s_port_args.min->ival[0] : 1; - int max = (s_port_args.max->count > 0) ? s_port_args.max->ival[0] : 1024; - - printf("Starting Port Scan on %s (%d-%d). This block the console...\n", ip, min, max); - - port_scan_result_t *results = malloc(sizeof(port_scan_result_t) * MAX_PORT_RESULTS); - if (results == NULL) { - printf("Memory error.\n"); - return 1; - } - - int count = port_scan_target_range(ip, min, max, results, MAX_PORT_RESULTS); - - printf("Scan finished. Found %d open ports:\n", count); - for (int i = 0; i < count; i++) { - printf(" %d/%s: %s\n", - results[i].port, - (results[i].protocol == PORT_SCAN_PROTO_TCP) ? "TCP" : "UDP", - results[i].banner); - } - free(results); - return 0; -} - -// STATUS -static int subcmd_status(int argc, char **argv) { - printf("--- Wi-Fi Status ---\n"); - printf("Service Active: %s\n", wifi_service_is_active() ? "Yes" : "No"); - - const char *conn_ssid = wifi_service_get_connected_ssid(); - printf("Connected STA: %s\n", conn_ssid ? conn_ssid : "Disconnected"); - - uint8_t mac_sta[6], mac_ap[6]; - esp_wifi_get_mac(WIFI_IF_STA, mac_sta); - esp_wifi_get_mac(WIFI_IF_AP, mac_ap); - - printf("MAC STA: " MACSTR "\n", MAC2STR(mac_sta)); - printf("MAC AP: " MACSTR "\n", MAC2STR(mac_ap)); - - printf("--- Applications ---\n"); - printf("Beacon Spam: %s\n", beacon_spam_is_running() ? "RUNNING" : "Stopped"); - printf("Deauther: %s\n", wifi_deauther_is_running() ? "RUNNING" : "Stopped"); - printf("Sniffer Pkts: %lu\n", wifi_sniffer_get_packet_count()); - printf("Deauth Det: %lu events detected\n", deauther_detector_get_count()); - - return 0; -} - -// MAIN WIFI COMMAND DISPATCHER -static int cmd_wifi(int argc, char **argv) { - if (argc < 2) { - printf("Usage: wifi [options]\n\n"); - printf("Commands:\n"); - printf(" scan Scan networks\n"); - printf(" connect Connect to AP\n"); - printf(" -s -p \n"); - printf(" ap Config Hotspot\n"); - printf(" -s -p \n"); - printf(" config Advanced Config\n"); - printf(" -e <0/1> (Enable) | -i | -m \n"); - printf(" spam Beacon Spam Attack\n"); - printf(" -r (Random) | -l (List) | -s (Stop)\n"); - printf(" deauth Deauth Attack\n"); - printf(" -t [-c ] | -s (Stop)\n"); - printf(" sniff Packet Sniffer\n"); - printf(" -t -c -f -v (Verbose) | -s (Stop)\n"); - printf(" probe Probe Request Monitor\n"); - printf(" start | -s (Stop)\n"); - printf(" clients Scan Connected Clients\n"); - printf(" start | -s (Stop)\n"); - printf(" target Target Scan\n"); - printf(" -t -c | -s (Stop)\n"); - printf(" evil Evil Twin Attack\n"); - printf(" -s | -s (Stop)\n"); - printf(" portscan Port Scanner\n"); - printf(" -i [-min ] [-max ]\n"); - printf(" status Show current status\n"); - return 0; - } - const char *subcmd = argv[1]; - int sub_argc = argc - 1; - char **sub_argv = &argv[1]; - - if (strcmp(subcmd, "scan") == 0) - return subcmd_scan(sub_argc, sub_argv); - if (strcmp(subcmd, "connect") == 0) - return subcmd_connect(sub_argc, sub_argv); - if (strcmp(subcmd, "ap") == 0) - return subcmd_ap(sub_argc, sub_argv); - if (strcmp(subcmd, "config") == 0) - return subcmd_config(sub_argc, sub_argv); - if (strcmp(subcmd, "spam") == 0) - return subcmd_spam(sub_argc, sub_argv); - if (strcmp(subcmd, "deauth") == 0) - return subcmd_deauth(sub_argc, sub_argv); - if (strcmp(subcmd, "status") == 0) - return subcmd_status(sub_argc, sub_argv); - if (strcmp(subcmd, "sniff") == 0) - return subcmd_sniff(sub_argc, sub_argv); - if (strcmp(subcmd, "probe") == 0) - return subcmd_probe(sub_argc, sub_argv); - if (strcmp(subcmd, "clients") == 0) - return subcmd_clients(sub_argc, sub_argv); - if (strcmp(subcmd, "target") == 0) - return subcmd_target(sub_argc, sub_argv); - if (strcmp(subcmd, "evil") == 0) - return subcmd_evil(sub_argc, sub_argv); - if (strcmp(subcmd, "portscan") == 0) - return subcmd_portscan(sub_argc, sub_argv); - - printf("Unknown wifi command: %s\n", subcmd); - return 1; -} - -void register_wifi_commands(void) { - s_scan_args.end = arg_end(1); - - s_connect_args.ssid = arg_str1("s", "ssid", "", "Network SSID"); - s_connect_args.password = arg_str0("p", "pass", "", "Password"); - s_connect_args.end = arg_end(1); - - s_ap_args.ssid = arg_str1("s", "ssid", "", "AP SSID"); - s_ap_args.password = arg_str0("p", "pass", "", "AP Password"); - s_ap_args.end = arg_end(1); - - s_config_args.enabled = arg_int0("e", "enabled", "<0/1>", "Enable/Disable Wi-Fi"); - s_config_args.ip = arg_str0("i", "ip", "", "Static IP"); - s_config_args.max_conn = arg_int0("m", "max", "", "Max Connections"); - s_config_args.end = arg_end(1); - - s_spam_args.random = arg_lit0("r", "random", "Random SSIDs"); - s_spam_args.list = arg_lit0("l", "list", "Use beacon_list.json"); - s_spam_args.stop = arg_lit0("s", "stop", "Stop spam"); - s_spam_args.end = arg_end(1); - - s_deauth_args.mac = arg_str0("t", "target", "", "Target BSSID"); - s_deauth_args.channel = arg_int0("c", "channel", "", "Channel"); - s_deauth_args.stop = arg_lit0("s", "stop", "Stop attack"); - s_deauth_args.end = arg_end(1); - - s_sniff_args.type = arg_str0("t", "type", "", "Sniff Type"); - s_sniff_args.channel = arg_int0("c", "channel", "", "Channel (0=Hop)"); - s_sniff_args.file = arg_str0("f", "file", "", "Save to .pcap"); - s_sniff_args.verbose = arg_lit0("v", "verbose", "Print packets"); - s_sniff_args.stop = arg_lit0("s", "stop", "Stop sniffer"); - s_sniff_args.end = arg_end(1); - - s_probe_args.start = arg_lit0(NULL, "start", "Start monitor"); - s_probe_args.stop = arg_lit0("s", "stop", "Stop monitor"); - s_probe_args.end = arg_end(1); - - s_clients_args.start = arg_lit0(NULL, "start", "Start scan"); - s_clients_args.stop = arg_lit0("s", "stop", "Stop scan"); - s_clients_args.end = arg_end(1); - - s_target_args.mac = arg_str0("t", "target", "", "BSSID"); - s_target_args.channel = arg_int0("c", "channel", "", "Channel"); - s_target_args.stop = arg_lit0("s", "stop", "Stop scan"); - s_target_args.end = arg_end(1); - - s_evil_args.ssid = arg_str0("s", "ssid", "", "Fake AP Name"); - s_evil_args.stop = arg_lit0(NULL, "stop", "Stop attack"); - s_evil_args.end = arg_end(1); - - s_port_args.ip = arg_str1("i", "ip", "", "Target IP"); - s_port_args.min = arg_int0(NULL, "min", "", "Start Port"); - s_port_args.max = arg_int0(NULL, "max", "", "End Port"); - s_port_args.end = arg_end(1); - - const esp_console_cmd_t wifi_cmd = {.command = "wifi", - .help = "Wi-Fi Management & Attacks", - .hint = " ...", - .func = &cmd_wifi, - .argtable = NULL}; - ESP_ERROR_CHECK(esp_console_cmd_register(&wifi_cmd)); -} diff --git a/firmware_c5/components/Service/console/console_service.c b/firmware_c5/components/Service/console/console_service.c deleted file mode 100644 index 4d5e04213..000000000 --- a/firmware_c5/components/Service/console/console_service.c +++ /dev/null @@ -1,66 +0,0 @@ -// Copyright (c) 2025 HIGH CODE LLC -// -// TentacleOS is free software: you can redistribute it and/or modify -// it under the terms of the GNU General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// TentacleOS is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU General Public License for more details. -// -// You should have received a copy of the GNU General Public License -// along with TentacleOS. If not, see . - -#include "console_service.h" - -#include -#include -#include -#include - -#include "esp_console.h" -#include "esp_log.h" -#include "driver/uart.h" -#include "linenoise/linenoise.h" - -static const char *TAG = "CONSOLE"; - -esp_err_t console_service_init(void) { - esp_console_repl_t *repl = NULL; - esp_console_repl_config_t repl_config = ESP_CONSOLE_REPL_CONFIG_DEFAULT(); - - repl_config.prompt = "highboy> "; - repl_config.max_cmdline_length = 512; - - ESP_ERROR_CHECK(esp_console_register_help_command()); - - register_system_commands(); - register_fs_commands(); - register_wifi_commands(); - -#if defined(CONFIG_ESP_CONSOLE_USB_SERIAL_JTAG) - ESP_LOGI(TAG, "Initializing USB Serial/JTAG Console (Native S3)"); - esp_console_dev_usb_serial_jtag_config_t usbjtag_config = - ESP_CONSOLE_DEV_USB_SERIAL_JTAG_CONFIG_DEFAULT(); - ESP_ERROR_CHECK(esp_console_new_repl_usb_serial_jtag(&usbjtag_config, &repl_config, &repl)); - -#elif defined(CONFIG_ESP_CONSOLE_USB_CDC) - ESP_LOGI(TAG, "Initializing USB CDC Console (TinyUSB)"); - esp_console_dev_usb_cdc_config_t cdc_config = ESP_CONSOLE_DEV_USB_CDC_CONFIG_DEFAULT(); - ESP_ERROR_CHECK(esp_console_new_repl_usb_cdc(&cdc_config, &repl_config, &repl)); - -#else - ESP_LOGI(TAG, "Initializing UART Console"); - esp_console_dev_uart_config_t uart_config = ESP_CONSOLE_DEV_UART_CONFIG_DEFAULT(); - // uart_config.rx_buffer_size = 1024; // Some versions don't expose this directly in the struct - // macro or name differs uart_config.tx_buffer_size = 1024; - ESP_ERROR_CHECK(esp_console_new_repl_uart(&uart_config, &repl_config, &repl)); -#endif - - ESP_ERROR_CHECK(esp_console_start_repl(repl)); - - ESP_LOGI(TAG, "Console started. Type 'help' for commands."); - return ESP_OK; -} diff --git a/firmware_c5/components/Service/dns_server/README.md b/firmware_c5/components/Service/dns_server/README.md index 6ef23bc1f..856642088 100644 --- a/firmware_c5/components/Service/dns_server/README.md +++ b/firmware_c5/components/Service/dns_server/README.md @@ -1,53 +1,7 @@ # DNS Server Service Component -This component implements a lightweight DNS server optimized for "Evil Twin" and Captive Portal applications. It intercepts all DNS queries and responds authoritatively with the device's own IP address, effectively redirecting all traffic to the local web server. +Documentation for this component lives in the project docs hub (single source of truth): -## Overview +- [docs/dns_server/README.md](../../../../docs/dns_server/README.md) -- **Location:** `components/Service/dns_server/` -- **Main Header:** `include/dns_server.h` -- **Socket Type:** UDP Port 53 -- **Response Strategy:** Authoritative (AA=1), Recursive (RA=0), No Error. -- **Dependencies:** `lwip/sockets`, `esp_netif` - -## Key Features - -- **Dynamic IP Resolution:** Automatically detects the current Access Point IP address using `esp_netif_get_ip_info`, ensuring correct redirection even if the network configuration changes. -- **Robust Parsing:** Implements a safe DNS name parser (`parse_dns_name`) to validate queries and prevent buffer overflows. -- **Evil Twin Optimization:** Uses specific DNS flags (`0x8500`) to mark responses as "Authoritative". This forces client devices (especially modern Android/iOS) to accept the redirection faster, improving Captive Portal detection. -- **IPv4 Focus:** Optimized for stability and simplicity, handling standard A-record queries. -- **Task Management:** Runs in a dedicated FreeRTOS task with an increased stack size (4096 bytes) to handle high loads and logging without overflow. - -## API Reference - -### `start_dns_server` -```c -void start_dns_server(void); -``` -Starts the DNS server task. -- Creates a UDP socket bound to port 53. -- Listens for incoming queries. -- Spawns the `dns_server` task with 4KB stack. - -### `stop_dns_server` -```c -void stop_dns_server(void); -``` -Stops the DNS server and frees resources. -- Deletes the FreeRTOS task. -- Closes the UDP socket (handled within the task loop upon deletion). - -## Internal Implementation Details - -### Packet Handling -1. **Validation:** Incoming packets are checked for minimum size (header length) and valid query flags. -2. **Parsing:** The domain name is extracted using `parse_dns_name` for logging and validation purposes. -3. **Response Construction:** - - Copies the transaction ID from the request. - - Sets Flags to `0x8500` (Response + Authoritative). - - Appends the original Question section. - - Appends an Answer section pointing to the AP's IP address (TTL 60s). - -### Configuration -- **Stack Size:** 4096 bytes (Safe for logging and network operations). -- **Socket Timeout:** 1 second (allows graceful shutdown checks). +This file is only a pointer: edit the docs there to keep things from drifting. diff --git a/firmware_c5/components/Service/dns_server/dns_server.c b/firmware_c5/components/Service/dns_server/dns_server.c index 59809cff7..cb80cc404 100644 --- a/firmware_c5/components/Service/dns_server/dns_server.c +++ b/firmware_c5/components/Service/dns_server/dns_server.c @@ -22,6 +22,7 @@ #include "esp_netif.h" #include "freertos/FreeRTOS.h" #include "freertos/task.h" +#include "sys_prio.h" #include "lwip/netdb.h" #include "lwip/sockets.h" @@ -30,7 +31,7 @@ static const char *TAG = "DNS_SERVER"; #define DNS_PORT 53 #define DNS_BUF_SIZE 512 #define DNS_TASK_STACK_SIZE 4096 -#define DNS_TASK_PRIORITY 5 +#define DNS_TASK_PRIORITY SYS_PRIO_SERVICE_HI #define DNS_RECV_TIMEOUT_S 1 #define DNS_RESPONSE_TTL 60 #define DNS_FALLBACK_IP "192.168.4.1" diff --git a/firmware_c5/components/Service/esp_now/README.md b/firmware_c5/components/Service/esp_now/README.md index bd800ba66..f7b4e5171 100644 --- a/firmware_c5/components/Service/esp_now/README.md +++ b/firmware_c5/components/Service/esp_now/README.md @@ -1,102 +1,7 @@ # ESP-NOW Service -The **ESP-NOW Service** is the low-level communication backbone for the Highboy project. It abstracts the ESP-IDF `esp_now` driver, providing a robust, connectionless messaging layer with auto-discovery, persistent peer management, and software-based security. +Documentation for this component lives in the project docs hub (single source of truth): -## Features +- [docs/esp_now/README.md](../../../../docs/esp_now/README.md) -- **Connectionless Communication**: Uses ESP-NOW (WiFi Vendor Specific Elements) to send small packets instantly without WiFi association. -- **Auto-Discovery**: "Hello" broadcast packets allow devices to find each other. -- **Auto-Pairing (The "Cat Jump" Logic)**: Automatically registers any device from which a packet is received, allowing immediate reply without manual pairing. -- **Smart Peer Management**: - - **Volatile (Session)**: Stores discovered peers in PSRAM (or RAM) to show who is currently online. - - **Permanent**: Saves trusted peers to `addresses.conf` (JSON). -- **Software Security**: - - Implements a Vigenère Cipher for message payloads to bypass ESP-NOW hardware limits (6-20 peers) while keeping packets ASCII-compatible. - - **Secure Handshake**: Special `KEY_SHARE` packet type to exchange keys automatically. -- **Configuration Persistence**: Saves Nickname, Online Status, and Encryption Keys to `chat.conf`. - -## Architecture - -### Packet Structure -The service uses a packed struct to ensure consistent data alignment over the air. - -| Field | Type | Size | Description | -|-------|------|------|-------------| -| `type` | `uint8_t` | 1 byte | Packet intent (see below). | -| `nick` | `char[]` | 16 bytes | Sender's nickname. | -| `text` | `char[]` | 201 bytes | Message content or Key payload. | - -### Message Types -1. **`HELLO` (0x01)**: Broadcast packet. Sent to `FF:FF:FF:FF:FF:FF`. Used for discovery. -2. **`MSG` (0x02)**: Direct message (Unicast). Encrypted if a key is set. -3. **`KEY_SHARE` (0x03)**: Handshake packet. Sent unencrypted containing the generated session key in the `text` field. - -### File System Integration -The service relies on the **Assets Partition** for configuration: - -1. **`/assets/config/chat/chat.conf`**: - ```json - { - "nick": "Highboy_User", - "online": true, - "key": "SecretKey123" - } - ``` -2. **`/assets/config/chat/addresses.conf`**: - ```json - [ - { "mac": "AA:BB:CC:DD:EE:FF", "name": "Friend_Device" } - ] - ``` - -## API Reference - -### Initialization -```c -esp_err_t service_esp_now_init(void); -void service_esp_now_deinit(void); -``` -Initializes ESP-NOW, registers callbacks, loads configuration, and allocates memory for the session list. - -### Configuration -```c -esp_err_t service_esp_now_set_nick(const char *nick); -const char* service_esp_now_get_nick(void); -esp_err_t service_esp_now_set_online(bool online); // Toggle TX/RX -bool service_esp_now_is_online(void); -esp_err_t service_esp_now_set_key(const char *key); // Sets encryption key -``` - -### Messaging -```c -// Send HELLO to Broadcast (Discovery) -esp_err_t service_esp_now_broadcast_hello(void); - -// Send Text Message (Auto-encrypts if key is set) -esp_err_t service_esp_now_send_msg(const uint8_t *target_mac, const char *text); - -// Initiate Secure Handshake (Generates key if missing, sends KEY_SHARE) -esp_err_t service_esp_now_secure_pair(const uint8_t *target_mac); -``` - -### Peer Management -```c -// Get list of currently visible devices (from RAM/PSRAM) -int service_esp_now_get_session_peers(service_esp_now_peer_info_t *out_peers, int max_peers); - -// Save a peer permanently to addresses.conf -esp_err_t service_esp_now_save_peer_to_conf(const uint8_t *mac_addr, const char *name); -``` - -### Callbacks -```c -typedef void (*service_esp_now_recv_cb_t)(const uint8_t *mac_addr, const service_esp_now_packet_t *data, int8_t rssi); -typedef void (*service_esp_now_send_cb_t)(const uint8_t *mac_addr, esp_now_send_status_t status); - -void service_esp_now_register_recv_cb(service_esp_now_recv_cb_t cb); -void service_esp_now_register_send_cb(service_esp_now_send_cb_t cb); -``` - -## Security Note regarding `peer.encrypt` -We explicitly set `peer.encrypt = false` in the hardware driver. -**Reason**: ESP32 hardware encryption limits the peer list drastically (approx. 10 devices). By implementing software encryption (Vigenère) on the payload, we allow **unlimited peers** while maintaining confidentiality and enabling instant "fire-and-forget" messaging without complex hardware handshake requirements. +This file is only a pointer: edit the docs there to keep things from drifting. diff --git a/firmware_c5/components/Service/host_link/README.md b/firmware_c5/components/Service/host_link/README.md new file mode 100644 index 000000000..a6b059c47 --- /dev/null +++ b/firmware_c5/components/Service/host_link/README.md @@ -0,0 +1,7 @@ +# Host Link — C5 (BLE relay + log tee) + +Documentation for this component lives in the project docs hub (single source of truth): + +- [docs/host_link/README.md#c5](../../../../docs/host_link/README.md#c5) + +This file is only a pointer: edit the docs there to keep things from drifting. diff --git a/firmware_c5/components/Service/host_link/c5_log.c b/firmware_c5/components/Service/host_link/c5_log.c new file mode 100644 index 000000000..6ffd12986 --- /dev/null +++ b/firmware_c5/components/Service/host_link/c5_log.c @@ -0,0 +1,156 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#include "c5_log.h" + +#include +#include +#include + +#include "esp_log.h" +#include "freertos/FreeRTOS.h" +#include "freertos/queue.h" +#include "freertos/task.h" +#include "sys_prio.h" + +#include "spi_bridge.h" +#include "spi_protocol.h" + +#define C5_LOG_TEXT_MAX 240 // bytes of stripped text kept per line +#define C5_LOG_QUEUE_DEPTH 12 +#define C5_LOG_TASK_STK 3072 +#define C5_LOG_TASK_PRIO SYS_PRIO_SERVICE_LO + +typedef struct { + uint8_t level; + uint16_t len; + char text[C5_LOG_TEXT_MAX]; +} log_line_t; + +static QueueHandle_t s_log_queue = NULL; +static TaskHandle_t s_log_task = NULL; +static vprintf_like_t s_prev_vprintf = NULL; +static volatile uint32_t s_dropped = 0; + +// Level enum on the wire: matches the P4 host_link_level_t (E=0,W=1,I=2,D=3,V=4). +static uint8_t level_from_letter(char c) { + switch (c) { + case 'E': + return 0; + case 'W': + return 1; + case 'D': + return 3; + case 'V': + return 4; + case 'I': + default: + return 2; + } +} + +// Copy src→dst dropping CSI/ANSI escape sequences and trailing CR/LF. +static uint16_t strip_ansi(const char *src, int src_len, char *dst, uint16_t dst_cap) { + uint16_t n = 0; + for (int i = 0; i < src_len && n < dst_cap; i++) { + char c = src[i]; + if (c == '\033') { + i++; // skip '[' + while (i + 1 < src_len && !(src[i + 1] >= '@' && src[i + 1] <= '~')) + i++; + i++; // skip the final byte of the sequence + continue; + } + dst[n++] = c; + } + while (n > 0 && (dst[n - 1] == '\n' || dst[n - 1] == '\r')) + n--; + return n; +} + +static int log_vprintf(const char *fmt, va_list args) { + int ret = 0; + if (s_prev_vprintf != NULL) { + va_list args_copy; + va_copy(args_copy, args); + ret = s_prev_vprintf(fmt, args_copy); + va_end(args_copy); + } + + if (s_log_queue == NULL) + return ret; + + char raw[C5_LOG_TEXT_MAX * 2]; + int raw_len = vsnprintf(raw, sizeof(raw), fmt, args); + if (raw_len <= 0) + return ret; + if (raw_len > (int)sizeof(raw) - 1) + raw_len = (int)sizeof(raw) - 1; + + log_line_t line; + line.len = strip_ansi(raw, raw_len, line.text, sizeof(line.text)); + if (line.len == 0) + return ret; + line.level = level_from_letter(line.text[0]); + + if (xQueueSend(s_log_queue, &line, 0) != pdTRUE) { + log_line_t discard; + if (xQueueReceive(s_log_queue, &discard, 0) == pdTRUE) + s_dropped++; + xQueueSend(s_log_queue, &line, 0); + } + return ret; +} + +static void log_task(void *arg) { + (void)arg; + log_line_t line; + uint8_t record[1 + C5_LOG_TEXT_MAX]; + for (;;) { + if (xQueueReceive(s_log_queue, &line, portMAX_DELAY) != pdTRUE) + continue; + // Push to the P4 only when the stream is enabled (a companion is listening). + if (!spi_bridge_stream_is_enabled(SPI_ID_SYSTEM_LOG)) + continue; + record[0] = line.level; + memcpy(record + 1, line.text, line.len); + spi_bridge_stream_push(SPI_ID_SYSTEM_LOG, record, (uint8_t)(1 + line.len)); + } +} + +esp_err_t c5_log_init(void) { + if (s_log_queue != NULL) + return ESP_OK; // already installed + + s_log_queue = xQueueCreate(C5_LOG_QUEUE_DEPTH, sizeof(log_line_t)); + if (s_log_queue == NULL) + return ESP_ERR_NO_MEM; + + if (xTaskCreate(log_task, "c5_log", C5_LOG_TASK_STK, NULL, C5_LOG_TASK_PRIO, &s_log_task) != + pdPASS) { + vQueueDelete(s_log_queue); + s_log_queue = NULL; + return ESP_FAIL; + } + + // NimBLE logs every GATT/GAP procedure at INFO. Besides the noise, those lines + // would be teed and forwarded to the companion over BLE, whose notify triggers + // another NimBLE "notify" log -> an infinite feedback loop. Keep only warnings+. + esp_log_level_set("NimBLE", ESP_LOG_WARN); + + spi_bridge_stream_enable(SPI_ID_SYSTEM_LOG, true); + s_prev_vprintf = esp_log_set_vprintf(log_vprintf); + return ESP_OK; +} diff --git a/firmware_c5/components/Service/host_link/host_link_gatt.c b/firmware_c5/components/Service/host_link/host_link_gatt.c new file mode 100644 index 000000000..1b3070f00 --- /dev/null +++ b/firmware_c5/components/Service/host_link/host_link_gatt.c @@ -0,0 +1,360 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#include "host_link_gatt.h" + +#include +#include + +#include "esp_log.h" +#include "esp_mac.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include "host/ble_gap.h" +#include "host/ble_gatt.h" +#include "host/ble_hs.h" +#include "host/ble_uuid.h" +#include "host/util/util.h" +#include "nimble/nimble_port.h" +#include "nimble/nimble_port_freertos.h" +#include "nvs_flash.h" +#include "services/gap/ble_svc_gap.h" +#include "services/gatt/ble_svc_gatt.h" + +#include "bluetooth_service.h" +#include "host_transport.h" + +extern void ble_store_config_init(void); + +static const char *TAG = "HOST_GATT"; + +#define HOST_PREFERRED_MTU 512 +#define HOST_RX_FRAME_MAX 512 +#define HOST_DEVICE_NAME_LEN 32 + +// TentacleOS companion host-link service (NUS-style, byte 14 = 0x54 'T' to keep +// it distinct from the MeshCore NUS variant). UUIDs are TBD-final. +static const ble_uuid128_t HOST_SERVICE_UUID = BLE_UUID128_INIT( + 0x9E, 0xCA, 0xDC, 0x24, 0x0E, 0xE5, 0xA9, 0xE0, 0x93, 0xF3, 0xA3, 0xB5, 0x01, 0x00, 0x54, 0x6E); +static const ble_uuid128_t HOST_RX_UUID = BLE_UUID128_INIT( + 0x9E, 0xCA, 0xDC, 0x24, 0x0E, 0xE5, 0xA9, 0xE0, 0x93, 0xF3, 0xA3, 0xB5, 0x02, 0x00, 0x54, 0x6E); +static const ble_uuid128_t HOST_TX_UUID = BLE_UUID128_INIT( + 0x9E, 0xCA, 0xDC, 0x24, 0x0E, 0xE5, 0xA9, 0xE0, 0x93, 0xF3, 0xA3, 0xB5, 0x03, 0x00, 0x54, 0x6E); + +static bool s_is_running = false; +static bool s_is_connected = false; +static bool s_is_subscribed = false; +static uint16_t s_conn_handle = BLE_HS_CONN_HANDLE_NONE; +static uint16_t s_tx_attr_handle = 0; +static uint8_t s_own_addr_type = 0; +static char s_device_name[HOST_DEVICE_NAME_LEN] = {0}; +static uint8_t s_rx_buf[HOST_RX_FRAME_MAX]; + +static void advertise_start(void); +static int gap_event(struct ble_gap_event *event, void *arg); +static int rx_access(uint16_t conn, uint16_t attr, struct ble_gatt_access_ctxt *ctxt, void *arg); +static int tx_access(uint16_t conn, uint16_t attr, struct ble_gatt_access_ctxt *ctxt, void *arg); +static void on_sync(void); +static void on_reset(int reason); +static void host_task(void *param); + +static const struct ble_gatt_svc_def GATT_SERVICES[] = { + { + .type = BLE_GATT_SVC_TYPE_PRIMARY, + .uuid = &HOST_SERVICE_UUID.u, + .characteristics = + (struct ble_gatt_chr_def[]){ + { + .uuid = &HOST_RX_UUID.u, + .access_cb = rx_access, + .flags = BLE_GATT_CHR_F_WRITE | BLE_GATT_CHR_F_WRITE_NO_RSP, + }, + { + .uuid = &HOST_TX_UUID.u, + .access_cb = tx_access, + .val_handle = &s_tx_attr_handle, + .flags = BLE_GATT_CHR_F_READ | BLE_GATT_CHR_F_NOTIFY, + }, + {0}, + }, + }, + {0}, +}; + +esp_err_t host_link_gatt_init(const char *name_prefix) { + if (name_prefix == NULL) { + return ESP_ERR_INVALID_ARG; + } + if (s_is_running) { + return ESP_ERR_INVALID_STATE; + } + if (bluetooth_service_is_running()) { + ESP_LOGE(TAG, "bluetooth_service already owns NimBLE — refuse init"); + return ESP_ERR_INVALID_STATE; + } + + esp_err_t ret = host_transport_init(); + if (ret != ESP_OK) { + return ret; + } + + ret = nvs_flash_init(); + if (ret == ESP_ERR_NVS_NO_FREE_PAGES || ret == ESP_ERR_NVS_NEW_VERSION_FOUND) { + nvs_flash_erase(); + ret = nvs_flash_init(); + } + if (ret != ESP_OK) { + ESP_LOGE(TAG, "NVS init failed: %s", esp_err_to_name(ret)); + return ret; + } + + s_is_connected = false; + s_is_subscribed = false; + s_conn_handle = BLE_HS_CONN_HANDLE_NONE; + + uint8_t mac[6] = {0}; + esp_efuse_mac_get_default(mac); + snprintf(s_device_name, sizeof(s_device_name), "%s-%02X%02X", name_prefix, mac[4], mac[5]); + + ret = nimble_port_init(); + if (ret != ESP_OK) { + ESP_LOGE(TAG, "nimble_port_init failed: %s", esp_err_to_name(ret)); + return ret; + } + + // "Just works" LE Secure Connections + bonding. App-level auth (PSK/HMAC) on + // the P4 is the trust boundary, so no MITM passkey is required here. + ble_hs_cfg.sm_io_cap = BLE_SM_IO_CAP_NO_IO; + ble_hs_cfg.sm_bonding = 1; + ble_hs_cfg.sm_mitm = 0; + ble_hs_cfg.sm_sc = 1; + 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_hs_cfg.reset_cb = on_reset; + ble_hs_cfg.sync_cb = on_sync; + ble_hs_cfg.store_status_cb = ble_store_util_status_rr; + + ble_svc_gap_init(); + ble_svc_gatt_init(); + ble_svc_gap_device_name_set(s_device_name); + + int rc = ble_gatts_count_cfg(GATT_SERVICES); + if (rc != 0) { + ESP_LOGE(TAG, "ble_gatts_count_cfg failed rc=%d", rc); + return ESP_FAIL; + } + rc = ble_gatts_add_svcs(GATT_SERVICES); + if (rc != 0) { + ESP_LOGE(TAG, "ble_gatts_add_svcs failed rc=%d", rc); + return ESP_FAIL; + } + + ble_att_set_preferred_mtu(HOST_PREFERRED_MTU); + ble_store_config_init(); + nimble_port_freertos_init(host_task); + + s_is_running = true; + ESP_LOGI(TAG, "Initialized — name='%s'", s_device_name); + return ESP_OK; +} + +void host_link_gatt_stop(void) { + if (!s_is_running) { + return; + } + ble_gap_adv_stop(); + if (s_is_connected && s_conn_handle != BLE_HS_CONN_HANDLE_NONE) { + ble_gap_terminate(s_conn_handle, BLE_ERR_REM_USER_CONN_TERM); + } + if (nimble_port_stop() == 0) { + nimble_port_deinit(); + } + s_is_running = false; + s_is_connected = false; + s_is_subscribed = false; + s_conn_handle = BLE_HS_CONN_HANDLE_NONE; + host_transport_reset(); +} + +bool host_link_gatt_is_running(void) { + return s_is_running; +} + +bool host_link_gatt_is_connected(void) { + return s_is_connected; +} + +bool host_link_gatt_is_subscribed(void) { + return s_is_subscribed; +} + +void host_link_gatt_notify(const uint8_t *frame, uint16_t len) { + if (frame == NULL || len == 0) { + return; + } + if (!s_is_connected || s_tx_attr_handle == 0) { + return; + } + + // A notification can carry at most (ATT_MTU - 3) bytes. Frames larger than + // that span multiple notifications; the app reassembles by the host-frame + // LEN field (notifications are ordered on the ATT connection). + uint16_t mtu = ble_att_mtu(s_conn_handle); + uint16_t slice_max = (mtu > 3) ? (uint16_t)(mtu - 3) : 20; + + uint16_t offset = 0; + while (offset < len) { + uint16_t slice = (uint16_t)(len - offset); + if (slice > slice_max) { + slice = slice_max; + } + struct os_mbuf *om = ble_hs_mbuf_from_flat(frame + offset, slice); + if (om == NULL) { + ESP_LOGW(TAG, "mbuf alloc failed (%u bytes)", slice); + return; + } + int rc = ble_gatts_notify_custom(s_conn_handle, s_tx_attr_handle, om); + if (rc != 0) { + ESP_LOGW(TAG, "notify failed rc=%d", rc); + return; + } + offset += slice; + } +} + +static int rx_access(uint16_t conn, uint16_t attr, struct ble_gatt_access_ctxt *ctxt, void *arg) { + (void)conn; + (void)attr; + (void)arg; + if (ctxt->op != BLE_GATT_ACCESS_OP_WRITE_CHR) { + return BLE_ATT_ERR_UNLIKELY; + } + uint16_t len = OS_MBUF_PKTLEN(ctxt->om); + if (len == 0 || len > sizeof(s_rx_buf)) { + return 0; + } + os_mbuf_copydata(ctxt->om, 0, len, s_rx_buf); + host_transport_send_to_p4(s_rx_buf, len); + return 0; +} + +static int tx_access(uint16_t conn, uint16_t attr, struct ble_gatt_access_ctxt *ctxt, void *arg) { + (void)conn; + (void)attr; + (void)ctxt; + (void)arg; + return 0; +} + +static int gap_event(struct ble_gap_event *event, void *arg) { + (void)arg; + switch (event->type) { + case BLE_GAP_EVENT_CONNECT: + if (event->connect.status == 0) { + s_conn_handle = event->connect.conn_handle; + s_is_connected = true; + s_is_subscribed = false; + ESP_LOGI(TAG, "Companion connected handle=%u", s_conn_handle); + ble_gattc_exchange_mtu(s_conn_handle, NULL, NULL); + } else { + ESP_LOGW(TAG, "Connect failed status=%d", event->connect.status); + advertise_start(); + } + break; + + case BLE_GAP_EVENT_DISCONNECT: + ESP_LOGI(TAG, "Companion disconnected reason=0x%x", event->disconnect.reason); + s_conn_handle = BLE_HS_CONN_HANDLE_NONE; + s_is_connected = false; + s_is_subscribed = false; + host_transport_reset(); + advertise_start(); + break; + + case BLE_GAP_EVENT_MTU: + ESP_LOGI(TAG, "MTU updated to %u", event->mtu.value); + break; + + case BLE_GAP_EVENT_SUBSCRIBE: + if (event->subscribe.attr_handle == s_tx_attr_handle) { + s_is_subscribed = (event->subscribe.cur_notify != 0); + ESP_LOGI(TAG, "TX subscribe=%d", s_is_subscribed); + } + break; + + case BLE_GAP_EVENT_ENC_CHANGE: + ESP_LOGI(TAG, "Encryption status=%d", event->enc_change.status); + break; + + case BLE_GAP_EVENT_REPEAT_PAIRING: { + struct ble_gap_conn_desc desc; + if (ble_gap_conn_find(event->repeat_pairing.conn_handle, &desc) != 0) { + return BLE_GAP_REPEAT_PAIRING_IGNORE; + } + ble_store_util_delete_peer(&desc.peer_id_addr); + return BLE_GAP_REPEAT_PAIRING_RETRY; + } + + default: + break; + } + return 0; +} + +static void advertise_start(void) { + struct ble_gap_adv_params adv = { + .conn_mode = BLE_GAP_CONN_MODE_UND, + .disc_mode = BLE_GAP_DISC_MODE_GEN, + }; + + struct ble_hs_adv_fields fields = {0}; + fields.flags = BLE_HS_ADV_F_DISC_GEN | BLE_HS_ADV_F_BREDR_UNSUP; + fields.tx_pwr_lvl_is_present = 1; + fields.tx_pwr_lvl = BLE_HS_ADV_TX_PWR_LVL_AUTO; + fields.name = (uint8_t *)s_device_name; + fields.name_len = strlen(s_device_name); + fields.name_is_complete = 1; + ble_gap_adv_set_fields(&fields); + + struct ble_hs_adv_fields rsp = {0}; + rsp.uuids128 = (ble_uuid128_t *)&HOST_SERVICE_UUID; + rsp.num_uuids128 = 1; + rsp.uuids128_is_complete = 1; + ble_gap_adv_rsp_set_fields(&rsp); + + int rc = ble_gap_adv_start(s_own_addr_type, NULL, BLE_HS_FOREVER, &adv, gap_event, NULL); + if (rc != 0 && rc != BLE_HS_EALREADY) { + ESP_LOGE(TAG, "adv_start failed rc=%d", rc); + return; + } + ESP_LOGI(TAG, "Advertising '%s'", s_device_name); +} + +static void on_sync(void) { + ble_hs_util_ensure_addr(0); + ble_hs_id_infer_auto(0, &s_own_addr_type); + advertise_start(); +} + +static void on_reset(int reason) { + ESP_LOGW(TAG, "NimBLE reset reason=%d", reason); +} + +static void host_task(void *param) { + (void)param; + ESP_LOGI(TAG, "NimBLE host task running"); + nimble_port_run(); + nimble_port_freertos_deinit(); +} diff --git a/firmware_c5/components/Service/host_link/host_transport.c b/firmware_c5/components/Service/host_link/host_transport.c new file mode 100644 index 000000000..dc85d10a9 --- /dev/null +++ b/firmware_c5/components/Service/host_link/host_transport.c @@ -0,0 +1,205 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#include "host_transport.h" + +#include + +#include "esp_log.h" +#include "freertos/FreeRTOS.h" +#include "freertos/semphr.h" + +#include "host_link_gatt.h" +#include "spi_bridge.h" + +static const char *TAG = "HOST_TRANSPORT"; + +#define TRANSPORT_TX_FRAME_MAX 4096 +#define TRANSPORT_CHUNK_HDR_SIZE sizeof(spi_mesh_chunk_hdr_t) +#define TRANSPORT_MUTEX_TIMEOUT_MS 50 + +typedef struct { + bool is_active; + uint8_t seq; + uint8_t total_chunks; + uint8_t next_chunk_idx; + uint16_t accumulated_len; + uint8_t buf[TRANSPORT_TX_FRAME_MAX]; +} host_reassembly_t; + +static bool s_is_initialized = false; +static SemaphoreHandle_t s_mutex = NULL; +static host_reassembly_t s_tx_in = {0}; +static uint8_t s_rx_seq = 0; + +static bool take_mutex(void); +static void give_mutex(void); + +esp_err_t host_transport_init(void) { + if (s_is_initialized) { + return ESP_OK; + } + s_mutex = xSemaphoreCreateMutex(); + if (s_mutex == NULL) { + ESP_LOGE(TAG, "Failed to create transport mutex"); + return ESP_ERR_NO_MEM; + } + memset(&s_tx_in, 0, sizeof(s_tx_in)); + s_rx_seq = 0; + s_is_initialized = true; + spi_bridge_stream_enable(SPI_ID_HOST_RX, true); + ESP_LOGI(TAG, "Transport initialized"); + return ESP_OK; +} + +void host_transport_inject_tx_chunk(const uint8_t *payload, uint8_t len) { + if (payload == NULL || len < (uint8_t)TRANSPORT_CHUNK_HDR_SIZE) { + return; + } + if (!take_mutex()) { + return; + } + + spi_mesh_chunk_hdr_t hdr; + memcpy(&hdr, payload, sizeof(hdr)); + const uint8_t *data = payload + sizeof(hdr); + uint8_t data_len = (uint8_t)(len - sizeof(hdr)); + + if (hdr.total_chunks == 0) { + give_mutex(); + return; + } + + if (hdr.chunk_idx == 0) { + s_tx_in.is_active = true; + s_tx_in.seq = hdr.seq; + s_tx_in.total_chunks = hdr.total_chunks; + s_tx_in.next_chunk_idx = 0; + s_tx_in.accumulated_len = 0; + } else if (!s_tx_in.is_active) { + give_mutex(); + return; + } + + if (hdr.seq != s_tx_in.seq || hdr.chunk_idx != s_tx_in.next_chunk_idx || + hdr.total_chunks != s_tx_in.total_chunks) { + s_tx_in.is_active = false; + give_mutex(); + return; + } + + if ((uint16_t)(s_tx_in.accumulated_len + data_len) > sizeof(s_tx_in.buf)) { + s_tx_in.is_active = false; + give_mutex(); + return; + } + + memcpy(s_tx_in.buf + s_tx_in.accumulated_len, data, data_len); + s_tx_in.accumulated_len = (uint16_t)(s_tx_in.accumulated_len + data_len); + s_tx_in.next_chunk_idx++; + + if (s_tx_in.next_chunk_idx >= s_tx_in.total_chunks) { + static uint8_t snapshot[TRANSPORT_TX_FRAME_MAX]; + uint16_t snapshot_len = s_tx_in.accumulated_len; + memcpy(snapshot, s_tx_in.buf, snapshot_len); + s_tx_in.is_active = false; + give_mutex(); + + host_link_gatt_notify(snapshot, snapshot_len); + return; + } + + give_mutex(); +} + +bool host_transport_send_to_p4(const uint8_t *frame, uint16_t len) { + if (frame == NULL || len == 0) { + return false; + } + if (!spi_bridge_stream_is_enabled(SPI_ID_HOST_RX)) { + return false; + } + + uint16_t total_u16 = + (uint16_t)((len + SPI_MESH_CHUNK_PAYLOAD_MAX - 1) / SPI_MESH_CHUNK_PAYLOAD_MAX); + if (total_u16 == 0 || total_u16 > 255) { + return false; + } + uint8_t total_chunks = (uint8_t)total_u16; + uint8_t seq; + if (take_mutex()) { + seq = s_rx_seq++; + give_mutex(); + } else { + return false; + } + + uint8_t buf[SPI_MESH_PAYLOAD_LIMIT]; + spi_mesh_chunk_hdr_t hdr; + uint16_t offset = 0; + + for (uint8_t idx = 0; idx < total_chunks; idx++) { + uint16_t remaining = (uint16_t)(len - offset); + uint16_t this_chunk = + remaining > SPI_MESH_CHUNK_PAYLOAD_MAX ? SPI_MESH_CHUNK_PAYLOAD_MAX : remaining; + + hdr.seq = seq; + hdr.chunk_idx = idx; + hdr.total_chunks = total_chunks; + hdr.flags = (idx == (uint8_t)(total_chunks - 1)) ? SPI_MESH_CHUNK_FLAG_LAST : 0; + + memcpy(buf, &hdr, sizeof(hdr)); + memcpy(buf + sizeof(hdr), frame + offset, this_chunk); + + uint8_t push_len = (uint8_t)(sizeof(hdr) + this_chunk); + if (!spi_bridge_stream_push(SPI_ID_HOST_RX, buf, push_len)) { + return false; + } + offset += this_chunk; + } + return true; +} + +void host_transport_get_status(spi_host_status_t *out_status) { + if (out_status == NULL) { + return; + } + out_status->ble_connected = host_link_gatt_is_connected() ? 1 : 0; + out_status->ble_subscribed = host_link_gatt_is_subscribed() ? 1 : 0; + out_status->reserved[0] = 0; + out_status->reserved[1] = 0; +} + +void host_transport_reset(void) { + if (!take_mutex()) { + return; + } + memset(&s_tx_in, 0, sizeof(s_tx_in)); + s_rx_seq = 0; + give_mutex(); +} + +static bool take_mutex(void) { + if (s_mutex == NULL) { + return false; + } + return xSemaphoreTake(s_mutex, pdMS_TO_TICKS(TRANSPORT_MUTEX_TIMEOUT_MS)) == pdTRUE; +} + +static void give_mutex(void) { + if (s_mutex != NULL) { + xSemaphoreGive(s_mutex); + } +} diff --git a/firmware_c5/components/Service/host_link/include/c5_log.h b/firmware_c5/components/Service/host_link/include/c5_log.h new file mode 100644 index 000000000..5a31573fe --- /dev/null +++ b/firmware_c5/components/Service/host_link/include/c5_log.h @@ -0,0 +1,38 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#ifndef C5_LOG_H +#define C5_LOG_H + +#ifdef __cplusplus +extern "C" { +#endif + +#include "esp_err.h" + +// C5 log tee. Hooks esp_log_set_vprintf so every ESP_LOGx line is still printed +// on the local C5 dev console AND copied (ANSI stripped) into a drop-oldest +// ring. A worker forwards each line to the P4 over the SPI_ID_SYSTEM_LOG stream +// as [level u8][utf-8 text]; the P4 relays it to the companion as a LOG frame +// with source=C5. + +/** @brief Install the C5 log tee and start the forwarding worker. */ +esp_err_t c5_log_init(void); + +#ifdef __cplusplus +} +#endif + +#endif // C5_LOG_H diff --git a/firmware_c5/components/Service/host_link/include/host_link_gatt.h b/firmware_c5/components/Service/host_link/include/host_link_gatt.h new file mode 100644 index 000000000..084023fdc --- /dev/null +++ b/firmware_c5/components/Service/host_link/include/host_link_gatt.h @@ -0,0 +1,56 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#ifndef HOST_LINK_GATT_H +#define HOST_LINK_GATT_H + +#ifdef __cplusplus +extern "C" { +#endif + +#include +#include + +#include "esp_err.h" + +// Companion host-link GATT server on the C5 (NimBLE). A NUS-style service with +// a write characteristic (app→device) and a notify characteristic +// (device→app). Opaque byte relay — all crypto/auth is on the P4. Mirrors +// meshcore_gatt, but uses "just works" LE Secure Connections (no MITM) since +// the host-link PSK/HMAC envelope is the real trust boundary. + +/** @brief Start the GATT server and begin advertising as "-XXXX". */ +esp_err_t host_link_gatt_init(const char *name_prefix); + +/** @brief Stop advertising / GATT and tear down the NimBLE host. */ +void host_link_gatt_stop(void); + +/** @brief True while the GATT server is running. */ +bool host_link_gatt_is_running(void); + +/** @brief True while a companion is connected. */ +bool host_link_gatt_is_connected(void); + +/** @brief True while the companion has enabled notifications on the TX char. */ +bool host_link_gatt_is_subscribed(void); + +/** @brief Notify the connected companion with a reassembled device→app frame. */ +void host_link_gatt_notify(const uint8_t *frame, uint16_t len); + +#ifdef __cplusplus +} +#endif + +#endif // HOST_LINK_GATT_H diff --git a/firmware_c5/components/Service/host_link/include/host_transport.h b/firmware_c5/components/Service/host_link/include/host_transport.h new file mode 100644 index 000000000..57b99edce --- /dev/null +++ b/firmware_c5/components/Service/host_link/include/host_transport.h @@ -0,0 +1,58 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#ifndef HOST_TRANSPORT_H +#define HOST_TRANSPORT_H + +#ifdef __cplusplus +extern "C" { +#endif + +#include +#include + +#include "esp_err.h" +#include "spi_protocol.h" + +// Companion host-link transport on the C5: chunk/reassemble opaque host frames +// between BLE (host_link_gatt) and the SPI bridge to the P4. The C5 never parses +// payloads — it only moves bytes. Mirrors meshcore_transport. + +/** @brief Init the transport (mutex, reassembly state) and enable the RX stream. */ +esp_err_t host_transport_init(void); + +/** + * @brief Feed one P4→C5 chunk (from SPI_ID_HOST_TX). Reassembles per seq and, + * on the last chunk, notifies the reassembled frame over BLE. + */ +void host_transport_inject_tx_chunk(const uint8_t *payload, uint8_t len); + +/** + * @brief Chunk a BLE-received frame and push it to the P4 over the + * SPI_ID_HOST_RX stream. Called from the GATT write handler. + */ +bool host_transport_send_to_p4(const uint8_t *frame, uint16_t len); + +/** @brief Fill the BLE connection status for SPI_ID_HOST_STATUS. */ +void host_transport_get_status(spi_host_status_t *out_status); + +/** @brief Clear reassembly + sequence state (on disconnect / stop). */ +void host_transport_reset(void); + +#ifdef __cplusplus +} +#endif + +#endif // HOST_TRANSPORT_H diff --git a/firmware_c5/components/Service/http_server/README.md b/firmware_c5/components/Service/http_server/README.md index 89e3a47ac..8ca0a6259 100644 --- a/firmware_c5/components/Service/http_server/README.md +++ b/firmware_c5/components/Service/http_server/README.md @@ -1,116 +1,7 @@ # HTTP Server Service Component Documentation -This component provides an abstraction layer over ESP-IDF's native `esp_http_server`, facilitating initialization, request handling, response sending, and file system (SD Card) integration for the Highboy project. +Documentation for this component lives in the project docs hub (single source of truth): -## Overview +- [docs/http_server/README.md](../../../../docs/http_server/README.md) -- **Location:** `components/Service/http_server/` -- **Main Header:** `include/http_server_service.h` -- **Implementation:** `http_server_service.c` - -The service manages the web server lifecycle (start/stop), route registration (URIs), and offers utilities for reading HTML files from storage and handling standard HTTP errors. - -## API Functions - -### Server Management - -#### `start_web_server` -```c -esp_err_t start_web_server(void); -``` -Starts the HTTP server with default configurations, enabling `lru_purge_enable` to manage old connections. - -#### `stop_http_server` -```c -esp_err_t stop_http_server(void); -``` -Stops the HTTP server if it is running and frees associated resources. - -#### `http_service_register_uri` -```c -esp_err_t http_service_register_uri(const httpd_uri_t *uri_handler); -``` -Registers a URI handler (route) on the active server. Returns an error if the server is not started. - -### Request and Response Handling - -#### `http_service_req_recv` -```c -esp_err_t http_service_req_recv(httpd_req_t *req, char *buffer, size_t buffer_size); -``` -Receives the content (body) of a request with safety checks for buffer size. -- Returns `ESP_ERR_INVALID_SIZE` if the content is larger than the buffer. -- Automatically handles timeouts. - -#### `http_service_query_key_value` -```c -esp_err_t http_service_query_key_value(const char *data_buffer, const char *key, char *out_val, size_t out_size); -``` -Extracts the value of a specific key from a query string (URL encoded). Handles cases where the key is not found or the value is truncated. - -#### `http_service_send_response` -```c -esp_err_t http_service_send_response(httpd_req_t *req, const char *buffer, ssize_t length); -``` -Sends a generic HTTP response. -- If `buffer` is `NULL`, it automatically sends a 500 error. - -#### `http_service_send_error` -```c -esp_err_t http_service_send_error(httpd_req_t *req, http_status_t status_code, const char *msg); -``` -Sends a standardized HTTP error response, mapping the internal `http_status_t` enum to ESP-IDF error codes (`httpd_err_code_t`). - -### Storage Integration (SD Card) - -#### `get_html_buffer` -```c -const char *get_html_buffer(const char *path); -``` -Reads an entire file from the specified path (usually from the SD Card) and returns a dynamically allocated buffer containing the data, null-terminated (`\0`). -- **Note:** The caller is responsible for freeing the returned memory (see Casting note below). - -#### `http_service_send_file_from_sd` -```c -esp_err_t http_service_send_file_from_sd(httpd_req_t *req, const char *filepath); -``` -Combines `get_html_buffer` and `http_service_send_response` to read a file and send it directly as a response to the request. Automatically frees the buffer memory after sending. - ---- - -## Castings and Implementation Details - -Below are listed all explicit "castings" (type conversions) performed in the source code `http_server_service.c`, which are fundamental for memory allocation and opaque type manipulation. - -### 1. File Buffer Allocation -**Location:** Function `get_html_buffer` -```c -char *buffer = (char *)malloc(file_size + 1); -``` -- **From:** `void *` (generic return from `malloc`) -- **To:** `char *` -- **Reason:** The pointer returned by `malloc` needs to be treated as a character string to store the file content and the null terminator. - -### 2. Constant Memory Deallocation -**Location:** Function `http_service_send_file_from_sd` -```c -free((void*)html_content); -``` -- **From:** `const char *` (type of `html_content` variable) -- **To:** `void *` -- **Reason:** The `get_html_buffer` function returns a `const char *` to semantically indicate that the receiver should not alter its content. However, to free this memory with `free()`, it is necessary to remove the `const` qualifier via a cast to `void *`; otherwise, the compiler would emit a warning or error, since `free` expects a pointer to mutable memory (even though it only frees it). - ---- - -## Auxiliary Data Structures - -### `http_status_t` -Enumeration defined in `http_server_service.h` to abstract HTTP status codes and facilitate internal mapping: -- `HTTP_STATUS_OK_200` -- `HTTP_STATUS_CREATED_201` -- `HTTP_STATUS_BAD_REQUEST_400` -- `HTTP_STATUS_UNAUTHORIZED_401` -- `HTTP_STATUS_FORBIDDEN_403` -- `HTTP_STATUS_NOT_FOUND_404` -- `HTTP_STATUS_REQUEST_TIMEOUT_408` -- `HTTP_STATUS_INTERNAL_ERROR_500` +This file is only a pointer: edit the docs there to keep things from drifting. diff --git a/firmware_c5/components/Service/meshcore/meshcore_gatt.c b/firmware_c5/components/Service/meshcore/meshcore_gatt.c index 0eebb1c32..ee2f3b2fd 100644 --- a/firmware_c5/components/Service/meshcore/meshcore_gatt.c +++ b/firmware_c5/components/Service/meshcore/meshcore_gatt.c @@ -178,7 +178,9 @@ void meshcore_gatt_stop(void) { if (s_is_connected && s_conn_handle != BLE_HS_CONN_HANDLE_NONE) { ble_gap_terminate(s_conn_handle, BLE_ERR_REM_USER_CONN_TERM); } - nimble_port_stop(); + if (nimble_port_stop() == 0) { + nimble_port_deinit(); + } s_is_running = false; s_is_connected = false; s_is_subscribed = false; diff --git a/firmware_c5/components/Service/meshtastic/meshtastic_gatt.c b/firmware_c5/components/Service/meshtastic/meshtastic_gatt.c index f5a1f70cc..15eecc222 100644 --- a/firmware_c5/components/Service/meshtastic/meshtastic_gatt.c +++ b/firmware_c5/components/Service/meshtastic/meshtastic_gatt.c @@ -44,7 +44,7 @@ static const char *TAG = "MESH_GATT"; #define GATT_PREFERRED_MTU 512 #define GATT_PASSKEY_MIN 100000 #define GATT_PASSKEY_RANGE 900000 -#define GATT_FROMRADIO_QUEUE_SLOTS 64 +#define GATT_FROMRADIO_QUEUE_SLOTS 16 #define GATT_FROMRADIO_FRAME_MAX 512 #define GATT_BATTERY_STUB_PCT 87 #define GATT_DEVICE_NAME_LEN 32 @@ -244,7 +244,9 @@ void meshtastic_gatt_stop(void) { if (s_is_connected && s_conn_handle != BLE_HS_CONN_HANDLE_NONE) { ble_gap_terminate(s_conn_handle, BLE_ERR_REM_USER_CONN_TERM); } - nimble_port_stop(); + if (nimble_port_stop() == 0) { + nimble_port_deinit(); + } s_is_running = false; s_is_connected = false; s_fromnum_subscribed = false; diff --git a/firmware_c5/components/Service/meshtastic/meshtastic_tcp.c b/firmware_c5/components/Service/meshtastic/meshtastic_tcp.c index 6728272c3..dc9c42961 100644 --- a/firmware_c5/components/Service/meshtastic/meshtastic_tcp.c +++ b/firmware_c5/components/Service/meshtastic/meshtastic_tcp.c @@ -23,6 +23,7 @@ #include "freertos/FreeRTOS.h" #include "freertos/semphr.h" #include "freertos/task.h" +#include "sys_prio.h" #include "lwip/sockets.h" #include "mdns.h" @@ -32,7 +33,7 @@ static const char *TAG = "MESH_TCP"; #define TCP_PORT 4403 #define TCP_TASK_STACK 4096 -#define TCP_TASK_PRIO 5 +#define TCP_TASK_PRIO SYS_PRIO_SERVICE_HI #define TCP_TASK_TICK_MS 50 #define TCP_RX_BUF_SIZE 512 #define TCP_FRAME_MAX 512 diff --git a/firmware_c5/components/Service/ota/include/ota_service.h b/firmware_c5/components/Service/ota/include/ota_service.h new file mode 100644 index 000000000..8d3fcc725 --- /dev/null +++ b/firmware_c5/components/Service/ota/include/ota_service.h @@ -0,0 +1,66 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#ifndef OTA_SERVICE_H +#define OTA_SERVICE_H + +#ifdef __cplusplus +extern "C" { +#endif + +#include + +#include "esp_err.h" + +#include "spi_protocol.h" + +// App OTA entirely over SPI - no UART involved. +// +// Flow: +// 1. P4 sends SPI_ID_SYSTEM_OTA_BEGIN with the image size -> begin(): a task +// erases the target partition and sets state=READY (async so the bridge +// stays responsive during the multi-second erase). +// 2. P4 polls SPI_ID_SYSTEM_OTA_STATUS -> get_status() until READY. +// 3. P4 sends the image as SPI_ID_SYSTEM_OTA_DATA chunks -> write(); each is +// written sequentially and acked by the command response. bytes_written is +// also the resync point if a chunk's response is lost. +// 4. When bytes_written reaches the size, write() finalizes, sets DONE, and +// reboots into the new app. + +// Begin an OTA of @p size bytes over @p transport (spi_ota_transport_t): validate, +// spawn the receiver/writer task, return at once. For SPI the image arrives via +// ota_service_write (OTA_DATA commands); for UART the task reads it from UART0. +// Returns ESP_ERR_INVALID_STATE if one is already running. +esp_err_t ota_service_begin(uint32_t size, uint8_t transport); + +// Write one firmware chunk (from an OTA_DATA command). Sequential; advances +// bytes_written and finalizes+reboots on the last chunk. Returns the esp_ota +// result so the bridge can ack/nak the chunk over SPI. +esp_err_t ota_service_write(const uint8_t *data, uint16_t len); + +// Fill @p out with the current OTA state and byte count. Non-blocking. +void ota_service_get_status(spi_ota_status_t *out); + +// Validate a freshly booted OTA image: if the running partition is pending +// verification, wait for the P4 to reach us over the bridge and mark the app +// valid, otherwise roll back. No-op on a normal boot. Call after kernel_init so +// the bridge slave is already listening. Blocks up to the validation window. +esp_err_t ota_post_boot_check(void); + +#ifdef __cplusplus +} +#endif + +#endif // OTA_SERVICE_H diff --git a/firmware_c5/components/Service/ota/ota_service.c b/firmware_c5/components/Service/ota/ota_service.c new file mode 100644 index 000000000..2c3eef3ec --- /dev/null +++ b/firmware_c5/components/Service/ota/ota_service.c @@ -0,0 +1,286 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#include "ota_service.h" + +#include "driver/uart.h" +#include "esp_log.h" +#include "esp_ota_ops.h" +#include "esp_partition.h" +#include "esp_system.h" +#include "esp_timer.h" +#include "freertos/FreeRTOS.h" +#include "freertos/stream_buffer.h" +#include "freertos/task.h" +#include "sys_prio.h" + +#include "spi_bridge.h" + +static const char *TAG = "OTA_SVC"; + +// Plausible C5 app image size bounds - guards against acting on a bogus size. +#define OTA_MIN_SIZE 0x10000 +#define OTA_MAX_SIZE 0x200000 + +#define OTA_VALIDATE_TIMEOUT_MS 15000 +#define OTA_VALIDATE_POLL_MS 100 + +// SPI transport: a RAM buffer decouples the OTA_DATA acks from the flash writes - +// the bridge task only pushes chunks here (fast, no flash) and acks at once, +// while the writer task drains this and does the slow esp_ota_write. +#define OTA_STREAM_SIZE (16 * 1024) +#define OTA_WRITE_BUF 1024 +#define OTA_RECV_STALL_MS 5000 // writer gives up if the source stops feeding it + +// UART transport: UART0 (U0RXD=GPIO12, U0TXD=GPIO11) carries the raw .bin from the +// P4; control (begin/status) stays on SPI so the C5 never transmits on UART here. +#define OTA_UART UART_NUM_0 +#define OTA_UART_RX_PIN 12 +#define OTA_UART_TX_PIN 11 +#define OTA_UART_BAUD 115200 +#define OTA_UART_RINGBUF (16 * 1024) + +static volatile uint8_t s_state = SPI_OTA_STATE_IDLE; +static volatile uint32_t s_bytes_written = 0; +static uint32_t s_ota_size = 0; +static uint8_t s_transport = SPI_OTA_TRANSPORT_SPI; +static StreamBufferHandle_t s_stream = NULL; + +static void ota_reboot_task(void *arg) { + (void)arg; + vTaskDelay(pdMS_TO_TICKS(500)); // let the P4 poll DONE before we drop the link + esp_restart(); +} + +// Bring UART0 up for RX. Console output on U0TXD (GPIO11) is untouched; the .bin +// only comes in on U0RXD (GPIO12). +static esp_err_t ota_uart_open(void) { + const uart_config_t cfg = { + .baud_rate = OTA_UART_BAUD, + .data_bits = UART_DATA_8_BITS, + .parity = UART_PARITY_DISABLE, + .stop_bits = UART_STOP_BITS_1, + .flow_ctrl = UART_HW_FLOWCTRL_DISABLE, + .source_clk = UART_SCLK_DEFAULT, + }; + if (!uart_is_driver_installed(OTA_UART)) { + esp_err_t err = uart_driver_install(OTA_UART, OTA_UART_RINGBUF, 0, 0, NULL, 0); + if (err != ESP_OK) { + return err; + } + } + esp_err_t err = uart_param_config(OTA_UART, &cfg); + if (err == ESP_OK) { + err = uart_set_pin( + OTA_UART, OTA_UART_TX_PIN, OTA_UART_RX_PIN, UART_PIN_NO_CHANGE, UART_PIN_NO_CHANGE); + } + return err; +} + +static void ota_writer_task(void *arg) { + (void)arg; + + const esp_partition_t *part = esp_ota_get_next_update_partition(NULL); + if (part == NULL) { + ESP_LOGE(TAG, "no OTA partition available"); + s_state = SPI_OTA_STATE_ERROR; + vTaskDelete(NULL); + return; + } + + const bool uart = (s_transport == SPI_OTA_TRANSPORT_UART); + if (uart && ota_uart_open() != ESP_OK) { + ESP_LOGE(TAG, "failed to open UART0 for OTA"); + s_state = SPI_OTA_STATE_ERROR; + vTaskDelete(NULL); + return; + } + + ESP_LOGW(TAG, + "OTA: %lu bytes -> '%s' via %s (erasing...)", + (unsigned long)s_ota_size, + part->label, + uart ? "UART" : "SPI"); + esp_ota_handle_t handle = 0; + s_state = SPI_OTA_STATE_ERASING; + esp_err_t err = esp_ota_begin(part, s_ota_size, &handle); // erases (multi-second) + if (err != ESP_OK) { + ESP_LOGE(TAG, "esp_ota_begin: %s", esp_err_to_name(err)); + s_state = SPI_OTA_STATE_ERROR; + goto fail; + } + + if (uart) { + uart_flush_input(OTA_UART); + } + s_state = SPI_OTA_STATE_READY; // the P4 polls for this before sending data + ESP_LOGI(TAG, "erased - ready for %lu bytes", (unsigned long)s_ota_size); + + static uint8_t buf[OTA_WRITE_BUF]; + while (s_bytes_written < s_ota_size) { + uint32_t remaining = s_ota_size - s_bytes_written; + size_t want = remaining < sizeof(buf) ? remaining : sizeof(buf); + size_t n; + if (uart) { + int r = uart_read_bytes(OTA_UART, buf, want, pdMS_TO_TICKS(OTA_RECV_STALL_MS)); + n = (r > 0) ? (size_t)r : 0; + } else { + n = xStreamBufferReceive(s_stream, buf, want, pdMS_TO_TICKS(OTA_RECV_STALL_MS)); + } + if (n == 0) { + ESP_LOGE(TAG, "stall @ %lu/%lu", (unsigned long)s_bytes_written, (unsigned long)s_ota_size); + esp_ota_abort(handle); + s_state = SPI_OTA_STATE_ERROR; + goto fail; + } + err = esp_ota_write(handle, buf, n); + if (err != ESP_OK) { + ESP_LOGE( + TAG, "esp_ota_write @ %lu: %s", (unsigned long)s_bytes_written, esp_err_to_name(err)); + esp_ota_abort(handle); + s_state = SPI_OTA_STATE_ERROR; + goto fail; + } + s_bytes_written += n; + s_state = SPI_OTA_STATE_RECEIVING; + if ((s_bytes_written % (256 * 1024)) < n) { + ESP_LOGI(TAG, " written %lu/%lu", (unsigned long)s_bytes_written, (unsigned long)s_ota_size); + } + } + + ESP_LOGI(TAG, "all %lu bytes written, validating...", (unsigned long)s_ota_size); + err = esp_ota_end(handle); + if (err == ESP_OK) { + err = esp_ota_set_boot_partition(part); + } + if (err != ESP_OK) { + ESP_LOGE(TAG, "finalize failed: %s", esp_err_to_name(err)); + s_state = SPI_OTA_STATE_ERROR; + goto fail; + } + + ESP_LOGW(TAG, "OTA OK - booting '%s'", part->label); + s_state = SPI_OTA_STATE_DONE; + if (uart && uart_is_driver_installed(OTA_UART)) { + uart_driver_delete(OTA_UART); + } + xTaskCreate(ota_reboot_task, "ota_reboot", 2048, NULL, SYS_PRIO_SERVICE_HI, NULL); + vTaskDelete(NULL); + return; + +fail: + if (uart && uart_is_driver_installed(OTA_UART)) { + uart_driver_delete(OTA_UART); + } + vTaskDelete(NULL); +} + +esp_err_t ota_service_begin(uint32_t size, uint8_t transport) { + if (s_state == SPI_OTA_STATE_ERASING || s_state == SPI_OTA_STATE_READY || + s_state == SPI_OTA_STATE_RECEIVING) { + return ESP_ERR_INVALID_STATE; // one already running + } + if (size < OTA_MIN_SIZE || size > OTA_MAX_SIZE) { + ESP_LOGE(TAG, "implausible OTA size %lu", (unsigned long)size); + s_state = SPI_OTA_STATE_ERROR; + return ESP_ERR_INVALID_SIZE; + } + if (transport != SPI_OTA_TRANSPORT_SPI && transport != SPI_OTA_TRANSPORT_UART) { + return ESP_ERR_INVALID_ARG; + } + + if (transport == SPI_OTA_TRANSPORT_SPI) { + if (s_stream == NULL) { + s_stream = xStreamBufferCreate(OTA_STREAM_SIZE, 1); + if (s_stream == NULL) { + ESP_LOGE(TAG, "failed to create OTA stream buffer"); + s_state = SPI_OTA_STATE_ERROR; + return ESP_ERR_NO_MEM; + } + } else { + xStreamBufferReset(s_stream); + } + } + + s_ota_size = size; + s_bytes_written = 0; + s_transport = transport; + s_state = SPI_OTA_STATE_ERASING; // visible to the P4's first status poll + + if (xTaskCreate(ota_writer_task, "ota_wr", 6144, NULL, SYS_PRIO_SERVICE_HI, NULL) != pdPASS) { + ESP_LOGE(TAG, "failed to create writer task"); + s_state = SPI_OTA_STATE_ERROR; + return ESP_ERR_NO_MEM; + } + return ESP_OK; +} + +esp_err_t ota_service_write(const uint8_t *data, uint16_t len) { + if (s_transport != SPI_OTA_TRANSPORT_SPI || s_stream == NULL || + (s_state != SPI_OTA_STATE_READY && s_state != SPI_OTA_STATE_RECEIVING && + s_state != SPI_OTA_STATE_ERASING)) { + return ESP_ERR_INVALID_STATE; // -> SPI_STATUS_ERROR (fatal for the P4) + } + if (len == 0) { + return ESP_OK; + } + if (xStreamBufferSpacesAvailable(s_stream) < len) { + return ESP_ERR_NO_MEM; // -> SPI_STATUS_BUSY: writer is behind, P4 retries + } + xStreamBufferSend(s_stream, data, len, 0); // never blocks: space was checked + return ESP_OK; +} + +void ota_service_get_status(spi_ota_status_t *out) { + if (out == NULL) { + return; + } + out->state = s_state; + out->bytes_written = s_bytes_written; +} + +esp_err_t ota_post_boot_check(void) { + const esp_partition_t *running = esp_ota_get_running_partition(); + if (running == NULL) { + ESP_LOGE(TAG, "Could not determine running partition"); + return ESP_FAIL; + } + + esp_ota_img_states_t ota_state; + if (esp_ota_get_state_partition(running, &ota_state) != ESP_OK || + ota_state != ESP_OTA_IMG_PENDING_VERIFY) { + return ESP_OK; + } + + ESP_LOGW(TAG, "New firmware pending verification; waiting for the P4 over the bridge"); + + // Health = the P4 reaches us over the bridge, the C5's only job and only + // recovery path. Confirm on the first command; if the P4 never talks within + // the window, actively roll back rather than strand a headless node that can + // no longer be recovered through the bridge. + int64_t deadline = esp_timer_get_time() + (int64_t)OTA_VALIDATE_TIMEOUT_MS * 1000; + while (esp_timer_get_time() < deadline) { + if (spi_bridge_commands_processed() > 0) { + esp_ota_mark_app_valid_cancel_rollback(); + ESP_LOGI(TAG, "Bridge confirmed by P4; firmware update accepted"); + return ESP_OK; + } + vTaskDelay(pdMS_TO_TICKS(OTA_VALIDATE_POLL_MS)); + } + + ESP_LOGE(TAG, "P4 did not use the bridge in %d ms; rolling back", OTA_VALIDATE_TIMEOUT_MS); + esp_ota_mark_app_invalid_rollback_and_reboot(); + return ESP_FAIL; +} diff --git a/firmware_c5/components/Service/sd_card/README.md b/firmware_c5/components/Service/sd_card/README.md index e447bfabf..dc9a2dfe3 100644 --- a/firmware_c5/components/Service/sd_card/README.md +++ b/firmware_c5/components/Service/sd_card/README.md @@ -1,962 +1,7 @@ # SD Directory Management Component -Component for managing directories on SD card storage. +Documentation for this component lives in the project docs hub (single source of truth): -## Overview +- [docs/sd_card/README.md#c5](../../../../docs/sd_card/README.md#c5) -- **Location:** `components/storage/sd_dir/` -- **Main Header:** `include/sd_dir.h` -- **Dependencies:** `esp_vfs`, `esp_vfs_fat`, `sdmmc`, `storage_sd` - -## Key Features - -- **Directory Operations:** Create, delete, list, and check existence -- **Recursive Operations:** Remove trees, copy directories, calculate sizes -- **Predefined Paths:** System-wide constants for organizing data -- **Callback System:** Efficient iteration with custom callbacks -- **Statistics:** Count files/directories, calculate storage usage - -## Predefined System Directories - -| Constant | Path | Purpose | -|----------|------|---------| -| `SD_BASE_PATH` | `/sdcard` | Root mount point | -| `SD_DIR_IR` | `/ir` | Infrared signal files | -| `SD_DIR_BADUSB` | `/badusb` | DuckyScript payloads | -| `SD_DIR_NFC` | `/nfc` | NFC tag data | -| `SD_DIR_RFID` | `/rfid` | RFID card data | -| `SD_DIR_SUBGHZ` | `/subghz` | Sub-GHz captures | -| `SD_DIR_CONFIG` | `/config` | Configuration files | -| `SD_DIR_LOGS` | `/logs` | Application logs | -| `SD_DIR_BACKUP` | `/backups` | System backups | - -**Note:** Paths are relative to `SD_BASE_PATH`. Use `SD_BASE_PATH SD_DIR_BADUSB` → `/sdcard/badusb` - -## API Reference - -### Directory Creation & Deletion - -#### `sd_dir_create` -```c -esp_err_t sd_dir_create(const char *path); -``` -Creates directory with automatic parent creation (like `mkdir -p`). - -**Returns:** `ESP_OK` on success, `ESP_FAIL` on failure. - ---- - -#### `sd_dir_remove_recursive` -```c -esp_err_t sd_dir_remove_recursive(const char *path); -``` -Recursively deletes directory and all contents. **Use with caution.** - -**Returns:** `ESP_OK` on success, `ESP_FAIL` on failure. - ---- - -### Directory Information - -#### `sd_dir_exists` -```c -bool sd_dir_exists(const char *path); -``` -Checks if directory exists. - -**Returns:** `true` if exists, `false` otherwise. - ---- - -#### `sd_dir_list` -```c -typedef void (*sd_dir_callback_t)(const char *name, bool is_dir, void *user_data); -esp_err_t sd_dir_list(const char *path, sd_dir_callback_t callback, void *user_data); -``` -Iterates through directory entries, calling callback for each item. - -**Example:** -```c -void print_entry(const char *name, bool is_dir, void *user_data) { - printf("%s %s\n", is_dir ? "[DIR]" : "[FILE]", name); -} -sd_dir_list("/sdcard/badusb", print_entry, NULL); -``` - ---- - -#### `sd_dir_count` -```c -esp_err_t sd_dir_count(const char *path, uint32_t *file_count, uint32_t *dir_count); -``` -Counts files and subdirectories (non-recursive). - -**Returns:** `ESP_OK` on success. - ---- - -#### `sd_dir_get_size` -```c -esp_err_t sd_dir_get_size(const char *path, uint64_t *total_size); -``` -Calculates total size of all files in directory tree (recursive). - -**Returns:** `ESP_OK` on success. - ---- - -### Directory Operations - -#### `sd_dir_copy_recursive` -```c -esp_err_t sd_dir_copy_recursive(const char *src, const char *dst); -``` -Copies entire directory tree, preserving structure. - -**Returns:** `ESP_OK` on success. - ---- - -## Implementation Details - -- All functions require full paths including `SD_BASE_PATH` -- Functions are not thread-safe - use mutexes for concurrent access -- Recursive operations may fail on deeply nested directories - -## Usage Example - -```c -void init_storage_structure(void) { - const char *dirs[] = {SD_DIR_IR, SD_DIR_BADUSB, SD_DIR_CONFIG, SD_DIR_LOGS}; - - for (int i = 0; i < 4; i++) { - char path[64]; - snprintf(path, sizeof(path), "%s%s", SD_BASE_PATH, dirs[i]); - sd_dir_create(path); - } -} -``` - ---- - -# SD Card Information Component - -Component for querying SD card hardware and filesystem statistics. - -## Overview - -- **Location:** `components/storage/sd_card_info/` -- **Main Header:** `include/sd_card_info.h` -- **Dependencies:** `esp_vfs_fat`, `sdmmc_cmd`, `ff`, `storage_sd` - -## Key Features - -- **Hardware Info:** Card name, capacity, speed, type -- **Filesystem Stats:** Total, used, free space with percentages -- **Mount Status:** Check if card is accessible -- **Debug Output:** Console logging of card information - -## Data Structures - -### `sd_card_info_t` -```c -typedef struct { - char name[16]; // Card manufacturer name - uint32_t capacity_mb; // Total capacity in MB - uint32_t sector_size; // Sector size in bytes - uint32_t num_sectors; // Total number of sectors - uint32_t speed_khz; // Max speed in kHz - uint8_t card_type; // Card type identifier - bool is_mounted; // Mount status -} sd_card_info_t; -``` - -### `sd_fs_stats_t` -```c -typedef struct { - uint64_t total_bytes; // Total capacity - uint64_t used_bytes; // Space in use - uint64_t free_bytes; // Available space -} sd_fs_stats_t; -``` - -## API Reference - -### Card Information - -#### `sd_get_card_info` -```c -esp_err_t sd_get_card_info(sd_card_info_t *info); -``` -Retrieves complete hardware information. - -**Returns:** `ESP_OK`, `ESP_ERR_INVALID_STATE`, or `ESP_ERR_INVALID_ARG`. - ---- - -#### `sd_print_card_info` -```c -void sd_print_card_info(void); -``` -Prints formatted card information to console. - ---- - -### Filesystem Statistics - -#### `sd_get_fs_stats` -```c -esp_err_t sd_get_fs_stats(sd_fs_stats_t *stats); -``` -Retrieves complete filesystem statistics. - -**Returns:** `ESP_OK`, `ESP_ERR_INVALID_STATE`, `ESP_ERR_INVALID_ARG`, or `ESP_FAIL`. - ---- - -#### `sd_get_free_space` -```c -esp_err_t sd_get_free_space(uint64_t *free_bytes); -``` -Gets available free space. - ---- - -#### `sd_get_total_space` -```c -esp_err_t sd_get_total_space(uint64_t *total_bytes); -``` -Gets total filesystem capacity. - ---- - -#### `sd_get_used_space` -```c -esp_err_t sd_get_used_space(uint64_t *used_bytes); -``` -Gets space currently in use. - ---- - -#### `sd_get_usage_percent` -```c -esp_err_t sd_get_usage_percent(float *percentage); -``` -Calculates usage percentage (0.0 to 100.0). - ---- - -### Individual Attributes - -#### `sd_get_card_name` -```c -esp_err_t sd_get_card_name(char *name, size_t size); -``` -Gets manufacturer name. - ---- - -#### `sd_get_capacity` -```c -esp_err_t sd_get_capacity(uint32_t *capacity_mb); -``` -Gets total capacity in MB. - ---- - -#### `sd_get_speed` -```c -esp_err_t sd_get_speed(uint32_t *speed_khz); -``` -Gets maximum communication speed. - ---- - -#### `sd_get_card_type` -```c -esp_err_t sd_get_card_type(uint8_t *type); -``` -Gets raw card type identifier. - ---- - -#### `sd_get_card_type_name` -```c -esp_err_t sd_get_card_type_name(char *type_name, size_t size); -``` -Gets human-readable card type string. - ---- - -## Implementation Details - -- Uses FatFS `f_getfree()` for filesystem stats -- Accesses SDMMC layer for hardware information -- All functions verify mount status before access -- Thread-safe for read operations - -## Usage Example - -```c -void check_storage_health(void) { - sd_card_info_t info; - float usage; - - if (sd_get_card_info(&info) == ESP_OK && - sd_get_usage_percent(&usage) == ESP_OK) { - - printf("Card: %s (%lu MB)\n", info.name, info.capacity_mb); - printf("Usage: %.1f%%\n", usage); - - if (usage > 90.0f) { - printf("WARNING: Low disk space!\n"); - } - } -} -``` - ---- - -# SD Card Initialization Component - -Component for SD card initialization, mounting, and lifecycle management. - -## Overview - -- **Location:** `components/storage/sd_card_init/` -- **Main Header:** `include/sd_card_init.h` -- **Dependencies:** `esp_vfs_fat`, `driver/sdspi_host`, `sdmmc_cmd`, `spi`, `pin_def` - -## Key Features - -- **Simple Initialization:** One-function setup with defaults -- **Custom Configuration:** Control max files, auto-format, allocation size -- **Mount Management:** Mount, unmount, remount, check status -- **Shared SPI Bus:** Integration with centralized SPI driver -- **Health Monitoring:** Basic health checks -- **Card Handle Access:** Low-level SDMMC handle for advanced use - -## Configuration - -```c -#define SD_MOUNT_POINT "/sdcard" // VFS mount point -#define SD_MAX_FILES 5 // Max open files -#define SD_ALLOCATION_UNIT 16 * 1024 // 16KB cluster size -#define SDMMC_FREQ_DEFAULT 20000 // 20MHz speed -``` - -## API Reference - -### Initialization - -#### `sd_init` -```c -esp_err_t sd_init(void); -``` -Initializes SD card with default settings. - -**Returns:** `ESP_OK`, `ESP_ERR_INVALID_STATE`, or `ESP_FAIL`. - ---- - -#### `sd_init_custom` -```c -esp_err_t sd_init_custom(uint8_t max_files, bool format_if_failed); -``` -Initializes with custom parameters. - -**Warning:** `format_if_failed=true` erases all data on mount failure. - ---- - -#### `sd_init_custom_pins` -```c -esp_err_t sd_init_custom_pins(int mosi, int miso, int clk, int cs); -``` -**Deprecated:** Custom pins not supported with shared SPI driver. - ---- - -### Deinitialization - -#### `sd_deinit` -```c -esp_err_t sd_deinit(void); -``` -Unmounts SD card and releases resources. Close all files first. - -**Returns:** `ESP_OK`, `ESP_ERR_INVALID_STATE`, or `ESP_FAIL`. - ---- - -### Status & Maintenance - -#### `sd_is_mounted` -```c -bool sd_is_mounted(void); -``` -Checks if SD card is mounted. - ---- - -#### `sd_remount` -```c -esp_err_t sd_remount(void); -``` -Unmounts and remounts SD card (useful for error recovery). - ---- - -#### `sd_check_health` -```c -esp_err_t sd_check_health(void); -``` -Performs basic health check. - ---- - -#### `sd_reset_bus` -```c -esp_err_t sd_reset_bus(void); -``` -**Not Supported:** Returns `ESP_ERR_NOT_SUPPORTED`. Use `sd_remount()` instead. - ---- - -### Advanced Access - -#### `sd_get_card_handle` -```c -sdmmc_card_t* sd_get_card_handle(void); -``` -Returns pointer to internal SDMMC card structure. Returns `NULL` if not mounted. - -**Warning:** Direct manipulation can interfere with VFS operations. - ---- - -## Implementation Details - -### SPI Configuration -```c -spi_device_config_t sd_cfg = { - .cs_pin = SD_CARD_CS_PIN, - .clock_speed_hz = 20000 * 1000, - .mode = 0, - .queue_size = 4, -}; -``` - -### Mount Configuration -```c -esp_vfs_fat_sdmmc_mount_config_t mount_config = { - .format_if_mount_failed = false, - .max_files = 5, - .allocation_unit_size = 16 * 1024, -}; -``` - -## Troubleshooting - -| Problem | Solutions | -|---------|-----------| -| `sd_init()` returns `ESP_FAIL` | Check card insertion, verify pins, try different card, enable debug logs | -| File operations fail | Check filesystem corruption, verify max_files limit, close file handles, try remount | -| Random disconnects | Check power supply, verify connections, reduce clock speed, add pull-ups | -| `sd_deinit()` fails | Close all file handles first, check for active tasks | - -## Usage Example - -```c -void storage_init(void) { - if (sd_init() == ESP_OK) { - ESP_LOGI(TAG, "SD card mounted"); - sd_dir_create("/sdcard/config"); - } else { - ESP_LOGE(TAG, "SD card mount failed"); - } -} -``` - ---- - -# SD Card Read Component - -Component for comprehensive SD card file reading operations. - -## Overview - -- **Location:** `components/storage/sd_card_read/` -- **Main Header:** `include/sd_card_read.h` -- **Dependencies:** `esp_vfs_fat`, `storage_sd` - -## Key Features - -- **Text Reading:** Entire files, specific lines, line-by-line processing -- **Binary Reading:** Raw data, chunks, individual bytes -- **Type Conversion:** Direct reading of integers, floats -- **Content Search:** String search and occurrence counting -- **Flexible Paths:** Automatic `/sdcard` prefix for relative paths - -## Configuration - -```c -#define MAX_PATH_LEN 256 // Maximum path length -#define MAX_LINE_LEN 512 // Maximum line length -``` - -## API Reference - -### Text Reading - -#### `sd_read_string` -```c -esp_err_t sd_read_string(const char *path, char *buffer, size_t buffer_size); -``` -Reads entire file as null-terminated string. - ---- - -#### `sd_read_line` -```c -esp_err_t sd_read_line(const char *path, char *buffer, size_t buffer_size, uint32_t line_number); -``` -Reads specific line (1-based index). - ---- - -#### `sd_read_first_line` -```c -esp_err_t sd_read_first_line(const char *path, char *buffer, size_t buffer_size); -``` -Reads first line. Equivalent to `sd_read_line(path, buffer, size, 1)`. - ---- - -#### `sd_read_last_line` -```c -esp_err_t sd_read_last_line(const char *path, char *buffer, size_t buffer_size); -``` -Reads last line. - ---- - -#### `sd_read_lines` -```c -typedef void (*sd_line_callback_t)(const char *line, void *user_data); -esp_err_t sd_read_lines(const char *path, sd_line_callback_t callback, void *user_data); -``` -Processes each line via callback. Memory-efficient for large files. - ---- - -#### `sd_count_lines` -```c -esp_err_t sd_count_lines(const char *path, uint32_t *line_count); -``` -Counts total lines in file. - ---- - -### Binary Reading - -#### `sd_read_binary` -```c -esp_err_t sd_read_binary(const char *path, void *buffer, size_t size, size_t *bytes_read); -``` -Reads raw binary data. - ---- - -#### `sd_read_chunk` -```c -esp_err_t sd_read_chunk(const char *path, size_t offset, void *buffer, size_t size, size_t *bytes_read); -``` -Reads data chunk from specific offset. - ---- - -#### `sd_read_bytes` -```c -esp_err_t sd_read_bytes(const char *path, uint8_t *bytes, size_t max_count, size_t *count); -``` -Alias for `sd_read_binary` with byte array typing. - ---- - -#### `sd_read_byte` -```c -esp_err_t sd_read_byte(const char *path, uint8_t *byte); -``` -Reads single byte. - ---- - -### Type Conversion - -#### `sd_read_int` -```c -esp_err_t sd_read_int(const char *path, int32_t *value); -``` -Reads and converts to 32-bit integer. - ---- - -#### `sd_read_float` -```c -esp_err_t sd_read_float(const char *path, float *value); -``` -Reads and converts to float. - ---- - -### Content Search - -#### `sd_file_contains` -```c -esp_err_t sd_file_contains(const char *path, const char *search, bool *found); -``` -Checks if string exists in file. - ---- - -#### `sd_count_occurrences` -```c -esp_err_t sd_count_occurrences(const char *path, const char *search, uint32_t *count); -``` -Counts string occurrences in file. - ---- - -## Implementation Details - -- Line functions allocate 512-byte stack buffers -- Use `sd_read_lines()` callback for large files -- Thread-safe for different files -- Automatic path formatting (relative → absolute) - -## Usage Example - -```c -void process_config(void) { - char buffer[256]; - - // Read entire file - if (sd_read_string("/config/settings.txt", buffer, sizeof(buffer)) == ESP_OK) { - printf("Config: %s\n", buffer); - } - - // Process line-by-line - sd_read_lines("/logs/system.log", [](const char *line, void *ctx) { - printf("Log: %s\n", line); - }, NULL); -} -``` - ---- - -# SD Card Write Component - -Component for comprehensive SD card file writing operations. - -## Overview - -- **Location:** `components/storage/sd_card_write/` -- **Main Header:** `include/sd_card_write.h` -- **Dependencies:** `esp_vfs_fat`, `storage_sd` - -## Key Features - -- **Text Writing:** Strings, lines, formatted text -- **Binary Writing:** Raw data, buffers, individual bytes -- **Append Operations:** Add to existing files -- **Formatted Output:** Printf-style writing -- **CSV Support:** Simplified row writing - -## API Reference - -### Text Writing - -#### `sd_write_string` / `sd_append_string` -```c -esp_err_t sd_write_string(const char *path, const char *data); -esp_err_t sd_append_string(const char *path, const char *data); -``` -Writes or appends string. - ---- - -#### `sd_write_line` / `sd_append_line` -```c -esp_err_t sd_write_line(const char *path, const char *line); -esp_err_t sd_append_line(const char *path, const char *line); -``` -Writes or appends line with automatic newline. - ---- - -#### `sd_write_formatted` / `sd_append_formatted` -```c -esp_err_t sd_write_formatted(const char *path, const char *format, ...); -esp_err_t sd_append_formatted(const char *path, const char *format, ...); -``` -Printf-style formatted writing. - ---- - -### Binary Writing - -#### `sd_write_binary` / `sd_append_binary` -```c -esp_err_t sd_write_binary(const char *path, const void *data, size_t size); -esp_err_t sd_append_binary(const char *path, const void *data, size_t size); -``` -Writes or appends binary data. - ---- - -#### `sd_write_buffer` -```c -esp_err_t sd_write_buffer(const char *path, const void *buffer, size_t size); -``` -Alias for `sd_write_binary`. - ---- - -#### `sd_write_bytes` -```c -esp_err_t sd_write_bytes(const char *path, const uint8_t *bytes, size_t count); -``` -Writes byte array. - ---- - -#### `sd_write_byte` -```c -esp_err_t sd_write_byte(const char *path, uint8_t byte); -``` -Writes single byte. - ---- - -### Type Helpers - -#### `sd_write_int` -```c -esp_err_t sd_write_int(const char *path, int32_t value); -``` -Writes integer as decimal text. - ---- - -#### `sd_write_float` -```c -esp_err_t sd_write_float(const char *path, float value); -``` -Writes float with 6 decimal places. - ---- - -### CSV Support - -#### `sd_write_csv_row` / `sd_append_csv_row` -```c -esp_err_t sd_write_csv_row(const char *path, const char **columns, size_t num_columns); -esp_err_t sd_append_csv_row(const char *path, const char **columns, size_t num_columns); -``` -Writes or appends CSV row (comma-separated with newline). - ---- - -## Implementation Details - -- All writes verify byte count matches expected size -- Automatic `/sdcard` prefix for relative paths -- Buffers flushed automatically on file close - -## Usage Example - -```c -void log_event(const char *type, const char *msg) { - time_t now = time(NULL); - sd_append_formatted("/logs/events.log", "[%ld] %s: %s\n", now, type, msg); -} - -void save_sensor_data(float temp, float humidity) { - const char *row[] = { - "Temperature", "Humidity" - }; - sd_write_csv_row("/data/sensors.csv", row, 2); - - char temp_str[16], hum_str[16]; - snprintf(temp_str, sizeof(temp_str), "%.2f", temp); - snprintf(hum_str, sizeof(hum_str), "%.2f", humidity); - - const char *data[] = {temp_str, hum_str}; - sd_append_csv_row("/data/sensors.csv", data, 2); -} -``` - ---- - -# SD Card File Management Component - -Component for comprehensive SD card file operations. - -## Overview - -- **Location:** `components/storage/sd_card_file/` -- **Main Header:** `include/sd_card_file.h` -- **Dependencies:** `esp_vfs`, `esp_vfs_fat`, `sdmmc`, `storage_sd` - -## Key Features - -- **File Operations:** Create, delete, rename, move, copy -- **Metadata Access:** Size, modification time, attributes -- **File Comparison:** Byte-by-byte comparison -- **File Truncation:** Resize to specific length -- **Utilities:** Check existence, get extensions, clear contents - -## Data Structures - -### `sd_file_info_t` -```c -typedef struct { - char path[256]; // Full path - size_t size; // File size in bytes - time_t modified_time; // Last modification time - bool is_directory; // Directory flag -} sd_file_info_t; -``` - -## API Reference - -### File Information - -#### `sd_file_exists` -```c -bool sd_file_exists(const char *path); -``` -Checks if file exists. - ---- - -#### `sd_file_get_info` -```c -esp_err_t sd_file_get_info(const char *path, sd_file_info_t *info); -``` -Retrieves complete file information. - ---- - -#### `sd_file_get_size` -```c -esp_err_t sd_file_get_size(const char *path, size_t *size); -``` -Gets file size in bytes. - ---- - -#### `sd_file_is_empty` -```c -esp_err_t sd_file_is_empty(const char *path, bool *is_empty); -``` -Checks if file has zero bytes. - ---- - -### File Manipulation - -#### `sd_file_delete` -```c -esp_err_t sd_file_delete(const char *path); -``` -Permanently deletes file. - ---- - -#### `sd_file_rename` -```c -esp_err_t sd_file_rename(const char *old_path, const char *new_path); -``` -Renames or moves file (same filesystem). - ---- - -#### `sd_file_move` -```c -esp_err_t sd_file_move(const char *src_path, const char *dst_path); -``` -Moves file (alias for rename). - ---- - -#### `sd_file_copy` -```c -esp_err_t sd_file_copy(const char *src_path, const char *dst_path); -``` -Copies file (source unchanged). - ---- - -#### `sd_file_truncate` -```c -esp_err_t sd_file_truncate(const char *path, size_t size); -``` -Resizes file to specified size. - ---- - -#### `sd_file_clear` -```c -esp_err_t sd_file_clear(const char *path); -``` -Clears all content (makes empty). - ---- - -### File Comparison - -#### `sd_file_compare` -```c -esp_err_t sd_file_compare(const char *path1, const char *path2, bool *are_equal); -``` -Byte-by-byte comparison. - ---- - -### Utilities - -#### `sd_file_get_extension` -```c -esp_err_t sd_file_get_extension(const char *path, char *extension, size_t size); -``` -Extracts file extension (without dot). - ---- - -## Implementation Details - -- Rename/move are atomic, copy is not -- Path buffer in `sd_file_info_t` is 256 bytes -- Not thread-safe - use mutexes for concurrent access - -## Usage Example - -```c -esp_err_t backup_config(void) { - const char *config = "/sdcard/config/settings.json"; - const char *backup = "/sdcard/backups/settings.json"; - - // Create backup - if (sd_file_copy(config, backup) != ESP_OK) { - return ESP_FAIL; - } - - // Verify backup - bool equal; - sd_file_compare(config, backup, &equal); - - return equal ? ESP_OK : ESP_FAIL; -} -``` \ No newline at end of file +This file is only a pointer: edit the docs there to keep things from drifting. diff --git a/firmware_c5/components/Service/spi_bridge/README.md b/firmware_c5/components/Service/spi_bridge/README.md index 96ac7f84b..1b069f38f 100644 --- a/firmware_c5/components/Service/spi_bridge/README.md +++ b/firmware_c5/components/Service/spi_bridge/README.md @@ -1,66 +1,7 @@ # SPI Bridge - C5 Slave -This component transforms the **ESP32-C5** into a high-performance radio co-processor for the ESP32-P4. +Documentation for this component lives in the project docs hub (single source of truth): -## How it Works -The C5 runs a background task (`spi_bridge_task`) that stays in a blocked state waiting for the P4 to send SPI bytes. +- [docs/spi_bridge/README.md#c5](../../../../docs/spi_bridge/README.md#c5) -1. **Reception**: When bytes arrive, the task validates the `0xAA` sync byte. -2. **Routing**: It checks the `ID` and routes the payload to the appropriate **Dispatcher** (WiFi or Bluetooth). -3. **Execution**: The Dispatcher executes the radio command (e.g., starts a scan). -4. **Notification**: Once the command is done (or results are ready), the C5 raises the **IRQ (Handshake)** pin. -5. **Response**: The P4 sees the IRQ, sends a dummy SPI clock, and the C5 "pushes" the response packet back. - -## Memory Mapping (Zero-Copy Results) -The C5 uses a `current_data_source` pointer system. Instead of copying large scan lists into a bridge buffer, the Dispatcher simply points the bridge to the existing result array in memory: -```c -spi_bridge_provide_results(wifi_records, count, sizeof(wifi_ap_record_t)); -``` -The bridge then serves these items one by one when the P4 asks for them via the generic `SPI_ID_SYSTEM_DATA` command. - -## Key Files -- `spi_bridge.c`: Main task and generic data provider logic. -- `wifi_dispatcher.c`: Logic to translate SPI IDs to WiFi driver calls. -- `bt_dispatcher.c`: Logic to translate SPI IDs to NimBLE/BT calls. -- `spi_slave_driver.c`: Low-level peripheral configuration. -- `session_manager.c`: Session lifecycle for long-running operations - (heartbeat watchdog + backpressure). See "Session Lifecycle" below. - -## Command Range -- `0x01 - 0x0F`: System/Bridge management. -- `0x10 - 0x4F`: WiFi operations. -- `0x50 - 0x7F`: Bluetooth operations. -- `0x80 - 0x8F`: LoRa operations. -- `0xF0 - 0xFF`: Session lifecycle (heartbeat, lost, stop). - -## Session Lifecycle (Long-Running Operations) - -For full design and migration recipe, see the -[P4 README "Session Lifecycle" section](../../../../firmware_p4/components/Service/spi_bridge/README.md#session-lifecycle-long-running-operations). -The two sides share `spi_protocol.h` so the wire format is identical. - -### Slave responsibilities (this side) - -The `session_manager` runs a background watchdog that auto-kills sessions -when the master stops sending heartbeats (5s timeout). Each long-running -operation must: - -1. Call `session_manager_start(op_id, kill_cb)` from its dispatcher case - to obtain a `session_id`. The dispatcher returns this id to the master - inside an `spi_session_resp_t` response payload. -2. Provide a `kill_cb(spi_id_t)` that calls the op's `_stop()` — invoked - by the watchdog when the master goes quiet, and also when the master - sends `SPI_ID_SESSION_STOP`. -3. **Streaming ops only**: store the id in the op (e.g. via a - `_bind_session(uint32_t)` setter) and emit packets via - `session_manager_try_emit(s_session_id, data, len)` instead of raw - `spi_bridge_stream_push` — this prefixes meta and applies backpressure. - -For non-streaming ops (deauther, flood, evil_twin, beacon_spam, etc.), -the `kill_cb` lives in the dispatcher itself — the op's `.c` file does -not need to know about sessions at all. - -References: -- Streaming pattern: `wifi_sniffer.c`, `ble_sniffer.c`. -- Non-streaming pattern: see the `killed_*` static functions plus the - `open_session()` / `bt_open_session()` helpers in the dispatchers. +This file is only a pointer: edit the docs there to keep things from drifting. diff --git a/firmware_c5/components/Service/spi_bridge/bt_dispatcher.c b/firmware_c5/components/Service/spi_bridge/bt_dispatcher.c index cec564aa3..480328ec2 100644 --- a/firmware_c5/components/Service/spi_bridge/bt_dispatcher.c +++ b/firmware_c5/components/Service/spi_bridge/bt_dispatcher.c @@ -22,8 +22,11 @@ #include "ble_connect_flood.h" #include "ble_scanner.h" #include "ble_sniffer.h" +#include "canned_spam.h" #include "session_manager.h" #include "bluetooth_service.h" +#include "host_link_gatt.h" +#include "host_transport.h" #include "meshcore_gatt.h" #include "meshcore_transport.h" #include "meshtastic_gatt.h" @@ -51,6 +54,33 @@ static void killed_tracker(spi_id_t id) { (void)id; tracker_detector_stop(); } +static void killed_spam(spi_id_t id) { + (void)id; + spam_stop(); +} + +static bool bt_ensure_service_ready(void) { + if (meshcore_gatt_is_running()) + meshcore_gatt_stop(); + if (host_link_gatt_is_running()) + host_link_gatt_stop(); + if (meshtastic_gatt_is_running()) + meshtastic_gatt_stop(); + if (!bluetooth_service_is_initialized() && bluetooth_service_init() != ESP_OK) + return false; + if (!bluetooth_service_is_running() && bluetooth_service_start() != ESP_OK) + return false; + bluetooth_service_stop_advertising(); + return true; +} + +static spi_status_t bt_release_service_for_gatt(void) { + if (!bluetooth_service_is_initialized()) + return SPI_STATUS_OK; + if (spi_bridge_async_scan_busy() || session_manager_is_active()) + return SPI_STATUS_BUSY; + return (bluetooth_service_deinit() == ESP_OK) ? SPI_STATUS_OK : SPI_STATUS_ERROR; +} static spi_status_t bt_open_session(spi_id_t op_id, session_kill_cb_t kill_cb, @@ -69,6 +99,17 @@ static spi_status_t bt_open_session(spi_id_t op_id, return SPI_STATUS_OK; } +// BLE scan work function run by the shared async runner. The duration is stashed +// by the SPI handler before it kicks the runner (one scan at a time). +static uint32_t s_bt_scan_duration = 0; + +static void scan_fn_bt(void) { + bluetooth_service_scan(s_bt_scan_duration); + spi_bridge_provide_results(bluetooth_service_get_scan_result(0), + bluetooth_service_get_scan_count(), + sizeof(bluetooth_service_scan_result_t)); +} + spi_status_t bt_dispatcher_execute(spi_id_t id, const uint8_t *payload, uint8_t len, @@ -78,17 +119,37 @@ spi_status_t bt_dispatcher_execute(spi_id_t id, *out_resp_len = 0; switch (id) { + case SPI_ID_BT_INIT: + if (meshcore_gatt_is_running()) + meshcore_gatt_stop(); + if (host_link_gatt_is_running()) + host_link_gatt_stop(); + if (meshtastic_gatt_is_running()) + meshtastic_gatt_stop(); + return (bluetooth_service_init() == ESP_OK) ? SPI_STATUS_OK : SPI_STATUS_ERROR; + + case SPI_ID_BT_DEINIT: + return (bluetooth_service_deinit() == ESP_OK) ? SPI_STATUS_OK : SPI_STATUS_ERROR; + + case SPI_ID_BT_START: + return (bluetooth_service_start() == ESP_OK) ? SPI_STATUS_OK : SPI_STATUS_ERROR; + + case SPI_ID_BT_STOP: + return (bluetooth_service_deinit() == ESP_OK) ? SPI_STATUS_OK : SPI_STATUS_ERROR; + case SPI_ID_BT_SCAN: { uint32_t duration = BT_SCAN_DEFAULT_DURATION_MS; if (len >= sizeof(duration)) memcpy(&duration, payload, sizeof(duration)); - bluetooth_service_scan(duration); - spi_bridge_provide_results(bluetooth_service_get_scan_result(0), - bluetooth_service_get_scan_count(), - sizeof(bluetooth_service_scan_result_t)); - return SPI_STATUS_OK; + s_bt_scan_duration = duration; + return spi_bridge_async_scan_start(scan_fn_bt) ? SPI_STATUS_OK : SPI_STATUS_BUSY; } + case SPI_ID_BT_SCAN_STATUS: + out_resp_payload[0] = spi_bridge_async_scan_busy() ? 1 : 0; + *out_resp_len = 1; + return SPI_STATUS_OK; + case SPI_ID_BT_CONNECT: { if (len < BT_CONNECT_MIN_PAYLOAD) return SPI_STATUS_ERROR; @@ -109,9 +170,13 @@ spi_status_t bt_dispatcher_execute(spi_id_t id, } case SPI_ID_BT_APP_SCANNER: + if (!bt_ensure_service_ready()) + return SPI_STATUS_ERROR; return ble_scanner_start() ? SPI_STATUS_OK : SPI_STATUS_BUSY; case SPI_ID_BT_APP_SNIFFER: { + if (!bt_ensure_service_ready()) + return SPI_STATUS_ERROR; if (ble_sniffer_start() != ESP_OK) return SPI_STATUS_ERROR; uint32_t sid = session_manager_start(SPI_ID_BT_APP_SNIFFER, ble_sniffer_session_killed); @@ -126,9 +191,29 @@ spi_status_t bt_dispatcher_execute(spi_id_t id, return SPI_STATUS_OK; } + case SPI_ID_BT_APP_SPAM: { + if (len < 1) + return SPI_STATUS_INVALID_ARG; + if (!bt_ensure_service_ready()) + return SPI_STATUS_ERROR; + if (spam_start((int)payload[0]) != ESP_OK) + return SPI_STATUS_ERROR; + uint32_t sid = session_manager_start(SPI_ID_BT_APP_SPAM, killed_spam); + if (sid == SPI_SESSION_INVALID_ID) { + spam_stop(); + return SPI_STATUS_ERROR; + } + spi_session_resp_t resp = {.session_id = sid}; + memcpy(out_resp_payload, &resp, sizeof(resp)); + *out_resp_len = sizeof(resp); + return SPI_STATUS_OK; + } + case SPI_ID_BT_APP_FLOOD: { if (len < BT_CONNECT_MIN_PAYLOAD) return SPI_STATUS_ERROR; + if (!bt_ensure_service_ready()) + return SPI_STATUS_ERROR; if (ble_connect_flood_start(payload, payload[BT_MAC_LEN]) != ESP_OK) return SPI_STATUS_ERROR; uint32_t sid = session_manager_start(SPI_ID_BT_APP_FLOOD, killed_ble_flood); @@ -143,6 +228,8 @@ spi_status_t bt_dispatcher_execute(spi_id_t id, } case SPI_ID_BT_APP_SKIMMER: + if (!bt_ensure_service_ready()) + return SPI_STATUS_ERROR; if (skimmer_detector_start() != ESP_OK) return SPI_STATUS_ERROR; return bt_open_session(SPI_ID_BT_APP_SKIMMER, @@ -152,6 +239,8 @@ spi_status_t bt_dispatcher_execute(spi_id_t id, skimmer_detector_stop); case SPI_ID_BT_APP_TRACKER: + if (!bt_ensure_service_ready()) + return SPI_STATUS_ERROR; if (tracker_detector_start() != ESP_OK) return SPI_STATUS_ERROR; return bt_open_session(SPI_ID_BT_APP_TRACKER, @@ -166,6 +255,14 @@ spi_status_t bt_dispatcher_execute(spi_id_t id, } spi_mesh_init_t req; memcpy(&req, payload, sizeof(req)); + spi_status_t rel = bt_release_service_for_gatt(); + if (rel != SPI_STATUS_OK) { + return rel; + } + if (meshcore_gatt_is_running()) + meshcore_gatt_stop(); + if (host_link_gatt_is_running()) + host_link_gatt_stop(); if (meshtastic_transport_init() != ESP_OK) { return SPI_STATUS_ERROR; } @@ -203,6 +300,14 @@ spi_status_t bt_dispatcher_execute(spi_id_t id, spi_mcore_init_t req; memcpy(&req, payload, sizeof(req)); req.name_prefix[sizeof(req.name_prefix) - 1] = '\0'; + spi_status_t rel = bt_release_service_for_gatt(); + if (rel != SPI_STATUS_OK) { + return rel; + } + if (meshtastic_gatt_is_running()) + meshtastic_gatt_stop(); + if (host_link_gatt_is_running()) + host_link_gatt_stop(); if (meshcore_transport_init() != ESP_OK) { return SPI_STATUS_ERROR; } @@ -229,6 +334,47 @@ spi_status_t bt_dispatcher_execute(spi_id_t id, return SPI_STATUS_OK; } + case SPI_ID_HOST_BLE_INIT: { + if (len < sizeof(spi_host_init_t)) { + return SPI_STATUS_INVALID_ARG; + } + spi_host_init_t req; + memcpy(&req, payload, sizeof(req)); + req.name_prefix[sizeof(req.name_prefix) - 1] = '\0'; + spi_status_t rel = bt_release_service_for_gatt(); + if (rel != SPI_STATUS_OK) { + return rel; + } + if (meshcore_gatt_is_running()) + meshcore_gatt_stop(); + if (meshtastic_gatt_is_running()) + meshtastic_gatt_stop(); + if (host_transport_init() != ESP_OK) { + return SPI_STATUS_ERROR; + } + esp_err_t ret = host_link_gatt_init(req.name_prefix); + if (ret == ESP_ERR_INVALID_STATE) { + return SPI_STATUS_OK; + } + return (ret == ESP_OK) ? SPI_STATUS_OK : SPI_STATUS_ERROR; + } + + case SPI_ID_HOST_BLE_STOP: + host_link_gatt_stop(); + return SPI_STATUS_OK; + + case SPI_ID_HOST_TX: + host_transport_inject_tx_chunk(payload, len); + return SPI_STATUS_OK; + + case SPI_ID_HOST_STATUS: { + spi_host_status_t status; + host_transport_get_status(&status); + memcpy(out_resp_payload, &status, sizeof(status)); + *out_resp_len = sizeof(status); + return SPI_STATUS_OK; + } + default: return SPI_STATUS_ERROR; } diff --git a/firmware_c5/components/Service/spi_bridge/include/session_manager.h b/firmware_c5/components/Service/spi_bridge/include/session_manager.h index 47b82d5d2..10c945a48 100644 --- a/firmware_c5/components/Service/spi_bridge/include/session_manager.h +++ b/firmware_c5/components/Service/spi_bridge/include/session_manager.h @@ -93,6 +93,13 @@ bool session_manager_heartbeat(uint32_t session_id, uint32_t last_acked_seq); */ esp_err_t session_manager_try_emit(uint32_t session_id, const uint8_t *data, uint8_t len); +/** + * @brief True while a long-running app session is open (sniffer/spam/flood/etc.). + * + * Used by the BT arbiter to avoid tearing NimBLE out from under an active app. + */ +bool session_manager_is_active(void); + #ifdef __cplusplus } #endif diff --git a/firmware_c5/components/Service/spi_bridge/include/spi_bridge.h b/firmware_c5/components/Service/spi_bridge/include/spi_bridge.h index 1c47afed2..27d3aa0ed 100644 --- a/firmware_c5/components/Service/spi_bridge/include/spi_bridge.h +++ b/firmware_c5/components/Service/spi_bridge/include/spi_bridge.h @@ -36,6 +36,18 @@ extern "C" { */ esp_err_t spi_bridge_slave_init(void); +/** + * @brief Initialize the SPI slave in a specific handshake mode. + * + * spi_bridge_slave_init() is the same as this with SPI_BRIDGE_MODE_IRQ. In + * SPI_BRIDGE_MODE_POLL the slave never pulses the IRQ line (the board has no + * IRQ trace); the P4 master must be initialized in the matching mode. + * + * @param mode SPI_BRIDGE_MODE_IRQ (default) or SPI_BRIDGE_MODE_POLL. + * @return ESP_OK on success, or an error code from the SPI slave driver. + */ +esp_err_t spi_bridge_slave_init_mode(spi_bridge_mode_t mode); + /** * @brief Point the bridge to a fixed-size result set in memory. * @@ -54,6 +66,17 @@ void spi_bridge_provide_results(void *source, uint16_t count, uint8_t item_size) */ void spi_bridge_provide_results_dynamic(void *source, const uint16_t *count_ptr, uint8_t item_size); +/** + * @brief Run a scan asynchronously off the SPI handler. + * + * @p fn does the blocking scan and calls spi_bridge_provide_results when done. + * Returns false if a scan is already running (respond SPI_STATUS_BUSY). The P4 + * polls a *_SCAN_STATUS command (which returns spi_bridge_async_scan_busy) until + * it clears, then fetches the results. + */ +bool spi_bridge_async_scan_start(void (*fn)(void)); +bool spi_bridge_async_scan_busy(void); + /** * @brief Check whether streaming is enabled for a given SPI ID. * @@ -86,6 +109,14 @@ bool spi_bridge_stream_push(spi_id_t id, const uint8_t *data, uint8_t len); */ void spi_bridge_notify_master(void); +/** + * @brief Count of valid commands processed from the P4 since boot. + * + * A nonzero value means the P4 has reached the C5 over the bridge, which is the + * OTA boot-health signal (see ota_post_boot_check). + */ +uint32_t spi_bridge_commands_processed(void); + #ifdef __cplusplus } #endif diff --git a/firmware_c5/components/Service/spi_bridge/include/spi_protocol.h b/firmware_c5/components/Service/spi_bridge/include/spi_protocol.h index 1724fee8f..707abeab0 100644 --- a/firmware_c5/components/Service/spi_bridge/include/spi_protocol.h +++ b/firmware_c5/components/Service/spi_bridge/include/spi_protocol.h @@ -16,16 +16,37 @@ #ifndef SPI_PROTOCOL_H #define SPI_PROTOCOL_H +// This file is the P4<->C5 wire contract and MUST stay byte-identical to its twin +// in the other firmware (firmware_p4 and firmware_c5 each keep a copy at +// components/Service/spi_bridge/include/spi_protocol.h). Both chips parse the same +// bytes, so any id/struct edited on one side has to be copied to the other or the +// two ends interpret the same frame differently. The P4 is the superset: ops it +// owns but the C5 does not implement (screen share, port scan, ...) still reserve +// their number here, so a given id never means two different things across the bus. + #ifdef __cplusplus extern "C" { #endif #include #include +#include + +#include "esp_rom_crc.h" + +#define SPI_SYNC_BYTE 0xAA +// Capped at 255: the header's `length` field is a uint8_t, so 256 was never +// representable ((uint8_t)256 == 0). For a RESPONSE, `length` also counts the +// status byte, so its data maxes at 254 (SPI_MAX_PAYLOAD - SPI_RESP_STATUS_SIZE). +#define SPI_MAX_PAYLOAD 255 +#define SPI_RESP_STATUS_SIZE 1 + +// Wire-protocol version. Bump on ANY change to the contract below (an id, a +// struct, or the header layout). The P4 reads the C5's value at bridge init via +// SPI_ID_SYSTEM_PROTO_VERSION and flags a mismatch loudly, so two copies of this +// header that drifted are caught at boot instead of silently misparsing frames. +#define SPI_PROTOCOL_VERSION 2 -#define SPI_SYNC_BYTE 0xAA -#define SPI_MAX_PAYLOAD 256 -#define SPI_RESP_STATUS_SIZE 1 #define SPI_BT_SPAM_ITEM_LEN 32 #define SPI_BT_SPAM_LIST_MAX 64 #define SPI_WIFI_SNIFFER_FILENAME_MAX 96 @@ -40,147 +61,276 @@ extern "C" { */ typedef enum { SPI_TYPE_CMD = 0x01, SPI_TYPE_RESP = 0x02, SPI_TYPE_STREAM = 0x03 } spi_type_t; +/** + * @brief Bridge handshake mode (physical layer only; the wire protocol is the + * same in both). IRQ: the C5 pulses a GPIO when a response/stream frame is + * armed (default; needs the IRQ trace). POLL: no IRQ line, so the P4 re-clocks + * the bus until the slave answers with a valid frame. + */ +typedef enum { SPI_BRIDGE_MODE_IRQ = 0, SPI_BRIDGE_MODE_POLL = 1 } spi_bridge_mode_t; + +/** + * @brief SPI command categories (subsystems). + * + * Carried as the `category` byte of the frame header. The C5 routes a command + * to a dispatcher by this byte alone; the `op` byte selects the operation + * within the category. + */ +typedef enum { + SPI_CAT_SYSTEM = 0x00, + SPI_CAT_WIFI = 0x01, + SPI_CAT_BT = 0x02, + SPI_CAT_LORA = 0x03, + SPI_CAT_MESH = 0x04, // Meshtastic phone bridge + SPI_CAT_MCORE = 0x05, // MeshCore phone bridge + SPI_CAT_HOST = 0x06, // Companion host-link BLE relay + SPI_CAT_SCREEN = 0x07, // P4-native screen sharing over the host link (USB) + SPI_CAT_SESSION = 0xFF +} spi_cat_t; + +/** Pack a (category, op) pair into a 16-bit command identifier. */ +#define SPI_CMD(cat, op) ((uint16_t)(((uint8_t)(cat) << 8) | (uint8_t)(op))) +/** Extract the category byte from a packed command identifier. */ +#define SPI_CMD_CAT(cmd) ((uint8_t)((cmd) >> 8)) +/** Extract the op byte from a packed command identifier. */ +#define SPI_CMD_OP(cmd) ((uint8_t)((cmd) & 0xFF)) + /** * @brief SPI function/command identifiers. + * + * Each value packs its category (high byte) and op (low byte) via SPI_CMD(). */ typedef enum { - // System (0x01 - 0x0F) - SPI_ID_SYSTEM_PING = 0x01, - SPI_ID_SYSTEM_STATUS = 0x02, - SPI_ID_SYSTEM_REBOOT = 0x03, - SPI_ID_SYSTEM_VERSION = 0x04, - SPI_ID_SYSTEM_DATA = 0x05, - SPI_ID_SYSTEM_STREAM = 0x06, - - // WiFi Basic (0x10 - 0x1F) - SPI_ID_WIFI_SCAN = 0x10, - SPI_ID_WIFI_CONNECT = 0x11, - SPI_ID_WIFI_DISCONNECT = 0x12, - SPI_ID_WIFI_GET_STA_INFO = 0x13, - SPI_ID_WIFI_SET_AP = 0x14, - SPI_ID_WIFI_START = 0x15, - SPI_ID_WIFI_STOP = 0x16, - SPI_ID_WIFI_SAVE_AP_CONFIG = 0x17, - SPI_ID_WIFI_SET_ENABLED = 0x18, - SPI_ID_WIFI_SET_AP_PASSWORD = 0x19, - SPI_ID_WIFI_SET_AP_MAX_CONN = 0x1A, - SPI_ID_WIFI_SET_AP_IP = 0x1B, - SPI_ID_WIFI_PROMISC_START = 0x1C, - SPI_ID_WIFI_PROMISC_STOP = 0x1D, - SPI_ID_WIFI_CH_HOP_START = 0x1E, - SPI_ID_WIFI_CH_HOP_STOP = 0x1F, - - // WiFi Applications & Attacks (0x20 - 0x4F) - SPI_ID_WIFI_APP_SCAN_AP = 0x20, - SPI_ID_WIFI_APP_SCAN_CLIENT = 0x21, - SPI_ID_WIFI_APP_BEACON_SPAM = 0x22, - SPI_ID_WIFI_APP_DEAUTHER = 0x23, - SPI_ID_WIFI_APP_FLOOD = 0x24, - SPI_ID_WIFI_APP_SNIFFER = 0x25, - SPI_ID_WIFI_APP_EVIL_TWIN = 0x26, - SPI_ID_WIFI_APP_DEAUTH_DET = 0x27, - SPI_ID_WIFI_APP_PROBE_MON = 0x28, - SPI_ID_WIFI_APP_SIGNAL_MON = 0x29, - SPI_ID_WIFI_SNIFFER_SET_SNAPLEN = 0x2B, - SPI_ID_WIFI_SNIFFER_SET_VERBOSE = 0x2C, - SPI_ID_WIFI_SNIFFER_SAVE_FLASH = 0x2D, - SPI_ID_WIFI_SNIFFER_SAVE_SD = 0x2E, - SPI_ID_WIFI_SNIFFER_FREE_BUFFER = 0x2F, - SPI_ID_WIFI_SNIFFER_STREAM_SD = 0x30, - SPI_ID_WIFI_SNIFFER_CLEAR_PMKID = 0x31, - SPI_ID_WIFI_SNIFFER_GET_PMKID_BSSID = 0x32, - SPI_ID_WIFI_SNIFFER_CLEAR_HANDSHAKE = 0x33, - SPI_ID_WIFI_SNIFFER_GET_HANDSHAKE_BSSID = 0x34, - SPI_ID_WIFI_DEAUTH_STATUS = 0x35, - SPI_ID_WIFI_DEAUTH_SEND_RAW = 0x36, - SPI_ID_WIFI_ASSOC_REQUEST = 0x37, - SPI_ID_WIFI_DEAUTH_SEND_FRAME = 0x38, - SPI_ID_WIFI_DEAUTH_SEND_BROADCAST = 0x39, - SPI_ID_WIFI_TARGET_SCAN_START = 0x3A, - SPI_ID_WIFI_TARGET_SCAN_STATUS = 0x3B, - SPI_ID_WIFI_TARGET_SAVE_FLASH = 0x3C, - SPI_ID_WIFI_TARGET_SAVE_SD = 0x3D, - SPI_ID_WIFI_TARGET_FREE = 0x3E, - SPI_ID_WIFI_PROBE_SAVE_FLASH = 0x3F, - SPI_ID_WIFI_PROBE_SAVE_SD = 0x40, - SPI_ID_WIFI_EVIL_TWIN_TEMPLATE = 0x41, - SPI_ID_WIFI_EVIL_TWIN_HAS_PASSWORD = 0x42, - SPI_ID_WIFI_EVIL_TWIN_GET_PASSWORD = 0x43, - SPI_ID_WIFI_EVIL_TWIN_RESET_CAPTURE = 0x44, - SPI_ID_WIFI_CLIENT_SAVE_FLASH = 0x45, - SPI_ID_WIFI_CLIENT_SAVE_SD = 0x46, - SPI_ID_WIFI_AP_SAVE_FLASH = 0x47, - SPI_ID_WIFI_AP_SAVE_SD = 0x48, - SPI_ID_WIFI_EVIL_TWIN_TMPL_BEGIN = 0xA0, - SPI_ID_WIFI_EVIL_TWIN_TMPL_CHUNK = 0xA1, - - // Bluetooth Basic (0x50 - 0x5F) - SPI_ID_BT_SCAN = 0x50, - SPI_ID_BT_CONNECT = 0x51, - SPI_ID_BT_DISCONNECT = 0x52, - SPI_ID_BT_GET_INFO = 0x53, - SPI_ID_BT_INIT = 0x54, - SPI_ID_BT_DEINIT = 0x55, - SPI_ID_BT_START = 0x56, - SPI_ID_BT_STOP = 0x57, - SPI_ID_BT_SET_RANDOM_MAC = 0x58, - SPI_ID_BT_START_ADV = 0x59, - SPI_ID_BT_STOP_ADV = 0x5A, - SPI_ID_BT_SET_MAX_POWER = 0x5B, - SPI_ID_BT_TRACKER_START = 0x5C, - SPI_ID_BT_TRACKER_STOP = 0x5D, - SPI_ID_BT_GET_ADDR_TYPE = 0x5E, - SPI_ID_BT_SAVE_ANNOUNCE_CFG = 0x5F, - - // Bluetooth Apps & Attacks (0x60 - 0x7F) - SPI_ID_BT_APP_SCANNER = 0x60, - SPI_ID_BT_APP_SNIFFER = 0x61, - SPI_ID_BT_APP_SPAM = 0x62, - SPI_ID_BT_APP_FLOOD = 0x63, - SPI_ID_BT_APP_SKIMMER = 0x64, - SPI_ID_BT_APP_TRACKER = 0x65, - SPI_ID_BT_APP_GATT_EXP = 0x66, - SPI_ID_BT_SPAM_LIST_LOAD = 0x68, - SPI_ID_BT_SPAM_LIST_BEGIN = 0x69, - SPI_ID_BT_SPAM_LIST_ITEM = 0x6A, - SPI_ID_BT_SPAM_LIST_COMMIT = 0x6B, - SPI_ID_BT_SCREEN_INIT = 0x6C, - SPI_ID_BT_SCREEN_DEINIT = 0x6D, - SPI_ID_BT_SCREEN_IS_ACTIVE = 0x6E, - SPI_ID_BT_SCREEN_SEND_PARTIAL = 0x6F, - SPI_ID_BT_L2CAP_STATUS = 0x70, - SPI_ID_BT_HID_INIT = 0x71, - SPI_ID_BT_HID_DEINIT = 0x72, - SPI_ID_BT_HID_IS_CONNECTED = 0x73, - SPI_ID_BT_HID_SEND_KEY = 0x74, - - // LoRa (0x80 - 0x8F) - SPI_ID_LORA_RX = 0x80, - SPI_ID_LORA_TX = 0x81, - - // Meshtastic phone bridge (0x90 - 0x97) - SPI_ID_MESH_BLE_INIT = 0x90, - SPI_ID_MESH_BLE_STOP = 0x91, - SPI_ID_MESH_WIFI_INIT = 0x92, - SPI_ID_MESH_WIFI_STOP = 0x93, - SPI_ID_MESH_FROMRADIO_PUSH = 0x94, - SPI_ID_MESH_LOG_PUSH = 0x95, - SPI_ID_MESH_STATUS = 0x96, - SPI_ID_MESH_TORADIO_STREAM = 0x97, - - // MeshCore phone bridge (0x98 - 0x9C) - SPI_ID_MCORE_BLE_INIT = 0x98, - SPI_ID_MCORE_BLE_STOP = 0x99, - SPI_ID_MCORE_TX_PUSH = 0x9A, - SPI_ID_MCORE_RX_STREAM = 0x9B, - SPI_ID_MCORE_STATUS = 0x9C, + // System + SPI_ID_SYSTEM_PING = SPI_CMD(SPI_CAT_SYSTEM, 0x01), + SPI_ID_SYSTEM_STATUS = SPI_CMD(SPI_CAT_SYSTEM, 0x02), + SPI_ID_SYSTEM_REBOOT = SPI_CMD(SPI_CAT_SYSTEM, 0x03), + SPI_ID_SYSTEM_VERSION = SPI_CMD(SPI_CAT_SYSTEM, 0x04), + SPI_ID_SYSTEM_DATA = SPI_CMD(SPI_CAT_SYSTEM, 0x05), + SPI_ID_SYSTEM_STREAM = SPI_CMD(SPI_CAT_SYSTEM, 0x06), + SPI_ID_SYSTEM_LOG = SPI_CMD(SPI_CAT_SYSTEM, 0x07), // C5→P4 stream: log lines [level u8][utf-8] + SPI_ID_SYSTEM_ENTER_DOWNLOAD = + SPI_CMD(SPI_CAT_SYSTEM, 0x08), // P4→C5: reboot into ROM download mode (serial-flash recovery) + SPI_ID_SYSTEM_OTA_BEGIN = + SPI_CMD(SPI_CAT_SYSTEM, + 0x09), // P4→C5: begin app OTA. Payload = spi_ota_begin_t (u32 size + u8 + // transport). C5 erases; bytes then arrive over SPI (OTA_DATA) or UART. + SPI_ID_SYSTEM_OTA_STATUS = SPI_CMD( + SPI_CAT_SYSTEM, 0x0A), // P4→C5: poll OTA progress. Response payload = spi_ota_status_t. + SPI_ID_SYSTEM_OTA_DATA = SPI_CMD( + SPI_CAT_SYSTEM, 0x0B), // P4→C5: one firmware chunk (SPI transport). Written sequentially; the + // C5 finalizes and reboots once bytes_written reaches the image size. + SPI_ID_SYSTEM_INFO = + SPI_CMD(SPI_CAT_SYSTEM, 0x0C), // P4→C5: read chip info. Response payload = spi_sys_info_t. + SPI_ID_SYSTEM_PROTO_VERSION = SPI_CMD( + SPI_CAT_SYSTEM, 0x0D), // P4→C5: read the C5's SPI_PROTOCOL_VERSION. Response payload = u16. + + // Companion file ops. P4-local host-link commands (the P4 owns flash + SD); + // listed here only so the app and P4 share one id space. Never relayed to C5. + SPI_ID_FILE_LIST = SPI_CMD(SPI_CAT_SYSTEM, 0x40), + SPI_ID_FILE_STAT = SPI_CMD(SPI_CAT_SYSTEM, 0x41), + SPI_ID_FILE_READ = SPI_CMD(SPI_CAT_SYSTEM, 0x42), + SPI_ID_FILE_WRITE = SPI_CMD(SPI_CAT_SYSTEM, 0x43), + SPI_ID_FILE_DELETE = SPI_CMD(SPI_CAT_SYSTEM, 0x44), + SPI_ID_FILE_MKDIR = SPI_CMD(SPI_CAT_SYSTEM, 0x45), + + // Companion device state + settings + console exec. Also P4-local host-link + // commands (never relayed to C5); ids shared so the app and P4 agree. + SPI_ID_SYSTEM_DEVICE_STATE = SPI_CMD(SPI_CAT_SYSTEM, 0x46), + SPI_ID_SYSTEM_CONSOLE_EXEC = SPI_CMD(SPI_CAT_SYSTEM, 0x47), + SPI_ID_SYSTEM_GET_SETTINGS = SPI_CMD(SPI_CAT_SYSTEM, 0x48), + SPI_ID_SYSTEM_SET_SETTINGS = SPI_CMD(SPI_CAT_SYSTEM, 0x49), + // P4→C5: device power state (payload = spi_power_state_t). Lets the C5 drop its + // radio when the P4 is idle/asleep instead of running full RX all the time. + SPI_ID_SYSTEM_POWER_STATE = SPI_CMD(SPI_CAT_SYSTEM, 0x4A), + + // WiFi Basic + SPI_ID_WIFI_SCAN = SPI_CMD(SPI_CAT_WIFI, 0x10), + SPI_ID_WIFI_SCAN_STATUS = SPI_CMD(SPI_CAT_WIFI, 0x50), // P4 polls this: 1 = scan running + SPI_ID_WIFI_CONNECT = SPI_CMD(SPI_CAT_WIFI, 0x11), + SPI_ID_WIFI_DISCONNECT = SPI_CMD(SPI_CAT_WIFI, 0x12), + SPI_ID_WIFI_GET_STA_INFO = SPI_CMD(SPI_CAT_WIFI, 0x13), + SPI_ID_WIFI_SET_AP = SPI_CMD(SPI_CAT_WIFI, 0x14), + SPI_ID_WIFI_START = SPI_CMD(SPI_CAT_WIFI, 0x15), + SPI_ID_WIFI_STOP = SPI_CMD(SPI_CAT_WIFI, 0x16), + SPI_ID_WIFI_SAVE_AP_CONFIG = SPI_CMD(SPI_CAT_WIFI, 0x17), + SPI_ID_WIFI_SET_ENABLED = SPI_CMD(SPI_CAT_WIFI, 0x18), + SPI_ID_WIFI_SET_AP_PASSWORD = SPI_CMD(SPI_CAT_WIFI, 0x19), + SPI_ID_WIFI_SET_AP_MAX_CONN = SPI_CMD(SPI_CAT_WIFI, 0x1A), + SPI_ID_WIFI_SET_AP_IP = SPI_CMD(SPI_CAT_WIFI, 0x1B), + SPI_ID_WIFI_PROMISC_START = SPI_CMD(SPI_CAT_WIFI, 0x1C), + SPI_ID_WIFI_PROMISC_STOP = SPI_CMD(SPI_CAT_WIFI, 0x1D), + SPI_ID_WIFI_CH_HOP_START = SPI_CMD(SPI_CAT_WIFI, 0x1E), + SPI_ID_WIFI_CH_HOP_STOP = SPI_CMD(SPI_CAT_WIFI, 0x1F), + + // WiFi Applications & Attacks + SPI_ID_WIFI_APP_SCAN_AP = SPI_CMD(SPI_CAT_WIFI, 0x20), + SPI_ID_WIFI_APP_SCAN_CLIENT = SPI_CMD(SPI_CAT_WIFI, 0x21), + SPI_ID_WIFI_APP_BEACON_SPAM = SPI_CMD(SPI_CAT_WIFI, 0x22), + SPI_ID_WIFI_APP_DEAUTHER = SPI_CMD(SPI_CAT_WIFI, 0x23), + SPI_ID_WIFI_APP_FLOOD = SPI_CMD(SPI_CAT_WIFI, 0x24), + SPI_ID_WIFI_APP_SNIFFER = SPI_CMD(SPI_CAT_WIFI, 0x25), + SPI_ID_WIFI_APP_EVIL_TWIN = SPI_CMD(SPI_CAT_WIFI, 0x26), + SPI_ID_WIFI_APP_DEAUTH_DET = SPI_CMD(SPI_CAT_WIFI, 0x27), + SPI_ID_WIFI_APP_PROBE_MON = SPI_CMD(SPI_CAT_WIFI, 0x28), + SPI_ID_WIFI_APP_SIGNAL_MON = SPI_CMD(SPI_CAT_WIFI, 0x29), + SPI_ID_WIFI_SNIFFER_SET_SNAPLEN = SPI_CMD(SPI_CAT_WIFI, 0x2B), + SPI_ID_WIFI_SNIFFER_SET_VERBOSE = SPI_CMD(SPI_CAT_WIFI, 0x2C), + SPI_ID_WIFI_SNIFFER_SAVE_FLASH = SPI_CMD(SPI_CAT_WIFI, 0x2D), + SPI_ID_WIFI_SNIFFER_SAVE_SD = SPI_CMD(SPI_CAT_WIFI, 0x2E), + SPI_ID_WIFI_SNIFFER_FREE_BUFFER = SPI_CMD(SPI_CAT_WIFI, 0x2F), + SPI_ID_WIFI_SNIFFER_STREAM_SD = SPI_CMD(SPI_CAT_WIFI, 0x30), + SPI_ID_WIFI_SNIFFER_CLEAR_PMKID = SPI_CMD(SPI_CAT_WIFI, 0x31), + SPI_ID_WIFI_SNIFFER_GET_PMKID_BSSID = SPI_CMD(SPI_CAT_WIFI, 0x32), + SPI_ID_WIFI_SNIFFER_CLEAR_HANDSHAKE = SPI_CMD(SPI_CAT_WIFI, 0x33), + SPI_ID_WIFI_SNIFFER_GET_HANDSHAKE_BSSID = SPI_CMD(SPI_CAT_WIFI, 0x34), + SPI_ID_WIFI_DEAUTH_STATUS = SPI_CMD(SPI_CAT_WIFI, 0x35), + SPI_ID_WIFI_DEAUTH_SEND_RAW = SPI_CMD(SPI_CAT_WIFI, 0x36), + SPI_ID_WIFI_ASSOC_REQUEST = SPI_CMD(SPI_CAT_WIFI, 0x37), + SPI_ID_WIFI_DEAUTH_SEND_FRAME = SPI_CMD(SPI_CAT_WIFI, 0x38), + SPI_ID_WIFI_DEAUTH_SEND_BROADCAST = SPI_CMD(SPI_CAT_WIFI, 0x39), + SPI_ID_WIFI_TARGET_SCAN_START = SPI_CMD(SPI_CAT_WIFI, 0x3A), + SPI_ID_WIFI_TARGET_SCAN_STATUS = SPI_CMD(SPI_CAT_WIFI, 0x3B), + SPI_ID_WIFI_TARGET_SAVE_FLASH = SPI_CMD(SPI_CAT_WIFI, 0x3C), + SPI_ID_WIFI_TARGET_SAVE_SD = SPI_CMD(SPI_CAT_WIFI, 0x3D), + SPI_ID_WIFI_TARGET_FREE = SPI_CMD(SPI_CAT_WIFI, 0x3E), + SPI_ID_WIFI_PROBE_SAVE_FLASH = SPI_CMD(SPI_CAT_WIFI, 0x3F), + SPI_ID_WIFI_PROBE_SAVE_SD = SPI_CMD(SPI_CAT_WIFI, 0x40), + SPI_ID_WIFI_EVIL_TWIN_TEMPLATE = SPI_CMD(SPI_CAT_WIFI, 0x41), + SPI_ID_WIFI_EVIL_TWIN_HAS_PASSWORD = SPI_CMD(SPI_CAT_WIFI, 0x42), + SPI_ID_WIFI_EVIL_TWIN_GET_PASSWORD = SPI_CMD(SPI_CAT_WIFI, 0x43), + SPI_ID_WIFI_EVIL_TWIN_RESET_CAPTURE = SPI_CMD(SPI_CAT_WIFI, 0x44), + SPI_ID_WIFI_CLIENT_SAVE_FLASH = SPI_CMD(SPI_CAT_WIFI, 0x45), + SPI_ID_WIFI_CLIENT_SAVE_SD = SPI_CMD(SPI_CAT_WIFI, 0x46), + SPI_ID_WIFI_AP_SAVE_FLASH = SPI_CMD(SPI_CAT_WIFI, 0x47), + SPI_ID_WIFI_AP_SAVE_SD = SPI_CMD(SPI_CAT_WIFI, 0x48), + SPI_ID_WIFI_PORT_SCAN_TARGET_RANGE = SPI_CMD(SPI_CAT_WIFI, 0x49), + SPI_ID_WIFI_PORT_SCAN_TARGET_LIST = SPI_CMD(SPI_CAT_WIFI, 0x4A), + SPI_ID_WIFI_PORT_SCAN_NETWORK = SPI_CMD(SPI_CAT_WIFI, 0x4B), + SPI_ID_WIFI_PORT_SCAN_CIDR = SPI_CMD(SPI_CAT_WIFI, 0x4C), + SPI_ID_WIFI_PORT_SCAN_STOP = SPI_CMD(SPI_CAT_WIFI, 0x4D), + SPI_ID_WIFI_GET_MAC = SPI_CMD(SPI_CAT_WIFI, 0x4E), + SPI_ID_WIFI_GET_IP_INFO = SPI_CMD(SPI_CAT_WIFI, 0x4F), + SPI_ID_WIFI_EVIL_TWIN_TMPL_BEGIN = SPI_CMD(SPI_CAT_WIFI, 0xA0), + SPI_ID_WIFI_EVIL_TWIN_TMPL_CHUNK = SPI_CMD(SPI_CAT_WIFI, 0xA1), + + // Bluetooth Basic + SPI_ID_BT_SCAN = SPI_CMD(SPI_CAT_BT, 0x50), + SPI_ID_BT_SCAN_STATUS = SPI_CMD(SPI_CAT_BT, 0x7F), // P4 polls this: 1 = scan running + SPI_ID_BT_CONNECT = SPI_CMD(SPI_CAT_BT, 0x51), + SPI_ID_BT_DISCONNECT = SPI_CMD(SPI_CAT_BT, 0x52), + SPI_ID_BT_GET_INFO = SPI_CMD(SPI_CAT_BT, 0x53), + SPI_ID_BT_INIT = SPI_CMD(SPI_CAT_BT, 0x54), + SPI_ID_BT_DEINIT = SPI_CMD(SPI_CAT_BT, 0x55), + SPI_ID_BT_START = SPI_CMD(SPI_CAT_BT, 0x56), + SPI_ID_BT_STOP = SPI_CMD(SPI_CAT_BT, 0x57), + SPI_ID_BT_SET_RANDOM_MAC = SPI_CMD(SPI_CAT_BT, 0x58), + SPI_ID_BT_START_ADV = SPI_CMD(SPI_CAT_BT, 0x59), + SPI_ID_BT_STOP_ADV = SPI_CMD(SPI_CAT_BT, 0x5A), + SPI_ID_BT_SET_MAX_POWER = SPI_CMD(SPI_CAT_BT, 0x5B), + SPI_ID_BT_TRACKER_START = SPI_CMD(SPI_CAT_BT, 0x5C), + SPI_ID_BT_TRACKER_STOP = SPI_CMD(SPI_CAT_BT, 0x5D), + SPI_ID_BT_GET_ADDR_TYPE = SPI_CMD(SPI_CAT_BT, 0x5E), + SPI_ID_BT_SAVE_ANNOUNCE_CFG = SPI_CMD(SPI_CAT_BT, 0x5F), + + // Bluetooth Apps & Attacks + SPI_ID_BT_APP_SCANNER = SPI_CMD(SPI_CAT_BT, 0x60), + SPI_ID_BT_APP_SNIFFER = SPI_CMD(SPI_CAT_BT, 0x61), + SPI_ID_BT_APP_SPAM = SPI_CMD(SPI_CAT_BT, 0x62), + SPI_ID_BT_APP_FLOOD = SPI_CMD(SPI_CAT_BT, 0x63), + SPI_ID_BT_APP_SKIMMER = SPI_CMD(SPI_CAT_BT, 0x64), + SPI_ID_BT_APP_TRACKER = SPI_CMD(SPI_CAT_BT, 0x65), + SPI_ID_BT_APP_GATT_EXP = SPI_CMD(SPI_CAT_BT, 0x66), + SPI_ID_BT_SPAM_LIST_LOAD = SPI_CMD(SPI_CAT_BT, 0x68), + SPI_ID_BT_SPAM_LIST_BEGIN = SPI_CMD(SPI_CAT_BT, 0x69), + SPI_ID_BT_SPAM_LIST_ITEM = SPI_CMD(SPI_CAT_BT, 0x6A), + SPI_ID_BT_SPAM_LIST_COMMIT = SPI_CMD(SPI_CAT_BT, 0x6B), + SPI_ID_BT_SCREEN_INIT = SPI_CMD(SPI_CAT_BT, 0x6C), + SPI_ID_BT_SCREEN_DEINIT = SPI_CMD(SPI_CAT_BT, 0x6D), + SPI_ID_BT_SCREEN_IS_ACTIVE = SPI_CMD(SPI_CAT_BT, 0x6E), + SPI_ID_BT_SCREEN_SEND_PARTIAL = SPI_CMD(SPI_CAT_BT, 0x6F), + SPI_ID_BT_L2CAP_STATUS = SPI_CMD(SPI_CAT_BT, 0x70), + SPI_ID_BT_HID_INIT = SPI_CMD(SPI_CAT_BT, 0x71), + SPI_ID_BT_HID_DEINIT = SPI_CMD(SPI_CAT_BT, 0x72), + SPI_ID_BT_HID_IS_CONNECTED = SPI_CMD(SPI_CAT_BT, 0x73), + SPI_ID_BT_HID_SEND_KEY = SPI_CMD(SPI_CAT_BT, 0x74), + + // LoRa + SPI_ID_LORA_RX = SPI_CMD(SPI_CAT_LORA, 0x80), + SPI_ID_LORA_TX = SPI_CMD(SPI_CAT_LORA, 0x81), + + // Meshtastic phone bridge + SPI_ID_MESH_BLE_INIT = SPI_CMD(SPI_CAT_MESH, 0x90), + SPI_ID_MESH_BLE_STOP = SPI_CMD(SPI_CAT_MESH, 0x91), + SPI_ID_MESH_WIFI_INIT = SPI_CMD(SPI_CAT_MESH, 0x92), + SPI_ID_MESH_WIFI_STOP = SPI_CMD(SPI_CAT_MESH, 0x93), + SPI_ID_MESH_FROMRADIO_PUSH = SPI_CMD(SPI_CAT_MESH, 0x94), + SPI_ID_MESH_LOG_PUSH = SPI_CMD(SPI_CAT_MESH, 0x95), + SPI_ID_MESH_STATUS = SPI_CMD(SPI_CAT_MESH, 0x96), + SPI_ID_MESH_TORADIO_STREAM = SPI_CMD(SPI_CAT_MESH, 0x97), + + // MeshCore phone bridge + SPI_ID_MCORE_BLE_INIT = SPI_CMD(SPI_CAT_MCORE, 0x98), + SPI_ID_MCORE_BLE_STOP = SPI_CMD(SPI_CAT_MCORE, 0x99), + SPI_ID_MCORE_TX_PUSH = SPI_CMD(SPI_CAT_MCORE, 0x9A), + SPI_ID_MCORE_RX_STREAM = SPI_CMD(SPI_CAT_MCORE, 0x9B), + SPI_ID_MCORE_STATUS = SPI_CMD(SPI_CAT_MCORE, 0x9C), + + // Companion host-link BLE relay (C5 owns BLE; transparent byte ferry — all + // crypto/auth lives on the P4). Mirrors the MeshCore phone-bridge pattern. + SPI_ID_HOST_BLE_INIT = SPI_CMD(SPI_CAT_HOST, 0xA0), // P4→C5: start GATT + advertise + SPI_ID_HOST_BLE_STOP = SPI_CMD(SPI_CAT_HOST, 0xA1), // P4→C5: stop GATT + SPI_ID_HOST_TX = SPI_CMD(SPI_CAT_HOST, 0xA2), // P4→C5 push: device→app (BLE notify) + SPI_ID_HOST_RX = SPI_CMD(SPI_CAT_HOST, 0xA3), // C5→P4 stream: app→device (BLE write) + SPI_ID_HOST_STATUS = SPI_CMD(SPI_CAT_HOST, 0xA4), // poll BLE connection state // Session lifecycle (long-running operations) - SPI_ID_SESSION_HEARTBEAT = 0xF0, - SPI_ID_SESSION_LOST = 0xF1, - SPI_ID_SESSION_STOP = 0xF2 + // Screen sharing (P4-native, over the USB host link — handled locally, never + // relayed to the C5). START/STOP/KEY are app->device commands; FRAME is a + // device->app STREAM carrying RGB565 row-strips of the live screen. + SPI_ID_SCREEN_START = SPI_CMD(SPI_CAT_SCREEN, 0x01), + SPI_ID_SCREEN_STOP = SPI_CMD(SPI_CAT_SCREEN, 0x02), + SPI_ID_SCREEN_KEY = SPI_CMD(SPI_CAT_SCREEN, 0x03), + SPI_ID_SCREEN_FRAME = SPI_CMD(SPI_CAT_SCREEN, 0x04), + + SPI_ID_SESSION_HEARTBEAT = SPI_CMD(SPI_CAT_SESSION, 0xF0), + SPI_ID_SESSION_LOST = SPI_CMD(SPI_CAT_SESSION, 0xF1), + SPI_ID_SESSION_STOP = SPI_CMD(SPI_CAT_SESSION, 0xF2) } spi_id_t; +/** + * @brief Device power state, sent P4→C5 as the SPI_ID_SYSTEM_POWER_STATE payload. + */ +typedef enum { + SPI_POWER_ACTIVE = 0, ///< Normal operation: full radio. + SPI_POWER_IDLE = 1, ///< Screen dimmed: deeper modem sleep (MAX_MODEM). + SPI_POWER_SLEEP = 2, ///< Device asleep: drop the radio (unless a capture is running). +} spi_power_state_t; + +/** + * @brief Screen-share control keys (payload byte 0 of SPI_ID_SCREEN_KEY). + * The device maps these to the LVGL keypad (up/down/ok/back/left/right). + */ +typedef enum { + SPI_SCREEN_KEY_UP = 0, + SPI_SCREEN_KEY_DOWN = 1, + SPI_SCREEN_KEY_LEFT = 2, + SPI_SCREEN_KEY_RIGHT = 3, + SPI_SCREEN_KEY_OK = 4, + SPI_SCREEN_KEY_BACK = 5 +} spi_screen_key_t; + +/** + * @brief Header of a SPI_ID_SCREEN_FRAME STREAM payload, followed by + * (rows * width) RGB565 little-endian pixels. The screen is streamed as + * horizontal row-strips; y==0 marks the first strip of a new frame. + */ +typedef struct __attribute__((packed)) { + uint16_t y; // row offset of this strip within the frame + uint16_t rows; // number of rows in this strip + uint16_t width; // pixels per row (full screen width) +} spi_screen_strip_t; + /** * @brief SPI response status codes (payload byte 0 for RESP type). */ @@ -193,16 +343,99 @@ typedef enum { } spi_status_t; /** - * @brief SPI frame header (4 bytes). + * @brief App-OTA state, reported by SPI_ID_SYSTEM_OTA_STATUS. */ -typedef struct { +typedef enum { + SPI_OTA_STATE_IDLE = 0, ///< No OTA in progress. + SPI_OTA_STATE_ERASING = 1, ///< esp_ota_begin erasing the target partition. + SPI_OTA_STATE_READY = 2, ///< Erased; ready to receive the .bin over UART0. + SPI_OTA_STATE_RECEIVING = 3, ///< Receiving/writing the image. + SPI_OTA_STATE_DONE = 4, ///< Image validated and set to boot; C5 about to reboot. + SPI_OTA_STATE_ERROR = 5 ///< Aborted (bad size, timeout, write/verify failure). +} spi_ota_state_t; + +/** + * @brief OTA progress snapshot (SPI_ID_SYSTEM_OTA_STATUS response payload). + */ +typedef struct __attribute__((packed)) { + uint8_t state; ///< spi_ota_state_t + uint32_t bytes_written; ///< Bytes committed to flash so far (the per-block ACK). +} spi_ota_status_t; + +/** @brief Where the C5 receives the firmware image after an OTA begin. */ +typedef enum { + SPI_OTA_TRANSPORT_SPI = 0, ///< Image bytes arrive as SPI_ID_SYSTEM_OTA_DATA chunks. + SPI_OTA_TRANSPORT_UART = 1 ///< Image bytes arrive raw on UART0; control stays on SPI. +} spi_ota_transport_t; + +/** @brief SPI_ID_SYSTEM_OTA_BEGIN request payload. */ +typedef struct __attribute__((packed)) { + uint32_t size; ///< Image size in bytes. + uint8_t transport; ///< spi_ota_transport_t +} spi_ota_begin_t; + +/** @brief SPI_ID_SYSTEM_INFO response payload: C5 chip identity. */ +typedef struct __attribute__((packed)) { + uint8_t chip_model; ///< esp_chip_model_t + uint16_t chip_revision; ///< major*100 + minor + uint8_t mac[6]; ///< base MAC + uint32_t free_heap; ///< current free heap in bytes +} spi_sys_info_t; + +/** + * @brief SPI frame header (7 bytes: 5-byte framing + a 2-byte CRC-16). + * + * Packed so the layout is identical byte-for-byte on the P4 and C5 (both + * little-endian). `crc` is validated on every received frame; a mismatch means + * the bus corrupted the frame and the receiver drops it (see spi_frame_valid). + */ +typedef struct __attribute__((packed)) { uint8_t sync; - uint8_t type; // spi_type_t - uint8_t id; // spi_id_t - uint8_t length; // Payload length + uint8_t type; // spi_type_t + uint8_t category; // spi_cat_t + uint8_t op; // operation within the category + uint8_t length; // Payload length + uint16_t crc; // CRC-16 over [type,category,op,length] + data } spi_header_t; -#define SPI_FRAME_SIZE (sizeof(spi_header_t) + SPI_MAX_PAYLOAD) +/** Read the packed command identifier (spi_id_t) from a header. */ +static inline uint16_t spi_header_cmd(const spi_header_t *h) { + return SPI_CMD(h->category, h->op); +} + +/** Write a packed command identifier (spi_id_t) into a header. */ +static inline void spi_header_set_cmd(spi_header_t *h, uint16_t cmd) { + h->category = SPI_CMD_CAT(cmd); + h->op = SPI_CMD_OP(cmd); +} + +// Frame integrity (CRC-16/CCITT via the ROM implementation). The CRC covers the +// header's [type,category,op,length] plus the `data_len` data bytes that follow +// the header in the frame buffer; the sync byte (framing marker) and the crc +// field itself are excluded. Header and data are always contiguous in a frame +// buffer, so callers pass only the data length: header.length for CMD/RESP +// frames, or (2 + batch_len) for STREAM frames (whose header.length is 0 and +// whose real size lives in the leading u16 of the payload). +static inline uint16_t spi_frame_crc(const spi_header_t *h, uint16_t data_len) { + uint16_t crc = esp_rom_crc16_le(0xFFFF, &h->type, 4); + if (data_len > 0) { + crc = esp_rom_crc16_le(crc, (const uint8_t *)h + sizeof(spi_header_t), data_len); + } + return crc; +} + +/** Stamp the CRC into a freshly built frame (call after header + data are set). */ +static inline void spi_frame_seal(spi_header_t *h, uint16_t data_len) { + uint16_t c = spi_frame_crc(h, data_len); + memcpy(&h->crc, &c, sizeof(c)); // memcpy: crc is at an odd (packed) offset +} + +/** True if the frame's stored CRC matches a recompute over `data_len` bytes. */ +static inline bool spi_frame_valid(const spi_header_t *h, uint16_t data_len) { + uint16_t stored; + memcpy(&stored, &h->crc, sizeof(stored)); + return stored == spi_frame_crc(h, data_len); +} // Session protocol — see spi_bridge/README.md "Session Lifecycle" #define SPI_SESSION_INVALID_ID 0u @@ -217,12 +450,12 @@ typedef struct __attribute__((packed)) { /** Sent by P4 every ~2s to keep a session alive and ack streams received. */ typedef struct __attribute__((packed)) { uint32_t session_id; - uint32_t last_acked_seq; + uint32_t last_acked_seq; // highest stream seq P4 has processed } spi_heartbeat_req_t; /** C5 reply to heartbeat. */ typedef struct __attribute__((packed)) { - uint8_t alive; + uint8_t alive; // 1 if session still active, 0 if not found / different op } spi_heartbeat_resp_t; /** Prefixed to every stream payload from a session-managed operation. */ @@ -239,9 +472,21 @@ typedef struct __attribute__((packed)) { /** Stream emitted by C5 when a session is auto-killed by the watchdog. */ typedef struct __attribute__((packed)) { uint32_t session_id; - uint8_t op_id; + uint16_t cmd; // spi_id_t of the lost operation } spi_session_lost_t; +// Rounded up to a multiple of 4 bytes: SPI DMA transfers must be word-aligned +// in length, and the 7-byte header would otherwise make the frame size odd. +#define SPI_FRAME_SIZE (((sizeof(spi_header_t) + SPI_MAX_PAYLOAD) + 3u) & ~3u) + +// Larger fixed transfer size used ONLY by the SYSTEM_STREAM response, which +// batches many stream records into one transfer to amortize per-frame overhead. +// The command/response path keeps using SPI_FRAME_SIZE. Must be a multiple of 4 +// for SPI DMA. Stream frame layout (after the 7-byte header, type = STREAM): +// [u16 batch_len][record]... where each record = [u16 op][u8 len][len bytes] +// and `record` data is exactly what session_manager queued (meta + payload). +#define SPI_STREAM_FRAME_SIZE 2048 + /** * @brief WiFi connect request payload. */ @@ -299,10 +544,36 @@ typedef struct { int8_t signal_rssi; bool handshake_captured; bool pmkid_captured; + // Extended monitor stats (appended; older readers can ignore the tail). + uint32_t beacons; // mgmt beacon frames + uint32_t probe_reqs; // mgmt probe requests + uint32_t probe_resps; // mgmt probe responses + uint32_t data_frames; // data frames + uint32_t ctrl_frames; // control frames + uint32_t mgmt_frames; // all management frames + uint32_t pkts_2ghz; // frames captured on 2.4 GHz + uint32_t pkts_5ghz; // frames captured on 5 GHz + uint32_t unique_aps; // distinct BSSIDs seen (approximate) + uint8_t channel; // channel of the last captured frame + int8_t last_rssi; // RSSI of the last captured frame (sniffer, not signal monitor) } __attribute__((packed)) spi_sniffer_stats_t; #define SPI_WIFI_SNIFFER_MAX_DATA (SPI_MAX_PAYLOAD - 4) +/** + * @brief Compact WiFi scan result, served by SPI_ID_WIFI_APP_SCAN_AP through the + * generic data pipe. Fixed-size and explicit (unlike the raw wifi_ap_record_t) + * so the companion app parses it without depending on the IDF struct layout. + * ssid is sanitized printable ASCII, null-terminated, empty for hidden networks. + */ +typedef struct { + uint8_t bssid[6]; // AP MAC + int8_t rssi; // signal strength, dBm + uint8_t channel; // primary channel + uint8_t authmode; // wifi_auth_mode_t value (0=open, 3=wpa2, 6=wpa3, ...) + uint8_t ssid[33]; // null-terminated, sanitized ASCII ('' = hidden) +} __attribute__((packed)) spi_wifi_scan_record_t; + /** * @brief WiFi sniffer stream frame. */ @@ -324,6 +595,64 @@ typedef struct { uint8_t data[31]; } __attribute__((packed)) spi_ble_sniffer_frame_t; +/** + * @brief WiFi IP info response payload. + */ +typedef struct { + uint8_t interface; // 0 = STA, 1 = AP + uint8_t mac[6]; + uint32_t ip; + uint32_t netmask; + uint32_t gw; +} __attribute__((packed)) spi_wifi_ip_info_t; + +/** + * @brief Port scan request with target IP and port range. + */ +typedef struct { + char ip[16]; + uint16_t start_port; + uint16_t end_port; + uint16_t max_results; + uint8_t reserved[2]; +} __attribute__((packed)) spi_port_scan_range_req_t; + +/** + * @brief Port scan request for a network IP range. + */ +typedef struct { + char start_ip[16]; + char end_ip[16]; + uint16_t start_port; + uint16_t end_port; + uint16_t max_results; + uint8_t scan_type; // 0 = port range, 1 = port list + uint8_t reserved; +} __attribute__((packed)) spi_port_scan_network_req_t; + +/** + * @brief Port scan request using CIDR notation. + */ +typedef struct { + char base_ip[16]; + uint8_t cidr; + uint8_t scan_type; // 0 = port range, 1 = port list + uint16_t start_port; + uint16_t end_port; + uint16_t max_results; +} __attribute__((packed)) spi_port_scan_cidr_req_t; + +/** + * @brief Port scan result record. + */ +typedef struct { + char ip_str[16]; + uint16_t port; + uint8_t protocol; // 0 = TCP, 1 = UDP + uint8_t status; // 0 = OPEN, 1 = OPEN_FILTERED + char banner[64]; +} __attribute__((packed)) spi_port_scan_result_t; + /** * @brief Meshtastic transport init payload. * @@ -386,6 +715,28 @@ typedef struct { uint8_t reserved[2]; } __attribute__((packed)) spi_mcore_status_t; +/** + * @brief Companion host-link BLE init payload. + * + * Sent with SPI_ID_HOST_BLE_INIT. The C5 advertises as "-XXXX" + * (last 4 hex of MAC). BLE bonding is "just works" (LE Secure Connections, + * no MITM) — the host-link PSK/HMAC envelope on the P4 is the trust boundary. + */ +typedef struct { + char name_prefix[16]; +} __attribute__((packed)) spi_host_init_t; + +/** + * @brief Companion host-link transport status payload. + * + * Returned by SPI_ID_HOST_STATUS. + */ +typedef struct { + uint8_t ble_connected; + uint8_t ble_subscribed; + uint8_t reserved[2]; +} __attribute__((packed)) spi_host_status_t; + #ifdef __cplusplus } #endif diff --git a/firmware_c5/components/Service/spi_bridge/session_manager.c b/firmware_c5/components/Service/spi_bridge/session_manager.c index 99a9ab118..c3c2369ed 100644 --- a/firmware_c5/components/Service/spi_bridge/session_manager.c +++ b/firmware_c5/components/Service/spi_bridge/session_manager.c @@ -23,6 +23,7 @@ #include "freertos/FreeRTOS.h" #include "freertos/semphr.h" #include "freertos/task.h" +#include "sys_prio.h" #include "spi_bridge.h" @@ -31,7 +32,7 @@ static const char *TAG = "SESSION_MGR"; #define SESSION_TIMEOUT_MS 5000 #define WATCHDOG_PERIOD_MS 1000 #define WATCHDOG_STACK_SIZE 3072 -#define WATCHDOG_PRIO 5 +#define WATCHDOG_PRIO SYS_PRIO_SERVICE_HI #define DROP_LOG_INTERVAL 1000 typedef struct { @@ -61,7 +62,7 @@ static void close_active_locked(const char *reason) { return; ESP_LOGW(TAG, - "Closing session 0x%08lx (op 0x%02X): %s", + "Closing session 0x%08lx (op 0x%04X): %s", (unsigned long)s_session.id, s_session.op_id, reason); @@ -81,7 +82,7 @@ static void close_active_locked(const char *reason) { } static void emit_session_lost(uint32_t session_id, spi_id_t op_id) { - spi_session_lost_t payload = {.session_id = session_id, .op_id = (uint8_t)op_id}; + spi_session_lost_t payload = {.session_id = session_id, .cmd = (uint16_t)op_id}; spi_bridge_stream_push(SPI_ID_SESSION_LOST, (const uint8_t *)&payload, sizeof(payload)); } @@ -135,7 +136,7 @@ uint32_t session_manager_start(spi_id_t op_id, session_kill_cb_t kill_cb) { uint32_t id = s_session.id; xSemaphoreGive(s_mutex); - ESP_LOGI(TAG, "Session 0x%08lx opened for op 0x%02X", (unsigned long)id, op_id); + ESP_LOGI(TAG, "Session 0x%08lx opened for op 0x%04X", (unsigned long)id, op_id); return id; } @@ -150,6 +151,15 @@ esp_err_t session_manager_stop(uint32_t session_id) { return ESP_OK; } +bool session_manager_is_active(void) { + if (s_mutex == NULL) + return false; + xSemaphoreTake(s_mutex, portMAX_DELAY); + bool active = (s_session.id != SPI_SESSION_INVALID_ID); + xSemaphoreGive(s_mutex); + return active; +} + bool session_manager_heartbeat(uint32_t session_id, uint32_t last_acked_seq) { xSemaphoreTake(s_mutex, portMAX_DELAY); bool match = (s_session.id == session_id && session_id != SPI_SESSION_INVALID_ID); diff --git a/firmware_c5/components/Service/spi_bridge/spi_bridge.c b/firmware_c5/components/Service/spi_bridge/spi_bridge.c index f3a9c18d6..9b8acac30 100644 --- a/firmware_c5/components/Service/spi_bridge/spi_bridge.c +++ b/firmware_c5/components/Service/spi_bridge/spi_bridge.c @@ -19,14 +19,22 @@ #include #include "esp_log.h" +#include "esp_rom_sys.h" +#include "esp_chip_info.h" +#include "esp_mac.h" #include "esp_system.h" #include "freertos/FreeRTOS.h" #include "freertos/portmacro.h" #include "freertos/task.h" +#include "sys_prio.h" +#include "soc/lp_aon_reg.h" +#include "soc/soc.h" #include "bt_dispatcher.h" #include "bluetooth_service.h" #include "deauther_detector.h" +#include "ota_service.h" +#include "ota_version.h" #include "session_manager.h" #include "signal_monitor.h" #include "spi_slave_driver.h" @@ -36,25 +44,12 @@ static const char *TAG = "SPI_BRIDGE_C5"; -#define SPI_STREAM_QUEUE_LEN 8 -#define SPI_BRIDGE_TASK_STACK 4096 -#define SPI_BRIDGE_TASK_PRIO 10 -#define SPI_IRQ_PULSE_MS 1 +#define SPI_STREAM_QUEUE_LEN 32 +#define SPI_BRIDGE_TASK_STACK 8192 +#define SPI_BRIDGE_TASK_PRIO SYS_PRIO_REALTIME +#define SPI_IRQ_PULSE_US 10 #define SPI_RESTART_DELAY_MS 50 -#define SPI_WIFI_CMD_MIN 0x10 -#define SPI_WIFI_CMD_MAX 0x4F -#define SPI_BT_CMD_MIN 0x50 -#define SPI_BT_CMD_MAX 0x7F -#define SPI_MESH_BT_CMD_MIN 0x90 -#define SPI_MESH_BT_CMD_MAX 0x91 -#define SPI_MESH_WIFI_CMD_MIN 0x92 -#define SPI_MESH_WIFI_CMD_MAX 0x93 -#define SPI_MESH_BT_DATA_MIN 0x94 -#define SPI_MESH_BT_DATA_MAX 0x96 -#define SPI_MCORE_CMD_MIN 0x98 -#define SPI_MCORE_CMD_MAX 0x9C #define SPI_FW_VERSION_LEN 32 -#define SPI_FW_VERSION_STRING "1.2.0" typedef struct { spi_id_t id; @@ -75,12 +70,17 @@ static bool s_is_wifi_sniffer_streaming = false; static bool s_is_bt_sniffer_streaming = false; static bool s_is_mesh_toradio_streaming = false; static bool s_is_mcore_rx_streaming = false; +static volatile uint32_t s_commands_processed = 0; +static bool s_is_host_rx_streaming = false; +static bool s_is_system_log_streaming = false; +static bool s_use_irq = true; // false = POLL mode (no IRQ trace); master polls static portMUX_TYPE s_stream_mux = portMUX_INITIALIZER_UNLOCKED; static volatile bool s_is_restart_pending = false; +static volatile bool s_is_download_pending = false; static char s_firmware_version[SPI_FW_VERSION_LEN] = "unknown"; static void load_firmware_version(void); -static bool stream_pop(spi_id_t *out_id, uint8_t *out_data, uint8_t *out_len); +static uint16_t stream_pop_into(uint8_t *buf, uint16_t offset, uint16_t cap); static void bridge_task(void *pvParameters); // Public functions @@ -101,6 +101,49 @@ void spi_bridge_provide_results_dynamic(void *source, s_item_size = item_size; } +// Async scan runner. A scan command posts its work function here and returns to +// the SPI handler immediately, so the blocking scan runs off the bridge and does +// not hold it for seconds. The P4 polls a *_SCAN_STATUS command until it clears. +// Only one scan runs at a time (the shared busy flag rejects overlaps). +static volatile bool s_scan_busy = false; +static void (*s_scan_fn)(void) = NULL; +static TaskHandle_t s_scan_runner = NULL; + +static void scan_runner_task(void *arg) { + (void)arg; + while (1) { + ulTaskNotifyTake(pdTRUE, portMAX_DELAY); + void (*fn)(void) = s_scan_fn; + if (fn != NULL) { + fn(); + } + s_scan_busy = false; + } +} + +bool spi_bridge_async_scan_start(void (*fn)(void)) { + if (s_scan_busy) { + return false; + } + if (s_scan_runner == NULL) { + xTaskCreatePinnedToCore(scan_runner_task, + "scan_runner", + 4096, + NULL, + SYS_PRIO_SERVICE_HI, + &s_scan_runner, + SYS_CORE_MAIN); + } + s_scan_fn = fn; + s_scan_busy = true; + xTaskNotifyGive(s_scan_runner); + return true; +} + +bool spi_bridge_async_scan_busy(void) { + return s_scan_busy; +} + bool spi_bridge_stream_is_enabled(spi_id_t id) { if (id == SPI_ID_WIFI_APP_SNIFFER) return s_is_wifi_sniffer_streaming; @@ -110,6 +153,10 @@ bool spi_bridge_stream_is_enabled(spi_id_t id) { return s_is_mesh_toradio_streaming; if (id == SPI_ID_MCORE_RX_STREAM) return s_is_mcore_rx_streaming; + if (id == SPI_ID_HOST_RX) + return s_is_host_rx_streaming; + if (id == SPI_ID_SYSTEM_LOG) + return s_is_system_log_streaming; return false; } @@ -122,6 +169,10 @@ void spi_bridge_stream_enable(spi_id_t id, bool enable) { s_is_mesh_toradio_streaming = enable; if (id == SPI_ID_MCORE_RX_STREAM) s_is_mcore_rx_streaming = enable; + if (id == SPI_ID_HOST_RX) + s_is_host_rx_streaming = enable; + if (id == SPI_ID_SYSTEM_LOG) + s_is_system_log_streaming = enable; } bool spi_bridge_stream_push(spi_id_t id, const uint8_t *data, uint8_t len) { @@ -129,8 +180,6 @@ bool spi_bridge_stream_push(spi_id_t id, const uint8_t *data, uint8_t len) { return false; if (data == NULL || len == 0) return false; - if (len > SPI_MAX_PAYLOAD) - return false; portENTER_CRITICAL(&s_stream_mux); if (s_stream_count >= SPI_STREAM_QUEUE_LEN) { @@ -149,12 +198,43 @@ bool spi_bridge_stream_push(spi_id_t id, const uint8_t *data, uint8_t len) { } void spi_bridge_notify_master(void) { + // POLL mode has no IRQ trace: the master polls the bus, so skip the pulse. + if (!s_use_irq) { + return; + } + // The P4 captures the IRQ via a GPIO rising-edge interrupt, so it only needs + // a clean edge — not a held level. A short microsecond pulse replaces the old + // 1 ms task delay, which dominated per-frame latency and capped stream rate. spi_slave_driver_set_irq(1); - vTaskDelay(pdMS_TO_TICKS(SPI_IRQ_PULSE_MS)); + esp_rom_delay_us(SPI_IRQ_PULSE_US); spi_slave_driver_set_irq(0); } +uint32_t spi_bridge_commands_processed(void) { + return s_commands_processed; +} + +static void enter_download_mode(void) { + ESP_LOGW(TAG, "Entering ROM serial download mode (force)"); + // On ESP32-C5 the force-download-boot selector lives in LP_AON_SYS_CFG_REG + // bits 29-30. Value 0b01 = force download boot (uart/usb): the ROM bootloader + // skips the app and stays in the serial-download stub on USB-Serial/JTAG, + // listening for esptool. This is the cleanest software trigger on a board + // with no hardware BOOT trace (used with SPI_ID_SYSTEM_ENTER_DOWNLOAD). + uint32_t v = REG_READ(LP_AON_SYS_CFG_REG); + v &= ~(LP_AON_FORCE_DOWNLOAD_BOOT_M); + v |= (0x1U << LP_AON_FORCE_DOWNLOAD_BOOT_S); + REG_WRITE(LP_AON_SYS_CFG_REG, v); + vTaskDelay(pdMS_TO_TICKS(20)); // flush the log line before the reset + esp_restart(); +} + esp_err_t spi_bridge_slave_init(void) { + return spi_bridge_slave_init_mode(SPI_BRIDGE_MODE_IRQ); +} + +esp_err_t spi_bridge_slave_init_mode(spi_bridge_mode_t mode) { + s_use_irq = (mode == SPI_BRIDGE_MODE_IRQ); esp_err_t ret = spi_slave_driver_init(); if (ret != ESP_OK) return ret; @@ -167,170 +247,321 @@ esp_err_t spi_bridge_slave_init(void) { // Static functions static void load_firmware_version(void) { - strncpy(s_firmware_version, SPI_FW_VERSION_STRING, sizeof(s_firmware_version) - 1); + strncpy(s_firmware_version, FIRMWARE_VERSION, sizeof(s_firmware_version) - 1); s_firmware_version[sizeof(s_firmware_version) - 1] = '\0'; ESP_LOGI(TAG, "Firmware version: %s", s_firmware_version); } -static bool stream_pop(spi_id_t *out_id, uint8_t *out_data, uint8_t *out_len) { - bool has_item = false; +// Pop the head stream item into buf at `offset`, encoded as a record +// [u16 op][u8 len][len bytes]. Returns the number of bytes written, or 0 if the +// queue is empty or the record would not fit in `cap`. Keeps the critical +// section short (one item, <=256 bytes) so the producer is never blocked long. +static uint16_t stream_pop_into(uint8_t *buf, uint16_t offset, uint16_t cap) { + uint16_t written = 0; portENTER_CRITICAL(&s_stream_mux); if (s_stream_count > 0) { spi_stream_item_t *item = &s_stream_queue[s_stream_head]; - if (out_id != NULL) - *out_id = item->id; - if (out_len != NULL) - *out_len = item->len; - if (out_data != NULL && item->len > 0) { - memcpy(out_data, item->data, item->len); + uint16_t need = 3u + item->len; // u16 op + u8 len + data + if ((uint32_t)offset + need <= cap) { + buf[offset] = (uint8_t)(item->id & 0xFF); + buf[offset + 1] = (uint8_t)((item->id >> 8) & 0xFF); + buf[offset + 2] = item->len; + if (item->len > 0) + memcpy(buf + offset + 3, item->data, item->len); + written = need; + s_stream_head = (uint8_t)((s_stream_head + 1) % SPI_STREAM_QUEUE_LEN); + s_stream_count--; } - s_stream_head = (uint8_t)((s_stream_head + 1) % SPI_STREAM_QUEUE_LEN); - s_stream_count--; - has_item = true; } portEXIT_CRITICAL(&s_stream_mux); - return has_item; + return written; } static void bridge_task(void *pvParameters) { - uint8_t rx_buf[SPI_FRAME_SIZE]; - uint8_t tx_buf[SPI_FRAME_SIZE]; + // Static (this is the only task touching them) so the larger stream TX buffer + // does not blow the task stack. RX/command stays at SPI_FRAME_SIZE; only the + // stream response uses the larger SPI_STREAM_FRAME_SIZE buffer. + static uint8_t rx_buf[SPI_FRAME_SIZE]; + static uint8_t tx_buf[SPI_STREAM_FRAME_SIZE]; + spi_slave_transaction_t rx_trans; + spi_slave_transaction_t tx_trans; + + // Keep a receive transaction armed in hardware at all times. The next command + // RX is re-armed right after the response TX is queued (below), so the master + // can never clock a command into an unarmed slave — even if this task is + // preempted between transfers. + memset(rx_buf, 0, sizeof(rx_buf)); + if (spi_slave_driver_queue(&rx_trans, NULL, rx_buf, SPI_FRAME_SIZE) != ESP_OK) { + vTaskDelete(NULL); + return; + } while (1) { - memset(rx_buf, 0, sizeof(rx_buf)); - if (spi_slave_driver_transmit(NULL, rx_buf, SPI_FRAME_SIZE) != ESP_OK) + if (spi_slave_driver_wait() != ESP_OK) { + memset(rx_buf, 0, sizeof(rx_buf)); + spi_slave_driver_queue(&rx_trans, NULL, rx_buf, SPI_FRAME_SIZE); continue; + } spi_header_t *header = (spi_header_t *)rx_buf; - if (header->sync != SPI_SYNC_BYTE || header->type != SPI_TYPE_CMD) - continue; - if (header->length > SPI_MAX_PAYLOAD) + // Drop framing-invalid or bus-corrupted commands: bad sync/type, or a CRC + // mismatch. Dropping (rather than acting) means the master gets no response + // and its command times out, so a corrupted op/payload is never executed. + if (header->sync != SPI_SYNC_BYTE || header->type != SPI_TYPE_CMD || + !spi_frame_valid(header, header->length)) { + memset(rx_buf, 0, sizeof(rx_buf)); + spi_slave_driver_queue(&rx_trans, NULL, rx_buf, SPI_FRAME_SIZE); continue; + } + + s_commands_processed++; spi_status_t status = SPI_STATUS_OK; uint8_t resp_payload[SPI_MAX_PAYLOAD]; uint8_t resp_len = 0; + bool tx_ready = false; // set when the case already built a complete tx_buf frame + size_t tx_size = SPI_FRAME_SIZE; // bytes the master will clock for the response + + uint16_t cmd = spi_header_cmd(header); + const uint8_t *cmd_payload = rx_buf + sizeof(spi_header_t); + + switch (header->category) { + case SPI_CAT_SYSTEM: + if (cmd == SPI_ID_SYSTEM_PING) { + status = SPI_STATUS_OK; + } else if (cmd == SPI_ID_SYSTEM_REBOOT) { + status = SPI_STATUS_OK; + s_is_restart_pending = true; + } else if (cmd == SPI_ID_SYSTEM_ENTER_DOWNLOAD) { + // Ack first, then reboot into ROM download mode after the response + // transfer completes (deferred, like reboot) so the P4 sees the OK. + status = SPI_STATUS_OK; + s_is_download_pending = true; + } else if (cmd == SPI_ID_SYSTEM_OTA_BEGIN) { + // Read {size, transport} and spawn the receiver task. Non-blocking: the + // P4 polls OTA_STATUS for READY, then sends the image over SPI (OTA_DATA) + // or UART depending on transport. + if (header->length >= sizeof(spi_ota_begin_t)) { + spi_ota_begin_t req; + memcpy(&req, cmd_payload, sizeof(req)); + status = (ota_service_begin(req.size, req.transport) == ESP_OK) ? SPI_STATUS_OK + : SPI_STATUS_ERROR; + } else { + status = SPI_STATUS_INVALID_ARG; + } + } else if (cmd == SPI_ID_SYSTEM_INFO) { + esp_chip_info_t ci; + esp_chip_info(&ci); + spi_sys_info_t info = {0}; + info.chip_model = (uint8_t)ci.model; + info.chip_revision = (uint16_t)ci.revision; + esp_read_mac(info.mac, ESP_MAC_WIFI_STA); + info.free_heap = esp_get_free_heap_size(); + memcpy(resp_payload, &info, sizeof(info)); + resp_len = sizeof(info); + status = SPI_STATUS_OK; + } else if (cmd == SPI_ID_SYSTEM_PROTO_VERSION) { + // Report our wire-protocol version so the P4 can detect a drifted + // spi_protocol.h copy at boot (see bridge_manager check_c5_protocol). + uint16_t proto = SPI_PROTOCOL_VERSION; + memcpy(resp_payload, &proto, sizeof(proto)); + resp_len = sizeof(proto); + status = SPI_STATUS_OK; + } else if (cmd == SPI_ID_SYSTEM_POWER_STATE) { + // P4 tells us the device power state so we can drop the radio when it + // is idle/asleep. A running capture (promiscuous) is never interrupted. + if (header->length >= 1) { + switch (cmd_payload[0]) { + case SPI_POWER_ACTIVE: + if (!wifi_service_is_active()) { + wifi_service_start(); + } + wifi_service_set_power_save(false); + break; + case SPI_POWER_IDLE: + wifi_service_set_power_save(true); + break; + case SPI_POWER_SLEEP: + if (!wifi_service_is_busy() && wifi_service_is_active()) { + wifi_service_stop(); + } + break; + default: + break; + } + status = SPI_STATUS_OK; + } else { + status = SPI_STATUS_INVALID_ARG; + } + } else if (cmd == SPI_ID_SYSTEM_OTA_DATA) { + // One firmware chunk. Buffered (fast) and acked at once; the writer + // task drains it to flash. BUSY means the writer is behind - the P4 + // retries the same chunk. + esp_err_t wr = ota_service_write(cmd_payload, header->length); + status = (wr == ESP_OK) ? SPI_STATUS_OK + : (wr == ESP_ERR_NO_MEM) ? SPI_STATUS_BUSY + : SPI_STATUS_ERROR; + } else if (cmd == SPI_ID_SYSTEM_OTA_STATUS) { + spi_ota_status_t ota_st; + ota_service_get_status(&ota_st); + memcpy(resp_payload, &ota_st, sizeof(ota_st)); + resp_len = sizeof(ota_st); + status = SPI_STATUS_OK; + } else if (cmd == SPI_ID_SYSTEM_VERSION) { + if (strcmp(s_firmware_version, "unknown") == 0) + load_firmware_version(); + size_t ver_len = strlen(s_firmware_version); + if (ver_len > (SPI_MAX_PAYLOAD - SPI_RESP_STATUS_SIZE)) + ver_len = (SPI_MAX_PAYLOAD - SPI_RESP_STATUS_SIZE); + memcpy(resp_payload, s_firmware_version, ver_len); + resp_len = (uint8_t)ver_len; + status = SPI_STATUS_OK; + } else if (cmd == SPI_ID_SYSTEM_STATUS) { + spi_system_status_t sys = {.wifi_active = wifi_service_is_active() ? 1 : 0, + .wifi_connected = wifi_service_is_connected() ? 1 : 0, + .bt_running = bluetooth_service_is_running() ? 1 : 0, + .bt_initialized = bluetooth_service_is_initialized() ? 1 : 0}; + memcpy(resp_payload, &sys, sizeof(sys)); + resp_len = sizeof(sys); + status = SPI_STATUS_OK; + } else if (cmd == SPI_ID_SYSTEM_DATA) { + uint16_t index; + if (header->length < sizeof(index)) { + status = SPI_STATUS_INVALID_ARG; + break; + } + memcpy(&index, cmd_payload, sizeof(index)); + uint16_t item_count = s_item_count_ptr != NULL ? *s_item_count_ptr : s_item_count; + + if (index == SPI_DATA_INDEX_COUNT) { + memcpy(resp_payload, &item_count, sizeof(item_count)); + resp_len = sizeof(item_count); + } else if (index == SPI_DATA_INDEX_STATS) { + spi_sniffer_stats_t stats = {.packets = wifi_sniffer_get_packet_count(), + .deauths = wifi_sniffer_get_deauth_count(), + .buffer_usage = wifi_sniffer_get_buffer_usage(), + .signal_rssi = signal_monitor_get_rssi(), + .handshake_captured = wifi_sniffer_handshake_captured(), + .pmkid_captured = wifi_sniffer_pmkid_captured()}; + wifi_sniffer_fill_ext_stats(&stats); + memcpy(resp_payload, &stats, sizeof(stats)); + resp_len = sizeof(stats); + } else if (index == SPI_DATA_INDEX_DEAUTH_COUNT) { + uint32_t deauth_count = deauther_detector_get_count(); + memcpy(resp_payload, &deauth_count, sizeof(deauth_count)); + resp_len = sizeof(deauth_count); + } else if (s_data_source != NULL && index < item_count) { + memcpy(resp_payload, (uint8_t *)s_data_source + (index * s_item_size), s_item_size); + resp_len = s_item_size; + } else { + status = SPI_STATUS_ERROR; + } + } else if (cmd == SPI_ID_SYSTEM_STREAM) { + // Batch as many queued records as fit into one large stream frame: + // [header type=STREAM][u16 batch_len][u16 op][u8 len][data]... + // The master always clocks SPI_STREAM_FRAME_SIZE for stream reads; + // batch_len = 0 means "no data" and the P4 just backs off. + uint8_t *recs = tx_buf + sizeof(spi_header_t) + sizeof(uint16_t); + uint16_t cap = SPI_STREAM_FRAME_SIZE - sizeof(spi_header_t) - sizeof(uint16_t); + uint16_t batch_len = 0; + uint16_t w; + while ((w = stream_pop_into(recs, batch_len, cap)) > 0) + batch_len += w; + + spi_header_t stream_header = { + .sync = SPI_SYNC_BYTE, .type = SPI_TYPE_STREAM, .category = 0, .op = 0, .length = 0}; + memcpy(tx_buf, &stream_header, sizeof(stream_header)); + tx_buf[sizeof(spi_header_t)] = (uint8_t)(batch_len & 0xFF); + tx_buf[sizeof(spi_header_t) + 1] = (uint8_t)((batch_len >> 8) & 0xFF); + spi_frame_seal((spi_header_t *)tx_buf, (uint16_t)(sizeof(uint16_t) + batch_len)); + tx_size = SPI_STREAM_FRAME_SIZE; + tx_ready = true; + } else { + status = SPI_STATUS_UNSUPPORTED; + } + break; + case SPI_CAT_SESSION: + if (cmd == SPI_ID_SESSION_HEARTBEAT) { + spi_heartbeat_req_t req = {0}; + if (header->length < sizeof(req)) { + status = SPI_STATUS_INVALID_ARG; + break; + } + memcpy(&req, cmd_payload, sizeof(req)); + bool alive = session_manager_heartbeat(req.session_id, req.last_acked_seq); + spi_heartbeat_resp_t resp = {.alive = alive ? (uint8_t)1 : (uint8_t)0}; + memcpy(resp_payload, &resp, sizeof(resp)); + resp_len = sizeof(resp); + status = SPI_STATUS_OK; + } else if (cmd == SPI_ID_SESSION_STOP) { + spi_session_stop_req_t req = {0}; + if (header->length < sizeof(req)) { + status = SPI_STATUS_INVALID_ARG; + break; + } + memcpy(&req, cmd_payload, sizeof(req)); + esp_err_t r = session_manager_stop(req.session_id); + status = (r == ESP_OK) ? SPI_STATUS_OK : SPI_STATUS_ERROR; + } else { + status = SPI_STATUS_UNSUPPORTED; + } + break; + case SPI_CAT_WIFI: + status = wifi_dispatcher_execute(cmd, cmd_payload, header->length, resp_payload, &resp_len); + break; + case SPI_CAT_BT: + case SPI_CAT_MCORE: + case SPI_CAT_HOST: + status = bt_dispatcher_execute(cmd, cmd_payload, header->length, resp_payload, &resp_len); + break; + case SPI_CAT_MESH: + // Meshtastic is split across dispatchers by transport: the WiFi + // transport ops live in the WiFi dispatcher, the rest (BLE, fromradio, + // log, status) in the BT dispatcher. + if (cmd == SPI_ID_MESH_WIFI_INIT || cmd == SPI_ID_MESH_WIFI_STOP) { + status = + wifi_dispatcher_execute(cmd, cmd_payload, header->length, resp_payload, &resp_len); + } else { + status = bt_dispatcher_execute(cmd, cmd_payload, header->length, resp_payload, &resp_len); + } + break; + default: + status = SPI_STATUS_UNSUPPORTED; + break; + } - if (header->id == SPI_ID_SYSTEM_PING) { - status = SPI_STATUS_OK; - } else if (header->id == SPI_ID_SYSTEM_REBOOT) { - status = SPI_STATUS_OK; - s_is_restart_pending = true; - } else if (header->id == SPI_ID_SYSTEM_VERSION) { - if (strcmp(s_firmware_version, "unknown") == 0) - load_firmware_version(); - size_t ver_len = strlen(s_firmware_version); - if (ver_len > (SPI_MAX_PAYLOAD - SPI_RESP_STATUS_SIZE)) - ver_len = (SPI_MAX_PAYLOAD - SPI_RESP_STATUS_SIZE); - memcpy(resp_payload, s_firmware_version, ver_len); - resp_len = (uint8_t)ver_len; - status = SPI_STATUS_OK; - } else if (header->id == SPI_ID_SYSTEM_STATUS) { - spi_system_status_t sys = {.wifi_active = wifi_service_is_active() ? 1 : 0, - .wifi_connected = wifi_service_is_connected() ? 1 : 0, - .bt_running = bluetooth_service_is_running() ? 1 : 0, - .bt_initialized = bluetooth_service_is_initialized() ? 1 : 0}; - memcpy(resp_payload, &sys, sizeof(sys)); - resp_len = sizeof(sys); - status = SPI_STATUS_OK; - } else if (header->id == SPI_ID_SYSTEM_DATA) { - uint16_t index; - memcpy(&index, rx_buf + sizeof(spi_header_t), sizeof(index)); - uint16_t item_count = s_item_count_ptr != NULL ? *s_item_count_ptr : s_item_count; - - if (index == SPI_DATA_INDEX_COUNT) { - memcpy(resp_payload, &item_count, sizeof(item_count)); - resp_len = sizeof(item_count); - } else if (index == SPI_DATA_INDEX_STATS) { - spi_sniffer_stats_t stats = {.packets = wifi_sniffer_get_packet_count(), - .deauths = wifi_sniffer_get_deauth_count(), - .buffer_usage = wifi_sniffer_get_buffer_usage(), - .signal_rssi = signal_monitor_get_rssi(), - .handshake_captured = wifi_sniffer_handshake_captured(), - .pmkid_captured = wifi_sniffer_pmkid_captured()}; - memcpy(resp_payload, &stats, sizeof(stats)); - resp_len = sizeof(stats); - } else if (index == SPI_DATA_INDEX_DEAUTH_COUNT) { - uint32_t deauth_count = deauther_detector_get_count(); - memcpy(resp_payload, &deauth_count, sizeof(deauth_count)); - resp_len = sizeof(deauth_count); - } else if (s_data_source != NULL && index < item_count) { - memcpy(resp_payload, (uint8_t *)s_data_source + (index * s_item_size), s_item_size); - resp_len = s_item_size; - } else { + if (!tx_ready) { + if (resp_len > (SPI_MAX_PAYLOAD - SPI_RESP_STATUS_SIZE)) { + resp_len = 0; status = SPI_STATUS_ERROR; } - } else if (header->id == SPI_ID_SYSTEM_STREAM) { - spi_id_t stream_id = 0; - uint8_t stream_len = 0; - if (stream_pop(&stream_id, resp_payload, &stream_len)) { - spi_header_t stream_header = { - .sync = SPI_SYNC_BYTE, .type = SPI_TYPE_STREAM, .id = stream_id, .length = stream_len}; - memset(tx_buf, 0, sizeof(tx_buf)); - memcpy(tx_buf, &stream_header, sizeof(stream_header)); - if (stream_len > 0) - memcpy(tx_buf + sizeof(stream_header), resp_payload, stream_len); - - spi_bridge_notify_master(); - spi_slave_driver_transmit(tx_buf, NULL, SPI_FRAME_SIZE); - if (s_is_restart_pending) { - vTaskDelay(pdMS_TO_TICKS(SPI_RESTART_DELAY_MS)); - esp_restart(); - } - continue; - } - status = SPI_STATUS_BUSY; - } else if (header->id == SPI_ID_SESSION_HEARTBEAT) { - spi_heartbeat_req_t req = {0}; - memcpy(&req, rx_buf + sizeof(spi_header_t), sizeof(req)); - bool alive = session_manager_heartbeat(req.session_id, req.last_acked_seq); - spi_heartbeat_resp_t resp = {.alive = alive ? (uint8_t)1 : (uint8_t)0}; - memcpy(resp_payload, &resp, sizeof(resp)); - resp_len = sizeof(resp); - status = SPI_STATUS_OK; - } else if (header->id == SPI_ID_SESSION_STOP) { - spi_session_stop_req_t req = {0}; - memcpy(&req, rx_buf + sizeof(spi_header_t), sizeof(req)); - esp_err_t r = session_manager_stop(req.session_id); - status = (r == ESP_OK) ? SPI_STATUS_OK : SPI_STATUS_ERROR; - } else if (header->id >= SPI_WIFI_CMD_MIN && header->id <= SPI_WIFI_CMD_MAX) { - status = wifi_dispatcher_execute( - header->id, rx_buf + sizeof(spi_header_t), header->length, resp_payload, &resp_len); - } else if (header->id >= SPI_BT_CMD_MIN && header->id <= SPI_BT_CMD_MAX) { - status = bt_dispatcher_execute( - header->id, rx_buf + sizeof(spi_header_t), header->length, resp_payload, &resp_len); - } else if ((header->id >= SPI_MESH_BT_CMD_MIN && header->id <= SPI_MESH_BT_CMD_MAX) || - (header->id >= SPI_MESH_BT_DATA_MIN && header->id <= SPI_MESH_BT_DATA_MAX)) { - status = bt_dispatcher_execute( - header->id, rx_buf + sizeof(spi_header_t), header->length, resp_payload, &resp_len); - } else if (header->id >= SPI_MCORE_CMD_MIN && header->id <= SPI_MCORE_CMD_MAX) { - status = bt_dispatcher_execute( - header->id, rx_buf + sizeof(spi_header_t), header->length, resp_payload, &resp_len); - } else if (header->id >= SPI_MESH_WIFI_CMD_MIN && header->id <= SPI_MESH_WIFI_CMD_MAX) { - status = wifi_dispatcher_execute( - header->id, rx_buf + sizeof(spi_header_t), header->length, resp_payload, &resp_len); - } else { - status = SPI_STATUS_UNSUPPORTED; - } - if (resp_len > (SPI_MAX_PAYLOAD - SPI_RESP_STATUS_SIZE)) { - resp_len = 0; - status = SPI_STATUS_ERROR; + spi_header_t resp_header = {.sync = SPI_SYNC_BYTE, + .type = SPI_TYPE_RESP, + .category = header->category, + .op = header->op, + .length = (uint8_t)(resp_len + SPI_RESP_STATUS_SIZE)}; + memset(tx_buf, 0, SPI_FRAME_SIZE); + memcpy(tx_buf, &resp_header, sizeof(resp_header)); + tx_buf[sizeof(resp_header)] = (uint8_t)status; + if (resp_len > 0) + memcpy(tx_buf + sizeof(resp_header) + SPI_RESP_STATUS_SIZE, resp_payload, resp_len); + spi_frame_seal((spi_header_t *)tx_buf, resp_header.length); } - spi_header_t resp_header = {.sync = SPI_SYNC_BYTE, - .type = SPI_TYPE_RESP, - .id = header->id, - .length = (uint8_t)(resp_len + SPI_RESP_STATUS_SIZE)}; - memset(tx_buf, 0, sizeof(tx_buf)); - memcpy(tx_buf, &resp_header, sizeof(resp_header)); - tx_buf[sizeof(resp_header)] = (uint8_t)status; - if (resp_len > 0) - memcpy(tx_buf + sizeof(resp_header) + SPI_RESP_STATUS_SIZE, resp_payload, resp_len); - + // Arm the response, signal the master, then immediately re-arm the next + // receive so the slave is ready before the response transfer even completes. + // tx_size is SPI_STREAM_FRAME_SIZE for a batched stream frame, else SPI_FRAME_SIZE. + spi_slave_driver_queue(&tx_trans, tx_buf, NULL, tx_size); spi_bridge_notify_master(); - spi_slave_driver_transmit(tx_buf, NULL, SPI_FRAME_SIZE); + memset(rx_buf, 0, sizeof(rx_buf)); + spi_slave_driver_queue(&rx_trans, NULL, rx_buf, SPI_FRAME_SIZE); + spi_slave_driver_wait(); // wait for the response transfer to complete + + if (s_is_download_pending) { + enter_download_mode(); + } if (s_is_restart_pending) { vTaskDelay(pdMS_TO_TICKS(SPI_RESTART_DELAY_MS)); esp_restart(); diff --git a/firmware_c5/components/Service/spi_bridge/wifi_dispatcher.c b/firmware_c5/components/Service/spi_bridge/wifi_dispatcher.c index ebe64119e..75ecdeed8 100644 --- a/firmware_c5/components/Service/spi_bridge/wifi_dispatcher.c +++ b/firmware_c5/components/Service/spi_bridge/wifi_dispatcher.c @@ -40,12 +40,15 @@ static const char *TAG = "WIFI_DISPATCHER"; +// Compact scan results for the companion app (SPI_ID_WIFI_APP_SCAN_AP). Built +// from the raw scan once, then served through the generic data pipe. +static spi_wifi_scan_record_t s_app_scan_records[WIFI_SCAN_LIST_SIZE]; + #define WIFI_SSID_MAX_LEN 32 #define WIFI_PASSWORD_MAX_LEN 64 #define WIFI_IP_ADDR_MAX_LEN 15 #define WIFI_DEAUTHER_MIN_PAYLOAD 13 #define WIFI_FLOOD_MIN_PAYLOAD 7 -#define WIFI_SNIFFER_MIN_PAYLOAD 2 #define WIFI_ASSOC_MIN_PAYLOAD 8 #define WIFI_DEAUTH_FRAME_MIN 8 #define WIFI_TARGET_MIN_PAYLOAD 7 @@ -113,6 +116,55 @@ static spi_status_t open_session(spi_id_t op_id, return SPI_STATUS_OK; } +// Scan work functions run by the shared async runner (spi_bridge_async_scan_start). +// Each does the blocking scan and provides the results; the SPI handler returns +// immediately so the bridge stays free. +static void scan_fn_wifi_scan(void) { + wifi_service_scan(); + spi_bridge_provide_results( + wifi_service_get_ap_record(0), wifi_service_get_ap_count(), sizeof(wifi_ap_record_t)); +} + +static void scan_fn_app_ap(void) { + wifi_service_scan(); + uint16_t count = wifi_service_get_ap_count(); + if (count > WIFI_SCAN_LIST_SIZE) + count = WIFI_SCAN_LIST_SIZE; + for (uint16_t i = 0; i < count; i++) { + spi_wifi_scan_record_t *rec = &s_app_scan_records[i]; + memset(rec, 0, sizeof(*rec)); + const wifi_ap_record_t *ap = wifi_service_get_ap_record(i); + if (ap == NULL) + continue; + memcpy(rec->bssid, ap->bssid, sizeof(rec->bssid)); + rec->rssi = ap->rssi; + rec->channel = ap->primary; + rec->authmode = (uint8_t)ap->authmode; + size_t j = 0; + for (; j < sizeof(rec->ssid) - 1 && ap->ssid[j] != '\0'; j++) { + uint8_t c = ap->ssid[j]; + rec->ssid[j] = (c < 0x20 || c > 0x7E) ? '?' : c; + } + rec->ssid[j] = '\0'; + } + spi_bridge_provide_results(s_app_scan_records, count, sizeof(spi_wifi_scan_record_t)); +} + +static void scan_fn_app_client(void) { + if (!client_scanner_start()) + return; + const TickType_t start = xTaskGetTickCount(); + const TickType_t timeout = pdMS_TO_TICKS(WIFI_CLIENT_SCAN_TIMEOUT); + uint16_t count = 0; + client_scanner_record_t *results = NULL; + while ((results = client_scanner_get_results(&count)) == NULL) { + if ((xTaskGetTickCount() - start) > timeout) + return; + vTaskDelay(pdMS_TO_TICKS(CLIENT_SCAN_POLL_DELAY_MS)); + } + spi_bridge_provide_results(results, count, sizeof(client_scanner_record_t)); +} + spi_status_t wifi_dispatcher_execute(spi_id_t id, const uint8_t *payload, uint8_t len, @@ -123,9 +175,11 @@ spi_status_t wifi_dispatcher_execute(spi_id_t id, switch (id) { case SPI_ID_WIFI_SCAN: - wifi_service_scan(); - spi_bridge_provide_results( - wifi_service_get_ap_record(0), wifi_service_get_ap_count(), sizeof(wifi_ap_record_t)); + return spi_bridge_async_scan_start(scan_fn_wifi_scan) ? SPI_STATUS_OK : SPI_STATUS_BUSY; + + case SPI_ID_WIFI_SCAN_STATUS: + out_resp_payload[0] = spi_bridge_async_scan_busy() ? 1 : 0; + *out_resp_len = 1; return SPI_STATUS_OK; case SPI_ID_WIFI_CONNECT: { @@ -229,28 +283,10 @@ spi_status_t wifi_dispatcher_execute(spi_id_t id, return SPI_STATUS_OK; case SPI_ID_WIFI_APP_SCAN_AP: - wifi_service_scan(); - spi_bridge_provide_results( - wifi_service_get_ap_record(0), wifi_service_get_ap_count(), sizeof(wifi_ap_record_t)); - return SPI_STATUS_OK; + return spi_bridge_async_scan_start(scan_fn_app_ap) ? SPI_STATUS_OK : SPI_STATUS_BUSY; case SPI_ID_WIFI_APP_SCAN_CLIENT: - if (!client_scanner_start()) - return SPI_STATUS_BUSY; - { - const TickType_t start = xTaskGetTickCount(); - const TickType_t timeout = pdMS_TO_TICKS(WIFI_CLIENT_SCAN_TIMEOUT); - uint16_t count = 0; - client_scanner_record_t *results = NULL; - while ((results = client_scanner_get_results(&count)) == NULL) { - if ((xTaskGetTickCount() - start) > timeout) { - return SPI_STATUS_BUSY; - } - vTaskDelay(pdMS_TO_TICKS(CLIENT_SCAN_POLL_DELAY_MS)); - } - spi_bridge_provide_results(results, count, sizeof(client_scanner_record_t)); - return SPI_STATUS_OK; - } + return spi_bridge_async_scan_start(scan_fn_app_client) ? SPI_STATUS_OK : SPI_STATUS_BUSY; case SPI_ID_WIFI_APP_BEACON_SPAM: { bool ok; @@ -313,14 +349,19 @@ spi_status_t wifi_dispatcher_execute(spi_id_t id, } case SPI_ID_WIFI_APP_SNIFFER: { - if (len < WIFI_SNIFFER_MIN_PAYLOAD) - return SPI_STATUS_ERROR; + // The companion app's live view wants raw frames across every channel. If + // it omits the args, default to RAW + channel 0 (hopping) instead of + // rejecting, so an empty START still streams something useful. + // payload[0]: sniffer type, payload[1]: channel (0 = hop all). // payload[2] (optional): monitor_mode flag — when set, buffer recycles // on overflow and packet counter keeps growing (used by Packet Monitor). + wifi_sniffer_type_t type = + (len >= 1) ? (wifi_sniffer_type_t)payload[0] : WIFI_SNIFFER_TYPE_RAW; + uint8_t channel = (len >= 2) ? payload[1] : 0; bool monitor_mode = (len >= 3) && (payload[2] != 0); wifi_sniffer_set_monitor_mode(monitor_mode); spi_bridge_stream_enable(SPI_ID_WIFI_APP_SNIFFER, true); - if (!wifi_sniffer_start((wifi_sniffer_type_t)payload[0], payload[1])) { + if (!wifi_sniffer_start(type, channel)) { spi_bridge_stream_enable(SPI_ID_WIFI_APP_SNIFFER, false); return SPI_STATUS_ERROR; } diff --git a/firmware_c5/components/Service/storage_api/README.md b/firmware_c5/components/Service/storage_api/README.md index 227b42b36..8103f04d8 100644 --- a/firmware_c5/components/Service/storage_api/README.md +++ b/firmware_c5/components/Service/storage_api/README.md @@ -1,449 +1,7 @@ # Storage API -The **Storage API** provides a unified, backend-agnostic interface for file system operations in the Highboy project. It abstracts the underlying storage mechanism (LittleFS, SD Card, etc.), allowing developers to perform file and directory operations using a consistent set of functions without worrying about low-level details or mount points. +Documentation for this component lives in the project docs hub (single source of truth): -## Features +- [docs/storage_api/README.md#c5](../../../../docs/storage_api/README.md#c5) -- **Unified Interface**: Same API for internal flash (LittleFS) and external SD cards. -- **Backend Abstraction**: Uses VFS layer underneath, works with any configured backend. -- **Automatic Path Resolution**: Automatically handles mount points - use relative paths. -- **Robustness**: Includes safety checks, recursive directory creation, and error handling. -- **High-Level Helpers**: Easy reading/writing of strings, lines, formatted text, and CSV data. - ---- - -## Architecture - -``` -Application Code - ↓ - Storage API ← You are here (recommended layer) - ↓ - VFS Core ← Backend abstraction - ↓ - SD Card / LittleFS / SPIFFS -``` - -**Dependencies:** -- Requires `vfs_core` to be initialized -- Backend selection is done in `vfs_config.h` - ---- - -## Initialization - -Before performing any operations, the storage system must be initialized. - -```c -#include "storage_init.h" - -// Initialize the storage system -// This calls vfs_init_auto() internally -esp_err_t ret = storage_init(); -if (ret != ESP_OK) { - // Handle error -} - -// Check if mounted -if (storage_is_mounted()) { - // Ready to use -} - -// Deinitialize when done (rarely needed for main application) -storage_deinit(); -``` - -### Default Directory Structure - -The storage system automatically creates a standard directory tree on initialization: - -``` -/ (e.g., /sdcard or /littlefs) -├── config/ - Configuration files -├── data/ - Application data -├── logs/ - Log files -├── cache/ - Temporary cache -├── temp/ - Temporary files -├── backup/ - Backup files -├── certs/ - SSL/TLS certificates -├── scripts/ - Script files -└── captive_portal/ - Captive portal files -``` - -These directories are defined in `storage_dirs.h` and can be accessed via macros: - -```c -#include "storage_dirs.h" - -// Macros automatically include the mount point -// Example: STORAGE_DIR_CONFIG expands to "/sdcard/config" or "/littlefs/config" - -// Write to config directory -storage_write_string(STORAGE_DIR_CONFIG "/settings.json", json_data); - -// Append to logs -storage_append_formatted(STORAGE_DIR_LOGS "/system.log", "[%lu] Event\n", timestamp); - -// Save backup -storage_file_copy(STORAGE_DIR_DATA "/important.dat", STORAGE_DIR_BACKUP "/important.dat"); -``` - -**Path Handling:** -- All Storage API functions accept **relative paths** (e.g., `/config/file.txt`) -- Mount point is automatically prepended internally -- You can use either `"/config/file.txt"` or `STORAGE_DIR_CONFIG "/file.txt"` -- Paths starting with `/` are treated as relative to mount point -- Paths already containing the mount point are used as-is - -**Note**: Directory creation is non-critical. If any directory fails to create, initialization continues successfully, and you can create directories manually later as needed. - ---- - -## File Operations - -Header: `storage_impl.h` - -### Basic Management - -| Function | Description | -|----------|-------------| -| `bool storage_file_exists(const char *path)` | Checks if a file exists. | -| `esp_err_t storage_file_delete(const char *path)` | Deletes a file. | -| `esp_err_t storage_file_rename(const char *old, const char *new)` | Renames or moves a file. | -| `esp_err_t storage_file_copy(const char *src, const char *dst)` | Copies a file. | -| `esp_err_t storage_file_move(const char *src, const char *dst)` | Moves a file (same as rename). | -| `esp_err_t storage_file_clear(const char *path)` | Clears file content (truncates to 0). | -| `esp_err_t storage_file_truncate(const char *path, size_t size)` | Truncates file to specified size. | -| `esp_err_t storage_file_compare(const char *p1, const char *p2, bool *equal)` | Compares two files for equality. | - -### Information - -```c -// File information structure -typedef struct { - char path[256]; // Full path to file - size_t size; // File size in bytes - time_t modified_time; // Last modification time (Unix timestamp) - time_t created_time; // Creation time (Unix timestamp) - bool is_directory; // True if this is a directory - bool is_hidden; // True if hidden file - bool is_readonly; // True if read-only -} storage_file_info_t; -``` - -| Function | Description | -|----------|-------------| -| `esp_err_t storage_file_get_size(const char *path, size_t *size)` | Gets file size in bytes. | -| `esp_err_t storage_file_is_empty(const char *path, bool *empty)` | Checks if a file is empty. | -| `esp_err_t storage_file_get_info(const char *path, storage_file_info_t *info)` | Gets detailed info (size, times, attributes). | -| `esp_err_t storage_file_get_extension(const char *path, char *ext, size_t size)` | Extracts file extension. | - ---- - -## Reading Data - -Header: `storage_read.h` - -The API provides various ways to read data depending on your needs. - -### Strings & Binary - -```c -// Read entire file into a string buffer (null-terminated) -char buffer[128]; -storage_read_string("/config/settings.txt", buffer, sizeof(buffer)); - -// Read binary data -uint8_t data[64]; -size_t bytes_read; -storage_read_binary("/data/image.bin", data, sizeof(data), &bytes_read); - -// Read chunk from specific offset -storage_read_chunk("/data/large.bin", 1024, data, sizeof(data), &bytes_read); -``` - -### Line-by-Line - -```c -// Read specific line (1-based index) -char line[64]; -storage_read_line("/logs/system.log", line, sizeof(line), 5); - -// Read first/last line helpers -storage_read_first_line("/logs/system.log", line, sizeof(line)); -storage_read_last_line("/logs/system.log", line, sizeof(line)); - -// Iterate over all lines using a callback -void my_line_callback(const char *line, void *user_data) { - printf("Read line: %s\n", line); -} -storage_read_lines("/data/list.txt", my_line_callback, NULL); - -// Count lines in file -uint32_t count; -storage_count_lines("/data/list.txt", &count); -``` - -### Typed Data - -```c -int32_t count; -storage_read_int("/config/boot_count", &count); - -float temperature; -storage_read_float("/config/temp_threshold", &temperature); - -uint8_t byte; -storage_read_byte("/data/flag", &byte); - -uint8_t bytes[16]; -size_t num_bytes; -storage_read_bytes("/data/raw", bytes, sizeof(bytes), &num_bytes); -``` - -### Search Operations - -```c -// Check if file contains a string -bool found; -storage_file_contains("/logs/events.log", "ERROR", &found); - -// Count occurrences of a string -uint32_t count; -storage_count_occurrences("/logs/events.log", "WARNING", &count); -``` - ---- - -## Writing Data - -Header: `storage_write.h` - -All write functions automatically create parent directories if they don't exist (recursive mkdir). - -### Strings & Binary - -```c -// Write (overwrite) a string to a file -storage_write_string("/data/status.txt", "System Ready"); - -// Append to a file -storage_append_string("/logs/app.log", "Event occurred"); - -// Write binary data -uint8_t raw_data[] = {0x01, 0x02, 0x03}; -storage_write_binary("/data/blob.bin", raw_data, sizeof(raw_data)); - -// Append binary data -storage_append_binary("/data/stream.bin", raw_data, sizeof(raw_data)); -``` - -### Line-Based Writing - -```c -// Write single line with newline -storage_write_line("/data/entry.txt", "First entry"); - -// Append line with newline -storage_append_line("/logs/events.log", "Event occurred at 12:00"); -``` - -### Formatted Output - -Similar to `printf`, useful for logs or human-readable data. - -```c -storage_write_formatted("/logs/info.txt", "Boot count: %d\nTime: %u", count, timestamp); -storage_append_formatted("/logs/events.log", "[INFO] Sensor %s: %.2f\n", sensor_name, value); -``` - -### Typed Data - -```c -// Write integer -storage_write_int("/config/counter", 42); - -// Write float -storage_write_float("/config/threshold", 3.14159); - -// Write single byte -storage_write_byte("/data/flag", 0xFF); - -// Write byte array -uint8_t data[] = {0xDE, 0xAD, 0xBE, 0xEF}; -storage_write_bytes("/data/magic", data, sizeof(data)); -``` - -### CSV Support - -Helper for writing structured data. - -```c -const char *header[] = {"Timestamp", "Value", "Unit"}; -storage_write_csv_row("/data/sensors.csv", header, 3); -// Writes: Timestamp,Value,Unit\n - -const char *row[] = {"1234567890", "23.5", "°C"}; -storage_append_csv_row("/data/sensors.csv", row, 3); -// Appends: 1234567890,23.5,°C\n -``` - ---- - -## Directory Operations - -Header: `storage_impl.h` - -| Function | Description | -|----------|-------------| -| `esp_err_t storage_dir_create(const char *path)` | Creates a directory. | -| `esp_err_t storage_dir_remove(const char *path)` | Removes an empty directory. | -| `esp_err_t storage_dir_remove_recursive(const char *path)` | Removes a directory and all contents. | -| `bool storage_dir_exists(const char *path)` | Checks if directory exists. | -| `esp_err_t storage_dir_is_empty(const char *path, bool *empty)` | Checks if directory is empty. | -| `esp_err_t storage_dir_list(const char *path, storage_dir_callback_t cb, void *user_data)` | Lists directory contents via callback. | -| `esp_err_t storage_dir_count(const char *path, uint32_t *file_count, uint32_t *dir_count)` | Counts files and subdirectories. | - -**Note**: `storage_dir_copy_recursive()` and `storage_dir_get_size()` return `ESP_ERR_NOT_SUPPORTED` (not yet implemented). - -### Directory Listing Example - -```c -void list_callback(const char *name, bool is_dir, void *user_data) { - printf("%s %s\n", is_dir ? "[DIR]" : "[FILE]", name); -} - -storage_dir_list("/data", list_callback, NULL); -``` - ---- - -## Storage Information - -Header: `storage_impl.h` - -Monitor storage usage and health. - -```c -// Print detailed usage report to log -storage_print_info_detailed(); - -// Get complete storage information -storage_info_t info; -storage_get_info(&info); -printf("Backend: %s\n", info.backend_name); -printf("Mount: %s\n", info.mount_point); -printf("Total: %llu bytes\n", info.total_bytes); - -// Get individual values -uint64_t total, free, used; -storage_get_total_space(&total); -storage_get_free_space(&free); -storage_get_used_space(&used); - -// Get usage percentage -float percent; -storage_get_usage_percent(&percent); - -// Get backend information -const char *backend = storage_get_backend_type(); -const char *mount = storage_get_mount_point_str(); -``` - ---- - -## Helper Functions - -Header: `storage_mkdir.h` - -```c -// Create directory path recursively (used internally by write functions) -esp_err_t storage_mkdir_recursive(const char *path); -``` - -This function creates all parent directories as needed. It's automatically called by write operations, but can be used directly when needed. - ---- - -## Example Usage - -```c -#include "storage_init.h" -#include "storage_impl.h" -#include "storage_read.h" -#include "storage_write.h" -#include "storage_dirs.h" - -void app_main() { - // Initialize storage (calls vfs_init_auto internally) - if (storage_init() != ESP_OK) { - printf("Storage init failed!\n"); - return; - } - - // Check for config file - if (storage_file_exists(STORAGE_DIR_CONFIG "/settings.json")) { - char config[1024]; - storage_read_string(STORAGE_DIR_CONFIG "/settings.json", config, sizeof(config)); - // Process config... - } else { - // Create default config - storage_write_string(STORAGE_DIR_CONFIG "/settings.json", "{ \"defaults\": true }"); - } - - // Log startup event with timestamp - storage_append_formatted(STORAGE_DIR_LOGS "/boot.log", - "System started at %lu\n", xTaskGetTickCount()); - - // Write sensor data to CSV - const char *header[] = {"Time", "Temp", "Humidity"}; - storage_write_csv_row(STORAGE_DIR_DATA "/sensors.csv", header, 3); - - const char *data[] = {"12:00", "23.5", "65"}; - storage_append_csv_row(STORAGE_DIR_DATA "/sensors.csv", data, 3); - - // Check storage health - float usage; - storage_get_usage_percent(&usage); - printf("Storage usage: %.1f%%\n", usage); - - // List directory contents - uint32_t files, dirs; - storage_dir_count(STORAGE_DIR_DATA, &files, &dirs); - printf("Data directory: %lu files, %lu subdirectories\n", files, dirs); -} -``` - ---- - -## Best Practices - -1. **Always use relative paths** - Let the API handle mount points -2. **Use directory macros** - `STORAGE_DIR_CONFIG` instead of hardcoded `"/config"` -3. **Check return values** - All functions return `esp_err_t` for error handling -4. **Monitor storage** - Use `storage_get_usage_percent()` to prevent full disk -5. **Use appropriate read functions** - Line-by-line for logs, binary for images -6. **Automatic directory creation** - Write functions create parent directories automatically -7. **Path flexibility** - Relative paths (`/config/file.txt`) or full mount paths both work - ---- - -## Error Handling - -All functions return `esp_err_t` values. Common return codes: - -- `ESP_OK` - Operation successful -- `ESP_ERR_INVALID_ARG` - Invalid argument (NULL pointer, invalid size) -- `ESP_ERR_INVALID_STATE` - Storage not mounted -- `ESP_FAIL` - General failure (file not found, I/O error, etc.) -- `ESP_ERR_NOT_FOUND` - Item not found (used by some search functions) -- `ESP_ERR_NOT_SUPPORTED` - Feature not implemented - -Always check return values: - -```c -esp_err_t ret = storage_write_string("/config/test.txt", "data"); -if (ret != ESP_OK) { - ESP_LOGE(TAG, "Write failed: %s", esp_err_to_name(ret)); -} -``` \ No newline at end of file +This file is only a pointer: edit the docs there to keep things from drifting. diff --git a/firmware_c5/components/Service/storage_api/include/tos_flash_paths.h b/firmware_c5/components/Service/storage_api/include/tos_flash_paths.h index 08641a6b0..9b3996228 100644 --- a/firmware_c5/components/Service/storage_api/include/tos_flash_paths.h +++ b/firmware_c5/components/Service/storage_api/include/tos_flash_paths.h @@ -20,7 +20,10 @@ extern "C" { #endif -#define FLASH_MOUNT "/assets" +#include "vfs_config.h" + +// Flash storage lives on the active VFS backend (LittleFS, 'storage' partition). +#define FLASH_MOUNT VFS_MOUNT_POINT // Config paths (read-only defaults) #define FLASH_CONFIG_WIFI_AP FLASH_MOUNT "/config/wifi/wifi_ap.conf" diff --git a/firmware_c5/components/Service/storage_assets/README.md b/firmware_c5/components/Service/storage_assets/README.md index 0eb3d7cff..475c15e70 100644 --- a/firmware_c5/components/Service/storage_assets/README.md +++ b/firmware_c5/components/Service/storage_assets/README.md @@ -1,621 +1,7 @@ # Storage Assets Component -This component provides read-only access to a dedicated LittleFS partition for storing static application assets like images, fonts, configuration files, and other resources that are flashed with the firmware. +Documentation for this component lives in the project docs hub (single source of truth): -## Overview +- [docs/storage_assets/README.md#c5](../../../../docs/storage_assets/README.md#c5) -- **Location:** `components/storage/storage_assets/` -- **Main Header:** `include/storage_assets.h` -- **Implementation:** `storage_assets.c` -- **Dependencies:** `esp_littlefs`, `esp_vfs` -- **Partition:** `assets` (LittleFS, read-only in production) - -## Key Features - -- **Dedicated Partition:** Separate from application code and main storage. -- **LittleFS Backend:** Efficient wear-leveling filesystem optimized for flash. -- **Read-Only Access:** Assets are flashed once and cannot be modified at runtime. -- **Auto-Discovery:** Automatically lists all files in partition on initialization. -- **Memory Management:** Helper function to load entire files with automatic allocation. -- **Directory Traversal:** Recursive directory listing for debugging. - -## Typical Use Cases - -- **Graphical Assets:** Logos, icons, sprites, bitmaps for displays. -- **Fonts:** Pre-compiled font files for text rendering. -- **Configuration Templates:** Default configuration files. -- **Audio Samples:** Short sound effects or melodies. -- **IR/RF Databases:** Preloaded signal databases. -- **Firmware Resources:** Any read-only data needed by the application. - -## Configuration - -### Partition Table - -The assets partition must be defined in your partition table (`partitions.csv`): - -```csv -# Name, Type, SubType, Offset, Size, Flags -nvs, data, nvs, 0x9000, 0x6000, -phy_init, data, phy, 0xf000, 0x1000, -factory, app, factory, 0x10000, 1M, -assets, data, spiffs, 0x110000, 512K, -storage, data, spiffs, 0x190000, 1M, -``` - -**Important Notes:** -- The SubType must be `spiffs` (even though we use LittleFS - this is an ESP-IDF quirk). -- Size should be sufficient for all your assets (adjust as needed). -- The partition must be flashed before use. - -### Constants - -```c -#define ASSETS_MOUNT_POINT "/assets" -#define ASSETS_PARTITION_LABEL "assets" -``` - -These are defined internally and cannot be changed without modifying the source. - ---- - -## API Reference - -### Initialization - -#### `storage_assets_init` - -```c -esp_err_t storage_assets_init(void); -``` - -Initializes and mounts the assets partition. Must be called before any other asset operations. - -**Behavior:** -- Mounts the LittleFS partition at `/assets`. -- Formats the partition if mounting fails (useful for first flash). -- Lists all files in the partition for debugging. -- Displays partition size and usage statistics. - -**Returns:** -- `ESP_OK` - Assets partition mounted successfully. -- `ESP_ERR_NOT_FOUND` - Partition 'assets' not found in partition table. -- `ESP_FAIL` - Mount or format failed. -- `ESP_ERR_INVALID_STATE` - Already initialized. - -**Example:** -```c -void app_main(void) { - esp_err_t ret = storage_assets_init(); - if (ret == ESP_OK) { - printf("Assets ready!\n"); - } else if (ret == ESP_ERR_NOT_FOUND) { - printf("ERROR: 'assets' partition not found!\n"); - printf("Check your partition table.\n"); - } else { - printf("Assets init failed: %s\n", esp_err_to_name(ret)); - } -} -``` - -**Console Output Example:** -``` -I (1234) storage_assets: Initializing LittleFS for assets partition -I (1245) storage_assets: Assets ready at /assets -I (1246) storage_assets: Partition size: 524288 bytes, used: 12345 bytes -I (1247) storage_assets: === Files in assets partition === -I (1248) storage_assets: [1] logo.bin (1200 bytes) -I (1249) storage_assets: [DIR] fonts/ -I (1250) storage_assets: [2] arial.ttf (45000 bytes) -I (1251) storage_assets: [3] config_template.json (567 bytes) -I (1252) storage_assets: Total: 3 file(s), 1 dir(s) -I (1253) storage_assets: ================================ -``` - ---- - -#### `storage_assets_deinit` - -```c -esp_err_t storage_assets_deinit(void); -``` - -Unmounts the assets partition and releases resources. - -**Returns:** -- `ESP_OK` - Unmounted successfully. -- `ESP_ERR_INVALID_STATE` - Not initialized. - -**Example:** -```c -// Before system shutdown -storage_assets_deinit(); -``` - ---- - -#### `storage_assets_is_mounted` - -```c -bool storage_assets_is_mounted(void); -``` - -Checks if the assets partition is currently mounted. - -**Returns:** -- `true` - Partition is mounted and ready. -- `false` - Partition is not mounted. - -**Example:** -```c -if (!storage_assets_is_mounted()) { - storage_assets_init(); -} -``` - ---- - -### File Access - -#### `storage_assets_get_file_size` - -```c -esp_err_t storage_assets_get_file_size(const char *filename, size_t *out_size); -``` - -Gets the size of a file in the assets partition without reading it. - -**Parameters:** -- `filename` - Name of the file (e.g., "logo.bin", "fonts/arial.ttf"). -- `out_size` - Pointer to store file size in bytes. - -**Returns:** -- `ESP_OK` - Size retrieved successfully. -- `ESP_ERR_INVALID_STATE` - Assets not initialized. -- `ESP_ERR_INVALID_ARG` - NULL parameters. -- `ESP_ERR_NOT_FOUND` - File doesn't exist. - -**Example:** -```c -size_t logo_size; -if (storage_assets_get_file_size("logo.bin", &logo_size) == ESP_OK) { - printf("Logo is %zu bytes\n", logo_size); - - // Allocate buffer of exact size - uint8_t *buffer = malloc(logo_size); -} -``` - ---- - -#### `storage_assets_read_file` - -```c -esp_err_t storage_assets_read_file(const char *filename, uint8_t *buffer, size_t size, size_t *out_read); -``` - -Reads file content into a pre-allocated buffer. - -**Parameters:** -- `filename` - Name of the file. -- `buffer` - Pre-allocated buffer to receive data. -- `size` - Maximum bytes to read (buffer size). -- `out_read` - Pointer to store actual bytes read (can be NULL). - -**Returns:** -- `ESP_OK` - File read successfully. -- `ESP_ERR_INVALID_STATE` - Assets not initialized. -- `ESP_ERR_INVALID_ARG` - Invalid parameters. -- `ESP_ERR_NOT_FOUND` - File doesn't exist. - -**Example:** -```c -uint8_t buffer[2048]; -size_t bytes_read; - -esp_err_t ret = storage_assets_read_file("config.json", buffer, sizeof(buffer), &bytes_read); -if (ret == ESP_OK) { - buffer[bytes_read] = '\0'; // Null-terminate if text - printf("Config: %s\n", (char *)buffer); -} else { - printf("Failed to read config: %s\n", esp_err_to_name(ret)); -} -``` - ---- - -#### `storage_assets_load_file` - -```c -uint8_t* storage_assets_load_file(const char *filename, size_t *out_size); -``` - -Loads an entire file into dynamically allocated memory. **Caller must free() the returned pointer.** - -**Parameters:** -- `filename` - Name of the file. -- `out_size` - Pointer to store file size (can be NULL). - -**Returns:** -- Pointer to allocated buffer containing file data. -- `NULL` on error (allocation failure, file not found, etc.). - -**Example:** -```c -size_t image_size; -uint8_t *image_data = storage_assets_load_file("splash_screen.bin", &image_size); - -if (image_data != NULL) { - // Use the image data - display_draw_bitmap(image_data, image_size); - - // IMPORTANT: Free when done! - free(image_data); -} else { - printf("Failed to load splash screen\n"); -} -``` - -**Memory Warning:** This function allocates heap memory. Ensure sufficient heap is available before loading large files. - ---- - -### Utility Functions - -#### `storage_assets_get_mount_point` - -```c -const char* storage_assets_get_mount_point(void); -``` - -Returns the mount point path for the assets partition. - -**Returns:** -- Constant string "/assets". - -**Example:** -```c -const char *mount = storage_assets_get_mount_point(); - -// Construct full path -char full_path[128]; -snprintf(full_path, sizeof(full_path), "%s/%s", mount, "config.json"); - -// Use with standard file operations -FILE *f = fopen(full_path, "r"); -``` - ---- - -#### `storage_assets_print_info` - -```c -void storage_assets_print_info(void); -``` - -Prints detailed information about the assets partition to the console. - -**Parameters:** None - -**Returns:** Nothing (void) - -**Example Output:** -``` -I (1234) storage_assets: === Assets Partition Info === -I (1235) storage_assets: Mount point: /assets -I (1236) storage_assets: Partition: assets -I (1237) storage_assets: Total size: 524288 bytes (512.00 KB) -I (1238) storage_assets: Used: 98765 bytes (96.45 KB) -I (1239) storage_assets: Free: 425523 bytes (415.55 KB) -I (1240) storage_assets: Usage: 18.8% -``` - -**Usage:** -```c -// During debugging or diagnostics -storage_assets_print_info(); -``` - ---- - -## Implementation Details - -### Directory Listing - -The component includes a recursive directory listing function that runs automatically during initialization: - -```c -static void list_directory_recursive(const char *path, const char *prefix, - int *file_count, int *dir_count); -``` - -This helps during development to verify that assets were flashed correctly. - -### Path Handling - -All file operations internally prepend the mount point: - -```c -// User provides: "logo.bin" -// Internally becomes: "/assets/logo.bin" -``` - -Subdirectories are supported: -```c -// User provides: "fonts/arial.ttf" -// Internally becomes: "/assets/fonts/arial.ttf" -``` - -### Error Handling - -All functions validate: -- Initialization state -- Parameter validity -- File existence -- Memory allocation success - -Always check return values to ensure robust operation. - ---- - -## Usage Patterns - -### Loading a Bitmap for Display - -```c -void display_splash_screen(void) { - size_t image_size; - uint8_t *image = storage_assets_load_file("splash.bin", &image_size); - - if (image == NULL) { - ESP_LOGE(TAG, "Failed to load splash screen"); - return; - } - - // Expected format: 128x64 monochrome bitmap - if (image_size != (128 * 64) / 8) { - ESP_LOGW(TAG, "Unexpected image size: %zu", image_size); - } - - // Send to display - oled_draw_bitmap(0, 0, image, 128, 64); - - // Clean up - free(image); -} -``` - ---- - -### Loading Configuration Template - -```c -cJSON* load_default_config(void) { - uint8_t *json_data = storage_assets_load_file("config_template.json", NULL); - if (json_data == NULL) { - return NULL; - } - - cJSON *config = cJSON_Parse((const char *)json_data); - free(json_data); - - return config; -} -``` - ---- - -### Preloading Assets at Boot - -```c -typedef struct { - uint8_t *logo_data; - size_t logo_size; - uint8_t *font_data; - size_t font_size; -} app_assets_t; - -app_assets_t g_assets = {0}; - -esp_err_t preload_assets(void) { - // Load logo - g_assets.logo_data = storage_assets_load_file("logo.bin", &g_assets.logo_size); - if (g_assets.logo_data == NULL) { - return ESP_FAIL; - } - - // Load font - g_assets.font_data = storage_assets_load_file("font.bin", &g_assets.font_size); - if (g_assets.font_data == NULL) { - free(g_assets.logo_data); - return ESP_FAIL; - } - - ESP_LOGI(TAG, "Assets preloaded (%zu + %zu bytes)", - g_assets.logo_size, g_assets.font_size); - - return ESP_OK; -} - -void cleanup_assets(void) { - free(g_assets.logo_data); - free(g_assets.font_data); - memset(&g_assets, 0, sizeof(g_assets)); -} -``` - ---- - -### Chunked Reading for Large Files - -```c -esp_err_t process_large_asset(const char *filename) { - FILE *f = fopen("/assets/large_file.dat", "rb"); - if (!f) { - return ESP_FAIL; - } - - uint8_t chunk[512]; - size_t bytes_read; - - while ((bytes_read = fread(chunk, 1, sizeof(chunk), f)) > 0) { - // Process chunk - process_data(chunk, bytes_read); - } - - fclose(f); - return ESP_OK; -} -``` - ---- - -### Conditional Asset Loading - -```c -void load_language_assets(const char *language) { - char filename[64]; - snprintf(filename, sizeof(filename), "strings_%s.json", language); - - uint8_t *strings = storage_assets_load_file(filename, NULL); - if (strings == NULL) { - ESP_LOGW(TAG, "Language '%s' not found, using default", language); - strings = storage_assets_load_file("strings_en.json", NULL); - } - - if (strings != NULL) { - parse_language_strings((const char *)strings); - free(strings); - } -} -``` - ---- - -## Flashing Assets - -### Option 1: Automatic (Recommended) - -Add to your `CMakeLists.txt`: - -```cmake -# Create assets partition image from 'assets' folder -littlefs_create_partition_image(assets assets FLASH_IN_PROJECT) -``` - -This automatically flashes the `assets/` folder content when running `idf.py flash`. - -### Option 2: Manual Flash - -```bash -# Build the assets partition image -idf.py build - -# Flash everything including assets -idf.py flash - -# Or flash only assets partition -esptool.py write_flash 0x110000 build/assets.bin -``` - -**Note:** Replace `0x110000` with the actual offset from your partition table. - -### Asset Folder Structure - -``` -project/ -├── assets/ -│ ├── logo.bin -│ ├── config_template.json -│ ├── fonts/ -│ │ ├── arial.ttf -│ │ └── mono.ttf -│ └── images/ -│ ├── icon_wifi.bin -│ └── icon_battery.bin -└── main/ - └── main.c -``` - ---- - -## Troubleshooting - -### "Partition 'assets' not found" - -**Problem:** The assets partition is not defined in the partition table. - -**Solution:** -1. Add partition to `partitions.csv`: - ```csv - assets, data, spiffs, 0x110000, 512K, - ``` -2. Set partition table in `sdkconfig`: - ``` - CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions.csv" - CONFIG_PARTITION_TABLE_CUSTOM=y - ``` -3. Rebuild: `idf.py fullclean && idf.py build` - ---- - -### "(empty - partition has no files!)" - -**Problem:** Assets partition exists but contains no files. - -**Solution:** -1. Create `assets/` folder in project root -2. Add files to the folder -3. Enable automatic flash in `CMakeLists.txt`: - ```cmake - littlefs_create_partition_image(assets assets FLASH_IN_PROJECT) - ``` -4. Rebuild and flash: `idf.py flash` - ---- - -### "Failed to allocate memory" - -**Problem:** Insufficient heap for large asset file. - -**Solutions:** -- Use `storage_assets_read_file()` with pre-allocated buffer instead of `load_file()` -- Read file in chunks instead of loading entirely -- Increase heap size in `sdkconfig`: - ``` - CONFIG_ESP_SYSTEM_EVENT_TASK_STACK_SIZE=4096 - CONFIG_FREERTOS_HZ=1000 - ``` - ---- - -### File Not Found at Runtime - -**Problem:** File exists in assets folder but not found at runtime. - -**Checklist:** -- [ ] Is partition flashed? (`idf.py flash`) -- [ ] Is filename correct? (case-sensitive!) -- [ ] Is `storage_assets_init()` called before reading? -- [ ] Check `storage_assets_print_info()` output - does it list your file? - ---- - -## Performance Considerations - -- **Initialization:** Takes 100-500ms depending on partition size and file count. -- **File Reading:** LittleFS is optimized for small files (< 1MB). -- **Memory:** `load_file()` allocates heap - monitor with `esp_get_free_heap_size()`. -- **Large Files:** For files > 100KB, consider chunked reading instead of full load. - ---- - -## Best Practices - -1. **Keep Assets Small:** LittleFS works best with many small files rather than few large ones. -2. **Compress When Possible:** Pre-compress assets (e.g., PNG → binary bitmap) before flashing. -3. **Validate Sizes:** Always check file sizes match expected values. -4. **Free Memory:** Always `free()` pointers returned by `load_file()`. -5. **Handle Errors:** Never assume assets are present - always validate return codes. -6. **Use Subdirectories:** Organize assets logically (fonts/, images/, sounds/). -7. **Version Assets:** Include version info in filenames or metadata for updates. \ No newline at end of file +This file is only a pointer: edit the docs there to keep things from drifting. diff --git a/firmware_c5/components/Service/storage_vfs/README.md b/firmware_c5/components/Service/storage_vfs/README.md index a60e33631..13ba16522 100644 --- a/firmware_c5/components/Service/storage_vfs/README.md +++ b/firmware_c5/components/Service/storage_vfs/README.md @@ -1,547 +1,7 @@ # Virtual File System (VFS) - Unified Storage Abstraction -The VFS system provides a unified, low-level abstraction layer for multiple storage backends, allowing applications to work with files using a consistent API regardless of the underlying storage medium (SD Card, SPIFFS, LittleFS, or RAM). +Documentation for this component lives in the project docs hub (single source of truth): -## Overview +- [docs/storage_vfs/README.md#c5](../../../../docs/storage_vfs/README.md#c5) -- **Location:** `components/storage/vfs/` -- **Main Headers:** - - `include/vfs_core.h` (Core API) - - `include/vfs_config.h` (Backend selection) - - `include/vfs_sdcard.h` (SD Card backend) - - `include/vfs_littlefs.h` (LittleFS backend) -- **Dependencies:** `esp_vfs`, `esp_vfs_fat`, `esp_littlefs`, `sdmmc`, `spi` - -## Architecture Position - -``` -Application Code - ↓ - Storage API ← Recommended for most applications - ↓ - VFS Core ← You are here (low-level abstraction) - ↓ -Backend-Specific Drivers (SD/LittleFS/SPIFFS/RAM) -``` - -**When to use VFS directly:** -- You need POSIX-like file descriptor operations -- You want manual control over open/read/write/close -- Storage API doesn't provide what you need -- You're building your own storage abstraction - -**When NOT to use VFS:** -- For simple file operations → Use **Storage API** instead -- For read-only assets → Use **Storage Assets** instead - ---- - -## Key Features - -- **Multiple Backends:** Support for SD Card (FAT), SPIFFS, LittleFS, and RAM filesystem -- **Single Backend Selection:** Compile-time selection ensures only one backend is active -- **POSIX-Like API:** Familiar file operations (open, read, write, close, lseek) -- **Directory Operations:** Full directory tree manipulation -- **Backend Abstraction:** Switch storage backends by changing configuration - ---- - -## Backend Selection (Compile-Time) - -The VFS system uses **compile-time backend selection** to ensure only one storage backend is active. - -Edit `vfs_config.h`: - -```c -// Only ONE backend can be uncommented at a time - -#define VFS_USE_SD_CARD // ← Active backend -// #define VFS_USE_SPIFFS -// #define VFS_USE_LITTLEFS -// #define VFS_USE_RAMFS -``` - -**Important:** The system validates this at compile time and will error if multiple backends are selected. - -### Backend Configurations - -Each backend has specific configuration in `vfs_config.h`: - -#### SD Card Backend -```c -#define VFS_MOUNT_POINT "/sdcard" -#define VFS_MAX_FILES 10 -#define VFS_FORMAT_ON_FAIL false -#define VFS_BACKEND_NAME "SD Card" -``` - -#### LittleFS Backend -```c -#define VFS_MOUNT_POINT "/littlefs" -#define VFS_MAX_FILES 10 -#define VFS_FORMAT_ON_FAIL true -#define VFS_PARTITION_LABEL "storage" -#define VFS_BACKEND_NAME "LittleFS" -``` - ---- - -## Data Structures - -### File Descriptor - -```c -typedef int vfs_fd_t; -#define VFS_INVALID_FD -1 -``` - -File descriptor for open files. Similar to POSIX file descriptors. - ---- - -### File/Directory Information - -```c -typedef struct { - char name[VFS_MAX_NAME]; // Entry name (64 chars max) - vfs_entry_type_t type; // VFS_TYPE_FILE or VFS_TYPE_DIR - size_t size; // File size in bytes - time_t mtime; // Last modification time - time_t ctime; // Creation time - bool is_hidden; // Hidden attribute - bool is_readonly; // Read-only attribute -} vfs_stat_t; -``` - ---- - -### Filesystem Statistics - -```c -typedef struct { - uint64_t total_bytes; // Total filesystem capacity - uint64_t free_bytes; // Available free space - uint64_t used_bytes; // Space currently in use - uint32_t block_size; // Filesystem block size - uint32_t total_blocks; // Total number of blocks - uint32_t free_blocks; // Available free blocks -} vfs_statvfs_t; -``` - ---- - -## Core API Reference - -### Initialization - -#### `vfs_init_auto` - -```c -esp_err_t vfs_init_auto(void); -``` - -Initializes the VFS backend selected in `vfs_config.h`. - -**Returns:** -- `ESP_OK` - Backend initialized and mounted successfully -- `ESP_FAIL` - Initialization failed (check logs) - ---- - -#### `vfs_deinit_auto` - -```c -esp_err_t vfs_deinit_auto(void); -``` - -Unmounts and deinitializes the active VFS backend. - -**Returns:** -- `ESP_OK` - Backend deinitialized successfully -- `ESP_FAIL` - Deinitialization failed - ---- - -#### `vfs_is_mounted_auto` - -```c -bool vfs_is_mounted_auto(void); -``` - -Checks if the active backend is currently mounted. - ---- - -#### `vfs_get_mount_point` - -```c -const char* vfs_get_mount_point(void); -``` - -Returns the mount point path for the active backend (e.g., "/sdcard", "/littlefs"). - ---- - -#### `vfs_get_backend_name` - -```c -const char* vfs_get_backend_name(void); -``` - -Returns the human-readable name of the active backend (e.g., "SD Card", "LittleFS"). - ---- - -#### `vfs_print_info` - -```c -void vfs_print_info(void); -``` - -Prints detailed information about the active VFS backend to the console, including mount point, capacity, and usage statistics. - ---- - -### File Operations (POSIX-like) - -#### `vfs_open` - -```c -vfs_fd_t vfs_open(const char *path, int flags, int mode); -``` - -Opens a file with specified flags and permissions. - -**Parameters:** -- `path` - Full path to file (e.g., "/sdcard/data.txt") -- `flags` - Opening mode flags (bitwise OR): - - `VFS_O_RDONLY` - Read-only - - `VFS_O_WRONLY` - Write-only - - `VFS_O_RDWR` - Read and write - - `VFS_O_CREAT` - Create if doesn't exist - - `VFS_O_TRUNC` - Truncate to zero length - - `VFS_O_APPEND` - Append to end of file - - `VFS_O_EXCL` - Fail if file exists (with O_CREAT) -- `mode` - File permissions (POSIX mode, e.g., 0644) - -**Returns:** -- Valid file descriptor (>= 0) on success -- `VFS_INVALID_FD` on failure - ---- - -#### `vfs_read` - -```c -ssize_t vfs_read(vfs_fd_t fd, void *buf, size_t size); -``` - -Reads data from an open file. - -**Returns:** -- Number of bytes read (>= 0) -- -1 on error - ---- - -#### `vfs_write` - -```c -ssize_t vfs_write(vfs_fd_t fd, const void *buf, size_t size); -``` - -Writes data to an open file. - -**Returns:** -- Number of bytes written (>= 0) -- -1 on error - ---- - -#### `vfs_lseek` - -```c -off_t vfs_lseek(vfs_fd_t fd, off_t offset, int whence); -``` - -Moves the file position pointer. - -**Parameters:** -- `whence` - Reference point: - - `VFS_SEEK_SET` - From beginning of file - - `VFS_SEEK_CUR` - From current position - - `VFS_SEEK_END` - From end of file - -**Returns:** -- New file position on success -- -1 on error - ---- - -#### `vfs_close` - -```c -esp_err_t vfs_close(vfs_fd_t fd); -``` - -Closes an open file descriptor. - ---- - -#### `vfs_fsync` - -```c -esp_err_t vfs_fsync(vfs_fd_t fd); -``` - -Flushes file buffers to storage, ensuring data is physically written. - ---- - -### File Metadata - -#### `vfs_stat` - -```c -esp_err_t vfs_stat(const char *path, vfs_stat_t *st); -``` - -Gets information about a file or directory. - ---- - -#### `vfs_exists` - -```c -bool vfs_exists(const char *path); -``` - -Checks if a file or directory exists. - ---- - -#### `vfs_get_size` - -```c -esp_err_t vfs_get_size(const char *path, size_t *size); -``` - -Gets the size of a file in bytes. - ---- - -### File Management - -#### `vfs_rename` - -```c -esp_err_t vfs_rename(const char *old_path, const char *new_path); -``` - -Renames or moves a file. - ---- - -#### `vfs_unlink` - -```c -esp_err_t vfs_unlink(const char *path); -``` - -Deletes a file. - ---- - -#### `vfs_truncate` - -```c -esp_err_t vfs_truncate(const char *path, off_t length); -``` - -Resizes a file to the specified length. - ---- - -### Directory Operations - -#### `vfs_mkdir` - -```c -esp_err_t vfs_mkdir(const char *path, int mode); -``` - -Creates a new directory. - ---- - -#### `vfs_rmdir` - -```c -esp_err_t vfs_rmdir(const char *path); -``` - -Removes an empty directory. - ---- - -#### `vfs_rmdir_recursive` - -```c -esp_err_t vfs_rmdir_recursive(const char *path); -``` - -Recursively removes a directory and all its contents. - ---- - -#### `vfs_opendir` / `vfs_readdir` / `vfs_closedir` - -```c -vfs_dir_t vfs_opendir(const char *path); -esp_err_t vfs_readdir(vfs_dir_t dir, vfs_stat_t *entry); -esp_err_t vfs_closedir(vfs_dir_t dir); -``` - -Directory traversal using iterator pattern. - ---- - -#### `vfs_list_dir` - -```c -typedef void (*vfs_dir_callback_t)(const vfs_stat_t *entry, void *user_data); -esp_err_t vfs_list_dir(const char *path, vfs_dir_callback_t callback, void *user_data); -``` - -Lists directory contents using callback. - ---- - -### Filesystem Information - -#### `vfs_statvfs` - -```c -esp_err_t vfs_statvfs(const char *path, vfs_statvfs_t *stat); -``` - -Gets filesystem statistics. - ---- - -#### `vfs_get_free_space` - -```c -esp_err_t vfs_get_free_space(const char *path, uint64_t *free_bytes); -``` - -Gets available free space. - ---- - -#### `vfs_get_usage_percent` - -```c -esp_err_t vfs_get_usage_percent(const char *path, float *percentage); -``` - -Calculates filesystem usage percentage. - ---- - -### High-Level Helpers - -These functions simplify common operations by handling open/close internally. - -#### `vfs_read_file` - -```c -esp_err_t vfs_read_file(const char *path, void *buf, size_t size, size_t *bytes_read); -``` - -Reads entire file content in one operation. - ---- - -#### `vfs_write_file` - -```c -esp_err_t vfs_write_file(const char *path, const void *buf, size_t size); -``` - -Writes data to file, creating or overwriting it. - ---- - -#### `vfs_append_file` - -```c -esp_err_t vfs_append_file(const char *path, const void *buf, size_t size); -``` - -Appends data to end of file. - ---- - -#### `vfs_copy_file` - -```c -esp_err_t vfs_copy_file(const char *src, const char *dst); -``` - -Copies a file. - ---- - -## Backend-Specific APIs - -### SD Card Backend - -```c -#include "vfs_sdcard.h" - -esp_err_t vfs_sdcard_init(void); -esp_err_t vfs_sdcard_deinit(void); -bool vfs_sdcard_is_mounted(void); -void vfs_sdcard_print_info(void); -esp_err_t vfs_sdcard_format(void); -``` - -### LittleFS Backend - -```c -#include "vfs_littlefs.h" - -esp_err_t vfs_littlefs_init(void); -esp_err_t vfs_littlefs_deinit(void); -bool vfs_littlefs_is_mounted(void); -void vfs_littlefs_print_info(void); -esp_err_t vfs_littlefs_format(void); -``` - ---- - -## Switching Backends - -To switch between storage backends, edit `vfs_config.h`: - -```c -// From SD Card: -#define VFS_USE_SD_CARD - -// To LittleFS: -// #define VFS_USE_SD_CARD -#define VFS_USE_LITTLEFS -``` - -Rebuild your project. All `vfs_*` function calls remain the same. - ---- - -## Best Practices - -1. **Consider Storage API first** - Use VFS only when you need low-level control -2. **Always check return values** - Especially for `vfs_open()` and `vfs_init_auto()` -3. **Close file descriptors** - Always call `vfs_close()` when done -4. **Use absolute paths** - Include mount point (e.g., "/sdcard/file.txt") -5. **Single backend only** - Never uncomment multiple backends in `vfs_config.h` \ No newline at end of file +This file is only a pointer: edit the docs there to keep things from drifting. diff --git a/firmware_c5/components/Service/storage_vfs/include/vfs_config.h b/firmware_c5/components/Service/storage_vfs/include/vfs_config.h index d093bbf28..54218925c 100644 --- a/firmware_c5/components/Service/storage_vfs/include/vfs_config.h +++ b/firmware_c5/components/Service/storage_vfs/include/vfs_config.h @@ -25,9 +25,9 @@ extern "C" { #endif -#define VFS_USE_SD_CARD // Active backend +// #define VFS_USE_SD_CARD // micro-SD lives on the P4; the C5 stores on flash // #define VFS_USE_SPIFFS -// #define VFS_USE_LITTLEFS +#define VFS_USE_LITTLEFS // Active backend // #define VFS_USE_RAMFS // Backend Configuration diff --git a/firmware_c5/components/Service/wifi/README.md b/firmware_c5/components/Service/wifi/README.md index dda8511fd..4a9c18ce3 100644 --- a/firmware_c5/components/Service/wifi/README.md +++ b/firmware_c5/components/Service/wifi/README.md @@ -1,184 +1,7 @@ # Wi-Fi Service Component Documentation -This component manages Wi-Fi functionalities including Access Point (AP) mode, Station (STA) mode, scanning, and configuration persistence using JSON files. +Documentation for this component lives in the project docs hub (single source of truth): -## Functionality Overview +- [docs/wifi/README.md#c5](../../../../docs/wifi/README.md#c5) -The service handles: -- **Initialization/Deinitialization:** Setup of NVS, Netif, Event Loops, and Wi-Fi drivers. -- **Access Point (AP):** Configurable SSID, password, max connections, and custom IP address. -- **Scanning:** Active scanning for nearby networks. -- **Station (STA):** Connecting to external Wi-Fi networks. -- **Hotspot Management:** Dynamic switching of AP configuration. -- **Promiscuous Mode:** Low-level packet sniffing and environment monitoring. -- **Channel Hopping:** Automated cycling through Wi-Fi channels for environment monitoring. -- **Configuration Persistence:** Loading and saving AP settings to/from `assets/config/wifi/wifi_ap.conf`. -- **Known Networks:** Automatically saves connected network credentials to `assets/storage/wifi/know_networks.json`. - -## API Functions - -### Initialization & Lifecycle - -#### `wifi_service_init` -```c -void wifi_service_init(void); -``` -Initializes the Wi-Fi stack in `APSTA` mode. -- Initializes NVS (performing erase if necessary). -- Sets up the default event loop and registers handlers. -- Loads AP configuration from storage (or uses defaults "Darth Maul"/"MyPassword123"). -- Configures the static IP (default: 192.168.4.1) and starts the DHCP server. - -#### `wifi_service_deinit` -```c -void wifi_service_deinit(void); -``` -Completely shuts down the Wi-Fi service. -- Stops the Wi-Fi driver. -- Unregisters event handlers. -- Deinitializes the driver. -- Frees synchronization primitives (mutexes) and clears static data. - -#### `wifi_service_start` / `wifi_service_stop` -```c -void wifi_service_start(void); -void wifi_service_stop(void); -``` -Simple wrappers to start or stop the Wi-Fi driver without full deinitialization. `wifi_service_stop` also clears stored scan results. - -### Scanning - -#### `wifi_service_scan` -```c -void wifi_service_scan(void); -``` -Performs an active Wi-Fi scan. -- Uses a mutex to ensure thread safety. -- Stores up to `WIFI_SCAN_LIST_SIZE` results internally. -- Provides visual feedback via LEDs (Green for AP connection, Red for failures, Blue for scan success). - -#### `wifi_service_get_ap_count` -```c -uint16_t wifi_service_get_ap_count(void); -``` -Returns the number of networks found in the last scan. - -#### `wifi_service_get_ap_record` -```c -wifi_ap_record_t* wifi_service_get_ap_record(uint16_t index); -``` -Retrieves a pointer to a specific scan result record. Returns `NULL` if the index is invalid. - -### Connection & Management - -#### `wifi_service_connect_to_ap` -```c -esp_err_t wifi_service_connect_to_ap(const char *ssid, const char *password); -``` -Connects the device (as a station) to an external Access Point. -- Configures authentication mode based on the presence of a password (WPA2_PSK or OPEN). -- Disconnects any existing connection before attempting a new one. -- **Persistence:** Automatically saves the SSID and password to `assets/storage/wifi/know_networks.json`. If the network already exists, the password is updated. - -#### `wifi_service_is_connected` -```c -bool wifi_service_is_connected(void); -``` -Returns `true` if the device is currently connected to an external Wi-Fi network and has an IP address. - -#### `wifi_service_is_active` -```c -bool wifi_service_is_active(void); -``` -Returns `true` if the Wi-Fi service is started (driver initialized and interface up). - -#### `wifi_service_get_connected_ssid` -```c -const char* wifi_service_get_connected_ssid(void); -``` -Returns the SSID of the currently connected network. Returns `NULL` if not connected. - -#### `wifi_service_change_to_hotspot` -```c -void wifi_service_change_to_hotspot(const char *new_ssid); -``` -Dynamically reconfigures the device's Access Point to an **Open** network with the specified SSID. -- Stops the Wi-Fi driver briefly to apply changes. -- Sets `authmode` to `WIFI_AUTH_OPEN`. -- Restarts Wi-Fi with the new configuration. - -### Promiscuous Mode - -#### `wifi_service_promiscuous_start` -```c -void wifi_service_promiscuous_start(wifi_promiscuous_cb_t cb, wifi_promiscuous_filter_t *filter); -``` -Enables promiscuous mode (sniffer) with a custom callback and filter. -- `cb`: Function to handle captured packets. -- `filter`: Filter mask (e.g., `WIFI_PROMIS_FILTER_MASK_MGMT`). - -#### `wifi_service_promiscuous_stop` -```c -void wifi_service_promiscuous_stop(void); -``` -Disables promiscuous mode and clears the callback. - -### Channel Hopping - -#### `wifi_service_start_channel_hopping` -```c -void wifi_service_start_channel_hopping(void); -``` -Starts a background task that cycles the Wi-Fi interface through channels 1 to 13. -- Useful for promiscuous mode applications (e.g., deauth detection). -- Task memory is allocated in PSRAM if available. - -#### `wifi_service_stop_channel_hopping` -```c -void wifi_service_stop_channel_hopping(void); -``` -Stops the channel hopping task and frees associated memory resources. - -### Configuration Storage - -#### `wifi_service_save_ap_config` -```c -esp_err_t wifi_service_save_ap_config(const char *ssid, const char *password, uint8_t max_conn, const char *ip_addr, bool enabled); -``` -Saves the AP configuration to a JSON file (`/assets/config/wifi/wifi_ap.conf`). -- Uses `cJSON` to serialize settings. -- Persists data using the storage API. -- **State Management:** If `enabled` is `true` and Wi-Fi is inactive, it calls `wifi_service_start()`. If `enabled` is `false` and Wi-Fi is active, it calls `wifi_service_stop()`. - -#### Individual Setters -Helper functions to update a single configuration parameter while preserving others. They automatically save the config and trigger state changes if `enabled` is toggled. - -```c -esp_err_t wifi_service_set_enabled(bool enabled); -esp_err_t wifi_service_set_ap_ssid(const char *ssid); -esp_err_t wifi_service_set_ap_password(const char *password); -esp_err_t wifi_service_set_ap_max_conn(uint8_t max_conn); -esp_err_t wifi_service_set_ap_ip(const char *ip_addr); -``` - -**Internal Loader:** `wifi_service_load_ap_config` is called during initialization to read these settings. If `enabled` is found to be `false` in the config, `wifi_service_init` will initialize the driver but **not** start the radio. - -## Internal Implementation Details - -### Event Handling -A static `wifi_event_handler` manages Wi-Fi and IP events: -- **WIFI_EVENT_AP_STACONNECTED:** Logs the MAC of the connected station and blinks Green. -- **WIFI_EVENT_AP_STADISCONNECTED:** Blinks Red. -- **IP_EVENT_AP_STAIPASSIGNED:** Logs IP assignment and blinks Green. - -### Thread Safety -A `wifi_mutex` (Semaphore) is used to protect the scanning process (`wifi_service_scan`), preventing concurrent scan requests which could lead to resource conflicts. - -### Channel Hopping Task -The channel hopping feature runs as a static FreeRTOS task. It uses `esp_wifi_set_channel` to switch channels every 250ms. To optimize internal RAM usage, both the task stack and the Task Control Block (TCB) are allocated in **PSRAM** using the `SPIRAM` capability. - -### Castings & Memory Management -- **cJSON:** Used extensively for parsing and generating configuration files. -- **PSRAM Allocation:** Critical tasks and large buffers are allocated in PSRAM to preserve internal memory. -- **Type Casting:** `event_data` is cast to specific event structures (e.g., `wifi_event_ap_staconnected_t*`) within handlers. -- **String Handling:** `strncpy` is used safely with explicit null-termination to prevent buffer overflows when handling SSIDs and passwords. +This file is only a pointer: edit the docs there to keep things from drifting. diff --git a/firmware_c5/components/Service/wifi/include/wifi_service.h b/firmware_c5/components/Service/wifi/include/wifi_service.h index 8328910f5..8773f3289 100644 --- a/firmware_c5/components/Service/wifi/include/wifi_service.h +++ b/firmware_c5/components/Service/wifi/include/wifi_service.h @@ -45,6 +45,12 @@ void wifi_service_deinit(void); */ void wifi_service_stop(void); +/** @brief True while a capture (promiscuous sniffer) is running. */ +bool wifi_service_is_busy(void); + +/** @brief Set WiFi modem sleep depth: deep = MAX_MODEM, otherwise MIN_MODEM. */ +void wifi_service_set_power_save(bool deep); + /** * @brief Start the Wi-Fi radio. */ diff --git a/firmware_c5/components/Service/wifi/wifi_service.c b/firmware_c5/components/Service/wifi/wifi_service.c index 38bc4b117..1d865b5fd 100644 --- a/firmware_c5/components/Service/wifi/wifi_service.c +++ b/firmware_c5/components/Service/wifi/wifi_service.c @@ -28,6 +28,7 @@ #include "freertos/FreeRTOS.h" #include "freertos/semphr.h" #include "freertos/task.h" +#include "sys_prio.h" #include "lwip/inet.h" #include "nvs_flash.h" @@ -38,7 +39,7 @@ static const char *TAG = "WIFI_SERVICE"; #define HOPPER_STACK_SIZE 4096 -#define HOPPER_TASK_PRIORITY 5 +#define HOPPER_TASK_PRIORITY SYS_PRIO_SERVICE_HI #define HOPPER_DELAY_MS 250 #define MAX_WIFI_CHANNEL 13 #define SCAN_MUTEX_TIMEOUT_MS 1000 @@ -57,6 +58,7 @@ static uint16_t s_stored_ap_count = 0; static SemaphoreHandle_t s_mutex = NULL; static bool s_is_active = false; static bool s_is_connected = false; +static bool s_promiscuous_active = false; static TaskHandle_t s_hopper_task_handle = NULL; static StackType_t *s_hopper_task_stack = NULL; static StaticTask_t *s_hopper_task_tcb = NULL; @@ -112,6 +114,13 @@ void wifi_service_init(void) { ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_APSTA)); + // Dual-band (2.4 + 5 GHz) BEFORE configuring the AP: a stale 5G-only band mode + // persisted in NVS otherwise rejects the 2.4 GHz AP channel and aborts set_config. + esp_err_t band_err = esp_wifi_set_band_mode(WIFI_BAND_MODE_AUTO); + if (band_err != ESP_OK) { + ESP_LOGW(TAG, "Could not enable dual-band: %s", esp_err_to_name(band_err)); + } + char target_ssid[SSID_MAX_LEN] = "Darth Maul"; char target_password[PASSWORD_MAX_LEN] = "MyPassword123"; uint8_t target_max_conn = DEFAULT_MAX_CONN; @@ -149,11 +158,19 @@ void wifi_service_init(void) { ap_config.ap.authmode = WIFI_AUTH_WPA_WPA2_PSK; } - ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_AP, &ap_config)); + esp_err_t ap_cfg_err = esp_wifi_set_config(WIFI_IF_AP, &ap_config); + if (ap_cfg_err != ESP_OK) { + ESP_LOGE(TAG, "esp_wifi_set_config(AP) failed: %s", esp_err_to_name(ap_cfg_err)); + } if (is_enabled) { ESP_ERROR_CHECK(esp_wifi_start()); s_is_active = true; + // Enable modem sleep explicitly. It only takes effect for an idle, connected + // STA (radio wakes per DTIM); AP mode and promiscuous sniffing keep the radio + // in continuous RX regardless. The real idle savings need the P4 to signal + // low-power over the bridge so the C5 can drop the radio (see power state API). + esp_wifi_set_ps(WIFI_PS_MIN_MODEM); ESP_LOGI(TAG, "Wi-Fi AP started with SSID: %s", target_ssid); } else { ESP_LOGI(TAG, "Wi-Fi AP initialized but disabled by config"); @@ -207,12 +224,11 @@ void wifi_service_deinit(void) { void wifi_service_stop(void) { esp_err_t err = esp_wifi_stop(); - if (err == ESP_OK) { - s_is_active = false; - s_is_connected = false; - } else { + if (err != ESP_OK) { ESP_LOGE(TAG, "Error stopping Wi-Fi: %s", esp_err_to_name(err)); } + s_is_active = false; + s_is_connected = false; s_stored_ap_count = 0; memset(s_stored_aps, 0, sizeof(s_stored_aps)); @@ -532,6 +548,7 @@ void wifi_service_promiscuous_start(wifi_promiscuous_cb_t cb, wifi_promiscuous_f if (err != ESP_OK) { ESP_LOGE(TAG, "Failed to enable promiscuous mode: %s", esp_err_to_name(err)); } else { + s_promiscuous_active = true; ESP_LOGI(TAG, "Promiscuous mode enabled"); } } @@ -542,10 +559,23 @@ void wifi_service_promiscuous_stop(void) { ESP_LOGE(TAG, "Failed to disable promiscuous mode: %s", esp_err_to_name(err)); } + s_promiscuous_active = false; esp_wifi_set_promiscuous_rx_cb(NULL); ESP_LOGI(TAG, "Promiscuous mode disabled"); } +bool wifi_service_is_busy(void) { + // A capture is in flight (sniffer / deauth detector / handshake). Do not drop + // the radio for power saving while this is true. + return s_promiscuous_active; +} + +void wifi_service_set_power_save(bool deep) { + // deep = screen dimmed (MAX_MODEM, longer beacon skips); otherwise MIN_MODEM. + // Only meaningful for an idle connected STA; harmless otherwise. + esp_wifi_set_ps(deep ? WIFI_PS_MAX_MODEM : WIFI_PS_MIN_MODEM); +} + void wifi_service_start_channel_hopping(void) { if (s_hopper_task_handle != NULL) { ESP_LOGW(TAG, "Channel hopping already running"); @@ -629,13 +659,20 @@ event_handler(void *arg, esp_event_base_t event_base, int32_t event_id, void *ev } } +// 2.4 GHz (1-13) plus the common non-DFS 5 GHz channels (UNII-1 + UNII-3). DFS +// channels (52-144) need radar detection and aren't usable for passive hopping, +// so they're left out. esp_wifi_set_channel picks the band from the number. +static const uint8_t HOP_CHANNELS[] = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, + 12, 13, 36, 40, 44, 48, 149, 153, 157, 161, 165}; +#define HOP_CHANNEL_COUNT (sizeof(HOP_CHANNELS) / sizeof(HOP_CHANNELS[0])) + static void channel_hopper_task(void *pvParameters) { - uint8_t channel = 1; + size_t idx = 0; while (1) { - esp_wifi_set_channel(channel, WIFI_SECOND_CHAN_NONE); - channel++; - if (channel > MAX_WIFI_CHANNEL) { - channel = 1; + esp_wifi_set_channel(HOP_CHANNELS[idx], WIFI_SECOND_CHAN_NONE); + idx++; + if (idx >= HOP_CHANNEL_COUNT) { + idx = 0; } vTaskDelay(pdMS_TO_TICKS(HOPPER_DELAY_MS)); } diff --git a/firmware_c5/main/main.c b/firmware_c5/main/main.c index 42b97c70a..b2de94406 100644 --- a/firmware_c5/main/main.c +++ b/firmware_c5/main/main.c @@ -2,7 +2,9 @@ #include "freertos/FreeRTOS.h" #include "freertos/task.h" #include "kernel.h" +#include "ota_service.h" void app_main(void) { kernel_init(); + ota_post_boot_check(); } diff --git a/firmware_c5/partitions.csv b/firmware_c5/partitions.csv index 3b3205835..4f89a0ba2 100644 --- a/firmware_c5/partitions.csv +++ b/firmware_c5/partitions.csv @@ -1,6 +1,8 @@ # Name, Type, SubType, Offset, Size, Flags nvs, data, nvs, 0x9000, 24K, -phy_init, data, phy, 0xf000, 4K, -factory, app, factory, 0x10000, 2M, -storage, data, fat, , 2M, -assets, data, littlefs, 0x410000, 2M, +otadata, data, ota, 0xf000, 8K, +phy_init, data, phy, 0x11000, 4K, +ota_0, app, ota_0, 0x20000, 2M, +ota_1, app, ota_1, 0x220000, 2M, +assets, data, littlefs, 0x420000, 0x1E0000, +coredump, data, coredump, 0x600000, 64K, diff --git a/firmware_c5/sdkconfig.defaults b/firmware_c5/sdkconfig.defaults index 400833c47..42bfd47b8 100644 --- a/firmware_c5/sdkconfig.defaults +++ b/firmware_c5/sdkconfig.defaults @@ -8,6 +8,25 @@ CONFIG_ESPTOOLPY_NO_STUB=y CONFIG_PARTITION_TABLE_CUSTOM=y CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions.csv" +# Core dump to the dedicated flash partition (ELF format) for field forensics, +# matching the P4. On a panic the backtrace/registers/task stacks are saved to +# the coredump partition. The C5 is headless (no boot-report viewer like the P4), +# so retrieve it over serial with `idf.py coredump-info` / `coredump-dbg`. +CONFIG_ESP_COREDUMP_ENABLE_TO_FLASH=y +CONFIG_ESP_COREDUMP_DATA_FORMAT_ELF=y +CONFIG_ESP_COREDUMP_CHECKSUM_CRC32=y +CONFIG_ESP_COREDUMP_STACK_SIZE=1792 + +# Hold the panic backtrace on the serial line for 2 s before rebooting, so field +# debugging can read it instead of it scrolling past with the next boot. +CONFIG_ESP_SYSTEM_PANIC_REBOOT_DELAY_SECONDS=2 + +# OTA rollback: a new image boots in PENDING_VERIFY. ota_post_boot_check() marks +# it valid only once the P4 has used the bridge, else it rolls back to the last +# good image. The bridge is the C5's only job and only recovery path, so bridge +# health is the boot health criterion. +CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE=y + # CPU 240MHz (C5 default is 160MHz) CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ_240=y @@ -15,20 +34,23 @@ CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ_240=y CONFIG_FREERTOS_UNICORE=y CONFIG_ESP_SYSTEM_SINGLE_CORE_MODE=y -# PSRAM quad, 40MHz +# 8MB PSRAM, in-package on the C5 module (confirmed at boot). IGNORE_NOTFOUND so a +# PSRAM fault degrades to internal RAM instead of boot-looping the headless C5. CONFIG_SPIRAM=y CONFIG_SPIRAM_MODE_QUAD=y CONFIG_SPIRAM_SPEED_40M=y -CONFIG_SPIRAM_USE_MALLOC=y -CONFIG_SPIRAM_MALLOC_ALWAYSINTERNAL=16384 -CONFIG_SPIRAM_MALLOC_RESERVE_INTERNAL=32768 +CONFIG_SPIRAM_IGNORE_NOTFOUND=y +CONFIG_SPIRAM_ALLOW_STACK_EXTERNAL_MEMORY=y # Larger stack for kernel_init CONFIG_ESP_MAIN_TASK_STACK_SIZE=8192 -# Console via UART0 (CH340 on C5 devkit: GPIO11/12) +# Console primary on UART0 (visible on the CP2105), secondary on USB-JTAG. +# UART0 is only borrowed for the OTA transfer, which is now triggered on demand +# over SPI (SPI_ID_SYSTEM_START_UART_OTA) and silences logging while it runs, so +# the console and the OTA no longer collide. Logs are also teed to the P4 over SPI. CONFIG_ESP_CONSOLE_UART_DEFAULT=y -CONFIG_ESP_CONSOLE_SECONDARY_NONE=y +CONFIG_ESP_CONSOLE_SECONDARY_USB_SERIAL_JTAG=y # Bluetooth NimBLE CONFIG_BT_ENABLED=y @@ -42,3 +64,7 @@ CONFIG_BOOTLOADER_LOG_VERSION_1=y # Required for sys_monitor (uxTaskGetSystemState / vTaskList) CONFIG_FREERTOS_USE_TRACE_FACILITY=y CONFIG_FREERTOS_USE_STATS_FORMATTING_FUNCTIONS=y + +# Task WDT reboots (panics) instead of only warning when a task starves the core +# or the sys_monitor heartbeat stops feeding it. Same policy as the P4. +CONFIG_ESP_TASK_WDT_PANIC=y diff --git a/firmware_p4/CMakeLists.txt b/firmware_p4/CMakeLists.txt index ff113953a..54191944d 100644 --- a/firmware_p4/CMakeLists.txt +++ b/firmware_p4/CMakeLists.txt @@ -39,6 +39,13 @@ if(NOT convert_result EQUAL 0) message(FATAL_ERROR "Failed to convert assets. Check the logs above for details.") endif() -# ============================================================================== +file(STRINGS "${CMAKE_CURRENT_LIST_DIR}/../common/metadata/version_info.txt" FW_VERSION LIMIT_COUNT 1) +string(STRIP "${FW_VERSION}" FW_VERSION) +set(FW_JSON "${CMAKE_CURRENT_LIST_DIR}/temp/config/OTA/firmware.json") +if(EXISTS "${FW_JSON}") + file(READ "${FW_JSON}" _fw_json) + string(REGEX REPLACE "\"version\"[ \t]*:[ \t]*\"[^\"]*\"" "\"version\": \"${FW_VERSION}\"" _fw_json "${_fw_json}") + file(WRITE "${FW_JSON}" "${_fw_json}") +endif() littlefs_create_partition_image(assets temp FLASH_IN_PROJECT) \ No newline at end of file diff --git a/firmware_p4/assets/UI/MENU_SELECT.png b/firmware_p4/assets/UI/MENU_SELECT.png deleted file mode 100644 index 33f32bec9..000000000 Binary files a/firmware_p4/assets/UI/MENU_SELECT.png and /dev/null differ diff --git a/firmware_p4/assets/config/led/led.conf b/firmware_p4/assets/config/led/led.conf new file mode 100644 index 000000000..91cec7e90 --- /dev/null +++ b/firmware_p4/assets/config/led/led.conf @@ -0,0 +1,6 @@ +{ + "brightness": 10, + "info_color": "#FF00FF", + "warning_color": "#FFFF00", + "error_color": "#FF0000" +} diff --git a/firmware_p4/assets/config/screen/screen_themes.conf b/firmware_p4/assets/config/screen/screen_themes.conf index 3bde2c023..ec5ab115a 100644 --- a/firmware_p4/assets/config/screen/screen_themes.conf +++ b/firmware_p4/assets/config/screen/screen_themes.conf @@ -10,17 +10,6 @@ "text_main": "0xFFFFFF", "screen_base": "0x0A0220" }, - "matrix": { - "bg_primary": "0x001a0d", - "bg_secondary": "0x003d1a", - "bg_item_top": "0x00120a", - "bg_item_bot": "0x002614", - "border_accent": "0x00FF41", - "border_interface": "0x39FF14", - "border_inactive": "0x1a4d2e", - "text_main": "0x7FFF00", - "screen_base": "0x000a05" - }, "cyber_blue": { "bg_primary": "0x0a1f29", "bg_secondary": "0x1a3d52", @@ -31,104 +20,5 @@ "border_inactive": "0x2a5266", "text_main": "0x0FF0FC", "screen_base": "0x020a0f" - }, - "blood": { - "bg_primary": "0x2d0a14", - "bg_secondary": "0x4d1429", - "bg_item_top": "0x1a0508", - "bg_item_bot": "0x330a1a", - "border_accent": "0xFF0055", - "border_interface": "0xFF3377", - "border_inactive": "0x4d1f33", - "text_main": "0xFF4466", - "screen_base": "0x14050a" - }, - "toxic": { - "bg_primary": "0x1f2900", - "bg_secondary": "0x334d00", - "bg_item_top": "0x0a1400", - "bg_item_bot": "0x1a2600", - "border_accent": "0xCCFF00", - "border_interface": "0x99FF00", - "border_inactive": "0x4d6633", - "text_main": "0xD4FF00", - "screen_base": "0x0f1400" - }, - "ghost": { - "bg_primary": "0x1a1a29", - "bg_secondary": "0x2e2e47", - "bg_item_top": "0x0a0a14", - "bg_item_bot": "0x1a1a2e", - "border_accent": "0xB8B8FF", - "border_interface": "0x9D9DFF", - "border_inactive": "0x3d3d5c", - "text_main": "0xE6E6FF", - "screen_base": "0x0d0d1a" - }, - "neon_pink": { - "bg_primary": "0x29001a", - "bg_secondary": "0x4d0033", - "bg_item_top": "0x14000a", - "bg_item_bot": "0x26001a", - "border_accent": "0xFF00AA", - "border_interface": "0xFF33CC", - "border_inactive": "0x4d2647", - "text_main": "0xFF66DD", - "screen_base": "0x14000d" - }, - "amber": { - "bg_primary": "0x291a00", - "bg_secondary": "0x4d3300", - "bg_item_top": "0x140a00", - "bg_item_bot": "0x261a00", - "border_accent": "0xFFAA00", - "border_interface": "0xFFCC33", - "border_inactive": "0x664d33", - "text_main": "0xFFDD55", - "screen_base": "0x140d00" - }, - "terminal": { - "bg_primary": "0x0f1a0f", - "bg_secondary": "0x1a2e1a", - "bg_item_top": "0x050a05", - "bg_item_bot": "0x0d1a0d", - "border_accent": "0x00FF00", - "border_interface": "0x33FF33", - "border_inactive": "0x2e4d2e", - "text_main": "0x44FF44", - "screen_base": "0x050a05" - }, - "ice": { - "bg_primary": "0x001a29", - "bg_secondary": "0x00334d", - "bg_item_top": "0x000a14", - "bg_item_bot": "0x001a2e", - "border_accent": "0x00FFFF", - "border_interface": "0x66DDFF", - "border_inactive": "0x2e5266", - "text_main": "0xAAFFFF", - "screen_base": "0x000d1a" - }, - "deep_purple": { - "bg_primary": "0x1a0029", - "bg_secondary": "0x33004d", - "bg_item_top": "0x0a0014", - "bg_item_bot": "0x1a0026", - "border_accent": "0xAA00FF", - "border_interface": "0xCC66FF", - "border_inactive": "0x4d2e66", - "text_main": "0xDD99FF", - "screen_base": "0x0d0014" - }, - "midnight": { - "bg_primary": "0x00142e", - "bg_secondary": "0x002952", - "bg_item_top": "0x000a1a", - "bg_item_bot": "0x001a33", - "border_accent": "0x4488FF", - "border_interface": "0x66AAFF", - "border_inactive": "0x2e4766", - "text_main": "0x99CCFF", - "screen_base": "0x000a1a" } -} \ No newline at end of file +} diff --git a/firmware_p4/assets/frames/apps_frame_0.png b/firmware_p4/assets/frames/apps_frame_0.png index 2791489d7..a9fe56db1 100644 Binary files a/firmware_p4/assets/frames/apps_frame_0.png and b/firmware_p4/assets/frames/apps_frame_0.png differ diff --git a/firmware_p4/assets/frames/ble_frame_0.png b/firmware_p4/assets/frames/ble_frame_0.png index 407f296d1..8d953146e 100644 Binary files a/firmware_p4/assets/frames/ble_frame_0.png and b/firmware_p4/assets/frames/ble_frame_0.png differ diff --git a/firmware_p4/assets/frames/card_frame_0.png b/firmware_p4/assets/frames/card_frame_0.png index 18fe4a0c3..a1452e047 100644 Binary files a/firmware_p4/assets/frames/card_frame_0.png and b/firmware_p4/assets/frames/card_frame_0.png differ diff --git a/firmware_p4/assets/frames/config_frame_0.png b/firmware_p4/assets/frames/config_frame_0.png index da0aad365..6038bf886 100644 Binary files a/firmware_p4/assets/frames/config_frame_0.png and b/firmware_p4/assets/frames/config_frame_0.png differ diff --git a/firmware_p4/assets/frames/dev_glyph.png b/firmware_p4/assets/frames/dev_glyph.png new file mode 100644 index 000000000..f0401a86e Binary files /dev/null and b/firmware_p4/assets/frames/dev_glyph.png differ diff --git a/firmware_p4/assets/frames/file_frame_0.png b/firmware_p4/assets/frames/file_frame_0.png index a88e26aed..c2c7ba016 100644 Binary files a/firmware_p4/assets/frames/file_frame_0.png and b/firmware_p4/assets/frames/file_frame_0.png differ diff --git a/firmware_p4/assets/frames/folder_frame_0.png b/firmware_p4/assets/frames/folder_frame_0.png index db183ea9d..2024bbd60 100644 Binary files a/firmware_p4/assets/frames/folder_frame_0.png and b/firmware_p4/assets/frames/folder_frame_0.png differ diff --git a/firmware_p4/assets/frames/gpios_frame_0.png b/firmware_p4/assets/frames/gpios_frame_0.png index c1f700aaf..d5378de32 100644 Binary files a/firmware_p4/assets/frames/gpios_frame_0.png and b/firmware_p4/assets/frames/gpios_frame_0.png differ diff --git a/firmware_p4/assets/frames/ir_frame_0.png b/firmware_p4/assets/frames/ir_frame_0.png index adc7c5e40..7afa85f5b 100644 Binary files a/firmware_p4/assets/frames/ir_frame_0.png and b/firmware_p4/assets/frames/ir_frame_0.png differ diff --git a/firmware_p4/assets/frames/logo_frame_0.png b/firmware_p4/assets/frames/logo_frame_0.png index 936353500..75ae3d621 100644 Binary files a/firmware_p4/assets/frames/logo_frame_0.png and b/firmware_p4/assets/frames/logo_frame_0.png differ diff --git a/firmware_p4/assets/frames/lora_frame_0.png b/firmware_p4/assets/frames/lora_frame_0.png index 93fdc5788..6907ac6f5 100644 Binary files a/firmware_p4/assets/frames/lora_frame_0.png and b/firmware_p4/assets/frames/lora_frame_0.png differ diff --git a/firmware_p4/assets/frames/nfc_frame_0.png b/firmware_p4/assets/frames/nfc_frame_0.png index ad667c127..983610c06 100644 Binary files a/firmware_p4/assets/frames/nfc_frame_0.png and b/firmware_p4/assets/frames/nfc_frame_0.png differ diff --git a/firmware_p4/assets/frames/player_glyph.png b/firmware_p4/assets/frames/player_glyph.png new file mode 100644 index 000000000..73f1c9fb0 Binary files /dev/null and b/firmware_p4/assets/frames/player_glyph.png differ diff --git a/firmware_p4/assets/frames/rfid_frame_0.png b/firmware_p4/assets/frames/rfid_frame_0.png new file mode 100644 index 000000000..5b3806736 Binary files /dev/null and b/firmware_p4/assets/frames/rfid_frame_0.png differ diff --git a/firmware_p4/assets/frames/subghz_frame_0.png b/firmware_p4/assets/frames/subghz_frame_0.png index 7534d6ea4..d3e915ee6 100644 Binary files a/firmware_p4/assets/frames/subghz_frame_0.png and b/firmware_p4/assets/frames/subghz_frame_0.png differ diff --git a/firmware_p4/assets/frames/usb_frame_0.png b/firmware_p4/assets/frames/usb_frame_0.png new file mode 100644 index 000000000..054647199 Binary files /dev/null and b/firmware_p4/assets/frames/usb_frame_0.png differ diff --git a/firmware_p4/assets/frames/wifi_frame_0.png b/firmware_p4/assets/frames/wifi_frame_0.png index 58f2ad2fd..891a4c800 100644 Binary files a/firmware_p4/assets/frames/wifi_frame_0.png and b/firmware_p4/assets/frames/wifi_frame_0.png differ diff --git a/firmware_p4/assets/icons/about_menu_icon.png b/firmware_p4/assets/icons/about_menu_icon.png deleted file mode 100644 index d595e6c76..000000000 Binary files a/firmware_p4/assets/icons/about_menu_icon.png and /dev/null differ diff --git a/firmware_p4/assets/icons/ac_unit.png b/firmware_p4/assets/icons/ac_unit.png new file mode 100644 index 000000000..3458eff75 Binary files /dev/null and b/firmware_p4/assets/icons/ac_unit.png differ diff --git a/firmware_p4/assets/icons/add.png b/firmware_p4/assets/icons/add.png new file mode 100644 index 000000000..b5de66675 Binary files /dev/null and b/firmware_p4/assets/icons/add.png differ diff --git a/firmware_p4/assets/icons/animation.png b/firmware_p4/assets/icons/animation.png new file mode 100644 index 000000000..cca5b8dbc Binary files /dev/null and b/firmware_p4/assets/icons/animation.png differ diff --git a/firmware_p4/assets/icons/app_shortcut.png b/firmware_p4/assets/icons/app_shortcut.png new file mode 100644 index 000000000..419103343 Binary files /dev/null and b/firmware_p4/assets/icons/app_shortcut.png differ diff --git a/firmware_p4/assets/icons/autorenew.png b/firmware_p4/assets/icons/autorenew.png new file mode 100644 index 000000000..911b7ef5e Binary files /dev/null and b/firmware_p4/assets/icons/autorenew.png differ diff --git a/firmware_p4/assets/icons/badge.png b/firmware_p4/assets/icons/badge.png new file mode 100644 index 000000000..9d1ce8834 Binary files /dev/null and b/firmware_p4/assets/icons/badge.png differ diff --git a/firmware_p4/assets/icons/battery_full.png b/firmware_p4/assets/icons/battery_full.png new file mode 100644 index 000000000..aa10075f1 Binary files /dev/null and b/firmware_p4/assets/icons/battery_full.png differ diff --git a/firmware_p4/assets/icons/battery_menu_icon.png b/firmware_p4/assets/icons/battery_menu_icon.png deleted file mode 100644 index b80f18b84..000000000 Binary files a/firmware_p4/assets/icons/battery_menu_icon.png and /dev/null differ diff --git a/firmware_p4/assets/icons/bluetooth.png b/firmware_p4/assets/icons/bluetooth.png new file mode 100644 index 000000000..0df083d9d Binary files /dev/null and b/firmware_p4/assets/icons/bluetooth.png differ diff --git a/firmware_p4/assets/icons/bluetooth_icon.png b/firmware_p4/assets/icons/bluetooth_icon.png deleted file mode 100644 index 2fe776c73..000000000 Binary files a/firmware_p4/assets/icons/bluetooth_icon.png and /dev/null differ diff --git a/firmware_p4/assets/icons/bluetooth_searching.png b/firmware_p4/assets/icons/bluetooth_searching.png new file mode 100644 index 000000000..dd1f4cf69 Binary files /dev/null and b/firmware_p4/assets/icons/bluetooth_searching.png differ diff --git a/firmware_p4/assets/icons/bluetooth_sel.png b/firmware_p4/assets/icons/bluetooth_sel.png deleted file mode 100644 index 6d6bc9ab9..000000000 Binary files a/firmware_p4/assets/icons/bluetooth_sel.png and /dev/null differ diff --git a/firmware_p4/assets/icons/bolt.png b/firmware_p4/assets/icons/bolt.png new file mode 100644 index 000000000..aa84daaef Binary files /dev/null and b/firmware_p4/assets/icons/bolt.png differ diff --git a/firmware_p4/assets/icons/bookmarks.png b/firmware_p4/assets/icons/bookmarks.png new file mode 100644 index 000000000..9cdf3e0fa Binary files /dev/null and b/firmware_p4/assets/icons/bookmarks.png differ diff --git a/firmware_p4/assets/icons/bright_icon.png b/firmware_p4/assets/icons/bright_icon.png deleted file mode 100644 index b8a6fabd4..000000000 Binary files a/firmware_p4/assets/icons/bright_icon.png and /dev/null differ diff --git a/firmware_p4/assets/icons/bright_menu_icon.png b/firmware_p4/assets/icons/bright_menu_icon.png deleted file mode 100644 index eecfe5815..000000000 Binary files a/firmware_p4/assets/icons/bright_menu_icon.png and /dev/null differ diff --git a/firmware_p4/assets/icons/brightness_6.png b/firmware_p4/assets/icons/brightness_6.png new file mode 100644 index 000000000..f83368a43 Binary files /dev/null and b/firmware_p4/assets/icons/brightness_6.png differ diff --git a/firmware_p4/assets/icons/brightness_auto.png b/firmware_p4/assets/icons/brightness_auto.png new file mode 100644 index 000000000..07f281a22 Binary files /dev/null and b/firmware_p4/assets/icons/brightness_auto.png differ diff --git a/firmware_p4/assets/icons/broadcast_on_personal.png b/firmware_p4/assets/icons/broadcast_on_personal.png new file mode 100644 index 000000000..8fe73de27 Binary files /dev/null and b/firmware_p4/assets/icons/broadcast_on_personal.png differ diff --git a/firmware_p4/assets/icons/burst_menu_icon.png b/firmware_p4/assets/icons/burst_menu_icon.png deleted file mode 100644 index a4e764fe0..000000000 Binary files a/firmware_p4/assets/icons/burst_menu_icon.png and /dev/null differ diff --git a/firmware_p4/assets/icons/cable.png b/firmware_p4/assets/icons/cable.png new file mode 100644 index 000000000..be9ad8b25 Binary files /dev/null and b/firmware_p4/assets/icons/cable.png differ diff --git a/firmware_p4/assets/icons/card_icon.png b/firmware_p4/assets/icons/card_icon.png deleted file mode 100644 index 3cfbd21eb..000000000 Binary files a/firmware_p4/assets/icons/card_icon.png and /dev/null differ diff --git a/firmware_p4/assets/icons/category.png b/firmware_p4/assets/icons/category.png new file mode 100644 index 000000000..dc1584a23 Binary files /dev/null and b/firmware_p4/assets/icons/category.png differ diff --git a/firmware_p4/assets/icons/cell_tower.png b/firmware_p4/assets/icons/cell_tower.png new file mode 100644 index 000000000..b0b66006e Binary files /dev/null and b/firmware_p4/assets/icons/cell_tower.png differ diff --git a/firmware_p4/assets/icons/chevron_right.png b/firmware_p4/assets/icons/chevron_right.png new file mode 100644 index 000000000..3317e73e9 Binary files /dev/null and b/firmware_p4/assets/icons/chevron_right.png differ diff --git a/firmware_p4/assets/icons/contactless.png b/firmware_p4/assets/icons/contactless.png new file mode 100644 index 000000000..f3a5ee2c7 Binary files /dev/null and b/firmware_p4/assets/icons/contactless.png differ diff --git a/firmware_p4/assets/icons/content_copy.png b/firmware_p4/assets/icons/content_copy.png new file mode 100644 index 000000000..4bb8c0e07 Binary files /dev/null and b/firmware_p4/assets/icons/content_copy.png differ diff --git a/firmware_p4/assets/icons/description.png b/firmware_p4/assets/icons/description.png new file mode 100644 index 000000000..b7d86b4ae Binary files /dev/null and b/firmware_p4/assets/icons/description.png differ diff --git a/firmware_p4/assets/icons/developer_board.png b/firmware_p4/assets/icons/developer_board.png new file mode 100644 index 000000000..e065f792d Binary files /dev/null and b/firmware_p4/assets/icons/developer_board.png differ diff --git a/firmware_p4/assets/icons/devices.png b/firmware_p4/assets/icons/devices.png new file mode 100644 index 000000000..e68a3df01 Binary files /dev/null and b/firmware_p4/assets/icons/devices.png differ diff --git a/firmware_p4/assets/icons/display_menu_icon.png b/firmware_p4/assets/icons/display_menu_icon.png deleted file mode 100644 index 4cbe07ff1..000000000 Binary files a/firmware_p4/assets/icons/display_menu_icon.png and /dev/null differ diff --git a/firmware_p4/assets/icons/display_settings.png b/firmware_p4/assets/icons/display_settings.png new file mode 100644 index 000000000..0aaa15524 Binary files /dev/null and b/firmware_p4/assets/icons/display_settings.png differ diff --git a/firmware_p4/assets/icons/download.png b/firmware_p4/assets/icons/download.png new file mode 100644 index 000000000..cedf223b2 Binary files /dev/null and b/firmware_p4/assets/icons/download.png differ diff --git a/firmware_p4/assets/icons/drag_indicator.png b/firmware_p4/assets/icons/drag_indicator.png new file mode 100644 index 000000000..cf6a6df13 Binary files /dev/null and b/firmware_p4/assets/icons/drag_indicator.png differ diff --git a/firmware_p4/assets/icons/earbuds.png b/firmware_p4/assets/icons/earbuds.png new file mode 100644 index 000000000..4f55831ab Binary files /dev/null and b/firmware_p4/assets/icons/earbuds.png differ diff --git a/firmware_p4/assets/icons/edit.png b/firmware_p4/assets/icons/edit.png new file mode 100644 index 000000000..dfd9a120f Binary files /dev/null and b/firmware_p4/assets/icons/edit.png differ diff --git a/firmware_p4/assets/icons/edit_note.png b/firmware_p4/assets/icons/edit_note.png new file mode 100644 index 000000000..fec3a16f3 Binary files /dev/null and b/firmware_p4/assets/icons/edit_note.png differ diff --git a/firmware_p4/assets/icons/eject.png b/firmware_p4/assets/icons/eject.png new file mode 100644 index 000000000..bde1a1b55 Binary files /dev/null and b/firmware_p4/assets/icons/eject.png differ diff --git a/firmware_p4/assets/icons/error.png b/firmware_p4/assets/icons/error.png new file mode 100644 index 000000000..7270b85a3 Binary files /dev/null and b/firmware_p4/assets/icons/error.png differ diff --git a/firmware_p4/assets/icons/fiber_manual_record.png b/firmware_p4/assets/icons/fiber_manual_record.png new file mode 100644 index 000000000..d029615f8 Binary files /dev/null and b/firmware_p4/assets/icons/fiber_manual_record.png differ diff --git a/firmware_p4/assets/icons/folder.png b/firmware_p4/assets/icons/folder.png new file mode 100644 index 000000000..5bb3ae08a Binary files /dev/null and b/firmware_p4/assets/icons/folder.png differ diff --git a/firmware_p4/assets/icons/folder_open.png b/firmware_p4/assets/icons/folder_open.png new file mode 100644 index 000000000..58f5ab2b6 Binary files /dev/null and b/firmware_p4/assets/icons/folder_open.png differ diff --git a/firmware_p4/assets/icons/graphic_eq.png b/firmware_p4/assets/icons/graphic_eq.png new file mode 100644 index 000000000..3c30e1d38 Binary files /dev/null and b/firmware_p4/assets/icons/graphic_eq.png differ diff --git a/firmware_p4/assets/icons/header_menu_icon.png b/firmware_p4/assets/icons/header_menu_icon.png deleted file mode 100644 index 332849b31..000000000 Binary files a/firmware_p4/assets/icons/header_menu_icon.png and /dev/null differ diff --git a/firmware_p4/assets/icons/hub.png b/firmware_p4/assets/icons/hub.png new file mode 100644 index 000000000..a8b095a62 Binary files /dev/null and b/firmware_p4/assets/icons/hub.png differ diff --git a/firmware_p4/assets/icons/info.png b/firmware_p4/assets/icons/info.png new file mode 100644 index 000000000..a34ac4be4 Binary files /dev/null and b/firmware_p4/assets/icons/info.png differ diff --git a/firmware_p4/assets/icons/interface_menu_icon.png b/firmware_p4/assets/icons/interface_menu_icon.png deleted file mode 100644 index 05040b0c6..000000000 Binary files a/firmware_p4/assets/icons/interface_menu_icon.png and /dev/null differ diff --git a/firmware_p4/assets/icons/invert_colors.png b/firmware_p4/assets/icons/invert_colors.png new file mode 100644 index 000000000..71579fd7d Binary files /dev/null and b/firmware_p4/assets/icons/invert_colors.png differ diff --git a/firmware_p4/assets/icons/ir_receive_menu_icon.png b/firmware_p4/assets/icons/ir_receive_menu_icon.png deleted file mode 100644 index 0713dca7e..000000000 Binary files a/firmware_p4/assets/icons/ir_receive_menu_icon.png and /dev/null differ diff --git a/firmware_p4/assets/icons/ir_send_menu_icon.png b/firmware_p4/assets/icons/ir_send_menu_icon.png deleted file mode 100644 index 3ff990326..000000000 Binary files a/firmware_p4/assets/icons/ir_send_menu_icon.png and /dev/null differ diff --git a/firmware_p4/assets/icons/key.png b/firmware_p4/assets/icons/key.png new file mode 100644 index 000000000..7ebdbd91c Binary files /dev/null and b/firmware_p4/assets/icons/key.png differ diff --git a/firmware_p4/assets/icons/keyboard.png b/firmware_p4/assets/icons/keyboard.png new file mode 100644 index 000000000..83d610878 Binary files /dev/null and b/firmware_p4/assets/icons/keyboard.png differ diff --git a/firmware_p4/assets/icons/lan.png b/firmware_p4/assets/icons/lan.png new file mode 100644 index 000000000..75c46ce37 Binary files /dev/null and b/firmware_p4/assets/icons/lan.png differ diff --git a/firmware_p4/assets/icons/language.png b/firmware_p4/assets/icons/language.png new file mode 100644 index 000000000..6e11ec25a Binary files /dev/null and b/firmware_p4/assets/icons/language.png differ diff --git a/firmware_p4/assets/icons/mic.png b/firmware_p4/assets/icons/mic.png new file mode 100644 index 000000000..61cde04ba Binary files /dev/null and b/firmware_p4/assets/icons/mic.png differ diff --git a/firmware_p4/assets/icons/monitoring.png b/firmware_p4/assets/icons/monitoring.png new file mode 100644 index 000000000..977c5c91c Binary files /dev/null and b/firmware_p4/assets/icons/monitoring.png differ diff --git a/firmware_p4/assets/icons/mouse.png b/firmware_p4/assets/icons/mouse.png new file mode 100644 index 000000000..551634aec Binary files /dev/null and b/firmware_p4/assets/icons/mouse.png differ diff --git a/firmware_p4/assets/icons/music_note.png b/firmware_p4/assets/icons/music_note.png new file mode 100644 index 000000000..dd453df3a Binary files /dev/null and b/firmware_p4/assets/icons/music_note.png differ diff --git a/firmware_p4/assets/icons/network_wifi_1_bar.png b/firmware_p4/assets/icons/network_wifi_1_bar.png new file mode 100644 index 000000000..bc0144092 Binary files /dev/null and b/firmware_p4/assets/icons/network_wifi_1_bar.png differ diff --git a/firmware_p4/assets/icons/network_wifi_2_bar.png b/firmware_p4/assets/icons/network_wifi_2_bar.png new file mode 100644 index 000000000..5236f87ab Binary files /dev/null and b/firmware_p4/assets/icons/network_wifi_2_bar.png differ diff --git a/firmware_p4/assets/icons/network_wifi_3_bar.png b/firmware_p4/assets/icons/network_wifi_3_bar.png new file mode 100644 index 000000000..54562fb20 Binary files /dev/null and b/firmware_p4/assets/icons/network_wifi_3_bar.png differ diff --git a/firmware_p4/assets/icons/nfc.png b/firmware_p4/assets/icons/nfc.png new file mode 100644 index 000000000..7ae93f3ba Binary files /dev/null and b/firmware_p4/assets/icons/nfc.png differ diff --git a/firmware_p4/assets/icons/notifications_active.png b/firmware_p4/assets/icons/notifications_active.png new file mode 100644 index 000000000..bc1c5286b Binary files /dev/null and b/firmware_p4/assets/icons/notifications_active.png differ diff --git a/firmware_p4/assets/icons/palette.png b/firmware_p4/assets/icons/palette.png new file mode 100644 index 000000000..c436cf3db Binary files /dev/null and b/firmware_p4/assets/icons/palette.png differ diff --git a/firmware_p4/assets/icons/pattern.png b/firmware_p4/assets/icons/pattern.png new file mode 100644 index 000000000..17fce6e63 Binary files /dev/null and b/firmware_p4/assets/icons/pattern.png differ diff --git a/firmware_p4/assets/icons/phone_icon.png b/firmware_p4/assets/icons/phone_icon.png deleted file mode 100644 index e550646e5..000000000 Binary files a/firmware_p4/assets/icons/phone_icon.png and /dev/null differ diff --git a/firmware_p4/assets/icons/play_arrow.png b/firmware_p4/assets/icons/play_arrow.png new file mode 100644 index 000000000..f4ca7d53c Binary files /dev/null and b/firmware_p4/assets/icons/play_arrow.png differ diff --git a/firmware_p4/assets/icons/podcasts.png b/firmware_p4/assets/icons/podcasts.png new file mode 100644 index 000000000..aedffcaf6 Binary files /dev/null and b/firmware_p4/assets/icons/podcasts.png differ diff --git a/firmware_p4/assets/icons/pointer.png b/firmware_p4/assets/icons/pointer.png deleted file mode 100644 index 2c8c91086..000000000 Binary files a/firmware_p4/assets/icons/pointer.png and /dev/null differ diff --git a/firmware_p4/assets/icons/power_icon.png b/firmware_p4/assets/icons/power_icon.png deleted file mode 100644 index 72288fa14..000000000 Binary files a/firmware_p4/assets/icons/power_icon.png and /dev/null differ diff --git a/firmware_p4/assets/icons/power_settings_new.png b/firmware_p4/assets/icons/power_settings_new.png new file mode 100644 index 000000000..9e24e2570 Binary files /dev/null and b/firmware_p4/assets/icons/power_settings_new.png differ diff --git a/firmware_p4/assets/icons/public.png b/firmware_p4/assets/icons/public.png new file mode 100644 index 000000000..0300cd882 Binary files /dev/null and b/firmware_p4/assets/icons/public.png differ diff --git a/firmware_p4/assets/icons/raw_on.png b/firmware_p4/assets/icons/raw_on.png new file mode 100644 index 000000000..2cb49aaa6 Binary files /dev/null and b/firmware_p4/assets/icons/raw_on.png differ diff --git a/firmware_p4/assets/icons/recharge_menu_icon.png b/firmware_p4/assets/icons/recharge_menu_icon.png deleted file mode 100644 index f355e4491..000000000 Binary files a/firmware_p4/assets/icons/recharge_menu_icon.png and /dev/null differ diff --git a/firmware_p4/assets/icons/remote_menu_icon.png b/firmware_p4/assets/icons/remote_menu_icon.png deleted file mode 100644 index fcd5b7844..000000000 Binary files a/firmware_p4/assets/icons/remote_menu_icon.png and /dev/null differ diff --git a/firmware_p4/assets/icons/repeat.png b/firmware_p4/assets/icons/repeat.png new file mode 100644 index 000000000..824a93162 Binary files /dev/null and b/firmware_p4/assets/icons/repeat.png differ diff --git a/firmware_p4/assets/icons/restart_alt.png b/firmware_p4/assets/icons/restart_alt.png new file mode 100644 index 000000000..a73975655 Binary files /dev/null and b/firmware_p4/assets/icons/restart_alt.png differ diff --git a/firmware_p4/assets/icons/rotate_menu_icon.png b/firmware_p4/assets/icons/rotate_menu_icon.png deleted file mode 100644 index 61129b8a3..000000000 Binary files a/firmware_p4/assets/icons/rotate_menu_icon.png and /dev/null differ diff --git a/firmware_p4/assets/icons/router.png b/firmware_p4/assets/icons/router.png new file mode 100644 index 000000000..7fb9249a3 Binary files /dev/null and b/firmware_p4/assets/icons/router.png differ diff --git a/firmware_p4/assets/icons/screen_rotation.png b/firmware_p4/assets/icons/screen_rotation.png new file mode 100644 index 000000000..20c37a43a Binary files /dev/null and b/firmware_p4/assets/icons/screen_rotation.png differ diff --git a/firmware_p4/assets/icons/sd_card.png b/firmware_p4/assets/icons/sd_card.png new file mode 100644 index 000000000..983d82b42 Binary files /dev/null and b/firmware_p4/assets/icons/sd_card.png differ diff --git a/firmware_p4/assets/icons/search_menu_icon.png b/firmware_p4/assets/icons/search_menu_icon.png deleted file mode 100644 index 9fe24a6f9..000000000 Binary files a/firmware_p4/assets/icons/search_menu_icon.png and /dev/null differ diff --git a/firmware_p4/assets/icons/sensors.png b/firmware_p4/assets/icons/sensors.png new file mode 100644 index 000000000..4ec52208c Binary files /dev/null and b/firmware_p4/assets/icons/sensors.png differ diff --git a/firmware_p4/assets/icons/settings.png b/firmware_p4/assets/icons/settings.png new file mode 100644 index 000000000..4820d6b0c Binary files /dev/null and b/firmware_p4/assets/icons/settings.png differ diff --git a/firmware_p4/assets/icons/settings_input_antenna.png b/firmware_p4/assets/icons/settings_input_antenna.png new file mode 100644 index 000000000..699964131 Binary files /dev/null and b/firmware_p4/assets/icons/settings_input_antenna.png differ diff --git a/firmware_p4/assets/icons/settings_remote.png b/firmware_p4/assets/icons/settings_remote.png new file mode 100644 index 000000000..880462640 Binary files /dev/null and b/firmware_p4/assets/icons/settings_remote.png differ diff --git a/firmware_p4/assets/icons/signal_wifi_0_bar.png b/firmware_p4/assets/icons/signal_wifi_0_bar.png new file mode 100644 index 000000000..06d336dce Binary files /dev/null and b/firmware_p4/assets/icons/signal_wifi_0_bar.png differ diff --git a/firmware_p4/assets/icons/slide_bar_v.png b/firmware_p4/assets/icons/slide_bar_v.png deleted file mode 100644 index d918b6be3..000000000 Binary files a/firmware_p4/assets/icons/slide_bar_v.png and /dev/null differ diff --git a/firmware_p4/assets/icons/smartphone.png b/firmware_p4/assets/icons/smartphone.png new file mode 100644 index 000000000..9ddb80da4 Binary files /dev/null and b/firmware_p4/assets/icons/smartphone.png differ diff --git a/firmware_p4/assets/icons/speaker.png b/firmware_p4/assets/icons/speaker.png new file mode 100644 index 000000000..6a26e27a7 Binary files /dev/null and b/firmware_p4/assets/icons/speaker.png differ diff --git a/firmware_p4/assets/icons/speed.png b/firmware_p4/assets/icons/speed.png new file mode 100644 index 000000000..e7641a6ba Binary files /dev/null and b/firmware_p4/assets/icons/speed.png differ diff --git a/firmware_p4/assets/icons/sports_esports.png b/firmware_p4/assets/icons/sports_esports.png new file mode 100644 index 000000000..837916ef5 Binary files /dev/null and b/firmware_p4/assets/icons/sports_esports.png differ diff --git a/firmware_p4/assets/icons/storage.png b/firmware_p4/assets/icons/storage.png new file mode 100644 index 000000000..913da4512 Binary files /dev/null and b/firmware_p4/assets/icons/storage.png differ diff --git a/firmware_p4/assets/icons/swap_horiz.png b/firmware_p4/assets/icons/swap_horiz.png new file mode 100644 index 000000000..36efa4a41 Binary files /dev/null and b/firmware_p4/assets/icons/swap_horiz.png differ diff --git a/firmware_p4/assets/icons/system_update.png b/firmware_p4/assets/icons/system_update.png new file mode 100644 index 000000000..6373df536 Binary files /dev/null and b/firmware_p4/assets/icons/system_update.png differ diff --git a/firmware_p4/assets/icons/theme_menu_icon.png b/firmware_p4/assets/icons/theme_menu_icon.png deleted file mode 100644 index 7fee73e39..000000000 Binary files a/firmware_p4/assets/icons/theme_menu_icon.png and /dev/null differ diff --git a/firmware_p4/assets/icons/timer.png b/firmware_p4/assets/icons/timer.png new file mode 100644 index 000000000..3e0842552 Binary files /dev/null and b/firmware_p4/assets/icons/timer.png differ diff --git a/firmware_p4/assets/icons/troubleshoot.png b/firmware_p4/assets/icons/troubleshoot.png new file mode 100644 index 000000000..2d1251f84 Binary files /dev/null and b/firmware_p4/assets/icons/troubleshoot.png differ diff --git a/firmware_p4/assets/icons/tune.png b/firmware_p4/assets/icons/tune.png new file mode 100644 index 000000000..bf437828f Binary files /dev/null and b/firmware_p4/assets/icons/tune.png differ diff --git a/firmware_p4/assets/icons/tv.png b/firmware_p4/assets/icons/tv.png new file mode 100644 index 000000000..e98196006 Binary files /dev/null and b/firmware_p4/assets/icons/tv.png differ diff --git a/firmware_p4/assets/icons/usb.png b/firmware_p4/assets/icons/usb.png new file mode 100644 index 000000000..3f85f63fa Binary files /dev/null and b/firmware_p4/assets/icons/usb.png differ diff --git a/firmware_p4/assets/icons/vibration.png b/firmware_p4/assets/icons/vibration.png new file mode 100644 index 000000000..2bb0eb457 Binary files /dev/null and b/firmware_p4/assets/icons/vibration.png differ diff --git a/firmware_p4/assets/icons/volume_icon.png b/firmware_p4/assets/icons/volume_icon.png deleted file mode 100644 index c0397cdf3..000000000 Binary files a/firmware_p4/assets/icons/volume_icon.png and /dev/null differ diff --git a/firmware_p4/assets/icons/volume_up.png b/firmware_p4/assets/icons/volume_up.png new file mode 100644 index 000000000..35937faf1 Binary files /dev/null and b/firmware_p4/assets/icons/volume_up.png differ diff --git a/firmware_p4/assets/icons/vpn_key.png b/firmware_p4/assets/icons/vpn_key.png new file mode 100644 index 000000000..389597c4c Binary files /dev/null and b/firmware_p4/assets/icons/vpn_key.png differ diff --git a/firmware_p4/assets/icons/warning.png b/firmware_p4/assets/icons/warning.png new file mode 100644 index 000000000..097aac100 Binary files /dev/null and b/firmware_p4/assets/icons/warning.png differ diff --git a/firmware_p4/assets/icons/warning_icon.png b/firmware_p4/assets/icons/warning_icon.png deleted file mode 100644 index a5119a14c..000000000 Binary files a/firmware_p4/assets/icons/warning_icon.png and /dev/null differ diff --git a/firmware_p4/assets/icons/watch.png b/firmware_p4/assets/icons/watch.png new file mode 100644 index 000000000..1c74b283d Binary files /dev/null and b/firmware_p4/assets/icons/watch.png differ diff --git a/firmware_p4/assets/icons/waves.png b/firmware_p4/assets/icons/waves.png new file mode 100644 index 000000000..f606938d1 Binary files /dev/null and b/firmware_p4/assets/icons/waves.png differ diff --git a/firmware_p4/assets/icons/wifi.png b/firmware_p4/assets/icons/wifi.png new file mode 100644 index 000000000..f8f0bfee0 Binary files /dev/null and b/firmware_p4/assets/icons/wifi.png differ diff --git a/firmware_p4/assets/icons/wifi_find.png b/firmware_p4/assets/icons/wifi_find.png new file mode 100644 index 000000000..3f35a0ed4 Binary files /dev/null and b/firmware_p4/assets/icons/wifi_find.png differ diff --git a/firmware_p4/assets/icons/wifi_menu_icon.png b/firmware_p4/assets/icons/wifi_menu_icon.png deleted file mode 100644 index 07f6a19bb..000000000 Binary files a/firmware_p4/assets/icons/wifi_menu_icon.png and /dev/null differ diff --git a/firmware_p4/assets/icons/wifi_off.png b/firmware_p4/assets/icons/wifi_off.png new file mode 100644 index 000000000..6d1505e19 Binary files /dev/null and b/firmware_p4/assets/icons/wifi_off.png differ diff --git a/firmware_p4/assets/icons/wifi_sel.png b/firmware_p4/assets/icons/wifi_sel.png deleted file mode 100644 index baf38914f..000000000 Binary files a/firmware_p4/assets/icons/wifi_sel.png and /dev/null differ diff --git a/firmware_p4/assets/icons/wifi_tethering.png b/firmware_p4/assets/icons/wifi_tethering.png new file mode 100644 index 000000000..97996a9ff Binary files /dev/null and b/firmware_p4/assets/icons/wifi_tethering.png differ diff --git a/firmware_p4/assets/img/OCTOBIT.png b/firmware_p4/assets/img/OCTOBIT.png deleted file mode 100644 index da12a84dc..000000000 Binary files a/firmware_p4/assets/img/OCTOBIT.png and /dev/null differ diff --git a/firmware_p4/assets/img/image.png b/firmware_p4/assets/img/image.png new file mode 100644 index 000000000..97f399683 Binary files /dev/null and b/firmware_p4/assets/img/image.png differ diff --git a/firmware_p4/assets/img/octobit_bird.png b/firmware_p4/assets/img/octobit_bird.png new file mode 100644 index 000000000..0438bdae4 Binary files /dev/null and b/firmware_p4/assets/img/octobit_bird.png differ diff --git a/firmware_p4/assets/img/octobit_boot_1.png b/firmware_p4/assets/img/octobit_boot_1.png index fcfd703dc..31976d475 100644 Binary files a/firmware_p4/assets/img/octobit_boot_1.png and b/firmware_p4/assets/img/octobit_boot_1.png differ diff --git a/firmware_p4/assets/img/octobit_portrait.png b/firmware_p4/assets/img/octobit_portrait.png index e0d143a29..cde8069a3 100644 Binary files a/firmware_p4/assets/img/octobit_portrait.png and b/firmware_p4/assets/img/octobit_portrait.png differ diff --git a/firmware_p4/assets/label/HOME_MENU.png b/firmware_p4/assets/label/HOME_MENU.png deleted file mode 100644 index 4b6e5d970..000000000 Binary files a/firmware_p4/assets/label/HOME_MENU.png and /dev/null differ diff --git a/firmware_p4/components/Applications/CMakeLists.txt b/firmware_p4/components/Applications/CMakeLists.txt index 9f291e8eb..c9f7e1884 100644 --- a/firmware_p4/components/Applications/CMakeLists.txt +++ b/firmware_p4/components/Applications/CMakeLists.txt @@ -13,56 +13,24 @@ # You should have received a copy of the GNU General Public License # along with TentacleOS. If not, see . +# The UI is a mirror of firmware_p4_prototype's screens (stubbed). One recursive +# glob picks up every screen, component and support file under ui/ (plus +# ui_manager.c and assets_manager.c at the ui/ root) — new screens compile +# automatically. INCLUDE_DIRS is the only hand-maintained part; add a screen's +# include/ dir there when a new screen area is introduced. file(GLOB_RECURSE UI_SRCS "ui/*.c") -file(GLOB_RECURSE BOOT_UI_SRCS "ui/screens/boot/*.c") -file(GLOB_RECURSE HOME_UI_SRCS "ui/screens/home/*.c") -file(GLOB_RECURSE MENU_UI_SRCS "ui/screens/menu/*.c") -file(GLOB_RECURSE WIFI_UI_SRCS "ui/screens/wifi/*.c") -list(APPEND WIFI_UI_SRCS "ui/screens/wifi/wifi_scan_menu_ui.c") -list(APPEND WIFI_UI_SRCS "ui/screens/wifi/wifi_attack_menu_ui.c") -list(APPEND WIFI_UI_SRCS "ui/screens/wifi/wifi_packets_menu_ui.c") -list(APPEND WIFI_UI_SRCS "ui/screens/wifi/wifi_sniffer_raw_ui.c") -list(APPEND WIFI_UI_SRCS "ui/screens/wifi/wifi_sniffer_attack_ui.c") -list(APPEND WIFI_UI_SRCS "ui/screens/wifi/wifi_sniffer_handshake_ui.c") -list(APPEND WIFI_UI_SRCS "ui/screens/wifi/wifi_deauth_attack_ui.c") -list(APPEND WIFI_UI_SRCS "ui/screens/wifi/wifi_beacon_spam_simple_ui.c") -list(APPEND WIFI_UI_SRCS "ui/screens/wifi/wifi_probe_flood_ui.c") -list(APPEND WIFI_UI_SRCS "ui/screens/wifi/wifi_auth_flood_ui.c") -list(APPEND WIFI_UI_SRCS "ui/screens/wifi/wifi_scan_ap_ui.c") -list(APPEND WIFI_UI_SRCS "ui/screens/wifi/wifi_scan_stations_ui.c") -list(APPEND WIFI_UI_SRCS "ui/screens/wifi/wifi_scan_target_ui.c") -list(APPEND WIFI_UI_SRCS "ui/screens/wifi/wifi_scan_probe_ui.c") -list(APPEND WIFI_UI_SRCS "ui/screens/wifi/wifi_scan_monitor_ui.c") -file(GLOB_RECURSE BLE_UI_SRCS "ui/screens/bluetooth/*.c") -file(GLOB_RECURSE SUBGHZ_UI_SRCS "ui/screens/SubGhz/*.c") -file(GLOB_RECURSE SUB_EXAMPLE_UI_SRCS "ui/screens/sub_example/*.c") -file(GLOB_RECURSE BADUSB_UI_SRCS "ui/screens/badusb/*.c") -file(GLOB_RECURSE IR_UI_SRCS "ui/screens/infrared/*.c") - -#COMPONENTS -file(GLOB_RECURSE HEADER_UI_SRCS "ui/components/header/*.c") -file(GLOB_RECURSE FOOTER_UI_SRCS "ui/components/footer/*.c") -file(GLOB_RECURSE KEYBOARD_UI_SRCS "ui/components/keyboard/*.c") -file(GLOB_RECURSE MSGBOX_UI_SRCS "ui/components/message_box/*.c") -file(GLOB_RECURSE DROPDOWN_UI_SRCS "ui/components/dropdown/*.c") -file(GLOB_RECURSE TOGGLE_UI_SRCS "ui/components/toggle/*.c") -file(GLOB_RECURSE SPINNER_UI_SRCS "ui/components/spinner/*.c") - -#SETINGS SCREENS -file(GLOB_RECURSE SETTINGS_UI_SRCS "ui/screens/settings/*.c") -file(GLOB_RECURSE DISPLAY_SETTINGS_UI_SRCS "ui/screens/display_settings/*.c") -file(GLOB_RECURSE SOUND_SETTINGS_UI_SRCS "ui/screens/sound_settings/*.c") -file(GLOB_RECURSE INTERFACE_SETTINGS_UI_SRCS "ui/screens/interface_settings/*.c") -file(GLOB_RECURSE BATTERY_SETTINGS_UI_SRCS "ui/screens/battery_settings/*.c") -file(GLOB_RECURSE CONNECTION_SETTINGS_UI_SRCS "ui/screens/connection_settings/*.c") -file(GLOB_RECURSE ABOUT_SETTINGS_UI_SRCS "ui/screens/about_settings/*.c") -file(GLOB_RECURSE THEME_SELECTOR_UI_SRCS "ui/screens/theme_selector/*.c") - -file(GLOB_RECURSE CONNECT_WIFI_UI_SRCS "ui/screens/connect_wifi/*.c") -file(GLOB_RECURSE CONNECT_BLUETOOTH_UI_SRCS "ui/screens/connect_bluetooth/*.c") +list(REMOVE_ITEM UI_SRCS + "${CMAKE_CURRENT_SOURCE_DIR}/ui/screens/games/games_menu_ui.c" + "${CMAKE_CURRENT_SOURCE_DIR}/ui/screens/games/flappy_ui.c" + "${CMAKE_CURRENT_SOURCE_DIR}/ui/screens/games/snake_ui.c" + "${CMAKE_CURRENT_SOURCE_DIR}/ui/screens/games/breakout_ui.c" + "${CMAKE_CURRENT_SOURCE_DIR}/ui/screens/games/octopet_ui.c" + "${CMAKE_CURRENT_SOURCE_DIR}/ui/screens/games/motion_ui.c" + "${CMAKE_CURRENT_SOURCE_DIR}/ui/screens/games/game_fx.c" +) -#APPLICATION +# Application (non-UI) sources — the real drivers/services stay in the build. file(GLOB_RECURSE SUBGHZ_APP_SRCS "SubGhz/*.c") file(GLOB_RECURSE BLE_APP_SRCS "bluetooth/*.c") file(GLOB_RECURSE BADUSB_APP_SRCS "bad_usb/*.c") @@ -70,47 +38,26 @@ file(GLOB_RECURSE WIFI_APP_SRCS "wifi/*.c") file(GLOB_RECURSE NFC_APP_SRCS "nfc/*.c") file(GLOB_RECURSE RFID_APP_SRCS "rfid/*.c") file(GLOB_RECURSE RNODE_APP_SRCS "LoRa/rnode/*.c") +file(GLOB MESHCORE_APP_SRCS "LoRa/meshcore/*.c") +file(GLOB MESHTASTIC_APP_SRCS "LoRa/meshtastic/*.c") +file(GLOB LORA_SESSION_SRCS "LoRa/session/*.c") -idf_component_register(SRCS +idf_component_register(SRCS ${UI_SRCS} ${WIFI_APP_SRCS} ${NFC_APP_SRCS} ${RFID_APP_SRCS} ${RNODE_APP_SRCS} + ${MESHCORE_APP_SRCS} + ${MESHTASTIC_APP_SRCS} + ${LORA_SESSION_SRCS} ${BADUSB_APP_SRCS} ${BLE_APP_SRCS} - "ui/ui_manager.c" - "ui/assets_manager.c" ${SUBGHZ_APP_SRCS} - ${SUBGHZ_UI_SRCS} - ${BLE_UI_SRCS} - ${BOOT_UI_SRCS} - ${HOME_UI_SRCS} - ${MENU_UI_SRCS} - ${WIFI_UI_SRCS} - ${SUB_EXAMPLE_UI_SRCS} - ${BADUSB_UI_SRCS} - ${IR_UI_SRCS} - ${HEADER_UI_SRCS} - ${FOOTER_UI_SRCS} - ${KEYBOARD_UI_SRCS} - ${SETTINGS_UI_SRCS} - ${DISPLAY_SETTINGS_UI_SRCS} - ${INTERFACE_SETTINGS_UI_SRCS} - ${SOUND_SETTINGS_UI_SRCS} - ${BATTERY_SETTINGS_UI_SRCS} - ${CONNECTION_SETTINGS_UI_SRCS} - ${CONNECT_BLUETOOTH_UI_SRCS} - ${CONNECT_WIFI_UI_SRCS} - ${ABOUT_SETTINGS_UI_SRCS} - ${THEME_SELECTOR_UI_SRCS} - ${MSGBOX_UI_SRCS} - ${DROPDOWN_UI_SRCS} - ${TOGGLE_UI_SRCS} - ${SPINNER_UI_SRCS} INCLUDE_DIRS - "wifi/include" + # --- application (non-ui) --- + "wifi/include" "nfc/include" "nfc/protocols/common/include" "nfc/protocols/emv/include" @@ -122,21 +69,6 @@ idf_component_register(SRCS "nfc/protocols/mifare/include" "nfc/protocols/t1t/include" "nfc/protocols/t2t/include" - "ui/include" - "ui/screens/SubGhz/include" - "ui/screens/bluetooth/include" - "ui/screens/settings/include" - "ui/screens/display_settings/include" - "ui/screens/sound_settings/include" - "ui/screens/interface_settings/include" - "ui/screens/battery_settings/include" - "ui/screens/connection_settings/include" - "ui/screens/connect_wifi/include" - "ui/screens/connect_bluetooth/include" - "ui/screens/about_settings/include" - "ui/screens/theme_selector/include" - "ui/screens/badusb/include" - "ui/screens/infrared/include" "bad_usb/include" "bluetooth/include" "SubGhz/include" @@ -144,33 +76,69 @@ idf_component_register(SRCS "rfid/include" "rfid/protocols/include" "LoRa/rnode/include" + "LoRa/meshcore/include" + "LoRa/meshtastic/include" + "LoRa/session/include" + # --- ui core --- "ui/include" - "ui/screens/home/include" + # --- ui screens (mirrors firmware_p4_prototype) --- + "ui/screens/audio/include" + "ui/screens/badusb/include" + "ui/screens/bluetooth/include" "ui/screens/boot/include" + "ui/screens/boot_report/include" + "ui/screens/connect_bluetooth/include" + "ui/screens/connect_wifi/include" + "ui/screens/connection_settings/include" + "ui/screens/dev/include" + "ui/screens/files/include" + "ui/screens/games/include" + "ui/screens/gpio/include" + "ui/screens/haptic/include" + "ui/screens/home/include" + "ui/screens/infrared/include" + "ui/screens/lora/include" "ui/screens/menu/include" - "ui/screens/wifi/include" "ui/screens/nfc/include" - "ui/screens/files/include" - "ui/screens/sub_example/include" - "ui/components/header/include" + "ui/screens/octobit/include" + "ui/screens/power/include" + "ui/screens/rfid/include" + "ui/screens/safe_mode/include" + "ui/screens/settings/include" + "ui/screens/subghz/include" + "ui/screens/theme/include" + "ui/screens/wifi/include" + # --- ui components --- + "ui/components/button/include" + "ui/components/capture_result/include" + "ui/components/chart/include" + "ui/components/chrome/include" + "ui/components/dropdown/include" + "ui/components/error/include" + "ui/components/feedback/include" "ui/components/footer/include" + "ui/components/header/include" + "ui/components/intensity_bar/include" "ui/components/keyboard/include" - "ui/components/message_box/include" - "ui/components/dropdown/include" - "ui/components/toggle/include" "ui/components/menu_component/include" - "ui/components/button/include" - "ui/components/terminal/include" + "ui/components/message_box/include" + "ui/components/notify/include" + "ui/components/octobit/include" + "ui/components/power_policy/include" + "ui/components/reboot/include" "ui/components/page_dots/include" - "ui/components/intensity_bar/include" - "ui/components/chart/include" - "ui/components/text_viewer/include" + "ui/components/sigwave/include" + "ui/components/subghz_scope/include" "ui/components/spinner/include" - - + "ui/components/terminal/include" + "ui/components/text_viewer/include" + "ui/components/toggle/include" + "ui/components/tutorial/include" + "ui/components/waves/include" REQUIRES driver + esp_driver_tsens Drivers Service esp_common @@ -178,5 +146,15 @@ idf_component_register(SRCS esp_tinyusb mbedtls bt + libsodium + mqtt + joltwallet__littlefs + nvs_flash + espressif__esp-dsp ) target_link_libraries(${COMPONENT_LIB} -Wl,-zmuldefs) +target_compile_definitions(${COMPONENT_LIB} PRIVATE MESH_HEADLESS=0) +# Ported screens/components use the LVGL idiom of casting a style setter to +# lv_anim_exec_xcb_t for animations; GCC14/IDF5 flags this under +# -Wcast-function-type. Downgrade it from error for this component. +target_compile_options(${COMPONENT_LIB} PRIVATE -Wno-error=cast-function-type) diff --git a/firmware_p4/components/Applications/LoRa/meshcore/include/meshcore_app.h b/firmware_p4/components/Applications/LoRa/meshcore/include/meshcore_app.h new file mode 100644 index 000000000..92bffd7fa --- /dev/null +++ b/firmware_p4/components/Applications/LoRa/meshcore/include/meshcore_app.h @@ -0,0 +1,57 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// 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. + +#ifndef MESHCORE_APP_H +#define MESHCORE_APP_H + +#ifdef __cplusplus +extern "C" { +#endif + +#include "esp_err.h" + +/** + * @brief Bring up the full MeshCore stack on the P4. + * + * Order of operations: + * 1. libsodium init (provides Ed25519 sign/verify and X25519 ECDH). + * 2. SX1262 HAL + driver init with MeshCore defaults + * (915 MHz, SF10, BW 250 kHz, CR 4/5, +20 dBm). + * 3. SX1262 IRQ task. + * 4. Load or generate Ed25519 identity (32-byte seed persisted in + * NVS under "id_seed"; keypair derived via crypto_sign_seed_keypair). + * 5. meshcore core (DB, router, radio prefs) with router callbacks + * wired to the phoneapi push helpers. + * 6. meshcore phoneapi (Companion protocol). + * 7. meshcore phone bridge (SPI to C5 + BLE NUS termination). + * 8. meshcore RX continuous. + * 9. Spawns the meshcore_poll() task. + * 10. Requests the C5 to start BLE advertising. + * + * Must be called AFTER bridge_manager_init() so the SPI bridge to the + * C5 is ready. + * + * @return + * - ESP_OK on success + * - ESP_FAIL if libsodium init or keypair derivation fails + * - Driver error code if SX1262 init or meshcore init fails + * - ESP_ERR_NO_MEM if the poll task cannot be spawned + */ +esp_err_t meshcore_app_start(void); + +#ifdef __cplusplus +} +#endif + +#endif // MESHCORE_APP_H diff --git a/firmware_p4/components/Applications/LoRa/meshcore/meshcore_internal.h b/firmware_p4/components/Applications/LoRa/meshcore/include/meshcore_internal.h similarity index 64% rename from firmware_p4/components/Applications/LoRa/meshcore/meshcore_internal.h rename to firmware_p4/components/Applications/LoRa/meshcore/include/meshcore_internal.h index 48c53cdfd..808fa3e8b 100644 --- a/firmware_p4/components/Applications/LoRa/meshcore/meshcore_internal.h +++ b/firmware_p4/components/Applications/LoRa/meshcore/include/meshcore_internal.h @@ -84,12 +84,77 @@ uint8_t mc_build_header(uint8_t ver, uint8_t ptype, uint8_t route); uint8_t mc_build_path_len(uint8_t hash_size_sel, uint8_t count); uint8_t mc_parse_hash_size(uint8_t sel); +/** + * @brief Plain SHA-256 of an arbitrary buffer. + */ +void meshcore_crypto_sha256(const uint8_t *data, size_t len, uint8_t out[32]); + +/** + * @brief AES-ECB encrypt + HMAC-SHA256 truncated MAC prefix. + */ +int meshcore_crypto_encrypt_mac(const uint8_t *shared_secret, + uint8_t *dest, + const uint8_t *src, + int src_len); + +/** + * @brief Verify HMAC prefix and AES-ECB decrypt. Returns plaintext length, 0 on fail. + */ +int meshcore_crypto_mac_decrypt(const uint8_t *shared_secret, + uint8_t *dest, + const uint8_t *src, + int src_len); + +/** + * @brief Parse a raw wire packet into a view. Returns false on malformed input. + */ +bool meshcore_packet_parse(const uint8_t *raw, uint16_t raw_len, meshcore_packet_view_t *out); + +/** + * @brief 8-byte content hash used for dedup. + */ +void meshcore_packet_hash(const meshcore_packet_view_t *pkt, uint8_t out[8]); + +/** + * @brief Build a self-advert wire packet (signed). Returns packet length, 0 on error. + */ +uint16_t meshcore_packet_build_advert(const meshcore_identity_t *identity, + int32_t lat_e6, + int32_t lon_e6, + bool has_latlon, + uint32_t unix_ts, + uint8_t *out, + uint16_t out_cap); + +/** + * @brief Build an encrypted group-text wire packet. Returns packet length, 0 on error. + */ +uint16_t meshcore_packet_build_grp_txt(uint8_t channel_hash, + const uint8_t channel_secret[32], + const char *sender_name, + const char *text, + uint32_t unix_ts, + uint8_t *out, + uint16_t out_cap); + /** * @brief sha256(a || b)[:out_len]. Used for ACK CRC. */ void mc_sha256_two( uint8_t *out, size_t out_len, const uint8_t *a, size_t a_len, const uint8_t *b, size_t b_len); +/** + * @brief X25519 ECDH derived from an Ed25519 keypair (libsodium-backed). + * + * Converts both keys to Curve25519 form and performs scalar multiplication. + * Used by router to derive per-peer shared secrets for DM encryption. + * + * @param[out] out_shared 32-byte shared secret. + * @param peer_pub_key Peer's 32-byte Ed25519 public key. + * @param my_sk Local 64-byte Ed25519 secret key (libsodium format). + */ +void mc_x25519(uint8_t out_shared[32], const uint8_t peer_pub_key[32], const uint8_t my_sk[64]); + /* DB internal (meshcore_db.c) */ bool mc_dedup_check_add(const uint8_t hash[8]); void mc_pendings_gc(void); diff --git a/firmware_p4/components/Applications/LoRa/meshcore/meshcore_app.c b/firmware_p4/components/Applications/LoRa/meshcore/meshcore_app.c new file mode 100644 index 000000000..79b8ec43a --- /dev/null +++ b/firmware_p4/components/Applications/LoRa/meshcore/meshcore_app.c @@ -0,0 +1,269 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// 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. + +#include "meshcore_app.h" + +#include +#include + +#include "esp_log.h" +#include "esp_random.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include "sys_prio.h" + +#include "lora_session.h" +#include "meshcore.h" +#include "meshcore_nvs.h" +#include "meshcore_phone_bridge.h" +#include "meshcore_phoneapi.h" +#include "sodium.h" +#include "sx1262.h" +#include "sx1262_hal.h" +#include "sx1262_regs.h" + +static const char *TAG = "MC_APP"; + +#define MC_IDENTITY_NVS_KEY "id_seed" +#define MC_IDENTITY_SEED_SIZE 32 +#define MC_DEFAULT_NAME "Highboy" +#define MC_BRIDGE_NAME_PREFIX NULL + +#define MC_POLL_TASK_STACK 12288 +#define MC_POLL_TASK_PRIO SYS_PRIO_BACKGROUND +#define MC_POLL_PERIOD_MS 50 + +static void poll_task(void *pv); +static void +on_advert_cb(const meshcore_contact_t *contact, int16_t rssi_dbm, int8_t snr_db, void *ctx); +static void on_grp_txt_cb(uint8_t channel_idx, + uint8_t path_len, + const char *text, + uint32_t timestamp, + int16_t rssi_dbm, + int8_t snr_db, + void *ctx); +static void on_direct_msg_cb(const uint8_t peer_pub_key[32], + const char *text, + uint32_t timestamp, + uint8_t txt_type, + uint8_t path_len, + int16_t rssi_dbm, + int8_t snr_db, + void *ctx); +static void on_ack_cb(uint32_t ack_crc, const uint8_t peer_pub_key[32], void *ctx); +static void on_path_update_cb(const meshcore_contact_t *contact, void *ctx); +static esp_err_t load_or_create_identity(meshcore_identity_t *out); + +esp_err_t meshcore_app_start(void) { + if (sodium_init() < 0) { + ESP_LOGE(TAG, "sodium_init failed"); + return ESP_FAIL; + } + + sx1262_config_t cfg = {0}; + esp_err_t ret = sx1262_hal_create(&cfg.hal); + if (ret != ESP_OK) { + ESP_LOGE(TAG, "sx1262_hal_create failed: %s", esp_err_to_name(ret)); + return ret; + } + + cfg.frequency_hz = MESHCORE_FREQ_HZ; + cfg.sf = MESHCORE_SF; + cfg.bw = SX1262_LORA_BW_250; + cfg.cr = SX1262_LORA_CR_4_5; + cfg.tx_power_dbm = MESHCORE_TX_POWER_DBM; + cfg.preamble_len = MESHCORE_PREAMBLE_LEN; + cfg.is_crc_on = true; + cfg.is_inverted_iq = false; + cfg.is_implicit_hdr = false; + cfg.is_public_network = false; + + ret = sx1262_init(&cfg); + if (ret != ESP_OK) { + ESP_LOGE(TAG, "sx1262_init failed: %s", esp_err_to_name(ret)); + return ret; + } + ret = sx1262_start(); + if (ret != ESP_OK) { + ESP_LOGE(TAG, "sx1262_start failed: %s", esp_err_to_name(ret)); + return ret; + } + ESP_LOGI(TAG, + "SX1262 ready (%lu Hz, SF%u, BW250k, CR4/5, %d dBm)", + (unsigned long)MESHCORE_FREQ_HZ, + MESHCORE_SF, + MESHCORE_TX_POWER_DBM); + + meshcore_identity_t identity; + ret = load_or_create_identity(&identity); + if (ret != ESP_OK) { + return ret; + } + + meshcore_callbacks_t cbs = { + .on_advert = on_advert_cb, + .on_grp_txt = on_grp_txt_cb, + .on_direct_msg = on_direct_msg_cb, + .on_ack = on_ack_cb, + .on_path_update = on_path_update_cb, + .ctx = NULL, + }; + ret = meshcore_init(&identity, &cbs); + if (ret != ESP_OK) { + ESP_LOGE(TAG, "meshcore_init failed: %s", esp_err_to_name(ret)); + return ret; + } + + ret = meshcore_phoneapi_init(); + if (ret != ESP_OK) { + ESP_LOGE(TAG, "meshcore_phoneapi_init failed: %s", esp_err_to_name(ret)); + return ret; + } + + ret = meshcore_phone_bridge_init(MC_BRIDGE_NAME_PREFIX); + if (ret != ESP_OK) { + ESP_LOGE(TAG, "meshcore_phone_bridge_init failed: %s", esp_err_to_name(ret)); + return ret; + } + + ret = meshcore_start(); + if (ret != ESP_OK) { + ESP_LOGE(TAG, "meshcore_start failed: %s", esp_err_to_name(ret)); + return ret; + } + + if (xTaskCreatePinnedToCore(poll_task, + "mc_poll", + MC_POLL_TASK_STACK, + NULL, + MC_POLL_TASK_PRIO, + NULL, + SYS_CORE_RADIO) != pdPASS) { + ESP_LOGE(TAG, "Failed to spawn poll task"); + return ESP_ERR_NO_MEM; + } + + ret = meshcore_phone_bridge_ble_start(); + if (ret != ESP_OK) { + ESP_LOGW(TAG, "meshcore_phone_bridge_ble_start failed: %s (will retry)", esp_err_to_name(ret)); + } + + ESP_LOGI(TAG, "MeshCore stack online -- waiting for phone"); + return ESP_OK; +} + +static void poll_task(void *pv) { + (void)pv; + const TickType_t period = pdMS_TO_TICKS(MC_POLL_PERIOD_MS); + while (1) { + meshcore_poll(); + vTaskDelay(period); + } +} + +static void +on_advert_cb(const meshcore_contact_t *contact, int16_t rssi_dbm, int8_t snr_db, void *ctx) { + (void)rssi_dbm; + (void)snr_db; + (void)ctx; + meshcore_phoneapi_push_new_advert(contact); +} + +static void on_grp_txt_cb(uint8_t channel_idx, + uint8_t path_len, + const char *text, + uint32_t timestamp, + int16_t rssi_dbm, + int8_t snr_db, + void *ctx) { + (void)rssi_dbm; + (void)ctx; + meshcore_phoneapi_push_channel_msg(channel_idx, path_len, timestamp, snr_db, text); + + char who[MESHCORE_NAME_MAX]; + snprintf(who, sizeof(who), "ch%u", channel_idx); + lora_session_on_rx_text(who, text); +} + +static void on_direct_msg_cb(const uint8_t peer_pub_key[32], + const char *text, + uint32_t timestamp, + uint8_t txt_type, + uint8_t path_len, + int16_t rssi_dbm, + int8_t snr_db, + void *ctx) { + (void)rssi_dbm; + (void)ctx; + meshcore_phoneapi_push_contact_msg(peer_pub_key, path_len, txt_type, timestamp, snr_db, text); + + char who[MESHCORE_NAME_MAX]; + const meshcore_contact_t *c = meshcore_contact_find(peer_pub_key); + if (c != NULL && c->name[0] != '\0') + snprintf(who, sizeof(who), "%s", c->name); + else + snprintf(who, sizeof(who), "dm"); + lora_session_on_rx_text(who, text); +} + +static void on_ack_cb(uint32_t ack_crc, const uint8_t peer_pub_key[32], void *ctx) { + (void)peer_pub_key; + (void)ctx; + meshcore_phoneapi_push_send_confirmed(ack_crc, 0); +} + +static void on_path_update_cb(const meshcore_contact_t *contact, void *ctx) { + (void)ctx; + meshcore_phoneapi_push_path_updated(contact); +} + +static esp_err_t load_or_create_identity(meshcore_identity_t *out) { + memset(out, 0, sizeof(*out)); + + uint8_t seed[MC_IDENTITY_SEED_SIZE]; + size_t len = sizeof(seed); + esp_err_t ret = mc_nvs_get_blob(MC_IDENTITY_NVS_KEY, seed, &len); + bool is_loaded = (ret == ESP_OK && len == sizeof(seed)); + + if (!is_loaded) { + esp_fill_random(seed, sizeof(seed)); + ret = mc_nvs_set_blob(MC_IDENTITY_NVS_KEY, seed, sizeof(seed)); + if (ret != ESP_OK) { + ESP_LOGE(TAG, "Persist identity seed failed: %s", esp_err_to_name(ret)); + sodium_memzero(seed, sizeof(seed)); + return ret; + } + } + + if (crypto_sign_seed_keypair(out->pub_key, out->priv_key, seed) != 0) { + ESP_LOGE(TAG, "crypto_sign_seed_keypair failed"); + sodium_memzero(seed, sizeof(seed)); + return ESP_FAIL; + } + sodium_memzero(seed, sizeof(seed)); + + ESP_LOGI(TAG, + "Identity %s (pub %02X%02X%02X%02X..)", + is_loaded ? "loaded from NVS" : "generated", + out->pub_key[0], + out->pub_key[1], + out->pub_key[2], + out->pub_key[3]); + + strncpy(out->name, MC_DEFAULT_NAME, MESHCORE_NAME_MAX - 1); + out->name[MESHCORE_NAME_MAX - 1] = 0; + out->adv_type = MC_ADV_TYPE_CHAT; + return ESP_OK; +} diff --git a/firmware_p4/components/Applications/LoRa/meshcore/meshcore_crypto.c b/firmware_p4/components/Applications/LoRa/meshcore/meshcore_crypto.c index fa8deb2fe..c815a6c79 100644 --- a/firmware_p4/components/Applications/LoRa/meshcore/meshcore_crypto.c +++ b/firmware_p4/components/Applications/LoRa/meshcore/meshcore_crypto.c @@ -22,8 +22,7 @@ #include "mbedtls/aes.h" #include "mbedtls/md.h" #include "mbedtls/sha256.h" - -#include "ed25519.h" +#include "sodium.h" static int aes_ecb_encrypt_pad(const uint8_t key[16], uint8_t *dest, const uint8_t *src, int src_len); @@ -181,7 +180,7 @@ uint16_t meshcore_packet_build_advert(const meshcore_identity_t *identity, memcpy(&sign_buf[32], &out[2 + 32], 4); memcpy(&sign_buf[36], app_data, app_len); - ed25519_sign(&out[sig_pos], sign_buf, sign_len, identity->pub_key, identity->priv_key); + crypto_sign_detached(&out[sig_pos], NULL, sign_buf, sign_len, identity->priv_key); return pos; } @@ -253,6 +252,16 @@ uint8_t mc_parse_hash_size(uint8_t sel) { } } +void mc_x25519(uint8_t out_shared[32], const uint8_t peer_pub_key[32], const uint8_t my_sk[64]) { + uint8_t curve_sk[crypto_scalarmult_SCALARBYTES]; + uint8_t curve_pk[crypto_scalarmult_BYTES]; + crypto_sign_ed25519_sk_to_curve25519(curve_sk, my_sk); + crypto_sign_ed25519_pk_to_curve25519(curve_pk, peer_pub_key); + crypto_scalarmult(out_shared, curve_sk, curve_pk); + sodium_memzero(curve_sk, sizeof(curve_sk)); + sodium_memzero(curve_pk, sizeof(curve_pk)); +} + void mc_sha256_two( uint8_t *out, size_t out_len, const uint8_t *a, size_t a_len, const uint8_t *b, size_t b_len) { uint8_t full[32]; diff --git a/firmware_p4/components/Applications/LoRa/meshcore/meshcore_phone_bridge.c b/firmware_p4/components/Applications/LoRa/meshcore/meshcore_phone_bridge.c index d48312be7..bcc906a74 100644 --- a/firmware_p4/components/Applications/LoRa/meshcore/meshcore_phone_bridge.c +++ b/firmware_p4/components/Applications/LoRa/meshcore/meshcore_phone_bridge.c @@ -21,6 +21,7 @@ #include "freertos/FreeRTOS.h" #include "freertos/semphr.h" #include "freertos/task.h" +#include "sys_prio.h" #include "meshcore_phoneapi.h" #include "spi_bridge.h" @@ -28,7 +29,7 @@ static const char *TAG = "MC_BRIDGE"; #define BRIDGE_TASK_STACK 4096 -#define BRIDGE_TASK_PRIO 5 +#define BRIDGE_TASK_PRIO SYS_PRIO_SERVICE_HI #define BRIDGE_STATUS_TICK_MS 200 #define BRIDGE_SPI_TIMEOUT_MS 1000 #define BRIDGE_RX_FRAME_MAX 512 @@ -106,8 +107,13 @@ esp_err_t meshcore_phone_bridge_init(const char *name_prefix) { meshcore_phoneapi_set_outbound(on_phoneapi_outbound, NULL); s_is_running = true; - BaseType_t ok = xTaskCreate( - status_task, "mc_bridge", BRIDGE_TASK_STACK, NULL, BRIDGE_TASK_PRIO, &s_status_task); + BaseType_t ok = xTaskCreatePinnedToCore(status_task, + "mc_bridge", + BRIDGE_TASK_STACK, + NULL, + BRIDGE_TASK_PRIO, + &s_status_task, + SYS_CORE_RADIO); if (ok != pdPASS) { s_is_running = false; meshcore_phoneapi_set_outbound(NULL, NULL); @@ -235,7 +241,7 @@ static esp_err_t push_frame(const uint8_t *frame, uint16_t len) { uint8_t cmd_len = (uint8_t)(sizeof(hdr) + this_chunk); esp_err_t ret = spi_bridge_send_command( - SPI_ID_MCORE_TX_PUSH, buf, cmd_len, NULL, NULL, BRIDGE_SPI_TIMEOUT_MS); + SPI_ID_MCORE_TX_PUSH, buf, cmd_len, NULL, NULL, 0, BRIDGE_SPI_TIMEOUT_MS); if (ret != ESP_OK) { return ret; } @@ -295,8 +301,13 @@ static esp_err_t fetch_status(spi_mcore_status_t *out_status) { return ESP_ERR_INVALID_ARG; } spi_header_t resp; - return spi_bridge_send_command( - SPI_ID_MCORE_STATUS, NULL, 0, &resp, (uint8_t *)out_status, BRIDGE_SPI_TIMEOUT_MS); + return spi_bridge_send_command(SPI_ID_MCORE_STATUS, + NULL, + 0, + &resp, + (uint8_t *)out_status, + sizeof(*out_status), + BRIDGE_SPI_TIMEOUT_MS); } static void store_status(const spi_mcore_status_t *status) { @@ -314,7 +325,7 @@ static esp_err_t request_ble_init(void) { strncpy(req.name_prefix, s_name_prefix, sizeof(req.name_prefix) - 1); req.pin = meshcore_phoneapi_get_pin(); esp_err_t ret = spi_bridge_send_command( - SPI_ID_MCORE_BLE_INIT, (uint8_t *)&req, sizeof(req), NULL, NULL, BRIDGE_SPI_TIMEOUT_MS); + SPI_ID_MCORE_BLE_INIT, (uint8_t *)&req, sizeof(req), NULL, NULL, 0, BRIDGE_SPI_TIMEOUT_MS); if (ret == ESP_OK) { s_ble_active_on_c5 = true; } @@ -323,7 +334,7 @@ static esp_err_t request_ble_init(void) { static esp_err_t request_ble_stop(void) { esp_err_t ret = - spi_bridge_send_command(SPI_ID_MCORE_BLE_STOP, NULL, 0, NULL, NULL, BRIDGE_SPI_TIMEOUT_MS); + spi_bridge_send_command(SPI_ID_MCORE_BLE_STOP, NULL, 0, NULL, NULL, 0, BRIDGE_SPI_TIMEOUT_MS); if (ret == ESP_OK) { s_ble_active_on_c5 = false; } diff --git a/firmware_p4/components/Applications/LoRa/meshcore/meshcore_router.c b/firmware_p4/components/Applications/LoRa/meshcore/meshcore_router.c index 8f508aed7..bbb874a40 100644 --- a/firmware_p4/components/Applications/LoRa/meshcore/meshcore_router.c +++ b/firmware_p4/components/Applications/LoRa/meshcore/meshcore_router.c @@ -24,7 +24,7 @@ #include "freertos/FreeRTOS.h" #include "freertos/task.h" -#include "ed25519.h" +#include "sodium.h" #include "sx1262.h" #include "sx1262_regs.h" @@ -133,7 +133,7 @@ esp_err_t meshcore_send_direct_msg(const uint8_t peer_pub_key[32], *out_expected_ack = ack_crc; uint8_t shared[32]; - ed25519_key_exchange(shared, peer_pub_key, id->priv_key); + mc_x25519(shared, peer_pub_key, id->priv_key); const meshcore_contact_t *c = meshcore_contact_find(peer_pub_key); bool has_path = (c != NULL && c->out_path_len != MESHCORE_OUT_PATH_UNKNOWN); @@ -274,7 +274,7 @@ esp_err_t mc_send_path_return(const uint8_t peer_pub_key[32], const meshcore_identity_t *id = mc_get_identity(); uint8_t shared[32]; - ed25519_key_exchange(shared, peer_pub_key, id->priv_key); + mc_x25519(shared, peer_pub_key, id->priv_key); uint8_t plaintext[2 + MESHCORE_MAX_PATH + 1 + 16]; int pt_len = 0; @@ -333,7 +333,7 @@ static void process_advert(const meshcore_packet_view_t *pkt) { memcpy(&sign_buf[0], pubkey, 32); memcpy(&sign_buf[32], &p[32], 4); memcpy(&sign_buf[36], app_data, sig_app_len); - if (!ed25519_verify(sig, sign_buf, 36 + sig_app_len, pubkey)) { + if (crypto_sign_verify_detached(sig, sign_buf, 36 + sig_app_len, pubkey) != 0) { ESP_LOGW(TAG, "ADVERT invalid sig (%02X%02X%02X%02X) -- discard", pubkey[0], @@ -466,7 +466,7 @@ static void process_txt_msg(const meshcore_packet_view_t *pkt) { continue; uint8_t shared[32]; - ed25519_key_exchange(shared, contacts[i].pub_key, id->priv_key); + mc_x25519(shared, contacts[i].pub_key, id->priv_key); uint8_t plaintext[160]; int pt_len = @@ -592,7 +592,7 @@ static void process_path_payload(const meshcore_packet_view_t *pkt) { continue; uint8_t shared[32]; - ed25519_key_exchange(shared, contacts[i].pub_key, id->priv_key); + mc_x25519(shared, contacts[i].pub_key, id->priv_key); uint8_t plaintext[200]; int pt_len = diff --git a/firmware_p4/components/Applications/LoRa/meshtastic/include/mt_mod_traceroute.h b/firmware_p4/components/Applications/LoRa/meshtastic/include/mt_mod_traceroute.h index 9d597db18..cab651c25 100644 --- a/firmware_p4/components/Applications/LoRa/meshtastic/include/mt_mod_traceroute.h +++ b/firmware_p4/components/Applications/LoRa/meshtastic/include/mt_mod_traceroute.h @@ -20,10 +20,13 @@ extern "C" { #endif +#include #include #include "mt_modules.h" +#define MT_TRACE_MAX_HOPS 8 + /** * @brief Initialize the TraceRouteModule. */ @@ -40,6 +43,26 @@ void mt_mod_traceroute_on_received(const mt_packet_meta_t *meta, const uint8_t *payload, uint16_t len); +/** + * @brief Send a TraceRoute request toward @p to (want_response set). + * + * Records @p to as the pending target; the reply is captured for the UI. + */ +void mt_mod_traceroute_start(uint32_t to); + +/** @brief True while a traceroute we started is awaiting its reply. */ +bool mt_mod_traceroute_is_pending(void); + +/** + * @brief Fetch the most recent completed traceroute result. + * + * @param out_hops Buffer of at least MT_TRACE_MAX_HOPS entries (route node nums). + * @param out_count Receives the number of hops written. + * @param out_target Receives the responding node number. + * @return true if a result is ready (and copies it); false otherwise. + */ +bool mt_mod_traceroute_get_result(uint32_t *out_hops, int *out_count, uint32_t *out_target); + #ifdef __cplusplus } #endif diff --git a/firmware_p4/components/Applications/LoRa/meshtastic/meshtastic_app.c b/firmware_p4/components/Applications/LoRa/meshtastic/meshtastic_app.c index 32dbc5c76..772ccb992 100644 --- a/firmware_p4/components/Applications/LoRa/meshtastic/meshtastic_app.c +++ b/firmware_p4/components/Applications/LoRa/meshtastic/meshtastic_app.c @@ -18,6 +18,7 @@ #include "esp_mac.h" #include "freertos/FreeRTOS.h" #include "freertos/task.h" +#include "sys_prio.h" #include "meshtastic_mesh.h" #include "meshtastic_nodedb.h" @@ -35,7 +36,7 @@ static const char *TAG = "MT_APP"; #define MT_POLL_TASK_STACK 8192 -#define MT_POLL_TASK_PRIO 4 +#define MT_POLL_TASK_PRIO SYS_PRIO_BACKGROUND #define MT_POLL_PERIOD_MS 50 #define MT_TICK_PERIOD_MS 1000 #define MT_DEFAULT_TX_POWER_DBM 20 @@ -66,6 +67,8 @@ esp_err_t meshtastic_app_start(void) { mt_region_t region = mt_region_current(); uint32_t freq_hz = mt_region_freq_for_channel(region, MT_PRIMARY_CHANNEL, p_info->bw_hz); + sx1262_deinit(); + sx1262_config_t cfg = {0}; ret = sx1262_hal_create(&cfg.hal); if (ret != ESP_OK) { @@ -145,8 +148,13 @@ esp_err_t meshtastic_app_start(void) { return ret; } - if (xTaskCreate(poll_task, "mt_poll", MT_POLL_TASK_STACK, NULL, MT_POLL_TASK_PRIO, NULL) != - pdPASS) { + if (xTaskCreatePinnedToCore(poll_task, + "mt_poll", + MT_POLL_TASK_STACK, + NULL, + MT_POLL_TASK_PRIO, + NULL, + SYS_CORE_RADIO) != pdPASS) { ESP_LOGE(TAG, "Failed to spawn poll task"); return ESP_ERR_NO_MEM; } diff --git a/firmware_p4/components/Applications/LoRa/meshtastic/meshtastic_mesh.c b/firmware_p4/components/Applications/LoRa/meshtastic/meshtastic_mesh.c index 487b92c2e..18d386b20 100644 --- a/firmware_p4/components/Applications/LoRa/meshtastic/meshtastic_mesh.c +++ b/firmware_p4/components/Applications/LoRa/meshtastic/meshtastic_mesh.c @@ -21,6 +21,7 @@ #include "esp_random.h" #include "freertos/FreeRTOS.h" #include "freertos/task.h" +#include "sys_prio.h" #include "freertos/semphr.h" #include "mbedtls/aes.h" @@ -1364,8 +1365,8 @@ esp_err_t meshtastic_mesh_start(void) { s_is_running = true; - BaseType_t ok = - xTaskCreate(mesh_task, "mesh_tx", 4096, NULL, tskIDLE_PRIORITY + 3, &s_mesh_task_handle); + BaseType_t ok = xTaskCreatePinnedToCore( + mesh_task, "mesh_tx", 4096, NULL, SYS_PRIO_BACKGROUND, &s_mesh_task_handle, SYS_CORE_RADIO); if (ok != pdPASS) { s_is_running = false; ESP_LOGE(TAG, "Failed to create mesh task"); diff --git a/firmware_p4/components/Applications/LoRa/meshtastic/meshtastic_phone_bridge.c b/firmware_p4/components/Applications/LoRa/meshtastic/meshtastic_phone_bridge.c index f4021b81c..a1f1726d4 100644 --- a/firmware_p4/components/Applications/LoRa/meshtastic/meshtastic_phone_bridge.c +++ b/firmware_p4/components/Applications/LoRa/meshtastic/meshtastic_phone_bridge.c @@ -24,6 +24,7 @@ #include "freertos/portmacro.h" #include "freertos/semphr.h" #include "freertos/task.h" +#include "sys_prio.h" #include "meshtastic_phoneapi.h" #include "spi_bridge.h" @@ -31,7 +32,7 @@ static const char *TAG = "MESH_BRIDGE"; #define BRIDGE_NOTIFY_TASK_STACK 4096 -#define BRIDGE_NOTIFY_TASK_PRIO 5 +#define BRIDGE_NOTIFY_TASK_PRIO SYS_PRIO_SERVICE_HI #define BRIDGE_NOTIFY_TICK_MS 100 #define BRIDGE_SPI_TIMEOUT_MS 1000 #define BRIDGE_FROMRADIO_BUF_SIZE 512 @@ -113,12 +114,13 @@ esp_err_t meshtastic_phone_bridge_init(uint32_t node_num) { spi_bridge_register_stream_cb(SPI_ID_MESH_TORADIO_STREAM, on_toradio_stream); s_is_running = true; - BaseType_t ok = xTaskCreate(notify_task, - "mesh_bridge", - BRIDGE_NOTIFY_TASK_STACK, - NULL, - BRIDGE_NOTIFY_TASK_PRIO, - &s_notify_task); + BaseType_t ok = xTaskCreatePinnedToCore(notify_task, + "mesh_bridge", + BRIDGE_NOTIFY_TASK_STACK, + NULL, + BRIDGE_NOTIFY_TASK_PRIO, + &s_notify_task, + SYS_CORE_RADIO); if (ok != pdPASS) { s_is_running = false; spi_bridge_unregister_stream_cb(SPI_ID_MESH_TORADIO_STREAM); @@ -262,7 +264,7 @@ static esp_err_t push_frame(spi_id_t id, uint8_t *seq_counter, const uint8_t *fr memcpy(buf + sizeof(hdr), frame + offset, this_chunk); uint8_t cmd_len = (uint8_t)(sizeof(hdr) + this_chunk); - esp_err_t ret = spi_bridge_send_command(id, buf, cmd_len, NULL, NULL, BRIDGE_SPI_TIMEOUT_MS); + esp_err_t ret = spi_bridge_send_command(id, buf, cmd_len, NULL, NULL, 0, BRIDGE_SPI_TIMEOUT_MS); if (ret != ESP_OK) { return ret; } @@ -366,8 +368,13 @@ static esp_err_t fetch_status(spi_mesh_status_t *out_status) { return ESP_ERR_INVALID_ARG; } spi_header_t resp; - return spi_bridge_send_command( - SPI_ID_MESH_STATUS, NULL, 0, &resp, (uint8_t *)out_status, BRIDGE_SPI_TIMEOUT_MS); + return spi_bridge_send_command(SPI_ID_MESH_STATUS, + NULL, + 0, + &resp, + (uint8_t *)out_status, + sizeof(*out_status), + BRIDGE_SPI_TIMEOUT_MS); } static void store_status(const spi_mesh_status_t *status) { @@ -383,7 +390,7 @@ static void store_status(const spi_mesh_status_t *status) { static esp_err_t request_ble_init(void) { spi_mesh_init_t req = {.node_num = s_node_num}; esp_err_t ret = spi_bridge_send_command( - SPI_ID_MESH_BLE_INIT, (uint8_t *)&req, sizeof(req), NULL, NULL, BRIDGE_SPI_TIMEOUT_MS); + SPI_ID_MESH_BLE_INIT, (uint8_t *)&req, sizeof(req), NULL, NULL, 0, BRIDGE_SPI_TIMEOUT_MS); if (ret == ESP_OK) { s_ble_active_on_c5 = true; } @@ -392,7 +399,7 @@ static esp_err_t request_ble_init(void) { static esp_err_t request_ble_stop(void) { esp_err_t ret = - spi_bridge_send_command(SPI_ID_MESH_BLE_STOP, NULL, 0, NULL, NULL, BRIDGE_SPI_TIMEOUT_MS); + spi_bridge_send_command(SPI_ID_MESH_BLE_STOP, NULL, 0, NULL, NULL, 0, BRIDGE_SPI_TIMEOUT_MS); if (ret == ESP_OK) { s_ble_active_on_c5 = false; } @@ -402,7 +409,7 @@ static esp_err_t request_ble_stop(void) { static esp_err_t request_wifi_init(void) { spi_mesh_init_t req = {.node_num = s_node_num}; esp_err_t ret = spi_bridge_send_command( - SPI_ID_MESH_WIFI_INIT, (uint8_t *)&req, sizeof(req), NULL, NULL, BRIDGE_SPI_TIMEOUT_MS); + SPI_ID_MESH_WIFI_INIT, (uint8_t *)&req, sizeof(req), NULL, NULL, 0, BRIDGE_SPI_TIMEOUT_MS); if (ret == ESP_OK) { s_wifi_active_on_c5 = true; } @@ -411,7 +418,7 @@ static esp_err_t request_wifi_init(void) { static esp_err_t request_wifi_stop(void) { esp_err_t ret = - spi_bridge_send_command(SPI_ID_MESH_WIFI_STOP, NULL, 0, NULL, NULL, BRIDGE_SPI_TIMEOUT_MS); + spi_bridge_send_command(SPI_ID_MESH_WIFI_STOP, NULL, 0, NULL, NULL, 0, BRIDGE_SPI_TIMEOUT_MS); if (ret == ESP_OK) { s_wifi_active_on_c5 = false; } diff --git a/firmware_p4/components/Applications/LoRa/meshtastic/mt_mod_text.c b/firmware_p4/components/Applications/LoRa/meshtastic/mt_mod_text.c index e0b4b85ef..c67799c31 100644 --- a/firmware_p4/components/Applications/LoRa/meshtastic/mt_mod_text.c +++ b/firmware_p4/components/Applications/LoRa/meshtastic/mt_mod_text.c @@ -15,16 +15,21 @@ #include "mt_mod_text.h" +#include #include #include "esp_log.h" #include "unishox2.h" +#include "lora_session.h" +#include "meshtastic_nodedb.h" + static const char *TAG = "MT_MOD_TEXT"; #define MT_TEXT_DEDUP_RING 50 #define MT_TEXT_MAX_LEN 220 +#define MT_WHO_NAME_LEN 24 static uint32_t s_seen_ids[MT_TEXT_DEDUP_RING]; static uint8_t s_seen_idx = 0; @@ -100,4 +105,12 @@ void mt_mod_text_on_received(const mt_packet_meta_t *meta, text); } (void)text_len; + + char who[MT_WHO_NAME_LEN]; + const mt_node_entry_t *node = mt_nodedb_get(meta->from); + if (node != NULL && node->short_name[0] != '\0') + snprintf(who, sizeof(who), "%s", node->short_name); + else + snprintf(who, sizeof(who), "!%08lx", (unsigned long)meta->from); + lora_session_on_rx_text(who, text); } diff --git a/firmware_p4/components/Applications/LoRa/meshtastic/mt_mod_traceroute.c b/firmware_p4/components/Applications/LoRa/meshtastic/mt_mod_traceroute.c index 1c238d275..46cb41083 100644 --- a/firmware_p4/components/Applications/LoRa/meshtastic/mt_mod_traceroute.c +++ b/firmware_p4/components/Applications/LoRa/meshtastic/mt_mod_traceroute.c @@ -24,9 +24,14 @@ static const char *TAG = "MT_MOD_TRACEROUTE"; #define MT_TRACE_HOP_LIMIT 3 -#define MT_TRACE_MAX_HOPS 8 static uint32_t s_node_num = 0; +static bool s_pending = false; +static uint32_t s_pending_target = 0; +static bool s_result_ready = false; +static uint32_t s_result_from = 0; +static uint32_t s_result_hops[MT_TRACE_MAX_HOPS]; +static int s_result_count = 0; static uint16_t enc_varint(uint8_t *buf, uint64_t value) { uint16_t pos = 0; @@ -122,6 +127,19 @@ void mt_mod_traceroute_on_received(const mt_packet_meta_t *meta, route_count, (unsigned long)meta->to); + if (s_pending && meta->to == s_node_num && !meta->want_response && + meta->from == s_pending_target) { + s_result_count = 0; + for (int k = 0; k < route_count && s_result_count < MT_TRACE_MAX_HOPS; k++) + s_result_hops[s_result_count++] = route_hops[k]; + s_result_from = meta->from; + s_result_ready = true; + s_pending = false; + ESP_LOGI( + TAG, "TraceRoute result from 0x%08lX (%d hops)", (unsigned long)meta->from, s_result_count); + return; + } + if (meta->to != s_node_num || !meta->want_response) return; @@ -146,3 +164,30 @@ void mt_mod_traceroute_on_received(const mt_packet_meta_t *meta, false, false); } + +void mt_mod_traceroute_start(uint32_t to) { + uint8_t req[1] = {0}; + s_pending = true; + s_pending_target = to; + s_result_ready = false; + ESP_LOGI(TAG, "TraceRoute -> 0x%08lX", (unsigned long)to); + meshtastic_mesh_send_data(to, 0, MT_TRACE_HOP_LIMIT, MT_PORT_TRACEROUTE, req, 0, 0, false, true); +} + +bool mt_mod_traceroute_is_pending(void) { + return s_pending; +} + +bool mt_mod_traceroute_get_result(uint32_t *out_hops, int *out_count, uint32_t *out_target) { + if (!s_result_ready) + return false; + if (out_hops != NULL) { + for (int k = 0; k < s_result_count; k++) + out_hops[k] = s_result_hops[k]; + } + if (out_count != NULL) + *out_count = s_result_count; + if (out_target != NULL) + *out_target = s_result_from; + return true; +} diff --git a/firmware_p4/components/Applications/LoRa/session/include/lora_session.h b/firmware_p4/components/Applications/LoRa/session/include/lora_session.h new file mode 100644 index 000000000..154eb4c2d --- /dev/null +++ b/firmware_p4/components/Applications/LoRa/session/include/lora_session.h @@ -0,0 +1,138 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +/** + * @file lora_session.h + * @brief One-stack LoRa mesh session manager and unified UI-facing view. + * + * The SX1262 radio is single-owner, so Meshtastic and MeshCore cannot run at the + * same time. This module starts the chosen stack (which brings up the radio + * itself), tracks which one is running, and exposes a protocol-agnostic view the + * chat hub reads: a local ring of sent/received text messages and a node list. + * Switching protocols requires a reboot (there is no clean whole-stack stop). + */ + +#ifndef LORA_SESSION_H +#define LORA_SESSION_H + +#ifdef __cplusplus +extern "C" { +#endif + +#include +#include + +#include "esp_err.h" + +typedef enum { + LORA_PROTO_NONE = 0, + LORA_PROTO_MESHTASTIC, + LORA_PROTO_MESHCORE, +} lora_proto_t; + +/** @brief One entry in the local chat ring. */ +typedef struct { + bool outgoing; + char who[24]; + char text[160]; +} lora_msg_t; + +/** @brief Protocol-agnostic node/contact summary. */ +typedef struct { + char name[32]; + int16_t rssi; + float snr; +} lora_node_t; + +/** + * @brief Start the selected mesh stack (idempotent for the same protocol). + * + * The stack's app_start brings up the SX1262 itself. The radio is single-owner, + * so once a protocol is running a request for a different one is refused. + * + * @param proto Protocol to start. + * @return ESP_OK if started (or already running that protocol); + * ESP_ERR_INVALID_STATE if a different protocol already owns the radio; + * an esp_err_t from the stack on failure. + */ +esp_err_t lora_session_start(lora_proto_t proto); + +/** @brief Protocol currently owning the radio, or LORA_PROTO_NONE. */ +lora_proto_t lora_session_active(void); + +/** + * @brief Send a broadcast / public-channel text on the active protocol. + * + * The message is also appended to the local chat ring as outgoing. + * + * @param text UTF-8 text to send. + * @return ESP_OK on success, ESP_ERR_INVALID_STATE if no stack is running. + */ +esp_err_t lora_session_send_text(const char *text); + +/** @brief Number of messages currently held in the local chat ring. */ +uint16_t lora_session_msg_count(void); + +/** + * @brief Atomically copy messages newer than a caller-held sequence cursor. + * + * Reads the ring under a single lock so the (oldest, total) window stays + * consistent even while the mesh poll task pushes concurrently. Advances + * @p io_seq past the copied messages; on first use pass 0 and it snaps forward + * to the oldest message still held. + * + * @param io_seq In/out absolute sequence cursor. + * @param out Destination buffer. + * @param max Capacity of @p out. + * @return Number of messages copied (call again while it returns @p max). + */ +uint16_t lora_session_msg_since(uint32_t *io_seq, lora_msg_t *out, uint16_t max); + +/** @brief Number of known nodes/contacts on the active protocol. */ +uint16_t lora_session_node_count(void); + +/** + * @brief Copy a node/contact summary by index. + * @return true if @p idx is valid. + */ +bool lora_session_node_get(uint16_t idx, lora_node_t *out); + +/** + * @brief Append a received message to the local chat ring. + * + * Called from the mesh poll task (NOT the LVGL thread) by the backend RX taps. + * + * @param who Short sender label. + * @param text Received text. + */ +void lora_session_on_rx_text(const char *who, const char *text); + +/** @brief True if a companion app is connected over the phone bridge (C5). */ +bool lora_session_app_connected(void); + +/** + * @brief Start phone-bridge BLE advertising for the active protocol. + * + * Idempotent; the transport terminates on the C5 co-processor. + * + * @return ESP_OK on success, ESP_ERR_INVALID_STATE if no stack is running. + */ +esp_err_t lora_session_app_connect(void); + +#ifdef __cplusplus +} +#endif + +#endif // LORA_SESSION_H diff --git a/firmware_p4/components/Applications/LoRa/session/lora_session.c b/firmware_p4/components/Applications/LoRa/session/lora_session.c new file mode 100644 index 000000000..a36488d95 --- /dev/null +++ b/firmware_p4/components/Applications/LoRa/session/lora_session.c @@ -0,0 +1,239 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#include "lora_session.h" + +#include +#include + +#include "freertos/FreeRTOS.h" +#include "freertos/semphr.h" + +#include "esp_log.h" + +#include "meshcore.h" +#include "meshcore_app.h" +#include "meshcore_phone_bridge.h" +#include "meshtastic_app.h" +#include "meshtastic_mesh.h" +#include "meshtastic_nodedb.h" +#include "meshtastic_phone_bridge.h" + +#define MSG_RING 24 +#define MT_BROADCAST 0xFFFFFFFFu +#define MC_PUBLIC_CHAN 0 +#define RING_LOCK_MS 50 + +static const char *TAG = "LORA_SESSION"; + +static lora_proto_t s_proto = LORA_PROTO_NONE; +static lora_msg_t s_msgs[MSG_RING]; +static uint16_t s_head = 0; +static uint16_t s_count = 0; +static uint32_t s_total = 0; +static SemaphoreHandle_t s_lock = NULL; + +static bool ring_lock(void) { + if (s_lock == NULL) + s_lock = xSemaphoreCreateMutex(); + if (s_lock == NULL) + return false; + return xSemaphoreTake(s_lock, pdMS_TO_TICKS(RING_LOCK_MS)) == pdTRUE; +} + +static void ring_unlock(void) { + if (s_lock != NULL) + xSemaphoreGive(s_lock); +} + +static void ring_push(bool outgoing, const char *who, const char *text) { + if (!ring_lock()) + return; + + uint16_t slot; + if (s_count < MSG_RING) { + slot = (uint16_t)((s_head + s_count) % MSG_RING); + s_count++; + } else { + slot = s_head; + s_head = (uint16_t)((s_head + 1) % MSG_RING); + } + + lora_msg_t *m = &s_msgs[slot]; + m->outgoing = outgoing; + snprintf(m->who, sizeof(m->who), "%s", (who != NULL) ? who : ""); + snprintf(m->text, sizeof(m->text), "%s", (text != NULL) ? text : ""); + s_total++; + + ring_unlock(); +} + +esp_err_t lora_session_start(lora_proto_t proto) { + if (s_lock == NULL) + s_lock = xSemaphoreCreateMutex(); + + if (s_proto != LORA_PROTO_NONE) { + if (s_proto == proto) + return ESP_OK; + ESP_LOGW(TAG, "Radio owned by proto %d; reboot to switch", (int)s_proto); + return ESP_ERR_INVALID_STATE; + } + + esp_err_t err; + switch (proto) { + case LORA_PROTO_MESHTASTIC: + err = meshtastic_app_start(); + break; + case LORA_PROTO_MESHCORE: + err = meshcore_app_start(); + break; + default: + return ESP_ERR_INVALID_ARG; + } + + if (err == ESP_OK) { + s_proto = proto; + ESP_LOGI(TAG, "Started proto %d", (int)proto); + } else { + ESP_LOGE(TAG, "Start proto %d failed: %s", (int)proto, esp_err_to_name(err)); + } + return err; +} + +lora_proto_t lora_session_active(void) { + return s_proto; +} + +esp_err_t lora_session_send_text(const char *text) { + if (text == NULL || text[0] == '\0') + return ESP_ERR_INVALID_ARG; + + esp_err_t err; + switch (s_proto) { + case LORA_PROTO_MESHTASTIC: + err = meshtastic_mesh_send_text(text, MT_BROADCAST); + break; + case LORA_PROTO_MESHCORE: + err = meshcore_send_grp_txt(MC_PUBLIC_CHAN, text); + break; + default: + return ESP_ERR_INVALID_STATE; + } + + if (err == ESP_OK) + ring_push(true, "me", text); + return err; +} + +uint16_t lora_session_msg_count(void) { + uint16_t n = 0; + if (ring_lock()) { + n = s_count; + ring_unlock(); + } + return n; +} + +uint16_t lora_session_msg_since(uint32_t *io_seq, lora_msg_t *out, uint16_t max) { + if (io_seq == NULL || out == NULL || max == 0) + return 0; + + uint16_t copied = 0; + if (ring_lock()) { + uint32_t oldest = s_total - s_count; + if (*io_seq < oldest) + *io_seq = oldest; + while (*io_seq < s_total && copied < max) { + uint16_t rel = (uint16_t)(*io_seq - oldest); + out[copied] = s_msgs[(s_head + rel) % MSG_RING]; + copied++; + (*io_seq)++; + } + ring_unlock(); + } + return copied; +} + +uint16_t lora_session_node_count(void) { + switch (s_proto) { + case LORA_PROTO_MESHTASTIC: + return mt_nodedb_count(); + case LORA_PROTO_MESHCORE: + return (uint16_t)meshcore_contacts_count(); + default: + return 0; + } +} + +bool lora_session_node_get(uint16_t idx, lora_node_t *out) { + if (out == NULL) + return false; + memset(out, 0, sizeof(*out)); + + if (s_proto == LORA_PROTO_MESHTASTIC) { + const mt_node_entry_t *n = mt_nodedb_get_by_index(idx); + if (n == NULL) + return false; + snprintf(out->name, sizeof(out->name), "%s", (n->long_name[0] != '\0') ? n->long_name : n->id); + out->rssi = n->rssi; + out->snr = n->snr; + return true; + } + + if (s_proto == LORA_PROTO_MESHCORE) { + const meshcore_contact_t *arr = meshcore_contacts_array(); + if (arr == NULL) + return false; + uint16_t seen = 0; + for (uint16_t i = 0; i < MESHCORE_MAX_CONTACTS; i++) { + if (!arr[i].is_used) + continue; + if (seen == idx) { + snprintf(out->name, sizeof(out->name), "%s", arr[i].name); + return true; + } + seen++; + } + return false; + } + + return false; +} + +void lora_session_on_rx_text(const char *who, const char *text) { + ring_push(false, who, text); +} + +bool lora_session_app_connected(void) { + switch (s_proto) { + case LORA_PROTO_MESHTASTIC: + return meshtastic_phone_bridge_is_connected(); + case LORA_PROTO_MESHCORE: + return meshcore_phone_bridge_is_connected(); + default: + return false; + } +} + +esp_err_t lora_session_app_connect(void) { + switch (s_proto) { + case LORA_PROTO_MESHTASTIC: + return meshtastic_phone_bridge_ble_start(); + case LORA_PROTO_MESHCORE: + return meshcore_phone_bridge_ble_start(); + default: + return ESP_ERR_INVALID_STATE; + } +} diff --git a/firmware_p4/components/Applications/SubGhz/README.md b/firmware_p4/components/Applications/SubGhz/README.md index 5d486c40a..0924f7699 100644 --- a/firmware_p4/components/Applications/SubGhz/README.md +++ b/firmware_p4/components/Applications/SubGhz/README.md @@ -1,279 +1,7 @@ # SubGhz Application -This component implements the complete Sub-GHz RF application layer: signal reception (with protocol decoding and frequency hopping), raw/encoded transmission, spectrum analysis, signal analysis, and file serialization. It sits on top of the `cc1101` driver and uses the ESP-IDF RMT peripheral for precise pulse timing. +Documentation for this component lives in the project docs hub (single source of truth): -## Overview +- [docs/SubGhz/README.md](../../../../docs/SubGhz/README.md) -- **Location:** `components/Applications/SubGhz/` -- **Dependencies:** `cc1101`, `driver/rmt_rx`, `driver/rmt_tx`, `freertos`, `pin_def` -- **RMT Resolution:** 1 MHz (1 us per tick) -- **RX GPIO:** GPIO 8 (GDO0 via `GPIO_SDA_PIN`) -- **TX GPIO:** GDO2 (via `GPIO_SCL_PIN`) - -## Architecture - -``` -┌─────────────────────────────────────────────────────┐ -│ SubGhz App │ -│ │ -│ ┌──────────┐ ┌──────────────┐ ┌───────────────┐ │ -│ │ Receiver │ │ Transmitter │ │ Spectrum │ │ -│ │ (RMT RX) │ │ (RMT TX) │ │ Analyzer │ │ -│ └────┬─────┘ └──────┬───────┘ └───────┬───────┘ │ -│ │ │ │ │ -│ ┌────┴─────┐ ┌────┴─────┐ ┌──────┴───────┐ │ -│ │ Protocol │ │ Queue │ │ RSSI Sweep │ │ -│ │ Registry │ │ Worker │ │ (80 bins) │ │ -│ └────┬─────┘ └──────────┘ └──────────────┘ │ -│ │ │ -│ ┌────┴─────┐ ┌──────────────┐ ┌───────────────┐ │ -│ │ Analyzer │ │ Serializer │ │ Storage │ │ -│ │(Histogram)│ │ (.sub files) │ │ (SD Card) │ │ -│ └──────────┘ └──────────────┘ └───────────────┘ │ -└─────────────────────────────────────────────────────┘ - │ - ┌─────────┴─────────┐ - │ CC1101 Driver │ - │ (SPI Bus) │ - └───────────────────┘ -``` - -## Modules - -### Receiver (`subghz_receiver`) - -Captures RF signals via the CC1101 GDO0 pin routed to the ESP32 RMT RX peripheral. Runs as a FreeRTOS task pinned to Core 1. - -**Operating Modes:** - -| Mode | Behavior | -|------|----------| -| `SUBGHZ_MODE_SCAN` | Decodes signals via protocol registry. Unknown signals are analyzed and saved as RAW. | -| `SUBGHZ_MODE_RAW` | Captures and saves all raw pulse data without decoding. | - -**Frequency Hopping:** When `freq == 0` is passed to `subghz_receiver_start`, the receiver cycles through 12 predefined frequencies (433.92, 868.35, 315, 300, 390, 418, 915 MHz, etc.) every 5 seconds. - -**Signal Processing Pipeline:** -1. RMT hardware captures pulse timings (min 1 us, idle timeout 10 ms) -2. Software filter removes pulses < 15 us -3. Pulses converted to signed int32 buffer (positive = HIGH, negative = LOW) -4. **SCAN mode:** Protocol registry tries all decoders -> Analyzer for unknowns -5. **RAW mode:** Direct save to storage - -#### API - -```c -esp_err_t subghz_receiver_start(subghz_mode_t mode, cc1101_preset_t preset, uint32_t freq); -void subghz_receiver_stop(void); -bool subghz_receiver_is_running(void); -``` -- `freq = 0` enables frequency hopping mode. -- Returns `ESP_OK` on success, `ESP_ERR_INVALID_STATE` if already running, `ESP_ERR_NO_MEM` on task creation failure. -- Task stack: 8192 bytes, priority 5, Core 1. - -### Transmitter (`subghz_transmitter`) - -Asynchronous queue-based transmitter. Converts signed pulse timings to RMT symbols and transmits via CC1101 GDO2 in async mode. - -**Flow:** `subghz_tx_send_raw()` -> FreeRTOS Queue -> TX Task -> RMT TX -> CC1101 - -#### API - -```c -esp_err_t subghz_tx_init(void); -void subghz_tx_stop(void); -esp_err_t subghz_tx_send_raw(const int32_t *timings, size_t count); -``` -- `subghz_tx_init` returns `ESP_OK` on success, `ESP_ERR_NO_MEM` on queue creation failure. -- `subghz_tx_send_raw` returns `ESP_OK` on success, `ESP_ERR_INVALID_ARG` if not running or invalid params, `ESP_ERR_NO_MEM` on allocation failure, `ESP_ERR_TIMEOUT` if queue is full. -- Queue depth: 10 items. Drops packets if full. -- Timing data is copied internally; caller retains ownership of the original buffer. -- Max RMT symbol duration: 32767 us per pulse. -- Task stack: 4096 bytes, priority 5, Core 1. - -### Spectrum Analyzer (`subghz_spectrum`) - -Sweeps across a frequency span by stepping the CC1101 through discrete frequencies and reading RSSI values. Produces 80-sample spectral lines. - -**Sweep Process:** -1. Divides the span into 80 frequency steps -2. For each step: tune CC1101, wait 400 us stabilization, take 3 RSSI peak samples -3. Updates a mutex-protected global `subghz_spectrum_line_t` structure - -#### Data Structure - -```c -typedef struct { - uint32_t center_freq; - uint32_t span_hz; - uint32_t start_freq; - uint32_t step_hz; - float dbm_values[SPECTRUM_SAMPLES]; - uint64_t timestamp; -} subghz_spectrum_line_t; -``` - -#### API - -```c -void subghz_spectrum_start(uint32_t center_freq, uint32_t span_hz); -void subghz_spectrum_stop(void); -bool subghz_spectrum_get_line(subghz_spectrum_line_t *out_line); -``` -- Task stack: 4096 bytes, priority 1, Core 1. -- Thread-safe reads via `subghz_spectrum_get_line`. - -### Signal Analyzer (`subghz_analyzer`) - -Analyzes unknown signals by building a pulse duration histogram to estimate modulation parameters and recover bitstreams. - -**Analysis Steps:** -1. **Histogram:** Builds 50 us bins (up to 5000 us) from absolute pulse durations -2. **TE Estimation:** First significant histogram peak = estimated Time Element -3. **Modulation Heuristic:** 2 peaks = Manchester/Biphase, 3+ peaks = PWM/Tri-state -4. **Bitstream Recovery:** Slices pulses into TE-sized bits using edge-to-edge detection - -#### Data Structure - -```c -typedef struct { - uint32_t estimated_te; - uint32_t pulse_min; - uint32_t pulse_max; - size_t pulse_count; - const char *modulation_hint; - uint8_t bitstream[128]; - size_t bitstream_len; -} subghz_analyzer_result_t; -``` - -#### API - -```c -bool subghz_analyzer_process(const int32_t *pulses, size_t count, subghz_analyzer_result_t *out_result); -``` -- Requires minimum 10 pulses. Filters durations < 50 us as noise. - -### Protocol Serializer (`subghz_protocol_serializer`) - -Serializes and parses `.sub` file format for decoded and raw signals. - -**File Format:** -``` -Filetype: High Boy SubGhz File -Version 1 -Frequency: 433920000 -Preset: 6 -Protocol: Princeton -Bit: 24 -Key: 00 00 00 00 XX XX XX XX -TE: 350 -``` - -RAW variant replaces Protocol/Bit/Key/TE with: -``` -Protocol: RAW -RAW_Data: 350 -700 350 -350 700 -350 ... -``` - -#### API - -```c -uint8_t subghz_protocol_get_preset_id(void); -size_t subghz_protocol_serialize_decoded(const subghz_data_t *data, uint32_t frequency, uint32_t te, char *out_buf, size_t out_size); -size_t subghz_protocol_serialize_raw(const int32_t *pulses, size_t count, uint32_t frequency, char *out_buf, size_t out_size); -size_t subghz_protocol_parse_raw(const char *content, int32_t *out_pulses, size_t max_count, uint32_t *out_frequency, uint8_t *out_preset); -``` - -### Storage (`subghz_storage`) - -Saves captured signals to persistent storage using the serializer. Currently operates in placeholder mode (outputs to log). - -#### API - -```c -esp_err_t subghz_storage_init(void); -esp_err_t subghz_storage_save_decoded(const char *name, const subghz_data_t *data, uint32_t frequency, uint32_t te); -esp_err_t subghz_storage_save_raw(const char *name, const int32_t *pulses, size_t count, uint32_t frequency); -``` -- Returns `ESP_OK` on success, `ESP_ERR_INVALID_ARG` on null arguments, `ESP_ERR_NO_MEM` on allocation failure. - -## Protocol Plugins (`protocols/`) - -The protocol system follows a **plugin architecture**. Each protocol is a self-contained module (e.g., `protocol_princeton.c`) that implements a common interface and is registered in a central registry. This design allows adding support for new protocols without modifying existing code — just create a new `protocol_*.c` file, implement the `subghz_protocol_t` interface, and register it in `subghz_protocol_registry.c`. - -### Plugin Interface - -Every protocol plugin must export a `subghz_protocol_t` struct with two function pointers: - -```c -typedef struct { - const char *name; - bool (*decode)(const int32_t *pulses, size_t count, subghz_data_t *out_data); - size_t (*encode)(const subghz_data_t *data, int32_t *pulses, size_t max_count); -} subghz_protocol_t; -``` - -- **`decode`**: Receives raw pulse timings and attempts to recognize the protocol. Returns `true` if the signal matches, filling `out_data` with serial, button, bit count, and raw value. -- **`encode`**: Converts structured data back into pulse timings for retransmission. - -### How It Works - -1. Each plugin file declares a global `subghz_protocol_t` (e.g., `protocol_princeton`) -2. The registry (`subghz_protocol_registry.c`) holds an array of pointers to all registered plugins -3. On signal reception, `subghz_protocol_registry_decode_all()` iterates through all plugins in order, calling each `decode()` until one claims the signal -4. If no plugin matches, the signal falls through to the `subghz_analyzer` for heuristic analysis - -### Adding a New Protocol Plugin - -1. Create `protocols/protocol_mydevice.c` -2. Implement `decode()` and optionally `encode()` -3. Export: `subghz_protocol_t protocol_mydevice = { .name = "MyDevice", .decode = ..., .encode = ... };` -4. Register in `subghz_protocol_registry.c`: - - Add `extern subghz_protocol_t protocol_mydevice;` - - Add `&protocol_mydevice` to the `s_protocols[]` array - -### Registered Plugins - -| Plugin | Modulation | Typical Use | -|--------------|------------|------------------------------| -| RCSwitch | OOK/PWM | Generic remote switches | -| Princeton | OOK/PWM | Fixed-code remotes | -| CAME | OOK/PWM | Gate/garage remotes | -| Nice FLO | OOK/PWM | Gate/garage remotes | -| Ansonic | OOK/PWM | Gate remotes | -| Chamberlain | OOK/PWM | Garage door openers | -| Holtek | OOK/PWM | Remote controls | -| LiftMaster | OOK/PWM | Garage door openers | -| Linear | OOK/PWM | Gate/access control | -| Rossi | OOK/PWM | Gate remotes | - -### Utility Functions (`subghz_protocol_utils.h`) - -```c -uint32_t subghz_abs_diff(uint32_t a, uint32_t b); -bool subghz_check_pulse(int32_t raw_len, uint32_t target_len, uint8_t tolerance_pct); -``` -Helper functions available to all plugins for pulse timing validation with percentage-based tolerance. - -### Registry API - -```c -void subghz_protocol_registry_init(void); -bool subghz_protocol_registry_decode_all(const int32_t *pulses, size_t count, subghz_data_t *out_data); -const subghz_protocol_t *subghz_protocol_registry_get_by_name(const char *name); -``` - -## Common Types (`subghz_types.h`) - -```c -typedef struct { - const char *protocol_name; - uint32_t serial; - uint8_t btn; - uint8_t bit_count; - uint32_t raw_value; -} subghz_data_t; -``` - -Shared data structure used across decoder, serializer, storage, and UI layers. +This file is only a pointer: edit the docs there to keep things from drifting. diff --git a/firmware_p4/components/Applications/SubGhz/subghz_receiver.c b/firmware_p4/components/Applications/SubGhz/subghz_receiver.c index 2c1ecf99e..d34bd4ab6 100644 --- a/firmware_p4/components/Applications/SubGhz/subghz_receiver.c +++ b/firmware_p4/components/Applications/SubGhz/subghz_receiver.c @@ -21,10 +21,12 @@ #include "esp_log.h" #include "freertos/FreeRTOS.h" #include "freertos/task.h" +#include "sys_prio.h" #include "freertos/queue.h" #include "cc1101.h" #include "pin_def.h" +#include "led_control.h" #include "subghz_protocol_registry.h" #include "subghz_analyzer.h" #include "subghz_storage.h" @@ -43,8 +45,8 @@ static const char *TAG = "SUBGHZ_RX"; #define RX_QUEUE_TIMEOUT_MS 100 #define HOP_INTERVAL_MS 5000 #define RX_TASK_STACK_SIZE 8192 -#define RX_TASK_PRIORITY 5 -#define RX_TASK_CORE 1 +#define RX_TASK_PRIORITY SYS_PRIO_SERVICE_HI +#define RX_TASK_CORE SYS_CORE_RADIO #define FILENAME_BUF_SIZE 32 #define HEX_BUF_SIZE 65 #define MAX_HEX_BYTES 32 @@ -163,6 +165,7 @@ static void handle_scan_mode(const int32_t *decode_buffer, size_t decode_idx) { subghz_analyzer_process(decode_buffer, decode_idx, &analysis); get_dynamic_filename(filename, sizeof(filename), "DEC"); subghz_storage_save_decoded(filename, &decoded, s_rx_freq, analysis.estimated_te); + led_signal_info(); // decoded a known protocol return; } @@ -178,6 +181,7 @@ static void handle_scan_mode(const int32_t *decode_buffer, size_t decode_idx) { get_dynamic_filename(filename, sizeof(filename), "UNK"); subghz_storage_save_raw(filename, decode_buffer, decode_idx, s_rx_freq); log_recovered_bitstream(&analysis); + led_signal_warning(); // captured RF but no known protocol matched } size_t preview = decode_idx > RAW_PREVIEW_COUNT ? RAW_PREVIEW_COUNT : decode_idx; @@ -215,6 +219,10 @@ static void subghz_rx_task(void *pvParameters) { (int)s_rx_preset, (unsigned long)s_rx_freq); + rmt_symbol_word_t *raw_symbols = NULL; + int32_t *decode_buffer = NULL; + esp_err_t err = ESP_OK; + rmt_rx_channel_config_t rx_channel_cfg = { .clk_src = RMT_CLK_SRC_DEFAULT, .resolution_hz = RMT_RESOLUTION_HZ, @@ -224,14 +232,26 @@ static void subghz_rx_task(void *pvParameters) { .flags.with_dma = true, }; - ESP_ERROR_CHECK(rmt_new_rx_channel(&rx_channel_cfg, &s_rx_channel)); + err = rmt_new_rx_channel(&rx_channel_cfg, &s_rx_channel); + if (err != ESP_OK) { + ESP_LOGE(TAG, "rmt_new_rx_channel failed: %s", esp_err_to_name(err)); + goto cleanup; + } s_rx_queue = xQueueCreate(RX_QUEUE_DEPTH, sizeof(rmt_rx_done_event_data_t)); rmt_rx_event_callbacks_t cbs = { .on_recv_done = subghz_rx_done_callback, }; - ESP_ERROR_CHECK(rmt_rx_register_event_callbacks(s_rx_channel, &cbs, s_rx_queue)); - ESP_ERROR_CHECK(rmt_enable(s_rx_channel)); + err = rmt_rx_register_event_callbacks(s_rx_channel, &cbs, s_rx_queue); + if (err != ESP_OK) { + ESP_LOGE(TAG, "rmt_rx_register_event_callbacks failed: %s", esp_err_to_name(err)); + goto cleanup; + } + err = rmt_enable(s_rx_channel); + if (err != ESP_OK) { + ESP_LOGE(TAG, "rmt_enable failed: %s", esp_err_to_name(err)); + goto cleanup; + } cc1101_set_preset(s_rx_preset, s_rx_freq); @@ -244,8 +264,6 @@ static void subghz_rx_task(void *pvParameters) { }; rmt_rx_done_event_data_t rx_data; - rmt_symbol_word_t *raw_symbols = NULL; - int32_t *decode_buffer = NULL; size_t raw_symbols_size = sizeof(rmt_symbol_word_t) * RX_BUFFER_SIZE; raw_symbols = heap_caps_aligned_alloc( @@ -261,7 +279,11 @@ static void subghz_rx_task(void *pvParameters) { goto cleanup; } - ESP_ERROR_CHECK(rmt_receive(s_rx_channel, raw_symbols, raw_symbols_size, &receive_config)); + err = rmt_receive(s_rx_channel, raw_symbols, raw_symbols_size, &receive_config); + if (err != ESP_OK) { + ESP_LOGE(TAG, "rmt_receive failed: %s", esp_err_to_name(err)); + goto cleanup; + } TickType_t last_hop_time = xTaskGetTickCount(); const TickType_t hop_interval = pdMS_TO_TICKS(HOP_INTERVAL_MS); @@ -274,8 +296,10 @@ static void subghz_rx_task(void *pvParameters) { } if (rx_data.num_symbols == 0) { - if (s_is_running) { - ESP_ERROR_CHECK(rmt_receive(s_rx_channel, raw_symbols, raw_symbols_size, &receive_config)); + if (s_is_running && + rmt_receive(s_rx_channel, raw_symbols, raw_symbols_size, &receive_config) != ESP_OK) { + ESP_LOGE(TAG, "rmt_receive failed; stopping RX"); + break; } continue; } @@ -293,8 +317,10 @@ static void subghz_rx_task(void *pvParameters) { } } - if (s_is_running) { - ESP_ERROR_CHECK(rmt_receive(s_rx_channel, raw_symbols, raw_symbols_size, &receive_config)); + if (s_is_running && + rmt_receive(s_rx_channel, raw_symbols, raw_symbols_size, &receive_config) != ESP_OK) { + ESP_LOGE(TAG, "rmt_receive failed; stopping RX"); + break; } } @@ -306,11 +332,17 @@ static void subghz_rx_task(void *pvParameters) { free(decode_buffer); } cc1101_strobe(CC1101_SIDLE); - rmt_disable(s_rx_channel); - rmt_del_channel(s_rx_channel); - vQueueDelete(s_rx_queue); - s_rx_channel = NULL; - s_rx_queue = NULL; + if (s_rx_channel != NULL) { + rmt_disable(s_rx_channel); + rmt_del_channel(s_rx_channel); + s_rx_channel = NULL; + } + if (s_rx_queue != NULL) { + vQueueDelete(s_rx_queue); + s_rx_queue = NULL; + } + s_is_running = false; + s_rx_task_handle = NULL; vTaskDelete(NULL); } diff --git a/firmware_p4/components/Applications/SubGhz/subghz_spectrum.c b/firmware_p4/components/Applications/SubGhz/subghz_spectrum.c index 25c1c4f51..7e6f8cfd5 100644 --- a/firmware_p4/components/Applications/SubGhz/subghz_spectrum.c +++ b/firmware_p4/components/Applications/SubGhz/subghz_spectrum.c @@ -22,6 +22,7 @@ #include "esp_timer.h" #include "freertos/FreeRTOS.h" #include "freertos/task.h" +#include "sys_prio.h" #include "freertos/semphr.h" #include "cc1101.h" @@ -37,8 +38,8 @@ static const char *TAG = "SUBGHZ_SPECTRUM"; #define MUTEX_TIMEOUT_MS 10 #define GET_LINE_TIMEOUT_MS 5 #define SPECTRUM_TASK_STACK 4096 -#define SPECTRUM_TASK_PRIORITY 1 -#define SPECTRUM_TASK_CORE 1 +#define SPECTRUM_TASK_PRIORITY SYS_PRIO_BACKGROUND_LO +#define SPECTRUM_TASK_CORE SYS_CORE_RADIO static TaskHandle_t s_spectrum_task_handle = NULL; static SemaphoreHandle_t s_spectrum_mutex = NULL; diff --git a/firmware_p4/components/Applications/SubGhz/subghz_transmitter.c b/firmware_p4/components/Applications/SubGhz/subghz_transmitter.c index 5fb53ccd5..ce4765028 100644 --- a/firmware_p4/components/Applications/SubGhz/subghz_transmitter.c +++ b/firmware_p4/components/Applications/SubGhz/subghz_transmitter.c @@ -15,6 +15,8 @@ #include "subghz_transmitter.h" +#include "led_control.h" + #include #include @@ -23,6 +25,7 @@ #include "esp_log.h" #include "freertos/FreeRTOS.h" #include "freertos/task.h" +#include "sys_prio.h" #include "freertos/queue.h" #include "cc1101.h" @@ -36,8 +39,8 @@ static const char *TAG = "SUBGHZ_TX"; #define TX_QUEUE_SEND_MS 100 #define TX_WAIT_TIMEOUT_MS 2000 #define TX_TASK_STACK_SIZE 4096 -#define TX_TASK_PRIORITY 5 -#define TX_TASK_CORE 1 +#define TX_TASK_PRIORITY SYS_PRIO_SERVICE_HI +#define TX_TASK_CORE SYS_CORE_RADIO #define TX_STOP_DELAY_MS 100 #define RMT_MEM_BLOCK_SYMBOLS 64 #define RMT_TRANS_QUEUE_DEPTH 4 @@ -108,8 +111,17 @@ static void subghz_tx_task(void *pvParameters) { .loop_count = 0, }; - ESP_ERROR_CHECK(rmt_transmit( - s_tx_channel, s_copy_encoder, symbols, num_words * sizeof(rmt_symbol_word_t), &tx_config)); + esp_err_t tx_err = rmt_transmit( + s_tx_channel, s_copy_encoder, symbols, num_words * sizeof(rmt_symbol_word_t), &tx_config); + if (tx_err != ESP_OK) { + ESP_LOGE(TAG, "rmt_transmit failed: %s", esp_err_to_name(tx_err)); + free(symbols); + cc1101_strobe(CC1101_SIDLE); + if (item.timings != NULL) { + free(item.timings); + } + continue; + } esp_err_t wait_err = rmt_tx_wait_all_done(s_tx_channel, TX_WAIT_TIMEOUT_MS); if (wait_err != ESP_OK) { @@ -147,12 +159,30 @@ esp_err_t subghz_tx_init(void) { .gpio_num = GPIO_CC1101_GDO2_PIN, .flags.invert_out = false, }; - ESP_ERROR_CHECK(rmt_new_tx_channel(&tx_channel_cfg, &s_tx_channel)); + esp_err_t err = rmt_new_tx_channel(&tx_channel_cfg, &s_tx_channel); + if (err != ESP_OK) { + ESP_LOGE(TAG, "rmt_new_tx_channel failed: %s", esp_err_to_name(err)); + return err; + } rmt_copy_encoder_config_t copy_encoder_cfg = {}; - ESP_ERROR_CHECK(rmt_new_copy_encoder(©_encoder_cfg, &s_copy_encoder)); + err = rmt_new_copy_encoder(©_encoder_cfg, &s_copy_encoder); + if (err != ESP_OK) { + ESP_LOGE(TAG, "rmt_new_copy_encoder failed: %s", esp_err_to_name(err)); + rmt_del_channel(s_tx_channel); + s_tx_channel = NULL; + return err; + } - ESP_ERROR_CHECK(rmt_enable(s_tx_channel)); + err = rmt_enable(s_tx_channel); + if (err != ESP_OK) { + ESP_LOGE(TAG, "rmt_enable failed: %s", esp_err_to_name(err)); + rmt_del_encoder(s_copy_encoder); + rmt_del_channel(s_tx_channel); + s_copy_encoder = NULL; + s_tx_channel = NULL; + return err; + } cc1101_enable_async_mode(TX_DEFAULT_FREQ); cc1101_strobe(CC1101_SIDLE); @@ -218,6 +248,7 @@ esp_err_t subghz_tx_send_raw(const int32_t *timings, size_t count) { int32_t *timings_copy = malloc(count * sizeof(int32_t)); if (timings_copy == NULL) { ESP_LOGE(TAG, "OOM: Failed to copy TX timings"); + led_signal_error(); return ESP_ERR_NO_MEM; } @@ -231,6 +262,7 @@ esp_err_t subghz_tx_send_raw(const int32_t *timings, size_t count) { if (xQueueSend(s_tx_queue, &item, pdMS_TO_TICKS(TX_QUEUE_SEND_MS)) != pdPASS) { ESP_LOGE(TAG, "TX Queue Full - Dropping packet"); free(timings_copy); + led_signal_error(); return ESP_ERR_TIMEOUT; } diff --git a/firmware_p4/components/Applications/bad_usb/README.md b/firmware_p4/components/Applications/bad_usb/README.md index 6222f59e6..015ec5b19 100644 --- a/firmware_p4/components/Applications/bad_usb/README.md +++ b/firmware_p4/components/Applications/bad_usb/README.md @@ -1,133 +1,7 @@ # BadUSB Application -This component implements a modular HID injection tool capable of emulating keyboard and mouse input to execute automated payloads. It features a 3-layer architecture that decouples script parsing, keyboard layouts, and hardware transport. +Documentation for this component lives in the project docs hub (single source of truth): -## Overview +- [docs/bad_usb/README.md](../../../../docs/bad_usb/README.md) -- **Location:** `components/Applications/bad_usb/` -- **Dependencies:** `tinyusb`, `tusb_desc`, `storage_api`, `freertos` -- **Transport:** USB HID via TinyUSB (Bluetooth planned) - -## Architecture - -``` -┌─────────────────────────────────────────────────┐ -│ BadUSB Application │ -│ │ -│ ┌─────────────────────────────────────────┐ │ -│ │ DuckyScript Parser │ │ -│ │ (ducky_parser.c) │ │ -│ │ Parses scripts, dispatches commands │ │ -│ └────────┬──────────────┬─────────────────┘ │ -│ │ │ │ -│ ┌────────┴────────┐ ┌─┴──────────────────┐ │ -│ │ HID Layouts │ │ HID HAL │ │ -│ │ (hid_layouts) │ │ (hid_hal) │ │ -│ │ US / ABNT2 │ │ Callback-based │ │ -│ │ char -> HID │ │ abstraction │ │ -│ └────────┬────────┘ └─┬──────────────────┘ │ -│ │ │ │ -│ └──────┬───────┘ │ -│ │ │ -│ ┌───────────────┴─────────────────────────┐ │ -│ │ Transport Backend │ │ -│ │ USB: bad_usb.c (TinyUSB) │ │ -│ │ BLE: (planned) │ │ -│ └─────────────────────────────────────────┘ │ -└─────────────────────────────────────────────────┘ -``` - -**Layer 1 - HAL (`hid_hal`):** Manages the registration of transport drivers and provides a common interface for sending key reports, mouse movements, and waiting for connections. The parser never calls USB directly. - -**Layer 2 - Layouts (`hid_layouts`):** Translates characters and strings into HID keycodes. Hardware-independent and reusable by any transport registered in the HAL. - -**Layer 3 - Parser (`ducky_parser`):** Processes DuckyScript files and calls the HAL/Layout functions to execute commands. - -## API Reference - -### BadUSB Driver (`bad_usb.h`) - -```c -esp_err_t bad_usb_init(void); -esp_err_t bad_usb_deinit(void); -void bad_usb_wait_for_connection(void); -``` -- `bad_usb_init` initializes TinyUSB and registers USB HID callbacks into the HAL. -- `bad_usb_deinit` unregisters callbacks and uninstalls the TinyUSB driver. -- `bad_usb_wait_for_connection` blocks until the USB host mounts the device, then waits 2 seconds for enumeration. - -### HID HAL (`hid_hal.h`) - -```c -void hid_hal_register_callback(hid_send_cb_t send_cb, - hid_mouse_cb_t mouse_cb, - hid_wait_cb_t wait_cb); -void hid_hal_press_key(uint8_t keycode, uint8_t modifiers); -void hid_hal_mouse_move(int8_t x, int8_t y); -void hid_hal_mouse_click(uint8_t buttons); -void hid_hal_mouse_scroll(int8_t wheel); -void hid_hal_wait_for_connection(void); -``` -- `hid_hal_press_key` sends a key-down + key-up report with ~5 ms per phase. -- Mouse functions use ~2 ms delay for moves and ~5 ms for clicks. -- All functions yield to the scheduler (`vTaskDelay(0)`) to prevent WDT starvation. - -### Keyboard Layouts (`hid_layouts.h`) - -```c -void hid_layouts_type_string_us(const char *str); -void hid_layouts_type_string_abnt2(const char *str); -``` -- `hid_layouts_type_string_us` maps ASCII characters to US keyboard HID keycodes. -- `hid_layouts_type_string_abnt2` handles Brazilian Portuguese layout including UTF-8 dead-key sequences for accented characters (e.g. a, e, c, a, o). - -### DuckyScript Parser (`ducky_parser.h`) - -```c -void ducky_set_output_mode(ducky_output_mode_t mode); -void ducky_set_layout(ducky_layout_t layout); -void ducky_set_progress_callback(ducky_progress_cb_t cb); -void ducky_parse_and_run(const char *script); -esp_err_t ducky_run_from_assets(const char *filename); -esp_err_t ducky_run_from_sdcard(const char *path); -void ducky_abort(void); -``` -- `ducky_parse_and_run` executes a script line-by-line with 20 ms inter-line delay. -- `ducky_run_from_assets` loads a script from the internal flash asset partition. -- `ducky_run_from_sdcard` loads a script from the SD card (max 8 KB). -- `ducky_abort` sets a flag that stops execution at the next line boundary. -- Progress callback is invoked after each line with current/total counts. - -## Supported DuckyScript Commands - -| Command | Arguments | Description | -|---------|-----------|-------------| -| `REM` | [comment] | Comment line (ignored) | -| `DELAY` | [ms] | Pause execution for N milliseconds | -| `STRING` | [text] | Type text using the active keyboard layout | -| `ENTER` / `RETURN` | - | Press Enter | -| `GUI` / `WINDOWS` / `COMMAND` | [key] | Windows/Command key (optionally with a key) | -| `CTRL` / `CONTROL` | [key] | Control + key | -| `SHIFT` | [key] | Shift + key | -| `ALT` | [key] | Alt + key | -| `TAB` | - | Tab key | -| `ESC` / `ESCAPE` | - | Escape key | -| `F1` - `F12` | - | Function keys | -| `UP` / `DOWN` / `LEFT` / `RIGHT` | - | Arrow keys | -| `HOME` / `END` / `INSERT` / `DELETE` | - | Navigation keys | -| `PAGEUP` / `PAGEDOWN` | - | Page navigation | -| `CAPSLOCK` / `NUMLOCK` / `SCROLLLOCK` | - | Lock keys | -| `PRINTSCREEN` / `PAUSE` / `APP` / `MENU` | - | Special system keys | -| `MOUSE_MOVE` | [x] [y] | Move mouse relative (-127 to 127) | -| `MOUSE_CLICK` / `LCLICK` | - | Left mouse click | -| `MOUSE_RIGHT_CLICK` / `RCLICK` | - | Right mouse click | -| `MOUSE_SCROLL` | [amount] | Scroll mouse wheel | - -Modifier keys can be combined: `CTRL SHIFT ESC`, `GUI r`, `ALT F4`. - -## Supported Layouts - -| Layout | Enum | Notes | -|--------|------|-------| -| US (QWERTY) | `DUCKY_LAYOUT_US` | Default. Standard ASCII mapping. | -| ABNT2 (Brazil) | `DUCKY_LAYOUT_ABNT2` | Dead-key accent support, remapped punctuation. | +This file is only a pointer: edit the docs there to keep things from drifting. diff --git a/firmware_p4/components/Applications/bad_usb/bad_usb.c b/firmware_p4/components/Applications/bad_usb/bad_usb.c index 8daa56cba..afea66022 100644 --- a/firmware_p4/components/Applications/bad_usb/bad_usb.c +++ b/firmware_p4/components/Applications/bad_usb/bad_usb.c @@ -32,6 +32,7 @@ static const char *TAG = "BAD_USB"; #define HID_REPORT_ID_MOUSE 2 #define USB_POLL_INTERVAL_MS 100 #define USB_SETTLE_DELAY_MS 2000 +#define USB_MOUNT_TIMEOUT_MS 8000 static bool s_is_initialized = false; @@ -77,13 +78,34 @@ esp_err_t bad_usb_deinit(void) { return ESP_OK; } -void bad_usb_wait_for_connection(void) { +bool bad_usb_wait_for_connection_ex(bad_usb_abort_cb_t should_abort) { ESP_LOGI(TAG, "Waiting for USB host connection..."); + int waited_ms = 0; while (!tud_mounted()) { + if (should_abort != NULL && should_abort()) { + ESP_LOGW(TAG, "Wait for connection aborted"); + return false; + } + if (waited_ms >= USB_MOUNT_TIMEOUT_MS) { + ESP_LOGE(TAG, "USB host not detected after %d ms", USB_MOUNT_TIMEOUT_MS); + return false; + } vTaskDelay(pdMS_TO_TICKS(USB_POLL_INTERVAL_MS)); + waited_ms += USB_POLL_INTERVAL_MS; } ESP_LOGI(TAG, "USB host connected. Settling for %d ms...", USB_SETTLE_DELAY_MS); - vTaskDelay(pdMS_TO_TICKS(USB_SETTLE_DELAY_MS)); + for (int elapsed = 0; elapsed < USB_SETTLE_DELAY_MS; elapsed += USB_POLL_INTERVAL_MS) { + if (should_abort != NULL && should_abort()) { + ESP_LOGW(TAG, "Settle aborted"); + return false; + } + vTaskDelay(pdMS_TO_TICKS(USB_POLL_INTERVAL_MS)); + } + return true; +} + +void bad_usb_wait_for_connection(void) { + (void)bad_usb_wait_for_connection_ex(NULL); } static void send_keyboard_report(uint8_t keycode, uint8_t modifier) { diff --git a/firmware_p4/components/Applications/bad_usb/ducky_parser.c b/firmware_p4/components/Applications/bad_usb/ducky_parser.c index a3c7c93f0..8fbbc84d5 100644 --- a/firmware_p4/components/Applications/bad_usb/ducky_parser.c +++ b/firmware_p4/components/Applications/bad_usb/ducky_parser.c @@ -26,6 +26,7 @@ #include "hid_hal.h" #include "hid_layouts.h" +#include "led_control.h" #include "storage_assets.h" #include "storage_read.h" @@ -155,6 +156,9 @@ void ducky_parse_and_run(const char *script) { } free(script_copy); + // Single execution chokepoint: both console and UI script runs pass here, so + // the status LED fires regardless of trigger. + s_is_abort_requested ? led_signal_warning() : led_signal_info(); } esp_err_t ducky_run_from_assets(const char *filename) { @@ -162,6 +166,7 @@ esp_err_t ducky_run_from_assets(const char *filename) { char *buffer = (char *)storage_assets_load_file(filename, &size); if (buffer == NULL) { ESP_LOGE(TAG, "Asset not found: %s", filename); + led_signal_error(); return ESP_ERR_NOT_FOUND; } @@ -169,6 +174,7 @@ esp_err_t ducky_run_from_assets(const char *filename) { if (script_str == NULL) { free(buffer); ESP_LOGE(TAG, "Failed to allocate script buffer"); + led_signal_error(); return ESP_ERR_NO_MEM; } @@ -190,6 +196,7 @@ esp_err_t ducky_run_from_sdcard(const char *path) { char *script_str = malloc(MAX_SCRIPT_SIZE); if (script_str == NULL) { ESP_LOGE(TAG, "Failed to allocate script buffer"); + led_signal_error(); return ESP_ERR_NO_MEM; } @@ -197,6 +204,7 @@ esp_err_t ducky_run_from_sdcard(const char *path) { if (err != ESP_OK) { free(script_str); ESP_LOGE(TAG, "Failed to read script from SD: %s", path); + led_signal_error(); return err; } diff --git a/firmware_p4/components/Applications/bad_usb/include/bad_usb.h b/firmware_p4/components/Applications/bad_usb/include/bad_usb.h index fdcb2c18d..92090b5bd 100644 --- a/firmware_p4/components/Applications/bad_usb/include/bad_usb.h +++ b/firmware_p4/components/Applications/bad_usb/include/bad_usb.h @@ -20,6 +20,7 @@ extern "C" { #endif +#include #include #include "esp_err.h" @@ -55,6 +56,25 @@ esp_err_t bad_usb_deinit(void); */ void bad_usb_wait_for_connection(void); +/** + * @brief Abort predicate polled while waiting for a USB host connection. + * + * @return true to cancel the wait, false to keep waiting. + */ +typedef bool (*bad_usb_abort_cb_t)(void); + +/** + * @brief Abortable variant of bad_usb_wait_for_connection(). + * + * Polls tud_mounted() (and the post-mount settle delay) while calling the abort + * predicate between polls, so a caller can cancel a run that is still waiting for + * a host that never arrives. + * + * @param should_abort Predicate polled between waits; NULL waits indefinitely. + * @return true if the host mounted and settled, false if aborted. + */ +bool bad_usb_wait_for_connection_ex(bad_usb_abort_cb_t should_abort); + #ifdef __cplusplus } #endif diff --git a/firmware_p4/components/Applications/bluetooth/ble_hid_keyboard.c b/firmware_p4/components/Applications/bluetooth/ble_hid_keyboard.c index 2a7263196..611e16dac 100644 --- a/firmware_p4/components/Applications/bluetooth/ble_hid_keyboard.c +++ b/firmware_p4/components/Applications/bluetooth/ble_hid_keyboard.c @@ -34,7 +34,7 @@ esp_err_t ble_hid_init(void) { uint8_t resp_buf[SPI_MAX_PAYLOAD]; esp_err_t ret = spi_bridge_send_command( - SPI_ID_BT_HID_INIT, NULL, 0, &resp_hdr, resp_buf, HID_SPI_INIT_TIMEOUT_MS); + SPI_ID_BT_HID_INIT, NULL, 0, &resp_hdr, resp_buf, sizeof(resp_buf), HID_SPI_INIT_TIMEOUT_MS); if (ret != ESP_OK || resp_buf[0] != SPI_STATUS_OK) { ESP_LOGE(TAG, "Failed to init HID on C5"); @@ -49,8 +49,13 @@ esp_err_t ble_hid_deinit(void) { spi_header_t resp_hdr; uint8_t resp_buf[SPI_MAX_PAYLOAD]; - esp_err_t ret = spi_bridge_send_command( - SPI_ID_BT_HID_DEINIT, NULL, 0, &resp_hdr, resp_buf, HID_SPI_DEINIT_TIMEOUT_MS); + esp_err_t ret = spi_bridge_send_command(SPI_ID_BT_HID_DEINIT, + NULL, + 0, + &resp_hdr, + resp_buf, + sizeof(resp_buf), + HID_SPI_DEINIT_TIMEOUT_MS); if (ret != ESP_OK || resp_buf[0] != SPI_STATUS_OK) { ESP_LOGW(TAG, "Failed to deinit HID on C5"); @@ -64,8 +69,13 @@ bool ble_hid_is_connected(void) { spi_header_t resp_hdr; uint8_t resp_buf[SPI_MAX_PAYLOAD]; - esp_err_t ret = spi_bridge_send_command( - SPI_ID_BT_HID_IS_CONNECTED, NULL, 0, &resp_hdr, resp_buf, HID_SPI_QUERY_TIMEOUT_MS); + esp_err_t ret = spi_bridge_send_command(SPI_ID_BT_HID_IS_CONNECTED, + NULL, + 0, + &resp_hdr, + resp_buf, + sizeof(resp_buf), + HID_SPI_QUERY_TIMEOUT_MS); if (ret != ESP_OK || resp_buf[0] != SPI_STATUS_OK) { return false; @@ -86,5 +96,6 @@ void ble_hid_send_key(uint8_t keycode, uint8_t modifier) { sizeof(payload), &resp_hdr, resp_buf, + sizeof(resp_buf), HID_SPI_SEND_KEY_TIMEOUT_MS); } diff --git a/firmware_p4/components/Applications/bluetooth/ble_scanner.c b/firmware_p4/components/Applications/bluetooth/ble_scanner.c index 430394a16..64e2a6ab1 100644 --- a/firmware_p4/components/Applications/bluetooth/ble_scanner.c +++ b/firmware_p4/components/Applications/bluetooth/ble_scanner.c @@ -22,6 +22,7 @@ #include "esp_log.h" #include "freertos/FreeRTOS.h" #include "freertos/task.h" +#include "sys_prio.h" #include "bluetooth_service.h" #include "oui_lookup.h" @@ -32,7 +33,7 @@ static const char *TAG = "BLE_SCANNER"; #define SCANNER_STACK_SIZE 4096 -#define SCANNER_TASK_PRIORITY 5 +#define SCANNER_TASK_PRIORITY SYS_PRIO_SERVICE_HI #define SCAN_DURATION_MS 10000 static TaskHandle_t s_scanner_task_handle = NULL; @@ -57,7 +58,8 @@ bool ble_scanner_start(void) { MALLOC_CAP_SPIRAM); } if (s_scanner_task_tcb == NULL) { - s_scanner_task_tcb = (StaticTask_t *)heap_caps_malloc(sizeof(StaticTask_t), MALLOC_CAP_SPIRAM); + s_scanner_task_tcb = (StaticTask_t *)heap_caps_malloc(sizeof(StaticTask_t), + MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT); } if (s_scanner_task_stack == NULL || s_scanner_task_tcb == NULL) { @@ -70,13 +72,14 @@ bool ble_scanner_start(void) { } s_is_scanning = true; - s_scanner_task_handle = xTaskCreateStatic(scanner_task, - "ble_scan_task", - SCANNER_STACK_SIZE, - NULL, - SCANNER_TASK_PRIORITY, - s_scanner_task_stack, - s_scanner_task_tcb); + s_scanner_task_handle = xTaskCreateStaticPinnedToCore(scanner_task, + "ble_scan_task", + SCANNER_STACK_SIZE, + NULL, + SCANNER_TASK_PRIORITY, + s_scanner_task_stack, + s_scanner_task_tcb, + SYS_CORE_RADIO); return (s_scanner_task_handle != NULL); } @@ -173,6 +176,14 @@ static bool save_results_to_loot(void) { static void scanner_task(void *pvParameters) { ESP_LOGI(TAG, "Starting BLE Scan Task (PSRAM)..."); + if (bluetooth_service_init() != ESP_OK || bluetooth_service_start() != ESP_OK) { + ESP_LOGE(TAG, "BLE not available on C5; aborting scan"); + s_is_scanning = false; + s_scanner_task_handle = NULL; + vTaskDelete(NULL); + return; + } + bluetooth_service_scan(SCAN_DURATION_MS); uint16_t dev_num = bluetooth_service_get_scan_count(); diff --git a/firmware_p4/components/Applications/bluetooth/ble_screen_server.c b/firmware_p4/components/Applications/bluetooth/ble_screen_server.c deleted file mode 100644 index f25889b1e..000000000 --- a/firmware_p4/components/Applications/bluetooth/ble_screen_server.c +++ /dev/null @@ -1,160 +0,0 @@ -// Copyright (c) 2025 HIGH CODE LLC -// -// TentacleOS is free software: you can redistribute it and/or modify -// it under the terms of the GNU General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// TentacleOS is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU General Public License for more details. -// -// You should have received a copy of the GNU General Public License -// along with TentacleOS. If not, see . - -#include "ble_screen_server.h" - -#include - -#include "esp_heap_caps.h" -#include "esp_log.h" - -#include "spi_bridge.h" -#include "spi_protocol.h" - -static const char *TAG = "BLE_SCREEN_SERVER"; - -#define MAX_CHUNK_SIZE 240 -#define COMPRESSION_BUFFER_SIZE (32 * 1024) -#define COMPRESSION_OVERFLOW_LIMIT 32000 -#define COMPRESSION_TAIL_LIMIT 32765 -#define RLE_HEADER_MAGIC 0xFE -#define SCREEN_SPI_INIT_TIMEOUT 5000 -#define SCREEN_SPI_DEINIT_TIMEOUT 2000 -#define SCREEN_SPI_SEND_TIMEOUT 1000 -#define SCREEN_SPI_QUERY_TIMEOUT 2000 - -static uint8_t *s_compression_buffer = NULL; -static bool s_is_initialized = false; - -esp_err_t ble_screen_server_init(void) { - if (s_is_initialized) - return ESP_OK; - - spi_header_t resp_hdr; - uint8_t resp_buf[SPI_MAX_PAYLOAD]; - - esp_err_t ret = spi_bridge_send_command( - SPI_ID_BT_SCREEN_INIT, NULL, 0, &resp_hdr, resp_buf, SCREEN_SPI_INIT_TIMEOUT); - - if (ret != ESP_OK || resp_buf[0] != SPI_STATUS_OK) { - ESP_LOGE(TAG, "Failed to init screen server on C5"); - return ESP_FAIL; - } - - s_compression_buffer = heap_caps_malloc(COMPRESSION_BUFFER_SIZE, MALLOC_CAP_SPIRAM); - if (s_compression_buffer == NULL) - return ESP_ERR_NO_MEM; - - s_is_initialized = true; - ESP_LOGI(TAG, "Screen Stream Service initialized"); - return ESP_OK; -} - -void ble_screen_server_deinit(void) { - spi_header_t resp_hdr; - uint8_t resp_buf[SPI_MAX_PAYLOAD]; - - spi_bridge_send_command( - SPI_ID_BT_SCREEN_DEINIT, NULL, 0, &resp_hdr, resp_buf, SCREEN_SPI_DEINIT_TIMEOUT); - - if (s_compression_buffer != NULL) { - free(s_compression_buffer); - s_compression_buffer = NULL; - } - s_is_initialized = false; -} - -void ble_screen_server_send_partial(const uint16_t *px_map, int x, int y, int w, int h) { - if (!s_is_initialized || s_compression_buffer == NULL || px_map == NULL) - return; - - size_t comp_idx = 0; - - // Header: [Magic: 0xFE] [X:2] [Y:2] [W:2] [H:2] - s_compression_buffer[comp_idx++] = RLE_HEADER_MAGIC; - s_compression_buffer[comp_idx++] = x & 0xFF; - s_compression_buffer[comp_idx++] = (x >> 8) & 0xFF; - s_compression_buffer[comp_idx++] = y & 0xFF; - s_compression_buffer[comp_idx++] = (y >> 8) & 0xFF; - s_compression_buffer[comp_idx++] = w & 0xFF; - s_compression_buffer[comp_idx++] = (w >> 8) & 0xFF; - s_compression_buffer[comp_idx++] = h & 0xFF; - s_compression_buffer[comp_idx++] = (h >> 8) & 0xFF; - - // RLE Compression - uint16_t current_pixel = px_map[0]; - uint8_t run_count = 0; - size_t total_pixels = w * h; - - for (size_t i = 0; i < total_pixels; i++) { - uint16_t px = px_map[i]; - - if (px == current_pixel && run_count < 255) { - run_count++; - } else { - s_compression_buffer[comp_idx++] = run_count; - s_compression_buffer[comp_idx++] = current_pixel & 0xFF; - s_compression_buffer[comp_idx++] = (current_pixel >> 8) & 0xFF; - - current_pixel = px; - run_count = 1; - } - - if (comp_idx > COMPRESSION_OVERFLOW_LIMIT) - break; - } - - if (run_count > 0 && comp_idx < COMPRESSION_TAIL_LIMIT) { - s_compression_buffer[comp_idx++] = run_count; - s_compression_buffer[comp_idx++] = current_pixel & 0xFF; - s_compression_buffer[comp_idx++] = (current_pixel >> 8) & 0xFF; - } - - // Send compressed data in chunks via SPI - size_t total_len = comp_idx; - size_t sent = 0; - - spi_header_t resp_hdr; - uint8_t resp_buf[SPI_MAX_PAYLOAD]; - - while (sent < total_len) { - size_t chunk_len = total_len - sent; - if (chunk_len > MAX_CHUNK_SIZE) - chunk_len = MAX_CHUNK_SIZE; - - spi_bridge_send_command(SPI_ID_BT_SCREEN_SEND_PARTIAL, - s_compression_buffer + sent, - chunk_len, - &resp_hdr, - resp_buf, - SCREEN_SPI_SEND_TIMEOUT); - - sent += chunk_len; - } -} - -bool ble_screen_server_is_active(void) { - spi_header_t resp_hdr; - uint8_t resp_buf[SPI_MAX_PAYLOAD]; - - esp_err_t ret = spi_bridge_send_command( - SPI_ID_BT_SCREEN_IS_ACTIVE, NULL, 0, &resp_hdr, resp_buf, SCREEN_SPI_QUERY_TIMEOUT); - - if (ret != ESP_OK || resp_buf[0] != SPI_STATUS_OK) { - return false; - } - - return resp_buf[1]; -} diff --git a/firmware_p4/components/Applications/bluetooth/ble_sniffer.c b/firmware_p4/components/Applications/bluetooth/ble_sniffer.c index a80a556c4..053ec9161 100644 --- a/firmware_p4/components/Applications/bluetooth/ble_sniffer.c +++ b/firmware_p4/components/Applications/bluetooth/ble_sniffer.c @@ -23,6 +23,7 @@ #include "freertos/FreeRTOS.h" #include "freertos/queue.h" #include "freertos/task.h" +#include "sys_prio.h" #include "bluetooth_service.h" @@ -40,6 +41,8 @@ typedef struct { uint8_t len; } ble_sniffer_packet_t; +static ble_sniffer_observer_t s_observer = NULL; + static QueueHandle_t s_sniffer_queue = NULL; static TaskHandle_t s_sniffer_task_handle = NULL; static StackType_t *s_sniffer_task_stack = NULL; @@ -49,6 +52,10 @@ static StaticQueue_t *s_sniffer_queue_struct = NULL; static void sniffer_task(void *pvParameters); +void ble_sniffer_set_observer(ble_sniffer_observer_t cb) { + s_observer = cb; +} + static void packet_handler( const uint8_t *addr, uint8_t addr_type, int rssi, const uint8_t *data, uint16_t len) { if (s_sniffer_queue != NULL) { @@ -97,18 +104,20 @@ esp_err_t ble_sniffer_start(void) { s_sniffer_queue_storage, s_sniffer_queue_struct); - s_sniffer_task_handle = xTaskCreateStatic(sniffer_task, - "sniffer_task", - SNIFFER_TASK_STACK_SIZE, - NULL, - tskIDLE_PRIORITY + 1, - s_sniffer_task_stack, - s_sniffer_task_tcb); + s_sniffer_task_handle = xTaskCreateStaticPinnedToCore(sniffer_task, + "sniffer_task", + SNIFFER_TASK_STACK_SIZE, + NULL, + SYS_PRIO_BACKGROUND_LO, + s_sniffer_task_stack, + s_sniffer_task_tcb, + SYS_CORE_RADIO); return bluetooth_service_start_sniffer(packet_handler); } void ble_sniffer_stop(void) { + s_observer = NULL; bluetooth_service_stop_sniffer(); if (s_sniffer_task_handle != NULL) { @@ -158,6 +167,18 @@ static void sniffer_task(void *pvParameters) { printf("%02X ", packet.data[i]); } printf("\n"); + + ble_sniffer_observer_t obs = s_observer; + if (obs != NULL) { + ble_sniffer_adv_t adv = { + .addr = packet.addr, + .addr_type = packet.addr_type, + .rssi = packet.rssi, + .data = packet.data, + .len = packet.len, + }; + obs(&adv); + } } } } diff --git a/firmware_p4/components/Applications/bluetooth/gatt_explorer.c b/firmware_p4/components/Applications/bluetooth/gatt_explorer.c index f1a6158db..dbee50157 100644 --- a/firmware_p4/components/Applications/bluetooth/gatt_explorer.c +++ b/firmware_p4/components/Applications/bluetooth/gatt_explorer.c @@ -46,8 +46,13 @@ bool gatt_explorer_start(const uint8_t *addr, uint8_t addr_type) { spi_header_t resp_hdr; uint8_t resp_buf[SPI_MAX_PAYLOAD]; - esp_err_t ret = spi_bridge_send_command( - SPI_ID_BT_APP_GATT_EXP, payload, sizeof(payload), &resp_hdr, resp_buf, GATT_SPI_TIMEOUT_MS); + esp_err_t ret = spi_bridge_send_command(SPI_ID_BT_APP_GATT_EXP, + payload, + sizeof(payload), + &resp_hdr, + resp_buf, + sizeof(resp_buf), + GATT_SPI_TIMEOUT_MS); if (ret != ESP_OK || resp_buf[0] != SPI_STATUS_OK) { ESP_LOGE(TAG, "Failed to start GATT exploration on C5"); diff --git a/firmware_p4/components/Applications/bluetooth/include/ble_screen_server.h b/firmware_p4/components/Applications/bluetooth/include/ble_screen_server.h deleted file mode 100644 index 145494707..000000000 --- a/firmware_p4/components/Applications/bluetooth/include/ble_screen_server.h +++ /dev/null @@ -1,64 +0,0 @@ -// Copyright (c) 2025 HIGH CODE LLC -// -// TentacleOS is free software: you can redistribute it and/or modify -// it under the terms of the GNU General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// TentacleOS is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU General Public License for more details. -// -// You should have received a copy of the GNU General Public License -// along with TentacleOS. If not, see . - -#ifndef BLE_SCREEN_SERVER_H -#define BLE_SCREEN_SERVER_H - -#ifdef __cplusplus -extern "C" { -#endif - -#include -#include - -#include "esp_err.h" - -/** - * @brief Initialize the BLE screen streaming GATT service. - * - * @return - * - ESP_OK on success - * - ESP_ERR_NO_MEM if allocation fails - */ -esp_err_t ble_screen_server_init(void); - -/** - * @brief Deinitialize the BLE screen streaming service and free resources. - */ -void ble_screen_server_deinit(void); - -/** - * @brief Send a partial screen update over BLE notifications. - * - * @param px_map Pointer to pixel data (RGB565). Must not be NULL. - * @param x X offset of the partial region. - * @param y Y offset of the partial region. - * @param w Width of the partial region. - * @param h Height of the partial region. - */ -void ble_screen_server_send_partial(const uint16_t *px_map, int x, int y, int w, int h); - -/** - * @brief Check if the screen server is initialized and active. - * - * @return true if active, false otherwise. - */ -bool ble_screen_server_is_active(void); - -#ifdef __cplusplus -} -#endif - -#endif // BLE_SCREEN_SERVER_H diff --git a/firmware_p4/components/Applications/bluetooth/include/ble_sniffer.h b/firmware_p4/components/Applications/bluetooth/include/ble_sniffer.h index 3fcb5ed53..9745b6308 100644 --- a/firmware_p4/components/Applications/bluetooth/include/ble_sniffer.h +++ b/firmware_p4/components/Applications/bluetooth/include/ble_sniffer.h @@ -20,8 +20,39 @@ extern "C" { #endif +#include + #include "esp_err.h" +/** + * @brief One sniffed advertisement passed to the observer. + * + * @p addr/@p data point at buffers owned by the sniffer, valid only for the + * duration of the observer call. + */ +typedef struct { + const uint8_t *addr; + uint8_t addr_type; + int rssi; + const uint8_t *data; + uint8_t len; +} ble_sniffer_adv_t; + +/** + * @brief Observer invoked for every sniffed advertisement. + * + * Runs in the sniffer task context (radio core), NOT the LVGL thread - a UI + * observer must only copy the data into its own lock-guarded buffer and never + * touch LVGL directly. + */ +typedef void (*ble_sniffer_observer_t)(const ble_sniffer_adv_t *adv); + +/** + * @brief Register (or clear, with NULL) an observer for live sniffed frames. + * Independent of start/stop; safe to set before or after starting. + */ +void ble_sniffer_set_observer(ble_sniffer_observer_t cb); + /** * @brief Start the BLE packet sniffer. * diff --git a/firmware_p4/components/Applications/bluetooth/skimmer_detector.c b/firmware_p4/components/Applications/bluetooth/skimmer_detector.c index 31e84968e..be232f09e 100644 --- a/firmware_p4/components/Applications/bluetooth/skimmer_detector.c +++ b/firmware_p4/components/Applications/bluetooth/skimmer_detector.c @@ -59,6 +59,7 @@ skimmer_detector_record_t *skimmer_detector_get_results(uint16_t *out_count) { sizeof(magic_count), &resp, payload, + sizeof(payload), SPI_DATA_TIMEOUT_MS) != ESP_OK) { if (out_count != NULL) { *out_count = s_cached_count; @@ -96,6 +97,7 @@ skimmer_detector_record_t *skimmer_detector_get_results(uint16_t *out_count) { sizeof(i), &resp, (uint8_t *)&s_cached_results[i], + sizeof(s_cached_results[i]), SPI_DATA_TIMEOUT_MS) != ESP_OK) { break; } diff --git a/firmware_p4/components/Applications/bluetooth/tracker_detector.c b/firmware_p4/components/Applications/bluetooth/tracker_detector.c index 720fa59ac..22e3c989a 100644 --- a/firmware_p4/components/Applications/bluetooth/tracker_detector.c +++ b/firmware_p4/components/Applications/bluetooth/tracker_detector.c @@ -59,6 +59,7 @@ tracker_detector_record_t *tracker_detector_get_results(uint16_t *out_count) { sizeof(magic_count), &resp, payload, + sizeof(payload), SPI_DATA_TIMEOUT_MS) != ESP_OK) { if (out_count != NULL) { *out_count = s_cached_count; @@ -96,6 +97,7 @@ tracker_detector_record_t *tracker_detector_get_results(uint16_t *out_count) { sizeof(i), &resp, (uint8_t *)&s_cached_results[i], + sizeof(s_cached_results[i]), SPI_DATA_TIMEOUT_MS) != ESP_OK) { break; } diff --git a/firmware_p4/components/Applications/nfc/nfc_listener.c b/firmware_p4/components/Applications/nfc/nfc_listener.c index 7dd53cc1f..153e24365 100644 --- a/firmware_p4/components/Applications/nfc/nfc_listener.c +++ b/firmware_p4/components/Applications/nfc/nfc_listener.c @@ -19,6 +19,7 @@ #include "esp_log.h" #include "freertos/FreeRTOS.h" #include "freertos/task.h" +#include "sys_prio.h" #include "nfc_device.h" #include "mf_classic.h" @@ -43,7 +44,8 @@ static void nfc_listener_task(void *arg) { static hb_nfc_err_t start_emu_task(void) { s_is_emu_running = true; - BaseType_t rc = xTaskCreate(nfc_listener_task, "nfc_emu", 4096, NULL, 6, &s_emu_task); + BaseType_t rc = xTaskCreatePinnedToCore( + nfc_listener_task, "nfc_emu", 4096, NULL, SYS_PRIO_SERVICE_HI, &s_emu_task, SYS_CORE_RADIO); return (rc == pdPASS) ? HB_NFC_OK : HB_NFC_ERR_INTERNAL; } diff --git a/firmware_p4/components/Applications/nfc/nfc_manager.c b/firmware_p4/components/Applications/nfc/nfc_manager.c index 5ace89d44..af1a612f2 100644 --- a/firmware_p4/components/Applications/nfc/nfc_manager.c +++ b/firmware_p4/components/Applications/nfc/nfc_manager.c @@ -17,8 +17,10 @@ #include "freertos/FreeRTOS.h" #include "freertos/task.h" +#include "sys_prio.h" #include "hb_nfc_timer.h" +#include "led_control.h" #include "nfc_card_info.h" #include "nfc_poller.h" #include "nfc_reader.h" @@ -85,6 +87,8 @@ static void nfc_manager_task(void *arg) { full.protocol = HB_PROTO_ISO14443_4A; } + led_signal_info(); // a card was detected and read + if (s_mgr.cb) { s_mgr.cb(&full, s_mgr.ctx); } else { @@ -111,7 +115,8 @@ hb_nfc_err_t nfc_manager_start(nfc_manager_card_found_cb_t cb, void *ctx) { s_mgr.ctx = ctx; s_mgr.running = true; - xTaskCreate(nfc_manager_task, "nfc_mgr", 8192, NULL, 5, &s_mgr.task); + xTaskCreatePinnedToCore( + nfc_manager_task, "nfc_mgr", 8192, NULL, SYS_PRIO_SERVICE_HI, &s_mgr.task, SYS_CORE_RADIO); return HB_NFC_OK; } diff --git a/firmware_p4/components/Applications/ui/README.md b/firmware_p4/components/Applications/ui/README.md index 2cbbcc1f0..c5ca11251 100644 --- a/firmware_p4/components/Applications/ui/README.md +++ b/firmware_p4/components/Applications/ui/README.md @@ -1,190 +1,7 @@ # ui_manager -step-by-step process for adding a new screen (feature) to the HighBoy system using the ui_manager architecture. -**Example** used: We'll create a fictional **Bluetooth (BLE)** screen. +Documentation for this component lives in the project docs hub (single source of truth): -### 1. Register the screen in the UI ui_manager -The `ui_manager` needs to know about the new screen to handle navigation. +- [docs/ui/README.md](../../../../docs/ui/README.md) -**File:** `ui/ui_manager.h` -1. Add a new identifier to the `enum`: -```c -typedef enum { - SCREEN_NONE, - SCREEN_HOME, - SCREEN_MENU, - SCREEN_WIFI_MENU, - // ... - SCREEN_BLE_MENU, // <--- NEW ID ADDED -} screen_id_t; -``` - -### 2. Configure routing and Power Management -Define how the ui_manager should open the screen and handle any required hardware power states. - -**File:** `ui/ui_manager.c` -1. Include de header for the new screen (created in Step 3): -```c -#include "screens/bluetooth/ui_ble_menu.h" -``` - -2. (Optional) Power Management: If the screen uses a radio (Wi-Fi, BLE, RF), add logic to automatically enable/disable the hardware. -```c -static bool is_ble_screen(screen_id_t screen) { - switch (screen) { - case SCREEN_BLE_MENU: - case SCREEN_BLE_SCAN: // Future sub-screens - return true; - default: - return false; - } -} -``` - -Update `ui_switch_screen` to call `ble_init()` / `ble_deinit()` based on this flag (similar to how Wi-Fi is handled). - -3. Add the case to the main switch statement: -```c -void ui_switch_screen(screen_id_t new_screen) { - if (ui_acquire()) { - // ... init/deinit logic ... - clear_current_screen(); - - switch (new_screen) { - // ... other cases ... - - case SCREEN_BLE_MENU: // <--- NEW ROUTE - ui_ble_menu_open(); - break; - } - // ... - } -} -``` - -### 3. Create the New Screen UI -Create the folder and files for the new feature: `ui/screens/bluetooth/` - -**Header File:** `ui_ble_menu.h` -```c -#ifndef UI_BLE_MENU_H -#define UI_BLE_MENU_H -#include "lvgl.h" -void ui_ble_menu_open(void); // Public function -#endif -``` - -**Source File:** `ui_ble_menu.c` -Standard template from any Highboy screen: - -```c -#include "ui_ble_menu.h" -#include "ui_manager.h" -#include "lv_port_indev.h" // Access to main_group -#include "esp_log.h" - -static const char *TAG = "UI_BLE"; -static lv_obj_t * screen_ble = NULL; - -// 1. Event Callback (Navigation) -static void ble_event_cb(lv_event_t * e) { - lv_event_code_t code = lv_event_get_code(e); - - if (code == LV_EVENT_KEY) { - uint32_t key = lv_event_get_key(e); - // BACK BUTTON (ESC/LEFT) - if (key == LV_KEY_ESC || key == LV_KEY_LEFT) { - ESP_LOGI(TAG, "Returning to Main Menu"); - // Destroy current screen and open Menu - ui_switch_screen(SCREEN_MENU); - } - } -} - -// 2. Screen Build Function -void ui_ble_menu_open(void) { - // Safety cleanup - if (screen_ble) { - lv_obj_del(screen_ble); - screen_ble = NULL; - } - - // A. Create Base Screen - screen_ble = lv_obj_create(NULL); - lv_obj_set_style_bg_color(screen_ble, lv_color_black(), 0); - - // B. Add Content (e.g., Title) - lv_obj_t * label = lv_label_create(screen_ble); - lv_label_set_text(label, "Bluetooth Menu"); - lv_obj_set_style_text_color(label, lv_color_white(), 0); - lv_obj_align(label, LV_ALIGN_CENTER, 0, 0); - - // C. Setup Navigation - lv_obj_add_event_cb(screen_ble, ble_event_cb, LV_EVENT_KEY, NULL); - - // Add to Input Group (Essential!) - if (main_group) { - lv_group_add_obj(main_group, screen_ble); - lv_group_focus_obj(screen_ble); - } - - // D. Load Screen - lv_screen_load(screen_ble); -} -``` - -### 4. Link from the main Menu -Add a button/entru in the main menu to access the new screen - -**File:** `ui/screens/menu/ui_menu.c` -1. In the `menu_event_cb` callback, locate the corresponding item ID case and add/uncomment the call: -```c -case MENU_ID_BLUETOOTH: - ui_switch_screen(SCREEN_BLE_MENU); // <--- Routes to the new screen - break; -``` -(Note: If the MENU_ID_BLUETOOTH entry doesn't exist yet in menu_item_id_t, create it.) - -### 5. Update Build System (CMake) -Commom error: forgettint to register the new source files. - -**File:** `CMakeLists.txt` (UI component) -1. Add the new sources files and include directory: -```cmake -file(GLOB_RECURSE HOME_UI_SRCS "ui/screens/home/*.c") -file(GLOB_RECURSE MENU_UI_SRCS "ui/screens/menu/*.c") -file(GLOB_RECURSE WIFI_UI_SRCS "ui/screens/wifi/*.c") -file(GLOB_RECURSE BLE_UI_SRCS "ui/screens/ble/*.c") # <---- Add srcs here - -idf_component_register(SRCS - "ui/ui_manager.c" - ${HOME_UI_SRCS} - ${MENU_UI_SRCS} - ${WIFI_UI_SRCS} - ${BLE_UI_SRCS} # <----- and call it here - - INCLUDE_DIRS - "ui/include" - "ui/screens/home/include" - "ui/screens/menu/include" - "ui/screens/wifi/include" - "ui/screens/ble/include" # <----- dont forget include files -) -``` -2. Recommended: Run `idf.py reconfigure` in the terminal after saving - ---- - -## Execution Flow Sumamary -1. User selects **Bluetooth** from the Main Menu. -2. Menu callback calls `ui_switch_screen(SCREEN_BLE_MENU)`. -3. `ui_manager`: - - Handles hardware power (enables BLE if needed). - - Clears previous screen. - - Calls `ui_ble_menu_open()`. -4. `ui_ble_menu_open`: - - Creates visual objects. - - Adds objects to `main_group`. - - Loads the screen. - -**Done! The new screen is fully integrated, safe and navigable.** +This file is only a pointer: edit the docs there to keep things from drifting. diff --git a/firmware_p4/components/Applications/ui/assets_manager.c b/firmware_p4/components/Applications/ui/assets_manager.c index 80d47358d..695539ca5 100644 --- a/firmware_p4/components/Applications/ui/assets_manager.c +++ b/firmware_p4/components/Applications/ui/assets_manager.c @@ -15,25 +15,22 @@ #include "assets_manager.h" -#include #include #include #include -#include -#include "esp_heap_caps.h" +#include "esp_littlefs.h" #include "esp_log.h" -static const char *TAG = "ASSETS_MANAGER"; +#include "lvgl_glue.h" -typedef struct asset_node { - char *path; - lv_image_dsc_t *dsc; - bool from_sd; - struct asset_node *next; -} asset_node_t; +#include "draw/lv_image_decoder_private.h" +#include "misc/cache/instance/lv_image_cache.h" +#include "misc/cache/lv_cache_private.h" -static asset_node_t *s_assets_head = NULL; +static const char *TAG = "ASSETS_MANAGER"; + +#define ARGB8888_BYTES_PER_PIXEL 4 typedef struct __attribute__((packed)) { uint32_t magic_cf; @@ -42,237 +39,239 @@ typedef struct __attribute__((packed)) { uint32_t stride; } bin_header_t; -static lv_image_dsc_t *load_asset_from_file(const char *path) { - FILE *f = fopen(path, "rb"); - if (f == NULL) { - ESP_LOGE(TAG, "Failed to open file: %s", path); - return NULL; - } - - fseek(f, 0, SEEK_END); - long file_size = ftell(f); - fseek(f, 0, SEEK_SET); +typedef struct asset_node { + lv_image_dsc_t dsc; + char *path; + struct asset_node *next; +} asset_node_t; - if (file_size < sizeof(bin_header_t)) { - ESP_LOGE(TAG, "File too small to contain header: %s", path); - fclose(f); - return NULL; +static asset_node_t *s_assets_head = NULL; +static bool s_decoder_registered = false; + +// Linear scan, but move a hit to the front so the assets a screen re-requests +// every build (header icons, etc.) settle near the head. Relinking only reorders +// the list; the nodes do not move, so every &node->dsc handed out stays valid. +static asset_node_t *find_node_by_path(const char *path) { + asset_node_t *prev = NULL; + for (asset_node_t *n = s_assets_head; n; prev = n, n = n->next) { + if (strcmp(n->path, path) == 0) { + if (prev != NULL) { + prev->next = n->next; + n->next = s_assets_head; + s_assets_head = n; + } + return n; + } } + return NULL; +} - bin_header_t header; - if (fread(&header, 1, sizeof(bin_header_t), f) != sizeof(bin_header_t)) { - ESP_LOGE(TAG, "Error reading header: %s", path); - fclose(f); - return NULL; +static asset_node_t *find_node_by_dsc(const void *src) { + for (asset_node_t *n = s_assets_head; n; n = n->next) { + if ((const void *)&n->dsc == src) + return n; } + return NULL; +} - long pixel_data_size = file_size - sizeof(bin_header_t); +static bool read_bin_header(const char *path, bin_header_t *out) { + FILE *f = fopen(path, "rb"); + if (f == NULL) + return false; + bool ok = fread(out, 1, sizeof(*out), f) == sizeof(*out); + fclose(f); + return ok; +} - lv_image_dsc_t *dsc = (lv_image_dsc_t *)heap_caps_malloc(sizeof(lv_image_dsc_t), - MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); - uint8_t *pixel_data = - (uint8_t *)heap_caps_malloc(pixel_data_size, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); +static lv_result_t asset_decoder_info(lv_image_decoder_t *decoder, + lv_image_decoder_dsc_t *dsc, + lv_image_header_t *header) { + (void)decoder; + if (dsc->src_type != LV_IMAGE_SRC_VARIABLE) + return LV_RESULT_INVALID; + asset_node_t *node = find_node_by_dsc(dsc->src); + if (node == NULL) + return LV_RESULT_INVALID; + *header = node->dsc.header; + return LV_RESULT_OK; +} - if (dsc == NULL || pixel_data == NULL) { - ESP_LOGE(TAG, "PSRAM allocation failed for %s. DSC: %p, Data: %p", path, dsc, pixel_data); - if (dsc) - free(dsc); - if (pixel_data) - free(pixel_data); - fclose(f); - return NULL; +static lv_result_t asset_decoder_open(lv_image_decoder_t *decoder, lv_image_decoder_dsc_t *dsc) { + asset_node_t *node = find_node_by_dsc(dsc->src); + if (node == NULL) + return LV_RESULT_INVALID; + + const uint32_t w = node->dsc.header.w; + const uint32_t h = node->dsc.header.h; + + lv_draw_buf_t *buf = lv_draw_buf_create(w, h, LV_COLOR_FORMAT_ARGB8888, LV_STRIDE_AUTO); + if (buf == NULL) { + ESP_LOGE(TAG, + "draw buf alloc failed for %s (%lux%lu)", + node->path, + (unsigned long)w, + (unsigned long)h); + return LV_RESULT_INVALID; } - if (fread(pixel_data, 1, pixel_data_size, f) != pixel_data_size) { - ESP_LOGE(TAG, "Error reading pixel data: %s", path); - free(dsc); - free(pixel_data); - fclose(f); - return NULL; + FILE *f = fopen(node->path, "rb"); + if (f == NULL || fseek(f, sizeof(bin_header_t), SEEK_SET) != 0) { + if (f) + fclose(f); + lv_draw_buf_destroy(buf); + return LV_RESULT_INVALID; } + const uint32_t row_bytes = w * ARGB8888_BYTES_PER_PIXEL; + bool ok = true; + for (uint32_t y = 0; y < h && ok; y++) { + uint8_t *row = buf->data + (size_t)y * buf->header.stride; + ok = fread(row, 1, row_bytes, f) == row_bytes; + } fclose(f); - dsc->header.magic = LV_IMAGE_HEADER_MAGIC; - dsc->header.cf = LV_COLOR_FORMAT_ARGB8888; - dsc->header.w = header.w; - dsc->header.h = header.h; - dsc->header.stride = header.stride; - dsc->header.flags = 0; - dsc->data_size = pixel_data_size; - dsc->data = pixel_data; - - ESP_LOGI(TAG, "Loaded: %s (%dx%d)", path, header.w, header.h); - return dsc; + if (!ok) { + ESP_LOGE(TAG, "pixel read failed for %s", node->path); + lv_draw_buf_destroy(buf); + return LV_RESULT_INVALID; + } + + dsc->decoded = buf; + + if (lv_image_cache_is_enabled()) { + lv_image_cache_data_t key; + key.src_type = dsc->src_type; + key.src = dsc->src; + key.slot.size = buf->data_size; + lv_cache_entry_t *entry = lv_image_decoder_add_to_cache(decoder, &key, buf, NULL); + if (entry == NULL) { + lv_draw_buf_destroy(buf); + dsc->decoded = NULL; + return LV_RESULT_INVALID; + } + dsc->cache_entry = entry; + dsc->user_data = NULL; + } else { + dsc->user_data = buf; + } + + return LV_RESULT_OK; } -static void add_asset_to_list(const char *path, lv_image_dsc_t *dsc, bool from_sd) { - asset_node_t *node = malloc(sizeof(asset_node_t)); - if (node == NULL) { - ESP_LOGE(TAG, "Error allocating list node for %s", path); - return; +static void asset_decoder_close(lv_image_decoder_t *decoder, lv_image_decoder_dsc_t *dsc) { + (void)decoder; + if (dsc->user_data) { + lv_draw_buf_destroy((lv_draw_buf_t *)dsc->user_data); + dsc->user_data = NULL; } - node->path = strdup(path); - node->dsc = dsc; - node->from_sd = from_sd; - node->next = s_assets_head; - s_assets_head = node; } -static void scan_and_load_recursive(const char *base_path) { - DIR *dir = opendir(base_path); - if (dir == NULL) +static void register_decoder(void) { + if (s_decoder_registered) + return; + lv_image_decoder_t *dec = lv_image_decoder_create(); + if (dec == NULL) { + ESP_LOGE(TAG, "Failed to create image decoder"); return; - - struct dirent *ent; - char path[512]; - - while ((ent = readdir(dir)) != NULL) { - if (ent->d_type == DT_DIR) { - if (strcmp(ent->d_name, ".") == 0 || strcmp(ent->d_name, "..") == 0) - continue; - snprintf(path, sizeof(path), "%s/%s", base_path, ent->d_name); - scan_and_load_recursive(path); - } else if (ent->d_type == DT_REG) { - size_t len = strlen(ent->d_name); - if (len > 4 && strcmp(ent->d_name + len - 4, ".bin") == 0) { - snprintf(path, sizeof(path), "%s/%s", base_path, ent->d_name); - - if (assets_get(path) == NULL) { - lv_image_dsc_t *dsc = load_asset_from_file(path); - if (dsc) { - add_asset_to_list(path, dsc, false); - } - } - } - } } - closedir(dir); + lv_image_decoder_set_info_cb(dec, asset_decoder_info); + lv_image_decoder_set_open_cb(dec, asset_decoder_open); + lv_image_decoder_set_close_cb(dec, asset_decoder_close); + s_decoder_registered = true; } void assets_manager_init(void) { - ESP_LOGI(TAG, "Starting assets loading..."); - - struct stat st; - if (stat("/assets", &st) == 0) { - scan_and_load_recursive("/assets"); - } else { - ESP_LOGE(TAG, "Directory /assets not found!"); + ESP_LOGI(TAG, "Starting assets manager..."); + + if (!esp_littlefs_mounted("assets")) { + esp_vfs_littlefs_conf_t conf = { + .base_path = "/assets", + .partition_label = "assets", + .format_if_mount_failed = false, + .dont_mount = false, + }; + esp_err_t err = esp_vfs_littlefs_register(&conf); + if (err != ESP_OK) { + ESP_LOGE(TAG, "Failed to mount /assets LittleFS (%s)", esp_err_to_name(err)); + } else { + size_t total = 0, used = 0; + if (esp_littlefs_info("assets", &total, &used) == ESP_OK) { + ESP_LOGI(TAG, "/assets mounted: %u/%u bytes used", (unsigned)used, (unsigned)total); + } + } } - ESP_LOGI(TAG, "Assets loading finished."); + register_decoder(); + + ESP_LOGI(TAG, "Assets manager ready."); } lv_image_dsc_t *assets_get(const char *path) { - asset_node_t *curr = s_assets_head; - while (curr) { - if (strcmp(curr->path, path) == 0) { - return curr->dsc; - } - curr = curr->next; + if (path == NULL) + return NULL; + + asset_node_t *node = find_node_by_path(path); + if (node != NULL) + return &node->dsc; + + bin_header_t hdr; + if (!read_bin_header(path, &hdr)) { + ESP_LOGW(TAG, "asset not found: %s", path); + return NULL; } - return NULL; + + node = calloc(1, sizeof(asset_node_t)); + if (node == NULL) + return NULL; + node->path = strdup(path); + if (node->path == NULL) { + free(node); + return NULL; + } + + node->dsc.header.magic = LV_IMAGE_HEADER_MAGIC; + node->dsc.header.cf = LV_COLOR_FORMAT_ARGB8888; + node->dsc.header.w = hdr.w; + node->dsc.header.h = hdr.h; + node->dsc.header.stride = hdr.w * ARGB8888_BYTES_PER_PIXEL; + node->dsc.header.flags = 0; + node->dsc.data_size = (uint32_t)hdr.w * hdr.h * ARGB8888_BYTES_PER_PIXEL; + node->dsc.data = (const uint8_t *)node->path; + + node->next = s_assets_head; + s_assets_head = node; + return &node->dsc; } void assets_manager_free_all(void) { asset_node_t *curr = s_assets_head; while (curr) { asset_node_t *next = curr->next; - if (curr->dsc) { - if (curr->dsc->data) - free((void *)curr->dsc->data); - free(curr->dsc); - } - if (curr->path) - free(curr->path); + free(curr->path); free(curr); curr = next; } s_assets_head = NULL; } -static void free_node_dsc(asset_node_t *node) { - if (node->dsc) { - if (node->dsc->data) - free((void *)node->dsc->data); - free(node->dsc); - node->dsc = NULL; - } -} - -static bool replace_asset_in_list(const char *key, lv_image_dsc_t *dsc) { - asset_node_t *curr = s_assets_head; - while (curr) { - if (strcmp(curr->path, key) == 0) { - free_node_dsc(curr); - curr->dsc = dsc; - curr->from_sd = true; - return true; - } - curr = curr->next; +// Drop the LVGL image cache (the decoded-pixel pool, up to CONFIG_LV_CACHE_DEF_SIZE) +// under memory pressure. Safe to call mid-session, unlike free_all: the asset +// nodes and the &node->dsc pointers the UI holds stay valid, so a redraw just +// re-decodes from LittleFS. Takes the LVGL lock; skips if the UI is mid-render. +#define ASSETS_EVICT_LOCK_MS 200 +void assets_manager_evict_cache(void) { + if (!lvgl_glue_lock(ASSETS_EVICT_LOCK_MS)) { + ESP_LOGW(TAG, "evict skipped: LVGL lock busy"); + return; } - return false; + lv_image_cache_drop(NULL); + lvgl_glue_unlock(); } int assets_load_from_sd(const char *sd_dir, const char *flash_prefix) { - if (sd_dir == NULL || flash_prefix == NULL) - return 0; - - DIR *dir = opendir(sd_dir); - if (dir == NULL) { - return 0; - } - - int count = 0; - struct dirent *ent; - char sd_path[512]; - char cache_key[512]; - - while ((ent = readdir(dir)) != NULL) { - if (ent->d_type != DT_REG) - continue; - - size_t len = strlen(ent->d_name); - if (len <= 4 || strcmp(ent->d_name + len - 4, ".bin") != 0) - continue; - - snprintf(sd_path, sizeof(sd_path), "%s/%s", sd_dir, ent->d_name); - snprintf(cache_key, sizeof(cache_key), "%s/%s", flash_prefix, ent->d_name); - - lv_image_dsc_t *dsc = load_asset_from_file(sd_path); - if (dsc == NULL) - continue; - - if (!replace_asset_in_list(cache_key, dsc)) { - add_asset_to_list(cache_key, dsc, true); - } - - ESP_LOGI(TAG, "SD override: %s -> %s", sd_path, cache_key); - count++; - } - - closedir(dir); - ESP_LOGI(TAG, "Loaded %d asset(s) from SD dir: %s", count, sd_dir); - return count; + (void)sd_dir; + (void)flash_prefix; + return 0; } -void assets_unload_sd(void) { - asset_node_t **pp = &s_assets_head; - int removed = 0; - - while (*pp) { - asset_node_t *node = *pp; - if (node->from_sd) { - *pp = node->next; - free_node_dsc(node); - if (node->path) - free(node->path); - free(node); - removed++; - } else { - pp = &node->next; - } - } - - ESP_LOGI(TAG, "Unloaded %d SD asset(s) from cache", removed); -} \ No newline at end of file +void assets_unload_sd(void) {} diff --git a/firmware_p4/components/Applications/ui/components/button/button_ui.c b/firmware_p4/components/Applications/ui/components/button/button_ui.c index a67b96b46..ceedeead5 100644 --- a/firmware_p4/components/Applications/ui/components/button/button_ui.c +++ b/firmware_p4/components/Applications/ui/components/button/button_ui.c @@ -37,8 +37,7 @@ button_ui_t button_ui_create(lv_obj_t *parent, lv_obj_set_style_radius(b.obj, height / 2, 0); lv_obj_set_style_bg_opa(b.obj, LV_OPA_COVER, 0); lv_obj_set_style_bg_color(b.obj, BTN_BG, 0); - lv_obj_set_style_bg_grad_color(b.obj, BTN_GRAD, 0); - lv_obj_set_style_bg_grad_dir(b.obj, LV_GRAD_DIR_HOR, 0); + lv_obj_set_style_bg_grad_dir(b.obj, LV_GRAD_DIR_NONE, 0); lv_obj_set_style_border_width(b.obj, 1, 0); lv_obj_set_style_border_color(b.obj, BTN_BORDER, 0); lv_obj_set_style_pad_left(b.obj, 10, 0); diff --git a/firmware_p4/components/Applications/ui/components/capture_result/capture_result_ui.c b/firmware_p4/components/Applications/ui/components/capture_result/capture_result_ui.c new file mode 100644 index 000000000..27b5cae63 --- /dev/null +++ b/firmware_p4/components/Applications/ui/components/capture_result/capture_result_ui.c @@ -0,0 +1,233 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#include "capture_result_ui.h" + +#include "st7789.h" + +#include "assets_manager.h" +#include "ui_chrome.h" +#include "ui_theme.h" + +#define COL_DIM 0x8A8594 +#define COL_RAISE 0x170A28 + +#define CR_TOP UI_CHROME_HEADER_H +#define CR_H (LCD_V_RES - UI_CHROME_HEADER_H - UI_CHROME_FOOTER_H) +#define CARD_H 70 +#define ROW_H 34 +#define GAP 6 +#define ICON_CELL 44 + +static const char *ACTION_SYM[CAP_ACT_MAX] = { + [CAP_ACT_PRIMARY] = LV_SYMBOL_UPLOAD, + [CAP_ACT_SAVE] = LV_SYMBOL_SAVE, + [CAP_ACT_AGAIN] = LV_SYMBOL_REFRESH, + [CAP_ACT_DISCARD] = LV_SYMBOL_CLOSE, +}; + +static lv_obj_t *bare(lv_obj_t *parent) { + lv_obj_t *o = lv_obj_create(parent); + lv_obj_remove_flag(o, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_remove_flag(o, LV_OBJ_FLAG_CLICKABLE); + lv_obj_set_style_border_width(o, 0, 0); + lv_obj_set_style_bg_opa(o, LV_OPA_TRANSP, 0); + lv_obj_set_style_radius(o, 0, 0); + lv_obj_set_style_pad_all(o, 0, 0); + return o; +} + +static void style_row(capture_result_t *cr, int i, bool sel) { + lv_obj_set_style_border_color(cr->rows[i], sel ? cr->accent : current_theme.border_inactive, 0); + lv_obj_set_style_border_opa(cr->rows[i], sel ? LV_OPA_COVER : LV_OPA_TRANSP, 0); + lv_obj_set_style_bg_color( + cr->rows[i], sel ? lv_color_hex(COL_RAISE) : current_theme.bg_secondary, 0); + lv_obj_set_style_shadow_width(cr->rows[i], sel ? 14 : 0, 0); + lv_obj_set_style_shadow_color(cr->rows[i], cr->accent, 0); + lv_obj_set_style_shadow_spread(cr->rows[i], sel ? -3 : 0, 0); + if (!(cr->saved && i == CAP_ACT_SAVE)) + lv_obj_set_style_text_color(cr->icons[i], sel ? cr->accent : lv_color_hex(COL_DIM), 0); +} + +static void refresh(capture_result_t *cr) { + for (int i = 0; i < cr->count; i++) + style_row(cr, i, i == cr->sel); +} + +static void make_card(capture_result_t *cr, lv_obj_t *root, const capture_result_cfg_t *cfg) { + lv_obj_t *card = lv_obj_create(root); + lv_obj_remove_flag(card, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_remove_flag(card, LV_OBJ_FLAG_CLICKABLE); + lv_obj_set_size(card, lv_pct(100), CARD_H); + lv_obj_set_style_radius(card, 13, 0); + lv_obj_set_style_bg_color(card, current_theme.bg_secondary, 0); + lv_obj_set_style_bg_opa(card, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(card, 1, 0); + lv_obj_set_style_border_color(card, cr->accent, 0); + lv_obj_set_style_shadow_width(card, 20, 0); + lv_obj_set_style_shadow_color(card, cr->accent, 0); + lv_obj_set_style_shadow_spread(card, -12, 0); + lv_obj_set_style_pad_hor(card, 10, 0); + lv_obj_set_style_pad_ver(card, 6, 0); + lv_obj_set_flex_flow(card, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(card, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_set_style_pad_column(card, 11, 0); + + lv_obj_t *sq = lv_obj_create(card); + lv_obj_remove_flag(sq, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_remove_flag(sq, LV_OBJ_FLAG_CLICKABLE); + lv_obj_set_size(sq, ICON_CELL, ICON_CELL); + lv_obj_set_style_radius(sq, 11, 0); + lv_obj_set_style_bg_color(sq, current_theme.bg_primary, 0); + lv_obj_set_style_bg_opa(sq, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(sq, 1, 0); + lv_obj_set_style_border_color(sq, cr->accent, 0); + lv_obj_set_style_pad_all(sq, 0, 0); + lv_obj_set_style_clip_corner(sq, true, 0); + if (cfg->card_icon) { + lv_image_dsc_t *dsc = assets_get(cfg->card_icon); + if (dsc) { + lv_obj_t *img = lv_image_create(sq); + lv_image_set_src(img, dsc); + lv_obj_set_size(img, ICON_CELL - 12, ICON_CELL - 12); + lv_image_set_inner_align(img, LV_IMAGE_ALIGN_CONTAIN); + lv_obj_center(img); + } + } + + lv_obj_t *col = bare(card); + lv_obj_set_size(col, lv_pct(100), LV_SIZE_CONTENT); + lv_obj_set_flex_grow(col, 1); + lv_obj_set_flex_flow(col, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(col, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START); + lv_obj_set_style_pad_row(col, 2, 0); + + lv_obj_t *title = lv_label_create(col); + lv_obj_set_width(title, lv_pct(100)); + lv_label_set_long_mode(title, LV_LABEL_LONG_DOT); + lv_label_set_text(title, cfg->card_title ? cfg->card_title : ""); + lv_obj_set_style_text_font(title, &lv_font_montserrat_14, 0); + lv_obj_set_style_text_color(title, current_theme.text_main, 0); + + if (cfg->card_sub) { + lv_obj_t *sub = lv_label_create(col); + lv_obj_set_width(sub, lv_pct(100)); + lv_label_set_long_mode(sub, LV_LABEL_LONG_DOT); + lv_label_set_text(sub, cfg->card_sub); + lv_obj_set_style_text_font(sub, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(sub, lv_color_hex(COL_DIM), 0); + } + if (cfg->card_value) { + lv_obj_t *val = lv_label_create(col); + lv_obj_set_width(val, lv_pct(100)); + lv_label_set_long_mode(val, LV_LABEL_LONG_DOT); + lv_label_set_text(val, cfg->card_value); + lv_obj_set_style_text_font(val, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(val, cr->accent, 0); + } +} + +static void make_action(capture_result_t *cr, lv_obj_t *root, int i, const char *label) { + lv_obj_t *row = lv_obj_create(root); + lv_obj_remove_flag(row, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_remove_flag(row, LV_OBJ_FLAG_CLICKABLE); + lv_obj_set_size(row, lv_pct(100), ROW_H); + lv_obj_set_style_radius(row, 10, 0); + lv_obj_set_style_bg_opa(row, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(row, 2, 0); + lv_obj_set_style_pad_hor(row, 12, 0); + lv_obj_set_style_pad_ver(row, 0, 0); + 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_column(row, 11, 0); + + lv_obj_t *ic = lv_label_create(row); + lv_label_set_text(ic, ACTION_SYM[i]); + lv_obj_set_width(ic, 18); + lv_obj_set_style_text_align(ic, LV_TEXT_ALIGN_CENTER, 0); + lv_obj_set_style_text_font(ic, &lv_font_montserrat_14, 0); + + lv_obj_t *lbl = lv_label_create(row); + lv_label_set_text(lbl, label); + lv_obj_set_style_text_font(lbl, &lv_font_montserrat_14, 0); + lv_obj_set_style_text_color(lbl, current_theme.text_main, 0); + lv_obj_set_flex_grow(lbl, 1); + + cr->rows[i] = row; + cr->icons[i] = ic; + cr->labels[i] = lbl; +} + +capture_result_t capture_result_create(lv_obj_t *parent, const capture_result_cfg_t *cfg) { + capture_result_t cr = {0}; + cr.accent = cfg->accent; + cr.count = CAP_ACT_MAX; + cr.sel = CAP_ACT_PRIMARY; + cr.saved = false; + + lv_obj_t *root = bare(parent); + lv_obj_set_size(root, LCD_H_RES, CR_H); + lv_obj_align(root, LV_ALIGN_TOP_LEFT, 0, CR_TOP); + lv_obj_set_style_pad_hor(root, 10, 0); + lv_obj_set_style_pad_ver(root, 8, 0); + lv_obj_set_flex_flow(root, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(root, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_START); + lv_obj_set_style_pad_row(root, GAP, 0); + cr.root = root; + + make_card(&cr, root, cfg); + + make_action(&cr, root, CAP_ACT_PRIMARY, cfg->primary_label ? cfg->primary_label : "Send"); + make_action(&cr, root, CAP_ACT_SAVE, "Save to library"); + make_action(&cr, root, CAP_ACT_AGAIN, cfg->again_label ? cfg->again_label : "Capture again"); + make_action(&cr, root, CAP_ACT_DISCARD, "Discard"); + + refresh(&cr); + return cr; +} + +void capture_result_next(capture_result_t *cr) { + if (!cr->root || cr->count <= 0) + return; + cr->sel = (cr->sel + 1) % cr->count; + refresh(cr); +} + +void capture_result_prev(capture_result_t *cr) { + if (!cr->root || cr->count <= 0) + return; + cr->sel = (cr->sel - 1 + cr->count) % cr->count; + refresh(cr); +} + +capture_action_t capture_result_selected(const capture_result_t *cr) { + return (capture_action_t)cr->sel; +} + +void capture_result_mark_saved(capture_result_t *cr) { + if (!cr->root || cr->saved) + return; + cr->saved = true; + lv_label_set_text(cr->icons[CAP_ACT_SAVE], LV_SYMBOL_OK); + lv_obj_set_style_text_color(cr->icons[CAP_ACT_SAVE], lv_color_hex(0x00E676), 0); + lv_label_set_text(cr->labels[CAP_ACT_SAVE], "Saved"); +} + +void capture_result_destroy(capture_result_t *cr) { + if (cr->root) { + lv_obj_del(cr->root); + cr->root = NULL; + } +} diff --git a/firmware_p4/components/Applications/ui/components/capture_result/include/capture_result_ui.h b/firmware_p4/components/Applications/ui/components/capture_result/include/capture_result_ui.h new file mode 100644 index 000000000..5195f41b8 --- /dev/null +++ b/firmware_p4/components/Applications/ui/components/capture_result/include/capture_result_ui.h @@ -0,0 +1,123 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#ifndef CAPTURE_RESULT_UI_H +#define CAPTURE_RESULT_UI_H + +#ifdef __cplusplus +extern "C" { +#endif + +#include "lvgl.h" + +/** + * @brief Shared "I captured a signal — now what?" panel for the capture + * protocols (NFC / RFID / IR / Sub-GHz). + * + * Draws a result card (icon + type + key value) plus a vertical action list, in + * the content area between the standard chrome header and footer. It is visual + + * selection only — the calling screen drives it from its own input loop (like + * menu_component): UP/DOWN -> prev/next, OK -> dispatch + * capture_result_selected(). The primary action verb differs per protocol + * ("Emulate" for NFC/RFID, "Send" for IR/Sub-GHz). + */ + +/** + * @brief Selectable actions offered on the capture-result panel. + */ +typedef enum { + CAP_ACT_PRIMARY = 0, ///< Emulate (NFC/RFID) or Send (IR/Sub-GHz) + CAP_ACT_SAVE, ///< persist to the library + CAP_ACT_AGAIN, ///< discard current, capture a new one + CAP_ACT_DISCARD, ///< drop it and leave + CAP_ACT_MAX, ///< action count sentinel +} capture_action_t; + +/** + * @brief Configuration for a capture-result panel. + */ +typedef struct { + lv_color_t accent; ///< protocol accent (tints card, value, selection) + const char *card_icon; ///< ".bin" path for the card icon (may be NULL) + const char *card_title; ///< e.g. "Signal captured" + const char *card_sub; ///< e.g. "NEC protocol" (may be NULL) + const char *card_value; ///< e.g. "cmd 08 F7" — shown in accent (may be NULL) + const char *primary_label; ///< "Emulate" / "Send" + const char *again_label; ///< "Read again" / "Receive again" / "Capture again" +} capture_result_cfg_t; + +/** + * @brief Runtime handle for a capture-result panel. + */ +typedef struct { + lv_obj_t *root; ///< root container of the panel + lv_obj_t *rows[CAP_ACT_MAX]; ///< action row containers + lv_obj_t *icons[CAP_ACT_MAX]; ///< action row icon labels + lv_obj_t *labels[CAP_ACT_MAX]; ///< action row text labels + int count; ///< number of active actions + int sel; ///< index of the highlighted action + bool saved; ///< true once the Save row is confirmed + lv_color_t accent; ///< protocol accent colour +} capture_result_t; + +/** + * @brief Build the decision menu (summary card + action list) on a screen that + * already has the standard chrome header/footer. + * + * Meant to be shown AFTER the calling screen has presented its own + * captured-signal view for a moment (the card/waveform each protocol draws). + * + * @param parent Screen that already carries the chrome header/footer. + * @param cfg Panel configuration (labels, accent, card content). + * @return The panel handle, returned by value. + */ +capture_result_t capture_result_create(lv_obj_t *parent, const capture_result_cfg_t *cfg); + +/** + * @brief Move the highlight to the next action. + * @param cr Panel handle. + */ +void capture_result_next(capture_result_t *cr); + +/** + * @brief Move the highlight to the previous action. + * @param cr Panel handle. + */ +void capture_result_prev(capture_result_t *cr); + +/** + * @brief Get the currently highlighted action. + * @param cr Panel handle. + * @return The highlighted action. + */ +capture_action_t capture_result_selected(const capture_result_t *cr); + +/** + * @brief Relabel the Save row to a "Saved" confirmation (call after persisting). + * @param cr Panel handle. + */ +void capture_result_mark_saved(capture_result_t *cr); + +/** + * @brief Delete the panel's objects (e.g. before starting a fresh capture). + * @param cr Panel handle. + */ +void capture_result_destroy(capture_result_t *cr); + +#ifdef __cplusplus +} +#endif + +#endif // CAPTURE_RESULT_UI_H diff --git a/firmware_p4/components/Applications/ui/components/chrome/include/ui_chrome.h b/firmware_p4/components/Applications/ui/components/chrome/include/ui_chrome.h new file mode 100644 index 000000000..9cb64b5c1 --- /dev/null +++ b/firmware_p4/components/Applications/ui/components/chrome/include/ui_chrome.h @@ -0,0 +1,129 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#ifndef UI_CHROME_H +#define UI_CHROME_H + +#ifdef __cplusplus +extern "C" { +#endif + +#include + +#include "lvgl.h" + +/** + * @brief Standardized screen chrome — the SAME header + footer the + * menu_component draws on list submenus. + * + * Exposed so hand-rolled "activity" screens (NFC/RFID read/write/emulate + * animations, etc.) get the identical look. Place the screen's own content + * between them (top = UI_CHROME_HEADER_H, bottom = UI_CHROME_FOOTER_H). Both + * span the full width and re-derive from LCD_H_RES, so they follow rotation. + */ + +#define UI_CHROME_HEADER_H 42 ///< height of the chrome header bar, in pixels +#define UI_CHROME_FOOTER_H 22 ///< height of the chrome footer bar, in pixels + +/** + * @brief Full-width top bar: raised surface, icon pinned in the left corner + * (optional; NULL to omit), centered accent title, accent underline. + * + * @param parent Screen/container to attach the header to. + * @param title Title text shown centered (NULL for none). + * @param icon_path ".bin" asset path for the left-corner icon (NULL to omit). + * @return The header object. + */ +lv_obj_t *ui_chrome_header(lv_obj_t *parent, const char *title, const char *icon_path); + +/** + * @brief Header for a TRANSIENT overlay drawn over a live screen. + * + * Same look as ui_chrome_header but with a STATIC snapshot status header (no + * global rebind, no timers) and never a letreiro — so tearing the overlay down + * cannot dangle the dynamic header of the screen underneath. Use for full-screen + * action overlays (transmit/emulate/now-playing/detail popups), NOT for regular + * navigable screens. + */ +lv_obj_t *ui_chrome_header_overlay(lv_obj_t *parent, const char *title, const char *icon_path); + +/** + * @brief Enable/disable the shared status cluster inside the next chrome headers. + * + * The UI manager sets this per screen before building it: browse screens enable + * it (so the header carries the global wifi/bt/sd/battery status bar), active + * "operation" screens (reading/sending/scanning/emulating) disable it so their + * chrome header stays a plain title bar. Default is enabled. + * + * @param enabled true to attach status icons, false for a plain title bar. + */ +void ui_chrome_set_status_enabled(bool enabled); + +/** @brief Whether the next chrome header carries the shared status bar (browse). */ +bool ui_chrome_status_enabled(void); + +/** + * @brief Set the breadcrumb root (the category/menu name) for the light labels. + * + * Menus call this with their own title; subsequent leaf screens then render their + * title as "root / title" (e.g. "NFC / EMULATE"). Cleared on home/coverflow. + */ +void ui_chrome_set_breadcrumb_root(const char *root); + +/** + * @brief Compose a display title from the current breadcrumb root + @p title. + * + * Writes "root / title" when a root is set, else just "title", into @p dst. + */ +void ui_chrome_compose_title(char *dst, size_t n, const char *title); + +/** + * @brief Fill a top-area container with the EXACT home/coverflow status header + * plus a light breadcrumb label naming the current submenu. + * + * Shared by ui_chrome_header() and the menu_component title area so every browse + * screen shows the identical status bar (12:00 + wifi/bt/sd/battery floating + * card) with a thin submenu label beneath it. @p container must sit at the top of + * the screen (its child card floats -12px and clips against the display edge). + * + * @param container Transparent top-area object, height UI_CHROME_HEADER_H. + * @param title Submenu name shown in the light label. + * @return The light label object. + */ +lv_obj_t *ui_chrome_light_title(lv_obj_t *container, const char *title); + +/** + * @brief Full-width bottom bar: raised surface, top accent border, centered + * dimmed hint text (the button instructions). + * + * @param parent Screen/container to attach the footer to. + * @param hint Hint text shown centered (NULL for none). + * @return The footer object. + */ +lv_obj_t *ui_chrome_footer(lv_obj_t *parent, const char *hint); + +/** + * @brief Update the hint text of a footer returned by ui_chrome_footer(). + * + * @param footer Footer object returned by ui_chrome_footer(). + * @param hint New hint text (NULL clears it). + */ +void ui_chrome_footer_set_text(lv_obj_t *footer, const char *hint); + +#ifdef __cplusplus +} +#endif + +#endif // UI_CHROME_H diff --git a/firmware_p4/components/Applications/ui/components/chrome/ui_chrome.c b/firmware_p4/components/Applications/ui/components/chrome/ui_chrome.c new file mode 100644 index 000000000..60691f7f1 --- /dev/null +++ b/firmware_p4/components/Applications/ui/components/chrome/ui_chrome.c @@ -0,0 +1,150 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#include "ui_chrome.h" + +#include + +#include "st7789.h" + +#include "header_ui.h" +#include "ui_theme.h" + +// Whether the next chrome header shows the breadcrumb letreiro. Set per screen by +// ui_chrome_set_status_enabled(): browse screens = true, active operation screens +// (reading/sending/scanning/emulating/...) = false. The status HEADER itself is +// drawn on EVERY screen regardless; only the letreiro (and the dropdown) are gated. +static bool s_status_enabled = true; + +void ui_chrome_set_status_enabled(bool enabled) { + s_status_enabled = enabled; +} + +bool ui_chrome_status_enabled(void) { + return s_status_enabled; +} + +// Breadcrumb root: the most-recent menu's title. Leaf screens show "root / title"; +// menus set it (showing just their own name); home/coverflow clear it. +static char s_bc_root[24] = ""; + +void ui_chrome_set_breadcrumb_root(const char *root) { + snprintf(s_bc_root, sizeof(s_bc_root), "%s", root ? root : ""); +} + +void ui_chrome_compose_title(char *dst, size_t n, const char *title) { + if (!dst || n == 0) + return; + const char *t = title ? title : ""; + if (s_bc_root[0]) + snprintf(dst, n, "%s / %s", s_bc_root, t); + else + snprintf(dst, n, "%s", t); +} + +lv_obj_t *ui_chrome_light_title(lv_obj_t *container, const char *title) { + if (!container) + return NULL; + // The dynamic status header, IDENTICAL to home/coverflow (floating card: 12:00 + + // wifi/bt/sd/battery), on EVERY screen. Sits at the very top so its -12 float + // clips against the display edge exactly as on home, and it (re)binds the shared + // status statics + singleton timers. + header_ui_create(container); + // The breadcrumb light label rides just below the card ONLY on browse screens; + // active operation screens show the header alone, no letreiro. + if (!s_status_enabled) + return NULL; + lv_obj_t *lbl = lv_label_create(container); + lv_label_set_text(lbl, title ? title : ""); + lv_obj_set_style_text_color(lbl, current_theme.border_accent, 0); + lv_obj_set_style_text_opa(lbl, LV_OPA_80, 0); + lv_obj_set_style_text_font(lbl, &lv_font_montserrat_12, 0); + lv_label_set_long_mode(lbl, LV_LABEL_LONG_DOT); + lv_obj_set_width(lbl, LCD_H_RES - 24); + lv_obj_align(lbl, LV_ALIGN_BOTTOM_LEFT, 12, -1); + return lbl; +} + +// Transparent top-area container of the standard header height, so screen content +// still starts at UI_CHROME_HEADER_H and no per-screen layout changes are needed. +static lv_obj_t *make_header_container(lv_obj_t *parent) { + lv_obj_t *hdr = lv_obj_create(parent); + lv_obj_set_size(hdr, LCD_H_RES, UI_CHROME_HEADER_H); + lv_obj_align(hdr, LV_ALIGN_TOP_LEFT, 0, 0); + lv_obj_remove_flag(hdr, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_remove_flag(hdr, LV_OBJ_FLAG_CLICKABLE); + lv_obj_set_style_bg_opa(hdr, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(hdr, 0, 0); + lv_obj_set_style_pad_all(hdr, 0, 0); + lv_obj_set_style_radius(hdr, 0, 0); + return hdr; +} + +lv_obj_t *ui_chrome_header(lv_obj_t *parent, const char *title, const char *icon_path) { + (void)icon_path; // the shared status header carries its own icons now + char composed[48]; + ui_chrome_compose_title(composed, sizeof(composed), title); + lv_obj_t *hdr = make_header_container(parent); + ui_chrome_light_title(hdr, composed); + return hdr; +} + +lv_obj_t *ui_chrome_header_overlay(lv_obj_t *parent, const char *title, const char *icon_path) { + (void)title; + (void)icon_path; + // Transient overlay drawn over a live screen: a STATIC snapshot header (no + // globals, no timers, no letreiro), so tearing the overlay down never dangles + // the dynamic header of the screen underneath. + lv_obj_t *hdr = make_header_container(parent); + header_ui_create_snapshot(hdr); + return hdr; +} + +lv_obj_t *ui_chrome_footer(lv_obj_t *parent, const char *hint) { + lv_obj_t *ft = lv_obj_create(parent); + lv_obj_set_size(ft, LCD_H_RES, UI_CHROME_FOOTER_H); + lv_obj_align(ft, LV_ALIGN_BOTTOM_LEFT, 0, 0); + lv_obj_remove_flag(ft, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_remove_flag(ft, LV_OBJ_FLAG_CLICKABLE); + lv_obj_set_style_bg_color(ft, current_theme.bg_secondary, 0); + lv_obj_set_style_bg_opa(ft, LV_OPA_COVER, 0); + lv_obj_set_style_radius(ft, 0, 0); + lv_obj_set_style_pad_all(ft, 0, 0); + lv_obj_set_style_border_width(ft, 2, 0); + lv_obj_set_style_border_color(ft, current_theme.border_interface, 0); + lv_obj_set_style_border_side(ft, LV_BORDER_SIDE_TOP, 0); + + lv_obj_t *lbl = lv_label_create(ft); + lv_label_set_text(lbl, hint ? hint : ""); + lv_obj_set_style_text_color(lbl, current_theme.text_main, 0); + lv_obj_set_style_text_opa(lbl, LV_OPA_70, 0); + lv_obj_set_style_text_font(lbl, &lv_font_montserrat_12, 0); + lv_obj_center(lbl); + + return ft; +} + +void ui_chrome_footer_set_text(lv_obj_t *footer, const char *hint) { + if (!footer) + return; + uint32_t n = lv_obj_get_child_count(footer); + for (uint32_t i = 0; i < n; i++) { + lv_obj_t *c = lv_obj_get_child(footer, i); + if (lv_obj_check_type(c, &lv_label_class)) { + lv_label_set_text(c, hint ? hint : ""); + return; + } + } +} diff --git a/firmware_p4/components/Applications/ui/components/dropdown/dropdown_ui.c b/firmware_p4/components/Applications/ui/components/dropdown/dropdown_ui.c index e373f445a..4d8c6c950 100644 --- a/firmware_p4/components/Applications/ui/components/dropdown/dropdown_ui.c +++ b/firmware_p4/components/Applications/ui/components/dropdown/dropdown_ui.c @@ -15,135 +15,220 @@ #include "dropdown_ui.h" +#include +#include + +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" + #include "st7789.h" +#include "sys_prio.h" #include "assets_manager.h" +#include "audio_i2s.h" +#include "battery_service.h" +#include "bluetooth_service.h" #include "buttons_gpio.h" -#include "page_dots_ui.h" -#include "toggle_ui.h" +#include "header_ui.h" +#include "lv_port_indev.h" +#include "notify_ui.h" +#include "reboot_ui.h" +#include "tos_config.h" +#include "tos_storage_paths.h" +#include "tutorial_ui.h" +#include "ui_manager.h" #include "ui_theme.h" - -#define DROPDOWN_HEIGHT_P0 ((LCD_V_RES * 85) / 100) -#define DROPDOWN_HEIGHT_P1 ((LCD_V_RES * 50) / 100) -#define DROPDOWN_HEIGHT DROPDOWN_HEIGHT_P0 -#define SEL_ITEMS 5 -#define BORDER_SEL_COLOR current_theme.border_accent - -static const int page_heights[] = {DROPDOWN_HEIGHT_P0, DROPDOWN_HEIGHT_P1}; +#include "vfs_sdcard.h" +#include "wifi_service.h" + +#define GREEN 0x00E676 +#define CHIP_BG current_theme.screen_base +#define PANEL_BG current_theme.bg_secondary +#define SL_STEP 10 + +#define SLIDE_ANIM_MS 300 +#define SLIDE_BTN_POLL_MS 50 +#define SLIDER_MIN_FILL_PCT 3 +#define BAT_ANIM_MS 350 +#define LONGPRESS_MS 450 // hold UP this long to toggle the dropdown + +#define ROW_BADGES 0 +#define ROW_BRIGHT 1 +#define ROW_SOUND 2 +#define ROW_COUNT 3 +static int focus_row = ROW_BADGES; + +#define BADGE_COUNT 4 +#define BADGE_WIFI 0 +#define BADGE_BLE 1 +#define BADGE_SD 2 +#define BADGE_REBOOT 3 +#define ACTION_ACCENT 0xF5B13D +#define ARMED_ACCENT 0xFF5252 +static lv_obj_t *badge_dot[BADGE_COUNT] = {NULL}; +static lv_obj_t *badge_ic[BADGE_COUNT] = {NULL}; +static lv_obj_t *badge_lbl[BADGE_COUNT] = {NULL}; +static bool badge_on[BADGE_COUNT] = {false, false, false, false}; +static const bool BADGE_ACTION[BADGE_COUNT] = {false, false, true, true}; +static int badge_sel = 0; +static int s_sd_armed = 0; +static bool s_sd_present_last = false; + +static lv_obj_t *sd_chip_val = NULL; +static lv_obj_t *sd_chip_fill = NULL; + +static lv_obj_t *bat_chip_val = NULL; +static lv_obj_t *bat_chip_fill = NULL; +static int bat_anim_pct = 0; +static uint32_t bat_last_anim = 0; + +#define SLIDER_COUNT 2 +#define SLIDER_SOUND 1 +static lv_obj_t *sl_track[SLIDER_COUNT] = {NULL}; +static lv_obj_t *sl_fill[SLIDER_COUNT] = {NULL}; +static lv_obj_t *sl_knob[SLIDER_COUNT] = {NULL}; +static lv_obj_t *sl_icon[SLIDER_COUNT] = {NULL}; +static lv_obj_t *sl_val[SLIDER_COUNT] = {NULL}; +static int sl_value[SLIDER_COUNT] = {80, 45}; +static bool s_vol_dirty = false; static lv_obj_t *slide_panel = NULL; -static lv_obj_t *slide_bar_obj = NULL; -static page_dots_t pg_dots; -static int current_page = 0; -#define DROPDOWN_PAGES 2 -static lv_obj_t *page_containers[DROPDOWN_PAGES] = {NULL}; +static int s_panel_h = 0; static bool slide_open = false; static bool slide_animating = false; static lv_obj_t **hide_objs_ref = NULL; static int hide_objs_count = 0; -static lv_obj_t *sel_items[SEL_ITEMS] = {NULL}; -static int selected_idx = 0; - -static toggle_ui_t toggles[2]; -static lv_obj_t *circles[2] = {NULL}; -static lv_obj_t *circle_icons_obj[2] = {NULL}; +static bool btn_up_last, btn_down_last, btn_left_last, btn_right_last, btn_ok_last, btn_back_last; +static lv_timer_t *slide_btn_timer = NULL; -#define SLIDER_STEPS 10 -#define SLIDER_MAX_W 100 -static lv_obj_t *slider_bars[3] = {NULL}; -static int slider_vals[3] = {5, 5, 5}; +static uint32_t up_hold_start = 0; // tick when UP was pressed (long-press timing) +static bool up_hold_consumed = false; // long-press already toggled for this hold -static bool btn_up_last = false; -static bool btn_down_last = false; -static bool btn_left_last = false; -static bool btn_right_last = false; -static bool btn_ok_last = false; -static bool btn_back_last = false; -static lv_timer_t *slide_btn_timer = NULL; +static void refresh_sd_status(void); +static void battery_tick(void); -static void update_slider(int idx) { - if (idx < 0 || idx >= 3 || !slider_bars[idx]) - return; - int32_t pct = (100 * slider_vals[idx]) / SLIDER_STEPS; - if (pct < 1) - pct = 1; - lv_obj_set_size(slider_bars[idx], lv_pct(pct), 33); +static bool badge_is_enabled(int idx) { + if (idx == BADGE_SD) + return vfs_sdcard_is_mounted(); + return true; } -static void update_selection(void) { - for (int i = 0; i < SEL_ITEMS; i++) { - if (!sel_items[i]) - continue; - if (i == selected_idx) { - lv_obj_set_style_border_width(sel_items[i], 2, 0); - lv_obj_set_style_border_color(sel_items[i], BORDER_SEL_COLOR, 0); - } else { - lv_obj_set_style_border_width(sel_items[i], 0, 0); - } +static void badge_step(int dir) { + int next = badge_sel; + for (int i = 0; i < BADGE_COUNT; i++) { + next = (next + dir + BADGE_COUNT) % BADGE_COUNT; + if (badge_is_enabled(next)) + break; } + badge_sel = next; } -static void update_circle(int idx) { - bool on = toggle_ui_get(&toggles[idx]); - - if (circles[idx]) { - if (on) { - lv_obj_set_style_bg_color(circles[idx], current_theme.border_accent, 0); - lv_obj_set_style_bg_grad_color(circles[idx], current_theme.border_accent, 0); - } else { - lv_obj_set_style_bg_color(circles[idx], current_theme.bg_item_top, 0); - lv_obj_set_style_bg_grad_color(circles[idx], current_theme.bg_secondary, 0); +static void refresh_focus(void) { + for (int i = 0; i < BADGE_COUNT; i++) { + if (!badge_dot[i]) + continue; + bool sel = (focus_row == ROW_BADGES && i == badge_sel); + if (BADGE_ACTION[i]) { + if (i == BADGE_SD && !badge_is_enabled(BADGE_SD)) { + lv_color_t dim = current_theme.border_inactive; + lv_obj_set_style_border_color(badge_dot[i], dim, 0); + lv_obj_set_style_border_width(badge_dot[i], 2, 0); + lv_obj_set_style_bg_color(badge_dot[i], CHIP_BG, 0); + lv_obj_set_style_shadow_width(badge_dot[i], 0, 0); + lv_obj_set_style_shadow_opa(badge_dot[i], LV_OPA_TRANSP, 0); + if (badge_ic[i]) + lv_obj_set_style_text_color(badge_ic[i], dim, 0); + if (badge_lbl[i]) + lv_obj_set_style_text_color(badge_lbl[i], dim, 0); + continue; + } + lv_color_t acc = + (i == BADGE_SD && s_sd_armed) ? lv_color_hex(ARMED_ACCENT) : lv_color_hex(ACTION_ACCENT); + lv_obj_set_style_border_color(badge_dot[i], sel ? current_theme.border_accent : acc, 0); + lv_obj_set_style_border_width(badge_dot[i], sel ? 3 : 2, 0); + lv_obj_set_style_bg_color(badge_dot[i], CHIP_BG, 0); + lv_obj_set_style_shadow_width(badge_dot[i], sel ? 10 : 0, 0); + lv_obj_set_style_shadow_color(badge_dot[i], acc, 0); + lv_obj_set_style_shadow_opa(badge_dot[i], sel ? LV_OPA_40 : LV_OPA_TRANSP, 0); + if (badge_ic[i]) + lv_obj_set_style_text_color(badge_ic[i], acc, 0); + if (badge_lbl[i]) + lv_obj_set_style_text_color(badge_lbl[i], sel ? current_theme.border_accent : acc, 0); + continue; } + bool on = badge_on[i]; + lv_color_t conn = on ? lv_color_hex(GREEN) : current_theme.border_inactive; + lv_obj_set_style_border_color(badge_dot[i], sel ? current_theme.border_accent : conn, 0); + lv_obj_set_style_border_width(badge_dot[i], sel ? 3 : 2, 0); + lv_obj_set_style_bg_color(badge_dot[i], on ? lv_color_hex(0x04160C) : CHIP_BG, 0); + lv_obj_set_style_shadow_width(badge_dot[i], on ? 12 : 0, 0); + lv_obj_set_style_shadow_color(badge_dot[i], lv_color_hex(GREEN), 0); + lv_obj_set_style_shadow_opa(badge_dot[i], on ? LV_OPA_40 : LV_OPA_TRANSP, 0); + if (badge_ic[i]) + lv_obj_set_style_text_color( + badge_ic[i], on ? lv_color_hex(GREEN) : current_theme.border_inactive, 0); + if (badge_lbl[i]) + lv_obj_set_style_text_color(badge_lbl[i], sel ? current_theme.border_accent : conn, 0); } - - if (circle_icons_obj[idx]) { - if (on) { - lv_obj_set_style_image_recolor(circle_icons_obj[idx], current_theme.screen_base, 0); - lv_obj_set_style_image_recolor_opa(circle_icons_obj[idx], LV_OPA_COVER, 0); - } else { - lv_obj_set_style_image_recolor_opa(circle_icons_obj[idx], LV_OPA_TRANSP, 0); - } + for (int s = 0; s < SLIDER_COUNT; s++) { + if (!sl_track[s]) + continue; + bool foc = (focus_row == ROW_BRIGHT + s); + lv_obj_set_style_border_width(sl_track[s], foc ? 2 : 1, 0); + lv_obj_set_style_border_color( + sl_track[s], foc ? current_theme.border_accent : current_theme.border_inactive, 0); + if (sl_icon[s]) + lv_obj_set_style_image_recolor_opa(sl_icon[s], foc ? LV_OPA_TRANSP : LV_OPA_50, 0); + if (sl_val[s]) + lv_obj_set_style_text_color( + sl_val[s], foc ? current_theme.border_accent : current_theme.border_inactive, 0); } } -static void animate_to_page_height(int page) { - int h = page_heights[page]; - lv_anim_t a; - lv_anim_init(&a); - lv_anim_set_var(&a, slide_panel); - lv_anim_set_values(&a, lv_obj_get_height(slide_panel), h); - lv_anim_set_duration(&a, 250); - lv_anim_set_path_cb(&a, lv_anim_path_ease_in_out); - lv_anim_set_exec_cb(&a, (lv_anim_exec_xcb_t)lv_obj_set_height); - lv_anim_start(&a); +static void sd_disarm(void) { + if (!s_sd_armed) + return; + s_sd_armed = 0; + if (badge_lbl[BADGE_SD]) + lv_label_set_text(badge_lbl[BADGE_SD], "Eject"); +} - if (slide_bar_obj) { - lv_anim_set_var(&a, slide_bar_obj); - lv_anim_set_values(&a, lv_obj_get_y(slide_bar_obj), h - 6); - lv_anim_set_exec_cb(&a, (lv_anim_exec_xcb_t)lv_obj_set_y); - lv_anim_start(&a); +static void set_slider(int s, int v) { + if (s < 0 || s >= SLIDER_COUNT || !sl_fill[s]) + return; + if (v < 0) + v = 0; + if (v > 100) + v = 100; + sl_value[s] = v; + int w = v < SLIDER_MIN_FILL_PCT ? SLIDER_MIN_FILL_PCT : v; + lv_obj_set_width(sl_fill[s], lv_pct(w)); + if (sl_val[s]) + lv_label_set_text_fmt(sl_val[s], "%d%%", v); + if (s == SLIDER_SOUND) { + audio_i2s_set_volume((uint8_t)v); + g_config_system.volume = v; } } -static void slide_anim_cb(void *var, int32_t val) { - lv_obj_set_y((lv_obj_t *)var, val); - if (slide_bar_obj) { - lv_obj_set_y(slide_bar_obj, val + DROPDOWN_HEIGHT - 6); - } +static void slide_anim_cb(void *var, int32_t v) { + lv_obj_set_y((lv_obj_t *)var, v); } -static void slide_anim_done_cb(lv_anim_t *a) { +static void slide_done_cb(lv_anim_t *a) { + (void)a; slide_animating = false; if (!slide_open) { - page_dots_hide(&pg_dots); - if (slide_bar_obj) - lv_obj_add_flag(slide_bar_obj, LV_OBJ_FLAG_HIDDEN); - for (int i = 0; i < hide_objs_count; i++) { + lv_obj_add_flag(slide_panel, LV_OBJ_FLAG_HIDDEN); + for (int i = 0; i < hide_objs_count; i++) if (hide_objs_ref[i]) lv_obj_remove_flag(hide_objs_ref[i], LV_OBJ_FLAG_HIDDEN); - } + // Fully closed: hand input back to the screen underneath, but swallow the + // press that closed us so it doesn't also act on that screen. + lv_port_indev_set_suppressed(false); + ui_input_lock(250); } } @@ -151,58 +236,123 @@ static void dropdown_open(void) { if (!slide_panel || slide_animating || slide_open) return; slide_animating = true; + slide_open = true; - selected_idx = 0; - update_selection(); - - for (int p = 0; p < DROPDOWN_PAGES; p++) { - if (page_containers[p]) { - if (p == 0) - lv_obj_remove_flag(page_containers[p], LV_OBJ_FLAG_HIDDEN); - else - lv_obj_add_flag(page_containers[p], LV_OBJ_FLAG_HIDDEN); - } - } - current_page = 0; - lv_obj_set_height(slide_panel, page_heights[0]); + // Take exclusive input: swallow keypad keys so the screen underneath (menus/ + // lists via the LVGL group) stops navigating while the panel is up. + lv_port_indev_set_suppressed(true); + + focus_row = ROW_BADGES; + badge_sel = 0; + sd_disarm(); + badge_on[BADGE_WIFI] = g_config_wifi.enabled; + badge_on[BADGE_BLE] = g_config_ble.enabled; + s_sd_present_last = badge_is_enabled(BADGE_SD); + set_slider(SLIDER_SOUND, g_config_system.volume); + refresh_sd_status(); + battery_tick(); + refresh_focus(); lv_obj_remove_flag(slide_panel, LV_OBJ_FLAG_HIDDEN); - if (slide_bar_obj) - lv_obj_remove_flag(slide_bar_obj, LV_OBJ_FLAG_HIDDEN); - page_dots_show(&pg_dots); - page_dots_set(&pg_dots, 0); - for (int i = 0; i < hide_objs_count; i++) { + lv_obj_move_foreground(slide_panel); + for (int i = 0; i < hide_objs_count; i++) if (hide_objs_ref[i]) lv_obj_add_flag(hide_objs_ref[i], LV_OBJ_FLAG_HIDDEN); - } lv_anim_t a; lv_anim_init(&a); lv_anim_set_var(&a, slide_panel); lv_anim_set_exec_cb(&a, slide_anim_cb); - lv_anim_set_time(&a, 300); + lv_anim_set_values(&a, -s_panel_h, 0); + lv_anim_set_duration(&a, SLIDE_ANIM_MS); lv_anim_set_path_cb(&a, lv_anim_path_ease_in_out); - lv_anim_set_completed_cb(&a, slide_anim_done_cb); - lv_anim_set_values(&a, -DROPDOWN_HEIGHT, 0); + lv_anim_set_completed_cb(&a, slide_done_cb); lv_anim_start(&a); - slide_open = true; } static void dropdown_close(void) { if (!slide_panel || slide_animating || !slide_open) return; + if (s_vol_dirty) { + (void)tos_config_save(TOS_PATH_CONFIG_SYSTEM, "system"); + s_vol_dirty = false; + } slide_animating = true; + slide_open = false; lv_anim_t a; lv_anim_init(&a); lv_anim_set_var(&a, slide_panel); lv_anim_set_exec_cb(&a, slide_anim_cb); - lv_anim_set_time(&a, 300); + lv_anim_set_values(&a, 0, -s_panel_h); + lv_anim_set_duration(&a, SLIDE_ANIM_MS); lv_anim_set_path_cb(&a, lv_anim_path_ease_in_out); - lv_anim_set_completed_cb(&a, slide_anim_done_cb); - lv_anim_set_values(&a, 0, -DROPDOWN_HEIGHT); + lv_anim_set_completed_cb(&a, slide_done_cb); lv_anim_start(&a); +} + +static void dropdown_reboot(void) { + if (slide_panel) { + lv_obj_add_flag(slide_panel, LV_OBJ_FLAG_HIDDEN); + lv_obj_set_y(slide_panel, -s_panel_h); + } + for (int i = 0; i < hide_objs_count; i++) + if (hide_objs_ref[i]) + lv_obj_remove_flag(hide_objs_ref[i], LV_OBJ_FLAG_HIDDEN); + lv_port_indev_set_suppressed(false); slide_open = false; + slide_animating = false; + reboot_ui_reboot(); +} + +static void wifi_apply_task(void *arg) { + if ((bool)(intptr_t)arg) + wifi_service_start(); + else + wifi_service_stop(); + vTaskDelete(NULL); +} + +static void ble_apply_task(void *arg) { + if ((bool)(intptr_t)arg) { + bluetooth_service_init(); + bluetooth_service_start(); + } else { + bluetooth_service_stop(); + } + vTaskDelete(NULL); +} + +static void conn_set_wifi(bool on) { + g_config_wifi.enabled = on; + tos_config_save(TOS_PATH_CONFIG_WIFI, "wifi"); + badge_on[BADGE_WIFI] = on; + refresh_focus(); + if (!on) + notify(NOTIFY_WARNING, "Wi-Fi off"); + xTaskCreatePinnedToCore(wifi_apply_task, + "wifi_apply", + 4096, + (void *)(intptr_t)on, + SYS_PRIO_SERVICE_LO, + NULL, + SYS_CORE_RADIO); +} + +static void conn_set_ble(bool on) { + g_config_ble.enabled = on; + tos_config_save(TOS_PATH_CONFIG_BLE, "ble"); + badge_on[BADGE_BLE] = on; + refresh_focus(); + if (!on) + notify(NOTIFY_WARNING, "BLE off"); + xTaskCreatePinnedToCore(ble_apply_task, + "ble_apply", + 4096, + (void *)(intptr_t)on, + SYS_PRIO_SERVICE_LO, + NULL, + SYS_CORE_RADIO); } static void slide_btn_timer_cb(lv_timer_t *timer) { @@ -211,314 +361,446 @@ static void slide_btn_timer_cb(lv_timer_t *timer) { slide_btn_timer = NULL; return; } - bool up_pressed = up_button_is_down(); - bool down_pressed = down_button_is_down(); - bool left_pressed = left_button_is_down(); - bool right_pressed = right_button_is_down(); - bool ok_pressed = ok_button_is_down(); - bool back_pressed = back_button_is_down(); - - if (up_pressed && !btn_up_last) { - if (!slide_open) { + bool up = up_button_is_down(), down = down_button_is_down(); + bool left = left_button_is_down(), right = right_button_is_down(); + bool ok = ok_button_is_down(), back = back_button_is_down(); + + uint32_t nowt = lv_tick_get(); + + // Long-press UP toggles the panel: open from a browse screen (never while input + // is locked or on an active operation screen), or close if already open. One + // toggle per hold. Short taps of UP fall through to the focused screen (closed) + // or to the in-panel row nav below (open). + if (up && !btn_up_last) { + up_hold_start = nowt; + up_hold_consumed = false; + } + if (up && !up_hold_consumed && (uint32_t)(nowt - up_hold_start) >= LONGPRESS_MS) { + up_hold_consumed = true; + if (slide_open) { + dropdown_close(); + } else if (!ui_input_is_locked() && !tutorial_is_active() && + ui_screen_shows_chrome(ui_current_screen())) { dropdown_open(); - } else if (selected_idx > 0) { - selected_idx--; - update_selection(); } } + if (!up) { + up_hold_consumed = false; + } - if (down_pressed && !btn_down_last && slide_open) { - if (selected_idx < SEL_ITEMS - 1) { - selected_idx++; - update_selection(); + if (slide_open) { + if (up && !btn_up_last && focus_row > 0) { + sd_disarm(); + focus_row--; + refresh_focus(); + } + if (down && !btn_down_last && focus_row < ROW_COUNT - 1) { + sd_disarm(); + focus_row++; + refresh_focus(); + } + if (left && !btn_left_last) { + if (focus_row == ROW_BADGES) { + sd_disarm(); + badge_step(-1); + refresh_focus(); + } else { + int s = focus_row - ROW_BRIGHT; + set_slider(s, sl_value[s] - SL_STEP); + if (s == SLIDER_SOUND) + s_vol_dirty = true; + } + } + if (right && !btn_right_last) { + if (focus_row == ROW_BADGES) { + sd_disarm(); + badge_step(+1); + refresh_focus(); + } else { + int s = focus_row - ROW_BRIGHT; + set_slider(s, sl_value[s] + SL_STEP); + if (s == SLIDER_SOUND) + s_vol_dirty = true; + } + } + if (ok && !btn_ok_last && focus_row == ROW_BADGES) { + if (badge_sel == BADGE_SD) { + if (!vfs_sdcard_is_mounted()) { + notify(NOTIFY_WARNING, "No SD card"); + } else if (s_sd_armed) { + sd_disarm(); + header_ui_sd_eject(); + notify(NOTIFY_WARNING, "SD card ejected"); + refresh_focus(); + } else { + s_sd_armed = 1; + if (badge_lbl[BADGE_SD]) + lv_label_set_text(badge_lbl[BADGE_SD], "Confirm?"); + refresh_focus(); + } + } else if (badge_sel == BADGE_REBOOT) { + dropdown_reboot(); + } else if (badge_sel == BADGE_WIFI) { + conn_set_wifi(!badge_on[BADGE_WIFI]); + } else if (badge_sel == BADGE_BLE) { + conn_set_ble(!badge_on[BADGE_BLE]); + } + } + if (back && !btn_back_last) { + dropdown_close(); } } - if (left_pressed && !btn_left_last && slide_open) { - if (current_page > 0) { - if (page_containers[current_page]) - lv_obj_add_flag(page_containers[current_page], LV_OBJ_FLAG_HIDDEN); - current_page--; - if (page_containers[current_page]) - lv_obj_remove_flag(page_containers[current_page], LV_OBJ_FLAG_HIDDEN); - page_dots_set(&pg_dots, current_page); - animate_to_page_height(current_page); - selected_idx = 0; - update_selection(); - } + // Freeze screens that poll the buttons directly while the panel is up or + // animating (the keypad group is already frozen via indev suppression). + if (slide_open || slide_animating) { + ui_input_lock(SLIDE_BTN_POLL_MS * 3); } - if (right_pressed && !btn_right_last && slide_open) { - if (current_page < DROPDOWN_PAGES - 1) { - if (page_containers[current_page]) - lv_obj_add_flag(page_containers[current_page], LV_OBJ_FLAG_HIDDEN); - current_page++; - if (page_containers[current_page]) - lv_obj_remove_flag(page_containers[current_page], LV_OBJ_FLAG_HIDDEN); - page_dots_set(&pg_dots, current_page); - animate_to_page_height(current_page); - selected_idx = 0; - update_selection(); + + if (slide_open) { + if ((uint32_t)(nowt - bat_last_anim) >= BAT_ANIM_MS) { + bat_last_anim = nowt; + battery_tick(); + refresh_sd_status(); // reflect SD insert/remove while the panel is open + bool sd_now = badge_is_enabled(BADGE_SD); + if (sd_now != s_sd_present_last) { + s_sd_present_last = sd_now; + if (!sd_now && badge_sel == BADGE_SD) { + sd_disarm(); + badge_step(-1); + } + refresh_focus(); + } } } - if (ok_pressed && !btn_ok_last && slide_open) { - if (selected_idx < 2) { - toggle_ui_toggle(&toggles[selected_idx]); - update_circle(selected_idx); - } + btn_up_last = up; + btn_down_last = down; + btn_left_last = left; + btn_right_last = right; + btn_ok_last = ok; + btn_back_last = back; +} + +static void make_badge(lv_obj_t *row, int idx, const char *sym, const char *caption) { + lv_obj_t *cell = lv_obj_create(row); + lv_obj_set_size(cell, LV_SIZE_CONTENT, LV_SIZE_CONTENT); + lv_obj_remove_flag(cell, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_add_flag(cell, LV_OBJ_FLAG_OVERFLOW_VISIBLE); + lv_obj_set_style_bg_opa(cell, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(cell, 0, 0); + lv_obj_set_style_pad_all(cell, 0, 0); + lv_obj_set_flex_flow(cell, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(cell, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_set_style_pad_row(cell, 6, 0); + + lv_obj_t *dot = lv_obj_create(cell); + lv_obj_set_size(dot, 46, 46); + lv_obj_remove_flag(dot, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_radius(dot, LV_RADIUS_CIRCLE, 0); + lv_obj_set_style_bg_opa(dot, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(dot, 2, 0); + lv_obj_set_style_pad_all(dot, 0, 0); + + lv_obj_t *ic = lv_label_create(dot); + lv_label_set_text(ic, sym); + lv_obj_set_style_text_font(ic, &lv_font_montserrat_14, 0); + lv_obj_center(ic); + + lv_obj_t *cap = lv_label_create(cell); + lv_label_set_text(cap, caption); + lv_obj_set_style_text_font(cap, &lv_font_montserrat_12, 0); + + badge_dot[idx] = dot; + badge_ic[idx] = ic; + badge_lbl[idx] = cap; +} + +static void make_slider(lv_obj_t *parent, int idx, const char *icon_path) { + lv_obj_t *row = lv_obj_create(parent); + lv_obj_set_size(row, lv_pct(100), 24); + lv_obj_remove_flag(row, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_add_flag(row, LV_OBJ_FLAG_OVERFLOW_VISIBLE); + lv_obj_set_style_bg_opa(row, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(row, 0, 0); + lv_obj_set_style_pad_all(row, 0, 0); + 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_column(row, 10, 0); + + lv_image_dsc_t *dsc = icon_path ? assets_get(icon_path) : NULL; + if (dsc) { + lv_obj_t *img = lv_image_create(row); + lv_image_set_src(img, dsc); + lv_obj_set_size(img, 22, 22); + lv_image_set_inner_align(img, LV_IMAGE_ALIGN_CONTAIN); + lv_obj_set_style_image_recolor(img, current_theme.text_main, 0); + sl_icon[idx] = img; } - if (back_pressed && !btn_back_last && slide_open) { - dropdown_close(); + lv_obj_t *track = lv_obj_create(row); + lv_obj_set_size(track, lv_pct(100), 14); + lv_obj_set_flex_grow(track, 1); + lv_obj_remove_flag(track, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_add_flag(track, LV_OBJ_FLAG_OVERFLOW_VISIBLE); + lv_obj_set_style_radius(track, 7, 0); + lv_obj_set_style_bg_color(track, CHIP_BG, 0); + lv_obj_set_style_bg_opa(track, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(track, 1, 0); + lv_obj_set_style_border_color(track, current_theme.border_inactive, 0); + lv_obj_set_style_pad_all(track, 0, 0); + + lv_obj_t *fill = lv_obj_create(track); + lv_obj_set_size(fill, lv_pct(50), lv_pct(100)); + lv_obj_remove_flag(fill, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_add_flag(fill, LV_OBJ_FLAG_OVERFLOW_VISIBLE); + lv_obj_set_style_radius(fill, 7, 0); + lv_obj_set_style_bg_color(fill, current_theme.border_interface, 0); + lv_obj_set_style_bg_grad_color(fill, current_theme.border_accent, 0); + lv_obj_set_style_bg_grad_dir(fill, LV_GRAD_DIR_HOR, 0); + lv_obj_set_style_bg_opa(fill, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(fill, 0, 0); + lv_obj_set_style_pad_all(fill, 0, 0); + lv_obj_align(fill, LV_ALIGN_LEFT_MID, 0, 0); + + lv_obj_t *knob = lv_obj_create(fill); + lv_obj_set_size(knob, 14, 14); + lv_obj_remove_flag(knob, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_radius(knob, LV_RADIUS_CIRCLE, 0); + lv_obj_set_style_bg_color(knob, current_theme.text_main, 0); + lv_obj_set_style_bg_opa(knob, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(knob, 2, 0); + lv_obj_set_style_border_color(knob, current_theme.border_accent, 0); + lv_obj_set_style_shadow_width(knob, 8, 0); + lv_obj_set_style_shadow_color(knob, current_theme.border_accent, 0); + lv_obj_align(knob, LV_ALIGN_RIGHT_MID, 7, 0); + lv_obj_move_foreground(knob); + + lv_obj_t *val = lv_label_create(row); + lv_obj_set_width(val, 38); + lv_obj_set_style_text_align(val, LV_TEXT_ALIGN_RIGHT, 0); + lv_obj_set_style_text_color(val, current_theme.border_inactive, 0); + lv_obj_set_style_text_font(val, &lv_font_montserrat_12, 0); + + sl_track[idx] = track; + sl_fill[idx] = fill; + sl_knob[idx] = knob; + sl_val[idx] = val; + set_slider(idx, sl_value[idx]); +} + +static void make_mini(lv_obj_t *row, + const char *label, + const char *value, + int pct, + bool battery, + lv_obj_t **val_out, + lv_obj_t **fill_out) { + (void)label; + lv_color_t c1 = battery ? lv_color_hex(0x00E676) : lv_color_hex(0x00BCD4); + lv_color_t c2 = battery ? lv_color_hex(0x00A651) : lv_color_hex(0x0091A7); + const char *sym = battery ? LV_SYMBOL_BATTERY_FULL : LV_SYMBOL_SD_CARD; + + lv_obj_t *chip = lv_obj_create(row); + lv_obj_set_size(chip, lv_pct(100), LV_SIZE_CONTENT); + lv_obj_set_flex_grow(chip, 1); + lv_obj_remove_flag(chip, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_radius(chip, 10, 0); + lv_obj_set_style_bg_color(chip, CHIP_BG, 0); + lv_obj_set_style_bg_opa(chip, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(chip, 1, 0); + lv_obj_set_style_border_color(chip, current_theme.border_inactive, 0); + lv_obj_set_style_pad_all(chip, 8, 0); + lv_obj_set_flex_flow(chip, LV_FLEX_FLOW_COLUMN); + lv_obj_set_style_pad_row(chip, 6, 0); + + lv_obj_t *head = lv_obj_create(chip); + lv_obj_set_size(head, lv_pct(100), LV_SIZE_CONTENT); + lv_obj_remove_flag(head, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_bg_opa(head, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(head, 0, 0); + lv_obj_set_style_pad_all(head, 0, 0); + lv_obj_set_flex_flow(head, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(head, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_set_style_pad_column(head, 7, 0); + + lv_obj_t *ic = lv_label_create(head); + lv_label_set_text(ic, sym); + lv_obj_set_style_text_color(ic, c1, 0); + lv_obj_set_style_text_font(ic, &lv_font_montserrat_14, 0); + + lv_obj_t *v = lv_label_create(head); + lv_label_set_text(v, value); + lv_obj_set_style_text_color(v, c1, 0); + lv_obj_set_style_text_font(v, &lv_font_montserrat_14, 0); + + lv_obj_t *track = lv_obj_create(chip); + lv_obj_set_size(track, lv_pct(100), 7); + lv_obj_remove_flag(track, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_radius(track, 4, 0); + lv_obj_set_style_bg_color(track, current_theme.text_main, 0); + lv_obj_set_style_bg_opa(track, LV_OPA_10, 0); + lv_obj_set_style_border_width(track, 0, 0); + lv_obj_set_style_pad_all(track, 0, 0); + lv_obj_set_style_clip_corner(track, true, 0); + + if (pct < 1) + pct = 1; + if (pct > 100) + pct = 100; + lv_obj_t *fill = lv_obj_create(track); + lv_obj_set_size(fill, lv_pct(pct), lv_pct(100)); + lv_obj_remove_flag(fill, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_radius(fill, 4, 0); + lv_obj_set_style_bg_color(fill, c2, 0); + lv_obj_set_style_bg_grad_color(fill, c1, 0); + lv_obj_set_style_bg_grad_dir(fill, LV_GRAD_DIR_HOR, 0); + lv_obj_set_style_bg_opa(fill, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(fill, 0, 0); + lv_obj_align(fill, LV_ALIGN_LEFT_MID, 0, 0); + + if (val_out) { + *val_out = v; + } + if (fill_out) { + *fill_out = fill; } +} + +static void refresh_sd_status(void) { + int used_pct = 0; + bool present = header_ui_sd_usage(&used_pct); - btn_up_last = up_pressed; - btn_down_last = down_pressed; - btn_left_last = left_pressed; - btn_right_last = right_pressed; - btn_ok_last = ok_pressed; - btn_back_last = back_pressed; + char buf[16]; + const char *val = "No SD"; + if (present) { + snprintf(buf, sizeof(buf), "%d%%", used_pct); + val = buf; + } + if (sd_chip_val) { + lv_label_set_text(sd_chip_val, val); + } + if (sd_chip_fill) { + lv_obj_set_width(sd_chip_fill, lv_pct(used_pct < 1 ? 1 : used_pct)); + } +} + +// Real battery in the dropdown mini: "--" when no charger answers, else the SoC. +// Only while actually charging: bolt prefix + fill sweeps upward (charging cue). +static void battery_tick(void) { + if (!bat_chip_val) { + return; + } + battery_snapshot_t bs; + if (!battery_service_get(&bs) || !bs.present) { + lv_label_set_text(bat_chip_val, "--"); + if (bat_chip_fill) { + lv_obj_set_width(bat_chip_fill, lv_pct(1)); + } + return; + } + if (bs.charging) { + // Charging: bolt prefix + fill sweeps the full range (filling animation). + lv_label_set_text_fmt(bat_chip_val, LV_SYMBOL_CHARGE " %d%%", bs.soc); + bat_anim_pct += 15; + if (bat_anim_pct > 100) { + bat_anim_pct = 0; + } + if (bat_chip_fill) { + lv_obj_set_width(bat_chip_fill, lv_pct(bat_anim_pct < 1 ? 1 : bat_anim_pct)); + } + } else { + // Not charging (on battery or plugged-idle): just the level, no bolt. + lv_label_set_text_fmt(bat_chip_val, "%d%%", bs.soc); + if (bat_chip_fill) { + lv_obj_set_width(bat_chip_fill, lv_pct(bs.soc < 1 ? 1 : bs.soc)); + } + } } void dropdown_ui_create(lv_obj_t *parent) { slide_panel = lv_obj_create(parent); - lv_obj_set_size(slide_panel, lv_pct(100), DROPDOWN_HEIGHT); - lv_obj_set_pos(slide_panel, 0, -DROPDOWN_HEIGHT); + lv_obj_set_size(slide_panel, lv_pct(100), LV_SIZE_CONTENT); + lv_obj_set_pos(slide_panel, 0, 0); lv_obj_remove_flag(slide_panel, LV_OBJ_FLAG_SCROLLABLE); lv_obj_add_flag(slide_panel, LV_OBJ_FLAG_HIDDEN); - lv_obj_move_foreground(slide_panel); - lv_obj_set_style_radius(slide_panel, 12, 0); - lv_obj_set_style_border_side( - slide_panel, LV_BORDER_SIDE_BOTTOM | LV_BORDER_SIDE_LEFT | LV_BORDER_SIDE_RIGHT, 0); + lv_obj_set_style_radius(slide_panel, 14, 0); + lv_obj_set_style_border_side(slide_panel, LV_BORDER_SIDE_BOTTOM, 0); lv_obj_set_style_border_width(slide_panel, 2, 0); lv_obj_set_style_border_color(slide_panel, current_theme.border_accent, 0); - lv_obj_set_style_pad_all(slide_panel, 0, 0); lv_obj_set_style_bg_opa(slide_panel, LV_OPA_COVER, 0); - lv_obj_set_style_bg_color(slide_panel, current_theme.bg_secondary, 0); - lv_obj_set_style_bg_grad_color(slide_panel, current_theme.border_interface, 0); - lv_obj_set_style_bg_grad_dir(slide_panel, LV_GRAD_DIR_VER, 0); - - page_containers[0] = lv_obj_create(slide_panel); - lv_obj_t *content = page_containers[0]; - lv_obj_set_size(content, lv_pct(100), LV_SIZE_CONTENT); - lv_obj_align(content, LV_ALIGN_TOP_MID, 0, 30); - lv_obj_remove_flag(content, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_set_style_bg_opa(content, LV_OPA_TRANSP, 0); - lv_obj_set_style_border_width(content, 0, 0); - lv_obj_set_style_pad_all(content, 0, 0); - lv_obj_set_flex_flow(content, LV_FLEX_FLOW_COLUMN); - lv_obj_set_flex_align(content, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); - lv_obj_set_style_pad_row(content, 10, 0); - - lv_obj_t *row_circles = lv_obj_create(content); - lv_obj_set_size(row_circles, LV_SIZE_CONTENT, LV_SIZE_CONTENT); - lv_obj_remove_flag(row_circles, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_set_style_bg_opa(row_circles, LV_OPA_TRANSP, 0); - lv_obj_set_style_border_width(row_circles, 0, 0); - lv_obj_set_style_pad_all(row_circles, 0, 0); - lv_obj_set_style_pad_column(row_circles, 20, 0); - lv_obj_set_flex_flow(row_circles, LV_FLEX_FLOW_ROW); + lv_obj_set_style_bg_color(slide_panel, PANEL_BG, 0); + lv_obj_set_style_bg_grad_dir(slide_panel, LV_GRAD_DIR_NONE, 0); + lv_obj_set_style_pad_hor(slide_panel, 16, 0); + lv_obj_set_style_pad_top(slide_panel, 12, 0); + lv_obj_set_style_pad_bottom(slide_panel, 16, 0); + lv_obj_set_flex_flow(slide_panel, LV_FLEX_FLOW_COLUMN); lv_obj_set_flex_align( - row_circles, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); - - static lv_image_dsc_t *bt_sel_dsc = NULL; - static lv_image_dsc_t *wifi_sel_dsc = NULL; - if (!bt_sel_dsc) - bt_sel_dsc = assets_get("/assets/icons/bluetooth_sel.bin"); - if (!wifi_sel_dsc) - wifi_sel_dsc = assets_get("/assets/icons/wifi_sel.bin"); - lv_image_dsc_t *circle_icon_dscs[] = {bt_sel_dsc, wifi_sel_dsc}; - - for (int i = 0; i < 2; i++) { - lv_obj_t *circle = lv_obj_create(row_circles); - lv_obj_set_size(circle, 67, 67); - lv_obj_remove_flag(circle, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_set_style_radius(circle, LV_RADIUS_CIRCLE, 0); - lv_obj_set_style_bg_opa(circle, LV_OPA_COVER, 0); - lv_obj_set_style_bg_color(circle, current_theme.bg_item_top, 0); - lv_obj_set_style_bg_grad_color(circle, current_theme.bg_secondary, 0); - lv_obj_set_style_bg_grad_dir(circle, LV_GRAD_DIR_VER, 0); - lv_obj_set_style_border_width(circle, 0, 0); - circles[i] = circle; - - if (circle_icon_dscs[i]) { - lv_obj_t *icon = lv_image_create(circle); - lv_image_set_src(icon, circle_icon_dscs[i]); - lv_obj_center(icon); - circle_icons_obj[i] = icon; - } - } - - lv_obj_t *row_toggles = lv_obj_create(content); - lv_obj_set_size(row_toggles, LV_SIZE_CONTENT, LV_SIZE_CONTENT); - lv_obj_remove_flag(row_toggles, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_set_style_bg_opa(row_toggles, LV_OPA_TRANSP, 0); - lv_obj_set_style_border_width(row_toggles, 0, 0); - lv_obj_set_style_pad_all(row_toggles, 0, 0); - lv_obj_set_style_pad_column(row_toggles, 20, 0); - lv_obj_set_flex_flow(row_toggles, LV_FLEX_FLOW_ROW); + slide_panel, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_START); + lv_obj_set_style_pad_row(slide_panel, 13, 0); + + lv_obj_t *grab = lv_obj_create(slide_panel); + lv_obj_set_size(grab, 46, 5); + lv_obj_remove_flag(grab, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_radius(grab, 3, 0); + lv_obj_set_style_bg_color(grab, current_theme.border_inactive, 0); + lv_obj_set_style_bg_opa(grab, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(grab, 0, 0); + + lv_obj_t *badges = lv_obj_create(slide_panel); + lv_obj_set_size(badges, lv_pct(100), LV_SIZE_CONTENT); + lv_obj_remove_flag(badges, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_add_flag(badges, LV_OBJ_FLAG_OVERFLOW_VISIBLE); + lv_obj_set_style_bg_opa(badges, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(badges, 0, 0); + lv_obj_set_style_pad_all(badges, 0, 0); + lv_obj_set_flex_flow(badges, LV_FLEX_FLOW_ROW); lv_obj_set_flex_align( - row_toggles, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); - - for (int i = 0; i < 2; i++) { - toggle_ui_create(&toggles[i], row_toggles); - sel_items[i] = toggles[i].obj; - } - - static lv_image_dsc_t *phone_dsc = NULL; - static lv_image_dsc_t *volume_dsc = NULL; - static lv_image_dsc_t *bright_dsc = NULL; - - if (!phone_dsc) - phone_dsc = assets_get("/assets/icons/phone_icon.bin"); - if (!volume_dsc) - volume_dsc = assets_get("/assets/icons/volume_icon.bin"); - if (!bright_dsc) - bright_dsc = assets_get("/assets/icons/bright_icon.bin"); - - lv_image_dsc_t *big_icons[] = {phone_dsc, volume_dsc, bright_dsc}; - - for (int i = 0; i < 3; i++) { - lv_obj_t *big_rect = lv_obj_create(content); - lv_obj_set_size(big_rect, lv_pct(80), 33); - lv_obj_remove_flag(big_rect, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_set_style_radius(big_rect, 12, 0); - lv_obj_set_style_bg_opa(big_rect, LV_OPA_COVER, 0); - lv_obj_set_style_bg_color(big_rect, current_theme.bg_item_top, 0); - lv_obj_set_style_bg_grad_color(big_rect, current_theme.bg_secondary, 0); - lv_obj_set_style_bg_grad_dir(big_rect, LV_GRAD_DIR_VER, 0); - lv_obj_set_style_border_width(big_rect, 0, 0); - lv_obj_set_style_pad_all(big_rect, 0, 0); - - int32_t pct = (100 * slider_vals[i]) / SLIDER_STEPS; - if (pct < 1) - pct = 1; - lv_obj_t *bar = lv_obj_create(big_rect); - lv_obj_set_size(bar, lv_pct(pct), 33); - lv_obj_remove_flag(bar, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_set_style_radius(bar, 12, 0); - lv_obj_set_style_bg_opa(bar, LV_OPA_COVER, 0); - lv_obj_set_style_bg_color(bar, current_theme.border_accent, 0); - lv_obj_set_style_bg_grad_color(bar, current_theme.border_accent, 0); - lv_obj_set_style_bg_grad_dir(bar, LV_GRAD_DIR_HOR, 0); - lv_obj_set_style_border_width(bar, 0, 0); - lv_obj_set_style_pad_all(bar, 0, 0); - lv_obj_set_pos(bar, 0, 0); - slider_bars[i] = bar; - - if (big_icons[i]) { - lv_obj_t *icon = lv_image_create(big_rect); - lv_image_set_src(icon, big_icons[i]); - lv_obj_align(icon, LV_ALIGN_LEFT_MID, 8, 0); - } - - sel_items[2 + i] = big_rect; - } - - static lv_image_dsc_t *slide_bar_dsc = NULL; - if (!slide_bar_dsc) - slide_bar_dsc = assets_get("/assets/icons/slide_bar.bin"); - - slide_bar_obj = lv_image_create(parent); - if (slide_bar_dsc) - lv_image_set_src(slide_bar_obj, slide_bar_dsc); - lv_obj_align(slide_bar_obj, LV_ALIGN_TOP_MID, 0, 0); - lv_obj_set_y(slide_bar_obj, -DROPDOWN_HEIGHT); - lv_obj_add_flag(slide_bar_obj, LV_OBJ_FLAG_HIDDEN); - lv_obj_move_foreground(slide_bar_obj); - - page_containers[1] = lv_obj_create(slide_panel); - lv_obj_set_size(page_containers[1], lv_pct(95), LV_SIZE_CONTENT); - lv_obj_align(page_containers[1], LV_ALIGN_TOP_MID, 0, 20); - lv_obj_remove_flag(page_containers[1], LV_OBJ_FLAG_SCROLLABLE); - lv_obj_set_style_bg_opa(page_containers[1], LV_OPA_TRANSP, 0); - lv_obj_set_style_border_width(page_containers[1], 0, 0); - lv_obj_set_style_pad_all(page_containers[1], 0, 0); - lv_obj_set_flex_flow(page_containers[1], LV_FLEX_FLOW_ROW); + badges, LV_FLEX_ALIGN_SPACE_BETWEEN, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + make_badge(badges, 0, LV_SYMBOL_WIFI, "Wi-Fi"); + make_badge(badges, 1, LV_SYMBOL_BLUETOOTH, "BLE"); + make_badge(badges, 2, LV_SYMBOL_SD_CARD, "Eject"); + make_badge(badges, 3, LV_SYMBOL_POWER, "Reboot"); + + sl_value[SLIDER_SOUND] = g_config_system.volume; + make_slider(slide_panel, 0, "/assets/icons/brightness_6.bin"); + make_slider(slide_panel, 1, "/assets/icons/volume_up.bin"); + + lv_obj_t *mini = lv_obj_create(slide_panel); + lv_obj_set_size(mini, lv_pct(100), LV_SIZE_CONTENT); + lv_obj_remove_flag(mini, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_bg_opa(mini, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(mini, 0, 0); + lv_obj_set_style_pad_all(mini, 0, 0); + lv_obj_set_flex_flow(mini, LV_FLEX_FLOW_ROW); lv_obj_set_flex_align( - page_containers[1], LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); - lv_obj_set_style_pad_column(page_containers[1], 10, 0); - lv_obj_add_flag(page_containers[1], LV_OBJ_FLAG_HIDDEN); - - lv_obj_t *avatar = lv_obj_create(page_containers[1]); - lv_obj_set_size(avatar, 80, 80); - lv_obj_remove_flag(avatar, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_set_style_radius(avatar, LV_RADIUS_CIRCLE, 0); - lv_obj_set_style_bg_opa(avatar, LV_OPA_COVER, 0); - lv_obj_set_style_bg_color(avatar, current_theme.screen_base, 0); - lv_obj_set_style_border_width(avatar, 2, 0); - lv_obj_set_style_border_color(avatar, current_theme.border_accent, 0); - lv_obj_set_style_pad_all(avatar, 0, 0); - - static lv_image_dsc_t *portrait_dsc = NULL; - if (!portrait_dsc) - portrait_dsc = assets_get("/assets/img/octobit_portrait.bin"); - if (portrait_dsc) { - lv_obj_t *portrait = lv_image_create(avatar); - lv_image_set_src(portrait, portrait_dsc); - lv_obj_center(portrait); - } - - lv_obj_t *tag = lv_obj_create(avatar); - lv_obj_set_size(tag, 45, 15); - lv_obj_remove_flag(tag, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_set_style_radius(tag, 7, 0); - lv_obj_set_style_bg_opa(tag, LV_OPA_COVER, 0); - lv_obj_set_style_bg_color(tag, current_theme.border_accent, 0); - lv_obj_set_style_border_width(tag, 0, 0); - lv_obj_set_style_pad_all(tag, 0, 0); - lv_obj_align(tag, LV_ALIGN_TOP_RIGHT, 2, -2); - lv_obj_move_foreground(tag); - - lv_obj_t *tag_lbl = lv_label_create(tag); - lv_label_set_text(tag_lbl, "octo"); - lv_obj_set_style_text_color(tag_lbl, current_theme.text_main, 0); - lv_obj_set_style_text_font(tag_lbl, &lv_font_montserrat_12, 0); - lv_obj_center(tag_lbl); - - lv_obj_t *bars_col = lv_obj_create(page_containers[1]); - lv_obj_set_size(bars_col, 120, LV_SIZE_CONTENT); - lv_obj_remove_flag(bars_col, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_set_style_bg_opa(bars_col, LV_OPA_TRANSP, 0); - lv_obj_set_style_border_width(bars_col, 0, 0); - lv_obj_set_style_pad_all(bars_col, 0, 0); - lv_obj_set_style_pad_row(bars_col, 6, 0); - lv_obj_set_flex_flow(bars_col, LV_FLEX_FLOW_COLUMN); - - static const int bar_pcts[] = {80, 55, 40, 65}; - for (int i = 0; i < 4; i++) { - lv_obj_t *bar_bg = lv_obj_create(bars_col); - lv_obj_set_size(bar_bg, lv_pct(100), 11); - lv_obj_remove_flag(bar_bg, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_set_style_radius(bar_bg, 5, 0); - lv_obj_set_style_bg_opa(bar_bg, LV_OPA_COVER, 0); - lv_obj_set_style_bg_color(bar_bg, current_theme.bg_item_top, 0); - lv_obj_set_style_border_width(bar_bg, 0, 0); - lv_obj_set_style_pad_all(bar_bg, 0, 0); - - lv_obj_t *bar_fill = lv_obj_create(bar_bg); - lv_obj_set_size(bar_fill, lv_pct(bar_pcts[i]), 11); - lv_obj_remove_flag(bar_fill, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_set_style_radius(bar_fill, 5, 0); - lv_obj_set_style_bg_opa(bar_fill, LV_OPA_COVER, 0); - lv_obj_set_style_bg_color(bar_fill, current_theme.border_accent, 0); - lv_obj_set_style_bg_grad_color(bar_fill, current_theme.border_accent, 0); - lv_obj_set_style_bg_grad_dir(bar_fill, LV_GRAD_DIR_HOR, 0); - lv_obj_set_style_border_width(bar_fill, 0, 0); - lv_obj_set_style_pad_all(bar_fill, 0, 0); - lv_obj_set_pos(bar_fill, 0, 0); - } - - int dots_y = -(LCD_V_RES - DROPDOWN_HEIGHT - 20) / 2 - 5; - pg_dots = page_dots_create(parent, DROPDOWN_PAGES, LV_ALIGN_BOTTOM_MID, 0, dots_y); - page_dots_hide(&pg_dots); - lv_obj_move_foreground(pg_dots.container); - - if (slide_btn_timer == NULL) { - slide_btn_timer = lv_timer_create(slide_btn_timer_cb, 50, NULL); - } + mini, LV_FLEX_ALIGN_SPACE_BETWEEN, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START); + lv_obj_set_style_pad_column(mini, 14, 0); + make_mini(mini, "Battery", "--", 1, true, &bat_chip_val, &bat_chip_fill); + make_mini(mini, "Storage", "--", 1, false, &sd_chip_val, &sd_chip_fill); + + lv_obj_t *hint = lv_label_create(slide_panel); + lv_label_set_text(hint, + LV_SYMBOL_UP LV_SYMBOL_DOWN " Row " LV_SYMBOL_LEFT LV_SYMBOL_RIGHT + " Adjust BACK Close"); + lv_obj_set_style_text_color(hint, current_theme.text_main, 0); + lv_obj_set_style_text_opa(hint, LV_OPA_50, 0); + lv_obj_set_style_text_font(hint, &lv_font_montserrat_12, 0); + + focus_row = ROW_BADGES; + badge_sel = 0; + refresh_focus(); + + lv_obj_update_layout(slide_panel); + s_panel_h = lv_obj_get_height(slide_panel); + if (s_panel_h < 40 || s_panel_h > LCD_V_RES) + s_panel_h = (LCD_V_RES * 85) / 100; + lv_obj_set_y(slide_panel, -s_panel_h); + + if (slide_btn_timer == NULL) + slide_btn_timer = lv_timer_create(slide_btn_timer_cb, SLIDE_BTN_POLL_MS, NULL); slide_open = false; slide_animating = false; @@ -536,8 +818,12 @@ bool dropdown_ui_is_open(void) { void dropdown_ui_raise(void) { if (slide_panel) lv_obj_move_foreground(slide_panel); - if (slide_bar_obj) - lv_obj_move_foreground(slide_bar_obj); - if (pg_dots.container) - lv_obj_move_foreground(pg_dots.container); } + +void dropdown_ui_global_init(void) { + if (slide_panel) // already created + return; + // The top layer sits above every screen, so the single panel survives screen + // switches and always renders on top. The caller holds the LVGL lock. + dropdown_ui_create(lv_layer_top()); +} \ No newline at end of file diff --git a/firmware_p4/components/Applications/ui/components/dropdown/include/dropdown_ui.h b/firmware_p4/components/Applications/ui/components/dropdown/include/dropdown_ui.h index 4973fd8c0..0e99be23d 100644 --- a/firmware_p4/components/Applications/ui/components/dropdown/include/dropdown_ui.h +++ b/firmware_p4/components/Applications/ui/components/dropdown/include/dropdown_ui.h @@ -25,6 +25,16 @@ extern "C" { /** @brief Create the dropdown panel on the given parent. */ void dropdown_ui_create(lv_obj_t *parent); +/** + * @brief Create the single global dropdown on the top layer (call once at init). + * + * The panel then rides above every screen and is opened from any browse screen + * with a long-press of UP (gated by ui_screen_shows_chrome), closed with BACK or + * another long-press. While open it holds exclusive input (keypad suppressed + + * polling screens input-locked). + */ +void dropdown_ui_global_init(void); + /** @brief Register objects to hide when the dropdown opens. */ void dropdown_ui_register_hide_objs(lv_obj_t **objs, int count); diff --git a/firmware_p4/components/Applications/ui/components/error/error_ui.c b/firmware_p4/components/Applications/ui/components/error/error_ui.c new file mode 100644 index 000000000..dc8122dd1 --- /dev/null +++ b/firmware_p4/components/Applications/ui/components/error/error_ui.c @@ -0,0 +1,172 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#include "error_ui.h" + +#include "lvgl.h" + +#include "ui_feedback.h" + +#define ERROR_MS 5000 +#define SLIDE_MS 220 +#define TOP_Y 8 +#define BANNER_W 224 +#define COL_TXT_W 150 +#define COL_ERR 0xFF3B47 + +static lv_obj_t *s_banner = NULL; +static lv_timer_t *s_timer = NULL; + +static void slide_y_cb(void *var, int32_t v) { + lv_obj_set_style_translate_y((lv_obj_t *)var, v, 0); +} +static void opa_cb(void *var, int32_t v) { + lv_obj_set_style_opa((lv_obj_t *)var, (lv_opa_t)v, 0); +} + +static void gone_cb(lv_anim_t *a) { + (void)a; + if (s_banner != NULL) { + lv_obj_del(s_banner); + s_banner = NULL; + } +} + +static void clear_now(void) { + if (s_timer != NULL) { + lv_timer_delete(s_timer); + s_timer = NULL; + } + if (s_banner != NULL) { + lv_anim_delete(s_banner, NULL); + lv_obj_del(s_banner); + s_banner = NULL; + } +} + +static void dismiss_cb(lv_timer_t *t) { + (void)t; + s_timer = NULL; + if (s_banner == NULL) + return; + lv_anim_t a; + lv_anim_init(&a); + lv_anim_set_var(&a, s_banner); + lv_anim_set_exec_cb(&a, slide_y_cb); + lv_anim_set_values(&a, 0, -60); + lv_anim_set_duration(&a, SLIDE_MS); + lv_anim_set_path_cb(&a, lv_anim_path_ease_in); + lv_anim_set_completed_cb(&a, gone_cb); + lv_anim_start(&a); + + lv_anim_t f; + lv_anim_init(&f); + lv_anim_set_var(&f, s_banner); + lv_anim_set_exec_cb(&f, opa_cb); + lv_anim_set_values(&f, LV_OPA_COVER, LV_OPA_TRANSP); + lv_anim_set_duration(&f, SLIDE_MS); + lv_anim_start(&f); +} + +void error_show(const char *title, const char *msg) { + clear_now(); + + lv_color_t err = lv_color_hex(COL_ERR); + lv_obj_t *b = lv_obj_create(lv_layer_top()); + s_banner = b; + lv_obj_remove_flag(b, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_remove_flag(b, LV_OBJ_FLAG_CLICKABLE); + lv_obj_set_width(b, BANNER_W); + lv_obj_set_height(b, LV_SIZE_CONTENT); + lv_obj_set_style_radius(b, 14, 0); + lv_obj_set_style_bg_color(b, lv_color_hex(0x1B0509), 0); + lv_obj_set_style_bg_grad_color(b, lv_color_hex(0x2A0D12), 0); + lv_obj_set_style_bg_grad_dir(b, LV_GRAD_DIR_VER, 0); + lv_obj_set_style_bg_opa(b, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(b, 1, 0); + lv_obj_set_style_border_color(b, err, 0); + lv_obj_set_style_shadow_width(b, 20, 0); + lv_obj_set_style_shadow_color(b, err, 0); + lv_obj_set_style_shadow_spread(b, -6, 0); + lv_obj_set_style_shadow_ofs_y(b, 6, 0); + lv_obj_set_style_pad_all(b, 10, 0); + lv_obj_set_flex_flow(b, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(b, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_set_style_pad_column(b, 11, 0); + lv_obj_align(b, LV_ALIGN_TOP_MID, 0, TOP_Y); + + lv_obj_t *chip = lv_obj_create(b); + lv_obj_remove_flag(chip, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(chip, 40, 40); + lv_obj_set_style_radius(chip, 11, 0); + lv_obj_set_style_bg_color(chip, err, 0); + lv_obj_set_style_bg_opa(chip, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(chip, 0, 0); + lv_obj_set_style_pad_all(chip, 0, 0); + lv_obj_t *x = lv_label_create(chip); + lv_label_set_text(x, LV_SYMBOL_CLOSE); + lv_obj_set_style_text_color(x, lv_color_hex(0xFFFFFF), 0); + lv_obj_set_style_text_font(x, &lv_font_montserrat_16, 0); + lv_obj_center(x); + + lv_obj_t *col = lv_obj_create(b); + lv_obj_remove_flag(col, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_width(col, COL_TXT_W); + lv_obj_set_height(col, LV_SIZE_CONTENT); + lv_obj_set_style_bg_opa(col, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(col, 0, 0); + lv_obj_set_style_pad_all(col, 0, 0); + lv_obj_set_flex_flow(col, LV_FLEX_FLOW_COLUMN); + lv_obj_set_style_pad_row(col, 2, 0); + + lv_obj_t *t = lv_label_create(col); + lv_obj_set_width(t, lv_pct(100)); + lv_label_set_long_mode(t, LV_LABEL_LONG_DOT); + lv_label_set_text(t, title ? title : "Error"); + lv_obj_set_style_text_font(t, &lv_font_montserrat_14, 0); + lv_obj_set_style_text_color(t, lv_color_hex(0xFFFFFF), 0); + + if (msg != NULL) { + lv_obj_t *m = lv_label_create(col); + lv_obj_set_width(m, lv_pct(100)); + lv_label_set_long_mode(m, LV_LABEL_LONG_WRAP); + lv_label_set_text(m, msg); + lv_obj_set_style_text_font(m, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(m, lv_color_hex(0xE7B7BB), 0); + } + + lv_obj_set_style_opa(b, LV_OPA_TRANSP, 0); + lv_anim_t a; + lv_anim_init(&a); + lv_anim_set_var(&a, b); + lv_anim_set_exec_cb(&a, slide_y_cb); + lv_anim_set_values(&a, -60, 0); + lv_anim_set_duration(&a, SLIDE_MS); + lv_anim_set_path_cb(&a, lv_anim_path_ease_out); + lv_anim_start(&a); + + lv_anim_t f; + lv_anim_init(&f); + lv_anim_set_var(&f, b); + lv_anim_set_exec_cb(&f, opa_cb); + lv_anim_set_values(&f, LV_OPA_TRANSP, LV_OPA_COVER); + lv_anim_set_duration(&f, SLIDE_MS); + lv_anim_start(&f); + + ui_feedback(UI_FB_WRITE); + + s_timer = lv_timer_create(dismiss_cb, ERROR_MS, NULL); + lv_timer_set_repeat_count(s_timer, 1); +} diff --git a/firmware_p4/components/Applications/ui/components/error/include/error_ui.h b/firmware_p4/components/Applications/ui/components/error/include/error_ui.h new file mode 100644 index 000000000..db84b7dfa --- /dev/null +++ b/firmware_p4/components/Applications/ui/components/error/include/error_ui.h @@ -0,0 +1,41 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#ifndef ERROR_UI_H +#define ERROR_UI_H + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * @brief Show a global error banner: a red toast on the LVGL TOP LAYER, so it + * floats above ANY screen (it can never be overlapped) and survives + * screen switches. + * + * Shows a short title + a one-line detail, buzzes, and auto-dismisses after a + * few seconds. Non-blocking (never steals input). A new call replaces the + * current one. For soft warnings prefer notify(NOTIFY_WARNING, ...). + * + * @param title Short banner title (NULL falls back to "Error"). + * @param msg One-line detail shown under the title (NULL for none). + */ +void error_show(const char *title, const char *msg); + +#ifdef __cplusplus +} +#endif + +#endif // ERROR_UI_H diff --git a/firmware_p4/components/Applications/ui/components/feedback/include/ui_feedback.h b/firmware_p4/components/Applications/ui/components/feedback/include/ui_feedback.h new file mode 100644 index 000000000..2f2e7f06a --- /dev/null +++ b/firmware_p4/components/Applications/ui/components/feedback/include/ui_feedback.h @@ -0,0 +1,64 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#ifndef UI_FEEDBACK_H +#define UI_FEEDBACK_H + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * @brief UI audio + haptic feedback cue identifiers. + * + * Sound plays on navigation and on every function read/write/emulate; + * vibration is added only on function results (read/write/emulate), never on + * plain button presses. Cues run on a short-lived worker task so UI callers + * never block, and overlapping cues are dropped while one is still playing. + */ +typedef enum { + UI_FB_NAV = 0, ///< Menu item changed: short tick, no vibration. + UI_FB_SELECT, ///< Open/confirm: soft blip, no vibration. + UI_FB_READ, ///< Function READ succeeded: rising tone + vibration. + UI_FB_WRITE, ///< Function WRITE/SAVE succeeded: two-tone + vibration. + UI_FB_EMULATE, ///< Function is EMULATING: pulse + vibration. + UI_FB_BOOT, ///< Startup chime, no vibration. + UI_FB_SD_CONNECT, ///< SD card inserted: ascending chime + click. + UI_FB_SD_DISCONNECT, ///< SD card removed: descending chime. + UI_FB_COUNT ///< Sentinel; number of cue kinds. +} ui_feedback_kind_t; + +/** + * @brief Initialize the feedback subsystem. + * + * Idempotent; call once at UI init. + */ +void ui_feedback_init(void); + +/** + * @brief Fire a feedback cue. + * + * Non-blocking and safe to call from any UI callback. The cue is dropped if + * another cue is already playing or if @p kind is out of range. + * + * @param kind Cue to play. + */ +void ui_feedback(ui_feedback_kind_t kind); + +#ifdef __cplusplus +} +#endif + +#endif // UI_FEEDBACK_H diff --git a/firmware_p4/components/Applications/ui/components/feedback/ui_feedback.c b/firmware_p4/components/Applications/ui/components/feedback/ui_feedback.c new file mode 100644 index 000000000..b351d28fd --- /dev/null +++ b/firmware_p4/components/Applications/ui/components/feedback/ui_feedback.c @@ -0,0 +1,182 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#include "ui_feedback.h" + +#include +#include + +#include "esp_timer.h" +#include "freertos/FreeRTOS.h" +#include "freertos/queue.h" +#include "freertos/task.h" +#include "sys_prio.h" + +#include "audio_i2s.h" +#include "drv2605l.h" + +#define FB_TASK_STACK_SIZE 3072 +#define FB_TASK_PRIORITY SYS_PRIO_SERVICE_LO + +/** @brief Single pending slot. Feedback is a reaction to an input that already + * happened - a stale one is worthless, so we drop instead of queueing. + * Spinning the coverflow must never build up a backlog of chirps. */ +#define FB_QUEUE_DEPTH 1 + +/** @brief Minimum spacing between two accepted bursts. + * + * The s_playing flag alone is not enough: the worker clears it the instant a + * burst ends, and there is a window between it taking an item off the queue and + * setting the flag. A keypad repeat firing several LV_EVENT_KEY in quick + * succession slips through those gaps and the chirps pile onto each other. + * This is a hard floor on the rate, independent of where the events come from. + * Slightly longer than the ~48 ms UI_FB_NAV burst so held navigation ticks + * cleanly instead of running the sounds together. */ +#define FB_MIN_GAP_MS 120 + +#define DRV_EFFECT_STRONG_CLICK 1 +#define DRV_EFFECT_SHARP_CLICK 4 +#define DRV_EFFECT_DOUBLE_CLICK 10 + +typedef struct { + const audio_note_t *notes; + int count; + float amp; + uint8_t haptic; +} fb_def_t; + +static const audio_note_t SND_NAV[] = {{2000, 32}}; +static const audio_note_t SND_SELECT[] = {{1568, 40}}; +static const audio_note_t SND_READ[] = {{1318, 60}, {1976, 95}}; +static const audio_note_t SND_WRITE[] = {{1568, 45}, {2093, 80}}; +static const audio_note_t SND_EMULATE[] = {{1046, 70}, {1568, 95}}; +static const audio_note_t SND_BOOT[] = {{523, 120}, {659, 120}, {784, 175}}; +static const audio_note_t SND_SD_IN[] = {{1046, 45}, {1568, 55}, {2093, 80}}; +static const audio_note_t SND_SD_OUT[] = {{2093, 45}, {1568, 55}, {1046, 80}}; + +static const fb_def_t DEFS[UI_FB_COUNT] = { + [UI_FB_NAV] = {SND_NAV, 1, 0.30f, 0}, + [UI_FB_SELECT] = {SND_SELECT, 1, 0.32f, 0}, + [UI_FB_READ] = {SND_READ, 2, 0.40f, DRV_EFFECT_STRONG_CLICK}, + [UI_FB_WRITE] = {SND_WRITE, 2, 0.40f, DRV_EFFECT_DOUBLE_CLICK}, + [UI_FB_EMULATE] = {SND_EMULATE, 2, 0.40f, DRV_EFFECT_SHARP_CLICK}, + [UI_FB_BOOT] = {SND_BOOT, 3, 0.35f, 0}, + [UI_FB_SD_CONNECT] = {SND_SD_IN, 3, 0.40f, DRV_EFFECT_STRONG_CLICK}, + [UI_FB_SD_DISCONNECT] = {SND_SD_OUT, 3, 0.35f, 0}, +}; + +/** @brief True from the moment a burst is ACCEPTED until the worker has fully + * finished playing it. Set by the producer (not the worker) so there is + * no window between accepting and marking - that gap is exactly how two + * requests used to slip through and run into each other. */ +static bool s_playing = false; + +/** @brief Monotonic ms of the last accepted burst, for the FB_MIN_GAP_MS floor. */ +static int64_t s_last_accept_ms = 0; + +/** @brief Guards s_playing + s_last_accept_ms. ui_feedback() has more than one + * producer: UI navigation runs on the LVGL thread while SD insert / + * removal comes from the storage monitor task, so the accept decision + * has to be atomic against itself. */ +static portMUX_TYPE s_fb_lock = portMUX_INITIALIZER_UNLOCKED; + +static QueueHandle_t s_fb_q = NULL; + +static void fb_task(void *arg); + +void ui_feedback_init(void) { + if (s_fb_q != NULL) // ui_manager calls this on every UI (re)start + return; + + s_fb_q = xQueueCreate(FB_QUEUE_DEPTH, sizeof(ui_feedback_kind_t)); + if (s_fb_q == NULL) + return; + + // One long-lived worker instead of a task per event: the old code spawned a + // 6 KB task on every nav tick, which under a fast coverflow spin meant many + // live tasks all racing for the single I2S TX channel. + // + // Pinned to SYS_CORE_RADIO, not the UI core: this worker renders the tone and + // feeds the I2S DMA, and the LVGL renderer sits above it in priority on the + // UI core - a full-frame redraw would preempt it long enough to starve the + // sample feed. Same reason the driver's own FX task lives there. + if (xTaskCreatePinnedToCore( + fb_task, "ui_fb", FB_TASK_STACK_SIZE, NULL, FB_TASK_PRIORITY, NULL, SYS_CORE_RADIO) != + pdPASS) { + vQueueDelete(s_fb_q); + s_fb_q = NULL; + } +} + +void ui_feedback(ui_feedback_kind_t kind) { + if ((int)kind < 0 || kind >= UI_FB_COUNT) + return; + if (s_fb_q == NULL) + return; + + const int64_t now_ms = esp_timer_get_time() / 1000; // outside the critical section + + // Claim the slot atomically: one burst in flight at a time, and never two + // closer together than FB_MIN_GAP_MS, no matter which task is asking. + bool accepted = false; + portENTER_CRITICAL(&s_fb_lock); + if (!s_playing && (now_ms - s_last_accept_ms) >= FB_MIN_GAP_MS) { + s_playing = true; + s_last_accept_ms = now_ms; + accepted = true; + } + portEXIT_CRITICAL(&s_fb_lock); + + if (!accepted) + return; + + // Depth-1 queue with a zero timeout: never blocks a caller, and the slot is + // guaranteed free because we just claimed s_playing. Release the claim if the + // send somehow fails, otherwise feedback would be wedged off forever. + if (xQueueSend(s_fb_q, &kind, 0) != pdTRUE) { + portENTER_CRITICAL(&s_fb_lock); + s_playing = false; + portEXIT_CRITICAL(&s_fb_lock); + } +} + +static void fb_task(void *arg) { + (void)arg; + ui_feedback_kind_t k; + while (true) { + if (xQueueReceive(s_fb_q, &k, portMAX_DELAY) != pdTRUE) + continue; + if ((int)k < 0 || k >= UI_FB_COUNT) { + portENTER_CRITICAL(&s_fb_lock); + s_playing = false; + portEXIT_CRITICAL(&s_fb_lock); + continue; + } + + // s_playing was already set by the producer that claimed this slot. + const fb_def_t *d = &DEFS[k]; + if (d->haptic) + (void)drv2605l_play_effect(d->haptic); + if (d->notes && d->count > 0) + (void)audio_i2s_play_song(d->notes, d->count, d->amp); + + // play_song only returns once the samples have actually reached the amp + // (the driver drains the DMA before releasing), so releasing the claim here + // means the next burst starts on silence instead of on top of this one. + portENTER_CRITICAL(&s_fb_lock); + s_playing = false; + portEXIT_CRITICAL(&s_fb_lock); + } +} diff --git a/firmware_p4/components/Applications/ui/components/footer/footer_ui.c b/firmware_p4/components/Applications/ui/components/footer/footer_ui.c index a47e88cbd..9a82c7c19 100644 --- a/firmware_p4/components/Applications/ui/components/footer/footer_ui.c +++ b/firmware_p4/components/Applications/ui/components/footer/footer_ui.c @@ -15,14 +15,17 @@ #include "footer_ui.h" +#include +#include + #include "esp_log.h" #include "cJSON.h" #include "storage_assets.h" +#include "tos_flash_paths.h" #include "ui_theme.h" -#define FOOTER_HEIGHT 20 -#include "tos_flash_paths.h" +#define FOOTER_HEIGHT 20 #define INTERFACE_CONFIG_PATH FLASH_CONFIG_INTERFACE static bool footer_is_hidden(void) { diff --git a/firmware_p4/components/Applications/ui/components/header/header_ui.c b/firmware_p4/components/Applications/ui/components/header/header_ui.c index 06dc749ed..57ab4543d 100644 --- a/firmware_p4/components/Applications/ui/components/header/header_ui.c +++ b/firmware_p4/components/Applications/ui/components/header/header_ui.c @@ -15,20 +15,316 @@ #include "header_ui.h" +#include +#include + +#include "driver/gpio.h" +#include "esp_attr.h" +#include "esp_log.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include "sys_prio.h" #include "lvgl.h" #include "st7789.h" #include "assets_manager.h" +#include "battery_service.h" +#include "bluetooth_service.h" +#include "bq25896.h" +#include "tos_config.h" +#include "msgbox_ui.h" +#include "notify_ui.h" +#include "pin_def.h" +#include "sys_time.h" +#include "ui_feedback.h" #include "ui_theme.h" +#include "vfs_config.h" +#include "vfs_core.h" +#include "vfs_sdcard.h" #include "wifi_service.h" #define HEADER_HEIGHT ((LCD_V_RES * 9) / 100) -static lv_font_t *inter_font = NULL; +#define HEADER_ACTIVE_TINT_HEX 0x00E676 +#define SD_CD_PRESENT_LEVEL 0 + +#define STATUS_POLL_MS 1000 +#define WIFI_ANIM_MS 800 +#define BATTERY_CHARGE_ANIM_MS 350 + +#define SD_MOUNT_RETRIES 3 +#define SD_MOUNT_RETRY_DELAY_MS 150 +#define SD_TASK_STACK 6144 // vfs mount/unmount logs via the deep console path +#define SD_CD_DEBOUNCE_MS 150 // settle the card-detect switch after an edge + +static lv_obj_t *bt_img_ref = NULL; +static lv_obj_t *card_img_ref = NULL; +static bool s_ble_active = false; +static lv_timer_t *header_poll_timer = NULL; +static lv_obj_t *s_lbl_time = NULL; + +static bool s_bt_tint_last = false; +static bool s_card_shown_last = false; +static bool s_wifi_shown_last = false; +static bool s_wifi_connected_last = false; + +static void header_sync_wifi_icon(void); +static bool s_cd_configured = false; +static bool s_sd_mounted = false; +static int s_sd_used_pct = 0; +static TaskHandle_t s_cd_task = NULL; // debounces the CD ISR + (un)mounts +static bool s_sd_present_committed = false; // last debounced CD state acted on +static volatile bool s_sd_remount_req = false; +static char s_sd_name[24]; +static char s_sd_size[16]; +static char s_sd_free[16]; +static char s_sd_fmt[12]; + +static void apply_active_tint(lv_obj_t *obj, bool active) { + if (!obj || !lv_obj_is_valid(obj)) + return; + lv_obj_set_style_text_color( + obj, active ? lv_color_hex(HEADER_ACTIVE_TINT_HEX) : current_theme.text_main, 0); + lv_obj_set_style_text_opa(obj, active ? LV_OPA_COVER : LV_OPA_50, 0); +} + +static void header_sync_ble_icon(void) { + bool running = bluetooth_service_is_running_cached(); + bool shown = g_config_ble.enabled || running; + if (shown == s_ble_active && running == s_bt_tint_last) + return; + s_ble_active = shown; + s_bt_tint_last = running; + if (!bt_img_ref || !lv_obj_is_valid(bt_img_ref)) + return; + if (!shown) { + lv_obj_add_flag(bt_img_ref, LV_OBJ_FLAG_HIDDEN); + return; + } + lv_obj_remove_flag(bt_img_ref, LV_OBJ_FLAG_HIDDEN); + apply_active_tint(bt_img_ref, running); +} + +static void sd_cd_isr(void *arg); +static void sd_cd_task(void *arg); + +static void sd_cd_ensure_configured(void) { + if (s_cd_configured) + return; + + gpio_config_t cfg = { + .pin_bit_mask = 1ULL << GPIO_SD_CD_PIN, + .mode = GPIO_MODE_INPUT, + .pull_up_en = GPIO_PULLUP_ENABLE, + .pull_down_en = GPIO_PULLDOWN_DISABLE, + .intr_type = GPIO_INTR_ANYEDGE, // interrupt-driven hotplug (item 20) + }; + gpio_config(&cfg); + + // The debounce + (un)mount worker. The ISR only notifies it; blocking work + // (vfs mount/statvfs) runs here and UI updates hop to the LVGL thread via + // lv_async_call. Boot state is handled by the task's first pass. + if (s_cd_task == NULL) { + xTaskCreatePinnedToCore( + sd_cd_task, "sd_cd", SD_TASK_STACK, NULL, SYS_PRIO_SERVICE_LO, &s_cd_task, SYS_CORE_RADIO); + } + + gpio_isr_handler_add(GPIO_SD_CD_PIN, sd_cd_isr, NULL); + + s_cd_configured = true; +} + +static bool sd_card_present(void) { + return gpio_get_level(GPIO_SD_CD_PIN) == SD_CD_PRESENT_LEVEL; +} + +static void set_card_icon_shown(bool shown) { + if (!card_img_ref || !lv_obj_is_valid(card_img_ref)) + return; + if (shown) { + lv_obj_remove_flag(card_img_ref, LV_OBJ_FLAG_HIDDEN); + } else { + lv_obj_add_flag(card_img_ref, LV_OBJ_FLAG_HIDDEN); + } +} + +static void fmt_bytes(char *out, size_t n, uint64_t bytes) { + const uint64_t gb = 1024ULL * 1024 * 1024; + const uint64_t mb = 1024ULL * 1024; + if (bytes >= gb) { + uint64_t t = (bytes * 10) / gb; + snprintf(out, n, "%llu.%llu GB", (unsigned long long)(t / 10), (unsigned long long)(t % 10)); + } else if (bytes >= mb) { + snprintf(out, n, "%llu MB", (unsigned long long)(bytes / mb)); + } else { + snprintf(out, n, "%llu KB", (unsigned long long)(bytes / 1024)); + } +} + +// --- LVGL-thread UI updates (posted from the CD task via lv_async_call) --- + +static void sd_apply_mounted(void *arg) { + bool boot = (bool)(intptr_t)arg; + s_sd_mounted = true; + set_card_icon_shown(true); + s_card_shown_last = true; + if (!boot) { + ui_feedback(UI_FB_SD_CONNECT); + msgbox_open_sd_info(s_sd_name, s_sd_size, s_sd_free, s_sd_fmt); + } +} + +static void sd_apply_removed(void *arg) { + (void)arg; + bool was = s_sd_mounted; + s_sd_mounted = false; + s_sd_used_pct = 0; + set_card_icon_shown(false); + s_card_shown_last = false; + if (was) { + ui_feedback(UI_FB_SD_DISCONNECT); + notify(NOTIFY_WARNING, "SD card removed"); + } +} + +void header_ui_sd_eject(void) { + if (vfs_sdcard_is_mounted()) + vfs_sdcard_deinit(); + s_sd_mounted = false; + s_sd_used_pct = 0; + set_card_icon_shown(false); + s_card_shown_last = false; +} + +// Mount (with retries) and gather the info strings. Runs on the CD task. +static bool sd_try_mount(void) { + bool ok = vfs_sdcard_is_mounted(); + for (int i = 0; !ok && i < SD_MOUNT_RETRIES; i++) { + if (!sd_card_present()) { + break; + } + ok = (vfs_sdcard_init() == ESP_OK); + if (!ok) { + vTaskDelay(pdMS_TO_TICKS(SD_MOUNT_RETRY_DELAY_MS)); + } + } + if (!ok) { + return false; + } + if (!vfs_sdcard_get_name(s_sd_name, sizeof(s_sd_name))) { + s_sd_name[0] = '\0'; + } + vfs_statvfs_t st; + if (vfs_statvfs(VFS_MOUNT_POINT, &st) == ESP_OK) { + fmt_bytes(s_sd_size, sizeof(s_sd_size), st.total_bytes); + fmt_bytes(s_sd_free, sizeof(s_sd_free), st.free_bytes); + s_sd_used_pct = (st.total_bytes > 0) ? (int)((st.used_bytes * 100) / st.total_bytes) : 0; + } else { + snprintf(s_sd_size, sizeof(s_sd_size), "-"); + snprintf(s_sd_free, sizeof(s_sd_free), "-"); + s_sd_used_pct = 0; + } + snprintf(s_sd_fmt, sizeof(s_sd_fmt), "FAT32"); + return true; +} + +static void IRAM_ATTR sd_cd_isr(void *arg) { + (void)arg; + BaseType_t hpw = pdFALSE; + if (s_cd_task != NULL) { + vTaskNotifyGiveFromISR(s_cd_task, &hpw); + } + portYIELD_FROM_ISR(hpw); +} + +static void sd_cd_task(void *arg) { + (void)arg; + bool boot = true; + for (;;) { + if (!boot) { + ulTaskNotifyTake(pdTRUE, portMAX_DELAY); // wait for a CD edge + vTaskDelay(pdMS_TO_TICKS(SD_CD_DEBOUNCE_MS)); // let the switch settle + ulTaskNotifyTake(pdTRUE, 0); // drain bounces during settle + } + + bool force = s_sd_remount_req; + s_sd_remount_req = false; + + bool present = sd_card_present(); + if (!boot && !force && present == s_sd_present_committed) { + continue; // spurious edge, no real change + } + + if (force && vfs_sdcard_is_mounted()) { + vfs_sdcard_deinit(); // drop the unhealthy mount so it is rebuilt fresh + } + s_sd_present_committed = present; + + if (present) { + if (sd_try_mount()) { + lv_async_call(sd_apply_mounted, (void *)(intptr_t)(boot || force)); + } else if (vfs_sdcard_is_mounted()) { + vfs_sdcard_deinit(); + } + } else if (!boot) { + // Real removal (not a card-less boot): unmount and tell the UI. + if (vfs_sdcard_is_mounted()) { + vfs_sdcard_deinit(); + } + lv_async_call(sd_apply_removed, NULL); + } + + boot = false; + } +} + +void header_ui_request_sd_remount(void) { + s_sd_remount_req = true; + if (s_cd_task != NULL) + xTaskNotifyGive(s_cd_task); +} + +static void battery_apply(void); + +static void header_set_clock_label(lv_obj_t *lbl) { + if (lbl == NULL) { + return; + } + char buf[8]; + if (!sys_time_format(buf, sizeof(buf), "%H:%M")) { + snprintf(buf, sizeof(buf), "--:--"); + } + const char *cur = lv_label_get_text(lbl); + if (cur != NULL && strcmp(cur, buf) == 0) { + return; + } + lv_label_set_text(lbl, buf); +} + +static void header_time_del_cb(lv_event_t *e) { + if (lv_event_get_target(e) == s_lbl_time) { + s_lbl_time = NULL; + } +} + +static void header_poll_cb(lv_timer_t *timer) { + (void)timer; + header_sync_wifi_icon(); + header_sync_ble_icon(); + battery_apply(); + if (s_lbl_time != NULL && lv_obj_is_valid(s_lbl_time)) { + header_set_clock_label(s_lbl_time); + } +} + +bool header_ui_sd_usage(int *out_used_pct) { + if (out_used_pct != NULL) { + *out_used_pct = s_sd_used_pct; + } + return s_sd_mounted; +} -static bool header_wifi_connected = false; -static bool header_wifi_enabled = true; -static lv_timer_t *wifi_status_timer = NULL; +static lv_font_t *inter_font = NULL; static lv_obj_t *wifi_img = NULL; static lv_image_dsc_t *wifi_dscs[4] = {NULL}; @@ -43,13 +339,19 @@ static const char *wifi_paths[4] = { "/assets/icons/wifi_icon_3.bin", }; +static lv_obj_t *battery_cont = NULL; static lv_obj_t *battery_img = NULL; static lv_obj_t *power_img = NULL; static lv_image_dsc_t *battery_dscs[4] = {NULL}; -static lv_image_dsc_t *power_icon_dsc = NULL; static int battery_frame = 0; -static int battery_dir = 1; -static lv_timer_t *battery_anim_timer = NULL; +static lv_timer_t *battery_charge_timer = NULL; + +// Last-shown battery state, so an on-battery (not charging) header only repaints +// when the level/low/charging/present actually changes instead of every tick. +static int s_batt_soc_idx_last = -1; +static bool s_batt_low_last = false; +static bool s_batt_charging_last = false; +static bool s_batt_present_last = false; static const char *battery_paths[4] = { "/assets/icons/battery_1.bin", @@ -58,18 +360,39 @@ static const char *battery_paths[4] = { "/assets/icons/battery_4.bin", }; -static void header_wifi_status_timer_cb(lv_timer_t *timer) { - if (wifi_status_timer && wifi_img && !lv_obj_is_valid(wifi_img)) { - lv_timer_delete(timer); - wifi_status_timer = NULL; +// Static WiFi icon reflecting the real (cached) state: full when connected, a +// mid bar when on but not connected, empty when off. Never animates here. +static void update_wifi_icon_static(void) { + if (!wifi_img || !lv_obj_is_valid(wifi_img)) { + return; + } + if (!s_wifi_shown_last) { + lv_obj_add_flag(wifi_img, LV_OBJ_FLAG_HIDDEN); return; } - bool current_active = wifi_service_is_active(); - bool current_connected = wifi_service_is_connected(); + lv_obj_remove_flag(wifi_img, LV_OBJ_FLAG_HIDDEN); + int frame = s_wifi_connected_last ? 3 : 2; + if (wifi_dscs[frame]) { + lv_image_set_src(wifi_img, wifi_dscs[frame]); + } + lv_obj_set_style_opa(wifi_img, s_wifi_connected_last ? LV_OPA_COVER : LV_OPA_50, 0); +} - if (current_active != header_wifi_enabled || current_connected != header_wifi_connected) { - header_wifi_enabled = current_active; - header_wifi_connected = current_connected; +// Called from the status timer: refresh the icon only when the WiFi state +// changed, and never while a connection animation is running (it would fight it). +// wifi_service_is_active/is_connected are cheap cached reads on the P4, so this +// is not an SPI transaction. Replaces the old 2 Hz timer that wrote variables +// nobody read. +static void header_sync_wifi_icon(void) { + bool shown = g_config_wifi.enabled || wifi_service_is_active(); + bool connected = wifi_service_is_connected(); + if (shown == s_wifi_shown_last && connected == s_wifi_connected_last) { + return; + } + s_wifi_shown_last = shown; + s_wifi_connected_last = connected; + if (wifi_anim_timer == NULL) { + update_wifi_icon_static(); } } @@ -96,66 +419,135 @@ static void wifi_anim_timer_cb(lv_timer_t *timer) { } } -static void battery_anim_timer_cb(lv_timer_t *timer) { - if (!battery_img || !lv_obj_is_valid(battery_img)) { - lv_timer_delete(timer); - battery_anim_timer = NULL; - battery_img = NULL; - power_img = NULL; - return; +void header_ui_set_wifi_connecting(bool connecting) { + if (connecting) { + if (wifi_anim_timer == NULL && wifi_img && lv_obj_is_valid(wifi_img)) { + wifi_frame = 0; + wifi_dir = 1; + wifi_anim_timer = lv_timer_create(wifi_anim_timer_cb, WIFI_ANIM_MS, NULL); + } + } else { + if (wifi_anim_timer != NULL) { + lv_timer_delete(wifi_anim_timer); + wifi_anim_timer = NULL; + } + update_wifi_icon_static(); // settle on the real state } +} + +static void battery_charge_anim_cb(lv_timer_t *timer); + +static void battery_apply(void) { + if (!battery_cont || !lv_obj_is_valid(battery_cont)) + return; + + battery_snapshot_t bs; + if (!battery_service_get(&bs)) + return; - battery_frame += battery_dir; - if (battery_frame >= 3) { - battery_frame = 3; - battery_dir = -1; + // No charger on I2C: drop the whole cell so the flex row leaves no empty slot. + if (!bs.present) { + if (s_batt_present_last) { + lv_obj_add_flag(battery_cont, LV_OBJ_FLAG_HIDDEN); + s_batt_present_last = false; + } + return; } - if (battery_frame <= 0) { - battery_frame = 0; - battery_dir = 1; + if (!s_batt_present_last) { + lv_obj_remove_flag(battery_cont, LV_OBJ_FLAG_HIDDEN); + s_batt_present_last = true; } - if (battery_dscs[battery_frame]) { - lv_image_set_src(battery_img, battery_dscs[battery_frame]); + if (!battery_img || !lv_obj_is_valid(battery_img)) + return; + + int soc_idx; + if (bs.soc < 20) + soc_idx = 0; + else if (bs.soc < 45) + soc_idx = 1; + else if (bs.soc < 75) + soc_idx = 2; + else + soc_idx = 3; + + bool charging_changed = (bs.charging != s_batt_charging_last); + + if (bs.charging) { + if (charging_changed) + lv_obj_set_style_image_recolor_opa(battery_img, LV_OPA_TRANSP, 0); + if (battery_charge_timer == NULL) + battery_charge_timer = lv_timer_create(battery_charge_anim_cb, BATTERY_CHARGE_ANIM_MS, NULL); + } else { + if (battery_charge_timer != NULL) { + lv_timer_delete(battery_charge_timer); + battery_charge_timer = NULL; + } + // On battery: static frame. Only write when the shown level/low changed (or + // we just stopped charging), so an idle header stops forcing redraws. + if (charging_changed || soc_idx != s_batt_soc_idx_last) { + if (battery_dscs[soc_idx]) + lv_image_set_src(battery_img, battery_dscs[soc_idx]); + } + if (charging_changed || bs.low != s_batt_low_last) { + if (bs.low) { + lv_obj_set_style_image_recolor(battery_img, lv_color_hex(0xE53935), 0); + lv_obj_set_style_image_recolor_opa(battery_img, LV_OPA_70, 0); + } else { + lv_obj_set_style_image_recolor_opa(battery_img, LV_OPA_TRANSP, 0); + } + } } - if (power_img) { - if (battery_dir == 1) { + // Bolt shows ONLY while actually charging (standard status-bar behavior) — no + // bolt when merely plugged-and-idle, charge-done, or on battery. Toggle only on + // the charging transition. + if (charging_changed && power_img && lv_obj_is_valid(power_img)) { + if (bs.charging) lv_obj_remove_flag(power_img, LV_OBJ_FLAG_HIDDEN); - } else { + else lv_obj_add_flag(power_img, LV_OBJ_FLAG_HIDDEN); - } } -} -void header_ui_create(lv_obj_t *parent) { - lv_obj_t *header = lv_obj_create(parent); - lv_obj_set_size(header, lv_pct(100), HEADER_HEIGHT + 12); - lv_obj_align(header, LV_ALIGN_TOP_MID, 0, -12); - lv_obj_remove_flag(header, LV_OBJ_FLAG_SCROLLABLE); - - lv_obj_set_style_radius(header, 12, 0); - lv_obj_set_style_border_width(header, 0, 0); - lv_obj_set_style_pad_all(header, 0, 0); - - lv_obj_set_style_bg_opa(header, LV_OPA_COVER, 0); - lv_obj_set_style_bg_color(header, current_theme.bg_primary, 0); - lv_obj_set_style_bg_grad_color(header, current_theme.bg_secondary, 0); - lv_obj_set_style_bg_grad_dir(header, LV_GRAD_DIR_HOR, 0); + s_batt_soc_idx_last = soc_idx; + s_batt_low_last = bs.low; + s_batt_charging_last = bs.charging; +} - if (!inter_font) { - inter_font = lv_binfont_create("A:assets/fonts/Inter.bin"); +static void battery_charge_anim_cb(lv_timer_t *timer) { + if (!battery_img || !lv_obj_is_valid(battery_img)) { + lv_timer_delete(timer); + battery_charge_timer = NULL; + return; } + battery_frame = (battery_frame + 1) & 3; + if (battery_dscs[battery_frame]) + lv_image_set_src(battery_img, battery_dscs[battery_frame]); +} - lv_obj_t *lbl_time = lv_label_create(header); - lv_label_set_text(lbl_time, "12:00"); - lv_obj_set_style_text_color(lbl_time, current_theme.text_main, 0); - lv_obj_set_style_text_font(lbl_time, inter_font ? inter_font : &lv_font_montserrat_12, 0); - lv_obj_align(lbl_time, LV_ALIGN_LEFT_MID, 6, 6); +// Safety net: when a status cluster is deleted (its screen/overlay is freed), null +// any global pointer that still belongs to it so the singleton timers never touch +// freed memory. A newer header may have already rebound the globals to a different +// container — then the parent check fails and we correctly leave them intact. +static void header_status_del_cb(lv_event_t *e) { + lv_obj_t *cont = lv_event_get_target(e); + if (wifi_img && lv_obj_get_parent(wifi_img) == cont) + wifi_img = NULL; + if (bt_img_ref && lv_obj_get_parent(bt_img_ref) == cont) + bt_img_ref = NULL; + if (card_img_ref && lv_obj_get_parent(card_img_ref) == cont) + card_img_ref = NULL; + if (battery_cont && lv_obj_get_parent(battery_cont) == cont) { + battery_cont = NULL; + battery_img = NULL; + power_img = NULL; + } +} - lv_obj_t *icon_cont = lv_obj_create(header); +void header_ui_attach_status(lv_obj_t *parent, int y_offset) { + lv_obj_t *icon_cont = lv_obj_create(parent); lv_obj_set_size(icon_cont, LV_SIZE_CONTENT, LV_SIZE_CONTENT); - lv_obj_align(icon_cont, LV_ALIGN_RIGHT_MID, -6, 6); + lv_obj_align(icon_cont, LV_ALIGN_RIGHT_MID, -6, y_offset); lv_obj_set_flex_flow(icon_cont, LV_FLEX_FLOW_ROW); lv_obj_set_flex_align( icon_cont, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); @@ -163,66 +555,214 @@ void header_ui_create(lv_obj_t *parent) { lv_obj_set_style_pad_all(icon_cont, 0, 0); lv_obj_set_style_bg_opa(icon_cont, LV_OPA_TRANSP, 0); lv_obj_set_style_border_width(icon_cont, 0, 0); - - static lv_image_dsc_t *bt_icon_dsc = NULL; - static lv_image_dsc_t *card_icon_dsc = NULL; + lv_obj_add_event_cb(icon_cont, header_status_del_cb, LV_EVENT_DELETE, NULL); for (int i = 0; i < 4; i++) { if (!wifi_dscs[i]) wifi_dscs[i] = assets_get(wifi_paths[i]); } - if (!bt_icon_dsc) - bt_icon_dsc = assets_get("/assets/icons/bluetooth_icon.bin"); - if (!card_icon_dsc) - card_icon_dsc = assets_get("/assets/icons/card_icon.bin"); wifi_img = lv_image_create(icon_cont); if (wifi_dscs[0]) lv_image_set_src(wifi_img, wifi_dscs[0]); - lv_obj_t *bt_img = lv_image_create(icon_cont); - if (bt_icon_dsc) - lv_image_set_src(bt_img, bt_icon_dsc); + lv_obj_t *bt_img = lv_label_create(icon_cont); + lv_label_set_text(bt_img, LV_SYMBOL_BLUETOOTH); + lv_obj_set_style_text_font(bt_img, &lv_font_montserrat_14, 0); + lv_obj_set_style_text_color(bt_img, current_theme.text_main, 0); + bt_img_ref = bt_img; + + lv_obj_t *card_img = lv_label_create(icon_cont); + lv_label_set_text(card_img, LV_SYMBOL_SD_CARD); + lv_obj_set_style_text_font(card_img, &lv_font_montserrat_14, 0); + lv_obj_set_style_text_color(card_img, current_theme.text_main, 0); + card_img_ref = card_img; + + sd_cd_ensure_configured(); + set_card_icon_shown(s_sd_mounted); + s_card_shown_last = s_sd_mounted; + s_bt_tint_last = bluetooth_service_is_running_cached(); + s_ble_active = g_config_ble.enabled || s_bt_tint_last; + if (s_ble_active) { + lv_obj_remove_flag(bt_img_ref, LV_OBJ_FLAG_HIDDEN); + apply_active_tint(bt_img_ref, s_bt_tint_last); + } else { + lv_obj_add_flag(bt_img_ref, LV_OBJ_FLAG_HIDDEN); + } - lv_obj_t *card_img = lv_image_create(icon_cont); - if (card_icon_dsc) - lv_image_set_src(card_img, card_icon_dsc); + if (header_poll_timer == NULL) { + header_poll_timer = lv_timer_create(header_poll_cb, STATUS_POLL_MS, NULL); + } for (int i = 0; i < 4; i++) { if (!battery_dscs[i]) battery_dscs[i] = assets_get(battery_paths[i]); } - if (!power_icon_dsc) - power_icon_dsc = assets_get("/assets/icons/power_icon.bin"); lv_obj_t *bat_cont = lv_obj_create(icon_cont); lv_obj_set_size(bat_cont, LV_SIZE_CONTENT, LV_SIZE_CONTENT); lv_obj_set_style_pad_all(bat_cont, 0, 0); lv_obj_set_style_bg_opa(bat_cont, LV_OPA_TRANSP, 0); lv_obj_set_style_border_width(bat_cont, 0, 0); + battery_cont = bat_cont; + // Start hidden so it doesn't flash before the first battery poll; the timer + // reveals it only when the charger actually answers on I2C. + lv_obj_add_flag(bat_cont, LV_OBJ_FLAG_HIDDEN); battery_img = lv_image_create(bat_cont); - if (battery_dscs[0]) - lv_image_set_src(battery_img, battery_dscs[0]); + if (battery_dscs[2]) + lv_image_set_src(battery_img, battery_dscs[2]); lv_obj_center(battery_img); - power_img = lv_image_create(bat_cont); - if (power_icon_dsc) - lv_image_set_src(power_img, power_icon_dsc); + power_img = lv_label_create(bat_cont); + lv_label_set_text(power_img, LV_SYMBOL_CHARGE); + lv_obj_set_style_text_font(power_img, &lv_font_montserrat_14, 0); + lv_obj_set_style_text_color(power_img, lv_color_white(), 0); lv_obj_center(power_img); + lv_obj_add_flag(power_img, LV_OBJ_FLAG_HIDDEN); - if (wifi_anim_timer == NULL) { - wifi_anim_timer = lv_timer_create(wifi_anim_timer_cb, 800, NULL); + s_wifi_shown_last = g_config_wifi.enabled || wifi_service_is_active(); + s_wifi_connected_last = wifi_service_is_connected(); + update_wifi_icon_static(); + + s_batt_present_last = false; + s_batt_soc_idx_last = -1; + s_batt_low_last = false; + s_batt_charging_last = false; + battery_apply(); +} + +void header_ui_create(lv_obj_t *parent) { + lv_obj_t *header = lv_obj_create(parent); + lv_obj_set_size(header, lv_pct(100), HEADER_HEIGHT + 12); + lv_obj_align(header, LV_ALIGN_TOP_MID, 0, -12); + lv_obj_remove_flag(header, LV_OBJ_FLAG_SCROLLABLE); + + lv_obj_set_style_radius(header, 12, 0); + lv_obj_set_style_border_width(header, 0, 0); + lv_obj_set_style_pad_all(header, 0, 0); + + lv_obj_set_style_bg_opa(header, LV_OPA_COVER, 0); + lv_obj_set_style_bg_color(header, current_theme.bg_primary, 0); + lv_obj_set_style_bg_grad_dir(header, LV_GRAD_DIR_NONE, 0); + + if (!inter_font) { + inter_font = lv_binfont_create("A:assets/fonts/Inter.bin"); + } + + s_lbl_time = lv_label_create(header); + lv_obj_add_event_cb(s_lbl_time, header_time_del_cb, LV_EVENT_DELETE, NULL); + lv_obj_set_style_text_color(s_lbl_time, current_theme.text_main, 0); + lv_obj_set_style_text_font(s_lbl_time, inter_font ? inter_font : &lv_font_montserrat_12, 0); + lv_obj_align(s_lbl_time, LV_ALIGN_LEFT_MID, 6, 6); + header_set_clock_label(s_lbl_time); + + // Home/menu full header: the status cluster is drawn 6px lower to sit on the + // bar's visual center (the bar is created with a -12 top inset). + header_ui_attach_status(header, 6); +} + +// Static status snapshot: paints the icons at the CURRENT state, but binds NO +// globals and registers NO timers. For transient overlays / temp screens drawn +// over a live screen — they must not rebind the dynamic header (which would dangle +// the globals when the overlay is freed and freeze the screen underneath). It just +// doesn't animate, which is fine for a brief overlay. +void header_ui_attach_status_snapshot(lv_obj_t *parent, int y_offset) { + lv_obj_t *icon_cont = lv_obj_create(parent); + lv_obj_set_size(icon_cont, LV_SIZE_CONTENT, LV_SIZE_CONTENT); + lv_obj_align(icon_cont, LV_ALIGN_RIGHT_MID, -6, y_offset); + lv_obj_set_flex_flow(icon_cont, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align( + icon_cont, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_set_style_pad_column(icon_cont, 10, 0); + lv_obj_set_style_pad_all(icon_cont, 0, 0); + lv_obj_set_style_bg_opa(icon_cont, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(icon_cont, 0, 0); + + for (int i = 0; i < 4; i++) { + if (!wifi_dscs[i]) + wifi_dscs[i] = assets_get(wifi_paths[i]); } + bool wifi_on = g_config_wifi.enabled || wifi_service_is_active(); + bool wifi_conn = wifi_service_is_connected(); + lv_obj_t *w = lv_image_create(icon_cont); + if (wifi_dscs[wifi_conn ? 3 : 2]) + lv_image_set_src(w, wifi_dscs[wifi_conn ? 3 : 2]); + lv_obj_set_style_opa(w, wifi_conn ? LV_OPA_COVER : LV_OPA_50, 0); + if (!wifi_on) + lv_obj_add_flag(w, LV_OBJ_FLAG_HIDDEN); + + lv_obj_t *bt = lv_label_create(icon_cont); + lv_label_set_text(bt, LV_SYMBOL_BLUETOOTH); + lv_obj_set_style_text_font(bt, &lv_font_montserrat_14, 0); + lv_obj_set_style_text_color(bt, current_theme.text_main, 0); + bool ble_run = bluetooth_service_is_running_cached(); + if (g_config_ble.enabled || ble_run) + apply_active_tint(bt, ble_run); + else + lv_obj_add_flag(bt, LV_OBJ_FLAG_HIDDEN); + + lv_obj_t *sd = lv_label_create(icon_cont); + lv_label_set_text(sd, LV_SYMBOL_SD_CARD); + lv_obj_set_style_text_font(sd, &lv_font_montserrat_14, 0); + lv_obj_set_style_text_color(sd, current_theme.text_main, 0); + if (!s_sd_mounted) + lv_obj_add_flag(sd, LV_OBJ_FLAG_HIDDEN); - if (battery_anim_timer == NULL) { - battery_anim_timer = lv_timer_create(battery_anim_timer_cb, 800, NULL); + for (int i = 0; i < 4; i++) { + if (!battery_dscs[i]) + battery_dscs[i] = assets_get(battery_paths[i]); + } + battery_snapshot_t bs; + bool present = battery_service_get(&bs) && bs.present; + + lv_obj_t *bcont = lv_obj_create(icon_cont); + lv_obj_set_size(bcont, LV_SIZE_CONTENT, LV_SIZE_CONTENT); + lv_obj_set_style_pad_all(bcont, 0, 0); + lv_obj_set_style_bg_opa(bcont, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(bcont, 0, 0); + if (!present) + lv_obj_add_flag(bcont, LV_OBJ_FLAG_HIDDEN); + + int soc_idx = present ? (bs.soc < 20 ? 0 : bs.soc < 45 ? 1 : bs.soc < 75 ? 2 : 3) : 2; + lv_obj_t *bimg = lv_image_create(bcont); + if (battery_dscs[soc_idx]) + lv_image_set_src(bimg, battery_dscs[soc_idx]); + lv_obj_center(bimg); + if (present && bs.low && !bs.charging) { + lv_obj_set_style_image_recolor(bimg, lv_color_hex(0xE53935), 0); + lv_obj_set_style_image_recolor_opa(bimg, LV_OPA_70, 0); } - header_wifi_enabled = wifi_service_is_active(); - header_wifi_connected = wifi_service_is_connected(); + lv_obj_t *pimg = lv_label_create(bcont); + lv_label_set_text(pimg, LV_SYMBOL_CHARGE); + lv_obj_set_style_text_font(pimg, &lv_font_montserrat_14, 0); + lv_obj_set_style_text_color(pimg, lv_color_white(), 0); + lv_obj_center(pimg); + if (!(present && bs.charging)) + lv_obj_add_flag(pimg, LV_OBJ_FLAG_HIDDEN); +} - if (wifi_status_timer == NULL) { - wifi_status_timer = lv_timer_create(header_wifi_status_timer_cb, 500, NULL); +void header_ui_create_snapshot(lv_obj_t *parent) { + lv_obj_t *header = lv_obj_create(parent); + lv_obj_set_size(header, lv_pct(100), HEADER_HEIGHT + 12); + lv_obj_align(header, LV_ALIGN_TOP_MID, 0, -12); + lv_obj_remove_flag(header, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_radius(header, 12, 0); + lv_obj_set_style_border_width(header, 0, 0); + lv_obj_set_style_pad_all(header, 0, 0); + lv_obj_set_style_bg_opa(header, LV_OPA_COVER, 0); + lv_obj_set_style_bg_color(header, current_theme.bg_primary, 0); + lv_obj_set_style_bg_grad_dir(header, LV_GRAD_DIR_NONE, 0); + + if (!inter_font) { + inter_font = lv_binfont_create("A:assets/fonts/Inter.bin"); } + lv_obj_t *lbl_time = lv_label_create(header); + lv_obj_set_style_text_color(lbl_time, current_theme.text_main, 0); + lv_obj_set_style_text_font(lbl_time, inter_font ? inter_font : &lv_font_montserrat_12, 0); + lv_obj_align(lbl_time, LV_ALIGN_LEFT_MID, 6, 6); + header_set_clock_label(lbl_time); + + header_ui_attach_status_snapshot(header, 6); } diff --git a/firmware_p4/components/Applications/ui/components/header/include/header_ui.h b/firmware_p4/components/Applications/ui/components/header/include/header_ui.h index e5d924c28..36353e245 100644 --- a/firmware_p4/components/Applications/ui/components/header/include/header_ui.h +++ b/firmware_p4/components/Applications/ui/components/header/include/header_ui.h @@ -20,11 +20,83 @@ extern "C" { #endif +#include + #include "lvgl.h" /** @brief Create the header bar on the given parent. */ void header_ui_create(lv_obj_t *parent); +/** + * @brief Attach the shared status cluster (wifi/bt/sd/battery) to @p parent. + * + * Builds the exact same icons + starts the exact same singleton status timers + * the home/menu header uses, right-aligned inside @p parent. Reused by the + * per-screen chrome header so every screen shows the identical status bar with + * identical behavior (charging animation, SD mount, BLE tint, ...). + * + * @param parent Bar object to attach the cluster to. + * @param y_offset Vertical nudge to the parent's visual center (home's bar is + * drawn with a negative top inset -> 6; the chrome bar -> 0). + */ +void header_ui_attach_status(lv_obj_t *parent, int y_offset); + +/** + * @brief Static one-shot version of the status cluster (no globals, no timers). + * + * Paints wifi/bt/sd/battery at the CURRENT state but does not bind the shared + * statics nor register the animation timers, so it never dangles when torn down. + * For transient overlays drawn over a live screen. See header_ui_create_snapshot. + */ +void header_ui_attach_status_snapshot(lv_obj_t *parent, int y_offset); + +/** + * @brief Full home-style status bar, but STATIC (snapshot cluster, no timers). + * + * Same visual as header_ui_create; use on a transient overlay so it does not + * rebind/dangle the dynamic header of the screen underneath. + */ +void header_ui_create_snapshot(lv_obj_t *parent); + +/** + * @brief Animate the WiFi icon while a connection is in progress. + * + * Call with true when a connect attempt starts and false when it finishes + * (connected or failed). While false the icon is static and reflects the real + * WiFi state; there is no perpetual animation. Safe to call from the UI thread. + */ +void header_ui_set_wifi_connecting(bool connecting); + +/** + * @brief Report the cached SD card usage, computed off the LVGL task. + * + * Reads a value cached by the header's mount worker, so callers (e.g. the + * home dropdown) avoid a blocking filesystem query on the LVGL task. + * + * @param[out] out_used_pct Used space percentage (0-100); may be NULL. + * @return true if a card is currently mounted/recognized. + */ +bool header_ui_sd_usage(int *out_used_pct); + +/** + * @brief Request the SD card mount owner (the card-detect worker) to remount. + * + * Routes a remount through the single task that owns the SD mount, so a health + * check failing elsewhere (e.g. the system monitor) never remounts concurrently + * with the hotplug worker. Safe to call from any task; no-op if the worker is + * not up yet. + */ +void header_ui_request_sd_remount(void); + +/** + * @brief Software-eject the SD card: unmount it and hide the header indicator. + * + * Call from the LVGL thread. The card stays unmounted (the card-detect worker is + * edge-driven, so it will not auto-remount a still-inserted card) until it is + * physically reinserted or header_ui_request_sd_remount() is called. + */ +void header_ui_sd_eject(void); + #ifdef __cplusplus } #endif diff --git a/firmware_p4/components/Applications/ui/components/keyboard/include/keyboard_ui.h b/firmware_p4/components/Applications/ui/components/keyboard/include/keyboard_ui.h index 45385581b..a63785fb9 100644 --- a/firmware_p4/components/Applications/ui/components/keyboard/include/keyboard_ui.h +++ b/firmware_p4/components/Applications/ui/components/keyboard/include/keyboard_ui.h @@ -20,8 +20,16 @@ extern "C" { #endif +#include + #include "lvgl.h" +/** + * @brief Callback invoked when the on-screen keyboard submits its text. + * + * @param text The submitted text. Valid only during the callback scope. + * @param user_data User context passed to keyboard_open(). + */ typedef void (*keyboard_submit_cb_t)(const char *text, void *user_data); /** @brief Open the on-screen keyboard. */ @@ -30,6 +38,9 @@ void keyboard_open(lv_obj_t *target_textarea, keyboard_submit_cb_t cb, void *use /** @brief Close the on-screen keyboard. */ void keyboard_close(void); +/** @brief Whether the on-screen keyboard overlay is currently shown. */ +bool keyboard_is_open(void); + #ifdef __cplusplus } #endif diff --git a/firmware_p4/components/Applications/ui/components/keyboard/keyboard_ui.c b/firmware_p4/components/Applications/ui/components/keyboard/keyboard_ui.c index 5c7cebf85..da8c9301e 100644 --- a/firmware_p4/components/Applications/ui/components/keyboard/keyboard_ui.c +++ b/firmware_p4/components/Applications/ui/components/keyboard/keyboard_ui.c @@ -34,9 +34,10 @@ #define KB_BTN_FOCUS current_theme.border_accent #define KB_TA_BG current_theme.screen_base -#define OUTER_BORDER 4 -#define TOP_BORDER_H 46 -#define KB_H 160 +#define OUTER_BORDER 4 +#define TOP_BORDER_H 46 +#define KB_H 184 +#define KB_TEXT_BUF_SIZE 65 static lv_obj_t *kb_screen = NULL; static lv_obj_t *kb_obj = NULL; @@ -56,7 +57,7 @@ static void kb_event_cb(lv_event_t *e) { const char *txt = lv_keyboard_get_btn_text(target_kb, btn_id); if (txt && (strcmp(txt, LV_SYMBOL_OK) == 0 || strcmp(txt, "Enter") == 0)) { - char text_buf[65]; + char text_buf[KB_TEXT_BUF_SIZE]; const char *input = lv_textarea_get_text(kb_ta); if (input) { strncpy(text_buf, input, sizeof(text_buf) - 1); @@ -112,23 +113,22 @@ void keyboard_open(lv_obj_t *target_textarea, keyboard_submit_cb_t cb, void *use lv_obj_remove_flag(title_bar, LV_OBJ_FLAG_SCROLLABLE); lv_obj_set_style_radius(title_bar, 12, 0); lv_obj_set_style_bg_opa(title_bar, LV_OPA_COVER, 0); - lv_obj_set_style_bg_color(title_bar, current_theme.border_interface, 0); - lv_obj_set_style_bg_grad_color(title_bar, current_theme.bg_secondary, 0); - lv_obj_set_style_bg_grad_dir(title_bar, LV_GRAD_DIR_HOR, 0); + lv_obj_set_style_bg_color(title_bar, current_theme.bg_primary, 0); + lv_obj_set_style_bg_grad_dir(title_bar, LV_GRAD_DIR_NONE, 0); lv_obj_set_style_border_width(title_bar, 2, 0); lv_obj_set_style_border_color(title_bar, ITEM_BORDER, 0); lv_obj_t *title_lbl = lv_label_create(title_bar); - lv_label_set_text(title_lbl, "KEYBOARD"); - lv_obj_set_style_text_color(title_lbl, current_theme.text_main, 0); - lv_obj_set_style_text_font(title_lbl, &lv_font_montserrat_12, 0); + lv_label_set_text(title_lbl, "[ KEYBOARD ]"); + lv_obj_set_style_text_color(title_lbl, current_theme.border_accent, 0); + lv_obj_set_style_text_font(title_lbl, &lv_font_montserrat_14, 0); lv_obj_center(title_lbl); int ta_y = TOP_BORDER_H + 10; int ta_h = LCD_V_RES - TOP_BORDER_H - KB_H - OUTER_BORDER - 20; kb_ta = lv_textarea_create(kb_screen); - lv_obj_set_size(kb_ta, LCD_H_RES - OUTER_BORDER * 2 - 20, ta_h > 60 ? 40 : 30); + lv_obj_set_size(kb_ta, LCD_H_RES - OUTER_BORDER * 2 - 4, ta_h > 60 ? 40 : 30); lv_obj_align(kb_ta, LV_ALIGN_TOP_MID, 0, ta_y + (ta_h - 40) / 2); lv_textarea_set_password_mode(kb_ta, false); lv_textarea_set_placeholder_text(kb_ta, "TYPE HERE..."); @@ -149,30 +149,27 @@ void keyboard_open(lv_obj_t *target_textarea, keyboard_submit_cb_t cb, void *use lv_obj_align(kb_obj, LV_ALIGN_BOTTOM_MID, 0, -OUTER_BORDER - 2); lv_keyboard_set_mode(kb_obj, LV_KEYBOARD_MODE_TEXT_LOWER); - lv_obj_set_style_bg_color(kb_obj, KB_BG_TOP, 0); - lv_obj_set_style_bg_grad_color(kb_obj, KB_BG_BOT, 0); - lv_obj_set_style_bg_grad_dir(kb_obj, LV_GRAD_DIR_VER, 0); + lv_obj_set_style_bg_color(kb_obj, current_theme.bg_primary, 0); + lv_obj_set_style_bg_grad_dir(kb_obj, LV_GRAD_DIR_NONE, 0); lv_obj_set_style_bg_opa(kb_obj, LV_OPA_COVER, 0); lv_obj_set_style_border_width(kb_obj, 2, 0); lv_obj_set_style_border_color(kb_obj, BORDER_COLOR, 0); lv_obj_set_style_radius(kb_obj, 12, 0); - lv_obj_set_style_pad_all(kb_obj, 6, 0); - lv_obj_set_style_pad_gap(kb_obj, 4, 0); + lv_obj_set_style_pad_all(kb_obj, 5, 0); + lv_obj_set_style_pad_gap(kb_obj, 5, 0); - lv_obj_set_style_bg_color(kb_obj, KB_BTN_BG, LV_PART_ITEMS); - lv_obj_set_style_bg_grad_color(kb_obj, KB_BTN_GRAD, LV_PART_ITEMS); - lv_obj_set_style_bg_grad_dir(kb_obj, LV_GRAD_DIR_VER, LV_PART_ITEMS); + lv_obj_set_style_bg_color(kb_obj, current_theme.bg_secondary, LV_PART_ITEMS); + lv_obj_set_style_bg_grad_dir(kb_obj, LV_GRAD_DIR_NONE, LV_PART_ITEMS); lv_obj_set_style_bg_opa(kb_obj, LV_OPA_COVER, LV_PART_ITEMS); lv_obj_set_style_border_width(kb_obj, 1, LV_PART_ITEMS); lv_obj_set_style_border_color(kb_obj, KB_BTN_BORDER, LV_PART_ITEMS); - lv_obj_set_style_radius(kb_obj, 8, LV_PART_ITEMS); + lv_obj_set_style_radius(kb_obj, 6, LV_PART_ITEMS); lv_obj_set_style_text_color(kb_obj, current_theme.text_main, LV_PART_ITEMS); - lv_obj_set_style_text_font(kb_obj, &lv_font_montserrat_12, LV_PART_ITEMS); + lv_obj_set_style_text_font(kb_obj, &lv_font_montserrat_16, LV_PART_ITEMS); - lv_obj_set_style_bg_color(kb_obj, KB_BTN_FOCUS, LV_PART_ITEMS | LV_STATE_FOCUS_KEY); - lv_obj_set_style_bg_grad_color( + lv_obj_set_style_bg_color( kb_obj, current_theme.border_accent, LV_PART_ITEMS | LV_STATE_FOCUS_KEY); - lv_obj_set_style_bg_grad_dir(kb_obj, LV_GRAD_DIR_VER, LV_PART_ITEMS | LV_STATE_FOCUS_KEY); + lv_obj_set_style_bg_grad_dir(kb_obj, LV_GRAD_DIR_NONE, LV_PART_ITEMS | LV_STATE_FOCUS_KEY); lv_obj_set_style_border_color( kb_obj, current_theme.border_accent, LV_PART_ITEMS | LV_STATE_FOCUS_KEY); lv_obj_set_style_border_width(kb_obj, 2, LV_PART_ITEMS | LV_STATE_FOCUS_KEY); @@ -192,6 +189,10 @@ void keyboard_open(lv_obj_t *target_textarea, keyboard_submit_cb_t cb, void *use } } +bool keyboard_is_open(void) { + return kb_screen != NULL; +} + void keyboard_close(void) { if (kb_screen) { if (main_group) { diff --git a/firmware_p4/components/Applications/ui/components/menu_component/include/menu_component_ui.h b/firmware_p4/components/Applications/ui/components/menu_component/include/menu_component_ui.h index b06ba18a4..d13c7b4f8 100644 --- a/firmware_p4/components/Applications/ui/components/menu_component/include/menu_component_ui.h +++ b/firmware_p4/components/Applications/ui/components/menu_component/include/menu_component_ui.h @@ -25,15 +25,25 @@ extern "C" { #include "toggle_ui.h" #include "intensity_bar_ui.h" -#define MENU_COMP_MAX_ITEMS 12 +/** @brief Maximum number of items a menu can hold. */ +#define MENU_COMP_MAX_ITEMS 20 +/** @brief Height in px of the persistent action/hint footer drawn at the bottom of every menu. */ +#define MENU_COMP_FOOTER_H 22 + +/** + * @brief State and widget handles for a full menu screen. + */ typedef struct { lv_obj_t *screen; lv_obj_t *title_bar; lv_obj_t *title_label; lv_obj_t *items_cont; lv_obj_t *items[MENU_COMP_MAX_ITEMS]; + lv_obj_t *scroll_track; lv_obj_t *scroll_bar; + lv_obj_t *footer; ///< persistent action/hint bar at the bottom + lv_obj_t *hint_label; ///< centered text inside the footer lv_obj_t *sel_dots[MENU_COMP_MAX_ITEMS]; lv_obj_t *val_labels[MENU_COMP_MAX_ITEMS]; toggle_ui_t toggles[MENU_COMP_MAX_ITEMS]; @@ -53,6 +63,13 @@ menu_component_create(lv_obj_t *parent, const char *title, const char *title_ico /** @brief Add a menu item. Returns the item object for customization. */ lv_obj_t *menu_component_add_item(menu_component_t *menu, const char *icon_path, const char *label); +/** + * @brief Add a centered, non-selectable group header (e.g. "Sound & Vibration") + * into the list. Navigation skips it; it just visually groups the items + * added after it. Call it before the items that belong to the group. + */ +void menu_component_add_section(menu_component_t *menu, const char *title); + /** @brief Add a selector item with left/right value navigation. */ lv_obj_t *menu_component_add_selector(menu_component_t *menu, const char *icon_path, @@ -104,6 +121,19 @@ void menu_component_prev(menu_component_t *menu); /** @brief Get the currently selected menu item index. */ int menu_component_get_selected(menu_component_t *menu); +/** + * @brief Recolour the label text of a specific menu item. Used by the + * Wi-Fi scan screen to render scanned SSIDs in green so they + * read as "captured" rather than just menu rows. + */ +void menu_component_set_item_label_color(menu_component_t *menu, int index, lv_color_t color); + +/** + * @brief Override the footer hint text (e.g. "LEFT/RIGHT change OK toggle"). + * The component shows a sensible default; call this to specialize it. + */ +void menu_component_set_hint(menu_component_t *menu, const char *text); + #ifdef __cplusplus } #endif diff --git a/firmware_p4/components/Applications/ui/components/menu_component/menu_component_ui.c b/firmware_p4/components/Applications/ui/components/menu_component/menu_component_ui.c index 246af12a7..4aea79cad 100644 --- a/firmware_p4/components/Applications/ui/components/menu_component/menu_component_ui.c +++ b/firmware_p4/components/Applications/ui/components/menu_component/menu_component_ui.c @@ -19,35 +19,90 @@ #include "st7789.h" #include "assets_manager.h" +#include "ui_chrome.h" +#include "ui_feedback.h" #include "ui_theme.h" -#define BORDER_COLOR current_theme.border_interface -#define ITEM_BORDER current_theme.border_accent -#define GRAD_LEFT current_theme.bg_primary -#define GRAD_RIGHT current_theme.bg_secondary -#define SEL_BORDER current_theme.border_accent -#define SEL_DOT_COLOR current_theme.border_accent - -#define TITLE_W 170 -#define TITLE_H 30 -#define ITEM_W 210 -#define ITEM_H 47 +#define HEADER_BG current_theme.bg_secondary +#define HEADER_LINE current_theme.border_accent +#define FOOTER_LINE current_theme.border_interface +#define TITLE_COLOR current_theme.border_accent +#define ITEM_BG current_theme.bg_secondary +#define ITEM_BORDER current_theme.border_inactive +#define SEL_BORDER current_theme.border_accent + +#define HEADER_H 46 // a touch taller so the submenu breadcrumb label has room below the header +#define FOOTER_H MENU_COMP_FOOTER_H +#define ITEM_H 44 +#define ITEM_GAP 6 +#define ICON_CELL 26 +#define LEFT_MARGIN 6 +#define RIGHT_GUTTER 16 +#define ITEMS_Y (HEADER_H + 4) #define OUTER_BORDER 4 -#define TOP_BORDER_H (TITLE_H + 16) -#define SEL_DOT_SIZE 8 +#define THUMB_FALLBACK_H 45 +#define SCROLL_ANIM_MS 200 +#define OVERFLOW_SLOP_PX 2 -static lv_font_t *menu_font = NULL; +#define DEFAULT_HINT \ + LV_SYMBOL_UP LV_SYMBOL_DOWN " Nav " LV_SYMBOL_OK " OK " LV_SYMBOL_LEFT " Back" + +static lv_obj_t *make_icon(lv_obj_t *parent, const char *icon_path) { + if (!icon_path) + return NULL; + lv_image_dsc_t *dsc = assets_get(icon_path); + if (!dsc) + return NULL; + lv_obj_t *img = lv_image_create(parent); + lv_image_set_src(img, dsc); + lv_obj_set_size(img, ICON_CELL, ICON_CELL); + lv_image_set_inner_align(img, LV_IMAGE_ALIGN_CONTAIN); + return img; +} + +static bool list_overflows(menu_component_t *m) { + if (!m || !m->items_cont) + return false; + lv_obj_update_layout(m->items_cont); + int32_t st = lv_obj_get_scroll_top(m->items_cont); + int32_t sb = lv_obj_get_scroll_bottom(m->items_cont); + return (st + sb) > OVERFLOW_SLOP_PX; +} + +static void update_scroll_state(menu_component_t *m) { + if (!m || !m->items_cont) + return; + bool overflow = list_overflows(m); + if (m->scroll_track) + lv_obj_remove_flag(m->scroll_track, LV_OBJ_FLAG_HIDDEN); + if (m->scroll_bar) + lv_obj_remove_flag(m->scroll_bar, LV_OBJ_FLAG_HIDDEN); + if (overflow) { + lv_obj_add_flag(m->items_cont, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_scroll_snap_y(m->items_cont, LV_SCROLL_SNAP_NONE); + } else { + lv_obj_remove_flag(m->items_cont, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_scroll_snap_y(m->items_cont, LV_SCROLL_SNAP_NONE); + lv_obj_scroll_to_y(m->items_cont, 0, LV_ANIM_OFF); + } +} static void update_scroll_bar(menu_component_t *m) { if (!m->scroll_bar || m->item_count <= 1) return; - int32_t pos = m->track_y_start + (m->selected * (m->track_h - 20)) / (m->item_count - 1); + int32_t thumb_h = lv_obj_get_height(m->scroll_bar); + if (thumb_h <= 0) + thumb_h = THUMB_FALLBACK_H; + int32_t travel = m->track_h - thumb_h; + if (travel < 0) + travel = 0; + int32_t pos = m->track_y_start + (m->selected * travel) / (m->item_count - 1); lv_anim_t a; lv_anim_init(&a); lv_anim_set_var(&a, m->scroll_bar); lv_anim_set_values(&a, lv_obj_get_y(m->scroll_bar), pos); - lv_anim_set_duration(&a, 200); + lv_anim_set_duration(&a, SCROLL_ANIM_MS); lv_anim_set_path_cb(&a, lv_anim_path_ease_in_out); lv_anim_set_exec_cb(&a, (lv_anim_exec_xcb_t)lv_obj_set_y); lv_anim_start(&a); @@ -57,7 +112,6 @@ static void update_selection(menu_component_t *m) { for (int i = 0; i < m->item_count; i++) { if (i == m->selected) { lv_obj_set_style_border_color(m->items[i], SEL_BORDER, 0); - lv_obj_set_style_border_width(m->items[i], 3, 0); bool has_widget = m->has_toggle[i] || m->has_intensity[i] || m->val_labels[i]; if (m->sel_dots[i]) { if (has_widget) @@ -67,13 +121,12 @@ static void update_selection(menu_component_t *m) { } } else { lv_obj_set_style_border_color(m->items[i], ITEM_BORDER, 0); - lv_obj_set_style_border_width(m->items[i], 1, 0); if (m->sel_dots[i]) lv_obj_add_flag(m->sel_dots[i], LV_OBJ_FLAG_HIDDEN); } } - if (m->items[m->selected]) { + if (m->items[m->selected] && list_overflows(m)) { lv_obj_scroll_to_view(m->items[m->selected], LV_ANIM_ON); } @@ -83,10 +136,7 @@ static void update_selection(menu_component_t *m) { menu_component_t menu_component_create(lv_obj_t *parent, const char *title, const char *title_icon_path) { menu_component_t m = {0}; - - if (!menu_font) { - menu_font = lv_binfont_create("A:assets/fonts/Inter.bin"); - } + (void)title_icon_path; // the shared status header carries its own icons now m.screen = lv_obj_create(parent); lv_obj_set_size(m.screen, LCD_H_RES, LCD_V_RES); @@ -95,68 +145,44 @@ menu_component_create(lv_obj_t *parent, const char *title, const char *title_ico lv_obj_set_style_bg_color(m.screen, current_theme.screen_base, 0); lv_obj_set_style_bg_opa(m.screen, LV_OPA_COVER, 0); lv_obj_set_style_pad_all(m.screen, 0, 0); - - lv_obj_set_style_border_width(m.screen, OUTER_BORDER, 0); - lv_obj_set_style_border_color(m.screen, BORDER_COLOR, 0); + lv_obj_set_style_border_width(m.screen, 0, 0); lv_obj_set_style_radius(m.screen, 0, 0); - lv_obj_t *top_area = lv_obj_create(m.screen); - lv_obj_set_size(top_area, LCD_H_RES - OUTER_BORDER * 2, TOP_BORDER_H); - lv_obj_align(top_area, LV_ALIGN_TOP_MID, 0, 0); - lv_obj_remove_flag(top_area, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_set_style_bg_opa(top_area, LV_OPA_TRANSP, 0); - lv_obj_set_style_border_width(top_area, 3, 0); - lv_obj_set_style_border_color(top_area, BORDER_COLOR, 0); - lv_obj_set_style_border_side(top_area, LV_BORDER_SIDE_BOTTOM, 0); - lv_obj_set_style_radius(top_area, 0, 0); - lv_obj_set_style_pad_all(top_area, 0, 0); - - m.title_bar = lv_obj_create(top_area); - lv_obj_set_size(m.title_bar, TITLE_W, TITLE_H); - lv_obj_align(m.title_bar, LV_ALIGN_CENTER, 0, 0); + // Every menu carries the dynamic home header; ui_chrome_light_title adds the + // breadcrumb letreiro only on browse screens (operation menus get the header + // alone). Kept in title_bar (transparent) so callers that fade_in(title_bar) + // still animate the whole area. + m.title_bar = lv_obj_create(m.screen); + lv_obj_set_size(m.title_bar, LCD_H_RES, HEADER_H); + lv_obj_align(m.title_bar, LV_ALIGN_TOP_LEFT, 0, 0); lv_obj_remove_flag(m.title_bar, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_set_style_radius(m.title_bar, 12, 0); - lv_obj_set_style_bg_opa(m.title_bar, LV_OPA_COVER, 0); - lv_obj_set_style_bg_color(m.title_bar, GRAD_LEFT, 0); - lv_obj_set_style_bg_grad_color(m.title_bar, GRAD_RIGHT, 0); - lv_obj_set_style_bg_grad_dir(m.title_bar, LV_GRAD_DIR_HOR, 0); - lv_obj_set_style_border_width(m.title_bar, 2, 0); - lv_obj_set_style_border_color(m.title_bar, ITEM_BORDER, 0); + lv_obj_remove_flag(m.title_bar, LV_OBJ_FLAG_CLICKABLE); + lv_obj_set_style_bg_opa(m.title_bar, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(m.title_bar, 0, 0); lv_obj_set_style_pad_all(m.title_bar, 0, 0); + lv_obj_set_style_radius(m.title_bar, 0, 0); + m.title_label = ui_chrome_light_title(m.title_bar, title); - if (title_icon_path) { - lv_image_dsc_t *ti_dsc = assets_get(title_icon_path); - if (ti_dsc) { - lv_obj_t *ti = lv_image_create(m.title_bar); - lv_image_set_src(ti, ti_dsc); - lv_obj_add_flag(ti, LV_OBJ_FLAG_FLOATING); - lv_obj_align(ti, LV_ALIGN_LEFT_MID, 4, 0); - } - } - - m.title_label = lv_label_create(m.title_bar); - lv_label_set_text(m.title_label, title ? title : ""); - lv_obj_set_style_text_color(m.title_label, current_theme.text_main, 0); - lv_obj_set_style_text_font(m.title_label, menu_font ? menu_font : &lv_font_montserrat_14, 0); - lv_obj_center(m.title_label); - - int items_y = TOP_BORDER_H + 4; - int items_h = LCD_V_RES - items_y - OUTER_BORDER - 4; + int items_h = LCD_V_RES - ITEMS_Y - FOOTER_H - 4; + if (items_h < ITEM_H) + items_h = ITEM_H; m.items_cont = lv_obj_create(m.screen); - lv_obj_set_size(m.items_cont, ITEM_W + 8, items_h); - lv_obj_align(m.items_cont, LV_ALIGN_TOP_LEFT, 4, items_y); + lv_obj_set_size(m.items_cont, LCD_H_RES - LEFT_MARGIN - RIGHT_GUTTER, items_h); + lv_obj_align(m.items_cont, LV_ALIGN_TOP_LEFT, LEFT_MARGIN, ITEMS_Y); lv_obj_set_style_bg_opa(m.items_cont, LV_OPA_TRANSP, 0); lv_obj_set_style_border_width(m.items_cont, 0, 0); lv_obj_set_style_pad_all(m.items_cont, 2, 0); - lv_obj_set_style_pad_row(m.items_cont, 6, 0); + lv_obj_set_style_pad_row(m.items_cont, ITEM_GAP, 0); lv_obj_set_flex_flow(m.items_cont, LV_FLEX_FLOW_COLUMN); lv_obj_set_scrollbar_mode(m.items_cont, LV_SCROLLBAR_MODE_OFF); - lv_obj_set_scroll_snap_y(m.items_cont, LV_SCROLL_SNAP_START); + lv_obj_set_scroll_snap_y(m.items_cont, LV_SCROLL_SNAP_NONE); int track_x = LCD_H_RES - OUTER_BORDER - 9; - m.track_y_start = items_y + 10; - m.track_h = items_h - 20; + m.track_y_start = ITEMS_Y + 8; + m.track_h = items_h - 16; + if (m.track_h < 0) + m.track_h = 0; static lv_point_precise_t track_pts[2]; track_pts[0].x = 0; @@ -164,18 +190,18 @@ menu_component_create(lv_obj_t *parent, const char *title, const char *title_ico track_pts[1].x = 0; track_pts[1].y = m.track_h; - lv_obj_t *track = lv_line_create(m.screen); - lv_line_set_points(track, track_pts, 2); - lv_obj_set_pos(track, track_x, m.track_y_start); - lv_obj_set_style_line_color(track, current_theme.border_inactive, 0); - lv_obj_set_style_line_opa(track, LV_OPA_COVER, 0); - lv_obj_set_style_line_width(track, 3, 0); - lv_obj_set_style_line_dash_width(track, 4, 0); - lv_obj_set_style_line_dash_gap(track, 4, 0); + m.scroll_track = lv_line_create(m.screen); + lv_line_set_points(m.scroll_track, track_pts, 2); + lv_obj_set_pos(m.scroll_track, track_x, m.track_y_start); + lv_obj_set_style_line_color(m.scroll_track, current_theme.border_inactive, 0); + lv_obj_set_style_line_opa(m.scroll_track, LV_OPA_COVER, 0); + lv_obj_set_style_line_width(m.scroll_track, 3, 0); + lv_obj_set_style_line_dash_width(m.scroll_track, 4, 0); + lv_obj_set_style_line_dash_gap(m.scroll_track, 4, 0); static lv_image_dsc_t *slide_bar_v_dsc = NULL; if (!slide_bar_v_dsc) - slide_bar_v_dsc = assets_get("/assets/icons/slide_bar_v.bin"); + slide_bar_v_dsc = assets_get("/assets/icons/drag_indicator.bin"); m.scroll_bar = lv_image_create(m.screen); if (slide_bar_v_dsc) @@ -183,9 +209,34 @@ menu_component_create(lv_obj_t *parent, const char *title, const char *title_ico lv_obj_set_pos(m.scroll_bar, track_x - 4, m.track_y_start); lv_obj_move_foreground(m.scroll_bar); + m.footer = lv_obj_create(m.screen); + lv_obj_set_size(m.footer, LCD_H_RES, FOOTER_H); + lv_obj_align(m.footer, LV_ALIGN_BOTTOM_LEFT, 0, 0); + lv_obj_remove_flag(m.footer, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_remove_flag(m.footer, LV_OBJ_FLAG_CLICKABLE); + lv_obj_set_style_bg_color(m.footer, HEADER_BG, 0); + lv_obj_set_style_bg_opa(m.footer, LV_OPA_COVER, 0); + lv_obj_set_style_radius(m.footer, 0, 0); + lv_obj_set_style_pad_all(m.footer, 0, 0); + lv_obj_set_style_border_width(m.footer, 2, 0); + lv_obj_set_style_border_color(m.footer, FOOTER_LINE, 0); + lv_obj_set_style_border_side(m.footer, LV_BORDER_SIDE_TOP, 0); + + m.hint_label = lv_label_create(m.footer); + lv_label_set_text(m.hint_label, DEFAULT_HINT); + lv_obj_set_style_text_color(m.hint_label, current_theme.text_main, 0); + lv_obj_set_style_text_opa(m.hint_label, LV_OPA_70, 0); + lv_obj_set_style_text_font(m.hint_label, &lv_font_montserrat_12, 0); + lv_obj_center(m.hint_label); + lv_obj_move_foreground(m.footer); + m.item_count = 0; m.selected = 0; + // This list is a menu/category level: its title becomes the breadcrumb root, so + // leaf screens opened from it render their title as "title / leaf". + ui_chrome_set_breadcrumb_root(title); + return m; } @@ -195,28 +246,22 @@ menu_component_add_item(menu_component_t *menu, const char *icon_path, const cha return NULL; lv_obj_t *item = lv_obj_create(menu->items_cont); - lv_obj_set_size(item, ITEM_W, ITEM_H); + lv_obj_set_width(item, lv_pct(100)); + lv_obj_set_height(item, ITEM_H); lv_obj_remove_flag(item, LV_OBJ_FLAG_SCROLLABLE); lv_obj_set_style_radius(item, 10, 0); lv_obj_set_style_bg_opa(item, LV_OPA_COVER, 0); - lv_obj_set_style_bg_color(item, GRAD_LEFT, 0); - lv_obj_set_style_bg_grad_color(item, GRAD_RIGHT, 0); - lv_obj_set_style_bg_grad_dir(item, LV_GRAD_DIR_HOR, 0); - lv_obj_set_style_border_width(item, 1, 0); + lv_obj_set_style_bg_color(item, ITEM_BG, 0); + lv_obj_set_style_bg_grad_dir(item, LV_GRAD_DIR_NONE, 0); + lv_obj_set_style_border_width(item, 2, 0); lv_obj_set_style_border_color(item, ITEM_BORDER, 0); - lv_obj_set_style_pad_left(item, 3, 0); - lv_obj_set_style_pad_right(item, 6, 0); + lv_obj_set_style_pad_left(item, 6, 0); + lv_obj_set_style_pad_right(item, 8, 0); lv_obj_set_flex_flow(item, LV_FLEX_FLOW_ROW); lv_obj_set_flex_align(item, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); - lv_obj_set_style_pad_column(item, 2, 0); + lv_obj_set_style_pad_column(item, 8, 0); - if (icon_path) { - lv_image_dsc_t *icon_dsc = assets_get(icon_path); - if (icon_dsc) { - lv_obj_t *icon = lv_image_create(item); - lv_image_set_src(icon, icon_dsc); - } - } + make_icon(item, icon_path); lv_obj_t *lbl = lv_label_create(item); lv_label_set_text(lbl, label ? label : ""); @@ -227,7 +272,7 @@ menu_component_add_item(menu_component_t *menu, const char *icon_path, const cha static lv_image_dsc_t *pointer_dsc = NULL; if (!pointer_dsc) - pointer_dsc = assets_get("/assets/icons/pointer.bin"); + pointer_dsc = assets_get("/assets/icons/chevron_right.bin"); lv_obj_t *ptr = lv_image_create(item); if (pointer_dsc) @@ -244,11 +289,12 @@ menu_component_add_item(menu_component_t *menu, const char *icon_path, const cha menu->item_count++; if (idx == menu->selected) { - lv_obj_set_style_border_width(item, 3, 0); lv_obj_set_style_border_color(item, SEL_BORDER, 0); lv_obj_remove_flag(ptr, LV_OBJ_FLAG_HIDDEN); } + update_scroll_state(menu); + return item; } @@ -258,7 +304,7 @@ lv_obj_t *menu_component_add_selector(menu_component_t *menu, const char *initial_value) { if (!menu || menu->item_count >= MENU_COMP_MAX_ITEMS) return NULL; - int idx = menu->item_count; /* peek before add_item increments */ + int idx = menu->item_count; lv_obj_t *item = menu_component_add_item(menu, icon_path, label); if (!item) @@ -273,6 +319,7 @@ lv_obj_t *menu_component_add_selector(menu_component_t *menu, lv_obj_align(val, LV_ALIGN_RIGHT_MID, -6, 0); menu->val_labels[idx] = val; + lv_obj_add_flag(menu->sel_dots[idx], LV_OBJ_FLAG_HIDDEN); return item; } @@ -301,6 +348,7 @@ lv_obj_t *menu_component_add_toggle(menu_component_t *menu, lv_obj_align(menu->toggles[idx].obj, LV_ALIGN_RIGHT_MID, -6, 0); toggle_ui_set(&menu->toggles[idx], initial_state); menu->has_toggle[idx] = true; + lv_obj_add_flag(menu->sel_dots[idx], LV_OBJ_FLAG_HIDDEN); return item; } @@ -340,6 +388,7 @@ lv_obj_t *menu_component_add_intensity(menu_component_t *menu, lv_obj_align(menu->intensities[idx].obj, LV_ALIGN_RIGHT_MID, -6, 0); intensity_bar_set(&menu->intensities[idx], initial_level); menu->has_intensity[idx] = true; + lv_obj_add_flag(menu->sel_dots[idx], LV_OBJ_FLAG_HIDDEN); return item; } @@ -374,6 +423,7 @@ void menu_component_next(menu_component_t *menu) { return; menu->selected = (menu->selected + 1) % menu->item_count; update_selection(menu); + ui_feedback(UI_FB_NAV); } void menu_component_prev(menu_component_t *menu) { @@ -381,8 +431,45 @@ void menu_component_prev(menu_component_t *menu) { return; menu->selected = (menu->selected == 0) ? menu->item_count - 1 : menu->selected - 1; update_selection(menu); + ui_feedback(UI_FB_NAV); } int menu_component_get_selected(menu_component_t *menu) { return menu ? menu->selected : -1; } + +void menu_component_set_item_label_color(menu_component_t *menu, int index, lv_color_t color) { + if (!menu || index < 0 || index >= menu->item_count) + return; + lv_obj_t *item = menu->items[index]; + if (!item) + return; + uint32_t n = lv_obj_get_child_count(item); + for (uint32_t i = 0; i < n; i++) { + lv_obj_t *child = lv_obj_get_child(item, i); + if (lv_obj_check_type(child, &lv_label_class)) { + lv_obj_set_style_text_color(child, color, 0); + return; + } + } +} + +void menu_component_set_hint(menu_component_t *menu, const char *text) { + if (!menu || !menu->hint_label) + return; + lv_label_set_text(menu->hint_label, text ? text : ""); +} + +void menu_component_add_section(menu_component_t *menu, const char *title) { + if (!menu || !menu->items_cont) + return; + lv_obj_t *sec = lv_label_create(menu->items_cont); + lv_label_set_text(sec, title ? title : ""); + lv_obj_set_width(sec, lv_pct(100)); + lv_obj_set_style_text_align(sec, LV_TEXT_ALIGN_CENTER, 0); + lv_obj_set_style_text_color(sec, current_theme.border_accent, 0); + lv_obj_set_style_text_opa(sec, LV_OPA_60, 0); + lv_obj_set_style_text_font(sec, &lv_font_montserrat_12, 0); + lv_obj_set_style_pad_top(sec, 6, 0); + lv_obj_set_style_pad_bottom(sec, 1, 0); +} diff --git a/firmware_p4/components/Applications/ui/components/message_box/include/msgbox_ui.h b/firmware_p4/components/Applications/ui/components/message_box/include/msgbox_ui.h index 193374b55..f7ad1c7e9 100644 --- a/firmware_p4/components/Applications/ui/components/message_box/include/msgbox_ui.h +++ b/firmware_p4/components/Applications/ui/components/message_box/include/msgbox_ui.h @@ -28,6 +28,34 @@ typedef void (*msgbox_cb_t)(bool confirm); void msgbox_open( const char *icon, const char *msg, const char *btn_ok, const char *btn_cancel, msgbox_cb_t cb); +/** + * @brief Open the "SD card connected" info modal (badge + card details + OK). + * + * A centered card showing the SD icon in an accent badge with a check mark, + * a title, and a Size/Free/Type list. Dismisses on OK or Back. Reuses the + * message-box input machinery, so msgbox_is_open() reports it too. + * + * @param name Card label/name (may be NULL). + * @param size Total capacity string, e.g. "32 GB". + * @param free Free space string, e.g. "29.7 GB". + * @param fmt Filesystem label, e.g. "FAT32". + */ +void msgbox_open_sd_info(const char *name, const char *size, const char *free, const char *fmt); + +/** + * @brief Open a centered info/result modal (icon badge + title + message + OK). + * + * Same look and input handling as the SD info modal: a centered card that + * slides up, an accent-tinted icon badge, an accent title, a wrapped body + * message and a single OK button. Dismisses on OK or Back. + * + * @param icon_path ".bin" asset path for the badge icon (NULL to omit). + * @param title Accent title text. + * @param msg Body message (may wrap over several lines). + * @param accent Accent color for the badge/title/border. + */ +void msgbox_open_info(const char *icon_path, const char *title, const char *msg, lv_color_t accent); + /** @brief Close the currently open message box. */ void msgbox_close(void); diff --git a/firmware_p4/components/Applications/ui/components/message_box/msgbox_ui.c b/firmware_p4/components/Applications/ui/components/message_box/msgbox_ui.c index 167dfe678..2249fdf98 100644 --- a/firmware_p4/components/Applications/ui/components/message_box/msgbox_ui.c +++ b/firmware_p4/components/Applications/ui/components/message_box/msgbox_ui.c @@ -21,13 +21,20 @@ #include "buttons_gpio.h" #include "ui_theme.h" -#define MSGBOX_H ((LCD_V_RES * 45) / 100) -#define ANIM_TIME 300 -#define BORDER_COLOR current_theme.border_accent -#define GRAD_TOP current_theme.border_interface -#define GRAD_BOT current_theme.bg_secondary -#define BTN_W 80 -#define BTN_H 28 +#define MSGBOX_H ((LCD_V_RES * 45) / 100) +#define ANIM_TIME 300 +#define BORDER_COLOR current_theme.border_accent +#define GRAD_TOP current_theme.border_interface +#define GRAD_BOT current_theme.bg_secondary +#define BTN_W 80 +#define BTN_H 28 +#define MSGBOX_POLL_MS 50 + +#define SD_MODAL_W 200 +#define SD_MODAL_H 200 +#define SD_MODAL_BTN_W 150 +#define SD_ICON_PX 34 +#define SD_BTN_RADIUS 14 static lv_obj_t *panel = NULL; static lv_obj_t *btn_objs[2] = {NULL}; @@ -61,37 +68,67 @@ static void slide_anim_cb(void *var, int32_t val) { lv_obj_set_y((lv_obj_t *)var, val); } -static void close_anim_done(lv_anim_t *a) { - if (panel) { - lv_obj_del(panel); - panel = NULL; - } +static void close_anim_del_cb(lv_anim_t *a) { + lv_obj_del((lv_obj_t *)a->var); +} + +static void panel_deleted_cb(lv_event_t *e) { + (void)e; + panel = NULL; btn_objs[0] = btn_objs[1] = NULL; btn_count = 0; + current_cb = NULL; + if (msgbox_timer) { + lv_timer_delete(msgbox_timer); + msgbox_timer = NULL; + } } static void do_close(bool confirm) { if (!panel) return; - if (current_cb) - current_cb(confirm); + lv_obj_t *closing = panel; + msgbox_cb_t cb = current_cb; + panel = NULL; current_cb = NULL; - + btn_objs[0] = btn_objs[1] = NULL; + btn_count = 0; if (msgbox_timer) { lv_timer_delete(msgbox_timer); msgbox_timer = NULL; } + lv_obj_remove_event_cb(closing, panel_deleted_cb); lv_anim_t a; lv_anim_init(&a); - lv_anim_set_var(&a, panel); - lv_anim_set_values(&a, lv_obj_get_y(panel), LCD_V_RES); + lv_anim_set_var(&a, closing); + lv_anim_set_values(&a, lv_obj_get_y(closing), LCD_V_RES); lv_anim_set_duration(&a, ANIM_TIME); lv_anim_set_path_cb(&a, lv_anim_path_ease_in_out); lv_anim_set_exec_cb(&a, slide_anim_cb); - lv_anim_set_completed_cb(&a, close_anim_done); + lv_anim_set_completed_cb(&a, close_anim_del_cb); lv_anim_start(&a); + + if (cb) + cb(confirm); +} + +static void discard_panel_silent(void) { + if (!panel) + return; + lv_obj_t *p = panel; + panel = NULL; + current_cb = NULL; + btn_objs[0] = btn_objs[1] = NULL; + btn_count = 0; + if (msgbox_timer) { + lv_timer_delete(msgbox_timer); + msgbox_timer = NULL; + } + lv_obj_remove_event_cb(p, panel_deleted_cb); + lv_anim_delete(p, NULL); + lv_obj_del(p); } static void msgbox_timer_cb(lv_timer_t *t) { @@ -162,7 +199,7 @@ static lv_obj_t *create_btn(lv_obj_t *parent, const char *text) { void msgbox_open( const char *icon, const char *msg, const char *btn_ok, const char *btn_cancel, msgbox_cb_t cb) { if (panel) - msgbox_close(); + discard_panel_silent(); current_cb = cb; input_locked = true; @@ -174,6 +211,7 @@ void msgbox_open( lv_obj_set_size(panel, LCD_H_RES, MSGBOX_H); lv_obj_set_pos(panel, 0, LCD_V_RES); lv_obj_remove_flag(panel, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_add_event_cb(panel, panel_deleted_cb, LV_EVENT_DELETE, NULL); lv_obj_set_style_radius(panel, 12, 0); lv_obj_set_style_border_side( @@ -200,7 +238,7 @@ void msgbox_open( static lv_image_dsc_t *warn_dsc = NULL; if (!warn_dsc) - warn_dsc = assets_get("/assets/icons/warning_icon.bin"); + warn_dsc = assets_get("/assets/icons/warning.bin"); if (warn_dsc) { lv_obj_t *icon_img = lv_image_create(content); lv_image_set_src(icon_img, warn_dsc); @@ -252,7 +290,211 @@ void msgbox_open( lv_anim_start(&a); if (!msgbox_timer) - msgbox_timer = lv_timer_create(msgbox_timer_cb, 50, NULL); + msgbox_timer = lv_timer_create(msgbox_timer_cb, MSGBOX_POLL_MS, NULL); +} + +static void sd_add_info_row(lv_obj_t *parent, const char *k, const char *v) { + lv_obj_t *row = lv_obj_create(parent); + lv_obj_set_size(row, lv_pct(100), LV_SIZE_CONTENT); + lv_obj_remove_flag(row, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_bg_opa(row, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(row, 0, 0); + lv_obj_set_style_pad_all(row, 0, 0); + lv_obj_set_flex_flow(row, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align( + row, LV_FLEX_ALIGN_SPACE_BETWEEN, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + + lv_obj_t *kl = lv_label_create(row); + lv_label_set_text(kl, k); + lv_obj_set_style_text_color(kl, current_theme.text_main, 0); + lv_obj_set_style_text_opa(kl, LV_OPA_60, 0); + lv_obj_set_style_text_font(kl, &lv_font_montserrat_12, 0); + + lv_obj_t *vl = lv_label_create(row); + lv_label_set_text(vl, v); + lv_obj_set_style_text_color(vl, current_theme.text_main, 0); + lv_obj_set_style_text_font(vl, &lv_font_montserrat_12, 0); +} + +void msgbox_open_sd_info(const char *name, const char *size, const char *free, const char *fmt) { + if (panel) + discard_panel_silent(); + + current_cb = NULL; + input_locked = true; + btn_count = 0; + btn_sel = 0; + + lv_obj_t *scr = lv_screen_active(); + panel = lv_obj_create(scr); + lv_obj_set_size(panel, SD_MODAL_W, SD_MODAL_H); + lv_obj_set_pos(panel, (LCD_H_RES - SD_MODAL_W) / 2, LCD_V_RES); + lv_obj_remove_flag(panel, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_add_event_cb(panel, panel_deleted_cb, LV_EVENT_DELETE, NULL); + + lv_obj_set_style_radius(panel, 14, 0); + lv_obj_set_style_border_width(panel, 2, 0); + lv_obj_set_style_border_color(panel, BORDER_COLOR, 0); + lv_obj_set_style_bg_opa(panel, LV_OPA_COVER, 0); + lv_obj_set_style_bg_color(panel, current_theme.bg_primary, 0); + lv_obj_set_style_bg_grad_color(panel, current_theme.bg_secondary, 0); + lv_obj_set_style_bg_grad_dir(panel, LV_GRAD_DIR_VER, 0); + lv_obj_set_style_pad_top(panel, 14, 0); + lv_obj_set_style_pad_bottom(panel, 14, 0); + lv_obj_set_style_pad_left(panel, 12, 0); + lv_obj_set_style_pad_right(panel, 12, 0); + lv_obj_set_style_pad_row(panel, 6, 0); + lv_obj_set_flex_flow(panel, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(panel, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_move_foreground(panel); + + static lv_image_dsc_t *card_dsc = NULL; + if (!card_dsc) + card_dsc = assets_get("/assets/icons/sd_card.bin"); + if (card_dsc) { + lv_obj_t *ci = lv_image_create(panel); + lv_image_set_src(ci, card_dsc); + lv_obj_set_size(ci, SD_ICON_PX, SD_ICON_PX); + lv_image_set_inner_align(ci, LV_IMAGE_ALIGN_CONTAIN); + } + + lv_obj_t *title = lv_label_create(panel); + lv_label_set_text(title, "SD Card Connected"); + lv_obj_set_style_text_color(title, current_theme.border_accent, 0); + lv_obj_set_style_text_font(title, &lv_font_montserrat_14, 0); + + if (name && name[0]) { + lv_obj_t *sub = lv_label_create(panel); + lv_label_set_text(sub, name); + lv_obj_set_style_text_color(sub, current_theme.text_main, 0); + lv_obj_set_style_text_opa(sub, LV_OPA_50, 0); + lv_obj_set_style_text_font(sub, &lv_font_montserrat_12, 0); + } + + lv_obj_t *info = lv_obj_create(panel); + lv_obj_set_size(info, lv_pct(100), LV_SIZE_CONTENT); + lv_obj_remove_flag(info, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_bg_opa(info, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(info, 0, 0); + lv_obj_set_style_pad_all(info, 0, 0); + lv_obj_set_style_pad_row(info, 3, 0); + lv_obj_set_flex_flow(info, LV_FLEX_FLOW_COLUMN); + sd_add_info_row(info, "Capacity", size ? size : "-"); + sd_add_info_row(info, "Free", free ? free : "-"); + sd_add_info_row(info, "Format", fmt ? fmt : "-"); + + lv_obj_t *btn = create_btn(panel, "OK"); + lv_obj_set_width(btn, SD_MODAL_BTN_W); + lv_obj_set_style_radius(btn, SD_BTN_RADIUS, 0); + btn_objs[0] = btn; + btn_confirms[0] = true; + btn_count = 1; + btn_sel = 0; + update_btn_selection(); + + int target_y = (LCD_V_RES - SD_MODAL_H) / 2; + lv_anim_t a; + lv_anim_init(&a); + lv_anim_set_var(&a, panel); + lv_anim_set_values(&a, LCD_V_RES, target_y); + lv_anim_set_duration(&a, ANIM_TIME); + lv_anim_set_path_cb(&a, lv_anim_path_ease_in_out); + lv_anim_set_exec_cb(&a, slide_anim_cb); + lv_anim_start(&a); + + if (!msgbox_timer) + msgbox_timer = lv_timer_create(msgbox_timer_cb, MSGBOX_POLL_MS, NULL); +} + +void msgbox_open_info(const char *icon_path, + const char *title, + const char *msg, + lv_color_t accent) { + if (panel) + discard_panel_silent(); + + current_cb = NULL; + input_locked = true; + btn_count = 0; + btn_sel = 0; + + lv_obj_t *scr = lv_screen_active(); + panel = lv_obj_create(scr); + lv_obj_set_size(panel, SD_MODAL_W, SD_MODAL_H); + lv_obj_set_pos(panel, (LCD_H_RES - SD_MODAL_W) / 2, LCD_V_RES); + lv_obj_remove_flag(panel, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_add_event_cb(panel, panel_deleted_cb, LV_EVENT_DELETE, NULL); + + lv_obj_set_style_radius(panel, 14, 0); + lv_obj_set_style_border_width(panel, 2, 0); + lv_obj_set_style_border_color(panel, accent, 0); + lv_obj_set_style_bg_opa(panel, LV_OPA_COVER, 0); + lv_obj_set_style_bg_color(panel, current_theme.bg_primary, 0); + lv_obj_set_style_bg_grad_color(panel, current_theme.bg_secondary, 0); + lv_obj_set_style_bg_grad_dir(panel, LV_GRAD_DIR_VER, 0); + lv_obj_set_style_shadow_width(panel, 26, 0); + lv_obj_set_style_shadow_color(panel, accent, 0); + lv_obj_set_style_shadow_opa(panel, LV_OPA_40, 0); + lv_obj_set_style_pad_all(panel, 14, 0); + lv_obj_set_style_pad_row(panel, 8, 0); + lv_obj_set_flex_flow(panel, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(panel, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_move_foreground(panel); + + if (icon_path) { + lv_image_dsc_t *dsc = assets_get(icon_path); + if (dsc) { + lv_obj_t *badge = lv_obj_create(panel); + lv_obj_set_size(badge, 48, 48); + lv_obj_remove_flag(badge, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_radius(badge, LV_RADIUS_CIRCLE, 0); + lv_obj_set_style_bg_color(badge, accent, 0); + lv_obj_set_style_bg_opa(badge, LV_OPA_20, 0); + lv_obj_set_style_border_width(badge, 2, 0); + lv_obj_set_style_border_color(badge, accent, 0); + lv_obj_set_style_pad_all(badge, 0, 0); + lv_obj_t *ci = lv_image_create(badge); + lv_image_set_src(ci, dsc); + lv_obj_set_size(ci, SD_ICON_PX, SD_ICON_PX); + lv_image_set_inner_align(ci, LV_IMAGE_ALIGN_CONTAIN); + lv_obj_center(ci); + } + } + + lv_obj_t *title_lbl = lv_label_create(panel); + lv_label_set_text(title_lbl, title ? title : ""); + lv_obj_set_style_text_color(title_lbl, accent, 0); + lv_obj_set_style_text_font(title_lbl, &lv_font_montserrat_14, 0); + + lv_obj_t *msg_lbl = lv_label_create(panel); + lv_label_set_text(msg_lbl, msg ? msg : ""); + lv_obj_set_style_text_color(msg_lbl, current_theme.text_main, 0); + lv_obj_set_style_text_font(msg_lbl, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_align(msg_lbl, LV_TEXT_ALIGN_CENTER, 0); + lv_label_set_long_mode(msg_lbl, LV_LABEL_LONG_WRAP); + lv_obj_set_width(msg_lbl, SD_MODAL_W - 32); + + lv_obj_t *btn = create_btn(panel, "OK"); + lv_obj_set_width(btn, SD_MODAL_BTN_W); + lv_obj_set_style_radius(btn, SD_BTN_RADIUS, 0); + btn_objs[0] = btn; + btn_confirms[0] = true; + btn_count = 1; + btn_sel = 0; + update_btn_selection(); + + int target_y = (LCD_V_RES - SD_MODAL_H) / 2; + lv_anim_t a; + lv_anim_init(&a); + lv_anim_set_var(&a, panel); + lv_anim_set_values(&a, LCD_V_RES, target_y); + lv_anim_set_duration(&a, ANIM_TIME); + lv_anim_set_path_cb(&a, lv_anim_path_ease_in_out); + lv_anim_set_exec_cb(&a, slide_anim_cb); + lv_anim_start(&a); + + if (!msgbox_timer) + msgbox_timer = lv_timer_create(msgbox_timer_cb, MSGBOX_POLL_MS, NULL); } void msgbox_close(void) { diff --git a/firmware_p4/components/Applications/ui/components/notify/include/notify_ui.h b/firmware_p4/components/Applications/ui/components/notify/include/notify_ui.h new file mode 100644 index 000000000..1873530b5 --- /dev/null +++ b/firmware_p4/components/Applications/ui/components/notify/include/notify_ui.h @@ -0,0 +1,56 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#ifndef NOTIFY_UI_H +#define NOTIFY_UI_H + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * @brief Global toast notification: a small pill that appears at the top of the + * screen on the LVGL TOP LAYER, so it floats above ANY screen (it can + * never be overlapped) and survives screen switches. + * + * It auto-dismisses after a few seconds and does not steal input from the + * active screen. A new call replaces the one currently showing. Keep the text + * short (it's a one-liner). + */ + +/** + * @brief Notification kind, selecting the pill's colour and icon. + */ +typedef enum { + NOTIFY_INFO = 0, ///< purple — generic (paired, connected, ...) + NOTIFY_SAVED, ///< green check — saved / applied confirmation + NOTIFY_UPDATE, ///< green — firmware / update available + NOTIFY_LORA, ///< cyan — LoRa / incoming message + NOTIFY_WARNING, ///< amber — battery low, C5 dropped, ... +} notify_type_t; + +/** + * @brief Show a short notification pill. Safe to call from any UI callback. + * + * @param type Notification kind (selects colour + icon). + * @param text One-line message shown in the pill (NULL for none). + */ +void notify(notify_type_t type, const char *text); + +#ifdef __cplusplus +} +#endif + +#endif // NOTIFY_UI_H diff --git a/firmware_p4/components/Applications/ui/components/notify/notify_ui.c b/firmware_p4/components/Applications/ui/components/notify/notify_ui.c new file mode 100644 index 000000000..eb59e375d --- /dev/null +++ b/firmware_p4/components/Applications/ui/components/notify/notify_ui.c @@ -0,0 +1,174 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#include "notify_ui.h" + +#include "lvgl.h" + +#include "ui_feedback.h" +#include "ui_theme.h" + +#define NOTIFY_MS 3200 +#define SLIDE_MS 220 +#define TOP_Y 6 +#define PILL_MAX_W 224 +#define TEXT_MAX_W 170 +#define COL_RAISE 0x170A28 + +static lv_obj_t *s_pill = NULL; +static lv_timer_t *s_timer = NULL; + +static lv_color_t type_color(notify_type_t t) { + switch (t) { + case NOTIFY_SAVED: + return lv_color_hex(0x00E676); + case NOTIFY_UPDATE: + return lv_color_hex(0x00E676); + case NOTIFY_LORA: + return lv_color_hex(0x00BCD4); + case NOTIFY_WARNING: + return lv_color_hex(0xFFC400); + default: + return current_theme.border_accent; + } +} + +static const char *type_sym(notify_type_t t) { + switch (t) { + case NOTIFY_SAVED: + return LV_SYMBOL_OK; + case NOTIFY_UPDATE: + return LV_SYMBOL_DOWNLOAD; + case NOTIFY_LORA: + return LV_SYMBOL_ENVELOPE; + case NOTIFY_WARNING: + return LV_SYMBOL_WARNING; + default: + return LV_SYMBOL_BELL; + } +} + +static void slide_y_cb(void *var, int32_t v) { + lv_obj_set_style_translate_y((lv_obj_t *)var, v, 0); +} +static void opa_cb(void *var, int32_t v) { + lv_obj_set_style_opa((lv_obj_t *)var, (lv_opa_t)v, 0); +} + +static void gone_cb(lv_anim_t *a) { + (void)a; + if (s_pill != NULL) { + lv_obj_del(s_pill); + s_pill = NULL; + } +} + +static void clear_now(void) { + if (s_timer != NULL) { + lv_timer_delete(s_timer); + s_timer = NULL; + } + if (s_pill != NULL) { + lv_anim_delete(s_pill, NULL); + lv_obj_del(s_pill); + s_pill = NULL; + } +} + +static void dismiss_cb(lv_timer_t *t) { + (void)t; + s_timer = NULL; + if (s_pill == NULL) + return; + lv_anim_t a; + lv_anim_init(&a); + lv_anim_set_var(&a, s_pill); + lv_anim_set_exec_cb(&a, slide_y_cb); + lv_anim_set_values(&a, 0, -50); + lv_anim_set_duration(&a, SLIDE_MS); + lv_anim_set_path_cb(&a, lv_anim_path_ease_in); + lv_anim_set_completed_cb(&a, gone_cb); + lv_anim_start(&a); + + lv_anim_t f; + lv_anim_init(&f); + lv_anim_set_var(&f, s_pill); + lv_anim_set_exec_cb(&f, opa_cb); + lv_anim_set_values(&f, LV_OPA_COVER, LV_OPA_TRANSP); + lv_anim_set_duration(&f, SLIDE_MS); + lv_anim_start(&f); +} + +void notify(notify_type_t type, const char *text) { + clear_now(); + + lv_color_t c = type_color(type); + lv_obj_t *pill = lv_obj_create(lv_layer_top()); + s_pill = pill; + lv_obj_remove_flag(pill, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_remove_flag(pill, LV_OBJ_FLAG_CLICKABLE); + lv_obj_set_size(pill, LV_SIZE_CONTENT, LV_SIZE_CONTENT); + lv_obj_set_style_max_width(pill, PILL_MAX_W, 0); + lv_obj_set_style_radius(pill, LV_RADIUS_CIRCLE, 0); + lv_obj_set_style_bg_color(pill, lv_color_hex(COL_RAISE), 0); + lv_obj_set_style_bg_opa(pill, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(pill, 1, 0); + lv_obj_set_style_border_color(pill, c, 0); + lv_obj_set_style_shadow_width(pill, 18, 0); + lv_obj_set_style_shadow_color(pill, c, 0); + lv_obj_set_style_shadow_spread(pill, -6, 0); + lv_obj_set_style_shadow_ofs_y(pill, 6, 0); + lv_obj_set_style_pad_hor(pill, 13, 0); + lv_obj_set_style_pad_ver(pill, 7, 0); + lv_obj_set_flex_flow(pill, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(pill, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_set_style_pad_column(pill, 8, 0); + lv_obj_align(pill, LV_ALIGN_TOP_MID, 0, TOP_Y); + + lv_obj_t *icon = lv_label_create(pill); + lv_label_set_text(icon, type_sym(type)); + lv_obj_set_style_text_font(icon, &lv_font_montserrat_14, 0); + lv_obj_set_style_text_color(icon, c, 0); + + lv_obj_t *lbl = lv_label_create(pill); + lv_label_set_text(lbl, text ? text : ""); + lv_label_set_long_mode(lbl, LV_LABEL_LONG_DOT); + lv_obj_set_style_max_width(lbl, TEXT_MAX_W, 0); + lv_obj_set_style_text_font(lbl, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(lbl, current_theme.text_main, 0); + + lv_obj_set_style_opa(pill, LV_OPA_TRANSP, 0); + lv_anim_t a; + lv_anim_init(&a); + lv_anim_set_var(&a, pill); + lv_anim_set_exec_cb(&a, slide_y_cb); + lv_anim_set_values(&a, -50, 0); + lv_anim_set_duration(&a, SLIDE_MS); + lv_anim_set_path_cb(&a, lv_anim_path_ease_out); + lv_anim_start(&a); + + lv_anim_t f; + lv_anim_init(&f); + lv_anim_set_var(&f, pill); + lv_anim_set_exec_cb(&f, opa_cb); + lv_anim_set_values(&f, LV_OPA_TRANSP, LV_OPA_COVER); + lv_anim_set_duration(&f, SLIDE_MS); + lv_anim_start(&f); + + ui_feedback(UI_FB_SELECT); + + s_timer = lv_timer_create(dismiss_cb, NOTIFY_MS, NULL); + lv_timer_set_repeat_count(s_timer, 1); +} diff --git a/firmware_p4/components/Applications/ui/components/octobit/include/octobit_ui.h b/firmware_p4/components/Applications/ui/components/octobit/include/octobit_ui.h new file mode 100644 index 000000000..3cbe3b452 --- /dev/null +++ b/firmware_p4/components/Applications/ui/components/octobit/include/octobit_ui.h @@ -0,0 +1,42 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#ifndef OCTOBIT_UI_H +#define OCTOBIT_UI_H + +#ifdef __cplusplus +extern "C" { +#endif + +#include "lvgl.h" + +/** + * @brief Show the Octobit mascot snug in the bottom-right corner with a speech + * balloon. Use for status states ("Pairing...", "Searching...", etc.). + * + * @param parent Screen/container to attach to. + * @param phrase Text shown in the balloon (may be NULL for none). + * @return The root object; pass it to octobit_set_text() / lv_obj_del(). + */ +lv_obj_t *octobit_create(lv_obj_t *parent, const char *phrase); + +/** @brief Update the balloon phrase of an existing Octobit. */ +void octobit_set_text(lv_obj_t *octobit_root, const char *phrase); + +#ifdef __cplusplus +} +#endif + +#endif // OCTOBIT_UI_H diff --git a/firmware_p4/components/Applications/ui/components/octobit/octobit_ui.c b/firmware_p4/components/Applications/ui/components/octobit/octobit_ui.c new file mode 100644 index 000000000..b6daa8ac4 --- /dev/null +++ b/firmware_p4/components/Applications/ui/components/octobit/octobit_ui.c @@ -0,0 +1,135 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#include "octobit_ui.h" + +#include "st7789.h" + +#include "assets_manager.h" +#include "ui_theme.h" + +#define OCTOBIT_ASSET "/assets/img/octobit.bin" +#define OCTOBIT_W 80 +#define OCTOBIT_H 115 +#define OCTOBIT_SCALE 448 +#define SCALED_W (OCTOBIT_W * OCTOBIT_SCALE / 256) +#define SCALED_H (OCTOBIT_H * OCTOBIT_SCALE / 256) + +#define BALLOON_MAX_W 150 +#define BALLOON_MIN_W 96 +#define BALLOON_PAD 12 +#define BALLOON_RADIUS 14 +#define BALLOON_BORDER 2 +#define BALLOON_OFFSET_X (-SCALED_W + 60) +#define BALLOON_OFFSET_Y (-SCALED_H + 30) + +#define SWAY_ANGLE 40 +#define SWAY_TIME 1300 + +#define SIGNAL_COUNT 3 +#define SIGNAL_DOT 7 +#define SIGNAL_X (LCD_H_RES - SCALED_W + 20) +#define SIGNAL_Y (LCD_V_RES - SCALED_H + 16) +#define SIGNAL_DX (-22) +#define SIGNAL_DY (-18) +#define SIGNAL_TIME 1100 + +static void signal_anim_cb(void *var, int32_t v) { + lv_obj_t *dot = (lv_obj_t *)var; + lv_obj_set_pos(dot, SIGNAL_X + SIGNAL_DX * v / 255, SIGNAL_Y + SIGNAL_DY * v / 255); + lv_obj_set_style_opa(dot, (lv_opa_t)(255 - v), 0); +} + +lv_obj_t *octobit_create(lv_obj_t *parent, const char *phrase) { + lv_obj_t *root = lv_obj_create(parent); + lv_obj_remove_style_all(root); + lv_obj_set_size(root, LCD_H_RES, LCD_V_RES); + lv_obj_center(root); + lv_obj_remove_flag(root, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_remove_flag(root, LV_OBJ_FLAG_CLICKABLE); + + lv_obj_t *img = lv_image_create(root); + lv_image_dsc_t *dsc = assets_get(OCTOBIT_ASSET); + if (dsc != NULL) + lv_image_set_src(img, dsc); + lv_image_set_pivot(img, OCTOBIT_W, OCTOBIT_H); + lv_image_set_scale(img, OCTOBIT_SCALE); + lv_obj_align(img, LV_ALIGN_BOTTOM_RIGHT, 0, 0); + + lv_anim_t sway; + lv_anim_init(&sway); + lv_anim_set_var(&sway, img); + lv_anim_set_exec_cb(&sway, (lv_anim_exec_xcb_t)lv_image_set_rotation); + lv_anim_set_values(&sway, -SWAY_ANGLE, SWAY_ANGLE); + lv_anim_set_duration(&sway, SWAY_TIME); + lv_anim_set_playback_duration(&sway, SWAY_TIME); + lv_anim_set_repeat_count(&sway, LV_ANIM_REPEAT_INFINITE); + lv_anim_set_path_cb(&sway, lv_anim_path_ease_in_out); + lv_anim_start(&sway); + + for (int i = 0; i < SIGNAL_COUNT; i++) { + lv_obj_t *dot = lv_obj_create(root); + lv_obj_remove_style_all(dot); + lv_obj_set_size(dot, SIGNAL_DOT, SIGNAL_DOT); + lv_obj_set_style_radius(dot, LV_RADIUS_CIRCLE, 0); + lv_obj_set_style_bg_opa(dot, LV_OPA_COVER, 0); + lv_obj_set_style_bg_color(dot, current_theme.border_accent, 0); + + lv_anim_t sig; + lv_anim_init(&sig); + lv_anim_set_var(&sig, dot); + lv_anim_set_exec_cb(&sig, signal_anim_cb); + lv_anim_set_values(&sig, 0, 255); + lv_anim_set_duration(&sig, SIGNAL_TIME); + lv_anim_set_delay(&sig, i * (SIGNAL_TIME / SIGNAL_COUNT)); + lv_anim_set_repeat_count(&sig, LV_ANIM_REPEAT_INFINITE); + lv_anim_set_path_cb(&sig, lv_anim_path_linear); + lv_anim_start(&sig); + } + + lv_obj_t *balloon = lv_obj_create(root); + lv_obj_remove_flag(balloon, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(balloon, LV_SIZE_CONTENT, LV_SIZE_CONTENT); + lv_obj_set_style_radius(balloon, BALLOON_RADIUS, 0); + lv_obj_set_style_bg_opa(balloon, LV_OPA_COVER, 0); + lv_obj_set_style_bg_color(balloon, current_theme.bg_primary, 0); + lv_obj_set_style_bg_grad_color(balloon, current_theme.bg_secondary, 0); + lv_obj_set_style_bg_grad_dir(balloon, LV_GRAD_DIR_VER, 0); + lv_obj_set_style_border_width(balloon, BALLOON_BORDER, 0); + lv_obj_set_style_border_color(balloon, current_theme.border_accent, 0); + lv_obj_set_style_pad_all(balloon, BALLOON_PAD, 0); + lv_obj_set_style_min_width(balloon, BALLOON_MIN_W, 0); + lv_obj_align(balloon, LV_ALIGN_BOTTOM_RIGHT, BALLOON_OFFSET_X, BALLOON_OFFSET_Y); + + lv_obj_t *lbl = lv_label_create(balloon); + lv_label_set_long_mode(lbl, LV_LABEL_LONG_WRAP); + lv_obj_set_style_text_color(lbl, current_theme.text_main, 0); + lv_obj_set_style_text_font(lbl, &lv_font_montserrat_14, 0); + lv_obj_set_style_text_align(lbl, LV_TEXT_ALIGN_CENTER, 0); + lv_obj_set_width(lbl, LV_SIZE_CONTENT); + lv_obj_set_style_max_width(lbl, BALLOON_MAX_W, 0); + lv_label_set_text(lbl, phrase != NULL ? phrase : ""); + + lv_obj_set_user_data(root, lbl); + return root; +} + +void octobit_set_text(lv_obj_t *octobit_root, const char *phrase) { + if (octobit_root == NULL) + return; + lv_obj_t *lbl = (lv_obj_t *)lv_obj_get_user_data(octobit_root); + if (lbl != NULL) + lv_label_set_text(lbl, phrase != NULL ? phrase : ""); +} diff --git a/firmware_p4/components/Applications/ui/components/page_dots/page_dots_ui.c b/firmware_p4/components/Applications/ui/components/page_dots/page_dots_ui.c index 0a24e3e01..6a5d9cabd 100644 --- a/firmware_p4/components/Applications/ui/components/page_dots/page_dots_ui.c +++ b/firmware_p4/components/Applications/ui/components/page_dots/page_dots_ui.c @@ -19,7 +19,32 @@ static const int DOT_PATTERN[] = {4, 7, 12, 7, 4}; #define PATTERN_LEN 5 -#define ANIM_MS 250 +#define ANIM_MS 450 + +static void dot_size_cb(void *var, int32_t v) { + lv_obj_set_width((lv_obj_t *)var, v); + lv_obj_set_height((lv_obj_t *)var, v); +} + +static void dot_opa_cb(void *var, int32_t v) { + lv_obj_set_style_bg_opa((lv_obj_t *)var, (lv_opa_t)v, 0); +} + +static void dot_animate_to(lv_obj_t *dot, int size, lv_opa_t opa) { + lv_anim_t a; + lv_anim_init(&a); + lv_anim_set_var(&a, dot); + lv_anim_set_duration(&a, ANIM_MS); + lv_anim_set_path_cb(&a, lv_anim_path_ease_in_out); + + lv_anim_set_exec_cb(&a, dot_size_cb); + lv_anim_set_values(&a, lv_obj_get_width(dot), size); + lv_anim_start(&a); + + lv_anim_set_exec_cb(&a, dot_opa_cb); + lv_anim_set_values(&a, lv_obj_get_style_bg_opa(dot, 0), opa); + lv_anim_start(&a); +} page_dots_t page_dots_create(lv_obj_t *parent, int total, lv_align_t align, int x_ofs, int y_ofs) { page_dots_t pd = {0}; @@ -62,10 +87,24 @@ void page_dots_set(page_dots_t *pd, int index) { int rel = i - index; int dist = rel < 0 ? -rel : rel; + lv_anim_delete(pd->dots[i], dot_size_cb); + lv_anim_delete(pd->dots[i], dot_opa_cb); + if (dist <= 2) { + bool was_hidden = lv_obj_has_flag(pd->dots[i], LV_OBJ_FLAG_HIDDEN); lv_obj_remove_flag(pd->dots[i], LV_OBJ_FLAG_HIDDEN); + int sz = DOT_PATTERN[2 + rel]; - lv_obj_set_size(pd->dots[i], sz, sz); + lv_opa_t opa = (dist == 0) ? LV_OPA_COVER : (dist == 1) ? LV_OPA_40 : LV_OPA_20; + lv_color_t col = (dist == 0) ? current_theme.border_accent : current_theme.text_main; + lv_obj_set_style_bg_color(pd->dots[i], col, 0); + + if (was_hidden) { + lv_obj_set_size(pd->dots[i], sz, sz); + lv_obj_set_style_bg_opa(pd->dots[i], opa, 0); + } else { + dot_animate_to(pd->dots[i], sz, opa); + } } else { lv_obj_add_flag(pd->dots[i], LV_OBJ_FLAG_HIDDEN); } diff --git a/firmware_p4/components/Applications/ui/components/power_policy/include/power_policy.h b/firmware_p4/components/Applications/ui/components/power_policy/include/power_policy.h new file mode 100644 index 000000000..f3bc12342 --- /dev/null +++ b/firmware_p4/components/Applications/ui/components/power_policy/include/power_policy.h @@ -0,0 +1,49 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#ifndef POWER_POLICY_H +#define POWER_POLICY_H + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * @brief Start the global power policy (idempotent). Call once from ui_init(), + * under the LVGL lock (it creates an lv_timer). + * + * Owns two always-on behaviours in a single LVGL timer: + * - Screen power: after auto_lock_seconds of button inactivity it dims (when + * auto_dim is set) and then sleeps the panel, restoring the user's brightness + * on the next input. Reads input_last_activity_ms() and g_config_screen. + * - Low-battery policy: raise a toast on the low-battery edge. + */ +void power_policy_init(void); + +/** + * @brief Whether the screen is currently asleep (panel off). + * + * The input router swallows input while asleep so the wake press only wakes the + * screen instead of also acting on the underlying UI. + */ +bool power_policy_is_asleep(void); + +#ifdef __cplusplus +} +#endif + +#endif // POWER_POLICY_H diff --git a/firmware_p4/components/Applications/ui/components/power_policy/power_policy.c b/firmware_p4/components/Applications/ui/components/power_policy/power_policy.c new file mode 100644 index 000000000..0f28af824 --- /dev/null +++ b/firmware_p4/components/Applications/ui/components/power_policy/power_policy.c @@ -0,0 +1,124 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#include "power_policy.h" + +#include "lvgl.h" + +#include "battery_service.h" +#include "bq25896.h" +#include "input_manager.h" +#include "led_control.h" +#include "notify_ui.h" +#include "power_manager.h" + +// Battery discharge policy only. The screen auto-dim / auto-sleep and the +// low-battery brightness cap were removed on request: the panel stays at the +// user's configured brightness and never turns itself off. What remains is the +// battery safety net - low/critical warnings and a graceful power-off at ~2% so +// the pack is not deep-discharged. It acts on battery only (no effect on USB). + +#define POWER_POLL_MS 200 // tick cadence (fine enough to time a button hold) +#define BATT_CHECK_MS 2000 // battery poll cadence (accumulated from POWER_POLL_MS) +#define POWEROFF_HOLD_MS 3000 // OK + LEFT held this long powers the device off + +#define BATT_CRIT_PCT 5 // at/below: repeated critical warning +#define BATT_SHUTDOWN_PCT 2 // at/below (sustained): graceful power off +#define BATT_SHUTDOWN_SAMPLES 3 // consecutive critical polls before shutdown + +static lv_timer_t *s_timer = NULL; +static bool s_low_last = false; +static bool s_crit_last = false; +static int s_shutdown_count = 0; +static uint32_t s_batt_accum_ms = 0; +static uint32_t s_poweroff_held_ms = 0; + +static void check_battery(void) { + battery_snapshot_t bs; + if (!battery_service_get(&bs)) { + return; + } + + // Route the charger's VBUS truth into the power manager (item 41a): it holds + // the device out of light sleep while on external power. + power_manager_set_external_power(bs.vbus_present); + + // Only run the discharge policy on battery (not charging, no external power). + bool on_battery = bs.present && !bs.charging && !bs.vbus_present; + + // 15%: low warning (once per entry). + if (bs.low && !s_low_last) { + notify(NOTIFY_WARNING, "Battery low"); + led_signal_warning(); + } + s_low_last = bs.low; + + // 5%: stronger, repeated critical warning. + bool crit = on_battery && bs.soc <= BATT_CRIT_PCT; + if (crit && !s_crit_last) { + notify(NOTIFY_WARNING, "Battery critical - charge now"); + } + s_crit_last = crit; + + // ~2% sustained: graceful power off so the pack is not deep-discharged. Requires + // several consecutive critical polls to avoid a single bad reading shutting down. + if (on_battery && bs.soc <= BATT_SHUTDOWN_PCT) { + if (++s_shutdown_count >= BATT_SHUTDOWN_SAMPLES) { + notify(NOTIFY_WARNING, "Battery empty - shutting down"); + bq25896_power_off(); // real ship mode (no effect while on USB) + } + } else { + s_shutdown_count = 0; + } +} + +// Power-off combo: hold OK + LEFT for POWEROFF_HOLD_MS. This pair is deliberate - +// BACK and LEFT are wired to the charger /QON and the P4 CHIP_PU (holding both +// would reset the P4 in hardware before firmware could act), while OK is not in +// that path, so the firmware stays alive to issue the ship-mode command. +static void check_power_combo(void) { + if (input_is_down(INPUT_BTN_OK) && input_is_down(INPUT_BTN_LEFT)) { + s_poweroff_held_ms += POWER_POLL_MS; + if (s_poweroff_held_ms >= POWEROFF_HOLD_MS) { + s_poweroff_held_ms = 0; + notify(NOTIFY_WARNING, "Desligando..."); + bq25896_power_off(); // ship mode (no effect while on USB / VBUS present) + } + } else { + s_poweroff_held_ms = 0; + } +} + +static void tick_cb(lv_timer_t *t) { + (void)t; + check_power_combo(); + + s_batt_accum_ms += POWER_POLL_MS; + if (s_batt_accum_ms >= BATT_CHECK_MS) { + s_batt_accum_ms = 0; + check_battery(); + } +} + +bool power_policy_is_asleep(void) { + return false; // the screen never auto-sleeps anymore +} + +void power_policy_init(void) { + if (s_timer != NULL) { + return; + } + s_timer = lv_timer_create(tick_cb, POWER_POLL_MS, NULL); +} diff --git a/firmware_p4/components/Applications/ui/components/reboot/include/reboot_ui.h b/firmware_p4/components/Applications/ui/components/reboot/include/reboot_ui.h new file mode 100644 index 000000000..129231c57 --- /dev/null +++ b/firmware_p4/components/Applications/ui/components/reboot/include/reboot_ui.h @@ -0,0 +1,43 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#ifndef REBOOT_UI_H +#define REBOOT_UI_H + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * @brief Load the full-screen "Rebooting..." screen. + * + * Must be called from the LVGL thread (or with the UI lock held). + */ +void reboot_ui_show(void); + +/** + * @brief Show the reboot screen and restart after a short hold. + * + * Loads the reboot screen, then restarts once it has had time to paint. The + * restart runs the registered system shutdown handler, which unmounts storage. + * Must be called from the LVGL thread. + */ +void reboot_ui_reboot(void); + +#ifdef __cplusplus +} +#endif + +#endif // REBOOT_UI_H diff --git a/firmware_p4/components/Applications/ui/components/reboot/reboot_ui.c b/firmware_p4/components/Applications/ui/components/reboot/reboot_ui.c new file mode 100644 index 000000000..3ffe432e9 --- /dev/null +++ b/firmware_p4/components/Applications/ui/components/reboot/reboot_ui.c @@ -0,0 +1,58 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#include "reboot_ui.h" + +#include "esp_log.h" +#include "esp_system.h" +#include "lvgl.h" + +#include "ui_manager.h" + +static const char *TAG = "REBOOT_UI"; + +#define REBOOT_UI_LABEL LV_SYMBOL_REFRESH " Rebooting..." +#define REBOOT_UI_TEXT_COLOR 0x8A8594 +#define REBOOT_UI_HOLD_MS 450 + +static void reboot_fire_cb(lv_timer_t *timer); + +void reboot_ui_show(void) { + lv_obj_t *scr = lv_obj_create(NULL); + lv_obj_set_style_bg_color(scr, lv_color_black(), 0); + lv_obj_set_style_bg_opa(scr, LV_OPA_COVER, 0); + lv_obj_remove_flag(scr, LV_OBJ_FLAG_SCROLLABLE); + + lv_obj_t *lbl = lv_label_create(scr); + lv_label_set_text(lbl, REBOOT_UI_LABEL); + lv_obj_set_style_text_font(lbl, &lv_font_montserrat_14, 0); + lv_obj_set_style_text_color(lbl, lv_color_hex(REBOOT_UI_TEXT_COLOR), 0); + lv_obj_center(lbl); + + ui_screen_load(scr); +} + +void reboot_ui_reboot(void) { + ESP_LOGI(TAG, "Graceful reboot requested"); + reboot_ui_show(); + + lv_timer_t *timer = lv_timer_create(reboot_fire_cb, REBOOT_UI_HOLD_MS, NULL); + lv_timer_set_repeat_count(timer, 1); +} + +static void reboot_fire_cb(lv_timer_t *timer) { + lv_timer_delete(timer); + esp_restart(); +} diff --git a/firmware_p4/components/Applications/ui/components/sigwave/include/sigwave_ui.h b/firmware_p4/components/Applications/ui/components/sigwave/include/sigwave_ui.h new file mode 100644 index 000000000..fdcb062e8 --- /dev/null +++ b/firmware_p4/components/Applications/ui/components/sigwave/include/sigwave_ui.h @@ -0,0 +1,58 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#ifndef UI_SIGWAVE_H +#define UI_SIGWAVE_H + +#ifdef __cplusplus +extern "C" { +#endif + +#include "lvgl.h" + +/** + * @brief An "IR signal being assembled" animation: a row of vertical pulse bars + * (an IR pulse-train / waveform) whose heights rise and fall in a + * staggered wave, so the signal looks like it's continuously being + * drawn. Looped forever. + * + * Creates a self-contained container positioned in `parent` via align/offset. + * Delete it (or its parent) to stop — the animations go with the objects. + * + * @param parent Container to attach the waveform to. + * @param align Alignment of the container within `parent`. + * @param x_ofs Horizontal offset from the alignment anchor, in pixels. + * @param y_ofs Vertical offset from the alignment anchor, in pixels. + * @return The container object. + */ +lv_obj_t *sigwave_create(lv_obj_t *parent, lv_align_t align, int x_ofs, int y_ofs); + +/** + * @brief Same pulse-train, but drawn complete and static (no animation) — used + * to present a captured signal. + * + * @param parent Container to attach the waveform to. + * @param align Alignment of the container within `parent`. + * @param x_ofs Horizontal offset from the alignment anchor, in pixels. + * @param y_ofs Vertical offset from the alignment anchor, in pixels. + * @return The container object. + */ +lv_obj_t *sigwave_create_static(lv_obj_t *parent, lv_align_t align, int x_ofs, int y_ofs); + +#ifdef __cplusplus +} +#endif + +#endif // UI_SIGWAVE_H diff --git a/firmware_p4/components/Applications/ui/components/sigwave/sigwave_ui.c b/firmware_p4/components/Applications/ui/components/sigwave/sigwave_ui.c new file mode 100644 index 000000000..f236c8459 --- /dev/null +++ b/firmware_p4/components/Applications/ui/components/sigwave/sigwave_ui.c @@ -0,0 +1,112 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#include "sigwave_ui.h" + +#include "ui_theme.h" + +static const int SEG_W[] = {22, 11, 5, 6, 5, 6, 5, 14, 5, 6, 9, 6, 5, 6, 5, 6, 5}; +#define SEG_COUNT ((int)(sizeof(SEG_W) / sizeof(SEG_W[0]))) +#define SIG_W 132 +#define SIG_H 40 +#define SIG_BASE_H 3 +#define SIG_PULSE_H 26 +#define SIG_STEP_MS 85 +#define SIG_HOLD_MS 650 + +static void sig_form_cb(void *var, int32_t v) { + lv_obj_t *holder = (lv_obj_t *)var; + uint32_t n = lv_obj_get_child_count(holder); + for (uint32_t i = 0; i < n; i++) { + lv_obj_t *m = lv_obj_get_child(holder, i); + int32_t local = v - (int32_t)i * 256; + int32_t opa = local <= 0 ? 0 : (local >= 256 ? 255 : local); + lv_obj_set_style_opa(m, (lv_opa_t)opa, 0); + } +} + +static lv_obj_t * +build_sigwave(lv_obj_t *parent, lv_align_t align, int x_ofs, int y_ofs, bool animate) { + lv_obj_t *cont = lv_obj_create(parent); + lv_obj_remove_flag(cont, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(cont, SIG_W, SIG_H); + lv_obj_align(cont, align, x_ofs, y_ofs); + lv_obj_set_style_bg_opa(cont, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(cont, 0, 0); + lv_obj_set_style_pad_all(cont, 0, 0); + + lv_obj_t *base = lv_obj_create(cont); + lv_obj_remove_flag(base, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(base, SIG_W, SIG_BASE_H); + lv_obj_set_pos(base, 0, SIG_H - SIG_BASE_H); + lv_obj_set_style_radius(base, 0, 0); + lv_obj_set_style_bg_color(base, current_theme.border_inactive, 0); + lv_obj_set_style_bg_opa(base, LV_OPA_50, 0); + lv_obj_set_style_border_width(base, 0, 0); + + lv_obj_t *holder = lv_obj_create(cont); + lv_obj_remove_flag(holder, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(holder, SIG_W, SIG_H); + lv_obj_set_pos(holder, 0, 0); + lv_obj_set_style_bg_opa(holder, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(holder, 0, 0); + lv_obj_set_style_pad_all(holder, 0, 0); + + int x = 0; + int marks = 0; + for (int i = 0; i < SEG_COUNT && x < SIG_W; i++) { + int w = SEG_W[i]; + if (x + w > SIG_W) + w = SIG_W - x; + if ((i % 2) == 0 && w > 0) { + lv_obj_t *m = lv_obj_create(holder); + lv_obj_remove_flag(m, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(m, w, SIG_PULSE_H); + lv_obj_set_pos(m, x, SIG_H - SIG_BASE_H - SIG_PULSE_H); + lv_obj_set_style_radius(m, 1, 0); + lv_obj_set_style_bg_color(m, current_theme.border_accent, 0); + lv_obj_set_style_bg_opa(m, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(m, 0, 0); + lv_obj_set_style_opa(m, animate ? LV_OPA_TRANSP : LV_OPA_COVER, 0); + marks++; + } + x += SEG_W[i]; + } + if (marks < 1) + marks = 1; + + if (animate) { + lv_anim_t a; + lv_anim_init(&a); + lv_anim_set_var(&a, holder); + lv_anim_set_exec_cb(&a, sig_form_cb); + lv_anim_set_values(&a, 0, marks * 256); + lv_anim_set_duration(&a, marks * SIG_STEP_MS); + lv_anim_set_repeat_delay(&a, SIG_HOLD_MS); + lv_anim_set_repeat_count(&a, LV_ANIM_REPEAT_INFINITE); + lv_anim_set_path_cb(&a, lv_anim_path_linear); + lv_anim_start(&a); + } + + return cont; +} + +lv_obj_t *sigwave_create(lv_obj_t *parent, lv_align_t align, int x_ofs, int y_ofs) { + return build_sigwave(parent, align, x_ofs, y_ofs, true); +} + +lv_obj_t *sigwave_create_static(lv_obj_t *parent, lv_align_t align, int x_ofs, int y_ofs) { + return build_sigwave(parent, align, x_ofs, y_ofs, false); +} diff --git a/firmware_p4/components/Applications/ui/components/subghz_scope/include/subghz_scope_ui.h b/firmware_p4/components/Applications/ui/components/subghz_scope/include/subghz_scope_ui.h new file mode 100644 index 000000000..0f5b24f5a --- /dev/null +++ b/firmware_p4/components/Applications/ui/components/subghz_scope/include/subghz_scope_ui.h @@ -0,0 +1,51 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#ifndef UI_SUBGHZ_SCOPE_H +#define UI_SUBGHZ_SCOPE_H + +#ifdef __cplusplus +extern "C" { +#endif + +#include "lvgl.h" + +/** + * @brief Animated oscilloscope panel, visually identical to the Sub-GHz Read + * screen (dark scope frame + accent waveform sweeping across it). + * + * Starts an internal animation timer that keeps the trace moving. The timer + * self-stops once the returned frame object is deleted (e.g. when the host + * screen is torn down), so callers only need to keep the frame in their tree. + * + * @param parent Parent object (usually the screen). + * @param align Alignment of the scope frame within the parent. + * @param x_ofs X offset. + * @param y_ofs Y offset. + * @return The scope frame object. + */ +lv_obj_t *subghz_scope_create(lv_obj_t *parent, lv_align_t align, int32_t x_ofs, int32_t y_ofs); + +/** @brief Freeze the trace into a steady decoded (OOK) waveform. */ +void subghz_scope_lock(void); + +/** @brief Stop and release the internal animation timer. */ +void subghz_scope_stop(void); + +#ifdef __cplusplus +} +#endif + +#endif // UI_SUBGHZ_SCOPE_H diff --git a/firmware_p4/components/Applications/ui/components/subghz_scope/subghz_scope_ui.c b/firmware_p4/components/Applications/ui/components/subghz_scope/subghz_scope_ui.c new file mode 100644 index 000000000..703d6fda3 --- /dev/null +++ b/firmware_p4/components/Applications/ui/components/subghz_scope/subghz_scope_ui.c @@ -0,0 +1,191 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#include "subghz_scope_ui.h" + +#include "ui_theme.h" + +#define SCOPE_W 208 +#define SCOPE_H 84 +#define SCOPE_PAD 6 +#define SCOPE_RADIUS 8 +#define SCOPE_BORDER 2 +#define SCOPE_BG 0x0A0614 +#define SCOPE_TICK_MS 38 + +#define WAVE_POINTS 49 +#define WAVE_W (SCOPE_W - SCOPE_PAD * 2 - SCOPE_BORDER * 2) +#define WAVE_H (SCOPE_H - SCOPE_PAD * 2 - SCOPE_BORDER * 2) +#define WAVE_CY (WAVE_H / 2) +#define WAVE_LINE_W 2 + +#define AMP_SCAN (WAVE_H / 2 - 4) +#define AMP_VAR (WAVE_H / 6) +#define AMP_LOCK (WAVE_H / 3) +#define ANGLE_STEP_BASE 15 +#define ANGLE_VAR 9 +#define ANGLE_STEP_LOCK 15 +#define PHASE_STEP_SCAN 34 +#define MOD_STEP 6 +#define NOISE_SPREAD 7 + +#define OOK_SYNC_T 2 +#define OOK_HI_WIDE 3 +#define OOK_HI_NARROW 1 +#define OOK_MAX_PTS 64 + +#define GRID_OPA LV_OPA_20 + +static const uint8_t OOK_BITS[] = {0, 0, 0, 1, 1, 0}; +#define OOK_BIT_COUNT ((int)(sizeof(OOK_BITS) / sizeof(OOK_BITS[0]))) + +static lv_obj_t *s_wave = NULL; +static lv_timer_t *s_timer = NULL; +static lv_point_precise_t s_wave_pts[WAVE_POINTS]; +static lv_point_precise_t s_ook_pts[OOK_MAX_PTS]; +static int s_phase = 0; +static int s_mod = 0; +static bool s_locked = false; + +static int clamp_y(int y) { + if (y < 0) + return 0; + if (y > WAVE_H) + return WAVE_H; + return y; +} + +static void fill_wave(void) { + int step = ANGLE_STEP_BASE + (ANGLE_VAR * lv_trigo_sin((int16_t)(s_mod % 360))) / 32767; + int amp = AMP_SCAN - (AMP_VAR * lv_trigo_sin((int16_t)((s_mod * 2) % 360))) / 32767; + for (int i = 0; i < WAVE_POINTS; i++) { + int ang = (s_phase + i * step) % 360; + if (ang < 0) + ang += 360; + int s = lv_trigo_sin((int16_t)ang); + int y = WAVE_CY - (amp * s) / 32767; + y += ((i * 13 + s_phase) % NOISE_SPREAD) - NOISE_SPREAD / 2; + s_wave_pts[i].x = i * WAVE_W / (WAVE_POINTS - 1); + s_wave_pts[i].y = clamp_y(y); + } + if (s_wave != NULL) + lv_line_set_points(s_wave, s_wave_pts, WAVE_POINTS); +} + +static void fill_ook(void) { + if (s_wave == NULL) + return; + int total_t = OOK_SYNC_T + OOK_BIT_COUNT * (OOK_HI_WIDE + OOK_HI_NARROW); + int unit = WAVE_W / total_t; + if (unit < 1) + unit = 1; + int hi = WAVE_CY - AMP_LOCK; + int lo = WAVE_CY + AMP_LOCK; + int n = 0; + int x = 0; + s_ook_pts[n].x = x; + s_ook_pts[n].y = lo; + n++; + x += OOK_SYNC_T * unit; + s_ook_pts[n].x = x; + s_ook_pts[n].y = lo; + n++; + for (int b = 0; b < OOK_BIT_COUNT && n + 4 <= OOK_MAX_PTS; b++) { + int hw = (OOK_BITS[b] ? OOK_HI_WIDE : OOK_HI_NARROW) * unit; + int lw = (OOK_BITS[b] ? OOK_HI_NARROW : OOK_HI_WIDE) * unit; + s_ook_pts[n].x = x; + s_ook_pts[n].y = hi; + n++; + x += hw; + s_ook_pts[n].x = x; + s_ook_pts[n].y = hi; + n++; + s_ook_pts[n].x = x; + s_ook_pts[n].y = lo; + n++; + x += lw; + s_ook_pts[n].x = x; + s_ook_pts[n].y = lo; + n++; + } + lv_line_set_points(s_wave, s_ook_pts, n); +} + +static void scope_tick_cb(lv_timer_t *t) { + if (!lv_obj_is_valid(s_wave)) { + lv_timer_delete(t); + s_timer = NULL; + s_wave = NULL; + return; + } + if (s_locked) + return; + s_phase = (s_phase + PHASE_STEP_SCAN) % 360; + s_mod = (s_mod + MOD_STEP) % 360; + fill_wave(); +} + +void subghz_scope_stop(void) { + if (s_timer != NULL) { + lv_timer_delete(s_timer); + s_timer = NULL; + } +} + +void subghz_scope_lock(void) { + s_locked = true; + if (lv_obj_is_valid(s_wave)) { + lv_obj_set_style_line_rounded(s_wave, false, 0); + fill_ook(); + } +} + +lv_obj_t *subghz_scope_create(lv_obj_t *parent, lv_align_t align, int32_t x_ofs, int32_t y_ofs) { + subghz_scope_stop(); + s_locked = false; + s_phase = 0; + s_mod = 0; + + lv_obj_t *frame = lv_obj_create(parent); + lv_obj_remove_flag(frame, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(frame, SCOPE_W, SCOPE_H); + lv_obj_align(frame, align, x_ofs, y_ofs); + lv_obj_set_style_radius(frame, SCOPE_RADIUS, 0); + lv_obj_set_style_pad_all(frame, SCOPE_PAD, 0); + lv_obj_set_style_bg_color(frame, lv_color_hex(SCOPE_BG), 0); + lv_obj_set_style_bg_opa(frame, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(frame, SCOPE_BORDER, 0); + lv_obj_set_style_border_color(frame, current_theme.border_accent, 0); + lv_obj_set_style_border_opa(frame, LV_OPA_70, 0); + + lv_obj_t *grid = lv_obj_create(frame); + lv_obj_remove_flag(grid, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(grid, WAVE_W, 1); + lv_obj_align(grid, LV_ALIGN_CENTER, 0, 0); + lv_obj_set_style_border_width(grid, 0, 0); + lv_obj_set_style_radius(grid, 0, 0); + lv_obj_set_style_bg_color(grid, current_theme.border_accent, 0); + lv_obj_set_style_bg_opa(grid, GRID_OPA, 0); + + s_wave = lv_line_create(frame); + lv_obj_align(s_wave, LV_ALIGN_TOP_LEFT, 0, 0); + lv_obj_set_style_line_width(s_wave, WAVE_LINE_W, 0); + lv_obj_set_style_line_color(s_wave, current_theme.border_accent, 0); + lv_obj_set_style_line_rounded(s_wave, true, 0); + + fill_wave(); + s_timer = lv_timer_create(scope_tick_cb, SCOPE_TICK_MS, NULL); + return frame; +} diff --git a/firmware_p4/components/Applications/ui/components/text_viewer/text_viewer_ui.c b/firmware_p4/components/Applications/ui/components/text_viewer/text_viewer_ui.c index 2551ed14b..d25d8ccea 100644 --- a/firmware_p4/components/Applications/ui/components/text_viewer/text_viewer_ui.c +++ b/firmware_p4/components/Applications/ui/components/text_viewer/text_viewer_ui.c @@ -17,45 +17,24 @@ #include #include -#include #include "st7789.h" -#include "assets_manager.h" +#include "ui_chrome.h" #include "ui_theme.h" -#define BORDER_COLOR current_theme.border_interface -#define ITEM_BORDER current_theme.border_accent -#define GRAD_LEFT current_theme.bg_primary -#define GRAD_RIGHT current_theme.bg_secondary -#define OUTER_BORDER 4 -#define TOP_BORDER_H 46 - -static text_viewer_t *active_viewer = NULL; - -static void scroll_event_cb(lv_event_t *e) { - if (!active_viewer || !active_viewer->scroll_bar) - return; - lv_obj_t *area = lv_event_get_target(e); - - int32_t scroll_y = lv_obj_get_scroll_y(area); - int32_t scroll_max = lv_obj_get_scroll_bottom(area) + scroll_y; - if (scroll_max <= 0) - return; - - int32_t pct = (scroll_y * 100) / scroll_max; - if (pct < 0) - pct = 0; - if (pct > 100) - pct = 100; - - int32_t bar_y = active_viewer->track_y_start + (pct * (active_viewer->track_h - 20)) / 100; - lv_obj_set_y(active_viewer->scroll_bar, bar_y); -} +#define META_H 16 // "N lines · M bytes" line under the header +#define GAP 4 // vertical breathing room +#define BODY_PAD_H 10 // horizontal inset of the text +#define BODY_PAD_V 6 // vertical inset of the text +#define SCROLLBAR_W 4 // themed scrollbar width +#define LINE_SPACE 4 // extra spacing between wrapped lines text_viewer_t text_viewer_create(lv_obj_t *parent, const char *filename) { text_viewer_t tv = {0}; + // Borderless full-screen surface; the chrome header/footer carry the framing so + // the viewer matches every other screen. tv.screen = lv_obj_create(parent); lv_obj_set_size(tv.screen, LCD_H_RES, LCD_V_RES); lv_obj_align(tv.screen, LV_ALIGN_TOP_LEFT, 0, 0); @@ -63,85 +42,42 @@ text_viewer_t text_viewer_create(lv_obj_t *parent, const char *filename) { lv_obj_set_style_bg_color(tv.screen, current_theme.screen_base, 0); lv_obj_set_style_bg_opa(tv.screen, LV_OPA_COVER, 0); lv_obj_set_style_pad_all(tv.screen, 0, 0); - lv_obj_set_style_border_width(tv.screen, OUTER_BORDER, 0); - lv_obj_set_style_border_color(tv.screen, BORDER_COLOR, 0); + lv_obj_set_style_border_width(tv.screen, 0, 0); lv_obj_set_style_radius(tv.screen, 0, 0); - lv_obj_t *top_area = lv_obj_create(tv.screen); - lv_obj_set_size(top_area, LCD_H_RES - OUTER_BORDER * 2, TOP_BORDER_H); - lv_obj_align(top_area, LV_ALIGN_TOP_MID, 0, 0); - lv_obj_remove_flag(top_area, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_set_style_bg_opa(top_area, LV_OPA_TRANSP, 0); - lv_obj_set_style_border_width(top_area, 3, 0); - lv_obj_set_style_border_color(top_area, BORDER_COLOR, 0); - lv_obj_set_style_border_side(top_area, LV_BORDER_SIDE_BOTTOM, 0); - lv_obj_set_style_radius(top_area, 0, 0); - lv_obj_set_style_pad_all(top_area, 0, 0); - - lv_obj_t *title_bar = lv_obj_create(top_area); - lv_obj_set_size(title_bar, 190, 30); - lv_obj_align(title_bar, LV_ALIGN_CENTER, 0, 0); - lv_obj_remove_flag(title_bar, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_set_style_radius(title_bar, 12, 0); - lv_obj_set_style_bg_opa(title_bar, LV_OPA_COVER, 0); - lv_obj_set_style_bg_color(title_bar, GRAD_LEFT, 0); - lv_obj_set_style_bg_grad_color(title_bar, GRAD_RIGHT, 0); - lv_obj_set_style_bg_grad_dir(title_bar, LV_GRAD_DIR_HOR, 0); - lv_obj_set_style_border_width(title_bar, 2, 0); - lv_obj_set_style_border_color(title_bar, ITEM_BORDER, 0); - - tv.title_label = lv_label_create(title_bar); - lv_label_set_text(tv.title_label, filename ? filename : "File"); - lv_obj_set_style_text_color(tv.title_label, current_theme.text_main, 0); - lv_obj_set_style_text_font(tv.title_label, &lv_font_montserrat_12, 0); - lv_obj_center(tv.title_label); + ui_chrome_header(tv.screen, filename ? filename : "File", NULL); + // Meta line (line/byte count), right-aligned just under the header. tv.line_label = lv_label_create(tv.screen); lv_label_set_text(tv.line_label, ""); - lv_obj_set_style_text_color(tv.line_label, current_theme.border_accent, 0); + lv_obj_set_style_text_color(tv.line_label, current_theme.text_main, 0); + lv_obj_set_style_text_opa(tv.line_label, LV_OPA_50, 0); lv_obj_set_style_text_font(tv.line_label, &lv_font_montserrat_12, 0); - lv_obj_align(tv.line_label, LV_ALIGN_TOP_MID, 0, TOP_BORDER_H + 2); + lv_obj_align(tv.line_label, LV_ALIGN_TOP_RIGHT, -BODY_PAD_H, UI_CHROME_HEADER_H + GAP); - int content_y = TOP_BORDER_H + 18; - int content_h = LCD_V_RES - content_y - OUTER_BORDER - 4; + int content_y = UI_CHROME_HEADER_H + GAP + META_H + GAP; + int content_h = LCD_V_RES - content_y - UI_CHROME_FOOTER_H - GAP; + // Scrollable body: borderless, themed thin scrollbar, and NO elastic/momentum + // so a button scroll clamps hard at the content bounds (no runaway scrolling). tv.text_area = lv_obj_create(tv.screen); - lv_obj_set_size(tv.text_area, LCD_H_RES - OUTER_BORDER * 2 - 16, content_h); - lv_obj_align(tv.text_area, LV_ALIGN_TOP_LEFT, OUTER_BORDER + 4, content_y); + lv_obj_set_size(tv.text_area, LCD_H_RES, content_h); + lv_obj_align(tv.text_area, LV_ALIGN_TOP_LEFT, 0, content_y); lv_obj_set_style_bg_opa(tv.text_area, LV_OPA_TRANSP, 0); lv_obj_set_style_border_width(tv.text_area, 0, 0); - lv_obj_set_style_pad_all(tv.text_area, 6, 0); - lv_obj_set_scrollbar_mode(tv.text_area, LV_SCROLLBAR_MODE_OFF); - - int track_x = LCD_H_RES - OUTER_BORDER - 10; - tv.track_y_start = content_y + 10; - tv.track_h = content_h - 20; - - static lv_point_precise_t track_pts[2]; - track_pts[0].x = 0; - track_pts[0].y = 0; - track_pts[1].x = 0; - track_pts[1].y = tv.track_h; - - lv_obj_t *track = lv_line_create(tv.screen); - lv_line_set_points(track, track_pts, 2); - lv_obj_set_pos(track, track_x, tv.track_y_start); - lv_obj_set_style_line_color(track, current_theme.text_main, 0); - lv_obj_set_style_line_opa(track, LV_OPA_COVER, 0); - lv_obj_set_style_line_width(track, 3, 0); - lv_obj_set_style_line_dash_width(track, 4, 0); - lv_obj_set_style_line_dash_gap(track, 4, 0); - - static lv_image_dsc_t *sb_dsc = NULL; - if (!sb_dsc) - sb_dsc = assets_get("/assets/icons/slide_bar_v.bin"); - tv.scroll_bar = lv_image_create(tv.screen); - if (sb_dsc) - lv_image_set_src(tv.scroll_bar, sb_dsc); - lv_obj_set_pos(tv.scroll_bar, track_x - 4, tv.track_y_start); - lv_obj_move_foreground(tv.scroll_bar); - - lv_obj_add_event_cb(tv.text_area, scroll_event_cb, LV_EVENT_SCROLL, NULL); + lv_obj_set_style_radius(tv.text_area, 0, 0); + lv_obj_set_style_pad_hor(tv.text_area, BODY_PAD_H, 0); + lv_obj_set_style_pad_ver(tv.text_area, BODY_PAD_V, 0); + lv_obj_set_scroll_dir(tv.text_area, LV_DIR_VER); + lv_obj_remove_flag(tv.text_area, LV_OBJ_FLAG_SCROLL_ELASTIC); + lv_obj_remove_flag(tv.text_area, LV_OBJ_FLAG_SCROLL_MOMENTUM); + lv_obj_set_scrollbar_mode(tv.text_area, LV_SCROLLBAR_MODE_AUTO); + lv_obj_set_style_bg_color(tv.text_area, current_theme.border_accent, LV_PART_SCROLLBAR); + lv_obj_set_style_bg_opa(tv.text_area, LV_OPA_COVER, LV_PART_SCROLLBAR); + lv_obj_set_style_width(tv.text_area, SCROLLBAR_W, LV_PART_SCROLLBAR); + lv_obj_set_style_radius(tv.text_area, 2, LV_PART_SCROLLBAR); + + ui_chrome_footer(tv.screen, LV_SYMBOL_UP LV_SYMBOL_DOWN " Scroll BACK Close"); return tv; } @@ -152,10 +88,7 @@ void text_viewer_load_file(text_viewer_t *tv, const char *path) { FILE *f = fopen(path, "r"); if (f == NULL) { - lv_obj_t *lbl = lv_label_create(tv->text_area); - lv_label_set_text(lbl, "Failed to open file"); - lv_obj_set_style_text_color(lbl, current_theme.border_accent, 0); - lv_obj_set_style_text_font(lbl, &lv_font_montserrat_12, 0); + text_viewer_set_text(tv, "Failed to open file"); return; } @@ -163,6 +96,8 @@ void text_viewer_load_file(text_viewer_t *tv, const char *path) { long size = ftell(f); fseek(f, 0, SEEK_SET); + if (size < 0) + size = 0; if (size > 4096) size = 4096; @@ -172,8 +107,8 @@ void text_viewer_load_file(text_viewer_t *tv, const char *path) { return; } - fread(buf, 1, size, f); - buf[size] = '\0'; + size_t rd = fread(buf, 1, size, f); + buf[rd] = '\0'; fclose(f); text_viewer_set_text(tv, buf); @@ -190,8 +125,9 @@ void text_viewer_set_text(text_viewer_t *tv, const char *text) { lv_label_set_text(lbl, text ? text : ""); lv_obj_set_style_text_color(lbl, current_theme.text_main, 0); lv_obj_set_style_text_font(lbl, &lv_font_montserrat_12, 0); - lv_obj_set_width(lbl, LCD_H_RES - OUTER_BORDER * 2 - 36); + lv_obj_set_width(lbl, LCD_H_RES - BODY_PAD_H * 2 - SCROLLBAR_W - 2); lv_label_set_long_mode(lbl, LV_LABEL_LONG_WRAP); + lv_obj_set_style_text_line_space(lbl, LINE_SPACE, 0); if (tv->line_label && text) { int lines = 1; @@ -208,5 +144,5 @@ void text_viewer_set_text(text_viewer_t *tv, const char *text) { } } - active_viewer = tv; + lv_obj_scroll_to_y(tv->text_area, 0, LV_ANIM_OFF); } diff --git a/firmware_p4/components/Applications/ui/components/tutorial/include/screen_tips.h b/firmware_p4/components/Applications/ui/components/tutorial/include/screen_tips.h new file mode 100644 index 000000000..d0bc2bc4d --- /dev/null +++ b/firmware_p4/components/Applications/ui/components/tutorial/include/screen_tips.h @@ -0,0 +1,55 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#ifndef SCREEN_TIPS_H +#define SCREEN_TIPS_H + +#ifdef __cplusplus +extern "C" { +#endif + +#include + +#include "ui_manager.h" + +/** + * @brief First-view screen tip. Call right after a screen is loaded and added + * to the input group. The first time a screen with a tip is opened, + * Octobit's explanation pops up over it (the screen behind dims and + * locks; OK/BACK dismisses). Seen screens are tracked in NVS and never + * nag again. No-op if the screen has no tip, was already seen, or a tip + * is already up. + */ +void screen_tips_hook(screen_id_t screen); + +/** @brief True while a screen tip is on screen and holding the input. */ +bool screen_tips_active(void); + +/** + * @brief Feed one input event to an active tip. While a tip is up it owns all + * input: OK/BACK/RIGHT dismisses it (after the intro settles) and every + * other event is swallowed. The caller must NOT also dispatch the event + * to the underlying screen. No-op if no tip is active. + */ +void screen_tips_handle_input(const input_event_t *ev); + +/** @brief Clear every seen flag so all first-view tips play again. */ +void screen_tips_reset(void); + +#ifdef __cplusplus +} +#endif + +#endif // SCREEN_TIPS_H diff --git a/firmware_p4/components/Applications/ui/components/tutorial/include/tutorial_ui.h b/firmware_p4/components/Applications/ui/components/tutorial/include/tutorial_ui.h new file mode 100644 index 000000000..eea077670 --- /dev/null +++ b/firmware_p4/components/Applications/ui/components/tutorial/include/tutorial_ui.h @@ -0,0 +1,49 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#ifndef TUTORIAL_UI_H +#define TUTORIAL_UI_H + +#ifdef __cplusplus +extern "C" { +#endif + +#include + +/** + * @brief First-boot onboarding wizard: a full-screen sequence of pages + * (setup + welcome) driven with OK (next) / BACK (previous). On the last + * page OK marks it done in NVS, closes the overlay and loads the home + * screen. It hijacks the input group while up. + * + * Call once at boot, right after the home screen is opened, guarded by + * tutorial_should_run(). + */ +void tutorial_start(void); + +/** @brief True on the very first boot (the "done" NVS flag is not set yet). */ +bool tutorial_should_run(void); + +/** @brief True while the onboarding wizard is on screen and holding the input. */ +bool tutorial_is_active(void); + +/** @brief Clear the "done" flag so the wizard runs again on next boot/start. */ +void tutorial_reset(void); + +#ifdef __cplusplus +} +#endif + +#endif // TUTORIAL_UI_H diff --git a/firmware_p4/components/Applications/ui/components/tutorial/screen_tips.c b/firmware_p4/components/Applications/ui/components/tutorial/screen_tips.c new file mode 100644 index 000000000..82dcab0d0 --- /dev/null +++ b/firmware_p4/components/Applications/ui/components/tutorial/screen_tips.c @@ -0,0 +1,527 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#include "screen_tips.h" + +#include + +#include "esp_log.h" +#include "nvs.h" + +#include "lvgl.h" + +#include "assets_manager.h" +#include "ui_theme.h" + +#define TIP_NVS_NS "scrtips" +#define TIP_NVS_KEY "seen" +#define TIP_NVS_KEY_SKIP "skip" +#define TIP_WORDS ((SCREEN_COUNT + 31) / 32) + +#define TIP_SCRIM_OPA 232 // how dark the screen behind gets +#define TIP_ARM_MS 500 // ignore input while the intro plays +#define TIP_TEXT_W 182 +#define TIP_ART "/assets/img/image.bin" + +extern lv_group_t *main_group; + +static const char *TAG = "SCRTIPS"; + +typedef struct { + screen_id_t id; + const char *title; + const char *tip; +} tip_entry_t; + +static const tip_entry_t TIPS[] = { + {SCREEN_HOME, + "HOME", + "Hey, I'm Octobit! This is home base. Right opens the apps, Down the settings, Left my " + "status."}, + {SCREEN_MENU, + "APP CAROUSEL", + "Spin the carousel with Left/Right and press OK to open a tool. Every gadget lives here."}, + + {SCREEN_WIFI_MENU, + "WI-FI", + "The whole Wi-Fi arsenal lives here: scan networks, map channels, hunt clients and much " + "more."}, + {SCREEN_WIFI_ATTACK_MENU, + "WI-FI ATTACKS", + "Five 802.11 attacks wait here: Deauth, Beacon Spam, Probe Flood, Auth Flood and Karma."}, + {SCREEN_WIFI_HANDSHAKE, + "WPA HANDSHAKE", + "The trophy: I grab the WPA handshake (M1-M4) and the PMKID, saved as .pcap or .hccapx to " + "crack later."}, + {SCREEN_WIFI_CLIENTS, + "CLIENT MAP", + "This map links each router to its devices; the line color tells you who is strong or weak."}, + {SCREEN_WIFI_EVIL_TWIN, + "EVIL TWIN", + "I raise a fake 'FreeWiFi_5G' AP to lure victims: every MAC that joins is logged and " + "counted."}, + {SCREEN_WIFI_PACKETS_MENU, + "PACKET SNIFFER", + "Four sniffer modes - Raw, EAPOL, Beacon and PMKID - with live rate, all saved to a .pcap."}, + {SCREEN_WIFI_CHANNELS, + "CHANNEL ANALYZER", + "Each wave shows how many networks crowd a channel, and I point out the clearest one."}, + {SCREEN_WIFI_SIGNAL_LOCATOR, + "SIGNAL LOCATOR", + "Hunting a hidden router? The arc heats up as you get closer and tells you warm or cold."}, + {SCREEN_WIFI_DEAUTH_DETECTOR, + "DEAUTH DETECTOR", + "Watching channels 1-13, it counts deauth frames and flashes red if someone knocks a network " + "offline."}, + + {SCREEN_BLE_MENU, + "BLUETOOTH", + "The Bluetooth hub: device spam, passive detection, HID keyboard and radio control, all in " + "one."}, + {SCREEN_GATT_EXPLORER, + "GATT EXPLORER", + "The GATT Explorer opens a target's services and characteristics: UUIDs, R/W/N props and live " + "values."}, + {SCREEN_BLE_KEYBOARD, + "BLE HID KEYBOARD", + "Posing as a BLE keyboard, the High Boy pairs with a target and injects keys - a wireless " + "BadUSB."}, + {SCREEN_BLE_SPAM_SELECT, + "DEVICE SPAM", + "Pick a profile - Apple Juice, SourApple, Android or Windows - and unleash fake BLE adverts."}, + {SCREEN_BLE_TRACKER, + "TRACKER HUNTER", + "The tracker hunter listens for nearby AirTags and Tiles and alarms if one seems to follow " + "you."}, + + {SCREEN_NFC_MENU, + "NFC", + "The whole 13.56 MHz NFC world: read tags, write, emulate and store your cards."}, + {SCREEN_CARD_EMU, + "CARD EMULATION", + "Build a card from scratch or pick a saved one, and the High Boy broadcasts it as the real " + "tag."}, + {SCREEN_NFC_READ, + "READ TAG", + "Bring a tag close and I decode its type and UID, then dump the keys and sectors in a snap."}, + {SCREEN_NFC_WRITE, + "WRITE TO BLANK", + "Choose a saved card, tap a blank tag, and I copy the data onto it - a clone, ready to go."}, + {SCREEN_NFC_SCAN, + "TECH SCAN", + "Not sure of the tag type? This scan probes NFC-A, B, F and V and shows which protocol " + "answers."}, + {SCREEN_NFC_BANKCARD, + "EMV BANK CARD", + "Here I read a contactless bank card and reveal the PAN, the network and the EMV chip's AID."}, + {SCREEN_NFC_DESFIRE, + "MIFARE DESFIRE", + "MIFARE DESFire opens with AES-128 auth and a CMAC session, and then I dump its files."}, + {SCREEN_NFC_P2P, + "P2P SHARE", + "In this mode I push an NDEF straight to a phone, negotiating LLCP and SNEP before the " + "transfer."}, + {SCREEN_NFC_ISO15693, + "NFC-V / ISO15693", + "ISO15693 vicinity tags reach farther; scroll the blocks to read, write and spot the locked " + "ones."}, + {SCREEN_NFC_KEYDICT, + "KEY DICTIONARY", + "The dictionary holds the MIFARE keys the reader tries; load a .dic from the SD card and edit " + "it."}, + + {SCREEN_SUBGHZ_MENU, + "SUB-GHZ", + "The Sub-GHz radio: capture, analyze and replay remote signals on 433, 868 and 315 MHz."}, + {SCREEN_SUBGHZ_BRUTE, + "CODE BRUTE FORCE", + "Brute Force fires thousands of codes across a range until one pops the gate - no key " + "needed."}, + {SCREEN_SUBGHZ_READ, + "CAPTURE & DECODE", + "In Read it listens to the carrier, locks on the signal and decodes protocol, modulation, " + "rate and key."}, + {SCREEN_RFID_MENU, + "RFID 125 kHz", + "RFID reads LF 125 kHz tags: read, emulate, add one by hand, even clone a whole access " + "badge."}, + {SCREEN_SUBGHZ_CONFIG, + "RADIO CONFIG", + "For the tough ones, Radio Config tunes modulation, bandwidth, data rate and preset before " + "capture."}, + + {SCREEN_IR_MENU, + "INFRARED", + "All things infrared: capture signals, send them, act as a remote, or fire a burst."}, + {SCREEN_IR_CONTROLLER, + "UNIVERSAL REMOTE", + "Turns into a universal remote - a TV, audio or A/C faceplate - and OK fires the focused " + "key."}, + {SCREEN_IR_RECEIVE, + "LEARN A SIGNAL", + "In Learn, aim an unknown remote: it listens, decodes the protocol and stores it to replay " + "later."}, + {SCREEN_IR_BURST, + "SIGNAL BURST", + "Burst fires many saved signals back-to-back - perfect to shut off every nearby TV at once."}, + {SCREEN_IR_RAW, + "RAW SIGNAL", + "With no known protocol, RAW keeps the pure pulse train and lets you tune the carrier, 36 to " + "40 kHz."}, + + {SCREEN_LORA_CHAT, + "LORA MESH", + "Step into the LoRa mesh: pick MeshCore or Meshtastic, see the nodes on the map and chat " + "off-grid."}, + {SCREEN_LORA_SECURE_DM, + "ENCRYPTED DM", + "Direct messages with per-contact X25519 keys. Compare fingerprints to be sure who is on the " + "other side."}, + {SCREEN_LORA_TRACEROUTE, + "MESH TRACEROUTE", + "Want your message's path? Traceroute maps every hop and shows the SNR of each leg."}, + {SCREEN_LORA_RNODE, + "RNODE / KISS", + "In RNode mode the radio becomes a KISS modem: set frequency, SF and power, count raw RX/TX " + "packets."}, + {SCREEN_LORA_MQTT, + "MQTT BRIDGE", + "The MQTT bridge links your mesh to the internet: set broker, user and password, then " + "connect."}, + + {SCREEN_BADUSB_MENU, + "BADUSB", + "Here the High Boy becomes a fake USB keyboard: run payloads, pick scripts and drive the HID " + "mouse."}, + {SCREEN_BADUSB_RUNNING, + "RUN PAYLOAD", + "Once plugged in, it types on its own like a keyboard, opens a terminal and injects the " + "payload."}, + {SCREEN_BADUSB_BROWSER, + "PAYLOAD LIBRARY", + "Each .duck file holds a keystroke script; preview it before you launch the attack."}, + {SCREEN_USB_MOUSE, + "HID MOUSE", + "It also becomes a USB mouse: move the cursor, click, scroll, and the jiggler keeps the " + "screen awake."}, + {SCREEN_BADUSB_LAYOUT, + "KEYBOARD LAYOUT", + "The layout must match the target keyboard: pick US, UK, DE, FR or BR so keys don't come out " + "wrong."}, + + {SCREEN_DEV_MENU, + "DEVELOPER", + "The developer area: Scripts, Console, P4 Update and Diagnostics, all in one place."}, + {SCREEN_SCRIPTS, + "SCRIPTS", + "The .js scripts show capability badges; if one wants USB or Wi-Fi, I ask your permission " + "first."}, + {SCREEN_STORAGE, + "STORAGE", + "See real SD and internal memory usage here. Careful: formatting wipes the whole card for " + "good."}, + {SCREEN_SYSTEM_UPDATE, + "P4 UPDATE", + "This screen fetches new firmware, installs the P4 update and reboots on its own. Don't power " + "off."}, + {SCREEN_FILES, + "FILES", + "This browser walks your real files: internal memory and the SD card, in list or grid view."}, + + {SCREEN_GPIO, + "GPIO BUS", + "The GPIO bus shows IO1-IO8, 5V and UART as green LEDs. Select a pin and press OK to toggle " + "it."}, + {SCREEN_HAPTIC, + "HAPTIC BENCH", + "The vibration bench drives the real DRV2605L motor: pick effects, fire patterns and " + "calibrate the ERM."}, + {SCREEN_SPEAKER, + "SPEAKER", + "On the speaker I synth chiptunes note by note over I2S: Mario, Tetris and a live 10-band " + "equalizer."}, + {SCREEN_MIC_REC, + "RECORDER", + "Capture up to 5s from the mic and play it back on the speaker, watching the live VU and " + "waveform."}, + + {SCREEN_SETTINGS, + "SETTINGS", + "All your settings: connections, display, sound, power and system. Pick a section and I'll " + "explain."}, + {SCREEN_POWER, + "POWER CONSOLE", + "Here I talk to the BQ25896 chip live: battery voltage, current and faults, plus I2C scan and " + "power off."}, + {SCREEN_OCTOBIT_STATUS, + "OCTOBIT STATUS", + "This is my status card: level, XP and real device stats like boots, battery and free " + "memory."}, + {SCREEN_THEME_SELECTOR, + "THEMES", + "Spin the carousel to switch theme: 12 palettes repaint the whole UI. Press OK to make it " + "yours."}, + {SCREEN_COMPANION_PAIRING, + "PAIR THE APP", + "To hook up the phone app, this screen shows the Bluetooth pairing code. Confirm in the app " + "to connect."}, +}; + +#define TIP_COUNT ((int)(sizeof(TIPS) / sizeof(TIPS[0]))) + +static bool s_active = false; +static bool s_seen_loaded = false; +static bool s_skip = false; +static uint32_t s_seen[TIP_WORDS]; +static lv_obj_t *s_scrim = NULL; +static lv_obj_t *s_prev_focus = NULL; +static uint32_t s_open_tick = 0; + +static const tip_entry_t *tip_for(screen_id_t screen) { + for (int i = 0; i < TIP_COUNT; i++) { + if (TIPS[i].id == screen) + return &TIPS[i]; + } + return NULL; +} + +static void seen_load(void) { + if (s_seen_loaded) + return; + memset(s_seen, 0, sizeof(s_seen)); + nvs_handle_t h; + if (nvs_open(TIP_NVS_NS, NVS_READONLY, &h) == ESP_OK) { + size_t len = sizeof(s_seen); + nvs_get_blob(h, TIP_NVS_KEY, s_seen, &len); + uint8_t skip = 0; + nvs_get_u8(h, TIP_NVS_KEY_SKIP, &skip); + s_skip = (skip != 0); + nvs_close(h); + } + s_seen_loaded = true; +} + +static bool seen_get(screen_id_t screen) { + uint32_t idx = (uint32_t)screen; + if (idx >= (uint32_t)SCREEN_COUNT) + return true; + return (s_seen[idx / 32] >> (idx % 32)) & 1u; +} + +static void seen_mark(screen_id_t screen) { + uint32_t idx = (uint32_t)screen; + if (idx >= (uint32_t)SCREEN_COUNT) + return; + s_seen[idx / 32] |= (1u << (idx % 32)); + nvs_handle_t h; + if (nvs_open(TIP_NVS_NS, NVS_READWRITE, &h) == ESP_OK) { + nvs_set_blob(h, TIP_NVS_KEY, s_seen, sizeof(s_seen)); + nvs_commit(h); + nvs_close(h); + } +} + +static void hijack_input(void) { + if (main_group == NULL) + return; + lv_obj_t *cur = lv_group_get_focused(main_group); + if (cur != NULL && cur != s_scrim) + s_prev_focus = cur; + lv_group_remove_all_objs(main_group); + lv_group_add_obj(main_group, s_scrim); + lv_group_focus_obj(s_scrim); + lv_group_set_editing(main_group, false); +} + +static void dismiss(void) { + if (!s_active) + return; + s_active = false; + + lv_obj_t *scrim = s_scrim; + lv_obj_t *restore = s_prev_focus; + s_scrim = NULL; + s_prev_focus = NULL; + + if (main_group != NULL && scrim != NULL) + lv_group_remove_obj(scrim); + if (scrim != NULL) + lv_obj_del_async(scrim); + if (main_group != NULL && restore != NULL) { + lv_group_add_obj(main_group, restore); + lv_group_focus_obj(restore); + } +} + +static void scrim_key_cb(lv_event_t *e) { + if (lv_event_get_code(e) != LV_EVENT_KEY) + return; + if (lv_tick_get() - s_open_tick < TIP_ARM_MS) + return; + uint32_t key = lv_event_get_key(e); + if (key == LV_KEY_ENTER || key == LV_KEY_ESC || key == LV_KEY_RIGHT) + dismiss(); +} + +void screen_tips_handle_input(const input_event_t *ev) { + if (!s_active || ev == NULL) + return; + if (ev->action != INPUT_ACTION_PRESS) + return; + if (lv_tick_get() - s_open_tick < TIP_ARM_MS) + return; + if (ev->button == INPUT_BTN_OK || ev->button == INPUT_BTN_BACK || ev->button == INPUT_BTN_RIGHT) + dismiss(); +} + +static void tip_opa_cb(void *o, int32_t v) { + lv_obj_set_style_opa((lv_obj_t *)o, (lv_opa_t)v, 0); +} +static void tip_bgopa_cb(void *o, int32_t v) { + lv_obj_set_style_bg_opa((lv_obj_t *)o, (lv_opa_t)v, 0); +} +static void tip_ty_cb(void *o, int32_t v) { + lv_obj_set_style_translate_y((lv_obj_t *)o, v, 0); +} + +// Fade an element in from transparent, after `delay` ms. +static void tip_fade(lv_obj_t *o, uint32_t delay) { + lv_obj_set_style_opa(o, LV_OPA_TRANSP, 0); + lv_anim_t a; + lv_anim_init(&a); + lv_anim_set_var(&a, o); + lv_anim_set_exec_cb(&a, tip_opa_cb); + lv_anim_set_values(&a, LV_OPA_TRANSP, LV_OPA_COVER); + lv_anim_set_duration(&a, 280); + lv_anim_set_delay(&a, delay); + lv_anim_set_path_cb(&a, lv_anim_path_ease_out); + lv_anim_start(&a); +} + +// Gentle continuous float for the mascot. +static void tip_bob(lv_obj_t *o) { + lv_anim_t a; + lv_anim_init(&a); + lv_anim_set_var(&a, o); + lv_anim_set_exec_cb(&a, tip_ty_cb); + lv_anim_set_values(&a, -5, 5); + lv_anim_set_duration(&a, 1600); + lv_anim_set_reverse_duration(&a, 1600); + lv_anim_set_repeat_count(&a, LV_ANIM_REPEAT_INFINITE); + lv_anim_set_path_cb(&a, lv_anim_path_ease_in_out); + lv_anim_start(&a); +} + +static void build_overlay(const tip_entry_t *entry) { + lv_obj_t *scrim = lv_obj_create(lv_layer_top()); + lv_obj_set_size(scrim, LV_PCT(100), LV_PCT(100)); + lv_obj_set_pos(scrim, 0, 0); + lv_obj_remove_flag(scrim, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_add_flag(scrim, LV_OBJ_FLAG_CLICKABLE); + lv_obj_set_style_bg_color(scrim, lv_color_black(), 0); + lv_obj_set_style_bg_opa(scrim, LV_OPA_TRANSP, 0); // fades in — the screen darkens + lv_obj_set_style_border_width(scrim, 0, 0); + lv_obj_set_style_radius(scrim, 0, 0); + lv_obj_set_style_pad_all(scrim, 0, 0); + lv_obj_add_event_cb(scrim, scrim_key_cb, LV_EVENT_KEY, NULL); + s_scrim = scrim; + + // The screen behind darkens first. + lv_anim_t a; + lv_anim_init(&a); + lv_anim_set_var(&a, scrim); + lv_anim_set_exec_cb(&a, tip_bgopa_cb); + lv_anim_set_values(&a, 0, TIP_SCRIM_OPA); + lv_anim_set_duration(&a, 240); + lv_anim_set_path_cb(&a, lv_anim_path_ease_out); + lv_anim_start(&a); + + // No boxed window: octobit on top, the explanation below it, centered on the scrim. + lv_obj_t *col = lv_obj_create(scrim); + lv_obj_remove_style_all(col); + lv_obj_remove_flag(col, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_width(col, LV_PCT(100)); + lv_obj_set_height(col, LV_SIZE_CONTENT); + lv_obj_set_flex_flow(col, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(col, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_set_style_pad_row(col, 11, 0); + lv_obj_center(col); + + lv_image_dsc_t *dsc = assets_get(TIP_ART); + if (dsc != NULL) { + lv_obj_t *img = lv_image_create(col); + lv_image_set_src(img, dsc); + lv_image_set_scale(img, 168); // ~66% so it leaves room for the text + tip_fade(img, 130); + tip_bob(img); + } + + lv_obj_t *title = lv_label_create(col); + lv_label_set_text(title, entry->title); + lv_obj_set_style_text_font(title, &lv_font_montserrat_16, 0); + lv_obj_set_style_text_color(title, current_theme.border_accent, 0); + tip_fade(title, 300); + + lv_obj_t *tip = lv_label_create(col); + lv_label_set_long_mode(tip, LV_LABEL_LONG_WRAP); + lv_obj_set_width(tip, TIP_TEXT_W); + lv_label_set_text(tip, entry->tip); + lv_obj_set_style_text_font(tip, &lv_font_montserrat_14, 0); + lv_obj_set_style_text_color(tip, current_theme.text_main, 0); + lv_obj_set_style_text_align(tip, LV_TEXT_ALIGN_CENTER, 0); + tip_fade(tip, 380); + + lv_obj_t *hint = lv_label_create(col); + lv_label_set_text(hint, LV_SYMBOL_OK " OK"); + lv_obj_set_style_text_font(hint, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(hint, current_theme.border_accent, 0); + lv_obj_set_style_text_opa(hint, 180, 0); + tip_fade(hint, 460); + + s_open_tick = lv_tick_get(); + s_active = true; + hijack_input(); +} + +void screen_tips_hook(screen_id_t screen) { + if (s_active) { + hijack_input(); + return; + } + const tip_entry_t *entry = tip_for(screen); + if (entry == NULL) + return; + seen_load(); + if (s_skip || seen_get(screen)) + return; + seen_mark(screen); + ESP_LOGI(TAG, "tip for screen %d: %s", (int)screen, entry->title); + build_overlay(entry); +} + +bool screen_tips_active(void) { + return s_active; +} + +void screen_tips_reset(void) { + memset(s_seen, 0, sizeof(s_seen)); + s_seen_loaded = true; + nvs_handle_t h; + if (nvs_open(TIP_NVS_NS, NVS_READWRITE, &h) == ESP_OK) { + nvs_set_blob(h, TIP_NVS_KEY, s_seen, sizeof(s_seen)); + nvs_commit(h); + nvs_close(h); + } +} diff --git a/firmware_p4/components/Applications/ui/components/tutorial/tutorial_ui.c b/firmware_p4/components/Applications/ui/components/tutorial/tutorial_ui.c new file mode 100644 index 000000000..7b4393f46 --- /dev/null +++ b/firmware_p4/components/Applications/ui/components/tutorial/tutorial_ui.c @@ -0,0 +1,763 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#include "tutorial_ui.h" + +#include +#include + +#include "esp_log.h" +#include "nvs.h" + +#include "lvgl.h" +#include "st7789.h" + +#include "assets_manager.h" +#include "host_link_sec.h" +#include "storage_assets.h" +#include "storage_init.h" +#include "sys_time.h" +#include "tos_config.h" +#include "tos_storage_paths.h" +#include "ui_manager.h" +#include "ui_theme.h" + +static const char *TAG = "TUTORIAL"; + +static const char *const WIZ_THEME_NAMES[] = {"default", "cyber_blue"}; + +#define TUT_NVS_NS "tutorial" +#define TUT_NVS_KEY "done" + +#define WIZ_ART_ASSET "/assets/img/image.bin" + +#define WIZ_MARGIN 16 +#define WIZ_STEP_Y 6 +#define WIZ_PROG_Y 24 +#define WIZ_PROG_W (LCD_H_RES - 2 * WIZ_MARGIN) +#define WIZ_PROG_H 4 +#define WIZ_CONTENT_Y 36 +#define WIZ_CONTENT_H 252 +#define WIZ_FOOT_Y -8 +#define WIZ_GAP 9 +#define WIZ_TEXT_W 206 +#define WIZ_ARM_MS 260 +#define WIZ_DIM_OPA 150 +#define WIZ_SUB_OPA 180 +#define WIZ_SWATCH 24 + +// Animation timing (slow + cinematic; affordable now that PSRAM keeps frames resident). +#define FADE_OUT_MS 180 +#define FADE_IN_MS 320 +#define PROG_MS 460 +#define STAGGER_MS 110 // gap between elements fading in one after another +#define MASCOT_FADE 420 // octobit fade-in +#define MASCOT_ENTER 640 // when the text starts fading in (after octobit is established) +#define BOB_MS 1800 // octobit float period (slow, gentle) +#define BOB_PX 6 +#define WOBBLE_MS 560 // arrow idle horizontal swing period +#define WOBBLE_PX 5 + +// Vertical chooser geometry. +#define CH_ROW_H 30 +#define CH_ARROW_W 22 +#define CH_LIST_W 190 +#define CH_MAX 6 + +extern lv_group_t *main_group; + +static bool s_active = false; +static bool s_busy = false; // mid page-transition: swallow input +static int s_page = 0; +static int s_pending = 0; +static uint32_t s_open_tick = 0; + +static lv_obj_t *s_root = NULL; +static lv_obj_t *s_content = NULL; +static lv_obj_t *s_prog_fill = NULL; +static lv_obj_t *s_step = NULL; +static lv_obj_t *s_foot = NULL; +static lv_obj_t *s_mascot = NULL; // octobit on the current page (NULL if none) + +// Active chooser (rebuilt per page; count==0 means the page is not a chooser). +static lv_obj_t *s_ch_rows[CH_MAX] = {NULL}; +static lv_obj_t *s_ch_arrow = NULL; +static int s_ch_count = 0; +static int s_ch_sel = 0; +static int *s_ch_selp = NULL; + +static int s_lang_sel = 0; +static int s_theme_sel = 0; + +// ---- small animation helpers ------------------------------------------------ + +static void anim_opa_cb(void *obj, int32_t v) { + lv_obj_set_style_opa((lv_obj_t *)obj, (lv_opa_t)v, 0); +} +static void anim_ty_cb(void *obj, int32_t v) { + lv_obj_set_style_translate_y((lv_obj_t *)obj, v, 0); +} +static void anim_tx_cb(void *obj, int32_t v) { + lv_obj_set_style_translate_x((lv_obj_t *)obj, v, 0); +} + +static void fade(lv_obj_t *o, + int32_t from, + int32_t to, + uint32_t ms, + uint32_t delay, + lv_anim_path_cb_t path, + lv_anim_completed_cb_t done) { + lv_obj_set_style_opa(o, (lv_opa_t)from, 0); + lv_anim_t a; + lv_anim_init(&a); + lv_anim_set_var(&a, o); + lv_anim_set_exec_cb(&a, anim_opa_cb); + lv_anim_set_values(&a, from, to); + lv_anim_set_duration(&a, ms); + lv_anim_set_delay(&a, delay); + lv_anim_set_path_cb(&a, path); + if (done) + lv_anim_set_completed_cb(&a, done); + lv_anim_start(&a); +} + +// ---- reusable page widgets -------------------------------------------------- + +static lv_obj_t *wiz_heading(lv_obj_t *p, const char *text) { + lv_obj_t *l = lv_label_create(p); + lv_label_set_text(l, text); + lv_obj_set_style_text_font(l, &lv_font_montserrat_16, 0); + lv_obj_set_style_text_color(l, current_theme.text_main, 0); + lv_obj_set_style_text_align(l, LV_TEXT_ALIGN_CENTER, 0); + return l; +} + +static lv_obj_t *wiz_sub(lv_obj_t *p, const char *text) { + lv_obj_t *l = lv_label_create(p); + lv_label_set_text(l, text); + lv_label_set_long_mode(l, LV_LABEL_LONG_WRAP); + lv_obj_set_width(l, WIZ_TEXT_W); + lv_obj_set_style_text_font(l, &lv_font_montserrat_14, 0); + lv_obj_set_style_text_color(l, current_theme.text_main, 0); + lv_obj_set_style_text_opa(l, WIZ_SUB_OPA, 0); + lv_obj_set_style_text_align(l, LV_TEXT_ALIGN_CENTER, 0); + return l; +} + +static lv_obj_t *wiz_flow(lv_obj_t *p, lv_flex_flow_t flow, int gap) { + lv_obj_t *box = lv_obj_create(p); + lv_obj_remove_style_all(box); + lv_obj_remove_flag(box, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_width(box, WIZ_TEXT_W); + lv_obj_set_height(box, LV_SIZE_CONTENT); + lv_obj_set_flex_flow(box, flow); + lv_obj_set_flex_align(box, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_set_style_pad_row(box, gap, 0); + lv_obj_set_style_pad_column(box, gap, 0); + return box; +} + +static lv_obj_t *wiz_keycap(lv_obj_t *p, const char *text) { + lv_obj_t *k = lv_label_create(p); + lv_label_set_text(k, text); + lv_obj_set_style_text_font(k, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(k, current_theme.text_main, 0); + lv_obj_set_style_bg_opa(k, LV_OPA_COVER, 0); + lv_obj_set_style_bg_color(k, current_theme.bg_secondary, 0); + lv_obj_set_style_border_width(k, 1, 0); + lv_obj_set_style_border_color(k, current_theme.border_inactive, 0); + lv_obj_set_style_radius(k, 3, 0); + lv_obj_set_style_pad_hor(k, 5, 0); + lv_obj_set_style_pad_ver(k, 3, 0); + return k; +} + +static void wiz_chip(lv_obj_t *p, const char *text, lv_color_t accent) { + lv_obj_t *k = lv_label_create(p); + lv_label_set_text(k, text); + lv_obj_set_style_text_font(k, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(k, current_theme.text_main, 0); + lv_obj_set_style_bg_opa(k, LV_OPA_COVER, 0); + lv_obj_set_style_bg_color(k, current_theme.bg_secondary, 0); + lv_obj_set_style_border_width(k, 1, 0); + lv_obj_set_style_border_color(k, accent, 0); + lv_obj_set_style_radius(k, 6, 0); + lv_obj_set_style_pad_hor(k, 8, 0); + lv_obj_set_style_pad_ver(k, 4, 0); +} + +// A small octobit that gently bobs, added to the top of a guide page's content. +static void wiz_mascot(lv_obj_t *p, int zoom) { + lv_image_dsc_t *dsc = assets_get(WIZ_ART_ASSET); + if (dsc == NULL) + return; + lv_obj_t *img = lv_image_create(p); + lv_image_set_src(img, dsc); + if (zoom != 256) + lv_image_set_scale(img, zoom); + s_mascot = img; // build_page_now choreographs its entrance (fade + glide in) + // Gentle continuous float (translate-y; independent of the entrance translate-x). + lv_anim_t a; + lv_anim_init(&a); + lv_anim_set_var(&a, img); + lv_anim_set_exec_cb(&a, anim_ty_cb); + lv_anim_set_values(&a, -BOB_PX, BOB_PX); + lv_anim_set_duration(&a, BOB_MS); + lv_anim_set_reverse_duration(&a, BOB_MS); + lv_anim_set_repeat_count(&a, LV_ANIM_REPEAT_INFINITE); + lv_anim_set_path_cb(&a, lv_anim_path_ease_in_out); + lv_anim_start(&a); +} + +// ---- animated vertical chooser (the "setinha" selector) --------------------- + +static void ch_restyle(void) { + for (int i = 0; i < s_ch_count; i++) { + lv_obj_t *row = s_ch_rows[i]; + if (!row) + continue; + bool on = (i == s_ch_sel); + lv_obj_set_style_bg_opa(row, on ? LV_OPA_COVER : LV_OPA_TRANSP, 0); + lv_obj_set_style_bg_color(row, current_theme.border_accent, 0); + lv_obj_t *lbl = lv_obj_get_child(row, 0); + if (lbl) { + lv_obj_set_style_text_color(lbl, on ? current_theme.screen_base : current_theme.text_main, 0); + lv_obj_set_style_text_opa(lbl, on ? LV_OPA_COVER : WIZ_DIM_OPA, 0); + } + } +} + +// Vertical alignment is INSTANT — the arrow snaps to the selected row; the only +// motion it keeps is the idle horizontal swing (started in build_chooser). +static void ch_arrow_to(int idx) { + if (!s_ch_arrow) + return; + lv_obj_set_y(s_ch_arrow, idx * CH_ROW_H + (CH_ROW_H - 16) / 2); +} + +static void +build_chooser(lv_obj_t *c, const char **items, const uint32_t *colors, int count, int *selp) { + if (count > CH_MAX) + count = CH_MAX; + s_ch_count = count; + s_ch_selp = selp; + s_ch_sel = (selp && *selp < count) ? *selp : 0; + + lv_obj_t *list = lv_obj_create(c); + lv_obj_remove_style_all(list); + lv_obj_remove_flag(list, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(list, CH_LIST_W, count * CH_ROW_H); + + for (int i = 0; i < count; i++) { + lv_obj_t *row = lv_obj_create(list); + lv_obj_remove_style_all(row); + lv_obj_remove_flag(row, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(row, CH_LIST_W - CH_ARROW_W, CH_ROW_H - 4); + lv_obj_set_pos(row, CH_ARROW_W, i * CH_ROW_H); + lv_obj_set_style_radius(row, 6, 0); + lv_obj_set_flex_flow(row, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align( + row, LV_FLEX_ALIGN_SPACE_BETWEEN, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_set_style_pad_hor(row, 10, 0); + + lv_obj_t *lbl = lv_label_create(row); + lv_label_set_text(lbl, items[i]); + lv_obj_set_style_text_font(lbl, &lv_font_montserrat_14, 0); + + if (colors != NULL) { + lv_obj_t *dot = lv_obj_create(row); + lv_obj_remove_style_all(dot); + lv_obj_set_size(dot, 16, 16); + lv_obj_set_style_radius(dot, LV_RADIUS_CIRCLE, 0); + lv_obj_set_style_bg_opa(dot, LV_OPA_COVER, 0); + lv_obj_set_style_bg_color(dot, lv_color_hex(colors[i]), 0); + } + s_ch_rows[i] = row; + } + + s_ch_arrow = lv_label_create(list); + lv_label_set_text(s_ch_arrow, LV_SYMBOL_RIGHT); + lv_obj_set_style_text_font(s_ch_arrow, &lv_font_montserrat_14, 0); + lv_obj_set_style_text_color(s_ch_arrow, current_theme.border_accent, 0); + lv_obj_set_pos(s_ch_arrow, 2, 0); + + ch_restyle(); + ch_arrow_to(s_ch_sel); + + // Idle swing so it reads as "point here, use UP/DOWN". + lv_anim_t a; + lv_anim_init(&a); + lv_anim_set_var(&a, s_ch_arrow); + lv_anim_set_exec_cb(&a, anim_tx_cb); + lv_anim_set_values(&a, 0, WOBBLE_PX); + lv_anim_set_duration(&a, WOBBLE_MS); + lv_anim_set_reverse_duration(&a, WOBBLE_MS); + lv_anim_set_repeat_count(&a, LV_ANIM_REPEAT_INFINITE); + lv_anim_set_path_cb(&a, lv_anim_path_ease_in_out); + lv_anim_start(&a); +} + +static void chooser_move(int delta) { + int n = s_ch_sel + delta; + if (n < 0 || n >= s_ch_count) + return; + s_ch_sel = n; + if (s_ch_selp) + *s_ch_selp = n; + ch_restyle(); + ch_arrow_to(n); +} + +// ---- pages ------------------------------------------------------------------ + +static void page_language(lv_obj_t *c) { + wiz_heading(c, "Language"); + wiz_sub(c, "Pick your language. UP / DOWN to choose."); + static const char *langs[] = {"English", "Portugues", "Espanol", "Deutsch"}; + build_chooser(c, langs, NULL, 4, &s_lang_sel); +} + +static void page_datetime(lv_obj_t *c) { + wiz_heading(c, "Date & Time"); + lv_obj_t *clk = lv_label_create(c); + char clkbuf[16]; + if (!sys_time_format(clkbuf, sizeof(clkbuf), "%H:%M")) + snprintf(clkbuf, sizeof(clkbuf), "--:--"); + lv_label_set_text(clk, clkbuf); + lv_obj_set_style_text_font(clk, &lv_font_montserrat_16, 0); + lv_obj_set_style_text_color(clk, current_theme.border_accent, 0); + wiz_sub(c, "High Boy timestamps every capture and log. Sync the clock from the companion app."); + lv_obj_t *pill = lv_label_create(c); + lv_label_set_text(pill, LV_SYMBOL_REFRESH " Sync from companion"); + lv_obj_set_style_text_font(pill, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(pill, current_theme.border_accent, 0); + lv_obj_set_style_bg_opa(pill, LV_OPA_COVER, 0); + lv_obj_set_style_bg_color(pill, current_theme.bg_secondary, 0); + lv_obj_set_style_border_width(pill, 1, 0); + lv_obj_set_style_border_color(pill, current_theme.border_accent, 0); + lv_obj_set_style_radius(pill, 8, 0); + lv_obj_set_style_pad_hor(pill, 9, 0); + lv_obj_set_style_pad_ver(pill, 4, 0); +} + +static void wiz_status_row(lv_obj_t *list, const char *name, const char *value) { + lv_obj_t *row = lv_obj_create(list); + lv_obj_remove_style_all(row); + lv_obj_remove_flag(row, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_width(row, WIZ_TEXT_W); + lv_obj_set_height(row, LV_SIZE_CONTENT); + lv_obj_set_flex_flow(row, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align( + row, LV_FLEX_ALIGN_SPACE_BETWEEN, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_set_style_pad_ver(row, 4, 0); + lv_obj_t *n = lv_label_create(row); + lv_label_set_text(n, name); + lv_obj_set_style_text_font(n, &lv_font_montserrat_14, 0); + lv_obj_set_style_text_color(n, current_theme.text_main, 0); + lv_obj_t *v = lv_label_create(row); + lv_label_set_text(v, value); + lv_obj_set_style_text_font(v, &lv_font_montserrat_14, 0); + lv_obj_set_style_text_color(v, current_theme.border_accent, 0); +} + +static void page_storage(lv_obj_t *c) { + wiz_heading(c, "Storage"); + lv_obj_t *list = wiz_flow(c, LV_FLEX_FLOW_COLUMN, 2); + wiz_status_row(list, + LV_SYMBOL_SD_CARD " microSD", + storage_is_mounted() ? "Ready " LV_SYMBOL_OK : "Missing " LV_SYMBOL_WARNING); + wiz_status_row(list, + LV_SYMBOL_DRIVE " Internal", + storage_assets_is_mounted() ? "OK " LV_SYMBOL_OK : "Fault " LV_SYMBOL_WARNING); + wiz_sub(c, "microSD keeps your captures, scripts and firmware; internal flash runs the OS."); +} + +static void page_companion(lv_obj_t *c) { + wiz_heading(c, "Companion App"); + wiz_sub(c, "Pair the phone app for time sync, file transfer and remote control."); + lv_obj_t *box = lv_label_create(c); + char psk[HOST_LINK_PSK_HEX_SIZE]; + if (host_link_sec_get_psk_hex(psk, sizeof(psk)) == ESP_OK) { + lv_label_set_text(box, psk); + lv_label_set_long_mode(box, LV_LABEL_LONG_WRAP); + lv_obj_set_width(box, WIZ_TEXT_W); + lv_obj_set_style_text_align(box, LV_TEXT_ALIGN_CENTER, 0); + } else { + lv_label_set_text(box, "Pair from Settings"); + } + lv_obj_set_style_text_font(box, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(box, current_theme.text_main, 0); + lv_obj_set_style_bg_opa(box, LV_OPA_COVER, 0); + lv_obj_set_style_bg_color(box, current_theme.bg_secondary, 0); + lv_obj_set_style_border_width(box, 2, 0); + lv_obj_set_style_border_color(box, current_theme.border_accent, 0); + lv_obj_set_style_radius(box, 8, 0); + lv_obj_set_style_pad_hor(box, 14, 0); + lv_obj_set_style_pad_ver(box, 8, 0); + wiz_sub(c, "Provision this key in the app, or press OK to skip."); +} + +static void page_terms(lv_obj_t *c) { + wiz_heading(c, "Responsible Use"); + wiz_sub(c, + "High Boy is a security tool. Only test devices and networks you own or are explicitly " + "authorized to. You are responsible for following local law."); + lv_obj_t *agree = lv_label_create(c); + lv_label_set_text(agree, LV_SYMBOL_OK " I understand and agree"); + lv_obj_set_style_text_font(agree, &lv_font_montserrat_14, 0); + lv_obj_set_style_text_color(agree, current_theme.border_accent, 0); +} + +static void page_setupdone(lv_obj_t *c) { + wiz_mascot(c, 200); + wiz_heading(c, "Setup complete"); + wiz_sub(c, "Now let's meet your guide."); +} + +static void page_welcome(lv_obj_t *c) { + wiz_mascot(c, 256); + wiz_heading(c, "Hi, I'm Octobit!"); + wiz_sub(c, "I'll ride along and point things out as you explore your High Boy."); +} + +static void page_controls(lv_obj_t *c) { + wiz_mascot(c, 150); + wiz_heading(c, "Controls"); + lv_obj_t *r1 = wiz_flow(c, LV_FLEX_FLOW_ROW, 4); + wiz_keycap(r1, LV_SYMBOL_UP); + wiz_keycap(r1, LV_SYMBOL_DOWN); + wiz_keycap(r1, LV_SYMBOL_LEFT); + wiz_keycap(r1, LV_SYMBOL_RIGHT); + lv_obj_t *m = lv_label_create(r1); + lv_label_set_text(m, "Move"); + lv_obj_set_style_text_font(m, &lv_font_montserrat_14, 0); + lv_obj_set_style_text_color(m, current_theme.text_main, 0); + lv_obj_t *r2 = wiz_flow(c, LV_FLEX_FLOW_ROW, 4); + wiz_keycap(r2, "OK"); + lv_obj_t *s = lv_label_create(r2); + lv_label_set_text(s, "Select - hold UP anywhere for quick settings"); + lv_obj_set_width(s, WIZ_TEXT_W - 40); + lv_label_set_long_mode(s, LV_LABEL_LONG_WRAP); + lv_obj_set_style_text_font(s, &lv_font_montserrat_14, 0); + lv_obj_set_style_text_color(s, current_theme.text_main, 0); +} + +static void page_toolkit(lv_obj_t *c) { + wiz_mascot(c, 150); + wiz_heading(c, "Every signal, one device"); + lv_obj_t *g = wiz_flow(c, LV_FLEX_FLOW_ROW_WRAP, 6); + wiz_chip(g, "WI-FI", current_theme.protocol_wifi); + wiz_chip(g, "BLE", current_theme.protocol_ble); + wiz_chip(g, "NFC", current_theme.protocol_nfc); + wiz_chip(g, "RFID", current_theme.protocol_rfid); + wiz_chip(g, "SUB-GHZ", current_theme.protocol_subghz); + wiz_chip(g, "IR", current_theme.protocol_ir); + wiz_chip(g, "LORA", current_theme.protocol_lora); + wiz_chip(g, "BADUSB", current_theme.border_accent); +} + +static void page_ethics(lv_obj_t *c) { + wiz_mascot(c, 150); + lv_obj_t *w = lv_label_create(c); + lv_label_set_text(w, LV_SYMBOL_WARNING); + lv_obj_set_style_text_font(w, &lv_font_montserrat_16, 0); + lv_obj_set_style_text_color(w, lv_color_hex(0xE0954A), 0); + wiz_heading(c, "Play fair"); + wiz_sub(c, "Only probe what's yours. Curiosity is great; consent comes first."); +} + +static void page_theme(lv_obj_t *c) { + wiz_heading(c, "Theme"); + wiz_sub(c, "UP / DOWN to choose. Applied when you finish."); + static const char *names[] = {"Default", "Cyber Blue"}; + static const uint32_t sw[] = {0x834EC6, 0x00D9FF}; + s_theme_sel = (strcmp(g_config_screen.theme, WIZ_THEME_NAMES[1]) == 0) ? 1 : 0; + build_chooser(c, names, sw, 2, &s_theme_sel); +} + +static void page_ready(lv_obj_t *c) { + wiz_mascot(c, 256); + wiz_heading(c, "You're all set!"); + wiz_sub(c, "Tips will nudge you as you go. Let's dive in."); +} + +typedef void (*wiz_build_fn)(lv_obj_t *); + +typedef struct { + wiz_build_fn build; + const char *foot; + bool mascot; // keep the persistent guide visible from here on +} wiz_page_t; + +static const wiz_page_t PAGES[] = { + {page_language, "UP/DOWN Pick OK Next", false}, + {page_datetime, "OK Next BACK Back", false}, + {page_storage, "OK Next BACK Back", false}, + {page_companion, "OK Next BACK Back", false}, + {page_terms, "OK Accept BACK Back", false}, + {page_setupdone, "OK Continue BACK Back", true}, + {page_welcome, "OK Next BACK Back", true}, + {page_controls, "OK Next BACK Back", true}, + {page_toolkit, "OK Next BACK Back", true}, + {page_ethics, "OK Got it BACK Back", true}, + {page_theme, "UP/DOWN Preview OK Next", true}, + {page_ready, "OK Enter BACK Back", true}, +}; + +#define PAGE_COUNT ((int)(sizeof(PAGES) / sizeof(PAGES[0]))) + +static void arm_done(lv_anim_t *a) { + (void)a; + s_busy = false; + s_open_tick = lv_tick_get(); +} + +static void build_page_now(int idx) { + s_busy = true; // cleared by arm_done() when the fade-in finishes + s_page = idx; + s_ch_count = 0; + s_ch_arrow = NULL; + s_mascot = NULL; // set by wiz_mascot() during the build if this page has one + lv_obj_clean(s_content); + PAGES[idx].build(s_content); + lv_label_set_text(s_foot, PAGES[idx].foot); + lv_label_set_text_fmt(s_step, "%d / %d", idx + 1, PAGE_COUNT); + + // Progress bar eases to the new fraction. + lv_anim_t a; + lv_anim_init(&a); + lv_anim_set_var(&a, s_prog_fill); + lv_anim_set_exec_cb(&a, (lv_anim_exec_xcb_t)lv_obj_set_width); + lv_anim_set_values(&a, lv_obj_get_width(s_prog_fill), WIZ_PROG_W * (idx + 1) / PAGE_COUNT); + lv_anim_set_duration(&a, PROG_MS); + lv_anim_set_path_cb(&a, lv_anim_path_ease_out); + lv_anim_start(&a); + + // Compose the page in on the dark canvas: octobit fades in and glides from the + // side first, THEN the text fades in below it, element by element. + lv_obj_set_style_opa(s_content, LV_OPA_COVER, 0); + + uint32_t base = 0; + if (s_mascot) { + // Octobit simply fades in and floats in place (no slide); the text waits for it. + fade(s_mascot, LV_OPA_TRANSP, LV_OPA_COVER, MASCOT_FADE, 0, lv_anim_path_ease_out, NULL); + base = MASCOT_ENTER; + } + + uint32_t n = lv_obj_get_child_count(s_content); + int step = 0; + for (uint32_t i = 0; i < n; i++) { + lv_obj_t *ch = lv_obj_get_child(s_content, i); + if (ch == s_mascot) + continue; + fade(ch, + LV_OPA_TRANSP, + LV_OPA_COVER, + FADE_IN_MS, + base + (uint32_t)step * STAGGER_MS, + lv_anim_path_ease_out, + NULL); + step++; + } + + // Arm input once the whole sequence has settled (a no-op timing anim). + uint32_t settle = base + (step > 0 ? (uint32_t)(step - 1) * STAGGER_MS : 0) + FADE_IN_MS; + fade(s_content, LV_OPA_COVER, LV_OPA_COVER, settle, 0, lv_anim_path_linear, arm_done); +} + +static void fade_out_done(lv_anim_t *a) { + (void)a; + build_page_now(s_pending); +} + +static void go_page(int idx) { + if (idx < 0 || idx >= PAGE_COUNT) + return; + s_pending = idx; + s_busy = true; + fade(s_content, LV_OPA_COVER, LV_OPA_TRANSP, FADE_OUT_MS, 0, lv_anim_path_ease_in, fade_out_done); +} + +static void mark_done(void) { + nvs_handle_t h; + if (nvs_open(TUT_NVS_NS, NVS_READWRITE, &h) == ESP_OK) { + nvs_set_u8(h, TUT_NVS_KEY, 1); + nvs_commit(h); + nvs_close(h); + } +} + +static void finish(void) { + if (!s_active) + return; + s_active = false; + mark_done(); + + if (s_theme_sel >= 0 && + s_theme_sel < (int)(sizeof(WIZ_THEME_NAMES) / sizeof(WIZ_THEME_NAMES[0]))) { + const char *name = WIZ_THEME_NAMES[s_theme_sel]; + if (strcmp(g_config_screen.theme, name) != 0) { + strncpy(g_config_screen.theme, name, sizeof(g_config_screen.theme) - 1); + g_config_screen.theme[sizeof(g_config_screen.theme) - 1] = '\0'; + tos_config_save(TOS_PATH_CONFIG_SCREEN, "screen"); + } + ui_theme_load_from_name(name); + } + + lv_obj_t *root = s_root; + s_root = NULL; + s_content = NULL; + s_prog_fill = NULL; + s_step = NULL; + s_foot = NULL; + s_ch_arrow = NULL; + s_ch_count = 0; + s_mascot = NULL; + + ui_switch_screen(SCREEN_HOME); + if (root != NULL) + lv_obj_del_async(root); +} + +static void key_cb(lv_event_t *e) { + if (lv_event_get_code(e) != LV_EVENT_KEY) + return; + if (s_busy) + return; + if (lv_tick_get() - s_open_tick < WIZ_ARM_MS) + return; + uint32_t key = lv_event_get_key(e); + + // On a chooser page UP/DOWN move the selection (arrow slides to it). + if (s_ch_count > 0) { + if (key == LV_KEY_UP) { + chooser_move(-1); + return; + } + if (key == LV_KEY_DOWN) { + chooser_move(1); + return; + } + } + + if (key == LV_KEY_ENTER || key == LV_KEY_RIGHT) { + if (s_page + 1 < PAGE_COUNT) + go_page(s_page + 1); + else + finish(); + } else if (key == LV_KEY_LEFT || key == LV_KEY_ESC) { + if (s_page > 0) + go_page(s_page - 1); + } +} + +void tutorial_start(void) { + if (s_active) + return; + + ESP_LOGI(TAG, "onboarding wizard: starting (%d pages)", PAGE_COUNT); + + lv_obj_t *root = lv_obj_create(NULL); + lv_obj_set_style_bg_color(root, current_theme.screen_base, 0); + lv_obj_set_style_bg_opa(root, LV_OPA_COVER, 0); + lv_obj_set_style_pad_all(root, 0, 0); + lv_obj_remove_flag(root, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_add_event_cb(root, key_cb, LV_EVENT_KEY, NULL); + s_root = root; + + s_step = lv_label_create(root); + lv_obj_set_style_text_font(s_step, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(s_step, current_theme.text_main, 0); + lv_obj_set_style_text_opa(s_step, WIZ_DIM_OPA, 0); + lv_obj_align(s_step, LV_ALIGN_TOP_RIGHT, -WIZ_MARGIN, WIZ_STEP_Y); + + lv_obj_t *track = lv_obj_create(root); + lv_obj_remove_style_all(track); + lv_obj_set_size(track, WIZ_PROG_W, WIZ_PROG_H); + lv_obj_set_style_bg_opa(track, LV_OPA_COVER, 0); + lv_obj_set_style_bg_color(track, current_theme.border_inactive, 0); + lv_obj_set_style_radius(track, WIZ_PROG_H / 2, 0); + lv_obj_align(track, LV_ALIGN_TOP_LEFT, WIZ_MARGIN, WIZ_PROG_Y); + + s_prog_fill = lv_obj_create(track); + lv_obj_remove_style_all(s_prog_fill); + lv_obj_set_height(s_prog_fill, WIZ_PROG_H); + lv_obj_set_width(s_prog_fill, 0); + lv_obj_set_style_bg_opa(s_prog_fill, LV_OPA_COVER, 0); + lv_obj_set_style_bg_color(s_prog_fill, current_theme.border_accent, 0); + lv_obj_set_style_radius(s_prog_fill, WIZ_PROG_H / 2, 0); + lv_obj_align(s_prog_fill, LV_ALIGN_LEFT_MID, 0, 0); + + s_content = lv_obj_create(root); + lv_obj_remove_style_all(s_content); + lv_obj_remove_flag(s_content, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(s_content, LCD_H_RES, WIZ_CONTENT_H); + lv_obj_align(s_content, LV_ALIGN_TOP_MID, 0, WIZ_CONTENT_Y); + lv_obj_set_flex_flow(s_content, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align( + s_content, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_set_style_pad_row(s_content, WIZ_GAP, 0); + lv_obj_set_style_pad_all(s_content, WIZ_MARGIN, 0); + + s_foot = lv_label_create(root); + lv_obj_set_style_text_font(s_foot, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(s_foot, current_theme.text_main, 0); + lv_obj_set_style_text_opa(s_foot, WIZ_SUB_OPA, 0); + lv_obj_align(s_foot, LV_ALIGN_BOTTOM_MID, 0, WIZ_FOOT_Y); + + s_active = true; + s_busy = false; + + if (main_group != NULL) { + lv_group_remove_all_objs(main_group); + lv_group_add_obj(main_group, root); + lv_group_focus_obj(root); + lv_group_set_editing(main_group, false); + } + + build_page_now(0); + lv_screen_load(root); +} + +bool tutorial_should_run(void) { + uint8_t done = 0; + nvs_handle_t h; + esp_err_t err = nvs_open(TUT_NVS_NS, NVS_READONLY, &h); + if (err == ESP_OK) { + nvs_get_u8(h, TUT_NVS_KEY, &done); + nvs_close(h); + } + bool run = (done == 0); + ESP_LOGI(TAG, + "should_run: nvs_open=%s done=%u -> %s", + esp_err_to_name(err), + done, + run ? "START" : "skip"); + return run; +} + +bool tutorial_is_active(void) { + return s_active; +} + +void tutorial_reset(void) { + nvs_handle_t h; + if (nvs_open(TUT_NVS_NS, NVS_READWRITE, &h) == ESP_OK) { + nvs_set_u8(h, TUT_NVS_KEY, 0); + nvs_commit(h); + nvs_close(h); + } +} diff --git a/firmware_p4/components/Applications/ui/components/waves/include/waves_ui.h b/firmware_p4/components/Applications/ui/components/waves/include/waves_ui.h new file mode 100644 index 000000000..9622c9fdb --- /dev/null +++ b/firmware_p4/components/Applications/ui/components/waves/include/waves_ui.h @@ -0,0 +1,56 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#ifndef UI_WAVES_H +#define UI_WAVES_H + +#ifdef __cplusplus +extern "C" { +#endif + +#include "lvgl.h" + +/** + * @brief Radar-style pulse: concentric accent rings expanding outward from a + * solid central node and fading, looped forever (same effect as the BLE + * pairing screen). Used to signal IR receive ("learning") and transmit + * activity. + * + * Creates a self-contained container positioned in `parent` via align/offset. + * Delete it (or its parent) to stop — the looping animations are removed with + * the objects automatically. + * + * @param parent Container to attach the pulse to. + * @param align Alignment of the container within `parent`. + * @param x_ofs Horizontal offset from the alignment anchor, in pixels. + * @param y_ofs Vertical offset from the alignment anchor, in pixels. + * @param symbol Optional glyph drawn in the centre node (e.g. LV_SYMBOL_*). + * @param icon_path Optional small image asset drawn (scaled down) in the + * centre node; takes precedence over `symbol`. Pass NULL for + * neither. + * @return The container object. + */ +lv_obj_t *waves_create(lv_obj_t *parent, + lv_align_t align, + int x_ofs, + int y_ofs, + const char *symbol, + const char *icon_path); + +#ifdef __cplusplus +} +#endif + +#endif // UI_WAVES_H diff --git a/firmware_p4/components/Applications/ui/components/waves/waves_ui.c b/firmware_p4/components/Applications/ui/components/waves/waves_ui.c new file mode 100644 index 000000000..c77a0f2d9 --- /dev/null +++ b/firmware_p4/components/Applications/ui/components/waves/waves_ui.c @@ -0,0 +1,100 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#include "waves_ui.h" + +#include "assets_manager.h" +#include "ui_theme.h" + +#define WAVES_RING_COUNT 3 +#define WAVES_MIN 30 +#define WAVES_MAX 132 +#define WAVES_MS 1800 +#define WAVES_NODE 38 +#define WAVES_ICON_PX 18 +#define WAVES_CONT (WAVES_MAX + 8) + +static void waves_ring_cb(void *var, int32_t v) { + lv_obj_t *ring = (lv_obj_t *)var; + int32_t sz = WAVES_MIN + (WAVES_MAX - WAVES_MIN) * v / 255; + lv_obj_set_size(ring, sz, sz); + lv_obj_center(ring); + lv_obj_set_style_opa(ring, (lv_opa_t)(255 - v), 0); +} + +lv_obj_t *waves_create(lv_obj_t *parent, + lv_align_t align, + int x_ofs, + int y_ofs, + const char *symbol, + const char *icon_path) { + lv_obj_t *cont = lv_obj_create(parent); + lv_obj_remove_flag(cont, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(cont, WAVES_CONT, WAVES_CONT); + lv_obj_align(cont, align, x_ofs, y_ofs); + lv_obj_set_style_bg_opa(cont, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(cont, 0, 0); + lv_obj_set_style_pad_all(cont, 0, 0); + + for (int i = 0; i < WAVES_RING_COUNT; i++) { + lv_obj_t *ring = lv_obj_create(cont); + lv_obj_remove_flag(ring, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_bg_opa(ring, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(ring, 3, 0); + lv_obj_set_style_border_color(ring, current_theme.border_accent, 0); + lv_obj_set_style_radius(ring, LV_RADIUS_CIRCLE, 0); + lv_obj_set_size(ring, WAVES_MIN, WAVES_MIN); + lv_obj_center(ring); + + lv_anim_t a; + lv_anim_init(&a); + lv_anim_set_var(&a, ring); + lv_anim_set_exec_cb(&a, waves_ring_cb); + lv_anim_set_values(&a, 0, 255); + lv_anim_set_duration(&a, WAVES_MS); + lv_anim_set_delay(&a, i * (WAVES_MS / WAVES_RING_COUNT)); + lv_anim_set_repeat_count(&a, LV_ANIM_REPEAT_INFINITE); + lv_anim_start(&a); + } + + lv_obj_t *node = lv_obj_create(cont); + lv_obj_remove_flag(node, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(node, WAVES_NODE, WAVES_NODE); + lv_obj_set_style_radius(node, LV_RADIUS_CIRCLE, 0); + lv_obj_set_style_bg_opa(node, LV_OPA_COVER, 0); + lv_obj_set_style_bg_color(node, current_theme.border_accent, 0); + lv_obj_set_style_border_width(node, 0, 0); + lv_obj_center(node); + + lv_image_dsc_t *icon_dsc = icon_path ? assets_get(icon_path) : NULL; + if (icon_dsc != NULL) { + lv_obj_t *img = lv_image_create(node); + lv_image_set_src(img, icon_dsc); + int32_t longest = + icon_dsc->header.w > icon_dsc->header.h ? icon_dsc->header.w : icon_dsc->header.h; + if (longest > 0) + lv_image_set_scale(img, WAVES_ICON_PX * 256 / longest); + lv_obj_set_style_image_recolor(img, current_theme.text_main, 0); + lv_obj_set_style_image_recolor_opa(img, LV_OPA_COVER, 0); + lv_obj_center(img); + } else if (symbol) { + lv_obj_t *sym = lv_label_create(node); + lv_label_set_text(sym, symbol); + lv_obj_set_style_text_color(sym, current_theme.text_main, 0); + lv_obj_center(sym); + } + + return cont; +} diff --git a/firmware_p4/components/Applications/ui/include/assets_manager.h b/firmware_p4/components/Applications/ui/include/assets_manager.h index 162eeeea2..fae1d5803 100644 --- a/firmware_p4/components/Applications/ui/include/assets_manager.h +++ b/firmware_p4/components/Applications/ui/include/assets_manager.h @@ -33,6 +33,14 @@ lv_image_dsc_t *assets_get(const char *path); /** @brief Free all loaded assets and release memory. */ void assets_manager_free_all(void); +/** + * @brief Drop the LVGL image cache under memory pressure (safe mid-session). + * + * Frees the decoded-pixel pool; asset nodes and handed-out descriptors stay + * valid, so a later redraw re-decodes from flash. Used by the heap policy. + */ +void assets_manager_evict_cache(void); + /** @brief Load assets from SD card directory, overriding flash assets. */ int assets_load_from_sd(const char *sd_dir, const char *flash_prefix); diff --git a/firmware_p4/components/Applications/ui/include/ui_liveness.h b/firmware_p4/components/Applications/ui/include/ui_liveness.h new file mode 100644 index 000000000..8515bb18d --- /dev/null +++ b/firmware_p4/components/Applications/ui/include/ui_liveness.h @@ -0,0 +1,44 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#ifndef UI_LIVENESS_H +#define UI_LIVENESS_H + +#ifdef __cplusplus +extern "C" { +#endif + +#include + +/** + * @brief Render-progress heartbeat for supervising the LVGL task. + * + * A lightweight lv_timer running inside the esp_lvgl_port task increments this + * counter. It advances only while that task is actually servicing timers, so a + * frozen renderer (lock deadlock, runaway screen callback, or a dead/suspended + * task) stalls it. The system monitor polls this instead of matching a task by + * name: a stalled beat is the real "UI is stuck" signal and triggers a + * controlled restart. + * + * @return Monotonic beat counter. 0 means the UI has not rendered yet (still + * booting); the monitor arms its stall check only after it advances. + */ +uint32_t ui_render_beat(void); + +#ifdef __cplusplus +} +#endif + +#endif // UI_LIVENESS_H diff --git a/firmware_p4/components/Applications/ui/include/ui_manager.h b/firmware_p4/components/Applications/ui/include/ui_manager.h index b12ab5d57..632decc78 100644 --- a/firmware_p4/components/Applications/ui/include/ui_manager.h +++ b/firmware_p4/components/Applications/ui/include/ui_manager.h @@ -22,6 +22,9 @@ extern "C" { #include +#include "lvgl.h" +#include "input_manager.h" + /** @brief Screen identifiers for the UI navigation system. */ typedef enum { SCREEN_NONE, @@ -69,7 +72,9 @@ typedef enum { SCREEN_CONNECTION_SETTINGS, SCREEN_CONNECT_WIFI, SCREEN_CONNECT_BLUETOOTH, + SCREEN_COMPANION_PAIRING, SCREEN_ABOUT_SETTINGS, + SCREEN_STORAGE, SCREEN_NFC_MENU, SCREEN_FILES, SCREEN_THEME_SELECTOR, @@ -79,14 +84,101 @@ typedef enum { SCREEN_IR_CONTROLLER, SCREEN_IR_SAVED, SCREEN_IR_BURST, + SCREEN_OCTOBIT_STATUS, + SCREEN_DEV_MENU, + SCREEN_GPIO, + SCREEN_HAPTIC, + SCREEN_SPEAKER, + SCREEN_MIC_REC, + SCREEN_WAV_PLAYER, + SCREEN_PLAYER, + SCREEN_SUBGHZ_MENU, + SCREEN_SUBGHZ_READ, + SCREEN_NFC_READ, + SCREEN_NFC_SAVED, + SCREEN_NFC_WRITE, + SCREEN_NFC_EMULATE, + SCREEN_NFC_CONFIG, + SCREEN_CARD_EMU, + SCREEN_RFID_MENU, + SCREEN_LORA_CHAT, + SCREEN_POWER, + SCREEN_SPECTRUM, + SCREEN_IR_REMOTE_TYPE, + SCREEN_BLE_SCAN, + SCREEN_BLE_MOUSE_PAIRING, + SCREEN_BLE_MOUSE, + SCREEN_WIFI_CHANNELS, + SCREEN_WIFI_CLIENTS, + SCREEN_WIFI_NAMES, + SCREEN_APPS, + SCREEN_SETTINGS_DEV, + SCREEN_SUBGHZ_BRUTE, + SCREEN_BLE_BEACON_SPAM, + SCREEN_BLE_DETECT_MENU, + SCREEN_BLE_SNIFFER, + SCREEN_BLE_TRACKER, + SCREEN_BLE_SKIMMER, + SCREEN_BLE_EXPOSURE, + SCREEN_GATT_EXPLORER, + SCREEN_BLE_KEYBOARD, + SCREEN_BLE_FLOOD, + SCREEN_BLE_RADIO, + SCREEN_BLE_TRACK_DEVICE, + SCREEN_BLE_SPAM_NAMES, + SCREEN_WIFI_PORT_SCAN, + SCREEN_WIFI_PROBE_MON, + SCREEN_WIFI_TARGET_CLIENTS, + SCREEN_WIFI_DEAUTH_DETECTOR, + SCREEN_WIFI_SIGNAL_LOCATOR, + SCREEN_NFC_SCAN, + SCREEN_SUBGHZ_SEND, + SCREEN_SYSTEM_UPDATE, + SCREEN_C5_STATUS, + SCREEN_LED_CTRL, + SCREEN_LORA_TRACEROUTE, + SCREEN_LORA_RNODE, + SCREEN_LORA_MQTT, + SCREEN_SCRIPTS, + SCREEN_DEV_CONSOLE, + SCREEN_DEV_DIAG, + SCREEN_BOOT_MAP, + SCREEN_CRASH_REPORT, + SCREEN_NFC_BANKCARD, + SCREEN_NFC_DESFIRE, + SCREEN_NFC_ISO15693, + SCREEN_NFC_ULTRALIGHT, + SCREEN_NFC_NDEF, + SCREEN_NFC_FELICA, + SCREEN_NFC_P2P, + SCREEN_NFC_KEYDICT, + SCREEN_SUBGHZ_CONFIG, + SCREEN_IR_RAW, + SCREEN_LORA_CHANNELS, + SCREEN_LORA_POSITION, + SCREEN_LORA_TELEMETRY, + SCREEN_LORA_SECURE_DM, + SCREEN_WIFI_HANDSHAKE, + SCREEN_WIFI_HOTSPOT, + SCREEN_IMU_MONITOR, + SCREEN_SD_HEALTH, + SCREEN_USB_MOUSE, + SCREEN_TIME, SCREEN_COUNT } screen_id_t; /** @brief Initialize the UI manager and start the UI task. */ void ui_init(void); -/** @brief Perform an emergency restart of the UI task. */ -void ui_hard_restart(void); +/** + * @brief Bring up a minimal UI showing only the safe-mode recovery screen. + * + * Used when the OK + BACK boot combo is held. Sets up the LVGL infrastructure + * (theme, input pump, render heartbeat) but skips the normal boot animation, + * home screen and power policy. Radios, custom themes and SD assets are not + * initialized by the caller in this mode. + */ +void ui_init_safe_mode(void); /** @brief Acquire the UI mutex for thread-safe LVGL access. */ bool ui_acquire(void); @@ -97,9 +189,80 @@ void ui_release(void); /** @brief Switch to a new screen by identifier. */ void ui_switch_screen(screen_id_t new_screen); +/** + * @brief Load a screen. Drop-in replacement for lv_screen_load used by the + * ported screens. + * + * @param scr Screen object to load. + */ +void ui_screen_load(lv_obj_t *scr); + +/** + * @brief Load a screen and bind @p slot to it. When the screen object is later + * deleted (freed on navigation), @p slot is set to NULL so the owning + * screen never double-frees or dereferences a stale pointer. + * + * @param slot Address of the screen's own object pointer (e.g. &s_screen). + * @param scr Screen object to load. + */ +void ui_screen_load_owned(lv_obj_t **slot, lv_obj_t *scr); + +/** @brief Returns the currently active screen id. */ +screen_id_t ui_current_screen(void); + +/** + * @brief Whether a screen shows the global chrome (status bar + dropdown). + * + * false for active "operation" screens — reading/sending/scanning/emulating/ + * recording/playing/running — where the status bar and the quick-settings + * dropdown are hidden; true for browse screens (menus, lists, settings). Used + * both to gate the chrome header's status cluster and to gate the global + * dropdown's long-press-to-open. + */ +bool ui_screen_shows_chrome(screen_id_t s); + +/** @brief Re-open the active screen (no-op here: no runtime rotation). */ +void ui_manager_relayout_current(void); + +/** @brief Rotation-aware button polling (portrait pass-through on this build). */ +bool ui_btn_up(void); +bool ui_btn_down(void); +bool ui_btn_left(void); +bool ui_btn_right(void); + +/** @brief Handler a screen registers to receive input events. */ +typedef void (*ui_input_handler_t)(const input_event_t *ev, void *ctx); + +/** + * @brief Register the active screen's input handler (event-driven input). + * + * The UI manager runs one pump that drains input_manager events and dispatches + * them to this handler, but only while input is not locked and no modal overlay + * (msgbox/keyboard) is up. A screen sets its handler in its open function; it is + * cleared automatically on the next screen switch. This replaces the per-screen + * polling lv_timers. Pass NULL to clear. + */ +void ui_input_set_screen_handler(ui_input_handler_t handler, void *ctx); + /** @brief Check if user input is temporarily locked. */ bool ui_input_is_locked(void); +/** + * @brief Guard for actions that persist to the SD card. Returns true if the SD + * is mounted; otherwise shows a warning toast ("Insert SD card") and + * returns false, so a save can be skipped cleanly. + */ +bool ui_sd_ready(void); + +/** + * @brief Temporarily lock user input for @p ms milliseconds. + * + * Screens that poll buttons gate on ui_input_is_locked(), so this swallows + * stray presses — e.g. the button that wakes the display from sleep should not + * also navigate. Extends (never shortens) any lock already in effect. + */ +void ui_input_lock(uint32_t ms); + #ifdef __cplusplus } #endif diff --git a/firmware_p4/components/Applications/ui/screens/SubGhz/subghz_spectrum_ui.c b/firmware_p4/components/Applications/ui/screens/SubGhz/subghz_spectrum_ui.c deleted file mode 100644 index ec6f74584..000000000 --- a/firmware_p4/components/Applications/ui/screens/SubGhz/subghz_spectrum_ui.c +++ /dev/null @@ -1,198 +0,0 @@ -// Copyright (c) 2025 HIGH CODE LLC -// -// TentacleOS is free software: you can redistribute it and/or modify -// it under the terms of the GNU General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// TentacleOS is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU General Public License for more details. -// -// You should have received a copy of the GNU General Public License -// along with TentacleOS. If not, see . - -#include "subghz_spectrum_ui.h" - -#include - -#include "esp_log.h" -#include "core/lv_group.h" -#include "lv_conf_internal.h" - -#include "ui_theme.h" -#include "header_ui.h" -#include "footer_ui.h" -#include "ui_manager.h" -#include "lv_port_indev.h" -#include "subghz_spectrum.h" - -static const char *TAG = "SUBGHZ_SPECTRUM_UI"; - -#define SPECTRUM_CENTER_FREQ 433920000 -#define SPECTRUM_SPAN_HZ 2000000 - -#define CHART_W 220 -#define CHART_H 120 -#define CHART_OFFSET_Y (-10) -#define CHART_BORDER_W 1 -#define CHART_LINE_W 2 -#define CHART_RANGE_MIN 0 -#define CHART_RANGE_MAX 100 -#define CHART_ITEM_BG_OPA 80 - -#define RSSI_CLAMP_MIN 0 -#define RSSI_CLAMP_MAX 100 -#define RSSI_FLOOR_DBM (-130.0f) -#define RSSI_PEAK_THRESHOLD_DBM (-60.0f) - -#define LABEL_OFFSET_Y (-35) -#define UPDATE_TIMER_PERIOD_MS 50 - -#define PEAK_LABEL_DEFAULT "Peak: --- dBm" -#define FREQ_LABEL_DEFAULT "433.92 MHz" -#define PEAK_LABEL_FMT "Peak: %.1f dBm" -#define FREQ_LABEL_FMT "%.2f MHz" -#define PEAK_LABEL_BUF_SIZE 32 -#define FREQ_LABEL_BUF_SIZE 32 - -static lv_obj_t *s_screen = NULL; -static lv_obj_t *s_chart = NULL; -static lv_chart_series_t *s_ser_rssi = NULL; -static lv_timer_t *s_update_timer = NULL; -static lv_obj_t *s_lbl_rssi = NULL; -static lv_obj_t *s_lbl_freq = NULL; - -static int32_t s_chart_points[SPECTRUM_SAMPLES]; - -static void update_spectrum_cb(lv_timer_t *t); -static void on_screen_key_event(lv_event_t *e); - -static void update_spectrum_cb(lv_timer_t *t) { - if (s_chart == NULL || s_ser_rssi == NULL) - return; - - subghz_spectrum_line_t line; - if (!subghz_spectrum_get_line(&line)) - return; - - float max_dbm = RSSI_FLOOR_DBM; - uint32_t peak_freq = line.start_freq; - - for (int i = 0; i < SPECTRUM_SAMPLES; i++) { - int32_t val = (int32_t)(line.dbm_values[i] + (-RSSI_FLOOR_DBM)); - if (val < RSSI_CLAMP_MIN) - val = RSSI_CLAMP_MIN; - if (val > RSSI_CLAMP_MAX) - val = RSSI_CLAMP_MAX; - s_chart_points[i] = val; - - if (line.dbm_values[i] > max_dbm) { - max_dbm = line.dbm_values[i]; - peak_freq = line.start_freq + (uint32_t)(i * line.step_hz); - } - } - - lv_chart_set_ext_y_array(s_chart, s_ser_rssi, s_chart_points); - lv_chart_refresh(s_chart); - - if (s_lbl_rssi != NULL) { - char buf[PEAK_LABEL_BUF_SIZE]; - snprintf(buf, sizeof(buf), PEAK_LABEL_FMT, max_dbm); - lv_label_set_text(s_lbl_rssi, buf); - } - - if (s_lbl_freq != NULL) { - char buf[FREQ_LABEL_BUF_SIZE]; - snprintf(buf, sizeof(buf), FREQ_LABEL_FMT, (double)(peak_freq / 1000000.0f)); - lv_label_set_text(s_lbl_freq, buf); - } -} - -static void on_screen_key_event(lv_event_t *e) { - if (lv_event_get_code(e) != LV_EVENT_KEY) - return; - - if (lv_event_get_key(e) != LV_KEY_ESC) - return; - - if (s_update_timer != NULL) { - lv_timer_del(s_update_timer); - s_update_timer = NULL; - } - - s_chart = NULL; - s_ser_rssi = NULL; - s_lbl_rssi = NULL; - s_lbl_freq = NULL; - - subghz_spectrum_stop(); - ui_switch_screen(SCREEN_MENU); -} - -void ui_subghz_spectrum_open(void) { - subghz_spectrum_start(SPECTRUM_CENTER_FREQ, SPECTRUM_SPAN_HZ); - - if (s_screen != NULL) { - lv_obj_del(s_screen); - s_screen = NULL; - } - - if (s_update_timer != NULL) { - lv_timer_del(s_update_timer); - s_update_timer = NULL; - } - - s_screen = lv_obj_create(NULL); - lv_obj_set_style_bg_color(s_screen, current_theme.screen_base, 0); - lv_obj_remove_flag(s_screen, LV_OBJ_FLAG_SCROLLABLE); - - s_chart = lv_chart_create(s_screen); - lv_obj_set_size(s_chart, CHART_W, CHART_H); - lv_obj_align(s_chart, LV_ALIGN_CENTER, 0, CHART_OFFSET_Y); - lv_chart_set_type(s_chart, LV_CHART_TYPE_LINE); - lv_chart_set_point_count(s_chart, SPECTRUM_SAMPLES); - lv_chart_set_range(s_chart, LV_CHART_AXIS_PRIMARY_Y, CHART_RANGE_MIN, CHART_RANGE_MAX); - lv_chart_set_update_mode(s_chart, LV_CHART_UPDATE_MODE_CIRCULAR); - lv_obj_set_style_width(s_chart, 0, LV_PART_INDICATOR); - lv_obj_set_style_height(s_chart, 0, LV_PART_INDICATOR); - lv_obj_set_style_line_width(s_chart, CHART_LINE_W, LV_PART_ITEMS); - lv_obj_set_style_bg_color(s_chart, current_theme.bg_primary, 0); - lv_obj_set_style_border_color(s_chart, current_theme.border_interface, 0); - lv_obj_set_style_border_width(s_chart, CHART_BORDER_W, 0); - lv_obj_set_style_bg_opa(s_chart, CHART_ITEM_BG_OPA, LV_PART_ITEMS); - lv_obj_set_style_bg_color(s_chart, current_theme.border_accent, LV_PART_ITEMS); - lv_obj_set_style_bg_grad_color(s_chart, current_theme.bg_secondary, LV_PART_ITEMS); - lv_obj_set_style_bg_grad_dir(s_chart, LV_GRAD_DIR_VER, LV_PART_ITEMS); - lv_obj_set_style_line_dash_width(s_chart, 0, LV_PART_MAIN); - lv_obj_set_style_line_color(s_chart, current_theme.border_inactive, LV_PART_MAIN); - - s_ser_rssi = lv_chart_add_series(s_chart, current_theme.border_accent, LV_CHART_AXIS_PRIMARY_Y); - - s_lbl_freq = lv_label_create(s_screen); - lv_label_set_text(s_lbl_freq, FREQ_LABEL_DEFAULT); - lv_obj_set_style_text_font(s_lbl_freq, &lv_font_montserrat_12, 0); - lv_obj_set_style_text_color(s_lbl_freq, current_theme.text_main, 0); - lv_obj_align(s_lbl_freq, LV_ALIGN_BOTTOM_LEFT, 0, LABEL_OFFSET_Y); - - s_lbl_rssi = lv_label_create(s_screen); - lv_label_set_text(s_lbl_rssi, PEAK_LABEL_DEFAULT); - lv_obj_set_style_text_font(s_lbl_rssi, &lv_font_montserrat_12, 0); - lv_obj_set_style_text_color(s_lbl_rssi, current_theme.border_accent, 0); - lv_obj_align(s_lbl_rssi, LV_ALIGN_BOTTOM_RIGHT, 0, LABEL_OFFSET_Y); - - header_ui_create(s_screen); - footer_ui_create(s_screen); - - s_update_timer = lv_timer_create(update_spectrum_cb, UPDATE_TIMER_PERIOD_MS, NULL); - - lv_obj_add_event_cb(s_screen, on_screen_key_event, LV_EVENT_KEY, NULL); - - if (main_group != NULL) { - lv_group_add_obj(main_group, s_screen); - lv_group_focus_obj(s_screen); - } - - lv_screen_load(s_screen); -} \ No newline at end of file diff --git a/firmware_p4/components/Applications/ui/screens/about_settings/about_settings_ui.c b/firmware_p4/components/Applications/ui/screens/about_settings/about_settings_ui.c deleted file mode 100644 index f6d89600a..000000000 --- a/firmware_p4/components/Applications/ui/screens/about_settings/about_settings_ui.c +++ /dev/null @@ -1,124 +0,0 @@ -// Copyright (c) 2025 HIGH CODE LLC -// -// TentacleOS is free software: you can redistribute it and/or modify -// it under the terms of the GNU General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// TentacleOS is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU General Public License for more details. -// -// You should have received a copy of the GNU General Public License -// along with TentacleOS. If not, see . - -#include "about_settings_ui.h" - -#include "core/lv_group.h" - -#include "esp_log.h" - -#include "footer_ui.h" -#include "header_ui.h" -#include "lv_port_indev.h" -#include "ui_manager.h" -#include "ui_theme.h" - -static const char *TAG = "ABOUT_SETTINGS_UI"; - -#define INFO_BOX_WIDTH 220 -#define INFO_BOX_HEIGHT 150 -#define INFO_BOX_ALIGN_OFFSET_Y 5 -#define INFO_BOX_BORDER_WIDTH 2 -#define INFO_BOX_RADIUS 8 -#define INFO_BOX_PAD 12 -#define TITLE_MARGIN_BOTTOM 10 -#define HINT_MARGIN_TOP 15 - -static lv_obj_t *screen_about = NULL; -static lv_style_t style_info_box; - -static void init_styles(void); -static void screen_back_event_cb(lv_event_t *e); - -void ui_about_settings_open(void) { - init_styles(); - - if (screen_about != NULL) { - lv_obj_del(screen_about); - } - - screen_about = lv_obj_create(NULL); - lv_obj_set_style_bg_color(screen_about, current_theme.screen_base, 0); - lv_obj_clear_flag(screen_about, LV_OBJ_FLAG_SCROLLABLE); - - header_ui_create(screen_about); - footer_ui_create(screen_about); - - lv_obj_t *info_box = lv_obj_create(screen_about); - lv_obj_set_size(info_box, INFO_BOX_WIDTH, INFO_BOX_HEIGHT); - lv_obj_align(info_box, LV_ALIGN_CENTER, 0, INFO_BOX_ALIGN_OFFSET_Y); - lv_obj_add_style(info_box, &style_info_box, 0); - lv_obj_set_flex_flow(info_box, LV_FLEX_FLOW_COLUMN); - lv_obj_set_flex_align(info_box, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); - lv_obj_clear_flag(info_box, LV_OBJ_FLAG_SCROLLABLE); - - lv_obj_t *title = lv_label_create(info_box); - lv_label_set_text(title, "TENTACLE OS"); - lv_obj_set_style_text_color(title, current_theme.text_main, 0); - lv_obj_set_style_margin_bottom(title, TITLE_MARGIN_BOTTOM, 0); - - lv_obj_t *version = lv_label_create(info_box); - lv_label_set_text(version, "Version: DEV"); - lv_obj_set_style_text_color(version, current_theme.text_main, 0); - - lv_obj_t *hardware = lv_label_create(info_box); - lv_label_set_text(hardware, "HW: ESP32-P4"); - lv_obj_set_style_text_color(hardware, current_theme.text_main, 0); - - lv_obj_t *build = lv_label_create(info_box); - lv_label_set_text(build, "Build: Jan 2026"); - lv_obj_set_style_text_color(build, current_theme.text_main, 0); - - lv_obj_t *hint = lv_label_create(info_box); - lv_label_set_text(hint, "< PRESS TO EXIT >"); - lv_obj_set_style_text_color(hint, current_theme.text_main, 0); - lv_obj_set_style_margin_top(hint, HINT_MARGIN_TOP, 0); - - lv_obj_add_event_cb(screen_about, screen_back_event_cb, LV_EVENT_KEY, NULL); - - if (main_group != NULL) { - lv_group_add_obj(main_group, screen_about); - lv_group_focus_obj(screen_about); - } - - lv_screen_load(screen_about); -} - -static void init_styles(void) { - static bool s_styles_initialized = false; - - if (s_styles_initialized) { - lv_style_reset(&style_info_box); - } - - lv_style_init(&style_info_box); - lv_style_set_bg_color(&style_info_box, current_theme.bg_item_bot); - lv_style_set_bg_grad_color(&style_info_box, current_theme.bg_item_top); - lv_style_set_bg_grad_dir(&style_info_box, LV_GRAD_DIR_VER); - lv_style_set_border_width(&style_info_box, INFO_BOX_BORDER_WIDTH); - lv_style_set_border_color(&style_info_box, ui_theme_get_accent()); - lv_style_set_radius(&style_info_box, INFO_BOX_RADIUS); - lv_style_set_pad_all(&style_info_box, INFO_BOX_PAD); - - s_styles_initialized = true; -} - -static void screen_back_event_cb(lv_event_t *e) { - uint32_t key = lv_event_get_key(e); - - if (key == LV_KEY_ESC || key == LV_KEY_LEFT || key == LV_KEY_ENTER) { - ui_switch_screen(SCREEN_SETTINGS); - } -} \ No newline at end of file diff --git a/firmware_p4/components/Applications/ui/screens/audio/include/micrec_ui.h b/firmware_p4/components/Applications/ui/screens/audio/include/micrec_ui.h new file mode 100644 index 000000000..3621a9b75 --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/audio/include/micrec_ui.h @@ -0,0 +1,30 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#ifndef MICREC_UI_H +#define MICREC_UI_H + +#ifdef __cplusplus +extern "C" { +#endif + +/** @brief Open the mic-record → speaker-playback test screen. */ +void ui_micrec_open(void); + +#ifdef __cplusplus +} +#endif + +#endif // MICREC_UI_H diff --git a/firmware_p4/components/Applications/ui/screens/audio/include/speaker_ui.h b/firmware_p4/components/Applications/ui/screens/audio/include/speaker_ui.h new file mode 100644 index 000000000..f9ab21b25 --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/audio/include/speaker_ui.h @@ -0,0 +1,30 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#ifndef SPEAKER_UI_H +#define SPEAKER_UI_H + +#ifdef __cplusplus +extern "C" { +#endif + +/** @brief Open the speaker test menu (play tones/sounds on the MAX98357 amp). */ +void ui_speaker_open(void); + +#ifdef __cplusplus +} +#endif + +#endif // SPEAKER_UI_H diff --git a/firmware_p4/components/Applications/ui/screens/audio/include/spectrum_ui.h b/firmware_p4/components/Applications/ui/screens/audio/include/spectrum_ui.h new file mode 100644 index 000000000..a6b1476da --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/audio/include/spectrum_ui.h @@ -0,0 +1,26 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#ifndef SPECTRUM_UI_H +#define SPECTRUM_UI_H + +/** + * @brief Open the live audio spectrum analyzer: a background task streams the + * PDM mic, runs a real FFT (esp-dsp) and feeds AGC-normalized frequency + * bands to an animated bar display. BACK returns to Settings. + */ +void ui_spectrum_open(void); + +#endif // SPECTRUM_UI_H diff --git a/firmware_p4/components/Applications/ui/screens/audio/include/wav_library_ui.h b/firmware_p4/components/Applications/ui/screens/audio/include/wav_library_ui.h new file mode 100644 index 000000000..098bbb1eb --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/audio/include/wav_library_ui.h @@ -0,0 +1,42 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#ifndef WAV_LIBRARY_UI_H +#define WAV_LIBRARY_UI_H + +#ifdef __cplusplus +extern "C" { +#endif + +/** @brief Open the audio player library: lists every .wav on the SD card. */ +void ui_wav_library_open(void); + +/** @brief Number of .wav tracks found on the last scan. */ +int ui_wav_library_count(void); + +/** @brief Full path of track @p i, or NULL if out of range. */ +const char *ui_wav_library_path(int i); + +/** @brief File name of track @p i, or NULL if out of range. */ +const char *ui_wav_library_name(int i); + +/** @brief Move the highlighted row so BACK returns to the playing track. */ +void ui_wav_library_set_selected(int i); + +#ifdef __cplusplus +} +#endif + +#endif // WAV_LIBRARY_UI_H diff --git a/firmware_p4/components/Applications/ui/screens/audio/include/wav_player_ui.h b/firmware_p4/components/Applications/ui/screens/audio/include/wav_player_ui.h new file mode 100644 index 000000000..b3597e339 --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/audio/include/wav_player_ui.h @@ -0,0 +1,53 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#ifndef WAV_PLAYER_UI_H +#define WAV_PLAYER_UI_H + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * @brief Set the .wav file path to play. Call this BEFORE switching to + * SCREEN_WAV_PLAYER (e.g. from the Files screen). The string is copied. + * Resets the player to standalone (no playlist) mode: on finish or skip + * the same track repeats. The Player library overrides this by also + * calling ui_wav_player_set_index(). + */ +void ui_wav_player_set_path(const char *path); + +/** + * @brief Bind the player to the Player-library track list at index @p index, + * enabling prev/next navigation and auto-advance. Call AFTER + * ui_wav_player_set_path() and BEFORE switching to SCREEN_WAV_PLAYER. + */ +void ui_wav_player_set_index(int index); + +/** + * @brief Set the screen to return to on BACK (as a screen_id_t value). Defaults + * to the Files screen. The Player library sets it to itself so BACK from + * a track returns to the track list. + */ +void ui_wav_player_set_return(int screen); + +/** @brief Open the WAV player screen and start streaming the set path. */ +void ui_wav_player_open(void); + +#ifdef __cplusplus +} +#endif + +#endif // WAV_PLAYER_UI_H diff --git a/firmware_p4/components/Applications/ui/screens/audio/micrec_ui.c b/firmware_p4/components/Applications/ui/screens/audio/micrec_ui.c new file mode 100644 index 000000000..6ec63be10 --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/audio/micrec_ui.c @@ -0,0 +1,663 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#include "micrec_ui.h" + +#include +#include +#include +#include + +#include "audio_i2s.h" +#include "esp_heap_caps.h" +#include "esp_log.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include "sys_prio.h" + +#include "menu_component_ui.h" +#include "ui_manager.h" +#include "ui_theme.h" + +static const char *TAG = "MICREC_UI"; + +#define STATUS_TICK_MS 50 +#define REC_RATE 16000 +#define REC_MAX_SECONDS 5 +#define BUF_HEADROOM (24 * 1024) +#define ACCENT_GREEN 0x00E676 +#define ACCENT_RED 0xFF3B30 +#define NORM_MAX_GAIN_Q8 (64 * 256) +#define VU_FULLSCALE_PEAK 7000 +#define SCOPE_W 80 +#define SCOPE_FULL 9000.0f +#define OV_BOX_W 210 +#define OV_BOX_H 132 +#define WF_X0 12 +#define WF_X1 198 +#define WF_CY 60 +#define WF_AMP 22 +#define OVERLAY_TICK_MS 60 +#define OVERLAY_HIDE_MS 700 +#define MIC_TASK_STACK 8192 +#define MIC_TASK_PRIORITY SYS_PRIO_SERVICE_LO +#define REC_TARGET_DEFAULT 26000 +#define REC_TARGET_MIN 16000 +#define REC_TARGET_STEP 3400 + +#define REC_DIR "/sdcard/recordings" +#define WAV_HDR_LEN 44 +#define WAV_FMT_CHUNK 16 +#define WAV_FMT_PCM 1 +#define WAV_CH_MONO 1 +#define WAV_BITS 16 +#define SAVE_MAX_IDX 999 +#define SAVE_OV_MS 1500 + +enum { ROW_LEVEL, ROW_REC, ROW_PLAY, ROW_LOOP, ROW_SAVE, ROW_COUNT }; +enum { OV_TIME, OV_VU }; +enum { ST_IDLE, ST_RECORDING, ST_PLAYING }; + +static lv_obj_t *s_screen = NULL; +static menu_component_t s_menu; +static lv_timer_t *s_status_timer = NULL; +static lv_obj_t *s_status_lbl = NULL; + +static lv_obj_t *s_ov = NULL; +static lv_obj_t *s_ov_dot = NULL; +static lv_obj_t *s_ov_state = NULL; +static lv_obj_t *s_ov_time = NULL; +static lv_obj_t *s_ov_db = NULL; +static lv_obj_t *s_ov_bar = NULL; +static lv_obj_t *s_scope_line = NULL; +static lv_obj_t *s_scope_line2 = NULL; +static lv_timer_t *s_ov_timer = NULL; +static int s_ov_mode = OV_TIME; +static uint32_t s_ov_start = 0; +static uint32_t s_ov_total = 1; +static volatile bool s_op_done = false; +static char s_done_text[24] = ""; +static volatile int s_live_peak = 0; +static volatile int s_live_rms = 0; +static int s_vu_display = 0; + +static volatile int16_t s_scope[SCOPE_W]; +static volatile int s_scope_head = 0; +static lv_point_precise_t s_scope_pts[SCOPE_W]; +static lv_point_precise_t s_scope_pts2[SCOPE_W]; + +static int16_t *s_rec_buf = NULL; +static size_t s_rec_capacity = 0; +static volatile size_t s_rec_samples = 0; +static volatile bool s_busy = false; +static volatile bool s_stop_req = false; +static volatile int s_state = ST_IDLE; +static bool s_loop = false; +static uint32_t s_last_ms = 0; +static int s_rec_target = REC_TARGET_DEFAULT; + +static bool ensure_buffer(void) { + if (s_rec_buf != NULL) + return true; + size_t want = (size_t)REC_RATE * REC_MAX_SECONDS; + size_t largest = heap_caps_get_largest_free_block(MALLOC_CAP_8BIT); + size_t avail = (largest > BUF_HEADROOM) ? (largest - BUF_HEADROOM) : 0; + size_t cap_samples = avail / sizeof(int16_t); + size_t n = (want < cap_samples) ? want : cap_samples; + if (n < REC_RATE) { + ESP_LOGE(TAG, "no RAM for mic buffer (largest free %u B)", (unsigned)largest); + return false; + } + s_rec_buf = heap_caps_malloc(n * sizeof(int16_t), MALLOC_CAP_8BIT); + if (s_rec_buf == NULL) + return false; + s_rec_capacity = n; + ESP_LOGI(TAG, "mic buffer: %u samples (~%u s)", (unsigned)n, (unsigned)(n / REC_RATE)); + return true; +} + +static void normalize_pcm(int16_t *buf, size_t n, int target) { + if (n == 0) + return; + int32_t peak = 1; + for (size_t i = 0; i < n; i++) { + int32_t a = buf[i] < 0 ? -(int32_t)buf[i] : buf[i]; + if (a > peak) + peak = a; + } + int32_t gain_q8 = (target * 256) / peak; + if (gain_q8 < 256) + gain_q8 = 256; + if (gain_q8 > NORM_MAX_GAIN_Q8) + gain_q8 = NORM_MAX_GAIN_Q8; + for (size_t i = 0; i < n; i++) { + int32_t v = ((int32_t)buf[i] * gain_q8) >> 8; + if (v > 32767) + v = 32767; + else if (v < -32768) + v = -32768; + buf[i] = (int16_t)v; + } +} + +static void scope_reset(void) { + for (int i = 0; i < SCOPE_W; i++) + s_scope[i] = 0; + s_scope_head = 0; +} + +static void scope_push(int peak) { + int h = s_scope_head; + s_scope[h] = (int16_t)(peak > 32767 ? 32767 : peak); + s_scope_head = (h + 1) % SCOPE_W; +} + +static void scope_redraw(void) { + if (s_scope_line == NULL) + return; + int head = s_scope_head; + for (int j = 0; j < SCOPE_W; j++) { + int idx = (head + j) % SCOPE_W; + float v = (float)s_scope[idx] / SCOPE_FULL; + if (v > 1.0f) + v = 1.0f; + int x = WF_X0 + j * (WF_X1 - WF_X0) / (SCOPE_W - 1); + int dy = (int)(v * (float)WF_AMP); + s_scope_pts[j].x = x; + s_scope_pts[j].y = WF_CY - dy; + s_scope_pts2[j].x = x; + s_scope_pts2[j].y = WF_CY + dy; + } + lv_obj_invalidate(s_scope_line); + if (s_scope_line2) + lv_obj_invalidate(s_scope_line2); +} + +static void fmt_mmss(char *buf, size_t sz, uint32_t ms) { + uint32_t s = ms / 1000; + snprintf(buf, sz, "%u:%02u", (unsigned)(s / 60), (unsigned)(s % 60)); +} + +static void overlay_hide_cb(lv_timer_t *t) { + lv_timer_delete(t); + if (s_ov) { + lv_obj_del(s_ov); + s_ov = NULL; + s_ov_dot = NULL; + s_ov_state = NULL; + s_ov_time = NULL; + s_ov_db = NULL; + s_ov_bar = NULL; + s_scope_line = NULL; + s_scope_line2 = NULL; + } +} + +static void overlay_tick(lv_timer_t *t) { + if (s_ov_bar == NULL) { + lv_timer_delete(t); + s_ov_timer = NULL; + return; + } + if (s_op_done) { + lv_bar_set_value(s_ov_bar, 100, LV_ANIM_OFF); + if (s_ov_state) + lv_label_set_text(s_ov_state, s_done_text); + if (s_ov_dot) + lv_obj_set_style_bg_opa(s_ov_dot, LV_OPA_COVER, 0); + if (s_scope_line) + lv_obj_add_flag(s_scope_line, LV_OBJ_FLAG_HIDDEN); + if (s_scope_line2) + lv_obj_add_flag(s_scope_line2, LV_OBJ_FLAG_HIDDEN); + if (s_ov_db) + lv_obj_add_flag(s_ov_db, LV_OBJ_FLAG_HIDDEN); + lv_timer_delete(t); + s_ov_timer = NULL; + lv_timer_t *h = lv_timer_create(overlay_hide_cb, OVERLAY_HIDE_MS, NULL); + lv_timer_set_repeat_count(h, 1); + return; + } + uint32_t elapsed = lv_tick_get() - s_ov_start; + char tbuf[12]; + fmt_mmss(tbuf, sizeof(tbuf), elapsed); + if (s_ov_time) + lv_label_set_text(s_ov_time, tbuf); + + if (s_ov_mode == OV_VU) { + if (s_ov_dot) { + uint32_t ph = elapsed % 1000; + uint32_t tri = ph < 500 ? ph : 1000 - ph; + lv_obj_set_style_bg_opa(s_ov_dot, (lv_opa_t)(90 + tri * 165 / 500), 0); + } + int pct = (int)((int64_t)s_live_peak * 100 / VU_FULLSCALE_PEAK); + if (pct > 100) + pct = 100; + if (pct > s_vu_display) + s_vu_display = pct; + else + s_vu_display = (s_vu_display * 7) / 10; + lv_bar_set_value(s_ov_bar, s_vu_display, LV_ANIM_OFF); + scope_redraw(); + if (s_ov_db) { + int rms = s_live_rms < 1 ? 1 : s_live_rms; + int db = (int)(20.0f * log10f((float)rms / 32768.0f)); + char dbuf[16]; + snprintf(dbuf, sizeof(dbuf), "%d dBFS", db); + lv_label_set_text(s_ov_db, dbuf); + } + } else { + int pct = (int)((uint64_t)elapsed * 100 / s_ov_total); + if (pct > 99) + pct = 99; + lv_bar_set_value(s_ov_bar, pct, LV_ANIM_OFF); + } +} + +static void overlay_show(const char *title, uint32_t total_ms, int mode) { + uint32_t accent = (mode == OV_VU) ? ACCENT_RED : ACCENT_GREEN; + if (s_ov == NULL) { + s_ov = lv_obj_create(s_screen); + lv_obj_set_size(s_ov, LV_PCT(100), LV_PCT(100)); + lv_obj_center(s_ov); + lv_obj_remove_flag(s_ov, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_bg_color(s_ov, lv_color_black(), 0); + lv_obj_set_style_bg_opa(s_ov, LV_OPA_80, 0); + lv_obj_set_style_border_width(s_ov, 0, 0); + lv_obj_set_style_pad_all(s_ov, 0, 0); + + lv_obj_t *box = lv_obj_create(s_ov); + lv_obj_set_size(box, OV_BOX_W, OV_BOX_H); + lv_obj_center(box); + lv_obj_remove_flag(box, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_bg_color(box, current_theme.bg_secondary, 0); + lv_obj_set_style_bg_opa(box, LV_OPA_COVER, 0); + lv_obj_set_style_border_color(box, current_theme.border_inactive, 0); + lv_obj_set_style_border_width(box, 1, 0); + lv_obj_set_style_radius(box, 14, 0); + lv_obj_set_style_pad_all(box, 0, 0); + + s_ov_dot = lv_obj_create(box); + lv_obj_set_size(s_ov_dot, 11, 11); + lv_obj_set_pos(s_ov_dot, 14, 13); + lv_obj_remove_flag(s_ov_dot, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_radius(s_ov_dot, LV_RADIUS_CIRCLE, 0); + lv_obj_set_style_border_width(s_ov_dot, 0, 0); + lv_obj_set_style_pad_all(s_ov_dot, 0, 0); + + s_ov_state = lv_label_create(box); + lv_obj_set_style_text_font(s_ov_state, &lv_font_montserrat_12, 0); + lv_obj_set_pos(s_ov_state, 32, 13); + + s_ov_time = lv_label_create(box); + lv_obj_set_style_text_font(s_ov_time, &lv_font_montserrat_16, 0); + lv_obj_set_style_text_color(s_ov_time, current_theme.text_main, 0); + lv_obj_align(s_ov_time, LV_ALIGN_TOP_RIGHT, -14, 9); + + s_scope_line = lv_line_create(box); + lv_obj_set_style_line_width(s_scope_line, 2, 0); + lv_obj_set_pos(s_scope_line, 0, 0); + s_scope_line2 = lv_line_create(box); + lv_obj_set_style_line_width(s_scope_line2, 2, 0); + lv_obj_set_pos(s_scope_line2, 0, 0); + for (int j = 0; j < SCOPE_W; j++) { + int x = WF_X0 + j * (WF_X1 - WF_X0) / (SCOPE_W - 1); + s_scope_pts[j].x = x; + s_scope_pts[j].y = WF_CY; + s_scope_pts2[j].x = x; + s_scope_pts2[j].y = WF_CY; + } + lv_line_set_points_mutable(s_scope_line, s_scope_pts, SCOPE_W); + lv_line_set_points_mutable(s_scope_line2, s_scope_pts2, SCOPE_W); + + s_ov_db = lv_label_create(box); + lv_obj_set_style_text_font(s_ov_db, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(s_ov_db, current_theme.text_main, 0); + lv_obj_set_style_text_opa(s_ov_db, LV_OPA_70, 0); + lv_obj_align(s_ov_db, LV_ALIGN_BOTTOM_LEFT, 14, -26); + + s_ov_bar = lv_bar_create(box); + lv_obj_set_size(s_ov_bar, OV_BOX_W - 28, 9); + lv_obj_align(s_ov_bar, LV_ALIGN_BOTTOM_MID, 0, -12); + lv_bar_set_range(s_ov_bar, 0, 100); + lv_obj_set_style_bg_color(s_ov_bar, lv_color_hex(0x202028), LV_PART_MAIN); + lv_obj_set_style_bg_opa(s_ov_bar, LV_OPA_COVER, LV_PART_MAIN); + lv_obj_set_style_radius(s_ov_bar, 5, LV_PART_MAIN); + lv_obj_set_style_radius(s_ov_bar, 5, LV_PART_INDICATOR); + } + + lv_obj_set_style_bg_color(s_ov_dot, lv_color_hex(accent), 0); + lv_obj_set_style_bg_opa(s_ov_dot, LV_OPA_COVER, 0); + lv_obj_set_style_text_color(s_ov_state, lv_color_hex(accent), 0); + lv_label_set_text(s_ov_state, title); + lv_obj_set_style_line_color(s_scope_line, lv_color_hex(accent), 0); + lv_obj_set_style_line_color(s_scope_line2, lv_color_hex(accent), 0); + lv_obj_set_style_bg_color(s_ov_bar, lv_color_hex(accent), LV_PART_INDICATOR); + lv_bar_set_value(s_ov_bar, 0, LV_ANIM_OFF); + lv_label_set_text(s_ov_time, "0:00"); + + bool wave = (mode == OV_VU); + if (s_scope_line) { + if (wave) { + lv_obj_remove_flag(s_scope_line, LV_OBJ_FLAG_HIDDEN); + lv_obj_remove_flag(s_scope_line2, LV_OBJ_FLAG_HIDDEN); + lv_obj_remove_flag(s_ov_db, LV_OBJ_FLAG_HIDDEN); + } else { + lv_obj_add_flag(s_scope_line, LV_OBJ_FLAG_HIDDEN); + lv_obj_add_flag(s_scope_line2, LV_OBJ_FLAG_HIDDEN); + lv_obj_add_flag(s_ov_db, LV_OBJ_FLAG_HIDDEN); + } + } + s_ov_mode = mode; + s_ov_start = lv_tick_get(); + s_ov_total = total_ms ? total_ms : 1; + s_op_done = false; + s_vu_display = 0; + if (s_ov_timer == NULL) + s_ov_timer = lv_timer_create(overlay_tick, OVERLAY_TICK_MS, NULL); +} + +static void op_done_cb(void *unused) { + (void)unused; + s_op_done = true; + s_state = ST_IDLE; +} + +static void finish(const char *done_text) { + strncpy(s_done_text, done_text, sizeof(s_done_text) - 1); + s_done_text[sizeof(s_done_text) - 1] = '\0'; + lv_async_call(op_done_cb, NULL); +} + +static void mic_level_cb(int peak, int rms, void *ctx) { + (void)ctx; + s_live_peak = peak; + s_live_rms = rms; + scope_push(peak); +} + +static void record_task(void *arg) { + (void)arg; + size_t got = 0; + audio_i2s_mic_record(s_rec_buf, s_rec_capacity, REC_RATE, &got, mic_level_cb, NULL); + normalize_pcm(s_rec_buf, got, s_rec_target); + s_rec_samples = got; + s_last_ms = (uint32_t)((uint64_t)got * 1000 / REC_RATE); + finish(got > 0 ? "Recorded!" : "Mic failed"); + s_busy = false; + vTaskDelete(NULL); +} + +static void play_task(void *arg) { + (void)arg; + do { + audio_i2s_play_pcm(s_rec_buf, s_rec_samples, REC_RATE); + } while (s_loop && !s_stop_req); + finish("Done"); + s_busy = false; + vTaskDelete(NULL); +} + +static void wav_u16(uint8_t *p, uint16_t v) { + p[0] = (uint8_t)v; + p[1] = (uint8_t)(v >> 8); +} + +static void wav_u32(uint8_t *p, uint32_t v) { + p[0] = (uint8_t)v; + p[1] = (uint8_t)(v >> 8); + p[2] = (uint8_t)(v >> 16); + p[3] = (uint8_t)(v >> 24); +} + +static bool write_wav(const char *path, const int16_t *pcm, size_t n, uint32_t rate) { + FILE *f = fopen(path, "wb"); + if (f == NULL) + return false; + uint16_t block_align = WAV_CH_MONO * (WAV_BITS / 8); + uint32_t data_len = (uint32_t)(n * (WAV_BITS / 8)); + uint8_t h[WAV_HDR_LEN]; + memcpy(h, "RIFF", 4); + wav_u32(h + 4, (WAV_HDR_LEN - 8) + data_len); + memcpy(h + 8, "WAVE", 4); + memcpy(h + 12, "fmt ", 4); + wav_u32(h + 16, WAV_FMT_CHUNK); + wav_u16(h + 20, WAV_FMT_PCM); + wav_u16(h + 22, WAV_CH_MONO); + wav_u32(h + 24, rate); + wav_u32(h + 28, rate * block_align); + wav_u16(h + 32, block_align); + wav_u16(h + 34, WAV_BITS); + memcpy(h + 36, "data", 4); + wav_u32(h + 40, data_len); + bool ok = fwrite(h, 1, WAV_HDR_LEN, f) == WAV_HDR_LEN; + if (ok) + ok = fwrite(pcm, 1, data_len, f) == data_len; + fclose(f); + return ok; +} + +static void next_wav_path(char *out, size_t out_sz) { + out[0] = '\0'; + for (int i = 1; i <= SAVE_MAX_IDX; i++) { + snprintf(out, out_sz, "%s/rec_%03d.wav", REC_DIR, i); + FILE *t = fopen(out, "rb"); + if (t == NULL) + return; + fclose(t); + } +} + +static void save_task(void *arg) { + (void)arg; + mkdir(REC_DIR, 0777); + char path[80]; + next_wav_path(path, sizeof(path)); + bool ok = path[0] != '\0' && write_wav(path, s_rec_buf, s_rec_samples, REC_RATE); + if (ok) { + const char *base = strrchr(path, '/'); + char msg[24]; + snprintf(msg, sizeof(msg), "Saved %.17s", base ? base + 1 : path); + finish(msg); + } else { + finish("Save failed"); + } + s_busy = false; + vTaskDelete(NULL); +} + +static void activate(int idx) { + if (s_busy) + return; + if (idx == ROW_REC) { + if (!ensure_buffer()) { + overlay_show("No memory", 1, OV_TIME); + finish("No memory"); + return; + } + s_live_peak = 0; + s_live_rms = 0; + scope_reset(); + s_rec_target = + REC_TARGET_MIN + menu_component_get_intensity(&s_menu, ROW_LEVEL) * REC_TARGET_STEP; + s_state = ST_RECORDING; + uint32_t dur_ms = (uint32_t)(s_rec_capacity / REC_RATE) * 1000 + 250; + overlay_show("RECORDING", dur_ms, OV_VU); + s_busy = true; + if (xTaskCreatePinnedToCore( + record_task, "mic_rec", MIC_TASK_STACK, NULL, MIC_TASK_PRIORITY, NULL, SYS_CORE_UI) != + pdPASS) { + s_busy = false; + s_state = ST_IDLE; + finish("Task error"); + } + } else if (idx == ROW_PLAY) { + if (s_rec_samples == 0) { + overlay_show("Record first", 1, OV_TIME); + finish("Record first"); + return; + } + s_loop = menu_component_get_toggle(&s_menu, ROW_LOOP); + s_stop_req = false; + s_state = ST_PLAYING; + uint32_t dur_ms = (uint32_t)(s_rec_samples / REC_RATE) * 1000 + 150; + overlay_show(s_loop ? "PLAYING (loop)" : "PLAYING", dur_ms, OV_TIME); + s_busy = true; + if (xTaskCreatePinnedToCore( + play_task, "mic_play", MIC_TASK_STACK, NULL, MIC_TASK_PRIORITY, NULL, SYS_CORE_UI) != + pdPASS) { + s_busy = false; + s_state = ST_IDLE; + finish("Task error"); + } + } else if (idx == ROW_SAVE) { + if (s_rec_samples == 0) { + overlay_show("Record first", 1, OV_TIME); + finish("Record first"); + return; + } + if (!ui_sd_ready()) + return; + overlay_show("SAVING", SAVE_OV_MS, OV_TIME); + s_busy = true; + if (xTaskCreatePinnedToCore( + save_task, "mic_save", MIC_TASK_STACK, NULL, MIC_TASK_PRIORITY, NULL, SYS_CORE_RADIO) != + pdPASS) { + s_busy = false; + finish("Task error"); + } + } +} + +static void refresh_status(void) { + if (s_status_lbl == NULL) + return; + char buf[40]; + if (s_state == ST_RECORDING) + snprintf(buf, sizeof(buf), "RECORDING..."); + else if (s_state == ST_PLAYING) + snprintf(buf, sizeof(buf), s_loop ? "PLAYING (loop)" : "PLAYING..."); + else if (s_rec_samples > 0) + snprintf(buf, + sizeof(buf), + "IDLE Last: %u.%us", + (unsigned)(s_last_ms / 1000), + (unsigned)((s_last_ms % 1000) / 100)); + else + snprintf(buf, sizeof(buf), "IDLE (no recording)"); + lv_label_set_text(s_status_lbl, buf); +} + +static void micrec_tick_cb(lv_timer_t *t) { + if (lv_screen_active() != s_screen) { + lv_timer_delete(t); + s_status_timer = NULL; + return; + } + if (s_ov != NULL) + return; + + refresh_status(); +} + +static void micrec_input(const input_event_t *ev, void *ctx) { + (void)ctx; + const bool press = (ev->action == INPUT_ACTION_PRESS); + const bool nav = press || (ev->action == INPUT_ACTION_REPEAT); + + if (s_ov != NULL) { + if (ev->button == INPUT_BTN_BACK && press && s_busy && s_state == ST_PLAYING) + s_stop_req = true; + return; + } + + int sel = menu_component_get_selected(&s_menu); + switch (ev->button) { + case INPUT_BTN_DOWN: + if (nav) + menu_component_next(&s_menu); + break; + case INPUT_BTN_UP: + if (nav) + menu_component_prev(&s_menu); + break; + case INPUT_BTN_RIGHT: + if (nav && sel == ROW_LEVEL) + menu_component_intensity_inc(&s_menu, ROW_LEVEL); + break; + case INPUT_BTN_LEFT: + if (nav && sel == ROW_LEVEL) + menu_component_intensity_dec(&s_menu, ROW_LEVEL); + break; + case INPUT_BTN_OK: + if (press) { + if (sel == ROW_LOOP) + menu_component_toggle_item(&s_menu, ROW_LOOP); + else + activate(sel); + } + break; + case INPUT_BTN_BACK: + if (press) + ui_switch_screen(SCREEN_SETTINGS); + break; + default: + break; + } +} + +void ui_micrec_open(void) { + if (s_screen != NULL) { + lv_obj_del(s_screen); + s_screen = NULL; + } + s_ov = NULL; + s_ov_dot = NULL; + s_ov_state = NULL; + s_ov_time = NULL; + s_ov_db = NULL; + s_ov_bar = NULL; + s_scope_line = NULL; + s_scope_line2 = NULL; + s_ov_timer = NULL; + s_state = ST_IDLE; + + s_screen = lv_obj_create(NULL); + lv_obj_set_style_bg_color(s_screen, current_theme.screen_base, 0); + lv_obj_set_style_bg_opa(s_screen, LV_OPA_COVER, 0); + lv_obj_remove_flag(s_screen, LV_OBJ_FLAG_SCROLLABLE); + + s_menu = menu_component_create(s_screen, "Recorder", "/assets/icons/mic.bin"); + menu_component_add_intensity(&s_menu, "/assets/icons/graphic_eq.bin", "Rec Level", 3); + menu_component_add_item(&s_menu, "/assets/icons/fiber_manual_record.bin", "Record"); + menu_component_add_item(&s_menu, "/assets/icons/play_arrow.bin", "Play"); + menu_component_add_toggle(&s_menu, "/assets/icons/repeat.bin", "Loop", false); + menu_component_add_item(&s_menu, "/assets/icons/sd_card.bin", "Save WAV"); + + s_status_lbl = lv_label_create(s_screen); + lv_label_set_text(s_status_lbl, "IDLE (no recording)"); + lv_obj_set_style_text_color(s_status_lbl, current_theme.text_main, 0); + lv_obj_set_style_text_opa(s_status_lbl, LV_OPA_70, 0); + lv_obj_set_style_text_font(s_status_lbl, &lv_font_montserrat_12, 0); + lv_obj_align(s_status_lbl, LV_ALIGN_BOTTOM_MID, 0, -4 - MENU_COMP_FOOTER_H); + refresh_status(); + + if (s_status_timer == NULL) + s_status_timer = lv_timer_create(micrec_tick_cb, STATUS_TICK_MS, NULL); + + ui_input_set_screen_handler(micrec_input, NULL); + + ui_screen_load_owned(&s_screen, s_screen); + ESP_LOGI(TAG, "mic-rec menu opened"); +} diff --git a/firmware_p4/components/Applications/ui/screens/audio/speaker_ui.c b/firmware_p4/components/Applications/ui/screens/audio/speaker_ui.c new file mode 100644 index 000000000..255145147 --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/audio/speaker_ui.c @@ -0,0 +1,568 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#include "speaker_ui.h" + +#include + +#include "audio_i2s.h" +#include "esp_log.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include "sys_prio.h" +#include "lvgl.h" + +#include "menu_component_ui.h" +#include "ui_chrome.h" +#include "ui_manager.h" +#include "ui_theme.h" + +static const char *TAG = "SPEAKER_UI"; + +#define VOLUME_ROW 0 +#define CATEGORY_ROW 1 +#define SONG_ROW_BASE 2 +#define SND_AMP 0.75f +#define MAX_SEQ 64 +#define N_EQ 10 +#define SPK_TASK_STACK 8192 +#define SPK_TASK_PRIORITY SYS_PRIO_SERVICE_LO +#define NP_TIMER_MS 33 +#define MAX_SONG_ROWS 10 +#define COUNT(a) ((int)(sizeof(a) / sizeof((a)[0]))) + +typedef struct { + const char *name; + const audio_note_t *notes; + uint16_t count; + uint8_t tempo_pct; +} speaker_song_t; + +typedef struct { + const char *name; + const speaker_song_t *songs; + int count; +} speaker_cat_t; + +static const audio_note_t M_MARIO[] = { + {659, 120}, + {0, 60}, + {659, 120}, + {0, 120}, + {659, 120}, + {0, 120}, + {523, 120}, + {659, 120}, + {0, 60}, + {784, 160}, + {0, 320}, + {392, 160}, +}; +static const audio_note_t M_TETRIS[] = { + {659, 300}, {494, 150}, {523, 150}, {587, 300}, {523, 150}, {494, 150}, {440, 300}, + {440, 150}, {523, 150}, {659, 300}, {587, 150}, {523, 150}, {494, 420}, {523, 150}, + {587, 300}, {659, 300}, {523, 300}, {440, 360}, {0, 120}, +}; +static const audio_note_t M_ZELDA[] = { + {784, 130}, + {740, 130}, + {622, 130}, + {440, 130}, + {415, 130}, + {659, 130}, + {831, 130}, + {1047, 440}, +}; +static const audio_note_t M_COIN[] = {{988, 90}, {1319, 520}}; +static const audio_note_t M_POWERUP[] = { + {523, 60}, + {659, 60}, + {784, 60}, + {1047, 60}, + {1319, 60}, + {1047, 60}, + {1175, 140}, +}; +static const audio_note_t M_LASER[] = { + {2600, 40}, + {2100, 40}, + {1600, 40}, + {1100, 40}, + {700, 60}, + {400, 80}, +}; + +static const audio_note_t M_ODE[] = { + {659, 200}, + {659, 200}, + {698, 200}, + {784, 200}, + {784, 200}, + {698, 200}, + {659, 200}, + {587, 200}, + {523, 200}, + {523, 200}, + {587, 200}, + {659, 200}, + {659, 280}, + {587, 360}, +}; +static const audio_note_t M_ELISE[] = { + {659, 160}, + {622, 160}, + {659, 160}, + {622, 160}, + {659, 160}, + {494, 160}, + {587, 160}, + {523, 160}, + {440, 360}, +}; +static const audio_note_t M_TWINKLE[] = { + {523, 300}, + {523, 300}, + {784, 300}, + {784, 300}, + {880, 300}, + {880, 300}, + {784, 500}, + {698, 300}, + {698, 300}, + {659, 300}, + {659, 300}, + {587, 300}, + {587, 300}, + {523, 500}, +}; +static const audio_note_t M_MINUET[] = { + {587, 380}, + {392, 190}, + {440, 190}, + {494, 190}, + {523, 190}, + {587, 380}, + {392, 380}, + {392, 380}, +}; + +static const audio_note_t M_BEEP1K[] = {{1000, 350}}; +static const audio_note_t M_BEEP2K[] = {{2000, 350}}; +static const audio_note_t M_CHIME[] = {{523, 140}, {659, 140}, {784, 240}}; +static const audio_note_t M_SIREN[] = { + {600, 220}, + {900, 220}, + {600, 220}, + {900, 220}, + {600, 220}, + {900, 220}, + {600, 220}, + {900, 220}, +}; +static const audio_note_t M_ALARM[] = { + {2500, 80}, + {0, 60}, + {2500, 80}, + {0, 60}, + {2500, 80}, + {0, 60}, + {2500, 80}, + {0, 60}, + {2500, 80}, + {0, 60}, + {2500, 80}, + {0, 60}, +}; +static const audio_note_t M_SOS[] = { + {800, 100}, + {0, 90}, + {800, 100}, + {0, 90}, + {800, 100}, + {0, 260}, + {800, 320}, + {0, 90}, + {800, 320}, + {0, 90}, + {800, 320}, + {0, 260}, + {800, 100}, + {0, 90}, + {800, 100}, + {0, 90}, + {800, 100}, + {0, 260}, +}; +static const audio_note_t M_NOTIFY[] = {{880, 120}, {1175, 320}}; + +#define SONG(n, arr, t) {n, arr, (uint16_t)COUNT(arr), t} +static const speaker_song_t CHIPTUNE[] = { + SONG("Mario", M_MARIO, 100), + SONG("Tetris", M_TETRIS, 100), + SONG("Zelda Secret", M_ZELDA, 100), + SONG("Coin", M_COIN, 100), + SONG("Power Up", M_POWERUP, 100), + SONG("Laser", M_LASER, 100), +}; +static const speaker_song_t CLASSICAL[] = { + SONG("Ode to Joy", M_ODE, 100), + SONG("Fur Elise", M_ELISE, 100), + SONG("Twinkle", M_TWINKLE, 100), + SONG("Minuet", M_MINUET, 100), +}; +static const speaker_song_t ALERTS[] = { + SONG("Beep 1kHz", M_BEEP1K, 100), + SONG("Beep 2kHz", M_BEEP2K, 100), + SONG("Chime", M_CHIME, 100), + SONG("Siren", M_SIREN, 100), + SONG("Alarm", M_ALARM, 100), + SONG("SOS", M_SOS, 100), + SONG("Notify", M_NOTIFY, 100), +}; +static const speaker_cat_t CATS[] = { + {"Chiptunes", CHIPTUNE, COUNT(CHIPTUNE)}, + {"Classical", CLASSICAL, COUNT(CLASSICAL)}, + {"Alerts", ALERTS, COUNT(ALERTS)}, +}; +#define NUM_CATS COUNT(CATS) + +static lv_obj_t *s_screen = NULL; +static menu_component_t s_menu; +static int s_category = 0; + +static lv_obj_t *s_nowplaying = NULL; +static lv_obj_t *s_eq[N_EQ]; +static lv_obj_t *s_progress = NULL; +static lv_obj_t *s_np_title = NULL; +static lv_obj_t *s_np_count = NULL; +static lv_timer_t *s_np_timer = NULL; +static float s_eq_disp[N_EQ]; + +static const speaker_song_t *s_active = NULL; +static volatile bool s_busy = false; +static volatile bool s_playing = false; +static volatile bool s_cancel = false; +static volatile int s_cur_idx = 0; +static volatile int s_note_count = 1; +static volatile uint16_t s_cur_freq = 0; + +static void apply_volume(int level) { + if (level < 0) + level = 0; + if (level > INTENSITY_BAR_STEPS) + level = INTENSITY_BAR_STEPS; + audio_i2s_set_volume((uint8_t)(level * 100 / INTENSITY_BAR_STEPS)); +} + +static int freq_to_band(uint16_t f) { + const float lo = 200.0f, hi = 3000.0f; + float ff = (float)f; + if (ff < lo) + ff = lo; + if (ff > hi) + ff = hi; + int b = (int)(logf(ff / lo) / logf(hi / lo) * (float)(N_EQ - 1) + 0.5f); + if (b < 0) + b = 0; + if (b >= N_EQ) + b = N_EQ - 1; + return b; +} + +static bool progress_cb(int i, int n, uint16_t freq, void *ctx) { + (void)ctx; + s_cur_idx = i; + s_note_count = n; + s_cur_freq = freq; + return !s_cancel; +} + +static void speaker_task(void *arg) { + (void)arg; + const speaker_song_t *s = s_active; + if (s != NULL && s->notes != NULL) { + int n = s->count; + if (n > MAX_SEQ) + n = MAX_SEQ; + audio_note_t seq[MAX_SEQ]; + uint8_t t = s->tempo_pct ? s->tempo_pct : 100; + for (int i = 0; i < n; i++) { + seq[i].freq_hz = s->notes[i].freq_hz; + uint32_t d = (uint32_t)s->notes[i].dur_ms * t / 100; + seq[i].dur_ms = (uint16_t)(d > 0 ? d : 1); + } + s_note_count = n; + audio_i2s_play_song_cb(seq, n, SND_AMP, progress_cb, NULL); + } + s_cur_freq = 0; + s_playing = false; + s_busy = false; + vTaskDelete(NULL); +} + +static lv_obj_t *make_eq_bar(lv_obj_t *parent) { + lv_obj_t *bar = lv_obj_create(parent); + lv_obj_remove_flag(bar, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_flex_grow(bar, 1); + lv_obj_set_height(bar, lv_pct(3)); + lv_obj_set_style_radius(bar, 2, 0); + lv_obj_set_style_border_width(bar, 0, 0); + lv_obj_set_style_pad_all(bar, 0, 0); + lv_obj_set_style_bg_opa(bar, LV_OPA_COVER, 0); + lv_obj_set_style_bg_color(bar, lv_color_hex(0xFF5252), 0); + lv_obj_set_style_bg_grad_color(bar, lv_color_hex(0x00E676), 0); + lv_obj_set_style_bg_grad_dir(bar, LV_GRAD_DIR_VER, 0); + return bar; +} + +static void np_timer_cb(lv_timer_t *t); +static void speaker_input(const input_event_t *ev, void *ctx); +static void speaker_np_input(const input_event_t *ev, void *ctx); + +static void nowplaying_open(const speaker_song_t *song) { + if (s_nowplaying != NULL) { + lv_obj_del(s_nowplaying); + s_nowplaying = NULL; + } + for (int i = 0; i < N_EQ; i++) + s_eq_disp[i] = 0.0f; + + s_nowplaying = lv_obj_create(NULL); + lv_obj_set_style_bg_color(s_nowplaying, current_theme.screen_base, 0); + lv_obj_set_style_bg_opa(s_nowplaying, LV_OPA_COVER, 0); + lv_obj_remove_flag(s_nowplaying, LV_OBJ_FLAG_SCROLLABLE); + + // Transient playback screen: snapshot header (no rebind) so freeing it never + // dangles the live speaker menu's dynamic header underneath. + ui_chrome_header_overlay(s_nowplaying, "NOW PLAYING", "/assets/icons/music_note.bin"); + ui_chrome_footer(s_nowplaying, "OK / BACK = stop"); + + s_np_title = lv_label_create(s_nowplaying); + lv_obj_set_width(s_np_title, lv_pct(86)); + lv_label_set_long_mode(s_np_title, LV_LABEL_LONG_MODE_SCROLL_CIRCULAR); + lv_label_set_text(s_np_title, song->name); + lv_obj_set_style_text_color(s_np_title, ui_theme_get_accent(), 0); + lv_obj_set_style_text_font(s_np_title, &lv_font_montserrat_14, 0); + lv_obj_set_style_text_align(s_np_title, LV_TEXT_ALIGN_CENTER, 0); + lv_obj_align(s_np_title, LV_ALIGN_TOP_MID, 0, UI_CHROME_HEADER_H + 8); + + lv_obj_t *eqc = lv_obj_create(s_nowplaying); + lv_obj_remove_flag(eqc, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_bg_opa(eqc, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(eqc, 0, 0); + lv_obj_set_style_pad_all(eqc, 4, 0); + lv_obj_set_style_pad_column(eqc, 4, 0); + lv_obj_set_size(eqc, lv_pct(86), lv_pct(40)); + lv_obj_align(eqc, LV_ALIGN_CENTER, 0, -6); + lv_obj_set_flex_flow(eqc, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(eqc, LV_FLEX_ALIGN_SPACE_EVENLY, LV_FLEX_ALIGN_END, LV_FLEX_ALIGN_END); + for (int i = 0; i < N_EQ; i++) + s_eq[i] = make_eq_bar(eqc); + + s_progress = lv_bar_create(s_nowplaying); + lv_obj_set_size(s_progress, lv_pct(86), 8); + lv_obj_align(s_progress, LV_ALIGN_CENTER, 0, 60); + lv_bar_set_range(s_progress, 0, song->count > 0 ? song->count : 1); + lv_bar_set_value(s_progress, 0, LV_ANIM_OFF); + + s_np_count = lv_label_create(s_nowplaying); + lv_label_set_text(s_np_count, "0 / 0"); + lv_obj_set_style_text_color(s_np_count, current_theme.text_main, 0); + lv_obj_set_style_text_opa(s_np_count, LV_OPA_70, 0); + lv_obj_set_style_text_font(s_np_count, &lv_font_montserrat_12, 0); + lv_obj_align(s_np_count, LV_ALIGN_CENTER, 0, 80); + + s_np_timer = lv_timer_create(np_timer_cb, NP_TIMER_MS, NULL); + ui_input_set_screen_handler(speaker_np_input, NULL); + ui_screen_load_owned(&s_nowplaying, s_nowplaying); +} + +static void speaker_np_input(const input_event_t *ev, void *ctx) { + (void)ctx; + const bool press = (ev->action == INPUT_ACTION_PRESS); + + switch (ev->button) { + case INPUT_BTN_OK: + case INPUT_BTN_BACK: + if (press) + s_cancel = true; + break; + default: + break; + } +} + +static void np_timer_cb(lv_timer_t *t) { + if (lv_screen_active() != s_nowplaying) { + lv_timer_delete(t); + s_np_timer = NULL; + if (s_nowplaying != NULL) { + lv_obj_del(s_nowplaying); + s_nowplaying = NULL; + } + return; + } + + if (!s_playing) { + lv_timer_delete(t); + s_np_timer = NULL; + if (s_nowplaying != NULL) { + lv_obj_del(s_nowplaying); + s_nowplaying = NULL; + } + ui_screen_load_owned(&s_screen, s_screen); + ui_input_set_screen_handler(speaker_input, NULL); + return; + } + + uint16_t f = s_cur_freq; + int band = (f > 0) ? freq_to_band(f) : -1; + for (int i = 0; i < N_EQ; i++) { + float target = 0.0f; + if (band >= 0) { + int d = i - band; + if (d < 0) + d = -d; + target = (d == 0) ? 1.0f : (d == 1) ? 0.55f : (d == 2) ? 0.25f : 0.0f; + } + if (target > s_eq_disp[i]) + s_eq_disp[i] = target; + else + s_eq_disp[i] *= 0.82f; + int h = (int)(3.0f + s_eq_disp[i] * 94.0f); + lv_obj_set_height(s_eq[i], lv_pct(h)); + } + + int idx = s_cur_idx, cnt = s_note_count; + lv_bar_set_value(s_progress, idx + 1, LV_ANIM_OFF); + lv_label_set_text_fmt(s_np_count, "%d / %d", idx + 1, cnt); +} + +static void play_song_now(const speaker_song_t *song) { + if (song == NULL || s_busy) + return; + s_active = song; + s_busy = true; + s_cancel = false; + s_playing = true; + s_cur_idx = 0; + s_cur_freq = 0; + s_note_count = song->count > 0 ? song->count : 1; + nowplaying_open(song); + if (xTaskCreatePinnedToCore( + speaker_task, "spk_play", SPK_TASK_STACK, NULL, SPK_TASK_PRIORITY, NULL, SYS_CORE_UI) != + pdPASS) { + s_busy = false; + s_playing = false; + } +} + +static void rebuild_async(void *p) { + (void)p; + ui_speaker_open(); + menu_component_select(&s_menu, CATEGORY_ROW); +} + +static void cycle_category(int dir) { + s_category = (s_category + dir + NUM_CATS) % NUM_CATS; + lv_async_call(rebuild_async, NULL); +} + +static void speaker_input(const input_event_t *ev, void *ctx) { + (void)ctx; + const bool press = (ev->action == INPUT_ACTION_PRESS); + const bool nav = press || (ev->action == INPUT_ACTION_REPEAT); + int sel = menu_component_get_selected(&s_menu); + + switch (ev->button) { + case INPUT_BTN_DOWN: + if (nav) + menu_component_next(&s_menu); + break; + case INPUT_BTN_UP: + if (nav) + menu_component_prev(&s_menu); + break; + case INPUT_BTN_RIGHT: + if (nav) { + if (sel == VOLUME_ROW) { + menu_component_intensity_inc(&s_menu, VOLUME_ROW); + apply_volume(menu_component_get_intensity(&s_menu, VOLUME_ROW)); + } else if (sel == CATEGORY_ROW) { + cycle_category(+1); + } else { + const speaker_cat_t *c = &CATS[s_category]; + int si = sel - SONG_ROW_BASE; + if (si >= 0 && si < c->count) + play_song_now(&c->songs[si]); + } + } + break; + case INPUT_BTN_LEFT: + if (nav) { + if (sel == VOLUME_ROW) { + menu_component_intensity_dec(&s_menu, VOLUME_ROW); + apply_volume(menu_component_get_intensity(&s_menu, VOLUME_ROW)); + } else if (sel == CATEGORY_ROW) { + cycle_category(-1); + } + } + break; + case INPUT_BTN_OK: + if (press) { + if (sel == CATEGORY_ROW) { + cycle_category(+1); + } else if (sel >= SONG_ROW_BASE) { + const speaker_cat_t *c = &CATS[s_category]; + int si = sel - SONG_ROW_BASE; + if (si >= 0 && si < c->count) + play_song_now(&c->songs[si]); + } + } + break; + case INPUT_BTN_BACK: + if (press) + ui_switch_screen(SCREEN_SETTINGS); + break; + default: + break; + } +} + +void ui_speaker_open(void) { + if (s_screen != NULL) { + lv_obj_del(s_screen); + s_screen = NULL; + } + + s_screen = lv_obj_create(NULL); + lv_obj_set_style_bg_color(s_screen, current_theme.screen_base, 0); + lv_obj_set_style_bg_opa(s_screen, LV_OPA_COVER, 0); + lv_obj_remove_flag(s_screen, LV_OBJ_FLAG_SCROLLABLE); + + s_menu = menu_component_create(s_screen, "Speaker", "/assets/icons/speaker.bin"); + menu_component_add_intensity(&s_menu, "/assets/icons/volume_up.bin", "Volume", 3); + apply_volume(3); + menu_component_add_selector( + &s_menu, "/assets/icons/category.bin", "Category", CATS[s_category].name); + const speaker_cat_t *c = &CATS[s_category]; + int n = c->count > MAX_SONG_ROWS ? MAX_SONG_ROWS : c->count; + for (int i = 0; i < n; i++) + menu_component_add_item(&s_menu, "/assets/icons/music_note.bin", c->songs[i].name); + + ui_input_set_screen_handler(speaker_input, NULL); + ui_screen_load_owned(&s_screen, s_screen); + ESP_LOGI(TAG, "speaker menu opened (category=%s)", c->name); +} diff --git a/firmware_p4/components/Applications/ui/screens/audio/spectrum_ui.c b/firmware_p4/components/Applications/ui/screens/audio/spectrum_ui.c new file mode 100644 index 000000000..d408475e3 --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/audio/spectrum_ui.c @@ -0,0 +1,391 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#include "spectrum_ui.h" + +#include +#include + +#include "esp_dsp.h" +#include "esp_log.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include "sys_prio.h" +#include "lvgl.h" +#include "st7789.h" + +#include "audio_i2s.h" +#include "ui_chrome.h" +#include "ui_manager.h" +#include "ui_theme.h" + +static const char *TAG = "SPECTRUM_UI"; + +#define SAMPLE_RATE 16000 +#define FFT_N 512 +#define N_BARS 24 +#define BIN_LO 2 +#define BIN_HI 200 +#define GMAX_FLOOR 6000.0f +#define NOISE_GATE 4000.0f +#define GMAX_DECAY 0.95f +#define CAP_H 3 +#define HOLD_FRAMES 12 +#define PEAK_FALL 0.015f +#define CLIP_SAMPLE 32000 +#define PEAK_DB_FLOOR (-60.0f) +#define TASK_STOP_RETRIES 40 +#define TASK_STOP_DELAY_MS 10 +#define ANIM_TIMER_MS 33 +#define SPECTRUM_TASK_STACK 8192 +#define SPECTRUM_TASK_PRIORITY SYS_PRIO_SERVICE_LO +#define SPECTRUM_TASK_CORE SYS_CORE_UI + +#define SPECTRUM_ICON "/assets/icons/graphic_eq.bin" +#define SPECTRUM_TITLE "SPECTRUM" +#define SPECTRUM_FOOTER "BACK to exit" +#define STATUS_ROW_TOP_OFS 4 +#define PLOT_TOP_OFS 24 +#define AXIS_H 16 +#define PLOT_BOTTOM_PAD 4 +#define PLOT_MIN_H 40 + +static lv_obj_t *s_screen = NULL; +static lv_obj_t *s_plot = NULL; +static lv_obj_t *s_bars[N_BARS]; +static lv_obj_t *s_caps[N_BARS]; +static lv_obj_t *s_db_label = NULL; +static lv_obj_t *s_clip_label = NULL; +static lv_timer_t *s_anim_timer = NULL; + +static volatile float s_bands[N_BARS]; +static volatile float s_peak_db = PEAK_DB_FLOOR; +static volatile bool s_clip = false; +static volatile bool s_running = false; +static volatile bool s_task_active = false; +static float s_gmax = GMAX_FLOOR; +static float s_disp[N_BARS]; +static float s_peak[N_BARS]; +static uint8_t s_hold[N_BARS]; +static int s_plot_h = 100; + +static void spectrum_task(void *arg) { + (void)arg; + s_task_active = true; + + int16_t *raw = malloc(FFT_N * sizeof(int16_t)); + float *y = malloc(2 * FFT_N * sizeof(float)); + float *win = malloc(FFT_N * sizeof(float)); + if (!raw || !y || !win) { + ESP_LOGE(TAG, "FFT buffer alloc failed"); + goto done; + } + + static bool dsp_inited = false; + if (!dsp_inited && dsps_fft2r_init_fc32(NULL, CONFIG_DSP_MAX_FFT_SIZE) == ESP_OK) + dsp_inited = true; + dsps_wind_hann_f32(win, FFT_N); + + int blo[N_BARS], bhi[N_BARS]; + for (int i = 0; i < N_BARS; i++) { + float e0 = (float)BIN_LO * powf((float)BIN_HI / BIN_LO, (float)i / N_BARS); + float e1 = (float)BIN_LO * powf((float)BIN_HI / BIN_LO, (float)(i + 1) / N_BARS); + blo[i] = (int)(e0 + 0.5f); + bhi[i] = (int)(e1 + 0.5f); + if (bhi[i] <= blo[i]) + bhi[i] = blo[i] + 1; + if (bhi[i] > FFT_N / 2) + bhi[i] = FFT_N / 2; + } + + if (audio_i2s_mic_stream_start(SAMPLE_RATE) != ESP_OK) { + ESP_LOGE(TAG, "mic stream start failed"); + goto done; + } + + while (s_running) { + int filled = 0; + while (s_running && filled < FFT_N) { + int got = audio_i2s_mic_stream_read(raw + filled, FFT_N - filled); + if (got <= 0) + break; + filled += got; + } + if (filled < FFT_N) + continue; + + int rawpk = 0; + for (int i = 0; i < FFT_N; i++) { + int a = raw[i] < 0 ? -raw[i] : raw[i]; + if (a > rawpk) + rawpk = a; + y[2 * i] = (float)raw[i] * win[i]; + y[2 * i + 1] = 0.0f; + } + + s_peak_db = rawpk > 0 ? 20.0f * log10f((float)rawpk / 32768.0f) : PEAK_DB_FLOOR; + if (s_peak_db < PEAK_DB_FLOOR) + s_peak_db = PEAK_DB_FLOOR; + s_clip = (rawpk >= CLIP_SAMPLE); + + if (dsp_inited) { + dsps_fft2r_fc32(y, FFT_N); + dsps_bit_rev_fc32(y, FFT_N); + dsps_cplx2reC_fc32(y, FFT_N); + } + + float mag[N_BARS]; + float fmax = 1.0f; + for (int b = 0; b < N_BARS; b++) { + float p = 0.0f; + for (int k = blo[b]; k < bhi[b]; k++) { + float re = y[2 * k], im = y[2 * k + 1]; + p += re * re + im * im; + } + float m = sqrtf(p / (float)(bhi[b] - blo[b])); + mag[b] = m; + if (m > fmax) + fmax = m; + } + + s_gmax *= GMAX_DECAY; + if (fmax > s_gmax) + s_gmax = fmax; + if (s_gmax < GMAX_FLOOR) + s_gmax = GMAX_FLOOR; + bool gate = (fmax < NOISE_GATE); + for (int b = 0; b < N_BARS; b++) { + float n = gate ? 0.0f : (mag[b] / s_gmax); + if (n > 1.0f) + n = 1.0f; + s_bands[b] = sqrtf(n); + } + } + + audio_i2s_mic_stream_stop(); + +done: + free(raw); + free(y); + free(win); + for (int b = 0; b < N_BARS; b++) + s_bands[b] = 0.0f; + s_running = false; + s_task_active = false; + vTaskDelete(NULL); +} + +static void spectrum_input(const input_event_t *ev, void *ctx) { + (void)ctx; + if (ev->button == INPUT_BTN_BACK && ev->action == INPUT_ACTION_PRESS) { + s_running = false; + ui_switch_screen(SCREEN_SETTINGS); + } +} + +static void anim_timer_cb(lv_timer_t *t) { + if (lv_screen_active() != s_screen) { + s_running = false; + lv_timer_delete(t); + s_anim_timer = NULL; + return; + } + + for (int i = 0; i < N_BARS; i++) { + float b = s_bands[i]; + if (b > s_disp[i]) + s_disp[i] = b; + else + s_disp[i] = s_disp[i] * 0.80f + b * 0.20f; + int h = (int)(2.0f + s_disp[i] * 96.0f); + lv_obj_set_height(s_bars[i], lv_pct(h)); + + if (s_disp[i] >= s_peak[i]) { + s_peak[i] = s_disp[i]; + s_hold[i] = HOLD_FRAMES; + } else if (s_hold[i] > 0) { + s_hold[i]--; + } else { + s_peak[i] -= PEAK_FALL; + if (s_peak[i] < s_disp[i]) + s_peak[i] = s_disp[i]; + } + int cy = s_plot_h - (int)(s_peak[i] * (float)s_plot_h) - CAP_H; + if (cy < 0) + cy = 0; + if (cy > s_plot_h - CAP_H) + cy = s_plot_h - CAP_H; + lv_obj_set_y(s_caps[i], cy); + } + + if (s_db_label) + lv_label_set_text_fmt(s_db_label, "%d dBFS", (int)s_peak_db); + if (s_clip_label) { + if (s_clip) + lv_obj_remove_flag(s_clip_label, LV_OBJ_FLAG_HIDDEN); + else + lv_obj_add_flag(s_clip_label, LV_OBJ_FLAG_HIDDEN); + } +} + +void ui_spectrum_open(void) { + s_running = false; + for (int i = 0; i < TASK_STOP_RETRIES && s_task_active; i++) + vTaskDelay(pdMS_TO_TICKS(TASK_STOP_DELAY_MS)); + + if (s_screen != NULL) { + lv_obj_del(s_screen); + s_screen = NULL; + } + for (int i = 0; i < N_BARS; i++) { + s_disp[i] = 0.0f; + s_bands[i] = 0.0f; + s_peak[i] = 0.0f; + s_hold[i] = 0; + } + s_gmax = GMAX_FLOOR; + s_peak_db = PEAK_DB_FLOOR; + s_clip = false; + + s_screen = lv_obj_create(NULL); + lv_obj_set_style_bg_color(s_screen, current_theme.screen_base, 0); + lv_obj_set_style_bg_opa(s_screen, LV_OPA_COVER, 0); + lv_obj_remove_flag(s_screen, LV_OBJ_FLAG_SCROLLABLE); + + ui_chrome_header(s_screen, SPECTRUM_TITLE, SPECTRUM_ICON); + ui_chrome_footer(s_screen, SPECTRUM_FOOTER); + + int band_top = UI_CHROME_HEADER_H; + int plot_top = band_top + PLOT_TOP_OFS; + int plot_h = LCD_V_RES - UI_CHROME_FOOTER_H - AXIS_H - PLOT_BOTTOM_PAD - plot_top; + if (plot_h < PLOT_MIN_H) + plot_h = PLOT_MIN_H; + + s_db_label = lv_label_create(s_screen); + lv_label_set_text(s_db_label, "-60 dBFS"); + lv_obj_set_style_text_color(s_db_label, current_theme.text_main, 0); + lv_obj_set_style_text_opa(s_db_label, LV_OPA_70, 0); + lv_obj_set_style_text_font(s_db_label, &lv_font_montserrat_12, 0); + lv_obj_align(s_db_label, LV_ALIGN_TOP_RIGHT, -8, band_top + STATUS_ROW_TOP_OFS); + + s_clip_label = lv_label_create(s_screen); + lv_label_set_text(s_clip_label, "CLIP"); + lv_obj_set_style_text_color(s_clip_label, lv_color_hex(0xFF5252), 0); + lv_obj_set_style_text_font(s_clip_label, &lv_font_montserrat_12, 0); + lv_obj_align(s_clip_label, LV_ALIGN_TOP_LEFT, 8, band_top + STATUS_ROW_TOP_OFS); + lv_obj_add_flag(s_clip_label, LV_OBJ_FLAG_HIDDEN); + + s_plot = lv_obj_create(s_screen); + lv_obj_remove_flag(s_plot, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_bg_opa(s_plot, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(s_plot, 0, 0); + lv_obj_set_style_pad_all(s_plot, 4, 0); + lv_obj_set_style_pad_column(s_plot, 3, 0); + lv_obj_set_size(s_plot, lv_pct(94), plot_h); + lv_obj_align(s_plot, LV_ALIGN_TOP_MID, 0, plot_top); + lv_obj_set_flex_flow(s_plot, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(s_plot, LV_FLEX_ALIGN_SPACE_EVENLY, LV_FLEX_ALIGN_END, LV_FLEX_ALIGN_END); + + for (int g = 1; g <= 3; g++) { + lv_obj_t *line = lv_obj_create(s_plot); + lv_obj_add_flag(line, LV_OBJ_FLAG_FLOATING); + lv_obj_remove_flag(line, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_border_width(line, 0, 0); + lv_obj_set_style_radius(line, 0, 0); + lv_obj_set_style_bg_color(line, current_theme.text_main, 0); + lv_obj_set_style_bg_opa(line, LV_OPA_20, 0); + lv_obj_set_size(line, lv_pct(100), 1); + lv_obj_set_align(line, LV_ALIGN_TOP_MID); + lv_obj_set_y(line, lv_pct(g * 25)); + } + + for (int i = 0; i < N_BARS; i++) { + lv_obj_t *bar = lv_obj_create(s_plot); + lv_obj_remove_flag(bar, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_flex_grow(bar, 1); + lv_obj_set_height(bar, lv_pct(2)); + lv_obj_set_style_radius(bar, 2, 0); + lv_obj_set_style_border_width(bar, 0, 0); + lv_obj_set_style_pad_all(bar, 0, 0); + lv_obj_set_style_bg_opa(bar, LV_OPA_COVER, 0); + lv_obj_set_style_bg_color(bar, lv_color_hex(0xFF5252), 0); + lv_obj_set_style_bg_grad_color(bar, lv_color_hex(0x00E676), 0); + lv_obj_set_style_bg_grad_dir(bar, LV_GRAD_DIR_VER, 0); + s_bars[i] = bar; + } + + for (int i = 0; i < N_BARS; i++) { + lv_obj_t *cap = lv_obj_create(s_plot); + lv_obj_add_flag(cap, LV_OBJ_FLAG_FLOATING); + lv_obj_remove_flag(cap, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_border_width(cap, 0, 0); + lv_obj_set_style_radius(cap, 1, 0); + lv_obj_set_style_bg_color(cap, current_theme.text_main, 0); + lv_obj_set_style_bg_opa(cap, LV_OPA_80, 0); + lv_obj_set_size(cap, 4, CAP_H); + s_caps[i] = cap; + } + + static const char *const FAXIS[] = {"60", "250", "1k", "4k"}; + lv_obj_t *axis = lv_obj_create(s_screen); + lv_obj_remove_flag(axis, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_bg_opa(axis, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(axis, 0, 0); + lv_obj_set_style_pad_all(axis, 0, 0); + lv_obj_set_size(axis, lv_pct(94), 16); + lv_obj_align_to(axis, s_plot, LV_ALIGN_OUT_BOTTOM_MID, 0, 2); + lv_obj_set_flex_flow(axis, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align( + axis, LV_FLEX_ALIGN_SPACE_BETWEEN, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + for (int i = 0; i < 4; i++) { + lv_obj_t *l = lv_label_create(axis); + lv_label_set_text(l, FAXIS[i]); + lv_obj_set_style_text_font(l, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(l, current_theme.text_main, 0); + lv_obj_set_style_text_opa(l, LV_OPA_50, 0); + } + + lv_obj_update_layout(s_screen); + s_plot_h = lv_obj_get_content_height(s_plot); + if (s_plot_h < 10) + s_plot_h = 100; + for (int i = 0; i < N_BARS; i++) { + int bw = lv_obj_get_width(s_bars[i]); + int bx = lv_obj_get_x(s_bars[i]); + lv_obj_set_size(s_caps[i], bw > 0 ? bw : 4, CAP_H); + lv_obj_set_x(s_caps[i], bx); + lv_obj_set_y(s_caps[i], s_plot_h - CAP_H); + } + + s_running = true; + if (xTaskCreatePinnedToCore(spectrum_task, + "spectrum", + SPECTRUM_TASK_STACK, + NULL, + SPECTRUM_TASK_PRIORITY, + NULL, + SPECTRUM_TASK_CORE) != pdPASS) { + ESP_LOGE(TAG, "spectrum task create failed"); + s_running = false; + } + + if (s_anim_timer == NULL) + s_anim_timer = lv_timer_create(anim_timer_cb, ANIM_TIMER_MS, NULL); + + ui_input_set_screen_handler(spectrum_input, NULL); + + ui_screen_load_owned(&s_screen, s_screen); + ESP_LOGI(TAG, "spectrum analyzer opened"); +} diff --git a/firmware_p4/components/Applications/ui/screens/audio/wav_library_ui.c b/firmware_p4/components/Applications/ui/screens/audio/wav_library_ui.c new file mode 100644 index 000000000..74320062a --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/audio/wav_library_ui.c @@ -0,0 +1,516 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#include "wav_library_ui.h" + +#include +#include +#include +#include + +#include "st7789.h" + +#include "assets_manager.h" +#include "ui_chrome.h" +#include "ui_feedback.h" +#include "ui_manager.h" +#include "ui_theme.h" +#include "wav_player_ui.h" + +#define SDCARD_ROOT "/sdcard" +#define MAX_TRACKS 64 +#define PATH_LEN 192 +#define NAME_LEN 56 +#define MAX_DEPTH 3 +#define HDR_ICON "/assets/icons/graphic_eq.bin" +#define ROW_ICON "/assets/icons/music_note.bin" + +#define EMPTY_ICON "/assets/icons/sd_card.bin" +#define EMPTY_MSG "No .wav on SD card" +#define EMPTY_SUB "Add tracks and reopen" +#define CARD_W 200 +#define CARD_H 132 +#define BADGE_PX 56 +#define ICON_PX 34 +#define DIM_COLOR 0x8A8594 + +#define LIST_PAD_SIDE 8 +#define LIST_PAD_ROW 6 +#define LIST_SB_W 4 +#define LIST_SB_RADIUS 2 + +#define ROW_H 44 +#define ROW_RADIUS 10 +#define ROW_PAD_HOR 10 +#define ROW_PAD_COL 10 +#define ROW_GLOW_W 14 +#define ROW_BADGE_PX 28 +#define ROW_ICON_PX 16 +#define ROW_EQ_W 20 +#define ROW_EQ_H 16 +#define ROW_EQ_BARS 4 +#define ROW_EQ_BAR_W 3 +#define EQ_TICK_MS 140 +#define NAME_DISP 48 +#define EXT_BUF 8 + +#define DUR_BASE_S 120 +#define DUR_STEP_S 17 +#define DUR_BUF 8 + +#define HINT_TEXT "OK play BACK exit" + +static lv_obj_t *s_screen = NULL; +static lv_obj_t *s_list = NULL; +static lv_obj_t *s_rows[MAX_TRACKS]; +static lv_obj_t *s_row_name[MAX_TRACKS]; +static lv_obj_t *s_row_val[MAX_TRACKS]; +static lv_obj_t *s_row_note[MAX_TRACKS]; +static lv_obj_t *s_row_eq[MAX_TRACKS]; +static lv_timer_t *s_eq_timer = NULL; +static int s_eq_phase = 0; + +static const uint8_t EQ_PAT[8] = {3, 7, 11, 15, 16, 12, 8, 4}; + +static char s_paths[MAX_TRACKS][PATH_LEN]; +static char s_names[MAX_TRACKS][NAME_LEN]; +static int s_count = 0; +static int s_sel = 0; +static bool s_resume = false; +static bool s_empty = false; + +static bool is_wav(const char *name) { + const char *dot = strrchr(name, '.'); + return dot != NULL && strcasecmp(dot, ".wav") == 0; +} + +static void scan_dir(const char *dir, int depth) { + if (depth > MAX_DEPTH || s_count >= MAX_TRACKS) + return; + DIR *d = opendir(dir); + if (d == NULL) + return; + struct dirent *ent; + while ((ent = readdir(d)) != NULL && s_count < MAX_TRACKS) { + const char *dn = ent->d_name; + if (dn[0] == '.') + continue; + if (strlen(dir) + 1 + strlen(dn) >= PATH_LEN) + continue; + char full[PATH_LEN]; + strlcpy(full, dir, sizeof(full)); + strlcat(full, "/", sizeof(full)); + strlcat(full, dn, sizeof(full)); + if (ent->d_type == DT_DIR) { + scan_dir(full, depth + 1); + } else if (is_wav(dn)) { + strncpy(s_paths[s_count], full, PATH_LEN - 1); + s_paths[s_count][PATH_LEN - 1] = '\0'; + strncpy(s_names[s_count], dn, NAME_LEN - 1); + s_names[s_count][NAME_LEN - 1] = '\0'; + s_count++; + } + } + closedir(d); +} + +static void scan_wavs(void) { + s_count = 0; + scan_dir(SDCARD_ROOT, 0); +} + +int ui_wav_library_count(void) { + return s_count; +} + +const char *ui_wav_library_path(int i) { + if (i < 0 || i >= s_count) + return NULL; + return s_paths[i]; +} + +const char *ui_wav_library_name(int i) { + if (i < 0 || i >= s_count) + return NULL; + return s_names[i]; +} + +void ui_wav_library_set_selected(int i) { + if (i >= 0 && i < s_count) + s_sel = i; +} + +static void track_duration(int i, char *out, size_t n) { + int total = DUR_BASE_S + i * DUR_STEP_S; + snprintf(out, n, "%d:%02d", total / 60, total % 60); +} + +static void build_empty_card(void) { + lv_obj_t *card = lv_obj_create(s_screen); + lv_obj_remove_flag(card, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(card, CARD_W, CARD_H); + lv_obj_align(card, LV_ALIGN_CENTER, 0, (UI_CHROME_HEADER_H - UI_CHROME_FOOTER_H) / 2); + lv_obj_set_style_radius(card, 14, 0); + lv_obj_set_style_bg_color(card, current_theme.bg_secondary, 0); + lv_obj_set_style_bg_opa(card, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(card, 1, 0); + lv_obj_set_style_border_color(card, current_theme.border_accent, 0); + lv_obj_set_style_shadow_width(card, 18, 0); + lv_obj_set_style_shadow_color(card, current_theme.border_accent, 0); + lv_obj_set_style_shadow_opa(card, LV_OPA_40, 0); + lv_obj_set_style_pad_all(card, 12, 0); + lv_obj_set_style_pad_row(card, 8, 0); + lv_obj_set_flex_flow(card, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(card, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + + lv_image_dsc_t *dsc = assets_get(EMPTY_ICON); + if (dsc != NULL) { + lv_obj_t *badge = lv_obj_create(card); + lv_obj_set_size(badge, BADGE_PX, BADGE_PX); + lv_obj_remove_flag(badge, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_radius(badge, LV_RADIUS_CIRCLE, 0); + lv_obj_set_style_bg_color(badge, current_theme.border_accent, 0); + lv_obj_set_style_bg_opa(badge, LV_OPA_20, 0); + lv_obj_set_style_border_width(badge, 2, 0); + lv_obj_set_style_border_color(badge, current_theme.border_accent, 0); + lv_obj_set_style_pad_all(badge, 0, 0); + lv_obj_t *img = lv_image_create(badge); + lv_image_set_src(img, dsc); + lv_obj_set_size(img, ICON_PX, ICON_PX); + lv_image_set_inner_align(img, LV_IMAGE_ALIGN_CONTAIN); + lv_obj_center(img); + } + + lv_obj_t *msg = lv_label_create(card); + lv_label_set_text(msg, EMPTY_MSG); + lv_obj_set_style_text_font(msg, &lv_font_montserrat_14, 0); + lv_obj_set_style_text_color(msg, current_theme.text_main, 0); + + lv_obj_t *sub = lv_label_create(card); + lv_label_set_text(sub, EMPTY_SUB); + lv_obj_set_style_text_font(sub, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(sub, lv_color_hex(DIM_COLOR), 0); +} + +static void split_name(const char *full, char *base, size_t bn, char *ext, size_t en) { + const char *dot = strrchr(full, '.'); + if (dot != NULL && dot != full) { + size_t blen = (size_t)(dot - full); + if (blen >= bn) + blen = bn - 1; + memcpy(base, full, blen); + base[blen] = '\0'; + size_t j = 0; + for (const char *p = dot + 1; *p != '\0' && j + 1 < en; p++) { + char c = *p; + if (c >= 'a' && c <= 'z') + c = (char)(c - 32); + ext[j++] = c; + } + ext[j] = '\0'; + } else { + snprintf(base, bn, "%s", full); + ext[0] = '\0'; + } +} + +static void eq_tick_cb(lv_timer_t *t) { + if (lv_screen_active() != s_screen) { + lv_timer_delete(t); + s_eq_timer = NULL; + return; + } + if (s_sel < 0 || s_sel >= s_count) + return; + lv_obj_t *eq = s_row_eq[s_sel]; + if (eq == NULL) + return; + s_eq_phase = (s_eq_phase + 1) & 7; + uint32_t n = lv_obj_get_child_count(eq); + for (uint32_t k = 0; k < n; k++) { + lv_obj_t *bar = lv_obj_get_child(eq, k); + lv_obj_set_height(bar, EQ_PAT[(s_eq_phase + k * 2) & 7]); + } +} + +static void style_row(int i, bool sel) { + lv_obj_t *row = s_rows[i]; + if (s_row_note[i] != NULL) { + if (sel) + lv_obj_add_flag(s_row_note[i], LV_OBJ_FLAG_HIDDEN); + else + lv_obj_remove_flag(s_row_note[i], LV_OBJ_FLAG_HIDDEN); + } + if (s_row_eq[i] != NULL) { + if (sel) + lv_obj_remove_flag(s_row_eq[i], LV_OBJ_FLAG_HIDDEN); + else + lv_obj_add_flag(s_row_eq[i], LV_OBJ_FLAG_HIDDEN); + } + if (sel) { + lv_obj_set_style_bg_color(row, current_theme.bg_secondary, 0); + lv_obj_set_style_bg_opa(row, LV_OPA_COVER, 0); + lv_obj_set_style_border_color(row, current_theme.border_accent, 0); + lv_obj_set_style_shadow_width(row, ROW_GLOW_W, 0); + lv_obj_set_style_shadow_opa(row, LV_OPA_40, 0); + lv_obj_set_style_shadow_spread(row, -2, 0); + lv_obj_set_style_text_color(s_row_name[i], current_theme.text_main, 0); + lv_obj_set_style_text_color(s_row_val[i], current_theme.border_accent, 0); + } else { + lv_obj_set_style_bg_color(row, current_theme.bg_primary, 0); + lv_obj_set_style_bg_opa(row, LV_OPA_COVER, 0); + lv_obj_set_style_border_color(row, current_theme.border_inactive, 0); + lv_obj_set_style_shadow_width(row, 0, 0); + lv_obj_set_style_shadow_opa(row, LV_OPA_TRANSP, 0); + lv_obj_set_style_text_color(s_row_name[i], lv_color_hex(DIM_COLOR), 0); + lv_obj_set_style_text_color(s_row_val[i], lv_color_hex(DIM_COLOR), 0); + } +} + +static void update_selection(void) { + for (int i = 0; i < s_count; i++) + style_row(i, i == s_sel); + if (s_list != NULL && s_sel >= 0 && s_sel < s_count && s_rows[s_sel] != NULL) { + lv_obj_update_layout(s_list); + lv_obj_scroll_to_view(s_rows[s_sel], LV_ANIM_ON); + } +} + +static void build_list(void) { + lv_color_t accent = current_theme.border_accent; + + lv_obj_t *cont = lv_obj_create(s_screen); + s_list = cont; + lv_obj_set_size(cont, lv_pct(100), LCD_V_RES - UI_CHROME_HEADER_H - UI_CHROME_FOOTER_H); + lv_obj_align(cont, LV_ALIGN_TOP_MID, 0, UI_CHROME_HEADER_H); + lv_obj_set_style_bg_opa(cont, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(cont, 0, 0); + lv_obj_set_style_pad_all(cont, 0, 0); + lv_obj_set_style_pad_left(cont, LIST_PAD_SIDE, 0); + lv_obj_set_style_pad_right(cont, LIST_PAD_SIDE, 0); + lv_obj_set_style_pad_row(cont, LIST_PAD_ROW, 0); + lv_obj_set_flex_flow(cont, LV_FLEX_FLOW_COLUMN); + lv_obj_add_flag(cont, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_scroll_dir(cont, LV_DIR_VER); + lv_obj_set_scrollbar_mode(cont, LV_SCROLLBAR_MODE_ON); + lv_obj_clear_flag(cont, LV_OBJ_FLAG_SCROLL_ELASTIC | LV_OBJ_FLAG_SCROLL_MOMENTUM); + lv_obj_set_style_bg_color(cont, accent, LV_PART_SCROLLBAR); + lv_obj_set_style_bg_opa(cont, LV_OPA_COVER, LV_PART_SCROLLBAR); + lv_obj_set_style_width(cont, LIST_SB_W, LV_PART_SCROLLBAR); + lv_obj_set_style_radius(cont, LIST_SB_RADIUS, LV_PART_SCROLLBAR); + + lv_image_dsc_t *glyph = assets_get(ROW_ICON); + + for (int i = 0; i < s_count; i++) { + lv_obj_t *row = lv_obj_create(cont); + s_rows[i] = row; + lv_obj_remove_flag(row, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_remove_flag(row, LV_OBJ_FLAG_CLICKABLE); + lv_obj_set_size(row, lv_pct(100), ROW_H); + lv_obj_set_style_radius(row, ROW_RADIUS, 0); + lv_obj_set_style_border_width(row, 1, 0); + lv_obj_set_style_bg_grad_dir(row, LV_GRAD_DIR_NONE, 0); + lv_obj_set_style_shadow_color(row, accent, 0); + lv_obj_set_style_pad_hor(row, ROW_PAD_HOR, 0); + lv_obj_set_style_pad_ver(row, 0, 0); + lv_obj_set_style_pad_column(row, ROW_PAD_COL, 0); + 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_t *badge = lv_obj_create(row); + lv_obj_remove_flag(badge, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(badge, ROW_BADGE_PX, ROW_BADGE_PX); + lv_obj_set_style_radius(badge, LV_RADIUS_CIRCLE, 0); + lv_obj_set_style_bg_color(badge, accent, 0); + lv_obj_set_style_bg_opa(badge, LV_OPA_20, 0); + lv_obj_set_style_border_width(badge, 1, 0); + lv_obj_set_style_border_color(badge, accent, 0); + lv_obj_set_style_pad_all(badge, 0, 0); + + s_row_note[i] = NULL; + if (glyph != NULL) { + lv_obj_t *img = lv_image_create(badge); + lv_image_set_src(img, glyph); + lv_obj_set_size(img, ROW_ICON_PX, ROW_ICON_PX); + lv_image_set_inner_align(img, LV_IMAGE_ALIGN_CONTAIN); + lv_obj_set_style_image_recolor(img, accent, 0); + lv_obj_set_style_image_recolor_opa(img, LV_OPA_COVER, 0); + lv_obj_center(img); + s_row_note[i] = img; + } + + lv_obj_t *eq = lv_obj_create(badge); + s_row_eq[i] = eq; + lv_obj_remove_flag(eq, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_remove_flag(eq, LV_OBJ_FLAG_CLICKABLE); + lv_obj_set_size(eq, ROW_EQ_W, ROW_EQ_H); + lv_obj_center(eq); + lv_obj_set_style_bg_opa(eq, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(eq, 0, 0); + lv_obj_set_style_pad_all(eq, 0, 0); + lv_obj_set_style_pad_column(eq, 2, 0); + lv_obj_set_flex_flow(eq, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(eq, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_END, LV_FLEX_ALIGN_END); + for (int k = 0; k < ROW_EQ_BARS; k++) { + lv_obj_t *bar = lv_obj_create(eq); + lv_obj_remove_flag(bar, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_remove_flag(bar, LV_OBJ_FLAG_CLICKABLE); + lv_obj_set_size(bar, ROW_EQ_BAR_W, EQ_PAT[(k * 2) & 7]); + lv_obj_set_style_radius(bar, 1, 0); + lv_obj_set_style_border_width(bar, 0, 0); + lv_obj_set_style_bg_color(bar, accent, 0); + lv_obj_set_style_bg_opa(bar, LV_OPA_COVER, 0); + } + lv_obj_add_flag(eq, LV_OBJ_FLAG_HIDDEN); + + lv_obj_t *col = lv_obj_create(row); + lv_obj_remove_flag(col, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_remove_flag(col, LV_OBJ_FLAG_CLICKABLE); + lv_obj_set_height(col, lv_pct(100)); + lv_obj_set_flex_grow(col, 1); + lv_obj_set_style_bg_opa(col, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(col, 0, 0); + lv_obj_set_style_pad_all(col, 0, 0); + lv_obj_set_style_pad_row(col, 1, 0); + lv_obj_set_flex_flow(col, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(col, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START); + + char base[NAME_DISP]; + char ext[EXT_BUF]; + split_name(s_names[i], base, sizeof(base), ext, sizeof(ext)); + + lv_obj_t *name = lv_label_create(col); + s_row_name[i] = name; + lv_obj_set_width(name, lv_pct(100)); + lv_label_set_long_mode(name, LV_LABEL_LONG_SCROLL_CIRCULAR); + lv_label_set_text(name, base); + lv_obj_set_style_text_font(name, &lv_font_montserrat_14, 0); + + lv_obj_t *extl = lv_label_create(col); + lv_label_set_text(extl, ext); + lv_obj_set_style_text_font(extl, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(extl, lv_color_hex(DIM_COLOR), 0); + + char dur[DUR_BUF]; + track_duration(i, dur, sizeof(dur)); + lv_obj_t *val = lv_label_create(row); + s_row_val[i] = val; + lv_label_set_text(val, dur); + lv_obj_set_style_text_font(val, &lv_font_montserrat_12, 0); + } + + update_selection(); + + if (s_eq_timer == NULL) + s_eq_timer = lv_timer_create(eq_tick_cb, EQ_TICK_MS, NULL); +} + +static void play_selected(void) { + if (s_count <= 0) + return; + if (s_sel < 0 || s_sel >= s_count) + return; + s_resume = true; + ui_feedback(UI_FB_SELECT); + ui_wav_player_set_path(s_paths[s_sel]); + ui_wav_player_set_index(s_sel); + ui_wav_player_set_return(SCREEN_PLAYER); + ui_switch_screen(SCREEN_WAV_PLAYER); +} + +static void wav_library_input(const input_event_t *ev, void *ctx) { + (void)ctx; + const bool press = (ev->action == INPUT_ACTION_PRESS); + const bool nav = press || (ev->action == INPUT_ACTION_REPEAT); + + switch (ev->button) { + case INPUT_BTN_BACK: + if (press) + ui_switch_screen(SCREEN_MENU); + break; + case INPUT_BTN_DOWN: + if (nav && !s_empty && s_sel < s_count - 1) { + s_sel++; + update_selection(); + ui_feedback(UI_FB_NAV); + } + break; + case INPUT_BTN_UP: + if (nav && !s_empty && s_sel > 0) { + s_sel--; + update_selection(); + ui_feedback(UI_FB_NAV); + } + break; + case INPUT_BTN_OK: + if (press && !s_empty) + play_selected(); + break; + default: + break; + } +} + +void ui_wav_library_open(void) { + if (s_screen != NULL) { + lv_obj_del(s_screen); + s_screen = NULL; + } + s_list = NULL; + if (s_eq_timer != NULL) { + lv_timer_delete(s_eq_timer); + s_eq_timer = NULL; + } + s_eq_phase = 0; + + if (!s_resume) { + scan_wavs(); + s_sel = 0; + } + s_resume = false; + + s_screen = lv_obj_create(NULL); + lv_obj_set_style_bg_color(s_screen, current_theme.screen_base, 0); + lv_obj_set_style_bg_opa(s_screen, LV_OPA_COVER, 0); + lv_obj_remove_flag(s_screen, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_border_width(s_screen, 0, 0); + lv_obj_set_style_pad_all(s_screen, 0, 0); + + if (s_count <= 0) { + s_empty = true; + ui_chrome_header(s_screen, "PLAYER", HDR_ICON); + build_empty_card(); + ui_chrome_footer(s_screen, "BACK exit"); + } else { + s_empty = false; + if (s_sel < 0) + s_sel = 0; + if (s_sel >= s_count) + s_sel = s_count - 1; + + // Track count folded into the title: the header's right edge now belongs to + // the shared status cluster, so a separate right-aligned label would collide. + char title[24]; + snprintf(title, sizeof(title), "PLAYER (%d)", s_count); + ui_chrome_header(s_screen, title, HDR_ICON); + + build_list(); + ui_chrome_footer(s_screen, HINT_TEXT); + } + + ui_input_set_screen_handler(wav_library_input, NULL); + + ui_screen_load_owned(&s_screen, s_screen); +} diff --git a/firmware_p4/components/Applications/ui/screens/audio/wav_player_ui.c b/firmware_p4/components/Applications/ui/screens/audio/wav_player_ui.c new file mode 100644 index 000000000..275166a44 --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/audio/wav_player_ui.c @@ -0,0 +1,710 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#include "wav_player_ui.h" + +#include +#include +#include +#include + +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include "sys_prio.h" + +#include "st7789.h" + +#include "audio_i2s.h" +#include "ui_chrome.h" +#include "ui_feedback.h" +#include "ui_manager.h" +#include "ui_theme.h" +#include "wav_library_ui.h" + +#define REFRESH_TIMER_MS 60 +#define N_BARS 12 +#define BAR_W 9 +#define SPEC_H 76 +#define READ_FRAMES 512 +#define SEEK_SEC 5 +#define VOL_STEP 10 +#define VOL_DEFAULT 80 +#define PATH_MAX_LEN 256 +#define PLAYER_TASK_STACK 8192 +#define PLAYER_TASK_PRIO SYS_PRIO_SERVICE_HI +#define DBLCLICK_MS 350 +#define G1 0x7A52D6 +#define G2 0xB89AFF +#define OK_GREEN 0x00E676 + +typedef struct { + uint16_t fmt; + uint16_t channels; + uint32_t rate; + uint16_t bits; + long data_off; + long data_size; +} wav_info_t; + +static lv_obj_t *s_screen = NULL; +static lv_obj_t *s_bars[N_BARS]; +static lv_obj_t *s_pfill = NULL; +static lv_obj_t *s_t_cur = NULL; +static lv_obj_t *s_t_tot = NULL; +static lv_obj_t *s_play_ic = NULL; +static lv_obj_t *s_fname = NULL; +static lv_obj_t *s_fmt = NULL; +static lv_obj_t *s_idx_lbl = NULL; +static lv_obj_t *s_prev_lbl = NULL; +static lv_obj_t *s_next_lbl = NULL; +static lv_timer_t *s_refresh_timer = NULL; + +static char s_path[PATH_MAX_LEN]; +static int s_index = -1; +static uint32_t s_left_ms = 0; +static uint32_t s_right_ms = 0; +static screen_id_t s_return = SCREEN_FILES; +static TaskHandle_t s_task = NULL; +static volatile bool s_task_run = false; +static volatile bool s_stop_req = false; +static volatile bool s_exit_req = false; +static volatile bool s_playing = false; +static volatile bool s_err = false; +static volatile bool s_finished = false; +static volatile bool s_pending_play = false; +static volatile int s_seek_req = 0; +static volatile int s_pos_sec = 0; +static volatile int s_total_sec = 0; +static volatile int s_level[N_BARS]; +static volatile int s_i_rate = 0; +static volatile int s_i_ch = 0; +static volatile int s_i_bits = 0; +static int s_vol = VOL_DEFAULT; + +static uint32_t rd_u32(const uint8_t *p) { + return (uint32_t)p[0] | ((uint32_t)p[1] << 8) | ((uint32_t)p[2] << 16) | ((uint32_t)p[3] << 24); +} +static uint16_t rd_u16(const uint8_t *p) { + return (uint16_t)((uint16_t)p[0] | ((uint16_t)p[1] << 8)); +} + +static bool parse_wav(FILE *f, wav_info_t *w) { + uint8_t hdr[12]; + if (fread(hdr, 1, 12, f) != 12) + return false; + if (memcmp(hdr, "RIFF", 4) != 0 || memcmp(hdr + 8, "WAVE", 4) != 0) + return false; + + bool have_fmt = false, have_data = false; + while (!(have_fmt && have_data)) { + uint8_t c[8]; + if (fread(c, 1, 8, f) != 8) + break; + uint32_t sz = rd_u32(c + 4); + if (memcmp(c, "fmt ", 4) == 0) { + uint8_t fb[16]; + uint32_t rd = sz < 16 ? sz : 16; + if (fread(fb, 1, rd, f) != rd) + break; + w->fmt = rd_u16(fb); + w->channels = rd_u16(fb + 2); + w->rate = rd_u32(fb + 4); + w->bits = rd_u16(fb + 14); + if (sz > rd) + fseek(f, sz - rd, SEEK_CUR); + if (sz & 1) + fseek(f, 1, SEEK_CUR); + have_fmt = true; + } else if (memcmp(c, "data", 4) == 0) { + w->data_off = ftell(f); + w->data_size = (long)sz; + have_data = true; + break; + } else { + fseek(f, (long)sz + (sz & 1), SEEK_CUR); + } + } + return have_fmt && have_data; +} + +static void compute_bars(const int16_t *mono, int n) { + if (n <= 0) + return; + for (int b = 0; b < N_BARS; b++) { + int a = (b * n) / N_BARS; + int z = ((b + 1) * n) / N_BARS; + if (z <= a) { + s_level[b] = 0; + continue; + } + uint64_t sumsq = 0; + for (int i = a; i < z; i++) { + int32_t v = mono[i]; + sumsq += (uint64_t)(v * v); + } + int rms = (int)sqrt((double)(sumsq / (z - a))); + int pct = (rms * 130) / 32768; + if (pct > 100) + pct = 100; + s_level[b] = pct; + } +} + +static void exit_to_files_cb(void *p) { + (void)p; + ui_switch_screen(s_return); +} + +static void player_task(void *arg) { + (void)arg; + FILE *f = fopen(s_path, "rb"); + wav_info_t w = {0}; + if (f == NULL || !parse_wav(f, &w) || w.fmt != 1 || w.bits != 16 || w.channels < 1 || + w.channels > 2) { + if (f) + fclose(f); + s_err = true; + s_task_run = false; + s_task = NULL; + vTaskDelete(NULL); + return; + } + + int ch = w.channels; + uint32_t rate = w.rate ? w.rate : 44100; + long bpf = (long)ch * 2; + long total_frames = w.data_size / bpf; + s_total_sec = (int)(total_frames / rate); + s_i_rate = (int)rate; + s_i_ch = ch; + s_i_bits = w.bits; + + int16_t *raw = malloc((size_t)READ_FRAMES * bpf); + int16_t *mono = malloc((size_t)READ_FRAMES * sizeof(int16_t)); + if (raw == NULL || mono == NULL || audio_i2s_stream_start(rate) != ESP_OK) { + free(raw); + free(mono); + fclose(f); + s_err = true; + s_task_run = false; + s_task = NULL; + vTaskDelete(NULL); + return; + } + + fseek(f, w.data_off, SEEK_SET); + long frame_pos = 0; + + while (!s_stop_req && frame_pos < total_frames) { + int sk = s_seek_req; + if (sk != 0) { + s_seek_req = 0; + frame_pos += (long)sk * (long)rate; + if (frame_pos < 0) + frame_pos = 0; + if (frame_pos > total_frames) + frame_pos = total_frames; + fseek(f, w.data_off + frame_pos * bpf, SEEK_SET); + s_pos_sec = (int)(frame_pos / rate); + if (frame_pos >= total_frames) + break; + } + + if (!s_playing) { + memset(mono, 0, 256 * sizeof(int16_t)); + audio_i2s_stream_write(mono, 256); + continue; + } + + long want = total_frames - frame_pos; + if (want > READ_FRAMES) + want = READ_FRAMES; + size_t got = fread(raw, (size_t)bpf, (size_t)want, f); + if (got == 0) + break; + + for (size_t i = 0; i < got; i++) { + if (ch == 2) { + int l = raw[2 * i]; + int r = raw[2 * i + 1]; + mono[i] = (int16_t)((l + r) / 2); + } else { + mono[i] = raw[i]; + } + } + compute_bars(mono, (int)got); + audio_i2s_stream_write(mono, (int)got); + frame_pos += (long)got; + s_pos_sec = (int)(frame_pos / rate); + } + + audio_i2s_stream_stop(); + free(raw); + free(mono); + fclose(f); + + for (int i = 0; i < N_BARS; i++) + s_level[i] = 0; + if (!s_stop_req) + s_finished = true; + s_playing = false; + s_task_run = false; + s_task = NULL; + if (s_exit_req) { + s_exit_req = false; + lv_async_call(exit_to_files_cb, NULL); + } + vTaskDelete(NULL); +} + +static void start_playback(void) { + if (s_task_run) { + s_stop_req = true; + s_pending_play = true; + return; + } + s_stop_req = false; + s_pending_play = false; + s_finished = false; + s_err = false; + s_seek_req = 0; + s_pos_sec = 0; + s_playing = true; + for (int i = 0; i < N_BARS; i++) + s_level[i] = 0; + s_task_run = true; + if (xTaskCreatePinnedToCore(player_task, + "wav_play", + PLAYER_TASK_STACK, + NULL, + PLAYER_TASK_PRIO, + &s_task, + SYS_CORE_UI) != pdPASS) { + s_task_run = false; + s_err = true; + } +} + +static int pl_count(void) { + return (s_index >= 0) ? ui_wav_library_count() : 1; +} + +static const char *pl_name(int i) { + if (s_index >= 0) { + const char *nm = ui_wav_library_name(i); + return nm ? nm : ""; + } + const char *slash = strrchr(s_path, '/'); + return s_path[0] ? (slash ? slash + 1 : s_path) : ""; +} + +static void refresh_track_labels(void) { + int n = pl_count(); + int cur = (s_index >= 0) ? s_index : 0; + + if (s_fname) { + const char *nm = pl_name(cur); + lv_label_set_text(s_fname, (nm && nm[0]) ? nm : "no file"); + } + if (s_idx_lbl) { + if (n > 1) + lv_label_set_text_fmt(s_idx_lbl, "%d / %d", cur + 1, n); + else + lv_label_set_text(s_idx_lbl, ""); + } + if (s_prev_lbl) { + if (n > 1) + lv_label_set_text_fmt(s_prev_lbl, LV_SYMBOL_PREV " %s", pl_name((cur - 1 + n) % n)); + else + lv_label_set_text(s_prev_lbl, ""); + } + if (s_next_lbl) { + if (n > 1) + lv_label_set_text_fmt(s_next_lbl, LV_SYMBOL_NEXT " %s", pl_name((cur + 1) % n)); + else + lv_label_set_text(s_next_lbl, ""); + } +} + +static void play_index(int i) { + if (s_index >= 0) { + int n = ui_wav_library_count(); + if (n > 0) { + if (i < 0) + i = n - 1; + if (i >= n) + i = 0; + s_index = i; + const char *p = ui_wav_library_path(i); + if (p != NULL) { + strncpy(s_path, p, sizeof(s_path) - 1); + s_path[sizeof(s_path) - 1] = '\0'; + } + ui_wav_library_set_selected(i); + } + } + refresh_track_labels(); + start_playback(); +} + +static void go_relative(int dir) { + int n = pl_count(); + if (n <= 1 || s_index < 0) { + refresh_track_labels(); + start_playback(); + return; + } + play_index((s_index + dir + n) % n); +} + +static void fmt_time(char *out, size_t n, int sec) { + if (sec < 0) + sec = 0; + snprintf(out, n, "%d:%02d", sec / 60, sec % 60); +} + +static void refresh_timer_cb(lv_timer_t *t) { + if (lv_screen_active() != s_screen) { + lv_timer_delete(t); + s_refresh_timer = NULL; + return; + } + + if (s_finished) { + s_finished = false; + go_relative(+1); + return; + } + + if (s_pending_play && !s_task_run) { + s_pending_play = false; + start_playback(); + return; + } + + for (int i = 0; i < N_BARS; i++) { + if (s_bars[i]) + lv_obj_set_height(s_bars[i], lv_pct(s_level[i] < 3 ? 3 : s_level[i])); + } + int tot = s_total_sec > 0 ? s_total_sec : 1; + int pct = (s_pos_sec * 100) / tot; + if (pct > 100) + pct = 100; + if (s_pfill) + lv_obj_set_width(s_pfill, lv_pct(pct)); + char b[12]; + fmt_time(b, sizeof(b), s_pos_sec); + lv_label_set_text(s_t_cur, b); + fmt_time(b, sizeof(b), s_total_sec); + lv_label_set_text(s_t_tot, b); + if (s_play_ic) + lv_label_set_text(s_play_ic, (s_playing && !s_finished) ? LV_SYMBOL_PAUSE : LV_SYMBOL_PLAY); + if (s_fmt) { + if (s_err) { + lv_label_set_text(s_fmt, "unsupported wav"); + } else if (s_i_rate > 0) { + lv_label_set_text_fmt(s_fmt, + "%d.%dkHz - %d-bit - %s", + s_i_rate / 1000, + (s_i_rate % 1000) / 100, + s_i_bits, + s_i_ch == 2 ? "stereo" : "mono"); + } + } +} + +static void wav_player_input(const input_event_t *ev, void *ctx) { + (void)ctx; + const bool press = (ev->action == INPUT_ACTION_PRESS); + const bool nav = press || (ev->action == INPUT_ACTION_REPEAT); + + switch (ev->button) { + case INPUT_BTN_BACK: + if (press) { + s_pending_play = false; + if (s_task_run) { + s_stop_req = true; + s_exit_req = true; + } else { + ui_switch_screen(s_return); + } + } + break; + case INPUT_BTN_OK: + if (press) { + if (!s_task_run || s_finished) + start_playback(); + else + s_playing = !s_playing; + ui_feedback(UI_FB_SELECT); + } + break; + case INPUT_BTN_RIGHT: + if (press) { + uint32_t now = lv_tick_get(); + if (now - s_right_ms < DBLCLICK_MS) { + s_seek_req = 0; + go_relative(+1); + } else { + s_seek_req += SEEK_SEC; + } + s_right_ms = now; + ui_feedback(UI_FB_NAV); + } + break; + case INPUT_BTN_LEFT: + if (press) { + uint32_t now = lv_tick_get(); + if (now - s_left_ms < DBLCLICK_MS) { + s_seek_req = 0; + go_relative(-1); + } else { + s_seek_req -= SEEK_SEC; + } + s_left_ms = now; + ui_feedback(UI_FB_NAV); + } + break; + case INPUT_BTN_DOWN: + if (nav) { + s_vol = (s_vol > VOL_STEP) ? s_vol - VOL_STEP : 0; + audio_i2s_set_volume((uint8_t)s_vol); + ui_feedback(UI_FB_NAV); + } + break; + case INPUT_BTN_UP: + if (nav) { + s_vol = (s_vol + VOL_STEP < 100) ? s_vol + VOL_STEP : 100; + audio_i2s_set_volume((uint8_t)s_vol); + ui_feedback(UI_FB_NAV); + } + break; + default: + break; + } +} + +static lv_obj_t *transport_btn(lv_obj_t *parent, const char *sym, bool primary) { + lv_obj_t *b = lv_obj_create(parent); + int d = primary ? 42 : 30; + lv_obj_set_size(b, d, d); + lv_obj_remove_flag(b, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_radius(b, LV_RADIUS_CIRCLE, 0); + lv_obj_set_style_border_width(b, 0, 0); + lv_obj_set_style_bg_grad_dir(b, LV_GRAD_DIR_NONE, 0); + lv_obj_set_style_shadow_width(b, 0, 0); + lv_obj_set_style_pad_all(b, 0, 0); + if (primary) { + lv_obj_set_style_bg_color(b, lv_color_hex(G2), 0); + lv_obj_set_style_bg_grad_color(b, lv_color_hex(G1), 0); + lv_obj_set_style_bg_grad_dir(b, LV_GRAD_DIR_VER, 0); + lv_obj_set_style_bg_opa(b, LV_OPA_COVER, 0); + } else { + lv_obj_set_style_bg_opa(b, LV_OPA_TRANSP, 0); + } + lv_obj_t *l = lv_label_create(b); + lv_label_set_text(l, sym); + lv_obj_set_style_text_font(l, &lv_font_montserrat_14, 0); + lv_obj_set_style_text_color(l, primary ? lv_color_hex(0x0A0220) : current_theme.text_main, 0); + lv_obj_center(l); + return l; +} + +void ui_wav_player_set_return(int screen) { + s_return = (screen_id_t)screen; +} + +void ui_wav_player_set_path(const char *path) { + s_index = -1; + if (path == NULL) { + s_path[0] = '\0'; + return; + } + strncpy(s_path, path, sizeof(s_path) - 1); + s_path[sizeof(s_path) - 1] = '\0'; +} + +void ui_wav_player_set_index(int index) { + s_index = index; +} + +void ui_wav_player_open(void) { + if (s_screen != NULL) { + lv_obj_del(s_screen); + s_screen = NULL; + } + s_left_ms = s_right_ms = 0; + s_pending_play = false; + + if (s_index >= 0) { + const char *p = ui_wav_library_path(s_index); + if (p != NULL) { + strncpy(s_path, p, sizeof(s_path) - 1); + s_path[sizeof(s_path) - 1] = '\0'; + } else { + s_index = -1; + } + } + + s_screen = lv_obj_create(NULL); + lv_obj_set_style_bg_color(s_screen, current_theme.screen_base, 0); + lv_obj_set_style_bg_opa(s_screen, LV_OPA_COVER, 0); + lv_obj_remove_flag(s_screen, LV_OBJ_FLAG_SCROLLABLE); + + ui_chrome_header(s_screen, "PLAYING", "/assets/icons/music_note.bin"); + ui_chrome_footer(s_screen, LV_SYMBOL_LEFT LV_SYMBOL_RIGHT " seek x2 skip OK play"); + + lv_obj_t *body = lv_obj_create(s_screen); + lv_obj_remove_style_all(body); + lv_obj_set_size(body, 216, LCD_V_RES - UI_CHROME_HEADER_H - UI_CHROME_FOOTER_H); + lv_obj_align(body, LV_ALIGN_TOP_MID, 0, UI_CHROME_HEADER_H); + lv_obj_remove_flag(body, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_flex_flow(body, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align( + body, LV_FLEX_ALIGN_SPACE_EVENLY, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_set_style_pad_all(body, 4, 0); + + lv_obj_t *spec = lv_obj_create(body); + lv_obj_remove_style_all(spec); + lv_obj_set_size(spec, 200, SPEC_H); + lv_obj_remove_flag(spec, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_flex_flow(spec, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(spec, LV_FLEX_ALIGN_SPACE_EVENLY, LV_FLEX_ALIGN_END, LV_FLEX_ALIGN_END); + for (int i = 0; i < N_BARS; i++) { + lv_obj_t *bar = lv_obj_create(spec); + lv_obj_remove_flag(bar, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_width(bar, BAR_W); + lv_obj_set_height(bar, lv_pct(3)); + lv_obj_set_style_radius(bar, 3, 0); + lv_obj_set_style_border_width(bar, 0, 0); + lv_obj_set_style_shadow_width(bar, 0, 0); + lv_obj_set_style_pad_all(bar, 0, 0); + lv_obj_set_style_bg_color(bar, lv_color_hex(G1), 0); + lv_obj_set_style_bg_grad_color(bar, lv_color_hex(G2), 0); + lv_obj_set_style_bg_grad_dir(bar, LV_GRAD_DIR_VER, 0); + lv_obj_set_style_bg_opa(bar, LV_OPA_COVER, 0); + s_bars[i] = bar; + } + + s_idx_lbl = lv_label_create(body); + lv_label_set_text(s_idx_lbl, ""); + lv_obj_set_style_text_font(s_idx_lbl, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(s_idx_lbl, current_theme.border_accent, 0); + + s_fname = lv_label_create(body); + const char *slash = strrchr(s_path, '/'); + lv_label_set_text(s_fname, s_path[0] ? (slash ? slash + 1 : s_path) : "no file"); + lv_label_set_long_mode(s_fname, LV_LABEL_LONG_DOT); + lv_obj_set_width(s_fname, 200); + lv_obj_set_style_text_align(s_fname, LV_TEXT_ALIGN_CENTER, 0); + lv_obj_set_style_text_font(s_fname, &lv_font_montserrat_14, 0); + lv_obj_set_style_text_color(s_fname, current_theme.text_main, 0); + + s_fmt = lv_label_create(body); + lv_label_set_text(s_fmt, "WAV - PCM"); + lv_obj_set_style_text_font(s_fmt, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(s_fmt, lv_color_hex(0x00E5D0), 0); + + lv_obj_t *prog = lv_obj_create(body); + lv_obj_remove_style_all(prog); + lv_obj_set_size(prog, 200, LV_SIZE_CONTENT); + lv_obj_remove_flag(prog, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_flex_flow(prog, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(prog, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_set_style_pad_row(prog, 4, 0); + + lv_obj_t *track = lv_obj_create(prog); + lv_obj_remove_style_all(track); + lv_obj_set_size(track, 200, 5); + lv_obj_set_style_radius(track, 3, 0); + lv_obj_set_style_bg_color(track, current_theme.border_inactive, 0); + lv_obj_set_style_bg_opa(track, LV_OPA_COVER, 0); + + s_pfill = lv_obj_create(track); + lv_obj_remove_style_all(s_pfill); + lv_obj_set_height(s_pfill, lv_pct(100)); + lv_obj_set_width(s_pfill, lv_pct(0)); + lv_obj_align(s_pfill, LV_ALIGN_LEFT_MID, 0, 0); + lv_obj_set_style_radius(s_pfill, 3, 0); + lv_obj_set_style_bg_color(s_pfill, lv_color_hex(G1), 0); + lv_obj_set_style_bg_grad_color(s_pfill, lv_color_hex(G2), 0); + lv_obj_set_style_bg_grad_dir(s_pfill, LV_GRAD_DIR_HOR, 0); + lv_obj_set_style_bg_opa(s_pfill, LV_OPA_COVER, 0); + + lv_obj_t *trow = lv_obj_create(prog); + lv_obj_remove_style_all(trow); + lv_obj_set_size(trow, 200, LV_SIZE_CONTENT); + lv_obj_remove_flag(trow, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_flex_flow(trow, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align( + trow, LV_FLEX_ALIGN_SPACE_BETWEEN, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + + s_t_cur = lv_label_create(trow); + lv_label_set_text(s_t_cur, "0:00"); + lv_obj_set_style_text_font(s_t_cur, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(s_t_cur, current_theme.text_main, 0); + + s_t_tot = lv_label_create(trow); + lv_label_set_text(s_t_tot, "0:00"); + lv_obj_set_style_text_font(s_t_tot, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(s_t_tot, current_theme.text_main, 0); + + lv_obj_t *ctrls = lv_obj_create(body); + lv_obj_remove_style_all(ctrls); + lv_obj_set_size(ctrls, 180, 46); + lv_obj_remove_flag(ctrls, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_flex_flow(ctrls, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(ctrls, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_set_style_pad_column(ctrls, 18, 0); + + transport_btn(ctrls, LV_SYMBOL_PREV, false); + s_play_ic = transport_btn(ctrls, LV_SYMBOL_PAUSE, true); + transport_btn(ctrls, LV_SYMBOL_NEXT, false); + + lv_obj_t *queue = lv_obj_create(body); + lv_obj_remove_style_all(queue); + lv_obj_set_size(queue, 210, LV_SIZE_CONTENT); + lv_obj_remove_flag(queue, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_flex_flow(queue, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align( + queue, LV_FLEX_ALIGN_SPACE_BETWEEN, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_set_style_pad_column(queue, 8, 0); + + s_prev_lbl = lv_label_create(queue); + lv_label_set_text(s_prev_lbl, ""); + lv_label_set_long_mode(s_prev_lbl, LV_LABEL_LONG_SCROLL_CIRCULAR); + lv_obj_set_width(s_prev_lbl, 98); + lv_obj_set_style_text_font(s_prev_lbl, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(s_prev_lbl, current_theme.border_inactive, 0); + + s_next_lbl = lv_label_create(queue); + lv_label_set_text(s_next_lbl, ""); + lv_label_set_long_mode(s_next_lbl, LV_LABEL_LONG_DOT); + lv_obj_set_width(s_next_lbl, 98); + lv_obj_set_style_text_align(s_next_lbl, LV_TEXT_ALIGN_RIGHT, 0); + lv_obj_set_style_text_font(s_next_lbl, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(s_next_lbl, current_theme.border_inactive, 0); + + refresh_track_labels(); + + audio_i2s_set_volume((uint8_t)s_vol); + start_playback(); + + if (s_refresh_timer == NULL) + s_refresh_timer = lv_timer_create(refresh_timer_cb, REFRESH_TIMER_MS, NULL); + ui_input_set_screen_handler(wav_player_input, NULL); + + ui_screen_load_owned(&s_screen, s_screen); +} diff --git a/firmware_p4/components/Applications/ui/screens/badusb/badusb_menu_ui.c b/firmware_p4/components/Applications/ui/screens/badusb/badusb_menu_ui.c new file mode 100644 index 000000000..671f25ed2 --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/badusb/badusb_menu_ui.c @@ -0,0 +1,1179 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#include "badusb_menu_ui.h" + +#include +#include +#include +#include +#include + +#include "esp_err.h" +#include "esp_heap_caps.h" +#include "esp_log.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include "lvgl.h" +#include "st7789.h" + +#include "assets_manager.h" +#include "bad_usb.h" +#include "ducky_parser.h" +#include "menu_component_ui.h" +#include "sys_prio.h" +#include "tos_flash_paths.h" +#include "tos_storage_paths.h" +#include "tusb_desc.h" +#include "ui_chrome.h" +#include "ui_feedback.h" +#include "ui_manager.h" +#include "ui_theme.h" +#include "waves_ui.h" + +static const char *TAG = "BADUSB_UI"; + +#define TERM_GREEN 0x00E676 +#define TERM_DIM_GREEN 0x1F7A52 +#define DARK_PANEL_COLOR 0x05090A +#define SIG_GREEN 0x00E676 +#define ERR_RED 0xFF5252 +#define WARN_AMBER 0xFFB300 + +#define FADE_MS 200 + +#define STATUS_Y_OFS (46 + 12) + +#define DOT_COUNT 3 +#define DOT_SIZE 10 +#define DOT_GAP 18 +#define DOT_Y_OFS 64 +#define DOT_PULSE_MS 480 +#define DOT_STAGGER_MS 160 + +#define STATUS_BLINK_MS 650 + +#define TERMINAL_W 216 +#define TERMINAL_H 138 +#define TERMINAL_TOP_Y 84 +#define TERMINAL_PAD 8 +#define TERMINAL_RADIUS 0 +#define TERMINAL_BORDER 2 +#define TERM_HEADER_Y 0 +#define TERM_BODY_Y 18 + +#define DELIVERY_W 214 +#define DELIVERY_H 36 +#define DELIVERY_Y 46 +#define DELIVERY_NODE_W 40 +#define DELIVERY_NODE_H 30 +#define DELIVERY_TRACK_H 2 +#define DELIVERY_PACKET 8 +#define DELIVERY_PACKET_COUNT 3 +#define DELIVERY_TRAVEL_MS 900 +#define DELIVERY_STAGGER_MS 300 +#define DELIVERY_NODE_COUNT 2 + +#define PROGRESS_W 214 +#define PROGRESS_H 8 +#define PROGRESS_Y 252 +#define PROGRESS_RADIUS 4 +#define PROGRESS_TRACK_COLOR 0x10211A +#define PROGRESS_FULL_PCT 100 +#define PCT_LABEL_Y 230 +#define PCT_LABEL_BUF 48 + +#define CONFIRM_Y_OFS -28 + +#define TERMINAL_BUF_LEN 320 +#define TERM_HEAD_LINES 6 +#define SCRIPT_LINE_BUF 96 +#define PREVIEW_TITLE_BUF 64 + +#define TERM_PROMPT "root@target:~#" +#define DETECT_STATUS "Waiting for host" +#define INSTRUCT_TEXT "RIGHT = Run again BACK = Exit" + +#define INFO_PANEL_W 200 +#define INFO_PANEL_H 120 +#define INFO_PANEL_RADIUS 10 +#define INFO_ROW_GAP 24 +#define INFO_FIRST_ROW_Y 14 +#define INFO_LABEL_X 12 +#define STATUS_FOOTER_HINT "BACK exit" + +#define PAY_LIST_TOP 46 +#define PAY_LIST_W 216 +#define PAY_LIST_H 150 +#define PAY_ROW_H 26 +#define PAY_ROW_GAP 4 +#define PAY_KEY_SZ 20 +#define PAY_KEY_RADIUS 6 +#define PAY_ROW_RADIUS 8 +#define PAY_ROW_PAD_HOR 8 +#define PAY_ROW_PAD_COL 8 +#define PAY_GLOW_W 14 +#define PAY_PREVIEW_W 216 +#define PAY_PREVIEW_H 92 +#define PAY_PREVIEW_BOT 26 +#define PAY_PREVIEW_RAD 8 +#define PAY_PREVIEW_PAD 8 +#define PAY_PREVIEW_BODY_Y 18 +#define PAY_KEY_TINT_OPA LV_OPA_20 +#define PAY_BODY_BUF_LEN 200 +#define PREVIEW_MAX_LINES 4 + +#define BADUSB_SCRIPT_DIR TOS_PATH_BADUSB +#define BADUSB_ASSET_DIR FLASH_STORAGE_BADUSB +#define ASSETS_PREFIX_LEN (sizeof(FLASH_MOUNT "/") - 1) +#define MAX_PAYLOADS 24 +#define PL_PATH_LEN 192 +#define PL_NAME_LEN 56 + +#define POLL_MS 80 + +#define RUN_TASK_STACK 4096 + +#define BADUSB_MIN_FREE_INTERNAL 45000 +#define BADUSB_MIN_BLOCK_INTERNAL 15000 + +static const struct { + const char *name; + const char *icon; +} MENU_ITEMS[] = { + {"Run Payload", "/assets/icons/play_arrow.bin"}, + {"Payloads", "/assets/icons/description.bin"}, + {"Keyboard Layout", "/assets/icons/keyboard.bin"}, + {"USB Status", "/assets/icons/usb.bin"}, + {"HID Mouse", "/assets/icons/usb.bin"}, +}; +#define MENU_ITEM_COUNT ((int)(sizeof(MENU_ITEMS) / sizeof(MENU_ITEMS[0]))) + +#define IDX_RUN_PAYLOAD 0 +#define IDX_PAYLOADS 1 +#define IDX_LAYOUT 2 +#define IDX_STATUS 3 +#define IDX_MOUSE 4 + +static const struct { + const char *label; + ducky_layout_t layout; +} LAYOUTS[] = { + {"US", DUCKY_LAYOUT_US}, + {"BR (ABNT2)", DUCKY_LAYOUT_ABNT2}, +}; +#define LAYOUT_COUNT ((int)(sizeof(LAYOUTS) / sizeof(LAYOUTS[0]))) + +typedef enum { + RUN_STAGE_DETECTING = 0, + RUN_STAGE_TYPING, + RUN_STAGE_DONE, +} run_stage_t; + +typedef enum { + VIEW_LIST = 0, + VIEW_PAYLOADS, + VIEW_RUNNING, + VIEW_LAYOUT, + VIEW_STATUS, +} view_t; + +typedef enum { + BAD_IDLE = 0, + BAD_WAIT, + BAD_RUN, + BAD_DONE, + BAD_ABORTED, + BAD_ERROR, +} bad_state_t; + +typedef struct { + const char *label; + const char *value; + bool is_accent; +} badusb_status_row_t; + +static lv_obj_t *s_screen = NULL; +static menu_component_t s_menu; +static view_t s_view = VIEW_LIST; + +static int s_payload_sel = 0; +static int s_layout_active = 0; + +static char s_pl_path[MAX_PAYLOADS][PL_PATH_LEN]; +static char s_pl_name[MAX_PAYLOADS][PL_NAME_LEN]; +static bool s_pl_is_asset[MAX_PAYLOADS]; +static int s_pl_count = 0; + +static lv_obj_t *s_pay_rows[MAX_PAYLOADS]; +static lv_obj_t *s_pay_list = NULL; +static lv_obj_t *s_pay_prev_title = NULL; +static lv_obj_t *s_pay_prev_body = NULL; + +static run_stage_t s_run_stage = RUN_STAGE_DETECTING; +static lv_obj_t *s_status_lbl = NULL; +static lv_obj_t *s_detect_group = NULL; +static lv_obj_t *s_delivery_group = NULL; +static lv_obj_t *s_term_lbl = NULL; +static lv_obj_t *s_progress = NULL; +static lv_obj_t *s_pct_lbl = NULL; +static lv_timer_t *s_poll_timer = NULL; + +static _Atomic bad_state_t s_bad_state = BAD_IDLE; +static _Atomic int s_bad_cur = 0; +static _Atomic int s_bad_total = 0; +static _Atomic esp_err_t s_bad_result = ESP_OK; +static _Atomic bool s_bad_abort = false; +static _Atomic bool s_run_active = false; +static bool s_prev_mux_native = false; +static char s_run_path[PL_PATH_LEN]; +static bool s_run_is_asset = false; + +static void badusb_input(const input_event_t *ev, void *ctx); +static void build_screen(void); + +static void stop_poll_timer(void) { + if (s_poll_timer != NULL) { + lv_timer_delete(s_poll_timer); + s_poll_timer = NULL; + } +} + +static void opa_anim_cb(void *var, int32_t v) { + lv_obj_set_style_opa((lv_obj_t *)var, (lv_opa_t)v, 0); +} + +static void translate_x_cb(void *var, int32_t v) { + lv_obj_set_style_translate_x((lv_obj_t *)var, v, 0); +} + +static void fade_in(lv_obj_t *obj, uint32_t duration_ms) { + lv_obj_set_style_opa(obj, LV_OPA_TRANSP, 0); + lv_anim_t a; + lv_anim_init(&a); + lv_anim_set_var(&a, obj); + lv_anim_set_exec_cb(&a, opa_anim_cb); + lv_anim_set_values(&a, LV_OPA_TRANSP, LV_OPA_COVER); + lv_anim_set_duration(&a, duration_ms); + lv_anim_set_path_cb(&a, lv_anim_path_ease_out); + lv_anim_start(&a); +} + +static bool is_ducky_script(const char *name) { + const char *dot = strrchr(name, '.'); + if (dot == NULL) + return false; + return strcasecmp(dot, ".txt") == 0 || strcasecmp(dot, ".dd") == 0 || + strcasecmp(dot, ".duck") == 0 || strcasecmp(dot, ".ducky") == 0; +} + +static void scan_dir_into(const char *dir, bool is_asset) { + DIR *d = opendir(dir); + if (d == NULL) { + ESP_LOGW(TAG, "No script dir: %s", dir); + return; + } + struct dirent *ent; + while ((ent = readdir(d)) != NULL && s_pl_count < MAX_PAYLOADS) { + if (ent->d_name[0] == '.') + continue; + if (ent->d_type == DT_DIR) + continue; + if (!is_ducky_script(ent->d_name)) + continue; + if (strlen(dir) + 1 + strlen(ent->d_name) >= PL_PATH_LEN) + continue; + strlcpy(s_pl_path[s_pl_count], dir, PL_PATH_LEN); + strlcat(s_pl_path[s_pl_count], "/", PL_PATH_LEN); + strlcat(s_pl_path[s_pl_count], ent->d_name, PL_PATH_LEN); + strlcpy(s_pl_name[s_pl_count], ent->d_name, PL_NAME_LEN); + s_pl_is_asset[s_pl_count] = is_asset; + s_pl_count++; + } + closedir(d); +} + +static void scan_payloads(void) { + s_pl_count = 0; + scan_dir_into(BADUSB_SCRIPT_DIR, false); + scan_dir_into(BADUSB_ASSET_DIR, true); + ESP_LOGI(TAG, "Found %d script(s)", s_pl_count); +} + +static void load_script_head(const char *path, char *out_buf, size_t out_sz, int max_lines) { + out_buf[0] = '\0'; + FILE *f = fopen(path, "r"); + if (f == NULL) { + strlcpy(out_buf, "(unreadable)", out_sz); + return; + } + size_t pos = 0; + int lines = 0; + char line[SCRIPT_LINE_BUF]; + while (lines < max_lines && fgets(line, sizeof(line), f) != NULL) { + size_t len = strlen(line); + while (len > 0 && (line[len - 1] == '\n' || line[len - 1] == '\r')) + line[--len] = '\0'; + int n = snprintf(out_buf + pos, out_sz - pos, "%s%s", pos ? "\n" : "", line); + if (n < 0) + break; + pos += (size_t)n; + if (pos >= out_sz) { + pos = out_sz - 1; + break; + } + lines++; + } + fclose(f); + if (pos == 0) + strlcpy(out_buf, "(empty)", out_sz); +} + +static void bad_progress_cb(int current_line, int total_lines) { + atomic_store(&s_bad_cur, current_line); + atomic_store(&s_bad_total, total_lines); +} + +static bool badusb_abort_requested(void) { + return atomic_load(&s_bad_abort); +} + +static void bad_run_task(void *arg) { + (void)arg; + esp_err_t err = ESP_OK; + + assets_manager_evict_cache(); + size_t free_int = heap_caps_get_free_size(MALLOC_CAP_INTERNAL); + size_t big_int = heap_caps_get_largest_free_block(MALLOC_CAP_INTERNAL); + ESP_LOGI(TAG, "pre-run internal heap: free=%u largest=%u", (unsigned)free_int, (unsigned)big_int); + if (free_int < BADUSB_MIN_FREE_INTERNAL || big_int < BADUSB_MIN_BLOCK_INTERNAL) { + ESP_LOGE(TAG, "Not enough internal RAM for USB; refusing run"); + atomic_store(&s_bad_result, ESP_ERR_NO_MEM); + atomic_store(&s_bad_state, BAD_ERROR); + atomic_store(&s_run_active, false); + vTaskDelete(NULL); + return; + } + + s_prev_mux_native = usb_mux_is_native(); + atomic_store(&s_bad_cur, 0); + atomic_store(&s_bad_total, 0); + atomic_store(&s_bad_state, BAD_WAIT); + + err = usb_mux_set_native(true); + if (err == ESP_OK) { + esp_err_t ie = bad_usb_init(); + if (ie != ESP_OK && ie != ESP_ERR_INVALID_STATE) + err = ie; + } + + if (err == ESP_OK) { + ducky_set_output_mode(DUCKY_OUTPUT_USB); + ducky_set_layout(LAYOUTS[s_layout_active].layout); + ducky_set_progress_callback(bad_progress_cb); + + if (bad_usb_wait_for_connection_ex(badusb_abort_requested)) { + if (!badusb_abort_requested()) { + atomic_store(&s_bad_state, BAD_RUN); + if (s_run_is_asset) + err = ducky_run_from_assets(s_run_path + ASSETS_PREFIX_LEN); + else + err = ducky_run_from_sdcard(s_run_path); + } + } else if (!badusb_abort_requested()) { + err = ESP_ERR_TIMEOUT; + } + ducky_set_progress_callback(NULL); + } + + usb_mux_set_native(s_prev_mux_native); + + atomic_store(&s_bad_result, err); + if (badusb_abort_requested()) + atomic_store(&s_bad_state, BAD_ABORTED); + else if (err != ESP_OK) + atomic_store(&s_bad_state, BAD_ERROR); + else + atomic_store(&s_bad_state, BAD_DONE); + + atomic_store(&s_run_active, false); + vTaskDelete(NULL); +} + +static void request_abort(void) { + atomic_store(&s_bad_abort, true); + ducky_abort(); +} + +static void build_detecting(void) { + s_status_lbl = lv_label_create(s_screen); + lv_label_set_text(s_status_lbl, DETECT_STATUS); + lv_obj_set_style_text_color(s_status_lbl, current_theme.text_main, 0); + lv_obj_set_style_text_font(s_status_lbl, &lv_font_montserrat_14, 0); + lv_obj_align(s_status_lbl, LV_ALIGN_TOP_MID, 0, STATUS_Y_OFS); + + lv_anim_t blink; + lv_anim_init(&blink); + lv_anim_set_var(&blink, s_status_lbl); + lv_anim_set_exec_cb(&blink, opa_anim_cb); + lv_anim_set_values(&blink, LV_OPA_40, LV_OPA_COVER); + lv_anim_set_duration(&blink, STATUS_BLINK_MS); + lv_anim_set_playback_duration(&blink, STATUS_BLINK_MS); + lv_anim_set_repeat_count(&blink, LV_ANIM_REPEAT_INFINITE); + lv_anim_set_path_cb(&blink, lv_anim_path_ease_in_out); + lv_anim_start(&blink); + + s_detect_group = lv_obj_create(s_screen); + lv_obj_remove_flag(s_detect_group, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(s_detect_group, lv_pct(100), lv_pct(100)); + lv_obj_align(s_detect_group, LV_ALIGN_CENTER, 0, 0); + lv_obj_set_style_bg_opa(s_detect_group, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(s_detect_group, 0, 0); + lv_obj_set_style_pad_all(s_detect_group, 0, 0); + + waves_create(s_detect_group, LV_ALIGN_CENTER, 0, 0, NULL, "/assets/icons/usb.bin"); + + int total_w = DOT_COUNT * DOT_SIZE + (DOT_COUNT - 1) * DOT_GAP; + int x0 = -(total_w / 2) + DOT_SIZE / 2; + for (int i = 0; i < DOT_COUNT; i++) { + lv_obj_t *dot = lv_obj_create(s_detect_group); + lv_obj_remove_flag(dot, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(dot, DOT_SIZE, DOT_SIZE); + lv_obj_align(dot, LV_ALIGN_CENTER, x0 + i * (DOT_SIZE + DOT_GAP), DOT_Y_OFS); + lv_obj_set_style_radius(dot, LV_RADIUS_CIRCLE, 0); + lv_obj_set_style_border_width(dot, 0, 0); + lv_obj_set_style_bg_opa(dot, LV_OPA_COVER, 0); + lv_obj_set_style_bg_color(dot, current_theme.border_accent, 0); + + lv_anim_t a; + lv_anim_init(&a); + lv_anim_set_var(&a, dot); + lv_anim_set_exec_cb(&a, opa_anim_cb); + lv_anim_set_values(&a, LV_OPA_30, LV_OPA_COVER); + lv_anim_set_duration(&a, DOT_PULSE_MS); + lv_anim_set_playback_duration(&a, DOT_PULSE_MS); + lv_anim_set_delay(&a, i * DOT_STAGGER_MS); + lv_anim_set_repeat_count(&a, LV_ANIM_REPEAT_INFINITE); + lv_anim_set_path_cb(&a, lv_anim_path_ease_in_out); + lv_anim_start(&a); + } +} + +static void build_terminal(void) { + lv_obj_t *panel = lv_obj_create(s_screen); + lv_obj_remove_flag(panel, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(panel, TERMINAL_W, TERMINAL_H); + lv_obj_align(panel, LV_ALIGN_TOP_MID, 0, TERMINAL_TOP_Y); + lv_obj_set_style_radius(panel, TERMINAL_RADIUS, 0); + lv_obj_set_style_bg_opa(panel, LV_OPA_COVER, 0); + lv_obj_set_style_bg_color(panel, lv_color_hex(DARK_PANEL_COLOR), 0); + lv_obj_set_style_border_width(panel, TERMINAL_BORDER, 0); + lv_obj_set_style_border_color(panel, lv_color_hex(TERM_GREEN), 0); + lv_obj_set_style_border_opa(panel, LV_OPA_70, 0); + lv_obj_set_style_shadow_color(panel, lv_color_hex(TERM_GREEN), 0); + lv_obj_set_style_shadow_width(panel, 12, 0); + lv_obj_set_style_shadow_opa(panel, LV_OPA_20, 0); + lv_obj_set_style_pad_all(panel, TERMINAL_PAD, 0); + + lv_obj_t *prompt = lv_label_create(panel); + lv_label_set_text(prompt, TERM_PROMPT); + lv_obj_set_style_text_color(prompt, lv_color_hex(TERM_DIM_GREEN), 0); + lv_obj_set_style_text_font(prompt, &lv_font_montserrat_12, 0); + lv_obj_align(prompt, LV_ALIGN_TOP_LEFT, 0, TERM_HEADER_Y); + + s_term_lbl = lv_label_create(panel); + lv_label_set_text(s_term_lbl, ""); + lv_obj_set_width(s_term_lbl, TERMINAL_W - TERMINAL_PAD * 2); + lv_label_set_long_mode(s_term_lbl, LV_LABEL_LONG_WRAP); + lv_obj_set_style_text_color(s_term_lbl, lv_color_hex(TERM_GREEN), 0); + lv_obj_set_style_text_font(s_term_lbl, &lv_font_montserrat_12, 0); + lv_obj_align(s_term_lbl, LV_ALIGN_TOP_LEFT, 0, TERM_BODY_Y); + + s_pct_lbl = lv_label_create(s_screen); + lv_label_set_text(s_pct_lbl, "Executing 0/0"); + lv_obj_set_style_text_color(s_pct_lbl, lv_color_hex(TERM_GREEN), 0); + lv_obj_set_style_text_font(s_pct_lbl, &lv_font_montserrat_12, 0); + lv_obj_align(s_pct_lbl, LV_ALIGN_TOP_MID, 0, PCT_LABEL_Y); + + s_progress = lv_bar_create(s_screen); + lv_obj_set_size(s_progress, PROGRESS_W, PROGRESS_H); + lv_obj_align(s_progress, LV_ALIGN_TOP_MID, 0, PROGRESS_Y); + lv_bar_set_range(s_progress, 0, PROGRESS_FULL_PCT); + lv_bar_set_value(s_progress, 0, LV_ANIM_OFF); + lv_obj_set_style_bg_color(s_progress, lv_color_hex(PROGRESS_TRACK_COLOR), LV_PART_MAIN); + lv_obj_set_style_bg_opa(s_progress, LV_OPA_COVER, LV_PART_MAIN); + lv_obj_set_style_border_width(s_progress, 1, LV_PART_MAIN); + lv_obj_set_style_border_color(s_progress, lv_color_hex(TERM_DIM_GREEN), LV_PART_MAIN); + lv_obj_set_style_bg_color(s_progress, lv_color_hex(TERM_DIM_GREEN), LV_PART_INDICATOR); + lv_obj_set_style_bg_grad_color(s_progress, lv_color_hex(TERM_GREEN), LV_PART_INDICATOR); + lv_obj_set_style_bg_grad_dir(s_progress, LV_GRAD_DIR_HOR, LV_PART_INDICATOR); + lv_obj_set_style_bg_opa(s_progress, LV_OPA_COVER, LV_PART_INDICATOR); + lv_obj_set_style_radius(s_progress, PROGRESS_RADIUS, LV_PART_MAIN); + lv_obj_set_style_radius(s_progress, PROGRESS_RADIUS, LV_PART_INDICATOR); +} + +static void build_delivery(void) { + s_delivery_group = lv_obj_create(s_screen); + lv_obj_remove_flag(s_delivery_group, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(s_delivery_group, DELIVERY_W, DELIVERY_H); + lv_obj_align(s_delivery_group, LV_ALIGN_TOP_MID, 0, DELIVERY_Y); + lv_obj_set_style_bg_opa(s_delivery_group, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(s_delivery_group, 0, 0); + lv_obj_set_style_pad_all(s_delivery_group, 0, 0); + + int track_x0 = DELIVERY_NODE_W; + int track_x1 = DELIVERY_W - DELIVERY_NODE_W; + int track_len = track_x1 - track_x0; + + lv_obj_t *track = lv_obj_create(s_delivery_group); + lv_obj_remove_flag(track, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(track, track_len, DELIVERY_TRACK_H); + lv_obj_align(track, LV_ALIGN_LEFT_MID, track_x0, 0); + lv_obj_set_style_border_width(track, 0, 0); + lv_obj_set_style_radius(track, 1, 0); + lv_obj_set_style_bg_color(track, current_theme.border_accent, 0); + lv_obj_set_style_bg_opa(track, LV_OPA_30, 0); + + const char *node_labels[DELIVERY_NODE_COUNT] = {"HID", "HOST"}; + for (int n = 0; n < DELIVERY_NODE_COUNT; n++) { + lv_obj_t *node = lv_obj_create(s_delivery_group); + lv_obj_remove_flag(node, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(node, DELIVERY_NODE_W, DELIVERY_NODE_H); + lv_obj_align(node, n == 0 ? LV_ALIGN_LEFT_MID : LV_ALIGN_RIGHT_MID, 0, 0); + lv_obj_set_style_radius(node, 4, 0); + lv_obj_set_style_pad_all(node, 0, 0); + lv_obj_set_style_bg_color(node, current_theme.bg_secondary, 0); + lv_obj_set_style_bg_opa(node, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(node, 1, 0); + lv_obj_set_style_border_color(node, current_theme.border_accent, 0); + + lv_obj_t *lbl = lv_label_create(node); + lv_label_set_text(lbl, node_labels[n]); + lv_obj_set_style_text_color(lbl, current_theme.text_main, 0); + lv_obj_set_style_text_font(lbl, &lv_font_montserrat_12, 0); + lv_obj_center(lbl); + } + + for (int i = 0; i < DELIVERY_PACKET_COUNT; i++) { + lv_obj_t *pkt = lv_obj_create(s_delivery_group); + lv_obj_remove_flag(pkt, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(pkt, DELIVERY_PACKET, DELIVERY_PACKET); + lv_obj_align(pkt, LV_ALIGN_LEFT_MID, track_x0, 0); + lv_obj_set_style_radius(pkt, 2, 0); + lv_obj_set_style_border_width(pkt, 0, 0); + lv_obj_set_style_bg_color(pkt, lv_color_hex(TERM_GREEN), 0); + lv_obj_set_style_bg_opa(pkt, LV_OPA_COVER, 0); + lv_obj_set_style_shadow_color(pkt, lv_color_hex(TERM_GREEN), 0); + lv_obj_set_style_shadow_width(pkt, 6, 0); + lv_obj_set_style_shadow_opa(pkt, LV_OPA_50, 0); + + lv_anim_t a; + lv_anim_init(&a); + lv_anim_set_var(&a, pkt); + lv_anim_set_exec_cb(&a, translate_x_cb); + lv_anim_set_values(&a, 0, track_len - DELIVERY_PACKET); + lv_anim_set_duration(&a, DELIVERY_TRAVEL_MS); + lv_anim_set_delay(&a, i * DELIVERY_STAGGER_MS); + lv_anim_set_repeat_count(&a, LV_ANIM_REPEAT_INFINITE); + lv_anim_set_path_cb(&a, lv_anim_path_ease_in_out); + lv_anim_start(&a); + } +} + +static void enter_stage_typing(void) { + if (s_detect_group != NULL) { + lv_obj_del(s_detect_group); + s_detect_group = NULL; + } + if (s_status_lbl != NULL) { + lv_obj_del(s_status_lbl); + s_status_lbl = NULL; + } + + build_delivery(); + build_terminal(); + + char head[TERMINAL_BUF_LEN]; + load_script_head(s_run_path, head, sizeof(head), TERM_HEAD_LINES); + if (s_term_lbl != NULL) + lv_label_set_text(s_term_lbl, head); + fade_in(s_term_lbl, FADE_MS); +} + +static void update_progress(void) { + int cur = atomic_load(&s_bad_cur); + int total = atomic_load(&s_bad_total); + int pct = (total > 0) ? (cur * PROGRESS_FULL_PCT / total) : 0; + if (pct > PROGRESS_FULL_PCT) + pct = PROGRESS_FULL_PCT; + if (s_progress != NULL) + lv_bar_set_value(s_progress, pct, LV_ANIM_OFF); + if (s_pct_lbl != NULL) { + char buf[PCT_LABEL_BUF]; + snprintf(buf, sizeof(buf), "Executing %d/%d", cur, total); + lv_label_set_text(s_pct_lbl, buf); + } +} + +static void show_done(bad_state_t final) { + if (s_detect_group != NULL) { + lv_obj_del(s_detect_group); + s_detect_group = NULL; + } + if (s_status_lbl != NULL) { + lv_obj_del(s_status_lbl); + s_status_lbl = NULL; + } + if (s_delivery_group != NULL) { + lv_obj_del(s_delivery_group); + s_delivery_group = NULL; + } + + const char *msg; + uint32_t col; + if (final == BAD_ERROR) { + msg = (atomic_load(&s_bad_result) == ESP_ERR_NO_MEM) ? LV_SYMBOL_WARNING " Low memory" + : LV_SYMBOL_WARNING " Run failed"; + col = ERR_RED; + } else if (final == BAD_ABORTED) { + msg = LV_SYMBOL_CLOSE " Aborted"; + col = WARN_AMBER; + } else { + msg = LV_SYMBOL_OK " Payload delivered"; + col = TERM_GREEN; + if (s_progress != NULL) + lv_bar_set_value(s_progress, PROGRESS_FULL_PCT, LV_ANIM_ON); + if (s_pct_lbl != NULL) + lv_label_set_text(s_pct_lbl, "Executing done"); + } + + lv_obj_t *confirm = lv_label_create(s_screen); + lv_label_set_text(confirm, msg); + lv_obj_set_style_text_color(confirm, lv_color_hex(col), 0); + lv_obj_set_style_text_font(confirm, &lv_font_montserrat_14, 0); + lv_obj_align(confirm, LV_ALIGN_BOTTOM_MID, 0, CONFIRM_Y_OFS); + fade_in(confirm, FADE_MS); + + ui_chrome_footer(s_screen, INSTRUCT_TEXT); + + ESP_LOGI(TAG, + "payload run %s: %s", + final == BAD_DONE ? "done" : (final == BAD_ABORTED ? "aborted" : "failed"), + s_run_path); + ui_feedback(final == BAD_DONE ? UI_FB_WRITE : UI_FB_SELECT); +} + +static void bad_poll_cb(lv_timer_t *t) { + (void)t; + if (lv_screen_active() != s_screen || s_view != VIEW_RUNNING) { + stop_poll_timer(); + return; + } + + bad_state_t st = atomic_load(&s_bad_state); + + if (st == BAD_RUN) { + if (s_run_stage == RUN_STAGE_DETECTING) { + s_run_stage = RUN_STAGE_TYPING; + enter_stage_typing(); + } + update_progress(); + } else if (st == BAD_DONE || st == BAD_ABORTED || st == BAD_ERROR) { + if (s_run_stage != RUN_STAGE_DONE) { + if (s_run_stage == RUN_STAGE_DETECTING && st == BAD_DONE) + enter_stage_typing(); + s_run_stage = RUN_STAGE_DONE; + show_done(st); + } + stop_poll_timer(); + } +} + +static void build_running(void) { + ui_chrome_header(s_screen, "BADUSB", "/assets/icons/usb.bin"); + + s_run_stage = RUN_STAGE_DETECTING; + build_detecting(); + + if (!atomic_load(&s_run_active)) { + atomic_store(&s_bad_abort, false); + atomic_store(&s_bad_cur, 0); + atomic_store(&s_bad_total, 0); + atomic_store(&s_bad_state, BAD_IDLE); + atomic_store(&s_run_active, true); + BaseType_t ok = xTaskCreatePinnedToCore(bad_run_task, + "badusb_run", + RUN_TASK_STACK, + NULL, + SYS_PRIO_SERVICE_HI, + NULL, + SYS_CORE_RADIO); + if (ok != pdPASS) { + atomic_store(&s_run_active, false); + atomic_store(&s_bad_result, ESP_ERR_NO_MEM); + atomic_store(&s_bad_state, BAD_ERROR); + ESP_LOGE(TAG, "Failed to spawn run task"); + } + } + + s_poll_timer = lv_timer_create(bad_poll_cb, POLL_MS, NULL); +} + +static void pay_style_row(lv_obj_t *row, bool selected) { + lv_obj_set_style_border_width(row, 1, 0); + lv_obj_set_style_bg_opa(row, LV_OPA_COVER, 0); + if (selected) { + lv_obj_set_style_bg_color(row, current_theme.bg_secondary, 0); + lv_obj_set_style_border_color(row, current_theme.border_accent, 0); + lv_obj_set_style_border_opa(row, LV_OPA_COVER, 0); + lv_obj_set_style_shadow_color(row, current_theme.border_accent, 0); + lv_obj_set_style_shadow_width(row, PAY_GLOW_W, 0); + lv_obj_set_style_shadow_opa(row, LV_OPA_40, 0); + lv_obj_set_style_shadow_spread(row, -2, 0); + } else { + lv_obj_set_style_bg_color(row, current_theme.bg_primary, 0); + lv_obj_set_style_border_color(row, current_theme.border_inactive, 0); + lv_obj_set_style_border_opa(row, LV_OPA_COVER, 0); + lv_obj_set_style_shadow_width(row, 0, 0); + lv_obj_set_style_shadow_opa(row, LV_OPA_TRANSP, 0); + } +} + +static void pay_update_preview(int idx) { + if (idx < 0 || idx >= s_pl_count) + return; + if (s_pay_prev_title != NULL) { + char title[PREVIEW_TITLE_BUF]; + snprintf(title, sizeof(title), "// %s", s_pl_name[idx]); + lv_label_set_text(s_pay_prev_title, title); + } + if (s_pay_prev_body != NULL) { + char body[PAY_BODY_BUF_LEN]; + load_script_head(s_pl_path[idx], body, sizeof(body), PREVIEW_MAX_LINES); + lv_label_set_text(s_pay_prev_body, body); + } +} + +static void pay_apply_sel(int idx) { + for (int i = 0; i < s_pl_count; i++) + if (s_pay_rows[i] != NULL) + pay_style_row(s_pay_rows[i], i == idx); + if (idx >= 0 && idx < s_pl_count && s_pay_rows[idx] != NULL) + lv_obj_scroll_to_view(s_pay_rows[idx], LV_ANIM_ON); + pay_update_preview(idx); +} + +static void build_payloads_empty(void) { + ui_chrome_header(s_screen, "PAYLOADS", "/assets/icons/description.bin"); + ui_chrome_footer(s_screen, "BACK back"); + + lv_obj_t *icon = lv_label_create(s_screen); + lv_label_set_text(icon, LV_SYMBOL_SD_CARD); + lv_obj_set_style_text_color(icon, current_theme.border_inactive, 0); + lv_obj_set_style_text_font(icon, &lv_font_montserrat_14, 0); + lv_obj_align(icon, LV_ALIGN_CENTER, 0, -24); + + lv_obj_t *msg = lv_label_create(s_screen); + lv_label_set_text(msg, "No scripts found"); + lv_obj_set_style_text_color(msg, current_theme.text_main, 0); + lv_obj_set_style_text_font(msg, &lv_font_montserrat_14, 0); + lv_obj_align(msg, LV_ALIGN_CENTER, 0, 2); + + lv_obj_t *sub = lv_label_create(s_screen); + lv_label_set_text(sub, "Add .txt/.duck to\n" BADUSB_SCRIPT_DIR); + lv_obj_set_style_text_align(sub, LV_TEXT_ALIGN_CENTER, 0); + lv_obj_set_style_text_color(sub, current_theme.border_inactive, 0); + lv_obj_set_style_text_font(sub, &lv_font_montserrat_12, 0); + lv_obj_align(sub, LV_ALIGN_CENTER, 0, 34); + fade_in(sub, FADE_MS); +} + +static void build_payloads(void) { + if (s_pl_count <= 0) { + build_payloads_empty(); + return; + } + + ui_chrome_header(s_screen, "PAYLOADS", "/assets/icons/description.bin"); + ui_chrome_footer(s_screen, "UP/DOWN pick OK run BACK back"); + + if (s_payload_sel < 0) + s_payload_sel = 0; + if (s_payload_sel >= s_pl_count) + s_payload_sel = s_pl_count - 1; + + s_pay_list = lv_obj_create(s_screen); + lv_obj_set_size(s_pay_list, PAY_LIST_W, PAY_LIST_H); + lv_obj_align(s_pay_list, LV_ALIGN_TOP_MID, 0, PAY_LIST_TOP); + lv_obj_set_style_bg_opa(s_pay_list, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(s_pay_list, 0, 0); + lv_obj_set_style_pad_all(s_pay_list, 0, 0); + lv_obj_set_style_pad_row(s_pay_list, PAY_ROW_GAP, 0); + lv_obj_set_flex_flow(s_pay_list, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(s_pay_list, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_START); + lv_obj_set_scroll_dir(s_pay_list, LV_DIR_VER); + lv_obj_set_scrollbar_mode(s_pay_list, LV_SCROLLBAR_MODE_AUTO); + + for (int i = 0; i < s_pl_count; i++) { + lv_obj_t *row = lv_obj_create(s_pay_list); + lv_obj_remove_flag(row, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(row, lv_pct(100), PAY_ROW_H); + lv_obj_set_style_radius(row, PAY_ROW_RADIUS, 0); + lv_obj_set_style_pad_hor(row, PAY_ROW_PAD_HOR, 0); + lv_obj_set_style_pad_ver(row, 0, 0); + 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_column(row, PAY_ROW_PAD_COL, 0); + + lv_obj_t *key = lv_obj_create(row); + lv_obj_remove_flag(key, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(key, PAY_KEY_SZ, PAY_KEY_SZ); + lv_obj_set_style_radius(key, PAY_KEY_RADIUS, 0); + lv_obj_set_style_pad_all(key, 0, 0); + lv_obj_set_style_border_width(key, 0, 0); + lv_obj_set_style_bg_color(key, current_theme.border_accent, 0); + lv_obj_set_style_bg_opa(key, PAY_KEY_TINT_OPA, 0); + lv_obj_t *kico = lv_label_create(key); + lv_label_set_text(kico, LV_SYMBOL_FILE); + lv_obj_set_style_text_color(kico, current_theme.border_accent, 0); + lv_obj_set_style_text_font(kico, &lv_font_montserrat_12, 0); + lv_obj_center(kico); + + lv_obj_t *name = lv_label_create(row); + lv_label_set_text(name, s_pl_name[i]); + lv_label_set_long_mode(name, LV_LABEL_LONG_DOT); + lv_obj_set_style_text_color(name, current_theme.text_main, 0); + lv_obj_set_style_text_font(name, &lv_font_montserrat_12, 0); + lv_obj_set_flex_grow(name, 1); + + s_pay_rows[i] = row; + } + + lv_obj_t *prev = lv_obj_create(s_screen); + lv_obj_remove_flag(prev, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(prev, PAY_PREVIEW_W, PAY_PREVIEW_H); + lv_obj_align(prev, LV_ALIGN_BOTTOM_MID, 0, -PAY_PREVIEW_BOT); + lv_obj_set_style_radius(prev, PAY_PREVIEW_RAD, 0); + lv_obj_set_style_bg_color(prev, lv_color_hex(DARK_PANEL_COLOR), 0); + lv_obj_set_style_bg_opa(prev, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(prev, TERMINAL_BORDER, 0); + lv_obj_set_style_border_color(prev, lv_color_hex(TERM_GREEN), 0); + lv_obj_set_style_border_opa(prev, LV_OPA_70, 0); + lv_obj_set_style_shadow_color(prev, lv_color_hex(TERM_GREEN), 0); + lv_obj_set_style_shadow_width(prev, 12, 0); + lv_obj_set_style_shadow_opa(prev, LV_OPA_20, 0); + lv_obj_set_style_pad_all(prev, PAY_PREVIEW_PAD, 0); + + s_pay_prev_title = lv_label_create(prev); + lv_obj_set_style_text_color(s_pay_prev_title, lv_color_hex(TERM_DIM_GREEN), 0); + lv_obj_set_style_text_font(s_pay_prev_title, &lv_font_montserrat_12, 0); + lv_obj_align(s_pay_prev_title, LV_ALIGN_TOP_LEFT, 0, 0); + + s_pay_prev_body = lv_label_create(prev); + lv_obj_set_width(s_pay_prev_body, PAY_PREVIEW_W - PAY_PREVIEW_PAD * 2); + lv_label_set_long_mode(s_pay_prev_body, LV_LABEL_LONG_WRAP); + lv_obj_set_style_text_color(s_pay_prev_body, lv_color_hex(TERM_GREEN), 0); + lv_obj_set_style_text_font(s_pay_prev_body, &lv_font_montserrat_12, 0); + lv_obj_align(s_pay_prev_body, LV_ALIGN_TOP_LEFT, 0, PAY_PREVIEW_BODY_Y); + + pay_apply_sel(s_payload_sel); + + fade_in(s_pay_list, FADE_MS); + fade_in(prev, FADE_MS); +} + +static void build_layout(void) { + s_menu = menu_component_create(s_screen, "LAYOUT", "/assets/icons/keyboard.bin"); + for (int i = 0; i < LAYOUT_COUNT; i++) { + menu_component_add_item(&s_menu, "/assets/icons/keyboard.bin", LAYOUTS[i].label); + if (i == s_layout_active) + menu_component_set_item_label_color(&s_menu, i, lv_color_hex(SIG_GREEN)); + } + menu_component_select(&s_menu, s_layout_active); + fade_in(s_menu.items_cont, FADE_MS); + fade_in(s_menu.title_bar, FADE_MS); +} + +static void status_row(lv_obj_t *panel, int index, const badusb_status_row_t *row) { + lv_obj_t *lab = lv_label_create(panel); + lv_label_set_text(lab, row->label); + lv_obj_set_style_text_color(lab, current_theme.border_inactive, 0); + lv_obj_set_style_text_font(lab, &lv_font_montserrat_12, 0); + lv_obj_align(lab, LV_ALIGN_TOP_LEFT, INFO_LABEL_X, INFO_FIRST_ROW_Y + index * INFO_ROW_GAP); + + lv_obj_t *val = lv_label_create(panel); + lv_label_set_text(val, row->value); + lv_obj_set_style_text_color( + val, row->is_accent ? lv_color_hex(SIG_GREEN) : current_theme.text_main, 0); + lv_obj_set_style_text_font(val, &lv_font_montserrat_12, 0); + lv_obj_align(val, LV_ALIGN_TOP_RIGHT, -INFO_LABEL_X, INFO_FIRST_ROW_Y + index * INFO_ROW_GAP); +} + +static void build_status(void) { + ui_chrome_header(s_screen, "USB STATUS", "/assets/icons/usb.bin"); + ui_chrome_footer(s_screen, STATUS_FOOTER_HINT); + + lv_obj_t *panel = lv_obj_create(s_screen); + lv_obj_remove_flag(panel, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(panel, INFO_PANEL_W, INFO_PANEL_H); + lv_obj_align(panel, LV_ALIGN_CENTER, 0, (UI_CHROME_HEADER_H - UI_CHROME_FOOTER_H) / 2); + lv_obj_set_style_radius(panel, INFO_PANEL_RADIUS, 0); + lv_obj_set_style_bg_opa(panel, LV_OPA_COVER, 0); + lv_obj_set_style_bg_color(panel, current_theme.bg_primary, 0); + lv_obj_set_style_bg_grad_color(panel, current_theme.bg_secondary, 0); + lv_obj_set_style_bg_grad_dir(panel, LV_GRAD_DIR_VER, 0); + lv_obj_set_style_border_width(panel, 2, 0); + lv_obj_set_style_border_color(panel, current_theme.border_accent, 0); + lv_obj_set_style_pad_all(panel, 0, 0); + + bool native = usb_mux_is_native(); + bool busy = atomic_load(&s_run_active); + const badusb_status_row_t rows[] = { + {"USB", "HID + CDC", false}, + {"VID:PID", "CAFE:4001", false}, + {"Mux", native ? "Native USB" : "UART bridge", native}, + {"State", busy ? "Running" : "Ready", !busy}, + }; + for (int i = 0; i < (int)(sizeof(rows) / sizeof(rows[0])); i++) + status_row(panel, i, &rows[i]); + + fade_in(panel, FADE_MS); +} + +static void build_screen(void) { + stop_poll_timer(); + if (s_screen != NULL) { + lv_obj_del(s_screen); + s_screen = NULL; + } + s_status_lbl = NULL; + s_detect_group = NULL; + s_delivery_group = NULL; + s_term_lbl = NULL; + s_progress = NULL; + s_pct_lbl = NULL; + s_pay_prev_title = NULL; + s_pay_prev_body = NULL; + s_pay_list = NULL; + for (int i = 0; i < MAX_PAYLOADS; i++) + s_pay_rows[i] = NULL; + + s_screen = lv_obj_create(NULL); + lv_obj_set_style_bg_color(s_screen, current_theme.screen_base, 0); + lv_obj_set_style_bg_opa(s_screen, LV_OPA_COVER, 0); + lv_obj_remove_flag(s_screen, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_border_width(s_screen, 0, 0); + lv_obj_set_style_pad_all(s_screen, 0, 0); + + switch (s_view) { + case VIEW_PAYLOADS: + build_payloads(); + break; + case VIEW_RUNNING: + build_running(); + break; + case VIEW_LAYOUT: + build_layout(); + break; + case VIEW_STATUS: + build_status(); + break; + case VIEW_LIST: + default: + s_menu = menu_component_create(s_screen, "BADUSB", "/assets/icons/usb.bin"); + for (int i = 0; i < MENU_ITEM_COUNT; i++) + menu_component_add_item(&s_menu, MENU_ITEMS[i].icon, MENU_ITEMS[i].name); + fade_in(s_menu.items_cont, FADE_MS); + fade_in(s_menu.title_bar, FADE_MS); + break; + } + + ui_input_set_screen_handler(badusb_input, NULL); + + ui_screen_load_owned(&s_screen, s_screen); +} + +static void input_view_list(const input_event_t *ev, bool press, bool nav) { + switch (ev->button) { + case INPUT_BTN_DOWN: + if (nav) + menu_component_next(&s_menu); + break; + case INPUT_BTN_UP: + if (nav) + menu_component_prev(&s_menu); + break; + case INPUT_BTN_OK: + if (press) { + int sel = menu_component_get_selected(&s_menu); + if (sel == IDX_RUN_PAYLOAD) { + scan_payloads(); + if (s_pl_count <= 0) { + s_view = VIEW_PAYLOADS; + } else { + if (s_payload_sel < 0 || s_payload_sel >= s_pl_count) + s_payload_sel = 0; + strlcpy(s_run_path, s_pl_path[s_payload_sel], sizeof(s_run_path)); + s_run_is_asset = s_pl_is_asset[s_payload_sel]; + s_view = VIEW_RUNNING; + } + build_screen(); + } else if (sel == IDX_PAYLOADS) { + scan_payloads(); + s_view = VIEW_PAYLOADS; + build_screen(); + } else if (sel == IDX_LAYOUT) { + s_view = VIEW_LAYOUT; + build_screen(); + } else if (sel == IDX_STATUS) { + s_view = VIEW_STATUS; + build_screen(); + } else if (sel == IDX_MOUSE) { + ui_switch_screen(SCREEN_USB_MOUSE); + } + } + break; + case INPUT_BTN_BACK: + if (press) + ui_switch_screen(SCREEN_MENU); + break; + default: + break; + } +} + +static void input_view_payloads(const input_event_t *ev, bool press, bool nav) { + switch (ev->button) { + case INPUT_BTN_DOWN: + if (nav && s_payload_sel < s_pl_count - 1) { + s_payload_sel++; + pay_apply_sel(s_payload_sel); + } + break; + case INPUT_BTN_UP: + if (nav && s_payload_sel > 0) { + s_payload_sel--; + pay_apply_sel(s_payload_sel); + } + break; + case INPUT_BTN_OK: + if (press && s_pl_count > 0) { + strlcpy(s_run_path, s_pl_path[s_payload_sel], sizeof(s_run_path)); + s_run_is_asset = s_pl_is_asset[s_payload_sel]; + s_view = VIEW_RUNNING; + build_screen(); + } + break; + case INPUT_BTN_BACK: + if (press) { + s_view = VIEW_LIST; + build_screen(); + } + break; + default: + break; + } +} + +static void input_view_running(const input_event_t *ev, bool press) { + switch (ev->button) { + case INPUT_BTN_RIGHT: + if (press && s_run_stage == RUN_STAGE_DONE && !atomic_load(&s_run_active)) + build_screen(); + break; + case INPUT_BTN_BACK: + if (press) { + request_abort(); + s_view = VIEW_LIST; + build_screen(); + } + break; + default: + break; + } +} + +static void input_view_layout(const input_event_t *ev, bool press, bool nav) { + switch (ev->button) { + case INPUT_BTN_DOWN: + if (nav) + menu_component_next(&s_menu); + break; + case INPUT_BTN_UP: + if (nav) + menu_component_prev(&s_menu); + break; + case INPUT_BTN_OK: + if (press) { + int sel = menu_component_get_selected(&s_menu); + if (sel >= 0 && sel < LAYOUT_COUNT && sel != s_layout_active) { + menu_component_set_item_label_color(&s_menu, s_layout_active, current_theme.text_main); + s_layout_active = sel; + menu_component_set_item_label_color(&s_menu, s_layout_active, lv_color_hex(SIG_GREEN)); + ESP_LOGI(TAG, "layout set: %s", LAYOUTS[s_layout_active].label); + } + } + break; + case INPUT_BTN_BACK: + if (press) { + s_view = VIEW_LIST; + build_screen(); + } + break; + default: + break; + } +} + +static void input_view_status(const input_event_t *ev, bool press) { + if (ev->button == INPUT_BTN_BACK && press) { + s_view = VIEW_LIST; + build_screen(); + } +} + +static void badusb_input(const input_event_t *ev, void *ctx) { + (void)ctx; + const bool press = (ev->action == INPUT_ACTION_PRESS); + const bool nav = press || (ev->action == INPUT_ACTION_REPEAT); + + switch (s_view) { + case VIEW_LIST: + input_view_list(ev, press, nav); + break; + case VIEW_PAYLOADS: + input_view_payloads(ev, press, nav); + break; + case VIEW_RUNNING: + input_view_running(ev, press); + break; + case VIEW_LAYOUT: + input_view_layout(ev, press, nav); + break; + case VIEW_STATUS: + input_view_status(ev, press); + break; + default: + break; + } +} + +void ui_badusb_menu_open(void) { + s_poll_timer = NULL; + s_view = VIEW_LIST; + s_payload_sel = 0; + scan_payloads(); + build_screen(); +} diff --git a/firmware_p4/components/Applications/ui/screens/badusb/include/ui_badusb_menu.h b/firmware_p4/components/Applications/ui/screens/badusb/include/badusb_menu_ui.h similarity index 91% rename from firmware_p4/components/Applications/ui/screens/badusb/include/ui_badusb_menu.h rename to firmware_p4/components/Applications/ui/screens/badusb/include/badusb_menu_ui.h index ba7ed0e61..20f4528e5 100644 --- a/firmware_p4/components/Applications/ui/screens/badusb/include/ui_badusb_menu.h +++ b/firmware_p4/components/Applications/ui/screens/badusb/include/badusb_menu_ui.h @@ -13,8 +13,8 @@ // You should have received a copy of the GNU General Public License // along with TentacleOS. If not, see . -#ifndef UI_BADUSB_MENU_H -#define UI_BADUSB_MENU_H +#ifndef BADUSB_MENU_UI_H +#define BADUSB_MENU_UI_H #ifdef __cplusplus extern "C" { @@ -27,4 +27,4 @@ void ui_badusb_menu_open(void); } #endif -#endif // UI_BADUSB_MENU_H +#endif // BADUSB_MENU_UI_H diff --git a/firmware_p4/components/Applications/ui/screens/SubGhz/include/subghz_spectrum_ui.h b/firmware_p4/components/Applications/ui/screens/badusb/include/usb_mouse_ui.h similarity index 80% rename from firmware_p4/components/Applications/ui/screens/SubGhz/include/subghz_spectrum_ui.h rename to firmware_p4/components/Applications/ui/screens/badusb/include/usb_mouse_ui.h index dcb7c2a9d..5f8e5d47a 100644 --- a/firmware_p4/components/Applications/ui/screens/SubGhz/include/subghz_spectrum_ui.h +++ b/firmware_p4/components/Applications/ui/screens/badusb/include/usb_mouse_ui.h @@ -13,18 +13,18 @@ // You should have received a copy of the GNU General Public License // along with TentacleOS. If not, see . -#ifndef SUBGHZ_SPECTRUM_UI_H -#define SUBGHZ_SPECTRUM_UI_H +#ifndef USB_MOUSE_UI_H +#define USB_MOUSE_UI_H #ifdef __cplusplus extern "C" { #endif -/** @brief Open the SubGHz spectrum analyzer screen. */ -void ui_subghz_spectrum_open(void); +/** @brief Open the HID mouse screen (trackpad, scroll rail, click buttons). */ +void ui_usb_mouse_open(void); #ifdef __cplusplus } #endif -#endif // SUBGHZ_SPECTRUM_UI_H +#endif // USB_MOUSE_UI_H diff --git a/firmware_p4/components/Applications/ui/screens/badusb/ui_badusb_browser.c b/firmware_p4/components/Applications/ui/screens/badusb/ui_badusb_browser.c deleted file mode 100644 index a4d4d38d3..000000000 --- a/firmware_p4/components/Applications/ui/screens/badusb/ui_badusb_browser.c +++ /dev/null @@ -1,107 +0,0 @@ -// Copyright (c) 2025 HIGH CODE LLC -// -// TentacleOS is free software: you can redistribute it and/or modify -// it under the terms of the GNU General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// TentacleOS is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU General Public License for more details. -// -// You should have received a copy of the GNU General Public License -// along with TentacleOS. If not, see . - -#include "ui_badusb_browser.h" - -#include -#include -#include - -#include "esp_log.h" - -#include "footer_ui.h" -#include "header_ui.h" -#include "lv_port_indev.h" -#include "storage_assets.h" -#include "tos_flash_paths.h" -#include "ui_badusb_running.h" -#include "ui_manager.h" -#include "ui_theme.h" - -static const char *TAG = "UI_BADUSB_BROWSER"; - -#define BROWSER_LIST_WIDTH 220 -#define BROWSER_LIST_HEIGHT 180 -#define BROWSER_LIST_BORDER_WIDTH 2 - -static lv_obj_t *screen_browser = NULL; - -static void file_select_event_handler(lv_event_t *e); - -void ui_badusb_browser_open(void) { - if (screen_browser != NULL) { - lv_obj_del(screen_browser); - } - - screen_browser = lv_obj_create(NULL); - lv_obj_set_style_bg_color(screen_browser, current_theme.screen_base, 0); - lv_obj_remove_flag(screen_browser, LV_OBJ_FLAG_SCROLLABLE); - - header_ui_create(screen_browser); - - lv_obj_t *list = lv_list_create(screen_browser); - lv_obj_set_size(list, BROWSER_LIST_WIDTH, BROWSER_LIST_HEIGHT); - lv_obj_center(list); - lv_obj_set_style_bg_color(list, current_theme.screen_base, 0); - lv_obj_set_style_text_color(list, current_theme.text_main, 0); - lv_obj_set_style_border_color(list, lv_palette_main(LV_PALETTE_DEEP_PURPLE), 0); - lv_obj_set_style_border_width(list, BROWSER_LIST_BORDER_WIDTH, 0); - - DIR *dir = opendir(FLASH_STORAGE_BADUSB); - if (dir != NULL) { - struct dirent *de; - while ((de = readdir(dir)) != NULL) { - if (de->d_type == DT_REG) { - lv_obj_t *btn = lv_list_add_button(list, LV_SYMBOL_FILE, de->d_name); - lv_obj_add_event_cb(btn, file_select_event_handler, LV_EVENT_KEY, NULL); - lv_obj_set_style_bg_color(btn, current_theme.screen_base, 0); - lv_obj_set_style_text_color(btn, current_theme.text_main, 0); - } - } - closedir(dir); - } else { - lv_obj_t *btn = lv_list_add_button(list, LV_SYMBOL_WARNING, "Directory not found"); - lv_obj_set_style_bg_color(btn, current_theme.screen_base, 0); - lv_obj_set_style_text_color(btn, current_theme.text_main, 0); - } - - footer_ui_create(screen_browser); - - lv_obj_add_event_cb(screen_browser, file_select_event_handler, LV_EVENT_KEY, NULL); - - if (main_group != NULL) { - lv_group_add_obj(main_group, list); - lv_group_focus_obj(list); - } - - lv_screen_load(screen_browser); -} - -static void file_select_event_handler(lv_event_t *e) { - lv_event_code_t code = lv_event_get_code(e); - lv_obj_t *obj = lv_event_get_target(e); - - if (code == LV_EVENT_KEY) { - uint32_t key = lv_event_get_key(e); - if (key == LV_KEY_ENTER) { - const char *filename = lv_list_get_button_text(lv_obj_get_parent(obj), obj); - ESP_LOGI(TAG, "Selected script: %s", filename); - ui_badusb_running_set_script(filename); - ui_switch_screen(SCREEN_BADUSB_LAYOUT); - } else if (key == LV_KEY_ESC || key == LV_KEY_LEFT) { - ui_switch_screen(SCREEN_BADUSB_MENU); - } - } -} \ No newline at end of file diff --git a/firmware_p4/components/Applications/ui/screens/badusb/ui_badusb_connect.c b/firmware_p4/components/Applications/ui/screens/badusb/ui_badusb_connect.c deleted file mode 100644 index 9a7efb083..000000000 --- a/firmware_p4/components/Applications/ui/screens/badusb/ui_badusb_connect.c +++ /dev/null @@ -1,111 +0,0 @@ -// Copyright (c) 2025 HIGH CODE LLC -// -// TentacleOS is free software: you can redistribute it and/or modify -// it under the terms of the GNU General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// TentacleOS is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU General Public License for more details. -// -// You should have received a copy of the GNU General Public License -// along with TentacleOS. If not, see . - -#include "ui_badusb_connect.h" - -#include "freertos/FreeRTOS.h" -#include "freertos/task.h" -#include "esp_log.h" - -#include "bad_usb.h" -#include "footer_ui.h" -#include "header_ui.h" -#include "lv_port_indev.h" -#include "ui_manager.h" -#include "ui_theme.h" - -static const char *TAG = "UI_BADUSB_CONNECT"; - -#define SPINNER_SIZE 50 -#define STATUS_LABEL_OFFSET_Y 50 -#define HINT_LABEL_OFFSET_Y 70 -#define WAITER_TASK_STACK 4096 -#define WAITER_TASK_PRIORITY 5 - -static lv_obj_t *s_screen_connect = NULL; -static lv_obj_t *s_spinner = NULL; -static TaskHandle_t s_waiter_task = NULL; - -static void connection_waiter_task(void *pvParameters); -static void connect_key_event_cb(lv_event_t *e); - -void ui_badusb_connect_open(void) { - if (s_screen_connect != NULL) { - lv_obj_del(s_screen_connect); - } - - s_screen_connect = lv_obj_create(NULL); - lv_obj_set_style_bg_color(s_screen_connect, current_theme.screen_base, 0); - lv_obj_remove_flag(s_screen_connect, LV_OBJ_FLAG_SCROLLABLE); - - header_ui_create(s_screen_connect); - - s_spinner = lv_spinner_create(s_screen_connect); - lv_obj_set_size(s_spinner, SPINNER_SIZE, SPINNER_SIZE); - lv_obj_center(s_spinner); - lv_obj_set_style_arc_color(s_spinner, lv_palette_main(LV_PALETTE_DEEP_PURPLE), LV_PART_INDICATOR); - - lv_obj_t *lbl_status = lv_label_create(s_screen_connect); - lv_label_set_text(lbl_status, "Waiting for USB..."); - lv_obj_set_style_text_color(lbl_status, current_theme.text_main, 0); - lv_obj_align(lbl_status, LV_ALIGN_CENTER, 0, STATUS_LABEL_OFFSET_Y); - - lv_obj_t *lbl_hint = lv_label_create(s_screen_connect); - lv_label_set_text(lbl_hint, "Connect to PC now"); - lv_obj_set_style_text_font(lbl_hint, &lv_font_montserrat_12, 0); - lv_obj_set_style_text_color(lbl_hint, current_theme.text_main, 0); - lv_obj_align(lbl_hint, LV_ALIGN_CENTER, 0, HINT_LABEL_OFFSET_Y); - - footer_ui_create(s_screen_connect); - - lv_obj_add_event_cb(s_screen_connect, connect_key_event_cb, LV_EVENT_KEY, NULL); - - if (main_group != NULL) { - lv_group_add_obj(main_group, s_screen_connect); - lv_group_focus_obj(s_screen_connect); - } - - lv_screen_load(s_screen_connect); - - xTaskCreate(connection_waiter_task, - "usb_waiter", - WAITER_TASK_STACK, - NULL, - WAITER_TASK_PRIORITY, - &s_waiter_task); -} - -static void connection_waiter_task(void *pvParameters) { - bad_usb_wait_for_connection(); - ui_switch_screen(SCREEN_BADUSB_RUNNING); - s_waiter_task = NULL; - vTaskDelete(NULL); -} - -static void connect_key_event_cb(lv_event_t *e) { - lv_event_code_t code = lv_event_get_code(e); - - if (code == LV_EVENT_KEY) { - uint32_t key = lv_event_get_key(e); - if (key == LV_KEY_ESC || key == LV_KEY_LEFT) { - if (s_waiter_task != NULL) { - vTaskDelete(s_waiter_task); - s_waiter_task = NULL; - } - bad_usb_deinit(); - ui_switch_screen(SCREEN_BADUSB_BROWSER); - } - } -} \ No newline at end of file diff --git a/firmware_p4/components/Applications/ui/screens/badusb/ui_badusb_layout.c b/firmware_p4/components/Applications/ui/screens/badusb/ui_badusb_layout.c deleted file mode 100644 index 60df26d94..000000000 --- a/firmware_p4/components/Applications/ui/screens/badusb/ui_badusb_layout.c +++ /dev/null @@ -1,100 +0,0 @@ -// Copyright (c) 2025 HIGH CODE LLC -// -// TentacleOS is free software: you can redistribute it and/or modify -// it under the terms of the GNU General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// TentacleOS is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU General Public License for more details. -// -// You should have received a copy of the GNU General Public License -// along with TentacleOS. If not, see . - -#include "ui_badusb_layout.h" - -#include "esp_log.h" - -#include "bad_usb.h" -#include "ducky_parser.h" -#include "footer_ui.h" -#include "header_ui.h" -#include "lv_port_indev.h" -#include "ui_manager.h" -#include "ui_theme.h" - -static const char *TAG = "UI_BADUSB_LAYOUT"; - -#define LAYOUT_LABEL_OFFSET_Y 40 -#define LAYOUT_LIST_WIDTH 200 -#define LAYOUT_LIST_HEIGHT 120 -#define LAYOUT_LIST_BORDER_WIDTH 2 - -static lv_obj_t *s_screen_layout = NULL; - -static void layout_key_event_cb(lv_event_t *e); - -void ui_badusb_layout_open(void) { - if (s_screen_layout != NULL) { - lv_obj_del(s_screen_layout); - } - - s_screen_layout = lv_obj_create(NULL); - lv_obj_set_style_bg_color(s_screen_layout, current_theme.screen_base, 0); - lv_obj_remove_flag(s_screen_layout, LV_OBJ_FLAG_SCROLLABLE); - - header_ui_create(s_screen_layout); - - lv_obj_t *lbl = lv_label_create(s_screen_layout); - lv_label_set_text(lbl, "Select Keyboard Layout:"); - lv_obj_set_style_text_color(lbl, current_theme.text_main, 0); - lv_obj_align(lbl, LV_ALIGN_TOP_MID, 0, LAYOUT_LABEL_OFFSET_Y); - - lv_obj_t *list = lv_list_create(s_screen_layout); - lv_obj_set_size(list, LAYOUT_LIST_WIDTH, LAYOUT_LIST_HEIGHT); - lv_obj_center(list); - lv_obj_set_style_bg_color(list, current_theme.screen_base, 0); - lv_obj_set_style_text_color(list, current_theme.text_main, 0); - lv_obj_set_style_border_color(list, lv_palette_main(LV_PALETTE_DEEP_PURPLE), 0); - lv_obj_set_style_border_width(list, LAYOUT_LIST_BORDER_WIDTH, 0); - - lv_obj_t *btn = lv_list_add_button(list, LV_SYMBOL_KEYBOARD, "US (Standard)"); - lv_obj_add_event_cb(btn, layout_key_event_cb, LV_EVENT_KEY, (void *)(intptr_t)DUCKY_LAYOUT_US); - lv_obj_set_style_bg_color(btn, current_theme.screen_base, 0); - lv_obj_set_style_text_color(btn, current_theme.text_main, 0); - - btn = lv_list_add_button(list, LV_SYMBOL_KEYBOARD, "ABNT2 (Brazilian)"); - lv_obj_add_event_cb(btn, layout_key_event_cb, LV_EVENT_KEY, (void *)(intptr_t)DUCKY_LAYOUT_ABNT2); - lv_obj_set_style_bg_color(btn, current_theme.screen_base, 0); - lv_obj_set_style_text_color(btn, current_theme.text_main, 0); - - footer_ui_create(s_screen_layout); - - lv_obj_add_event_cb(s_screen_layout, layout_key_event_cb, LV_EVENT_KEY, NULL); - - if (main_group != NULL) { - lv_group_add_obj(main_group, list); - lv_group_focus_obj(list); - } - - lv_screen_load(s_screen_layout); -} - -static void layout_key_event_cb(lv_event_t *e) { - lv_event_code_t code = lv_event_get_code(e); - ducky_layout_t layout = (ducky_layout_t)(intptr_t)lv_event_get_user_data(e); - - if (code == LV_EVENT_KEY) { - uint32_t key = lv_event_get_key(e); - if (key == LV_KEY_ENTER) { - ESP_LOGI(TAG, "Selected Layout: %d", layout); - ducky_set_layout(layout); - bad_usb_init(); - ui_switch_screen(SCREEN_BADUSB_CONNECT); - } else if (key == LV_KEY_ESC) { - ui_switch_screen(SCREEN_BADUSB_BROWSER); - } - } -} \ No newline at end of file diff --git a/firmware_p4/components/Applications/ui/screens/badusb/ui_badusb_menu.c b/firmware_p4/components/Applications/ui/screens/badusb/ui_badusb_menu.c deleted file mode 100644 index 97b95593c..000000000 --- a/firmware_p4/components/Applications/ui/screens/badusb/ui_badusb_menu.c +++ /dev/null @@ -1,120 +0,0 @@ -// Copyright (c) 2025 HIGH CODE LLC -// -// TentacleOS is free software: you can redistribute it and/or modify -// it under the terms of the GNU General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// TentacleOS is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU General Public License for more details. -// -// You should have received a copy of the GNU General Public License -// along with TentacleOS. If not, see . - -#include "ui_badusb_menu.h" - -#include "esp_log.h" - -#include "buttons_gpio.h" -#include "lv_port_indev.h" -#include "menu_component_ui.h" -#include "ui_manager.h" -#include "ui_theme.h" - -static const char *TAG = "UI_BADUSB_MENU"; - -#define NAV_TIMER_INTERVAL_MS 50 - -typedef struct { - const char *name; - const char *icon; - int target; -} ui_badusb_menu_item_t; - -static const ui_badusb_menu_item_t MENU_ITEMS[] = { - {"Internal Memory", NULL, SCREEN_BADUSB_BROWSER}, - {"Micro-SD", NULL, SCREEN_BADUSB_BROWSER}, -}; -#define MENU_ITEMS_COUNT (sizeof(MENU_ITEMS) / sizeof(MENU_ITEMS[0])) - -static lv_obj_t *s_screen = NULL; -static menu_component_t s_menu; -static lv_timer_t *s_nav_timer = NULL; -static bool s_btn_up_last = false; -static bool s_btn_down_last = false; -static bool s_btn_left_last = false; -static bool s_btn_right_last = false; -static bool s_btn_ok_last = false; -static bool s_btn_back_last = false; - -static void nav_timer_cb(lv_timer_t *t); - -void ui_badusb_menu_open(void) { - if (s_screen != NULL) { - lv_obj_del(s_screen); - s_screen = NULL; - } - - s_screen = lv_obj_create(NULL); - lv_obj_set_style_bg_color(s_screen, current_theme.screen_base, 0); - lv_obj_set_style_bg_opa(s_screen, LV_OPA_COVER, 0); - lv_obj_remove_flag(s_screen, LV_OBJ_FLAG_SCROLLABLE); - - s_menu = menu_component_create(s_screen, "BAD USB", NULL); - for (int i = 0; i < (int)MENU_ITEMS_COUNT; i++) { - menu_component_add_item(&s_menu, MENU_ITEMS[i].icon, MENU_ITEMS[i].name); - } - - if (s_nav_timer == NULL) { - s_nav_timer = lv_timer_create(nav_timer_cb, NAV_TIMER_INTERVAL_MS, NULL); - } - - lv_screen_load(s_screen); -} - -static void nav_timer_cb(lv_timer_t *t) { - if (lv_screen_active() != s_screen) { - lv_timer_delete(t); - s_nav_timer = NULL; - return; - } - - if (ui_input_is_locked()) { - return; - } - - bool up = up_button_is_down(); - bool down = down_button_is_down(); - bool left = left_button_is_down(); - bool right = right_button_is_down(); - bool ok = ok_button_is_down(); - bool back = back_button_is_down(); - - if (down && !s_btn_down_last) { - menu_component_next(&s_menu); - } - - if (up && !s_btn_up_last) { - menu_component_prev(&s_menu); - } - - if ((back && !s_btn_back_last) || (left && !s_btn_left_last)) { - ui_switch_screen(SCREEN_MENU); - } - - if ((ok && !s_btn_ok_last) || (right && !s_btn_right_last)) { - int sel = menu_component_get_selected(&s_menu); - if (sel >= 0 && sel < (int)MENU_ITEMS_COUNT) { - ui_switch_screen(MENU_ITEMS[sel].target); - } - } - - s_btn_up_last = up; - s_btn_down_last = down; - s_btn_left_last = left; - s_btn_right_last = right; - s_btn_ok_last = ok; - s_btn_back_last = back; -} \ No newline at end of file diff --git a/firmware_p4/components/Applications/ui/screens/badusb/ui_badusb_running.c b/firmware_p4/components/Applications/ui/screens/badusb/ui_badusb_running.c deleted file mode 100644 index 24788500b..000000000 --- a/firmware_p4/components/Applications/ui/screens/badusb/ui_badusb_running.c +++ /dev/null @@ -1,161 +0,0 @@ -// Copyright (c) 2025 HIGH CODE LLC -// -// TentacleOS is free software: you can redistribute it and/or modify -// it under the terms of the GNU General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// TentacleOS is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU General Public License for more details. -// -// You should have received a copy of the GNU General Public License -// along with TentacleOS. If not, see . - -#include "ui_badusb_running.h" - -#include - -#include "freertos/FreeRTOS.h" -#include "freertos/task.h" -#include "esp_log.h" - -#include "bad_usb.h" -#include "ducky_parser.h" -#include "footer_ui.h" -#include "header_ui.h" -#include "lv_port_indev.h" -#include "ui_manager.h" -#include "ui_theme.h" - -static const char *TAG = "UI_BADUSB_RUNNING"; - -#define SCRIPT_NAME_MAX_LEN 64 -#define SCRIPT_FULL_PATH_MAX_LEN 128 -#define SCRIPT_DISPLAY_NAME_MAX_LEN 56 -#define SCRIPT_PATH_PREFIX "storage/bad_usb_scripts/" -#define SCRIPT_TASK_STACK_SIZE 4096 -#define SCRIPT_TASK_PRIORITY 5 -#define TITLE_LABEL_OFFSET_Y (-40) -#define INFO_LABEL_OFFSET_Y 40 -#define PROGRESS_BAR_WIDTH 200 -#define PROGRESS_BAR_HEIGHT 20 -#define PROGRESS_BAR_BORDER_WIDTH 1 -#define PROGRESS_PERCENT_MAX 100 - -static lv_obj_t *s_screen_running = NULL; -static lv_obj_t *s_progress_bar = NULL; -static TaskHandle_t s_script_task_handle = NULL; -static char s_script_name[SCRIPT_NAME_MAX_LEN] = "rickroll.txt"; - -static void ducky_progress_cb(int current_line, int total_lines); -static void script_runner_task(void *pvParameters); -static void running_key_event_cb(lv_event_t *e); - -void ui_badusb_running_set_script(const char *name) { - if (name != NULL) { - strncpy(s_script_name, name, sizeof(s_script_name) - 1); - s_script_name[sizeof(s_script_name) - 1] = '\0'; - } -} - -void ui_badusb_running_open(void) { - if (s_screen_running != NULL) { - lv_obj_del(s_screen_running); - } - - s_screen_running = lv_obj_create(NULL); - lv_obj_set_style_bg_color(s_screen_running, current_theme.screen_base, 0); - lv_obj_remove_flag(s_screen_running, LV_OBJ_FLAG_SCROLLABLE); - - header_ui_create(s_screen_running); - footer_ui_create(s_screen_running); - - char display_name[SCRIPT_DISPLAY_NAME_MAX_LEN]; - char *dot = strrchr(s_script_name, '.'); - if (dot != NULL) { - size_t len = dot - s_script_name; - if (len > sizeof(display_name) - 1) { - len = sizeof(display_name) - 1; - } - strncpy(display_name, s_script_name, len); - display_name[len] = '\0'; - } else { - strncpy(display_name, s_script_name, sizeof(display_name) - 1); - display_name[sizeof(display_name) - 1] = '\0'; - } - - lv_obj_t *lbl_title = lv_label_create(s_screen_running); - lv_label_set_text_fmt(lbl_title, "Running: %s", display_name); - lv_obj_align(lbl_title, LV_ALIGN_CENTER, 0, TITLE_LABEL_OFFSET_Y); - - s_progress_bar = lv_bar_create(s_screen_running); - lv_obj_set_size(s_progress_bar, PROGRESS_BAR_WIDTH, PROGRESS_BAR_HEIGHT); - lv_obj_center(s_progress_bar); - lv_bar_set_value(s_progress_bar, 0, LV_ANIM_OFF); - lv_obj_set_style_radius(s_progress_bar, 0, LV_PART_MAIN); - lv_obj_set_style_radius(s_progress_bar, 0, LV_PART_INDICATOR); - lv_obj_set_style_border_width(s_progress_bar, PROGRESS_BAR_BORDER_WIDTH, LV_PART_MAIN); - lv_obj_set_style_border_color( - s_progress_bar, lv_palette_main(LV_PALETTE_DEEP_PURPLE), LV_PART_MAIN); - lv_obj_set_style_bg_color( - s_progress_bar, lv_palette_main(LV_PALETTE_DEEP_PURPLE), LV_PART_INDICATOR); - - lv_obj_t *lbl_info = lv_label_create(s_screen_running); - lv_label_set_text(lbl_info, "Press BACK to cancel"); - lv_obj_align(lbl_info, LV_ALIGN_CENTER, 0, INFO_LABEL_OFFSET_Y); - - lv_obj_add_event_cb(s_screen_running, running_key_event_cb, LV_EVENT_KEY, NULL); - - if (main_group != NULL) { - lv_group_add_obj(main_group, s_screen_running); - lv_group_focus_obj(s_screen_running); - } - - lv_screen_load(s_screen_running); - - xTaskCreate(script_runner_task, - "script_runner", - SCRIPT_TASK_STACK_SIZE, - NULL, - SCRIPT_TASK_PRIORITY, - &s_script_task_handle); -} - -static void ducky_progress_cb(int current_line, int total_lines) { - if (s_progress_bar != NULL && ui_acquire()) { - int progress = (current_line * PROGRESS_PERCENT_MAX) / total_lines; - lv_bar_set_value(s_progress_bar, progress, LV_ANIM_OFF); - ui_release(); - } -} - -static void script_runner_task(void *pvParameters) { - ESP_LOGI(TAG, "Starting script: %s", s_script_name); - - char full_path[SCRIPT_FULL_PATH_MAX_LEN]; - snprintf(full_path, sizeof(full_path), "%s%s", SCRIPT_PATH_PREFIX, s_script_name); - - ducky_set_progress_callback(ducky_progress_cb); - ducky_run_from_assets(full_path); - ducky_set_progress_callback(NULL); - - bad_usb_deinit(); - ui_switch_screen(SCREEN_BADUSB_BROWSER); - s_script_task_handle = NULL; - vTaskDelete(NULL); -} - -static void running_key_event_cb(lv_event_t *e) { - lv_event_code_t code = lv_event_get_code(e); - - if (code == LV_EVENT_KEY) { - uint32_t key = lv_event_get_key(e); - if (key == LV_KEY_ESC) { - if (s_script_task_handle != NULL) { - ducky_abort(); - } - } - } -} \ No newline at end of file diff --git a/firmware_p4/components/Applications/ui/screens/badusb/usb_mouse_ui.c b/firmware_p4/components/Applications/ui/screens/badusb/usb_mouse_ui.c new file mode 100644 index 000000000..e88a7e520 --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/badusb/usb_mouse_ui.c @@ -0,0 +1,354 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#include "usb_mouse_ui.h" + +#include "lvgl.h" + +#include "ui_chrome.h" +#include "ui_feedback.h" +#include "ui_manager.h" +#include "ui_theme.h" + +#define MOVE_TICK_MS 50 +#define JIGGLE_TICK_MS 70 +#define JIGGLE_STEP_DEG 18 +#define JIGGLE_RADIUS 16 + +#define HDR_TITLE "HID MOUSE" +#define HDR_ICON "/assets/icons/mouse.bin" +#define FOOTER_TXT "MOVE pad - L/R click - SEL jiggle" + +#define MX 8 +#define CONTENT_W (240 - 2 * MX) + +#define COL_DIM 0x8A8594 +#define COL_SUCCESS 0x00E676 +#define COL_ACC2 0xB89AFF + +#define STATUS_Y 48 +#define STATUS_H 18 + +#define PAD_Y 71 +#define PAD_W (CONTENT_W - RAIL_W - ROW_GAP) +#define PAD_H 148 +#define RAIL_W 26 +#define ROW_GAP 6 + +#define CLICK_Y 226 +#define CLICK_H 34 +#define CLICK_W ((CONTENT_W - ROW_GAP) / 2) + +#define CUR_W 16 +#define CUR_H 22 +#define CUR_MIN 4 +#define CUR_MAX_X (PAD_W - CUR_W - 6) +#define CUR_MAX_Y (PAD_H - CUR_H - 6) +#define MOVE_STEP 12 +#define CUR_START_X 112 +#define CUR_START_Y 66 + +#define JIGGLE_ON "JIGGLE ON" +#define JIGGLE_OFF "JIGGLE OFF" + +static const lv_point_precise_t CURSOR_PTS[] = { + {1, 1}, {1, 20}, {6, 15}, {10, 22}, {13, 21}, {8, 13}, {16, 13}, {1, 1}}; +#define CURSOR_PT_COUNT ((int)(sizeof(CURSOR_PTS) / sizeof(CURSOR_PTS[0]))) + +static lv_obj_t *s_screen = NULL; +static lv_timer_t *s_move_timer = NULL; +static lv_timer_t *s_jiggle_timer = NULL; + +static lv_obj_t *s_cursor = NULL; +static lv_obj_t *s_chip = NULL; +static lv_obj_t *s_chip_lbl = NULL; + +static int s_cur_x = CUR_START_X; +static int s_cur_y = CUR_START_Y; +static int s_jig_ang = 0; +static bool s_jiggle = true; + +static int clamp(int v, int lo, int hi) { + if (v < lo) + return lo; + if (v > hi) + return hi; + return v; +} + +static void update_cursor(void) { + s_cur_x = clamp(s_cur_x, CUR_MIN, CUR_MAX_X); + s_cur_y = clamp(s_cur_y, CUR_MIN, CUR_MAX_Y); + if (s_cursor) + lv_obj_set_pos(s_cursor, s_cur_x, s_cur_y); +} + +static void refresh_chip(void) { + if (s_chip_lbl) + lv_label_set_text(s_chip_lbl, s_jiggle ? JIGGLE_ON : JIGGLE_OFF); + if (s_chip) { + lv_obj_set_style_bg_opa(s_chip, s_jiggle ? LV_OPA_COVER : LV_OPA_30, 0); + } + if (s_chip_lbl) + lv_obj_set_style_text_color( + s_chip_lbl, s_jiggle ? current_theme.screen_base : lv_color_hex(COL_DIM), 0); +} + +static void build_status(void) { + lv_obj_t *dot = lv_obj_create(s_screen); + lv_obj_remove_flag(dot, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(dot, 7, 7); + lv_obj_align(dot, LV_ALIGN_TOP_LEFT, MX, STATUS_Y + 5); + lv_obj_set_style_radius(dot, LV_RADIUS_CIRCLE, 0); + lv_obj_set_style_border_width(dot, 0, 0); + lv_obj_set_style_bg_color(dot, lv_color_hex(COL_SUCCESS), 0); + lv_obj_set_style_bg_opa(dot, LV_OPA_COVER, 0); + + lv_obj_t *lbl = lv_label_create(s_screen); + lv_label_set_text(lbl, "HID MOUSE - ACTIVE"); + lv_obj_set_style_text_font(lbl, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(lbl, current_theme.text_main, 0); + lv_obj_align(lbl, LV_ALIGN_TOP_LEFT, MX + 13, STATUS_Y + 1); + + s_chip = lv_obj_create(s_screen); + lv_obj_remove_flag(s_chip, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(s_chip, 72, STATUS_H); + lv_obj_align(s_chip, LV_ALIGN_TOP_RIGHT, -MX, STATUS_Y - 1); + lv_obj_set_style_radius(s_chip, 6, 0); + lv_obj_set_style_pad_all(s_chip, 0, 0); + lv_obj_set_style_bg_color(s_chip, current_theme.border_accent, 0); + lv_obj_set_style_border_width(s_chip, 0, 0); + + s_chip_lbl = lv_label_create(s_chip); + lv_obj_set_style_text_font(s_chip_lbl, &lv_font_montserrat_12, 0); + lv_obj_center(s_chip_lbl); + refresh_chip(); +} + +static void build_trackpad(void) { + lv_obj_t *pad = lv_obj_create(s_screen); + lv_obj_remove_flag(pad, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(pad, PAD_W, PAD_H); + lv_obj_align(pad, LV_ALIGN_TOP_LEFT, MX, PAD_Y); + lv_obj_set_style_radius(pad, 10, 0); + lv_obj_set_style_pad_all(pad, 0, 0); + lv_obj_set_style_bg_color(pad, current_theme.bg_primary, 0); + lv_obj_set_style_bg_opa(pad, LV_OPA_COVER, 0); + lv_obj_set_style_border_color(pad, current_theme.border_accent, 0); + lv_obj_set_style_border_width(pad, 1, 0); + lv_obj_set_style_border_opa(pad, LV_OPA_70, 0); + + lv_obj_t *vline = lv_obj_create(pad); + lv_obj_remove_flag(vline, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(vline, 1, PAD_H - 24); + lv_obj_align(vline, LV_ALIGN_CENTER, 0, 0); + lv_obj_set_style_border_width(vline, 0, 0); + lv_obj_set_style_bg_color(vline, current_theme.border_accent, 0); + lv_obj_set_style_bg_opa(vline, LV_OPA_20, 0); + + lv_obj_t *hline = lv_obj_create(pad); + lv_obj_remove_flag(hline, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(hline, PAD_W - 24, 1); + lv_obj_align(hline, LV_ALIGN_CENTER, 0, 0); + lv_obj_set_style_border_width(hline, 0, 0); + lv_obj_set_style_bg_color(hline, current_theme.border_accent, 0); + lv_obj_set_style_bg_opa(hline, LV_OPA_20, 0); + + s_cursor = lv_line_create(pad); + lv_line_set_points(s_cursor, CURSOR_PTS, CURSOR_PT_COUNT); + lv_obj_set_style_line_width(s_cursor, 2, 0); + lv_obj_set_style_line_color(s_cursor, lv_color_hex(COL_ACC2), 0); + lv_obj_set_style_line_rounded(s_cursor, false, 0); + update_cursor(); + + lv_obj_t *tag = lv_label_create(pad); + lv_label_set_text(tag, "trackpad"); + lv_obj_set_style_text_font(tag, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(tag, lv_color_hex(COL_DIM), 0); + lv_obj_align(tag, LV_ALIGN_BOTTOM_LEFT, 6, -4); +} + +static void build_rail(void) { + lv_obj_t *rail = lv_obj_create(s_screen); + lv_obj_remove_flag(rail, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(rail, RAIL_W, PAD_H); + lv_obj_align(rail, LV_ALIGN_TOP_RIGHT, -MX, PAD_Y); + lv_obj_set_style_radius(rail, 8, 0); + lv_obj_set_style_pad_ver(rail, 8, 0); + lv_obj_set_style_pad_hor(rail, 0, 0); + lv_obj_set_style_bg_color(rail, current_theme.bg_primary, 0); + lv_obj_set_style_bg_opa(rail, LV_OPA_COVER, 0); + lv_obj_set_style_border_color(rail, lv_color_hex(COL_DIM), 0); + lv_obj_set_style_border_opa(rail, LV_OPA_40, 0); + lv_obj_set_style_border_width(rail, 1, 0); + lv_obj_set_flex_flow(rail, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align( + rail, LV_FLEX_ALIGN_SPACE_BETWEEN, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + + lv_obj_t *up = lv_label_create(rail); + lv_label_set_text(up, LV_SYMBOL_UP); + lv_obj_set_style_text_font(up, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(up, current_theme.border_accent, 0); + + lv_obj_t *scr = lv_label_create(rail); + lv_label_set_text(scr, "SCR"); + lv_obj_set_style_text_font(scr, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(scr, lv_color_hex(COL_DIM), 0); + + lv_obj_t *down = lv_label_create(rail); + lv_label_set_text(down, LV_SYMBOL_DOWN); + lv_obj_set_style_text_font(down, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(down, current_theme.border_accent, 0); +} + +static void build_click(int x, const char *text, bool selected) { + lv_obj_t *btn = lv_obj_create(s_screen); + lv_obj_remove_flag(btn, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(btn, CLICK_W, CLICK_H); + lv_obj_align(btn, LV_ALIGN_TOP_LEFT, x, CLICK_Y); + lv_obj_set_style_radius(btn, 9, 0); + lv_obj_set_style_pad_all(btn, 0, 0); + lv_obj_set_style_bg_color(btn, current_theme.bg_secondary, 0); + lv_obj_set_style_bg_opa(btn, LV_OPA_COVER, 0); + lv_obj_set_style_border_color( + btn, selected ? current_theme.border_accent : current_theme.border_inactive, 0); + lv_obj_set_style_border_opa(btn, selected ? LV_OPA_COVER : LV_OPA_40, 0); + lv_obj_set_style_border_width(btn, 2, 0); + if (selected) { + lv_obj_set_style_shadow_width(btn, 12, 0); + lv_obj_set_style_shadow_color(btn, current_theme.border_accent, 0); + lv_obj_set_style_shadow_spread(btn, -3, 0); + } + + lv_obj_t *lbl = lv_label_create(btn); + lv_label_set_text(lbl, text); + lv_obj_set_style_text_font(lbl, &lv_font_montserrat_14, 0); + lv_obj_set_style_text_color( + lbl, selected ? current_theme.border_accent : lv_color_hex(COL_DIM), 0); + lv_obj_center(lbl); +} + +static void jiggle_tick_cb(lv_timer_t *t) { + if (lv_screen_active() != s_screen) { + lv_timer_delete(t); + s_jiggle_timer = NULL; + return; + } + if (!s_jiggle) + return; + s_jig_ang = (s_jig_ang + JIGGLE_STEP_DEG) % 360; + int cx = (CUR_MIN + CUR_MAX_X) / 2; + int cy = (CUR_MIN + CUR_MAX_Y) / 2; + s_cur_x = cx + (JIGGLE_RADIUS * lv_trigo_sin((int16_t)s_jig_ang)) / 32767; + s_cur_y = cy + (JIGGLE_RADIUS * lv_trigo_cos((int16_t)s_jig_ang)) / 32767; + update_cursor(); +} + +static void usb_mouse_input(const input_event_t *ev, void *ctx) { + (void)ctx; + const bool press = (ev->action == INPUT_ACTION_PRESS); + + switch (ev->button) { + case INPUT_BTN_BACK: + if (press) + ui_switch_screen(SCREEN_BADUSB_MENU); + break; + case INPUT_BTN_OK: + if (press) { + s_jiggle = !s_jiggle; + refresh_chip(); + ui_feedback(UI_FB_SELECT); + } + break; + default: + break; + } +} + +static void move_tick_cb(lv_timer_t *t) { + if (lv_screen_active() != s_screen) { + lv_timer_delete(t); + s_move_timer = NULL; + return; + } + if (ui_input_is_locked()) + return; + if (s_jiggle) + return; + + bool moved = false; + if (input_is_down(INPUT_BTN_UP)) { + s_cur_y -= MOVE_STEP; + moved = true; + } + if (input_is_down(INPUT_BTN_DOWN)) { + s_cur_y += MOVE_STEP; + moved = true; + } + if (input_is_down(INPUT_BTN_LEFT)) { + s_cur_x -= MOVE_STEP; + moved = true; + } + if (input_is_down(INPUT_BTN_RIGHT)) { + s_cur_x += MOVE_STEP; + moved = true; + } + if (moved) { + update_cursor(); + ui_feedback(UI_FB_NAV); + } +} + +void ui_usb_mouse_open(void) { + if (s_jiggle_timer != NULL) { + lv_timer_delete(s_jiggle_timer); + s_jiggle_timer = NULL; + } + if (s_screen != NULL) { + lv_obj_del(s_screen); + s_screen = NULL; + } + s_cursor = NULL; + s_chip = NULL; + s_chip_lbl = NULL; + s_cur_x = CUR_START_X; + s_cur_y = CUR_START_Y; + s_jig_ang = 0; + s_jiggle = true; + + s_screen = lv_obj_create(NULL); + lv_obj_set_style_bg_color(s_screen, current_theme.screen_base, 0); + lv_obj_set_style_bg_opa(s_screen, LV_OPA_COVER, 0); + lv_obj_remove_flag(s_screen, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_border_width(s_screen, 0, 0); + lv_obj_set_style_pad_all(s_screen, 0, 0); + + ui_chrome_header(s_screen, HDR_TITLE, HDR_ICON); + + build_status(); + build_trackpad(); + build_rail(); + build_click(MX, "L CLICK", true); + build_click(MX + CLICK_W + ROW_GAP, "R CLICK", false); + + ui_chrome_footer(s_screen, FOOTER_TXT); + + s_jiggle_timer = lv_timer_create(jiggle_tick_cb, JIGGLE_TICK_MS, NULL); + if (s_move_timer == NULL) + s_move_timer = lv_timer_create(move_tick_cb, MOVE_TICK_MS, NULL); + ui_input_set_screen_handler(usb_mouse_input, NULL); + + ui_screen_load_owned(&s_screen, s_screen); +} diff --git a/firmware_p4/components/Applications/ui/screens/battery_settings/battery_settings_ui.c b/firmware_p4/components/Applications/ui/screens/battery_settings/battery_settings_ui.c deleted file mode 100644 index 1f65cac93..000000000 --- a/firmware_p4/components/Applications/ui/screens/battery_settings/battery_settings_ui.c +++ /dev/null @@ -1,144 +0,0 @@ -// Copyright (c) 2025 HIGH CODE LLC -// -// TentacleOS is free software: you can redistribute it and/or modify -// it under the terms of the GNU General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// TentacleOS is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU General Public License for more details. -// -// You should have received a copy of the GNU General Public License -// along with TentacleOS. If not, see . - -#include "battery_settings_ui.h" - -#include "esp_log.h" - -#include "buttons_gpio.h" -#include "lv_port_indev.h" -#include "menu_component_ui.h" -#include "ui_manager.h" -#include "ui_theme.h" - -static const char *TAG = "BATTERY_SETTINGS_UI"; - -#define NAV_TIMER_INTERVAL_MS 50 - -#define IDX_PWR_SAVE 0 -#define IDX_TIMEOUT 1 -#define IDX_MODE 2 - -static const char *TIMEOUT_OPTIONS[] = {"30s", "1m", "5m", "NEVER"}; -#define TIMEOUT_OPTIONS_COUNT (sizeof(TIMEOUT_OPTIONS) / sizeof(TIMEOUT_OPTIONS[0])) - -static const char *PERF_OPTIONS[] = {"MIN", "BAL", "MAX"}; -#define PERF_OPTIONS_COUNT (sizeof(PERF_OPTIONS) / sizeof(PERF_OPTIONS[0])) - -static lv_obj_t *s_screen_battery = NULL; -static menu_component_t s_menu; -static lv_timer_t *s_nav_timer = NULL; -static bool s_is_power_save = false; -static int s_timeout_idx = 1; -static int s_perf_idx = 1; -static bool s_btn_up_last = false; -static bool s_btn_down_last = false; -static bool s_btn_left_last = false; -static bool s_btn_right_last = false; -static bool s_btn_ok_last = false; -static bool s_btn_back_last = false; - -static void nav_timer_cb(lv_timer_t *t); - -void ui_battery_settings_open(void) { - if (s_screen_battery != NULL) { - lv_obj_del(s_screen_battery); - s_screen_battery = NULL; - } - - s_screen_battery = lv_obj_create(NULL); - lv_obj_set_style_bg_color(s_screen_battery, current_theme.screen_base, 0); - lv_obj_set_style_bg_opa(s_screen_battery, LV_OPA_COVER, 0); - lv_obj_remove_flag(s_screen_battery, LV_OBJ_FLAG_SCROLLABLE); - - s_menu = menu_component_create(s_screen_battery, "BATTERY", NULL); - - menu_component_add_toggle( - &s_menu, "/assets/icons/battery_menu_icon.bin", "PWR SAVE", s_is_power_save); - menu_component_add_selector(&s_menu, NULL, "TIMEOUT", TIMEOUT_OPTIONS[s_timeout_idx]); - menu_component_add_selector(&s_menu, NULL, "MODE", PERF_OPTIONS[s_perf_idx]); - - if (s_nav_timer == NULL) { - s_nav_timer = lv_timer_create(nav_timer_cb, NAV_TIMER_INTERVAL_MS, NULL); - } - - lv_screen_load(s_screen_battery); -} - -static void nav_timer_cb(lv_timer_t *t) { - if (lv_screen_active() != s_screen_battery) { - lv_timer_delete(t); - s_nav_timer = NULL; - return; - } - - if (ui_input_is_locked()) { - return; - } - - bool up = up_button_is_down(); - bool down = down_button_is_down(); - bool left = left_button_is_down(); - bool right = right_button_is_down(); - bool ok = ok_button_is_down(); - bool back = back_button_is_down(); - - int sel = menu_component_get_selected(&s_menu); - - if (down && !s_btn_down_last) { - menu_component_next(&s_menu); - } - - if (up && !s_btn_up_last) { - menu_component_prev(&s_menu); - } - - if (back && !s_btn_back_last) { - ui_switch_screen(SCREEN_SETTINGS); - } - - if (right && !s_btn_right_last) { - if (sel == IDX_PWR_SAVE) { - menu_component_toggle_item(&s_menu, IDX_PWR_SAVE); - s_is_power_save = menu_component_get_toggle(&s_menu, IDX_PWR_SAVE); - } else if (sel == IDX_TIMEOUT) { - s_timeout_idx = (s_timeout_idx + 1) % (int)TIMEOUT_OPTIONS_COUNT; - menu_component_set_selector_value(&s_menu, IDX_TIMEOUT, TIMEOUT_OPTIONS[s_timeout_idx]); - } else if (sel == IDX_MODE) { - s_perf_idx = (s_perf_idx + 1) % (int)PERF_OPTIONS_COUNT; - menu_component_set_selector_value(&s_menu, IDX_MODE, PERF_OPTIONS[s_perf_idx]); - } - } - - if (left && !s_btn_left_last) { - if (sel == IDX_PWR_SAVE) { - menu_component_toggle_item(&s_menu, IDX_PWR_SAVE); - s_is_power_save = menu_component_get_toggle(&s_menu, IDX_PWR_SAVE); - } else if (sel == IDX_TIMEOUT) { - s_timeout_idx = (s_timeout_idx - 1 + (int)TIMEOUT_OPTIONS_COUNT) % (int)TIMEOUT_OPTIONS_COUNT; - menu_component_set_selector_value(&s_menu, IDX_TIMEOUT, TIMEOUT_OPTIONS[s_timeout_idx]); - } else if (sel == IDX_MODE) { - s_perf_idx = (s_perf_idx - 1 + (int)PERF_OPTIONS_COUNT) % (int)PERF_OPTIONS_COUNT; - menu_component_set_selector_value(&s_menu, IDX_MODE, PERF_OPTIONS[s_perf_idx]); - } - } - - s_btn_up_last = up; - s_btn_down_last = down; - s_btn_left_last = left; - s_btn_right_last = right; - s_btn_ok_last = ok; - s_btn_back_last = back; -} \ No newline at end of file diff --git a/firmware_p4/components/Applications/ui/screens/bluetooth/ble_beacon_ui.c b/firmware_p4/components/Applications/ui/screens/bluetooth/ble_beacon_ui.c new file mode 100644 index 000000000..fe21279c3 --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/bluetooth/ble_beacon_ui.c @@ -0,0 +1,219 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#include "ble_beacon_ui.h" + +#include "lvgl.h" +#include "st7789.h" + +#include "notify_ui.h" +#include "ui_chrome.h" +#include "ui_feedback.h" +#include "ui_manager.h" +#include "ui_theme.h" + +#define SPAM_TICK_MS 120 +#define BEACON_CYCLE_MS 400 + +#define BODY_W 240 +#define BODY_H 256 +#define CARD_W 160 +#define CARD_H 54 + +#define COL_DIM 0x8A8594 + +typedef struct { + const char *kind; + const char *uuid; +} beacon_t; + +static const beacon_t BEACONS[] = { + {"iBeacon", "e2c56db5-dffb-...-cc0215"}, + {"Eddystone URL", "https://high.co/xY9"}, + {"Eddystone UID", "ed0102 03040506 07a1"}, + {"AltBeacon", "4f8a1b2c-9d21-...-8e04"}, + {"iBeacon", "b9407f30-f5f8-...-2415a1"}, + {"Eddystone UID", "aa0b3c 5566778899 c4d2"}, +}; +#define BEACONS_COUNT (sizeof(BEACONS) / sizeof(BEACONS[0])) + +static lv_obj_t *s_screen = NULL; +static lv_timer_t *s_spam_timer = NULL; +static lv_timer_t *s_cycle_timer = NULL; + +static lv_obj_t *s_count_label = NULL; +static lv_obj_t *s_uuid_label = NULL; + +static int s_beacons = 0; +static int s_beacon_idx = 0; + +static void beacon_input(const input_event_t *ev, void *ctx); +static void spam_tick_cb(lv_timer_t *timer); +static void cycle_tick_cb(lv_timer_t *timer); + +static void fade_in(lv_obj_t *obj, uint32_t ms) { + if (obj != NULL) + lv_obj_fade_in(obj, ms, 0); +} + +static void run_stop_timers(void) { + if (s_spam_timer != NULL) { + lv_timer_delete(s_spam_timer); + s_spam_timer = NULL; + } + if (s_cycle_timer != NULL) { + lv_timer_delete(s_cycle_timer); + s_cycle_timer = NULL; + } +} + +static lv_obj_t *lit_card(lv_obj_t *parent, int w, int h) { + lv_obj_t *card = lv_obj_create(parent); + lv_obj_set_size(card, w, h); + lv_obj_remove_flag(card, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_radius(card, 13, 0); + lv_obj_set_style_bg_color(card, current_theme.bg_secondary, 0); + lv_obj_set_style_bg_opa(card, LV_OPA_COVER, 0); + lv_obj_set_style_bg_grad_dir(card, LV_GRAD_DIR_NONE, 0); + lv_obj_set_style_border_width(card, 1, 0); + lv_obj_set_style_border_color(card, current_theme.border_accent, 0); + lv_obj_set_style_border_opa(card, LV_OPA_COVER, 0); + lv_obj_set_style_pad_all(card, 0, 0); + lv_obj_set_style_shadow_width(card, 18, 0); + lv_obj_set_style_shadow_color(card, current_theme.border_accent, 0); + lv_obj_set_style_shadow_opa(card, LV_OPA_40, 0); + lv_obj_set_style_shadow_spread(card, -4, 0); + return card; +} + +void ui_beacon_spam_open(void) { + if (s_screen != NULL) { + lv_obj_del(s_screen); + s_screen = NULL; + } + s_beacons = 0; + s_beacon_idx = 0; + s_spam_timer = NULL; + s_cycle_timer = NULL; + + s_screen = lv_obj_create(NULL); + lv_obj_set_style_bg_color(s_screen, current_theme.screen_base, 0); + lv_obj_set_style_bg_opa(s_screen, LV_OPA_COVER, 0); + lv_obj_remove_flag(s_screen, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_border_width(s_screen, 0, 0); + lv_obj_set_style_pad_all(s_screen, 0, 0); + + lv_obj_t *header = ui_chrome_header(s_screen, "BEACON SPAM", "/assets/icons/sensors.bin"); + ui_chrome_footer(s_screen, "BACK Stop"); + + lv_obj_t *body = lv_obj_create(s_screen); + lv_obj_set_size(body, BODY_W, BODY_H); + lv_obj_align(body, LV_ALIGN_TOP_LEFT, 0, UI_CHROME_HEADER_H); + lv_obj_remove_flag(body, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_bg_opa(body, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(body, 0, 0); + lv_obj_set_style_radius(body, 0, 0); + lv_obj_set_style_pad_all(body, 10, 0); + + lv_obj_t *status = lv_label_create(body); + lv_label_set_text(status, "Broadcasting..."); + lv_obj_set_style_text_color(status, current_theme.text_main, 0); + lv_obj_set_style_text_font(status, &lv_font_montserrat_14, 0); + lv_obj_set_style_text_align(status, LV_TEXT_ALIGN_CENTER, 0); + lv_obj_align(status, LV_ALIGN_TOP_MID, 0, 54); + + lv_obj_t *type = lv_label_create(body); + lv_label_set_text(type, "Type: Mixed"); + lv_obj_set_style_text_color(type, lv_color_hex(COL_DIM), 0); + lv_obj_set_style_text_font(type, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_align(type, LV_TEXT_ALIGN_CENTER, 0); + lv_obj_align(type, LV_ALIGN_TOP_MID, 0, 78); + + lv_obj_t *card = lit_card(body, CARD_W, CARD_H); + lv_obj_align(card, LV_ALIGN_TOP_MID, 0, 100); + + s_count_label = lv_label_create(card); + lv_label_set_text(s_count_label, "Beacons: 0"); + lv_obj_set_style_text_color(s_count_label, current_theme.border_accent, 0); + lv_obj_set_style_text_font(s_count_label, &lv_font_montserrat_16, 0); + lv_obj_set_style_text_align(s_count_label, LV_TEXT_ALIGN_CENTER, 0); + lv_obj_center(s_count_label); + + s_uuid_label = lv_label_create(body); + lv_label_set_text_fmt(s_uuid_label, "%s %s", BEACONS[0].kind, BEACONS[0].uuid); + lv_obj_set_style_text_color(s_uuid_label, lv_color_hex(COL_DIM), 0); + lv_obj_set_style_text_font(s_uuid_label, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_align(s_uuid_label, LV_TEXT_ALIGN_CENTER, 0); + lv_obj_align(s_uuid_label, LV_ALIGN_TOP_MID, 0, 166); + + fade_in(header, 200); + fade_in(status, 200); + fade_in(type, 240); + fade_in(card, 260); + fade_in(s_uuid_label, 300); + + ui_input_set_screen_handler(beacon_input, NULL); + s_spam_timer = lv_timer_create(spam_tick_cb, SPAM_TICK_MS, NULL); + s_cycle_timer = lv_timer_create(cycle_tick_cb, BEACON_CYCLE_MS, NULL); + + ui_feedback(UI_FB_EMULATE); + notify(NOTIFY_INFO, "Beacon spam started"); + + ui_screen_load_owned(&s_screen, s_screen); +} + +static void spam_tick_cb(lv_timer_t *timer) { + if (lv_screen_active() != s_screen) { + lv_timer_delete(timer); + if (s_spam_timer == timer) + s_spam_timer = NULL; + return; + } + + s_beacons++; + if (s_count_label != NULL) + lv_label_set_text_fmt(s_count_label, "Beacons: %d", s_beacons); +} + +static void cycle_tick_cb(lv_timer_t *timer) { + if (lv_screen_active() != s_screen) { + lv_timer_delete(timer); + if (s_cycle_timer == timer) + s_cycle_timer = NULL; + return; + } + + s_beacon_idx = (s_beacon_idx + 1) % (int)BEACONS_COUNT; + if (s_uuid_label != NULL) + lv_label_set_text_fmt( + s_uuid_label, "%s %s", BEACONS[s_beacon_idx].kind, BEACONS[s_beacon_idx].uuid); +} + +static void beacon_input(const input_event_t *ev, void *ctx) { + (void)ctx; + const bool press = (ev->action == INPUT_ACTION_PRESS); + + switch (ev->button) { + case INPUT_BTN_BACK: + case INPUT_BTN_LEFT: + if (press) { + run_stop_timers(); + ui_switch_screen(SCREEN_BLE_MENU); + } + break; + default: + break; + } +} diff --git a/firmware_p4/components/Applications/ui/screens/bluetooth/ble_companion_ui.c b/firmware_p4/components/Applications/ui/screens/bluetooth/ble_companion_ui.c new file mode 100644 index 000000000..4f34daa43 --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/bluetooth/ble_companion_ui.c @@ -0,0 +1,268 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#include "ble_companion_ui.h" + +#include "esp_log.h" +#include "lvgl.h" +#include "st7789.h" + +#include "host_link_ble.h" +#include "host_link_sec.h" +#include "ui_chrome.h" +#include "ui_feedback.h" +#include "ui_manager.h" +#include "ui_theme.h" +#include "waves_ui.h" + +static const char *TAG = "BLE_COMPANION_UI"; + +#define CONN_POLL_MS 500 +#define COMPANION_ICON "/assets/icons/app_shortcut.bin" + +#define SIG_GREEN 0x00E676 +#define COL_DIM 0x8A8594 + +static lv_obj_t *s_screen = NULL; +static lv_obj_t *s_body = NULL; +static lv_obj_t *s_footer = NULL; +static lv_timer_t *s_phase_timer = NULL; +static bool s_is_linked = false; + +static void companion_input(const input_event_t *ev, void *ctx); +static void phase_timer_cb(lv_timer_t *timer); +static void show_success(void); + +static void fade_in(lv_obj_t *obj, uint32_t ms) { + if (obj != NULL) + lv_obj_fade_in(obj, ms, 0); +} + +static lv_obj_t *lit_panel(lv_obj_t *parent, int w, int h, int radius, int glow) { + lv_obj_t *p = lv_obj_create(parent); + lv_obj_remove_flag(p, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(p, w, h); + lv_obj_set_style_radius(p, radius, 0); + lv_obj_set_style_bg_color(p, current_theme.bg_secondary, 0); + lv_obj_set_style_bg_opa(p, LV_OPA_COVER, 0); + lv_obj_set_style_bg_grad_dir(p, LV_GRAD_DIR_NONE, 0); + lv_obj_set_style_border_width(p, 1, 0); + lv_obj_set_style_border_color(p, current_theme.border_accent, 0); + lv_obj_set_style_shadow_color(p, current_theme.border_accent, 0); + lv_obj_set_style_shadow_width(p, glow, 0); + lv_obj_set_style_shadow_opa(p, LV_OPA_40, 0); + lv_obj_set_style_shadow_spread(p, -4, 0); + return p; +} + +static void pop_size_cb(void *var, int32_t v) { + lv_obj_set_size((lv_obj_t *)var, v, v); + lv_obj_center((lv_obj_t *)var); +} +static void pop_opa_cb(void *var, int32_t v) { + lv_obj_set_style_opa((lv_obj_t *)var, (lv_opa_t)v, 0); +} +static void pop_in(lv_obj_t *obj, int target_px, uint32_t ms) { + lv_anim_t a; + lv_anim_init(&a); + lv_anim_set_var(&a, obj); + lv_anim_set_duration(&a, ms); + lv_anim_set_path_cb(&a, lv_anim_path_overshoot); + lv_anim_set_exec_cb(&a, pop_size_cb); + lv_anim_set_values(&a, 0, target_px); + lv_anim_start(&a); + + lv_anim_set_path_cb(&a, lv_anim_path_linear); + lv_anim_set_exec_cb(&a, pop_opa_cb); + lv_anim_set_values(&a, 0, LV_OPA_COVER); + lv_anim_start(&a); +} + +static lv_obj_t *make_body(lv_obj_t *parent) { + lv_obj_t *b = lv_obj_create(parent); + lv_obj_remove_flag(b, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(b, LCD_H_RES, LCD_V_RES - UI_CHROME_HEADER_H - UI_CHROME_FOOTER_H); + lv_obj_align(b, LV_ALIGN_TOP_LEFT, 0, UI_CHROME_HEADER_H); + lv_obj_set_style_bg_opa(b, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(b, 0, 0); + lv_obj_set_style_pad_all(b, 0, 0); + return b; +} + +static void build_pairing(void) { + waves_create(s_body, LV_ALIGN_CENTER, 0, -34, LV_SYMBOL_BLUETOOTH, NULL); + + lv_obj_t *status = lv_label_create(s_body); + lv_label_set_text(status, "Waiting for app..."); + lv_obj_set_style_text_color(status, current_theme.text_main, 0); + lv_obj_set_style_text_font(status, &lv_font_montserrat_14, 0); + lv_obj_set_style_text_align(status, LV_TEXT_ALIGN_CENTER, 0); + lv_obj_align(status, LV_ALIGN_CENTER, 0, 50); + + lv_obj_t *caption = lv_label_create(s_body); + lv_label_set_text(caption, "Pairing key (PSK):"); + lv_obj_set_style_text_color(caption, lv_color_hex(COL_DIM), 0); + lv_obj_set_style_text_font(caption, &lv_font_montserrat_12, 0); + lv_obj_align(caption, LV_ALIGN_CENTER, 0, 78); + + lv_obj_t *card = lit_panel(s_body, 224, 56, 13, 16); + lv_obj_set_style_pad_all(card, 6, 0); + lv_obj_align(card, LV_ALIGN_CENTER, 0, 116); + + lv_obj_t *code = lv_label_create(card); + char psk[HOST_LINK_PSK_HEX_SIZE]; + if (host_link_sec_get_psk_hex(psk, sizeof(psk)) == ESP_OK) + lv_label_set_text(code, psk); + else + lv_label_set_text(code, "PSK unavailable"); + lv_label_set_long_mode(code, LV_LABEL_LONG_WRAP); + lv_obj_set_width(code, 224 - 12); + lv_obj_set_style_text_color(code, current_theme.border_accent, 0); + lv_obj_set_style_text_font(code, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_align(code, LV_TEXT_ALIGN_CENTER, 0); + lv_obj_center(code); + + fade_in(status, 200); + fade_in(card, 240); +} + +static void show_success(void) { + lv_obj_clean(s_body); + + waves_create(s_body, LV_ALIGN_CENTER, 0, -34, LV_SYMBOL_OK, NULL); + + lv_obj_t *seal_slot = lv_obj_create(s_body); + lv_obj_remove_flag(seal_slot, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(seal_slot, 36, 36); + lv_obj_set_style_bg_opa(seal_slot, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(seal_slot, 0, 0); + lv_obj_set_style_pad_all(seal_slot, 0, 0); + lv_obj_align(seal_slot, LV_ALIGN_CENTER, 0, -34); + + lv_obj_t *seal = lv_obj_create(seal_slot); + lv_obj_remove_flag(seal, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_radius(seal, LV_RADIUS_CIRCLE, 0); + lv_obj_set_style_bg_color(seal, lv_color_hex(SIG_GREEN), 0); + lv_obj_set_style_bg_opa(seal, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(seal, 0, 0); + lv_obj_set_style_pad_all(seal, 0, 0); + lv_obj_set_size(seal, 0, 0); + lv_obj_center(seal); + + lv_obj_t *seal_glyph = lv_label_create(seal); + lv_label_set_text(seal_glyph, LV_SYMBOL_OK); + lv_obj_set_style_text_color(seal_glyph, current_theme.text_main, 0); + lv_obj_set_style_text_font(seal_glyph, &lv_font_montserrat_14, 0); + lv_obj_center(seal_glyph); + + lv_obj_t *status = lv_label_create(s_body); + lv_label_set_text(status, "Companion linked!"); + lv_obj_set_style_text_color(status, lv_color_hex(SIG_GREEN), 0); + lv_obj_set_style_text_font(status, &lv_font_montserrat_14, 0); + lv_obj_set_style_text_align(status, LV_TEXT_ALIGN_CENTER, 0); + lv_obj_align(status, LV_ALIGN_CENTER, 0, 42); + + lv_obj_t *badge = lit_panel(s_body, 200, 66, 14, 18); + lv_obj_align(badge, LV_ALIGN_CENTER, 0, 92); + lv_obj_set_style_pad_all(badge, 10, 0); + lv_obj_set_flex_flow(badge, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(badge, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_set_style_pad_row(badge, 4, 0); + + lv_obj_t *name = lv_label_create(badge); + lv_label_set_text(name, "HighBoy-Companion"); + lv_obj_set_style_text_color(name, current_theme.text_main, 0); + lv_obj_set_style_text_font(name, &lv_font_montserrat_14, 0); + + lv_obj_t *ver = lv_label_create(badge); + lv_label_set_text(ver, "v1.2 - Companion v1.0"); + lv_obj_set_style_text_color(ver, lv_color_hex(COL_DIM), 0); + lv_obj_set_style_text_font(ver, &lv_font_montserrat_12, 0); + + pop_in(seal, 30, 360); + fade_in(status, 240); + fade_in(badge, 300); + + ui_feedback(UI_FB_READ); + ui_chrome_footer_set_text(s_footer, "BACK Exit"); +} + +void ui_companion_pairing_open(void) { + if (s_screen != NULL) { + lv_obj_del(s_screen); + s_screen = NULL; + } + + s_screen = lv_obj_create(NULL); + lv_obj_set_style_bg_color(s_screen, current_theme.screen_base, 0); + lv_obj_set_style_bg_opa(s_screen, LV_OPA_COVER, 0); + lv_obj_remove_flag(s_screen, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_border_width(s_screen, 0, 0); + lv_obj_set_style_pad_all(s_screen, 0, 0); + + ui_chrome_header(s_screen, "COMPANION", COMPANION_ICON); + s_footer = ui_chrome_footer(s_screen, "BACK Cancel"); + + s_body = make_body(s_screen); + s_is_linked = false; + build_pairing(); + + ui_input_set_screen_handler(companion_input, NULL); + + if (host_link_ble_init() == ESP_OK) + host_link_ble_start(); + else + ESP_LOGW(TAG, "host_link_ble_init failed; USB companion still works"); + + s_phase_timer = lv_timer_create(phase_timer_cb, CONN_POLL_MS, NULL); + + ui_screen_load_owned(&s_screen, s_screen); +} + +static void phase_timer_cb(lv_timer_t *timer) { + if (lv_screen_active() != s_screen) { + lv_timer_delete(timer); + if (s_phase_timer == timer) + s_phase_timer = NULL; + return; + } + if (!s_is_linked && host_link_sec_is_authenticated()) { + s_is_linked = true; + lv_timer_delete(timer); + s_phase_timer = NULL; + show_success(); + } +} + +static void companion_input(const input_event_t *ev, void *ctx) { + (void)ctx; + const bool press = (ev->action == INPUT_ACTION_PRESS); + + switch (ev->button) { + case INPUT_BTN_BACK: + if (press) { + if (s_phase_timer != NULL) { + lv_timer_delete(s_phase_timer); + s_phase_timer = NULL; + } + if (!s_is_linked) + host_link_ble_stop(); + ui_switch_screen(SCREEN_BLE_MENU); + } + break; + default: + break; + } +} diff --git a/firmware_p4/components/Applications/ui/screens/bluetooth/ble_detect_ui.c b/firmware_p4/components/Applications/ui/screens/bluetooth/ble_detect_ui.c new file mode 100644 index 000000000..eb6b3daea --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/bluetooth/ble_detect_ui.c @@ -0,0 +1,102 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#include "ble_detect_ui.h" + +#include "esp_log.h" + +#include "lv_port_indev.h" +#include "menu_component_ui.h" +#include "ui_manager.h" +#include "ui_theme.h" + +static const char *TAG = "UI_BLE_DETECT"; + +typedef struct { + const char *name; + const char *icon; + int target; +} ui_ble_detect_item_t; + +static const ui_ble_detect_item_t MENU_ITEMS[] = { + {"Scan Devices", "/assets/icons/bluetooth_searching.bin", SCREEN_BLE_SCAN}, + {"Sniffer", "/assets/icons/monitoring.bin", SCREEN_BLE_SNIFFER}, + {"Tracker Detector", "/assets/icons/troubleshoot.bin", SCREEN_BLE_TRACKER}, + {"Skimmer Detector", "/assets/icons/warning.bin", SCREEN_BLE_SKIMMER}, + {"Exposure Beacons", "/assets/icons/broadcast_on_personal.bin", SCREEN_BLE_EXPOSURE}, + {"GATT Explorer", "/assets/icons/hub.bin", SCREEN_GATT_EXPLORER}, + {"Track Device", "/assets/icons/sensors.bin", SCREEN_BLE_TRACK_DEVICE}, +}; +#define MENU_ITEMS_COUNT (sizeof(MENU_ITEMS) / sizeof(MENU_ITEMS[0])) + +static lv_obj_t *s_screen = NULL; +static menu_component_t s_menu; + +static void ble_detect_input(const input_event_t *ev, void *ctx); + +void ui_ble_detect_open(void) { + if (s_screen != NULL) { + lv_obj_del(s_screen); + s_screen = NULL; + } + + s_screen = lv_obj_create(NULL); + lv_obj_set_style_bg_color(s_screen, current_theme.screen_base, 0); + lv_obj_set_style_bg_opa(s_screen, LV_OPA_COVER, 0); + lv_obj_remove_flag(s_screen, LV_OBJ_FLAG_SCROLLABLE); + + s_menu = menu_component_create(s_screen, "DETECT", "/assets/icons/bluetooth_searching.bin"); + for (int i = 0; i < (int)MENU_ITEMS_COUNT; i++) { + menu_component_add_item(&s_menu, MENU_ITEMS[i].icon, MENU_ITEMS[i].name); + } + + ui_input_set_screen_handler(ble_detect_input, NULL); + + ui_screen_load_owned(&s_screen, s_screen); + ESP_LOGI(TAG, "BLE detect menu opened"); +} + +static void ble_detect_input(const input_event_t *ev, void *ctx) { + (void)ctx; + const bool press = (ev->action == INPUT_ACTION_PRESS); + const bool nav = press || (ev->action == INPUT_ACTION_REPEAT); + + switch (ev->button) { + case INPUT_BTN_BACK: + case INPUT_BTN_LEFT: + if (press) + ui_switch_screen(SCREEN_BLE_MENU); + break; + case INPUT_BTN_OK: + case INPUT_BTN_RIGHT: + if (press) { + int sel = menu_component_get_selected(&s_menu); + if (sel >= 0 && sel < (int)MENU_ITEMS_COUNT && MENU_ITEMS[sel].target >= 0) { + ui_switch_screen(MENU_ITEMS[sel].target); + } + } + break; + case INPUT_BTN_DOWN: + if (nav) + menu_component_next(&s_menu); + break; + case INPUT_BTN_UP: + if (nav) + menu_component_prev(&s_menu); + break; + default: + break; + } +} diff --git a/firmware_p4/components/Applications/ui/screens/bluetooth/ble_exposure_ui.c b/firmware_p4/components/Applications/ui/screens/bluetooth/ble_exposure_ui.c new file mode 100644 index 000000000..4a71dbb3e --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/bluetooth/ble_exposure_ui.c @@ -0,0 +1,201 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#include "ble_exposure_ui.h" + +#include + +#include "esp_log.h" +#include "lvgl.h" + +#include "exposure_notification.h" +#include "notify_ui.h" +#include "ui_chrome.h" +#include "ui_feedback.h" +#include "ui_manager.h" +#include "ui_theme.h" + +static const char *TAG = "BLE_EXPOSURE_UI"; + +#define FEED_TICK_MS 700 + +#define MAX_ADV 8 +#define MAC_LEN 18 +#define ROW_LEN 36 +#define LIST_BUF_LEN (MAX_ADV * ROW_LEN) + +#define COL_DIM 0x8A8594 +#define COL_SUCCESS 0x00E676 + +#define EXPO_ICON "/assets/icons/broadcast_on_personal.bin" + +#define COUNT_CARD_W 190 +#define COUNT_CARD_H 34 +#define COUNT_CARD_Y 50 +#define LIST_PANEL_W 224 +#define LIST_PANEL_H 190 +#define LIST_PANEL_Y 94 +#define LIST_PAD 8 + +static lv_obj_t *s_screen = NULL; +static lv_timer_t *s_feed_timer = NULL; +static lv_obj_t *s_count_label = NULL; +static lv_obj_t *s_list_label = NULL; + +static void exposure_input(const input_event_t *ev, void *ctx); +static void feed_tick_cb(lv_timer_t *timer); + +static void stop_scanning(void) { + if (s_feed_timer != NULL) { + lv_timer_delete(s_feed_timer); + s_feed_timer = NULL; + } + exposure_notification_stop(); +} + +static void render_list(void) { + uint16_t count = 0; + exposure_notification_device_t *list = exposure_notification_get_list(&count); + + if (s_list_label != NULL) { + char buf[LIST_BUF_LEN]; + size_t pos = 0; + bool first = true; + for (uint16_t i = 0; i < count && i < MAX_ADV && pos < sizeof(buf) && list != NULL; i++) { + int m = snprintf(buf + pos, + sizeof(buf) - pos, + first ? "%02X:%02X:%02X:%02X:%02X:%02X %d" + : "\n%02X:%02X:%02X:%02X:%02X:%02X %d", + list[i].addr[5], + list[i].addr[4], + list[i].addr[3], + list[i].addr[2], + list[i].addr[1], + list[i].addr[0], + list[i].rssi); + if (m < 0) + break; + pos += (size_t)m; + first = false; + } + if (first) + snprintf(buf, sizeof(buf), "No advertisers"); + lv_label_set_text(s_list_label, buf); + } + + if (s_count_label != NULL) + lv_label_set_text_fmt(s_count_label, "Advertisers: %u", (unsigned)count); +} + +void ui_ble_exposure_open(void) { + if (s_screen != NULL) { + lv_obj_del(s_screen); + s_screen = NULL; + } + s_feed_timer = NULL; + s_count_label = NULL; + s_list_label = NULL; + + s_screen = lv_obj_create(NULL); + lv_obj_set_style_bg_color(s_screen, current_theme.screen_base, 0); + lv_obj_set_style_bg_opa(s_screen, LV_OPA_COVER, 0); + lv_obj_remove_flag(s_screen, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_border_width(s_screen, 0, 0); + lv_obj_set_style_pad_all(s_screen, 0, 0); + + ui_chrome_header(s_screen, "EXPOSURE", EXPO_ICON); + ui_chrome_footer(s_screen, "BACK Back"); + + lv_obj_t *card = lv_obj_create(s_screen); + lv_obj_set_size(card, COUNT_CARD_W, COUNT_CARD_H); + lv_obj_align(card, LV_ALIGN_TOP_MID, 0, COUNT_CARD_Y); + lv_obj_remove_flag(card, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_radius(card, 13, 0); + lv_obj_set_style_bg_color(card, current_theme.bg_secondary, 0); + lv_obj_set_style_bg_opa(card, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(card, 1, 0); + lv_obj_set_style_border_color(card, current_theme.border_accent, 0); + lv_obj_set_style_border_opa(card, LV_OPA_COVER, 0); + lv_obj_set_style_pad_all(card, 0, 0); + lv_obj_set_style_shadow_width(card, 18, 0); + lv_obj_set_style_shadow_color(card, current_theme.border_accent, 0); + lv_obj_set_style_shadow_opa(card, LV_OPA_40, 0); + lv_obj_set_style_shadow_spread(card, -4, 0); + + s_count_label = lv_label_create(card); + lv_label_set_text(s_count_label, "Advertisers: 0"); + lv_obj_set_style_text_color(s_count_label, current_theme.border_accent, 0); + lv_obj_set_style_text_font(s_count_label, &lv_font_montserrat_16, 0); + lv_obj_center(s_count_label); + + lv_obj_t *panel = lv_obj_create(s_screen); + lv_obj_set_size(panel, LIST_PANEL_W, LIST_PANEL_H); + lv_obj_align(panel, LV_ALIGN_TOP_MID, 0, LIST_PANEL_Y); + lv_obj_remove_flag(panel, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_radius(panel, 10, 0); + lv_obj_set_style_bg_color(panel, current_theme.bg_secondary, 0); + lv_obj_set_style_bg_opa(panel, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(panel, 1, 0); + lv_obj_set_style_border_color(panel, current_theme.border_accent, 0); + lv_obj_set_style_border_opa(panel, LV_OPA_40, 0); + lv_obj_set_style_pad_all(panel, LIST_PAD, 0); + + s_list_label = lv_label_create(panel); + lv_obj_set_style_text_color(s_list_label, current_theme.text_main, 0); + lv_obj_set_style_text_font(s_list_label, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_align(s_list_label, LV_TEXT_ALIGN_LEFT, 0); + lv_obj_align(s_list_label, LV_ALIGN_TOP_LEFT, 0, 0); + + exposure_notification_reset(); + if (exposure_notification_start() != ESP_OK) { + lv_label_set_text(s_list_label, "Radio unavailable"); + notify(NOTIFY_WARNING, "Radio unavailable"); + ESP_LOGE(TAG, "exposure_notification_start failed"); + } else { + s_feed_timer = lv_timer_create(feed_tick_cb, FEED_TICK_MS, NULL); + } + render_list(); + + ui_input_set_screen_handler(exposure_input, NULL); + + ui_feedback(UI_FB_SELECT); + ui_screen_load_owned(&s_screen, s_screen); + ESP_LOGI(TAG, "BLE exposure screen opened"); +} + +static void feed_tick_cb(lv_timer_t *timer) { + if (lv_screen_active() != s_screen) { + stop_scanning(); + return; + } + render_list(); +} + +static void exposure_input(const input_event_t *ev, void *ctx) { + (void)ctx; + const bool press = (ev->action == INPUT_ACTION_PRESS); + + switch (ev->button) { + case INPUT_BTN_BACK: + case INPUT_BTN_LEFT: + if (press) { + stop_scanning(); + ui_switch_screen(SCREEN_BLE_DETECT_MENU); + } + break; + default: + break; + } +} diff --git a/firmware_p4/components/Applications/ui/screens/bluetooth/ble_flood_ui.c b/firmware_p4/components/Applications/ui/screens/bluetooth/ble_flood_ui.c new file mode 100644 index 000000000..14e094895 --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/bluetooth/ble_flood_ui.c @@ -0,0 +1,306 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#include "ble_flood_ui.h" + +#include +#include + +#include "esp_log.h" +#include "esp_timer.h" +#include "lvgl.h" +#include "st7789.h" + +#include "ble_connect_flood.h" +#include "ble_l2cap_flood.h" +#include "ble_scanner.h" +#include "notify_ui.h" +#include "ui_chrome.h" +#include "ui_feedback.h" +#include "ui_manager.h" +#include "ui_theme.h" + +static const char *TAG = "BLE_FLOOD_UI"; + +#define SCAN_POLL_MS 200 +#define SCAN_TIMEOUT_MS 15000 +#define FLOOD_TICK_MS 500 + +#define FLOOD_ICON "/assets/icons/broadcast_on_personal.bin" + +#define BLE_ADDR_LEN 6 + +#define COL_DIM 0x8A8594 + +#define CARD_W 172 +#define CARD_H 54 + +#define MODE_CONNECT 0 +#define MODE_L2CAP 1 + +static const char *const FLOOD_MODES[] = { + "Connect flood", + "L2CAP flood", +}; +#define FLOOD_MODES_COUNT ((int)(sizeof(FLOOD_MODES) / sizeof(FLOOD_MODES[0]))) + +typedef enum { PHASE_SCAN, PHASE_FLOOD, PHASE_NOTGT } flood_phase_t; + +static lv_obj_t *s_screen = NULL; +static lv_obj_t *s_status_label = NULL; +static lv_obj_t *s_target_label = NULL; +static lv_obj_t *s_mode_label = NULL; +static lv_obj_t *s_count_label = NULL; +static lv_obj_t *s_rate_label = NULL; +static lv_timer_t *s_flood_timer = NULL; + +static flood_phase_t s_phase = PHASE_SCAN; +static uint32_t s_scan_waited = 0; +static uint8_t s_target_addr[BLE_ADDR_LEN]; +static uint8_t s_target_type = 0; +static char s_target[40]; +static int s_mode = 0; +static int64_t s_flood_start_us = 0; + +static void ble_flood_input(const input_event_t *ev, void *ctx); +static void flood_tick_cb(lv_timer_t *timer); + +static void fade_in(lv_obj_t *obj, uint32_t ms) { + if (obj != NULL) + lv_obj_fade_in(obj, ms, 0); +} + +static void flood_mode_stop(void) { + if (s_mode == MODE_L2CAP) + ble_l2cap_flood_stop(); + else + ble_connect_flood_stop(); +} + +static esp_err_t flood_mode_start(void) { + return (s_mode == MODE_L2CAP) ? ble_l2cap_flood_start(s_target_addr, s_target_type) + : ble_connect_flood_start(s_target_addr, s_target_type); +} + +static void stop_all(void) { + if (s_flood_timer != NULL) { + lv_timer_delete(s_flood_timer); + s_flood_timer = NULL; + } + if (s_phase == PHASE_FLOOD) + flood_mode_stop(); +} + +static lv_obj_t *lit_card(lv_obj_t *parent, int w, int h) { + lv_obj_t *card = lv_obj_create(parent); + lv_obj_remove_flag(card, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(card, w, h); + lv_obj_set_style_radius(card, 13, 0); + lv_obj_set_style_bg_color(card, current_theme.bg_secondary, 0); + lv_obj_set_style_bg_opa(card, LV_OPA_COVER, 0); + lv_obj_set_style_bg_grad_dir(card, LV_GRAD_DIR_NONE, 0); + lv_obj_set_style_border_width(card, 1, 0); + lv_obj_set_style_border_color(card, current_theme.border_accent, 0); + lv_obj_set_style_shadow_color(card, current_theme.border_accent, 0); + lv_obj_set_style_shadow_width(card, 20, 0); + lv_obj_set_style_shadow_opa(card, LV_OPA_50, 0); + lv_obj_set_style_shadow_spread(card, -4, 0); + lv_obj_set_style_pad_all(card, 0, 0); + return card; +} + +static void set_mode_text(void) { + if (s_mode_label != NULL) + lv_label_set_text_fmt( + s_mode_label, LV_SYMBOL_LEFT " %s " LV_SYMBOL_RIGHT, FLOOD_MODES[s_mode]); +} + +void ui_ble_flood_open(void) { + if (s_screen != NULL) { + lv_obj_del(s_screen); + s_screen = NULL; + } + s_mode = 0; + s_phase = PHASE_SCAN; + s_scan_waited = 0; + s_flood_timer = NULL; + s_target[0] = '\0'; + + s_screen = lv_obj_create(NULL); + lv_obj_set_style_bg_color(s_screen, current_theme.screen_base, 0); + lv_obj_set_style_bg_opa(s_screen, LV_OPA_COVER, 0); + lv_obj_remove_flag(s_screen, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_border_width(s_screen, 0, 0); + lv_obj_set_style_pad_all(s_screen, 0, 0); + + ui_chrome_header(s_screen, "BLE FLOOD", FLOOD_ICON); + ui_chrome_footer(s_screen, "L/R Mode BACK Stop"); + + lv_obj_t *body = lv_obj_create(s_screen); + lv_obj_remove_flag(body, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(body, LCD_H_RES, LCD_V_RES - UI_CHROME_HEADER_H - UI_CHROME_FOOTER_H); + lv_obj_align(body, LV_ALIGN_TOP_LEFT, 0, UI_CHROME_HEADER_H); + lv_obj_set_style_bg_opa(body, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(body, 0, 0); + lv_obj_set_style_pad_all(body, 0, 0); + + s_status_label = lv_label_create(body); + lv_label_set_text(s_status_label, "Scanning for target..."); + lv_obj_set_style_text_color(s_status_label, current_theme.text_main, 0); + lv_obj_set_style_text_font(s_status_label, &lv_font_montserrat_14, 0); + lv_obj_align(s_status_label, LV_ALIGN_TOP_MID, 0, 12); + + s_target_label = lv_label_create(body); + lv_label_set_text(s_target_label, ""); + lv_obj_set_style_text_color(s_target_label, lv_color_hex(COL_DIM), 0); + lv_obj_set_style_text_font(s_target_label, &lv_font_montserrat_12, 0); + lv_obj_align(s_target_label, LV_ALIGN_TOP_MID, 0, 38); + + s_mode_label = lv_label_create(body); + lv_obj_set_style_text_color(s_mode_label, current_theme.border_accent, 0); + lv_obj_set_style_text_font(s_mode_label, &lv_font_montserrat_14, 0); + lv_obj_align(s_mode_label, LV_ALIGN_TOP_MID, 0, 72); + set_mode_text(); + + lv_obj_t *card = lit_card(body, CARD_W, CARD_H); + lv_obj_align(card, LV_ALIGN_TOP_MID, 0, 108); + + s_count_label = lv_label_create(card); + lv_label_set_text(s_count_label, "--:--"); + lv_obj_set_style_text_color(s_count_label, current_theme.border_accent, 0); + lv_obj_set_style_text_font(s_count_label, &lv_font_montserrat_16, 0); + lv_obj_center(s_count_label); + + s_rate_label = lv_label_create(body); + lv_label_set_text(s_rate_label, ""); + lv_obj_set_style_text_color(s_rate_label, lv_color_hex(COL_DIM), 0); + lv_obj_set_style_text_font(s_rate_label, &lv_font_montserrat_12, 0); + lv_obj_align(s_rate_label, LV_ALIGN_TOP_MID, 0, 178); + + fade_in(s_status_label, 200); + fade_in(s_mode_label, 280); + fade_in(card, 320); + + if (!ble_scanner_start()) { + s_phase = PHASE_NOTGT; + lv_label_set_text(s_status_label, "Radio unavailable"); + ESP_LOGE(TAG, "ble_scanner_start failed"); + } else { + s_flood_timer = lv_timer_create(flood_tick_cb, SCAN_POLL_MS, NULL); + } + + ui_input_set_screen_handler(ble_flood_input, NULL); + + ui_feedback(UI_FB_EMULATE); + ui_screen_load_owned(&s_screen, s_screen); +} + +static bool select_target(void) { + uint16_t n = 0; + bluetooth_service_scan_result_t *res = ble_scanner_get_results(&n); + if (res == NULL || n == 0) + return false; + int best = 0; + for (uint16_t i = 1; i < n; i++) + if (res[i].rssi > res[best].rssi) + best = i; + memcpy(s_target_addr, res[best].addr, BLE_ADDR_LEN); + s_target_type = res[best].addr_type; + snprintf(s_target, + sizeof(s_target), + "%.16s %02X:%02X:%02X:%02X:%02X:%02X", + (res[best].name[0] != '\0') ? res[best].name : "(unknown)", + res[best].addr[5], + res[best].addr[4], + res[best].addr[3], + res[best].addr[2], + res[best].addr[1], + res[best].addr[0]); + return true; +} + +static void begin_flood(void) { + esp_err_t err = flood_mode_start(); + if (err != ESP_OK) { + lv_label_set_text(s_status_label, "Flood failed"); + ESP_LOGE(TAG, "flood start (mode %d) failed: %s", s_mode, esp_err_to_name(err)); + return; + } + s_flood_start_us = esp_timer_get_time(); + lv_label_set_text(s_status_label, "Flooding..."); + notify(NOTIFY_INFO, "Flood started"); +} + +static void flood_tick_cb(lv_timer_t *timer) { + if (lv_screen_active() != s_screen) { + stop_all(); + return; + } + + if (s_phase == PHASE_SCAN) { + uint16_t dummy = 0; + s_scan_waited += SCAN_POLL_MS; + if (ble_scanner_get_results(&dummy) == NULL && s_scan_waited < SCAN_TIMEOUT_MS) + return; + + bool ok = select_target(); + ble_scanner_free_results(); + if (!ok) { + s_phase = PHASE_NOTGT; + lv_label_set_text(s_status_label, "No device to flood"); + return; + } + s_phase = PHASE_FLOOD; + lv_label_set_text(s_target_label, s_target); + lv_timer_set_period(timer, FLOOD_TICK_MS); + begin_flood(); + return; + } + + if (s_phase == PHASE_FLOOD && s_count_label != NULL) { + int secs = (int)((esp_timer_get_time() - s_flood_start_us) / 1000000); + lv_label_set_text_fmt(s_count_label, "%02d:%02d", secs / 60, secs % 60); + } +} + +static void ble_flood_input(const input_event_t *ev, void *ctx) { + (void)ctx; + const bool press = (ev->action == INPUT_ACTION_PRESS); + const bool nav = press || (ev->action == INPUT_ACTION_REPEAT); + + switch (ev->button) { + case INPUT_BTN_BACK: + if (press) { + stop_all(); + ui_switch_screen(SCREEN_BLE_MENU); + } + break; + case INPUT_BTN_RIGHT: + case INPUT_BTN_LEFT: + if (nav && s_phase == PHASE_FLOOD) { + flood_mode_stop(); + s_mode = (ev->button == INPUT_BTN_RIGHT) + ? (s_mode + 1) % FLOOD_MODES_COUNT + : (s_mode - 1 + FLOOD_MODES_COUNT) % FLOOD_MODES_COUNT; + set_mode_text(); + fade_in(s_mode_label, 160); + begin_flood(); + ui_feedback(UI_FB_NAV); + } + break; + default: + break; + } +} diff --git a/firmware_p4/components/Applications/ui/screens/bluetooth/ble_keyboard_ui.c b/firmware_p4/components/Applications/ui/screens/bluetooth/ble_keyboard_ui.c new file mode 100644 index 000000000..03ffa2214 --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/bluetooth/ble_keyboard_ui.c @@ -0,0 +1,357 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#include "ble_keyboard_ui.h" + +#include + +#include "esp_log.h" +#include "lvgl.h" +#include "st7789.h" + +#include "ble_hid_keyboard.h" +#include "notify_ui.h" +#include "ui_chrome.h" +#include "ui_feedback.h" +#include "ui_manager.h" +#include "ui_theme.h" +#include "waves_ui.h" + +static const char *TAG = "BLE_KEYBOARD_UI"; + +#define CONN_POLL_MS 400 +#define CURSOR_BLINK_MS 500 + +#define HID_KEY_ENTER 0x28 +#define HID_KEY_SPACE 0x2C +#define HID_MOD_SHIFT 0x02 +#define HID_MOD_GUI 0x08 + +#define KB_ICON "/assets/icons/keyboard.bin" + +#define SIG_GREEN 0x00E676 +#define COL_DIM 0x8A8594 + +#define CONSOLE_W 212 +#define CONSOLE_H 150 +#define TYPED_MAX 160 + +static const char CANNED_TEXT[] = "notepad.exe\nHello from TentacleOS!\n"; + +static lv_obj_t *s_screen = NULL; +static lv_obj_t *s_body = NULL; +static lv_obj_t *s_footer = NULL; +static lv_obj_t *s_term_label = NULL; +static lv_timer_t *s_phase_timer = NULL; +static lv_timer_t *s_cursor_timer = NULL; + +static char s_typed[TYPED_MAX]; +static int s_type_pos = 0; +static bool s_connected = false; +static bool s_cursor_on = true; + +static void keyboard_input(const input_event_t *ev, void *ctx); +static void phase_timer_cb(lv_timer_t *timer); +static void cursor_timer_cb(lv_timer_t *timer); +static void build_console(void); + +static void fade_in(lv_obj_t *obj, uint32_t ms) { + if (obj != NULL) + lv_obj_fade_in(obj, ms, 0); +} + +static lv_obj_t *lit_panel(lv_obj_t *parent, int w, int h) { + lv_obj_t *p = lv_obj_create(parent); + lv_obj_remove_flag(p, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(p, w, h); + lv_obj_set_style_radius(p, 10, 0); + lv_obj_set_style_bg_color(p, current_theme.bg_secondary, 0); + lv_obj_set_style_bg_opa(p, LV_OPA_COVER, 0); + lv_obj_set_style_bg_grad_dir(p, LV_GRAD_DIR_NONE, 0); + lv_obj_set_style_border_width(p, 1, 0); + lv_obj_set_style_border_color(p, current_theme.border_accent, 0); + lv_obj_set_style_shadow_color(p, current_theme.border_accent, 0); + lv_obj_set_style_shadow_width(p, 18, 0); + lv_obj_set_style_shadow_opa(p, LV_OPA_40, 0); + lv_obj_set_style_shadow_spread(p, -4, 0); + lv_obj_set_style_pad_all(p, 8, 0); + return p; +} + +static lv_obj_t *make_body(lv_obj_t *parent) { + lv_obj_t *b = lv_obj_create(parent); + lv_obj_remove_flag(b, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(b, LCD_H_RES, LCD_V_RES - UI_CHROME_HEADER_H - UI_CHROME_FOOTER_H); + lv_obj_align(b, LV_ALIGN_TOP_LEFT, 0, UI_CHROME_HEADER_H); + lv_obj_set_style_bg_opa(b, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(b, 0, 0); + lv_obj_set_style_pad_all(b, 0, 0); + return b; +} + +static void build_pairing(void) { + waves_create(s_body, LV_ALIGN_CENTER, 0, -34, NULL, KB_ICON); + + lv_obj_t *status = lv_label_create(s_body); + lv_label_set_text(status, "Pairing as HID keyboard..."); + lv_obj_set_style_text_color(status, current_theme.text_main, 0); + lv_obj_set_style_text_font(status, &lv_font_montserrat_14, 0); + lv_obj_set_style_text_align(status, LV_TEXT_ALIGN_CENTER, 0); + lv_obj_align(status, LV_ALIGN_CENTER, 0, 58); + + lv_obj_t *hint = lv_label_create(s_body); + lv_label_set_text(hint, "Accept on Target-PC"); + lv_obj_set_style_text_color(hint, lv_color_hex(COL_DIM), 0); + lv_obj_set_style_text_font(hint, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_align(hint, LV_TEXT_ALIGN_CENTER, 0); + lv_obj_align(hint, LV_ALIGN_CENTER, 0, 82); + + fade_in(status, 220); + fade_in(hint, 260); +} + +static void update_terminal(void) { + if (s_term_label == NULL) + return; + char buf[TYPED_MAX + 2]; + snprintf(buf, sizeof(buf), "%s%s", s_typed, s_cursor_on ? "_" : " "); + lv_label_set_text(s_term_label, buf); +} + +static void build_console(void) { + lv_obj_clean(s_body); + s_term_label = NULL; + s_type_pos = 0; + s_cursor_on = true; + s_typed[0] = '\0'; + s_connected = true; + + lv_obj_t *status = lv_label_create(s_body); + lv_label_set_text(status, LV_SYMBOL_OK " Connected"); + lv_obj_set_style_text_color(status, lv_color_hex(SIG_GREEN), 0); + lv_obj_set_style_text_font(status, &lv_font_montserrat_14, 0); + lv_obj_set_style_text_align(status, LV_TEXT_ALIGN_CENTER, 0); + lv_obj_align(status, LV_ALIGN_TOP_MID, 0, 10); + + lv_obj_t *card = lit_panel(s_body, CONSOLE_W, CONSOLE_H); + lv_obj_align(card, LV_ALIGN_TOP_MID, 0, 44); + + s_term_label = lv_label_create(card); + lv_label_set_long_mode(s_term_label, LV_LABEL_LONG_WRAP); + lv_obj_set_width(s_term_label, CONSOLE_W - 20); + lv_obj_set_style_text_color(s_term_label, lv_color_hex(SIG_GREEN), 0); + lv_obj_set_style_text_font(s_term_label, &lv_font_montserrat_14, 0); + lv_obj_align(s_term_label, LV_ALIGN_TOP_LEFT, 0, 0); + update_terminal(); + + fade_in(status, 220); + fade_in(card, 260); + + ui_feedback(UI_FB_READ); + notify(NOTIFY_INFO, "HID keyboard connected"); + ui_chrome_footer_set_text(s_footer, "OK Key L GUI R Enter BACK Exit"); + + if (s_cursor_timer == NULL) + s_cursor_timer = lv_timer_create(cursor_timer_cb, CURSOR_BLINK_MS, NULL); +} + +static bool ascii_to_hid(char c, uint8_t *key, uint8_t *mod) { + *mod = 0; + if (c >= 'a' && c <= 'z') { + *key = 0x04 + (c - 'a'); + return true; + } + if (c >= 'A' && c <= 'Z') { + *key = 0x04 + (c - 'A'); + *mod = HID_MOD_SHIFT; + return true; + } + if (c >= '1' && c <= '9') { + *key = 0x1E + (c - '1'); + return true; + } + switch (c) { + case '0': + *key = 0x27; + return true; + case '\n': + *key = HID_KEY_ENTER; + return true; + case ' ': + *key = HID_KEY_SPACE; + return true; + case '.': + *key = 0x37; + return true; + case ',': + *key = 0x36; + return true; + case '-': + *key = 0x2D; + return true; + case '_': + *key = 0x2D; + *mod = HID_MOD_SHIFT; + return true; + case '/': + *key = 0x38; + return true; + case ':': + *key = 0x33; + *mod = HID_MOD_SHIFT; + return true; + case ';': + *key = 0x33; + return true; + case '!': + *key = 0x1E; + *mod = HID_MOD_SHIFT; + return true; + case '?': + *key = 0x38; + *mod = HID_MOD_SHIFT; + return true; + default: + return false; + } +} + +static void type_char(char c) { + uint8_t key, mod; + if (ascii_to_hid(c, &key, &mod)) + ble_hid_send_key(key, mod); + if (s_type_pos >= TYPED_MAX - 2) + return; + s_typed[s_type_pos++] = c; + s_typed[s_type_pos] = '\0'; + update_terminal(); +} + +void ui_ble_keyboard_open(void) { + if (s_screen != NULL) { + lv_obj_del(s_screen); + s_screen = NULL; + } + s_term_label = NULL; + s_type_pos = 0; + s_connected = false; + s_cursor_on = true; + s_typed[0] = '\0'; + s_phase_timer = NULL; + s_cursor_timer = NULL; + + s_screen = lv_obj_create(NULL); + lv_obj_set_style_bg_color(s_screen, current_theme.screen_base, 0); + lv_obj_set_style_bg_opa(s_screen, LV_OPA_COVER, 0); + lv_obj_remove_flag(s_screen, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_border_width(s_screen, 0, 0); + lv_obj_set_style_pad_all(s_screen, 0, 0); + + ui_chrome_header(s_screen, "BLE KEYBOARD", KB_ICON); + s_footer = ui_chrome_footer(s_screen, "BACK Cancel"); + + s_body = make_body(s_screen); + build_pairing(); + + ui_input_set_screen_handler(keyboard_input, NULL); + + if (ble_hid_init() != ESP_OK) { + lv_obj_t *err = lv_label_create(s_body); + lv_label_set_text(err, "HID init failed (C5?)"); + lv_obj_set_style_text_color(err, current_theme.text_main, 0); + lv_obj_align(err, LV_ALIGN_CENTER, 0, 58); + ESP_LOGE(TAG, "ble_hid_init failed"); + } else { + s_phase_timer = lv_timer_create(phase_timer_cb, CONN_POLL_MS, NULL); + } + + ui_feedback(UI_FB_EMULATE); + ui_screen_load_owned(&s_screen, s_screen); +} + +static void phase_timer_cb(lv_timer_t *timer) { + if (lv_screen_active() != s_screen) { + lv_timer_delete(timer); + if (s_phase_timer == timer) + s_phase_timer = NULL; + return; + } + if (ble_hid_is_connected()) { + lv_timer_delete(timer); + s_phase_timer = NULL; + build_console(); + } +} + +static void cursor_timer_cb(lv_timer_t *timer) { + if (lv_screen_active() != s_screen || !s_connected) { + lv_timer_delete(timer); + s_cursor_timer = NULL; + return; + } + s_cursor_on = !s_cursor_on; + update_terminal(); +} + +static void stop_timers(void) { + if (s_phase_timer != NULL) { + lv_timer_delete(s_phase_timer); + s_phase_timer = NULL; + } + if (s_cursor_timer != NULL) { + lv_timer_delete(s_cursor_timer); + s_cursor_timer = NULL; + } +} + +static void keyboard_input(const input_event_t *ev, void *ctx) { + (void)ctx; + const bool press = (ev->action == INPUT_ACTION_PRESS); + + switch (ev->button) { + case INPUT_BTN_BACK: + if (press) { + stop_timers(); + ble_hid_deinit(); + s_connected = false; + ui_switch_screen(SCREEN_BLE_MENU); + } + break; + case INPUT_BTN_OK: + if (press && s_connected) { + if (CANNED_TEXT[s_type_pos] != '\0') { + type_char(CANNED_TEXT[s_type_pos]); + ui_feedback(UI_FB_NAV); + } else { + notify(NOTIFY_SAVED, "Payload typed"); + } + } + break; + case INPUT_BTN_RIGHT: + if (press && s_connected) { + type_char('\n'); + ui_feedback(UI_FB_NAV); + } + break; + case INPUT_BTN_LEFT: + if (press && s_connected) { + ble_hid_send_key(0, HID_MOD_GUI); + ui_feedback(UI_FB_NAV); + } + break; + default: + break; + } +} diff --git a/firmware_p4/components/Applications/ui/screens/bluetooth/ble_mouse_ui.c b/firmware_p4/components/Applications/ui/screens/bluetooth/ble_mouse_ui.c new file mode 100644 index 000000000..dde30f53e --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/bluetooth/ble_mouse_ui.c @@ -0,0 +1,357 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#include "ble_mouse_ui.h" + +#include "esp_log.h" +#include "esp_timer.h" +#include "lvgl.h" +#include "st7789.h" + +#include "buttons_gpio.h" +#include "ui_chrome.h" +#include "ui_feedback.h" +#include "ui_manager.h" +#include "ui_theme.h" +#include "waves_ui.h" + +static const char *TAG = "BLE_MOUSE_UI"; + +#define NAV_TIMER_INTERVAL_MS 50 +#define PAIRING_DURATION_US 1800000 +#define FOUND_DWELL_US 600000 +#define DOT_CYCLE_US 350000 + +#define SIG_GREEN 0x00E676 +#define COL_RAISE 0x170A28 + +static void opa_cb(void *var, int32_t v) { + lv_obj_set_style_opa((lv_obj_t *)var, (lv_opa_t)v, 0); +} + +static void blink_loop(lv_obj_t *obj, int32_t from, int32_t to, uint32_t ms) { + lv_anim_t a; + lv_anim_init(&a); + lv_anim_set_var(&a, obj); + lv_anim_set_exec_cb(&a, opa_cb); + lv_anim_set_values(&a, from, to); + lv_anim_set_duration(&a, ms); + lv_anim_set_playback_duration(&a, ms); + lv_anim_set_repeat_count(&a, LV_ANIM_REPEAT_INFINITE); + lv_anim_start(&a); +} + +static lv_obj_t *make_screen_root(void) { + lv_obj_t *scr = lv_obj_create(NULL); + lv_obj_set_style_bg_color(scr, current_theme.screen_base, 0); + lv_obj_set_style_bg_opa(scr, LV_OPA_COVER, 0); + lv_obj_remove_flag(scr, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_border_width(scr, 0, 0); + lv_obj_set_style_pad_all(scr, 0, 0); + return scr; +} + +enum { PAIR_WAIT, PAIR_FOUND }; + +static lv_obj_t *s_pair_screen = NULL; +static lv_obj_t *s_pair_waves = NULL; +static lv_obj_t *s_pair_status = NULL; +static lv_timer_t *s_pair_timer = NULL; +static int64_t s_pair_start = 0; +static int64_t s_pair_found_at = 0; +static int s_pair_state = PAIR_WAIT; +static bool s_pair_back_last = false; + +static void pair_reveal(void) { + s_pair_state = PAIR_FOUND; + s_pair_found_at = esp_timer_get_time(); + + if (s_pair_waves) { + lv_obj_del(s_pair_waves); + s_pair_waves = NULL; + } + s_pair_waves = waves_create(s_pair_screen, LV_ALIGN_CENTER, 0, -24, LV_SYMBOL_OK, NULL); + + lv_label_set_text(s_pair_status, "Connected!"); + lv_obj_set_style_text_color(s_pair_status, lv_color_hex(SIG_GREEN), 0); + ui_feedback(UI_FB_EMULATE); +} + +static void pair_timer_cb(lv_timer_t *t) { + if (lv_screen_active() != s_pair_screen) { + lv_timer_delete(t); + s_pair_timer = NULL; + return; + } + if (ui_input_is_locked()) { + s_pair_back_last = back_button_is_down(); + return; + } + + bool back = back_button_is_down(); + if (back && !s_pair_back_last) { + ui_switch_screen(SCREEN_BLE_MENU); + return; + } + s_pair_back_last = back; + + int64_t now = esp_timer_get_time(); + if (s_pair_state == PAIR_WAIT) { + int dots = (int)((now - s_pair_start) / DOT_CYCLE_US) % 4; + lv_label_set_text(s_pair_status, + dots == 1 ? "Pairing." + : dots == 2 ? "Pairing.." + : dots == 3 ? "Pairing..." + : "Pairing"); + if (now - s_pair_start >= PAIRING_DURATION_US) + pair_reveal(); + } else { + if (now - s_pair_found_at >= FOUND_DWELL_US) + ui_switch_screen(SCREEN_BLE_MOUSE); + } +} + +void ui_ble_mouse_pairing_open(void) { + if (s_pair_screen != NULL) { + lv_obj_del(s_pair_screen); + s_pair_screen = NULL; + } + + s_pair_screen = make_screen_root(); + ui_chrome_header(s_pair_screen, "MouseAir", "/assets/icons/mouse.bin"); + + s_pair_waves = waves_create(s_pair_screen, LV_ALIGN_CENTER, 0, -24, LV_SYMBOL_BLUETOOTH, NULL); + + s_pair_status = lv_label_create(s_pair_screen); + lv_label_set_text(s_pair_status, "Pairing"); + lv_obj_set_style_text_color(s_pair_status, current_theme.text_main, 0); + lv_obj_set_style_text_font(s_pair_status, &lv_font_montserrat_14, 0); + lv_obj_set_style_text_align(s_pair_status, LV_TEXT_ALIGN_CENTER, 0); + lv_obj_align(s_pair_status, LV_ALIGN_CENTER, 0, 70); + + ui_chrome_footer(s_pair_screen, "BACK Cancel"); + + s_pair_state = PAIR_WAIT; + s_pair_start = esp_timer_get_time(); + s_pair_found_at = 0; + s_pair_back_last = false; + if (s_pair_timer == NULL) + s_pair_timer = lv_timer_create(pair_timer_cb, NAV_TIMER_INTERVAL_MS, NULL); + + ui_screen_load_owned(&s_pair_screen, s_pair_screen); +} + +typedef struct { + const char *text; + int up, down, left, right; +} mouse_btn_t; + +enum { + M_LCLICK, + M_RCLICK, + M_SCRL_UP, + M_SCRL_DN, + M_COUNT, +}; + +static const mouse_btn_t MOUSE_BTNS[M_COUNT] = { + [M_LCLICK] = {"L CLICK", -1, M_SCRL_UP, -1, M_RCLICK}, + [M_RCLICK] = {"R CLICK", -1, M_SCRL_UP, M_LCLICK, -1}, + [M_SCRL_UP] = {LV_SYMBOL_UP " SCROLL", M_LCLICK, M_SCRL_DN, -1, -1}, + [M_SCRL_DN] = {LV_SYMBOL_DOWN " SCROLL", M_SCRL_UP, -1, -1, -1}, +}; + +static lv_obj_t *s_mouse_screen = NULL; +static lv_obj_t *s_mouse_objs[M_COUNT]; +static lv_timer_t *s_mouse_timer = NULL; +static int s_mouse_focus = M_LCLICK; + +static bool s_m_up_last = false; +static bool s_m_down_last = false; +static bool s_m_left_last = false; +static bool s_m_right_last = false; +static bool s_m_ok_last = false; +static bool s_m_back_last = false; + +static void mouse_apply_focus(lv_obj_t *btn, bool focused) { + lv_obj_set_style_border_color( + btn, focused ? current_theme.border_accent : current_theme.border_inactive, 0); + lv_obj_set_style_border_width(btn, focused ? 2 : 1, 0); + lv_obj_set_style_bg_color(btn, focused ? lv_color_hex(COL_RAISE) : current_theme.bg_secondary, 0); + lv_obj_set_style_bg_opa(btn, focused ? LV_OPA_COVER : LV_OPA_80, 0); + lv_obj_set_style_shadow_width(btn, focused ? 14 : 0, 0); + lv_obj_set_style_shadow_color(btn, current_theme.border_accent, 0); + lv_obj_set_style_shadow_opa(btn, focused ? LV_OPA_50 : LV_OPA_TRANSP, 0); + lv_obj_set_style_shadow_spread(btn, focused ? -3 : 0, 0); + + lv_obj_t *lbl = lv_obj_get_child(btn, 0); + if (lbl) + lv_obj_set_style_text_color( + lbl, focused ? current_theme.border_accent : current_theme.text_main, 0); +} + +static void mouse_set_focus(int idx) { + if (idx < 0 || idx >= M_COUNT || idx == s_mouse_focus) + return; + mouse_apply_focus(s_mouse_objs[s_mouse_focus], false); + s_mouse_focus = idx; + mouse_apply_focus(s_mouse_objs[s_mouse_focus], true); + ui_feedback(UI_FB_NAV); +} + +static lv_obj_t *mouse_make_button(lv_obj_t *parent, const char *text) { + lv_obj_t *btn = lv_obj_create(parent); + lv_obj_remove_flag(btn, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_radius(btn, 12, 0); + lv_obj_set_style_bg_color(btn, current_theme.bg_secondary, 0); + lv_obj_set_style_bg_opa(btn, LV_OPA_80, 0); + lv_obj_set_style_bg_grad_dir(btn, LV_GRAD_DIR_NONE, 0); + lv_obj_set_style_border_width(btn, 1, 0); + lv_obj_set_style_border_color(btn, current_theme.border_inactive, 0); + lv_obj_set_style_pad_all(btn, 0, 0); + + lv_obj_t *lbl = lv_label_create(btn); + lv_label_set_text(lbl, text); + lv_obj_set_style_text_color(lbl, current_theme.text_main, 0); + lv_obj_set_style_text_font(lbl, &lv_font_montserrat_12, 0); + lv_obj_center(lbl); + return btn; +} + +static void mouse_timer_cb(lv_timer_t *t) { + if (lv_screen_active() != s_mouse_screen) { + lv_timer_delete(t); + s_mouse_timer = NULL; + return; + } + + bool up = up_button_is_down(); + bool down = down_button_is_down(); + bool left = left_button_is_down(); + bool right = right_button_is_down(); + bool ok = ok_button_is_down(); + bool back = back_button_is_down(); + + if (ui_input_is_locked()) { + s_m_up_last = up; + s_m_down_last = down; + s_m_left_last = left; + s_m_right_last = right; + s_m_ok_last = ok; + s_m_back_last = back; + return; + } + + if (back && !s_m_back_last) { + ui_switch_screen(SCREEN_BLE_MENU); + return; + } + + if (up && !s_m_up_last) + mouse_set_focus(MOUSE_BTNS[s_mouse_focus].up); + if (down && !s_m_down_last) + mouse_set_focus(MOUSE_BTNS[s_mouse_focus].down); + if (left && !s_m_left_last) + mouse_set_focus(MOUSE_BTNS[s_mouse_focus].left); + if (right && !s_m_right_last) + mouse_set_focus(MOUSE_BTNS[s_mouse_focus].right); + + if (ok && !s_m_ok_last) { + ui_feedback(UI_FB_SELECT); + ESP_LOGI(TAG, "press: %s", MOUSE_BTNS[s_mouse_focus].text); + } + + s_m_up_last = up; + s_m_down_last = down; + s_m_left_last = left; + s_m_right_last = right; + s_m_ok_last = ok; + s_m_back_last = back; +} + +void ui_ble_mouse_open(void) { + if (s_mouse_screen != NULL) { + lv_obj_del(s_mouse_screen); + s_mouse_screen = NULL; + } + s_m_up_last = s_m_down_last = s_m_left_last = false; + s_m_right_last = s_m_ok_last = s_m_back_last = false; + + s_mouse_screen = make_screen_root(); + ui_chrome_header(s_mouse_screen, "MouseAir", "/assets/icons/mouse.bin"); + + lv_obj_t *body = lv_obj_create(s_mouse_screen); + lv_obj_remove_flag(body, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(body, LCD_H_RES, LCD_V_RES - UI_CHROME_HEADER_H - UI_CHROME_FOOTER_H); + lv_obj_align(body, LV_ALIGN_TOP_LEFT, 0, UI_CHROME_HEADER_H); + lv_obj_set_style_bg_opa(body, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(body, 0, 0); + lv_obj_set_style_pad_all(body, 10, 0); + lv_obj_set_style_pad_row(body, 8, 0); + lv_obj_set_flex_flow(body, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(body, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + + lv_obj_t *pad = lv_obj_create(body); + lv_obj_remove_flag(pad, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(pad, 200, 84); + lv_obj_set_style_radius(pad, 14, 0); + lv_obj_set_style_bg_color(pad, current_theme.bg_secondary, 0); + lv_obj_set_style_bg_opa(pad, LV_OPA_COVER, 0); + lv_obj_set_style_bg_grad_dir(pad, LV_GRAD_DIR_NONE, 0); + lv_obj_set_style_border_width(pad, 1, 0); + lv_obj_set_style_border_color(pad, current_theme.border_accent, 0); + lv_obj_set_style_shadow_width(pad, 16, 0); + lv_obj_set_style_shadow_color(pad, current_theme.border_accent, 0); + lv_obj_set_style_shadow_opa(pad, LV_OPA_40, 0); + lv_obj_set_style_shadow_spread(pad, -4, 0); + lv_obj_set_style_pad_all(pad, 0, 0); + + lv_obj_t *cross = lv_label_create(pad); + lv_label_set_text(cross, LV_SYMBOL_GPS); + lv_obj_set_style_text_color(cross, current_theme.border_accent, 0); + lv_obj_center(cross); + blink_loop(cross, LV_OPA_COVER, 120, 900); + + lv_obj_t *row = lv_obj_create(body); + lv_obj_remove_flag(row, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_width(row, lv_pct(100)); + lv_obj_set_height(row, 44); + lv_obj_set_style_bg_opa(row, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(row, 0, 0); + lv_obj_set_style_pad_all(row, 0, 0); + lv_obj_set_style_pad_column(row, 8, 0); + lv_obj_set_flex_flow(row, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(row, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + + s_mouse_objs[M_LCLICK] = mouse_make_button(row, MOUSE_BTNS[M_LCLICK].text); + lv_obj_set_size(s_mouse_objs[M_LCLICK], lv_pct(48), 44); + s_mouse_objs[M_RCLICK] = mouse_make_button(row, MOUSE_BTNS[M_RCLICK].text); + lv_obj_set_size(s_mouse_objs[M_RCLICK], lv_pct(48), 44); + + s_mouse_objs[M_SCRL_UP] = mouse_make_button(body, MOUSE_BTNS[M_SCRL_UP].text); + lv_obj_set_size(s_mouse_objs[M_SCRL_UP], lv_pct(100), 40); + s_mouse_objs[M_SCRL_DN] = mouse_make_button(body, MOUSE_BTNS[M_SCRL_DN].text); + lv_obj_set_size(s_mouse_objs[M_SCRL_DN], lv_pct(100), 40); + + s_mouse_focus = M_LCLICK; + mouse_apply_focus(s_mouse_objs[s_mouse_focus], true); + + ui_chrome_footer(s_mouse_screen, "Arrows Move OK Press BACK Exit"); + + if (s_mouse_timer == NULL) + s_mouse_timer = lv_timer_create(mouse_timer_cb, NAV_TIMER_INTERVAL_MS, NULL); + + ui_screen_load_owned(&s_mouse_screen, s_mouse_screen); +} diff --git a/firmware_p4/components/Applications/ui/screens/bluetooth/ble_radio_ui.c b/firmware_p4/components/Applications/ui/screens/bluetooth/ble_radio_ui.c new file mode 100644 index 000000000..87d941dba --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/bluetooth/ble_radio_ui.c @@ -0,0 +1,290 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#include "ble_radio_ui.h" + +#include + +#include "lvgl.h" +#include "st7789.h" + +#include "bluetooth_service.h" +#include "notify_ui.h" +#include "ui_chrome.h" +#include "ui_feedback.h" +#include "ui_manager.h" +#include "ui_theme.h" + +#define RADIO_ICON "/assets/icons/bluetooth.bin" + +#define BLE_ADDR_LEN 6 + +#define MX 10 +#define CONTENT_W (LCD_H_RES - 2 * MX) +#define CARD_Y 50 +#define CARD_H 58 +#define ROWS_Y 124 +#define ROW_H 42 +#define ROW_GAP 8 +#define ROW_STEP (ROW_H + ROW_GAP) + +#define COL_DIM 0x8A8594 +#define COL_RAISE 0x170A28 + +enum { + R_RANDOMIZE = 0, + R_TXPOWER, + R_DISCONNECT, + R_COUNT, +}; + +typedef struct { + const char *sym; + const char *name; +} radio_row_def_t; + +static const radio_row_def_t ROW_DEFS[R_COUNT] = { + {LV_SYMBOL_REFRESH, "Randomize MAC"}, + {LV_SYMBOL_CHARGE, "Max TX Power"}, + {LV_SYMBOL_POWER, "Disconnect All"}, +}; + +static lv_obj_t *s_screen = NULL; +static lv_obj_t *s_card_mac = NULL; +static lv_obj_t *s_card_conn = NULL; +static lv_obj_t *s_row[R_COUNT]; +static lv_obj_t *s_icon_lbl[R_COUNT]; +static lv_obj_t *s_val_lbl[R_COUNT]; + +static char s_mac[18]; +static int s_connected = 0; +static bool s_is_tx_maxed = false; +static int s_sel = 0; + +static void radio_input(const input_event_t *ev, void *ctx); + +static void read_mac(void) { + uint8_t mac[BLE_ADDR_LEN] = {0}; + bluetooth_service_get_mac(mac); + snprintf(s_mac, + sizeof(s_mac), + "%02X:%02X:%02X:%02X:%02X:%02X", + mac[0], + mac[1], + mac[2], + mac[3], + mac[4], + mac[5]); +} + +static void update_card(void) { + if (s_card_mac != NULL) + lv_label_set_text_fmt(s_card_mac, "MAC: %s", s_mac); + if (s_card_conn != NULL) + lv_label_set_text_fmt(s_card_conn, "Connected: %d", s_connected); +} + +static void update_values(void) { + lv_label_set_text(s_val_lbl[R_TXPOWER], s_is_tx_maxed ? "MAX" : ""); + lv_label_set_text_fmt(s_val_lbl[R_DISCONNECT], "%d", s_connected); + lv_label_set_text(s_val_lbl[R_RANDOMIZE], ""); +} + +static void refresh_selection(void) { + const lv_color_t accent = current_theme.border_accent; + const lv_color_t dim = lv_color_hex(COL_DIM); + for (int i = 0; i < R_COUNT; i++) { + bool sel = (i == s_sel); + lv_obj_set_style_border_color(s_row[i], sel ? accent : current_theme.border_inactive, 0); + lv_obj_set_style_border_opa(s_row[i], sel ? LV_OPA_COVER : LV_OPA_TRANSP, 0); + lv_obj_set_style_bg_color( + s_row[i], sel ? lv_color_hex(COL_RAISE) : current_theme.bg_secondary, 0); + lv_obj_set_style_shadow_width(s_row[i], sel ? 16 : 0, 0); + lv_obj_set_style_shadow_color(s_row[i], accent, 0); + lv_obj_set_style_shadow_spread(s_row[i], sel ? -3 : 0, 0); + lv_obj_set_style_text_color(s_icon_lbl[i], sel ? accent : dim, 0); + lv_obj_set_style_text_color(s_val_lbl[i], sel ? accent : dim, 0); + } +} + +static lv_obj_t *lit_card(lv_obj_t *parent) { + lv_obj_t *card = lv_obj_create(parent); + lv_obj_remove_flag(card, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(card, CONTENT_W, CARD_H); + lv_obj_align(card, LV_ALIGN_TOP_MID, 0, CARD_Y); + lv_obj_set_style_radius(card, 13, 0); + lv_obj_set_style_bg_color(card, current_theme.bg_secondary, 0); + lv_obj_set_style_bg_opa(card, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(card, 1, 0); + lv_obj_set_style_border_color(card, current_theme.border_accent, 0); + lv_obj_set_style_shadow_color(card, current_theme.border_accent, 0); + lv_obj_set_style_shadow_width(card, 18, 0); + lv_obj_set_style_shadow_opa(card, LV_OPA_40, 0); + lv_obj_set_style_shadow_spread(card, -4, 0); + lv_obj_set_style_pad_hor(card, 12, 0); + lv_obj_set_style_pad_ver(card, 8, 0); + lv_obj_set_flex_flow(card, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(card, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START); + lv_obj_set_style_pad_row(card, 4, 0); + return card; +} + +static lv_obj_t *make_row(lv_obj_t *parent, int i) { + lv_obj_t *row = lv_obj_create(parent); + lv_obj_remove_flag(row, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_remove_flag(row, LV_OBJ_FLAG_CLICKABLE); + lv_obj_set_size(row, CONTENT_W, ROW_H); + lv_obj_align(row, LV_ALIGN_TOP_MID, 0, ROWS_Y + i * ROW_STEP); + lv_obj_set_style_radius(row, 10, 0); + lv_obj_set_style_bg_opa(row, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(row, 2, 0); + lv_obj_set_style_pad_left(row, 12, 0); + lv_obj_set_style_pad_right(row, 12, 0); + lv_obj_set_style_pad_ver(row, 0, 0); + 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_t *ic = lv_label_create(row); + lv_label_set_text(ic, ROW_DEFS[i].sym); + lv_obj_set_style_text_font(ic, &lv_font_montserrat_16, 0); + lv_obj_set_width(ic, 22); + + lv_obj_t *name = lv_label_create(row); + lv_label_set_text(name, ROW_DEFS[i].name); + lv_obj_set_style_text_font(name, &lv_font_montserrat_14, 0); + lv_obj_set_style_text_color(name, current_theme.text_main, 0); + lv_obj_set_style_pad_left(name, 8, 0); + lv_obj_set_flex_grow(name, 1); + + lv_obj_t *val = lv_label_create(row); + lv_label_set_text(val, ""); + lv_obj_set_style_text_font(val, &lv_font_montserrat_14, 0); + + s_icon_lbl[i] = ic; + s_val_lbl[i] = val; + return row; +} + +static void do_action(int idx) { + switch (idx) { + case R_RANDOMIZE: + if (bluetooth_service_set_random_mac() == ESP_OK) { + read_mac(); + update_card(); + ui_feedback(UI_FB_WRITE); + notify(NOTIFY_INFO, "MAC randomized"); + } else { + notify(NOTIFY_WARNING, "Radio not running"); + } + break; + case R_TXPOWER: + if (bluetooth_service_set_max_power() == ESP_OK) { + s_is_tx_maxed = true; + update_values(); + ui_feedback(UI_FB_WRITE); + notify(NOTIFY_SAVED, "TX power maxed"); + } else { + notify(NOTIFY_WARNING, "Radio not running"); + } + break; + case R_DISCONNECT: + bluetooth_service_disconnect_all(); + s_connected = bluetooth_service_get_connected_count(); + update_card(); + update_values(); + ui_feedback(UI_FB_WRITE); + notify(NOTIFY_WARNING, "All devices dropped"); + break; + default: + break; + } + refresh_selection(); +} + +void ui_ble_radio_open(void) { + if (s_screen != NULL) { + lv_obj_del(s_screen); + s_screen = NULL; + } + s_is_tx_maxed = false; + s_sel = 0; + read_mac(); + s_connected = bluetooth_service_get_connected_count(); + + s_screen = lv_obj_create(NULL); + lv_obj_set_style_bg_color(s_screen, current_theme.screen_base, 0); + lv_obj_set_style_bg_opa(s_screen, LV_OPA_COVER, 0); + lv_obj_remove_flag(s_screen, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_border_width(s_screen, 0, 0); + lv_obj_set_style_pad_all(s_screen, 0, 0); + + ui_chrome_header(s_screen, "BLE RADIO", RADIO_ICON); + ui_chrome_footer(s_screen, "UP/DOWN choose OK do BACK back"); + + lv_obj_t *card = lit_card(s_screen); + + s_card_mac = lv_label_create(card); + lv_obj_set_style_text_color(s_card_mac, current_theme.border_accent, 0); + lv_obj_set_style_text_font(s_card_mac, &lv_font_montserrat_14, 0); + + s_card_conn = lv_label_create(card); + lv_obj_set_style_text_color(s_card_conn, current_theme.text_main, 0); + lv_obj_set_style_text_font(s_card_conn, &lv_font_montserrat_12, 0); + + for (int i = 0; i < R_COUNT; i++) + s_row[i] = make_row(s_screen, i); + + update_card(); + update_values(); + refresh_selection(); + + ui_input_set_screen_handler(radio_input, NULL); + + ui_screen_load_owned(&s_screen, s_screen); +} + +static void radio_input(const input_event_t *ev, void *ctx) { + (void)ctx; + const bool press = (ev->action == INPUT_ACTION_PRESS); + const bool nav = press || (ev->action == INPUT_ACTION_REPEAT); + + switch (ev->button) { + case INPUT_BTN_BACK: + case INPUT_BTN_LEFT: + if (press) + ui_switch_screen(SCREEN_BLE_MENU); + break; + case INPUT_BTN_DOWN: + if (nav) { + s_sel = (s_sel + 1) % R_COUNT; + refresh_selection(); + ui_feedback(UI_FB_NAV); + } + break; + case INPUT_BTN_UP: + if (nav) { + s_sel = (s_sel - 1 + R_COUNT) % R_COUNT; + refresh_selection(); + ui_feedback(UI_FB_NAV); + } + break; + case INPUT_BTN_OK: + if (press) + do_action(s_sel); + break; + default: + break; + } +} diff --git a/firmware_p4/components/Applications/ui/screens/bluetooth/ble_scan_ui.c b/firmware_p4/components/Applications/ui/screens/bluetooth/ble_scan_ui.c new file mode 100644 index 000000000..9b908888c --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/bluetooth/ble_scan_ui.c @@ -0,0 +1,571 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#include "ble_scan_ui.h" + +#include +#include + +#include "esp_log.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include "sys_prio.h" +#include "lvgl.h" + +#include "assets_manager.h" +#include "ble_scanner.h" +#include "msgbox_ui.h" +#include "notify_ui.h" +#include "oui_lookup.h" +#include "ui_chrome.h" +#include "ui_feedback.h" +#include "ui_manager.h" +#include "ui_theme.h" +#include "waves_ui.h" + +static const char *TAG = "BLE_SCAN_UI"; + +#define STATUS_TICK_MS 50 +#define DOT_CYCLE_MS 350 +#define SCAN_RESULT_COLOR_HEX 0x00E676 +#define COL_DIM 0x8A8594 +#define BLE_MAX_DEVS 12 +#define SCAN_POLL_MS 150 +#define SCAN_TIMEOUT_MS 15000 +#define BLE_DEV_ICON "/assets/icons/bluetooth.bin" +#define BLE_SEARCH_ICON "/assets/icons/bluetooth_searching.bin" +#define BLE_SCAN_TASK_STACK 8192 +#define BLE_SCAN_TASK_PRIO SYS_PRIO_SERVICE_LO +#define HERO_CARD_W 200 +#define HERO_CARD_H 108 + +#define RADAR_BOX 190 +#define RADAR_TOP_Y (UI_CHROME_HEADER_H + 4) +#define RADAR_RING_1 28 +#define RADAR_RING_2 55 +#define RADAR_RING_3 82 +#define RADAR_BLIP_RMIN 14 +#define RADAR_BLIP_RMAX 72 +#define RADAR_BLIP_SZ 10 +#define RADAR_YOU_SZ 10 +#define RADAR_START_DEG 234 +#define TRIGO_MAX 32767 + +#define RSSI_MAP_NEAR (-40) +#define RSSI_MAP_FAR (-92) +#define RSSI_NEAR_DBM (-55) +#define RSSI_FAR_DBM (-80) +#define BLIP_NEAR_HEX SCAN_RESULT_COLOR_HEX +#define BLIP_MID_HEX 0xF5B13D +#define BLIP_FAR_HEX 0xFF6B6B + +#define CHIP_W 208 +#define CHIP_H 46 + +typedef enum { SCAN_RUNNING, SCAN_DONE, SCAN_FAIL } scan_state_t; + +typedef struct { + char name[24]; + int8_t rssi; + char mac[18]; + char vendor[24]; +} ble_dev_t; + +static lv_obj_t *s_screen = NULL; +static lv_obj_t *s_status = NULL; +static lv_timer_t *s_status_timer = NULL; + +static scan_state_t s_scan_state = SCAN_RUNNING; +static bool s_scanning = false; +static bool s_showing_radar = false; +static int s_dev_count = 0; +static ble_dev_t s_devs[BLE_MAX_DEVS]; +static uint32_t s_scan_start = 0; + +static int s_sel = 0; +static lv_obj_t *s_blip_dot[BLE_MAX_DEVS]; +static lv_obj_t *s_chip = NULL; +static lv_obj_t *s_chip_name = NULL; +static lv_obj_t *s_chip_meta = NULL; +static lv_obj_t *s_chip_rssi = NULL; + +static void scan_status_tick_cb(lv_timer_t *t); +static void ble_scan_input(const input_event_t *ev, void *ctx); + +static uint32_t blip_hex(int rssi) { + if (rssi >= RSSI_NEAR_DBM) + return BLIP_NEAR_HEX; + if (rssi >= RSSI_FAR_DBM) + return BLIP_MID_HEX; + return BLIP_FAR_HEX; +} + +static int rssi_to_radius(int rssi) { + int span = RSSI_MAP_FAR - RSSI_MAP_NEAR; + int off = rssi - RSSI_MAP_NEAR; + int r = RADAR_BLIP_RMIN + off * (RADAR_BLIP_RMAX - RADAR_BLIP_RMIN) / span; + if (r < RADAR_BLIP_RMIN) + r = RADAR_BLIP_RMIN; + if (r > RADAR_BLIP_RMAX) + r = RADAR_BLIP_RMAX; + return r; +} + +static lv_obj_t *radar_ring(lv_obj_t *parent, int r, lv_opa_t opa) { + lv_obj_t *o = lv_obj_create(parent); + lv_obj_remove_flag(o, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_remove_flag(o, LV_OBJ_FLAG_CLICKABLE); + lv_obj_set_size(o, r * 2, r * 2); + lv_obj_set_style_radius(o, LV_RADIUS_CIRCLE, 0); + lv_obj_set_style_bg_opa(o, LV_OPA_TRANSP, 0); + lv_obj_set_style_pad_all(o, 0, 0); + lv_obj_set_style_border_width(o, 1, 0); + lv_obj_set_style_border_color(o, current_theme.border_accent, 0); + lv_obj_set_style_border_opa(o, opa, 0); + lv_obj_align(o, LV_ALIGN_CENTER, 0, 0); + return o; +} + +static lv_obj_t *radar_dot(lv_obj_t *parent, int sz, lv_color_t c) { + lv_obj_t *o = lv_obj_create(parent); + lv_obj_remove_flag(o, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_remove_flag(o, LV_OBJ_FLAG_CLICKABLE); + lv_obj_set_size(o, sz, sz); + lv_obj_set_style_radius(o, LV_RADIUS_CIRCLE, 0); + lv_obj_set_style_bg_color(o, c, 0); + lv_obj_set_style_bg_opa(o, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(o, 0, 0); + lv_obj_set_style_pad_all(o, 0, 0); + return o; +} + +static void radar_axis(lv_obj_t *parent, int w, int h) { + lv_obj_t *o = lv_obj_create(parent); + lv_obj_remove_flag(o, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_remove_flag(o, LV_OBJ_FLAG_CLICKABLE); + lv_obj_set_size(o, w, h); + lv_obj_set_style_radius(o, 0, 0); + lv_obj_set_style_bg_color(o, current_theme.border_accent, 0); + lv_obj_set_style_bg_opa(o, LV_OPA_20, 0); + lv_obj_set_style_border_width(o, 0, 0); + lv_obj_set_style_pad_all(o, 0, 0); + lv_obj_align(o, LV_ALIGN_CENTER, 0, 0); +} + +static void build_scan_hero(void) { + s_showing_radar = false; + ui_chrome_header(s_screen, "DETECT", BLE_SEARCH_ICON); + + waves_create(s_screen, LV_ALIGN_CENTER, 0, -24, LV_SYMBOL_BLUETOOTH, NULL); + + s_status = lv_label_create(s_screen); + lv_label_set_text(s_status, "Searching"); + lv_obj_set_style_text_color(s_status, current_theme.text_main, 0); + lv_obj_set_style_text_font(s_status, &lv_font_montserrat_14, 0); + lv_obj_align(s_status, LV_ALIGN_CENTER, 0, 70); + + ui_chrome_footer(s_screen, "BACK Cancel"); + + s_scan_start = lv_tick_get(); +} + +static lv_obj_t *lit_panel(lv_obj_t *parent, int w, int h) { + lv_obj_t *p = lv_obj_create(parent); + lv_obj_remove_flag(p, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(p, w, h); + lv_obj_set_style_radius(p, 13, 0); + lv_obj_set_style_bg_color(p, current_theme.bg_secondary, 0); + lv_obj_set_style_bg_opa(p, LV_OPA_COVER, 0); + lv_obj_set_style_bg_grad_dir(p, LV_GRAD_DIR_NONE, 0); + lv_obj_set_style_border_width(p, 1, 0); + lv_obj_set_style_border_color(p, current_theme.border_accent, 0); + lv_obj_set_style_shadow_color(p, current_theme.border_accent, 0); + lv_obj_set_style_shadow_width(p, 16, 0); + lv_obj_set_style_shadow_opa(p, LV_OPA_40, 0); + lv_obj_set_style_shadow_spread(p, -4, 0); + return p; +} + +static void build_empty_hero(const char *title, const char *sub) { + s_showing_radar = false; + + ui_chrome_header(s_screen, "BLE Devices", BLE_SEARCH_ICON); + + lv_obj_t *card = lit_panel(s_screen, HERO_CARD_W, HERO_CARD_H); + lv_obj_align(card, LV_ALIGN_CENTER, 0, 0); + lv_obj_set_style_pad_all(card, 12, 0); + lv_obj_set_flex_flow(card, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(card, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_set_style_pad_row(card, 6, 0); + + lv_image_dsc_t *dsc = assets_get(BLE_SEARCH_ICON); + if (dsc != NULL) { + lv_obj_t *img = lv_image_create(card); + lv_image_set_src(img, dsc); + lv_obj_set_style_image_recolor(img, current_theme.text_main, 0); + lv_obj_set_style_image_recolor_opa(img, LV_OPA_COVER, 0); + } + + lv_obj_t *t = lv_label_create(card); + lv_label_set_text(t, title); + lv_obj_set_style_text_font(t, &lv_font_montserrat_14, 0); + lv_obj_set_style_text_color(t, current_theme.text_main, 0); + lv_obj_set_style_text_align(t, LV_TEXT_ALIGN_CENTER, 0); + + lv_obj_t *s = lv_label_create(card); + lv_label_set_text(s, sub); + lv_obj_set_style_text_font(s, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(s, lv_color_hex(COL_DIM), 0); + lv_obj_set_style_text_align(s, LV_TEXT_ALIGN_CENTER, 0); + + ui_chrome_footer(s_screen, "RIGHT Rescan BACK Back"); + + lv_obj_fade_in(card, 240, 0); +} + +static void update_selection(void) { + for (int i = 0; i < s_dev_count; i++) { + if (s_blip_dot[i] == NULL) + continue; + bool on = (i == s_sel); + lv_obj_set_style_outline_width(s_blip_dot[i], on ? 3 : 0, 0); + lv_obj_set_style_outline_color(s_blip_dot[i], current_theme.border_accent, 0); + lv_obj_set_style_outline_opa(s_blip_dot[i], on ? LV_OPA_50 : LV_OPA_TRANSP, 0); + lv_obj_set_style_outline_pad(s_blip_dot[i], on ? 2 : 0, 0); + lv_obj_set_style_shadow_width(s_blip_dot[i], on ? 12 : 0, 0); + lv_obj_set_style_shadow_color(s_blip_dot[i], current_theme.border_accent, 0); + lv_obj_set_style_shadow_opa(s_blip_dot[i], on ? LV_OPA_60 : LV_OPA_TRANSP, 0); + } + + if (s_sel < 0 || s_sel >= s_dev_count) + return; + if (s_chip_name != NULL) + lv_label_set_text(s_chip_name, s_devs[s_sel].name); + if (s_chip_meta != NULL) + lv_label_set_text_fmt(s_chip_meta, "%s %.8s", s_devs[s_sel].vendor, s_devs[s_sel].mac); + if (s_chip_rssi != NULL) { + lv_label_set_text_fmt(s_chip_rssi, "%d", s_devs[s_sel].rssi); + lv_obj_set_style_text_color(s_chip_rssi, lv_color_hex(blip_hex(s_devs[s_sel].rssi)), 0); + } +} + +static void build_radar(void) { + s_showing_radar = true; + s_sel = 0; + + ui_chrome_header(s_screen, "BLE Devices", BLE_SEARCH_ICON); + + lv_obj_t *cont = lv_obj_create(s_screen); + lv_obj_remove_flag(cont, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_remove_flag(cont, LV_OBJ_FLAG_CLICKABLE); + lv_obj_set_size(cont, RADAR_BOX, RADAR_BOX); + lv_obj_align(cont, LV_ALIGN_TOP_MID, 0, RADAR_TOP_Y); + lv_obj_set_style_bg_opa(cont, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(cont, 0, 0); + lv_obj_set_style_pad_all(cont, 0, 0); + + radar_axis(cont, RADAR_RING_3 * 2, 1); + radar_axis(cont, 1, RADAR_RING_3 * 2); + + radar_ring(cont, RADAR_RING_3, LV_OPA_30); + radar_ring(cont, RADAR_RING_2, LV_OPA_50); + radar_ring(cont, RADAR_RING_1, LV_OPA_70); + + lv_obj_t *you = radar_dot(cont, RADAR_YOU_SZ, current_theme.border_accent); + lv_obj_set_style_shadow_width(you, 14, 0); + lv_obj_set_style_shadow_color(you, current_theme.border_accent, 0); + lv_obj_set_style_shadow_opa(you, LV_OPA_70, 0); + lv_obj_align(you, LV_ALIGN_CENTER, 0, 0); + + int step = 360 / s_dev_count; + for (int i = 0; i < s_dev_count; i++) { + int deg = RADAR_START_DEG + i * step; + int r = rssi_to_radius(s_devs[i].rssi); + int trig_x = lv_trigo_sin((int16_t)(deg + 90)); + int trig_y = lv_trigo_sin((int16_t)deg); + int dx = r * trig_x / TRIGO_MAX; + int dy = r * trig_y / TRIGO_MAX; + + s_blip_dot[i] = radar_dot(cont, RADAR_BLIP_SZ, lv_color_hex(blip_hex(s_devs[i].rssi))); + lv_obj_align(s_blip_dot[i], LV_ALIGN_CENTER, dx, dy); + } + + s_chip = lit_panel(s_screen, CHIP_W, CHIP_H); + lv_obj_align(s_chip, LV_ALIGN_BOTTOM_MID, 0, -(UI_CHROME_FOOTER_H + 6)); + lv_obj_set_style_pad_all(s_chip, 8, 0); + + s_chip_name = lv_label_create(s_chip); + lv_obj_set_style_text_font(s_chip_name, &lv_font_montserrat_14, 0); + lv_obj_set_style_text_color(s_chip_name, current_theme.text_main, 0); + lv_obj_align(s_chip_name, LV_ALIGN_TOP_LEFT, 0, 0); + + s_chip_meta = lv_label_create(s_chip); + lv_obj_set_style_text_font(s_chip_meta, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(s_chip_meta, lv_color_hex(COL_DIM), 0); + lv_obj_align(s_chip_meta, LV_ALIGN_BOTTOM_LEFT, 0, 0); + + s_chip_rssi = lv_label_create(s_chip); + lv_obj_set_style_text_font(s_chip_rssi, &lv_font_montserrat_16, 0); + lv_obj_align(s_chip_rssi, LV_ALIGN_RIGHT_MID, 0, 0); + + ui_chrome_footer(s_screen, "L/R Select OK Info BACK"); + + update_selection(); + lv_obj_fade_in(cont, 240, 0); + lv_obj_fade_in(s_chip, 280, 0); +} + +static void build_results(void) { + if (s_scan_state == SCAN_FAIL) { + build_empty_hero("Scan failed", "Radio error (C5?)"); + return; + } + if (s_dev_count == 0) { + build_empty_hero("No devices found", "Try rescanning"); + return; + } + build_radar(); +} + +static void build_screen(void) { + if (s_screen != NULL) { + lv_obj_del(s_screen); + s_screen = NULL; + } + s_status = NULL; + s_chip = NULL; + s_chip_name = NULL; + s_chip_meta = NULL; + s_chip_rssi = NULL; + for (int i = 0; i < BLE_MAX_DEVS; i++) + s_blip_dot[i] = NULL; + + s_screen = lv_obj_create(NULL); + lv_obj_set_style_bg_color(s_screen, current_theme.screen_base, 0); + lv_obj_set_style_bg_opa(s_screen, LV_OPA_COVER, 0); + lv_obj_remove_flag(s_screen, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_border_width(s_screen, 0, 0); + lv_obj_set_style_pad_all(s_screen, 0, 0); + + if (s_scan_state == SCAN_RUNNING) + build_scan_hero(); + else + build_results(); + + if (s_status_timer == NULL) + s_status_timer = lv_timer_create(scan_status_tick_cb, STATUS_TICK_MS, NULL); + + ui_input_set_screen_handler(ble_scan_input, NULL); + + ui_screen_load_owned(&s_screen, s_screen); +} + +static void open_detail(int idx) { + if (idx < 0 || idx >= s_dev_count) + return; + char body[96]; + snprintf(body, + sizeof(body), + "Vendor %s\nRSSI %d dBm\nMAC %s", + s_devs[idx].vendor, + s_devs[idx].rssi, + s_devs[idx].mac); + ui_feedback(UI_FB_SELECT); + msgbox_open_info(BLE_DEV_ICON, s_devs[idx].name, body, current_theme.border_accent); +} + +static void scan_done_cb(void *unused) { + (void)unused; + if (ui_current_screen() != SCREEN_BLE_SCAN) + return; + build_screen(); + if (s_scan_state == SCAN_DONE && s_dev_count > 0) + ui_feedback(UI_FB_READ); + ESP_LOGI(TAG, "scan finished: state=%d, %d device(s)", (int)s_scan_state, s_dev_count); +} + +static void collect_results(void) { + uint16_t n = 0; + bluetooth_service_scan_result_t *res = ble_scanner_get_results(&n); + int count = 0; + if (res != NULL) { + for (uint16_t i = 0; i < n && count < BLE_MAX_DEVS; i++) { + const bluetooth_service_scan_result_t *d = &res[i]; + snprintf(s_devs[count].name, + sizeof(s_devs[count].name), + "%.23s", + (d->name[0] != '\0') ? d->name : "(unknown)"); + s_devs[count].rssi = (int8_t)d->rssi; + snprintf(s_devs[count].mac, + sizeof(s_devs[count].mac), + "%02X:%02X:%02X:%02X:%02X:%02X", + d->addr[5], + d->addr[4], + d->addr[3], + d->addr[2], + d->addr[1], + d->addr[0]); + snprintf( + s_devs[count].vendor, sizeof(s_devs[count].vendor), "%.23s", oui_get_vendor(d->addr)); + count++; + } + } + s_dev_count = count; + ble_scanner_free_results(); +} + +static void ble_scan_task(void *arg) { + (void)arg; + + if (!ble_scanner_start()) { + s_dev_count = 0; + s_scan_state = SCAN_FAIL; + s_scanning = false; + lv_async_call(scan_done_cb, NULL); + vTaskDelete(NULL); + return; + } + + uint16_t dummy = 0; + uint32_t waited = 0; + while (ble_scanner_get_results(&dummy) == NULL && waited < SCAN_TIMEOUT_MS) { + vTaskDelay(pdMS_TO_TICKS(SCAN_POLL_MS)); + waited += SCAN_POLL_MS; + } + + if (waited >= SCAN_TIMEOUT_MS) { + ble_scanner_free_results(); + s_dev_count = 0; + s_scan_state = SCAN_FAIL; + } else { + collect_results(); + s_scan_state = SCAN_DONE; + } + + s_scanning = false; + lv_async_call(scan_done_cb, NULL); + vTaskDelete(NULL); +} + +static void tick_scan_status(void) { + if (!s_status) + return; + uint32_t el = lv_tick_get() - s_scan_start; + int dots = (el / DOT_CYCLE_MS) % 4; + char buf[20]; + snprintf(buf, + sizeof(buf), + "Searching%s", + dots == 1 ? "." + : dots == 2 ? ".." + : dots == 3 ? "..." + : ""); + lv_label_set_text(s_status, buf); +} + +static void radar_step(int dir) { + if (s_dev_count <= 0) + return; + s_sel = (s_sel + dir + s_dev_count) % s_dev_count; + ui_feedback(UI_FB_NAV); + update_selection(); +} + +static void scan_status_tick_cb(lv_timer_t *t) { + if (lv_screen_active() != s_screen) { + lv_timer_delete(t); + s_status_timer = NULL; + return; + } + if (s_scan_state == SCAN_RUNNING) + tick_scan_status(); +} + +static void ble_scan_input(const input_event_t *ev, void *ctx) { + (void)ctx; + const bool press = (ev->action == INPUT_ACTION_PRESS); + const bool nav = press || (ev->action == INPUT_ACTION_REPEAT); + + if (ev->button == INPUT_BTN_BACK) { + if (press) + ui_switch_screen(SCREEN_BLE_MENU); + return; + } + + if (s_scan_state == SCAN_RUNNING) { + if (ev->button == INPUT_BTN_LEFT && press) + ui_switch_screen(SCREEN_BLE_MENU); + return; + } + + if (s_showing_radar) { + switch (ev->button) { + case INPUT_BTN_UP: + case INPUT_BTN_LEFT: + if (nav) + radar_step(-1); + break; + case INPUT_BTN_DOWN: + case INPUT_BTN_RIGHT: + if (nav) + radar_step(1); + break; + case INPUT_BTN_OK: + if (press) + open_detail(s_sel); + break; + default: + break; + } + return; + } + + switch (ev->button) { + case INPUT_BTN_LEFT: + if (press) + ui_switch_screen(SCREEN_BLE_MENU); + break; + case INPUT_BTN_RIGHT: + if (press && !s_scanning) + ui_ble_scan_open(); + break; + default: + break; + } +} + +void ui_ble_scan_open(void) { + s_scan_state = SCAN_RUNNING; + s_dev_count = 0; + build_screen(); + + if (!s_scanning) { + s_scanning = true; + if (xTaskCreatePinnedToCore(ble_scan_task, + "ble_scan", + BLE_SCAN_TASK_STACK, + NULL, + BLE_SCAN_TASK_PRIO, + NULL, + SYS_CORE_RADIO) != pdPASS) { + s_scanning = false; + s_scan_state = SCAN_FAIL; + build_screen(); + notify(NOTIFY_WARNING, "Scan failed"); + } + } + + ESP_LOGI(TAG, "BLE scan screen opened"); +} diff --git a/firmware_p4/components/Applications/ui/screens/bluetooth/ble_skimmer_ui.c b/firmware_p4/components/Applications/ui/screens/bluetooth/ble_skimmer_ui.c new file mode 100644 index 000000000..9f38f562a --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/bluetooth/ble_skimmer_ui.c @@ -0,0 +1,265 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#include "ble_skimmer_ui.h" + +#include + +#include "esp_log.h" +#include "lvgl.h" + +#include "skimmer_detector.h" +#include "notify_ui.h" +#include "ui_chrome.h" +#include "ui_feedback.h" +#include "ui_manager.h" +#include "ui_theme.h" +#include "waves_ui.h" + +static const char *TAG = "BLE_SKIMMER_UI"; + +#define POLL_MS 800 + +#define MAC_LEN 18 +#define NAME_LEN 24 +#define DETAIL_LEN 48 +#define SUSPECTS_SHOWN 3 + +#define COL_DIM 0x8A8594 +#define COL_THREAT 0xFFB300 + +#define SKIM_ICON "/assets/icons/warning.bin" + +#define BODY_W 240 +#define BODY_H 256 +#define BODY_PAD 10 +#define BODY_GAP 8 +#define CARD_W 220 +#define CARD_H 52 +#define BANNER_H 46 +#define CARD_PAD 8 +#define TYPE_Y 2 +#define DETAIL_Y 22 +#define STATUS_Y_OFS 64 +#define SUB_Y_OFS 88 + +static lv_obj_t *s_screen = NULL; +static lv_timer_t *s_scan_timer = NULL; +static bool s_is_showing_results = false; + +static void ble_skimmer_input(const input_event_t *ev, void *ctx); +static void poll_cb(lv_timer_t *timer); +static void build_scanning_view(void); +static void build_results_view(void); + +static void stop_detector(void) { + if (s_scan_timer != NULL) { + lv_timer_delete(s_scan_timer); + s_scan_timer = NULL; + } + skimmer_detector_stop(); +} + +static lv_obj_t *body_container(void) { + lv_obj_t *body = lv_obj_create(s_screen); + lv_obj_set_size(body, BODY_W, BODY_H); + lv_obj_align(body, LV_ALIGN_TOP_MID, 0, UI_CHROME_HEADER_H); + lv_obj_remove_flag(body, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_bg_opa(body, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(body, 0, 0); + lv_obj_set_style_radius(body, 0, 0); + lv_obj_set_style_pad_all(body, BODY_PAD, 0); + lv_obj_set_flex_flow(body, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(body, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_set_style_pad_row(body, BODY_GAP, 0); + return body; +} + +static lv_obj_t *lit_card(lv_obj_t *parent, int w, int h) { + lv_obj_t *card = lv_obj_create(parent); + lv_obj_set_size(card, w, h); + lv_obj_remove_flag(card, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_radius(card, 13, 0); + lv_obj_set_style_bg_color(card, current_theme.bg_secondary, 0); + lv_obj_set_style_bg_opa(card, LV_OPA_COVER, 0); + lv_obj_set_style_bg_grad_dir(card, LV_GRAD_DIR_NONE, 0); + lv_obj_set_style_border_width(card, 1, 0); + lv_obj_set_style_border_color(card, current_theme.border_accent, 0); + lv_obj_set_style_border_opa(card, LV_OPA_COVER, 0); + lv_obj_set_style_pad_all(card, CARD_PAD, 0); + lv_obj_set_style_shadow_width(card, 18, 0); + lv_obj_set_style_shadow_color(card, current_theme.border_accent, 0); + lv_obj_set_style_shadow_opa(card, LV_OPA_40, 0); + lv_obj_set_style_shadow_spread(card, -4, 0); + return card; +} + +static void make_suspect_card(lv_obj_t *parent, const char *name, const char *mac, int8_t rssi) { + lv_obj_t *card = lit_card(parent, CARD_W, CARD_H); + + lv_obj_t *title = lv_label_create(card); + lv_label_set_text(title, name); + lv_obj_set_style_text_color(title, current_theme.text_main, 0); + lv_obj_set_style_text_font(title, &lv_font_montserrat_14, 0); + lv_obj_align(title, LV_ALIGN_TOP_LEFT, 0, TYPE_Y); + + lv_obj_t *detail = lv_label_create(card); + char buf[DETAIL_LEN]; + snprintf(buf, sizeof(buf), "%s %d dBm", mac, rssi); + lv_label_set_text(detail, buf); + lv_obj_set_style_text_color(detail, lv_color_hex(COL_DIM), 0); + lv_obj_set_style_text_font(detail, &lv_font_montserrat_12, 0); + lv_obj_align(detail, LV_ALIGN_TOP_LEFT, 0, DETAIL_Y); +} + +static void build_scanning_view(void) { + lv_obj_clean(s_screen); + + ui_chrome_header(s_screen, "SKIMMER SCAN", SKIM_ICON); + ui_chrome_footer(s_screen, "BACK Cancel"); + + waves_create(s_screen, LV_ALIGN_CENTER, 0, -20, NULL, SKIM_ICON); + + lv_obj_t *status = lv_label_create(s_screen); + lv_label_set_text(status, "Probing serial modules"); + lv_obj_set_style_text_color(status, current_theme.text_main, 0); + lv_obj_set_style_text_font(status, &lv_font_montserrat_14, 0); + lv_obj_set_style_text_align(status, LV_TEXT_ALIGN_CENTER, 0); + lv_obj_align(status, LV_ALIGN_CENTER, 0, STATUS_Y_OFS); + + lv_obj_t *sub = lv_label_create(s_screen); + lv_label_set_text(sub, "Looking for HC / RNBT chips"); + lv_obj_set_style_text_color(sub, lv_color_hex(COL_DIM), 0); + lv_obj_set_style_text_font(sub, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_align(sub, LV_TEXT_ALIGN_CENTER, 0); + lv_obj_align(sub, LV_ALIGN_CENTER, 0, SUB_Y_OFS); +} + +static void build_results_view(void) { + lv_obj_clean(s_screen); + + ui_chrome_header(s_screen, "SKIMMERS", SKIM_ICON); + ui_chrome_footer(s_screen, "BACK Back"); + + lv_obj_t *body = body_container(); + + lv_obj_t *banner = lit_card(body, CARD_W, BANNER_H); + lv_obj_set_style_bg_color(banner, lv_color_hex(COL_THREAT), 0); + lv_obj_set_style_bg_opa(banner, LV_OPA_20, 0); + lv_obj_set_style_border_color(banner, lv_color_hex(COL_THREAT), 0); + lv_obj_set_style_shadow_color(banner, lv_color_hex(COL_THREAT), 0); + + lv_obj_t *threat = lv_label_create(banner); + lv_label_set_text(threat, LV_SYMBOL_WARNING " SKIMMER SIGNATURE"); + lv_obj_set_style_text_color(threat, lv_color_hex(COL_THREAT), 0); + lv_obj_set_style_text_font(threat, &lv_font_montserrat_14, 0); + lv_obj_align(threat, LV_ALIGN_LEFT_MID, 0, -8); + + lv_obj_t *threat_sub = lv_label_create(banner); + lv_label_set_text(threat_sub, "Serial BT modules nearby"); + lv_obj_set_style_text_color(threat_sub, current_theme.text_main, 0); + lv_obj_set_style_text_font(threat_sub, &lv_font_montserrat_12, 0); + lv_obj_align(threat_sub, LV_ALIGN_LEFT_MID, 0, 12); + + uint16_t count = 0; + skimmer_detector_record_t *rec = skimmer_detector_get_results(&count); + int shown = 0; + for (uint16_t i = 0; i < count && shown < SUSPECTS_SHOWN && rec != NULL; i++) { + char name[NAME_LEN]; + char mac[MAC_LEN]; + snprintf(name, sizeof(name), "%.23s", (rec[i].name[0] != '\0') ? rec[i].name : "(unknown)"); + snprintf(mac, + sizeof(mac), + "%02X:%02X:%02X:%02X:%02X:%02X", + rec[i].addr[5], + rec[i].addr[4], + rec[i].addr[3], + rec[i].addr[2], + rec[i].addr[1], + rec[i].addr[0]); + make_suspect_card(body, name, mac, rec[i].rssi); + shown++; + } + + lv_obj_fade_in(body, 220, 0); + ui_feedback(UI_FB_READ); + notify(NOTIFY_WARNING, "Possible skimmer detected"); +} + +void ui_ble_skimmer_open(void) { + if (s_screen != NULL) { + lv_obj_del(s_screen); + s_screen = NULL; + } + s_scan_timer = NULL; + + s_screen = lv_obj_create(NULL); + lv_obj_set_style_bg_color(s_screen, current_theme.screen_base, 0); + lv_obj_set_style_bg_opa(s_screen, LV_OPA_COVER, 0); + lv_obj_remove_flag(s_screen, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_border_width(s_screen, 0, 0); + lv_obj_set_style_pad_all(s_screen, 0, 0); + + s_is_showing_results = false; + build_scanning_view(); + + ui_input_set_screen_handler(ble_skimmer_input, NULL); + + if (!skimmer_detector_start()) { + notify(NOTIFY_WARNING, "Radio unavailable"); + ESP_LOGE(TAG, "skimmer_detector_start failed"); + } else { + s_scan_timer = lv_timer_create(poll_cb, POLL_MS, NULL); + } + + ui_screen_load_owned(&s_screen, s_screen); + ESP_LOGI(TAG, "BLE skimmer screen opened"); +} + +static void poll_cb(lv_timer_t *timer) { + if (lv_screen_active() != s_screen) { + stop_detector(); + return; + } + + uint16_t count = 0; + (void)skimmer_detector_get_results(&count); + if (count == 0) + return; + + static uint16_t s_last_shown = 0; + if (!s_is_showing_results || count != s_last_shown) { + s_is_showing_results = true; + s_last_shown = count; + build_results_view(); + } +} + +static void ble_skimmer_input(const input_event_t *ev, void *ctx) { + (void)ctx; + const bool press = (ev->action == INPUT_ACTION_PRESS); + + switch (ev->button) { + case INPUT_BTN_BACK: + case INPUT_BTN_LEFT: + if (press) { + stop_detector(); + ui_switch_screen(SCREEN_BLE_DETECT_MENU); + } + break; + default: + break; + } +} diff --git a/firmware_p4/components/Applications/ui/screens/bluetooth/ble_sniffer_ui.c b/firmware_p4/components/Applications/ui/screens/bluetooth/ble_sniffer_ui.c new file mode 100644 index 000000000..bc1fc6336 --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/bluetooth/ble_sniffer_ui.c @@ -0,0 +1,257 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#include "ble_sniffer_ui.h" + +#include +#include + +#include "esp_log.h" +#include "freertos/FreeRTOS.h" +#include "lvgl.h" + +#include "ble_sniffer.h" +#include "ui_chrome.h" +#include "ui_feedback.h" +#include "ui_manager.h" +#include "ui_theme.h" + +static const char *TAG = "BLE_SNIFFER_UI"; + +#define FEED_TICK_MS 200 + +#define MAX_ROWS 7 +#define ROW_LEN 40 +#define FEED_BUF_LEN (MAX_ROWS * ROW_LEN) + +#define COL_DIM 0x8A8594 + +#define SNIFF_ICON "/assets/icons/monitoring.bin" + +#define STATUS_Y 48 +#define FRAMES_CARD_W 150 +#define FRAMES_CARD_H 34 +#define FRAMES_CARD_Y 70 +#define HEX_PANEL_W 224 +#define HEX_PANEL_H 150 +#define HEX_PANEL_Y 112 +#define HEX_PANEL_PAD 8 + +static lv_obj_t *s_screen = NULL; +static lv_timer_t *s_feed_timer = NULL; +static lv_obj_t *s_count_label = NULL; +static lv_obj_t *s_hex_label = NULL; + +static char s_rows[MAX_ROWS][ROW_LEN]; +static int s_row_count = 0; +static uint32_t s_frames = 0; +static uint32_t s_rendered_frames = 0; +static portMUX_TYPE s_lock = portMUX_INITIALIZER_UNLOCKED; + +static void ble_sniffer_input(const input_event_t *ev, void *ctx); +static void feed_tick_cb(lv_timer_t *timer); + +static void stop_sniffing(void) { + ble_sniffer_set_observer(NULL); + ble_sniffer_stop(); + if (s_feed_timer != NULL) { + lv_timer_delete(s_feed_timer); + s_feed_timer = NULL; + } +} + +static void sniffer_observer(const ble_sniffer_adv_t *adv) { + const uint8_t *addr = adv->addr; + char row[ROW_LEN]; + int n = snprintf(row, + sizeof(row), + "%02X:%02X:%02X:%02X:%02X:%02X %d", + addr[5], + addr[4], + addr[3], + addr[2], + addr[1], + addr[0], + adv->rssi); + for (int i = 0; i < adv->len && n > 0 && n < (int)sizeof(row) - 3; i++) + n += snprintf(row + n, sizeof(row) - n, " %02X", adv->data[i]); + + portENTER_CRITICAL(&s_lock); + if (s_row_count < MAX_ROWS) { + memcpy(s_rows[s_row_count], row, sizeof(row)); + s_row_count++; + } else { + for (int i = 1; i < MAX_ROWS; i++) + memcpy(s_rows[i - 1], s_rows[i], ROW_LEN); + memcpy(s_rows[MAX_ROWS - 1], row, sizeof(row)); + } + s_frames++; + portEXIT_CRITICAL(&s_lock); +} + +static lv_obj_t *lit_card(lv_obj_t *parent, int w, int h) { + lv_obj_t *card = lv_obj_create(parent); + lv_obj_set_size(card, w, h); + lv_obj_remove_flag(card, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_radius(card, 13, 0); + lv_obj_set_style_bg_color(card, current_theme.bg_secondary, 0); + lv_obj_set_style_bg_opa(card, LV_OPA_COVER, 0); + lv_obj_set_style_bg_grad_dir(card, LV_GRAD_DIR_NONE, 0); + lv_obj_set_style_border_width(card, 1, 0); + lv_obj_set_style_border_color(card, current_theme.border_accent, 0); + lv_obj_set_style_border_opa(card, LV_OPA_COVER, 0); + lv_obj_set_style_pad_all(card, 0, 0); + lv_obj_set_style_shadow_width(card, 18, 0); + lv_obj_set_style_shadow_color(card, current_theme.border_accent, 0); + lv_obj_set_style_shadow_opa(card, LV_OPA_40, 0); + lv_obj_set_style_shadow_spread(card, -4, 0); + return card; +} + +static void render_rows(void) { + if (s_hex_label == NULL) + return; + char rows[MAX_ROWS][ROW_LEN]; + int count; + portENTER_CRITICAL(&s_lock); + count = s_row_count; + memcpy(rows, s_rows, sizeof(rows)); + portEXIT_CRITICAL(&s_lock); + + char buf[FEED_BUF_LEN]; + size_t pos = 0; + for (int i = 0; i < count && pos < sizeof(buf); i++) { + int m = snprintf(buf + pos, sizeof(buf) - pos, (i == 0) ? "%s" : "\n%s", rows[i]); + if (m < 0) + break; + pos += (size_t)m; + } + if (count == 0) + snprintf(buf, sizeof(buf), "Waiting for frames..."); + lv_label_set_text(s_hex_label, buf); +} + +void ui_ble_sniffer_open(void) { + if (s_screen != NULL) { + lv_obj_del(s_screen); + s_screen = NULL; + } + s_feed_timer = NULL; + s_count_label = NULL; + s_hex_label = NULL; + portENTER_CRITICAL(&s_lock); + s_row_count = 0; + s_frames = 0; + portEXIT_CRITICAL(&s_lock); + s_rendered_frames = 0; + + s_screen = lv_obj_create(NULL); + lv_obj_set_style_bg_color(s_screen, current_theme.screen_base, 0); + lv_obj_set_style_bg_opa(s_screen, LV_OPA_COVER, 0); + lv_obj_remove_flag(s_screen, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_border_width(s_screen, 0, 0); + lv_obj_set_style_pad_all(s_screen, 0, 0); + + ui_chrome_header(s_screen, "SNIFFER", SNIFF_ICON); + ui_chrome_footer(s_screen, "BACK Stop"); + + lv_obj_t *status = lv_label_create(s_screen); + lv_label_set_text(status, "Sniffing..."); + lv_obj_set_style_text_color(status, current_theme.text_main, 0); + lv_obj_set_style_text_font(status, &lv_font_montserrat_14, 0); + lv_obj_set_style_text_align(status, LV_TEXT_ALIGN_CENTER, 0); + lv_obj_align(status, LV_ALIGN_TOP_MID, 0, STATUS_Y); + + lv_obj_t *card = lit_card(s_screen, FRAMES_CARD_W, FRAMES_CARD_H); + lv_obj_align(card, LV_ALIGN_TOP_MID, 0, FRAMES_CARD_Y); + + s_count_label = lv_label_create(card); + lv_label_set_text(s_count_label, "Frames: 0"); + lv_obj_set_style_text_color(s_count_label, current_theme.border_accent, 0); + lv_obj_set_style_text_font(s_count_label, &lv_font_montserrat_16, 0); + lv_obj_center(s_count_label); + + lv_obj_t *panel = lv_obj_create(s_screen); + lv_obj_set_size(panel, HEX_PANEL_W, HEX_PANEL_H); + lv_obj_align(panel, LV_ALIGN_TOP_MID, 0, HEX_PANEL_Y); + lv_obj_remove_flag(panel, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_radius(panel, 10, 0); + lv_obj_set_style_bg_color(panel, current_theme.bg_secondary, 0); + lv_obj_set_style_bg_opa(panel, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(panel, 1, 0); + lv_obj_set_style_border_color(panel, current_theme.border_accent, 0); + lv_obj_set_style_border_opa(panel, LV_OPA_40, 0); + lv_obj_set_style_pad_all(panel, HEX_PANEL_PAD, 0); + + s_hex_label = lv_label_create(panel); + lv_label_set_text(s_hex_label, ""); + lv_obj_set_style_text_color(s_hex_label, current_theme.text_main, 0); + lv_obj_set_style_text_font(s_hex_label, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_align(s_hex_label, LV_TEXT_ALIGN_LEFT, 0); + lv_obj_align(s_hex_label, LV_ALIGN_TOP_LEFT, 0, 0); + + ui_input_set_screen_handler(ble_sniffer_input, NULL); + + ble_sniffer_set_observer(sniffer_observer); + esp_err_t err = ble_sniffer_start(); + if (err != ESP_OK) { + ble_sniffer_set_observer(NULL); + lv_label_set_text(status, "Radio unavailable"); + lv_label_set_text(s_hex_label, "Could not start sniffer (C5?)"); + ESP_LOGE(TAG, "ble_sniffer_start failed: %s", esp_err_to_name(err)); + } else { + s_feed_timer = lv_timer_create(feed_tick_cb, FEED_TICK_MS, NULL); + } + + ui_feedback(UI_FB_SELECT); + ui_screen_load_owned(&s_screen, s_screen); + ESP_LOGI(TAG, "BLE sniffer screen opened"); +} + +static void feed_tick_cb(lv_timer_t *timer) { + if (lv_screen_active() != s_screen) { + stop_sniffing(); + return; + } + + uint32_t frames; + portENTER_CRITICAL(&s_lock); + frames = s_frames; + portEXIT_CRITICAL(&s_lock); + + if (frames == s_rendered_frames) + return; + s_rendered_frames = frames; + + render_rows(); + if (s_count_label != NULL) + lv_label_set_text_fmt(s_count_label, "Frames: %lu", (unsigned long)frames); +} + +static void ble_sniffer_input(const input_event_t *ev, void *ctx) { + (void)ctx; + const bool press = (ev->action == INPUT_ACTION_PRESS); + switch (ev->button) { + case INPUT_BTN_BACK: + case INPUT_BTN_LEFT: + if (press) { + stop_sniffing(); + ui_switch_screen(SCREEN_BLE_DETECT_MENU); + } + break; + default: + break; + } +} diff --git a/firmware_p4/components/Applications/ui/screens/bluetooth/ble_spam_names_ui.c b/firmware_p4/components/Applications/ui/screens/bluetooth/ble_spam_names_ui.c new file mode 100644 index 000000000..bd77163ef --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/bluetooth/ble_spam_names_ui.c @@ -0,0 +1,183 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#include "ble_spam_names_ui.h" + +#include +#include + +#include "lvgl.h" + +#include "esp_log.h" + +#include "bluetooth_service.h" +#include "keyboard_ui.h" +#include "menu_component_ui.h" +#include "msgbox_ui.h" +#include "notify_ui.h" +#include "ui_feedback.h" +#include "ui_manager.h" +#include "ui_theme.h" + +static const char *TAG = "BLE_SPAM_NAMES_UI"; + +#define NAMES_MAX 10 +#define NAME_LEN 24 + +#define NAME_ICON "/assets/icons/broadcast_on_personal.bin" +#define ADD_ICON "/assets/icons/add.bin" +#define HEAD_ICON "/assets/icons/edit_note.bin" + +#define COLOR_NAME 0x00E676 +#define COLOR_ADD 0xCC00FF + +static lv_obj_t *s_screen = NULL; +static menu_component_t s_menu; + +static char s_names[NAMES_MAX][NAME_LEN]; +static int s_count = 0; +static int s_del_index = -1; + +static void ble_spam_names_input(const input_event_t *ev, void *ctx); + +static void load_names(void) { + s_count = 0; + char **list = NULL; + size_t count = 0; + if (bluetooth_service_load_spam_list(&list, &count) != ESP_OK || list == NULL) + return; + for (size_t i = 0; i < count && s_count < NAMES_MAX; i++) { + if (list[i] != NULL) { + snprintf(s_names[s_count], NAME_LEN, "%s", list[i]); + s_count++; + } + } + bluetooth_service_free_spam_list(list, count); +} + +static void save_names(void) { + const char *ptrs[NAMES_MAX]; + for (int i = 0; i < s_count; i++) + ptrs[i] = s_names[i]; + esp_err_t err = bluetooth_service_save_spam_list(ptrs, (size_t)s_count); + if (err != ESP_OK) + ESP_LOGE(TAG, "save spam list failed: %s", esp_err_to_name(err)); +} + +static void build_screen(void) { + if (s_screen != NULL) { + lv_obj_del(s_screen); + s_screen = NULL; + } + + s_screen = lv_obj_create(NULL); + lv_obj_set_style_bg_color(s_screen, current_theme.screen_base, 0); + lv_obj_set_style_bg_opa(s_screen, LV_OPA_COVER, 0); + lv_obj_remove_flag(s_screen, LV_OBJ_FLAG_SCROLLABLE); + + s_menu = menu_component_create(s_screen, "SPAM NAMES", HEAD_ICON); + + for (int i = 0; i < s_count; i++) { + menu_component_add_item(&s_menu, NAME_ICON, s_names[i]); + menu_component_set_item_label_color(&s_menu, i, lv_color_hex(COLOR_NAME)); + } + + if (s_count < NAMES_MAX) { + menu_component_add_item(&s_menu, ADD_ICON, "+ Add name"); + menu_component_set_item_label_color(&s_menu, s_count, lv_color_hex(COLOR_ADD)); + } + + menu_component_set_hint(&s_menu, "OK add / delete BACK back"); + + ui_input_set_screen_handler(ble_spam_names_input, NULL); + + ui_screen_load_owned(&s_screen, s_screen); +} + +static void rebuild_async(void *unused) { + (void)unused; + if (ui_current_screen() == SCREEN_BLE_SPAM_NAMES) + build_screen(); +} + +static void on_kb_submit(const char *text, void *ud) { + (void)ud; + if (text != NULL && text[0] != '\0' && s_count < NAMES_MAX) { + snprintf(s_names[s_count], NAME_LEN, "%s", text); + s_count++; + save_names(); + ui_feedback(UI_FB_WRITE); + notify(NOTIFY_SAVED, "Name added"); + } + lv_async_call(rebuild_async, NULL); +} + +static void on_delete_confirm(bool confirm) { + if (confirm && s_del_index >= 0 && s_del_index < s_count) { + for (int i = s_del_index; i < s_count - 1; i++) + memmove(s_names[i], s_names[i + 1], NAME_LEN); + s_count--; + save_names(); + ui_feedback(UI_FB_WRITE); + notify(NOTIFY_INFO, "Name deleted"); + } + s_del_index = -1; + lv_async_call(rebuild_async, NULL); +} + +static void ble_spam_names_input(const input_event_t *ev, void *ctx) { + (void)ctx; + const bool press = (ev->action == INPUT_ACTION_PRESS); + const bool nav = press || (ev->action == INPUT_ACTION_REPEAT); + + switch (ev->button) { + case INPUT_BTN_BACK: + case INPUT_BTN_LEFT: + if (press) + ui_switch_screen(SCREEN_BLE_SPAM_SELECT); + break; + case INPUT_BTN_OK: + if (press) { + int sel = menu_component_get_selected(&s_menu); + if (sel >= 0 && sel < s_count) { + s_del_index = sel; + msgbox_open(LV_SYMBOL_TRASH, "Delete this name?", "Delete", "Cancel", on_delete_confirm); + } else if (sel == s_count && s_count < NAMES_MAX) { + keyboard_open(NULL, on_kb_submit, NULL); + } + } + break; + case INPUT_BTN_DOWN: + if (nav) { + menu_component_next(&s_menu); + ui_feedback(UI_FB_NAV); + } + break; + case INPUT_BTN_UP: + if (nav) { + menu_component_prev(&s_menu); + ui_feedback(UI_FB_NAV); + } + break; + default: + break; + } +} + +void ui_ble_spam_names_open(void) { + load_names(); + s_del_index = -1; + build_screen(); +} diff --git a/firmware_p4/components/Applications/ui/screens/bluetooth/ble_spam_ui.c b/firmware_p4/components/Applications/ui/screens/bluetooth/ble_spam_ui.c new file mode 100644 index 000000000..4b7050658 --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/bluetooth/ble_spam_ui.c @@ -0,0 +1,363 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#include "ble_spam_ui.h" + +#include + +#include "esp_log.h" +#include "esp_timer.h" +#include "lvgl.h" +#include "st7789.h" + +#include "canned_spam.h" +#include "intensity_bar_ui.h" +#include "notify_ui.h" +#include "ui_chrome.h" +#include "ui_feedback.h" +#include "ui_manager.h" +#include "ui_theme.h" + +static const char *TAG = "BLE_SPAM_UI"; + +#define RUN_TICK_MS 250 +#define COL_DIM 0x8A8594 +#define SPAM_ICON "/assets/icons/broadcast_on_personal.bin" + +#define SPAM_GRID_PAD 8 +#define SPAM_GRID_GAP 6 +#define SPAM_CARD_W 109 +#define SPAM_CARD_H 76 +#define SPAM_CARD_PAD 6 +#define SPAM_CARD_ROW_GAP 3 +#define SPAM_CARD_GLOW_ON 22 +#define SPAM_CARD_GLOW_OFF 16 +#define SPAM_EDIT_ICON_HEX 0xB89AFF + +static const uint32_t SPAM_ECO_COLOR[] = { + 0xE0E0E0, + 0xFF8A5B, + 0x4CBDD6, + 0x54D08A, + 0x54D08A, + 0xFFC24C, +}; +#define SPAM_ECO_COLOR_COUNT (sizeof(SPAM_ECO_COLOR) / sizeof(SPAM_ECO_COLOR[0])) + +#define SPAM_MAX_ATTACKS 12 +#define SPAM_CARD_MAX (SPAM_MAX_ATTACKS + 1) + +static int s_attack_count = 0; +static int s_custom_idx = 0; +static int s_spam_mode = 0; + +static lv_obj_t *lit_panel(lv_obj_t *parent, int w, int h) { + lv_obj_t *p = lv_obj_create(parent); + lv_obj_remove_flag(p, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(p, w, h); + lv_obj_set_style_radius(p, 13, 0); + lv_obj_set_style_bg_color(p, current_theme.bg_secondary, 0); + lv_obj_set_style_bg_opa(p, LV_OPA_COVER, 0); + lv_obj_set_style_bg_grad_dir(p, LV_GRAD_DIR_NONE, 0); + lv_obj_set_style_border_width(p, 1, 0); + lv_obj_set_style_border_color(p, current_theme.border_accent, 0); + lv_obj_set_style_shadow_color(p, current_theme.border_accent, 0); + lv_obj_set_style_shadow_width(p, 18, 0); + lv_obj_set_style_shadow_opa(p, LV_OPA_40, 0); + lv_obj_set_style_shadow_spread(p, -4, 0); + lv_obj_set_style_pad_all(p, 0, 0); + return p; +} + +static void fade_in(lv_obj_t *obj, uint32_t ms) { + if (obj != NULL) + lv_obj_fade_in(obj, ms, 0); +} + +static lv_obj_t *s_select_screen = NULL; +static lv_obj_t *s_spam_cards[SPAM_CARD_MAX]; +static int s_card_count = 0; +static int s_sel_idx = 0; + +static void ble_spam_select_input(const input_event_t *ev, void *ctx); + +static lv_obj_t *build_spam_card( + lv_obj_t *parent, const char *eco, uint32_t eco_hex, const char *title, const char *effect) { + lv_obj_t *c = lit_panel(parent, SPAM_CARD_W, SPAM_CARD_H); + lv_obj_set_style_pad_all(c, SPAM_CARD_PAD, 0); + lv_obj_set_flex_flow(c, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(c, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START); + lv_obj_set_style_pad_row(c, SPAM_CARD_ROW_GAP, 0); + + lv_obj_t *e = lv_label_create(c); + lv_label_set_text(e, eco); + lv_obj_set_style_text_font(e, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(e, lv_color_hex(eco_hex), 0); + + lv_obj_t *t = lv_label_create(c); + lv_obj_set_width(t, SPAM_CARD_W - 2 * SPAM_CARD_PAD); + lv_label_set_long_mode(t, LV_LABEL_LONG_DOT); + lv_label_set_text(t, title); + lv_obj_set_style_text_font(t, &lv_font_montserrat_14, 0); + lv_obj_set_style_text_color(t, current_theme.text_main, 0); + + lv_obj_t *d = lv_label_create(c); + lv_label_set_text(d, effect); + lv_obj_set_style_text_font(d, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(d, lv_color_hex(COL_DIM), 0); + return c; +} + +static void select_card(int idx) { + if (idx < 0 || idx >= s_card_count) + return; + for (int i = 0; i < s_card_count; i++) { + if (s_spam_cards[i] == NULL) + continue; + bool on = (i == idx); + lv_obj_set_style_border_width(s_spam_cards[i], on ? 2 : 1, 0); + lv_obj_set_style_shadow_width(s_spam_cards[i], on ? SPAM_CARD_GLOW_ON : SPAM_CARD_GLOW_OFF, 0); + lv_obj_set_style_shadow_opa(s_spam_cards[i], on ? LV_OPA_70 : LV_OPA_30, 0); + } + s_sel_idx = idx; +} + +void ui_ble_spam_select_open(void) { + if (s_select_screen != NULL) { + lv_obj_del(s_select_screen); + s_select_screen = NULL; + } + + s_attack_count = spam_get_attack_count(); + if (s_attack_count > SPAM_MAX_ATTACKS) + s_attack_count = SPAM_MAX_ATTACKS; + s_custom_idx = s_attack_count; + s_card_count = s_attack_count + 1; + + for (int i = 0; i < SPAM_CARD_MAX; i++) + s_spam_cards[i] = NULL; + + s_select_screen = lv_obj_create(NULL); + lv_obj_set_style_bg_color(s_select_screen, current_theme.screen_base, 0); + lv_obj_set_style_bg_opa(s_select_screen, LV_OPA_COVER, 0); + lv_obj_remove_flag(s_select_screen, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_border_width(s_select_screen, 0, 0); + lv_obj_set_style_pad_all(s_select_screen, 0, 0); + + ui_chrome_header(s_select_screen, "DEVICE SPAM", SPAM_ICON); + ui_chrome_footer(s_select_screen, "OK Start BACK Back"); + + lv_obj_t *grid = lv_obj_create(s_select_screen); + lv_obj_remove_flag(grid, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(grid, LCD_H_RES, LCD_V_RES - UI_CHROME_HEADER_H - UI_CHROME_FOOTER_H); + lv_obj_align(grid, LV_ALIGN_TOP_MID, 0, UI_CHROME_HEADER_H); + lv_obj_set_style_bg_opa(grid, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(grid, 0, 0); + lv_obj_set_style_pad_all(grid, SPAM_GRID_PAD, 0); + lv_obj_set_style_pad_row(grid, SPAM_GRID_GAP, 0); + lv_obj_set_style_pad_column(grid, SPAM_GRID_GAP, 0); + lv_obj_set_flex_flow(grid, LV_FLEX_FLOW_ROW_WRAP); + lv_obj_set_flex_align(grid, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START); + + for (int i = 0; i < s_attack_count; i++) { + const canned_spam_type_t *t = spam_get_attack_type(i); + const char *name = (t != NULL && t->name != NULL) ? t->name : "Attack"; + uint32_t color = SPAM_ECO_COLOR[i % SPAM_ECO_COLOR_COUNT]; + s_spam_cards[i] = build_spam_card(grid, "BLE", color, name, "advertise"); + } + s_spam_cards[s_custom_idx] = + build_spam_card(grid, "EDIT", SPAM_EDIT_ICON_HEX, "Custom Names", "your list"); + + s_sel_idx = 0; + select_card(0); + + ui_input_set_screen_handler(ble_spam_select_input, NULL); + + ui_screen_load_owned(&s_select_screen, s_select_screen); + fade_in(grid, 240); +} + +static void ble_spam_select_input(const input_event_t *ev, void *ctx) { + (void)ctx; + const bool press = (ev->action == INPUT_ACTION_PRESS); + const bool nav = press || (ev->action == INPUT_ACTION_REPEAT); + switch (ev->button) { + case INPUT_BTN_DOWN: + if (nav) { + select_card((s_sel_idx + 1) % s_card_count); + ui_feedback(UI_FB_NAV); + } + break; + case INPUT_BTN_UP: + if (nav) { + select_card((s_sel_idx - 1 + s_card_count) % s_card_count); + ui_feedback(UI_FB_NAV); + } + break; + case INPUT_BTN_BACK: + case INPUT_BTN_LEFT: + if (press) + ui_switch_screen(SCREEN_BLE_MENU); + break; + case INPUT_BTN_OK: + if (press) { + int sel = s_sel_idx; + ui_feedback(UI_FB_SELECT); + if (sel == s_custom_idx) { + ui_switch_screen(SCREEN_BLE_SPAM_NAMES); + } else { + if (sel >= 0 && sel < s_attack_count) + s_spam_mode = sel; + ui_switch_screen(SCREEN_BLE_SPAM); + } + } + break; + default: + break; + } +} + +static lv_obj_t *s_run_screen = NULL; +static lv_obj_t *s_run_hint = NULL; +static lv_timer_t *s_run_spam_timer = NULL; +static lv_obj_t *s_run_count_label = NULL; +static lv_obj_t *s_run_rate_label = NULL; +static intensity_bar_t s_run_intensity; +static int64_t s_run_start_us = 0; +static bool s_is_run_active = false; + +static void ble_spam_run_input(const input_event_t *ev, void *ctx); +static void run_spam_tick_cb(lv_timer_t *timer); + +static void run_stop(void) { + if (s_run_spam_timer != NULL) { + lv_timer_delete(s_run_spam_timer); + s_run_spam_timer = NULL; + } + if (s_is_run_active) { + spam_stop(); + s_is_run_active = false; + } +} + +void ui_ble_spam_open(void) { + if (s_run_screen != NULL) { + lv_obj_del(s_run_screen); + s_run_screen = NULL; + } + s_run_spam_timer = NULL; + s_is_run_active = false; + s_run_start_us = esp_timer_get_time(); + + s_run_screen = lv_obj_create(NULL); + lv_obj_set_style_bg_color(s_run_screen, current_theme.screen_base, 0); + lv_obj_set_style_bg_opa(s_run_screen, LV_OPA_COVER, 0); + lv_obj_remove_flag(s_run_screen, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_border_width(s_run_screen, 0, 0); + lv_obj_set_style_pad_all(s_run_screen, 0, 0); + + ui_chrome_header(s_run_screen, "DEVICE SPAM", SPAM_ICON); + s_run_hint = ui_chrome_footer(s_run_screen, "BACK Stop"); + + lv_obj_t *body = lv_obj_create(s_run_screen); + lv_obj_remove_flag(body, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(body, LCD_H_RES, LCD_V_RES - UI_CHROME_HEADER_H - UI_CHROME_FOOTER_H); + lv_obj_align(body, LV_ALIGN_TOP_LEFT, 0, UI_CHROME_HEADER_H); + lv_obj_set_style_bg_opa(body, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(body, 0, 0); + lv_obj_set_style_pad_all(body, 0, 0); + + lv_obj_t *status = lv_label_create(body); + lv_label_set_text(status, "Spamming..."); + lv_obj_set_style_text_color(status, current_theme.text_main, 0); + lv_obj_set_style_text_font(status, &lv_font_montserrat_14, 0); + lv_obj_align(status, LV_ALIGN_TOP_MID, 0, 8); + + const canned_spam_type_t *mt = spam_get_attack_type(s_spam_mode); + lv_obj_t *mode = lv_label_create(body); + lv_label_set_text_fmt(mode, "Mode: %s", (mt != NULL && mt->name) ? mt->name : "?"); + lv_obj_set_style_text_color(mode, lv_color_hex(COL_DIM), 0); + lv_obj_set_style_text_font(mode, &lv_font_montserrat_12, 0); + lv_obj_align(mode, LV_ALIGN_TOP_MID, 0, 34); + + lv_obj_t *card = lit_panel(body, 168, 50); + lv_obj_align(card, LV_ALIGN_TOP_MID, 0, 64); + s_run_count_label = lv_label_create(card); + lv_label_set_text(s_run_count_label, "00:00"); + lv_obj_set_style_text_color(s_run_count_label, current_theme.border_accent, 0); + lv_obj_set_style_text_font(s_run_count_label, &lv_font_montserrat_16, 0); + lv_obj_center(s_run_count_label); + + intensity_bar_create(&s_run_intensity, body); + lv_obj_align(s_run_intensity.obj, LV_ALIGN_TOP_MID, 0, 138); + intensity_bar_set(&s_run_intensity, 5); + + s_run_rate_label = lv_label_create(body); + lv_label_set_text(s_run_rate_label, "Broadcasting"); + lv_obj_set_style_text_color(s_run_rate_label, lv_color_hex(COL_DIM), 0); + lv_obj_set_style_text_font(s_run_rate_label, &lv_font_montserrat_12, 0); + lv_obj_align(s_run_rate_label, LV_ALIGN_TOP_MID, 0, 180); + + esp_err_t err = spam_start(s_spam_mode); + if (err == ESP_OK) { + s_is_run_active = true; + s_run_start_us = esp_timer_get_time(); + } else { + lv_label_set_text(status, "Spam unavailable"); + lv_label_set_text(s_run_rate_label, "Radio not running"); + intensity_bar_set(&s_run_intensity, 0); + ESP_LOGE(TAG, "spam_start(%d) failed: %s", s_spam_mode, esp_err_to_name(err)); + } + + fade_in(status, 200); + fade_in(mode, 240); + fade_in(card, 280); + fade_in(s_run_intensity.obj, 320); + fade_in(s_run_rate_label, 340); + + ui_feedback(UI_FB_EMULATE); + + ui_input_set_screen_handler(ble_spam_run_input, NULL); + if (s_is_run_active) + s_run_spam_timer = lv_timer_create(run_spam_tick_cb, RUN_TICK_MS, NULL); + + ui_screen_load_owned(&s_run_screen, s_run_screen); +} + +static void run_spam_tick_cb(lv_timer_t *timer) { + if (lv_screen_active() != s_run_screen) { + run_stop(); + return; + } + if (s_run_count_label == NULL) + return; + int secs = (int)((esp_timer_get_time() - s_run_start_us) / 1000000); + lv_label_set_text_fmt(s_run_count_label, "%02d:%02d", secs / 60, secs % 60); +} + +static void ble_spam_run_input(const input_event_t *ev, void *ctx) { + (void)ctx; + const bool press = (ev->action == INPUT_ACTION_PRESS); + switch (ev->button) { + case INPUT_BTN_BACK: + if (press) { + run_stop(); + ui_switch_screen(SCREEN_BLE_SPAM_SELECT); + } + break; + default: + break; + } +} diff --git a/firmware_p4/components/Applications/ui/screens/bluetooth/ble_track_device_ui.c b/firmware_p4/components/Applications/ui/screens/bluetooth/ble_track_device_ui.c new file mode 100644 index 000000000..3e528882f --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/bluetooth/ble_track_device_ui.c @@ -0,0 +1,284 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#include "ble_track_device_ui.h" + +#include +#include + +#include "esp_log.h" +#include "lvgl.h" + +#include "ble_scanner.h" +#include "ble_tracker.h" +#include "ui_chrome.h" +#include "ui_feedback.h" +#include "ui_manager.h" +#include "ui_theme.h" + +static const char *TAG = "BLE_TRACK_DEV"; + +#define POLL_MS 300 +#define SCAN_POLL_MS 200 +#define SCAN_TIMEOUT_MS 15000 + +#define BLE_ADDR_LEN 6 +#define NAME_LEN 24 + +#define TRACK_ICON "/assets/icons/bluetooth_searching.bin" + +#define ARC_SIZE 140 +#define ARC_WIDTH 15 +#define ARC_ROTATION 270 +#define ARC_TOP_Y 50 + +#define RSSI_MIN (-95) +#define RSSI_MAX (-35) +#define RSSI_START (-75) +#define RSSI_CLOSE (-48) + +#define COL_COLD 0x2A6FDB +#define COL_WARM 0xFFB300 +#define COL_HOT 0x00E676 +#define COL_DIM 0x8A8594 + +typedef enum { PHASE_SCAN, PHASE_TRACK, PHASE_NOTGT } track_phase_t; + +static lv_obj_t *s_screen = NULL; +static lv_obj_t *s_arc = NULL; +static lv_obj_t *s_dbm_label = NULL; +static lv_obj_t *s_caption = NULL; +static lv_obj_t *s_target_lbl = NULL; +static lv_timer_t *s_timer = NULL; + +static track_phase_t s_phase = PHASE_SCAN; +static int s_rssi = RSSI_START; +static int s_prev_rssi = RSSI_START; +static uint32_t s_scan_waited = 0; +static uint8_t s_target_addr[BLE_ADDR_LEN]; +static char s_target_name[NAME_LEN]; + +static void ble_track_device_input(const input_event_t *ev, void *ctx); +static void track_timer_cb(lv_timer_t *t); + +static void stop_all(void) { + if (s_timer != NULL) { + lv_timer_delete(s_timer); + s_timer = NULL; + } + if (s_phase == PHASE_TRACK) + ble_tracker_stop(); +} + +static int rssi_to_pct(int rssi) { + int pct = (rssi - RSSI_MIN) * 100 / (RSSI_MAX - RSSI_MIN); + if (pct < 0) + return 0; + if (pct > 100) + return 100; + return pct; +} + +static lv_color_t strength_color(int rssi) { + if (rssi >= RSSI_CLOSE) + return lv_color_hex(COL_HOT); + if (rssi >= RSSI_START) + return lv_color_hex(COL_WARM); + return lv_color_hex(COL_COLD); +} + +static void apply_reading(int trend) { + if (s_arc != NULL) { + lv_arc_set_value(s_arc, rssi_to_pct(s_rssi)); + lv_obj_set_style_arc_color(s_arc, strength_color(s_rssi), LV_PART_INDICATOR); + } + if (s_dbm_label != NULL) + lv_label_set_text_fmt(s_dbm_label, "%d dBm", s_rssi); + if (s_caption != NULL) { + if (s_rssi >= RSSI_CLOSE) { + lv_label_set_text(s_caption, "Very close!"); + lv_obj_set_style_text_color(s_caption, lv_color_hex(COL_HOT), 0); + } else if (trend > 0) { + lv_label_set_text(s_caption, LV_SYMBOL_UP " Warmer"); + lv_obj_set_style_text_color(s_caption, lv_color_hex(COL_WARM), 0); + } else if (trend < 0) { + lv_label_set_text(s_caption, LV_SYMBOL_DOWN " Colder"); + lv_obj_set_style_text_color(s_caption, lv_color_hex(COL_COLD), 0); + } else { + lv_label_set_text(s_caption, "Hold steady"); + lv_obj_set_style_text_color(s_caption, lv_color_hex(COL_DIM), 0); + } + } +} + +void ui_ble_track_device_open(void) { + if (s_screen != NULL) { + lv_obj_del(s_screen); + s_screen = NULL; + s_arc = NULL; + } + s_rssi = RSSI_START; + s_prev_rssi = RSSI_START; + s_phase = PHASE_SCAN; + s_scan_waited = 0; + s_timer = NULL; + s_target_name[0] = '\0'; + + s_screen = lv_obj_create(NULL); + lv_obj_set_style_bg_color(s_screen, current_theme.screen_base, 0); + lv_obj_set_style_bg_opa(s_screen, LV_OPA_COVER, 0); + lv_obj_remove_flag(s_screen, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_border_width(s_screen, 0, 0); + lv_obj_set_style_pad_all(s_screen, 0, 0); + + ui_chrome_header(s_screen, "TRACK DEVICE", TRACK_ICON); + ui_chrome_footer(s_screen, "BACK Exit"); + + s_target_lbl = lv_label_create(s_screen); + lv_label_set_text(s_target_lbl, "Scanning for target..."); + lv_obj_set_style_text_font(s_target_lbl, &lv_font_montserrat_14, 0); + lv_obj_set_style_text_color(s_target_lbl, current_theme.text_main, 0); + lv_obj_align(s_target_lbl, LV_ALIGN_TOP_MID, 0, UI_CHROME_HEADER_H + 2); + + s_arc = lv_arc_create(s_screen); + lv_obj_set_size(s_arc, ARC_SIZE, ARC_SIZE); + lv_obj_align(s_arc, LV_ALIGN_TOP_MID, 0, ARC_TOP_Y + 20); + lv_arc_set_rotation(s_arc, ARC_ROTATION); + lv_arc_set_bg_angles(s_arc, 0, 360); + lv_arc_set_range(s_arc, 0, 100); + lv_arc_set_value(s_arc, rssi_to_pct(s_rssi)); + lv_obj_remove_flag(s_arc, LV_OBJ_FLAG_CLICKABLE); + lv_obj_remove_style(s_arc, NULL, LV_PART_KNOB); + lv_obj_set_style_arc_width(s_arc, ARC_WIDTH, LV_PART_MAIN); + lv_obj_set_style_arc_color(s_arc, current_theme.bg_secondary, LV_PART_MAIN); + lv_obj_set_style_arc_rounded(s_arc, true, LV_PART_MAIN); + lv_obj_set_style_arc_width(s_arc, ARC_WIDTH, LV_PART_INDICATOR); + lv_obj_set_style_arc_rounded(s_arc, true, LV_PART_INDICATOR); + + s_dbm_label = lv_label_create(s_screen); + lv_obj_set_style_text_font(s_dbm_label, &lv_font_montserrat_16, 0); + lv_obj_set_style_text_color(s_dbm_label, current_theme.text_main, 0); + lv_obj_align_to(s_dbm_label, s_arc, LV_ALIGN_CENTER, 0, -6); + + s_caption = lv_label_create(s_screen); + lv_obj_set_style_text_font(s_caption, &lv_font_montserrat_14, 0); + lv_obj_align_to(s_caption, s_arc, LV_ALIGN_CENTER, 0, 16); + + lv_obj_t *tip = lv_label_create(s_screen); + lv_label_set_text(tip, "Move around to home in"); + lv_obj_set_style_text_font(tip, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(tip, lv_color_hex(COL_DIM), 0); + lv_obj_align(tip, LV_ALIGN_BOTTOM_MID, 0, -(UI_CHROME_FOOTER_H + 14)); + + if (s_caption != NULL) { + lv_label_set_text(s_caption, "Finding devices..."); + lv_obj_set_style_text_color(s_caption, lv_color_hex(COL_DIM), 0); + } + lv_obj_fade_in(s_screen, 240, 0); + + ui_input_set_screen_handler(ble_track_device_input, NULL); + + if (!ble_scanner_start()) { + s_phase = PHASE_NOTGT; + lv_label_set_text(s_target_lbl, "Radio unavailable"); + ESP_LOGE(TAG, "ble_scanner_start failed"); + } else { + s_timer = lv_timer_create(track_timer_cb, SCAN_POLL_MS, NULL); + } + + ui_screen_load_owned(&s_screen, s_screen); +} + +static bool select_target(void) { + uint16_t n = 0; + bluetooth_service_scan_result_t *res = ble_scanner_get_results(&n); + if (res == NULL || n == 0) + return false; + int best = 0; + for (uint16_t i = 1; i < n; i++) + if (res[i].rssi > res[best].rssi) + best = i; + memcpy(s_target_addr, res[best].addr, BLE_ADDR_LEN); + snprintf(s_target_name, + sizeof(s_target_name), + "%.*s", + NAME_LEN - 1, + (res[best].name[0] != '\0') ? res[best].name : "(unknown)"); + s_rssi = res[best].rssi; + s_prev_rssi = s_rssi; + return true; +} + +static void track_timer_cb(lv_timer_t *t) { + if (lv_screen_active() != s_screen) { + stop_all(); + return; + } + + if (s_phase == PHASE_SCAN) { + uint16_t dummy = 0; + s_scan_waited += SCAN_POLL_MS; + if (ble_scanner_get_results(&dummy) == NULL && s_scan_waited < SCAN_TIMEOUT_MS) + return; + + bool ok = select_target(); + ble_scanner_free_results(); + if (!ok) { + s_phase = PHASE_NOTGT; + lv_label_set_text(s_target_lbl, "No device to track"); + if (s_caption != NULL) + lv_label_set_text(s_caption, "Nothing found"); + return; + } + + if (ble_tracker_start(s_target_addr) != ESP_OK) { + s_phase = PHASE_NOTGT; + lv_label_set_text(s_target_lbl, "Track failed"); + return; + } + s_phase = PHASE_TRACK; + lv_label_set_text_fmt(s_target_lbl, "Target: %s", s_target_name); + lv_timer_set_period(t, POLL_MS); + apply_reading(0); + return; + } + + if (s_phase == PHASE_TRACK) { + s_prev_rssi = s_rssi; + s_rssi = ble_tracker_get_rssi(); + if (s_rssi < RSSI_MIN) + s_rssi = RSSI_MIN; + if (s_rssi > RSSI_MAX) + s_rssi = RSSI_MAX; + int trend = (s_rssi > s_prev_rssi) ? 1 : (s_rssi < s_prev_rssi) ? -1 : 0; + apply_reading(trend); + } +} + +static void ble_track_device_input(const input_event_t *ev, void *ctx) { + (void)ctx; + const bool press = (ev->action == INPUT_ACTION_PRESS); + switch (ev->button) { + case INPUT_BTN_BACK: + case INPUT_BTN_LEFT: + if (press) { + stop_all(); + ui_switch_screen(SCREEN_BLE_DETECT_MENU); + } + break; + default: + break; + } +} diff --git a/firmware_p4/components/Applications/ui/screens/bluetooth/ble_tracker_ui.c b/firmware_p4/components/Applications/ui/screens/bluetooth/ble_tracker_ui.c new file mode 100644 index 000000000..36bb03ad0 --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/bluetooth/ble_tracker_ui.c @@ -0,0 +1,279 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#include "ble_tracker_ui.h" + +#include + +#include "esp_log.h" +#include "lvgl.h" + +#include "tracker_detector.h" +#include "notify_ui.h" +#include "ui_chrome.h" +#include "ui_feedback.h" +#include "ui_manager.h" +#include "ui_theme.h" +#include "waves_ui.h" + +static const char *TAG = "BLE_TRACKER_UI"; + +#define POLL_MS 800 + +#define MAC_LEN 18 +#define DETAIL_LEN 48 +#define TRACKERS_SHOWN 3 + +#define COL_DIM 0x8A8594 +#define COL_ALERT 0xFF5252 + +#define TRACK_ICON "/assets/icons/troubleshoot.bin" + +#define BODY_W 240 +#define BODY_H 256 +#define BODY_PAD 10 +#define BODY_GAP 8 +#define CARD_W 220 +#define CARD_H 52 +#define BANNER_H 46 +#define CARD_PAD 8 +#define TYPE_Y 2 +#define DETAIL_Y 22 +#define STATUS_Y_OFS 64 +#define SUB_Y_OFS 88 + +static lv_obj_t *s_screen = NULL; +static lv_timer_t *s_poll_timer = NULL; +static bool s_is_showing_results = false; + +static lv_obj_t *s_banner = NULL; + +static void ble_tracker_input(const input_event_t *ev, void *ctx); +static void poll_cb(lv_timer_t *timer); +static void build_scanning_view(void); +static void build_results_view(void); + +static void stop_detector(void) { + if (s_poll_timer != NULL) { + lv_timer_delete(s_poll_timer); + s_poll_timer = NULL; + } + tracker_detector_stop(); +} + +static void clear_screen_children(void) { + lv_obj_clean(s_screen); + s_banner = NULL; +} + +static lv_obj_t *body_container(void) { + lv_obj_t *body = lv_obj_create(s_screen); + lv_obj_set_size(body, BODY_W, BODY_H); + lv_obj_align(body, LV_ALIGN_TOP_MID, 0, UI_CHROME_HEADER_H); + lv_obj_remove_flag(body, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_bg_opa(body, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(body, 0, 0); + lv_obj_set_style_radius(body, 0, 0); + lv_obj_set_style_pad_all(body, BODY_PAD, 0); + lv_obj_set_flex_flow(body, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(body, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_set_style_pad_row(body, BODY_GAP, 0); + return body; +} + +static lv_obj_t *lit_card(lv_obj_t *parent, int w, int h) { + lv_obj_t *card = lv_obj_create(parent); + lv_obj_set_size(card, w, h); + lv_obj_remove_flag(card, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_radius(card, 13, 0); + lv_obj_set_style_bg_color(card, current_theme.bg_secondary, 0); + lv_obj_set_style_bg_opa(card, LV_OPA_COVER, 0); + lv_obj_set_style_bg_grad_dir(card, LV_GRAD_DIR_NONE, 0); + lv_obj_set_style_border_width(card, 1, 0); + lv_obj_set_style_border_color(card, current_theme.border_accent, 0); + lv_obj_set_style_border_opa(card, LV_OPA_COVER, 0); + lv_obj_set_style_pad_all(card, CARD_PAD, 0); + lv_obj_set_style_shadow_width(card, 18, 0); + lv_obj_set_style_shadow_color(card, current_theme.border_accent, 0); + lv_obj_set_style_shadow_opa(card, LV_OPA_40, 0); + lv_obj_set_style_shadow_spread(card, -4, 0); + return card; +} + +static void make_tracker_card(lv_obj_t *parent, const char *type, const char *mac, int8_t rssi) { + lv_obj_t *card = lit_card(parent, CARD_W, CARD_H); + + lv_obj_t *name = lv_label_create(card); + lv_label_set_text(name, type); + lv_obj_set_style_text_color(name, current_theme.text_main, 0); + lv_obj_set_style_text_font(name, &lv_font_montserrat_14, 0); + lv_obj_align(name, LV_ALIGN_TOP_LEFT, 0, TYPE_Y); + + lv_obj_t *detail = lv_label_create(card); + char buf[DETAIL_LEN]; + snprintf(buf, sizeof(buf), "%s %d dBm", mac, rssi); + lv_label_set_text(detail, buf); + lv_obj_set_style_text_color(detail, lv_color_hex(COL_DIM), 0); + lv_obj_set_style_text_font(detail, &lv_font_montserrat_12, 0); + lv_obj_align(detail, LV_ALIGN_TOP_LEFT, 0, DETAIL_Y); +} + +static void build_scanning_view(void) { + clear_screen_children(); + + ui_chrome_header(s_screen, "TRACKER SCAN", TRACK_ICON); + ui_chrome_footer(s_screen, "BACK Cancel"); + + waves_create(s_screen, LV_ALIGN_CENTER, 0, -20, NULL, TRACK_ICON); + + lv_obj_t *status = lv_label_create(s_screen); + lv_label_set_text(status, "Scanning for trackers"); + lv_obj_set_style_text_color(status, current_theme.text_main, 0); + lv_obj_set_style_text_font(status, &lv_font_montserrat_14, 0); + lv_obj_set_style_text_align(status, LV_TEXT_ALIGN_CENTER, 0); + lv_obj_align(status, LV_ALIGN_CENTER, 0, STATUS_Y_OFS); + + lv_obj_t *sub = lv_label_create(s_screen); + lv_label_set_text(sub, "Hold still while we listen"); + lv_obj_set_style_text_color(sub, lv_color_hex(COL_DIM), 0); + lv_obj_set_style_text_font(sub, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_align(sub, LV_TEXT_ALIGN_CENTER, 0); + lv_obj_align(sub, LV_ALIGN_CENTER, 0, SUB_Y_OFS); +} + +static void build_results_view(void) { + clear_screen_children(); + + ui_chrome_header(s_screen, "TRACKERS", TRACK_ICON); + ui_chrome_footer(s_screen, "BACK Back"); + + lv_obj_t *body = body_container(); + + s_banner = lit_card(body, CARD_W, BANNER_H); + lv_obj_set_style_bg_color(s_banner, lv_color_hex(COL_ALERT), 0); + lv_obj_set_style_bg_opa(s_banner, LV_OPA_20, 0); + lv_obj_set_style_border_color(s_banner, lv_color_hex(COL_ALERT), 0); + lv_obj_set_style_shadow_color(s_banner, lv_color_hex(COL_ALERT), 0); + lv_obj_add_flag(s_banner, LV_OBJ_FLAG_HIDDEN); + + lv_obj_t *alert = lv_label_create(s_banner); + lv_label_set_text(alert, LV_SYMBOL_WARNING " TRACKER NEARBY"); + lv_obj_set_style_text_color(alert, lv_color_hex(COL_ALERT), 0); + lv_obj_set_style_text_font(alert, &lv_font_montserrat_14, 0); + lv_obj_align(alert, LV_ALIGN_LEFT_MID, 0, -8); + + lv_obj_t *alert_sub = lv_label_create(s_banner); + lv_obj_set_style_text_color(alert_sub, current_theme.text_main, 0); + lv_obj_set_style_text_font(alert_sub, &lv_font_montserrat_12, 0); + lv_obj_align(alert_sub, LV_ALIGN_LEFT_MID, 0, 12); + + uint16_t count = 0; + tracker_detector_record_t *rec = tracker_detector_get_results(&count); + int shown = 0; + for (uint16_t i = 0; i < count && shown < TRACKERS_SHOWN && rec != NULL; i++) { + char mac[MAC_LEN]; + const char *type = (rec[i].type_str[0] != '\0') ? rec[i].type_str : "Tracker"; + snprintf(mac, + sizeof(mac), + "%02X:%02X:%02X:%02X:%02X:%02X", + rec[i].addr[5], + rec[i].addr[4], + rec[i].addr[3], + rec[i].addr[2], + rec[i].addr[1], + rec[i].addr[0]); + make_tracker_card(body, type, mac, rec[i].rssi); + if (shown == 0) + lv_label_set_text_fmt(alert_sub, "%s in range", type); + shown++; + } + + if (shown > 0) { + lv_obj_remove_flag(s_banner, LV_OBJ_FLAG_HIDDEN); + lv_obj_fade_in(s_banner, 200, 0); + } + + lv_obj_fade_in(body, 220, 0); +} + +void ui_ble_tracker_open(void) { + if (s_screen != NULL) { + lv_obj_del(s_screen); + s_screen = NULL; + } + s_poll_timer = NULL; + s_banner = NULL; + s_is_showing_results = false; + + s_screen = lv_obj_create(NULL); + lv_obj_set_style_bg_color(s_screen, current_theme.screen_base, 0); + lv_obj_set_style_bg_opa(s_screen, LV_OPA_COVER, 0); + lv_obj_remove_flag(s_screen, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_border_width(s_screen, 0, 0); + lv_obj_set_style_pad_all(s_screen, 0, 0); + + build_scanning_view(); + + ui_input_set_screen_handler(ble_tracker_input, NULL); + + if (!tracker_detector_start()) { + notify(NOTIFY_WARNING, "Radio unavailable"); + ESP_LOGE(TAG, "tracker_detector_start failed"); + } else { + s_poll_timer = lv_timer_create(poll_cb, POLL_MS, NULL); + } + + ui_screen_load_owned(&s_screen, s_screen); + ESP_LOGI(TAG, "BLE tracker screen opened"); +} + +static void poll_cb(lv_timer_t *timer) { + if (lv_screen_active() != s_screen) { + stop_detector(); + return; + } + + uint16_t count = 0; + (void)tracker_detector_get_results(&count); + if (count == 0) + return; + + static uint16_t s_last_shown = 0; + if (!s_is_showing_results || count != s_last_shown) { + bool first = !s_is_showing_results; + s_is_showing_results = true; + s_last_shown = count; + build_results_view(); + if (first) + ui_feedback(UI_FB_READ); + } +} + +static void ble_tracker_input(const input_event_t *ev, void *ctx) { + (void)ctx; + const bool press = (ev->action == INPUT_ACTION_PRESS); + + switch (ev->button) { + case INPUT_BTN_BACK: + case INPUT_BTN_LEFT: + if (press) { + stop_detector(); + ui_switch_screen(SCREEN_BLE_DETECT_MENU); + } + break; + default: + break; + } +} diff --git a/firmware_p4/components/Applications/ui/screens/bluetooth/gatt_explorer_ui.c b/firmware_p4/components/Applications/ui/screens/bluetooth/gatt_explorer_ui.c new file mode 100644 index 000000000..85dee9c98 --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/bluetooth/gatt_explorer_ui.c @@ -0,0 +1,221 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#include "gatt_explorer_ui.h" + +#include + +#include "esp_random.h" +#include "lvgl.h" + +#include "menu_component_ui.h" +#include "notify_ui.h" +#include "ui_feedback.h" +#include "ui_manager.h" +#include "ui_theme.h" + +#define LABEL_LEN 40 + +#define GATT_ICON "/assets/icons/hub.bin" +#define SERVICE_ICON "/assets/icons/folder.bin" +#define CHAR_ICON "/assets/icons/key.bin" + +typedef struct { + const char *name; + const char *uuid; + const char *props; +} gatt_char_t; + +typedef struct { + const char *name; + const char *uuid; + const gatt_char_t *chars; + int char_count; +} gatt_service_t; + +static const gatt_char_t GENERIC_ACCESS_CHARS[] = { + {"Device Name", "0x2A00", "R/W"}, + {"Appearance", "0x2A01", "R"}, + {"Conn Params", "0x2A04", "R"}, +}; + +static const gatt_char_t DEVICE_INFO_CHARS[] = { + {"Manufacturer", "0x2A29", "R"}, + {"Model Number", "0x2A24", "R"}, + {"Firmware Rev", "0x2A26", "R"}, + {"Serial Number", "0x2A25", "R"}, +}; + +static const gatt_char_t BATTERY_CHARS[] = { + {"Battery Level", "0x2A19", "R/N"}, +}; + +static const gatt_char_t HID_CHARS[] = { + {"HID Info", "0x2A4A", "R"}, + {"Report Map", "0x2A4B", "R"}, + {"HID Report", "0x2A4D", "R/W/N"}, + {"Protocol Mode", "0x2A4E", "R/W"}, +}; + +static const gatt_char_t HEART_RATE_CHARS[] = { + {"HR Measurement", "0x2A37", "N"}, + {"Body Sensor Loc", "0x2A38", "R"}, + {"HR Control Pt", "0x2A39", "W"}, +}; + +static const gatt_service_t SERVICES[] = { + {"Generic Access", + "0x1800", + GENERIC_ACCESS_CHARS, + (int)(sizeof(GENERIC_ACCESS_CHARS) / sizeof(GENERIC_ACCESS_CHARS[0]))}, + {"Device Information", + "0x180A", + DEVICE_INFO_CHARS, + (int)(sizeof(DEVICE_INFO_CHARS) / sizeof(DEVICE_INFO_CHARS[0]))}, + {"Battery Service", + "0x180F", + BATTERY_CHARS, + (int)(sizeof(BATTERY_CHARS) / sizeof(BATTERY_CHARS[0]))}, + {"Human Interface Device", + "0x1812", + HID_CHARS, + (int)(sizeof(HID_CHARS) / sizeof(HID_CHARS[0]))}, + {"Heart Rate", + "0x180D", + HEART_RATE_CHARS, + (int)(sizeof(HEART_RATE_CHARS) / sizeof(HEART_RATE_CHARS[0]))}, +}; +#define SERVICES_COUNT ((int)(sizeof(SERVICES) / sizeof(SERVICES[0]))) + +typedef enum { + LEVEL_SERVICES = 0, + LEVEL_CHARS, +} gatt_level_t; + +static lv_obj_t *s_screen = NULL; +static menu_component_t s_menu; + +static gatt_level_t s_level = LEVEL_SERVICES; +static int s_service = 0; + +static void build_screen(void); +static void gatt_explorer_input(const input_event_t *ev, void *ctx); + +static void build_screen(void) { + if (s_screen != NULL) { + lv_obj_del(s_screen); + s_screen = NULL; + } + + s_screen = lv_obj_create(NULL); + lv_obj_set_style_bg_color(s_screen, current_theme.screen_base, 0); + lv_obj_set_style_bg_opa(s_screen, LV_OPA_COVER, 0); + lv_obj_remove_flag(s_screen, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_border_width(s_screen, 0, 0); + lv_obj_set_style_pad_all(s_screen, 0, 0); + + char label[LABEL_LEN]; + + if (s_level == LEVEL_SERVICES) { + s_menu = menu_component_create(s_screen, "GATT SERVICES", GATT_ICON); + for (int i = 0; i < SERVICES_COUNT; i++) { + snprintf(label, sizeof(label), "%s %s", SERVICES[i].uuid, SERVICES[i].name); + menu_component_add_item(&s_menu, SERVICE_ICON, label); + } + menu_component_set_hint(&s_menu, "OK Open BACK Exit"); + } else { + const gatt_service_t *svc = &SERVICES[s_service]; + s_menu = menu_component_create(s_screen, svc->name, SERVICE_ICON); + for (int i = 0; i < svc->char_count; i++) { + snprintf(label, + sizeof(label), + "%s %s %s", + svc->chars[i].uuid, + svc->chars[i].props, + svc->chars[i].name); + menu_component_add_item(&s_menu, CHAR_ICON, label); + } + menu_component_set_hint(&s_menu, "OK Read BACK Up"); + } + + ui_input_set_screen_handler(gatt_explorer_input, NULL); + + ui_screen_load_owned(&s_screen, s_screen); +} + +static void read_selected_char(int sel) { + const gatt_service_t *svc = &SERVICES[s_service]; + if (sel < 0 || sel >= svc->char_count) + return; + char msg[LABEL_LEN]; + snprintf(msg, sizeof(msg), "%s = 0x%02X", svc->chars[sel].uuid, (unsigned)(esp_random() & 0xFF)); + ui_feedback(UI_FB_READ); + notify(NOTIFY_INFO, msg); +} + +static void gatt_explorer_input(const input_event_t *ev, void *ctx) { + (void)ctx; + const bool press = (ev->action == INPUT_ACTION_PRESS); + const bool nav = press || (ev->action == INPUT_ACTION_REPEAT); + + switch (ev->button) { + case INPUT_BTN_DOWN: + if (nav) { + menu_component_next(&s_menu); + ui_feedback(UI_FB_NAV); + } + break; + case INPUT_BTN_UP: + if (nav) { + menu_component_prev(&s_menu); + ui_feedback(UI_FB_NAV); + } + break; + case INPUT_BTN_OK: + if (press) { + int sel = menu_component_get_selected(&s_menu); + if (s_level == LEVEL_SERVICES) { + if (sel >= 0 && sel < SERVICES_COUNT) { + s_service = sel; + s_level = LEVEL_CHARS; + ui_feedback(UI_FB_SELECT); + build_screen(); + } + } else { + read_selected_char(sel); + } + } + break; + case INPUT_BTN_BACK: + case INPUT_BTN_LEFT: + if (press) { + if (s_level == LEVEL_CHARS) { + s_level = LEVEL_SERVICES; + build_screen(); + } else { + ui_switch_screen(SCREEN_BLE_DETECT_MENU); + } + } + break; + default: + break; + } +} + +void ui_gatt_explorer_open(void) { + s_level = LEVEL_SERVICES; + s_service = 0; + build_screen(); +} diff --git a/firmware_p4/components/Applications/ui/screens/bluetooth/include/ble_beacon_ui.h b/firmware_p4/components/Applications/ui/screens/bluetooth/include/ble_beacon_ui.h new file mode 100644 index 000000000..694833e11 --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/bluetooth/include/ble_beacon_ui.h @@ -0,0 +1,30 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#ifndef BLE_BEACON_UI_H +#define BLE_BEACON_UI_H + +#ifdef __cplusplus +extern "C" { +#endif + +/** @brief Open the BLE beacon-spam broadcast screen. */ +void ui_beacon_spam_open(void); + +#ifdef __cplusplus +} +#endif + +#endif // BLE_BEACON_UI_H diff --git a/firmware_p4/components/Applications/ui/screens/bluetooth/include/ble_companion_ui.h b/firmware_p4/components/Applications/ui/screens/bluetooth/include/ble_companion_ui.h new file mode 100644 index 000000000..11b76cf7d --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/bluetooth/include/ble_companion_ui.h @@ -0,0 +1,30 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#ifndef BLE_COMPANION_UI_H +#define BLE_COMPANION_UI_H + +#ifdef __cplusplus +extern "C" { +#endif + +/** @brief Companion "pairing" screen (MOCK); animates and waits for BACK. */ +void ui_companion_pairing_open(void); + +#ifdef __cplusplus +} +#endif + +#endif // BLE_COMPANION_UI_H diff --git a/firmware_p4/components/Applications/ui/screens/bluetooth/include/ble_detect_ui.h b/firmware_p4/components/Applications/ui/screens/bluetooth/include/ble_detect_ui.h new file mode 100644 index 000000000..d97c5938f --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/bluetooth/include/ble_detect_ui.h @@ -0,0 +1,30 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#ifndef BLE_DETECT_UI_H +#define BLE_DETECT_UI_H + +#ifdef __cplusplus +extern "C" { +#endif + +/** @brief Open the BLE detect submenu screen. */ +void ui_ble_detect_open(void); + +#ifdef __cplusplus +} +#endif + +#endif // BLE_DETECT_UI_H diff --git a/firmware_p4/components/Applications/ui/screens/bluetooth/include/ble_exposure_ui.h b/firmware_p4/components/Applications/ui/screens/bluetooth/include/ble_exposure_ui.h new file mode 100644 index 000000000..bc46bb61f --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/bluetooth/include/ble_exposure_ui.h @@ -0,0 +1,30 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#ifndef BLE_EXPOSURE_UI_H +#define BLE_EXPOSURE_UI_H + +#ifdef __cplusplus +extern "C" { +#endif + +/** @brief Open the BLE exposure-notification monitor screen (mock GAEN). */ +void ui_ble_exposure_open(void); + +#ifdef __cplusplus +} +#endif + +#endif // BLE_EXPOSURE_UI_H diff --git a/firmware_p4/components/Applications/ui/screens/bluetooth/include/ble_flood_ui.h b/firmware_p4/components/Applications/ui/screens/bluetooth/include/ble_flood_ui.h new file mode 100644 index 000000000..3e9c4f373 --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/bluetooth/include/ble_flood_ui.h @@ -0,0 +1,30 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#ifndef BLE_FLOOD_UI_H +#define BLE_FLOOD_UI_H + +#ifdef __cplusplus +extern "C" { +#endif + +/** @brief BLE connection-flood run screen (MOCK); climbs a sent counter with a mode toggle. */ +void ui_ble_flood_open(void); + +#ifdef __cplusplus +} +#endif + +#endif // BLE_FLOOD_UI_H diff --git a/firmware_p4/components/Applications/ui/screens/bluetooth/include/ble_keyboard_ui.h b/firmware_p4/components/Applications/ui/screens/bluetooth/include/ble_keyboard_ui.h new file mode 100644 index 000000000..49a40b398 --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/bluetooth/include/ble_keyboard_ui.h @@ -0,0 +1,30 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#ifndef BLE_KEYBOARD_UI_H +#define BLE_KEYBOARD_UI_H + +#ifdef __cplusplus +extern "C" { +#endif + +/** @brief BLE HID keyboard screen (MOCK); pairs, then types a canned string into a console. */ +void ui_ble_keyboard_open(void); + +#ifdef __cplusplus +} +#endif + +#endif // BLE_KEYBOARD_UI_H diff --git a/firmware_p4/components/Applications/ui/screens/bluetooth/include/ble_mouse_ui.h b/firmware_p4/components/Applications/ui/screens/bluetooth/include/ble_mouse_ui.h new file mode 100644 index 000000000..3d202b9c9 --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/bluetooth/include/ble_mouse_ui.h @@ -0,0 +1,33 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#ifndef BLE_MOUSE_UI_H +#define BLE_MOUSE_UI_H + +#ifdef __cplusplus +extern "C" { +#endif + +/** @brief "Pairing..." screen; auto-advances to the mouse control screen. */ +void ui_ble_mouse_pairing_open(void); + +/** @brief BLE mouse control screen (IR-remote style, stub). */ +void ui_ble_mouse_open(void); + +#ifdef __cplusplus +} +#endif + +#endif // BLE_MOUSE_UI_H diff --git a/firmware_p4/components/Applications/ui/screens/bluetooth/include/ble_radio_ui.h b/firmware_p4/components/Applications/ui/screens/bluetooth/include/ble_radio_ui.h new file mode 100644 index 000000000..3b63a5c47 --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/bluetooth/include/ble_radio_ui.h @@ -0,0 +1,30 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#ifndef BLE_RADIO_UI_H +#define BLE_RADIO_UI_H + +#ifdef __cplusplus +extern "C" { +#endif + +/** @brief BLE radio control screen (MOCK); MAC/link card plus randomize/power/disconnect rows. */ +void ui_ble_radio_open(void); + +#ifdef __cplusplus +} +#endif + +#endif // BLE_RADIO_UI_H diff --git a/firmware_p4/components/Applications/ui/screens/badusb/include/ui_badusb_browser.h b/firmware_p4/components/Applications/ui/screens/bluetooth/include/ble_scan_ui.h similarity index 81% rename from firmware_p4/components/Applications/ui/screens/badusb/include/ui_badusb_browser.h rename to firmware_p4/components/Applications/ui/screens/bluetooth/include/ble_scan_ui.h index f4ec567a5..e7ec0e0dc 100644 --- a/firmware_p4/components/Applications/ui/screens/badusb/include/ui_badusb_browser.h +++ b/firmware_p4/components/Applications/ui/screens/bluetooth/include/ble_scan_ui.h @@ -13,18 +13,18 @@ // You should have received a copy of the GNU General Public License // along with TentacleOS. If not, see . -#ifndef UI_BADUSB_BROWSER_H -#define UI_BADUSB_BROWSER_H +#ifndef BLE_SCAN_UI_H +#define BLE_SCAN_UI_H #ifdef __cplusplus extern "C" { #endif -/** @brief Open the BadUSB script browser screen. */ -void ui_badusb_browser_open(void); +/** @brief Open the BLE device scan screen (real scan via the C5 bridge). */ +void ui_ble_scan_open(void); #ifdef __cplusplus } #endif -#endif // UI_BADUSB_BROWSER_H +#endif // BLE_SCAN_UI_H diff --git a/firmware_p4/components/Applications/ui/screens/bluetooth/include/ble_skimmer_ui.h b/firmware_p4/components/Applications/ui/screens/bluetooth/include/ble_skimmer_ui.h new file mode 100644 index 000000000..e6f8d293e --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/bluetooth/include/ble_skimmer_ui.h @@ -0,0 +1,30 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#ifndef BLE_SKIMMER_UI_H +#define BLE_SKIMMER_UI_H + +#ifdef __cplusplus +extern "C" { +#endif + +/** @brief Open the BLE skimmer-detector screen (mock HC-05/RNBT scan). */ +void ui_ble_skimmer_open(void); + +#ifdef __cplusplus +} +#endif + +#endif // BLE_SKIMMER_UI_H diff --git a/firmware_p4/components/Applications/ui/screens/bluetooth/include/ble_sniffer_ui.h b/firmware_p4/components/Applications/ui/screens/bluetooth/include/ble_sniffer_ui.h new file mode 100644 index 000000000..90691d2e7 --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/bluetooth/include/ble_sniffer_ui.h @@ -0,0 +1,30 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#ifndef BLE_SNIFFER_UI_H +#define BLE_SNIFFER_UI_H + +#ifdef __cplusplus +extern "C" { +#endif + +/** @brief Open the BLE advertisement sniffer screen (mock live hexdump). */ +void ui_ble_sniffer_open(void); + +#ifdef __cplusplus +} +#endif + +#endif // BLE_SNIFFER_UI_H diff --git a/firmware_p4/components/Applications/ui/screens/bluetooth/include/ble_spam_names_ui.h b/firmware_p4/components/Applications/ui/screens/bluetooth/include/ble_spam_names_ui.h new file mode 100644 index 000000000..b3916b17a --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/bluetooth/include/ble_spam_names_ui.h @@ -0,0 +1,30 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#ifndef BLE_SPAM_NAMES_UI_H +#define BLE_SPAM_NAMES_UI_H + +#ifdef __cplusplus +extern "C" { +#endif + +/** @brief Editable custom advertiser-name list (MOCK); add via keyboard, delete via msgbox. */ +void ui_ble_spam_names_open(void); + +#ifdef __cplusplus +} +#endif + +#endif // BLE_SPAM_NAMES_UI_H diff --git a/firmware_p4/components/Applications/ui/screens/bluetooth/include/ble_spam_ui.h b/firmware_p4/components/Applications/ui/screens/bluetooth/include/ble_spam_ui.h new file mode 100644 index 000000000..db7904b96 --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/bluetooth/include/ble_spam_ui.h @@ -0,0 +1,33 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#ifndef BLE_SPAM_UI_H +#define BLE_SPAM_UI_H + +#ifdef __cplusplus +extern "C" { +#endif + +/** @brief Device-spam profile selection menu (MOCK). */ +void ui_ble_spam_select_open(void); + +/** @brief BLE spam "running" screen (MOCK); auto-starts a packet counter. */ +void ui_ble_spam_open(void); + +#ifdef __cplusplus +} +#endif + +#endif // BLE_SPAM_UI_H diff --git a/firmware_p4/components/Applications/ui/screens/bluetooth/include/ble_track_device_ui.h b/firmware_p4/components/Applications/ui/screens/bluetooth/include/ble_track_device_ui.h new file mode 100644 index 000000000..6f7e0b4f9 --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/bluetooth/include/ble_track_device_ui.h @@ -0,0 +1,30 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#ifndef BLE_TRACK_DEVICE_UI_H +#define BLE_TRACK_DEVICE_UI_H + +#ifdef __cplusplus +extern "C" { +#endif + +/** @brief BLE hot/cold RSSI locator (MOCK); animates a gauge and dBm reading toward a target. */ +void ui_ble_track_device_open(void); + +#ifdef __cplusplus +} +#endif + +#endif // BLE_TRACK_DEVICE_UI_H diff --git a/firmware_p4/components/Applications/ui/screens/bluetooth/include/ble_tracker_ui.h b/firmware_p4/components/Applications/ui/screens/bluetooth/include/ble_tracker_ui.h new file mode 100644 index 000000000..0b0087664 --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/bluetooth/include/ble_tracker_ui.h @@ -0,0 +1,30 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#ifndef BLE_TRACKER_UI_H +#define BLE_TRACKER_UI_H + +#ifdef __cplusplus +extern "C" { +#endif + +/** @brief Open the BLE tracker-detector screen (mock AirTag/Tile scan). */ +void ui_ble_tracker_open(void); + +#ifdef __cplusplus +} +#endif + +#endif // BLE_TRACKER_UI_H diff --git a/firmware_p4/components/Applications/ui/screens/bluetooth/include/gatt_explorer_ui.h b/firmware_p4/components/Applications/ui/screens/bluetooth/include/gatt_explorer_ui.h new file mode 100644 index 000000000..8f359d192 --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/bluetooth/include/gatt_explorer_ui.h @@ -0,0 +1,30 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#ifndef GATT_EXPLORER_UI_H +#define GATT_EXPLORER_UI_H + +#ifdef __cplusplus +extern "C" { +#endif + +/** @brief GATT explorer (MOCK); drills fake services down into their characteristics. */ +void ui_gatt_explorer_open(void); + +#ifdef __cplusplus +} +#endif + +#endif // GATT_EXPLORER_UI_H diff --git a/firmware_p4/components/Applications/ui/screens/bluetooth/ui_ble_menu.c b/firmware_p4/components/Applications/ui/screens/bluetooth/ui_ble_menu.c index 7fd33e3d1..8059c1ac6 100644 --- a/firmware_p4/components/Applications/ui/screens/bluetooth/ui_ble_menu.c +++ b/firmware_p4/components/Applications/ui/screens/bluetooth/ui_ble_menu.c @@ -17,7 +17,6 @@ #include "esp_log.h" -#include "buttons_gpio.h" #include "lv_port_indev.h" #include "menu_component_ui.h" #include "ui_manager.h" @@ -25,8 +24,6 @@ static const char *TAG = "UI_BLE_MENU"; -#define NAV_TIMER_INTERVAL_MS 50 - typedef struct { const char *name; const char *icon; @@ -34,23 +31,21 @@ typedef struct { } ui_ble_menu_item_t; static const ui_ble_menu_item_t MENU_ITEMS[] = { - {"Device Spam", NULL, SCREEN_BLE_SPAM_SELECT}, - {"Detect Devices", NULL, -1}, - {"Beacon Spam", NULL, -1}, + {"Companion App", "/assets/icons/app_shortcut.bin", SCREEN_COMPANION_PAIRING}, + {"MouseAir", "/assets/icons/mouse.bin", SCREEN_BLE_MOUSE_PAIRING}, + {"Device Spam", "/assets/icons/broadcast_on_personal.bin", SCREEN_BLE_SPAM_SELECT}, + {"Beacon Spam", "/assets/icons/sensors.bin", SCREEN_BLE_BEACON_SPAM}, + {"Detect Devices", "/assets/icons/bluetooth_searching.bin", SCREEN_BLE_DETECT_MENU}, + {"HID Keyboard", "/assets/icons/keyboard.bin", SCREEN_BLE_KEYBOARD}, + {"BLE Flood", "/assets/icons/bolt.bin", SCREEN_BLE_FLOOD}, + {"Radio / Identity", "/assets/icons/settings.bin", SCREEN_BLE_RADIO}, }; #define MENU_ITEMS_COUNT (sizeof(MENU_ITEMS) / sizeof(MENU_ITEMS[0])) static lv_obj_t *s_screen = NULL; static menu_component_t s_menu; -static lv_timer_t *s_nav_timer = NULL; -static bool s_btn_up_last = false; -static bool s_btn_down_last = false; -static bool s_btn_left_last = false; -static bool s_btn_right_last = false; -static bool s_btn_ok_last = false; -static bool s_btn_back_last = false; -static void nav_timer_cb(lv_timer_t *t); +static void ble_menu_input(const input_event_t *ev, void *ctx); void ui_ble_menu_open(void) { if (s_screen != NULL) { @@ -63,59 +58,43 @@ void ui_ble_menu_open(void) { lv_obj_set_style_bg_opa(s_screen, LV_OPA_COVER, 0); lv_obj_remove_flag(s_screen, LV_OBJ_FLAG_SCROLLABLE); - s_menu = menu_component_create(s_screen, "BLUETOOTH", NULL); + s_menu = menu_component_create(s_screen, "BLUETOOTH", "/assets/icons/bluetooth.bin"); for (int i = 0; i < (int)MENU_ITEMS_COUNT; i++) { menu_component_add_item(&s_menu, MENU_ITEMS[i].icon, MENU_ITEMS[i].name); } - if (s_nav_timer == NULL) { - s_nav_timer = lv_timer_create(nav_timer_cb, NAV_TIMER_INTERVAL_MS, NULL); - } + ui_input_set_screen_handler(ble_menu_input, NULL); - lv_screen_load(s_screen); + ui_screen_load_owned(&s_screen, s_screen); } -static void nav_timer_cb(lv_timer_t *t) { - if (lv_screen_active() != s_screen) { - lv_timer_delete(t); - s_nav_timer = NULL; - return; - } - - if (ui_input_is_locked()) { - return; +static void ble_menu_input(const input_event_t *ev, void *ctx) { + (void)ctx; + const bool press = (ev->action == INPUT_ACTION_PRESS); + const bool nav = press || (ev->action == INPUT_ACTION_REPEAT); + switch (ev->button) { + case INPUT_BTN_DOWN: + if (nav) + menu_component_next(&s_menu); + break; + case INPUT_BTN_UP: + if (nav) + menu_component_prev(&s_menu); + break; + case INPUT_BTN_BACK: + case INPUT_BTN_LEFT: + if (press) + ui_switch_screen(SCREEN_MENU); + break; + case INPUT_BTN_OK: + case INPUT_BTN_RIGHT: + if (press) { + int sel = menu_component_get_selected(&s_menu); + if (sel >= 0 && sel < (int)MENU_ITEMS_COUNT && MENU_ITEMS[sel].target >= 0) + ui_switch_screen(MENU_ITEMS[sel].target); + } + break; + default: + break; } - - bool up = up_button_is_down(); - bool down = down_button_is_down(); - bool left = left_button_is_down(); - bool right = right_button_is_down(); - bool ok = ok_button_is_down(); - bool back = back_button_is_down(); - - if (down && !s_btn_down_last) { - menu_component_next(&s_menu); - } - - if (up && !s_btn_up_last) { - menu_component_prev(&s_menu); - } - - if ((back && !s_btn_back_last) || (left && !s_btn_left_last)) { - ui_switch_screen(SCREEN_MENU); - } - - if ((ok && !s_btn_ok_last) || (right && !s_btn_right_last)) { - int sel = menu_component_get_selected(&s_menu); - if (sel >= 0 && sel < (int)MENU_ITEMS_COUNT && MENU_ITEMS[sel].target >= 0) { - ui_switch_screen(MENU_ITEMS[sel].target); - } - } - - s_btn_up_last = up; - s_btn_down_last = down; - s_btn_left_last = left; - s_btn_right_last = right; - s_btn_ok_last = ok; - s_btn_back_last = back; -} \ No newline at end of file +} diff --git a/firmware_p4/components/Applications/ui/screens/bluetooth/ui_ble_spam.c b/firmware_p4/components/Applications/ui/screens/bluetooth/ui_ble_spam.c deleted file mode 100644 index f997a348e..000000000 --- a/firmware_p4/components/Applications/ui/screens/bluetooth/ui_ble_spam.c +++ /dev/null @@ -1,97 +0,0 @@ -// Copyright (c) 2025 HIGH CODE LLC -// -// TentacleOS is free software: you can redistribute it and/or modify -// it under the terms of the GNU General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// TentacleOS is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU General Public License for more details. -// -// You should have received a copy of the GNU General Public License -// along with TentacleOS. If not, see . - -#include "ui_ble_spam.h" - -#include - -#include "esp_log.h" - -#include "canned_spam.h" -#include "footer_ui.h" -#include "header_ui.h" -#include "lv_port_indev.h" -#include "ui_manager.h" -#include "ui_theme.h" - -static const char *TAG = "UI_BLE_SPAM"; - -#define SPAM_NAME_MAX_LEN 32 -#define TITLE_OFFSET_Y (-20) -#define INSTR_LABEL_OFFSET_Y 40 -#define SPINNER_SIZE 15 -#define SPINNER_OFFSET_Y (-40) - -static lv_obj_t *s_screen_spam = NULL; -static char s_current_spam_name[SPAM_NAME_MAX_LEN] = "Unknown"; - -static void spam_event_cb(lv_event_t *e); - -void ui_ble_spam_set_name(const char *name) { - if (name != NULL) { - snprintf(s_current_spam_name, sizeof(s_current_spam_name), "%s", name); - } -} - -void ui_ble_spam_open(void) { - if (s_screen_spam != NULL) { - lv_obj_del(s_screen_spam); - s_screen_spam = NULL; - } - - s_screen_spam = lv_obj_create(NULL); - lv_obj_set_style_bg_color(s_screen_spam, current_theme.screen_base, 0); - lv_obj_remove_flag(s_screen_spam, LV_OBJ_FLAG_SCROLLABLE); - - header_ui_create(s_screen_spam); - footer_ui_create(s_screen_spam); - - lv_obj_t *lbl_title = lv_label_create(s_screen_spam); - lv_label_set_text_fmt(lbl_title, "SPAM RUNNING:\n#FF0000 %s#", s_current_spam_name); - lv_label_set_recolor(lbl_title, true); - lv_obj_set_style_text_align(lbl_title, LV_TEXT_ALIGN_CENTER, 0); - lv_obj_set_style_text_color(lbl_title, current_theme.text_main, 0); - lv_obj_center(lbl_title); - lv_obj_set_y(lbl_title, TITLE_OFFSET_Y); - - lv_obj_t *lbl_instr = lv_label_create(s_screen_spam); - lv_label_set_text(lbl_instr, "Press BACK to Stop"); - lv_obj_set_style_text_color(lbl_instr, current_theme.text_main, 0); - lv_obj_align(lbl_instr, LV_ALIGN_CENTER, 0, INSTR_LABEL_OFFSET_Y); - - lv_obj_t *spinner = lv_spinner_create(s_screen_spam); - lv_obj_set_size(spinner, SPINNER_SIZE, SPINNER_SIZE); - lv_obj_align(spinner, LV_ALIGN_BOTTOM_MID, 0, SPINNER_OFFSET_Y); - lv_obj_set_style_arc_color(spinner, current_theme.border_accent, LV_PART_INDICATOR); - - lv_obj_add_event_cb(s_screen_spam, spam_event_cb, LV_EVENT_KEY, NULL); - - if (main_group != NULL) { - lv_group_add_obj(main_group, s_screen_spam); - lv_group_focus_obj(s_screen_spam); - } - - lv_screen_load(s_screen_spam); -} - -static void spam_event_cb(lv_event_t *e) { - if (lv_event_get_code(e) == LV_EVENT_KEY) { - uint32_t key = lv_event_get_key(e); - if (key == LV_KEY_ESC || key == LV_KEY_LEFT) { - spam_stop(); - ui_switch_screen(SCREEN_BLE_MENU); - } - } -} \ No newline at end of file diff --git a/firmware_p4/components/Applications/ui/screens/bluetooth/ui_ble_spam_select.c b/firmware_p4/components/Applications/ui/screens/bluetooth/ui_ble_spam_select.c deleted file mode 100644 index 6841520ae..000000000 --- a/firmware_p4/components/Applications/ui/screens/bluetooth/ui_ble_spam_select.c +++ /dev/null @@ -1,200 +0,0 @@ -// Copyright (c) 2025 HIGH CODE LLC -// -// TentacleOS is free software: you can redistribute it and/or modify -// it under the terms of the GNU General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// TentacleOS is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU General Public License for more details. -// -// You should have received a copy of the GNU General Public License -// along with TentacleOS. If not, see . - -#include "ui_ble_spam_select.h" - -#include "esp_log.h" - -#include "canned_spam.h" -#include "font/lv_symbol_def.h" -#include "footer_ui.h" -#include "header_ui.h" -#include "lv_port_indev.h" -#include "ui_ble_spam.h" -#include "ui_manager.h" -#include "ui_theme.h" - -static const char *TAG = "UI_BLE_SPAM_SELECT"; - -#define COLOR_BORDER 0x834EC6 -#define COLOR_GRADIENT_TOP 0x000000 -#define COLOR_GRADIENT_BOT 0x2E0157 - -#define SCREEN_HEIGHT 240 -#define HEADER_HEIGHT 24 -#define FOOTER_HEIGHT 20 -#define MENU_ALIGN_OFFSET_Y 2 -#define MENU_BORDER_WIDTH 2 -#define MENU_RADIUS 6 -#define MENU_PAD 10 -#define BTN_HEIGHT 40 -#define BTN_ICON_OFFSET_X 8 -#define BTN_BORDER_WIDTH 2 -#define BTN_RADIUS 6 - -static lv_obj_t *s_screen_ble_spam_select = NULL; -static lv_style_t s_style_menu; -static lv_style_t s_style_btn; -static bool s_is_styles_initialized = false; - -static void init_styles(void); -static void menu_item_event_cb(lv_event_t *e); -static void spam_toggle_event_cb(lv_event_t *e); -static void ble_spam_select_event_cb(lv_event_t *e); -static void create_menu(lv_obj_t *parent); - -void ui_ble_spam_select_open(void) { - if (s_screen_ble_spam_select != NULL) { - lv_obj_del(s_screen_ble_spam_select); - s_screen_ble_spam_select = NULL; - } - - s_screen_ble_spam_select = lv_obj_create(NULL); - lv_obj_set_style_bg_color(s_screen_ble_spam_select, current_theme.screen_base, 0); - lv_obj_remove_flag(s_screen_ble_spam_select, LV_OBJ_FLAG_SCROLLABLE); - - header_ui_create(s_screen_ble_spam_select); - footer_ui_create(s_screen_ble_spam_select); - create_menu(s_screen_ble_spam_select); - - lv_obj_add_event_cb(s_screen_ble_spam_select, ble_spam_select_event_cb, LV_EVENT_KEY, NULL); - - lv_screen_load(s_screen_ble_spam_select); -} - -static void init_styles(void) { - if (s_is_styles_initialized) { - return; - } - - lv_style_init(&s_style_menu); - lv_style_set_bg_opa(&s_style_menu, LV_OPA_TRANSP); - lv_style_set_border_width(&s_style_menu, MENU_BORDER_WIDTH); - lv_style_set_border_color(&s_style_menu, lv_color_hex(COLOR_BORDER)); - lv_style_set_radius(&s_style_menu, MENU_RADIUS); - lv_style_set_pad_all(&s_style_menu, MENU_PAD); - lv_style_set_pad_row(&s_style_menu, MENU_PAD); - - lv_style_init(&s_style_btn); - lv_style_set_bg_color(&s_style_btn, lv_color_hex(COLOR_GRADIENT_BOT)); - lv_style_set_bg_grad_color(&s_style_btn, lv_color_hex(COLOR_GRADIENT_TOP)); - lv_style_set_bg_grad_dir(&s_style_btn, LV_GRAD_DIR_VER); - lv_style_set_border_width(&s_style_btn, BTN_BORDER_WIDTH); - lv_style_set_border_color(&s_style_btn, lv_color_hex(COLOR_BORDER)); - lv_style_set_radius(&s_style_btn, BTN_RADIUS); - - s_is_styles_initialized = true; -} - -static void menu_item_event_cb(lv_event_t *e) { - lv_obj_t *img_sel = lv_event_get_user_data(e); - lv_event_code_t code = lv_event_get_code(e); - - if (code == LV_EVENT_FOCUSED) { - lv_obj_clear_flag(img_sel, LV_OBJ_FLAG_HIDDEN); - } else if (code == LV_EVENT_DEFOCUSED) { - lv_obj_add_flag(img_sel, LV_OBJ_FLAG_HIDDEN); - } -} - -static void spam_toggle_event_cb(lv_event_t *e) { - if (lv_event_get_code(e) != LV_EVENT_KEY) { - return; - } - - uint32_t key = lv_event_get_key(e); - if (key == LV_KEY_ENTER) { - int index = (int)(intptr_t)lv_event_get_user_data(e); - - const canned_spam_type_t *type = spam_get_attack_type(index); - if (type != NULL) { - ui_ble_spam_set_name(type->name); - } - - ESP_LOGI(TAG, "Starting Spam Index: %d", index); - spam_start(index); - - ui_switch_screen(SCREEN_BLE_SPAM); - } -} - -static void ble_spam_select_event_cb(lv_event_t *e) { - if (lv_event_get_code(e) == LV_EVENT_KEY) { - if (lv_event_get_key(e) == LV_KEY_ESC) { - ESP_LOGI(TAG, "Returning to BLE Options Menu"); - ui_switch_screen(SCREEN_BLE_SPAM_SELECT); - } - } -} - -static void create_menu(lv_obj_t *parent) { - init_styles(); - - lv_coord_t menu_h = SCREEN_HEIGHT - HEADER_HEIGHT - FOOTER_HEIGHT; - - lv_obj_t *menu = lv_obj_create(parent); - lv_obj_set_size(menu, SCREEN_HEIGHT, menu_h); - lv_obj_align(menu, LV_ALIGN_CENTER, 0, MENU_ALIGN_OFFSET_Y); - lv_obj_add_style(menu, &s_style_menu, 0); - lv_obj_set_scroll_dir(menu, LV_DIR_VER); - lv_obj_set_scrollbar_mode(menu, LV_SCROLLBAR_MODE_OFF); - lv_obj_set_flex_flow(menu, LV_FLEX_FLOW_COLUMN); - - static const void *s_ble_icon = NULL; - static const void *s_select_icon = NULL; - - if (s_ble_icon == NULL) { - s_ble_icon = LV_SYMBOL_BLUETOOTH; - } - if (s_select_icon == NULL) { - s_select_icon = LV_SYMBOL_RIGHT; - } - - int count = spam_get_attack_count(); - - for (int i = 0; i < count; i++) { - const canned_spam_type_t *type = spam_get_attack_type(i); - if (type == NULL) { - continue; - } - - lv_obj_t *btn = lv_btn_create(menu); - lv_obj_set_size(btn, lv_pct(100), BTN_HEIGHT); - lv_obj_add_style(btn, &s_style_btn, 0); - lv_obj_set_style_anim_time(btn, 0, 0); - - lv_obj_t *img_left = lv_label_create(btn); - lv_label_set_text(img_left, s_ble_icon); - lv_obj_align(img_left, LV_ALIGN_LEFT_MID, BTN_ICON_OFFSET_X, 0); - - lv_obj_t *lbl = lv_label_create(btn); - lv_label_set_text(lbl, type->name); - lv_obj_center(lbl); - - lv_obj_t *img_sel = lv_label_create(btn); - lv_label_set_text(img_sel, s_select_icon); - lv_obj_align(img_sel, LV_ALIGN_RIGHT_MID, -BTN_ICON_OFFSET_X, 0); - lv_obj_add_flag(img_sel, LV_OBJ_FLAG_HIDDEN); - - lv_obj_add_event_cb(btn, menu_item_event_cb, LV_EVENT_FOCUSED, img_sel); - lv_obj_add_event_cb(btn, menu_item_event_cb, LV_EVENT_DEFOCUSED, img_sel); - lv_obj_add_event_cb(btn, spam_toggle_event_cb, LV_EVENT_KEY, (void *)(intptr_t)i); - lv_obj_add_event_cb(btn, ble_spam_select_event_cb, LV_EVENT_KEY, NULL); - - if (main_group != NULL) { - lv_group_add_obj(main_group, btn); - } - } -} \ No newline at end of file diff --git a/firmware_p4/components/Applications/ui/screens/boot/boot_ui.c b/firmware_p4/components/Applications/ui/screens/boot/boot_ui.c index 66cf50b6f..b85cc3740 100644 --- a/firmware_p4/components/Applications/ui/screens/boot/boot_ui.c +++ b/firmware_p4/components/Applications/ui/screens/boot/boot_ui.c @@ -30,18 +30,38 @@ static const char *TAG = "UI_BOOT"; #define BOOT_OCTO2_FADE_OUT_MS 400 #define BOOT_OCTO1_DELAY_MS 3400 #define BOOT_OCTO1_FADE_IN_MS 500 -#define BOOT_LABEL_OFFSET_Y (-10) +#define BOOT_LABEL_OFFSET_Y (-48) #define BOOT_TEXT_ANIM_DURATION_MS 10000 #define BOOT_TEXT_ANIM_END_VAL 30 #define BOOT_FADE_HOLD_MS 2600 #define BOOT_BG_COLOR 0x000000 #define BOOT_TEXT_COLOR 0xFFFFFF +#define BOOT_LOG_COLOR 0x00E676 +#define BOOT_LOG_STEP_MS 220 +#define BOOT_LOG_OFFSET_Y (-26) +#define BOOT_LOG_BUF_SIZE 64 static lv_image_dsc_t *s_octo1_dsc = NULL; static lv_image_dsc_t *s_octo2_dsc = NULL; +static lv_obj_t *s_boot_screen = NULL; +static lv_obj_t *s_log_label = NULL; +static uint32_t s_log_index = 0; + +static const char *s_boot_log_steps[] = {"st7789 display", + "lvgl 9.4", + "buttons", + "i2s audio", + "led rgb", + "sd card", + "haptics", + "console", + "c5 bridge", + "ui manager"}; + static void anim_set_opa_cb(void *var, int32_t v); static void boot_text_anim_cb(void *var, int32_t v); +static void boot_log_timer_cb(lv_timer_t *t); static lv_obj_t *create_fade_image( lv_obj_t *parent, lv_image_dsc_t *src, uint32_t delay, uint32_t fade_in, uint32_t fade_out); @@ -81,6 +101,16 @@ void ui_boot_show(void) { lv_anim_set_exec_cb(&a_text, boot_text_anim_cb); lv_anim_set_repeat_count(&a_text, LV_ANIM_REPEAT_INFINITE); lv_anim_start(&a_text); + + s_boot_screen = boot_screen; + s_log_index = 0; + s_log_label = lv_label_create(boot_screen); + lv_label_set_text(s_log_label, ""); + lv_obj_set_style_text_color(s_log_label, lv_color_hex(BOOT_LOG_COLOR), 0); + lv_obj_set_style_text_font(s_log_label, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_align(s_log_label, LV_TEXT_ALIGN_CENTER, 0); + lv_obj_align(s_log_label, LV_ALIGN_BOTTOM_MID, 0, BOOT_LOG_OFFSET_Y); + lv_timer_create(boot_log_timer_cb, BOOT_LOG_STEP_MS, NULL); } static void anim_set_opa_cb(void *var, int32_t v) { @@ -92,6 +122,19 @@ static void boot_text_anim_cb(void *var, int32_t v) { lv_label_set_text((lv_obj_t *)var, dots[v % 3]); } +static void boot_log_timer_cb(lv_timer_t *t) { + if (lv_screen_active() != s_boot_screen || s_log_label == NULL) { + lv_timer_delete(t); + return; + } + + uint32_t count = sizeof(s_boot_log_steps) / sizeof(s_boot_log_steps[0]); + char buf[BOOT_LOG_BUF_SIZE]; + snprintf(buf, sizeof(buf), "> %s", s_boot_log_steps[s_log_index % count]); + lv_label_set_text(s_log_label, buf); + s_log_index++; +} + static lv_obj_t *create_fade_image( lv_obj_t *parent, lv_image_dsc_t *src, uint32_t delay, uint32_t fade_in, uint32_t fade_out) { if (src == NULL) diff --git a/firmware_p4/components/Applications/ui/screens/boot_report/boot_map_ui.c b/firmware_p4/components/Applications/ui/screens/boot_report/boot_map_ui.c new file mode 100644 index 000000000..13200c22c --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/boot_report/boot_map_ui.c @@ -0,0 +1,134 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#include "boot_map_ui.h" + +#include "lvgl.h" + +#include "boot_report.h" +#include "ui_manager.h" +#include "ui_theme.h" + +#define TITLE "BOOT MAP" +#define ICON "/assets/icons/troubleshoot.bin" +#define HINT "BACK exit" + +#define OK_COLOR 0x00E676 +#define FAIL_COLOR 0xFF5252 +#define SKIP_COLOR 0x8A8594 + +static lv_obj_t *s_screen = NULL; +static void (*s_on_back)(void) = NULL; + +static void boot_map_input(const input_event_t *ev, void *ctx); + +static void add_row(lv_obj_t *list, const boot_stage_t *st) { + lv_obj_t *row = lv_obj_create(list); + lv_obj_remove_flag(row, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(row, lv_pct(100), LV_SIZE_CONTENT); + lv_obj_set_style_bg_opa(row, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(row, 0, 0); + lv_obj_set_style_pad_all(row, 2, 0); + lv_obj_set_flex_flow(row, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align( + row, LV_FLEX_ALIGN_SPACE_BETWEEN, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + + lv_obj_t *name = lv_label_create(row); + lv_label_set_text_fmt(name, "%s%s", st->name, st->required ? " *" : ""); + lv_obj_set_style_text_color(name, current_theme.text_main, 0); + lv_obj_set_style_text_font(name, &lv_font_montserrat_14, 0); + + const char *state; + uint32_t color; + if (st->result == ESP_OK) { + state = "OK"; + color = OK_COLOR; + } else if (st->result == ESP_ERR_NOT_FOUND) { + state = "skip"; + color = SKIP_COLOR; + } else { + state = esp_err_to_name(st->result); + color = FAIL_COLOR; + } + + lv_obj_t *val = lv_label_create(row); + lv_label_set_text(val, state); + lv_obj_set_style_text_color(val, lv_color_hex(color), 0); + lv_obj_set_style_text_font(val, &lv_font_montserrat_12, 0); +} + +static void build_screen(void) { + if (s_screen != NULL) { + lv_obj_del(s_screen); + s_screen = NULL; + } + + s_screen = lv_obj_create(NULL); + lv_obj_set_style_bg_color(s_screen, current_theme.screen_base, 0); + lv_obj_set_style_bg_opa(s_screen, LV_OPA_COVER, 0); + lv_obj_remove_flag(s_screen, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_border_width(s_screen, 0, 0); + lv_obj_set_style_pad_all(s_screen, 0, 0); + + lv_obj_t *title = lv_label_create(s_screen); + lv_label_set_text(title, TITLE); + lv_obj_set_style_text_color(title, ui_theme_get_accent(), 0); + lv_obj_set_style_text_font(title, &lv_font_montserrat_16, 0); + lv_obj_align(title, LV_ALIGN_TOP_MID, 0, 12); + + lv_obj_t *list = lv_obj_create(s_screen); + lv_obj_set_size(list, lv_pct(92), lv_pct(72)); + lv_obj_align(list, LV_ALIGN_TOP_MID, 0, 40); + lv_obj_set_style_bg_opa(list, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(list, 0, 0); + lv_obj_set_style_pad_all(list, 4, 0); + lv_obj_set_style_pad_row(list, 2, 0); + lv_obj_set_flex_flow(list, LV_FLEX_FLOW_COLUMN); + + int count = 0; + const boot_stage_t *stages = boot_report_stages(&count); + for (int i = 0; i < count; i++) { + add_row(list, &stages[i]); + } + + lv_obj_t *hint = lv_label_create(s_screen); + lv_label_set_text(hint, HINT); + lv_obj_set_style_text_color(hint, current_theme.border_inactive, 0); + lv_obj_align(hint, LV_ALIGN_BOTTOM_MID, 0, -12); + + ui_input_set_screen_handler(boot_map_input, NULL); + ui_screen_load_owned(&s_screen, s_screen); +} + +static void boot_map_input(const input_event_t *ev, void *ctx) { + (void)ctx; + if (ev->action == INPUT_ACTION_PRESS && ev->button == INPUT_BTN_BACK) { + if (s_on_back != NULL) { + s_on_back(); + } else { + ui_switch_screen(SCREEN_DEV_MENU); + } + } +} + +void ui_boot_map_open(void) { + s_on_back = NULL; + build_screen(); +} + +void ui_boot_map_open_cb(void (*on_back)(void)) { + s_on_back = on_back; + build_screen(); +} diff --git a/firmware_p4/components/Applications/ui/screens/boot_report/crash_report_ui.c b/firmware_p4/components/Applications/ui/screens/boot_report/crash_report_ui.c new file mode 100644 index 000000000..6e057aded --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/boot_report/crash_report_ui.c @@ -0,0 +1,144 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#include "crash_report_ui.h" + +#include + +#include "lvgl.h" + +#include "boot_report.h" +#include "ui_manager.h" +#include "ui_theme.h" + +#define TITLE "LAST CRASH" + +static lv_obj_t *s_screen = NULL; +static void (*s_on_back)(void) = NULL; +static bool s_has_dump = false; + +static void crash_report_input(const input_event_t *ev, void *ctx); + +static void add_line(lv_obj_t *parent, const char *text, uint32_t color) { + lv_obj_t *lbl = lv_label_create(parent); + lv_label_set_text(lbl, text); + lv_obj_set_style_text_color(lbl, lv_color_hex(color), 0); + lv_obj_set_style_text_font(lbl, &lv_font_montserrat_14, 0); +} + +static void build_screen(void) { + if (s_screen != NULL) { + lv_obj_del(s_screen); + s_screen = NULL; + } + + const crash_info_t *c = boot_report_crash(); + s_has_dump = c->has_coredump; + + s_screen = lv_obj_create(NULL); + lv_obj_set_style_bg_color(s_screen, current_theme.screen_base, 0); + lv_obj_set_style_bg_opa(s_screen, LV_OPA_COVER, 0); + lv_obj_remove_flag(s_screen, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_border_width(s_screen, 0, 0); + lv_obj_set_style_pad_all(s_screen, 0, 0); + + lv_obj_t *title = lv_label_create(s_screen); + lv_label_set_text(title, TITLE); + lv_obj_set_style_text_color(title, ui_theme_get_accent(), 0); + lv_obj_set_style_text_font(title, &lv_font_montserrat_16, 0); + lv_obj_align(title, LV_ALIGN_TOP_MID, 0, 12); + + lv_obj_t *body = lv_obj_create(s_screen); + lv_obj_set_size(body, lv_pct(92), lv_pct(72)); + lv_obj_align(body, LV_ALIGN_TOP_MID, 0, 40); + lv_obj_set_style_bg_opa(body, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(body, 0, 0); + lv_obj_set_style_pad_all(body, 4, 0); + lv_obj_set_style_pad_row(body, 3, 0); + lv_obj_set_flex_flow(body, LV_FLEX_FLOW_COLUMN); + + char buf[48]; + snprintf(buf, sizeof(buf), "Reset: %s", boot_report_reason_str(c->reason)); + add_line(body, buf, 0xFFFFFF); + + snprintf(buf, sizeof(buf), "Panics total: %lu", (unsigned long)boot_report_panic_total()); + add_line(body, buf, 0xE0E0E0); + + uint32_t abnormal = boot_report_abnormal_boots(); + if (abnormal > 0) { + snprintf(buf, + sizeof(buf), + "Abnormal boots: %lu/%d", + (unsigned long)abnormal, + BOOT_REPORT_BOOTLOOP_THRESHOLD); + add_line(body, buf, abnormal >= BOOT_REPORT_BOOTLOOP_THRESHOLD ? 0xFF5252 : 0xFFC23D); + } + + if (!c->crash) { + add_line(body, "No crash recorded.", 0x00E676); + } else { + if (c->has_coredump) { + snprintf(buf, sizeof(buf), "Task: %s", c->task[0] ? c->task : "?"); + add_line(body, buf, 0xFF5252); + snprintf(buf, sizeof(buf), "PC: 0x%08lx", (unsigned long)c->pc); + add_line(body, buf, 0xE0E0E0); + snprintf(buf, sizeof(buf), "RA: 0x%08lx", (unsigned long)c->ra); + add_line(body, buf, 0xE0E0E0); + snprintf(buf, sizeof(buf), "SP: 0x%08lx", (unsigned long)c->sp); + add_line(body, buf, 0xE0E0E0); + snprintf(buf, sizeof(buf), "mcause 0x%08lx", (unsigned long)c->mcause); + add_line(body, buf, 0xE0E0E0); + snprintf(buf, sizeof(buf), "mtval 0x%08lx", (unsigned long)c->mtval); + add_line(body, buf, 0xE0E0E0); + } else { + add_line(body, "No core dump image.", 0xFFC23D); + } + } + + lv_obj_t *hint = lv_label_create(s_screen); + lv_label_set_text(hint, s_has_dump ? "OK clear BACK exit" : "BACK exit"); + lv_obj_set_style_text_color(hint, current_theme.border_inactive, 0); + lv_obj_align(hint, LV_ALIGN_BOTTOM_MID, 0, -12); + + ui_input_set_screen_handler(crash_report_input, NULL); + ui_screen_load_owned(&s_screen, s_screen); +} + +static void crash_report_input(const input_event_t *ev, void *ctx) { + (void)ctx; + if (ev->action != INPUT_ACTION_PRESS) { + return; + } + if (ev->button == INPUT_BTN_OK && s_has_dump) { + boot_report_clear_crash(); + build_screen(); // redraw: dump is gone now + } else if (ev->button == INPUT_BTN_BACK) { + if (s_on_back != NULL) { + s_on_back(); + } else { + ui_switch_screen(SCREEN_DEV_MENU); + } + } +} + +void ui_crash_report_open(void) { + s_on_back = NULL; + build_screen(); +} + +void ui_crash_report_open_cb(void (*on_back)(void)) { + s_on_back = on_back; + build_screen(); +} diff --git a/firmware_p4/components/Applications/ui/screens/boot_report/include/boot_map_ui.h b/firmware_p4/components/Applications/ui/screens/boot_report/include/boot_map_ui.h new file mode 100644 index 000000000..dda59a932 --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/boot_report/include/boot_map_ui.h @@ -0,0 +1,42 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#ifndef BOOT_MAP_UI_H +#define BOOT_MAP_UI_H + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * @brief Open the boot-map viewer (BACK returns to the developer menu). + * + * Lists each kernel_init subsystem from boot_report with its OK/FAIL/skip state. + */ +void ui_boot_map_open(void); + +/** + * @brief Open the boot-map viewer with a custom BACK action. + * + * Used by safe mode so BACK returns to the recovery menu instead of the normal + * developer menu. Call under the LVGL lock. + */ +void ui_boot_map_open_cb(void (*on_back)(void)); + +#ifdef __cplusplus +} +#endif + +#endif // BOOT_MAP_UI_H diff --git a/firmware_c5/components/Service/console/include/console_service.h b/firmware_p4/components/Applications/ui/screens/boot_report/include/crash_report_ui.h similarity index 58% rename from firmware_c5/components/Service/console/include/console_service.h rename to firmware_p4/components/Applications/ui/screens/boot_report/include/crash_report_ui.h index b9c293c1c..a799e0b13 100644 --- a/firmware_c5/components/Service/console/include/console_service.h +++ b/firmware_p4/components/Applications/ui/screens/boot_report/include/crash_report_ui.h @@ -13,39 +13,31 @@ // You should have received a copy of the GNU General Public License // along with TentacleOS. If not, see . -#ifndef CONSOLE_SERVICE_H -#define CONSOLE_SERVICE_H +#ifndef CRASH_REPORT_UI_H +#define CRASH_REPORT_UI_H #ifdef __cplusplus extern "C" { #endif -#include "esp_err.h" - /** - * @brief Initialize the console service and register all commands. + * @brief Open the last-crash viewer (BACK returns to the developer menu). * - * @return ESP_OK on success. - */ -esp_err_t console_service_init(void); - -/** - * @brief Register filesystem commands (ls, cd, pwd, cat). + * Shows the previous run's reset reason and, if a core dump was captured, the + * faulting task and RISC-V fault registers. OK clears the stored dump. */ -void register_fs_commands(void); +void ui_crash_report_open(void); /** - * @brief Register system commands (free, restart, ip, tasks). - */ -void register_system_commands(void); - -/** - * @brief Register Wi-Fi commands (scan, connect, deauth, etc.). + * @brief Open the last-crash viewer with a custom BACK action. + * + * Used by safe mode so BACK returns to the recovery menu. Call under the LVGL + * lock. */ -void register_wifi_commands(void); +void ui_crash_report_open_cb(void (*on_back)(void)); #ifdef __cplusplus } #endif -#endif // CONSOLE_SERVICE_H +#endif // CRASH_REPORT_UI_H diff --git a/firmware_p4/components/Applications/ui/screens/connect_bluetooth/connect_bt_ui.c b/firmware_p4/components/Applications/ui/screens/connect_bluetooth/connect_bt_ui.c index d6bee2f79..00f805572 100644 --- a/firmware_p4/components/Applications/ui/screens/connect_bluetooth/connect_bt_ui.c +++ b/firmware_p4/components/Applications/ui/screens/connect_bluetooth/connect_bt_ui.c @@ -15,154 +15,87 @@ #include "connect_bt_ui.h" -#include "freertos/FreeRTOS.h" -#include "freertos/task.h" +#include -#include "core/lv_group.h" +#include "esp_log.h" -#include "bluetooth_service.h" -#include "footer_ui.h" -#include "header_ui.h" +#include "menu_component_ui.h" #include "ui_manager.h" #include "ui_theme.h" -#define BT_MENU_WIDTH 230 -#define BT_MENU_HEIGHT 160 -#define BT_MENU_OFFSET_Y 5 -#define BT_MENU_BORDER_WIDTH 2 -#define BT_MENU_PAD 4 -#define BT_ITEM_HEIGHT 40 -#define BT_ITEM_BORDER_WIDTH 1 -#define BT_ITEM_ICON_MARGIN 8 -#define BT_ITEM_PAIRED_MARGIN 5 -#define BT_SCAN_DELAY_MS 600 +static const char *TAG = "CONNECT_BT_UI"; -extern lv_group_t *main_group; +#define PAIRED_DEVICE_COLOR_HEX 0x00E676 typedef struct { const char *name; - const char *symbol; bool is_paired; } bt_device_t; static const bt_device_t MOCK_DEVICES[] = { - {"PIXEL_BUDS_PRO", LV_SYMBOL_AUDIO, true}, - {"MECHANICAL_KB", LV_SYMBOL_KEYBOARD, true}, - {"UNKNOWN_PHONE", LV_SYMBOL_BLUETOOTH, false}, - {"SMART_WATCH_X", LV_SYMBOL_IMAGE, false}, + {"PIXEL_BUDS_PRO", true}, + {"MECHANICAL_KB", true}, + {"UNKNOWN_PHONE", false}, + {"SMART_WATCH_X", false}, }; -#define MOCK_DEVICES_COUNT (sizeof(MOCK_DEVICES) / sizeof(MOCK_DEVICES[0])) +#define MOCK_DEVICES_COUNT ((int)(sizeof(MOCK_DEVICES) / sizeof(MOCK_DEVICES[0]))) -static lv_obj_t *s_screen_bt_list = NULL; -static lv_style_t s_style_menu; -static lv_style_t s_style_item; -static bool s_is_styles_initialized = false; +static const char *const DEVICE_ICONS[] = { + "/assets/icons/earbuds.bin", + "/assets/icons/keyboard.bin", + "/assets/icons/smartphone.bin", + "/assets/icons/watch.bin", +}; -static void init_styles(void); -static void bt_item_event_cb(lv_event_t *e); +static lv_obj_t *s_screen = NULL; +static menu_component_t s_menu; +static void connect_bt_input(const input_event_t *ev, void *ctx) { + (void)ctx; + const bool press = (ev->action == INPUT_ACTION_PRESS); + const bool nav = press || (ev->action == INPUT_ACTION_REPEAT); + switch (ev->button) { + case INPUT_BTN_DOWN: + if (nav) + menu_component_next(&s_menu); + break; + case INPUT_BTN_UP: + if (nav) + menu_component_prev(&s_menu); + break; + case INPUT_BTN_OK: + case INPUT_BTN_RIGHT: + case INPUT_BTN_BACK: + case INPUT_BTN_LEFT: + if (press) + ui_switch_screen(SCREEN_CONNECTION_SETTINGS); + break; + default: + break; + } +} void ui_connect_bt_open(void) { - init_styles(); - - if (s_screen_bt_list != NULL) - lv_obj_del(s_screen_bt_list); - - s_screen_bt_list = lv_obj_create(NULL); - lv_obj_set_style_bg_color(s_screen_bt_list, current_theme.screen_base, 0); - lv_obj_clear_flag(s_screen_bt_list, LV_OBJ_FLAG_SCROLLABLE); - - header_ui_create(s_screen_bt_list); - footer_ui_create(s_screen_bt_list); - - lv_obj_t *menu = lv_obj_create(s_screen_bt_list); - lv_obj_set_size(menu, BT_MENU_WIDTH, BT_MENU_HEIGHT); - lv_obj_align(menu, LV_ALIGN_CENTER, 0, BT_MENU_OFFSET_Y); - lv_obj_add_style(menu, &s_style_menu, 0); - lv_obj_set_flex_flow(menu, LV_FLEX_FLOW_COLUMN); - lv_obj_set_scrollbar_mode(menu, LV_SCROLLBAR_MODE_OFF); - - lv_obj_t *loading_label = lv_label_create(menu); - lv_label_set_text(loading_label, "BUSCANDO DISPOSITIVOS..."); - lv_obj_set_style_text_color(loading_label, current_theme.text_main, 0); - lv_obj_set_width(loading_label, lv_pct(100)); - lv_obj_set_style_text_align(loading_label, LV_TEXT_ALIGN_CENTER, 0); - - lv_screen_load(s_screen_bt_list); - lv_refr_now(NULL); - - vTaskDelay(pdMS_TO_TICKS(BT_SCAN_DELAY_MS)); - - lv_obj_del(loading_label); - - for (size_t i = 0; i < MOCK_DEVICES_COUNT; i++) { - lv_obj_t *item = lv_obj_create(menu); - lv_obj_set_size(item, lv_pct(100), BT_ITEM_HEIGHT); - lv_obj_add_style(item, &s_style_item, 0); - lv_obj_set_flex_flow(item, LV_FLEX_FLOW_ROW); - lv_obj_set_flex_align(item, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); - lv_obj_clear_flag(item, LV_OBJ_FLAG_SCROLLABLE); - - lv_obj_t *icon = lv_label_create(item); - lv_label_set_text(icon, MOCK_DEVICES[i].symbol); - lv_obj_set_style_text_color(icon, current_theme.text_main, 0); - - lv_obj_t *lbl_name = lv_label_create(item); - lv_label_set_text(lbl_name, MOCK_DEVICES[i].name); - lv_obj_set_style_text_color(lbl_name, current_theme.text_main, 0); - lv_obj_set_flex_grow(lbl_name, 1); - lv_obj_set_style_margin_left(lbl_name, BT_ITEM_ICON_MARGIN, 0); - - if (MOCK_DEVICES[i].is_paired) { - lv_obj_t *paired_icon = lv_label_create(item); - lv_label_set_text(paired_icon, LV_SYMBOL_OK); - lv_obj_set_style_text_color(paired_icon, current_theme.text_main, 0); - lv_obj_set_style_margin_right(paired_icon, BT_ITEM_PAIRED_MARGIN, 0); - } - - lv_obj_add_event_cb(item, bt_item_event_cb, LV_EVENT_ALL, NULL); - - if (main_group != NULL) - lv_group_add_obj(main_group, item); + if (s_screen != NULL) { + lv_obj_del(s_screen); + s_screen = NULL; } -} -static void init_styles(void) { - if (s_is_styles_initialized) - return; - - lv_style_init(&s_style_menu); - lv_style_set_bg_opa(&s_style_menu, LV_OPA_TRANSP); - lv_style_set_border_width(&s_style_menu, BT_MENU_BORDER_WIDTH); - lv_style_set_border_color(&s_style_menu, ui_theme_get_accent()); - lv_style_set_radius(&s_style_menu, 0); - lv_style_set_pad_all(&s_style_menu, BT_MENU_PAD); - lv_style_set_pad_row(&s_style_menu, BT_MENU_PAD); - - lv_style_init(&s_style_item); - lv_style_set_bg_color(&s_style_item, current_theme.bg_item_bot); - lv_style_set_bg_grad_color(&s_style_item, current_theme.bg_item_top); - lv_style_set_bg_grad_dir(&s_style_item, LV_GRAD_DIR_VER); - lv_style_set_border_width(&s_style_item, BT_ITEM_BORDER_WIDTH); - lv_style_set_border_color(&s_style_item, current_theme.border_inactive); - lv_style_set_radius(&s_style_item, 0); - - s_is_styles_initialized = true; -} + s_screen = lv_obj_create(NULL); + lv_obj_set_style_bg_color(s_screen, current_theme.screen_base, 0); + lv_obj_set_style_bg_opa(s_screen, LV_OPA_COVER, 0); + lv_obj_remove_flag(s_screen, LV_OBJ_FLAG_SCROLLABLE); -static void bt_item_event_cb(lv_event_t *e) { - lv_event_code_t code = lv_event_get_code(e); - lv_obj_t *item = lv_event_get_target(e); - - if (code == LV_EVENT_FOCUSED) { - lv_obj_set_style_border_color(item, ui_theme_get_accent(), 0); - lv_obj_set_style_border_width(item, BT_MENU_BORDER_WIDTH, 0); - lv_obj_scroll_to_view(item, LV_ANIM_ON); - } else if (code == LV_EVENT_DEFOCUSED) { - lv_obj_set_style_border_color(item, current_theme.border_inactive, 0); - lv_obj_set_style_border_width(item, BT_ITEM_BORDER_WIDTH, 0); - } else if (code == LV_EVENT_KEY) { - uint32_t key = lv_event_get_key(e); - if (key == LV_KEY_ESC || key == LV_KEY_LEFT || key == LV_KEY_ENTER || key == LV_KEY_RIGHT) - ui_switch_screen(SCREEN_CONNECTION_SETTINGS); + s_menu = menu_component_create(s_screen, "DEVICES", "/assets/icons/bluetooth.bin"); + + for (int i = 0; i < MOCK_DEVICES_COUNT; i++) { + menu_component_add_item(&s_menu, DEVICE_ICONS[i], MOCK_DEVICES[i].name); + + if (MOCK_DEVICES[i].is_paired) + menu_component_set_item_label_color(&s_menu, i, lv_color_hex(PAIRED_DEVICE_COLOR_HEX)); } -} \ No newline at end of file + + ui_input_set_screen_handler(connect_bt_input, NULL); + + ui_screen_load_owned(&s_screen, s_screen); + ESP_LOGI(TAG, "BT device list opened (%d device(s))", MOCK_DEVICES_COUNT); +} diff --git a/firmware_p4/components/Applications/ui/screens/connect_wifi/connect_wifi_ui.c b/firmware_p4/components/Applications/ui/screens/connect_wifi/connect_wifi_ui.c index a57a356e3..4ca554a85 100644 --- a/firmware_p4/components/Applications/ui/screens/connect_wifi/connect_wifi_ui.c +++ b/firmware_p4/components/Applications/ui/screens/connect_wifi/connect_wifi_ui.c @@ -15,317 +15,432 @@ #include "connect_wifi_ui.h" -#include +#include #include -#include "cJSON.h" -#include "esp_err.h" -#include "esp_wifi.h" -#include "lvgl.h" +#include "esp_log.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include "sys_prio.h" + +#include "st7789.h" -#include "footer_ui.h" -#include "header_ui.h" #include "keyboard_ui.h" +#include "menu_component_ui.h" #include "msgbox_ui.h" -#include "storage_assets.h" -#include "core/lv_group.h" +#include "ui_chrome.h" #include "ui_manager.h" #include "ui_theme.h" -#include "wifi_service.h" - -#define WIFI_MENU_WIDTH 230 -#define WIFI_MENU_HEIGHT 160 -#define WIFI_MENU_OFFSET_Y 10 -#define WIFI_MENU_BORDER_WIDTH 2 -#define WIFI_MENU_PAD 4 -#define WIFI_ITEM_HEIGHT 40 -#define WIFI_ITEM_BORDER_WIDTH 1 -#define WIFI_ITEM_ICON_MARGIN 8 -#define WIFI_STATUS_POLL_MAX 20 -#define WIFI_STATUS_POLL_INTERVAL_MS 500 -#define WIFI_RESTORE_GROUP_DELAY_MS 10 -#define WIFI_SSID_MAX_LEN 33 -#define WIFI_PASS_MAX_LEN 65 - -extern lv_group_t *main_group; - -static lv_obj_t *s_screen_wifi_list = NULL; -static lv_obj_t *s_wifi_list_cont = NULL; -static lv_style_t s_style_menu; -static lv_style_t s_style_item; -static bool s_is_styles_initialized = false; - -static char s_selected_ssid[WIFI_SSID_MAX_LEN]; -static char s_selected_pass[WIFI_PASS_MAX_LEN]; -static char s_known_pass[WIFI_PASS_MAX_LEN]; - -static lv_timer_t *s_restore_group_timer = NULL; -static lv_timer_t *s_wifi_status_timer = NULL; -static uint32_t s_wifi_status_poll_count = 0; -static bool s_is_awaiting_connect = false; -static bool s_is_pending_connected = false; - -static void init_styles(void); -static bool local_get_known_password(const char *ssid, char *out_password, size_t buffer_size); -static void restore_wifi_group(lv_timer_t *timer); -static void on_msgbox_closed(bool confirm); -static void on_wifi_status_async(void *user_data); -static void wifi_status_timer_cb(lv_timer_t *timer); -static void on_keyboard_submit(const char *text, void *user_data); -static void wifi_item_event_cb(lv_event_t *e); - -void ui_connect_wifi_open(void) { - init_styles(); - - if (s_screen_wifi_list != NULL) - lv_obj_del(s_screen_wifi_list); - - s_screen_wifi_list = lv_obj_create(NULL); - lv_obj_set_style_bg_color(s_screen_wifi_list, current_theme.screen_base, 0); - lv_obj_clear_flag(s_screen_wifi_list, LV_OBJ_FLAG_SCROLLABLE); - - header_ui_create(s_screen_wifi_list); - footer_ui_create(s_screen_wifi_list); - - s_wifi_list_cont = lv_obj_create(s_screen_wifi_list); - lv_obj_set_size(s_wifi_list_cont, WIFI_MENU_WIDTH, WIFI_MENU_HEIGHT); - lv_obj_align(s_wifi_list_cont, LV_ALIGN_CENTER, 0, WIFI_MENU_OFFSET_Y); - lv_obj_add_style(s_wifi_list_cont, &s_style_menu, 0); - lv_obj_set_flex_flow(s_wifi_list_cont, LV_FLEX_FLOW_COLUMN); - lv_obj_set_scrollbar_mode(s_wifi_list_cont, LV_SCROLLBAR_MODE_OFF); - lv_obj_add_flag(s_wifi_list_cont, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_set_scroll_dir(s_wifi_list_cont, LV_DIR_VER); - - lv_obj_t *loading = lv_label_create(s_screen_wifi_list); - lv_label_set_text(loading, "SCANNING..."); - lv_obj_set_style_text_color(loading, current_theme.text_main, 0); - lv_obj_center(loading); - - lv_screen_load(s_screen_wifi_list); - lv_refr_now(NULL); +#include "waves_ui.h" + +static const char *TAG = "CONNECT_WIFI_UI"; + +#define WIFI_MAX_APS 12 +#define SCAN_SIM_STEPS 4 +#define SCAN_SIM_STEP_MS 220 + +#define WIFI_TASK_STACK_SIZE 8192 +#define WIFI_TASK_PRIORITY SYS_PRIO_SERVICE_LO +#define CONNECT_SIM_STEPS 4 +#define CONNECT_SIM_STEP_MS 300 + +#define CONNECT_STATE_FAILED 0 +#define CONNECT_STATE_CONNECTED 1 + +#define SCAN_WAVES_Y_OFS -6 +#define SCAN_CAPTION_Y_OFS 78 + +#define COLOR_OPEN_HEX 0x00E676 +#define COLOR_LOCK_HEX 0xF5B13D +#define COLOR_DIM_HEX 0x8A8594 + +#define NET_BODY_H (LCD_V_RES - UI_CHROME_HEADER_H - UI_CHROME_FOOTER_H) +#define NET_ROW_H 46 +#define NET_ROW_GAP 6 +#define NET_SIDE_PAD 8 +#define NET_CARD_RADIUS 8 +#define NET_GLOW_W 12 +#define NET_GLYPH_SZ 16 +#define NET_GLYPH_X 8 +#define NET_TEXT_X 34 +#define NET_KEYHOLE_SZ 4 +#define NET_BAR_COUNT 3 +#define NET_BAR_W 5 +#define NET_BAR_GAP 3 +#define NET_BAR_H0 8 +#define NET_BAR_STEP 4 +#define NET_ARC_X -12 +#define RSSI_L3_DBM -55 +#define RSSI_L2_DBM -65 +#define RSSI_L1_DBM -75 + +typedef enum { SCAN_RUNNING, SCAN_DONE, SCAN_FAIL } scan_state_t; + +typedef struct { + const char *ssid; + int8_t rssi; +} mock_ap_t; + +typedef struct { + char ssid[25]; + int8_t rssi; + bool open; + uint8_t channel; +} ap_entry_t; + +static const mock_ap_t MOCK_APS[] = { + {"TentacleNet", -38}, + {"HighCode-Guest", -52}, + {"Familia Souza", -60}, + {"iPhone de Ana", -67}, + {"NET_2G_A1B2", -74}, +}; +#define MOCK_AP_COUNT (sizeof(MOCK_APS) / sizeof(MOCK_APS[0])) + +static const uint8_t MOCK_AP_CH[MOCK_AP_COUNT] = {36, 6, 1, 11, 44}; + +static lv_obj_t *s_screen = NULL; +static menu_component_t s_menu; + +static scan_state_t s_scan_state = SCAN_RUNNING; +static bool s_scanning = false; +static int s_ap_count = 0; +static ap_entry_t s_aps[WIFI_MAX_APS]; + +static int s_sel = 0; +static lv_obj_t *s_row[WIFI_MAX_APS]; + +static char s_connect_ssid[33]; +static char s_connect_pass[64]; +static bool s_connecting = false; +static uint8_t s_connect_state = 0; +static uint8_t s_connect_ip[4] = {0}; + +static void connect_wifi_input(const input_event_t *ev, void *ctx); + +static int signal_level(int8_t rssi) { + if (rssi >= RSSI_L3_DBM) + return 3; + if (rssi >= RSSI_L2_DBM) + return 2; + if (rssi >= RSSI_L1_DBM) + return 1; + return 0; +} - if (!wifi_service_is_active()) { - lv_label_set_text(loading, "WIFI OFF"); +static void connect_done_cb(void *unused) { + (void)unused; + if (ui_current_screen() != SCREEN_CONNECT_WIFI) return; + if (s_connect_state == CONNECT_STATE_CONNECTED) { + char msg[48]; + snprintf(msg, + sizeof(msg), + "CONNECTED\n%u.%u.%u.%u", + s_connect_ip[0], + s_connect_ip[1], + s_connect_ip[2], + s_connect_ip[3]); + msgbox_open(LV_SYMBOL_OK, msg, "OK", NULL, NULL); + } else { + msgbox_open(LV_SYMBOL_CLOSE, "CONNECT FAILED\nwrong pass / range?", "OK", NULL, NULL); } +} - wifi_service_scan(); - uint16_t ap_count = wifi_service_get_ap_count(); - lv_obj_del(loading); - - if (main_group != NULL) - lv_group_remove_all_objs(main_group); - - for (uint16_t i = 0; i < ap_count; i++) { - wifi_ap_record_t *ap = wifi_service_get_ap_record(i); - if (ap == NULL) - continue; - - lv_obj_t *item = lv_obj_create(s_wifi_list_cont); - lv_obj_set_size(item, lv_pct(100), WIFI_ITEM_HEIGHT); - lv_obj_add_style(item, &s_style_item, 0); - lv_obj_set_flex_flow(item, LV_FLEX_FLOW_ROW); - lv_obj_set_flex_align(item, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); - lv_obj_clear_flag(item, LV_OBJ_FLAG_SCROLLABLE); - - lv_obj_t *icon = lv_label_create(item); - lv_label_set_text(icon, LV_SYMBOL_WIFI); - lv_obj_set_style_text_color(icon, current_theme.text_main, 0); - - lv_obj_t *lbl_ssid = lv_label_create(item); - lv_label_set_text(lbl_ssid, (char *)ap->ssid); - lv_obj_set_style_text_color(lbl_ssid, current_theme.text_main, 0); - lv_obj_set_flex_grow(lbl_ssid, 1); - lv_obj_set_style_margin_left(lbl_ssid, WIFI_ITEM_ICON_MARGIN, 0); - - if (ap->authmode != WIFI_AUTH_OPEN) { - lv_obj_t *lock = lv_label_create(item); - lv_label_set_text(lock, "KEY"); - lv_obj_set_style_text_color(lock, current_theme.text_main, 0); - } - - lv_obj_set_user_data(item, (void *)ap); - lv_obj_add_event_cb(item, wifi_item_event_cb, LV_EVENT_ALL, NULL); +static void wifi_connect_task(void *arg) { + (void)arg; - if (main_group != NULL) - lv_group_add_obj(main_group, item); - } + for (int i = 0; i < CONNECT_SIM_STEPS; i++) + vTaskDelay(pdMS_TO_TICKS(CONNECT_SIM_STEP_MS)); - if (main_group != NULL) { - lv_obj_t *first = lv_obj_get_child(s_wifi_list_cont, 0); - if (first != NULL) - lv_group_focus_obj(first); - } + s_connect_state = CONNECT_STATE_CONNECTED; + s_connect_ip[0] = 192; + s_connect_ip[1] = 168; + s_connect_ip[2] = 1; + s_connect_ip[3] = 42; + s_connecting = false; + lv_async_call(connect_done_cb, NULL); + vTaskDelete(NULL); } -static void init_styles(void) { - if (s_is_styles_initialized) +static void on_keyboard_submit(const char *text, void *user_data) { + (void)user_data; + if (s_connecting || s_scanning) return; + strncpy(s_connect_pass, text ? text : "", sizeof(s_connect_pass) - 1); + s_connect_pass[sizeof(s_connect_pass) - 1] = '\0'; + s_connecting = true; + ESP_LOGI(TAG, "connecting to '%s'...", s_connect_ssid); + if (xTaskCreatePinnedToCore(wifi_connect_task, + "wifi_conn", + WIFI_TASK_STACK_SIZE, + NULL, + WIFI_TASK_PRIORITY, + NULL, + SYS_CORE_RADIO) != pdPASS) + s_connecting = false; +} - lv_style_init(&s_style_menu); - lv_style_set_bg_color(&s_style_menu, current_theme.screen_base); - lv_style_set_bg_opa(&s_style_menu, LV_OPA_COVER); - lv_style_set_border_width(&s_style_menu, WIFI_MENU_BORDER_WIDTH); - lv_style_set_border_color(&s_style_menu, current_theme.border_interface); - lv_style_set_radius(&s_style_menu, 0); - lv_style_set_pad_all(&s_style_menu, WIFI_MENU_PAD); - - lv_style_init(&s_style_item); - lv_style_set_bg_color(&s_style_item, current_theme.bg_item_bot); - lv_style_set_bg_grad_color(&s_style_item, current_theme.bg_item_top); - lv_style_set_bg_grad_dir(&s_style_item, LV_GRAD_DIR_VER); - lv_style_set_border_width(&s_style_item, WIFI_ITEM_BORDER_WIDTH); - lv_style_set_border_color(&s_style_item, current_theme.border_inactive); - lv_style_set_radius(&s_style_item, 0); - - s_is_styles_initialized = true; +static void add_sec_glyph(lv_obj_t *card, bool open) { + lv_obj_t *g = lv_obj_create(card); + lv_obj_remove_flag(g, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(g, NET_GLYPH_SZ, NET_GLYPH_SZ); + lv_obj_align(g, LV_ALIGN_LEFT_MID, NET_GLYPH_X, 0); + if (open) { + lv_obj_set_style_radius(g, LV_RADIUS_CIRCLE, 0); + lv_obj_set_style_bg_opa(g, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(g, 2, 0); + lv_obj_set_style_border_color(g, lv_color_hex(COLOR_OPEN_HEX), 0); + } else { + lv_obj_set_style_radius(g, 3, 0); + lv_obj_set_style_bg_color(g, lv_color_hex(COLOR_LOCK_HEX), 0); + lv_obj_set_style_bg_opa(g, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(g, 0, 0); + lv_obj_t *hole = lv_obj_create(g); + lv_obj_remove_flag(hole, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(hole, NET_KEYHOLE_SZ, NET_KEYHOLE_SZ); + lv_obj_center(hole); + lv_obj_set_style_radius(hole, LV_RADIUS_CIRCLE, 0); + lv_obj_set_style_bg_color(hole, current_theme.screen_base, 0); + lv_obj_set_style_bg_opa(hole, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(hole, 0, 0); + } } -static bool local_get_known_password(const char *ssid, char *out_password, size_t buffer_size) { - if (ssid == NULL || out_password == NULL) - return false; - - size_t size = 0; - char *buffer = (char *)storage_assets_load_file(WIFI_KNOWN_NETWORKS_FILE, &size); - if (buffer == NULL) - return false; - - cJSON *root = cJSON_Parse(buffer); - free(buffer); - if (root == NULL) - return false; - - bool found = false; - cJSON *item = NULL; - cJSON_ArrayForEach(item, root) { - cJSON *j_ssid = cJSON_GetObjectItem(item, "ssid"); - if (!cJSON_IsString(j_ssid) || strcmp(j_ssid->valuestring, ssid) != 0) - continue; - - cJSON *j_pass = cJSON_GetObjectItem(item, "password"); - if (cJSON_IsString(j_pass)) { - strncpy(out_password, j_pass->valuestring, buffer_size - 1); - out_password[buffer_size - 1] = '\0'; - found = true; - } - break; +static void add_signal_arc(lv_obj_t *card, int level) { + int total_w = NET_BAR_COUNT * NET_BAR_W + (NET_BAR_COUNT - 1) * NET_BAR_GAP; + int max_h = NET_BAR_H0 + (NET_BAR_COUNT - 1) * NET_BAR_STEP; + lv_obj_t *arc = lv_obj_create(card); + lv_obj_remove_flag(arc, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(arc, total_w, max_h); + lv_obj_align(arc, LV_ALIGN_RIGHT_MID, NET_ARC_X, 0); + lv_obj_set_style_bg_opa(arc, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(arc, 0, 0); + lv_obj_set_style_pad_all(arc, 0, 0); + + for (int b = 0; b < NET_BAR_COUNT; b++) { + int h = NET_BAR_H0 + b * NET_BAR_STEP; + lv_obj_t *bar = lv_obj_create(arc); + lv_obj_remove_flag(bar, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(bar, NET_BAR_W, h); + lv_obj_set_pos(bar, b * (NET_BAR_W + NET_BAR_GAP), max_h - h); + lv_obj_set_style_radius(bar, 1, 0); + lv_obj_set_style_border_width(bar, 0, 0); + lv_obj_set_style_bg_opa(bar, LV_OPA_COVER, 0); + lv_obj_set_style_bg_color( + bar, b < level ? lv_color_hex(COLOR_OPEN_HEX) : current_theme.border_inactive, 0); } +} - cJSON_Delete(root); - return found; +static void apply_net_selection(void) { + for (int i = 0; i < s_ap_count; i++) { + bool on = (i == s_sel); + lv_obj_set_style_border_color( + s_row[i], on ? current_theme.border_accent : current_theme.border_inactive, 0); + lv_obj_set_style_shadow_width(s_row[i], on ? NET_GLOW_W : 0, 0); + lv_obj_set_style_shadow_opa(s_row[i], on ? LV_OPA_50 : LV_OPA_TRANSP, 0); + } + if (s_ap_count > 0) + lv_obj_scroll_to_view(s_row[s_sel], LV_ANIM_OFF); } -static void restore_wifi_group(lv_timer_t *timer) { - (void)timer; - s_restore_group_timer = NULL; +static void build_join_list(void) { + ui_chrome_header(s_screen, "Networks", "/assets/icons/wifi_find.bin"); + + lv_obj_t *col = lv_obj_create(s_screen); + lv_obj_set_size(col, LCD_H_RES, NET_BODY_H); + lv_obj_align(col, LV_ALIGN_TOP_MID, 0, UI_CHROME_HEADER_H); + lv_obj_set_style_bg_opa(col, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(col, 0, 0); + lv_obj_set_style_pad_all(col, NET_SIDE_PAD, 0); + lv_obj_set_style_pad_row(col, NET_ROW_GAP, 0); + lv_obj_set_flex_flow(col, LV_FLEX_FLOW_COLUMN); + lv_obj_set_scroll_dir(col, LV_DIR_VER); + + for (int i = 0; i < s_ap_count; i++) { + lv_obj_t *card = lv_obj_create(col); + lv_obj_remove_flag(card, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_width(card, lv_pct(100)); + lv_obj_set_height(card, NET_ROW_H); + lv_obj_set_style_radius(card, NET_CARD_RADIUS, 0); + lv_obj_set_style_bg_color(card, current_theme.bg_secondary, 0); + lv_obj_set_style_bg_opa(card, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(card, 1, 0); + lv_obj_set_style_border_color(card, current_theme.border_inactive, 0); + lv_obj_set_style_shadow_color(card, current_theme.border_accent, 0); + lv_obj_set_style_pad_all(card, 0, 0); + + add_sec_glyph(card, s_aps[i].open); + + lv_obj_t *ssid = lv_label_create(card); + lv_label_set_text(ssid, s_aps[i].ssid); + lv_obj_set_style_text_color(ssid, current_theme.text_main, 0); + lv_obj_set_style_text_font(ssid, &lv_font_montserrat_14, 0); + lv_obj_align(ssid, LV_ALIGN_TOP_LEFT, NET_TEXT_X, 7); + + lv_obj_t *sec = lv_label_create(card); + lv_label_set_text_fmt(sec, "%s ch %d", s_aps[i].open ? "Open" : "WPA2", s_aps[i].channel); + lv_obj_set_style_text_color(sec, lv_color_hex(COLOR_DIM_HEX), 0); + lv_obj_set_style_text_font(sec, &lv_font_montserrat_12, 0); + lv_obj_align(sec, LV_ALIGN_BOTTOM_LEFT, NET_TEXT_X, -6); + + add_signal_arc(card, signal_level(s_aps[i].rssi)); + s_row[i] = card; + } - if (main_group == NULL || s_wifi_list_cont == NULL) - return; + apply_net_selection(); + ui_chrome_footer(s_screen, + LV_SYMBOL_UP LV_SYMBOL_DOWN " Pick " LV_SYMBOL_OK " Join " LV_SYMBOL_LEFT + " Back"); +} - lv_group_remove_all_objs(main_group); - uint32_t child_count = lv_obj_get_child_cnt(s_wifi_list_cont); - for (uint32_t i = 0; i < child_count; i++) { - lv_obj_t *child = lv_obj_get_child(s_wifi_list_cont, i); - if (child != NULL) - lv_group_add_obj(main_group, child); +static void build_screen(void) { + if (s_screen != NULL) { + lv_obj_del(s_screen); + s_screen = NULL; } - lv_obj_t *first = lv_obj_get_child(s_wifi_list_cont, 0); - if (first != NULL) - lv_group_focus_obj(first); -} + s_screen = lv_obj_create(NULL); + lv_obj_set_style_bg_color(s_screen, current_theme.screen_base, 0); + lv_obj_set_style_bg_opa(s_screen, LV_OPA_COVER, 0); + lv_obj_remove_flag(s_screen, LV_OBJ_FLAG_SCROLLABLE); + + s_menu = (menu_component_t){0}; + + if (s_scan_state == SCAN_RUNNING) { + ui_chrome_header(s_screen, "Networks", "/assets/icons/wifi_find.bin"); + waves_create(s_screen, + LV_ALIGN_CENTER, + 0, + SCAN_WAVES_Y_OFS, + LV_SYMBOL_WIFI, + "/assets/icons/wifi_find.bin"); + lv_obj_t *caption = lv_label_create(s_screen); + lv_label_set_text(caption, "Scanning..."); + lv_obj_set_style_text_color(caption, current_theme.text_main, 0); + lv_obj_set_style_text_font(caption, &lv_font_montserrat_14, 0); + lv_obj_set_style_text_align(caption, LV_TEXT_ALIGN_CENTER, 0); + lv_obj_align(caption, LV_ALIGN_CENTER, 0, SCAN_CAPTION_Y_OFS); + ui_chrome_footer(s_screen, LV_SYMBOL_LEFT " Back"); + } else if (s_scan_state == SCAN_FAIL) { + s_menu = menu_component_create(s_screen, "Networks", "/assets/icons/wifi_find.bin"); + menu_component_add_item(&s_menu, "/assets/icons/wifi_find.bin", "Scan failed (C5?)"); + } else if (s_ap_count == 0) { + s_menu = menu_component_create(s_screen, "Networks", "/assets/icons/wifi_find.bin"); + menu_component_add_item(&s_menu, "/assets/icons/wifi_find.bin", "No networks found"); + } else { + build_join_list(); + } -static void on_msgbox_closed(bool confirm) { - (void)confirm; - if (s_restore_group_timer != NULL) - lv_timer_del(s_restore_group_timer); + ui_input_set_screen_handler(connect_wifi_input, NULL); - s_restore_group_timer = lv_timer_create(restore_wifi_group, WIFI_RESTORE_GROUP_DELAY_MS, NULL); - lv_timer_set_repeat_count(s_restore_group_timer, 1); + ui_screen_load_owned(&s_screen, s_screen); } -static void on_wifi_status_async(void *user_data) { - (void)user_data; - if (!s_is_awaiting_connect) +static void scan_done_cb(void *unused) { + (void)unused; + if (ui_current_screen() != SCREEN_CONNECT_WIFI) return; + build_screen(); + ESP_LOGI(TAG, "scan finished: state=%d, %d AP(s)", (int)s_scan_state, s_ap_count); +} - s_is_awaiting_connect = false; - msgbox_close(); +static void wifi_scan_task(void *arg) { + (void)arg; + int count = 0; - if (s_is_pending_connected) - msgbox_open(LV_SYMBOL_OK, "CONECTADO COM SUCESSO", "OK", NULL, on_msgbox_closed); - else - msgbox_open(LV_SYMBOL_CLOSE, "FALHA NA CONEXAO", "OK", NULL, on_msgbox_closed); -} + for (int i = 0; i < SCAN_SIM_STEPS; i++) + vTaskDelay(pdMS_TO_TICKS(SCAN_SIM_STEP_MS)); -static void wifi_status_timer_cb(lv_timer_t *timer) { - if (!s_is_awaiting_connect) { - lv_timer_del(timer); - s_wifi_status_timer = NULL; - return; + for (size_t i = 0; i < MOCK_AP_COUNT && count < WIFI_MAX_APS; i++) { + strncpy(s_aps[count].ssid, MOCK_APS[i].ssid, sizeof(s_aps[count].ssid) - 1); + s_aps[count].ssid[sizeof(s_aps[count].ssid) - 1] = '\0'; + s_aps[count].rssi = MOCK_APS[i].rssi; + s_aps[count].open = (strstr(MOCK_APS[i].ssid, "Guest") != NULL); + s_aps[count].channel = MOCK_AP_CH[i]; + count++; } - if (wifi_service_is_connected()) { - s_is_pending_connected = true; - lv_async_call(on_wifi_status_async, NULL); - lv_timer_del(timer); - s_wifi_status_timer = NULL; - return; + for (int i = 1; i < count; i++) { + for (int j = i; j > 0 && s_aps[j].rssi > s_aps[j - 1].rssi; j--) { + ap_entry_t tmp = s_aps[j]; + s_aps[j] = s_aps[j - 1]; + s_aps[j - 1] = tmp; + } } - if (++s_wifi_status_poll_count >= WIFI_STATUS_POLL_MAX) { - s_is_pending_connected = false; - lv_async_call(on_wifi_status_async, NULL); - lv_timer_del(timer); - s_wifi_status_timer = NULL; - } + s_ap_count = count; + s_scan_state = SCAN_DONE; + s_scanning = false; + lv_async_call(scan_done_cb, NULL); + vTaskDelete(NULL); } -static void on_keyboard_submit(const char *text, void *user_data) { - (void)user_data; - strncpy(s_selected_pass, text != NULL ? text : "", sizeof(s_selected_pass) - 1); - s_selected_pass[sizeof(s_selected_pass) - 1] = '\0'; - - if (wifi_service_connect_to_ap(s_selected_ssid, s_selected_pass) == ESP_OK) { - s_is_awaiting_connect = true; - s_wifi_status_poll_count = 0; - msgbox_open(LV_SYMBOL_WIFI, "CONECTANDO...", NULL, NULL, NULL); - s_wifi_status_timer = lv_timer_create(wifi_status_timer_cb, WIFI_STATUS_POLL_INTERVAL_MS, NULL); - } else { - msgbox_open(LV_SYMBOL_CLOSE, "FALHA NA CONEXAO", "OK", NULL, on_msgbox_closed); +static void connect_wifi_input(const input_event_t *ev, void *ctx) { + (void)ctx; + const bool press = (ev->action == INPUT_ACTION_PRESS); + const bool nav = press || (ev->action == INPUT_ACTION_REPEAT); + const bool join = (s_scan_state == SCAN_DONE && s_ap_count > 0); + + switch (ev->button) { + case INPUT_BTN_DOWN: + if (nav) { + if (join) { + s_sel = (s_sel + 1) % s_ap_count; + apply_net_selection(); + } else { + menu_component_next(&s_menu); + } + } + break; + case INPUT_BTN_UP: + if (nav) { + if (join) { + s_sel = (s_sel - 1 + s_ap_count) % s_ap_count; + apply_net_selection(); + } else { + menu_component_prev(&s_menu); + } + } + break; + case INPUT_BTN_BACK: + case INPUT_BTN_LEFT: + if (press) + ui_switch_screen(SCREEN_CONNECTION_SETTINGS); + break; + case INPUT_BTN_OK: + case INPUT_BTN_RIGHT: + if (press && join && s_sel >= 0 && s_sel < s_ap_count) { + strncpy(s_connect_ssid, s_aps[s_sel].ssid, sizeof(s_connect_ssid) - 1); + s_connect_ssid[sizeof(s_connect_ssid) - 1] = '\0'; + keyboard_open(NULL, on_keyboard_submit, NULL); + } + break; + default: + break; } } -static void wifi_item_event_cb(lv_event_t *e) { - lv_event_code_t code = lv_event_get_code(e); - lv_obj_t *item = lv_event_get_target(e); - - if (code == LV_EVENT_FOCUSED) { - lv_obj_set_style_border_color(item, ui_theme_get_accent(), 0); - lv_obj_set_style_border_width(item, WIFI_MENU_BORDER_WIDTH, 0); - lv_obj_scroll_to_view(item, LV_ANIM_ON); - } else if (code == LV_EVENT_DEFOCUSED) { - lv_obj_set_style_border_color(item, current_theme.border_inactive, 0); - lv_obj_set_style_border_width(item, WIFI_ITEM_BORDER_WIDTH, 0); - } else if (code == LV_EVENT_KEY) { - uint32_t key = lv_event_get_key(e); - if (key == LV_KEY_ESC || key == LV_KEY_LEFT) { - ui_switch_screen(SCREEN_CONNECTION_SETTINGS); - } else if (key == LV_KEY_ENTER || key == LV_KEY_RIGHT) { - wifi_ap_record_t *ap = (wifi_ap_record_t *)lv_obj_get_user_data(item); - if (ap == NULL) - return; - - strncpy(s_selected_ssid, (const char *)ap->ssid, sizeof(s_selected_ssid) - 1); - s_selected_ssid[sizeof(s_selected_ssid) - 1] = '\0'; - - if (ap->authmode == WIFI_AUTH_OPEN) { - on_keyboard_submit("", NULL); - } else if (local_get_known_password(s_selected_ssid, s_known_pass, sizeof(s_known_pass))) { - on_keyboard_submit(s_known_pass, NULL); - } else { - keyboard_open(NULL, on_keyboard_submit, NULL); - } +void ui_connect_wifi_open(void) { + s_scan_state = SCAN_RUNNING; + s_ap_count = 0; + s_sel = 0; + build_screen(); + + if (!s_scanning) { + s_scanning = true; + if (xTaskCreatePinnedToCore(wifi_scan_task, + "wifi_scan", + WIFI_TASK_STACK_SIZE, + NULL, + WIFI_TASK_PRIORITY, + NULL, + SYS_CORE_RADIO) != pdPASS) { + s_scanning = false; + s_scan_state = SCAN_FAIL; + build_screen(); } } -} \ No newline at end of file + + ESP_LOGI(TAG, "Networks screen opened (mock scan)"); +} diff --git a/firmware_p4/components/Applications/ui/screens/connection_settings/connection_settings_ui.c b/firmware_p4/components/Applications/ui/screens/connection_settings/connection_settings_ui.c index bdf137542..7b2c60c94 100644 --- a/firmware_p4/components/Applications/ui/screens/connection_settings/connection_settings_ui.c +++ b/firmware_p4/components/Applications/ui/screens/connection_settings/connection_settings_ui.c @@ -18,10 +18,13 @@ #include "esp_log.h" #include "esp_timer.h" -#include "buttons_gpio.h" +#include "host_link.h" #include "lv_port_indev.h" #include "menu_component_ui.h" #include "msgbox_ui.h" +#include "notify_ui.h" +#include "power_manager.h" +#include "tusb_desc.h" #include "ui_manager.h" #include "ui_theme.h" #include "wifi_service.h" @@ -30,7 +33,7 @@ static const char *TAG = "CONN_UI"; #define IDX_WIFI 0 #define IDX_NETWORKS 1 -#define NAV_TIMER_INTERVAL_MS 50 +#define IDX_USB_NATIVE 2 #define WIFI_LOADING_TIMER_INTERVAL_MS 100 #define WIFI_LOADING_MIN_US 1500000 #define WIFI_LOADING_MAX_US 5000000 @@ -38,22 +41,14 @@ static const char *TAG = "CONN_UI"; static lv_obj_t *s_screen_conn = NULL; static menu_component_t s_menu; -static lv_timer_t *s_nav_timer = NULL; static lv_timer_t *s_wifi_loading_timer = NULL; static int64_t s_wifi_loading_start_time = 0; static int64_t s_msgbox_open_time = 0; - -static bool s_btn_up_last = false; -static bool s_btn_down_last = false; -static bool s_btn_left_last = false; -static bool s_btn_right_last = false; -static bool s_btn_ok_last = false; -static bool s_btn_back_last = false; +static bool s_usb_no_sleep_held = false; static void wifi_loading_timer_cb(lv_timer_t *timer); static void show_wifi_loading(void); -static void update_wifi_toggle(void); -static void nav_timer_cb(lv_timer_t *timer); +static void connection_settings_input(const input_event_t *ev, void *ctx); void ui_connection_settings_open(void) { if (s_screen_conn != NULL) { @@ -68,14 +63,15 @@ void ui_connection_settings_open(void) { lv_obj_set_style_bg_opa(s_screen_conn, LV_OPA_COVER, 0); lv_obj_remove_flag(s_screen_conn, LV_OBJ_FLAG_SCROLLABLE); - s_menu = menu_component_create(s_screen_conn, "CONNECTION", NULL); - menu_component_add_toggle(&s_menu, "/assets/icons/wifi_menu_icon.bin", "WI-FI", is_wifi_active); - menu_component_add_item(&s_menu, "/assets/icons/search_menu_icon.bin", "NETWORKS"); + s_menu = menu_component_create(s_screen_conn, "CONNECTION", "/assets/icons/hub.bin"); + menu_component_add_toggle(&s_menu, "/assets/icons/wifi.bin", "WI-FI", is_wifi_active); + menu_component_add_item(&s_menu, "/assets/icons/wifi_find.bin", "NETWORKS"); + // USB-C data mux: OFF = UART bridge (serial/flash, default), ON = native P4 USB. + menu_component_add_toggle(&s_menu, "/assets/icons/usb.bin", "USB NATIVE", usb_mux_is_native()); - if (s_nav_timer == NULL) - s_nav_timer = lv_timer_create(nav_timer_cb, NAV_TIMER_INTERVAL_MS, NULL); + ui_input_set_screen_handler(connection_settings_input, NULL); - lv_screen_load(s_screen_conn); + ui_screen_load_owned(&s_screen_conn, s_screen_conn); } static void wifi_loading_timer_cb(lv_timer_t *timer) { @@ -88,6 +84,7 @@ static void wifi_loading_timer_cb(lv_timer_t *timer) { lv_timer_del(timer); s_wifi_loading_timer = NULL; msgbox_close(); + notify(NOTIFY_INFO, "Wi-Fi on"); } static void show_wifi_loading(void) { @@ -100,70 +97,71 @@ static void show_wifi_loading(void) { lv_timer_create(wifi_loading_timer_cb, WIFI_LOADING_TIMER_INTERVAL_MS, NULL); } -static void update_wifi_toggle(void) { - bool is_active = wifi_service_is_active(); - menu_component_set_toggle(&s_menu, IDX_WIFI, is_active); -} - -static void nav_timer_cb(lv_timer_t *timer) { - if (lv_screen_active() != s_screen_conn) { - lv_timer_delete(timer); - s_nav_timer = NULL; - return; +static void conn_toggle(int sel) { + if (sel == IDX_WIFI) { + menu_component_toggle_item(&s_menu, IDX_WIFI); + bool is_new_state = menu_component_get_toggle(&s_menu, IDX_WIFI); + wifi_service_set_enabled(is_new_state); + + if (is_new_state) { + show_wifi_loading(); + } else { + msgbox_close(); + notify(NOTIFY_INFO, "Wi-Fi off"); + } + } else if (sel == IDX_USB_NATIVE) { + menu_component_toggle_item(&s_menu, IDX_USB_NATIVE); + bool native = menu_component_get_toggle(&s_menu, IDX_USB_NATIVE); + usb_mux_set_native(native); + if (native) { + host_link_cdc_init(); + } + if (native && !s_usb_no_sleep_held) { + power_manager_no_sleep_acquire(); + s_usb_no_sleep_held = true; + } else if (!native && s_usb_no_sleep_held) { + power_manager_no_sleep_release(); + s_usb_no_sleep_held = false; + } + notify(NOTIFY_INFO, native ? "USB: native (serial off)" : "USB: UART bridge"); } +} - if (ui_input_is_locked()) - return; - - bool is_up = up_button_is_down(); - bool is_down = down_button_is_down(); - bool is_left = left_button_is_down(); - bool is_right = right_button_is_down(); - bool is_ok = ok_button_is_down(); - bool is_back = back_button_is_down(); - +static void connection_settings_input(const input_event_t *ev, void *ctx) { + (void)ctx; + const bool press = (ev->action == INPUT_ACTION_PRESS); + const bool nav = press || (ev->action == INPUT_ACTION_REPEAT); int sel = menu_component_get_selected(&s_menu); - if (is_down && !s_btn_down_last) - menu_component_next(&s_menu); - - if (is_up && !s_btn_up_last) - menu_component_prev(&s_menu); - - if (is_back && !s_btn_back_last) - ui_switch_screen(SCREEN_SETTINGS); - - if ((is_left && !s_btn_left_last) || (is_right && !s_btn_right_last)) { - if (sel == IDX_WIFI) { - menu_component_toggle_item(&s_menu, IDX_WIFI); - bool is_new_state = menu_component_get_toggle(&s_menu, IDX_WIFI); - wifi_service_set_enabled(is_new_state); - - if (is_new_state) - show_wifi_loading(); - else - msgbox_close(); - } - } - - if ((is_ok && !s_btn_ok_last) || (is_right && !s_btn_right_last)) { - if (sel == IDX_NETWORKS) { - if (!wifi_service_is_active()) { - int64_t now = esp_timer_get_time(); - if (now - s_msgbox_open_time >= MSGBOX_DEBOUNCE_US) { - s_msgbox_open_time = now; - msgbox_open(LV_SYMBOL_CLOSE, "WIFI OFF", "OK", NULL, NULL); - } - } else { - ui_switch_screen(SCREEN_CONNECT_WIFI); + switch (ev->button) { + case INPUT_BTN_DOWN: + if (nav) + menu_component_next(&s_menu); + break; + case INPUT_BTN_UP: + if (nav) + menu_component_prev(&s_menu); + break; + case INPUT_BTN_BACK: + if (press) + ui_switch_screen(SCREEN_SETTINGS); + break; + case INPUT_BTN_LEFT: + if (press) + conn_toggle(sel); + break; + case INPUT_BTN_RIGHT: + if (press) { + conn_toggle(sel); + if (sel == IDX_NETWORKS) + ui_switch_screen(SCREEN_CONNECT_WIFI); } - } + break; + case INPUT_BTN_OK: + if (press && sel == IDX_NETWORKS) + ui_switch_screen(SCREEN_CONNECT_WIFI); + break; + default: + break; } - - s_btn_up_last = is_up; - s_btn_down_last = is_down; - s_btn_left_last = is_left; - s_btn_right_last = is_right; - s_btn_ok_last = is_ok; - s_btn_back_last = is_back; -} \ No newline at end of file +} diff --git a/firmware_p4/components/Applications/ui/screens/dev/dev_console_ui.c b/firmware_p4/components/Applications/ui/screens/dev/dev_console_ui.c new file mode 100644 index 000000000..cdfe436ba --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/dev/dev_console_ui.c @@ -0,0 +1,304 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#include "dev_console_ui.h" + +#include +#include + +#include "esp_heap_caps.h" +#include "lvgl.h" +#include "st7789.h" + +#include "assets_manager.h" +#include "battery_service.h" +#include "bluetooth_service.h" +#include "spi_bridge.h" +#include "terminal_ui.h" +#include "ui_chrome.h" +#include "ui_manager.h" +#include "ui_theme.h" +#include "wifi_service.h" + +#define THUMB_TICK_MS 50 +#define STREAM_TIMER_MS 350 + +#define TITLE "CONSOLE" +#define ICON "/assets/icons/monitoring.bin" +#define DRAG_ICON "/assets/icons/drag_indicator.bin" +#define FOOTER_HINT "UP/DOWN scroll BACK exit" + +#define BODY_X 6 +#define BODY_TOP_PAD 4 +#define BODY_BOTTOM_PAD 4 +#define RIGHT_GUTTER 16 +#define BODY_Y (UI_CHROME_HEADER_H + BODY_TOP_PAD) +#define TERM_W (LCD_H_RES - BODY_X - RIGHT_GUTTER) +#define TERM_H (LCD_V_RES - BODY_Y - UI_CHROME_FOOTER_H - BODY_BOTTOM_PAD) + +#define SCROLL_TRACK_X 227 +#define SCROLL_TRACK_Y 54 +#define SCROLL_TRACK_LEN 232 +#define SCROLL_LINE_W 3 +#define SCROLL_DASH 4 +#define SCROLL_THUMB_X 223 +#define SCROLL_THUMB_H 45 + +#define LINE_STEP 24 +#define FOLLOW_SLOP 2 + +#define LOG_SOFT_CAP 1400 +#define LOG_KEEP 1000 +#define SEED_LINES 6 + +#define STATUS_KINDS 6 +#define STATUS_LINE_LEN 64 + +static void format_status_line(int idx, char *buf, size_t n) { + switch (idx % STATUS_KINDS) { + case 0: + snprintf(buf, + n, + "[heap] int %uKB dma %uKB", + (unsigned)(heap_caps_get_free_size(MALLOC_CAP_INTERNAL) / 1024), + (unsigned)(heap_caps_get_free_size(MALLOC_CAP_DMA) / 1024)); + break; + case 1: + snprintf(buf, + n, + "[psram] free %uKB", + (unsigned)(heap_caps_get_free_size(MALLOC_CAP_SPIRAM) / 1024)); + break; + case 2: + snprintf(buf, n, "[c5] bridge %s", spi_bridge_is_alive() ? "alive" : "down"); + break; + case 3: { + battery_snapshot_t b; + if (battery_service_get(&b) && b.valid) { + snprintf(buf, + n, + "[batt] %d%% %umV %s", + b.soc, + (unsigned)b.vbat_mv, + b.charging ? "chg" : (b.vbus_present ? "usb" : "bat")); + } else { + snprintf(buf, n, "[batt] --"); + } + break; + } + case 4: + snprintf(buf, + n, + "[radio] wifi %s ble %s", + wifi_service_is_active() ? "on" : "off", + bluetooth_service_is_running_cached() ? "on" : "off"); + break; + default: + snprintf(buf, + n, + "[sys] up %lus scr %d", + (unsigned long)(lv_tick_get() / 1000), + (int)ui_current_screen()); + break; + } +} + +static lv_obj_t *s_screen = NULL; +static lv_obj_t *s_term = NULL; +static lv_obj_t *s_thumb = NULL; +static lv_timer_t *s_thumb_timer = NULL; +static lv_timer_t *s_stream = NULL; + +static int s_line_idx = 0; +static bool s_follow = true; + +static char s_trim_buf[LOG_KEEP + 1]; + +static void update_thumb(void) { + if (s_term == NULL || s_thumb == NULL) + return; + + lv_obj_update_layout(s_term); + + int32_t thumb_h = lv_obj_get_height(s_thumb); + if (thumb_h <= 0) + thumb_h = SCROLL_THUMB_H; + + int32_t travel = SCROLL_TRACK_LEN - thumb_h; + if (travel < 0) + travel = 0; + + int32_t scroll_y = lv_obj_get_scroll_y(s_term); + int32_t scroll_bottom = lv_obj_get_scroll_bottom(s_term); + int32_t max_scroll = scroll_y + scroll_bottom; + + int32_t pos = SCROLL_TRACK_Y; + if (max_scroll > 0) { + if (scroll_y < 0) + scroll_y = 0; + if (scroll_y > max_scroll) + scroll_y = max_scroll; + pos = SCROLL_TRACK_Y + (scroll_y * travel) / max_scroll; + } + + if (lv_obj_get_y(s_thumb) != pos) + lv_obj_set_y(s_thumb, pos); +} + +static void push_line(void) { + if (s_term == NULL) + return; + + char line[STATUS_LINE_LEN]; + format_status_line(s_line_idx, line, sizeof(line)); + lv_textarea_add_text(s_term, line); + lv_textarea_add_text(s_term, "\n"); + + s_line_idx++; + if (s_line_idx >= STATUS_KINDS) + s_line_idx = 0; + + const char *txt = lv_textarea_get_text(s_term); + size_t len = (txt != NULL) ? strlen(txt) : 0; + if (len > LOG_SOFT_CAP) { + const char *tail = txt + (len - LOG_KEEP); + const char *nl = strchr(tail, '\n'); + if (nl != NULL) + tail = nl + 1; + size_t keep = strlen(tail); + if (keep > LOG_KEEP) + keep = LOG_KEEP; + memcpy(s_trim_buf, tail, keep); + s_trim_buf[keep] = '\0'; + lv_textarea_set_text(s_term, s_trim_buf); + } + + lv_obj_update_layout(s_term); + int32_t bottom = lv_obj_get_scroll_y(s_term) + lv_obj_get_scroll_bottom(s_term); + lv_obj_scroll_to_y(s_term, bottom, LV_ANIM_OFF); + + update_thumb(); +} + +static void stream_cb(lv_timer_t *t) { + if (lv_screen_active() != s_screen) { + lv_timer_delete(t); + s_stream = NULL; + return; + } + if (ui_input_is_locked()) + return; + if (!s_follow) + return; + push_line(); +} + +static void thumb_tick_cb(lv_timer_t *t) { + if (lv_screen_active() != s_screen) { + lv_timer_delete(t); + s_thumb_timer = NULL; + return; + } + update_thumb(); +} + +static void dev_console_input(const input_event_t *ev, void *ctx) { + (void)ctx; + const bool press = (ev->action == INPUT_ACTION_PRESS); + const bool nav = press || (ev->action == INPUT_ACTION_REPEAT); + switch (ev->button) { + case INPUT_BTN_UP: + if (nav) { + lv_obj_scroll_by(s_term, 0, LINE_STEP, LV_ANIM_ON); + s_follow = false; + } + break; + case INPUT_BTN_DOWN: + if (nav) { + int32_t scroll_bottom = lv_obj_get_scroll_bottom(s_term); + lv_obj_scroll_by(s_term, 0, -LINE_STEP, LV_ANIM_ON); + if (scroll_bottom <= LINE_STEP + FOLLOW_SLOP) + s_follow = true; + } + break; + case INPUT_BTN_BACK: + case INPUT_BTN_LEFT: + if (press) + ui_switch_screen(SCREEN_DEV_MENU); + break; + default: + break; + } +} + +static void build_screen(void) { + s_screen = lv_obj_create(NULL); + lv_obj_set_style_bg_color(s_screen, current_theme.screen_base, 0); + lv_obj_set_style_bg_opa(s_screen, LV_OPA_COVER, 0); + lv_obj_remove_flag(s_screen, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_border_width(s_screen, 0, 0); + lv_obj_set_style_pad_all(s_screen, 0, 0); + + ui_chrome_header(s_screen, TITLE, ICON); + ui_chrome_footer(s_screen, FOOTER_HINT); + + s_term = terminal_ui_create(s_screen, TERM_W, TERM_H, LV_ALIGN_TOP_LEFT, BODY_X, BODY_Y); + lv_obj_set_scrollbar_mode(s_term, LV_SCROLLBAR_MODE_OFF); + lv_textarea_set_cursor_click_pos(s_term, false); + lv_obj_set_style_anim_duration(s_term, 0, LV_PART_CURSOR); + lv_obj_set_style_opa(s_term, LV_OPA_TRANSP, LV_PART_CURSOR); + lv_obj_set_style_bg_opa(s_term, LV_OPA_TRANSP, LV_PART_CURSOR); + lv_obj_set_style_border_width(s_term, 0, LV_PART_CURSOR); + + static lv_point_precise_t track_pts[2] = {{0, 0}, {0, SCROLL_TRACK_LEN}}; + lv_obj_t *track = lv_line_create(s_screen); + lv_line_set_points(track, track_pts, 2); + lv_obj_set_pos(track, SCROLL_TRACK_X, SCROLL_TRACK_Y); + lv_obj_set_style_line_width(track, SCROLL_LINE_W, 0); + lv_obj_set_style_line_color(track, current_theme.border_inactive, 0); + lv_obj_set_style_line_opa(track, LV_OPA_COVER, 0); + lv_obj_set_style_line_dash_width(track, SCROLL_DASH, 0); + lv_obj_set_style_line_dash_gap(track, SCROLL_DASH, 0); + + s_thumb = lv_image_create(s_screen); + lv_image_dsc_t *thumb_dsc = assets_get(DRAG_ICON); + if (thumb_dsc != NULL) + lv_image_set_src(s_thumb, thumb_dsc); + lv_obj_set_pos(s_thumb, SCROLL_THUMB_X, SCROLL_TRACK_Y); + lv_obj_move_foreground(s_thumb); + + for (int i = 0; i < SEED_LINES; i++) + push_line(); + + update_thumb(); + ui_input_set_screen_handler(dev_console_input, NULL); + ui_screen_load_owned(&s_screen, s_screen); +} + +void ui_dev_console_open(void) { + s_screen = NULL; + s_term = NULL; + s_thumb = NULL; + s_thumb_timer = NULL; + s_stream = NULL; + s_line_idx = 0; + s_follow = true; + s_trim_buf[0] = '\0'; + + build_screen(); + + s_thumb_timer = lv_timer_create(thumb_tick_cb, THUMB_TICK_MS, NULL); + s_stream = lv_timer_create(stream_cb, STREAM_TIMER_MS, NULL); +} diff --git a/firmware_p4/components/Applications/ui/screens/dev/dev_diag_ui.c b/firmware_p4/components/Applications/ui/screens/dev/dev_diag_ui.c new file mode 100644 index 000000000..e3ed8949d --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/dev/dev_diag_ui.c @@ -0,0 +1,305 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#include "dev_diag_ui.h" + +#include + +#include "esp_heap_caps.h" +#include "lvgl.h" + +#include "battery_service.h" +#include "spi_bridge.h" +#include "sys_metrics.h" +#include "ui_chrome.h" +#include "ui_manager.h" +#include "ui_theme.h" + +#define TICK_MS 260 + +#define TITLE "DIAGNOSTICS" +#define ICON "/assets/icons/troubleshoot.bin" +#define FOOTER_HINT LV_SYMBOL_LEFT " BACK exit" + +#define OK_COLOR 0x00E676 +#define WARN_COLOR 0xFFC23D +#define CYAN_COLOR 0x37E0A8 +#define DIM_COLOR 0x8A8594 +#define GRID_COLOR 0x241F31 + +#define PANEL_PAD 8 +#define PANEL_RAD 11 +#define CHART_POINTS 30 +#define CHART_H 44 +#define STATS_H 40 + +#define INT_MAX_KB 256 +#define DMA_MAX_KB 64 +#define TEMP_MIN_C 10 +#define TEMP_MAX_C 80 +#define TEMP_INVALID_C (-300.0f) +#define BATT_LOW_PCT 20 + +static lv_obj_t *s_screen = NULL; +static lv_timer_t *s_tick = NULL; + +static lv_obj_t *s_heap_chart = NULL; +static lv_chart_series_t *s_heap_ser = NULL; +static lv_obj_t *s_dma_chart = NULL; +static lv_chart_series_t *s_dma_ser = NULL; +static lv_obj_t *s_heap_val = NULL; +static lv_obj_t *s_dma_val = NULL; +static lv_obj_t *s_batt_val = NULL; +static lv_obj_t *s_temp_val = NULL; +static lv_obj_t *s_c5_val = NULL; + +static lv_obj_t *make_panel(lv_obj_t *parent, int h) { + lv_obj_t *p = lv_obj_create(parent); + lv_obj_remove_flag(p, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(p, lv_pct(100), h); + lv_obj_set_style_radius(p, PANEL_RAD, 0); + lv_obj_set_style_bg_color(p, current_theme.bg_secondary, 0); + lv_obj_set_style_bg_opa(p, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(p, 1, 0); + lv_obj_set_style_border_color(p, current_theme.border_inactive, 0); + lv_obj_set_style_pad_all(p, PANEL_PAD, 0); + lv_obj_set_style_pad_row(p, 4, 0); + lv_obj_set_flex_flow(p, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(p, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START); + return p; +} + +static lv_obj_t * +make_value_label(lv_obj_t *panel, const char *title, uint32_t color, const char *initial) { + lv_obj_t *head = lv_obj_create(panel); + lv_obj_remove_flag(head, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(head, lv_pct(100), LV_SIZE_CONTENT); + lv_obj_set_style_bg_opa(head, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(head, 0, 0); + lv_obj_set_style_pad_all(head, 0, 0); + lv_obj_set_flex_flow(head, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align( + head, LV_FLEX_ALIGN_SPACE_BETWEEN, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + + lv_obj_t *t = lv_label_create(head); + lv_label_set_text(t, title); + lv_obj_set_style_text_color(t, lv_color_hex(DIM_COLOR), 0); + lv_obj_set_style_text_font(t, &lv_font_montserrat_12, 0); + + lv_obj_t *v = lv_label_create(head); + lv_label_set_text(v, initial); + lv_obj_set_style_text_color(v, lv_color_hex(color), 0); + lv_obj_set_style_text_font(v, &lv_font_montserrat_14, 0); + return v; +} + +static void style_chart(lv_obj_t *chart, uint32_t line_color, bool fill) { + lv_obj_set_size(chart, lv_pct(100), CHART_H); + lv_obj_set_style_bg_opa(chart, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(chart, 0, 0); + lv_obj_set_style_pad_all(chart, 0, 0); + lv_obj_set_style_line_color(chart, lv_color_hex(GRID_COLOR), LV_PART_MAIN); + lv_obj_set_style_line_opa(chart, LV_OPA_50, LV_PART_MAIN); + lv_obj_set_style_width(chart, 0, LV_PART_INDICATOR); + lv_obj_set_style_height(chart, 0, LV_PART_INDICATOR); + lv_chart_set_type(chart, LV_CHART_TYPE_LINE); + lv_chart_set_update_mode(chart, LV_CHART_UPDATE_MODE_SHIFT); + lv_chart_set_point_count(chart, CHART_POINTS); + lv_chart_set_range(chart, LV_CHART_AXIS_PRIMARY_Y, 0, 100); + lv_chart_set_div_line_count(chart, 2, 0); + lv_obj_set_style_line_width(chart, 2, LV_PART_ITEMS); + if (fill) { + lv_obj_set_style_bg_color(chart, lv_color_hex(line_color), LV_PART_ITEMS); + lv_obj_set_style_bg_opa(chart, LV_OPA_20, LV_PART_ITEMS); + } else { + lv_obj_set_style_bg_opa(chart, LV_OPA_TRANSP, LV_PART_ITEMS); + } +} + +static lv_obj_t *make_stat(lv_obj_t *row, const char *key, const char *val, uint32_t color) { + lv_obj_t *c = lv_obj_create(row); + lv_obj_remove_flag(c, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_flex_grow(c, 1); + lv_obj_set_height(c, lv_pct(100)); + lv_obj_set_style_radius(c, 8, 0); + lv_obj_set_style_bg_color(c, current_theme.bg_primary, 0); + lv_obj_set_style_bg_opa(c, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(c, 1, 0); + lv_obj_set_style_border_color(c, current_theme.border_inactive, 0); + lv_obj_set_style_pad_all(c, 5, 0); + lv_obj_set_style_pad_row(c, 1, 0); + lv_obj_set_flex_flow(c, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(c, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START); + + lv_obj_t *k = lv_label_create(c); + lv_label_set_text(k, key); + lv_obj_set_style_text_color(k, lv_color_hex(DIM_COLOR), 0); + lv_obj_set_style_text_font(k, &lv_font_montserrat_12, 0); + + lv_obj_t *v = lv_label_create(c); + lv_label_set_text(v, val); + lv_obj_set_style_text_color(v, lv_color_hex(color), 0); + lv_obj_set_style_text_font(v, &lv_font_montserrat_14, 0); + return v; +} + +static float read_die_temp(void) { + float celsius = TEMP_INVALID_C; + if (!sys_metrics_die_temp_c(&celsius)) { + return TEMP_INVALID_C; + } + return celsius; +} + +static void tick_cb(lv_timer_t *t) { + if (lv_screen_active() != s_screen) { + lv_timer_delete(t); + s_tick = NULL; + return; + } + + int int_kb = (int)(heap_caps_get_free_size(MALLOC_CAP_INTERNAL) / 1024); + int dma_kb = (int)(heap_caps_get_free_size(MALLOC_CAP_DMA) / 1024); + + if (s_heap_ser != NULL) + lv_chart_set_next_value(s_heap_chart, s_heap_ser, int_kb); + if (s_dma_ser != NULL) + lv_chart_set_next_value(s_dma_chart, s_dma_ser, dma_kb); + + char buf[24]; + if (s_heap_val != NULL) { + snprintf(buf, sizeof(buf), "%d KB", int_kb); + lv_label_set_text(s_heap_val, buf); + } + if (s_dma_val != NULL) { + snprintf(buf, sizeof(buf), "%d KB", dma_kb); + lv_label_set_text(s_dma_val, buf); + } + + if (s_batt_val != NULL) { + battery_snapshot_t b; + if (battery_service_get(&b) && b.valid) { + snprintf(buf, sizeof(buf), "%d%%", b.soc); + lv_obj_set_style_text_color( + s_batt_val, lv_color_hex(b.soc <= BATT_LOW_PCT ? WARN_COLOR : OK_COLOR), 0); + } else { + snprintf(buf, sizeof(buf), "--"); + } + lv_label_set_text(s_batt_val, buf); + } + + if (s_temp_val != NULL) { + float celsius = read_die_temp(); + if (celsius > TEMP_INVALID_C) { + snprintf(buf, + sizeof(buf), + "%d\xC2\xB0" + "C", + (int)(celsius + 0.5f)); + } else { + snprintf(buf, sizeof(buf), "--"); + } + lv_label_set_text(s_temp_val, buf); + } + + if (s_c5_val != NULL) { + bool alive = spi_bridge_is_alive(); + lv_label_set_text(s_c5_val, alive ? "OK" : "OFF"); + lv_obj_set_style_text_color(s_c5_val, lv_color_hex(alive ? OK_COLOR : WARN_COLOR), 0); + } +} + +static void dev_diag_input(const input_event_t *ev, void *ctx) { + (void)ctx; + const bool press = (ev->action == INPUT_ACTION_PRESS); + switch (ev->button) { + case INPUT_BTN_BACK: + case INPUT_BTN_LEFT: + if (press) + ui_switch_screen(SCREEN_DEV_MENU); + break; + default: + break; + } +} + +static void build_screen(void) { + s_screen = lv_obj_create(NULL); + lv_obj_set_style_bg_color(s_screen, current_theme.screen_base, 0); + lv_obj_set_style_bg_opa(s_screen, LV_OPA_COVER, 0); + lv_obj_remove_flag(s_screen, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_border_width(s_screen, 0, 0); + lv_obj_set_style_pad_all(s_screen, 0, 0); + + ui_chrome_header(s_screen, TITLE, ICON); + ui_chrome_footer(s_screen, FOOTER_HINT); + + lv_obj_t *col = lv_obj_create(s_screen); + lv_obj_remove_flag(col, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(col, lv_pct(100), LV_SIZE_CONTENT); + lv_obj_align(col, LV_ALIGN_TOP_MID, 0, UI_CHROME_HEADER_H + 6); + lv_obj_set_style_bg_opa(col, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(col, 0, 0); + lv_obj_set_style_pad_hor(col, 10, 0); + lv_obj_set_style_pad_row(col, 8, 0); + lv_obj_set_flex_flow(col, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(col, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START); + + int int_kb = (int)(heap_caps_get_free_size(MALLOC_CAP_INTERNAL) / 1024); + int dma_kb = (int)(heap_caps_get_free_size(MALLOC_CAP_DMA) / 1024); + + lv_obj_t *heap_panel = make_panel(col, CHART_H + 40); + s_heap_val = make_value_label(heap_panel, "Internal free", 0xB89AFF, "-- KB"); + s_heap_chart = lv_chart_create(heap_panel); + style_chart(s_heap_chart, 0xB89AFF, true); + lv_chart_set_range(s_heap_chart, LV_CHART_AXIS_PRIMARY_Y, 0, INT_MAX_KB); + s_heap_ser = lv_chart_add_series(s_heap_chart, lv_color_hex(0xB89AFF), LV_CHART_AXIS_PRIMARY_Y); + + lv_obj_t *dma_panel = make_panel(col, CHART_H + 40); + s_dma_val = make_value_label(dma_panel, "DMA free", CYAN_COLOR, "-- KB"); + s_dma_chart = lv_chart_create(dma_panel); + style_chart(s_dma_chart, CYAN_COLOR, false); + lv_chart_set_range(s_dma_chart, LV_CHART_AXIS_PRIMARY_Y, 0, DMA_MAX_KB); + s_dma_ser = lv_chart_add_series(s_dma_chart, lv_color_hex(CYAN_COLOR), LV_CHART_AXIS_PRIMARY_Y); + + for (int i = 0; i < CHART_POINTS; i++) { + lv_chart_set_next_value(s_heap_chart, s_heap_ser, int_kb); + lv_chart_set_next_value(s_dma_chart, s_dma_ser, dma_kb); + } + + lv_obj_t *stats = lv_obj_create(col); + lv_obj_remove_flag(stats, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(stats, lv_pct(100), STATS_H); + lv_obj_set_style_bg_opa(stats, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(stats, 0, 0); + lv_obj_set_style_pad_all(stats, 0, 0); + lv_obj_set_style_pad_column(stats, 6, 0); + lv_obj_set_flex_flow(stats, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align( + stats, LV_FLEX_ALIGN_SPACE_BETWEEN, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + s_batt_val = make_stat(stats, "Batt", "--", OK_COLOR); + s_temp_val = make_stat(stats, "Temp", "--", CYAN_COLOR); + s_c5_val = make_stat(stats, "C5", "--", DIM_COLOR); + + ui_input_set_screen_handler(dev_diag_input, NULL); + + ui_screen_load_owned(&s_screen, s_screen); +} + +void ui_dev_diag_open(void) { + s_tick = NULL; + build_screen(); + s_tick = lv_timer_create(tick_cb, TICK_MS, NULL); +} diff --git a/firmware_p4/components/Applications/ui/screens/dev/dev_menu_ui.c b/firmware_p4/components/Applications/ui/screens/dev/dev_menu_ui.c new file mode 100644 index 000000000..e9b40dcbb --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/dev/dev_menu_ui.c @@ -0,0 +1,125 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#include "dev_menu_ui.h" + +#include "lvgl.h" + +#include "menu_component_ui.h" +#include "ui_feedback.h" +#include "ui_manager.h" +#include "ui_theme.h" + +#define FADE_MS 200 + +#define TITLE "DEVELOPER" +#define TITLE_ICON "/assets/icons/developer_board.bin" +#define FOOTER_HINT "UP/DOWN OK select BACK" + +static const struct { + const char *name; + const char *icon; + screen_id_t screen; +} MENU_ITEMS[] = { + {"Scripts", "/assets/icons/description.bin", SCREEN_SCRIPTS}, + {"Console", "/assets/icons/monitoring.bin", SCREEN_DEV_CONSOLE}, + {"P4 Update", "/assets/icons/system_update.bin", SCREEN_SYSTEM_UPDATE}, + {"Diagnostics", "/assets/icons/troubleshoot.bin", SCREEN_DEV_DIAG}, + {"Boot map", "/assets/icons/storage.bin", SCREEN_BOOT_MAP}, + {"Last crash", "/assets/icons/warning.bin", SCREEN_CRASH_REPORT}, +}; +#define MENU_ITEM_COUNT ((int)(sizeof(MENU_ITEMS) / sizeof(MENU_ITEMS[0]))) + +static lv_obj_t *s_screen = NULL; +static menu_component_t s_menu; + +static void dev_menu_input(const input_event_t *ev, void *ctx); + +static void opa_anim_cb(void *var, int32_t v) { + lv_obj_set_style_opa((lv_obj_t *)var, (lv_opa_t)v, 0); +} + +static void fade_in(lv_obj_t *obj, uint32_t duration_ms) { + lv_obj_set_style_opa(obj, LV_OPA_TRANSP, 0); + lv_anim_t a; + lv_anim_init(&a); + lv_anim_set_var(&a, obj); + lv_anim_set_exec_cb(&a, opa_anim_cb); + lv_anim_set_values(&a, LV_OPA_TRANSP, LV_OPA_COVER); + lv_anim_set_duration(&a, duration_ms); + lv_anim_set_path_cb(&a, lv_anim_path_ease_out); + lv_anim_start(&a); +} + +static void build_screen(void) { + if (s_screen != NULL) { + lv_obj_del(s_screen); + s_screen = NULL; + } + + s_screen = lv_obj_create(NULL); + lv_obj_set_style_bg_color(s_screen, current_theme.screen_base, 0); + lv_obj_set_style_bg_opa(s_screen, LV_OPA_COVER, 0); + lv_obj_remove_flag(s_screen, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_border_width(s_screen, 0, 0); + lv_obj_set_style_pad_all(s_screen, 0, 0); + + s_menu = menu_component_create(s_screen, TITLE, TITLE_ICON); + for (int i = 0; i < MENU_ITEM_COUNT; i++) + menu_component_add_item(&s_menu, MENU_ITEMS[i].icon, MENU_ITEMS[i].name); + menu_component_set_hint(&s_menu, FOOTER_HINT); + + fade_in(s_menu.items_cont, FADE_MS); + fade_in(s_menu.title_bar, FADE_MS); + + ui_input_set_screen_handler(dev_menu_input, NULL); + + ui_screen_load_owned(&s_screen, s_screen); +} + +static void dev_menu_input(const input_event_t *ev, void *ctx) { + (void)ctx; + const bool press = (ev->action == INPUT_ACTION_PRESS); + const bool nav = press || (ev->action == INPUT_ACTION_REPEAT); + switch (ev->button) { + case INPUT_BTN_DOWN: + if (nav) + menu_component_next(&s_menu); + break; + case INPUT_BTN_UP: + if (nav) + menu_component_prev(&s_menu); + break; + case INPUT_BTN_OK: + if (press) { + int sel = menu_component_get_selected(&s_menu); + if (sel >= 0 && sel < MENU_ITEM_COUNT) { + ui_feedback(UI_FB_SELECT); + ui_switch_screen(MENU_ITEMS[sel].screen); + } + } + break; + case INPUT_BTN_BACK: + if (press) + ui_switch_screen(SCREEN_MENU); + break; + default: + break; + } +} + +void ui_dev_menu_open(void) { + build_screen(); +} diff --git a/firmware_p4/components/Applications/ui/screens/dev/include/dev_console_ui.h b/firmware_p4/components/Applications/ui/screens/dev/include/dev_console_ui.h new file mode 100644 index 000000000..af2e6694c --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/dev/include/dev_console_ui.h @@ -0,0 +1,33 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#ifndef DEV_CONSOLE_UI_H +#define DEV_CONSOLE_UI_H + +#ifdef __cplusplus +extern "C" { +#endif + +/** @brief Open the live status console screen (reached from the Developer + * submenu). Read-only, auto-scrolling green terminal that streams real device + * status (heap, PSRAM, C5 bridge, battery, radios, uptime); UP/DOWN scroll, + * BACK returns to SCREEN_DEV_MENU. */ +void ui_dev_console_open(void); + +#ifdef __cplusplus +} +#endif + +#endif // DEV_CONSOLE_UI_H diff --git a/firmware_p4/components/Applications/ui/screens/dev/include/dev_diag_ui.h b/firmware_p4/components/Applications/ui/screens/dev/include/dev_diag_ui.h new file mode 100644 index 000000000..3554ff2ae --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/dev/include/dev_diag_ui.h @@ -0,0 +1,33 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#ifndef DEV_DIAG_UI_H +#define DEV_DIAG_UI_H + +#ifdef __cplusplus +extern "C" { +#endif + +/** @brief Open the diagnostics / self-test screen (reached from the Developer + * submenu). Live charts of internal and DMA-capable heap plus battery, die + * temperature and C5 bridge status, refreshed on a timer; BACK returns to + * SCREEN_DEV_MENU. */ +void ui_dev_diag_open(void); + +#ifdef __cplusplus +} +#endif + +#endif // DEV_DIAG_UI_H diff --git a/firmware_p4/components/Applications/ui/screens/dev/include/dev_menu_ui.h b/firmware_p4/components/Applications/ui/screens/dev/include/dev_menu_ui.h new file mode 100644 index 000000000..33983ed8f --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/dev/include/dev_menu_ui.h @@ -0,0 +1,31 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#ifndef DEV_MENU_UI_H +#define DEV_MENU_UI_H + +#ifdef __cplusplus +extern "C" { +#endif + +/** @brief Open the developer submenu (reached from the "dev" coverflow entry). + * Holds a single entry that opens Settings > Developer (SCREEN_SETTINGS_DEV). */ +void ui_dev_menu_open(void); + +#ifdef __cplusplus +} +#endif + +#endif // DEV_MENU_UI_H diff --git a/firmware_p4/components/Applications/ui/screens/dev/include/scripts_ui.h b/firmware_p4/components/Applications/ui/screens/dev/include/scripts_ui.h new file mode 100644 index 000000000..c76eaeca6 --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/dev/include/scripts_ui.h @@ -0,0 +1,35 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#ifndef SCRIPTS_UI_H +#define SCRIPTS_UI_H + +#ifdef __cplusplus +extern "C" { +#endif + +/** @brief Open the Scripts app: a mock JS-script runner. + * + * Browse a static list of mock scripts (each with capability badges and a + * green terminal preview), grant permission for dangerous capabilities, then + * watch a mock streaming run finish in a success or error state. All data is + * mock; no real script engine or backend is involved. */ +void ui_scripts_open(void); + +#ifdef __cplusplus +} +#endif + +#endif // SCRIPTS_UI_H diff --git a/firmware_p4/components/Applications/ui/screens/dev/scripts_ui.c b/firmware_p4/components/Applications/ui/screens/dev/scripts_ui.c new file mode 100644 index 000000000..fd156812f --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/dev/scripts_ui.c @@ -0,0 +1,921 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#include "scripts_ui.h" + +#include +#include +#include + +#include "esp_log.h" +#include "lvgl.h" +#include "st7789.h" + +#include "assets_manager.h" +#include "msgbox_ui.h" +#include "notify_ui.h" +#include "text_viewer_ui.h" +#include "ui_chrome.h" +#include "ui_feedback.h" +#include "ui_manager.h" +#include "ui_theme.h" + +static const char *TAG = "SCRIPTS_UI"; + +#define FADE_MS 200 +#define PENDING_POLL_MS 50 + +#define TERM_GREEN 0x00E676 +#define TERM_DIM_GREEN 0x1F7A52 +#define DARK_PANEL_COLOR 0x05090A +#define DIM_COLOR 0x8A8594 +#define DANGER_COLOR 0xFF5252 +#define SUCCESS_COLOR 0x00E676 + +#define HEADER_TITLE "SCRIPTS" +#define HEADER_ICON "/assets/icons/description.bin" +#define ERROR_ICON "/assets/icons/error.bin" + +#define BROWSER_FOOTER "UP/DOWN pick OK open BACK back" +#define VIEWER_FOOTER "OK run BACK back" +#define RUN_FOOTER "BACK = Abort" +#define DONE_FOOTER "RIGHT = Run again BACK = Back" + +#define LIST_X 6 +#define LIST_Y 46 +#define LIST_GUTTER 16 +#define LIST_W (LCD_H_RES - LIST_X - LIST_GUTTER) +#define LIST_BODY_H (LCD_V_RES - LIST_Y - UI_CHROME_FOOTER_H - 4) +#define ROW_H 26 +#define ROW_GAP 4 +#define ROW_RADIUS 8 +#define ROW_PAD_HOR 8 +#define ROW_COL_GAP 6 +#define ROW_GLOW_W 14 +#define CHEVRON_TEXT LV_SYMBOL_RIGHT +#define BADGE_RADIUS 4 +#define BADGE_PAD_HOR 4 +#define BADGE_PAD_VER 1 +#define BADGE_TINT_OPA LV_OPA_20 + +#define SCROLL_TRACK_X 227 +#define SCROLL_TRACK_Y 54 +#define SCROLL_TRACK_LEN 232 +#define SCROLL_TRACK_WIDTH 3 +#define SCROLL_DASH 4 +#define SCROLL_THUMB_X 223 +#define SCROLL_THUMB_FALLBACK_H 45 +#define SCROLL_THUMB_SRC "/assets/icons/drag_indicator.bin" + +#define VIEWER_SCROLL_STEP 28 +#define VIEWER_SRC_LEN 320 + +#define EMPTY_CARD_W 204 +#define EMPTY_CARD_H 100 +#define EMPTY_GLOW_W 18 + +#define TERM_W 216 +#define TERM_H 150 +#define TERM_TOP_Y 52 +#define TERM_PAD 8 +#define TERM_BORDER 2 +#define TERM_HEADER_Y 0 +#define TERM_BODY_Y 18 +#define TERM_PROMPT "tentacle@p4:~$ js" +#define TERM_BUF_LEN 320 + +#define PCT_TEXT "Running" +#define PCT_Y 210 +#define PROGRESS_W 214 +#define PROGRESS_H 8 +#define PROGRESS_Y 228 +#define PROGRESS_RADIUS 4 +#define PROGRESS_TRACK_COLOR 0x10211A + +#define RESULT_Y 248 +#define TYPE_TICK_MS 42 +#define CURSOR_BLINK_TICKS 9 +#define DONE_DELAY_MS 420 + +#define STREAM_MAX 6 +#define STREAM_HDR_LEN 48 +#define PERM_MSG_LEN 96 + +#define SCRIPT_CODE_MAX 3 +#define SCRIPT_CAP_MAX 3 + +typedef enum { + CAP_NFC = 0, + CAP_USB, + CAP_C5, + CAP_LED, + CAP_IR, + CAP_COUNT, +} cap_t; + +static const struct { + const char *badge; + const char *control; + bool available; + bool danger; +} CAPS[CAP_COUNT] = { + {"NFC", "NFC", true, false}, + {"USB", "USB HID", true, true}, + {"C5", "Wi-Fi", false, true}, + {"LED", "LED", true, false}, + {"IR", "IR", true, false}, +}; + +typedef struct { + const char *name; + const char *desc; + cap_t caps[SCRIPT_CAP_MAX]; + int cap_count; + const char *code[SCRIPT_CODE_MAX]; + int code_count; + bool error_outcome; + const char *result; + const char *error; +} script_t; + +static const script_t SCRIPTS[] = { + {"badge.js", + "Reads an NFC card and types the UID", + {CAP_NFC, CAP_USB}, + 2, + {"let c = nfc.read()", "usb.type(c.uid)"}, + 2, + false, + "value: 04A23B9C", + NULL}, + {"wifi_probe.js", + "Probe nearby Wi-Fi APs", + {CAP_C5}, + 1, + {"let aps = wifi.scan()", "print(aps.length)"}, + 2, + false, + "value: 6 APs", + NULL}, + {"blink.js", + "Blink the status LED", + {CAP_LED}, + 1, + {"led.on()", "delay(200)", "led.off()"}, + 3, + false, + "value: ok", + NULL}, + {"tvoff.js", + "Turn off any TV nearby", + {CAP_IR}, + 1, + {"ir.send('NEC', 0x20DF10EF)"}, + 1, + false, + "value: sent", + NULL}, + {"uid_dump.js", + "Dump card memory to console", + {CAP_NFC}, + 1, + {"let c = nfc.read()", "dump(c.blocks)"}, + 2, + true, + NULL, + "nfc is not defined line 3"}, + {"duck.js", + "Type a scripted HID payload", + {CAP_USB}, + 1, + {"usb.press('GUI r')", "usb.type('notepad')"}, + 2, + false, + "value: injected", + NULL}, + {"ir_learn.js", + "Learn one IR remote button", + {CAP_IR}, + 1, + {"let s = ir.recv()", "save(s)"}, + 2, + false, + "value: captured", + NULL}, + {"rainbow.js", + "Cycle the LED through hues", + {CAP_LED}, + 1, + {"for (h = 0; h < 360; h++)", "led.hue(h)"}, + 2, + false, + "value: ok", + NULL}, + {"deauth.js", + "Flood deauth on a Wi-Fi target", + {CAP_C5, CAP_USB}, + 2, + {"wifi.deauth(bssid)", "usb.log('sent')"}, + 2, + false, + "value: n/a", + NULL}, + {"clone.js", + "Clone a card UID over HID", + {CAP_NFC, CAP_USB}, + 2, + {"let c = nfc.read()", "usb.type(c.uid)"}, + 2, + false, + "value: cloned", + NULL}, +}; +#define SCRIPT_COUNT ((int)(sizeof(SCRIPTS) / sizeof(SCRIPTS[0]))) + +typedef enum { + VIEW_BROWSER = 0, + VIEW_VIEWER, + VIEW_RUNNING, +} view_t; + +typedef enum { + RUN_STAGE_STREAMING = 0, + RUN_STAGE_DONE, +} run_stage_t; + +static lv_obj_t *s_screen = NULL; +static view_t s_view = VIEW_BROWSER; +static int s_sel = 0; +static bool s_pending_run = false; + +static lv_timer_t *s_pending_timer = NULL; +static lv_timer_t *s_type_timer = NULL; +static lv_timer_t *s_stage_timer = NULL; + +static lv_obj_t *s_rows[SCRIPT_COUNT]; +static lv_obj_t *s_list = NULL; +static lv_obj_t *s_thumb = NULL; +static text_viewer_t s_tv; + +static lv_obj_t *s_footer = NULL; +static lv_obj_t *s_term_lbl = NULL; +static lv_obj_t *s_progress = NULL; +static lv_obj_t *s_pct_lbl = NULL; + +static run_stage_t s_run_stage = RUN_STAGE_STREAMING; +static const char *s_stream[STREAM_MAX]; +static int s_stream_count = 0; +static char s_run_hdr[STREAM_HDR_LEN]; +static char s_term_buf[TERM_BUF_LEN]; +static int s_type_line = 0; +static int s_type_col = 0; +static int s_typed_chars = 0; +static int s_total_chars = 0; +static int s_cursor_ticks = 0; +static bool s_cursor_on = true; + +static void scripts_pending_cb(lv_timer_t *t); +static void scripts_input(const input_event_t *ev, void *ctx); +static void build_screen(void); +static void type_tick_cb(lv_timer_t *t); +static void stage_advance_cb(lv_timer_t *t); +static void update_scrollbar(void); +static void build_viewer(void); + +static void stop_type_timer(void) { + if (s_type_timer != NULL) { + lv_timer_delete(s_type_timer); + s_type_timer = NULL; + } +} + +static void stop_stage_timer(void) { + if (s_stage_timer != NULL) { + lv_timer_delete(s_stage_timer); + s_stage_timer = NULL; + } +} + +static void opa_anim_cb(void *var, int32_t v) { + lv_obj_set_style_opa((lv_obj_t *)var, (lv_opa_t)v, 0); +} + +static void fade_in(lv_obj_t *obj, uint32_t duration_ms) { + lv_obj_set_style_opa(obj, LV_OPA_TRANSP, 0); + lv_anim_t a; + lv_anim_init(&a); + lv_anim_set_var(&a, obj); + lv_anim_set_exec_cb(&a, opa_anim_cb); + lv_anim_set_values(&a, LV_OPA_TRANSP, LV_OPA_COVER); + lv_anim_set_duration(&a, duration_ms); + lv_anim_set_path_cb(&a, lv_anim_path_ease_out); + lv_anim_start(&a); +} + +static bool script_has_unavailable(const script_t *s) { + for (int i = 0; i < s->cap_count; i++) + if (!CAPS[s->caps[i]].available) + return true; + return false; +} + +static bool script_needs_permission(const script_t *s) { + for (int i = 0; i < s->cap_count; i++) + if (CAPS[s->caps[i]].danger) + return true; + return false; +} + +static void build_badge(lv_obj_t *parent, cap_t c) { + bool avail = CAPS[c].available; + lv_color_t color = avail ? current_theme.border_accent : lv_color_hex(DIM_COLOR); + + lv_obj_t *badge = lv_obj_create(parent); + lv_obj_remove_flag(badge, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(badge, LV_SIZE_CONTENT, LV_SIZE_CONTENT); + lv_obj_set_style_radius(badge, BADGE_RADIUS, 0); + lv_obj_set_style_pad_hor(badge, BADGE_PAD_HOR, 0); + lv_obj_set_style_pad_ver(badge, BADGE_PAD_VER, 0); + lv_obj_set_style_border_width(badge, 1, 0); + lv_obj_set_style_border_color(badge, color, 0); + lv_obj_set_style_bg_color(badge, current_theme.border_accent, 0); + lv_obj_set_style_bg_opa(badge, avail ? BADGE_TINT_OPA : LV_OPA_TRANSP, 0); + + lv_obj_t *lbl = lv_label_create(badge); + lv_label_set_text(lbl, CAPS[c].badge); + lv_obj_set_style_text_color(lbl, color, 0); + lv_obj_set_style_text_font(lbl, &lv_font_montserrat_12, 0); + lv_obj_center(lbl); +} + +static void style_row(lv_obj_t *row, bool selected) { + lv_obj_set_style_border_width(row, 1, 0); + lv_obj_set_style_bg_opa(row, LV_OPA_COVER, 0); + if (selected) { + lv_obj_set_style_bg_color(row, current_theme.bg_secondary, 0); + lv_obj_set_style_border_color(row, current_theme.border_accent, 0); + lv_obj_set_style_border_opa(row, LV_OPA_COVER, 0); + lv_obj_set_style_shadow_color(row, current_theme.border_accent, 0); + lv_obj_set_style_shadow_width(row, ROW_GLOW_W, 0); + lv_obj_set_style_shadow_opa(row, LV_OPA_40, 0); + lv_obj_set_style_shadow_spread(row, -2, 0); + } else { + lv_obj_set_style_bg_color(row, current_theme.bg_primary, 0); + lv_obj_set_style_border_color(row, current_theme.border_inactive, 0); + lv_obj_set_style_border_opa(row, LV_OPA_COVER, 0); + lv_obj_set_style_shadow_width(row, 0, 0); + lv_obj_set_style_shadow_opa(row, LV_OPA_TRANSP, 0); + } +} + +static void update_scrollbar(void) { + if (s_thumb == NULL) + return; + int thumb_h = (int)lv_obj_get_height(s_thumb); + if (thumb_h <= 0) + thumb_h = SCROLL_THUMB_FALLBACK_H; + int travel = SCROLL_TRACK_LEN - thumb_h; + if (travel < 0) + travel = 0; + int y = SCROLL_TRACK_Y; + if (SCRIPT_COUNT > 1) + y = SCROLL_TRACK_Y + (s_sel * travel) / (SCRIPT_COUNT - 1); + lv_obj_set_y(s_thumb, y); +} + +static void apply_sel(int idx) { + for (int i = 0; i < SCRIPT_COUNT; i++) + if (s_rows[i] != NULL) + style_row(s_rows[i], i == idx); + if (s_list != NULL && s_rows[idx] != NULL) + lv_obj_scroll_to_view(s_rows[idx], LV_ANIM_ON); + update_scrollbar(); +} + +static void build_empty(void) { + ui_chrome_footer(s_screen, BROWSER_FOOTER); + + lv_obj_t *card = lv_obj_create(s_screen); + lv_obj_remove_flag(card, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(card, EMPTY_CARD_W, EMPTY_CARD_H); + lv_obj_align(card, LV_ALIGN_CENTER, 0, (UI_CHROME_HEADER_H - UI_CHROME_FOOTER_H) / 2); + lv_obj_set_style_radius(card, 14, 0); + lv_obj_set_style_bg_color(card, current_theme.bg_secondary, 0); + lv_obj_set_style_bg_opa(card, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(card, 1, 0); + lv_obj_set_style_border_color(card, current_theme.border_accent, 0); + lv_obj_set_style_shadow_width(card, EMPTY_GLOW_W, 0); + lv_obj_set_style_shadow_color(card, current_theme.border_accent, 0); + lv_obj_set_style_shadow_opa(card, LV_OPA_40, 0); + lv_obj_set_flex_flow(card, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(card, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_set_style_pad_row(card, 6, 0); + + lv_obj_t *t1 = lv_label_create(card); + lv_label_set_text(t1, "No scripts"); + lv_obj_set_style_text_font(t1, &lv_font_montserrat_14, 0); + lv_obj_set_style_text_color(t1, current_theme.text_main, 0); + + lv_obj_t *t2 = lv_label_create(card); + lv_label_set_text(t2, "Copy .js to /apps/scripts"); + lv_obj_set_style_text_font(t2, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(t2, lv_color_hex(DIM_COLOR), 0); + + fade_in(card, FADE_MS); +} + +static void build_browser(void) { + ui_chrome_header(s_screen, HEADER_TITLE, HEADER_ICON); + + if (SCRIPT_COUNT == 0) { + build_empty(); + return; + } + + ui_chrome_footer(s_screen, BROWSER_FOOTER); + + if (s_sel < 0) + s_sel = 0; + if (s_sel >= SCRIPT_COUNT) + s_sel = SCRIPT_COUNT - 1; + + lv_obj_t *list = lv_obj_create(s_screen); + lv_obj_set_size(list, LIST_W, LIST_BODY_H); + lv_obj_set_pos(list, LIST_X, LIST_Y); + lv_obj_set_style_bg_opa(list, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(list, 0, 0); + lv_obj_set_style_pad_all(list, 0, 0); + lv_obj_set_style_pad_row(list, ROW_GAP, 0); + lv_obj_set_flex_flow(list, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(list, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_START); + lv_obj_set_scroll_dir(list, LV_DIR_VER); + lv_obj_remove_flag(list, LV_OBJ_FLAG_SCROLL_ELASTIC); + lv_obj_remove_flag(list, LV_OBJ_FLAG_SCROLL_MOMENTUM); + lv_obj_set_scrollbar_mode(list, LV_SCROLLBAR_MODE_OFF); + s_list = list; + + for (int i = 0; i < SCRIPT_COUNT; i++) { + const script_t *s = &SCRIPTS[i]; + + lv_obj_t *row = lv_obj_create(list); + lv_obj_remove_flag(row, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(row, lv_pct(100), ROW_H); + lv_obj_set_style_radius(row, ROW_RADIUS, 0); + lv_obj_set_style_pad_hor(row, ROW_PAD_HOR, 0); + lv_obj_set_style_pad_ver(row, 0, 0); + 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_column(row, ROW_COL_GAP, 0); + + lv_obj_t *name = lv_label_create(row); + lv_label_set_text(name, s->name); + lv_obj_set_style_text_color(name, current_theme.text_main, 0); + lv_obj_set_style_text_font(name, &lv_font_montserrat_12, 0); + lv_obj_set_flex_grow(name, 1); + + for (int c = 0; c < s->cap_count; c++) + build_badge(row, s->caps[c]); + + lv_obj_t *chev = lv_label_create(row); + lv_label_set_text(chev, CHEVRON_TEXT); + lv_obj_set_style_text_color(chev, lv_color_hex(DIM_COLOR), 0); + lv_obj_set_style_text_font(chev, &lv_font_montserrat_12, 0); + + s_rows[i] = row; + } + + static lv_point_precise_t scroll_pts[2] = {{0, 0}, {0, SCROLL_TRACK_LEN}}; + lv_obj_t *track = lv_line_create(s_screen); + lv_line_set_points(track, scroll_pts, 2); + lv_obj_set_pos(track, SCROLL_TRACK_X, SCROLL_TRACK_Y); + lv_obj_set_style_line_width(track, SCROLL_TRACK_WIDTH, 0); + lv_obj_set_style_line_color(track, current_theme.border_inactive, 0); + lv_obj_set_style_line_opa(track, LV_OPA_COVER, 0); + lv_obj_set_style_line_dash_width(track, SCROLL_DASH, 0); + lv_obj_set_style_line_dash_gap(track, SCROLL_DASH, 0); + + s_thumb = lv_image_create(s_screen); + lv_image_dsc_t *thumb_dsc = assets_get(SCROLL_THUMB_SRC); + if (thumb_dsc != NULL) + lv_image_set_src(s_thumb, thumb_dsc); + lv_obj_set_pos(s_thumb, SCROLL_THUMB_X, SCROLL_TRACK_Y); + lv_obj_move_foreground(s_thumb); + + lv_obj_update_layout(list); + apply_sel(s_sel); + + fade_in(list, FADE_MS); +} + +static void build_terminal(void) { + lv_obj_t *panel = lv_obj_create(s_screen); + lv_obj_remove_flag(panel, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(panel, TERM_W, TERM_H); + lv_obj_align(panel, LV_ALIGN_TOP_MID, 0, TERM_TOP_Y); + lv_obj_set_style_radius(panel, 0, 0); + lv_obj_set_style_bg_opa(panel, LV_OPA_COVER, 0); + lv_obj_set_style_bg_color(panel, lv_color_hex(DARK_PANEL_COLOR), 0); + lv_obj_set_style_border_width(panel, TERM_BORDER, 0); + lv_obj_set_style_border_color(panel, lv_color_hex(TERM_GREEN), 0); + lv_obj_set_style_border_opa(panel, LV_OPA_70, 0); + lv_obj_set_style_shadow_color(panel, lv_color_hex(TERM_GREEN), 0); + lv_obj_set_style_shadow_width(panel, 12, 0); + lv_obj_set_style_shadow_opa(panel, LV_OPA_20, 0); + lv_obj_set_style_pad_all(panel, TERM_PAD, 0); + + lv_obj_t *prompt = lv_label_create(panel); + lv_label_set_text(prompt, TERM_PROMPT); + lv_obj_set_style_text_color(prompt, lv_color_hex(TERM_DIM_GREEN), 0); + lv_obj_set_style_text_font(prompt, &lv_font_montserrat_12, 0); + lv_obj_align(prompt, LV_ALIGN_TOP_LEFT, 0, TERM_HEADER_Y); + + s_term_lbl = lv_label_create(panel); + lv_label_set_text(s_term_lbl, ""); + lv_obj_set_width(s_term_lbl, TERM_W - TERM_PAD * 2); + lv_label_set_long_mode(s_term_lbl, LV_LABEL_LONG_WRAP); + lv_obj_set_style_text_color(s_term_lbl, lv_color_hex(TERM_GREEN), 0); + lv_obj_set_style_text_font(s_term_lbl, &lv_font_montserrat_12, 0); + lv_obj_align(s_term_lbl, LV_ALIGN_TOP_LEFT, 0, TERM_BODY_Y); + + s_pct_lbl = lv_label_create(s_screen); + lv_label_set_text(s_pct_lbl, PCT_TEXT " 0%"); + lv_obj_set_style_text_color(s_pct_lbl, lv_color_hex(TERM_GREEN), 0); + lv_obj_set_style_text_font(s_pct_lbl, &lv_font_montserrat_12, 0); + lv_obj_align(s_pct_lbl, LV_ALIGN_TOP_MID, 0, PCT_Y); + + s_progress = lv_bar_create(s_screen); + lv_obj_set_size(s_progress, PROGRESS_W, PROGRESS_H); + lv_obj_align(s_progress, LV_ALIGN_TOP_MID, 0, PROGRESS_Y); + lv_bar_set_range(s_progress, 0, 100); + lv_bar_set_value(s_progress, 0, LV_ANIM_OFF); + lv_obj_set_style_bg_color(s_progress, lv_color_hex(PROGRESS_TRACK_COLOR), LV_PART_MAIN); + lv_obj_set_style_bg_opa(s_progress, LV_OPA_COVER, LV_PART_MAIN); + lv_obj_set_style_border_width(s_progress, 1, LV_PART_MAIN); + lv_obj_set_style_border_color(s_progress, lv_color_hex(TERM_DIM_GREEN), LV_PART_MAIN); + lv_obj_set_style_bg_color(s_progress, lv_color_hex(TERM_DIM_GREEN), LV_PART_INDICATOR); + lv_obj_set_style_bg_grad_color(s_progress, lv_color_hex(TERM_GREEN), LV_PART_INDICATOR); + lv_obj_set_style_bg_grad_dir(s_progress, LV_GRAD_DIR_HOR, LV_PART_INDICATOR); + lv_obj_set_style_bg_opa(s_progress, LV_OPA_COVER, LV_PART_INDICATOR); + lv_obj_set_style_radius(s_progress, PROGRESS_RADIUS, LV_PART_MAIN); + lv_obj_set_style_radius(s_progress, PROGRESS_RADIUS, LV_PART_INDICATOR); +} + +static void render_terminal(void) { + s_term_buf[0] = '\0'; + int pos = 0; + for (int i = 0; i < s_type_line && i < s_stream_count; i++) { + pos += snprintf(s_term_buf + pos, TERM_BUF_LEN - pos, "%s\n", s_stream[i]); + if (pos >= TERM_BUF_LEN) + pos = TERM_BUF_LEN - 1; + } + if (s_type_line < s_stream_count) { + pos += + snprintf(s_term_buf + pos, TERM_BUF_LEN - pos, "%.*s", s_type_col, s_stream[s_type_line]); + if (pos >= TERM_BUF_LEN) + pos = TERM_BUF_LEN - 1; + } + if (s_cursor_on && pos < TERM_BUF_LEN - 2) { + s_term_buf[pos++] = '_'; + s_term_buf[pos] = '\0'; + } + if (s_term_lbl) + lv_label_set_text(s_term_lbl, s_term_buf); +} + +static void show_done(void) { + const script_t *s = &SCRIPTS[s_sel]; + + lv_obj_t *result = lv_label_create(s_screen); + if (s->error_outcome) { + char buf[80]; + snprintf(buf, sizeof(buf), LV_SYMBOL_CLOSE " Error: %s", s->error); + lv_label_set_text(result, buf); + lv_obj_set_style_text_color(result, lv_color_hex(DANGER_COLOR), 0); + notify(NOTIFY_WARNING, "Script error"); + ui_feedback(UI_FB_SELECT); + ESP_LOGI(TAG, "mock script error: %s", s->name); + } else { + char buf[80]; + snprintf(buf, sizeof(buf), LV_SYMBOL_OK " %s", s->result); + lv_label_set_text(result, buf); + lv_obj_set_style_text_color(result, lv_color_hex(SUCCESS_COLOR), 0); + ui_feedback(UI_FB_WRITE); + ESP_LOGI(TAG, "mock script done: %s", s->name); + } + lv_obj_set_width(result, TERM_W); + lv_label_set_long_mode(result, LV_LABEL_LONG_WRAP); + lv_obj_set_style_text_align(result, LV_TEXT_ALIGN_CENTER, 0); + lv_obj_set_style_text_font(result, &lv_font_montserrat_14, 0); + lv_obj_align(result, LV_ALIGN_TOP_MID, 0, RESULT_Y); + fade_in(result, FADE_MS); + + if (s_footer != NULL) + ui_chrome_footer_set_text(s_footer, DONE_FOOTER); +} + +static void stage_advance_cb(lv_timer_t *t) { + (void)t; + s_stage_timer = NULL; + if (lv_screen_active() != s_screen || s_view != VIEW_RUNNING) + return; + if (s_run_stage == RUN_STAGE_DONE) + show_done(); +} + +static void type_tick_cb(lv_timer_t *t) { + if (lv_screen_active() != s_screen || s_view != VIEW_RUNNING) { + lv_timer_delete(t); + s_type_timer = NULL; + return; + } + + s_cursor_ticks++; + if (s_cursor_ticks >= CURSOR_BLINK_TICKS) { + s_cursor_ticks = 0; + s_cursor_on = !s_cursor_on; + } + + if (s_type_line < s_stream_count) { + int line_len = (int)strlen(s_stream[s_type_line]); + if (s_type_col < line_len) { + s_type_col++; + s_typed_chars++; + } else { + s_type_line++; + s_type_col = 0; + } + if (s_total_chars > 0) { + int pct = s_typed_chars * 100 / s_total_chars; + if (s_progress) + lv_bar_set_value(s_progress, pct, LV_ANIM_OFF); + if (s_pct_lbl) { + char buf[40]; + snprintf(buf, sizeof(buf), "%s %d%%", PCT_TEXT, pct); + lv_label_set_text(s_pct_lbl, buf); + } + } + render_terminal(); + } else { + render_terminal(); + lv_timer_delete(t); + s_type_timer = NULL; + if (s_progress) + lv_bar_set_value(s_progress, 100, LV_ANIM_ON); + if (s_pct_lbl) + lv_label_set_text(s_pct_lbl, PCT_TEXT " 100%"); + s_run_stage = RUN_STAGE_DONE; + s_stage_timer = lv_timer_create(stage_advance_cb, DONE_DELAY_MS, NULL); + lv_timer_set_repeat_count(s_stage_timer, 1); + } +} + +static void build_running(void) { + const script_t *s = &SCRIPTS[s_sel]; + + ui_chrome_header(s_screen, HEADER_TITLE, HEADER_ICON); + s_footer = ui_chrome_footer(s_screen, RUN_FOOTER); + + snprintf(s_run_hdr, sizeof(s_run_hdr), "$ run %s", s->name); + s_stream_count = 0; + s_stream[s_stream_count++] = s_run_hdr; + for (int i = 0; i < s->code_count && s_stream_count < STREAM_MAX; i++) + s_stream[s_stream_count++] = s->code[i]; + + s_run_stage = RUN_STAGE_STREAMING; + s_type_line = 0; + s_type_col = 0; + s_typed_chars = 0; + s_cursor_ticks = 0; + s_cursor_on = true; + s_total_chars = 0; + for (int i = 0; i < s_stream_count; i++) + s_total_chars += (int)strlen(s_stream[i]); + + build_terminal(); + render_terminal(); + fade_in(s_term_lbl, FADE_MS); + + s_type_timer = lv_timer_create(type_tick_cb, TYPE_TICK_MS, NULL); +} + +static void build_script_source(const script_t *s, char *buf, int n) { + int pos = snprintf(buf, n, "// %s\n// @desc %s\n// caps:", s->name, s->desc); + if (pos >= n) + pos = n - 1; + for (int i = 0; i < s->cap_count && pos < n - 1; i++) { + pos += snprintf(buf + pos, n - pos, " %s", CAPS[s->caps[i]].badge); + if (pos >= n) + pos = n - 1; + } + pos += snprintf(buf + pos, n - pos, "\n\n\"use strict\";\n\n"); + if (pos >= n) + pos = n - 1; + for (int i = 0; i < s->code_count && pos < n - 1; i++) { + pos += snprintf(buf + pos, n - pos, "%s\n", s->code[i]); + if (pos >= n) + pos = n - 1; + } + snprintf(buf + pos, n - pos, "\nprint(\"%s done\")\n", s->name); +} + +static void build_viewer(void) { + static char src[VIEWER_SRC_LEN]; + const script_t *s = &SCRIPTS[s_sel]; + s_tv = text_viewer_create(s_screen, s->name); + build_script_source(s, src, VIEWER_SRC_LEN); + text_viewer_set_text(&s_tv, src); + ui_chrome_footer(s_screen, VIEWER_FOOTER); +} + +static void build_screen(void) { + stop_type_timer(); + stop_stage_timer(); + if (s_screen != NULL) { + lv_obj_del(s_screen); + s_screen = NULL; + } + s_list = NULL; + s_thumb = NULL; + memset(&s_tv, 0, sizeof(s_tv)); + s_footer = NULL; + s_term_lbl = NULL; + s_progress = NULL; + s_pct_lbl = NULL; + for (int i = 0; i < SCRIPT_COUNT; i++) + s_rows[i] = NULL; + + s_screen = lv_obj_create(NULL); + lv_obj_set_style_bg_color(s_screen, current_theme.screen_base, 0); + lv_obj_set_style_bg_opa(s_screen, LV_OPA_COVER, 0); + lv_obj_remove_flag(s_screen, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_border_width(s_screen, 0, 0); + lv_obj_set_style_pad_all(s_screen, 0, 0); + + switch (s_view) { + case VIEW_RUNNING: + build_running(); + break; + case VIEW_VIEWER: + build_viewer(); + break; + case VIEW_BROWSER: + default: + build_browser(); + break; + } + + if (s_pending_timer == NULL) + s_pending_timer = lv_timer_create(scripts_pending_cb, PENDING_POLL_MS, NULL); + ui_input_set_screen_handler(scripts_input, NULL); + + ui_screen_load_owned(&s_screen, s_screen); +} + +static void on_perm_confirm(bool confirm) { + if (confirm) + s_pending_run = true; +} + +static void try_run_selected(void) { + const script_t *s = &SCRIPTS[s_sel]; + + if (script_has_unavailable(s)) { + msgbox_open_info(ERROR_ICON, "Requires C5 (offline)", s->desc, lv_color_hex(DANGER_COLOR)); + notify(NOTIFY_WARNING, "C5 offline"); + return; + } + + if (script_needs_permission(s)) { + char msg[PERM_MSG_LEN]; + int pos = snprintf(msg, sizeof(msg), "Run %s? It can control: ", s->name); + for (int i = 0; i < s->cap_count && pos < PERM_MSG_LEN - 1; i++) + pos += snprintf( + msg + pos, PERM_MSG_LEN - pos, "%s%s", i == 0 ? "" : ", ", CAPS[s->caps[i]].control); + msgbox_open(LV_SYMBOL_WARNING, msg, "Run", "Cancel", on_perm_confirm); + return; + } + + s_pending_run = true; +} + +static void scripts_pending_cb(lv_timer_t *t) { + if (lv_screen_active() != s_screen) { + lv_timer_delete(t); + s_pending_timer = NULL; + return; + } + if (ui_input_is_locked()) + return; + + if (s_pending_run) { + s_pending_run = false; + s_view = VIEW_RUNNING; + build_screen(); + } +} + +static void scripts_input(const input_event_t *ev, void *ctx) { + (void)ctx; + const bool press = (ev->action == INPUT_ACTION_PRESS); + const bool nav = press || (ev->action == INPUT_ACTION_REPEAT); + + switch (s_view) { + case VIEW_BROWSER: + if (SCRIPT_COUNT == 0) { + if (ev->button == INPUT_BTN_BACK && press) + ui_switch_screen(SCREEN_DEV_MENU); + break; + } + switch (ev->button) { + case INPUT_BTN_DOWN: + if (nav && s_sel < SCRIPT_COUNT - 1) { + s_sel++; + apply_sel(s_sel); + } + break; + case INPUT_BTN_UP: + if (nav && s_sel > 0) { + s_sel--; + apply_sel(s_sel); + } + break; + case INPUT_BTN_OK: + if (press) { + s_view = VIEW_VIEWER; + build_screen(); + } + break; + case INPUT_BTN_BACK: + if (press) + ui_switch_screen(SCREEN_DEV_MENU); + break; + default: + break; + } + break; + + case VIEW_VIEWER: + switch (ev->button) { + case INPUT_BTN_DOWN: + if (nav && s_tv.text_area != NULL) + lv_obj_scroll_by(s_tv.text_area, 0, -VIEWER_SCROLL_STEP, LV_ANIM_ON); + break; + case INPUT_BTN_UP: + if (nav && s_tv.text_area != NULL) + lv_obj_scroll_by(s_tv.text_area, 0, VIEWER_SCROLL_STEP, LV_ANIM_ON); + break; + case INPUT_BTN_OK: + if (press) + try_run_selected(); + break; + case INPUT_BTN_BACK: + if (press) { + s_view = VIEW_BROWSER; + build_screen(); + } + break; + default: + break; + } + break; + + case VIEW_RUNNING: + switch (ev->button) { + case INPUT_BTN_RIGHT: + if (press && s_run_stage == RUN_STAGE_DONE) + build_screen(); + break; + case INPUT_BTN_BACK: + if (press) { + s_view = VIEW_BROWSER; + build_screen(); + } + break; + default: + break; + } + break; + } +} + +void ui_scripts_open(void) { + s_pending_timer = NULL; + s_type_timer = NULL; + s_stage_timer = NULL; + s_view = VIEW_BROWSER; + s_sel = 0; + s_pending_run = false; + build_screen(); +} diff --git a/firmware_p4/components/Applications/ui/screens/display_settings/display_settings_ui.c b/firmware_p4/components/Applications/ui/screens/display_settings/display_settings_ui.c deleted file mode 100644 index 47d4c94ec..000000000 --- a/firmware_p4/components/Applications/ui/screens/display_settings/display_settings_ui.c +++ /dev/null @@ -1,161 +0,0 @@ -// Copyright (c) 2025 HIGH CODE LLC -// -// TentacleOS is free software: you can redistribute it and/or modify -// it under the terms of the GNU General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// TentacleOS is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU General Public License for more details. -// -// You should have received a copy of the GNU General Public License -// along with TentacleOS. If not, see . - -#include "display_settings_ui.h" - -#include - -#include "esp_log.h" -#include "st7789.h" - -#include "buttons_gpio.h" -#include "menu_component_ui.h" -#include "ui_manager.h" -#include "ui_theme.h" - -static const char *TAG = "DISPLAY_UI"; - -#define NAV_TIMER_INTERVAL_MS 50 -#define BRIGHTNESS_STEP 20 -#define BRIGHTNESS_MIN 1 -#define ROTATION_BUF_SIZE 8 -#define ROTATION_MIN 1 -#define ROTATION_MAX 4 - -typedef enum { - DISPLAY_ITEM_BRIGHTNESS = 0, - DISPLAY_ITEM_ROTATION = 1, -} display_item_t; - -static lv_obj_t *s_screen_display = NULL; -static menu_component_t s_menu; -static lv_timer_t *s_nav_timer = NULL; - -static int s_brightness_val = 3; -static int s_rotation_val = 1; - -static bool s_btn_up_last = false; -static bool s_btn_down_last = false; -static bool s_btn_left_last = false; -static bool s_btn_right_last = false; -static bool s_btn_ok_last = false; -static bool s_btn_back_last = false; - -static void update_rotation_value(void); -static void nav_timer_cb(lv_timer_t *timer); - -void update_lvgl_display_rotation(uint8_t rotation) { - (void)rotation; - lv_obj_invalidate(lv_scr_act()); -} - -void ui_display_settings_open(void) { - if (s_screen_display != NULL) { - lv_obj_del(s_screen_display); - s_screen_display = NULL; - } - - s_brightness_val = lcd_get_brightness() / BRIGHTNESS_STEP; - if (s_brightness_val < BRIGHTNESS_MIN) - s_brightness_val = BRIGHTNESS_MIN; - - s_rotation_val = lcd_get_rotation(); - - s_screen_display = lv_obj_create(NULL); - lv_obj_set_style_bg_color(s_screen_display, current_theme.screen_base, 0); - lv_obj_set_style_bg_opa(s_screen_display, LV_OPA_COVER, 0); - lv_obj_remove_flag(s_screen_display, LV_OBJ_FLAG_SCROLLABLE); - - s_menu = menu_component_create(s_screen_display, "DISPLAY", NULL); - menu_component_add_intensity( - &s_menu, "/assets/icons/bright_menu_icon.bin", "BRIGHTNESS", s_brightness_val); - - char buf[ROTATION_BUF_SIZE]; - snprintf(buf, sizeof(buf), "%d", s_rotation_val); - menu_component_add_selector(&s_menu, "/assets/icons/rotate_menu_icon.bin", "ROTATION", buf); - - if (s_nav_timer == NULL) - s_nav_timer = lv_timer_create(nav_timer_cb, NAV_TIMER_INTERVAL_MS, NULL); - - lv_screen_load(s_screen_display); -} - -static void update_rotation_value(void) { - char buf[ROTATION_BUF_SIZE]; - snprintf(buf, sizeof(buf), "%d", s_rotation_val); - menu_component_set_selector_value(&s_menu, DISPLAY_ITEM_ROTATION, buf); -} - -static void nav_timer_cb(lv_timer_t *timer) { - if (lv_screen_active() != s_screen_display) { - lv_timer_delete(timer); - s_nav_timer = NULL; - return; - } - - if (ui_input_is_locked()) - return; - - bool is_up = up_button_is_down(); - bool is_down = down_button_is_down(); - bool is_left = left_button_is_down(); - bool is_right = right_button_is_down(); - bool is_ok = ok_button_is_down(); - bool is_back = back_button_is_down(); - - if (is_down && !s_btn_down_last) - menu_component_next(&s_menu); - - if (is_up && !s_btn_up_last) - menu_component_prev(&s_menu); - - if (is_back && !s_btn_back_last) - ui_switch_screen(SCREEN_SETTINGS); - - int sel = menu_component_get_selected(&s_menu); - - if (is_left && !s_btn_left_last) { - if (sel == DISPLAY_ITEM_BRIGHTNESS) { - menu_component_intensity_dec(&s_menu, DISPLAY_ITEM_BRIGHTNESS); - s_brightness_val = menu_component_get_intensity(&s_menu, DISPLAY_ITEM_BRIGHTNESS); - lcd_set_brightness(s_brightness_val * BRIGHTNESS_STEP); - } else if (sel == DISPLAY_ITEM_ROTATION) { - s_rotation_val = (s_rotation_val == ROTATION_MIN) ? ROTATION_MAX : s_rotation_val - 1; - lcd_set_rotation(s_rotation_val); - update_lvgl_display_rotation(s_rotation_val); - update_rotation_value(); - } - } - - if (is_right && !s_btn_right_last) { - if (sel == DISPLAY_ITEM_BRIGHTNESS) { - menu_component_intensity_inc(&s_menu, DISPLAY_ITEM_BRIGHTNESS); - s_brightness_val = menu_component_get_intensity(&s_menu, DISPLAY_ITEM_BRIGHTNESS); - lcd_set_brightness(s_brightness_val * BRIGHTNESS_STEP); - } else if (sel == DISPLAY_ITEM_ROTATION) { - s_rotation_val = (s_rotation_val % ROTATION_MAX) + 1; - lcd_set_rotation(s_rotation_val); - update_lvgl_display_rotation(s_rotation_val); - update_rotation_value(); - } - } - - s_btn_up_last = is_up; - s_btn_down_last = is_down; - s_btn_left_last = is_left; - s_btn_right_last = is_right; - s_btn_ok_last = is_ok; - s_btn_back_last = is_back; -} \ No newline at end of file diff --git a/firmware_p4/components/Applications/ui/screens/files/files_ui.c b/firmware_p4/components/Applications/ui/screens/files/files_ui.c index 755cb519f..733ea7313 100644 --- a/firmware_p4/components/Applications/ui/screens/files/files_ui.c +++ b/firmware_p4/components/Applications/ui/screens/files/files_ui.c @@ -17,474 +17,1175 @@ #include #include +#include #include +#include #include #include "esp_log.h" +#include "lvgl.h" #include "st7789.h" #include "assets_manager.h" -#include "buttons_gpio.h" #include "storage_assets.h" -#include "text_viewer_ui.h" +#include "ui_feedback.h" #include "ui_manager.h" +#include "wav_player_ui.h" +#include "text_viewer_ui.h" #include "ui_theme.h" +#include "vfs_sdcard.h" static const char *TAG = "FILES_UI"; -#define NAV_TIMER_INTERVAL_MS 50 -#define MAX_ENTRIES 20 -#define ENTRY_NAME_MAX_LEN 64 -#define PATH_MAX_LEN 256 -#define FULL_PATH_MAX_LEN 384 -#define OUTER_BORDER 4 -#define TOP_BORDER_H 46 -#define ITEM_H 50 -#define ITEM_W 210 -#define ITEM_BORDER_WIDTH 1 -#define ITEM_SELECTED_WIDTH 2 -#define ITEM_RADIUS 10 -#define ITEM_PAD_H 8 -#define ITEM_PAD_COL 6 -#define ITEM_ICON_SCALE 128 -#define TITLE_BAR_W 170 -#define TITLE_BAR_H 30 -#define TITLE_BAR_RADIUS 12 -#define TITLE_BAR_BORDER_WIDTH 2 -#define TITLE_ICON_SCALE 80 -#define TOP_AREA_BORDER_WIDTH 3 -#define PATH_LABEL_OFFSET_X (-8) -#define PATH_LABEL_OFFSET_Y 4 -#define PATH_LABEL_MARGIN_RIGHT 30 -#define ITEMS_PATH_GAP 28 -#define ITEMS_CONT_PAD 2 -#define ITEMS_CONT_PAD_ROW 6 -#define ITEMS_CONT_X_OFFSET 4 -#define SCROLL_TRACK_OFFSET_X 10 -#define SCROLL_TRACK_MARGIN 10 -#define SCROLL_TRACK_WIDTH 3 -#define SCROLL_TRACK_DASH_W 4 -#define SCROLL_TRACK_DASH_GAP 4 -#define SCROLL_BAR_OFFSET_X (-4) -#define SCROLL_BAR_THUMB_H 20 -#define SCROLL_ANIM_DURATION_MS 150 -#define VIEWER_SCROLL_STEP 30 -#define DEFAULT_PATH "/assets" - -#define COLOR_BORDER current_theme.border_interface -#define COLOR_ITEM_BORDER current_theme.border_accent -#define COLOR_GRAD_LEFT current_theme.border_interface -#define COLOR_GRAD_RIGHT current_theme.bg_secondary -#define COLOR_SEL_BORDER current_theme.border_accent - -static lv_obj_t *s_screen_files = NULL; -static lv_timer_t *s_nav_timer = NULL; - -static lv_obj_t *s_path_label = NULL; -static lv_obj_t *s_items_cont = NULL; -static lv_obj_t *s_item_objs[MAX_ENTRIES]; -static lv_obj_t *s_scroll_bar = NULL; - -static char s_current_path[PATH_MAX_LEN] = DEFAULT_PATH; -static char s_entry_names[MAX_ENTRIES][ENTRY_NAME_MAX_LEN]; -static bool s_entry_is_dir[MAX_ENTRIES]; -static int s_entry_count = 0; -static int s_selected = 0; -static bool s_is_viewing_file = false; -static text_viewer_t s_viewer; - -static bool s_btn_up_last = false; -static bool s_btn_down_last = false; -static bool s_btn_left_last = false; -static bool s_btn_right_last = false; -static bool s_btn_back_last = false; - -static lv_font_t *s_file_font = NULL; - -static int s_track_y_start; -static int s_track_h; - -static void update_scroll_bar(void); -static void update_selection(void); -static void scan_directory(void); -static void build_file_list(void); -static void open_file_viewer(void); -static void close_file_viewer(void); -static void navigate_into(void); -static void navigate_back(void); -static void nav_timer_cb(lv_timer_t *timer); +#define HEADER_H 36 +#define PATH_H 18 +#define FOOTER_H 22 +#define PEEK_H 72 +#define CONTENT_Y (HEADER_H + PATH_H) +#define ROW_H 31 +#define ROW_GAP 3 +#define ROW_STEP (ROW_H + ROW_GAP) +#define LIST_VIS 4 +#define TILE_W 68 +#define TILE_H 64 +#define GLABEL_H 28 + +#define ROW_CONTENT_W (LCD_H_RES - 16) +#define ROW_NAME_X 36 +#define ROW_NAME_W_FILE (ROW_CONTENT_W - ROW_NAME_X - 52) +#define ROW_NAME_W_DIR (ROW_CONTENT_W - ROW_NAME_X - 24) + +#define HOLD_TICK_MS 50 +#define OK_LONG_MS 450 +#define SCROLL_STEP 36 + +#define COL_RAISE 0x170A28 +#define COL_DIM 0x8A8594 +#define COL_DIRNM 0xF0E6FF + +#define MAX_ENTRIES 96 +#define MAX_DEPTH 10 +#define MAX_PATH 256 +#define MAX_NAME 64 +#define PREVIEW_MAX 2048 +#define GUTTER_MAX 512 +#define FULL_PATH (MAX_PATH + MAX_NAME) +#define ROW_POOL LIST_VIS +#define GRID_COLS 2 +#define GRID_ROWS 3 +#define GRID_POOL (GRID_COLS * GRID_ROWS) + +#define ASSETS_ROOT "/assets" +#define SDCARD_ROOT "/sdcard" + +enum { FT_DIR = 0, FT_IR, FT_SUB, FT_NFC, FT_LOG }; + +typedef struct { + char name[MAX_NAME]; + bool is_dir; + long size; + uint8_t type; +} entry_t; + +typedef enum { VIEW_LIST = 0, VIEW_GRID } view_t; + +static const char *const VOL_LABELS[] = {"Assets", "SD Card"}; +static const char *const VOL_PATHS[] = {ASSETS_ROOT, SDCARD_ROOT}; +#define VOL_COUNT 2 +#define VOL_SD 1 + +static const char *ICON_OF[] = { + [FT_DIR] = "/assets/icons/folder.bin", + [FT_IR] = "/assets/icons/settings_remote.bin", + [FT_SUB] = "/assets/icons/settings_input_antenna.bin", + [FT_NFC] = "/assets/icons/nfc.bin", + [FT_LOG] = "/assets/icons/description.bin", +}; + +static lv_color_t color_of(uint8_t t) { + switch (t) { + case FT_DIR: + return lv_color_hex(0xFFC400); + case FT_IR: + return lv_color_hex(0xFF5470); + case FT_SUB: + return lv_color_hex(0x00E676); + case FT_NFC: + return lv_color_hex(0x00BCD4); + default: + return lv_color_hex(0x9A93A6); + } +} +static const char *tag_of(uint8_t t) { + switch (t) { + case FT_DIR: + return "DIR"; + case FT_IR: + return "IR"; + case FT_SUB: + return "SUB"; + case FT_NFC: + return "NFC"; + default: + return "TXT"; + } +} +static const char *type_desc(uint8_t t) { + switch (t) { + case FT_DIR: + return "Folder"; + case FT_IR: + return "Infrared signal"; + case FT_SUB: + return "Sub-GHz capture"; + case FT_NFC: + return "NFC dump"; + default: + return "File"; + } +} -void ui_files_open(void) { - if (s_screen_files != NULL) { - lv_obj_del(s_screen_files); - s_screen_files = NULL; - } - - s_screen_files = lv_obj_create(NULL); - lv_obj_set_style_bg_color(s_screen_files, current_theme.screen_base, 0); - lv_obj_set_style_bg_opa(s_screen_files, LV_OPA_COVER, 0); - lv_obj_remove_flag(s_screen_files, LV_OBJ_FLAG_SCROLLABLE); - - if (s_file_font == NULL) { - extern lv_font_t *lv_binfont_create(const char *); - s_file_font = lv_binfont_create("A:assets/fonts/Inter.bin"); - } - - lv_obj_set_style_border_width(s_screen_files, OUTER_BORDER, 0); - lv_obj_set_style_border_color(s_screen_files, COLOR_BORDER, 0); - lv_obj_set_style_radius(s_screen_files, 0, 0); - lv_obj_set_style_pad_all(s_screen_files, 0, 0); - - lv_obj_t *top_area = lv_obj_create(s_screen_files); - lv_obj_set_size(top_area, LCD_H_RES - OUTER_BORDER * 2, TOP_BORDER_H); - lv_obj_align(top_area, LV_ALIGN_TOP_MID, 0, 0); - lv_obj_remove_flag(top_area, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_set_style_bg_opa(top_area, LV_OPA_TRANSP, 0); - lv_obj_set_style_border_width(top_area, TOP_AREA_BORDER_WIDTH, 0); - lv_obj_set_style_border_color(top_area, COLOR_BORDER, 0); - lv_obj_set_style_border_side(top_area, LV_BORDER_SIDE_BOTTOM, 0); - lv_obj_set_style_radius(top_area, 0, 0); - lv_obj_set_style_pad_all(top_area, 0, 0); - - lv_obj_t *title_bar = lv_obj_create(top_area); - lv_obj_set_size(title_bar, TITLE_BAR_W, TITLE_BAR_H); - lv_obj_align(title_bar, LV_ALIGN_CENTER, 0, 0); - lv_obj_remove_flag(title_bar, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_set_style_radius(title_bar, TITLE_BAR_RADIUS, 0); - lv_obj_set_style_bg_opa(title_bar, LV_OPA_COVER, 0); - lv_obj_set_style_bg_color(title_bar, COLOR_GRAD_LEFT, 0); - lv_obj_set_style_bg_grad_color(title_bar, COLOR_GRAD_RIGHT, 0); - lv_obj_set_style_bg_grad_dir(title_bar, LV_GRAD_DIR_HOR, 0); - lv_obj_set_style_border_width(title_bar, TITLE_BAR_BORDER_WIDTH, 0); - lv_obj_set_style_border_color(title_bar, COLOR_ITEM_BORDER, 0); - - static lv_image_dsc_t *s_folder_icon = NULL; - if (s_folder_icon == NULL) - s_folder_icon = assets_get("/assets/frames/folder_frame_0.bin"); - - if (s_folder_icon != NULL) { - lv_obj_t *title_icon = lv_image_create(title_bar); - lv_image_set_src(title_icon, s_folder_icon); - lv_obj_add_flag(title_icon, LV_OBJ_FLAG_FLOATING); - lv_obj_align(title_icon, LV_ALIGN_LEFT_MID, ITEM_PAD_H, 0); - lv_image_set_scale(title_icon, TITLE_ICON_SCALE); - } - - lv_obj_t *title_lbl = lv_label_create(title_bar); - lv_label_set_text(title_lbl, "Files"); - lv_obj_set_style_text_color(title_lbl, current_theme.text_main, 0); - lv_obj_set_style_text_font( - title_lbl, s_file_font != NULL ? s_file_font : &lv_font_montserrat_14, 0); - lv_obj_center(title_lbl); - - int content_y = TOP_BORDER_H + 4; - - s_path_label = lv_label_create(s_screen_files); - lv_label_set_text(s_path_label, s_current_path); - lv_obj_set_style_text_color(s_path_label, current_theme.text_main, 0); - lv_obj_set_style_text_font(s_path_label, &lv_font_montserrat_12, 0); - lv_obj_set_width(s_path_label, LCD_H_RES - OUTER_BORDER * 2 - PATH_LABEL_MARGIN_RIGHT); - lv_label_set_long_mode(s_path_label, LV_LABEL_LONG_SCROLL_CIRCULAR); - lv_obj_align( - s_path_label, LV_ALIGN_TOP_MID, PATH_LABEL_OFFSET_X, content_y + PATH_LABEL_OFFSET_Y); - - int items_y = content_y + ITEMS_PATH_GAP; - int items_h = LCD_V_RES - items_y - OUTER_BORDER - 4; - - s_items_cont = lv_obj_create(s_screen_files); - lv_obj_set_size(s_items_cont, ITEM_W + 8, items_h); - lv_obj_align(s_items_cont, LV_ALIGN_TOP_LEFT, ITEMS_CONT_X_OFFSET, items_y); - lv_obj_set_style_bg_opa(s_items_cont, LV_OPA_TRANSP, 0); - lv_obj_set_style_border_width(s_items_cont, 0, 0); - lv_obj_set_style_pad_all(s_items_cont, ITEMS_CONT_PAD, 0); - lv_obj_set_style_pad_row(s_items_cont, ITEMS_CONT_PAD_ROW, 0); - lv_obj_set_flex_flow(s_items_cont, LV_FLEX_FLOW_COLUMN); - lv_obj_set_scrollbar_mode(s_items_cont, LV_SCROLLBAR_MODE_OFF); - lv_obj_set_scroll_snap_y(s_items_cont, LV_SCROLL_SNAP_START); - - int track_x = LCD_H_RES - OUTER_BORDER - SCROLL_TRACK_OFFSET_X; - s_track_y_start = items_y + SCROLL_TRACK_MARGIN; - s_track_h = items_h - SCROLL_TRACK_MARGIN * 2; - - static lv_point_precise_t s_track_pts[2]; - s_track_pts[0].x = 0; - s_track_pts[0].y = 0; - s_track_pts[1].x = 0; - s_track_pts[1].y = s_track_h; - - lv_obj_t *track = lv_line_create(s_screen_files); - lv_line_set_points(track, s_track_pts, 2); - lv_obj_set_pos(track, track_x, s_track_y_start); - lv_obj_set_style_line_color(track, current_theme.text_main, 0); - lv_obj_set_style_line_opa(track, LV_OPA_COVER, 0); - lv_obj_set_style_line_width(track, SCROLL_TRACK_WIDTH, 0); - lv_obj_set_style_line_dash_width(track, SCROLL_TRACK_DASH_W, 0); - lv_obj_set_style_line_dash_gap(track, SCROLL_TRACK_DASH_GAP, 0); - - static lv_image_dsc_t *s_sb_dsc = NULL; - if (s_sb_dsc == NULL) - s_sb_dsc = assets_get("/assets/icons/slide_bar_v.bin"); - - s_scroll_bar = lv_image_create(s_screen_files); - if (s_sb_dsc != NULL) - lv_image_set_src(s_scroll_bar, s_sb_dsc); - - lv_obj_set_pos(s_scroll_bar, track_x + SCROLL_BAR_OFFSET_X, s_track_y_start); - lv_obj_move_foreground(s_scroll_bar); - - strncpy(s_current_path, DEFAULT_PATH, sizeof(s_current_path) - 1); - s_current_path[sizeof(s_current_path) - 1] = '\0'; - s_selected = 0; - build_file_list(); - - if (s_nav_timer == NULL) - s_nav_timer = lv_timer_create(nav_timer_cb, NAV_TIMER_INTERVAL_MS, NULL); - - lv_screen_load(s_screen_files); -} - -static void update_scroll_bar(void) { - if (s_scroll_bar == NULL || s_entry_count <= 1) - return; +static entry_t *s_entries = NULL; +static int *s_vol_idx = NULL; +static int s_count = 0; + +static char s_cwd[MAX_PATH]; +static int s_depth = 0; +static int s_sel = 0; +static int s_top = 0; +static uint8_t s_sel_stack[MAX_DEPTH]; +static bool s_in_viewer = false; +static bool s_resume = false; + +static lv_obj_t *s_screen = NULL; +static lv_timer_t *s_timer = NULL; +static view_t s_view = VIEW_LIST; + +static lv_obj_t *s_row[ROW_POOL]; +static lv_obj_t *s_row_icon[ROW_POOL]; +static lv_obj_t *s_row_name[ROW_POOL]; +static lv_obj_t *s_row_right[ROW_POOL]; + +static lv_obj_t *s_tile[GRID_POOL]; +static lv_obj_t *s_tile_icon[GRID_POOL]; +static lv_obj_t *s_tile_name[GRID_POOL]; + +static lv_obj_t *s_pk_icon, *s_pk_name, *s_pk_tag, *s_pk_meta, *s_pk_snip; +static lv_obj_t *s_gl_name, *s_gl_type; +static lv_obj_t *s_vbody = NULL; +static text_viewer_t s_tv; + +static uint32_t s_ok_down_since = 0; +static bool s_ok_long_fired = false; +static bool s_ok_armed = false; + +static char *s_vbuf = NULL; +static char *s_gbuf = NULL; + +static void files_hold_tick_cb(lv_timer_t *t); +static void files_input(const input_event_t *ev, void *ctx); +static void build_screen(void); + +static bool files_alloc(void) { + s_entries = malloc(sizeof(entry_t) * MAX_ENTRIES); + s_vol_idx = malloc(sizeof(int) * MAX_ENTRIES); + s_vbuf = malloc(PREVIEW_MAX); + s_gbuf = malloc(GUTTER_MAX); + return s_entries != NULL && s_vol_idx != NULL && s_vbuf != NULL && s_gbuf != NULL; +} - int32_t pos = - s_track_y_start + (s_selected * (s_track_h - SCROLL_BAR_THUMB_H)) / (s_entry_count - 1); +static void files_free(void) { + free(s_entries); + s_entries = NULL; + free(s_vol_idx); + s_vol_idx = NULL; + free(s_vbuf); + s_vbuf = NULL; + free(s_gbuf); + s_gbuf = NULL; +} - lv_anim_t a; - lv_anim_init(&a); - lv_anim_set_var(&a, s_scroll_bar); - lv_anim_set_values(&a, lv_obj_get_y(s_scroll_bar), pos); - lv_anim_set_duration(&a, SCROLL_ANIM_DURATION_MS); - lv_anim_set_path_cb(&a, lv_anim_path_ease_in_out); - lv_anim_set_exec_cb(&a, (lv_anim_exec_xcb_t)lv_obj_set_y); - lv_anim_start(&a); +static void copy_str(char *dst, size_t n, const char *src) { + if (n == 0) { + return; + } + size_t i = 0; + for (; i + 1 < n && src[i] != '\0'; i++) { + dst[i] = src[i]; + } + dst[i] = '\0'; } -static void update_selection(void) { - for (int i = 0; i < s_entry_count; i++) { - if (i == s_selected) { - lv_obj_set_style_border_width(s_item_objs[i], ITEM_SELECTED_WIDTH, 0); - lv_obj_set_style_border_color(s_item_objs[i], COLOR_SEL_BORDER, 0); - } else { - lv_obj_set_style_border_width(s_item_objs[i], ITEM_BORDER_WIDTH, 0); - lv_obj_set_style_border_color(s_item_objs[i], COLOR_ITEM_BORDER, 0); - } +static uint8_t type_from_ext(const char *name) { + const char *dot = strrchr(name, '.'); + if (dot == NULL) { + return FT_LOG; + } + if (strcasecmp(dot, ".nfc") == 0) { + return FT_NFC; + } + if (strcasecmp(dot, ".sub") == 0) { + return FT_SUB; + } + if (strcasecmp(dot, ".ir") == 0) { + return FT_IR; + } + return FT_LOG; +} + +static const char *entry_icon(int idx) { + if (s_depth == 0) { + return (s_vol_idx[idx] == VOL_SD) ? "/assets/icons/sd_card.bin" : "/assets/icons/folder.bin"; } + return ICON_OF[s_entries[idx].type]; +} - if (s_entry_count > 0 && s_item_objs[s_selected] != NULL) - lv_obj_scroll_to_view(s_item_objs[s_selected], LV_ANIM_ON); +static lv_color_t entry_color(int idx) { + if (s_depth == 0) { + return (s_vol_idx[idx] == VOL_SD) ? lv_color_hex(0x00BCD4) : lv_color_hex(0xFFC400); + } + return color_of(s_entries[idx].type); +} - update_scroll_bar(); +static void fmt_size(char *out, size_t n, long bytes) { + if (bytes >= 1048576) { + snprintf(out, n, "%ld.%ld MB", bytes / 1048576, ((bytes % 1048576) * 10) / 1048576); + } else if (bytes >= 1024) { + snprintf(out, n, "%ld.%ld KB", bytes / 1024, ((bytes % 1024) * 10) / 1024); + } else { + snprintf(out, n, "%ld B", bytes); + } } -#pragma GCC diagnostic push -#pragma GCC diagnostic ignored "-Wformat-truncation" -static void scan_directory(void) { - s_entry_count = 0; +static void scan_dir(void) { + s_count = 0; + if (s_entries == NULL) { + return; + } - DIR *dir = opendir(s_current_path); - if (dir == NULL) { - ESP_LOGE(TAG, "Failed to open: %s", s_current_path); + if (s_depth == 0) { + for (int k = 0; k < VOL_COUNT && s_count < MAX_ENTRIES; k++) { + bool present = (k == VOL_SD) ? vfs_sdcard_is_mounted() : storage_assets_is_mounted(); + if (!present) { + continue; + } + entry_t *e = &s_entries[s_count]; + snprintf(e->name, sizeof(e->name), "%s", VOL_LABELS[k]); + e->is_dir = true; + e->size = 0; + e->type = FT_DIR; + s_vol_idx[s_count] = k; + s_count++; + } return; } + DIR *d = opendir(s_cwd); + if (d == NULL) { + ESP_LOGW(TAG, "opendir failed: %s", s_cwd); + return; + } struct dirent *ent; - while ((ent = readdir(dir)) != NULL && s_entry_count < MAX_ENTRIES) { - if (ent->d_name[0] == '.') + while ((ent = readdir(d)) != NULL && s_count < MAX_ENTRIES) { + if (strcmp(ent->d_name, ".") == 0 || strcmp(ent->d_name, "..") == 0) { continue; + } + entry_t *e = &s_entries[s_count]; + copy_str(e->name, sizeof(e->name), ent->d_name); + e->is_dir = (ent->d_type == DT_DIR); + e->size = 0; + if (!e->is_dir) { + char full[FULL_PATH]; + snprintf(full, sizeof(full), "%s/%s", s_cwd, e->name); + struct stat st; + if (stat(full, &st) == 0) { + e->size = (long)st.st_size; + } + } + e->type = e->is_dir ? FT_DIR : type_from_ext(e->name); + s_count++; + } + closedir(d); +} - strncpy(s_entry_names[s_entry_count], ent->d_name, ENTRY_NAME_MAX_LEN - 1); - s_entry_names[s_entry_count][ENTRY_NAME_MAX_LEN - 1] = '\0'; +static const char *cur_name(void) { + if (s_depth == 0) { + return "Files"; + } + const char *slash = strrchr(s_cwd, '/'); + return (slash != NULL && slash[1] != '\0') ? slash + 1 : s_cwd; +} - char full[FULL_PATH_MAX_LEN]; - snprintf(full, sizeof(full), "%s/%s", s_current_path, ent->d_name); +static void style_item(lv_obj_t *o, lv_color_t c, bool sel) { + lv_obj_set_style_border_color(o, sel ? c : current_theme.border_inactive, 0); + lv_obj_set_style_border_opa(o, sel ? LV_OPA_COVER : LV_OPA_TRANSP, 0); + lv_obj_set_style_bg_color(o, sel ? lv_color_hex(COL_RAISE) : current_theme.bg_secondary, 0); + lv_obj_set_style_shadow_width(o, sel ? 14 : 0, 0); + lv_obj_set_style_shadow_color(o, c, 0); + lv_obj_set_style_shadow_spread(o, sel ? -3 : 0, 0); +} - struct stat st; - s_entry_is_dir[s_entry_count] = (stat(full, &st) == 0 && S_ISDIR(st.st_mode)); - s_entry_count++; +static void fill_peek_empty(void) { + if (!s_pk_name) { + return; } + lv_label_set_text(s_pk_name, "(empty)"); + lv_label_set_text(s_pk_tag, ""); + lv_label_set_text(s_pk_meta, "No items"); + lv_label_set_text(s_pk_snip, ""); +} - closedir(dir); +static void fill_peek(int idx) { + if (!s_pk_name) { + return; + } + const entry_t *e = &s_entries[idx]; + lv_color_t c = entry_color(idx); + lv_image_dsc_t *ic = assets_get(entry_icon(idx)); + if (ic && s_pk_icon) { + lv_image_set_src(s_pk_icon, ic); + } + lv_label_set_text(s_pk_name, e->name); + lv_label_set_text(s_pk_tag, s_depth == 0 ? "VOL" : tag_of(e->type)); + lv_obj_set_style_text_color(s_pk_tag, c, 0); + lv_obj_set_style_border_color(s_pk_tag, c, 0); + + if (e->is_dir) { + lv_label_set_text(s_pk_meta, s_depth == 0 ? "Storage volume" : "Folder"); + if (s_depth == 0) { + lv_label_set_text(s_pk_snip, s_vol_idx[idx] == VOL_SD ? "SD card" : "Internal flash"); + } else { + lv_label_set_text(s_pk_snip, ""); + } + } else { + char sz[16]; + fmt_size(sz, sizeof(sz), e->size); + lv_label_set_text(s_pk_meta, sz); + lv_label_set_text(s_pk_snip, type_desc(e->type)); + } } -#pragma GCC diagnostic pop -#pragma GCC diagnostic push -#pragma GCC diagnostic ignored "-Wformat-truncation" -static void open_file_viewer(void) { - char full_path[FULL_PATH_MAX_LEN]; - snprintf(full_path, sizeof(full_path), "%s/%s", s_current_path, s_entry_names[s_selected]); +static void fill_glabel(int idx) { + if (!s_gl_name) { + return; + } + const entry_t *e = &s_entries[idx]; + lv_label_set_text(s_gl_name, e->name); + if (e->is_dir) { + lv_label_set_text(s_gl_type, s_depth == 0 ? "Volume" : "Folder"); + } else { + char sz[16]; + fmt_size(sz, sizeof(sz), e->size); + lv_label_set_text(s_gl_type, sz); + } + lv_obj_set_style_text_color(s_gl_type, entry_color(idx), 0); +} - s_viewer = text_viewer_create(s_screen_files, s_entry_names[s_selected]); - text_viewer_load_file(&s_viewer, full_path); - lv_obj_move_foreground(s_viewer.screen); - s_is_viewing_file = true; +static void hide_obj(lv_obj_t *o) { + lv_obj_add_flag(o, LV_OBJ_FLAG_HIDDEN); + lv_obj_add_flag(o, LV_OBJ_FLAG_IGNORE_LAYOUT); +} +static void show_obj(lv_obj_t *o) { + lv_obj_remove_flag(o, LV_OBJ_FLAG_HIDDEN); + lv_obj_remove_flag(o, LV_OBJ_FLAG_IGNORE_LAYOUT); } -#pragma GCC diagnostic pop -static void close_file_viewer(void) { - if (s_viewer.screen != NULL) { - lv_obj_del(s_viewer.screen); - s_viewer.screen = NULL; +static void populate_row(int j) { + int idx = s_top + j; + lv_obj_t *row = s_row[j]; + if (idx >= s_count) { + hide_obj(row); + return; + } + show_obj(row); + const entry_t *e = &s_entries[idx]; + lv_image_dsc_t *ic = assets_get(entry_icon(idx)); + if (ic) { + lv_image_set_src(s_row_icon[j], ic); + } + lv_obj_set_width(s_row_name[j], e->is_dir ? ROW_NAME_W_DIR : ROW_NAME_W_FILE); + lv_label_set_text(s_row_name[j], e->name); + lv_obj_set_style_text_color( + s_row_name[j], e->is_dir ? lv_color_hex(COL_DIRNM) : current_theme.text_main, 0); + if (e->is_dir) { + lv_label_set_text(s_row_right[j], LV_SYMBOL_RIGHT); + lv_obj_set_style_text_color(s_row_right[j], entry_color(idx), 0); + } else { + char sz[16]; + fmt_size(sz, sizeof(sz), e->size); + lv_label_set_text(s_row_right[j], sz); + lv_obj_set_style_text_color(s_row_right[j], lv_color_hex(COL_DIM), 0); } - s_is_viewing_file = false; + style_item(row, entry_color(idx), idx == s_sel); } -#pragma GCC diagnostic push -#pragma GCC diagnostic ignored "-Wformat-truncation" -static void navigate_into(void) { - if (s_entry_count == 0) +static void populate_tile(int j) { + int idx = s_top + j; + lv_obj_t *tile = s_tile[j]; + if (idx >= s_count) { + hide_obj(tile); return; + } + show_obj(tile); + const entry_t *e = &s_entries[idx]; + lv_image_dsc_t *ic = assets_get(entry_icon(idx)); + if (ic) { + lv_image_set_src(s_tile_icon[j], ic); + } + lv_label_set_text(s_tile_name[j], e->name); + lv_obj_set_style_text_color( + s_tile_name[j], e->is_dir ? lv_color_hex(COL_DIRNM) : current_theme.text_main, 0); + style_item(tile, entry_color(idx), idx == s_sel); +} - if (!s_entry_is_dir[s_selected]) { - open_file_viewer(); +static void refresh_selection(void) { + if (s_count == 0) { + if (s_view == VIEW_LIST) { + for (int j = 0; j < ROW_POOL; j++) { + hide_obj(s_row[j]); + } + fill_peek_empty(); + } else { + for (int j = 0; j < GRID_POOL; j++) { + hide_obj(s_tile[j]); + } + } return; } + if (s_sel < 0) { + s_sel = 0; + } + if (s_sel >= s_count) { + s_sel = s_count - 1; + } + + if (s_view == VIEW_LIST) { + if (s_sel < s_top) { + s_top = s_sel; + } + if (s_sel >= s_top + LIST_VIS) { + s_top = s_sel - LIST_VIS + 1; + } + int maxtop = s_count - LIST_VIS; + if (maxtop < 0) { + maxtop = 0; + } + if (s_top > maxtop) { + s_top = maxtop; + } + if (s_top < 0) { + s_top = 0; + } + for (int j = 0; j < ROW_POOL; j++) { + populate_row(j); + } + fill_peek(s_sel); + } else { + int row = s_sel / GRID_COLS; + int toprow = s_top / GRID_COLS; + if (row < toprow) { + toprow = row; + } + if (row >= toprow + GRID_ROWS) { + toprow = row - GRID_ROWS + 1; + } + if (toprow < 0) { + toprow = 0; + } + s_top = toprow * GRID_COLS; + for (int j = 0; j < GRID_POOL; j++) { + populate_tile(j); + } + fill_glabel(s_sel); + } +} + +static lv_obj_t *plain(lv_obj_t *parent) { + lv_obj_t *o = lv_obj_create(parent); + lv_obj_remove_flag(o, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_remove_flag(o, LV_OBJ_FLAG_CLICKABLE); + lv_obj_set_style_border_width(o, 0, 0); + lv_obj_set_style_bg_opa(o, LV_OPA_TRANSP, 0); + lv_obj_set_style_pad_all(o, 0, 0); + lv_obj_set_style_radius(o, 0, 0); + return o; +} + +static void flex_spacer(lv_obj_t *parent) { + lv_obj_t *sp = lv_obj_create(parent); + lv_obj_remove_style_all(sp); + lv_obj_set_height(sp, 1); + lv_obj_set_flex_grow(sp, 1); +} + +static void build_header(void) { + lv_obj_t *hdr = lv_obj_create(s_screen); + lv_obj_remove_flag(hdr, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_remove_flag(hdr, LV_OBJ_FLAG_CLICKABLE); + lv_obj_set_size(hdr, LCD_H_RES, HEADER_H); + lv_obj_align(hdr, LV_ALIGN_TOP_LEFT, 0, 0); + lv_obj_set_style_radius(hdr, 0, 0); + lv_obj_set_style_bg_color(hdr, current_theme.bg_secondary, 0); + lv_obj_set_style_bg_opa(hdr, LV_OPA_COVER, 0); + lv_obj_set_style_pad_hor(hdr, 8, 0); + lv_obj_set_style_pad_ver(hdr, 0, 0); + lv_obj_set_style_border_width(hdr, 2, 0); + lv_obj_set_style_border_color(hdr, current_theme.border_accent, 0); + lv_obj_set_style_border_side(hdr, LV_BORDER_SIDE_BOTTOM, 0); + lv_obj_set_flex_flow(hdr, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(hdr, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + + lv_obj_t *chev = lv_label_create(hdr); + lv_label_set_text(chev, LV_SYMBOL_LEFT); + lv_obj_set_style_text_font(chev, &lv_font_montserrat_14, 0); + lv_obj_set_style_text_color( + chev, s_depth > 0 ? current_theme.border_accent : current_theme.border_inactive, 0); + + lv_obj_t *name = lv_label_create(hdr); + lv_label_set_text(name, cur_name()); + lv_label_set_long_mode(name, LV_LABEL_LONG_DOT); + lv_obj_set_style_text_font(name, &lv_font_montserrat_14, 0); + lv_obj_set_style_text_color(name, current_theme.border_accent, 0); + lv_obj_set_style_pad_left(name, 6, 0); + lv_obj_set_flex_grow(name, 1); + + lv_obj_t *vw = lv_label_create(hdr); + lv_label_set_text(vw, s_view == VIEW_LIST ? "LIST" : "GRID"); + lv_obj_set_style_text_font(vw, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(vw, current_theme.border_accent, 0); + lv_obj_set_style_bg_color(vw, current_theme.bg_primary, 0); + lv_obj_set_style_bg_opa(vw, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(vw, 1, 0); + lv_obj_set_style_border_color(vw, current_theme.border_interface, 0); + lv_obj_set_style_radius(vw, 6, 0); + lv_obj_set_style_pad_hor(vw, 6, 0); + lv_obj_set_style_pad_ver(vw, 1, 0); +} + +static void build_pathbar(void) { + lv_obj_t *bar = lv_obj_create(s_screen); + lv_obj_remove_flag(bar, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_remove_flag(bar, LV_OBJ_FLAG_CLICKABLE); + lv_obj_set_size(bar, LCD_H_RES, PATH_H); + lv_obj_align(bar, LV_ALIGN_TOP_LEFT, 0, HEADER_H); + lv_obj_set_style_radius(bar, 0, 0); + lv_obj_set_style_bg_color(bar, lv_color_hex(0x0A0710), 0); + lv_obj_set_style_bg_opa(bar, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(bar, 0, 0); + lv_obj_set_style_pad_hor(bar, 10, 0); + lv_obj_set_style_pad_ver(bar, 0, 0); + lv_obj_set_flex_flow(bar, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(bar, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + + lv_obj_t *lbl = lv_label_create(bar); + lv_label_set_text(lbl, s_depth == 0 ? "root" : s_cwd); + lv_label_set_long_mode(lbl, LV_LABEL_LONG_DOT); + lv_obj_set_style_text_font(lbl, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(lbl, lv_color_hex(COL_DIM), 0); + lv_obj_set_flex_grow(lbl, 1); + + lv_obj_t *dots = plain(bar); + lv_obj_set_size(dots, LV_SIZE_CONTENT, LV_SIZE_CONTENT); + lv_obj_set_flex_flow(dots, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(dots, LV_FLEX_ALIGN_END, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_set_style_pad_column(dots, 3, 0); + for (int k = 0; k < 4; k++) { + lv_obj_t *dot = plain(dots); + lv_obj_set_size(dot, 5, 5); + lv_obj_set_style_radius(dot, LV_RADIUS_CIRCLE, 0); + lv_obj_set_style_bg_opa(dot, LV_OPA_COVER, 0); + lv_obj_set_style_bg_color( + dot, k < s_depth ? current_theme.border_accent : current_theme.border_inactive, 0); + } +} + +static lv_obj_t *type_icon(lv_obj_t *parent, const char *path, int cell) { + lv_image_dsc_t *ic = assets_get(path); + lv_obj_t *img = lv_image_create(parent); + if (ic) { + lv_image_set_src(img, ic); + } + lv_obj_set_size(img, cell, cell); + lv_image_set_inner_align(img, LV_IMAGE_ALIGN_CONTAIN); + return img; +} - char new_path[PATH_MAX_LEN]; - snprintf(new_path, sizeof(new_path), "%s/%s", s_current_path, s_entry_names[s_selected]); - strncpy(s_current_path, new_path, sizeof(s_current_path) - 1); - s_current_path[sizeof(s_current_path) - 1] = '\0'; - s_selected = 0; - build_file_list(); +static lv_obj_t *make_row_pool(lv_obj_t *parent, int j) { + lv_obj_t *row = lv_obj_create(parent); + lv_obj_remove_flag(row, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_remove_flag(row, LV_OBJ_FLAG_CLICKABLE); + lv_obj_set_size(row, lv_pct(100), ROW_H); + lv_obj_set_style_radius(row, 8, 0); + lv_obj_set_style_bg_opa(row, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(row, 2, 0); + lv_obj_set_style_pad_all(row, 0, 0); + + s_row_icon[j] = type_icon(row, ICON_OF[FT_LOG], 20); + lv_obj_align(s_row_icon[j], LV_ALIGN_LEFT_MID, 8, 0); + + s_row_name[j] = lv_label_create(row); + lv_obj_set_width(s_row_name[j], ROW_NAME_W_FILE); + lv_label_set_long_mode(s_row_name[j], LV_LABEL_LONG_SCROLL_CIRCULAR); + lv_obj_set_style_text_font(s_row_name[j], &lv_font_montserrat_14, 0); + lv_obj_align(s_row_name[j], LV_ALIGN_LEFT_MID, ROW_NAME_X, 0); + + s_row_right[j] = lv_label_create(row); + lv_obj_set_style_text_font(s_row_right[j], &lv_font_montserrat_12, 0); + lv_obj_align(s_row_right[j], LV_ALIGN_RIGHT_MID, -8, 0); + + return row; } -#pragma GCC diagnostic pop -static void navigate_back(void) { - char *last = strrchr(s_current_path, '/'); - if (last == NULL || last == s_current_path) +static void build_list(void) { + int list_h = LCD_V_RES - CONTENT_Y - FOOTER_H - PEEK_H - 8; + + lv_obj_t *wrap = lv_obj_create(s_screen); + lv_obj_remove_flag(wrap, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_remove_flag(wrap, LV_OBJ_FLAG_CLICKABLE); + lv_obj_set_size(wrap, LCD_H_RES, list_h); + lv_obj_align(wrap, LV_ALIGN_TOP_LEFT, 0, CONTENT_Y); + lv_obj_set_style_bg_opa(wrap, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(wrap, 0, 0); + lv_obj_set_style_pad_hor(wrap, 8, 0); + lv_obj_set_style_pad_ver(wrap, 4, 0); + lv_obj_set_style_pad_row(wrap, ROW_GAP, 0); + lv_obj_set_style_clip_corner(wrap, true, 0); + lv_obj_set_flex_flow(wrap, LV_FLEX_FLOW_COLUMN); + + for (int j = 0; j < ROW_POOL; j++) { + s_row[j] = make_row_pool(wrap, j); + } +} + +static lv_obj_t *make_tile_pool(lv_obj_t *parent, int j) { + lv_obj_t *tile = lv_obj_create(parent); + lv_obj_remove_flag(tile, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_remove_flag(tile, LV_OBJ_FLAG_CLICKABLE); + lv_obj_set_size(tile, TILE_W, TILE_H); + lv_obj_set_style_radius(tile, 11, 0); + lv_obj_set_style_bg_opa(tile, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(tile, 2, 0); + lv_obj_set_style_pad_all(tile, 4, 0); + lv_obj_set_flex_flow(tile, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(tile, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_set_style_pad_row(tile, 4, 0); + + s_tile_icon[j] = type_icon(tile, ICON_OF[FT_LOG], 28); + + s_tile_name[j] = lv_label_create(tile); + lv_obj_set_width(s_tile_name[j], TILE_W - 12); + lv_label_set_long_mode(s_tile_name[j], LV_LABEL_LONG_SCROLL_CIRCULAR); + lv_obj_set_style_text_align(s_tile_name[j], LV_TEXT_ALIGN_CENTER, 0); + lv_obj_set_style_text_font(s_tile_name[j], &lv_font_montserrat_12, 0); + + return tile; +} + +static void build_grid(void) { + int grid_h = LCD_V_RES - CONTENT_Y - FOOTER_H - GLABEL_H; + + lv_obj_t *g = lv_obj_create(s_screen); + lv_obj_remove_flag(g, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_remove_flag(g, LV_OBJ_FLAG_CLICKABLE); + lv_obj_set_size(g, LCD_H_RES, grid_h); + lv_obj_align(g, LV_ALIGN_TOP_LEFT, 0, CONTENT_Y); + lv_obj_set_style_bg_opa(g, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(g, 0, 0); + lv_obj_set_style_pad_all(g, 8, 0); + lv_obj_set_style_clip_corner(g, true, 0); + lv_obj_set_flex_flow(g, LV_FLEX_FLOW_ROW_WRAP); + lv_obj_set_flex_align(g, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START); + lv_obj_set_style_pad_row(g, 6, 0); + lv_obj_set_style_pad_column(g, 6, 0); + + for (int j = 0; j < GRID_POOL; j++) { + s_tile[j] = make_tile_pool(g, j); + } + + lv_obj_t *gl = lv_obj_create(s_screen); + lv_obj_remove_flag(gl, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_remove_flag(gl, LV_OBJ_FLAG_CLICKABLE); + lv_obj_set_size(gl, LCD_H_RES, GLABEL_H); + lv_obj_align(gl, LV_ALIGN_BOTTOM_LEFT, 0, -FOOTER_H); + lv_obj_set_style_radius(gl, 0, 0); + lv_obj_set_style_bg_opa(gl, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(gl, 1, 0); + lv_obj_set_style_border_color(gl, current_theme.border_inactive, 0); + lv_obj_set_style_border_side(gl, LV_BORDER_SIDE_TOP, 0); + lv_obj_set_style_pad_hor(gl, 12, 0); + lv_obj_set_style_pad_ver(gl, 0, 0); + lv_obj_set_flex_flow(gl, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(gl, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + + s_gl_name = lv_label_create(gl); + lv_obj_set_width(s_gl_name, 128); + lv_label_set_long_mode(s_gl_name, LV_LABEL_LONG_SCROLL_CIRCULAR); + lv_obj_set_style_text_font(s_gl_name, &lv_font_montserrat_14, 0); + lv_obj_set_style_text_color(s_gl_name, current_theme.text_main, 0); + + flex_spacer(gl); + + s_gl_type = lv_label_create(gl); + lv_obj_set_style_text_font(s_gl_type, &lv_font_montserrat_12, 0); + lv_obj_set_style_pad_left(s_gl_type, 8, 0); +} + +static void build_peek(void) { + lv_obj_t *pk = lv_obj_create(s_screen); + lv_obj_remove_flag(pk, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_remove_flag(pk, LV_OBJ_FLAG_CLICKABLE); + lv_obj_set_size(pk, LCD_H_RES - 16, PEEK_H); + lv_obj_align(pk, LV_ALIGN_BOTTOM_MID, 0, -(FOOTER_H + 4)); + lv_obj_set_style_radius(pk, 11, 0); + lv_obj_set_style_bg_color(pk, lv_color_hex(COL_RAISE), 0); + lv_obj_set_style_bg_grad_color(pk, current_theme.bg_secondary, 0); + lv_obj_set_style_bg_grad_dir(pk, LV_GRAD_DIR_VER, 0); + lv_obj_set_style_bg_opa(pk, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(pk, 1, 0); + lv_obj_set_style_border_color(pk, current_theme.border_inactive, 0); + lv_obj_set_style_pad_hor(pk, 10, 0); + lv_obj_set_style_pad_ver(pk, 6, 0); + lv_obj_set_flex_flow(pk, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(pk, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START); + lv_obj_set_style_pad_row(pk, 3, 0); + + lv_obj_t *hrow = plain(pk); + lv_obj_set_size(hrow, lv_pct(100), LV_SIZE_CONTENT); + lv_obj_set_flex_flow(hrow, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(hrow, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_set_style_pad_column(hrow, 7, 0); + + s_pk_icon = type_icon(hrow, ICON_OF[FT_LOG], 16); + + s_pk_name = lv_label_create(hrow); + lv_obj_set_width(s_pk_name, 118); + lv_label_set_long_mode(s_pk_name, LV_LABEL_LONG_SCROLL_CIRCULAR); + lv_obj_set_style_text_font(s_pk_name, &lv_font_montserrat_14, 0); + lv_obj_set_style_text_color(s_pk_name, current_theme.text_main, 0); + + flex_spacer(hrow); + + s_pk_tag = lv_label_create(hrow); + lv_obj_set_style_text_font(s_pk_tag, &lv_font_montserrat_12, 0); + lv_obj_set_style_border_width(s_pk_tag, 1, 0); + lv_obj_set_style_radius(s_pk_tag, 5, 0); + lv_obj_set_style_pad_hor(s_pk_tag, 4, 0); + + s_pk_meta = lv_label_create(pk); + lv_label_set_long_mode(s_pk_meta, LV_LABEL_LONG_DOT); + lv_obj_set_width(s_pk_meta, lv_pct(100)); + lv_obj_set_style_text_font(s_pk_meta, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(s_pk_meta, lv_color_hex(COL_DIM), 0); + + s_pk_snip = lv_label_create(pk); + lv_label_set_long_mode(s_pk_snip, LV_LABEL_LONG_DOT); + lv_obj_set_width(s_pk_snip, lv_pct(100)); + lv_obj_set_style_text_font(s_pk_snip, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(s_pk_snip, current_theme.text_main, 0); + lv_obj_set_style_text_opa(s_pk_snip, LV_OPA_60, 0); +} + +static void build_footer(const char *hint) { + lv_obj_t *ft = lv_obj_create(s_screen); + lv_obj_remove_flag(ft, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_remove_flag(ft, LV_OBJ_FLAG_CLICKABLE); + lv_obj_set_size(ft, LCD_H_RES, FOOTER_H); + lv_obj_align(ft, LV_ALIGN_BOTTOM_LEFT, 0, 0); + lv_obj_set_style_radius(ft, 0, 0); + lv_obj_set_style_bg_color(ft, current_theme.bg_secondary, 0); + lv_obj_set_style_bg_opa(ft, LV_OPA_COVER, 0); + lv_obj_set_style_pad_all(ft, 0, 0); + lv_obj_set_style_border_width(ft, 2, 0); + lv_obj_set_style_border_color(ft, current_theme.border_interface, 0); + lv_obj_set_style_border_side(ft, LV_BORDER_SIDE_TOP, 0); + + lv_obj_t *lbl = lv_label_create(ft); + lv_label_set_text(lbl, hint); + lv_obj_set_style_text_color(lbl, current_theme.text_main, 0); + lv_obj_set_style_text_opa(lbl, LV_OPA_70, 0); + lv_obj_set_style_text_font(lbl, &lv_font_montserrat_12, 0); + lv_obj_center(lbl); +} + +static void load_preview(const char *path) { + s_vbuf[0] = '\0'; + FILE *f = fopen(path, "rb"); + if (f == NULL) { + snprintf(s_vbuf, PREVIEW_MAX, "(cannot open file)"); return; + } + size_t n = fread(s_vbuf, 1, PREVIEW_MAX - 1, f); + fseek(f, 0, SEEK_END); + long total = ftell(f); + fclose(f); + if (total < 0) { + total = (long)n; + } - *last = '\0'; - s_selected = 0; - build_file_list(); -} - -static void build_file_list(void) { - if (s_items_cont != NULL) - lv_obj_clean(s_items_cont); - - scan_directory(); - - if (s_path_label != NULL) - lv_label_set_text(s_path_label, s_current_path); - - static lv_image_dsc_t *s_folder_dsc = NULL; - static lv_image_dsc_t *s_file_dsc = NULL; - - if (s_folder_dsc == NULL) - s_folder_dsc = assets_get("/assets/frames/folder_frame_0.bin"); - if (s_file_dsc == NULL) - s_file_dsc = assets_get("/assets/frames/file_frame_0.bin"); - - for (int i = 0; i < s_entry_count; i++) { - lv_obj_t *item = lv_obj_create(s_items_cont); - lv_obj_set_size(item, ITEM_W, ITEM_H); - lv_obj_remove_flag(item, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_set_style_radius(item, ITEM_RADIUS, 0); - lv_obj_set_style_bg_opa(item, LV_OPA_COVER, 0); - lv_obj_set_style_bg_color(item, COLOR_GRAD_LEFT, 0); - lv_obj_set_style_bg_grad_color(item, COLOR_GRAD_RIGHT, 0); - lv_obj_set_style_bg_grad_dir(item, LV_GRAD_DIR_HOR, 0); - lv_obj_set_style_border_width(item, ITEM_BORDER_WIDTH, 0); - lv_obj_set_style_border_color(item, COLOR_ITEM_BORDER, 0); - lv_obj_set_style_pad_left(item, ITEM_PAD_H, 0); - lv_obj_set_style_pad_right(item, ITEM_PAD_H, 0); - lv_obj_set_flex_flow(item, LV_FLEX_FLOW_ROW); - lv_obj_set_flex_align(item, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); - lv_obj_set_style_pad_column(item, ITEM_PAD_COL, 0); - - lv_image_dsc_t *dsc = s_entry_is_dir[i] ? s_folder_dsc : s_file_dsc; - if (dsc != NULL) { - lv_obj_t *icon = lv_image_create(item); - lv_image_set_src(icon, dsc); - lv_image_set_scale(icon, ITEM_ICON_SCALE); + // Classify: a NUL byte or lots of non-printables means it's a binary file. + int nonprint = 0; + bool binary = false; + for (size_t i = 0; i < n; i++) { + unsigned char c = (unsigned char)s_vbuf[i]; + if (c == 0) { + binary = true; + break; + } + if (c != '\n' && c != '\r' && c != '\t' && (c < 32 || c > 126)) { + nonprint++; } + } + if (!binary && n > 0 && (nonprint * 100 / (int)n) > 12) { + binary = true; + } - lv_obj_t *lbl = lv_label_create(item); - lv_label_set_text(lbl, s_entry_names[i]); - lv_obj_set_style_text_color(lbl, current_theme.text_main, 0); - lv_obj_set_style_text_font(lbl, &lv_font_montserrat_12, 0); - lv_obj_set_flex_grow(lbl, 1); - lv_label_set_long_mode(lbl, LV_LABEL_LONG_SCROLL_CIRCULAR); - - if (s_entry_is_dir[i]) { - lv_obj_t *arrow = lv_label_create(item); - lv_label_set_text(arrow, LV_SYMBOL_REFRESH); - lv_obj_set_style_text_color(arrow, current_theme.border_accent, 0); - lv_obj_set_style_text_font(arrow, &lv_font_montserrat_12, 0); + if (!binary) { + // Text: keep it, replacing any stray control byte with a dot. + s_vbuf[n] = '\0'; + for (size_t i = 0; i < n; i++) { + unsigned char c = (unsigned char)s_vbuf[i]; + if (c == '\n' || c == '\r' || c == '\t') { + continue; + } + if (c < 32 || c > 126) { + s_vbuf[i] = '.'; + } } + return; + } - s_item_objs[i] = item; + // Binary: give it a real hex preview (offset + bytes) so it still opens. + unsigned char raw[256]; + size_t hn = n < sizeof(raw) ? n : sizeof(raw); + for (size_t i = 0; i < hn; i++) { + raw[i] = (unsigned char)s_vbuf[i]; } + int pos = snprintf(s_vbuf, PREVIEW_MAX, "Binary file\n%ld bytes\n\n", total); + for (size_t i = 0; i < hn && pos < (int)PREVIEW_MAX - 40; i += 8) { + pos += snprintf(s_vbuf + pos, PREVIEW_MAX - pos, "%04X ", (unsigned)i); + for (size_t j = 0; j < 8 && i + j < hn; j++) { + pos += snprintf(s_vbuf + pos, PREVIEW_MAX - pos, "%02X ", raw[i + j]); + } + if (pos < (int)PREVIEW_MAX - 2) { + s_vbuf[pos++] = '\n'; + s_vbuf[pos] = '\0'; + } + } +} + +static void build_viewer(void) { + const entry_t *e = &s_entries[s_sel]; + char full[FULL_PATH]; + snprintf(full, sizeof(full), "%s/%s", s_cwd, e->name); + load_preview(full); + + // Use the shared text_viewer component (wrapped text, line/byte meta, scroll + // bar). It builds a full-screen viewer under s_screen; keep its scroll area in + // s_vbody so the nav timer's UP/DOWN can scroll it. + s_tv = text_viewer_create(s_screen, e->name); + text_viewer_set_text(&s_tv, s_vbuf); + s_vbody = s_tv.text_area; +} - if (s_entry_count == 0) { - lv_obj_t *empty = lv_label_create(s_items_cont); - lv_label_set_text(empty, "Empty folder"); - lv_obj_set_style_text_color(empty, current_theme.border_inactive, 0); - lv_obj_set_style_text_font(empty, &lv_font_montserrat_12, 0); +static void build_screen(void) { + if (s_screen != NULL) { + lv_obj_del(s_screen); + s_screen = NULL; + } + s_vbody = NULL; + s_pk_icon = s_pk_name = s_pk_tag = s_pk_meta = s_pk_snip = NULL; + s_gl_name = s_gl_type = NULL; + for (int j = 0; j < ROW_POOL; j++) { + s_row[j] = NULL; + } + for (int j = 0; j < GRID_POOL; j++) { + s_tile[j] = NULL; } - update_selection(); + s_screen = lv_obj_create(NULL); + lv_obj_set_style_bg_color(s_screen, current_theme.screen_base, 0); + lv_obj_set_style_bg_opa(s_screen, LV_OPA_COVER, 0); + lv_obj_remove_flag(s_screen, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_border_width(s_screen, 0, 0); + lv_obj_set_style_pad_all(s_screen, 0, 0); + + if (s_in_viewer) { + build_viewer(); + } else { + build_header(); + build_pathbar(); + if (s_view == VIEW_LIST) { + build_list(); + build_peek(); + } else { + build_grid(); + } + build_footer("OK open BACK up hold OK: view"); + refresh_selection(); + } + + if (s_timer == NULL) { + s_timer = lv_timer_create(files_hold_tick_cb, HOLD_TICK_MS, NULL); + } + ui_input_set_screen_handler(files_input, NULL); + + ui_screen_load_owned(&s_screen, s_screen); } -static void nav_timer_cb(lv_timer_t *timer) { - if (lv_screen_active() != s_screen_files) { - lv_timer_delete(timer); - s_nav_timer = NULL; +static void move_list(int d) { + if (s_count <= 0) { return; } - - if (ui_input_is_locked()) + s_sel = (s_sel + d + s_count) % s_count; + refresh_selection(); + ui_feedback(UI_FB_NAV); +} +static void move_grid(int dx, int dy) { + if (s_count <= 0) { return; + } + int s = s_sel; + if (dx > 0 && s < s_count - 1) { + s++; + } + if (dx < 0 && s > 0) { + s--; + } + if (dy > 0) { + int t = s + GRID_COLS; + s = (t < s_count) ? t : s_count - 1; + } + if (dy < 0) { + int t = s - GRID_COLS; + if (t >= 0) { + s = t; + } + } + if (s != s_sel) { + s_sel = s; + refresh_selection(); + ui_feedback(UI_FB_NAV); + } +} - bool is_up = up_button_is_down(); - bool is_down = down_button_is_down(); - bool is_left = left_button_is_down(); - bool is_right = right_button_is_down(); - bool is_back = back_button_is_down(); +static void enter_dir(const char *path, bool absolute) { + if (s_depth >= MAX_DEPTH - 1) { + return; + } + s_sel_stack[s_depth] = (uint8_t)s_sel; + if (absolute) { + snprintf(s_cwd, sizeof(s_cwd), "%s", path); + } else { + size_t l = strlen(s_cwd); + snprintf(s_cwd + l, sizeof(s_cwd) - l, "/%s", path); + } + s_depth++; + s_sel = 0; + s_top = 0; + scan_dir(); + ui_feedback(UI_FB_SELECT); + build_screen(); +} - if (s_is_viewing_file) { - if (is_down && !s_btn_down_last && s_viewer.text_area != NULL) - lv_obj_scroll_by(s_viewer.text_area, 0, -VIEWER_SCROLL_STEP, LV_ANIM_ON); +static void do_enter(void) { + if (s_count <= 0) { + return; + } + if (s_depth == 0) { + enter_dir(VOL_PATHS[s_vol_idx[s_sel]], true); + return; + } + const entry_t *e = &s_entries[s_sel]; + if (e->is_dir) { + enter_dir(e->name, false); + return; + } + const char *dot = strrchr(e->name, '.'); + if (dot != NULL && strcasecmp(dot, ".wav") == 0) { + char full[FULL_PATH]; + snprintf(full, sizeof(full), "%s/%s", s_cwd, e->name); + ui_feedback(UI_FB_SELECT); + s_resume = true; + ui_wav_player_set_path(full); + ui_wav_player_set_return(SCREEN_FILES); + ui_switch_screen(SCREEN_WAV_PLAYER); + return; + } + s_in_viewer = true; + ui_feedback(UI_FB_SELECT); + build_screen(); +} - if (is_up && !s_btn_up_last && s_viewer.text_area != NULL) - lv_obj_scroll_by(s_viewer.text_area, 0, VIEWER_SCROLL_STEP, LV_ANIM_ON); +static void do_back(void) { + if (s_in_viewer) { + s_in_viewer = false; + build_screen(); + return; + } + if (s_depth == 0) { + files_free(); + ui_switch_screen(SCREEN_MENU); + return; + } + s_depth--; + if (s_depth == 0) { + s_cwd[0] = '\0'; + } else { + char *slash = strrchr(s_cwd, '/'); + if (slash != NULL) { + *slash = '\0'; + } + } + s_sel = s_sel_stack[s_depth]; + s_top = 0; + scan_dir(); + ui_feedback(UI_FB_NAV); + build_screen(); +} - if ((is_left && !s_btn_left_last) || (is_back && !s_btn_back_last)) - close_file_viewer(); +static void toggle_view(void) { + s_view = (s_view == VIEW_LIST) ? VIEW_GRID : VIEW_LIST; + s_top = 0; + ui_feedback(UI_FB_SELECT); + build_screen(); +} - s_btn_up_last = is_up; - s_btn_down_last = is_down; - s_btn_left_last = is_left; - s_btn_right_last = is_right; - s_btn_back_last = is_back; +static void files_hold_tick_cb(lv_timer_t *t) { + if (lv_screen_active() != s_screen) { + lv_timer_delete(t); + s_timer = NULL; return; } + if (ui_input_is_locked()) + return; + if (s_in_viewer) + return; + + uint32_t now = lv_tick_get(); + bool ok = input_is_down(INPUT_BTN_OK); - if (is_down && !s_btn_down_last && s_entry_count > 0) { - s_selected = (s_selected + 1) % s_entry_count; - update_selection(); + if (ok) { + if (!s_ok_armed && !s_ok_long_fired) { + s_ok_down_since = now; + s_ok_armed = true; + } + if (s_ok_armed && !s_ok_long_fired && (now - s_ok_down_since) >= OK_LONG_MS) { + s_ok_long_fired = true; + s_ok_armed = false; + toggle_view(); + } + } else { + if (s_ok_armed && !s_ok_long_fired) { + s_ok_armed = false; + do_enter(); + } + s_ok_long_fired = false; } +} - if (is_up && !s_btn_up_last && s_entry_count > 0) { - s_selected = (s_selected == 0) ? s_entry_count - 1 : s_selected - 1; - update_selection(); +static void files_input(const input_event_t *ev, void *ctx) { + (void)ctx; + const bool press = (ev->action == INPUT_ACTION_PRESS); + const bool nav = press || (ev->action == INPUT_ACTION_REPEAT); + + if (s_in_viewer) { + // _bounded clamps the scroll to the content — plain lv_obj_scroll_by() ignores + // the content bounds (and SCROLL_ELASTIC/MOMENTUM), which let it scroll forever. + switch (ev->button) { + case INPUT_BTN_DOWN: + if (nav && s_vbody) + lv_obj_scroll_by_bounded(s_vbody, 0, -SCROLL_STEP, LV_ANIM_ON); + break; + case INPUT_BTN_UP: + if (nav && s_vbody) + lv_obj_scroll_by_bounded(s_vbody, 0, SCROLL_STEP, LV_ANIM_ON); + break; + case INPUT_BTN_BACK: + case INPUT_BTN_LEFT: + if (press) + do_back(); + break; + default: + break; + } + return; + } + + if (s_view == VIEW_LIST) { + switch (ev->button) { + case INPUT_BTN_DOWN: + if (nav) + move_list(+1); + break; + case INPUT_BTN_UP: + if (nav) + move_list(-1); + break; + case INPUT_BTN_RIGHT: + if (press) + do_enter(); + break; + case INPUT_BTN_BACK: + case INPUT_BTN_LEFT: + if (press) + do_back(); + break; + default: + break; + } + } else { + switch (ev->button) { + case INPUT_BTN_DOWN: + if (nav) + move_grid(0, +1); + break; + case INPUT_BTN_UP: + if (nav) + move_grid(0, -1); + break; + case INPUT_BTN_RIGHT: + if (nav) + move_grid(+1, 0); + break; + case INPUT_BTN_LEFT: + if (nav) + move_grid(-1, 0); + break; + case INPUT_BTN_BACK: + if (press) + do_back(); + break; + default: + break; + } } +} - if (is_right && !s_btn_right_last) - navigate_into(); +void ui_files_open(void) { + ESP_LOGI(TAG, "files explorer"); + files_free(); + if (!files_alloc()) { + ESP_LOGE(TAG, "files: out of memory"); + files_free(); + } - if (is_left && !s_btn_left_last) - navigate_back(); + s_in_viewer = false; + s_ok_long_fired = false; + s_ok_armed = false; - if (is_back && !s_btn_back_last) { - ui_switch_screen(SCREEN_MENU); + if (s_resume) { + s_resume = false; + scan_dir(); + if (s_sel >= s_count) + s_sel = s_count > 0 ? s_count - 1 : 0; + build_screen(); return; } - s_btn_up_last = is_up; - s_btn_down_last = is_down; - s_btn_left_last = is_left; - s_btn_right_last = is_right; - s_btn_back_last = is_back; -} \ No newline at end of file + s_depth = 0; + s_cwd[0] = '\0'; + s_sel = 0; + s_top = 0; + scan_dir(); + build_screen(); +} diff --git a/firmware_p4/components/Applications/ui/screens/files/include/files_ui.h b/firmware_p4/components/Applications/ui/screens/files/include/files_ui.h index 9b47a3dcc..f8b24b4c5 100644 --- a/firmware_p4/components/Applications/ui/screens/files/include/files_ui.h +++ b/firmware_p4/components/Applications/ui/screens/files/include/files_ui.h @@ -13,18 +13,23 @@ // You should have received a copy of the GNU General Public License // along with TentacleOS. If not, see . -#ifndef FILES_UI_H -#define FILES_UI_H +#ifndef UI_FILES_H +#define UI_FILES_H #ifdef __cplusplus extern "C" { #endif -/** @brief Open the file browser screen. */ +/** + * @brief Open the file browser screen (mock). + * + * Two-level navigation (folders -> files) from a canned table. No filesystem + * is touched. + */ void ui_files_open(void); #ifdef __cplusplus } #endif -#endif // FILES_UI_H +#endif // UI_FILES_H diff --git a/firmware_p4/components/Applications/ui/screens/games/breakout_ui.c b/firmware_p4/components/Applications/ui/screens/games/breakout_ui.c new file mode 100644 index 000000000..6d239ab9c --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/games/breakout_ui.c @@ -0,0 +1,359 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#include "breakout_ui.h" + +#include + +#include "esp_random.h" +#include "lvgl.h" +#include "nvs.h" + +#include "buttons_gpio.h" +#include "game_fx.h" +#include "st7789.h" +#include "ui_manager.h" +#include "ui_theme.h" + +#define TICK_MS 33 +#define TOPBAR 26 +#define BRICK_COLS 7 +#define BRICK_ROWS 4 +#define BRICK_N (BRICK_COLS * BRICK_ROWS) +#define BRICK_H 14 +#define MARGIN 6 +#define BGAP 4 +#define PADDLE_W 48 +#define PADDLE_H 9 +#define PADDLE_SPEED 6 +#define BALL_SZ 9 +#define BALL_SPEED 3.4f +#define PADDLE_MAXVX 4.2f +#define START_LIVES 3 +#define SCORE_PER_BRICK 10 + +#define COL_BG 0x0A0014 +#define COL_PADDLE 0xE040FB +#define COL_BALL 0xFFFFFF + +enum { ST_READY, ST_PLAY, ST_DEAD }; + +static const uint32_t ROW_COLORS[BRICK_ROWS] = {0xE040FB, 0xBA3FD0, 0x9C27B0, 0x7B1FA2}; + +static lv_obj_t *s_screen = NULL; +static lv_obj_t *s_paddle = NULL; +static lv_obj_t *s_ball = NULL; +static lv_obj_t *s_brick[BRICK_N]; +static bool s_brick_on[BRICK_N]; +static lv_obj_t *s_score_lbl = NULL; +static lv_obj_t *s_msg_panel = NULL, *s_msg_lbl = NULL; +static lv_timer_t *s_timer = NULL; + +static int s_state = ST_READY; +static int s_w, s_h; +static float s_px; +static float s_bx, s_by, s_bvx, s_bvy; +static int s_brick_x[BRICK_N], s_brick_y[BRICK_N], s_brick_w; +static int s_alive; +static int s_score, s_lives; +static uint32_t s_best; + +static bool s_ok_last, s_back_last; + +static uint32_t load_best(void) { + nvs_handle_t h; + uint32_t v = 0; + if (nvs_open("breakout", NVS_READONLY, &h) == ESP_OK) { + nvs_get_u32(h, "best", &v); + nvs_close(h); + } + return v; +} +static void save_best(uint32_t v) { + nvs_handle_t h; + if (nvs_open("breakout", NVS_READWRITE, &h) == ESP_OK) { + nvs_set_u32(h, "best", v); + nvs_commit(h); + nvs_close(h); + } +} + +static void set_score_text(void) { + lv_label_set_text_fmt(s_score_lbl, "Score %d Lives %d", s_score, s_lives); +} + +static int paddle_y(void) { + return s_h - 22; +} + +static void build_bricks(void) { + s_brick_w = (s_w - 2 * MARGIN - (BRICK_COLS - 1) * BGAP) / BRICK_COLS; + for (int r = 0; r < BRICK_ROWS; r++) { + for (int c = 0; c < BRICK_COLS; c++) { + int i = r * BRICK_COLS + c; + s_brick_x[i] = MARGIN + c * (s_brick_w + BGAP); + s_brick_y[i] = TOPBAR + 8 + r * (BRICK_H + BGAP); + s_brick_on[i] = true; + lv_obj_set_pos(s_brick[i], s_brick_x[i], s_brick_y[i]); + lv_obj_set_size(s_brick[i], s_brick_w, BRICK_H); + lv_obj_set_style_bg_color(s_brick[i], lv_color_hex(ROW_COLORS[r]), 0); + lv_obj_remove_flag(s_brick[i], LV_OBJ_FLAG_HIDDEN); + } + } + s_alive = BRICK_N; +} + +static void park_ball_on_paddle(void) { + s_bx = s_px + PADDLE_W / 2.0f - BALL_SZ / 2.0f; + s_by = paddle_y() - BALL_SZ - 1; + s_bvx = 0; + s_bvy = 0; + lv_obj_set_pos(s_ball, (int)s_bx, (int)s_by); +} + +static void reset_game(void) { + s_state = ST_READY; + s_score = 0; + s_lives = START_LIVES; + s_px = s_w / 2.0f - PADDLE_W / 2.0f; + lv_obj_set_pos(s_paddle, (int)s_px, paddle_y()); + build_bricks(); + park_ball_on_paddle(); + set_score_text(); + lv_obj_add_flag(s_msg_panel, LV_OBJ_FLAG_HIDDEN); +} + +static void launch_ball(void) { + s_state = ST_PLAY; + s_bvy = -BALL_SPEED; + s_bvx = ((esp_random() & 1) ? 1.0f : -1.0f) * (BALL_SPEED * 0.5f); + game_fx(GFX_START); +} + +static void die(void) { + s_state = ST_DEAD; + game_fx(GFX_CRASH); + if ((uint32_t)s_score > s_best) { + s_best = (uint32_t)s_score; + save_best(s_best); + } + lv_label_set_text_fmt(s_msg_lbl, + "GAME OVER\n\nScore %d\nBest %u\n\nOK = retry\nBACK = exit", + s_score, + (unsigned)s_best); + lv_obj_remove_flag(s_msg_panel, LV_OBJ_FLAG_HIDDEN); + lv_obj_move_foreground(s_msg_panel); +} + +static bool hit_bricks(void) { + int bl = (int)s_bx, br = bl + BALL_SZ, bt = (int)s_by, bb = bt + BALL_SZ; + for (int i = 0; i < BRICK_N; i++) { + if (!s_brick_on[i]) + continue; + int xl = s_brick_x[i], xr = xl + s_brick_w, yt = s_brick_y[i], yb = yt + BRICK_H; + if (br > xl && bl < xr && bb > yt && bt < yb) { + s_brick_on[i] = false; + s_alive--; + lv_obj_add_flag(s_brick[i], LV_OBJ_FLAG_HIDDEN); + + int pen_x = (s_bvx > 0) ? (br - xl) : (xr - bl); + int pen_y = (s_bvy > 0) ? (bb - yt) : (yb - bt); + if (pen_x < pen_y) + s_bvx = -s_bvx; + else + s_bvy = -s_bvy; + s_score += SCORE_PER_BRICK; + set_score_text(); + game_fx(GFX_SCORE); + return true; + } + } + return false; +} + +static void step_ball(void) { + s_bx += s_bvx; + s_by += s_bvy; + + if (s_bx < 0) { + s_bx = 0; + s_bvx = -s_bvx; + game_fx(GFX_BOUNCE); + } + if (s_bx + BALL_SZ > s_w) { + s_bx = s_w - BALL_SZ; + s_bvx = -s_bvx; + game_fx(GFX_BOUNCE); + } + if (s_by < TOPBAR) { + s_by = TOPBAR; + s_bvy = -s_bvy; + game_fx(GFX_BOUNCE); + } + + int py = paddle_y(); + if (s_bvy > 0 && s_by + BALL_SZ >= py && s_by + BALL_SZ <= py + PADDLE_H + 4 && + s_bx + BALL_SZ > s_px && s_bx < s_px + PADDLE_W) { + s_by = py - BALL_SZ; + float hit = ((s_bx + BALL_SZ / 2.0f) - (s_px + PADDLE_W / 2.0f)) / (PADDLE_W / 2.0f); + if (hit < -1) + hit = -1; + if (hit > 1) + hit = 1; + s_bvx = hit * PADDLE_MAXVX; + s_bvy = -BALL_SPEED; + game_fx(GFX_BOUNCE); + } + + hit_bricks(); + + if (s_by > s_h) { + s_lives--; + set_score_text(); + if (s_lives <= 0) { + die(); + return; + } + s_state = ST_READY; + park_ball_on_paddle(); + return; + } + + if (s_alive <= 0) { + build_bricks(); + s_state = ST_READY; + park_ball_on_paddle(); + return; + } + + lv_obj_set_pos(s_ball, (int)s_bx, (int)s_by); +} + +static void tick_cb(lv_timer_t *t) { + if (lv_screen_active() != s_screen) { + lv_timer_delete(t); + s_timer = NULL; + return; + } + + bool left = ui_btn_left(), right = ui_btn_right(); + bool ok = ok_button_is_down(), back = back_button_is_down(); + + if (!ui_input_is_locked()) { + if (back && !s_back_last) { + s_back_last = back; + ui_switch_screen(SCREEN_GAMES_MENU); + return; + } + + if (left) + s_px -= PADDLE_SPEED; + if (right) + s_px += PADDLE_SPEED; + if (s_px < 0) + s_px = 0; + if (s_px + PADDLE_W > s_w) + s_px = s_w - PADDLE_W; + lv_obj_set_x(s_paddle, (int)s_px); + + if (ok && !s_ok_last) { + if (s_state == ST_READY) + launch_ball(); + else if (s_state == ST_DEAD) + reset_game(); + } + } + + if (s_state == ST_READY) { + park_ball_on_paddle(); + } else if (s_state == ST_PLAY) { + step_ball(); + } + + s_ok_last = ok; + s_back_last = back; +} + +static lv_obj_t *make_rect(uint32_t color) { + lv_obj_t *o = lv_obj_create(s_screen); + lv_obj_remove_flag(o, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_remove_flag(o, LV_OBJ_FLAG_CLICKABLE); + lv_obj_set_style_radius(o, 3, 0); + lv_obj_set_style_border_width(o, 0, 0); + lv_obj_set_style_bg_opa(o, LV_OPA_COVER, 0); + lv_obj_set_style_bg_color(o, lv_color_hex(color), 0); + lv_obj_set_style_pad_all(o, 0, 0); + return o; +} + +void ui_breakout_open(void) { + if (s_screen != NULL) { + lv_obj_del(s_screen); + s_screen = NULL; + } + s_ok_last = s_back_last = false; + s_best = load_best(); + + s_screen = lv_obj_create(NULL); + lv_obj_set_style_bg_color(s_screen, lv_color_hex(COL_BG), 0); + lv_obj_set_style_bg_opa(s_screen, LV_OPA_COVER, 0); + lv_obj_remove_flag(s_screen, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_pad_all(s_screen, 0, 0); + lv_obj_set_style_border_width(s_screen, 0, 0); + + s_w = LCD_H_RES; + s_h = LCD_V_RES; + + for (int i = 0; i < BRICK_N; i++) { + s_brick[i] = make_rect(ROW_COLORS[0]); + lv_obj_add_flag(s_brick[i], LV_OBJ_FLAG_HIDDEN); + } + + s_paddle = make_rect(COL_PADDLE); + lv_obj_set_size(s_paddle, PADDLE_W, PADDLE_H); + lv_obj_set_style_radius(s_paddle, 4, 0); + + s_ball = make_rect(COL_BALL); + lv_obj_set_size(s_ball, BALL_SZ, BALL_SZ); + lv_obj_set_style_radius(s_ball, LV_RADIUS_CIRCLE, 0); + + s_score_lbl = lv_label_create(s_screen); + lv_obj_set_style_text_color(s_score_lbl, lv_color_hex(0xFFFFFF), 0); + lv_obj_set_style_text_font(s_score_lbl, &lv_font_montserrat_14, 0); + lv_obj_align(s_score_lbl, LV_ALIGN_TOP_MID, 0, 5); + + s_msg_panel = lv_obj_create(s_screen); + lv_obj_remove_flag(s_msg_panel, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(s_msg_panel, s_w - 60, LV_SIZE_CONTENT); + lv_obj_align(s_msg_panel, LV_ALIGN_CENTER, 0, 0); + lv_obj_set_style_radius(s_msg_panel, 14, 0); + lv_obj_set_style_bg_color(s_msg_panel, lv_color_hex(0x1A0426), 0); + lv_obj_set_style_bg_opa(s_msg_panel, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(s_msg_panel, 2, 0); + lv_obj_set_style_border_color(s_msg_panel, ui_theme_get_accent(), 0); + lv_obj_set_style_pad_all(s_msg_panel, 14, 0); + s_msg_lbl = lv_label_create(s_msg_panel); + lv_obj_set_style_text_color(s_msg_lbl, lv_color_hex(0xFFFFFF), 0); + lv_obj_set_style_text_font(s_msg_lbl, &lv_font_montserrat_14, 0); + lv_obj_set_style_text_align(s_msg_lbl, LV_TEXT_ALIGN_CENTER, 0); + lv_obj_center(s_msg_lbl); + + reset_game(); + + if (s_timer == NULL) + s_timer = lv_timer_create(tick_cb, TICK_MS, NULL); + + ui_screen_load_owned(&s_screen, s_screen); +} diff --git a/firmware_p4/components/Applications/ui/screens/games/flappy_ui.c b/firmware_p4/components/Applications/ui/screens/games/flappy_ui.c new file mode 100644 index 000000000..8adb89ce3 --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/games/flappy_ui.c @@ -0,0 +1,368 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#include "flappy_ui.h" + +#include + +#include "esp_random.h" +#include "lvgl.h" +#include "nvs.h" + +#include "assets_manager.h" +#include "buttons_gpio.h" +#include "game_fx.h" +#include "st7789.h" +#include "ui_manager.h" +#include "ui_theme.h" + +#define TICK_MS 33 +#define PIPE_COUNT 3 +#define PIPE_W 38 +#define CAP_H 12 +#define CAP_OVER 5 +#define GAP 100 +#define BIRD_X 60 +#define BIRD_W 34 +#define BIRD_H 49 +#define HB_MX 7 +#define HB_MY 11 +#define HB_W (BIRD_W - 2 * HB_MX) +#define HB_H (BIRD_H - 2 * HB_MY) +#define GROUND_H 20 +#define GRAVITY 0.9f +#define FLAP_V (-7.7f) +#define VEL_MAX 11.0f +#define VEL_MIN (-10.0f) +#define SPEED 2.6f +#define GAP_MARGIN 28 +#define STAR_COUNT 7 + +#define COL_BG 0x0A0014 +#define COL_PIPE 0x9C27B0 +#define COL_CAP 0xBA3FD0 +#define COL_GROUND 0x3A0A4A +#define COL_STAR 0x46286A + +enum { ST_READY, ST_PLAY, ST_DEAD }; + +static lv_obj_t *s_screen = NULL; +static lv_obj_t *s_bird = NULL; +static lv_obj_t *s_pipe_top[PIPE_COUNT]; +static lv_obj_t *s_pipe_bot[PIPE_COUNT]; +static lv_obj_t *s_cap_top[PIPE_COUNT]; +static lv_obj_t *s_cap_bot[PIPE_COUNT]; +static lv_obj_t *s_star[STAR_COUNT]; +static lv_obj_t *s_ground = NULL; +static lv_obj_t *s_score_lbl = NULL; +static lv_obj_t *s_msg_panel = NULL; +static lv_obj_t *s_msg_lbl = NULL; +static lv_timer_t *s_timer = NULL; + +static int s_state = ST_READY; +static int s_w = 0, s_h = 0, s_playh = 0; +static float s_bird_y = 0, s_vel = 0; +static float s_pipe_x[PIPE_COUNT]; +static int s_gap_y[PIPE_COUNT]; +static bool s_scored[PIPE_COUNT]; +static float s_star_x[STAR_COUNT]; +static int s_star_y[STAR_COUNT]; +static float s_spacing = 0; +static int s_score = 0; +static uint32_t s_best = 0; + +static bool s_ok_last, s_up_last, s_back_last; + +static uint32_t load_best(void) { + nvs_handle_t h; + uint32_t v = 0; + if (nvs_open("flappy", NVS_READONLY, &h) == ESP_OK) { + nvs_get_u32(h, "best", &v); + nvs_close(h); + } + return v; +} +static void save_best(uint32_t v) { + nvs_handle_t h; + if (nvs_open("flappy", NVS_READWRITE, &h) == ESP_OK) { + nvs_set_u32(h, "best", v); + nvs_commit(h); + nvs_close(h); + } +} + +static int rand_gap_y(void) { + int lo = GAP / 2 + GAP_MARGIN; + int hi = s_playh - GAP / 2 - GAP_MARGIN; + if (hi <= lo) + return s_playh / 2; + return lo + (int)(esp_random() % (uint32_t)(hi - lo)); +} + +static void layout_pipe(int i) { + int x = (int)s_pipe_x[i]; + int gap_top = s_gap_y[i] - GAP / 2; + int gap_bot = s_gap_y[i] + GAP / 2; + lv_obj_set_pos(s_pipe_top[i], x, 0); + lv_obj_set_size(s_pipe_top[i], PIPE_W, gap_top > 0 ? gap_top : 1); + lv_obj_set_pos(s_pipe_bot[i], x, gap_bot); + lv_obj_set_size(s_pipe_bot[i], PIPE_W, (s_playh - gap_bot) > 0 ? (s_playh - gap_bot) : 1); + lv_obj_set_pos(s_cap_top[i], x - CAP_OVER, gap_top - CAP_H); + lv_obj_set_pos(s_cap_bot[i], x - CAP_OVER, gap_bot); +} + +static void set_score_text(void) { + lv_label_set_text_fmt(s_score_lbl, "%d", s_score); +} + +static void reset_game(void) { + s_state = ST_READY; + s_score = 0; + s_vel = 0; + s_bird_y = s_playh / 2.0f - BIRD_H / 2.0f; + for (int i = 0; i < PIPE_COUNT; i++) { + s_pipe_x[i] = s_w + 40 + i * s_spacing; + s_gap_y[i] = rand_gap_y(); + s_scored[i] = false; + layout_pipe(i); + } + lv_obj_set_y(s_bird, (int)s_bird_y); + lv_image_set_rotation(s_bird, 0); + set_score_text(); + lv_obj_add_flag(s_msg_panel, LV_OBJ_FLAG_HIDDEN); +} + +static void die(void) { + s_state = ST_DEAD; + game_fx(GFX_CRASH); + if ((uint32_t)s_score > s_best) { + s_best = (uint32_t)s_score; + save_best(s_best); + } + lv_label_set_text_fmt(s_msg_lbl, + "GAME OVER\n\nScore %d\nBest %u\n\nOK = retry\nBACK = exit", + s_score, + (unsigned)s_best); + lv_obj_remove_flag(s_msg_panel, LV_OBJ_FLAG_HIDDEN); + lv_obj_move_foreground(s_msg_panel); +} + +static void update_bird_tilt(void) { + float deg = s_vel * 4.0f; + if (deg < -28) + deg = -28; + if (deg > 72) + deg = 72; + lv_image_set_rotation(s_bird, (int16_t)(deg * 10)); +} + +static void step_physics(void) { + s_vel += GRAVITY; + if (s_vel > VEL_MAX) + s_vel = VEL_MAX; + if (s_vel < VEL_MIN) + s_vel = VEL_MIN; + s_bird_y += s_vel; + if (s_bird_y < 0) { + s_bird_y = 0; + s_vel = 0; + } + lv_obj_set_y(s_bird, (int)s_bird_y); + update_bird_tilt(); + + for (int i = 0; i < STAR_COUNT; i++) { + s_star_x[i] -= SPEED * 0.35f; + if (s_star_x[i] < -4) { + s_star_x[i] += s_w + 8; + s_star_y[i] = 10 + (int)(esp_random() % (uint32_t)(s_playh - 20)); + lv_obj_set_y(s_star[i], s_star_y[i]); + } + lv_obj_set_x(s_star[i], (int)s_star_x[i]); + } + + for (int i = 0; i < PIPE_COUNT; i++) { + s_pipe_x[i] -= SPEED; + if (s_pipe_x[i] + PIPE_W < 0) { + s_pipe_x[i] += s_spacing * PIPE_COUNT; + s_gap_y[i] = rand_gap_y(); + s_scored[i] = false; + } + layout_pipe(i); + if (!s_scored[i] && s_pipe_x[i] + PIPE_W < BIRD_X) { + s_scored[i] = true; + s_score++; + set_score_text(); + game_fx(GFX_SCORE); + } + } + + int hb_l = BIRD_X + HB_MX, hb_r = hb_l + HB_W; + int hb_t = (int)s_bird_y + HB_MY, hb_b = hb_t + HB_H; + if (hb_b >= s_playh) { + s_bird_y = s_playh - HB_MY - HB_H; + lv_obj_set_y(s_bird, (int)s_bird_y); + die(); + return; + } + for (int i = 0; i < PIPE_COUNT; i++) { + int px = (int)s_pipe_x[i]; + if (hb_r > px && hb_l < px + PIPE_W) { + int gap_top = s_gap_y[i] - GAP / 2; + int gap_bot = s_gap_y[i] + GAP / 2; + if (hb_t < gap_top || hb_b > gap_bot) { + die(); + return; + } + } + } +} + +static void tick_cb(lv_timer_t *t) { + if (lv_screen_active() != s_screen) { + lv_timer_delete(t); + s_timer = NULL; + return; + } + bool ok = ok_button_is_down(); + bool up = ui_btn_up(); + bool back = back_button_is_down(); + + if (!ui_input_is_locked()) { + if (back && !s_back_last) { + s_back_last = back; + ui_switch_screen(SCREEN_GAMES_MENU); + return; + } + bool flap_edge = (ok && !s_ok_last) || (up && !s_up_last); + if (flap_edge) { + if (s_state == ST_READY) { + s_state = ST_PLAY; + set_score_text(); + s_vel = FLAP_V; + game_fx(GFX_START); + } else if (s_state == ST_PLAY) { + s_vel = FLAP_V; + game_fx(GFX_FLAP); + } else { + reset_game(); + } + } + } + + if (s_state == ST_PLAY) + step_physics(); + + s_ok_last = ok; + s_up_last = up; + s_back_last = back; +} + +static lv_obj_t *make_rect(uint32_t color, uint32_t border) { + lv_obj_t *o = lv_obj_create(s_screen); + lv_obj_remove_flag(o, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_remove_flag(o, LV_OBJ_FLAG_CLICKABLE); + lv_obj_set_style_radius(o, 3, 0); + lv_obj_set_style_bg_opa(o, LV_OPA_COVER, 0); + lv_obj_set_style_bg_color(o, lv_color_hex(color), 0); + lv_obj_set_style_border_width(o, border ? 2 : 0, 0); + if (border) + lv_obj_set_style_border_color(o, lv_color_hex(border), 0); + lv_obj_set_style_pad_all(o, 0, 0); + return o; +} + +void ui_flappy_open(void) { + if (s_screen != NULL) { + lv_obj_del(s_screen); + s_screen = NULL; + } + s_ok_last = s_up_last = s_back_last = false; + s_best = load_best(); + + s_screen = lv_obj_create(NULL); + lv_obj_set_style_bg_color(s_screen, lv_color_hex(COL_BG), 0); + lv_obj_set_style_bg_opa(s_screen, LV_OPA_COVER, 0); + lv_obj_remove_flag(s_screen, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_pad_all(s_screen, 0, 0); + lv_obj_set_style_border_width(s_screen, 0, 0); + + s_w = LCD_H_RES; + s_h = LCD_V_RES; + s_playh = s_h - GROUND_H; + s_spacing = (s_w + PIPE_W) / 2.0f; + + for (int i = 0; i < STAR_COUNT; i++) { + s_star[i] = lv_obj_create(s_screen); + lv_obj_remove_flag(s_star[i], LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(s_star[i], 3, 3); + lv_obj_set_style_radius(s_star[i], LV_RADIUS_CIRCLE, 0); + lv_obj_set_style_border_width(s_star[i], 0, 0); + lv_obj_set_style_bg_opa(s_star[i], LV_OPA_COVER, 0); + lv_obj_set_style_bg_color(s_star[i], lv_color_hex(COL_STAR), 0); + s_star_x[i] = (float)(esp_random() % (uint32_t)s_w); + s_star_y[i] = 10 + (int)(esp_random() % (uint32_t)(s_playh - 20)); + lv_obj_set_pos(s_star[i], (int)s_star_x[i], s_star_y[i]); + } + + for (int i = 0; i < PIPE_COUNT; i++) { + s_pipe_top[i] = make_rect(COL_PIPE, 0xCC00FF); + s_pipe_bot[i] = make_rect(COL_PIPE, 0xCC00FF); + s_cap_top[i] = make_rect(COL_CAP, 0xCC00FF); + s_cap_bot[i] = make_rect(COL_CAP, 0xCC00FF); + lv_obj_set_size(s_cap_top[i], PIPE_W + 2 * CAP_OVER, CAP_H); + lv_obj_set_size(s_cap_bot[i], PIPE_W + 2 * CAP_OVER, CAP_H); + } + + s_ground = make_rect(COL_GROUND, 0xCC00FF); + lv_obj_set_size(s_ground, s_w, GROUND_H); + lv_obj_set_pos(s_ground, 0, s_playh); + + s_bird = lv_image_create(s_screen); + lv_image_dsc_t *dsc = assets_get("/assets/img/octobit_bird.bin"); + if (dsc != NULL) + lv_image_set_src(s_bird, dsc); + lv_image_set_pivot(s_bird, BIRD_W / 2, BIRD_H / 2); + lv_obj_set_x(s_bird, BIRD_X); + + s_score_lbl = lv_label_create(s_screen); + lv_obj_set_style_text_color(s_score_lbl, lv_color_hex(0xFFFFFF), 0); + lv_obj_set_style_text_font(s_score_lbl, &lv_font_montserrat_16, 0); + lv_obj_align(s_score_lbl, LV_ALIGN_TOP_MID, 0, 8); + + s_msg_panel = lv_obj_create(s_screen); + lv_obj_remove_flag(s_msg_panel, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(s_msg_panel, s_w - 60, LV_SIZE_CONTENT); + lv_obj_align(s_msg_panel, LV_ALIGN_CENTER, 0, 0); + lv_obj_set_style_radius(s_msg_panel, 14, 0); + lv_obj_set_style_bg_color(s_msg_panel, lv_color_hex(0x1A0426), 0); + lv_obj_set_style_bg_opa(s_msg_panel, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(s_msg_panel, 2, 0); + lv_obj_set_style_border_color(s_msg_panel, ui_theme_get_accent(), 0); + lv_obj_set_style_pad_all(s_msg_panel, 14, 0); + + s_msg_lbl = lv_label_create(s_msg_panel); + lv_obj_set_style_text_color(s_msg_lbl, lv_color_hex(0xFFFFFF), 0); + lv_obj_set_style_text_font(s_msg_lbl, &lv_font_montserrat_14, 0); + lv_obj_set_style_text_align(s_msg_lbl, LV_TEXT_ALIGN_CENTER, 0); + lv_obj_center(s_msg_lbl); + + reset_game(); + lv_label_set_text(s_score_lbl, "TAP OK"); + + if (s_timer == NULL) + s_timer = lv_timer_create(tick_cb, TICK_MS, NULL); + + ui_screen_load_owned(&s_screen, s_screen); +} diff --git a/firmware_p4/components/Applications/ui/screens/games/game_fx.c b/firmware_p4/components/Applications/ui/screens/games/game_fx.c new file mode 100644 index 000000000..fc16379a3 --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/games/game_fx.c @@ -0,0 +1,88 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#include "game_fx.h" + +#include + +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include "sys_prio.h" + +#include "audio_i2s.h" +#include "drv2605l.h" + +#define FX_AMP 0.42f +#define SND_TASK_STACK 8192 +#define SND_TASK_PRIORITY SYS_PRIO_SERVICE_LO + +static const audio_note_t SND_FLAP[] = {{780, 28}}; +static const audio_note_t SND_SCORE[] = {{1568, 45}, {2093, 70}}; +static const audio_note_t SND_EAT[] = {{1318, 35}, {1760, 45}}; +static const audio_note_t SND_BOUNCE[] = {{1046, 24}}; +static const audio_note_t SND_CRASH[] = {{330, 110}, {196, 180}}; +static const audio_note_t SND_START[] = {{1046, 55}, {1318, 55}, {1568, 85}}; + +typedef struct { + const audio_note_t *notes; + int count; + uint8_t effect; +} cue_t; + +static cue_t cue_for(game_fx_t k) { + switch (k) { + case GFX_FLAP: + return (cue_t){SND_FLAP, 1, 7}; + case GFX_SCORE: + return (cue_t){SND_SCORE, 2, 10}; + case GFX_EAT: + return (cue_t){SND_EAT, 2, 1}; + case GFX_BOUNCE: + return (cue_t){SND_BOUNCE, 1, 5}; + case GFX_CRASH: + return (cue_t){SND_CRASH, 2, 16}; + case GFX_START: + return (cue_t){SND_START, 3, 4}; + default: + return (cue_t){SND_FLAP, 1, 7}; + } +} + +static volatile bool s_snd_busy = false; +static const audio_note_t *s_snd_notes; +static int s_snd_count; + +static void snd_task(void *arg) { + (void)arg; + audio_i2s_play_song(s_snd_notes, s_snd_count, FX_AMP); + s_snd_busy = false; + vTaskDelete(NULL); +} + +void game_fx(game_fx_t kind) { + cue_t c = cue_for(kind); + + drv2605l_play_effect(c.effect); + + if (s_snd_busy) + return; + s_snd_busy = true; + s_snd_notes = c.notes; + s_snd_count = c.count; + if (xTaskCreatePinnedToCore( + snd_task, "game_snd", SND_TASK_STACK, NULL, SND_TASK_PRIORITY, NULL, SYS_CORE_UI) != + pdPASS) + s_snd_busy = false; +} diff --git a/firmware_p4/components/Applications/ui/screens/games/games_menu_ui.c b/firmware_p4/components/Applications/ui/screens/games/games_menu_ui.c new file mode 100644 index 000000000..cf243bc62 --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/games/games_menu_ui.c @@ -0,0 +1,218 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#include "games_menu_ui.h" + +#include "assets_manager.h" +#include "notify_ui.h" +#include "page_dots_ui.h" +#include "st7789.h" +#include "ui_chrome.h" +#include "ui_feedback.h" +#include "ui_manager.h" +#include "ui_theme.h" + +#define TARGET_STUB (-1) + +#define TITLE_ICON "/assets/icons/sports_esports.bin" +#define BASE_FRAME "/assets/frames/base_frame_0.bin" +#define CARD_Y_BIAS (-18) +#define CONTRAST_DARK 0x0A0220 + +typedef struct { + const char *name; + const char *icon; + uint32_t color; + bool dark_glyph; + int target; +} game_t; + +static const game_t GAMES[] = { + {"Octo Pet", "/assets/icons/game_pet.bin", 0x834EC6, false, SCREEN_GAME_OCTOPET}, + {"Octo Flap", "/assets/icons/game_flap.bin", 0x00E5D0, true, SCREEN_GAME_FLAPPY}, + {"Snake", "/assets/icons/game_snake.bin", 0x00E676, true, SCREEN_GAME_SNAKE}, + {"Breakout", "/assets/icons/game_brk.bin", 0xFFB020, true, SCREEN_GAME_BREAKOUT}, + {"Motion / Level", "/assets/icons/sensors.bin", 0x22D3EE, true, SCREEN_MOTION}, + {"Coming Soon", "/assets/icons/timer.bin", 0x5E12A0, false, TARGET_STUB}, +}; +#define GAME_COUNT ((int)(sizeof(GAMES) / sizeof(GAMES[0]))) + +static const int32_t CAR_PX[] = {-94, -50, 0, 50, 94}; +static const int32_t CAR_PY[] = {-14, -6, 0, -6, -14}; +static const int32_t CAR_SC[] = {117, 161, 234, 161, 117}; +static const int32_t GLYPH_SC[] = {170, 235, 341, 235, 170}; +static const int32_t CAR_OP[] = {LV_OPA_50, LV_OPA_80, LV_OPA_COVER, LV_OPA_80, LV_OPA_50}; +static const int32_t CAR_Z[] = {0, 1, 2, 1, 0}; +#define CAR_SLOTS 5 +#define CAR_CENTER 2 + +static lv_obj_t *s_screen = NULL; +static lv_obj_t *s_base[GAME_COUNT]; +static lv_obj_t *s_glyph[GAME_COUNT]; +static lv_obj_t *s_label = NULL; +static page_dots_t s_dots; +static lv_image_dsc_t *s_base_dsc = NULL; +static int s_sel = 0; + +static int32_t carousel_slot(int item_idx) { + int32_t n = GAME_COUNT; + int32_t d = (item_idx - s_sel + n) % n; + if (d > n / 2) + d -= n; + int32_t slot = CAR_CENTER + d; + return (slot >= 0 && slot < CAR_SLOTS) ? slot : -1; +} + +static void make_card(lv_obj_t *parent, int i) { + const game_t *g = &GAMES[i]; + + lv_obj_t *base = lv_image_create(parent); + if (s_base_dsc) + lv_image_set_src(base, s_base_dsc); + lv_image_set_antialias(base, false); + lv_obj_align(base, LV_ALIGN_CENTER, 0, CARD_Y_BIAS); + lv_obj_set_style_image_recolor(base, lv_color_hex(g->color), 0); + lv_obj_set_style_image_recolor_opa(base, LV_OPA_COVER, 0); + s_base[i] = base; + + lv_obj_t *glyph = lv_image_create(parent); + lv_image_dsc_t *gd = assets_get(g->icon); + if (gd) + lv_image_set_src(glyph, gd); + lv_image_set_antialias(glyph, false); + lv_obj_align(glyph, LV_ALIGN_CENTER, 0, CARD_Y_BIAS); + lv_obj_set_style_image_recolor( + glyph, g->dark_glyph ? lv_color_hex(CONTRAST_DARK) : lv_color_white(), 0); + lv_obj_set_style_image_recolor_opa(glyph, LV_OPA_COVER, 0); + s_glyph[i] = glyph; +} + +static void place_card(int i) { + if (s_base[i] == NULL || s_glyph[i] == NULL) + return; + int32_t slot = carousel_slot(i); + + if (slot < 0) { + lv_obj_add_flag(s_base[i], LV_OBJ_FLAG_HIDDEN); + lv_obj_add_flag(s_glyph[i], LV_OBJ_FLAG_HIDDEN); + return; + } + lv_obj_remove_flag(s_base[i], LV_OBJ_FLAG_HIDDEN); + lv_obj_remove_flag(s_glyph[i], LV_OBJ_FLAG_HIDDEN); + + int32_t x = CAR_PX[slot]; + int32_t y = CAR_PY[slot] + CARD_Y_BIAS; + + lv_obj_align(s_base[i], LV_ALIGN_CENTER, x, y); + lv_image_set_scale(s_base[i], CAR_SC[slot]); + lv_obj_set_style_opa(s_base[i], CAR_OP[slot], 0); + + lv_obj_align(s_glyph[i], LV_ALIGN_CENTER, x, y); + lv_image_set_scale(s_glyph[i], GLYPH_SC[slot]); + lv_obj_set_style_opa(s_glyph[i], CAR_OP[slot], 0); +} + +static void fix_z_order(void) { + for (int z = 0; z <= CAR_CENTER; z++) { + for (int i = 0; i < GAME_COUNT; i++) { + int32_t slot = carousel_slot(i); + if (slot >= 0 && CAR_Z[slot] == z) { + lv_obj_move_foreground(s_base[i]); + lv_obj_move_foreground(s_glyph[i]); + } + } + } +} + +static void update_view(void) { + lv_label_set_text_fmt(s_label, LV_SYMBOL_LEFT " %s " LV_SYMBOL_RIGHT, GAMES[s_sel].name); + page_dots_set(&s_dots, s_sel); + for (int i = 0; i < GAME_COUNT; i++) + place_card(i); + fix_z_order(); +} + +static void games_menu_input(const input_event_t *ev, void *ctx) { + (void)ctx; + const bool press = (ev->action == INPUT_ACTION_PRESS); + const bool nav = press || (ev->action == INPUT_ACTION_REPEAT); + switch (ev->button) { + case INPUT_BTN_BACK: + if (press) + ui_switch_screen(SCREEN_MENU); + break; + case INPUT_BTN_OK: + if (press) { + if (GAMES[s_sel].target == TARGET_STUB) + notify(NOTIFY_INFO, "More apps coming soon"); + else + ui_switch_screen(GAMES[s_sel].target); + } + break; + case INPUT_BTN_RIGHT: + case INPUT_BTN_DOWN: + if (nav) { + s_sel = (s_sel + 1) % GAME_COUNT; + ui_feedback(UI_FB_NAV); + update_view(); + } + break; + case INPUT_BTN_LEFT: + case INPUT_BTN_UP: + if (nav) { + s_sel = (s_sel == 0) ? GAME_COUNT - 1 : s_sel - 1; + ui_feedback(UI_FB_NAV); + update_view(); + } + break; + default: + break; + } +} + +void ui_games_menu_open(void) { + if (s_screen != NULL) { + lv_obj_del(s_screen); + s_screen = NULL; + } + s_sel = 0; + + if (s_base_dsc == NULL) + s_base_dsc = assets_get(BASE_FRAME); + + s_screen = lv_obj_create(NULL); + lv_obj_set_style_bg_color(s_screen, current_theme.screen_base, 0); + lv_obj_set_style_bg_opa(s_screen, LV_OPA_COVER, 0); + lv_obj_remove_flag(s_screen, LV_OBJ_FLAG_SCROLLABLE); + + ui_chrome_header(s_screen, "APPS", TITLE_ICON); + ui_chrome_footer(s_screen, LV_SYMBOL_LEFT LV_SYMBOL_RIGHT " Browse " LV_SYMBOL_OK " Play"); + + for (int i = 0; i < GAME_COUNT; i++) + make_card(s_screen, i); + + s_label = lv_label_create(s_screen); + lv_obj_set_style_text_font(s_label, &lv_font_montserrat_14, 0); + lv_obj_set_style_text_color(s_label, current_theme.border_accent, 0); + lv_obj_align(s_label, LV_ALIGN_CENTER, 0, 52); + + s_dots = page_dots_create(s_screen, GAME_COUNT, LV_ALIGN_BOTTOM_MID, 0, -26); + + update_view(); + + ui_input_set_screen_handler(games_menu_input, NULL); + + ui_screen_load_owned(&s_screen, s_screen); +} diff --git a/firmware_p4/components/Applications/ui/screens/games/imu_monitor_ui.c b/firmware_p4/components/Applications/ui/screens/games/imu_monitor_ui.c new file mode 100644 index 000000000..ecc20a9b9 --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/games/imu_monitor_ui.c @@ -0,0 +1,393 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#include "imu_monitor_ui.h" + +#include "lvgl.h" + +#include "ui_chrome.h" +#include "ui_feedback.h" +#include "ui_manager.h" +#include "ui_theme.h" + +#define SCOPE_TICK_MS 60 +#define PHASE_STEP 6 + +#define HDR_TITLE "MOTION / IMU" +#define HDR_ICON "/assets/icons/sensors.bin" +#define FOOTER_TXT "OK ZERO R RATE BACK" + +#define MX 8 +#define CONTENT_W (240 - 2 * MX) + +#define COL_DIM 0x8A8594 +#define COL_CYAN 0x37E0A8 +#define COL_WARN 0xFFC23D +#define COL_ACC2 0xB89AFF +#define COL_ZERO 0x3A3350 + +#define ACCEL_LBL_Y 46 +#define SCOPE_CARD_Y 68 +#define SCOPE_CARD_H 90 +#define SCOPE_PAD 6 + +#define WAVE_W (CONTENT_W - 2 * SCOPE_PAD - 2) +#define WAVE_H 58 +#define WAVE_N 24 +#define SCOPE_CENTER_Y 29 +#define Z_BASE_Y 10 +#define VAL_ROW_Y (WAVE_H + 4) + +#define AMP_X 4 +#define AMP_Y 15 +#define AMP_Z 3 +#define SPD_X 11 +#define SPD_Y 7 +#define SPD_Z 5 +#define STEP_X 48 +#define STEP_Y 62 +#define STEP_Z 37 + +#define GYRO_LBL_Y 165 +#define GYRO_CARD_Y 187 +#define GYRO_CARD_H 82 +#define GYRO_PAD 9 + +#define GY_CONTENT_W (CONTENT_W - 2 * GYRO_PAD - 2) +#define GY_LBL_W 18 +#define GY_VAL_W 46 +#define GY_GAP 6 +#define GY_TRACK_X (GY_LBL_W + GY_GAP) +#define GY_TRACK_W (GY_CONTENT_W - GY_LBL_W - GY_VAL_W - 2 * GY_GAP) +#define GY_VAL_X (GY_TRACK_X + GY_TRACK_W + GY_GAP) +#define GY_TRACK_H 8 +#define GY_FILL_H 6 +#define GY_ROW_STEP 22 +#define GY_ROW0_Y 2 + +#define RATE_COUNT 4 + +typedef struct { + const char *label; + int pct; + bool right; + uint32_t color; + const char *val; +} gyro_def_t; + +static const gyro_def_t GYRO_ROWS[] = { + {"gX", 12, true, 0, "+0.62"}, + {"gY", 24, false, COL_WARN, "-1.14"}, + {"gZ", 4, true, COL_ACC2, "+0.08"}, +}; +#define GYRO_COUNT ((int)(sizeof(GYRO_ROWS) / sizeof(GYRO_ROWS[0]))) + +static const int RATES[RATE_COUNT] = {104, 208, 416, 833}; + +static lv_obj_t *s_screen = NULL; +static lv_timer_t *s_scope_timer = NULL; + +static lv_obj_t *s_xline = NULL; +static lv_obj_t *s_yline = NULL; +static lv_obj_t *s_zline = NULL; +static lv_obj_t *s_rate_lbl = NULL; +static lv_obj_t *s_gyro_fill[GYRO_COUNT]; +static lv_obj_t *s_gyro_val[GYRO_COUNT]; + +static lv_point_precise_t s_xpts[WAVE_N]; +static lv_point_precise_t s_ypts[WAVE_N]; +static lv_point_precise_t s_zpts[WAVE_N]; + +static int s_phase = 0; +static int s_rate_idx = RATE_COUNT - 1; +static bool s_zeroed = false; + +static void fill_traces(void) { + for (int i = 0; i < WAVE_N; i++) { + int x = i * WAVE_W / (WAVE_N - 1); + int yx, yy, yz; + if (s_zeroed) { + yx = SCOPE_CENTER_Y; + yy = SCOPE_CENTER_Y; + yz = SCOPE_CENTER_Y; + } else { + yx = SCOPE_CENTER_Y - + (AMP_X * lv_trigo_sin((int16_t)((s_phase * SPD_X + i * STEP_X) % 360))) / 32767; + yy = SCOPE_CENTER_Y - + (AMP_Y * lv_trigo_sin((int16_t)((s_phase * SPD_Y + i * STEP_Y) % 360))) / 32767; + yz = Z_BASE_Y - + (AMP_Z * lv_trigo_sin((int16_t)((s_phase * SPD_Z + i * STEP_Z) % 360))) / 32767; + } + s_xpts[i].x = x; + s_xpts[i].y = yx; + s_ypts[i].x = x; + s_ypts[i].y = yy; + s_zpts[i].x = x; + s_zpts[i].y = yz; + } + if (s_xline) + lv_line_set_points(s_xline, s_xpts, WAVE_N); + if (s_yline) + lv_line_set_points(s_yline, s_ypts, WAVE_N); + if (s_zline) + lv_line_set_points(s_zline, s_zpts, WAVE_N); +} + +static void refresh_gyro(void) { + for (int i = 0; i < GYRO_COUNT; i++) { + int pct = s_zeroed ? 0 : GYRO_ROWS[i].pct; + int w = pct * GY_TRACK_W / 100; + int half = GY_TRACK_W / 2; + int x = GYRO_ROWS[i].right ? half : half - w; + if (s_gyro_fill[i]) { + lv_obj_set_width(s_gyro_fill[i], w); + lv_obj_align(s_gyro_fill[i], LV_ALIGN_LEFT_MID, x, 0); + } + if (s_gyro_val[i]) + lv_label_set_text(s_gyro_val[i], s_zeroed ? "+0.00" : GYRO_ROWS[i].val); + } +} + +static lv_obj_t *make_mono_label(lv_obj_t *parent, const char *text, uint32_t color, int x, int y) { + lv_obj_t *lbl = lv_label_create(parent); + lv_label_set_text(lbl, text); + lv_obj_set_style_text_font(lbl, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(lbl, lv_color_hex(color), 0); + lv_obj_align(lbl, LV_ALIGN_TOP_LEFT, x, y); + return lbl; +} + +static lv_obj_t *make_card(int y, int h) { + lv_obj_t *card = lv_obj_create(s_screen); + lv_obj_remove_flag(card, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_remove_flag(card, LV_OBJ_FLAG_CLICKABLE); + lv_obj_set_size(card, CONTENT_W, h); + lv_obj_align(card, LV_ALIGN_TOP_MID, 0, y); + lv_obj_set_style_radius(card, 10, 0); + lv_obj_set_style_bg_color(card, current_theme.bg_secondary, 0); + lv_obj_set_style_bg_opa(card, LV_OPA_COVER, 0); + lv_obj_set_style_border_color(card, current_theme.border_accent, 0); + lv_obj_set_style_border_width(card, 1, 0); + lv_obj_set_style_pad_all(card, 0, 0); + return card; +} + +static void build_scope_card(void) { + lv_obj_t *card = make_card(SCOPE_CARD_Y, SCOPE_CARD_H); + lv_obj_set_style_pad_all(card, SCOPE_PAD, 0); + + lv_obj_t *zero = lv_obj_create(card); + lv_obj_remove_flag(zero, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(zero, WAVE_W, 1); + lv_obj_align(zero, LV_ALIGN_TOP_LEFT, 0, SCOPE_CENTER_Y); + lv_obj_set_style_border_width(zero, 0, 0); + lv_obj_set_style_radius(zero, 0, 0); + lv_obj_set_style_bg_color(zero, lv_color_hex(COL_ZERO), 0); + lv_obj_set_style_bg_opa(zero, LV_OPA_COVER, 0); + + s_zline = lv_line_create(card); + lv_obj_align(s_zline, LV_ALIGN_TOP_LEFT, 0, 0); + lv_obj_set_style_line_width(s_zline, 2, 0); + lv_obj_set_style_line_color(s_zline, lv_color_hex(COL_WARN), 0); + lv_obj_set_style_line_rounded(s_zline, true, 0); + + s_yline = lv_line_create(card); + lv_obj_align(s_yline, LV_ALIGN_TOP_LEFT, 0, 0); + lv_obj_set_style_line_width(s_yline, 2, 0); + lv_obj_set_style_line_color(s_yline, lv_color_hex(COL_CYAN), 0); + lv_obj_set_style_line_rounded(s_yline, true, 0); + + s_xline = lv_line_create(card); + lv_obj_align(s_xline, LV_ALIGN_TOP_LEFT, 0, 0); + lv_obj_set_style_line_width(s_xline, 2, 0); + lv_obj_set_style_line_color(s_xline, current_theme.border_accent, 0); + lv_obj_set_style_line_rounded(s_xline, true, 0); + + fill_traces(); + + lv_obj_t *xval = make_mono_label(card, "X -0.01", COL_DIM, 0, VAL_ROW_Y); + lv_obj_set_style_text_color(xval, current_theme.border_accent, 0); + make_mono_label(card, "Y +0.05", COL_CYAN, 80, VAL_ROW_Y); + make_mono_label(card, "Z +1.00", COL_WARN, 158, VAL_ROW_Y); +} + +static void build_gyro_card(void) { + lv_obj_t *card = make_card(GYRO_CARD_Y, GYRO_CARD_H); + lv_obj_set_style_pad_all(card, GYRO_PAD, 0); + + for (int i = 0; i < GYRO_COUNT; i++) { + int row_y = GY_ROW0_Y + i * GY_ROW_STEP; + + lv_obj_t *lbl = lv_label_create(card); + lv_label_set_text(lbl, GYRO_ROWS[i].label); + lv_obj_set_style_text_font(lbl, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(lbl, lv_color_hex(COL_DIM), 0); + lv_obj_align(lbl, LV_ALIGN_TOP_LEFT, 0, row_y); + + lv_obj_t *track = lv_obj_create(card); + lv_obj_remove_flag(track, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(track, GY_TRACK_W, GY_TRACK_H); + lv_obj_align(track, LV_ALIGN_TOP_LEFT, GY_TRACK_X, row_y + 4); + lv_obj_set_style_radius(track, 4, 0); + lv_obj_set_style_pad_all(track, 0, 0); + lv_obj_set_style_bg_color(track, current_theme.bg_primary, 0); + lv_obj_set_style_bg_opa(track, LV_OPA_COVER, 0); + lv_obj_set_style_border_color(track, lv_color_hex(COL_DIM), 0); + lv_obj_set_style_border_opa(track, LV_OPA_40, 0); + lv_obj_set_style_border_width(track, 1, 0); + lv_obj_set_style_clip_corner(track, true, 0); + + lv_obj_t *tick = lv_obj_create(track); + lv_obj_remove_flag(tick, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(tick, 1, GY_TRACK_H); + lv_obj_align(tick, LV_ALIGN_LEFT_MID, GY_TRACK_W / 2, 0); + lv_obj_set_style_border_width(tick, 0, 0); + lv_obj_set_style_radius(tick, 0, 0); + lv_obj_set_style_bg_color(tick, lv_color_hex(COL_ZERO), 0); + lv_obj_set_style_bg_opa(tick, LV_OPA_COVER, 0); + + lv_obj_t *fill = lv_obj_create(track); + lv_obj_remove_flag(fill, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(fill, 0, GY_FILL_H); + lv_obj_set_style_radius(fill, 2, 0); + lv_obj_set_style_border_width(fill, 0, 0); + lv_obj_set_style_pad_all(fill, 0, 0); + lv_obj_set_style_bg_color(fill, + GYRO_ROWS[i].color ? lv_color_hex(GYRO_ROWS[i].color) + : current_theme.border_accent, + 0); + lv_obj_set_style_bg_opa(fill, LV_OPA_COVER, 0); + s_gyro_fill[i] = fill; + + lv_obj_t *val = lv_label_create(card); + lv_label_set_text(val, GYRO_ROWS[i].val); + lv_obj_set_style_text_font(val, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(val, current_theme.text_main, 0); + lv_obj_set_width(val, GY_VAL_W); + lv_obj_set_style_text_align(val, LV_TEXT_ALIGN_RIGHT, 0); + lv_obj_align(val, LV_ALIGN_TOP_LEFT, GY_VAL_X, row_y); + s_gyro_val[i] = val; + } + refresh_gyro(); +} + +static void set_rate_label(void) { + if (!s_rate_lbl) + return; + char buf[16]; + lv_snprintf(buf, sizeof(buf), "%d Hz", RATES[s_rate_idx]); + lv_label_set_text(s_rate_lbl, buf); +} + +static void scope_tick_cb(lv_timer_t *t) { + if (lv_screen_active() != s_screen) { + lv_timer_delete(t); + s_scope_timer = NULL; + return; + } + if (s_zeroed) + return; + s_phase = (s_phase + PHASE_STEP) % 360; + fill_traces(); +} + +static void imu_monitor_input(const input_event_t *ev, void *ctx) { + (void)ctx; + const bool press = (ev->action == INPUT_ACTION_PRESS); + const bool nav = press || (ev->action == INPUT_ACTION_REPEAT); + switch (ev->button) { + case INPUT_BTN_BACK: + case INPUT_BTN_LEFT: + if (press) + ui_switch_screen(SCREEN_MENU); + break; + case INPUT_BTN_OK: + if (press) { + s_zeroed = !s_zeroed; + fill_traces(); + refresh_gyro(); + ui_feedback(UI_FB_SELECT); + } + break; + case INPUT_BTN_RIGHT: + if (nav) { + s_rate_idx = (s_rate_idx + 1) % RATE_COUNT; + set_rate_label(); + ui_feedback(UI_FB_NAV); + } + break; + default: + break; + } +} + +void ui_imu_monitor_open(void) { + if (s_scope_timer != NULL) { + lv_timer_delete(s_scope_timer); + s_scope_timer = NULL; + } + if (s_screen != NULL) { + lv_obj_del(s_screen); + s_screen = NULL; + } + s_xline = s_yline = s_zline = NULL; + s_rate_lbl = NULL; + for (int i = 0; i < GYRO_COUNT; i++) { + s_gyro_fill[i] = NULL; + s_gyro_val[i] = NULL; + } + s_phase = 0; + s_rate_idx = RATE_COUNT - 1; + s_zeroed = false; + + s_screen = lv_obj_create(NULL); + lv_obj_set_style_bg_color(s_screen, current_theme.screen_base, 0); + lv_obj_set_style_bg_opa(s_screen, LV_OPA_COVER, 0); + lv_obj_remove_flag(s_screen, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_border_width(s_screen, 0, 0); + lv_obj_set_style_pad_all(s_screen, 0, 0); + + ui_chrome_header(s_screen, HDR_TITLE, HDR_ICON); + + lv_obj_t *acc_lbl = lv_label_create(s_screen); + lv_label_set_text(acc_lbl, "ACCEL - SCOPE g"); + lv_obj_set_style_text_font(acc_lbl, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(acc_lbl, current_theme.border_accent, 0); + lv_obj_align(acc_lbl, LV_ALIGN_TOP_LEFT, MX, ACCEL_LBL_Y); + + s_rate_lbl = lv_label_create(s_screen); + lv_obj_set_style_text_font(s_rate_lbl, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(s_rate_lbl, lv_color_hex(COL_CYAN), 0); + lv_obj_align(s_rate_lbl, LV_ALIGN_TOP_RIGHT, -MX, ACCEL_LBL_Y); + set_rate_label(); + + build_scope_card(); + + lv_obj_t *gyro_lbl = lv_label_create(s_screen); + lv_label_set_text(gyro_lbl, "GYRO - dps"); + lv_obj_set_style_text_font(gyro_lbl, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(gyro_lbl, lv_color_hex(COL_CYAN), 0); + lv_obj_align(gyro_lbl, LV_ALIGN_TOP_LEFT, MX, GYRO_LBL_Y); + + build_gyro_card(); + + ui_chrome_footer(s_screen, FOOTER_TXT); + + s_scope_timer = lv_timer_create(scope_tick_cb, SCOPE_TICK_MS, NULL); + + ui_input_set_screen_handler(imu_monitor_input, NULL); + + ui_screen_load_owned(&s_screen, s_screen); +} diff --git a/firmware_p4/components/Applications/ui/screens/wifi/include/wifi_probe_ui.h b/firmware_p4/components/Applications/ui/screens/games/include/breakout_ui.h similarity index 83% rename from firmware_p4/components/Applications/ui/screens/wifi/include/wifi_probe_ui.h rename to firmware_p4/components/Applications/ui/screens/games/include/breakout_ui.h index 6a3798f88..a282da441 100644 --- a/firmware_p4/components/Applications/ui/screens/wifi/include/wifi_probe_ui.h +++ b/firmware_p4/components/Applications/ui/screens/games/include/breakout_ui.h @@ -13,18 +13,18 @@ // You should have received a copy of the GNU General Public License // along with TentacleOS. If not, see . -#ifndef WIFI_PROBE_UI_H -#define WIFI_PROBE_UI_H +#ifndef BREAKOUT_UI_H +#define BREAKOUT_UI_H #ifdef __cplusplus extern "C" { #endif -/** @brief Open the Wi-Fi probe screen. */ -void ui_wifi_probe_open(void); +/** @brief Open the Breakout mini-game. */ +void ui_breakout_open(void); #ifdef __cplusplus } #endif -#endif // WIFI_PROBE_UI_H +#endif // BREAKOUT_UI_H diff --git a/firmware_p4/components/Applications/ui/screens/sub_example/include/sub_example_ui.h b/firmware_p4/components/Applications/ui/screens/games/include/flappy_ui.h similarity index 83% rename from firmware_p4/components/Applications/ui/screens/sub_example/include/sub_example_ui.h rename to firmware_p4/components/Applications/ui/screens/games/include/flappy_ui.h index 99b772a60..bffa77a73 100644 --- a/firmware_p4/components/Applications/ui/screens/sub_example/include/sub_example_ui.h +++ b/firmware_p4/components/Applications/ui/screens/games/include/flappy_ui.h @@ -13,18 +13,18 @@ // You should have received a copy of the GNU General Public License // along with TentacleOS. If not, see . -#ifndef SUB_EXAMPLE_UI_H -#define SUB_EXAMPLE_UI_H +#ifndef FLAPPY_UI_H +#define FLAPPY_UI_H #ifdef __cplusplus extern "C" { #endif -/** @brief Open the sub-example screen. */ -void ui_sub_example_open(void); +/** @brief Open the purple Flappy-Bird mini-game. */ +void ui_flappy_open(void); #ifdef __cplusplus } #endif -#endif // SUB_EXAMPLE_UI_H \ No newline at end of file +#endif // FLAPPY_UI_H diff --git a/firmware_p4/components/Applications/ui/screens/games/include/game_fx.h b/firmware_p4/components/Applications/ui/screens/games/include/game_fx.h new file mode 100644 index 000000000..723fbcfdd --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/games/include/game_fx.h @@ -0,0 +1,55 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +/** + * @file game_fx.h + * @brief Shared sound and haptic cues for the mini-games. + * + * Each cue plays a short melody on the speaker (off-thread, self-contained I2S) + * and fires a DRV2605L haptic effect. Safe to call from the LVGL/game-loop + * thread. + */ + +#ifndef GAME_FX_H +#define GAME_FX_H + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * @brief Sound and haptic cue kinds for the mini-games. + */ +typedef enum { + GFX_FLAP, ///< Wing flap / move + GFX_SCORE, ///< Point gained + GFX_EAT, ///< Snake ate food + GFX_BOUNCE, ///< Ball bounce + GFX_CRASH, ///< Collision / game over + GFX_START, ///< Round start +} game_fx_t; + +/** + * @brief Play the sound and haptic effect for a cue. + * + * @param kind The cue to play. + */ +void game_fx(game_fx_t kind); + +#ifdef __cplusplus +} +#endif + +#endif // GAME_FX_H diff --git a/firmware_p4/components/Applications/ui/screens/games/include/games_menu_ui.h b/firmware_p4/components/Applications/ui/screens/games/include/games_menu_ui.h new file mode 100644 index 000000000..46b5c137d --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/games/include/games_menu_ui.h @@ -0,0 +1,30 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#ifndef GAMES_MENU_UI_H +#define GAMES_MENU_UI_H + +#ifdef __cplusplus +extern "C" { +#endif + +/** @brief Open the Games list (Octo Flap / Snake / Breakout). */ +void ui_games_menu_open(void); + +#ifdef __cplusplus +} +#endif + +#endif // GAMES_MENU_UI_H diff --git a/firmware_p4/components/Applications/ui/screens/games/include/imu_monitor_ui.h b/firmware_p4/components/Applications/ui/screens/games/include/imu_monitor_ui.h new file mode 100644 index 000000000..20e8806b4 --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/games/include/imu_monitor_ui.h @@ -0,0 +1,30 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#ifndef IMU_MONITOR_UI_H +#define IMU_MONITOR_UI_H + +#ifdef __cplusplus +extern "C" { +#endif + +/** @brief Open the motion/IMU monitor screen (live accel scope + gyro meters). */ +void ui_imu_monitor_open(void); + +#ifdef __cplusplus +} +#endif + +#endif // IMU_MONITOR_UI_H diff --git a/firmware_p4/components/Applications/ui/screens/games/include/motion_ui.h b/firmware_p4/components/Applications/ui/screens/games/include/motion_ui.h new file mode 100644 index 000000000..43ae658c8 --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/games/include/motion_ui.h @@ -0,0 +1,30 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#ifndef MOTION_UI_H +#define MOTION_UI_H + +#ifdef __cplusplus +extern "C" { +#endif + +/** @brief Open the bubble-level motion tool (mock tilt sensor). */ +void ui_motion_open(void); + +#ifdef __cplusplus +} +#endif + +#endif // MOTION_UI_H diff --git a/firmware_p4/components/Applications/ui/screens/games/include/octopet_ui.h b/firmware_p4/components/Applications/ui/screens/games/include/octopet_ui.h new file mode 100644 index 000000000..137b76136 --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/games/include/octopet_ui.h @@ -0,0 +1,40 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +/** + * @file octopet_ui.h + * @brief Octo-Pet, a Tamagotchi-style virtual pet built around the octobit mascot. + * + * Stats (Hunger/Happy/Energy/Clean) decay over time; Feed/Play/Sleep/Clean + * actions keep it alive. Neglect it and it faints (revive with OK). + */ + +#ifndef UI_OCTOPET_H +#define UI_OCTOPET_H + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * @brief Open the Octo-Pet virtual-pet screen. + */ +void ui_octopet_open(void); + +#ifdef __cplusplus +} +#endif + +#endif // UI_OCTOPET_H diff --git a/firmware_p4/components/Service/ota/include/ota_version.h b/firmware_p4/components/Applications/ui/screens/games/include/snake_ui.h similarity index 85% rename from firmware_p4/components/Service/ota/include/ota_version.h rename to firmware_p4/components/Applications/ui/screens/games/include/snake_ui.h index d911879d5..1dfb2a1a1 100644 --- a/firmware_p4/components/Service/ota/include/ota_version.h +++ b/firmware_p4/components/Applications/ui/screens/games/include/snake_ui.h @@ -13,17 +13,18 @@ // You should have received a copy of the GNU General Public License // along with TentacleOS. If not, see . -#ifndef OTA_VERSION_H -#define OTA_VERSION_H +#ifndef SNAKE_UI_H +#define SNAKE_UI_H #ifdef __cplusplus extern "C" { #endif -#define FIRMWARE_VERSION "1.3.1" +/** @brief Open the Snake mini-game. */ +void ui_snake_open(void); #ifdef __cplusplus } #endif -#endif // OTA_VERSION_H +#endif // SNAKE_UI_H diff --git a/firmware_p4/components/Applications/ui/screens/games/motion_ui.c b/firmware_p4/components/Applications/ui/screens/games/motion_ui.c new file mode 100644 index 000000000..f642ab5bb --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/games/motion_ui.c @@ -0,0 +1,201 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#include "motion_ui.h" + +#include + +#include "esp_random.h" + +#include "buttons_gpio.h" +#include "ui_chrome.h" +#include "ui_manager.h" +#include "ui_theme.h" + +#define TICK_MS 50 + +#define BIG_D 156 +#define BUBBLE_D 30 +#define RING_D 44 +#define TRAVEL 59 + +#define MAX_DEG 24.0f +#define LEVEL_TOL 3.0f +#define WALK_STEP 1.4f +#define DECAY 0.94f + +#define COL_LEVEL 0x00E676 +#define COL_DIM 0x8A8594 + +#define HDR_ICON "/assets/icons/sensors.bin" +#define HDR_TITLE "BUBBLE LEVEL" + +static lv_obj_t *s_screen = NULL; +static lv_obj_t *s_bubble = NULL; +static lv_obj_t *s_status = NULL; +static lv_obj_t *s_read = NULL; +static lv_timer_t *s_timer = NULL; + +static float s_pitch = 0.0f; +static float s_roll = 0.0f; +static bool s_back_last = false; +static bool s_left_last = false; + +static float rand_walk_delta(void) { + int r = (int)(esp_random() % 200) - 100; + return (float)r / 100.0f * WALK_STEP; +} + +static float clampf(float v, float lo, float hi) { + if (v < lo) + return lo; + if (v > hi) + return hi; + return v; +} + +static void build_level(void) { + lv_obj_t *big = lv_obj_create(s_screen); + lv_obj_remove_flag(big, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_remove_flag(big, LV_OBJ_FLAG_CLICKABLE); + lv_obj_set_size(big, BIG_D, BIG_D); + lv_obj_align(big, LV_ALIGN_CENTER, 0, (UI_CHROME_HEADER_H - UI_CHROME_FOOTER_H) / 2 - 24); + lv_obj_set_style_radius(big, LV_RADIUS_CIRCLE, 0); + lv_obj_set_style_bg_color(big, current_theme.bg_secondary, 0); + lv_obj_set_style_bg_opa(big, LV_OPA_COVER, 0); + lv_obj_set_style_border_color(big, current_theme.border_accent, 0); + lv_obj_set_style_border_width(big, 2, 0); + lv_obj_set_style_pad_all(big, 0, 0); + lv_obj_set_style_shadow_width(big, 22, 0); + lv_obj_set_style_shadow_color(big, current_theme.border_accent, 0); + lv_obj_set_style_shadow_opa(big, LV_OPA_40, 0); + + lv_obj_t *ring = lv_obj_create(big); + lv_obj_remove_flag(ring, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_remove_flag(ring, LV_OBJ_FLAG_CLICKABLE); + lv_obj_set_size(ring, RING_D, RING_D); + lv_obj_center(ring); + lv_obj_set_style_radius(ring, LV_RADIUS_CIRCLE, 0); + lv_obj_set_style_bg_opa(ring, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_color(ring, lv_color_hex(COL_DIM), 0); + lv_obj_set_style_border_width(ring, 2, 0); + lv_obj_set_style_pad_all(ring, 0, 0); + + s_bubble = lv_obj_create(big); + lv_obj_remove_flag(s_bubble, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_remove_flag(s_bubble, LV_OBJ_FLAG_CLICKABLE); + lv_obj_set_size(s_bubble, BUBBLE_D, BUBBLE_D); + lv_obj_align(s_bubble, LV_ALIGN_CENTER, 0, 0); + lv_obj_set_style_radius(s_bubble, LV_RADIUS_CIRCLE, 0); + lv_obj_set_style_border_width(s_bubble, 0, 0); + lv_obj_set_style_bg_color(s_bubble, current_theme.border_accent, 0); + lv_obj_set_style_bg_opa(s_bubble, LV_OPA_COVER, 0); + lv_obj_set_style_shadow_width(s_bubble, 12, 0); + lv_obj_set_style_shadow_color(s_bubble, current_theme.border_accent, 0); + lv_obj_set_style_shadow_opa(s_bubble, LV_OPA_70, 0); + + s_status = lv_label_create(s_screen); + lv_label_set_text(s_status, "TILTED"); + lv_obj_set_style_text_font(s_status, &lv_font_montserrat_14, 0); + lv_obj_set_style_text_color(s_status, lv_color_hex(COL_DIM), 0); + lv_obj_align(s_status, LV_ALIGN_BOTTOM_MID, 0, -52); + + s_read = lv_label_create(s_screen); + lv_label_set_text(s_read, "Pitch 0 deg Roll 0 deg"); + lv_obj_set_style_text_font(s_read, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(s_read, current_theme.text_main, 0); + lv_obj_align(s_read, LV_ALIGN_BOTTOM_MID, 0, -30); +} + +static void update_physics(void) { + s_pitch = clampf((s_pitch + rand_walk_delta()) * DECAY, -MAX_DEG, MAX_DEG); + s_roll = clampf((s_roll + rand_walk_delta()) * DECAY, -MAX_DEG, MAX_DEG); + + float fx = s_roll / MAX_DEG; + float fy = -s_pitch / MAX_DEG; + float mag = sqrtf(fx * fx + fy * fy); + if (mag > 1.0f) { + fx /= mag; + fy /= mag; + } + int ox = (int)(fx * TRAVEL); + int oy = (int)(fy * TRAVEL); + if (s_bubble) + lv_obj_align(s_bubble, LV_ALIGN_CENTER, ox, oy); + + bool level = (fabsf(s_pitch) <= LEVEL_TOL && fabsf(s_roll) <= LEVEL_TOL); + lv_color_t c = level ? lv_color_hex(COL_LEVEL) : current_theme.border_accent; + if (s_bubble) { + lv_obj_set_style_bg_color(s_bubble, c, 0); + lv_obj_set_style_shadow_color(s_bubble, c, 0); + } + if (s_status) { + lv_label_set_text(s_status, level ? "LEVEL" : "TILTED"); + lv_obj_set_style_text_color( + s_status, level ? lv_color_hex(COL_LEVEL) : lv_color_hex(COL_DIM), 0); + } + if (s_read) + lv_label_set_text_fmt(s_read, "Pitch %d deg Roll %d deg", (int)s_pitch, (int)s_roll); +} + +static void tick_cb(lv_timer_t *t) { + if (lv_screen_active() != s_screen) { + lv_timer_delete(t); + s_timer = NULL; + return; + } + if (ui_input_is_locked()) + return; + + bool back = back_button_is_down(); + bool left = ui_btn_left(); + if ((back && !s_back_last) || (left && !s_left_last)) { + ui_switch_screen(SCREEN_GAMES_MENU); + return; + } + + update_physics(); + + s_back_last = back; + s_left_last = left; +} + +void ui_motion_open(void) { + if (s_screen != NULL) { + lv_obj_del(s_screen); + s_screen = NULL; + } + s_pitch = 0.0f; + s_roll = 0.0f; + s_back_last = false; + s_left_last = false; + s_bubble = NULL; + s_status = NULL; + s_read = NULL; + + s_screen = lv_obj_create(NULL); + lv_obj_set_style_bg_color(s_screen, current_theme.screen_base, 0); + lv_obj_set_style_bg_opa(s_screen, LV_OPA_COVER, 0); + lv_obj_remove_flag(s_screen, LV_OBJ_FLAG_SCROLLABLE); + + ui_chrome_header(s_screen, HDR_TITLE, HDR_ICON); + build_level(); + ui_chrome_footer(s_screen, "BACK: EXIT"); + + if (s_timer == NULL) + s_timer = lv_timer_create(tick_cb, TICK_MS, NULL); + + ui_screen_load_owned(&s_screen, s_screen); +} diff --git a/firmware_p4/components/Applications/ui/screens/games/octopet_ui.c b/firmware_p4/components/Applications/ui/screens/games/octopet_ui.c new file mode 100644 index 000000000..a75c9b62f --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/games/octopet_ui.c @@ -0,0 +1,852 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#include "octopet_ui.h" + +#include + +#include "esp_log.h" +#include "lvgl.h" + +#include "assets_manager.h" +#include "buttons_gpio.h" +#include "game_fx.h" +#include "ui_manager.h" +#include "ui_theme.h" + +static const char *TAG = "OCTOPET"; + +#define NAV_MS 50 +#define LIFE_MS 1500 +#define PET_ASSET "/assets/img/octobit.bin" +#define FAINT_TICKS 10 +#define COL_GOOD 0x00E676 +#define COL_WARN 0xFFC400 +#define COL_BAD 0xFF5252 +#define COL_HEART 0xFF4081 +#define COL_CRUMB 0xFFB74D +#define COL_SPARKLE 0x40C4FF +#define COL_POOP 0x8D6E63 +#define COL_LEVEL 0xFFC400 + +#define LVL_BASE 6 +#define INIT_STAT 75 +#define STAT_MAX 100 + +enum { ACT_FEED = 0, ACT_PLAY, ACT_SLEEP, ACT_CLEAN, ACT_COUNT }; +static const char *ACT_NAMES[ACT_COUNT] = {"FEED", "PLAY", "SLEEP", "CLEAN"}; + +static bool s_inited = false; +static int s_hunger, s_happy, s_energy, s_clean; +static bool s_sleeping = false; +static bool s_fainted = false; +static int s_neglect = 0; +static int s_age = 0; +static int s_level = 1; +static bool s_poop = false; + +static lv_obj_t *s_screen = NULL; +static lv_timer_t *s_nav_timer = NULL; +static lv_timer_t *s_life_timer = NULL; +static lv_obj_t *s_pet = NULL; +static lv_obj_t *s_mood = NULL; +static lv_obj_t *s_zzz = NULL; +static lv_obj_t *s_poop_obj = NULL; +static lv_obj_t *s_faint_ov = NULL; +static lv_obj_t *s_bar[4]; +static lv_obj_t *s_val[4]; +static lv_obj_t *s_age_lbl = NULL; +static lv_obj_t *s_lvl_lbl = NULL; +static int s_sel = 0; +static lv_obj_t *s_cell[ACT_COUNT]; +static int32_t s_pet_rest_y = 0; +static uint32_t s_rng = 0x1234abcd; + +static bool s_l_last, s_r_last, s_ok_last, s_back_last; + +static void nav_timer_cb(lv_timer_t *t); +static void life_tick_cb(lv_timer_t *t); +static void refresh_pet_look(void); + +static uint32_t rng_next(void) { + s_rng ^= s_rng << 13; + s_rng ^= s_rng >> 17; + s_rng ^= s_rng << 5; + return s_rng; +} + +static int level_for_age(int age) { + int lvl = 1, need = LVL_BASE, acc = 0; + while (age >= acc + need) { + acc += need; + lvl++; + need += LVL_BASE; + } + return lvl; +} + +static void init_pet(void) { + s_hunger = s_happy = s_energy = s_clean = INIT_STAT; + s_sleeping = false; + s_fainted = false; + s_neglect = 0; + s_age = 0; + s_level = 1; + s_poop = false; + s_inited = true; +} + +static int clampi(int v) { + return v < 0 ? 0 : (v > STAT_MAX ? STAT_MAX : v); +} +static int stat_min(void) { + int m = s_hunger; + if (s_happy < m) + m = s_happy; + if (s_energy < m) + m = s_energy; + if (s_clean < m) + m = s_clean; + return m; +} +static uint32_t bar_color(int v) { + return v < 25 ? COL_BAD : (v < 55 ? COL_WARN : COL_GOOD); +} + +static void float_del_cb(lv_anim_t *a) { + lv_obj_del((lv_obj_t *)a->var); +} +static void float_y_cb(void *var, int32_t v) { + lv_obj_set_y((lv_obj_t *)var, v); +} +static void float_x_cb(void *var, int32_t v) { + lv_obj_set_x((lv_obj_t *)var, v); +} +static void float_opa_cb(void *var, int32_t v) { + lv_obj_set_style_opa((lv_obj_t *)var, (lv_opa_t)v, 0); +} + +static void spawn_particle(const char *text, + uint32_t color, + const lv_font_t *font, + int32_t x0, + int32_t y0, + int32_t dx, + int32_t dy, + uint32_t dur) { + if (s_screen == NULL) + return; + lv_obj_t *l = lv_label_create(s_screen); + lv_label_set_text(l, text); + lv_obj_set_style_text_color(l, lv_color_hex(color), 0); + lv_obj_set_style_text_font(l, font, 0); + lv_obj_set_pos(l, x0, y0); + + lv_anim_t ay; + lv_anim_init(&ay); + lv_anim_set_var(&ay, l); + lv_anim_set_exec_cb(&ay, float_y_cb); + lv_anim_set_values(&ay, y0, y0 + dy); + lv_anim_set_duration(&ay, dur); + lv_anim_set_path_cb(&ay, lv_anim_path_ease_out); + lv_anim_set_completed_cb(&ay, float_del_cb); + lv_anim_start(&ay); + + if (dx != 0) { + lv_anim_t ax; + lv_anim_init(&ax); + lv_anim_set_var(&ax, l); + lv_anim_set_exec_cb(&ax, float_x_cb); + lv_anim_set_values(&ax, x0, x0 + dx); + lv_anim_set_duration(&ax, dur); + lv_anim_set_path_cb(&ax, lv_anim_path_ease_in_out); + lv_anim_start(&ax); + } + + lv_anim_t ao; + lv_anim_init(&ao); + lv_anim_set_var(&ao, l); + lv_anim_set_exec_cb(&ao, float_opa_cb); + lv_anim_set_values(&ao, 255, 0); + lv_anim_set_duration(&ao, dur); + lv_anim_start(&ao); +} + +static void feedback(const char *text, uint32_t color) { + if (s_screen == NULL) + return; + lv_obj_t *l = lv_label_create(s_screen); + lv_label_set_text(l, text); + lv_obj_set_style_text_color(l, lv_color_hex(color), 0); + lv_obj_set_style_text_font(l, &lv_font_montserrat_14, 0); + lv_obj_align(l, LV_ALIGN_CENTER, 0, -24); + int32_t y0 = lv_obj_get_y(l); + + lv_anim_t ay; + lv_anim_init(&ay); + lv_anim_set_var(&ay, l); + lv_anim_set_exec_cb(&ay, float_y_cb); + lv_anim_set_values(&ay, y0, y0 - 38); + lv_anim_set_duration(&ay, 700); + lv_anim_set_path_cb(&ay, lv_anim_path_ease_out); + lv_anim_set_completed_cb(&ay, float_del_cb); + lv_anim_start(&ay); + + lv_anim_t ao; + lv_anim_init(&ao); + lv_anim_set_var(&ao, l); + lv_anim_set_exec_cb(&ao, float_opa_cb); + lv_anim_set_values(&ao, 255, 0); + lv_anim_set_duration(&ao, 700); + lv_anim_start(&ao); +} + +static void burst(int kind) { + if (s_screen == NULL) + return; + + int32_t sw = lv_obj_get_width(s_screen); + int32_t sh = lv_obj_get_height(s_screen); + int32_t bx = sw / 2; + int32_t by = sh / 2; + + switch (kind) { + case ACT_FEED: { + for (int i = 0; i < 5; i++) { + int32_t ox = (int32_t)(rng_next() % 44) - 22; + spawn_particle(".", + COL_CRUMB, + &lv_font_montserrat_14, + bx + ox, + by + 6, + (int32_t)(rng_next() % 16) - 8, + 22 + (int32_t)(rng_next() % 14), + 640); + } + break; + } + case ACT_PLAY: { + for (int i = 0; i < 4; i++) { + int32_t ox = (int32_t)(rng_next() % 50) - 25; + const char *g = (i & 1) ? "<3" : "*"; + spawn_particle(g, + COL_HEART, + (i & 1) ? &lv_font_montserrat_14 : &lv_font_montserrat_12, + bx + ox, + by - 6, + (int32_t)(rng_next() % 18) - 9, + -(34 + (int32_t)(rng_next() % 20)), + 820); + } + break; + } + case ACT_CLEAN: { + for (int i = 0; i < 6; i++) { + int32_t ox = (int32_t)(rng_next() % 60) - 30; + spawn_particle("+", + COL_SPARKLE, + &lv_font_montserrat_12, + bx + ox, + by - 2, + (int32_t)(rng_next() % 24) - 12, + -(20 + (int32_t)(rng_next() % 22)), + 700); + } + break; + } + default: + break; + } +} + +static void poop_gone_cb(lv_anim_t *a) { + lv_obj_del((lv_obj_t *)a->var); + if ((lv_obj_t *)a->var == s_poop_obj) + s_poop_obj = NULL; +} + +static void make_poop_obj(void) { + if (s_poop_obj || s_screen == NULL) + return; + s_poop_obj = lv_label_create(s_screen); + lv_label_set_text(s_poop_obj, "~"); + lv_obj_set_style_text_color(s_poop_obj, lv_color_hex(COL_POOP), 0); + lv_obj_set_style_text_font(s_poop_obj, &lv_font_montserrat_16, 0); + lv_obj_align(s_poop_obj, LV_ALIGN_CENTER, 38, 44); + + lv_obj_set_style_opa(s_poop_obj, LV_OPA_TRANSP, 0); + lv_anim_t a; + lv_anim_init(&a); + lv_anim_set_var(&a, s_poop_obj); + lv_anim_set_exec_cb(&a, float_opa_cb); + lv_anim_set_values(&a, LV_OPA_TRANSP, LV_OPA_COVER); + lv_anim_set_duration(&a, 280); + lv_anim_start(&a); +} + +static void add_poop(void) { + if (s_poop) + return; + s_poop = true; + make_poop_obj(); +} + +static void sweep_poop_away(void) { + if (!s_poop) + return; + s_poop = false; + if (s_poop_obj) { + int32_t x0 = lv_obj_get_x(s_poop_obj); + lv_anim_t ax; + lv_anim_init(&ax); + lv_anim_set_var(&ax, s_poop_obj); + lv_anim_set_exec_cb(&ax, float_x_cb); + lv_anim_set_values(&ax, x0, x0 + 60); + lv_anim_set_duration(&ax, 380); + lv_anim_set_path_cb(&ax, lv_anim_path_ease_in); + lv_anim_start(&ax); + lv_anim_t ao; + lv_anim_init(&ao); + lv_anim_set_var(&ao, s_poop_obj); + lv_anim_set_exec_cb(&ao, float_opa_cb); + lv_anim_set_values(&ao, LV_OPA_COVER, LV_OPA_TRANSP); + lv_anim_set_duration(&ao, 380); + lv_anim_set_completed_cb(&ao, poop_gone_cb); + lv_anim_start(&ao); + } +} + +static const char *mood_face(uint32_t *col) { + if (s_fainted) { + *col = COL_BAD; + return "x_x"; + } + if (s_sleeping) { + *col = 0x82B1FF; + return "-_-"; + } + if (s_poop && s_clean < 40) { + *col = COL_POOP; + return ">_<"; + } + if (s_hunger < 25) { + *col = COL_BAD; + return ":<"; + } + if (s_clean < 25) { + *col = COL_POOP; + return ":S"; + } + if (s_energy < 25) { + *col = COL_WARN; + return "u_u"; + } + if (s_happy < 25) { + *col = COL_WARN; + return ":("; + } + if (stat_min() >= 70) { + *col = COL_GOOD; + return ":D"; + } + *col = COL_GOOD; + return ":)"; +} + +static const char *mood_word(void) { + if (s_fainted) + return "Fainted..."; + if (s_sleeping) + return "Sleeping"; + if (s_poop && s_clean < 40) + return "Eww, poop!"; + if (s_hunger < 25) + return "Hungry!"; + if (s_energy < 25) + return "Sleepy..."; + if (s_clean < 25) + return "Dirty!"; + if (s_happy < 25) + return "Sad"; + if (stat_min() >= 70) + return "Happy!"; + return "OK"; +} + +static void refresh_pet_look(void) { + if (s_pet) { + lv_obj_set_style_opa(s_pet, s_sleeping ? LV_OPA_60 : LV_OPA_COVER, 0); + bool sick = (!s_sleeping && stat_min() < 20); + lv_obj_set_style_image_recolor_opa(s_pet, sick ? LV_OPA_40 : LV_OPA_TRANSP, 0); + lv_obj_set_style_image_recolor(s_pet, lv_color_hex(COL_BAD), 0); + } + if (s_mood) { + uint32_t col; + const char *face = mood_face(&col); + lv_label_set_text(s_mood, face); + lv_obj_set_style_text_color(s_mood, lv_color_hex(col), 0); + } + + if (s_sleeping && s_zzz == NULL && s_screen) { + s_zzz = lv_label_create(s_screen); + lv_label_set_text(s_zzz, "Zzz"); + lv_obj_set_style_text_color(s_zzz, current_theme.border_accent, 0); + lv_obj_set_style_text_font(s_zzz, &lv_font_montserrat_16, 0); + lv_obj_align(s_zzz, LV_ALIGN_CENTER, 46, -54); + lv_anim_t a; + lv_anim_init(&a); + lv_anim_set_var(&a, s_zzz); + lv_anim_set_exec_cb(&a, float_opa_cb); + lv_anim_set_values(&a, LV_OPA_30, LV_OPA_COVER); + lv_anim_set_duration(&a, 900); + lv_anim_set_playback_duration(&a, 900); + lv_anim_set_repeat_count(&a, LV_ANIM_REPEAT_INFINITE); + lv_anim_start(&a); + } else if (!s_sleeping && s_zzz != NULL) { + lv_obj_del(s_zzz); + s_zzz = NULL; + } + + if (s_poop && s_poop_obj == NULL && !s_fainted) + make_poop_obj(); +} + +static void refresh_bars(bool anim) { + int v[4] = {s_hunger, s_happy, s_energy, s_clean}; + for (int i = 0; i < 4; i++) { + if (s_bar[i]) { + lv_bar_set_value(s_bar[i], v[i], anim ? LV_ANIM_ON : LV_ANIM_OFF); + lv_obj_set_style_bg_color(s_bar[i], lv_color_hex(bar_color(v[i])), LV_PART_INDICATOR); + } + if (s_val[i]) { + lv_label_set_text_fmt(s_val[i], "%d", v[i]); + lv_obj_set_style_text_color(s_val[i], lv_color_hex(bar_color(v[i])), 0); + } + } +} + +static void refresh_age(void) { + if (s_age_lbl) + lv_label_set_text_fmt(s_age_lbl, "AGE %d", s_age); + if (s_lvl_lbl) + lv_label_set_text_fmt(s_lvl_lbl, "Lv %d", s_level); +} + +static void pet_scale_cb(void *var, int32_t v) { + lv_image_set_scale((lv_obj_t *)var, (uint32_t)v); +} +static void pet_pop(void) { + if (s_pet == NULL) + return; + lv_anim_t a; + lv_anim_init(&a); + lv_anim_set_var(&a, s_pet); + lv_anim_set_exec_cb(&a, pet_scale_cb); + lv_anim_set_values(&a, 256, 296); + lv_anim_set_duration(&a, 120); + lv_anim_set_playback_duration(&a, 140); + lv_anim_set_path_cb(&a, lv_anim_path_ease_out); + lv_anim_start(&a); +} + +static void pet_wiggle(void) { + if (s_pet == NULL) + return; + int32_t x0 = lv_obj_get_x(s_pet); + lv_anim_t a; + lv_anim_init(&a); + lv_anim_set_var(&a, s_pet); + lv_anim_set_exec_cb(&a, float_x_cb); + lv_anim_set_values(&a, x0, x0 + 4); + lv_anim_set_duration(&a, 90); + lv_anim_set_playback_duration(&a, 90); + lv_anim_set_repeat_count(&a, 2); + lv_anim_set_path_cb(&a, lv_anim_path_ease_in_out); + lv_anim_start(&a); +} + +static void pet_blink(void) { + if (s_mood == NULL) + return; + lv_anim_t a; + lv_anim_init(&a); + lv_anim_set_var(&a, s_mood); + lv_anim_set_exec_cb(&a, float_opa_cb); + lv_anim_set_values(&a, LV_OPA_COVER, LV_OPA_30); + lv_anim_set_duration(&a, 110); + lv_anim_set_playback_duration(&a, 110); + lv_anim_set_path_cb(&a, lv_anim_path_ease_in_out); + lv_anim_start(&a); +} + +static void update_action_focus(void) { + for (int i = 0; i < ACT_COUNT; i++) { + if (!s_cell[i]) + continue; + bool sel = (i == s_sel); + lv_obj_set_style_border_color( + s_cell[i], sel ? current_theme.border_accent : current_theme.border_interface, 0); + lv_obj_set_style_border_width(s_cell[i], sel ? 3 : 1, 0); + lv_obj_set_style_bg_opa(s_cell[i], sel ? LV_OPA_COVER : LV_OPA_50, 0); + lv_obj_set_style_shadow_width(s_cell[i], sel ? 12 : 0, 0); + lv_obj_set_style_shadow_color(s_cell[i], current_theme.border_accent, 0); + lv_obj_set_style_shadow_spread(s_cell[i], sel ? 1 : 0, 0); + } +} + +static void do_action(int a) { + switch (a) { + case ACT_FEED: + s_hunger = clampi(s_hunger + 28); + s_clean = clampi(s_clean - 6); + game_fx(GFX_EAT); + feedback("+ Food", COL_GOOD); + burst(ACT_FEED); + + if (!s_poop && (rng_next() % 100) < 35) + add_poop(); + break; + case ACT_PLAY: + if (s_sleeping) { + feedback("zzz...", COL_WARN); + break; + } + s_happy = clampi(s_happy + 28); + s_energy = clampi(s_energy - 12); + game_fx(GFX_SCORE); + feedback("Fun!", COL_HEART); + burst(ACT_PLAY); + break; + case ACT_SLEEP: + s_sleeping = !s_sleeping; + game_fx(GFX_START); + feedback(s_sleeping ? "Zzz" : "Wake!", 0x82B1FF); + break; + case ACT_CLEAN: + s_clean = clampi(s_clean + 40); + game_fx(GFX_BOUNCE); + feedback(s_poop ? "Sparkly!" : "Clean!", COL_SPARKLE); + burst(ACT_CLEAN); + sweep_poop_away(); + break; + default: + break; + } + pet_pop(); + refresh_bars(true); + refresh_pet_look(); +} + +static void show_faint(void) { + s_fainted = true; + s_sleeping = false; + if (s_faint_ov || s_screen == NULL) + return; + s_faint_ov = lv_obj_create(s_screen); + lv_obj_remove_flag(s_faint_ov, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(s_faint_ov, lv_pct(80), 96); + lv_obj_align(s_faint_ov, LV_ALIGN_CENTER, 0, 0); + lv_obj_set_style_radius(s_faint_ov, 12, 0); + lv_obj_set_style_bg_color(s_faint_ov, current_theme.bg_primary, 0); + lv_obj_set_style_bg_opa(s_faint_ov, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(s_faint_ov, 2, 0); + lv_obj_set_style_border_color(s_faint_ov, lv_color_hex(COL_BAD), 0); + lv_obj_set_flex_flow(s_faint_ov, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align( + s_faint_ov, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + + lv_obj_t *t = lv_label_create(s_faint_ov); + lv_label_set_text(t, "Octo fainted!"); + lv_obj_set_style_text_color(t, current_theme.text_main, 0); + lv_obj_set_style_text_font(t, &lv_font_montserrat_16, 0); + lv_obj_t *h = lv_label_create(s_faint_ov); + lv_label_set_text(h, "OK to revive"); + lv_obj_set_style_text_color(h, current_theme.border_accent, 0); + lv_obj_set_style_text_font(h, &lv_font_montserrat_12, 0); + + game_fx(GFX_CRASH); +} + +static void revive(void) { + if (s_faint_ov) { + lv_obj_del(s_faint_ov); + s_faint_ov = NULL; + } + if (s_poop_obj) { + lv_obj_del(s_poop_obj); + s_poop_obj = NULL; + } + init_pet(); + refresh_bars(true); + refresh_age(); + refresh_pet_look(); + feedback("Revived!", COL_GOOD); +} + +static void life_tick_cb(lv_timer_t *t) { + if (lv_screen_active() != s_screen) { + lv_timer_delete(t); + s_life_timer = NULL; + return; + } + if (s_fainted) + return; + + s_age++; + int new_level = level_for_age(s_age); + if (new_level > s_level) { + s_level = new_level; + game_fx(GFX_SCORE); + feedback("Level up!", COL_LEVEL); + } + refresh_age(); + + if (s_sleeping) { + s_energy = clampi(s_energy + 8); + s_hunger = clampi(s_hunger - 1); + s_clean = clampi(s_clean - 1); + if (s_energy >= STAT_MAX) + s_sleeping = false; + } else { + s_hunger = clampi(s_hunger - 3); + s_happy = clampi(s_happy - 2); + s_energy = clampi(s_energy - 2); + s_clean = clampi(s_clean - 2); + } + + if (s_poop) { + s_clean = clampi(s_clean - 3); + if ((s_age & 1) == 0) + s_happy = clampi(s_happy - 1); + } else if (!s_sleeping && (rng_next() % 100) < 6) { + add_poop(); + } + + if (!s_sleeping) { + uint32_t r = rng_next() % 100; + if (r < 12) + pet_blink(); + else if (r < 18) + pet_wiggle(); + } + + if (s_hunger == 0 && s_energy == 0) + s_neglect++; + else + s_neglect = 0; + + refresh_bars(true); + refresh_pet_look(); + + if (s_neglect >= FAINT_TICKS) + show_faint(); +} + +static void nav_timer_cb(lv_timer_t *t) { + if (lv_screen_active() != s_screen) { + lv_timer_delete(t); + s_nav_timer = NULL; + return; + } + if (ui_input_is_locked()) + return; + + bool left = ui_btn_left(), right = ui_btn_right(); + bool ok = ok_button_is_down(), back = back_button_is_down(); + + if (back && !s_back_last) { + ui_switch_screen(SCREEN_GAMES_MENU); + goto edges; + } + + if (s_fainted) { + if (ok && !s_ok_last) + revive(); + goto edges; + } + + if (left && !s_l_last) { + s_sel = (s_sel - 1 + ACT_COUNT) % ACT_COUNT; + update_action_focus(); + } + if (right && !s_r_last) { + s_sel = (s_sel + 1) % ACT_COUNT; + update_action_focus(); + } + if (ok && !s_ok_last) + do_action(s_sel); + +edges: + s_l_last = left; + s_r_last = right; + s_ok_last = ok; + s_back_last = back; +} + +void ui_octopet_open(void) { + if (!s_inited) + init_pet(); + + s_level = level_for_age(s_age); + + if (s_screen != NULL) { + lv_obj_del(s_screen); + s_screen = NULL; + } + s_zzz = NULL; + s_poop_obj = NULL; + s_faint_ov = NULL; + s_l_last = s_r_last = s_ok_last = s_back_last = false; + + s_screen = lv_obj_create(NULL); + lv_obj_set_style_bg_color(s_screen, current_theme.screen_base, 0); + lv_obj_set_style_bg_opa(s_screen, LV_OPA_COVER, 0); + lv_obj_remove_flag(s_screen, LV_OBJ_FLAG_SCROLLABLE); + + lv_obj_t *title = lv_label_create(s_screen); + lv_label_set_text(title, "OCTO-PET"); + lv_obj_set_style_text_color(title, current_theme.border_accent, 0); + lv_obj_set_style_text_font(title, &lv_font_montserrat_14, 0); + lv_obj_align(title, LV_ALIGN_TOP_MID, 0, 6); + + s_lvl_lbl = lv_label_create(s_screen); + lv_label_set_text_fmt(s_lvl_lbl, "Lv %d", s_level); + lv_obj_set_style_text_color(s_lvl_lbl, lv_color_hex(COL_LEVEL), 0); + lv_obj_set_style_text_font(s_lvl_lbl, &lv_font_montserrat_12, 0); + lv_obj_align(s_lvl_lbl, LV_ALIGN_TOP_LEFT, 8, 8); + + s_age_lbl = lv_label_create(s_screen); + lv_label_set_text_fmt(s_age_lbl, "AGE %d", s_age); + lv_obj_set_style_text_color(s_age_lbl, current_theme.border_inactive, 0); + lv_obj_set_style_text_font(s_age_lbl, &lv_font_montserrat_12, 0); + lv_obj_align(s_age_lbl, LV_ALIGN_TOP_RIGHT, -8, 8); + + static const char *cap[4] = {"HUN", "HAP", "ENE", "CLN"}; + for (int i = 0; i < 4; i++) { + lv_obj_t *c = lv_label_create(s_screen); + lv_label_set_text(c, cap[i]); + lv_obj_set_style_text_color(c, current_theme.border_inactive, 0); + lv_obj_set_style_text_font(c, &lv_font_montserrat_12, 0); + lv_obj_align(c, LV_ALIGN_TOP_LEFT, 8, 30 + i * 18); + + lv_obj_t *b = lv_bar_create(s_screen); + lv_obj_set_size(b, 88, 10); + lv_obj_align(b, LV_ALIGN_TOP_LEFT, 44, 32 + i * 18); + lv_bar_set_range(b, 0, STAT_MAX); + lv_obj_set_style_bg_color(b, current_theme.bg_secondary, LV_PART_MAIN); + lv_obj_set_style_radius(b, 5, LV_PART_MAIN); + lv_obj_set_style_radius(b, 5, LV_PART_INDICATOR); + s_bar[i] = b; + + lv_obj_t *vl = lv_label_create(s_screen); + lv_obj_set_style_text_font(vl, &lv_font_montserrat_12, 0); + lv_obj_align(vl, LV_ALIGN_TOP_LEFT, 138, 30 + i * 18); + s_val[i] = vl; + } + + lv_obj_t *glow = lv_obj_create(s_screen); + lv_obj_remove_flag(glow, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(glow, 108, 108); + lv_obj_align(glow, LV_ALIGN_CENTER, 0, 2); + lv_obj_set_style_radius(glow, LV_RADIUS_CIRCLE, 0); + lv_obj_set_style_border_width(glow, 0, 0); + lv_obj_set_style_bg_color(glow, current_theme.border_accent, 0); + lv_obj_set_style_bg_opa(glow, LV_OPA_20, 0); + + lv_obj_t *plat = lv_obj_create(s_screen); + lv_obj_remove_flag(plat, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(plat, 118, 14); + lv_obj_align(plat, LV_ALIGN_CENTER, 0, 58); + lv_obj_set_style_radius(plat, 7, 0); + lv_obj_set_style_border_width(plat, 0, 0); + lv_obj_set_style_bg_color(plat, current_theme.bg_secondary, 0); + lv_obj_set_style_bg_opa(plat, LV_OPA_70, 0); + + lv_image_dsc_t *dsc = assets_get(PET_ASSET); + if (dsc != NULL) { + s_pet = lv_image_create(s_screen); + lv_image_set_src(s_pet, dsc); + lv_image_set_pivot(s_pet, dsc->header.w / 2, dsc->header.h / 2); + lv_obj_align(s_pet, LV_ALIGN_CENTER, 0, 6); + s_pet_rest_y = lv_obj_get_y(s_pet); + + lv_anim_t a; + lv_anim_init(&a); + lv_anim_set_var(&a, s_pet); + lv_anim_set_exec_cb(&a, float_y_cb); + lv_anim_set_values(&a, s_pet_rest_y, s_pet_rest_y - 6); + lv_anim_set_duration(&a, 1100); + lv_anim_set_playback_duration(&a, 1100); + lv_anim_set_repeat_count(&a, LV_ANIM_REPEAT_INFINITE); + lv_anim_set_path_cb(&a, lv_anim_path_ease_in_out); + lv_anim_start(&a); + } + + s_mood = lv_label_create(s_screen); + lv_obj_set_style_text_color(s_mood, current_theme.text_main, 0); + lv_obj_set_style_text_font(s_mood, &lv_font_montserrat_16, 0); + lv_obj_align(s_mood, LV_ALIGN_CENTER, -46, -34); + + lv_obj_t *moodw = lv_label_create(s_screen); + lv_obj_set_style_text_color(moodw, current_theme.text_main, 0); + lv_obj_set_style_text_font(moodw, &lv_font_montserrat_14, 0); + lv_obj_align(moodw, LV_ALIGN_BOTTOM_MID, 0, -64); + lv_label_set_text(moodw, mood_word()); + + lv_obj_t *bar = lv_obj_create(s_screen); + lv_obj_remove_flag(bar, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(bar, lv_pct(100), 48); + lv_obj_align(bar, LV_ALIGN_BOTTOM_MID, 0, -6); + lv_obj_set_style_bg_opa(bar, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(bar, 0, 0); + lv_obj_set_style_pad_all(bar, 2, 0); + lv_obj_set_flex_flow(bar, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align( + bar, LV_FLEX_ALIGN_SPACE_EVENLY, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + + for (int i = 0; i < ACT_COUNT; i++) { + lv_obj_t *cell = lv_obj_create(bar); + lv_obj_remove_flag(cell, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(cell, 70, 38); + lv_obj_set_style_radius(cell, 8, 0); + lv_obj_set_style_bg_color(cell, current_theme.bg_primary, 0); + lv_obj_set_style_bg_grad_color(cell, current_theme.bg_secondary, 0); + lv_obj_set_style_bg_grad_dir(cell, LV_GRAD_DIR_VER, 0); + lv_obj_set_style_border_color(cell, current_theme.border_interface, 0); + lv_obj_set_style_border_width(cell, 1, 0); + lv_obj_t *l = lv_label_create(cell); + lv_label_set_text(l, ACT_NAMES[i]); + lv_obj_set_style_text_color(l, current_theme.text_main, 0); + lv_obj_set_style_text_font(l, &lv_font_montserrat_12, 0); + lv_obj_center(l); + s_cell[i] = cell; + } + + refresh_bars(false); + refresh_age(); + update_action_focus(); + refresh_pet_look(); + if (s_fainted) + show_faint(); + + if (s_nav_timer == NULL) + s_nav_timer = lv_timer_create(nav_timer_cb, NAV_MS, NULL); + if (s_life_timer == NULL) + s_life_timer = lv_timer_create(life_tick_cb, LIFE_MS, NULL); + + ui_screen_load_owned(&s_screen, s_screen); + ESP_LOGI( + TAG, "octo-pet opened (H%d P%d E%d C%d Lv%d)", s_hunger, s_happy, s_energy, s_clean, s_level); +} diff --git a/firmware_p4/components/Applications/ui/screens/games/snake_ui.c b/firmware_p4/components/Applications/ui/screens/games/snake_ui.c new file mode 100644 index 000000000..3995516fc --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/games/snake_ui.c @@ -0,0 +1,317 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#include "snake_ui.h" + +#include + +#include "esp_random.h" +#include "lvgl.h" +#include "nvs.h" + +#include "buttons_gpio.h" +#include "game_fx.h" +#include "st7789.h" +#include "ui_manager.h" +#include "ui_theme.h" + +#define TICK_MS 33 +#define STEP_TICKS 4 +#define CELL 16 +#define TOPBAR 26 +#define MAX_SEG 96 +#define START_LEN 4 +#define FOOD_PLACE_TRIES 200 + +#define COL_BG 0x0A0014 +#define COL_BODY 0x9C27B0 +#define COL_HEAD 0xE040FB +#define COL_FOOD 0x00E676 + +enum { ST_PLAY, ST_DEAD }; + +static lv_obj_t *s_screen = NULL; +static lv_obj_t *s_seg[MAX_SEG]; +static lv_obj_t *s_food_obj = NULL; +static lv_obj_t *s_score_lbl = NULL; +static lv_obj_t *s_msg_panel = NULL, *s_msg_lbl = NULL; +static lv_timer_t *s_timer = NULL; + +static int s_state = ST_PLAY; +static int s_cols, s_rows, s_origin_x, s_origin_y; +static int s_bx[MAX_SEG], s_by[MAX_SEG]; +static int s_len; +static int s_dx, s_dy; +static int s_ndx, s_ndy; +static int s_food_x, s_food_y; +static int s_score; +static uint32_t s_best; +static int s_tick_acc; + +static bool s_up_last, s_down_last, s_left_last, s_right_last, s_ok_last, s_back_last; + +static uint32_t load_best(void) { + nvs_handle_t h; + uint32_t v = 0; + if (nvs_open("snake", NVS_READONLY, &h) == ESP_OK) { + nvs_get_u32(h, "best", &v); + nvs_close(h); + } + return v; +} +static void save_best(uint32_t v) { + nvs_handle_t h; + if (nvs_open("snake", NVS_READWRITE, &h) == ESP_OK) { + nvs_set_u32(h, "best", v); + nvs_commit(h); + nvs_close(h); + } +} + +static void cell_pos(lv_obj_t *o, int cx, int cy) { + lv_obj_set_pos(o, s_origin_x + cx * CELL, s_origin_y + cy * CELL); +} + +static bool on_snake(int cx, int cy) { + for (int i = 0; i < s_len; i++) + if (s_bx[i] == cx && s_by[i] == cy) + return true; + return false; +} + +static void place_food(void) { + for (int tries = 0; tries < FOOD_PLACE_TRIES; tries++) { + int cx = esp_random() % s_cols; + int cy = esp_random() % s_rows; + if (!on_snake(cx, cy)) { + s_food_x = cx; + s_food_y = cy; + break; + } + } + cell_pos(s_food_obj, s_food_x, s_food_y); +} + +static void render_body(void) { + for (int i = 0; i < MAX_SEG; i++) { + if (i < s_len) { + lv_obj_remove_flag(s_seg[i], LV_OBJ_FLAG_HIDDEN); + cell_pos(s_seg[i], s_bx[i], s_by[i]); + lv_obj_set_style_bg_color(s_seg[i], lv_color_hex(i == 0 ? COL_HEAD : COL_BODY), 0); + } else { + lv_obj_add_flag(s_seg[i], LV_OBJ_FLAG_HIDDEN); + } + } +} + +static void set_score_text(void) { + lv_label_set_text_fmt(s_score_lbl, "Score %d", s_score); +} + +static void reset_game(void) { + s_state = ST_PLAY; + s_score = 0; + s_len = START_LEN; + int sx = s_cols / 2, sy = s_rows / 2; + for (int i = 0; i < s_len; i++) { + s_bx[i] = sx - i; + s_by[i] = sy; + } + s_dx = 1; + s_dy = 0; + s_ndx = 1; + s_ndy = 0; + s_tick_acc = 0; + place_food(); + render_body(); + set_score_text(); + lv_obj_add_flag(s_msg_panel, LV_OBJ_FLAG_HIDDEN); +} + +static void die(void) { + s_state = ST_DEAD; + game_fx(GFX_CRASH); + if ((uint32_t)s_score > s_best) { + s_best = (uint32_t)s_score; + save_best(s_best); + } + lv_label_set_text_fmt(s_msg_lbl, + "GAME OVER\n\nScore %d\nBest %u\n\nOK = retry\nBACK = exit", + s_score, + (unsigned)s_best); + lv_obj_remove_flag(s_msg_panel, LV_OBJ_FLAG_HIDDEN); + lv_obj_move_foreground(s_msg_panel); +} + +static void step(void) { + if (!(s_ndx == -s_dx && s_ndy == -s_dy)) { + s_dx = s_ndx; + s_dy = s_ndy; + } + + int nhx = s_bx[0] + s_dx; + int nhy = s_by[0] + s_dy; + + if (nhx < 0 || nhx >= s_cols || nhy < 0 || nhy >= s_rows) { + die(); + return; + } + + bool grow = (nhx == s_food_x && nhy == s_food_y); + + int last = s_len - 1; + for (int i = 0; i < s_len; i++) { + if (i == last && !grow) + continue; + if (s_bx[i] == nhx && s_by[i] == nhy) { + die(); + return; + } + } + + if (grow && s_len < MAX_SEG) + s_len++; + for (int i = s_len - 1; i > 0; i--) { + s_bx[i] = s_bx[i - 1]; + s_by[i] = s_by[i - 1]; + } + s_bx[0] = nhx; + s_by[0] = nhy; + + if (grow) { + s_score++; + set_score_text(); + game_fx(GFX_EAT); + place_food(); + } + render_body(); +} + +static void tick_cb(lv_timer_t *t) { + if (lv_screen_active() != s_screen) { + lv_timer_delete(t); + s_timer = NULL; + return; + } + bool up = ui_btn_up(), down = ui_btn_down(), left = ui_btn_left(); + bool right = ui_btn_right(), ok = ok_button_is_down(), back = back_button_is_down(); + + if (!ui_input_is_locked()) { + if (back && !s_back_last) { + s_back_last = back; + ui_switch_screen(SCREEN_GAMES_MENU); + return; + } + + if (s_state == ST_PLAY) { + if (up && !s_up_last) { + s_ndx = 0; + s_ndy = -1; + } else if (down && !s_down_last) { + s_ndx = 0; + s_ndy = 1; + } else if (left && !s_left_last) { + s_ndx = -1; + s_ndy = 0; + } else if (right && !s_right_last) { + s_ndx = 1; + s_ndy = 0; + } + } else if (ok && !s_ok_last) { + reset_game(); + } + } + + if (s_state == ST_PLAY && ++s_tick_acc >= STEP_TICKS) { + s_tick_acc = 0; + step(); + } + + s_up_last = up; + s_down_last = down; + s_left_last = left; + s_right_last = right; + s_ok_last = ok; + s_back_last = back; +} + +void ui_snake_open(void) { + if (s_screen != NULL) { + lv_obj_del(s_screen); + s_screen = NULL; + } + s_up_last = s_down_last = s_left_last = s_right_last = s_ok_last = s_back_last = false; + s_best = load_best(); + + s_screen = lv_obj_create(NULL); + lv_obj_set_style_bg_color(s_screen, lv_color_hex(COL_BG), 0); + lv_obj_set_style_bg_opa(s_screen, LV_OPA_COVER, 0); + lv_obj_remove_flag(s_screen, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_pad_all(s_screen, 0, 0); + lv_obj_set_style_border_width(s_screen, 0, 0); + + int w = LCD_H_RES, h = LCD_V_RES; + s_cols = w / CELL; + s_rows = (h - TOPBAR) / CELL; + s_origin_x = (w - s_cols * CELL) / 2; + s_origin_y = TOPBAR + (h - TOPBAR - s_rows * CELL) / 2; + + s_food_obj = lv_obj_create(s_screen); + lv_obj_remove_flag(s_food_obj, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(s_food_obj, CELL - 2, CELL - 2); + lv_obj_set_style_radius(s_food_obj, LV_RADIUS_CIRCLE, 0); + lv_obj_set_style_border_width(s_food_obj, 0, 0); + lv_obj_set_style_bg_opa(s_food_obj, LV_OPA_COVER, 0); + lv_obj_set_style_bg_color(s_food_obj, lv_color_hex(COL_FOOD), 0); + + for (int i = 0; i < MAX_SEG; i++) { + s_seg[i] = lv_obj_create(s_screen); + lv_obj_remove_flag(s_seg[i], LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(s_seg[i], CELL - 1, CELL - 1); + lv_obj_set_style_radius(s_seg[i], 3, 0); + lv_obj_set_style_border_width(s_seg[i], 0, 0); + lv_obj_set_style_bg_opa(s_seg[i], LV_OPA_COVER, 0); + lv_obj_set_style_bg_color(s_seg[i], lv_color_hex(COL_BODY), 0); + lv_obj_add_flag(s_seg[i], LV_OBJ_FLAG_HIDDEN); + } + + s_score_lbl = lv_label_create(s_screen); + lv_obj_set_style_text_color(s_score_lbl, lv_color_hex(0xFFFFFF), 0); + lv_obj_set_style_text_font(s_score_lbl, &lv_font_montserrat_14, 0); + lv_obj_align(s_score_lbl, LV_ALIGN_TOP_MID, 0, 5); + + s_msg_panel = lv_obj_create(s_screen); + lv_obj_remove_flag(s_msg_panel, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(s_msg_panel, w - 60, LV_SIZE_CONTENT); + lv_obj_align(s_msg_panel, LV_ALIGN_CENTER, 0, 0); + lv_obj_set_style_radius(s_msg_panel, 14, 0); + lv_obj_set_style_bg_color(s_msg_panel, lv_color_hex(0x1A0426), 0); + lv_obj_set_style_bg_opa(s_msg_panel, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(s_msg_panel, 2, 0); + lv_obj_set_style_border_color(s_msg_panel, ui_theme_get_accent(), 0); + lv_obj_set_style_pad_all(s_msg_panel, 14, 0); + s_msg_lbl = lv_label_create(s_msg_panel); + lv_obj_set_style_text_color(s_msg_lbl, lv_color_hex(0xFFFFFF), 0); + lv_obj_set_style_text_font(s_msg_lbl, &lv_font_montserrat_14, 0); + lv_obj_set_style_text_align(s_msg_lbl, LV_TEXT_ALIGN_CENTER, 0); + lv_obj_center(s_msg_lbl); + + reset_game(); + + if (s_timer == NULL) + s_timer = lv_timer_create(tick_cb, TICK_MS, NULL); + + ui_screen_load_owned(&s_screen, s_screen); +} diff --git a/firmware_p4/components/Applications/ui/screens/gpio/gpio_ui.c b/firmware_p4/components/Applications/ui/screens/gpio/gpio_ui.c new file mode 100644 index 000000000..fe5f61e79 --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/gpio/gpio_ui.c @@ -0,0 +1,308 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#include "gpio_ui.h" + +#include "esp_log.h" +#include "lvgl.h" + +#include "ui_chrome.h" +#include "ui_manager.h" +#include "ui_theme.h" + +static const char *TAG = "GPIO_UI"; + +#define HEADER_TITLE "GPIO" +#define HINT_TEXT "OK = Toggle BACK = Exit" + +#define ON_COLOR 0x00E676 +#define HOLE_COLOR 0x05030C + +#define BODY_W 202 +#define ROW_H 26 +#define ROW_GAP 6 +#define BODY_PAD 12 +#define BODY_RADIUS 12 +#define BODY_Y (-2) + +#define PAD_SIZE 18 +#define PAD_HOLE 7 +#define COL_INSET 10 +#define LABEL_GAP 36 + +#define SEL_BORDER 2 +#define PULSE_BORDER_PEAK 5 + +#define RAIL_W 2 +#define RAIL_OPA LV_OPA_20 + +#define STATUS_GAP 12 +#define BODY_FADE_MS 220 +#define PULSE_MS 150 + +#define COLS 2 + +static const struct { + const char *name; + const char *tag; + bool on; +} PINS[] = { + {"PIN 1 (IO1)", "IO1", false}, + {"PIN 2 (IO2)", "IO2", true}, + {"PIN 3 (IO3)", "IO3", false}, + {"PIN 4 (IO4)", "IO4", false}, + {"PIN 5 (IO5)", "IO5", true}, + {"PIN 6 (IO6)", "IO6", false}, + {"PIN 7 (IO7)", "IO7", false}, + {"PIN 8 (IO8)", "IO8", false}, + {"5V on pin 1", "5V", false}, + {"USB-UART bridge", "UART", false}, +}; +#define PIN_COUNT ((int)(sizeof(PINS) / sizeof(PINS[0]))) +#define ROWS_PER_COL ((PIN_COUNT + COLS - 1) / COLS) + +static lv_obj_t *s_screen = NULL; +static lv_obj_t *s_body = NULL; +static lv_obj_t *s_pad[PIN_COUNT]; +static lv_obj_t *s_label[PIN_COUNT]; +static lv_obj_t *s_status = NULL; + +static bool s_on[PIN_COUNT]; +static int s_sel = 0; + +static void opa_anim_cb(void *var, int32_t v) { + lv_obj_set_style_opa((lv_obj_t *)var, (lv_opa_t)v, 0); +} + +static void border_width_cb(void *var, int32_t v) { + lv_obj_set_style_border_width((lv_obj_t *)var, v, 0); +} + +static void apply_pin_state(int i) { + if (i < 0 || i >= PIN_COUNT) + return; + bool on = s_on[i]; + lv_color_t on_color = lv_color_hex(ON_COLOR); + + lv_obj_set_style_bg_color(s_pad[i], on ? on_color : current_theme.bg_primary, 0); + lv_obj_set_style_bg_opa(s_pad[i], LV_OPA_COVER, 0); + + lv_obj_set_style_text_color(s_label[i], on ? on_color : current_theme.text_main, 0); + lv_obj_set_style_text_opa(s_label[i], on ? LV_OPA_COVER : LV_OPA_80, 0); +} + +static void apply_selection(void) { + for (int i = 0; i < PIN_COUNT; i++) { + bool sel = (i == s_sel); + bool on = s_on[i]; + lv_obj_set_style_border_width(s_pad[i], sel ? SEL_BORDER : 0, 0); + lv_obj_set_style_border_color(s_pad[i], current_theme.border_accent, 0); + lv_obj_set_style_border_opa(s_pad[i], sel ? LV_OPA_COVER : LV_OPA_TRANSP, 0); + + if (on) { + lv_obj_set_style_shadow_color(s_pad[i], lv_color_hex(ON_COLOR), 0); + lv_obj_set_style_shadow_width(s_pad[i], 12, 0); + lv_obj_set_style_shadow_opa(s_pad[i], LV_OPA_60, 0); + lv_obj_set_style_shadow_spread(s_pad[i], 0, 0); + } else if (sel) { + lv_obj_set_style_shadow_color(s_pad[i], current_theme.border_accent, 0); + lv_obj_set_style_shadow_width(s_pad[i], 10, 0); + lv_obj_set_style_shadow_opa(s_pad[i], LV_OPA_50, 0); + lv_obj_set_style_shadow_spread(s_pad[i], -2, 0); + } else { + lv_obj_set_style_shadow_width(s_pad[i], 0, 0); + lv_obj_set_style_shadow_opa(s_pad[i], LV_OPA_TRANSP, 0); + } + } +} + +static void update_status(void) { + bool on = s_on[s_sel]; + lv_label_set_text_fmt(s_status, "%s %s", PINS[s_sel].name, on ? "HIGH" : "LOW"); + lv_obj_set_style_text_color(s_status, on ? lv_color_hex(ON_COLOR) : current_theme.text_main, 0); + lv_obj_set_style_text_opa(s_status, on ? LV_OPA_COVER : LV_OPA_60, 0); +} + +static void pulse_pin(int i) { + if (i < 0 || i >= PIN_COUNT) + return; + lv_anim_t a; + lv_anim_init(&a); + lv_anim_set_var(&a, s_pad[i]); + lv_anim_set_exec_cb(&a, border_width_cb); + lv_anim_set_values(&a, SEL_BORDER, PULSE_BORDER_PEAK); + lv_anim_set_duration(&a, PULSE_MS); + lv_anim_set_playback_duration(&a, PULSE_MS); + lv_anim_set_path_cb(&a, lv_anim_path_ease_out); + lv_anim_start(&a); +} + +static void make_pin_row(int i) { + int col = i / ROWS_PER_COL; + int row = i % ROWS_PER_COL; + bool left = (col == 0); + + int avail = ROWS_PER_COL * ROW_H + (ROWS_PER_COL - 1) * ROW_GAP; + int y = -avail / 2 + row * (ROW_H + ROW_GAP) + ROW_H / 2; + + lv_obj_t *pad = lv_obj_create(s_body); + lv_obj_remove_flag(pad, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_remove_flag(pad, LV_OBJ_FLAG_CLICKABLE); + lv_obj_set_size(pad, PAD_SIZE, PAD_SIZE); + lv_obj_set_style_radius(pad, LV_RADIUS_CIRCLE, 0); + lv_obj_set_style_border_width(pad, 0, 0); + lv_obj_set_style_pad_all(pad, 0, 0); + lv_obj_set_style_bg_opa(pad, LV_OPA_COVER, 0); + lv_obj_align( + pad, left ? LV_ALIGN_LEFT_MID : LV_ALIGN_RIGHT_MID, left ? COL_INSET : -COL_INSET, y); + s_pad[i] = pad; + + lv_obj_t *hole = lv_obj_create(pad); + lv_obj_remove_flag(hole, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(hole, PAD_HOLE, PAD_HOLE); + lv_obj_set_style_radius(hole, LV_RADIUS_CIRCLE, 0); + lv_obj_set_style_border_width(hole, 0, 0); + lv_obj_set_style_pad_all(hole, 0, 0); + lv_obj_set_style_bg_color(hole, lv_color_hex(HOLE_COLOR), 0); + lv_obj_set_style_bg_opa(hole, LV_OPA_70, 0); + lv_obj_center(hole); + + lv_obj_t *lbl = lv_label_create(s_body); + lv_label_set_long_mode(lbl, LV_LABEL_LONG_DOT); + lv_label_set_text(lbl, PINS[i].tag); + lv_obj_set_style_text_font(lbl, &lv_font_montserrat_14, 0); + lv_obj_set_width(lbl, BODY_W / 2 - LABEL_GAP - BODY_PAD); + lv_obj_set_style_text_align(lbl, left ? LV_TEXT_ALIGN_LEFT : LV_TEXT_ALIGN_RIGHT, 0); + lv_obj_align( + lbl, left ? LV_ALIGN_LEFT_MID : LV_ALIGN_RIGHT_MID, left ? LABEL_GAP : -LABEL_GAP, y); + s_label[i] = lbl; +} + +static void build_connector(void) { + int body_h = ROWS_PER_COL * ROW_H + (ROWS_PER_COL - 1) * ROW_GAP + BODY_PAD * 2; + + s_body = lv_obj_create(s_screen); + lv_obj_remove_flag(s_body, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_remove_flag(s_body, LV_OBJ_FLAG_CLICKABLE); + lv_obj_set_size(s_body, BODY_W, body_h); + lv_obj_align(s_body, LV_ALIGN_CENTER, 0, BODY_Y); + lv_obj_set_style_pad_all(s_body, BODY_PAD, 0); + lv_obj_set_style_radius(s_body, BODY_RADIUS, 0); + lv_obj_set_style_bg_color(s_body, current_theme.bg_secondary, 0); + lv_obj_set_style_bg_opa(s_body, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(s_body, 1, 0); + lv_obj_set_style_border_color(s_body, current_theme.border_accent, 0); + lv_obj_set_style_shadow_color(s_body, current_theme.border_accent, 0); + lv_obj_set_style_shadow_width(s_body, 14, 0); + lv_obj_set_style_shadow_opa(s_body, LV_OPA_30, 0); + lv_obj_set_style_shadow_spread(s_body, -4, 0); + + lv_obj_t *rail = lv_obj_create(s_body); + lv_obj_remove_flag(rail, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(rail, RAIL_W, body_h - BODY_PAD * 2); + lv_obj_center(rail); + lv_obj_set_style_border_width(rail, 0, 0); + lv_obj_set_style_radius(rail, 1, 0); + lv_obj_set_style_bg_color(rail, current_theme.border_accent, 0); + lv_obj_set_style_bg_opa(rail, RAIL_OPA, 0); + + for (int i = 0; i < PIN_COUNT; i++) + make_pin_row(i); +} + +static void toggle_selected(void) { + s_on[s_sel] = !s_on[s_sel]; + apply_pin_state(s_sel); + apply_selection(); + pulse_pin(s_sel); + update_status(); + ESP_LOGI(TAG, "mock toggle pin %d -> %d", s_sel, s_on[s_sel]); +} + +static void move_selection(int dir) { + s_sel = (s_sel + dir + PIN_COUNT) % PIN_COUNT; + apply_selection(); + update_status(); +} + +static void gpio_input(const input_event_t *ev, void *ctx) { + (void)ctx; + const bool press = (ev->action == INPUT_ACTION_PRESS); + const bool nav = press || (ev->action == INPUT_ACTION_REPEAT); + switch (ev->button) { + case INPUT_BTN_DOWN: + if (nav) + move_selection(1); + break; + case INPUT_BTN_UP: + if (nav) + move_selection(-1); + break; + case INPUT_BTN_OK: + if (press) + toggle_selected(); + break; + case INPUT_BTN_BACK: + if (press) + ui_switch_screen(SCREEN_MENU); + break; + default: + break; + } +} + +void ui_gpio_open(void) { + if (s_screen != NULL) { + lv_obj_del(s_screen); + s_screen = NULL; + } + + s_sel = 0; + for (int i = 0; i < PIN_COUNT; i++) + s_on[i] = PINS[i].on; + + s_screen = lv_obj_create(NULL); + lv_obj_set_style_bg_color(s_screen, current_theme.screen_base, 0); + lv_obj_set_style_bg_opa(s_screen, LV_OPA_COVER, 0); + lv_obj_remove_flag(s_screen, LV_OBJ_FLAG_SCROLLABLE); + + ui_chrome_header(s_screen, HEADER_TITLE, "/assets/icons/developer_board.bin"); + build_connector(); + + for (int i = 0; i < PIN_COUNT; i++) + apply_pin_state(i); + apply_selection(); + + s_status = lv_label_create(s_screen); + lv_obj_set_style_text_font(s_status, &lv_font_montserrat_12, 0); + lv_obj_align_to(s_status, s_body, LV_ALIGN_OUT_BOTTOM_MID, 0, STATUS_GAP); + update_status(); + + ui_chrome_footer(s_screen, HINT_TEXT); + + lv_obj_set_style_opa(s_body, LV_OPA_TRANSP, 0); + lv_anim_t a; + lv_anim_init(&a); + lv_anim_set_var(&a, s_body); + lv_anim_set_exec_cb(&a, opa_anim_cb); + lv_anim_set_values(&a, LV_OPA_TRANSP, LV_OPA_COVER); + lv_anim_set_duration(&a, BODY_FADE_MS); + lv_anim_set_path_cb(&a, lv_anim_path_ease_out); + lv_anim_start(&a); + + ui_input_set_screen_handler(gpio_input, NULL); + + ui_screen_load_owned(&s_screen, s_screen); +} diff --git a/firmware_p4/components/Applications/ui/screens/wifi/include/wifi_ap_list_ui.h b/firmware_p4/components/Applications/ui/screens/gpio/include/gpio_ui.h similarity index 76% rename from firmware_p4/components/Applications/ui/screens/wifi/include/wifi_ap_list_ui.h rename to firmware_p4/components/Applications/ui/screens/gpio/include/gpio_ui.h index 5b48dc7c5..19a476879 100644 --- a/firmware_p4/components/Applications/ui/screens/wifi/include/wifi_ap_list_ui.h +++ b/firmware_p4/components/Applications/ui/screens/gpio/include/gpio_ui.h @@ -13,22 +13,23 @@ // You should have received a copy of the GNU General Public License // along with TentacleOS. If not, see . -#ifndef WIFI_AP_LIST_UI_H -#define WIFI_AP_LIST_UI_H - -#include "esp_wifi_types.h" +#ifndef UI_GPIO_H +#define UI_GPIO_H #ifdef __cplusplus extern "C" { #endif /** - * @brief Open the Wi-Fi AP list screen. + * @brief Open the GPIO control screen (MOCK). + * + * Shows a list of pins with ON/OFF toggles. OK flips the focused pin's toggle. + * No real GPIO is driven. */ -void ui_wifi_ap_list_open(void); +void ui_gpio_open(void); #ifdef __cplusplus } #endif -#endif // WIFI_AP_LIST_UI_H +#endif // UI_GPIO_H diff --git a/firmware_p4/components/Applications/ui/screens/haptic/haptic_ui.c b/firmware_p4/components/Applications/ui/screens/haptic/haptic_ui.c new file mode 100644 index 000000000..9e811d54b --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/haptic/haptic_ui.c @@ -0,0 +1,357 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#include "haptic_ui.h" + +#include + +#include "esp_log.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include "sys_prio.h" + +#include "drv2605l.h" +#include "menu_component_ui.h" +#include "ui_manager.h" +#include "ui_theme.h" + +static const char *TAG = "HAPTIC_UI"; + +#define LIVE_ROW 0 +#define CAT_ROW 1 +#define FX_ROW 2 +#define PAT_ROW 3 +#define CAL_ROW 4 + +#define HAPTIC_TASK_STACK_SIZE 2816 +#define HAPTIC_TASK_PRIORITY SYS_PRIO_SERVICE_HI + +typedef struct { + uint8_t id; + const char *name; +} fx_t; + +typedef struct { + const char *name; + const fx_t *fx; + int count; +} fx_cat_t; + +static const fx_t CLICKS[] = { + {1, "Strong Click"}, + {2, "Str Click 60"}, + {4, "Sharp Click"}, + {5, "Shp Click 60"}, + {7, "Soft Bump"}, + {10, "Double Click"}, + {12, "Triple Click"}, + {13, "Soft Fuzz"}, +}; +static const fx_t TICKS[] = { + {24, "Sharp Tick 1"}, + {25, "Sharp Tick 2"}, + {26, "Sharp Tick 3"}, + {23, "Med Click 3"}, + {17, "Med Click 1"}, +}; +static const fx_t BUZZES[] = { + {14, "Strong Buzz"}, + {47, "Buzz 1"}, + {48, "Buzz 2"}, + {49, "Buzz 3"}, + {50, "Buzz 4"}, + {51, "Buzz 5"}, + {15, "Alert 750ms"}, + {16, "Alert 1s"}, +}; +static const fx_t PULSES[] = { + {52, "Puls Strong1"}, + {53, "Puls Strong2"}, + {54, "Puls Med 1"}, + {55, "Puls Med 2"}, + {56, "Puls Sharp 1"}, + {57, "Puls Sharp 2"}, +}; +static const fx_t TRANS[] = { + {58, "Tran Click 1"}, + {64, "Tran Hum 1"}, + {82, "Ramp Up Long"}, + {88, "Ramp Up Shrt"}, + {93, "Ramp Dn Long"}, + {99, "Ramp Dn Shrt"}, +}; +static const fx_t HUMS[] = { + {119, "Smooth Hum 1"}, + {120, "Smooth Hum 2"}, + {121, "Smooth Hum 3"}, + {122, "Smooth Hum 4"}, + {123, "Smooth Hum 5"}, +}; + +#define CAT(n, arr) {n, arr, (int)(sizeof(arr) / sizeof((arr)[0]))} +static const fx_cat_t CATS[] = { + CAT("Clicks", CLICKS), + CAT("Ticks", TICKS), + CAT("Buzzes", BUZZES), + CAT("Pulses", PULSES), + CAT("Transitions", TRANS), + CAT("Hums", HUMS), +}; +#define NCAT ((int)(sizeof(CATS) / sizeof(CATS[0]))) + +enum { PAT_HEARTBEAT, PAT_SOS, PAT_NOTIFY, PAT_RAMP, PAT_THROB, PAT_COUNT }; +static const char *const PAT_NAMES[PAT_COUNT] = {"Heartbeat", "SOS", "Notify", "Ramp", "Throb"}; + +static lv_obj_t *s_screen = NULL; +static menu_component_t s_menu; +static volatile bool s_busy = false; +static volatile bool s_pat_cancel = false; +static bool s_rtp_live = false; +static int s_cat = 0, s_fx = 0, s_pat = 0; + +static uint8_t rtp_for_level(int level) { + if (level <= 0) + return 0; + if (level >= INTENSITY_BAR_STEPS) + return 127; + return (uint8_t)((level * 127) / INTENSITY_BAR_STEPS); +} + +static void apply_live_intensity(void) { + int lv = menu_component_get_intensity(&s_menu, LIVE_ROW); + uint8_t rtp = rtp_for_level(lv); + if (rtp == 0) { + drv2605l_stop(); + s_rtp_live = false; + } else { + drv2605l_set_rtp(rtp); + s_rtp_live = true; + } +} + +static void stop_live_intensity(void) { + if (s_rtp_live) { + drv2605l_stop(); + s_rtp_live = false; + } +} + +static void update_fx_label(void) { + menu_component_set_selector_value(&s_menu, FX_ROW, CATS[s_cat].fx[s_fx].name); +} + +static void play_current_fx(void) { + drv2605l_play_effect(CATS[s_cat].fx[s_fx].id); +} + +static void pat_dot(int rtp, int on_ms) { + if (s_pat_cancel) + return; + drv2605l_set_rtp((uint8_t)rtp); + vTaskDelay(pdMS_TO_TICKS(on_ms)); + drv2605l_stop(); + vTaskDelay(pdMS_TO_TICKS(110)); +} + +static void pattern_task(void *arg) { + (void)arg; + switch (s_pat) { + case PAT_HEARTBEAT: + for (int k = 0; k < 3 && !s_pat_cancel; k++) { + drv2605l_play_effect(1); + vTaskDelay(pdMS_TO_TICKS(130)); + drv2605l_play_effect(1); + vTaskDelay(pdMS_TO_TICKS(560)); + } + break; + case PAT_SOS: + for (int i = 0; i < 3 && !s_pat_cancel; i++) + pat_dot(110, 120); + vTaskDelay(pdMS_TO_TICKS(120)); + for (int i = 0; i < 3 && !s_pat_cancel; i++) + pat_dot(110, 340); + vTaskDelay(pdMS_TO_TICKS(120)); + for (int i = 0; i < 3 && !s_pat_cancel; i++) + pat_dot(110, 120); + break; + case PAT_NOTIFY: + drv2605l_play_effect(10); + vTaskDelay(pdMS_TO_TICKS(180)); + if (!s_pat_cancel) + drv2605l_play_effect(4); + break; + case PAT_RAMP: + for (int v = 0; v <= 127 && !s_pat_cancel; v += 8) { + drv2605l_set_rtp((uint8_t)v); + vTaskDelay(pdMS_TO_TICKS(22)); + } + for (int v = 127; v >= 0 && !s_pat_cancel; v -= 8) { + drv2605l_set_rtp((uint8_t)v); + vTaskDelay(pdMS_TO_TICKS(22)); + } + break; + case PAT_THROB: + for (int c = 0; c < 3 && !s_pat_cancel; c++) { + for (int a = 0; a < 32 && !s_pat_cancel; a++) { + float ph = (float)a / 32.0f * 6.2831853f; + int v = (int)((0.5f - 0.5f * cosf(ph)) * 120.0f); + drv2605l_set_rtp((uint8_t)v); + vTaskDelay(pdMS_TO_TICKS(18)); + } + } + break; + default: + break; + } + drv2605l_stop(); + s_busy = false; + vTaskDelete(NULL); +} + +static void autocal_task(void *arg) { + (void)arg; + drv2605l_autocal(); + drv2605l_play_effect(1); + s_busy = false; + vTaskDelete(NULL); +} + +static void start_worker(TaskFunction_t fn, const char *name) { + if (s_busy) + return; + s_busy = true; + s_pat_cancel = false; + if (xTaskCreatePinnedToCore( + fn, name, HAPTIC_TASK_STACK_SIZE, NULL, HAPTIC_TASK_PRIORITY, NULL, SYS_CORE_UI) != + pdPASS) + s_busy = false; +} + +static void haptic_input(const input_event_t *ev, void *ctx) { + (void)ctx; + const bool press = (ev->action == INPUT_ACTION_PRESS); + const bool nav = press || (ev->action == INPUT_ACTION_REPEAT); + + if (s_busy) { + if (press && ev->button == INPUT_BTN_BACK) + s_pat_cancel = true; + return; + } + + int sel = menu_component_get_selected(&s_menu); + + switch (ev->button) { + case INPUT_BTN_DOWN: + if (nav) { + stop_live_intensity(); + menu_component_next(&s_menu); + } + break; + case INPUT_BTN_UP: + if (nav) { + stop_live_intensity(); + menu_component_prev(&s_menu); + } + break; + case INPUT_BTN_RIGHT: + if (press) { + if (sel == LIVE_ROW) { + menu_component_intensity_inc(&s_menu, LIVE_ROW); + apply_live_intensity(); + } else if (sel == CAT_ROW) { + s_cat = (s_cat + 1) % NCAT; + s_fx = 0; + menu_component_set_selector_value(&s_menu, CAT_ROW, CATS[s_cat].name); + update_fx_label(); + } else if (sel == FX_ROW) { + s_fx = (s_fx + 1) % CATS[s_cat].count; + update_fx_label(); + play_current_fx(); + } else if (sel == PAT_ROW) { + s_pat = (s_pat + 1) % PAT_COUNT; + menu_component_set_selector_value(&s_menu, PAT_ROW, PAT_NAMES[s_pat]); + } + } + break; + case INPUT_BTN_LEFT: + if (press) { + if (sel == LIVE_ROW) { + menu_component_intensity_dec(&s_menu, LIVE_ROW); + apply_live_intensity(); + } else if (sel == CAT_ROW) { + s_cat = (s_cat - 1 + NCAT) % NCAT; + s_fx = 0; + menu_component_set_selector_value(&s_menu, CAT_ROW, CATS[s_cat].name); + update_fx_label(); + } else if (sel == FX_ROW) { + s_fx = (s_fx - 1 + CATS[s_cat].count) % CATS[s_cat].count; + update_fx_label(); + play_current_fx(); + } else if (sel == PAT_ROW) { + s_pat = (s_pat - 1 + PAT_COUNT) % PAT_COUNT; + menu_component_set_selector_value(&s_menu, PAT_ROW, PAT_NAMES[s_pat]); + } + } + break; + case INPUT_BTN_OK: + if (press) { + if (sel == LIVE_ROW) + apply_live_intensity(); + else if (sel == FX_ROW) + play_current_fx(); + else if (sel == PAT_ROW) + start_worker(pattern_task, "haptic_pat"); + else if (sel == CAL_ROW) + start_worker(autocal_task, "haptic_cal"); + } + break; + case INPUT_BTN_BACK: + if (press) { + stop_live_intensity(); + ui_switch_screen(SCREEN_SETTINGS); + } + break; + default: + break; + } +} + +void ui_haptic_open(void) { + if (s_screen != NULL) { + lv_obj_del(s_screen); + s_screen = NULL; + } + s_rtp_live = false; + s_cat = 0; + s_fx = 0; + s_pat = 0; + + s_screen = lv_obj_create(NULL); + lv_obj_set_style_bg_color(s_screen, current_theme.screen_base, 0); + lv_obj_set_style_bg_opa(s_screen, LV_OPA_COVER, 0); + lv_obj_remove_flag(s_screen, LV_OBJ_FLAG_SCROLLABLE); + + s_menu = menu_component_create(s_screen, "Vibration", "/assets/icons/vibration.bin"); + menu_component_add_intensity(&s_menu, "/assets/icons/graphic_eq.bin", "Live Intensity", 3); + menu_component_add_selector(&s_menu, "/assets/icons/category.bin", "Category", CATS[0].name); + menu_component_add_selector(&s_menu, "/assets/icons/waves.bin", "Effect", CATS[0].fx[0].name); + menu_component_add_selector(&s_menu, "/assets/icons/pattern.bin", "Pattern", PAT_NAMES[0]); + menu_component_add_item(&s_menu, "/assets/icons/tune.bin", "Calibrate ERM"); + + ui_input_set_screen_handler(haptic_input, NULL); + + ui_screen_load_owned(&s_screen, s_screen); + ESP_LOGI(TAG, "haptic menu opened"); +} diff --git a/firmware_p4/components/Applications/ui/screens/badusb/include/ui_badusb_connect.h b/firmware_p4/components/Applications/ui/screens/haptic/include/haptic_ui.h similarity index 81% rename from firmware_p4/components/Applications/ui/screens/badusb/include/ui_badusb_connect.h rename to firmware_p4/components/Applications/ui/screens/haptic/include/haptic_ui.h index 7b063116e..5381471e5 100644 --- a/firmware_p4/components/Applications/ui/screens/badusb/include/ui_badusb_connect.h +++ b/firmware_p4/components/Applications/ui/screens/haptic/include/haptic_ui.h @@ -13,18 +13,18 @@ // You should have received a copy of the GNU General Public License // along with TentacleOS. If not, see . -#ifndef UI_BADUSB_CONNECT_H -#define UI_BADUSB_CONNECT_H +#ifndef HAPTIC_UI_H +#define HAPTIC_UI_H #ifdef __cplusplus extern "C" { #endif -/** @brief Open the BadUSB connect screen. */ -void ui_badusb_connect_open(void); +/** @brief Open the haptic test menu (pick a DRV2605L effect; OK plays it). */ +void ui_haptic_open(void); #ifdef __cplusplus } #endif -#endif // UI_BADUSB_CONNECT_H +#endif // HAPTIC_UI_H diff --git a/firmware_p4/components/Applications/ui/screens/home/favorites.c b/firmware_p4/components/Applications/ui/screens/home/favorites.c new file mode 100644 index 000000000..39a419edd --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/home/favorites.c @@ -0,0 +1,85 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#include "favorites.h" + +#include + +#include "esp_log.h" +#include "nvs.h" + +static const char *TAG = "FAVORITES"; + +#define FAV_NVS_NS "favorites" +#define FAV_NVS_KEY "set" +#define FAV_BITS 32 +#define FAV_WORDS (((int)SCREEN_COUNT + FAV_BITS - 1) / FAV_BITS) + +static uint32_t s_fav[FAV_WORDS]; +static bool s_loaded = false; + +static void fav_load(void) { + if (s_loaded) { + return; + } + memset(s_fav, 0, sizeof(s_fav)); + nvs_handle_t h; + if (nvs_open(FAV_NVS_NS, NVS_READONLY, &h) == ESP_OK) { + size_t len = sizeof(s_fav); + nvs_get_blob(h, FAV_NVS_KEY, s_fav, &len); + nvs_close(h); + } + s_loaded = true; +} + +static void fav_save(void) { + nvs_handle_t h; + if (nvs_open(FAV_NVS_NS, NVS_READWRITE, &h) == ESP_OK) { + nvs_set_blob(h, FAV_NVS_KEY, s_fav, sizeof(s_fav)); + nvs_commit(h); + nvs_close(h); + } +} + +bool favorites_is(screen_id_t screen) { + fav_load(); + if ((int)screen < 0 || (int)screen >= (int)SCREEN_COUNT) { + return false; + } + return (s_fav[(int)screen / FAV_BITS] >> ((int)screen % FAV_BITS)) & 1u; +} + +void favorites_toggle(screen_id_t screen) { + fav_load(); + if ((int)screen < 0 || (int)screen >= (int)SCREEN_COUNT) { + return; + } + s_fav[(int)screen / FAV_BITS] ^= (1u << ((int)screen % FAV_BITS)); + fav_save(); + ESP_LOGI(TAG, "toggle screen %d -> %d", (int)screen, favorites_is(screen) ? 1 : 0); +} + +int favorites_count(void) { + fav_load(); + int count = 0; + for (int i = 0; i < FAV_WORDS; i++) { + uint32_t w = s_fav[i]; + while (w) { + count += (int)(w & 1u); + w >>= 1; + } + } + return count; +} diff --git a/firmware_p4/components/Applications/ui/screens/home/home_ui.c b/firmware_p4/components/Applications/ui/screens/home/home_ui.c index f7ae0efbc..53dcfe3bd 100644 --- a/firmware_p4/components/Applications/ui/screens/home/home_ui.c +++ b/firmware_p4/components/Applications/ui/screens/home/home_ui.c @@ -16,7 +16,6 @@ #include "home_ui.h" #include -#include #include "esp_heap_caps.h" #include "esp_log.h" @@ -25,8 +24,12 @@ #include "assets_manager.h" #include "core/lv_group.h" #include "dropdown_ui.h" +#include "favorites.h" #include "header_ui.h" #include "lv_port_indev.h" +#include "menu_ui.h" +#include "sys_time.h" +#include "ui_feedback.h" #include "ui_manager.h" #include "ui_theme.h" @@ -34,63 +37,313 @@ static const char *TAG = "HOME_UI"; #define HOME_HEADER_HEIGHT_PCT 9 #define HOME_HEADER_HEIGHT ((LCD_V_RES * HOME_HEADER_HEIGHT_PCT) / 100) -#define HOME_PUSH_ICON_COUNT 4 -#define HOME_ROTATION_DOWN 1800 -#define HOME_ROTATION_LEFT 2700 -#define HOME_ROTATION_RIGHT 900 + +#define HOME_ART_ASSET "/assets/img/image.bin" +#define HOME_DATE_FONT "A:assets/fonts/Inter.bin" + +#define HOME_PAD 10 +#define HOME_ROW_GAP 8 +#define HOME_INNER_W (LCD_H_RES - 2 * HOME_PAD) +#define HOME_ART_MAX_W 210 +#define HOME_ART_MAX_H 132 +#define HOME_FLOAT_AMP 5 +#define HOME_FLOAT_MS 1600 + +#define HOME_FAV_MAX 5 +#define HOME_USER_FAV_MAX (HOME_FAV_MAX - 1) +#define HOME_OCTO_NAME "OCTOBIT" +#define HOME_TAG_LEN 4 +#define HOME_FAV_GAP 8 +#define HOME_TILE_MAX 50 +#define HOME_TILE_MIN 30 +#define HOME_TILE_RADIUS 12 + +#define COL_INK 0xD3DBD8 +#define COL_LINE 0x223029 +#define COL_DIM 0x6D7A75 +#define COL_GREEN 0x00E676 static lv_obj_t *s_screen_home = NULL; +static lv_obj_t *s_fav_tiles[HOME_FAV_MAX]; +static screen_id_t s_fav_targets[HOME_FAV_MAX]; +static const char *s_fav_names[HOME_FAV_MAX]; +static int s_fav_count = 0; +static int s_fav_focus = 0; +static lv_font_t *s_date_font = NULL; static void home_event_cb(lv_event_t *e); +static void str_upper(char *s) { + for (; *s != '\0'; s++) + if (*s >= 'a' && *s <= 'z') + *s = (char)(*s - 'a' + 'A'); +} + +static void fav_tag(const char *name, char *out, size_t outsz) { + size_t j = 0; + for (size_t i = 0; name[i] != '\0' && j + 1 < outsz && j < HOME_TAG_LEN; i++) { + char ch = name[i]; + if ((ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9')) + out[j++] = ch; + else if (ch >= 'a' && ch <= 'z') + out[j++] = (char)(ch - 'a' + 'A'); + } + out[j] = '\0'; + if (j == 0 && outsz > 1) { + out[0] = '?'; + out[1] = '\0'; + } +} + +static const char *icon_for_screen(screen_id_t s) { + switch (s) { + case SCREEN_WIFI_MENU: + return "/assets/icons/wifi.bin"; + case SCREEN_BLE_MENU: + return "/assets/icons/bluetooth.bin"; + case SCREEN_NFC_MENU: + return "/assets/icons/nfc.bin"; + case SCREEN_RFID_MENU: + return "/assets/icons/contactless.bin"; + case SCREEN_IR_MENU: + return "/assets/icons/settings_input_antenna.bin"; + case SCREEN_SUBGHZ_MENU: + return "/assets/icons/cell_tower.bin"; + case SCREEN_LORA_CHAT: + return "/assets/icons/router.bin"; + case SCREEN_BADUSB_MENU: + return "/assets/icons/usb.bin"; + case SCREEN_GPIO: + return "/assets/icons/developer_board.bin"; + case SCREEN_SETTINGS: + return "/assets/icons/settings.bin"; + case SCREEN_FILES: + return "/assets/icons/folder.bin"; + case SCREEN_PLAYER: + return "/assets/icons/music_note.bin"; + case SCREEN_DEV_MENU: + return "/assets/icons/developer_board.bin"; + case SCREEN_OCTOBIT_STATUS: + return "/assets/icons/monitoring.bin"; + default: + return NULL; + } +} + +static lv_obj_t *make_group(lv_obj_t *parent, lv_flex_flow_t flow, int gap) { + lv_obj_t *g = lv_obj_create(parent); + lv_obj_set_width(g, LV_PCT(100)); + lv_obj_set_height(g, LV_SIZE_CONTENT); + lv_obj_remove_flag(g, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_bg_opa(g, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(g, 0, 0); + lv_obj_set_style_pad_all(g, 0, 0); + lv_obj_set_flex_flow(g, flow); + lv_obj_set_flex_align(g, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + if (flow == LV_FLEX_FLOW_ROW) + lv_obj_set_style_pad_column(g, gap, 0); + else + lv_obj_set_style_pad_row(g, gap, 0); + return g; +} + +static void make_fav_tile(lv_obj_t *row, int idx, int tile_sz) { + lv_obj_t *t = lv_obj_create(row); + lv_obj_set_size(t, tile_sz, tile_sz); + lv_obj_remove_flag(t, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_bg_color(t, lv_color_hex(0xFFFFFF), 0); + lv_obj_set_style_bg_opa(t, LV_OPA_10, 0); + lv_obj_set_style_border_color(t, lv_color_hex(COL_LINE), 0); + lv_obj_set_style_border_width(t, 1, 0); + lv_obj_set_style_radius(t, HOME_TILE_RADIUS, 0); + lv_obj_set_style_pad_all(t, 0, 0); + + const char *icon_path = icon_for_screen(s_fav_targets[idx]); + lv_image_dsc_t *icon = icon_path != NULL ? assets_get(icon_path) : NULL; + if (icon != NULL) { + lv_obj_t *img = lv_image_create(t); + lv_image_set_src(img, icon); + lv_image_set_antialias(img, false); + int w = icon->header.w; + int target = tile_sz - 16; + if (w > target && w > 0) { + lv_image_set_pivot(img, w / 2, icon->header.h / 2); + lv_image_set_scale(img, (uint16_t)(target * 256 / w)); + } + lv_obj_set_style_image_recolor(img, lv_color_hex(COL_INK), 0); + lv_obj_set_style_image_recolor_opa(img, LV_OPA_COVER, 0); + lv_obj_align(img, LV_ALIGN_CENTER, 0, 0); + } else { + char tag[HOME_TAG_LEN + 1]; + fav_tag(s_fav_names[idx], tag, sizeof(tag)); + lv_obj_t *l = lv_label_create(t); + lv_label_set_text(l, tag); + lv_obj_set_style_text_font(l, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(l, lv_color_hex(COL_INK), 0); + lv_obj_center(l); + } + s_fav_tiles[idx] = t; +} + +static void refresh_fav_focus(void) { + for (int i = 0; i < s_fav_count; i++) { + bool sel = (i == s_fav_focus); + lv_obj_set_style_border_color(s_fav_tiles[i], lv_color_hex(sel ? COL_GREEN : COL_LINE), 0); + lv_obj_set_style_border_width(s_fav_tiles[i], sel ? 2 : 1, 0); + lv_obj_set_style_shadow_color(s_fav_tiles[i], lv_color_hex(COL_GREEN), 0); + lv_obj_set_style_shadow_width(s_fav_tiles[i], sel ? 16 : 0, 0); + lv_obj_set_style_shadow_opa(s_fav_tiles[i], sel ? LV_OPA_40 : LV_OPA_TRANSP, 0); + } +} + +static void build_date(lv_obj_t *content) { + lv_obj_t *grp = make_group(content, LV_FLEX_FLOW_COLUMN, 2); + + char big[16]; + if (!sys_time_format(big, sizeof(big), "%d %b")) + big[0] = '\0'; + str_upper(big); + + lv_obj_t *date = lv_label_create(grp); + lv_label_set_text(date, big); + lv_obj_set_style_text_font(date, s_date_font != NULL ? s_date_font : &lv_font_montserrat_16, 0); + lv_obj_set_style_text_color(date, lv_color_hex(COL_GREEN), 0); + + char sub[24]; + if (!sys_time_format(sub, sizeof(sub), "%A %Y")) + sub[0] = '\0'; + str_upper(sub); + + lv_obj_t *wk = lv_label_create(grp); + lv_label_set_text(wk, sub); + lv_obj_set_style_text_font(wk, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(wk, lv_color_hex(COL_DIM), 0); + lv_obj_set_style_text_letter_space(wk, 2, 0); +} + +static void float_anim_cb(void *var, int32_t v) { + lv_obj_set_style_translate_y((lv_obj_t *)var, v, 0); +} + +static void build_octobit(lv_obj_t *content) { + lv_obj_t *stage = lv_obj_create(content); + lv_obj_set_width(stage, LV_PCT(100)); + lv_obj_set_height(stage, 0); + lv_obj_set_flex_grow(stage, 1); + lv_obj_remove_flag(stage, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_bg_opa(stage, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(stage, 0, 0); + lv_obj_set_style_pad_all(stage, 0, 0); + + static lv_image_dsc_t *s_art_dsc = NULL; + if (s_art_dsc == NULL) + s_art_dsc = assets_get(HOME_ART_ASSET); + if (s_art_dsc == NULL) + return; + + lv_obj_t *art = lv_image_create(stage); + lv_image_set_src(art, s_art_dsc); + lv_image_set_antialias(art, false); + + int w = s_art_dsc->header.w; + int h = s_art_dsc->header.h; + int sx = (w > 0) ? (HOME_ART_MAX_W * 256 / w) : 256; + int sy = (h > 0) ? (HOME_ART_MAX_H * 256 / h) : 256; + int scale = (sx < sy) ? sx : sy; + if (scale > 256) + scale = 256; + if (scale != 256) { + lv_image_set_pivot(art, w / 2, h / 2); + lv_image_set_scale(art, (uint16_t)scale); + } + lv_obj_align(art, LV_ALIGN_CENTER, 0, 0); + + lv_anim_t a; + lv_anim_init(&a); + lv_anim_set_var(&a, art); + lv_anim_set_values(&a, -HOME_FLOAT_AMP, HOME_FLOAT_AMP); + lv_anim_set_duration(&a, HOME_FLOAT_MS); + lv_anim_set_playback_duration(&a, HOME_FLOAT_MS); + lv_anim_set_repeat_count(&a, LV_ANIM_REPEAT_INFINITE); + lv_anim_set_path_cb(&a, lv_anim_path_ease_in_out); + lv_anim_set_exec_cb(&a, float_anim_cb); + lv_anim_start(&a); +} + +static void build_favorites(lv_obj_t *content) { + s_fav_count = 0; + s_fav_focus = 0; + + int n = menu_catalog_count(); + for (int i = 0; i < n && s_fav_count < HOME_USER_FAV_MAX; i++) { + screen_id_t tgt = menu_catalog_target(i); + if (favorites_is(tgt)) { + s_fav_targets[s_fav_count] = tgt; + s_fav_names[s_fav_count] = menu_catalog_name(i); + s_fav_count++; + } + } + + if (s_fav_count == 0) { + s_fav_targets[s_fav_count] = SCREEN_SETTINGS; + s_fav_names[s_fav_count] = "CONFIG"; + s_fav_count++; + } + + s_fav_targets[s_fav_count] = SCREEN_OCTOBIT_STATUS; + s_fav_names[s_fav_count] = HOME_OCTO_NAME; + s_fav_count++; + + int tile = (HOME_INNER_W - (s_fav_count - 1) * HOME_FAV_GAP) / s_fav_count; + if (tile > HOME_TILE_MAX) + tile = HOME_TILE_MAX; + if (tile < HOME_TILE_MIN) + tile = HOME_TILE_MIN; + + lv_obj_t *favs = make_group(content, LV_FLEX_FLOW_ROW, HOME_FAV_GAP); + for (int i = 0; i < s_fav_count; i++) + make_fav_tile(favs, i, tile); + refresh_fav_focus(); +} + void ui_home_open(void) { + ESP_LOGI(TAG, + "home open: free=%u largest=%u", + (unsigned)heap_caps_get_free_size(MALLOC_CAP_INTERNAL), + (unsigned)heap_caps_get_largest_free_block(MALLOC_CAP_INTERNAL)); + if (s_screen_home != NULL) { lv_obj_del(s_screen_home); s_screen_home = NULL; } + if (s_date_font == NULL) + s_date_font = lv_binfont_create(HOME_DATE_FONT); + s_screen_home = lv_obj_create(NULL); lv_obj_set_style_bg_color(s_screen_home, current_theme.screen_base, 0); lv_obj_set_style_bg_opa(s_screen_home, LV_OPA_COVER, 0); lv_obj_remove_flag(s_screen_home, LV_OBJ_FLAG_SCROLLABLE); header_ui_create(s_screen_home); - dropdown_ui_create(s_screen_home); - - static lv_image_dsc_t *s_push_dsc = NULL; - if (s_push_dsc == NULL) - s_push_dsc = assets_get("/assets/icons/push_icon.bin"); - - if (s_push_dsc != NULL) { - int16_t w = s_push_dsc->header.w; - int16_t h = s_push_dsc->header.h; - - static lv_obj_t *s_push_icons[HOME_PUSH_ICON_COUNT]; - s_push_icons[0] = lv_image_create(s_screen_home); - lv_image_set_src(s_push_icons[0], s_push_dsc); - lv_obj_align(s_push_icons[0], LV_ALIGN_TOP_MID, 0, HOME_HEADER_HEIGHT); + lv_obj_t *content = lv_obj_create(s_screen_home); + lv_obj_set_size(content, LCD_H_RES, LCD_V_RES - HOME_HEADER_HEIGHT); + lv_obj_align(content, LV_ALIGN_TOP_MID, 0, HOME_HEADER_HEIGHT); + lv_obj_remove_flag(content, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_bg_opa(content, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(content, 0, 0); + lv_obj_set_style_pad_left(content, HOME_PAD, 0); + lv_obj_set_style_pad_right(content, HOME_PAD, 0); + lv_obj_set_style_pad_top(content, 8, 0); + lv_obj_set_style_pad_bottom(content, HOME_PAD, 0); + lv_obj_set_style_pad_row(content, HOME_ROW_GAP, 0); + lv_obj_set_flex_flow(content, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(content, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); - s_push_icons[1] = lv_image_create(s_screen_home); - lv_image_set_src(s_push_icons[1], s_push_dsc); - lv_image_set_pivot(s_push_icons[1], w / 2, h / 2); - lv_image_set_rotation(s_push_icons[1], HOME_ROTATION_DOWN); - lv_obj_align(s_push_icons[1], LV_ALIGN_BOTTOM_MID, 0, 0); - - s_push_icons[2] = lv_image_create(s_screen_home); - lv_image_set_src(s_push_icons[2], s_push_dsc); - lv_image_set_pivot(s_push_icons[2], w / 2, h / 2); - lv_image_set_rotation(s_push_icons[2], HOME_ROTATION_LEFT); - lv_obj_align(s_push_icons[2], LV_ALIGN_LEFT_MID, -h / 2, 0); - - s_push_icons[3] = lv_image_create(s_screen_home); - lv_image_set_src(s_push_icons[3], s_push_dsc); - lv_image_set_pivot(s_push_icons[3], w / 2, h / 2); - lv_image_set_rotation(s_push_icons[3], HOME_ROTATION_RIGHT); - lv_obj_align(s_push_icons[3], LV_ALIGN_RIGHT_MID, h / 2, 0); - - dropdown_ui_register_hide_objs(s_push_icons, HOME_PUSH_ICON_COUNT); - } + build_date(content); + build_octobit(content); + build_favorites(content); lv_obj_add_event_cb(s_screen_home, home_event_cb, LV_EVENT_KEY, NULL); @@ -99,18 +352,38 @@ void ui_home_open(void) { lv_group_focus_obj(s_screen_home); } - lv_screen_load(s_screen_home); + ui_screen_load_owned(&s_screen_home, s_screen_home); } static void home_event_cb(lv_event_t *e) { - (void)e; if (dropdown_ui_is_open()) return; - lv_event_code_t code = lv_event_get_code(e); - if (code == LV_EVENT_KEY) { - uint32_t key = lv_event_get_key(e); + if (ui_input_is_locked()) + return; + + if (lv_event_get_code(e) != LV_EVENT_KEY) + return; + + uint32_t key = lv_event_get_key(e); + + if (key == LV_KEY_LEFT || key == LV_KEY_RIGHT) { if (key == LV_KEY_RIGHT) - ui_switch_screen(SCREEN_MENU); + s_fav_focus = (s_fav_focus + 1) % s_fav_count; + else + s_fav_focus = (s_fav_focus == 0) ? s_fav_count - 1 : s_fav_focus - 1; + ui_feedback(UI_FB_NAV); + refresh_fav_focus(); + return; + } + + if (key == LV_KEY_ENTER) { + ui_switch_screen(s_fav_targets[s_fav_focus]); + return; } -} \ No newline at end of file + + if (key == LV_KEY_DOWN) { + ui_switch_screen(SCREEN_MENU); + return; + } +} diff --git a/firmware_p4/components/Applications/ui/screens/home/include/favorites.h b/firmware_p4/components/Applications/ui/screens/home/include/favorites.h new file mode 100644 index 000000000..ac82df8b3 --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/home/include/favorites.h @@ -0,0 +1,47 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#ifndef FAVORITES_H +#define FAVORITES_H + +#ifdef __cplusplus +extern "C" { +#endif + +#include + +#include "ui_manager.h" + +/** + * @brief Home favorites: a persistent set of app screens the user pinned. + * + * Backed by NVS (a bitmask over screen IDs), so it survives reboots and works + * without an SD card. The Menu toggles entries; the Home renders the pinned apps. + */ + +/** @brief True if @p screen is currently a favorite. */ +bool favorites_is(screen_id_t screen); + +/** @brief Toggle @p screen in/out of the favorites set and persist. */ +void favorites_toggle(screen_id_t screen); + +/** @brief Number of screens currently favorited. */ +int favorites_count(void); + +#ifdef __cplusplus +} +#endif + +#endif // FAVORITES_H diff --git a/firmware_p4/components/Applications/ui/screens/home/include/sys_metrics.h b/firmware_p4/components/Applications/ui/screens/home/include/sys_metrics.h new file mode 100644 index 000000000..a2d6bbcff --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/home/include/sys_metrics.h @@ -0,0 +1,42 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#ifndef SYS_METRICS_H +#define SYS_METRICS_H + +#ifdef __cplusplus +extern "C" { +#endif + +#include + +/** + * @brief Read the P4 die temperature in Celsius. + * + * The chip has a single temperature-sensor peripheral, so this owns one shared + * handle installed on first use — any screen may call it without clashing over + * the driver. Returns false (and leaves @p out_celsius untouched) if the sensor + * is unavailable. + * + * @param[out] out_celsius Receives the temperature on success. + * @return true on a valid reading, false otherwise. + */ +bool sys_metrics_die_temp_c(float *out_celsius); + +#ifdef __cplusplus +} +#endif + +#endif // SYS_METRICS_H diff --git a/firmware_p4/components/Applications/ui/screens/home/sys_metrics.c b/firmware_p4/components/Applications/ui/screens/home/sys_metrics.c new file mode 100644 index 000000000..db2554542 --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/home/sys_metrics.c @@ -0,0 +1,51 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#include "sys_metrics.h" + +#include "driver/temperature_sensor.h" + +#define SM_TEMP_MIN_C (-10) +#define SM_TEMP_MAX_C 80 + +static temperature_sensor_handle_t s_tsens = NULL; +static bool s_tsens_failed = false; + +bool sys_metrics_die_temp_c(float *out_celsius) { + if (out_celsius == NULL || s_tsens_failed) { + return false; + } + if (s_tsens == NULL) { + temperature_sensor_config_t cfg = + TEMPERATURE_SENSOR_CONFIG_DEFAULT(SM_TEMP_MIN_C, SM_TEMP_MAX_C); + if (temperature_sensor_install(&cfg, &s_tsens) != ESP_OK) { + s_tsens = NULL; + s_tsens_failed = true; + return false; + } + if (temperature_sensor_enable(s_tsens) != ESP_OK) { + temperature_sensor_uninstall(s_tsens); + s_tsens = NULL; + s_tsens_failed = true; + return false; + } + } + float celsius = 0.0f; + if (temperature_sensor_get_celsius(s_tsens, &celsius) != ESP_OK) { + return false; + } + *out_celsius = celsius; + return true; +} diff --git a/firmware_p4/components/Applications/ui/screens/infrared/include/ir_controller_ui.h b/firmware_p4/components/Applications/ui/screens/infrared/include/ir_controller_ui.h index e27eec777..9c8b883d0 100644 --- a/firmware_p4/components/Applications/ui/screens/infrared/include/ir_controller_ui.h +++ b/firmware_p4/components/Applications/ui/screens/infrared/include/ir_controller_ui.h @@ -20,6 +20,22 @@ extern "C" { #endif +/** + * @brief Appliance layout the remote controller renders. + */ +typedef enum { + IR_DEV_TV, ///< Television remote layout + IR_DEV_SOUND, ///< Sound system remote layout + IR_DEV_AC, ///< Air conditioner remote layout +} ir_device_t; + +/** + * @brief Choose which appliance layout the next ui_ir_controller_open() shows. + * + * @param dev Appliance layout to render on the next open. + */ +void ui_ir_controller_set_device(ir_device_t dev); + /** * @brief Open the infrared remote controller screen. */ diff --git a/firmware_p4/components/Applications/ui/screens/infrared/include/ir_raw_ui.h b/firmware_p4/components/Applications/ui/screens/infrared/include/ir_raw_ui.h new file mode 100644 index 000000000..f2deb0af6 --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/infrared/include/ir_raw_ui.h @@ -0,0 +1,30 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#ifndef IR_RAW_UI_H +#define IR_RAW_UI_H + +#ifdef __cplusplus +extern "C" { +#endif + +/** @brief Open the infrared RAW signal scope screen (pulse-train mock). */ +void ui_ir_raw_open(void); + +#ifdef __cplusplus +} +#endif + +#endif // IR_RAW_UI_H diff --git a/firmware_p4/components/Applications/ui/screens/infrared/include/ir_remote_type_ui.h b/firmware_p4/components/Applications/ui/screens/infrared/include/ir_remote_type_ui.h new file mode 100644 index 000000000..96c4344e2 --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/infrared/include/ir_remote_type_ui.h @@ -0,0 +1,30 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#ifndef IR_REMOTE_TYPE_UI_H +#define IR_REMOTE_TYPE_UI_H + +#ifdef __cplusplus +extern "C" { +#endif + +/** @brief Pick the appliance type (TV / Sound / AC) for the IR remote. */ +void ui_ir_remote_type_open(void); + +#ifdef __cplusplus +} +#endif + +#endif // IR_REMOTE_TYPE_UI_H diff --git a/firmware_p4/components/Applications/ui/screens/infrared/include/ir_store.h b/firmware_p4/components/Applications/ui/screens/infrared/include/ir_store.h new file mode 100644 index 000000000..05b1ceade --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/infrared/include/ir_store.h @@ -0,0 +1,154 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +// Thin bridge between the infrared UI screens and the Service/ir library: +// - /sdcard/ir/*.ir file storage (list / load / save / delete / rename) +// - blocking TX wrappers that lazily bring the RMT TX channel up +// - a single background RX capture task (ir_receive() blocks, so it cannot +// run inside an lv_timer) with a queue hand-off polled from the UI thread. +// The screens keep their exact layout; only the data and effects become real. + +#ifndef IR_STORE_H +#define IR_STORE_H + +#ifdef __cplusplus +extern "C" { +#endif + +#include +#include +#include + +#include "esp_err.h" +#include "driver/rmt_types.h" + +#include "ir.h" +#include "ir_file.h" + +/** @brief Max signal files surfaced by ir_store_list(). */ +#define IR_STORE_MAX_ENTRIES 64 +/** @brief Max length of a stored signal name (stem, no extension). */ +#define IR_STORE_NAME_MAX IR_FILE_NAME_MAX +/** @brief Full path buffer length for a stored signal. */ +#define IR_STORE_PATH_MAX 96 + +/** + * @brief One .ir file on disk, summarized for list rows. + */ +typedef struct { + char name[IR_STORE_NAME_MAX]; ///< File stem, no ".ir". + char path[IR_STORE_PATH_MAX]; ///< Full path under /sdcard/ir. + char proto[16]; ///< First signal's protocol name, or "RAW". +} ir_store_entry_t; + +/** + * @brief List *.ir files under /sdcard/ir. + * + * Missing directory is treated as empty (returns 0), not an error. + * + * @param[out] out Destination array. Must not be NULL. + * @param[in] max Capacity of @p out. + * @return Number of entries written (0..max), or -1 on argument error. + */ +int ir_store_list(ir_store_entry_t *out, int max); + +/** + * @brief Read and parse a .ir file into @p file. + * + * Caller owns @p file: ir_file_init() before, ir_file_free() after. + */ +esp_err_t ir_store_load(const char *path, ir_file_t *file); + +/** @brief True if /sdcard/ir/.ir already exists. */ +bool ir_store_exists(const char *name); + +/** @brief Save one decoded frame as /sdcard/ir/.ir (creates the dir). */ +esp_err_t ir_store_save_data(const char *name, const ir_data_t *data); + +/** @brief Save a raw symbol capture as /sdcard/ir/.ir (creates the dir). */ +esp_err_t +ir_store_save_raw(const char *name, const rmt_symbol_word_t *symbols, size_t count, uint32_t freq); + +/** @brief Delete /sdcard/ir/.ir. */ +esp_err_t ir_store_delete(const char *name); + +/** @brief Rename /sdcard/ir/.ir to .ir. */ +esp_err_t ir_store_rename(const char *old_name, const char *new_name); + +/** + * @brief Build a stable file name for a decoded frame, e.g. "NEC_04_08". + * + * @param[in] data Decoded frame. Must not be NULL. + * @param[out] buf Destination. Must not be NULL. + * @param[in] cap Capacity of @p buf. + */ +void ir_store_name_for_data(const ir_data_t *data, char *buf, size_t cap); + +/** + * @brief Pick the next free "_NNN" name that does not exist yet. + */ +void ir_store_next_free(const char *prefix, char *buf, size_t cap); + +/** @brief Transmit a decoded frame (brings TX up first). */ +esp_err_t ir_store_send_data(const ir_data_t *data); + +/** @brief Transmit a parsed/raw signal from a loaded file (brings TX up first). */ +esp_err_t ir_store_send_signal(const ir_signal_t *signal); + +/** @brief Transmit a raw symbol buffer (brings TX up first). */ +esp_err_t ir_store_send_raw(const rmt_symbol_word_t *symbols, size_t count, uint32_t freq); + +/** + * @brief Send every signal in @p file whose name matches @p name (case-insensitive). + * + * Universal-remote brute force: one button maps to many brand codes. Blocking — + * call at most a few per UI tick. + * + * @return Number of signals transmitted. + */ +int ir_store_send_named(const ir_file_t *file, const char *name); + +/** @brief Capture task status. */ +typedef enum { + IR_CAP_IDLE = 0, ///< No capture started (or reset). + IR_CAP_BUSY, ///< Capture task running. + IR_CAP_GOT, ///< A frame arrived (decoded, or raw-only with UNKNOWN protocol). + IR_CAP_TIMEOUT, ///< Window elapsed with no frame. + IR_CAP_ERROR, ///< RX init or driver error. +} ir_cap_status_t; + +/** + * @brief Start a one-shot RX capture in a background task (brings RX up first). + * + * No-op returning ESP_ERR_INVALID_STATE if a capture is already running. + */ +esp_err_t ir_capture_start(uint32_t timeout_ms); + +/** + * @brief Poll the capture. When IR_CAP_GOT and @p out != NULL, copies the frame. + */ +ir_cap_status_t ir_capture_poll(ir_data_t *out); + +/** @brief True if the captured frame decoded to a known protocol (not raw-only). */ +bool ir_capture_decoded(void); + +/** @brief Return to IDLE if no task is running (call on screen enter/exit). */ +void ir_capture_reset(void); + +#ifdef __cplusplus +} +#endif + +#endif // IR_STORE_H diff --git a/firmware_p4/components/Applications/ui/screens/infrared/ir_burst_ui.c b/firmware_p4/components/Applications/ui/screens/infrared/ir_burst_ui.c index d7315a72e..33218c523 100644 --- a/firmware_p4/components/Applications/ui/screens/infrared/ir_burst_ui.c +++ b/firmware_p4/components/Applications/ui/screens/infrared/ir_burst_ui.c @@ -15,287 +15,272 @@ #include "ir_burst_ui.h" -#include #include -#include -#include #include "esp_log.h" -#include "freertos/FreeRTOS.h" -#include "freertos/task.h" -#include "st7789.h" - -#include "buttons_gpio.h" -#include "ir.h" -#include "ir_file.h" -#include "msgbox_ui.h" -#include "spinner_ui.h" -#include "tos_storage_paths.h" +#include "lvgl.h" + +#include "ir_store.h" +#include "notify_ui.h" +#include "sigwave_ui.h" +#include "ui_chrome.h" +#include "ui_feedback.h" #include "ui_manager.h" #include "ui_theme.h" static const char *TAG = "IR_BURST_UI"; -#define OUTER_BORDER 4 -#define TOP_BORDER_H 46 -#define TOP_AREA_BORDER_WIDTH 3 -#define TITLE_BAR_W 170 -#define TITLE_BAR_H 30 -#define TITLE_BAR_RADIUS 12 -#define TITLE_BAR_BORDER_WIDTH 2 -#define STATUS_LABEL_OFFSET_Y (-10) -#define COUNT_LABEL_OFFSET_Y 15 -#define SPINNER_OFFSET_Y (-20) -#define SPINNER_SIZE 30 -#define NAV_TIMER_INTERVAL_MS 50 -#define BURST_DELAY_MS 150 -#define BURST_TASK_STACK_SIZE 8192 -#define BURST_TASK_PRIORITY 5 -#define BURST_STOP_WAIT_MS 50 -#define IR_FILE_MAX_SIZE 4096 -#define SUB_PATH_MAX_LEN 512 -#define FILE_PATH_MAX_LEN 600 -#define IR_FILE_EXT ".ir" -#define IR_FILE_EXT_LEN 3 +#define SIG_GREEN 0x00E676 +#define COL_DIM 0x8A8594 +#define BAR_TRACK 0x202028 + +#define IR_BURST_ICON "/assets/icons/bolt.bin" + +#define STATUS_Y 48 +#define COUNT_Y 72 +#define BAR_Y 94 +#define BAR_W 200 +#define BAR_H 8 + +#define CARD_W 200 +#define CARD_H 86 +#define CARD_RADIUS 13 +#define CARD_Y_OFS 16 +#define CARD_RISE_PX 26 +#define CARD_RISE_MS 300 + +#define LOG_Y -30 + +#define BURST_TICK_MS 180 + +#define STATUS_BUSY "Bursting..." +#define STATUS_DONE "Burst complete!" +#define STATUS_EMPTY "No saved signals" +#define HINT_BUSY "BACK to cancel" +#define HINT_DONE "BACK = Exit" static lv_obj_t *s_screen = NULL; -static lv_timer_t *s_nav_timer = NULL; +static lv_timer_t *s_burst_timer = NULL; static lv_obj_t *s_status_label = NULL; static lv_obj_t *s_count_label = NULL; -static spinner_ui_t s_spinner; +static lv_obj_t *s_bar = NULL; +static lv_obj_t *s_card = NULL; +static lv_obj_t *s_caption = NULL; +static lv_obj_t *s_sig = NULL; +static lv_obj_t *s_log_label = NULL; +static lv_obj_t *s_footer = NULL; +static int s_sent = 0; + +static ir_store_entry_t s_entries[IR_STORE_MAX_ENTRIES]; +static int s_total = 0; + +static void ir_burst_input(const input_event_t *ev, void *ctx); +static void burst_tick_cb(lv_timer_t *timer); + +static void transy_cb(void *var, int32_t v) { + lv_obj_set_style_translate_y((lv_obj_t *)var, v, 0); +} -static bool s_btn_back_last = false; -static TaskHandle_t s_burst_task = NULL; -static volatile bool s_is_done = false; -static volatile bool s_stop_requested = false; -static volatile int s_sent_count = 0; -static volatile int s_total_count = 0; +static void opa_cb(void *var, int32_t v) { + lv_obj_set_style_opa((lv_obj_t *)var, (lv_opa_t)v, 0); +} + +static lv_obj_t *lit_panel(lv_obj_t *parent, int w, int h) { + lv_obj_t *p = lv_obj_create(parent); + lv_obj_remove_flag(p, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(p, w, h); + lv_obj_set_style_radius(p, CARD_RADIUS, 0); + lv_obj_set_style_bg_color(p, current_theme.bg_secondary, 0); + lv_obj_set_style_bg_grad_dir(p, LV_GRAD_DIR_NONE, 0); + lv_obj_set_style_bg_opa(p, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(p, 1, 0); + lv_obj_set_style_border_color(p, current_theme.border_accent, 0); + lv_obj_set_style_border_opa(p, LV_OPA_COVER, 0); + lv_obj_set_style_shadow_color(p, current_theme.border_accent, 0); + lv_obj_set_style_shadow_width(p, 16, 0); + lv_obj_set_style_shadow_opa(p, LV_OPA_40, 0); + lv_obj_set_style_shadow_spread(p, -4, 0); + lv_obj_set_style_pad_all(p, 8, 0); + return p; +} -static void send_one_file(const char *path); -static void burst_task(void *pvParameters); -static void nav_timer_cb(lv_timer_t *timer); +static void card_rise(lv_obj_t *obj) { + lv_anim_t a; + lv_anim_init(&a); + lv_anim_set_var(&a, obj); + lv_anim_set_exec_cb(&a, transy_cb); + lv_anim_set_values(&a, CARD_RISE_PX, 0); + lv_anim_set_duration(&a, CARD_RISE_MS); + lv_anim_set_path_cb(&a, lv_anim_path_ease_out); + lv_anim_start(&a); + + lv_anim_t f; + lv_anim_init(&f); + lv_anim_set_var(&f, obj); + lv_anim_set_exec_cb(&f, opa_cb); + lv_anim_set_values(&f, LV_OPA_TRANSP, LV_OPA_COVER); + lv_anim_set_duration(&f, 280); + lv_anim_set_path_cb(&f, lv_anim_path_ease_out); + lv_anim_start(&f); +} void ui_ir_burst_open(void) { if (s_screen != NULL) { lv_obj_del(s_screen); s_screen = NULL; } + s_sent = 0; + s_burst_timer = NULL; + s_card = NULL; + s_sig = NULL; + s_caption = NULL; - s_is_done = false; - s_stop_requested = false; - s_sent_count = 0; - s_total_count = 0; - s_burst_task = NULL; + s_total = ir_store_list(s_entries, IR_STORE_MAX_ENTRIES); + if (s_total < 0) + s_total = 0; s_screen = lv_obj_create(NULL); lv_obj_set_style_bg_color(s_screen, current_theme.screen_base, 0); lv_obj_set_style_bg_opa(s_screen, LV_OPA_COVER, 0); lv_obj_remove_flag(s_screen, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_set_style_border_width(s_screen, OUTER_BORDER, 0); - lv_obj_set_style_border_color(s_screen, current_theme.border_interface, 0); + lv_obj_set_style_border_width(s_screen, 0, 0); lv_obj_set_style_pad_all(s_screen, 0, 0); - lv_obj_t *top_area = lv_obj_create(s_screen); - lv_obj_set_size(top_area, LCD_H_RES - OUTER_BORDER * 2, TOP_BORDER_H); - lv_obj_align(top_area, LV_ALIGN_TOP_MID, 0, 0); - lv_obj_remove_flag(top_area, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_set_style_bg_opa(top_area, LV_OPA_TRANSP, 0); - lv_obj_set_style_border_width(top_area, TOP_AREA_BORDER_WIDTH, 0); - lv_obj_set_style_border_color(top_area, current_theme.border_interface, 0); - lv_obj_set_style_border_side(top_area, LV_BORDER_SIDE_BOTTOM, 0); - lv_obj_set_style_radius(top_area, 0, 0); - lv_obj_set_style_pad_all(top_area, 0, 0); - - lv_obj_t *title_bar = lv_obj_create(top_area); - lv_obj_set_size(title_bar, TITLE_BAR_W, TITLE_BAR_H); - lv_obj_align(title_bar, LV_ALIGN_CENTER, 0, 0); - lv_obj_remove_flag(title_bar, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_set_style_radius(title_bar, TITLE_BAR_RADIUS, 0); - lv_obj_set_style_bg_opa(title_bar, LV_OPA_COVER, 0); - lv_obj_set_style_bg_color(title_bar, current_theme.bg_primary, 0); - lv_obj_set_style_bg_grad_color(title_bar, current_theme.bg_secondary, 0); - lv_obj_set_style_bg_grad_dir(title_bar, LV_GRAD_DIR_HOR, 0); - lv_obj_set_style_border_width(title_bar, TITLE_BAR_BORDER_WIDTH, 0); - lv_obj_set_style_border_color(title_bar, current_theme.border_accent, 0); - - lv_obj_t *title_lbl = lv_label_create(title_bar); - lv_label_set_text(title_lbl, "IR BURST"); - lv_obj_set_style_text_color(title_lbl, current_theme.text_main, 0); - lv_obj_set_style_text_font(title_lbl, &lv_font_montserrat_14, 0); - lv_obj_center(title_lbl); + ui_chrome_header(s_screen, "IR BURST", IR_BURST_ICON); s_status_label = lv_label_create(s_screen); - lv_label_set_text(s_status_label, "Burst running..."); + lv_label_set_text(s_status_label, s_total > 0 ? STATUS_BUSY : STATUS_EMPTY); lv_obj_set_style_text_color(s_status_label, current_theme.text_main, 0); lv_obj_set_style_text_font(s_status_label, &lv_font_montserrat_14, 0); lv_obj_set_style_text_align(s_status_label, LV_TEXT_ALIGN_CENTER, 0); - lv_obj_align(s_status_label, LV_ALIGN_CENTER, 0, STATUS_LABEL_OFFSET_Y); + lv_obj_align(s_status_label, LV_ALIGN_TOP_MID, 0, STATUS_Y); s_count_label = lv_label_create(s_screen); - lv_label_set_text(s_count_label, "Scanning files..."); - lv_obj_set_style_text_color(s_count_label, current_theme.border_accent, 0); + lv_label_set_text_fmt(s_count_label, "0 / %d files", s_total); + lv_obj_set_style_text_color(s_count_label, lv_color_hex(COL_DIM), 0); lv_obj_set_style_text_font(s_count_label, &lv_font_montserrat_12, 0); lv_obj_set_style_text_align(s_count_label, LV_TEXT_ALIGN_CENTER, 0); - lv_obj_align(s_count_label, LV_ALIGN_CENTER, 0, COUNT_LABEL_OFFSET_Y); - - s_spinner = spinner_ui_create(s_screen, SPINNER_SIZE); - lv_obj_align(s_spinner.obj, LV_ALIGN_BOTTOM_MID, 0, SPINNER_OFFSET_Y); - - if (s_nav_timer == NULL) - s_nav_timer = lv_timer_create(nav_timer_cb, NAV_TIMER_INTERVAL_MS, NULL); - - xTaskCreate( - burst_task, "ir_burst", BURST_TASK_STACK_SIZE, NULL, BURST_TASK_PRIORITY, &s_burst_task); - - lv_screen_load(s_screen); + lv_obj_align(s_count_label, LV_ALIGN_TOP_MID, 0, COUNT_Y); + + s_bar = lv_bar_create(s_screen); + lv_obj_set_size(s_bar, BAR_W, BAR_H); + lv_obj_align(s_bar, LV_ALIGN_TOP_MID, 0, BAR_Y); + lv_obj_set_style_radius(s_bar, 4, LV_PART_MAIN); + lv_obj_set_style_bg_color(s_bar, lv_color_hex(BAR_TRACK), LV_PART_MAIN); + lv_obj_set_style_bg_grad_dir(s_bar, LV_GRAD_DIR_NONE, LV_PART_MAIN); + lv_obj_set_style_bg_opa(s_bar, LV_OPA_COVER, LV_PART_MAIN); + lv_obj_set_style_radius(s_bar, 4, LV_PART_INDICATOR); + lv_obj_set_style_bg_color(s_bar, current_theme.border_accent, LV_PART_INDICATOR); + lv_obj_set_style_bg_grad_dir(s_bar, LV_GRAD_DIR_NONE, LV_PART_INDICATOR); + lv_obj_set_style_bg_opa(s_bar, LV_OPA_COVER, LV_PART_INDICATOR); + lv_bar_set_range(s_bar, 0, s_total > 0 ? s_total : 1); + lv_bar_set_value(s_bar, 0, LV_ANIM_OFF); + + s_card = lit_panel(s_screen, CARD_W, CARD_H); + lv_obj_align(s_card, LV_ALIGN_CENTER, 0, CARD_Y_OFS); + + s_caption = lv_label_create(s_card); + lv_label_set_text(s_caption, "IR emitter 38 kHz"); + lv_obj_set_style_text_color(s_caption, current_theme.border_accent, 0); + lv_obj_set_style_text_font(s_caption, &lv_font_montserrat_12, 0); + lv_obj_align(s_caption, LV_ALIGN_TOP_MID, 0, 0); + + s_sig = sigwave_create(s_card, LV_ALIGN_BOTTOM_MID, 0, -2); + + s_log_label = lv_label_create(s_screen); + lv_label_set_text(s_log_label, s_total > 0 ? "" : "Capture signals in Learn first"); + lv_obj_set_style_text_color(s_log_label, lv_color_hex(COL_DIM), 0); + lv_obj_set_style_text_font(s_log_label, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_align(s_log_label, LV_TEXT_ALIGN_CENTER, 0); + lv_obj_align(s_log_label, LV_ALIGN_BOTTOM_MID, 0, LOG_Y); + + s_footer = ui_chrome_footer(s_screen, s_total > 0 ? HINT_BUSY : HINT_DONE); + + ui_input_set_screen_handler(ir_burst_input, NULL); + + if (s_total > 0) + s_burst_timer = lv_timer_create(burst_tick_cb, BURST_TICK_MS, NULL); + else + s_burst_timer = NULL; + + ui_screen_load_owned(&s_screen, s_screen); } -static void send_one_file(const char *path) { - FILE *f = fopen(path, "r"); - if (f == NULL) - return; - - fseek(f, 0, SEEK_END); - long sz = ftell(f); - fseek(f, 0, SEEK_SET); - - if (sz <= 0 || sz > IR_FILE_MAX_SIZE) { - fclose(f); - return; - } - - char *buf = malloc(sz + 1); - if (buf == NULL) { - ESP_LOGE(TAG, "Failed to allocate buffer for %s", path); - fclose(f); - return; - } - - fread(buf, 1, sz, f); - buf[sz] = '\0'; - fclose(f); - - ir_file_t ir_file; - ir_file_init(&ir_file); - - if (ir_file_parse(buf, &ir_file)) { - for (size_t i = 0; i < ir_file.count && !s_stop_requested; i++) { - ir_file_send(&ir_file.signals[i]); - vTaskDelay(pdMS_TO_TICKS(BURST_DELAY_MS)); - } - s_sent_count++; - } - - ir_file_free(&ir_file); - free(buf); +// Transmit the first signal of file s_entries[idx]; returns true on success. +static bool burst_send_one(int idx) { + if (idx < 0 || idx >= s_total) + return false; + ir_file_t f; + ir_file_init(&f); + bool ok = false; + if (ir_store_load(s_entries[idx].path, &f) == ESP_OK && f.count > 0) + ok = (ir_store_send_signal(&f.signals[0]) == ESP_OK); + ir_file_free(&f); + return ok; } -static void burst_task(void *pvParameters) { - (void)pvParameters; - - ir_tx_init(); - - DIR *root = opendir(TOS_PATH_IR); - if (root == NULL) { - ESP_LOGE(TAG, "Failed to open IR path: %s", TOS_PATH_IR); - s_is_done = true; - s_burst_task = NULL; - vTaskDelete(NULL); +static void burst_tick_cb(lv_timer_t *timer) { + if (lv_screen_active() != s_screen) { + lv_timer_delete(timer); + if (s_burst_timer == timer) + s_burst_timer = NULL; return; } - int total = 0; - struct dirent *proto_ent; - - while ((proto_ent = readdir(root)) != NULL) { - if (proto_ent->d_name[0] == '.' || proto_ent->d_type != DT_DIR) - continue; - - char sub_path[SUB_PATH_MAX_LEN]; - snprintf(sub_path, sizeof(sub_path), TOS_PATH_IR "/%.64s", proto_ent->d_name); - - DIR *sub = opendir(sub_path); - if (sub == NULL) - continue; - - struct dirent *fe; - while ((fe = readdir(sub)) != NULL) { - size_t len = strlen(fe->d_name); - if (len >= IR_FILE_EXT_LEN + 1 && - strcmp(fe->d_name + len - IR_FILE_EXT_LEN, IR_FILE_EXT) == 0) - total++; + bool ok = burst_send_one(s_sent); + s_sent++; + lv_label_set_text_fmt(s_count_label, "%d / %d files", s_sent, s_total); + if (s_bar) + lv_bar_set_value(s_bar, s_sent, LV_ANIM_OFF); + if (s_log_label) + lv_label_set_text_fmt(s_log_label, "> %-16s %s", s_entries[s_sent - 1].name, ok ? "OK" : "--"); + + if (s_sent >= s_total) { + lv_label_set_text(s_status_label, STATUS_DONE); + lv_obj_set_style_text_color(s_status_label, lv_color_hex(SIG_GREEN), 0); + lv_label_set_text_fmt(s_count_label, "%d / %d files", s_total, s_total); + if (s_footer) + ui_chrome_footer_set_text(s_footer, HINT_DONE); + if (s_log_label) + lv_obj_add_flag(s_log_label, LV_OBJ_FLAG_HIDDEN); + + if (s_sig) { + lv_obj_del(s_sig); + s_sig = sigwave_create_static(s_card, LV_ALIGN_BOTTOM_MID, 0, -2); } - closedir(sub); - } - - s_total_count = total; - rewinddir(root); - - while ((proto_ent = readdir(root)) != NULL && !s_stop_requested) { - if (proto_ent->d_name[0] == '.' || proto_ent->d_type != DT_DIR) - continue; - - char sub_path[SUB_PATH_MAX_LEN]; - snprintf(sub_path, sizeof(sub_path), TOS_PATH_IR "/%.64s", proto_ent->d_name); - - DIR *sub = opendir(sub_path); - if (sub == NULL) - continue; - - struct dirent *fe; - while ((fe = readdir(sub)) != NULL && !s_stop_requested) { - size_t len = strlen(fe->d_name); - if (len < IR_FILE_EXT_LEN + 1 || strcmp(fe->d_name + len - IR_FILE_EXT_LEN, IR_FILE_EXT) != 0) - continue; - - char file_path[FILE_PATH_MAX_LEN]; - snprintf(file_path, sizeof(file_path), "%.299s/%.255s", sub_path, fe->d_name); - send_one_file(file_path); + if (s_caption) { + lv_label_set_text_fmt(s_caption, "%d signals sent", s_total); + lv_obj_set_style_text_color(s_caption, current_theme.text_main, 0); + lv_obj_set_style_text_font(s_caption, &lv_font_montserrat_14, 0); } - closedir(sub); - } + if (s_card) + card_rise(s_card); - closedir(root); - s_is_done = true; - s_burst_task = NULL; - vTaskDelete(NULL); -} + ESP_LOGI(TAG, "burst complete: %d signals", s_total); + ui_feedback(UI_FB_WRITE); + notify(NOTIFY_SAVED, "Burst sent"); -static void nav_timer_cb(lv_timer_t *timer) { - if (lv_screen_active() != s_screen) { lv_timer_delete(timer); - s_nav_timer = NULL; - return; - } - - if (ui_input_is_locked()) - return; - - if (msgbox_is_open()) - return; - - if (!s_is_done && s_total_count > 0) - lv_label_set_text_fmt(s_count_label, "%d / %d files", s_sent_count, s_total_count); - - if (s_is_done) { - s_is_done = false; - spinner_ui_hide(&s_spinner); - lv_label_set_text(s_status_label, "Burst complete!"); - lv_label_set_text_fmt(s_count_label, "%d signals sent", s_sent_count); + s_burst_timer = NULL; } +} - bool is_back = back_button_is_down(); - if (is_back && !s_btn_back_last) { - s_stop_requested = true; - if (s_burst_task != NULL) { - vTaskDelay(pdMS_TO_TICKS(BURST_STOP_WAIT_MS)); - if (s_burst_task != NULL) { - vTaskDelete(s_burst_task); - s_burst_task = NULL; +static void ir_burst_input(const input_event_t *ev, void *ctx) { + (void)ctx; + const bool press = (ev->action == INPUT_ACTION_PRESS); + + switch (ev->button) { + case INPUT_BTN_BACK: + if (press) { + if (s_burst_timer != NULL) { + lv_timer_delete(s_burst_timer); + s_burst_timer = NULL; + } + ESP_LOGI(TAG, "burst cancelled"); + ui_switch_screen(SCREEN_IR_MENU); } - } - ui_switch_screen(SCREEN_IR_MENU); + break; + default: + break; } - - s_btn_back_last = is_back; -} \ No newline at end of file +} diff --git a/firmware_p4/components/Applications/ui/screens/infrared/ir_controller_ui.c b/firmware_p4/components/Applications/ui/screens/infrared/ir_controller_ui.c index 2f884c43d..f3c18bb98 100644 --- a/firmware_p4/components/Applications/ui/screens/infrared/ir_controller_ui.c +++ b/firmware_p4/components/Applications/ui/screens/infrared/ir_controller_ui.c @@ -15,98 +15,646 @@ #include "ir_controller_ui.h" +#include +#include + #include "esp_log.h" -#include "st7789.h" -#include "buttons_gpio.h" +#include "ir_store.h" +#include "notify_ui.h" +#include "ui_chrome.h" +#include "ui_feedback.h" #include "ui_manager.h" #include "ui_theme.h" static const char *TAG = "IR_CTRL_UI"; -#define OUTER_BORDER 4 -#define TOP_BORDER_H 46 -#define TOP_AREA_BORDER_WIDTH 3 -#define TITLE_BAR_W 170 -#define TITLE_BAR_H 30 -#define TITLE_BAR_RADIUS 12 -#define TITLE_BAR_BORDER_WIDTH 2 -#define NAV_TIMER_INTERVAL_MS 50 +#define COL_DIM 0x8A8594 + +#define MAX_BTNS 16 + +#define KEYPAD_W 232 +#define KEYPAD_H 250 +#define KEYPAD_Y 44 + +#define FLASH_MS 150 + +#define AC_FIELD_COUNT 4 +#define AC_F_POWER 0 +#define AC_F_MODE 1 +#define AC_F_TEMP 2 +#define AC_F_FAN 3 + +#define AC_TEMP_MIN 16 +#define AC_TEMP_MAX 30 +#define AC_TEMP_DEFAULT 22 +#define AC_FAN_DEFAULT 1 +#define AC_ON_COLOR 0x00E676 + +#define AC_ROW_W 210 +#define AC_ROW_H 48 +#define AC_ROW_GAP 10 +#define AC_ROW_RADIUS 10 +#define AC_ROW_PAD 16 +#define AC_ROW_BORDER 2 +#define AC_ROW_GLOW 14 +#define AC_ROW_SPREAD (-3) + +typedef struct { + const char *text; + int dx, dy, w, h; + const char *sig; // universal-remote signal name in the .ir file, or NULL if none +} rc_btn_t; + +typedef struct { + const char *title; + const rc_btn_t *btns; + int count; + int start; +} rc_layout_t; + +static const rc_btn_t TV_BTNS[] = { + {LV_SYMBOL_POWER, -64, 8, 52, 32, "Power"}, + {LV_SYMBOL_MUTE, 64, 8, 52, 32, "Mute"}, + {LV_SYMBOL_UP, 0, 50, 44, 30, NULL}, + {LV_SYMBOL_LEFT, -54, 96, 42, 34, NULL}, + {"OK", 0, 90, 54, 52, NULL}, + {LV_SYMBOL_RIGHT, 54, 96, 42, 34, NULL}, + {LV_SYMBOL_DOWN, 0, 148, 44, 30, NULL}, + {"VOL +", -76, 186, 54, 28, "Vol_up"}, + {"VOL -", -76, 218, 54, 28, "Vol_dn"}, + {LV_SYMBOL_LIST, 0, 186, 48, 28, NULL}, + {LV_SYMBOL_HOME, 0, 218, 48, 28, NULL}, + {"CH +", 76, 186, 54, 28, "Ch_next"}, + {"CH -", 76, 218, 54, 28, "Ch_prev"}, +}; + +static const rc_btn_t SOUND_BTNS[] = { + {LV_SYMBOL_POWER, -58, 16, 58, 34, "Power"}, + {"SRC", 58, 16, 58, 34, NULL}, + {"VOL -", -58, 68, 58, 34, "Vol_dn"}, + {"VOL +", 58, 68, 58, 34, "Vol_up"}, + {LV_SYMBOL_PREV, -70, 128, 50, 42, "Prev"}, + {LV_SYMBOL_PLAY, 0, 124, 58, 50, "Play"}, + {LV_SYMBOL_NEXT, 70, 128, 50, 42, "Next"}, + {LV_SYMBOL_MUTE, -58, 194, 58, 34, "Mute"}, + {"MODE", 58, 194, 58, 34, NULL}, +}; + +// AC uses the state panel (build_ac_panel), not these buttons — sig stays NULL. +static const rc_btn_t AC_BTNS[] = { + {LV_SYMBOL_POWER, -58, 14, 58, 34, NULL}, + {"MODE", 58, 14, 58, 34, NULL}, + {"TEMP +", 0, 64, 80, 38, NULL}, + {"TEMP -", 0, 110, 80, 38, NULL}, + {"FAN", -58, 162, 58, 34, NULL}, + {"SWING", 58, 162, 58, 34, NULL}, + {"TIMER", -58, 206, 58, 34, NULL}, + {"ECO", 58, 206, 58, 34, NULL}, +}; + +static const rc_layout_t LAYOUTS[] = { + [IR_DEV_TV] = {"TV Remote", TV_BTNS, (int)(sizeof(TV_BTNS) / sizeof(TV_BTNS[0])), 4}, + [IR_DEV_SOUND] = {"Sound System", + SOUND_BTNS, + (int)(sizeof(SOUND_BTNS) / sizeof(SOUND_BTNS[0])), + 5}, + [IR_DEV_AC] = {"Air Cond.", AC_BTNS, (int)(sizeof(AC_BTNS) / sizeof(AC_BTNS[0])), 2}, +}; +#define LAYOUT_COUNT ((int)(sizeof(LAYOUTS) / sizeof(LAYOUTS[0]))) + +static const char *const AC_LABELS[AC_FIELD_COUNT] = {"Power", "Mode", "Temp", "Fan"}; +static const char *const AC_MODES[] = {"Cool", "Heat", "Fan", "Auto"}; +#define AC_MODE_COUNT ((int)(sizeof(AC_MODES) / sizeof(AC_MODES[0]))) +static const char *const AC_FANS[] = {"Low", "Med", "High", "Auto"}; +#define AC_FAN_COUNT ((int)(sizeof(AC_FANS) / sizeof(AC_FANS[0]))) + +static ir_device_t s_device = IR_DEV_TV; +static const rc_layout_t *s_lay = &LAYOUTS[IR_DEV_TV]; static lv_obj_t *s_screen = NULL; -static lv_timer_t *s_nav_timer = NULL; -static bool s_btn_back_last = false; +static lv_obj_t *s_keypad = NULL; +static lv_obj_t *s_btn_objs[MAX_BTNS]; +static lv_timer_t *s_flash_timer = NULL; +static int s_focus = 0; + +static bool s_is_ac = false; +static lv_obj_t *s_ac_rows[AC_FIELD_COUNT]; +static lv_obj_t *s_ac_vals[AC_FIELD_COUNT]; +static int s_ac_sel = 0; +static bool s_ac_power = true; +static int s_ac_mode = 0; +static int s_ac_temp = AC_TEMP_DEFAULT; +static int s_ac_fan = AC_FAN_DEFAULT; + +static void ir_controller_input(const input_event_t *ev, void *ctx); + +// --- Universal remote: send codes sourced from Flipper .ir files on the SD --- + +static const char *const UNIVERSAL_FILE[] = { + [IR_DEV_TV] = "/sdcard/ir/tv.ir", + [IR_DEV_SOUND] = "/sdcard/ir/audio.ir", + [IR_DEV_AC] = "/sdcard/ir/ac.ir", +}; + +static ir_file_t s_uni = {0}; +static bool s_uni_loaded = false; +static char s_send_name[24]; +static size_t s_send_idx = 0; +static lv_timer_t *s_send_timer = NULL; + +static void load_universal(ir_device_t dev) { + if (s_send_timer != NULL) { + lv_timer_delete(s_send_timer); + s_send_timer = NULL; + } + // Free unconditionally: a prior ir_store_load() can partially allocate signals[] + // and then fail (leaving s_uni_loaded false but memory live). ir_file_free is safe + // on a zeroed file, so this covers both the loaded and partial-failure cases. + ir_file_free(&s_uni); + s_uni_loaded = false; + ir_file_init(&s_uni); + if ((int)dev >= 0 && (int)dev < LAYOUT_COUNT && UNIVERSAL_FILE[dev] != NULL) { + if (ir_store_load(UNIVERSAL_FILE[dev], &s_uni) == ESP_OK && s_uni.count > 0) + s_uni_loaded = true; + } +} + +static int uni_count(const char *name) { + if (name == NULL) + return 0; + int c = 0; + for (size_t i = 0; i < s_uni.count; i++) + if (strcasecmp(s_uni.signals[i].name, name) == 0) + c++; + return c; +} + +// Transmit one matching code per tick — a universal button can map to many +// brand codes, and sending them all inline would stall the UI. +static void uni_send_tick(lv_timer_t *t) { + if (lv_screen_active() != s_screen) { + lv_timer_delete(t); + s_send_timer = NULL; + return; + } + while (s_send_idx < s_uni.count) { + ir_signal_t *sig = &s_uni.signals[s_send_idx++]; + if (strcasecmp(sig->name, s_send_name) == 0) { + ir_store_send_signal(sig); + return; + } + } + lv_timer_delete(t); + s_send_timer = NULL; +} + +static void uni_send(const char *name) { + if (!s_uni_loaded) { + notify(NOTIFY_WARNING, "Add universal .ir to /sdcard/ir"); + return; + } + if (name == NULL || uni_count(name) == 0) { + notify(NOTIFY_WARNING, "No code for this key"); + return; + } + snprintf(s_send_name, sizeof(s_send_name), "%s", name); + s_send_idx = 0; + if (s_send_timer != NULL) + lv_timer_delete(s_send_timer); + s_send_timer = lv_timer_create(uni_send_tick, 90, NULL); +} + +// Map the AC panel state onto the universal ac.ir signal names. +static const char *ac_universal_name(void) { + if (!s_ac_power) + return "Off"; + bool hi = s_ac_temp >= 24; + switch (s_ac_mode) { + case 0: + return hi ? "Cool_hi" : "Cool_lo"; // Cool + case 1: + return hi ? "Heat_hi" : "Heat_lo"; // Heat + case 2: + return "Dh"; // Fan / dehumidify + default: + return hi ? "Cool_hi" : "Cool_lo"; + } +} + +void ui_ir_controller_set_device(ir_device_t dev) { + if ((int)dev >= 0 && (int)dev < LAYOUT_COUNT) + s_device = dev; +} + +static void apply_focus_style(lv_obj_t *btn, bool focused) { + lv_obj_t *lbl = lv_obj_get_child(btn, 0); + lv_obj_set_style_bg_color(btn, current_theme.bg_secondary, 0); + if (focused) { + lv_obj_set_style_bg_opa(btn, LV_OPA_COVER, 0); + lv_obj_set_style_border_color(btn, current_theme.border_accent, 0); + lv_obj_set_style_border_width(btn, 2, 0); + lv_obj_set_style_shadow_color(btn, current_theme.border_accent, 0); + lv_obj_set_style_shadow_width(btn, 14, 0); + lv_obj_set_style_shadow_opa(btn, LV_OPA_50, 0); + lv_obj_set_style_shadow_spread(btn, -3, 0); + if (lbl) + lv_obj_set_style_text_color(lbl, current_theme.border_accent, 0); + } else { + lv_obj_set_style_bg_opa(btn, LV_OPA_80, 0); + lv_obj_set_style_border_color(btn, current_theme.border_inactive, 0); + lv_obj_set_style_border_width(btn, 2, 0); + lv_obj_set_style_shadow_width(btn, 0, 0); + lv_obj_set_style_shadow_opa(btn, LV_OPA_TRANSP, 0); + if (lbl) + lv_obj_set_style_text_color(lbl, lv_color_hex(COL_DIM), 0); + } +} + +static void set_focus(int idx) { + if (idx < 0 || idx >= s_lay->count || idx == s_focus) + return; + apply_focus_style(s_btn_objs[s_focus], false); + s_focus = idx; + apply_focus_style(s_btn_objs[s_focus], true); + ui_feedback(UI_FB_NAV); +} + +static void ac_apply_row_style(lv_obj_t *row, bool focused) { + lv_obj_t *name = lv_obj_get_child(row, 0); + lv_obj_set_style_bg_color(row, current_theme.bg_secondary, 0); + if (focused) { + lv_obj_set_style_bg_opa(row, LV_OPA_COVER, 0); + lv_obj_set_style_border_color(row, current_theme.border_accent, 0); + lv_obj_set_style_border_width(row, AC_ROW_BORDER, 0); + lv_obj_set_style_shadow_color(row, current_theme.border_accent, 0); + lv_obj_set_style_shadow_width(row, AC_ROW_GLOW, 0); + lv_obj_set_style_shadow_opa(row, LV_OPA_50, 0); + lv_obj_set_style_shadow_spread(row, AC_ROW_SPREAD, 0); + if (name) + lv_obj_set_style_text_color(name, current_theme.text_main, 0); + } else { + lv_obj_set_style_bg_opa(row, LV_OPA_80, 0); + lv_obj_set_style_border_color(row, current_theme.border_inactive, 0); + lv_obj_set_style_border_width(row, AC_ROW_BORDER, 0); + lv_obj_set_style_shadow_width(row, 0, 0); + lv_obj_set_style_shadow_opa(row, LV_OPA_TRANSP, 0); + if (name) + lv_obj_set_style_text_color(name, lv_color_hex(COL_DIM), 0); + } +} -static void nav_timer_cb(lv_timer_t *timer); +static void ac_update_values(void) { + char temp_buf[8]; + snprintf(temp_buf, sizeof(temp_buf), "%d C", s_ac_temp); + lv_label_set_text(s_ac_vals[AC_F_POWER], s_ac_power ? "On" : "Off"); + lv_label_set_text(s_ac_vals[AC_F_MODE], AC_MODES[s_ac_mode]); + lv_label_set_text(s_ac_vals[AC_F_TEMP], temp_buf); + lv_label_set_text(s_ac_vals[AC_F_FAN], AC_FANS[s_ac_fan]); + + lv_color_t active = current_theme.border_accent; + lv_color_t idle = lv_color_hex(COL_DIM); + lv_color_t body = s_ac_power ? active : idle; + lv_obj_set_style_text_color( + s_ac_vals[AC_F_POWER], s_ac_power ? lv_color_hex(AC_ON_COLOR) : idle, 0); + lv_obj_set_style_text_color(s_ac_vals[AC_F_MODE], body, 0); + lv_obj_set_style_text_color(s_ac_vals[AC_F_TEMP], body, 0); + lv_obj_set_style_text_color(s_ac_vals[AC_F_FAN], body, 0); +} + +static void flash_restore_cb(lv_timer_t *t) { + (void)t; + s_flash_timer = NULL; + if (lv_screen_active() != s_screen) + return; + if (s_is_ac) { + ac_apply_row_style(s_ac_rows[s_ac_sel], true); + ac_update_values(); + return; + } + apply_focus_style(s_btn_objs[s_focus], true); +} + +static void flash_focus(void) { + lv_obj_t *btn = s_btn_objs[s_focus]; + lv_obj_t *lbl = lv_obj_get_child(btn, 0); + lv_obj_set_style_bg_color(btn, current_theme.border_accent, 0); + lv_obj_set_style_bg_opa(btn, LV_OPA_COVER, 0); + if (lbl) + lv_obj_set_style_text_color(lbl, current_theme.screen_base, 0); + if (s_flash_timer != NULL) + lv_timer_delete(s_flash_timer); + s_flash_timer = lv_timer_create(flash_restore_cb, FLASH_MS, NULL); + lv_timer_set_repeat_count(s_flash_timer, 1); +} + +static lv_obj_t *ac_make_row(const char *label, lv_obj_t **out_val) { + lv_obj_t *row = lv_obj_create(s_keypad); + lv_obj_remove_flag(row, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(row, AC_ROW_W, AC_ROW_H); + lv_obj_set_style_radius(row, AC_ROW_RADIUS, 0); + lv_obj_set_style_bg_grad_dir(row, LV_GRAD_DIR_NONE, 0); + lv_obj_set_style_pad_top(row, 0, 0); + lv_obj_set_style_pad_bottom(row, 0, 0); + lv_obj_set_style_pad_left(row, AC_ROW_PAD, 0); + lv_obj_set_style_pad_right(row, AC_ROW_PAD, 0); + lv_obj_set_flex_flow(row, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align( + row, LV_FLEX_ALIGN_SPACE_BETWEEN, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + + lv_obj_t *name = lv_label_create(row); + lv_label_set_text(name, label); + lv_obj_set_style_text_font(name, &lv_font_montserrat_14, 0); + + lv_obj_t *val = lv_label_create(row); + lv_obj_set_style_text_font(val, &lv_font_montserrat_14, 0); + lv_obj_set_style_text_color(val, current_theme.border_accent, 0); + + if (out_val != NULL) + *out_val = val; + return row; +} + +static void build_ac_panel(void) { + lv_obj_set_flex_flow(s_keypad, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(s_keypad, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_set_style_pad_row(s_keypad, AC_ROW_GAP, 0); + + for (int i = 0; i < AC_FIELD_COUNT; i++) + s_ac_rows[i] = ac_make_row(AC_LABELS[i], &s_ac_vals[i]); + + s_ac_sel = 0; + ac_update_values(); + for (int i = 0; i < AC_FIELD_COUNT; i++) + ac_apply_row_style(s_ac_rows[i], i == s_ac_sel); +} + +static void ac_flash(void) { + lv_obj_t *row = s_ac_rows[s_ac_sel]; + lv_obj_t *name = lv_obj_get_child(row, 0); + lv_obj_t *val = lv_obj_get_child(row, 1); + lv_obj_set_style_bg_color(row, current_theme.border_accent, 0); + lv_obj_set_style_bg_opa(row, LV_OPA_COVER, 0); + if (name) + lv_obj_set_style_text_color(name, current_theme.screen_base, 0); + if (val) + lv_obj_set_style_text_color(val, current_theme.screen_base, 0); + if (s_flash_timer != NULL) + lv_timer_delete(s_flash_timer); + s_flash_timer = lv_timer_create(flash_restore_cb, FLASH_MS, NULL); + lv_timer_set_repeat_count(s_flash_timer, 1); +} + +static void ac_set_sel(int idx) { + if (idx < 0 || idx >= AC_FIELD_COUNT || idx == s_ac_sel) + return; + ac_apply_row_style(s_ac_rows[s_ac_sel], false); + s_ac_sel = idx; + ac_apply_row_style(s_ac_rows[s_ac_sel], true); + ui_feedback(UI_FB_NAV); +} + +static void ac_send(void) { + ui_feedback(UI_FB_EMULATE); + ac_flash(); + uni_send(ac_universal_name()); + char buf[48]; + snprintf(buf, + sizeof(buf), + "AC %s %s %d C %s", + s_ac_power ? "On" : "Off", + AC_MODES[s_ac_mode], + s_ac_temp, + AC_FANS[s_ac_fan]); + notify(NOTIFY_INFO, buf); + ESP_LOGI(TAG, + "AC send: power=%d mode=%s temp=%d fan=%s", + s_ac_power, + AC_MODES[s_ac_mode], + s_ac_temp, + AC_FANS[s_ac_fan]); +} + +static void ac_change(int dir) { + switch (s_ac_sel) { + case AC_F_POWER: + s_ac_power = !s_ac_power; + break; + case AC_F_MODE: + s_ac_mode = (s_ac_mode + dir + AC_MODE_COUNT) % AC_MODE_COUNT; + break; + case AC_F_TEMP: + s_ac_temp += dir; + if (s_ac_temp < AC_TEMP_MIN) + s_ac_temp = AC_TEMP_MIN; + if (s_ac_temp > AC_TEMP_MAX) + s_ac_temp = AC_TEMP_MAX; + break; + case AC_F_FAN: + s_ac_fan = (s_ac_fan + dir + AC_FAN_COUNT) % AC_FAN_COUNT; + break; + default: + break; + } + ac_update_values(); + ac_send(); +} + +static int neighbor(int dir) { + const rc_btn_t *cur = &s_lay->btns[s_focus]; + int ccx = cur->dx, ccy = cur->dy; + int best = -1; + long best_cost = 0; + for (int i = 0; i < s_lay->count; i++) { + if (i == s_focus) + continue; + const rc_btn_t *b = &s_lay->btns[i]; + int ddx = b->dx - ccx; + int ddy = b->dy - ccy; + int along, perp; + bool ok; + switch (dir) { + case 0: + ok = ddy < -4; + along = -ddy; + perp = ddx < 0 ? -ddx : ddx; + break; + case 1: + ok = ddy > 4; + along = ddy; + perp = ddx < 0 ? -ddx : ddx; + break; + case 2: + ok = ddx < -4; + along = -ddx; + perp = ddy < 0 ? -ddy : ddy; + break; + default: + ok = ddx > 4; + along = ddx; + perp = ddy < 0 ? -ddy : ddy; + break; + } + if (!ok) + continue; + long cost = (long)along + 2L * perp; + if (best < 0 || cost < best_cost) { + best = i; + best_cost = cost; + } + } + return best; +} + +static lv_obj_t *make_button(const rc_btn_t *def) { + lv_obj_t *btn = lv_obj_create(s_keypad); + lv_obj_set_size(btn, def->w, def->h); + lv_obj_align(btn, LV_ALIGN_TOP_MID, def->dx, def->dy); + lv_obj_remove_flag(btn, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_radius(btn, (def->w == def->h) ? LV_RADIUS_CIRCLE : 10, 0); + lv_obj_set_style_bg_grad_dir(btn, LV_GRAD_DIR_NONE, 0); + lv_obj_set_style_pad_all(btn, 0, 0); + + lv_obj_t *lbl = lv_label_create(btn); + lv_label_set_text(lbl, def->text); + lv_obj_set_style_text_font(lbl, &lv_font_montserrat_14, 0); + lv_obj_center(lbl); + + apply_focus_style(btn, false); + return btn; +} void ui_ir_controller_open(void) { if (s_screen != NULL) { lv_obj_del(s_screen); s_screen = NULL; } + if (s_flash_timer != NULL) { + lv_timer_delete(s_flash_timer); + s_flash_timer = NULL; + } + s_lay = &LAYOUTS[s_device]; + load_universal(s_device); s_screen = lv_obj_create(NULL); lv_obj_set_style_bg_color(s_screen, current_theme.screen_base, 0); lv_obj_set_style_bg_opa(s_screen, LV_OPA_COVER, 0); lv_obj_remove_flag(s_screen, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_set_style_border_width(s_screen, OUTER_BORDER, 0); - lv_obj_set_style_border_color(s_screen, current_theme.border_interface, 0); + lv_obj_set_style_border_width(s_screen, 0, 0); lv_obj_set_style_pad_all(s_screen, 0, 0); - lv_obj_t *top_area = lv_obj_create(s_screen); - lv_obj_set_size(top_area, LCD_H_RES - OUTER_BORDER * 2, TOP_BORDER_H); - lv_obj_align(top_area, LV_ALIGN_TOP_MID, 0, 0); - lv_obj_remove_flag(top_area, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_set_style_bg_opa(top_area, LV_OPA_TRANSP, 0); - lv_obj_set_style_border_width(top_area, TOP_AREA_BORDER_WIDTH, 0); - lv_obj_set_style_border_color(top_area, current_theme.border_interface, 0); - lv_obj_set_style_border_side(top_area, LV_BORDER_SIDE_BOTTOM, 0); - lv_obj_set_style_radius(top_area, 0, 0); - lv_obj_set_style_pad_all(top_area, 0, 0); - - lv_obj_t *title_bar = lv_obj_create(top_area); - lv_obj_set_size(title_bar, TITLE_BAR_W, TITLE_BAR_H); - lv_obj_align(title_bar, LV_ALIGN_CENTER, 0, 0); - lv_obj_remove_flag(title_bar, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_set_style_radius(title_bar, TITLE_BAR_RADIUS, 0); - lv_obj_set_style_bg_opa(title_bar, LV_OPA_COVER, 0); - lv_obj_set_style_bg_color(title_bar, current_theme.bg_primary, 0); - lv_obj_set_style_bg_grad_color(title_bar, current_theme.bg_secondary, 0); - lv_obj_set_style_bg_grad_dir(title_bar, LV_GRAD_DIR_HOR, 0); - lv_obj_set_style_border_width(title_bar, TITLE_BAR_BORDER_WIDTH, 0); - lv_obj_set_style_border_color(title_bar, current_theme.border_accent, 0); - - lv_obj_t *title_lbl = lv_label_create(title_bar); - lv_label_set_text(title_lbl, "CONTROLLER"); - lv_obj_set_style_text_color(title_lbl, current_theme.text_main, 0); - lv_obj_set_style_text_font(title_lbl, &lv_font_montserrat_14, 0); - lv_obj_center(title_lbl); - - lv_obj_t *lbl = lv_label_create(s_screen); - lv_label_set_text(lbl, "Coming soon..."); - lv_obj_set_style_text_color(lbl, current_theme.border_inactive, 0); - lv_obj_set_style_text_font(lbl, &lv_font_montserrat_14, 0); - lv_obj_center(lbl); + ui_chrome_header(s_screen, s_lay->title, "/assets/icons/settings_remote.bin"); + + s_keypad = lv_obj_create(s_screen); + lv_obj_remove_flag(s_keypad, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(s_keypad, KEYPAD_W, KEYPAD_H); + lv_obj_align(s_keypad, LV_ALIGN_TOP_MID, 0, KEYPAD_Y); + lv_obj_set_style_bg_opa(s_keypad, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(s_keypad, 0, 0); + lv_obj_set_style_radius(s_keypad, 0, 0); + lv_obj_set_style_pad_all(s_keypad, 0, 0); + + s_is_ac = (s_device == IR_DEV_AC); + + if (s_is_ac) { + s_ac_power = true; + s_ac_mode = 0; + s_ac_temp = AC_TEMP_DEFAULT; + s_ac_fan = AC_FAN_DEFAULT; + build_ac_panel(); + } else { + for (int i = 0; i < s_lay->count && i < MAX_BTNS; i++) + s_btn_objs[i] = make_button(&s_lay->btns[i]); + + s_focus = (s_lay->start >= 0 && s_lay->start < s_lay->count) ? s_lay->start : 0; + apply_focus_style(s_btn_objs[s_focus], true); + } + + ui_input_set_screen_handler(ir_controller_input, NULL); - if (s_nav_timer == NULL) - s_nav_timer = lv_timer_create(nav_timer_cb, NAV_TIMER_INTERVAL_MS, NULL); + ui_chrome_footer(s_screen, s_is_ac ? "U/D pick L/R set OK send" : "OK Send BACK Back"); - lv_screen_load(s_screen); + ui_screen_load_owned(&s_screen, s_screen); } -static void nav_timer_cb(lv_timer_t *timer) { - if (lv_screen_active() != s_screen) { - lv_timer_delete(timer); - s_nav_timer = NULL; +static void ir_controller_input(const input_event_t *ev, void *ctx) { + (void)ctx; + const bool press = (ev->action == INPUT_ACTION_PRESS); + const bool nav = press || (ev->action == INPUT_ACTION_REPEAT); + + if (ev->button == INPUT_BTN_BACK) { + if (press) + ui_switch_screen(SCREEN_IR_REMOTE_TYPE); return; } - if (ui_input_is_locked()) + if (s_is_ac) { + switch (ev->button) { + case INPUT_BTN_UP: + if (nav) + ac_set_sel((s_ac_sel + AC_FIELD_COUNT - 1) % AC_FIELD_COUNT); + break; + case INPUT_BTN_DOWN: + if (nav) + ac_set_sel((s_ac_sel + 1) % AC_FIELD_COUNT); + break; + case INPUT_BTN_LEFT: + if (nav) + ac_change(-1); + break; + case INPUT_BTN_RIGHT: + if (nav) + ac_change(1); + break; + case INPUT_BTN_OK: + if (press) + ac_change(1); + break; + default: + break; + } return; + } - bool is_back = back_button_is_down(); - if (is_back && !s_btn_back_last) - ui_switch_screen(SCREEN_IR_MENU); - - s_btn_back_last = is_back; -} \ No newline at end of file + switch (ev->button) { + case INPUT_BTN_UP: + if (nav) { + int n = neighbor(0); + if (n >= 0) + set_focus(n); + } + break; + case INPUT_BTN_DOWN: + if (nav) { + int n = neighbor(1); + if (n >= 0) + set_focus(n); + } + break; + case INPUT_BTN_LEFT: + if (nav) { + int n = neighbor(2); + if (n >= 0) + set_focus(n); + } + break; + case INPUT_BTN_RIGHT: + if (nav) { + int n = neighbor(3); + if (n >= 0) + set_focus(n); + } + break; + case INPUT_BTN_OK: + if (press) { + const rc_btn_t *b = &s_lay->btns[s_focus]; + ESP_LOGI(TAG, "press [%s]: %s", s_lay->title, b->text); + ui_feedback(UI_FB_EMULATE); + flash_focus(); + uni_send(b->sig); + } + break; + default: + break; + } +} diff --git a/firmware_p4/components/Applications/ui/screens/infrared/ir_menu_ui.c b/firmware_p4/components/Applications/ui/screens/infrared/ir_menu_ui.c index 37caa9058..e0a06da7a 100644 --- a/firmware_p4/components/Applications/ui/screens/infrared/ir_menu_ui.c +++ b/firmware_p4/components/Applications/ui/screens/infrared/ir_menu_ui.c @@ -17,15 +17,12 @@ #include "esp_log.h" -#include "buttons_gpio.h" #include "menu_component_ui.h" #include "ui_manager.h" #include "ui_theme.h" static const char *TAG = "IR_MENU_UI"; -#define NAV_TIMER_INTERVAL_MS 50 - typedef struct { const char *name; const char *icon; @@ -33,23 +30,19 @@ typedef struct { } ir_menu_item_t; static const ir_menu_item_t MENU_ITEMS[] = { - {"Learn", "/assets/icons/ir_receive_menu_icon.bin", SCREEN_IR_RECEIVE}, - {"Send", "/assets/icons/ir_send_menu_icon.bin", SCREEN_IR_SEND}, - {"Browse Signals", "/assets/icons/search_menu_icon.bin", SCREEN_IR_SAVED}, - {"Burst", "/assets/icons/burst_menu_icon.bin", SCREEN_IR_BURST}, + {"Learn", "/assets/icons/settings_input_antenna.bin", SCREEN_IR_RECEIVE}, + {"Send", "/assets/icons/podcasts.bin", SCREEN_IR_SEND}, + {"Remote Control", "/assets/icons/settings_remote.bin", SCREEN_IR_REMOTE_TYPE}, + {"Browse Signals", "/assets/icons/folder_open.bin", SCREEN_IR_SAVED}, + {"Burst", "/assets/icons/bolt.bin", SCREEN_IR_BURST}, + {"RAW Signal", "/assets/icons/graphic_eq.bin", SCREEN_IR_RAW}, }; #define MENU_ITEMS_COUNT (sizeof(MENU_ITEMS) / sizeof(MENU_ITEMS[0])) static lv_obj_t *s_screen = NULL; static menu_component_t s_menu; -static lv_timer_t *s_nav_timer = NULL; - -static bool s_btn_up_last = false; -static bool s_btn_down_last = false; -static bool s_btn_ok_last = false; -static bool s_btn_back_last = false; -static void nav_timer_cb(lv_timer_t *timer); +static void ir_menu_input(const input_event_t *ev, void *ctx); void ui_ir_menu_open(void) { if (s_screen != NULL) { @@ -62,48 +55,40 @@ void ui_ir_menu_open(void) { lv_obj_set_style_bg_opa(s_screen, LV_OPA_COVER, 0); lv_obj_remove_flag(s_screen, LV_OBJ_FLAG_SCROLLABLE); - s_menu = menu_component_create(s_screen, "INFRARED", NULL); + s_menu = menu_component_create(s_screen, "INFRARED", "/assets/icons/settings_remote.bin"); for (size_t i = 0; i < MENU_ITEMS_COUNT; i++) menu_component_add_item(&s_menu, MENU_ITEMS[i].icon, MENU_ITEMS[i].name); - if (s_nav_timer == NULL) - s_nav_timer = lv_timer_create(nav_timer_cb, NAV_TIMER_INTERVAL_MS, NULL); + ui_input_set_screen_handler(ir_menu_input, NULL); - lv_screen_load(s_screen); + ui_screen_load_owned(&s_screen, s_screen); } -static void nav_timer_cb(lv_timer_t *timer) { - if (lv_screen_active() != s_screen) { - lv_timer_delete(timer); - s_nav_timer = NULL; - return; +static void ir_menu_input(const input_event_t *ev, void *ctx) { + (void)ctx; + const bool press = (ev->action == INPUT_ACTION_PRESS); + const bool nav = press || (ev->action == INPUT_ACTION_REPEAT); + switch (ev->button) { + case INPUT_BTN_DOWN: + if (nav) + menu_component_next(&s_menu); + break; + case INPUT_BTN_UP: + if (nav) + menu_component_prev(&s_menu); + break; + case INPUT_BTN_BACK: + if (press) + ui_switch_screen(SCREEN_MENU); + break; + case INPUT_BTN_OK: + if (press) { + int sel = menu_component_get_selected(&s_menu); + if (sel >= 0 && sel < (int)MENU_ITEMS_COUNT && MENU_ITEMS[sel].target >= 0) + ui_switch_screen(MENU_ITEMS[sel].target); + } + break; + default: + break; } - - if (ui_input_is_locked()) - return; - - bool is_up = up_button_is_down(); - bool is_down = down_button_is_down(); - bool is_ok = ok_button_is_down(); - bool is_back = back_button_is_down(); - - if (is_down && !s_btn_down_last) - menu_component_next(&s_menu); - - if (is_up && !s_btn_up_last) - menu_component_prev(&s_menu); - - if (is_back && !s_btn_back_last) - ui_switch_screen(SCREEN_MENU); - - if (is_ok && !s_btn_ok_last) { - int sel = menu_component_get_selected(&s_menu); - if (sel >= 0 && sel < (int)MENU_ITEMS_COUNT && MENU_ITEMS[sel].target >= 0) - ui_switch_screen(MENU_ITEMS[sel].target); - } - - s_btn_up_last = is_up; - s_btn_down_last = is_down; - s_btn_ok_last = is_ok; - s_btn_back_last = is_back; -} \ No newline at end of file +} diff --git a/firmware_p4/components/Applications/ui/screens/infrared/ir_raw_ui.c b/firmware_p4/components/Applications/ui/screens/infrared/ir_raw_ui.c new file mode 100644 index 000000000..7823fcc0c --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/infrared/ir_raw_ui.c @@ -0,0 +1,376 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#include "ir_raw_ui.h" + +#include +#include + +#include "lvgl.h" +#include "st7789.h" + +#include "ir_store.h" +#include "notify_ui.h" +#include "ui_chrome.h" +#include "ui_feedback.h" +#include "ui_manager.h" +#include "ui_theme.h" + +#define HDR_TITLE "RAW SIGNAL" +#define HDR_ICON "/assets/icons/graphic_eq.bin" +#define FOOTER "OK replay BACK exit" + +#define MX 8 +#define CONTENT_W (LCD_H_RES - 2 * MX) + +#define CARD1_Y 50 +#define CARD1_H 76 +#define CARD_RADIUS 12 +#define CARD_PAD_X 9 +#define CARD_HDR_Y 8 +#define SCOPE_X CARD_PAD_X +#define SCOPE_Y 24 +#define DOT_SIZE 7 + +#define GRID_Y (CARD1_Y + CARD1_H + 8) +#define G_TILE_W 70 +#define G_TILE_H 46 +#define G_GAP 7 +#define G_TILE_PAD 7 +#define G_CAP_Y 6 +#define G_VAL_Y 22 +#define G_RADIUS 8 + +#define CAR_LBL_Y (GRID_Y + G_TILE_H + 8) +#define CHIP_Y (CAR_LBL_Y + 20) +#define CHIP_H 24 +#define CHIP_GAP 6 +#define CHIP_PAD 10 +#define CHIP_RAD 8 +#define KV_Y (CHIP_Y + CHIP_H + 8) + +#define COL_DIM 0x8A8594 +#define COL_OK 0x00E676 + +#define CARRIER_COUNT 4 +#define CHIP_TXT_LEN 10 + +static const int G_TILE_X[3] = {MX, MX + G_TILE_W + G_GAP, MX + 2 * (G_TILE_W + G_GAP)}; + +typedef struct { + const char *cap; + const char *unit; +} stat_t; + +static const stat_t STATS[3] = { + {"PULSES", NULL}, + {"LENGTH", "ms"}, + {"PEAK", "ms"}, +}; +static char s_stat_vals[3][12]; + +static const char *CARRIERS[CARRIER_COUNT] = {"36", "37", "38", "40"}; + +static const lv_point_precise_t PULSE_PTS[] = { + {0, 38}, {6, 38}, {6, 6}, {14, 6}, {14, 38}, {40, 38}, {40, 6}, {46, 6}, + {46, 38}, {54, 38}, {54, 6}, {60, 6}, {60, 38}, {84, 38}, {84, 6}, {90, 6}, + {90, 38}, {98, 38}, {98, 6}, {104, 6}, {104, 38}, {128, 38}, {128, 6}, {134, 6}, + {134, 38}, {142, 38}, {142, 6}, {148, 6}, {148, 38}, {172, 38}, {172, 6}, {178, 6}, + {178, 38}, {186, 38}, {186, 6}, {192, 6}, {192, 38}, {200, 38}}; +#define PULSE_PT_COUNT ((int)(sizeof(PULSE_PTS) / sizeof(PULSE_PTS[0]))) + +static lv_obj_t *s_screen = NULL; +static lv_obj_t *s_chip[CARRIER_COUNT]; +static lv_obj_t *s_chip_lbl[CARRIER_COUNT]; + +static int s_sel = 2; + +static rmt_symbol_word_t s_raw[IR_MAX_SYMBOLS]; +static size_t s_raw_count = 0; + +// Pull the last frame the RMT RX captured and derive real scope stats from it. +static void compute_stats(void) { + s_raw_count = 0; + if (ir_rx_init() == ESP_OK) + ir_get_last_raw(s_raw, IR_MAX_SYMBOLS, &s_raw_count); + + uint32_t total = 0, peak = 0; + for (size_t i = 0; i < s_raw_count; i++) { + uint32_t d0 = s_raw[i].duration0; + uint32_t d1 = s_raw[i].duration1; + total += d0 + d1; + if (d0 > peak) + peak = d0; + if (d1 > peak) + peak = d1; + } + snprintf(s_stat_vals[0], sizeof(s_stat_vals[0]), "%u", (unsigned)s_raw_count); + snprintf(s_stat_vals[1], sizeof(s_stat_vals[1]), "%u", (unsigned)(total / 1000)); + snprintf(s_stat_vals[2], + sizeof(s_stat_vals[2]), + "%u.%u", + (unsigned)(peak / 1000), + (unsigned)((peak % 1000) / 100)); +} + +static void build_scope_card(void) { + bool has = s_raw_count > 0; + + lv_obj_t *card = lv_obj_create(s_screen); + lv_obj_remove_flag(card, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_remove_flag(card, LV_OBJ_FLAG_CLICKABLE); + lv_obj_set_size(card, CONTENT_W, CARD1_H); + lv_obj_align(card, LV_ALIGN_TOP_MID, 0, CARD1_Y); + lv_obj_set_style_radius(card, CARD_RADIUS, 0); + lv_obj_set_style_pad_all(card, 0, 0); + lv_obj_set_style_bg_color(card, current_theme.bg_secondary, 0); + lv_obj_set_style_bg_opa(card, LV_OPA_COVER, 0); + lv_obj_set_style_border_color(card, current_theme.border_accent, 0); + lv_obj_set_style_border_width(card, 1, 0); + + lv_obj_t *hdr = lv_label_create(card); + lv_label_set_text(hdr, "PULSE TRAIN"); + lv_obj_set_style_text_font(hdr, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(hdr, lv_color_hex(COL_DIM), 0); + lv_obj_align(hdr, LV_ALIGN_TOP_LEFT, CARD_PAD_X, CARD_HDR_Y); + + lv_obj_t *cap_grp = lv_obj_create(card); + lv_obj_remove_flag(cap_grp, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(cap_grp, LV_SIZE_CONTENT, 16); + lv_obj_align(cap_grp, LV_ALIGN_TOP_RIGHT, -CARD_PAD_X, CARD_HDR_Y); + lv_obj_set_style_bg_opa(cap_grp, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(cap_grp, 0, 0); + lv_obj_set_style_pad_all(cap_grp, 0, 0); + lv_obj_set_style_pad_column(cap_grp, 4, 0); + lv_obj_set_flex_flow(cap_grp, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(cap_grp, LV_FLEX_ALIGN_END, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + + lv_obj_t *dot = lv_obj_create(cap_grp); + lv_obj_remove_flag(dot, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(dot, DOT_SIZE, DOT_SIZE); + lv_obj_set_style_radius(dot, LV_RADIUS_CIRCLE, 0); + lv_obj_set_style_border_width(dot, 0, 0); + lv_obj_set_style_pad_all(dot, 0, 0); + lv_obj_set_style_bg_color(dot, lv_color_hex(has ? COL_OK : COL_DIM), 0); + lv_obj_set_style_bg_opa(dot, LV_OPA_COVER, 0); + + lv_obj_t *cap_lbl = lv_label_create(cap_grp); + lv_label_set_text(cap_lbl, has ? "CAPTURED" : "EMPTY"); + lv_obj_set_style_text_font(cap_lbl, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(cap_lbl, lv_color_hex(has ? COL_OK : COL_DIM), 0); + + lv_obj_t *scope = lv_line_create(card); + lv_line_set_points(scope, PULSE_PTS, PULSE_PT_COUNT); + lv_obj_set_pos(scope, SCOPE_X, SCOPE_Y); + lv_obj_set_style_line_color(scope, current_theme.border_accent, 0); + lv_obj_set_style_line_opa(scope, has ? LV_OPA_COVER : LV_OPA_40, 0); + lv_obj_set_style_line_width(scope, 2, 0); + lv_obj_set_style_line_rounded(scope, false, 0); +} + +static void build_stat_tile(int i) { + lv_obj_t *tile = lv_obj_create(s_screen); + lv_obj_remove_flag(tile, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_remove_flag(tile, LV_OBJ_FLAG_CLICKABLE); + lv_obj_set_size(tile, G_TILE_W, G_TILE_H); + lv_obj_set_pos(tile, G_TILE_X[i], GRID_Y); + lv_obj_set_style_radius(tile, G_RADIUS, 0); + lv_obj_set_style_pad_all(tile, 0, 0); + lv_obj_set_style_bg_color(tile, current_theme.bg_primary, 0); + lv_obj_set_style_bg_opa(tile, LV_OPA_COVER, 0); + lv_obj_set_style_border_color(tile, current_theme.border_inactive, 0); + lv_obj_set_style_border_width(tile, 1, 0); + + lv_obj_t *cap = lv_label_create(tile); + lv_label_set_text(cap, STATS[i].cap); + lv_obj_set_style_text_font(cap, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(cap, lv_color_hex(COL_DIM), 0); + lv_obj_align(cap, LV_ALIGN_TOP_LEFT, G_TILE_PAD, G_CAP_Y); + + lv_obj_t *grp = lv_obj_create(tile); + lv_obj_remove_flag(grp, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(grp, G_TILE_W - 2 * G_TILE_PAD, 22); + lv_obj_align(grp, LV_ALIGN_TOP_LEFT, G_TILE_PAD, G_VAL_Y); + lv_obj_set_style_bg_opa(grp, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(grp, 0, 0); + lv_obj_set_style_pad_all(grp, 0, 0); + lv_obj_set_style_pad_column(grp, 3, 0); + lv_obj_set_flex_flow(grp, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(grp, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + + lv_obj_t *val = lv_label_create(grp); + lv_label_set_text(val, s_stat_vals[i]); + lv_obj_set_style_text_font(val, &lv_font_montserrat_16, 0); + lv_obj_set_style_text_color(val, current_theme.text_main, 0); + + if (STATS[i].unit != NULL) { + lv_obj_t *unit = lv_label_create(grp); + lv_label_set_text(unit, STATS[i].unit); + lv_obj_set_style_text_font(unit, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(unit, lv_color_hex(COL_DIM), 0); + } +} + +static void style_chip(int i, bool sel) { + char buf[CHIP_TXT_LEN]; + if (sel) { + lv_snprintf(buf, sizeof(buf), "%s kHz", CARRIERS[i]); + lv_label_set_text(s_chip_lbl[i], buf); + lv_obj_set_style_bg_color(s_chip[i], current_theme.border_accent, 0); + lv_obj_set_style_bg_opa(s_chip[i], LV_OPA_20, 0); + lv_obj_set_style_border_color(s_chip[i], current_theme.border_accent, 0); + lv_obj_set_style_text_color(s_chip_lbl[i], current_theme.border_accent, 0); + } else { + lv_label_set_text(s_chip_lbl[i], CARRIERS[i]); + lv_obj_set_style_bg_color(s_chip[i], current_theme.bg_primary, 0); + lv_obj_set_style_bg_opa(s_chip[i], LV_OPA_COVER, 0); + lv_obj_set_style_border_color(s_chip[i], current_theme.border_inactive, 0); + lv_obj_set_style_text_color(s_chip_lbl[i], lv_color_hex(COL_DIM), 0); + } +} + +static void build_carrier(void) { + lv_obj_t *lbl = lv_label_create(s_screen); + lv_label_set_text(lbl, "CARRIER FREQUENCY"); + lv_obj_set_style_text_font(lbl, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(lbl, lv_color_hex(COL_DIM), 0); + lv_obj_align(lbl, LV_ALIGN_TOP_LEFT, MX, CAR_LBL_Y); + + lv_obj_t *row = lv_obj_create(s_screen); + lv_obj_remove_flag(row, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(row, CONTENT_W, CHIP_H); + lv_obj_align(row, LV_ALIGN_TOP_LEFT, MX, CHIP_Y); + lv_obj_set_style_bg_opa(row, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(row, 0, 0); + lv_obj_set_style_pad_all(row, 0, 0); + lv_obj_set_style_pad_column(row, CHIP_GAP, 0); + 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); + + for (int i = 0; i < CARRIER_COUNT; i++) { + lv_obj_t *chip = lv_obj_create(row); + lv_obj_remove_flag(chip, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(chip, LV_SIZE_CONTENT, CHIP_H); + lv_obj_set_style_radius(chip, CHIP_RAD, 0); + lv_obj_set_style_border_width(chip, 1, 0); + lv_obj_set_style_pad_hor(chip, CHIP_PAD, 0); + lv_obj_set_style_pad_ver(chip, 0, 0); + lv_obj_set_flex_flow(chip, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(chip, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + s_chip[i] = chip; + + lv_obj_t *ct = lv_label_create(chip); + lv_obj_set_style_text_font(ct, &lv_font_montserrat_12, 0); + s_chip_lbl[i] = ct; + + style_chip(i, i == s_sel); + } +} + +static void build_saved_row(void) { + lv_obj_t *tag = lv_label_create(s_screen); + lv_label_set_text(tag, "Last capture"); + lv_obj_set_style_text_font(tag, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(tag, lv_color_hex(COL_DIM), 0); + lv_obj_align(tag, LV_ALIGN_TOP_LEFT, MX, KV_Y); + + lv_obj_t *name = lv_label_create(s_screen); + if (s_raw_count > 0) + lv_label_set_text_fmt(name, "%u pulses", (unsigned)s_raw_count); + else + lv_label_set_text(name, "none"); + lv_obj_set_style_text_font(name, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(name, current_theme.border_accent, 0); + lv_obj_align(name, LV_ALIGN_TOP_RIGHT, -MX, KV_Y); +} + +static void refresh_selection(void) { + for (int i = 0; i < CARRIER_COUNT; i++) + style_chip(i, i == s_sel); +} + +static void ir_raw_input(const input_event_t *ev, void *ctx) { + (void)ctx; + const bool press = (ev->action == INPUT_ACTION_PRESS); + const bool nav = press || (ev->action == INPUT_ACTION_REPEAT); + + switch (ev->button) { + case INPUT_BTN_BACK: + if (press) + ui_switch_screen(SCREEN_IR_MENU); + break; + case INPUT_BTN_DOWN: + case INPUT_BTN_RIGHT: + if (nav) { + s_sel = (s_sel + 1) % CARRIER_COUNT; + refresh_selection(); + ui_feedback(UI_FB_NAV); + } + break; + case INPUT_BTN_UP: + case INPUT_BTN_LEFT: + if (nav) { + s_sel = (s_sel - 1 + CARRIER_COUNT) % CARRIER_COUNT; + refresh_selection(); + ui_feedback(UI_FB_NAV); + } + break; + case INPUT_BTN_OK: + if (press) { + if (s_raw_count > 0) { + uint32_t hz = (uint32_t)atoi(CARRIERS[s_sel]) * 1000; + ir_store_send_raw(s_raw, s_raw_count, hz); + notify(NOTIFY_INFO, "Replaying RAW signal"); + ui_feedback(UI_FB_EMULATE); + } else { + notify(NOTIFY_WARNING, "No RAW signal captured"); + } + } + break; + default: + break; + } +} + +void ui_ir_raw_open(void) { + if (s_screen != NULL) { + lv_obj_del(s_screen); + s_screen = NULL; + } + s_sel = 2; + + compute_stats(); + + s_screen = lv_obj_create(NULL); + lv_obj_set_style_bg_color(s_screen, current_theme.screen_base, 0); + lv_obj_set_style_bg_opa(s_screen, LV_OPA_COVER, 0); + lv_obj_remove_flag(s_screen, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_border_width(s_screen, 0, 0); + lv_obj_set_style_pad_all(s_screen, 0, 0); + + ui_chrome_header(s_screen, HDR_TITLE, HDR_ICON); + + build_scope_card(); + for (int i = 0; i < 3; i++) + build_stat_tile(i); + build_carrier(); + build_saved_row(); + + ui_chrome_footer(s_screen, FOOTER); + + ui_input_set_screen_handler(ir_raw_input, NULL); + + ui_screen_load_owned(&s_screen, s_screen); +} diff --git a/firmware_p4/components/Applications/ui/screens/infrared/ir_receive_ui.c b/firmware_p4/components/Applications/ui/screens/infrared/ir_receive_ui.c index dbf02e31d..4eaa9e5b3 100644 --- a/firmware_p4/components/Applications/ui/screens/infrared/ir_receive_ui.c +++ b/firmware_p4/components/Applications/ui/screens/infrared/ir_receive_ui.c @@ -16,287 +16,446 @@ #include "ir_receive_ui.h" #include -#include #include "esp_log.h" -#include "freertos/FreeRTOS.h" -#include "freertos/task.h" -#include "st7789.h" - -#include "buttons_gpio.h" -#include "ir.h" -#include "ir_file.h" -#include "ir_protocol.h" -#include "keyboard_ui.h" -#include "msgbox_ui.h" -#include "spinner_ui.h" -#include "storage_mkdir.h" -#include "tos_storage_paths.h" +#include "lvgl.h" + +#include "capture_result_ui.h" +#include "ir_store.h" +#include "notify_ui.h" +#include "sigwave_ui.h" +#include "ui_chrome.h" +#include "ui_feedback.h" #include "ui_manager.h" #include "ui_theme.h" +#include "waves_ui.h" static const char *TAG = "IR_RX_UI"; -#define OUTER_BORDER 4 -#define TOP_BORDER_H 46 -#define TOP_AREA_BORDER_WIDTH 3 -#define TITLE_BAR_W 170 -#define TITLE_BAR_H 30 -#define TITLE_BAR_RADIUS 12 -#define TITLE_BAR_BORDER_WIDTH 2 -#define STATUS_LABEL_OFFSET_Y 12 -#define DETAIL_LABEL_OFFSET_Y 35 -#define DETAIL_LABEL_MARGIN 20 -#define SPINNER_SIZE 30 -#define SPINNER_OFFSET_Y (-20) -#define NAV_TIMER_INTERVAL_MS 50 -#define KB_OPEN_DELAY_MS 300 -#define RX_TASK_STACK_SIZE 4096 -#define RX_TASK_PRIORITY 5 -#define RX_TIMEOUT_MS 15000 -#define IR_DIR_MAX_LEN 300 -#define IR_PATH_MAX_LEN 300 -#define IR_BUF_MAX_LEN 512 -#define IR_DETAIL_BUF_LEN 128 +#define SIG_GREEN 0x00E676 + +#define HEADER_TITLE_Y 10 +#define HEADER_RULE_Y 32 +#define HEADER_RULE_W 70 +#define HEADER_RULE_H 2 +#define HEADER_RULE_RADIUS 1 + +#define STATUS_Y 48 +#define DETAIL_Y 66 + +#define POLL_TICK_MS 50 +#define CAPTURE_WINDOW_MS 1500 // one ir_capture_start() window; re-armed while listening +#define DOT_CYCLE_MS 350 + +#define CARD_W 162 +#define CARD_H 82 +#define CARD_RADIUS 12 +#define CARD_BORDER 2 +#define CARD_Y_OFS -28 +#define CARD_RISE_PX 70 +#define CARD_RISE_MS 450 + +#define IR_ICON "/assets/icons/settings_input_antenna.bin" + +#define STATUS_IDLE "Press OK to start" +#define STATUS_BUSY "Waiting for signal" +#define STATUS_CAPTURED "Signal captured!" +#define STATUS_SAVED "Signal saved!" + +#define DETAIL_AIM "Point remote at device" + +#define HINT_IDLE "OK = Capture BACK = Exit" +#define HINT_BUSY "BACK to cancel" +#define HINT_SHOW "BACK = Exit" +#define HINT_CAPTURED "UP/DOWN choose OK do BACK exit" + +#define REVEAL_MS 3000 + +#define WAVES_IDLE_OPA LV_OPA_40 + +typedef enum { + ST_IDLE = 0, + ST_CAPTURING, + ST_CAPTURED, + ST_OPTIONS, +} rx_state_t; static lv_obj_t *s_screen = NULL; -static lv_timer_t *s_nav_timer = NULL; +static lv_timer_t *s_tick_timer = NULL; static lv_obj_t *s_status_label = NULL; static lv_obj_t *s_detail_label = NULL; -static spinner_ui_t s_spinner; - -static bool s_btn_back_last = false; -static bool s_btn_ok_last = false; -static TaskHandle_t s_rx_task_handle = NULL; -static volatile bool s_is_rx_done = false; -static volatile bool s_is_rx_success = false; -static ir_data_t s_rx_result; - -static void rx_task(void *pvParameters); -static void on_save_result(bool is_confirm); -static void on_name_entered(const char *text, void *user_data); -static void deferred_kb_open(lv_timer_t *timer); -static void on_ask_save(bool is_confirm); -static void show_waiting(void); -static void show_result(void); -static void nav_timer_cb(lv_timer_t *timer); +static lv_obj_t *s_hint_label = NULL; +static lv_obj_t *s_waves = NULL; +static lv_obj_t *s_sig = NULL; +static lv_obj_t *s_card = NULL; +static capture_result_t s_cr = {0}; +static rx_state_t s_state = ST_IDLE; +static uint32_t s_capture_start = 0; +static uint32_t s_captured_at = 0; +static bool s_saved = false; +static bool s_listening = false; +static ir_data_t s_captured = {0}; +static char s_card_text[48]; +static rmt_symbol_word_t s_raw_buf[IR_MAX_SYMBOLS]; + +static void ir_receive_tick_cb(lv_timer_t *timer); +static void ir_receive_input(const input_event_t *ev, void *ctx); +static void build_captured_card(void); + +static void clear_result(void) { + if (s_card != NULL) { + lv_obj_del(s_card); + s_card = NULL; + } + capture_result_destroy(&s_cr); +} + +static void card_rise_cb(void *var, int32_t v) { + lv_obj_set_style_translate_y((lv_obj_t *)var, v, 0); +} + +static void set_status(const char *text, bool success) { + if (s_status_label == NULL) + return; + lv_label_set_text(s_status_label, text); + lv_obj_set_style_text_color( + s_status_label, success ? lv_color_hex(SIG_GREEN) : current_theme.text_main, 0); +} + +static void set_hint(const char *text) { + if (s_hint_label != NULL) + ui_chrome_footer_set_text(s_hint_label, text); +} + +static void stop_listening(void) { + s_listening = false; + ir_capture_reset(); +} + +static void start_capture(void) { + clear_result(); + s_state = ST_CAPTURING; + s_saved = false; + s_capture_start = lv_tick_get(); + if (s_status_label) + lv_obj_remove_flag(s_status_label, LV_OBJ_FLAG_HIDDEN); + if (s_detail_label) + lv_obj_remove_flag(s_detail_label, LV_OBJ_FLAG_HIDDEN); + set_status(STATUS_BUSY, false); + if (s_detail_label) + lv_label_set_text(s_detail_label, DETAIL_AIM); + if (s_waves) { + lv_obj_remove_flag(s_waves, LV_OBJ_FLAG_HIDDEN); + lv_obj_set_style_opa(s_waves, LV_OPA_COVER, 0); + } + if (s_sig) + lv_obj_remove_flag(s_sig, LV_OBJ_FLAG_HIDDEN); + set_hint(HINT_BUSY); + + ir_capture_reset(); + ir_capture_start(CAPTURE_WINDOW_MS); + s_listening = true; +} + +// Populate the captured card from the real decoded frame in s_captured. +static void build_captured_card(void) { + s_state = ST_CAPTURED; + s_listening = false; + if (s_waves) + lv_obj_add_flag(s_waves, LV_OBJ_FLAG_HIDDEN); + if (s_sig) + lv_obj_add_flag(s_sig, LV_OBJ_FLAG_HIDDEN); + set_status(STATUS_CAPTURED, true); + if (s_detail_label) + lv_label_set_text(s_detail_label, ""); + + s_card = lv_obj_create(s_screen); + lv_obj_remove_flag(s_card, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(s_card, CARD_W, CARD_H); + lv_obj_align(s_card, LV_ALIGN_CENTER, 0, CARD_Y_OFS); + lv_obj_set_style_radius(s_card, CARD_RADIUS, 0); + lv_obj_set_style_bg_opa(s_card, LV_OPA_COVER, 0); + lv_obj_set_style_bg_color(s_card, current_theme.bg_primary, 0); + lv_obj_set_style_bg_grad_color(s_card, current_theme.bg_secondary, 0); + lv_obj_set_style_bg_grad_dir(s_card, LV_GRAD_DIR_VER, 0); + lv_obj_set_style_border_width(s_card, CARD_BORDER, 0); + lv_obj_set_style_border_color(s_card, current_theme.border_accent, 0); + lv_obj_set_style_pad_all(s_card, 6, 0); + + lv_obj_t *info = lv_label_create(s_card); + if (s_captured.protocol != IR_PROTO_UNKNOWN) + snprintf(s_card_text, + sizeof(s_card_text), + LV_SYMBOL_OK " %s 0x%02lX / 0x%02lX", + ir_protocol_name(s_captured.protocol), + (unsigned long)s_captured.address, + (unsigned long)s_captured.command); + else + snprintf(s_card_text, sizeof(s_card_text), LV_SYMBOL_OK " RAW signal"); + lv_label_set_text(info, s_card_text); + lv_obj_set_style_text_color(info, current_theme.text_main, 0); + lv_obj_set_style_text_font(info, &lv_font_montserrat_12, 0); + lv_obj_align(info, LV_ALIGN_TOP_MID, 0, 0); + + sigwave_create_static(s_card, LV_ALIGN_BOTTOM_MID, 0, -2); + + lv_anim_t a; + lv_anim_init(&a); + lv_anim_set_var(&a, s_card); + lv_anim_set_exec_cb(&a, card_rise_cb); + lv_anim_set_values(&a, CARD_RISE_PX, 0); + lv_anim_set_duration(&a, CARD_RISE_MS); + lv_anim_set_path_cb(&a, lv_anim_path_ease_out); + lv_anim_start(&a); + + s_captured_at = lv_tick_get(); + set_hint(HINT_SHOW); + ESP_LOGI(TAG, "captured %s", ir_protocol_name(s_captured.protocol)); + ir_print_data(&s_captured); + ui_feedback(UI_FB_READ); +} + +// Re-transmit the captured signal — decoded frame, or raw fallback. +static void send_captured(void) { + if (s_captured.protocol != IR_PROTO_UNKNOWN) { + ir_store_send_data(&s_captured); + return; + } + size_t n = 0; + if (ir_get_last_raw(s_raw_buf, IR_MAX_SYMBOLS, &n) == ESP_OK && n > 0) + ir_store_send_raw(s_raw_buf, n, IR_CARRIER_HZ_DEFAULT); +} + +// Persist the captured signal to /sdcard/ir as a Flipper .ir file. +static bool save_captured(void) { + char name[IR_STORE_NAME_MAX]; + if (s_captured.protocol != IR_PROTO_UNKNOWN) { + ir_store_name_for_data(&s_captured, name, sizeof(name)); + return ir_store_save_data(name, &s_captured) == ESP_OK; + } + size_t n = 0; + if (ir_get_last_raw(s_raw_buf, IR_MAX_SYMBOLS, &n) != ESP_OK || n == 0) + return false; + ir_store_next_free("raw", name, sizeof(name)); + return ir_store_save_raw(name, s_raw_buf, n, IR_CARRIER_HZ_DEFAULT) == ESP_OK; +} + +static void show_options(void) { + if (s_card != NULL) { + lv_obj_del(s_card); + s_card = NULL; + } + if (s_status_label) + lv_obj_add_flag(s_status_label, LV_OBJ_FLAG_HIDDEN); + if (s_detail_label) + lv_obj_add_flag(s_detail_label, LV_OBJ_FLAG_HIDDEN); + + static char sub[24]; + static char val[32]; + if (s_captured.protocol != IR_PROTO_UNKNOWN) { + snprintf(sub, sizeof(sub), "%s protocol", ir_protocol_name(s_captured.protocol)); + snprintf(val, + sizeof(val), + "cmd 0x%02lX / 0x%02lX", + (unsigned long)s_captured.address, + (unsigned long)s_captured.command); + } else { + snprintf(sub, sizeof(sub), "RAW capture"); + snprintf(val, sizeof(val), "unknown protocol"); + } + + capture_result_cfg_t cfg = { + .accent = current_theme.border_accent, + .card_icon = IR_ICON, + .card_title = "Signal captured", + .card_sub = sub, + .card_value = val, + .primary_label = "Send", + .again_label = "Receive again", + }; + s_cr = capture_result_create(s_screen, &cfg); + s_state = ST_OPTIONS; + set_hint(HINT_CAPTURED); +} void ui_ir_receive_open(void) { if (s_screen != NULL) { lv_obj_del(s_screen); s_screen = NULL; } - - s_is_rx_done = false; - s_is_rx_success = false; - s_rx_task_handle = NULL; + stop_listening(); + s_card = NULL; + s_cr = (capture_result_t){0}; + s_state = ST_IDLE; + s_saved = false; + s_captured = (ir_data_t){0}; s_screen = lv_obj_create(NULL); lv_obj_set_style_bg_color(s_screen, current_theme.screen_base, 0); lv_obj_set_style_bg_opa(s_screen, LV_OPA_COVER, 0); lv_obj_remove_flag(s_screen, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_set_style_border_width(s_screen, OUTER_BORDER, 0); - lv_obj_set_style_border_color(s_screen, current_theme.border_interface, 0); + lv_obj_set_style_border_width(s_screen, 0, 0); lv_obj_set_style_pad_all(s_screen, 0, 0); - lv_obj_t *top_area = lv_obj_create(s_screen); - lv_obj_set_size(top_area, LCD_H_RES - OUTER_BORDER * 2, TOP_BORDER_H); - lv_obj_align(top_area, LV_ALIGN_TOP_MID, 0, 0); - lv_obj_remove_flag(top_area, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_set_style_bg_opa(top_area, LV_OPA_TRANSP, 0); - lv_obj_set_style_border_width(top_area, TOP_AREA_BORDER_WIDTH, 0); - lv_obj_set_style_border_color(top_area, current_theme.border_interface, 0); - lv_obj_set_style_border_side(top_area, LV_BORDER_SIDE_BOTTOM, 0); - lv_obj_set_style_radius(top_area, 0, 0); - lv_obj_set_style_pad_all(top_area, 0, 0); - - lv_obj_t *title_bar = lv_obj_create(top_area); - lv_obj_set_size(title_bar, TITLE_BAR_W, TITLE_BAR_H); - lv_obj_align(title_bar, LV_ALIGN_CENTER, 0, 0); - lv_obj_remove_flag(title_bar, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_set_style_radius(title_bar, TITLE_BAR_RADIUS, 0); - lv_obj_set_style_bg_opa(title_bar, LV_OPA_COVER, 0); - lv_obj_set_style_bg_color(title_bar, current_theme.bg_primary, 0); - lv_obj_set_style_bg_grad_color(title_bar, current_theme.bg_secondary, 0); - lv_obj_set_style_bg_grad_dir(title_bar, LV_GRAD_DIR_HOR, 0); - lv_obj_set_style_border_width(title_bar, TITLE_BAR_BORDER_WIDTH, 0); - lv_obj_set_style_border_color(title_bar, current_theme.border_accent, 0); - - lv_obj_t *title_lbl = lv_label_create(title_bar); - lv_label_set_text(title_lbl, "IR LEARN"); - lv_obj_set_style_text_color(title_lbl, current_theme.text_main, 0); - lv_obj_set_style_text_font(title_lbl, &lv_font_montserrat_14, 0); - lv_obj_center(title_lbl); + ui_chrome_header(s_screen, "Learn", "/assets/icons/settings_input_antenna.bin"); s_status_label = lv_label_create(s_screen); - lv_label_set_text(s_status_label, "Press OK to start"); + lv_label_set_text(s_status_label, STATUS_IDLE); lv_obj_set_style_text_color(s_status_label, current_theme.text_main, 0); lv_obj_set_style_text_font(s_status_label, &lv_font_montserrat_14, 0); lv_obj_set_style_text_align(s_status_label, LV_TEXT_ALIGN_CENTER, 0); - lv_obj_align(s_status_label, LV_ALIGN_TOP_MID, 0, TOP_BORDER_H + STATUS_LABEL_OFFSET_Y); + lv_obj_align(s_status_label, LV_ALIGN_TOP_MID, 0, STATUS_Y); s_detail_label = lv_label_create(s_screen); lv_label_set_text(s_detail_label, ""); lv_obj_set_style_text_color(s_detail_label, current_theme.border_accent, 0); lv_obj_set_style_text_font(s_detail_label, &lv_font_montserrat_12, 0); lv_obj_set_style_text_align(s_detail_label, LV_TEXT_ALIGN_CENTER, 0); - lv_obj_set_width(s_detail_label, LCD_H_RES - OUTER_BORDER * 2 - DETAIL_LABEL_MARGIN); - lv_obj_align(s_detail_label, LV_ALIGN_TOP_MID, 0, TOP_BORDER_H + DETAIL_LABEL_OFFSET_Y); - - s_spinner = spinner_ui_create(s_screen, SPINNER_SIZE); - lv_obj_align(s_spinner.obj, LV_ALIGN_BOTTOM_MID, 0, SPINNER_OFFSET_Y); - spinner_ui_hide(&s_spinner); + lv_obj_align(s_detail_label, LV_ALIGN_TOP_MID, 0, DETAIL_Y); - if (s_nav_timer == NULL) - s_nav_timer = lv_timer_create(nav_timer_cb, NAV_TIMER_INTERVAL_MS, NULL); + s_waves = waves_create(s_screen, LV_ALIGN_CENTER, 0, 12, NULL, IR_ICON); + lv_obj_set_style_opa(s_waves, WAVES_IDLE_OPA, 0); + s_sig = sigwave_create(s_screen, LV_ALIGN_BOTTOM_MID, 0, -28); + lv_obj_add_flag(s_sig, LV_OBJ_FLAG_HIDDEN); - lv_screen_load(s_screen); -} + s_hint_label = ui_chrome_footer(s_screen, HINT_IDLE); -static void rx_task(void *pvParameters) { - (void)pvParameters; - ir_rx_init(); - s_is_rx_success = ir_receive(&s_rx_result, RX_TIMEOUT_MS); - s_is_rx_done = true; - s_rx_task_handle = NULL; - vTaskDelete(NULL); -} + if (s_tick_timer == NULL) + s_tick_timer = lv_timer_create(ir_receive_tick_cb, POLL_TICK_MS, NULL); + ui_input_set_screen_handler(ir_receive_input, NULL); -static void on_save_result(bool is_confirm) { - if (is_confirm) { - if (s_status_label != NULL) - lv_label_set_text(s_status_label, "Press OK to start"); - if (s_detail_label != NULL) - lv_label_set_text(s_detail_label, ""); - } else { - ui_switch_screen(SCREEN_IR_MENU); - } + ui_screen_load_owned(&s_screen, s_screen); } -static void on_name_entered(const char *text, void *user_data) { - (void)user_data; - - if (text == NULL || strlen(text) == 0) +static void capturing_tick(void) { + if (s_status_label == NULL) return; - - ir_file_t file; - ir_file_init(&file); - ir_file_add_parsed(&file, text, &s_rx_result); - - char buf[IR_BUF_MAX_LEN]; - size_t len = ir_file_to_string(&file, buf, sizeof(buf)); - bool is_saved = false; - - if (len > 0) { - const char *proto = ir_protocol_name(s_rx_result.protocol); - - char dir[IR_DIR_MAX_LEN]; - snprintf(dir, sizeof(dir), TOS_PATH_IR "/%.64s", proto); - storage_mkdir_recursive(dir); - - char path[IR_PATH_MAX_LEN]; - snprintf(path, sizeof(path), TOS_PATH_IR "/%.64s/%.64s.ir", proto, text); - - FILE *f = fopen(path, "w"); - if (f != NULL) { - fwrite(buf, 1, len, f); - fclose(f); - is_saved = true; - ESP_LOGI(TAG, "Saved: %s", path); - } - } - - ir_file_free(&file); - - if (is_saved) - msgbox_open(LV_SYMBOL_OK, "Signal saved!", "Continue", "Exit", on_save_result); - else - msgbox_open(LV_SYMBOL_WARNING, "Failed to save!", "Continue", "Exit", on_save_result); -} - -static void deferred_kb_open(lv_timer_t *timer) { - (void)timer; - keyboard_open(NULL, on_name_entered, NULL); + int dots = ((lv_tick_get() - s_capture_start) / DOT_CYCLE_MS) % 4; + char buf[24]; + snprintf(buf, + sizeof(buf), + "%s%s", + STATUS_BUSY, + dots == 1 ? "." + : dots == 2 ? ".." + : dots == 3 ? "..." + : ""); + lv_label_set_text(s_status_label, buf); } -static void on_ask_save(bool is_confirm) { - if (is_confirm) { - lv_timer_t *kb_timer = lv_timer_create(deferred_kb_open, KB_OPEN_DELAY_MS, NULL); - lv_timer_set_repeat_count(kb_timer, 1); - } else { - if (s_status_label != NULL) - lv_label_set_text(s_status_label, "Press OK to start"); - if (s_detail_label != NULL) - lv_label_set_text(s_detail_label, ""); +// Return the screen to its idle "Press OK" state (used on capture error). +static void back_to_idle(void) { + stop_listening(); + s_state = ST_IDLE; + set_status(STATUS_IDLE, false); + if (s_detail_label) + lv_label_set_text(s_detail_label, ""); + if (s_waves) { + lv_obj_remove_flag(s_waves, LV_OBJ_FLAG_HIDDEN); + lv_obj_set_style_opa(s_waves, WAVES_IDLE_OPA, 0); } + if (s_sig) + lv_obj_add_flag(s_sig, LV_OBJ_FLAG_HIDDEN); + set_hint(HINT_IDLE); } -static void show_waiting(void) { - if (s_status_label != NULL) - lv_label_set_text(s_status_label, "Waiting for signal..."); - if (s_detail_label != NULL) - lv_label_set_text(s_detail_label, "Point remote at device"); - spinner_ui_show(&s_spinner); -} - -static void show_result(void) { - spinner_ui_hide(&s_spinner); - - if (s_is_rx_success) { - lv_label_set_text(s_status_label, "Signal captured!"); - - char buf[IR_DETAIL_BUF_LEN]; - snprintf(buf, - sizeof(buf), - "Protocol: %s\nAddress: 0x%04X\nCommand: 0x%04X", - ir_protocol_name(s_rx_result.protocol), - s_rx_result.address, - s_rx_result.command); - lv_label_set_text(s_detail_label, buf); - - msgbox_open(LV_SYMBOL_OK, "Save signal?", "Yes", "No", on_ask_save); - } else { - lv_label_set_text(s_status_label, "No signal detected"); - lv_label_set_text(s_detail_label, "Press OK to try again"); +static void poll_capture(void) { + capturing_tick(); + ir_data_t d; + ir_cap_status_t st = ir_capture_poll(&d); + if (st == IR_CAP_GOT) { + s_captured = d; + build_captured_card(); + } else if (st == IR_CAP_TIMEOUT && s_listening) { + // Nothing yet — re-arm and keep listening until a signal or the user cancels. + ir_capture_reset(); + ir_capture_start(CAPTURE_WINDOW_MS); + } else if (st == IR_CAP_ERROR) { + // RX channel couldn't be brought up — don't spin forever on "Waiting...". + back_to_idle(); + notify(NOTIFY_WARNING, "IR receiver unavailable"); } } -static void nav_timer_cb(lv_timer_t *timer) { +static void ir_receive_tick_cb(lv_timer_t *timer) { if (lv_screen_active() != s_screen) { lv_timer_delete(timer); - s_nav_timer = NULL; + s_tick_timer = NULL; return; } - if (ui_input_is_locked()) - return; - - if (msgbox_is_open()) - return; - - if (s_is_rx_done) { - s_is_rx_done = false; - show_result(); + if (s_state == ST_CAPTURING) { + poll_capture(); + } else if (s_state == ST_CAPTURED) { + if (lv_tick_get() - s_captured_at >= REVEAL_MS) + show_options(); } +} - bool is_back = back_button_is_down(); - bool is_ok = ok_button_is_down(); +static void ir_receive_input(const input_event_t *ev, void *ctx) { + (void)ctx; + const bool press = (ev->action == INPUT_ACTION_PRESS); + const bool nav = press || (ev->action == INPUT_ACTION_REPEAT); - if (is_back && !s_btn_back_last) { - if (s_rx_task_handle != NULL) { - vTaskDelete(s_rx_task_handle); - s_rx_task_handle = NULL; + if (ev->button == INPUT_BTN_BACK) { + if (press) { + stop_listening(); + ui_switch_screen(SCREEN_IR_MENU); } - ui_switch_screen(SCREEN_IR_MENU); + return; } - if (is_ok && !s_btn_ok_last && s_rx_task_handle == NULL) { - s_is_rx_done = false; - s_is_rx_success = false; - show_waiting(); - xTaskCreate(rx_task, "ir_rx", RX_TASK_STACK_SIZE, NULL, RX_TASK_PRIORITY, &s_rx_task_handle); + if (s_state == ST_IDLE) { + if (ev->button == INPUT_BTN_OK && press) + start_capture(); + } else if (s_state == ST_OPTIONS) { + switch (ev->button) { + case INPUT_BTN_DOWN: + if (nav) { + capture_result_next(&s_cr); + ui_feedback(UI_FB_NAV); + } + break; + case INPUT_BTN_UP: + if (nav) { + capture_result_prev(&s_cr); + ui_feedback(UI_FB_NAV); + } + break; + case INPUT_BTN_OK: + if (press) { + switch (capture_result_selected(&s_cr)) { + case CAP_ACT_PRIMARY: + send_captured(); + ui_feedback(UI_FB_EMULATE); + notify(NOTIFY_INFO, "Signal sent"); + break; + case CAP_ACT_SAVE: + if (!s_saved) { + if (save_captured()) { + s_saved = true; + capture_result_mark_saved(&s_cr); + ui_feedback(UI_FB_WRITE); + notify(NOTIFY_SAVED, "IR signal saved"); + } else { + notify(NOTIFY_WARNING, "Save failed"); + } + } + break; + case CAP_ACT_AGAIN: + start_capture(); + break; + case CAP_ACT_DISCARD: + stop_listening(); + ui_switch_screen(SCREEN_IR_MENU); + return; + default: + break; + } + } + break; + default: + break; + } } - - s_btn_back_last = is_back; - s_btn_ok_last = is_ok; -} \ No newline at end of file +} diff --git a/firmware_p4/components/Applications/ui/screens/infrared/ir_remote_type_ui.c b/firmware_p4/components/Applications/ui/screens/infrared/ir_remote_type_ui.c new file mode 100644 index 000000000..3b80eb15a --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/infrared/ir_remote_type_ui.c @@ -0,0 +1,216 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#include "ir_remote_type_ui.h" + +#include "assets_manager.h" +#include "ir_controller_ui.h" +#include "page_dots_ui.h" +#include "ui_chrome.h" +#include "ui_feedback.h" +#include "ui_manager.h" +#include "ui_theme.h" + +#define TITLE_ICON "/assets/icons/settings_remote.bin" +#define BASE_FRAME "/assets/frames/base_frame_0.bin" +#define CARD_Y_BIAS (-18) +#define CONTRAST_DARK 0x0A0220 +#define LABEL_Y 52 +#define DOTS_Y (-26) + +typedef struct { + const char *name; + const char *icon; + uint32_t color; + bool dark_glyph; + ir_device_t dev; +} device_t; + +static const device_t DEVICES[] = { + {"TV", "/assets/icons/tv.bin", 0x834EC6, false, IR_DEV_TV}, + {"Sound System", "/assets/icons/speaker.bin", 0xFFB020, true, IR_DEV_SOUND}, + {"Air Conditioner", "/assets/icons/ac_unit.bin", 0x00E5D0, true, IR_DEV_AC}, +}; +#define DEVICE_COUNT ((int)(sizeof(DEVICES) / sizeof(DEVICES[0]))) + +static const int32_t CAR_PX[] = {-94, -50, 0, 50, 94}; +static const int32_t CAR_PY[] = {-14, -6, 0, -6, -14}; +static const int32_t CAR_SC[] = {117, 161, 234, 161, 117}; +static const int32_t GLYPH_SC[] = {170, 235, 341, 235, 170}; +static const int32_t CAR_OP[] = {LV_OPA_50, LV_OPA_80, LV_OPA_COVER, LV_OPA_80, LV_OPA_50}; +static const int32_t CAR_Z[] = {0, 1, 2, 1, 0}; +#define CAR_SLOTS 5 +#define CAR_CENTER 2 + +static lv_obj_t *s_screen = NULL; +static lv_obj_t *s_base[DEVICE_COUNT]; +static lv_obj_t *s_glyph[DEVICE_COUNT]; +static lv_obj_t *s_label = NULL; +static page_dots_t s_dots; +static lv_image_dsc_t *s_base_dsc = NULL; +static int s_sel = 0; + +static int32_t carousel_slot(int item_idx) { + int32_t n = DEVICE_COUNT; + int32_t d = (item_idx - s_sel + n) % n; + if (d > n / 2) + d -= n; + int32_t slot = CAR_CENTER + d; + return (slot >= 0 && slot < CAR_SLOTS) ? slot : -1; +} + +static void make_card(lv_obj_t *parent, int i) { + const device_t *dev = &DEVICES[i]; + + lv_obj_t *base = lv_image_create(parent); + if (s_base_dsc) + lv_image_set_src(base, s_base_dsc); + lv_image_set_antialias(base, false); + lv_obj_align(base, LV_ALIGN_CENTER, 0, CARD_Y_BIAS); + lv_obj_set_style_image_recolor(base, lv_color_hex(dev->color), 0); + lv_obj_set_style_image_recolor_opa(base, LV_OPA_COVER, 0); + s_base[i] = base; + + lv_obj_t *glyph = lv_image_create(parent); + lv_image_dsc_t *gd = assets_get(dev->icon); + if (gd) + lv_image_set_src(glyph, gd); + lv_image_set_antialias(glyph, false); + lv_obj_align(glyph, LV_ALIGN_CENTER, 0, CARD_Y_BIAS); + lv_obj_set_style_image_recolor( + glyph, dev->dark_glyph ? lv_color_hex(CONTRAST_DARK) : lv_color_white(), 0); + lv_obj_set_style_image_recolor_opa(glyph, LV_OPA_COVER, 0); + s_glyph[i] = glyph; +} + +static void place_card(int i) { + if (s_base[i] == NULL || s_glyph[i] == NULL) + return; + int32_t slot = carousel_slot(i); + + if (slot < 0) { + lv_obj_add_flag(s_base[i], LV_OBJ_FLAG_HIDDEN); + lv_obj_add_flag(s_glyph[i], LV_OBJ_FLAG_HIDDEN); + return; + } + lv_obj_remove_flag(s_base[i], LV_OBJ_FLAG_HIDDEN); + lv_obj_remove_flag(s_glyph[i], LV_OBJ_FLAG_HIDDEN); + + int32_t x = CAR_PX[slot]; + int32_t y = CAR_PY[slot] + CARD_Y_BIAS; + + lv_obj_align(s_base[i], LV_ALIGN_CENTER, x, y); + lv_image_set_scale(s_base[i], CAR_SC[slot]); + lv_obj_set_style_opa(s_base[i], CAR_OP[slot], 0); + + lv_obj_align(s_glyph[i], LV_ALIGN_CENTER, x, y); + lv_image_set_scale(s_glyph[i], GLYPH_SC[slot]); + lv_obj_set_style_opa(s_glyph[i], CAR_OP[slot], 0); +} + +static void fix_z_order(void) { + for (int z = 0; z <= CAR_CENTER; z++) { + for (int i = 0; i < DEVICE_COUNT; i++) { + int32_t slot = carousel_slot(i); + if (slot >= 0 && CAR_Z[slot] == z) { + lv_obj_move_foreground(s_base[i]); + lv_obj_move_foreground(s_glyph[i]); + } + } + } +} + +static void update_view(void) { + lv_label_set_text_fmt(s_label, LV_SYMBOL_LEFT " %s " LV_SYMBOL_RIGHT, DEVICES[s_sel].name); + page_dots_set(&s_dots, s_sel); + for (int i = 0; i < DEVICE_COUNT; i++) + place_card(i); + fix_z_order(); +} + +static void ir_remote_type_input(const input_event_t *ev, void *ctx) { + (void)ctx; + const bool press = (ev->action == INPUT_ACTION_PRESS); + const bool nav = press || (ev->action == INPUT_ACTION_REPEAT); + + switch (ev->button) { + case INPUT_BTN_BACK: + if (press) + ui_switch_screen(SCREEN_IR_MENU); + break; + case INPUT_BTN_OK: + if (press) { + ui_feedback(UI_FB_SELECT); + ui_ir_controller_set_device(DEVICES[s_sel].dev); + ui_switch_screen(SCREEN_IR_CONTROLLER); + } + break; + case INPUT_BTN_DOWN: + case INPUT_BTN_RIGHT: + if (nav) { + s_sel = (s_sel + 1) % DEVICE_COUNT; + ui_feedback(UI_FB_NAV); + update_view(); + } + break; + case INPUT_BTN_UP: + case INPUT_BTN_LEFT: + if (nav) { + s_sel = (s_sel == 0) ? DEVICE_COUNT - 1 : s_sel - 1; + ui_feedback(UI_FB_NAV); + update_view(); + } + break; + default: + break; + } +} + +void ui_ir_remote_type_open(void) { + if (s_screen != NULL) { + lv_obj_del(s_screen); + s_screen = NULL; + } + s_sel = 0; + + if (s_base_dsc == NULL) + s_base_dsc = assets_get(BASE_FRAME); + + s_screen = lv_obj_create(NULL); + lv_obj_set_style_bg_color(s_screen, current_theme.screen_base, 0); + lv_obj_set_style_bg_opa(s_screen, LV_OPA_COVER, 0); + lv_obj_remove_flag(s_screen, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_border_width(s_screen, 0, 0); + lv_obj_set_style_pad_all(s_screen, 0, 0); + + ui_chrome_header(s_screen, "REMOTE", TITLE_ICON); + ui_chrome_footer(s_screen, LV_SYMBOL_LEFT LV_SYMBOL_RIGHT " Browse " LV_SYMBOL_OK " Select"); + + for (int i = 0; i < DEVICE_COUNT; i++) + make_card(s_screen, i); + + s_label = lv_label_create(s_screen); + lv_obj_set_style_text_font(s_label, &lv_font_montserrat_14, 0); + lv_obj_set_style_text_color(s_label, current_theme.border_accent, 0); + lv_obj_align(s_label, LV_ALIGN_CENTER, 0, LABEL_Y); + + s_dots = page_dots_create(s_screen, DEVICE_COUNT, LV_ALIGN_BOTTOM_MID, 0, DOTS_Y); + + update_view(); + + ui_input_set_screen_handler(ir_remote_type_input, NULL); + + ui_screen_load_owned(&s_screen, s_screen); +} diff --git a/firmware_p4/components/Applications/ui/screens/infrared/ir_saved_ui.c b/firmware_p4/components/Applications/ui/screens/infrared/ir_saved_ui.c index 7bbc40265..0a37c8654 100644 --- a/firmware_p4/components/Applications/ui/screens/infrared/ir_saved_ui.c +++ b/firmware_p4/components/Applications/ui/screens/infrared/ir_saved_ui.c @@ -15,421 +15,521 @@ #include "ir_saved_ui.h" -#include #include #include #include "esp_log.h" +#include "lvgl.h" #include "st7789.h" #include "assets_manager.h" -#include "buttons_gpio.h" -#include "text_viewer_ui.h" -#include "tos_storage_paths.h" +#include "capture_result_ui.h" +#include "ir_store.h" +#include "keyboard_ui.h" +#include "menu_component_ui.h" +#include "msgbox_ui.h" +#include "notify_ui.h" +#include "ui_chrome.h" +#include "ui_feedback.h" #include "ui_manager.h" #include "ui_theme.h" static const char *TAG = "IR_SAVED_UI"; -#define OUTER_BORDER 4 -#define TOP_BORDER_H 46 -#define TOP_AREA_BORDER_WIDTH 3 -#define TITLE_BAR_W 170 -#define TITLE_BAR_H 30 -#define TITLE_BAR_RADIUS 12 -#define TITLE_BAR_BORDER_WIDTH 2 -#define ITEM_H 47 -#define ITEM_W 210 -#define ITEM_RADIUS 10 -#define ITEM_PAD_H 8 -#define ITEM_PAD_COL 6 -#define ITEM_BORDER_WIDTH 1 -#define ITEM_SELECTED_WIDTH 3 -#define ITEMS_Y_OFFSET 4 -#define ITEMS_CONT_X_OFFSET 4 -#define ITEMS_CONT_PAD 2 -#define ITEMS_CONT_PAD_ROW 6 -#define SCROLL_TRACK_OFFSET_X 10 -#define SCROLL_TRACK_MARGIN 10 -#define SCROLL_TRACK_WIDTH 3 -#define SCROLL_TRACK_DASH_W 4 -#define SCROLL_TRACK_DASH_GAP 4 -#define SCROLL_BAR_OFFSET_X (-4) -#define SCROLL_BAR_THUMB_H 20 -#define SCROLL_ANIM_DURATION_MS 150 -#define VIEWER_SCROLL_STEP 30 -#define NAV_TIMER_INTERVAL_MS 50 -#define MAX_ENTRIES 24 -#define ENTRY_NAME_MAX_LEN 64 -#define PROTO_NAME_MAX_LEN 32 -#define DIR_PATH_MAX_LEN 300 -#define FILE_PATH_MAX_LEN 300 -#define IR_FILE_EXT ".ir" -#define IR_FILE_EXT_LEN 3 +#define MAX_PROTOS 16 +#define MAX_FILES IR_STORE_MAX_ENTRIES + +#define IR_ICON "/assets/icons/settings_input_antenna.bin" +#define IR_SIGNAL_ICON "/assets/icons/settings_remote.bin" +#define IR_CARRIER "38 kHz" + +#define FILES_COL_DIM 0x8A8594 +#define IRC_LEFT 6 +#define IRC_GUTTER 16 +#define IRC_TOP_Y 46 +#define IRC_LIST_PAD 2 +#define IRC_LIST_ROW 8 +#define IRC_CARD_H 86 +#define IRC_CARD_RADIUS 12 +#define IRC_CARD_PAD 10 +#define IRC_GLOW_W 14 +#define IRC_TRACK_X 227 +#define IRC_TRACK_Y 54 +#define IRC_TRACK_LEN 232 +#define IRC_THUMB_H 45 +#define IRC_THUMB_ICON "/assets/icons/drag_indicator.bin" typedef enum { - IR_BROWSE_LEVEL_PROTOCOLS = 0, - IR_BROWSE_LEVEL_FILES, -} ir_browse_level_t; + LEVEL_PROTOCOLS = 0, + LEVEL_FILES, + LEVEL_ACTIONS, +} browse_level_t; static lv_obj_t *s_screen = NULL; -static lv_timer_t *s_nav_timer = NULL; -static lv_obj_t *s_items_cont = NULL; -static lv_obj_t *s_item_objs[MAX_ENTRIES]; -static lv_obj_t *s_scroll_bar = NULL; -static lv_obj_t *s_title_lbl = NULL; - -static char s_entries[MAX_ENTRIES][ENTRY_NAME_MAX_LEN]; -static int s_entry_count = 0; -static int s_selected = 0; -static int s_track_y_start; -static int s_track_h; - -static ir_browse_level_t s_level = IR_BROWSE_LEVEL_PROTOCOLS; -static char s_current_proto[PROTO_NAME_MAX_LEN]; - -static bool s_is_viewing = false; -static text_viewer_t s_viewer; - -static bool s_btn_up_last = false; -static bool s_btn_down_last = false; -static bool s_btn_ok_last = false; -static bool s_btn_back_last = false; - -static void update_scroll_bar(void); -static void update_selection(void); -static void scan_protocols(void); -static void scan_files(const char *proto); -static lv_obj_t *create_item(lv_obj_t *parent, const char *text, const char *icon_sym); -static void build_list(void); -static void view_selected(void); -static void close_viewer(void); -static void nav_timer_cb(lv_timer_t *timer); - -void ui_ir_saved_open(void) { - if (s_screen != NULL) { - lv_obj_del(s_screen); - s_screen = NULL; +static menu_component_t s_menu; +static capture_result_t s_cr = {0}; + +static browse_level_t s_level = LEVEL_PROTOCOLS; +static int s_proto = 0; +static int s_file = 0; +static bool s_saved = false; + +// Real data: every .ir under /sdcard/ir, the distinct protocols across them, +// and the files filtered to the currently-open protocol. +static ir_store_entry_t s_all[IR_STORE_MAX_ENTRIES]; +static int s_all_count = 0; +static char s_protos[MAX_PROTOS][16]; +static int s_proto_count = 0; +static char s_proto_name[16] = {0}; +static ir_store_entry_t s_files[MAX_FILES]; +static int s_file_count = 0; + +static lv_obj_t *s_file_list = NULL; +static lv_obj_t *s_file_rows[MAX_FILES]; +static lv_obj_t *s_file_names[MAX_FILES]; +static lv_obj_t *s_file_values[MAX_FILES]; +static lv_obj_t *s_file_thumb = NULL; + +static void build_screen(void); +static void ir_saved_input(const input_event_t *ev, void *ctx); + +// Re-scan /sdcard/ir and rebuild the distinct-protocol bucket list. +static void reload_all(void) { + s_all_count = ir_store_list(s_all, IR_STORE_MAX_ENTRIES); + if (s_all_count < 0) + s_all_count = 0; + + s_proto_count = 0; + for (int i = 0; i < s_all_count; i++) { + bool found = false; + for (int j = 0; j < s_proto_count; j++) { + if (strcmp(s_protos[j], s_all[i].proto) == 0) { + found = true; + break; + } + } + if (!found && s_proto_count < MAX_PROTOS) { + snprintf(s_protos[s_proto_count], sizeof(s_protos[0]), "%s", s_all[i].proto); + s_proto_count++; + } } - - s_selected = 0; - s_level = IR_BROWSE_LEVEL_PROTOCOLS; - s_is_viewing = false; - memset(&s_viewer, 0, sizeof(s_viewer)); - - s_screen = lv_obj_create(NULL); - lv_obj_set_style_bg_color(s_screen, current_theme.screen_base, 0); - lv_obj_set_style_bg_opa(s_screen, LV_OPA_COVER, 0); - lv_obj_remove_flag(s_screen, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_set_style_border_width(s_screen, OUTER_BORDER, 0); - lv_obj_set_style_border_color(s_screen, current_theme.border_interface, 0); - lv_obj_set_style_pad_all(s_screen, 0, 0); - - lv_obj_t *top_area = lv_obj_create(s_screen); - lv_obj_set_size(top_area, LCD_H_RES - OUTER_BORDER * 2, TOP_BORDER_H); - lv_obj_align(top_area, LV_ALIGN_TOP_MID, 0, 0); - lv_obj_remove_flag(top_area, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_set_style_bg_opa(top_area, LV_OPA_TRANSP, 0); - lv_obj_set_style_border_width(top_area, TOP_AREA_BORDER_WIDTH, 0); - lv_obj_set_style_border_color(top_area, current_theme.border_interface, 0); - lv_obj_set_style_border_side(top_area, LV_BORDER_SIDE_BOTTOM, 0); - lv_obj_set_style_radius(top_area, 0, 0); - lv_obj_set_style_pad_all(top_area, 0, 0); - - lv_obj_t *title_bar = lv_obj_create(top_area); - lv_obj_set_size(title_bar, TITLE_BAR_W, TITLE_BAR_H); - lv_obj_align(title_bar, LV_ALIGN_CENTER, 0, 0); - lv_obj_remove_flag(title_bar, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_set_style_radius(title_bar, TITLE_BAR_RADIUS, 0); - lv_obj_set_style_bg_opa(title_bar, LV_OPA_COVER, 0); - lv_obj_set_style_bg_color(title_bar, current_theme.bg_primary, 0); - lv_obj_set_style_bg_grad_color(title_bar, current_theme.bg_secondary, 0); - lv_obj_set_style_bg_grad_dir(title_bar, LV_GRAD_DIR_HOR, 0); - lv_obj_set_style_border_width(title_bar, TITLE_BAR_BORDER_WIDTH, 0); - lv_obj_set_style_border_color(title_bar, current_theme.border_accent, 0); - - s_title_lbl = lv_label_create(title_bar); - lv_label_set_text(s_title_lbl, "BROWSE SIGNALS"); - lv_obj_set_style_text_color(s_title_lbl, current_theme.text_main, 0); - lv_obj_set_style_text_font(s_title_lbl, &lv_font_montserrat_14, 0); - lv_obj_center(s_title_lbl); - - int items_y = TOP_BORDER_H + ITEMS_Y_OFFSET; - int items_h = LCD_V_RES - items_y - OUTER_BORDER - ITEMS_Y_OFFSET; - - s_items_cont = lv_obj_create(s_screen); - lv_obj_set_size(s_items_cont, ITEM_W + 8, items_h); - lv_obj_align(s_items_cont, LV_ALIGN_TOP_LEFT, ITEMS_CONT_X_OFFSET, items_y); - lv_obj_set_style_bg_opa(s_items_cont, LV_OPA_TRANSP, 0); - lv_obj_set_style_border_width(s_items_cont, 0, 0); - lv_obj_set_style_pad_all(s_items_cont, ITEMS_CONT_PAD, 0); - lv_obj_set_style_pad_row(s_items_cont, ITEMS_CONT_PAD_ROW, 0); - lv_obj_set_flex_flow(s_items_cont, LV_FLEX_FLOW_COLUMN); - lv_obj_set_scrollbar_mode(s_items_cont, LV_SCROLLBAR_MODE_OFF); - lv_obj_set_scroll_snap_y(s_items_cont, LV_SCROLL_SNAP_START); - - int track_x = LCD_H_RES - OUTER_BORDER - SCROLL_TRACK_OFFSET_X; - s_track_y_start = items_y + SCROLL_TRACK_MARGIN; - s_track_h = items_h - SCROLL_TRACK_MARGIN * 2; - - static lv_point_precise_t s_track_pts[2]; - s_track_pts[0].x = 0; - s_track_pts[0].y = 0; - s_track_pts[1].x = 0; - s_track_pts[1].y = s_track_h; - - lv_obj_t *track = lv_line_create(s_screen); - lv_line_set_points(track, s_track_pts, 2); - lv_obj_set_pos(track, track_x, s_track_y_start); - lv_obj_set_style_line_color(track, current_theme.border_inactive, 0); - lv_obj_set_style_line_opa(track, LV_OPA_COVER, 0); - lv_obj_set_style_line_width(track, SCROLL_TRACK_WIDTH, 0); - lv_obj_set_style_line_dash_width(track, SCROLL_TRACK_DASH_W, 0); - lv_obj_set_style_line_dash_gap(track, SCROLL_TRACK_DASH_GAP, 0); - - static lv_image_dsc_t *s_sb_dsc = NULL; - if (s_sb_dsc == NULL) - s_sb_dsc = assets_get("/assets/icons/slide_bar_v.bin"); - - s_scroll_bar = lv_image_create(s_screen); - if (s_sb_dsc != NULL) - lv_image_set_src(s_scroll_bar, s_sb_dsc); - - lv_obj_set_pos(s_scroll_bar, track_x + SCROLL_BAR_OFFSET_X, s_track_y_start); - lv_obj_move_foreground(s_scroll_bar); - - build_list(); - - if (s_nav_timer == NULL) - s_nav_timer = lv_timer_create(nav_timer_cb, NAV_TIMER_INTERVAL_MS, NULL); - - lv_screen_load(s_screen); } -static void update_scroll_bar(void) { - if (s_scroll_bar == NULL || s_entry_count <= 1) - return; - - int32_t pos = - s_track_y_start + (s_selected * (s_track_h - SCROLL_BAR_THUMB_H)) / (s_entry_count - 1); - - lv_anim_t a; - lv_anim_init(&a); - lv_anim_set_var(&a, s_scroll_bar); - lv_anim_set_values(&a, lv_obj_get_y(s_scroll_bar), pos); - lv_anim_set_duration(&a, SCROLL_ANIM_DURATION_MS); - lv_anim_set_path_cb(&a, lv_anim_path_ease_in_out); - lv_anim_set_exec_cb(&a, (lv_anim_exec_xcb_t)lv_obj_set_y); - lv_anim_start(&a); +// Filter the flat file list down to the files of one protocol. +static void filter_for_proto(const char *proto) { + s_file_count = 0; + for (int i = 0; i < s_all_count && s_file_count < MAX_FILES; i++) { + if (strcmp(s_all[i].proto, proto) == 0) + s_files[s_file_count++] = s_all[i]; + } + if (s_file >= s_file_count) + s_file = s_file_count - 1; + if (s_file < 0) + s_file = 0; } -static void update_selection(void) { - for (int i = 0; i < s_entry_count; i++) { - if (i == s_selected) { - lv_obj_set_style_border_width(s_item_objs[i], ITEM_SELECTED_WIDTH, 0); - lv_obj_set_style_border_color(s_item_objs[i], current_theme.border_accent, 0); - } else { - lv_obj_set_style_border_width(s_item_objs[i], ITEM_BORDER_WIDTH, 0); - lv_obj_set_style_border_color(s_item_objs[i], current_theme.border_interface, 0); - } +// Load the selected file and format its first signal's command for display. +static void selected_value(char *buf, size_t cap) { + buf[0] = '\0'; + ir_file_t f; + ir_file_init(&f); + if (ir_store_load(s_files[s_file].path, &f) == ESP_OK && f.count > 0) { + ir_signal_t *s = &f.signals[0]; + if (!s->is_raw && s->data.protocol != IR_PROTO_UNKNOWN) + snprintf(buf, + cap, + "cmd 0x%02lX / 0x%02lX", + (unsigned long)s->data.address, + (unsigned long)s->data.command); + else + snprintf(buf, cap, "raw signal"); + } else { + snprintf(buf, cap, "--"); } - - if (s_entry_count > 0 && s_item_objs[s_selected] != NULL) - lv_obj_scroll_to_view(s_item_objs[s_selected], LV_ANIM_ON); - - update_scroll_bar(); + ir_file_free(&f); } -static void scan_protocols(void) { - s_entry_count = 0; - - DIR *d = opendir(TOS_PATH_IR); - if (d == NULL) - return; - - struct dirent *ent; - while ((ent = readdir(d)) != NULL && s_entry_count < MAX_ENTRIES) { - if (ent->d_name[0] == '.' || ent->d_type != DT_DIR) - continue; - - strncpy(s_entries[s_entry_count], ent->d_name, ENTRY_NAME_MAX_LEN - 1); - s_entries[s_entry_count][ENTRY_NAME_MAX_LEN - 1] = '\0'; - s_entry_count++; +static void send_selected(void) { + ir_file_t f; + ir_file_init(&f); + if (ir_store_load(s_files[s_file].path, &f) == ESP_OK && f.count > 0) { + ir_store_send_signal(&f.signals[0]); + ESP_LOGI(TAG, "sent %s (%s)", s_files[s_file].name, s_files[s_file].proto); } - - closedir(d); + ir_file_free(&f); } -static void scan_files(const char *proto) { - s_entry_count = 0; - - char dir_path[DIR_PATH_MAX_LEN]; - snprintf(dir_path, sizeof(dir_path), TOS_PATH_IR "/%.64s", proto); +static void rebuild_async(void *p) { + (void)p; + build_screen(); +} - DIR *d = opendir(dir_path); - if (d == NULL) +static void on_rename_submit(const char *text, void *ud) { + (void)ud; + if (text == NULL || text[0] == '\0') return; - - struct dirent *ent; - while ((ent = readdir(d)) != NULL && s_entry_count < MAX_ENTRIES) { - size_t len = strlen(ent->d_name); - if (len < IR_FILE_EXT_LEN + 1 || strcmp(ent->d_name + len - IR_FILE_EXT_LEN, IR_FILE_EXT) != 0) - continue; - - strncpy(s_entries[s_entry_count], ent->d_name, ENTRY_NAME_MAX_LEN - 1); - s_entries[s_entry_count][ENTRY_NAME_MAX_LEN - 1] = '\0'; - s_entry_count++; + if (ir_store_rename(s_files[s_file].name, text) == ESP_OK) { + ui_feedback(UI_FB_WRITE); + notify(NOTIFY_INFO, "Signal renamed"); + } else { + notify(NOTIFY_WARNING, "Rename failed"); } - - closedir(d); + reload_all(); + filter_for_proto(s_proto_name); + if (s_file_count == 0) + s_level = LEVEL_PROTOCOLS; + lv_async_call(rebuild_async, NULL); } -static lv_obj_t *create_item(lv_obj_t *parent, const char *text, const char *icon_sym) { - lv_obj_t *item = lv_obj_create(parent); - lv_obj_set_size(item, ITEM_W, ITEM_H); - lv_obj_remove_flag(item, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_set_style_radius(item, ITEM_RADIUS, 0); - lv_obj_set_style_bg_opa(item, LV_OPA_COVER, 0); - lv_obj_set_style_bg_color(item, current_theme.bg_primary, 0); - lv_obj_set_style_bg_grad_color(item, current_theme.bg_secondary, 0); - lv_obj_set_style_bg_grad_dir(item, LV_GRAD_DIR_HOR, 0); - lv_obj_set_style_border_width(item, ITEM_BORDER_WIDTH, 0); - lv_obj_set_style_border_color(item, current_theme.border_interface, 0); - lv_obj_set_style_pad_left(item, ITEM_PAD_H, 0); - lv_obj_set_style_pad_right(item, ITEM_PAD_H, 0); - lv_obj_set_flex_flow(item, LV_FLEX_FLOW_ROW); - lv_obj_set_flex_align(item, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); - lv_obj_set_style_pad_column(item, ITEM_PAD_COL, 0); - - lv_obj_t *lbl = lv_label_create(item); - lv_label_set_text(lbl, text); - lv_obj_set_style_text_color(lbl, current_theme.text_main, 0); - lv_obj_set_style_text_font(lbl, &lv_font_montserrat_12, 0); - lv_obj_set_flex_grow(lbl, 1); - lv_label_set_long_mode(lbl, LV_LABEL_LONG_SCROLL_CIRCULAR); - - lv_obj_t *arrow = lv_label_create(item); - lv_label_set_text(arrow, icon_sym); - lv_obj_set_style_text_color(arrow, current_theme.border_accent, 0); - lv_obj_set_style_text_font(arrow, &lv_font_montserrat_12, 0); - - return item; +static void on_delete_confirm(bool confirm) { + if (!confirm) + return; + ir_store_delete(s_files[s_file].name); + ui_feedback(UI_FB_WRITE); + notify(NOTIFY_INFO, "Signal deleted"); + reload_all(); + filter_for_proto(s_proto_name); + s_level = (s_file_count > 0) ? LEVEL_FILES : LEVEL_PROTOCOLS; + lv_async_call(rebuild_async, NULL); } -static void build_list(void) { - if (s_items_cont != NULL) - lv_obj_clean(s_items_cont); +static void move_file_thumb(void) { + if (s_file_thumb == NULL || s_file_count <= 1) + return; + int thumb_h = lv_obj_get_height(s_file_thumb); + if (thumb_h <= 0) + thumb_h = IRC_THUMB_H; + int travel = IRC_TRACK_LEN - thumb_h; + if (travel < 0) + travel = 0; + int pos = IRC_TRACK_Y + (s_file * travel) / (s_file_count - 1); + lv_obj_set_y(s_file_thumb, pos); +} - if (s_level == IR_BROWSE_LEVEL_PROTOCOLS) { - scan_protocols(); - if (s_title_lbl != NULL) - lv_label_set_text(s_title_lbl, "BROWSE SIGNALS"); +static void style_file_row(int i, bool sel) { + lv_obj_t *card = s_file_rows[i]; + if (card == NULL) + return; + if (sel) { + lv_obj_set_style_border_color(card, current_theme.border_accent, 0); + lv_obj_set_style_border_opa(card, LV_OPA_COVER, 0); + lv_obj_set_style_shadow_color(card, current_theme.border_accent, 0); + lv_obj_set_style_shadow_width(card, IRC_GLOW_W, 0); + lv_obj_set_style_shadow_opa(card, LV_OPA_40, 0); + lv_obj_set_style_shadow_spread(card, -2, 0); + lv_obj_set_style_text_color(s_file_values[i], current_theme.border_accent, 0); } else { - scan_files(s_current_proto); - if (s_title_lbl != NULL) - lv_label_set_text(s_title_lbl, s_current_proto); + lv_obj_set_style_border_color(card, current_theme.border_inactive, 0); + lv_obj_set_style_border_opa(card, LV_OPA_60, 0); + lv_obj_set_style_shadow_width(card, 0, 0); + lv_obj_set_style_shadow_opa(card, LV_OPA_TRANSP, 0); + lv_obj_set_style_text_color(s_file_values[i], lv_color_hex(FILES_COL_DIM), 0); } +} - const char *icon = - (s_level == IR_BROWSE_LEVEL_PROTOCOLS) ? LV_SYMBOL_DIRECTORY : LV_SYMBOL_EYE_OPEN; - - for (int i = 0; i < s_entry_count; i++) - s_item_objs[i] = create_item(s_items_cont, s_entries[i], icon); - - if (s_entry_count == 0) { - lv_obj_t *empty = lv_label_create(s_items_cont); - lv_label_set_text(empty, - s_level == IR_BROWSE_LEVEL_PROTOCOLS ? "No protocols found" : "No signals"); - lv_obj_set_style_text_color(empty, current_theme.border_inactive, 0); - lv_obj_set_style_text_font(empty, &lv_font_montserrat_12, 0); +static void update_file_selection(void) { + for (int i = 0; i < s_file_count; i++) + style_file_row(i, i == s_file); + if (s_file_list != NULL && s_file >= 0 && s_file < s_file_count && s_file_rows[s_file] != NULL) { + lv_obj_update_layout(s_file_list); + lv_obj_scroll_to_view(s_file_rows[s_file], LV_ANIM_ON); } - - s_selected = 0; - update_selection(); + move_file_thumb(); } -static void view_selected(void) { - if (s_entry_count == 0) - return; +static void build_files_list(void) { + if (s_file >= s_file_count) + s_file = s_file_count - 1; + if (s_file < 0) + s_file = 0; + + ui_chrome_header(s_screen, s_proto_name, IR_ICON); + + lv_obj_t *cont = lv_obj_create(s_screen); + s_file_list = cont; + lv_obj_set_size( + cont, LCD_H_RES - IRC_LEFT - IRC_GUTTER, LCD_V_RES - IRC_TOP_Y - UI_CHROME_FOOTER_H - 4); + lv_obj_align(cont, LV_ALIGN_TOP_LEFT, IRC_LEFT, IRC_TOP_Y); + lv_obj_set_style_bg_opa(cont, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(cont, 0, 0); + lv_obj_set_style_pad_all(cont, IRC_LIST_PAD, 0); + lv_obj_set_style_pad_row(cont, IRC_LIST_ROW, 0); + lv_obj_set_flex_flow(cont, LV_FLEX_FLOW_COLUMN); + lv_obj_add_flag(cont, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_scroll_dir(cont, LV_DIR_VER); + lv_obj_set_scrollbar_mode(cont, LV_SCROLLBAR_MODE_OFF); + lv_obj_clear_flag(cont, LV_OBJ_FLAG_SCROLL_ELASTIC | LV_OBJ_FLAG_SCROLL_MOMENTUM); + + static const lv_point_precise_t ir_pulse_pts[] = { + {0, 20}, {0, 4}, {10, 4}, {10, 20}, {24, 20}, {24, 4}, {28, 4}, {28, 20}, {46, 20}, + {46, 4}, {50, 4}, {50, 20}, {78, 20}, {78, 4}, {82, 4}, {82, 20}, {112, 20}, {112, 4}, + {116, 4}, {116, 20}, {150, 20}, {150, 4}, {154, 4}, {154, 20}, {190, 20}}; + + for (int i = 0; i < s_file_count; i++) { + lv_obj_t *card = lv_obj_create(cont); + s_file_rows[i] = card; + lv_obj_remove_flag(card, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_remove_flag(card, LV_OBJ_FLAG_CLICKABLE); + lv_obj_set_size(card, lv_pct(100), IRC_CARD_H); + lv_obj_set_style_radius(card, IRC_CARD_RADIUS, 0); + lv_obj_set_style_pad_all(card, IRC_CARD_PAD, 0); + lv_obj_set_style_border_width(card, 1, 0); + lv_obj_set_style_bg_color(card, current_theme.bg_secondary, 0); + lv_obj_set_style_bg_grad_color(card, current_theme.bg_primary, 0); + lv_obj_set_style_bg_grad_dir(card, LV_GRAD_DIR_VER, 0); + lv_obj_set_style_bg_opa(card, LV_OPA_COVER, 0); + + lv_obj_t *name = lv_label_create(card); + s_file_names[i] = name; + lv_obj_set_width(name, lv_pct(66)); + lv_label_set_long_mode(name, LV_LABEL_LONG_DOT); + lv_label_set_text(name, s_files[i].name); + lv_obj_set_style_text_font(name, &lv_font_montserrat_14, 0); + lv_obj_set_style_text_color(name, current_theme.text_main, 0); + lv_obj_align(name, LV_ALIGN_TOP_LEFT, 0, 0); + + lv_obj_t *value = lv_label_create(card); + s_file_values[i] = value; + lv_label_set_text(value, IR_CARRIER); + lv_obj_set_style_text_font(value, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(value, current_theme.border_accent, 0); + lv_obj_align(value, LV_ALIGN_TOP_RIGHT, 0, 2); + + lv_obj_t *proto = lv_label_create(card); + lv_obj_set_width(proto, lv_pct(100)); + lv_label_set_long_mode(proto, LV_LABEL_LONG_DOT); + lv_label_set_text(proto, s_files[i].proto); + lv_obj_set_style_text_font(proto, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(proto, lv_color_hex(FILES_COL_DIM), 0); + lv_obj_align(proto, LV_ALIGN_TOP_LEFT, 0, 20); + + lv_obj_t *pulse = lv_line_create(card); + lv_line_set_points(pulse, ir_pulse_pts, sizeof(ir_pulse_pts) / sizeof(ir_pulse_pts[0])); + lv_obj_align(pulse, LV_ALIGN_BOTTOM_LEFT, 0, 0); + lv_obj_set_style_line_color(pulse, current_theme.border_accent, 0); + lv_obj_set_style_line_opa(pulse, LV_OPA_COVER, 0); + lv_obj_set_style_line_width(pulse, 2, 0); + } + + static lv_point_precise_t ir_track_pts[2]; + ir_track_pts[0].x = 0; + ir_track_pts[0].y = 0; + ir_track_pts[1].x = 0; + ir_track_pts[1].y = IRC_TRACK_LEN; + lv_obj_t *track = lv_line_create(s_screen); + lv_line_set_points(track, ir_track_pts, 2); + lv_obj_set_pos(track, IRC_TRACK_X, IRC_TRACK_Y); + lv_obj_set_style_line_color(track, current_theme.border_inactive, 0); + lv_obj_set_style_line_opa(track, LV_OPA_COVER, 0); + lv_obj_set_style_line_width(track, 3, 0); + lv_obj_set_style_line_dash_width(track, 4, 0); + lv_obj_set_style_line_dash_gap(track, 4, 0); - char path[FILE_PATH_MAX_LEN]; - snprintf(path, sizeof(path), TOS_PATH_IR "/%.64s/%.64s", s_current_proto, s_entries[s_selected]); + lv_image_dsc_t *thumb = assets_get(IRC_THUMB_ICON); + s_file_thumb = lv_image_create(s_screen); + if (thumb != NULL) + lv_image_set_src(s_file_thumb, thumb); + lv_obj_set_pos(s_file_thumb, IRC_TRACK_X - 4, IRC_TRACK_Y); + lv_obj_move_foreground(s_file_thumb); - s_viewer = text_viewer_create(s_screen, s_entries[s_selected]); - text_viewer_load_file(&s_viewer, path); - lv_obj_move_foreground(s_viewer.screen); - s_is_viewing = true; -} + lv_obj_update_layout(cont); + update_file_selection(); -static void close_viewer(void) { - if (s_viewer.screen != NULL) { - lv_obj_del(s_viewer.screen); - s_viewer.screen = NULL; - } - s_is_viewing = false; + ui_chrome_footer(s_screen, "OK Open BACK Back"); } -static void nav_timer_cb(lv_timer_t *timer) { - if (lv_screen_active() != s_screen) { - lv_timer_delete(timer); - s_nav_timer = NULL; - return; +static void build_screen(void) { + if (s_screen != NULL) { + lv_obj_del(s_screen); + s_screen = NULL; } + s_file_list = NULL; + s_file_thumb = NULL; + s_cr = (capture_result_t){0}; - if (ui_input_is_locked()) - return; - - bool is_up = up_button_is_down(); - bool is_down = down_button_is_down(); - bool is_ok = ok_button_is_down(); - bool is_back = back_button_is_down(); + s_screen = lv_obj_create(NULL); + lv_obj_set_style_bg_color(s_screen, current_theme.screen_base, 0); + lv_obj_set_style_bg_opa(s_screen, LV_OPA_COVER, 0); + lv_obj_remove_flag(s_screen, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_border_width(s_screen, 0, 0); + lv_obj_set_style_pad_all(s_screen, 0, 0); - if (s_is_viewing) { - if (is_up && !s_btn_up_last && s_viewer.text_area != NULL) { - if (lv_obj_get_scroll_y(s_viewer.text_area) > 0) - lv_obj_scroll_by(s_viewer.text_area, 0, VIEWER_SCROLL_STEP, LV_ANIM_ON); + if (s_level == LEVEL_PROTOCOLS) { + s_menu = menu_component_create(s_screen, "BROWSE SIGNALS", "/assets/icons/folder_open.bin"); + if (s_proto_count == 0) { + menu_component_add_item(&s_menu, "/assets/icons/folder.bin", "No saved signals"); + } else { + for (int i = 0; i < s_proto_count; i++) + menu_component_add_item(&s_menu, "/assets/icons/folder.bin", s_protos[i]); } + menu_component_set_hint(&s_menu, "OK Open BACK Exit"); + } else if (s_level == LEVEL_FILES) { + build_files_list(); + } else { + char value[32]; + selected_value(value, sizeof(value)); + ui_chrome_header(s_screen, s_files[s_file].name, IR_ICON); + capture_result_cfg_t cfg = { + .accent = current_theme.border_accent, + .card_icon = IR_ICON, + .card_title = s_files[s_file].name, + .card_sub = s_files[s_file].proto, + .card_value = value, + .primary_label = "Send", + .again_label = "Rename", + }; + s_cr = capture_result_create(s_screen, &cfg); + if (s_saved) + capture_result_mark_saved(&s_cr); + ui_chrome_footer(s_screen, "UP/DOWN choose OK do BACK back"); + } - if (is_down && !s_btn_down_last && s_viewer.text_area != NULL) - lv_obj_scroll_by(s_viewer.text_area, 0, -VIEWER_SCROLL_STEP, LV_ANIM_ON); + ui_input_set_screen_handler(ir_saved_input, NULL); - if (is_back && !s_btn_back_last) - close_viewer(); + ui_screen_load_owned(&s_screen, s_screen); +} - } else { - if (is_down && !s_btn_down_last && s_entry_count > 0) { - s_selected = (s_selected + 1) % s_entry_count; - update_selection(); +static void ir_saved_input(const input_event_t *ev, void *ctx) { + (void)ctx; + const bool press = (ev->action == INPUT_ACTION_PRESS); + const bool nav = press || (ev->action == INPUT_ACTION_REPEAT); + + if (s_level == LEVEL_ACTIONS) { + switch (ev->button) { + case INPUT_BTN_DOWN: + if (nav) { + capture_result_next(&s_cr); + ui_feedback(UI_FB_NAV); + } + break; + case INPUT_BTN_UP: + if (nav) { + capture_result_prev(&s_cr); + ui_feedback(UI_FB_NAV); + } + break; + case INPUT_BTN_OK: + if (press) { + switch (capture_result_selected(&s_cr)) { + case CAP_ACT_PRIMARY: + ui_feedback(UI_FB_EMULATE); + send_selected(); + notify(NOTIFY_INFO, "Signal sent"); + break; + case CAP_ACT_SAVE: + if (!s_saved) { + s_saved = true; + capture_result_mark_saved(&s_cr); + notify(NOTIFY_INFO, "Already saved"); + } + break; + case CAP_ACT_AGAIN: + keyboard_open(NULL, on_rename_submit, NULL); + break; + case CAP_ACT_DISCARD: + msgbox_open( + LV_SYMBOL_TRASH, "Delete this signal?", "Delete", "Cancel", on_delete_confirm); + break; + default: + break; + } + } + break; + case INPUT_BTN_BACK: + case INPUT_BTN_LEFT: + if (press) { + s_level = LEVEL_FILES; + build_screen(); + } + break; + default: + break; } + return; + } - if (is_up && !s_btn_up_last && s_entry_count > 0) { - s_selected = (s_selected == 0) ? s_entry_count - 1 : s_selected - 1; - update_selection(); + if (s_level == LEVEL_PROTOCOLS) { + switch (ev->button) { + case INPUT_BTN_DOWN: + if (nav) { + menu_component_next(&s_menu); + ui_feedback(UI_FB_NAV); + } + break; + case INPUT_BTN_UP: + if (nav) { + menu_component_prev(&s_menu); + ui_feedback(UI_FB_NAV); + } + break; + case INPUT_BTN_OK: + if (press) { + if (s_proto_count == 0) { + notify(NOTIFY_INFO, "Capture a signal in Learn first"); + return; + } + s_proto = menu_component_get_selected(&s_menu); + if (s_proto < 0 || s_proto >= s_proto_count) + s_proto = 0; + snprintf(s_proto_name, sizeof(s_proto_name), "%s", s_protos[s_proto]); + s_file = 0; + filter_for_proto(s_proto_name); + s_level = LEVEL_FILES; + ui_feedback(UI_FB_SELECT); + build_screen(); + } + break; + case INPUT_BTN_BACK: + case INPUT_BTN_LEFT: + if (press) + ui_switch_screen(SCREEN_IR_MENU); + break; + default: + break; } + return; + } - if (is_ok && !s_btn_ok_last && s_entry_count > 0) { - if (s_level == IR_BROWSE_LEVEL_PROTOCOLS) { - strncpy(s_current_proto, s_entries[s_selected], sizeof(s_current_proto) - 1); - s_current_proto[sizeof(s_current_proto) - 1] = '\0'; - s_level = IR_BROWSE_LEVEL_FILES; - build_list(); - } else { - view_selected(); + switch (ev->button) { + case INPUT_BTN_DOWN: + if (nav && s_file < s_file_count - 1) { + s_file++; + update_file_selection(); + ui_feedback(UI_FB_NAV); } - } - - if (is_back && !s_btn_back_last) { - if (s_level == IR_BROWSE_LEVEL_FILES) { - s_level = IR_BROWSE_LEVEL_PROTOCOLS; - build_list(); - } else { - ui_switch_screen(SCREEN_IR_MENU); + break; + case INPUT_BTN_UP: + if (nav && s_file > 0) { + s_file--; + update_file_selection(); + ui_feedback(UI_FB_NAV); } - } + break; + case INPUT_BTN_OK: + if (press && s_file_count > 0) { + s_saved = false; + s_level = LEVEL_ACTIONS; + ui_feedback(UI_FB_SELECT); + build_screen(); + } + break; + case INPUT_BTN_BACK: + case INPUT_BTN_LEFT: + if (press) { + s_level = LEVEL_PROTOCOLS; + build_screen(); + } + break; + default: + break; } +} - s_btn_up_last = is_up; - s_btn_down_last = is_down; - s_btn_ok_last = is_ok; - s_btn_back_last = is_back; -} \ No newline at end of file +void ui_ir_saved_open(void) { + s_level = LEVEL_PROTOCOLS; + s_proto = 0; + s_file = 0; + s_saved = false; + reload_all(); + build_screen(); +} diff --git a/firmware_p4/components/Applications/ui/screens/infrared/ir_send_ui.c b/firmware_p4/components/Applications/ui/screens/infrared/ir_send_ui.c index 7199ba7b8..644ccab18 100644 --- a/firmware_p4/components/Applications/ui/screens/infrared/ir_send_ui.c +++ b/firmware_p4/components/Applications/ui/screens/infrared/ir_send_ui.c @@ -15,419 +15,507 @@ #include "ir_send_ui.h" -#include #include -#include -#include #include "esp_log.h" +#include "lvgl.h" -#include "ui_theme.h" -#include "ui_manager.h" -#include "menu_component_ui.h" -#include "msgbox_ui.h" -#include "buttons_gpio.h" #include "assets_manager.h" -#include "ir.h" -#include "ir_file.h" -#include "tos_storage_paths.h" -#include "st7789.h" +#include "capture_result_ui.h" +#include "ir_store.h" +#include "menu_component_ui.h" +#include "notify_ui.h" +#include "sigwave_ui.h" +#include "ui_chrome.h" +#include "ui_feedback.h" +#include "ui_manager.h" +#include "ui_theme.h" static const char *TAG = "IR_SEND_UI"; -#define OUTER_BORDER 4 -#define TOP_BORDER_H 46 -#define ITEM_H 47 -#define ITEM_W 210 -#define MAX_FILES 24 - -#define FILE_NAME_MAX_LEN 96 -#define FILE_PATH_MAX_LEN 300 -#define DIR_NAME_FMT_MAX_LEN 64 -#define SUBPATH_BUF_SIZE 512 -#define SEND_PATH_BUF_SIZE 512 -#define IR_FILE_MAX_BYTES 4096 - -#define TITLE_BAR_W 170 -#define TITLE_BAR_H 30 -#define TITLE_BAR_RADIUS 12 -#define TITLE_BAR_BORDER_W 2 - -#define TOP_AREA_BORDER_W 3 -#define OUTER_BORDER_W 3 -#define ACCENT_BORDER_W 3 - -#define ITEM_RADIUS 10 -#define ITEM_BORDER_SELECTED 3 -#define ITEM_BORDER_NORMAL 1 -#define ITEM_PAD_H 8 -#define ITEM_PAD_COL 6 -#define ITEM_FLEX_GROW 1 - -#define ITEMS_CONT_PAD 2 -#define ITEMS_CONT_PAD_ROW 6 -#define ITEMS_CONT_OFFSET_X 4 -#define ITEMS_CONT_OFFSET_Y 4 - -#define SCROLL_BAR_TRACK_W 3 -#define SCROLL_BAR_TRACK_OFF 10 -#define SCROLL_BAR_IMG_OFF 4 -#define SCROLL_BAR_THUMB_H 20 -#define SCROLL_BAR_ANIM_MS 150 -#define SCROLL_TRACK_X_FROM_RIGHT 10 - -#define NAV_TIMER_PERIOD_MS 50 - -#define SUBPATH_DIR_FMT TOS_PATH_IR "/%.64s" -#define FILE_NAME_FMT "[%.30s] %.60s" -#define FILE_PATH_FMT "%.128s/%.128s" -#define SEND_PATH_FMT TOS_PATH_IR "/%.300s" +#define SIG_GREEN 0x00E676 +#define COL_DIM 0x8A8594 +#define IR_ICON "/assets/icons/podcasts.bin" -static lv_obj_t *s_screen = NULL; -static lv_timer_t *s_nav_timer = NULL; -static lv_obj_t *s_items_cont = NULL; -static lv_obj_t *s_item_objs[MAX_FILES]; -static lv_obj_t *s_scroll_bar = NULL; - -static char s_file_names[MAX_FILES][FILE_NAME_MAX_LEN]; -static char s_file_paths[MAX_FILES][FILE_PATH_MAX_LEN]; -static size_t s_file_count = 0; -static size_t s_selected = 0; - -static int32_t s_track_y_start; -static int32_t s_track_h; - -static bool s_btn_up_last = false; -static bool s_btn_down_last = false; -static bool s_btn_ok_last = false; -static bool s_btn_back_last = false; - -static void update_scroll_bar(void); -static void update_selection(void); -static void scan_ir_files(void); -static void send_selected(void); -static void build_list(void); -static void nav_timer_cb(lv_timer_t *t); +#define TICK_MS 50 +#define SENDING_MS 1600 +#define DOT_CYCLE_MS 350 +#define REVEAL_MS 2600 -static void update_scroll_bar(void) { - if (s_scroll_bar == NULL || s_file_count <= 1) - return; +#define STATUS_Y 50 +#define CARD_W 210 +#define CARD_H 64 +#define CARD_Y_OFS -30 +#define SIG_Y_OFS 44 - int32_t pos = s_track_y_start + ((int32_t)s_selected * (s_track_h - SCROLL_BAR_THUMB_H)) / - (int32_t)(s_file_count - 1); +#define STATUS_SENDING "Sending" +#define STATUS_SENT "Signal sent!" - lv_anim_t a; - lv_anim_init(&a); - lv_anim_set_var(&a, s_scroll_bar); - lv_anim_set_values(&a, lv_obj_get_y(s_scroll_bar), pos); - lv_anim_set_duration(&a, SCROLL_BAR_ANIM_MS); - lv_anim_set_path_cb(&a, lv_anim_path_ease_in_out); - lv_anim_set_exec_cb(&a, (lv_anim_exec_xcb_t)lv_obj_set_y); - lv_anim_start(&a); +#define HINT_SENDING "Transmitting..." +#define HINT_SENT "BACK = Exit" +#define HINT_OPTIONS "UP/DOWN choose OK do BACK exit" + +#define EMPTY_NAME "No signals — use Learn" + +typedef enum { + VIEW_LIST = 0, + VIEW_SENDING, + VIEW_SENT, +} send_view_t; + +static lv_obj_t *s_screen = NULL; +static menu_component_t s_menu; +static send_view_t s_view = VIEW_LIST; +static int s_sel = 0; + +static ir_store_entry_t s_entries[IR_STORE_MAX_ENTRIES]; +static int s_count = 0; + +static lv_timer_t *s_tick_timer = NULL; +static lv_timer_t *s_send_timer = NULL; + +static lv_obj_t *s_status_label = NULL; +static lv_obj_t *s_hint_label = NULL; +static lv_obj_t *s_card = NULL; +static lv_obj_t *s_sig = NULL; +static capture_result_t s_cr = {0}; +static bool s_options = false; +static uint32_t s_send_start = 0; +static uint32_t s_sent_at = 0; + +static void ir_send_tick_cb(lv_timer_t *t); +static void ir_send_input(const input_event_t *ev, void *ctx); +static void build_list(void); +static void build_sending(void); +static void send_done_cb(lv_timer_t *t); + +static const char *sel_name(void) { + return (s_count > 0 && s_sel >= 0 && s_sel < s_count) ? s_entries[s_sel].name : EMPTY_NAME; } -static void update_selection(void) { - for (size_t i = 0; i < s_file_count; i++) { - if (i == s_selected) { - lv_obj_set_style_border_width(s_item_objs[i], ITEM_BORDER_SELECTED, 0); - lv_obj_set_style_border_color(s_item_objs[i], current_theme.border_accent, 0); - } else { - lv_obj_set_style_border_width(s_item_objs[i], ITEM_BORDER_NORMAL, 0); - lv_obj_set_style_border_color(s_item_objs[i], current_theme.border_interface, 0); - } - } +static const char *sel_proto(void) { + return (s_count > 0 && s_sel >= 0 && s_sel < s_count) ? s_entries[s_sel].proto : "IR"; +} - if (s_file_count > 0 && s_item_objs[s_selected] != NULL) { - lv_obj_scroll_to_view(s_item_objs[s_selected], LV_ANIM_ON); +static void stop_send_timer(void) { + if (s_send_timer != NULL) { + lv_timer_delete(s_send_timer); + s_send_timer = NULL; } +} - update_scroll_bar(); +static lv_obj_t *new_screen(void) { + lv_obj_t *screen = lv_obj_create(NULL); + lv_obj_set_style_bg_color(screen, current_theme.screen_base, 0); + lv_obj_set_style_bg_opa(screen, LV_OPA_COVER, 0); + lv_obj_remove_flag(screen, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_border_width(screen, 0, 0); + lv_obj_set_style_pad_all(screen, 0, 0); + return screen; } -static void scan_ir_files(void) { - s_file_count = 0; +static void transy_cb(void *var, int32_t v) { + lv_obj_set_style_translate_y((lv_obj_t *)var, v, 0); +} - DIR *root = opendir(TOS_PATH_IR); - if (root == NULL) - return; +static void card_rise(lv_obj_t *o) { + lv_anim_t a; + lv_anim_init(&a); + lv_anim_set_var(&a, o); + lv_anim_set_exec_cb(&a, transy_cb); + lv_anim_set_values(&a, 26, 0); + lv_anim_set_duration(&a, 300); + lv_anim_set_path_cb(&a, lv_anim_path_ease_out); + lv_anim_start(&a); +} - struct dirent *proto_ent; - while ((proto_ent = readdir(root)) != NULL && s_file_count < MAX_FILES) { - if (proto_ent->d_name[0] == '.' || proto_ent->d_type != DT_DIR) - continue; - - char sub_path[SUBPATH_BUF_SIZE]; - snprintf(sub_path, sizeof(sub_path), SUBPATH_DIR_FMT, proto_ent->d_name); - - DIR *sub = opendir(sub_path); - if (sub == NULL) - continue; - - struct dirent *file_ent; - while ((file_ent = readdir(sub)) != NULL && s_file_count < MAX_FILES) { - size_t len = strlen(file_ent->d_name); - if (len < 4 || strcmp(file_ent->d_name + len - 3, ".ir") != 0) - continue; - - snprintf(s_file_names[s_file_count], - sizeof(s_file_names[0]), - FILE_NAME_FMT, - proto_ent->d_name, - file_ent->d_name); - - snprintf(s_file_paths[s_file_count], - sizeof(s_file_paths[0]), - FILE_PATH_FMT, - proto_ent->d_name, - file_ent->d_name); - - s_file_count++; - } - closedir(sub); - } - closedir(root); +static lv_obj_t *lit_panel(lv_obj_t *parent, int w, int h) { + lv_obj_t *p = lv_obj_create(parent); + lv_obj_remove_flag(p, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(p, w, h); + lv_obj_set_style_radius(p, 13, 0); + lv_obj_set_style_bg_color(p, current_theme.bg_secondary, 0); + lv_obj_set_style_bg_opa(p, LV_OPA_COVER, 0); + lv_obj_set_style_bg_grad_dir(p, LV_GRAD_DIR_NONE, 0); + lv_obj_set_style_border_width(p, 1, 0); + lv_obj_set_style_border_color(p, current_theme.border_accent, 0); + lv_obj_set_style_shadow_color(p, current_theme.border_accent, 0); + lv_obj_set_style_shadow_width(p, 16, 0); + lv_obj_set_style_shadow_opa(p, LV_OPA_40, 0); + lv_obj_set_style_shadow_spread(p, -4, 0); + return p; } -static void send_selected(void) { - if (s_file_count == 0) +static void set_status(const char *text, bool success) { + if (s_status_label == NULL) return; + lv_label_set_text(s_status_label, text); + lv_obj_set_style_text_color( + s_status_label, success ? lv_color_hex(SIG_GREEN) : current_theme.text_main, 0); +} - char path[SEND_PATH_BUF_SIZE]; - snprintf(path, sizeof(path), SEND_PATH_FMT, s_file_paths[s_selected]); +static void set_hint(const char *text) { + if (s_hint_label != NULL) + ui_chrome_footer_set_text(s_hint_label, text); +} - FILE *f = fopen(path, "r"); - if (f == NULL) { - ESP_LOGE(TAG, "Failed to open IR file: %s", path); - msgbox_open(LV_SYMBOL_WARNING, "Failed to open file", "OK", NULL, NULL); - return; +static lv_obj_t *build_cartridge(lv_obj_t *parent) { + lv_obj_t *card = lit_panel(parent, CARD_W, CARD_H); + lv_obj_align(card, LV_ALIGN_CENTER, 0, CARD_Y_OFS); + lv_obj_set_style_pad_all(card, 10, 0); + lv_obj_set_flex_flow(card, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(card, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_set_style_pad_column(card, 10, 0); + + lv_obj_t *well = lv_obj_create(card); + lv_obj_remove_flag(well, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(well, 40, 40); + lv_obj_set_style_radius(well, 10, 0); + lv_obj_set_style_bg_color(well, current_theme.bg_primary, 0); + lv_obj_set_style_bg_opa(well, LV_OPA_COVER, 0); + lv_obj_set_style_bg_grad_dir(well, LV_GRAD_DIR_NONE, 0); + lv_obj_set_style_border_width(well, 0, 0); + lv_obj_set_style_pad_all(well, 0, 0); + + lv_image_dsc_t *dsc = assets_get(IR_ICON); + if (dsc != NULL) { + lv_obj_t *img = lv_image_create(well); + lv_image_set_src(img, dsc); + lv_obj_center(img); + lv_obj_set_style_image_recolor(img, current_theme.text_main, 0); + lv_obj_set_style_image_recolor_opa(img, LV_OPA_COVER, 0); } - fseek(f, 0, SEEK_END); - int32_t sz = (int32_t)ftell(f); - fseek(f, 0, SEEK_SET); + lv_obj_t *col = lv_obj_create(card); + lv_obj_remove_flag(col, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_height(col, LV_SIZE_CONTENT); + lv_obj_set_flex_grow(col, 1); + lv_obj_set_style_bg_opa(col, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(col, 0, 0); + lv_obj_set_style_pad_all(col, 0, 0); + lv_obj_set_flex_flow(col, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(col, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START); + lv_obj_set_style_pad_row(col, 3, 0); + + lv_obj_t *name = lv_label_create(col); + lv_label_set_long_mode(name, LV_LABEL_LONG_DOT); + lv_obj_set_width(name, lv_pct(100)); + lv_label_set_text(name, sel_name()); + lv_obj_set_style_text_font(name, &lv_font_montserrat_14, 0); + lv_obj_set_style_text_color(name, current_theme.text_main, 0); + + lv_obj_t *sub = lv_label_create(col); + lv_label_set_text_fmt(sub, "Transmitting %s", sel_proto()); + lv_obj_set_style_text_font(sub, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(sub, current_theme.border_accent, 0); + + return card; +} - if (sz <= 0 || sz > IR_FILE_MAX_BYTES) { - ESP_LOGE(TAG, "Invalid IR file size: %ld", (long)sz); - fclose(f); - msgbox_open(LV_SYMBOL_WARNING, "Invalid file", "OK", NULL, NULL); - return; +static void build_list(void) { + if (s_screen != NULL) { + lv_obj_del(s_screen); + s_screen = NULL; + } + s_status_label = NULL; + s_hint_label = NULL; + s_card = NULL; + s_sig = NULL; + s_cr = (capture_result_t){0}; + s_options = false; + + s_count = ir_store_list(s_entries, IR_STORE_MAX_ENTRIES); + if (s_count < 0) + s_count = 0; + if (s_count > MENU_COMP_MAX_ITEMS) { + // menu_component holds at most MENU_COMP_MAX_ITEMS rows — don't let extra + // files silently fall off the end (selection math would desync). Browse/Burst + // still reach the rest. + ESP_LOGW(TAG, "%d IR files; showing first %d", s_count, MENU_COMP_MAX_ITEMS); + s_count = MENU_COMP_MAX_ITEMS; } + if (s_sel >= s_count) + s_sel = (s_count > 0) ? s_count - 1 : 0; - char *buf = malloc((size_t)sz + 1); - if (buf == NULL) { - ESP_LOGE(TAG, "Failed to allocate IR file buffer"); - fclose(f); - return; + s_screen = lv_obj_create(NULL); + lv_obj_set_style_bg_color(s_screen, current_theme.screen_base, 0); + lv_obj_set_style_bg_opa(s_screen, LV_OPA_COVER, 0); + lv_obj_remove_flag(s_screen, LV_OBJ_FLAG_SCROLLABLE); + + s_menu = menu_component_create(s_screen, "IR SEND", IR_ICON); + if (s_count == 0) { + menu_component_add_item(&s_menu, "/assets/icons/graphic_eq.bin", EMPTY_NAME); + } else { + for (int i = 0; i < s_count; i++) + menu_component_add_item(&s_menu, "/assets/icons/graphic_eq.bin", s_entries[i].name); + if (s_sel > 0 && s_sel < s_count) + menu_component_select(&s_menu, s_sel); } - size_t read = fread(buf, 1, (size_t)sz, f); - fclose(f); + ui_screen_load_owned(&s_screen, s_screen); +} - if ((int32_t)read != sz) { - ESP_LOGE(TAG, "Short read on IR file: expected %ld, got %zu", (long)sz, read); - free(buf); - msgbox_open(LV_SYMBOL_WARNING, "Failed to read file", "OK", NULL, NULL); - return; +static void build_sending(void) { + if (s_screen != NULL) { + lv_obj_del(s_screen); + s_screen = NULL; } + s_cr = (capture_result_t){0}; + s_options = false; - buf[sz] = '\0'; + s_screen = new_screen(); + ui_chrome_header(s_screen, "Send", IR_ICON); - ir_file_t ir_file; - ir_file_init(&ir_file); + s_status_label = lv_label_create(s_screen); + lv_label_set_text(s_status_label, STATUS_SENDING); + lv_obj_set_style_text_color(s_status_label, current_theme.text_main, 0); + lv_obj_set_style_text_font(s_status_label, &lv_font_montserrat_14, 0); + lv_obj_set_style_text_align(s_status_label, LV_TEXT_ALIGN_CENTER, 0); + lv_obj_align(s_status_label, LV_ALIGN_TOP_MID, 0, STATUS_Y); - if (ir_file_parse(buf, &ir_file) && ir_file.count > 0) { - ir_tx_init(); - ir_file_send(&ir_file.signals[0]); - msgbox_open(LV_SYMBOL_OK, "Signal sent!", "OK", NULL, NULL); - } else { - ESP_LOGW(TAG, "IR file parse failed or empty: %s", path); - msgbox_open(LV_SYMBOL_WARNING, "Failed to send", "OK", NULL, NULL); - } + s_card = build_cartridge(s_screen); + s_sig = sigwave_create(s_screen, LV_ALIGN_CENTER, 0, SIG_Y_OFS); - ir_file_free(&ir_file); - free(buf); + s_hint_label = ui_chrome_footer(s_screen, HINT_SENDING); + + ui_screen_load_owned(&s_screen, s_screen); } -static void build_list(void) { - if (s_items_cont != NULL) - lv_obj_clean(s_items_cont); - - scan_ir_files(); - - for (size_t i = 0; i < s_file_count; i++) { - lv_obj_t *item = lv_obj_create(s_items_cont); - lv_obj_set_size(item, ITEM_W, ITEM_H); - lv_obj_remove_flag(item, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_set_style_radius(item, ITEM_RADIUS, 0); - lv_obj_set_style_bg_opa(item, LV_OPA_COVER, 0); - lv_obj_set_style_bg_color(item, current_theme.bg_primary, 0); - lv_obj_set_style_bg_grad_color(item, current_theme.bg_secondary, 0); - lv_obj_set_style_bg_grad_dir(item, LV_GRAD_DIR_HOR, 0); - lv_obj_set_style_border_width(item, ITEM_BORDER_NORMAL, 0); - lv_obj_set_style_border_color(item, current_theme.border_interface, 0); - lv_obj_set_style_pad_left(item, ITEM_PAD_H, 0); - lv_obj_set_style_pad_right(item, ITEM_PAD_H, 0); - lv_obj_set_flex_flow(item, LV_FLEX_FLOW_ROW); - lv_obj_set_flex_align(item, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); - lv_obj_set_style_pad_column(item, ITEM_PAD_COL, 0); - - lv_obj_t *lbl = lv_label_create(item); - lv_label_set_text(lbl, s_file_names[i]); - lv_obj_set_style_text_color(lbl, current_theme.text_main, 0); - lv_obj_set_style_text_font(lbl, &lv_font_montserrat_12, 0); - lv_obj_set_flex_grow(lbl, ITEM_FLEX_GROW); - lv_label_set_long_mode(lbl, LV_LABEL_LONG_SCROLL_CIRCULAR); - - lv_obj_t *arrow = lv_label_create(item); - lv_label_set_text(arrow, LV_SYMBOL_PLAY); - lv_obj_set_style_text_color(arrow, current_theme.border_accent, 0); - lv_obj_set_style_text_font(arrow, &lv_font_montserrat_12, 0); - - s_item_objs[i] = item; +static void show_options(void) { + if (s_card != NULL) { + lv_obj_del(s_card); + s_card = NULL; } - - if (s_file_count == 0) { - lv_obj_t *empty = lv_label_create(s_items_cont); - lv_label_set_text(empty, "No .ir files found"); - lv_obj_set_style_text_color(empty, current_theme.border_inactive, 0); - lv_obj_set_style_text_font(empty, &lv_font_montserrat_12, 0); + if (s_sig != NULL) { + lv_obj_del(s_sig); + s_sig = NULL; + } + if (s_status_label != NULL) { + lv_obj_add_flag(s_status_label, LV_OBJ_FLAG_HIDDEN); } - update_selection(); + capture_result_cfg_t cfg = { + .accent = current_theme.border_accent, + .card_icon = IR_ICON, + .card_title = sel_name(), + .card_sub = sel_proto(), + .card_value = "sent", + .primary_label = "Send again", + .again_label = "Pick another", + }; + s_cr = capture_result_create(s_screen, &cfg); + s_options = true; + set_hint(HINT_OPTIONS); } -static void nav_timer_cb(lv_timer_t *t) { - if (lv_screen_active() != s_screen) { - lv_timer_delete(t); - s_nav_timer = NULL; - return; - } - if (ui_input_is_locked()) +// Actually transmit the selected file's first signal. Quick and blocking; the +// on-screen "Sending" animation continues cosmetically. +static void transmit_selected(void) { + if (s_count <= 0 || s_sel < 0 || s_sel >= s_count) return; - if (msgbox_is_open()) + ir_file_t f; + ir_file_init(&f); + esp_err_t r = ESP_FAIL; + if (ir_store_load(s_entries[s_sel].path, &f) == ESP_OK && f.count > 0) + r = ir_store_send_signal(&f.signals[0]); + else + ESP_LOGW(TAG, "load %s failed", s_entries[s_sel].path); + ir_file_free(&f); + if (r != ESP_OK) + notify(NOTIFY_WARNING, "Send failed"); +} + +static void start_send(void) { + stop_send_timer(); + s_view = VIEW_SENDING; + s_send_start = lv_tick_get(); + transmit_selected(); + build_sending(); + ui_feedback(UI_FB_SELECT); + s_send_timer = lv_timer_create(send_done_cb, SENDING_MS, NULL); + lv_timer_set_repeat_count(s_send_timer, 1); +} + +static void send_done_cb(lv_timer_t *t) { + (void)t; + s_send_timer = NULL; + if (lv_screen_active() != s_screen) return; - bool up = up_button_is_down(); - bool down = down_button_is_down(); - bool ok = ok_button_is_down(); - bool back = back_button_is_down(); + s_view = VIEW_SENT; + s_options = false; + set_status(STATUS_SENT, true); - if (down && !s_btn_down_last && s_file_count > 0) { - s_selected = (s_selected + 1) % s_file_count; - update_selection(); + if (s_sig != NULL) { + lv_obj_del(s_sig); + s_sig = NULL; } - if (up && !s_btn_up_last && s_file_count > 0) { - s_selected = (s_selected == 0) ? s_file_count - 1 : s_selected - 1; - update_selection(); + s_sig = sigwave_create_static(s_screen, LV_ALIGN_CENTER, 0, SIG_Y_OFS); + + if (s_card != NULL) { + lv_obj_fade_in(s_card, 280, 0); + card_rise(s_card); } - if (ok && !s_btn_ok_last) { - send_selected(); + + s_sent_at = lv_tick_get(); + set_hint(HINT_SENT); + ui_feedback(UI_FB_WRITE); +} + +static void sending_tick(void) { + if (s_status_label == NULL) + return; + int dots = ((lv_tick_get() - s_send_start) / DOT_CYCLE_MS) % 4; + char buf[24]; + snprintf(buf, + sizeof(buf), + "%s%s", + STATUS_SENDING, + dots == 1 ? "." + : dots == 2 ? ".." + : dots == 3 ? "..." + : ""); + lv_label_set_text(s_status_label, buf); +} + +static void ir_send_tick_cb(lv_timer_t *t) { + if (lv_screen_active() != s_screen) { + lv_timer_delete(t); + s_tick_timer = NULL; + return; } - if (back && !s_btn_back_last) { - ui_switch_screen(SCREEN_IR_MENU); + + if (s_view == VIEW_SENDING) { + sending_tick(); + } else if (s_view == VIEW_SENT && !s_options && lv_tick_get() - s_sent_at >= REVEAL_MS) { + show_options(); } +} - s_btn_up_last = up; - s_btn_down_last = down; - s_btn_ok_last = ok; - s_btn_back_last = back; +static void ir_send_input(const input_event_t *ev, void *ctx) { + (void)ctx; + const bool press = (ev->action == INPUT_ACTION_PRESS); + const bool nav = press || (ev->action == INPUT_ACTION_REPEAT); + + switch (s_view) { + case VIEW_LIST: + switch (ev->button) { + case INPUT_BTN_DOWN: + if (nav) { + menu_component_next(&s_menu); + ui_feedback(UI_FB_NAV); + } + break; + case INPUT_BTN_UP: + if (nav) { + menu_component_prev(&s_menu); + ui_feedback(UI_FB_NAV); + } + break; + case INPUT_BTN_OK: + case INPUT_BTN_RIGHT: + if (press) { + if (s_count <= 0) { + notify(NOTIFY_INFO, "Capture a signal in Learn first"); + } else { + s_sel = menu_component_get_selected(&s_menu); + start_send(); + } + } + break; + case INPUT_BTN_BACK: + case INPUT_BTN_LEFT: + if (press) + ui_switch_screen(SCREEN_IR_MENU); + break; + default: + break; + } + break; + + case VIEW_SENDING: + if (ev->button == INPUT_BTN_BACK && press) { + stop_send_timer(); + s_view = VIEW_LIST; + build_list(); + } + break; + + case VIEW_SENT: + if (!s_options) { + if (ev->button == INPUT_BTN_BACK && press) { + s_view = VIEW_LIST; + build_list(); + } + } else { + switch (ev->button) { + case INPUT_BTN_DOWN: + if (nav) { + capture_result_next(&s_cr); + ui_feedback(UI_FB_NAV); + } + break; + case INPUT_BTN_UP: + if (nav) { + capture_result_prev(&s_cr); + ui_feedback(UI_FB_NAV); + } + break; + case INPUT_BTN_OK: + if (press) { + switch (capture_result_selected(&s_cr)) { + case CAP_ACT_PRIMARY: + start_send(); + break; + case CAP_ACT_SAVE: + capture_result_mark_saved(&s_cr); + notify(NOTIFY_INFO, "Already saved"); + break; + case CAP_ACT_AGAIN: + s_view = VIEW_LIST; + build_list(); + break; + case CAP_ACT_DISCARD: + ui_switch_screen(SCREEN_IR_MENU); + break; + default: + break; + } + } + break; + case INPUT_BTN_BACK: + if (press) + ui_switch_screen(SCREEN_IR_MENU); + break; + default: + break; + } + } + break; + + default: + break; + } } void ui_ir_send_open(void) { + stop_send_timer(); if (s_screen != NULL) { lv_obj_del(s_screen); s_screen = NULL; } - - s_selected = 0; - s_screen = lv_obj_create(NULL); - lv_obj_set_style_bg_color(s_screen, current_theme.screen_base, 0); - lv_obj_set_style_bg_opa(s_screen, LV_OPA_COVER, 0); - lv_obj_remove_flag(s_screen, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_set_style_border_width(s_screen, OUTER_BORDER, 0); - lv_obj_set_style_border_color(s_screen, current_theme.border_interface, 0); - lv_obj_set_style_pad_all(s_screen, 0, 0); - - lv_obj_t *top_area = lv_obj_create(s_screen); - lv_obj_set_size(top_area, LCD_H_RES - OUTER_BORDER * 2, TOP_BORDER_H); - lv_obj_align(top_area, LV_ALIGN_TOP_MID, 0, 0); - lv_obj_remove_flag(top_area, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_set_style_bg_opa(top_area, LV_OPA_TRANSP, 0); - lv_obj_set_style_border_width(top_area, TOP_AREA_BORDER_W, 0); - lv_obj_set_style_border_color(top_area, current_theme.border_interface, 0); - lv_obj_set_style_border_side(top_area, LV_BORDER_SIDE_BOTTOM, 0); - lv_obj_set_style_radius(top_area, 0, 0); - lv_obj_set_style_pad_all(top_area, 0, 0); - - lv_obj_t *title_bar = lv_obj_create(top_area); - lv_obj_set_size(title_bar, TITLE_BAR_W, TITLE_BAR_H); - lv_obj_align(title_bar, LV_ALIGN_CENTER, 0, 0); - lv_obj_remove_flag(title_bar, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_set_style_radius(title_bar, TITLE_BAR_RADIUS, 0); - lv_obj_set_style_bg_opa(title_bar, LV_OPA_COVER, 0); - lv_obj_set_style_bg_color(title_bar, current_theme.bg_primary, 0); - lv_obj_set_style_bg_grad_color(title_bar, current_theme.bg_secondary, 0); - lv_obj_set_style_bg_grad_dir(title_bar, LV_GRAD_DIR_HOR, 0); - lv_obj_set_style_border_width(title_bar, TITLE_BAR_BORDER_W, 0); - lv_obj_set_style_border_color(title_bar, current_theme.border_accent, 0); - - lv_obj_t *title_lbl = lv_label_create(title_bar); - lv_label_set_text(title_lbl, "IR SEND"); - lv_obj_set_style_text_color(title_lbl, current_theme.text_main, 0); - lv_obj_set_style_text_font(title_lbl, &lv_font_montserrat_14, 0); - lv_obj_center(title_lbl); - - int32_t items_y = TOP_BORDER_H + ITEMS_CONT_OFFSET_Y; - int32_t items_h = LCD_V_RES - items_y - OUTER_BORDER - ITEMS_CONT_OFFSET_Y; - - s_items_cont = lv_obj_create(s_screen); - lv_obj_set_size(s_items_cont, ITEM_W + ITEMS_CONT_PAD * 4, items_h); - lv_obj_align(s_items_cont, LV_ALIGN_TOP_LEFT, ITEMS_CONT_OFFSET_X, items_y); - lv_obj_set_style_bg_opa(s_items_cont, LV_OPA_TRANSP, 0); - lv_obj_set_style_border_width(s_items_cont, 0, 0); - lv_obj_set_style_pad_all(s_items_cont, ITEMS_CONT_PAD, 0); - lv_obj_set_style_pad_row(s_items_cont, ITEMS_CONT_PAD_ROW, 0); - lv_obj_set_flex_flow(s_items_cont, LV_FLEX_FLOW_COLUMN); - lv_obj_set_scrollbar_mode(s_items_cont, LV_SCROLLBAR_MODE_OFF); - lv_obj_set_scroll_snap_y(s_items_cont, LV_SCROLL_SNAP_START); - - int32_t track_x = LCD_H_RES - OUTER_BORDER - SCROLL_TRACK_X_FROM_RIGHT; - s_track_y_start = items_y + SCROLL_BAR_TRACK_OFF; - s_track_h = items_h - SCROLL_BAR_TRACK_OFF * 2; - - // Points must outlive this function (used by lv_line) - static lv_point_precise_t track_pts[2]; - track_pts[0].x = 0; - track_pts[0].y = 0; - track_pts[1].x = 0; - track_pts[1].y = s_track_h; - - lv_obj_t *track = lv_line_create(s_screen); - lv_line_set_points(track, track_pts, 2); - lv_obj_set_pos(track, track_x, s_track_y_start); - lv_obj_set_style_line_color(track, current_theme.border_inactive, 0); - lv_obj_set_style_line_opa(track, LV_OPA_COVER, 0); - lv_obj_set_style_line_width(track, SCROLL_BAR_TRACK_W, 0); - lv_obj_set_style_line_dash_width(track, SCROLL_BAR_TRACK_W + 1, 0); - lv_obj_set_style_line_dash_gap(track, SCROLL_BAR_TRACK_W + 1, 0); - - // Cached across calls: asset descriptor is constant after first load - static lv_image_dsc_t *sb_dsc = NULL; - if (sb_dsc == NULL) - sb_dsc = assets_get("/assets/icons/slide_bar_v.bin"); - - s_scroll_bar = lv_image_create(s_screen); - if (sb_dsc != NULL) - lv_image_set_src(s_scroll_bar, sb_dsc); - - lv_obj_set_pos(s_scroll_bar, track_x - SCROLL_BAR_IMG_OFF, s_track_y_start); - lv_obj_move_foreground(s_scroll_bar); + s_status_label = NULL; + s_hint_label = NULL; + s_card = NULL; + s_sig = NULL; + s_cr = (capture_result_t){0}; + s_options = false; + s_view = VIEW_LIST; + s_sel = 0; build_list(); - if (s_nav_timer == NULL) { - s_nav_timer = lv_timer_create(nav_timer_cb, NAV_TIMER_PERIOD_MS, NULL); - } - - lv_screen_load(s_screen); -} \ No newline at end of file + ui_input_set_screen_handler(ir_send_input, NULL); + if (s_tick_timer == NULL) + s_tick_timer = lv_timer_create(ir_send_tick_cb, TICK_MS, NULL); +} diff --git a/firmware_p4/components/Applications/ui/screens/infrared/ir_store.c b/firmware_p4/components/Applications/ui/screens/infrared/ir_store.c new file mode 100644 index 000000000..7665e59f1 --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/infrared/ir_store.c @@ -0,0 +1,432 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#include "ir_store.h" + +#include +#include +#include +#include +#include +#include + +#include "esp_log.h" +#include "freertos/FreeRTOS.h" +#include "freertos/queue.h" +#include "freertos/task.h" +#include "sys_prio.h" + +#include "ir_protocol.h" +#include "storage_mkdir.h" +#include "tos_storage_paths.h" + +static const char *TAG = "IR_STORE"; + +#define IR_DIR TOS_PATH_IR // "/sdcard/ir" +#define IR_STORE_FILE_MAX (512 * 1024) +#define IR_SERIALIZE_MAX 16384 +#define IR_CAP_TASK_STACK 8192 +#define IR_CAP_TASK_PRIO SYS_PRIO_SERVICE_HI + +// ---------------------------------------------------------------------------- +// Path helpers +// ---------------------------------------------------------------------------- + +static void path_for(const char *name, char *out, size_t cap) { + snprintf(out, cap, "%s/%s.ir", IR_DIR, name); +} + +bool ir_store_exists(const char *name) { + if (name == NULL || name[0] == '\0') + return false; + char path[IR_STORE_PATH_MAX]; + path_for(name, path, sizeof(path)); + struct stat st; + return stat(path, &st) == 0; +} + +// Read the first block of a file and pull out the protocol name (or "RAW") so +// list rows can show it without paying for a full parse of every file. +static void sniff_proto(const char *path, char *out, size_t cap) { + out[0] = '\0'; + FILE *f = fopen(path, "rb"); + if (f == NULL) + return; + char buf[512]; + size_t n = fread(buf, 1, sizeof(buf) - 1, f); + fclose(f); + buf[n] = '\0'; + + const char *p = strstr(buf, "protocol:"); + if (p != NULL) { + p += strlen("protocol:"); + while (*p == ' ' || *p == '\t') + p++; + size_t i = 0; + while (*p != '\0' && *p != '\r' && *p != '\n' && i < cap - 1) + out[i++] = *p++; + out[i] = '\0'; + return; + } + if (strstr(buf, "type: raw") != NULL || strstr(buf, "type:raw") != NULL) { + snprintf(out, cap, "RAW"); + return; + } + snprintf(out, cap, "IR"); +} + +// ---------------------------------------------------------------------------- +// Listing +// ---------------------------------------------------------------------------- + +int ir_store_list(ir_store_entry_t *out, int max) { + if (out == NULL || max <= 0) + return -1; + + DIR *dir = opendir(IR_DIR); + if (dir == NULL) + return 0; // dir not created yet == no saved signals + + int n = 0; + struct dirent *ent; + while ((ent = readdir(dir)) != NULL && n < max) { + const char *nm = ent->d_name; + size_t len = strlen(nm); + if (len < 4 || strcasecmp(nm + len - 3, ".ir") != 0) + continue; + + ir_store_entry_t *e = &out[n]; + size_t stem = len - 3; + if (stem >= IR_STORE_NAME_MAX) + stem = IR_STORE_NAME_MAX - 1; + memcpy(e->name, nm, stem); + e->name[stem] = '\0'; + // Rebuild the path from the bounded stem (char[32]) rather than d_name + // (char[256]) so the length is provable to the compiler; the SD FAT is + // case-insensitive, so this still opens the real file. + snprintf(e->path, sizeof(e->path), "%s/%s.ir", IR_DIR, e->name); + sniff_proto(e->path, e->proto, sizeof(e->proto)); + n++; + } + closedir(dir); + return n; +} + +// ---------------------------------------------------------------------------- +// Load / save / delete / rename +// ---------------------------------------------------------------------------- + +esp_err_t ir_store_load(const char *path, ir_file_t *file) { + if (path == NULL || file == NULL) + return ESP_ERR_INVALID_ARG; + + FILE *f = fopen(path, "rb"); + if (f == NULL) + return ESP_ERR_NOT_FOUND; + + fseek(f, 0, SEEK_END); + long sz = ftell(f); + fseek(f, 0, SEEK_SET); + if (sz <= 0 || sz > IR_STORE_FILE_MAX) { + fclose(f); + return ESP_ERR_INVALID_SIZE; + } + + char *buf = malloc((size_t)sz + 1); + if (buf == NULL) { + fclose(f); + return ESP_ERR_NO_MEM; + } + size_t rd = fread(buf, 1, (size_t)sz, f); + fclose(f); + buf[rd] = '\0'; + + esp_err_t r = ir_file_parse(buf, file); + free(buf); + return r; +} + +static esp_err_t write_file(const ir_file_t *file, const char *name) { + storage_mkdir_recursive(IR_DIR); + + char *buf = malloc(IR_SERIALIZE_MAX); + if (buf == NULL) + return ESP_ERR_NO_MEM; + size_t n = ir_file_to_string(file, buf, IR_SERIALIZE_MAX); + + char path[IR_STORE_PATH_MAX]; + path_for(name, path, sizeof(path)); + FILE *f = fopen(path, "wb"); + if (f == NULL) { + free(buf); + ESP_LOGE(TAG, "open %s for write failed", path); + return ESP_FAIL; + } + size_t wr = fwrite(buf, 1, n, f); + fclose(f); + free(buf); + return wr == n ? ESP_OK : ESP_FAIL; +} + +esp_err_t ir_store_save_data(const char *name, const ir_data_t *data) { + if (name == NULL || data == NULL) + return ESP_ERR_INVALID_ARG; + + ir_file_t file; + ir_file_init(&file); + esp_err_t r = ir_file_add_parsed(&file, name, data); + if (r == ESP_OK) + r = write_file(&file, name); + ir_file_free(&file); + if (r == ESP_OK) + ESP_LOGI(TAG, "saved %s.ir", name); + return r; +} + +esp_err_t +ir_store_save_raw(const char *name, const rmt_symbol_word_t *symbols, size_t count, uint32_t freq) { + if (name == NULL || symbols == NULL || count == 0) + return ESP_ERR_INVALID_ARG; + + ir_file_t file; + ir_file_init(&file); + ir_file_add_raw_cfg_t cfg = { + .name = name, + .symbols = symbols, + .count = count, + .freq = freq, + }; + esp_err_t r = ir_file_add_raw(&file, &cfg); + if (r == ESP_OK) + r = write_file(&file, name); + ir_file_free(&file); + if (r == ESP_OK) + ESP_LOGI(TAG, "saved %s.ir (raw, %u symbols)", name, (unsigned)count); + return r; +} + +esp_err_t ir_store_delete(const char *name) { + if (name == NULL) + return ESP_ERR_INVALID_ARG; + char path[IR_STORE_PATH_MAX]; + path_for(name, path, sizeof(path)); + return remove(path) == 0 ? ESP_OK : ESP_FAIL; +} + +esp_err_t ir_store_rename(const char *old_name, const char *new_name) { + if (old_name == NULL || new_name == NULL) + return ESP_ERR_INVALID_ARG; + char op[IR_STORE_PATH_MAX]; + char np[IR_STORE_PATH_MAX]; + path_for(old_name, op, sizeof(op)); + path_for(new_name, np, sizeof(np)); + return rename(op, np) == 0 ? ESP_OK : ESP_FAIL; +} + +void ir_store_name_for_data(const ir_data_t *data, char *buf, size_t cap) { + if (buf == NULL || cap == 0) + return; + if (data == NULL) { + snprintf(buf, cap, "signal"); + return; + } + snprintf(buf, + cap, + "%s_%02lX_%02lX", + ir_protocol_name(data->protocol), + (unsigned long)data->address, + (unsigned long)data->command); +} + +void ir_store_next_free(const char *prefix, char *buf, size_t cap) { + if (buf == NULL || cap == 0) + return; + const char *pfx = (prefix != NULL && prefix[0] != '\0') ? prefix : "signal"; + for (int i = 1; i < 1000; i++) { + snprintf(buf, cap, "%s_%03d", pfx, i); + if (!ir_store_exists(buf)) + return; + } + snprintf(buf, cap, "%s_new", pfx); +} + +// ---------------------------------------------------------------------------- +// Transmit (blocking; lazily brings TX up) +// ---------------------------------------------------------------------------- + +// Bring TX up, logging loudly on failure (a swallowed ir_tx_init error looks +// exactly like "the LEDs never blink"). +static esp_err_t tx_ready(void) { + esp_err_t r = ir_tx_init(); + if (r != ESP_OK) + ESP_LOGE(TAG, "ir_tx_init failed: %s", esp_err_to_name(r)); + return r; +} + +esp_err_t ir_store_send_data(const ir_data_t *data) { + if (data == NULL) + return ESP_ERR_INVALID_ARG; + esp_err_t r = tx_ready(); + if (r != ESP_OK) + return r; + r = ir_send(data); + ESP_LOGI(TAG, + "send %s 0x%lX/0x%lX -> %s", + ir_protocol_name(data->protocol), + (unsigned long)data->address, + (unsigned long)data->command, + esp_err_to_name(r)); + return r; +} + +esp_err_t ir_store_send_signal(const ir_signal_t *signal) { + if (signal == NULL) + return ESP_ERR_INVALID_ARG; + esp_err_t r = tx_ready(); + if (r != ESP_OK) + return r; + r = ir_file_send(signal); + ESP_LOGI(TAG, + "send '%s' (%s) -> %s", + signal->name, + signal->is_raw ? "raw" : ir_protocol_name(signal->data.protocol), + esp_err_to_name(r)); + return r; +} + +esp_err_t ir_store_send_raw(const rmt_symbol_word_t *symbols, size_t count, uint32_t freq) { + if (symbols == NULL || count == 0) + return ESP_ERR_INVALID_ARG; + esp_err_t r = tx_ready(); + if (r != ESP_OK) + return r; + r = ir_send_raw(symbols, count, freq); + ESP_LOGI(TAG, + "send raw %u sym @%luHz -> %s", + (unsigned)count, + (unsigned long)freq, + esp_err_to_name(r)); + return r; +} + +int ir_store_send_named(const ir_file_t *file, const char *name) { + if (file == NULL || name == NULL) + return 0; + if (tx_ready() != ESP_OK) + return 0; + int sent = 0; + for (size_t i = 0; i < file->count; i++) { + if (strcasecmp(file->signals[i].name, name) == 0) { + if (ir_file_send(&file->signals[i]) == ESP_OK) + sent++; + } + } + ESP_LOGI(TAG, "send named '%s' -> %d code(s)", name, sent); + return sent; +} + +// ---------------------------------------------------------------------------- +// Background RX capture (ir_receive blocks; UI thread polls a 1-slot queue) +// ---------------------------------------------------------------------------- + +typedef struct { + esp_err_t status; + ir_data_t data; +} cap_msg_t; + +static QueueHandle_t s_cap_q = NULL; +static volatile bool s_cap_running = false; +static ir_cap_status_t s_cap_state = IR_CAP_IDLE; +static ir_data_t s_cap_data = {0}; + +static void capture_task(void *arg) { + uint32_t timeout_ms = (uint32_t)(uintptr_t)arg; + cap_msg_t msg = {0}; + + if (ir_rx_init() != ESP_OK) { + msg.status = ESP_FAIL; + } else { + ir_rx_prime(); // drop stale frame + re-arm if a frame left the channel idle + msg.status = ir_receive(&msg.data, timeout_ms); + } + + xQueueOverwrite(s_cap_q, &msg); + s_cap_running = false; + vTaskDelete(NULL); +} + +esp_err_t ir_capture_start(uint32_t timeout_ms) { + if (s_cap_running) + return ESP_ERR_INVALID_STATE; + + if (s_cap_q == NULL) { + s_cap_q = xQueueCreate(1, sizeof(cap_msg_t)); + if (s_cap_q == NULL) + return ESP_ERR_NO_MEM; + } + xQueueReset(s_cap_q); + + s_cap_state = IR_CAP_BUSY; + s_cap_running = true; + if (xTaskCreatePinnedToCore(capture_task, + "ir_capture", + IR_CAP_TASK_STACK, + (void *)(uintptr_t)timeout_ms, + IR_CAP_TASK_PRIO, + NULL, + SYS_CORE_RADIO) != pdPASS) { + s_cap_running = false; + s_cap_state = IR_CAP_ERROR; + return ESP_FAIL; + } + return ESP_OK; +} + +ir_cap_status_t ir_capture_poll(ir_data_t *out) { + if (s_cap_state == IR_CAP_BUSY && s_cap_q != NULL) { + cap_msg_t msg; + if (xQueueReceive(s_cap_q, &msg, 0) == pdPASS) { + if (msg.status == ESP_OK) { + s_cap_data = msg.data; + s_cap_state = IR_CAP_GOT; + } else if (msg.status == ESP_ERR_NOT_FOUND) { + // A frame arrived but no protocol matched — raw is still available via + // ir_get_last_raw(); present it as an unknown signal. + s_cap_data = (ir_data_t){0}; + s_cap_state = IR_CAP_GOT; + } else if (msg.status == ESP_ERR_TIMEOUT) { + s_cap_state = IR_CAP_TIMEOUT; + } else { + s_cap_state = IR_CAP_ERROR; + } + } + } + if (s_cap_state == IR_CAP_GOT && out != NULL) + *out = s_cap_data; + return s_cap_state; +} + +bool ir_capture_decoded(void) { + return s_cap_state == IR_CAP_GOT && s_cap_data.protocol != IR_PROTO_UNKNOWN; +} + +void ir_capture_reset(void) { + if (s_cap_running) + return; // let the in-flight task finish; start() is guarded anyway + s_cap_state = IR_CAP_IDLE; + s_cap_data = (ir_data_t){0}; + if (s_cap_q != NULL) + xQueueReset(s_cap_q); +} diff --git a/firmware_p4/components/Applications/ui/screens/interface_settings/interface_settings_ui.c b/firmware_p4/components/Applications/ui/screens/interface_settings/interface_settings_ui.c deleted file mode 100644 index d990c3a6c..000000000 --- a/firmware_p4/components/Applications/ui/screens/interface_settings/interface_settings_ui.c +++ /dev/null @@ -1,256 +0,0 @@ -// Copyright (c) 2025 HIGH CODE LLC -// -// TentacleOS is free software: you can redistribute it and/or modify -// it under the terms of the GNU General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// TentacleOS is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU General Public License for more details. -// -// You should have received a copy of the GNU General Public License -// along with TentacleOS. If not, see . - -#include "interface_settings_ui.h" - -#include -#include -#include - -#include "esp_log.h" -#include "cJSON.h" - -#include "ui_theme.h" -#include "menu_component_ui.h" -#include "ui_manager.h" -#include "lv_port_indev.h" -#include "buttons_gpio.h" -#include "storage_assets.h" -#include "tos_flash_paths.h" - -static const char *TAG = "INTERFACE_SETTINGS_UI"; - -#define INTERFACE_CONFIG_PATH FLASH_CONFIG_INTERFACE -#define HEADER_COUNT 4 -#define NAV_TIMER_PERIOD_MS 50 -#define CONFIG_FILE_MAX_BYTES 4096 -#define CONFIG_DIR_MODE 0777 - -typedef enum { - ITEM_THEME = 0, - ITEM_HEADER = 1, - ITEM_FOOTER = 2, - ITEM_COUNT = 3, -} interface_item_t; - -static lv_obj_t *s_screen = NULL; -static menu_component_t s_menu; -static lv_timer_t *s_nav_timer = NULL; - -static bool s_btn_up_last = false; -static bool s_btn_down_last = false; -static bool s_btn_left_last = false; -static bool s_btn_right_last = false; -static bool s_btn_ok_last = false; -static bool s_btn_back_last = false; - -static int s_header_idx = 0; -static bool s_hide_footer = false; -static int s_lang_idx = 0; - -static const char *HEADER_OPTIONS[HEADER_COUNT] = {"DEFAULT", "GD TOP", "GD BOT", "MINIMAL"}; -static const char *LANG_OPTIONS[] = {"EN", "PT", "ES", "FR"}; - -static void nav_timer_cb(lv_timer_t *t); - -void interface_save_config(void) { - if (!storage_assets_is_mounted()) - return; - - mkdir("/assets/config", CONFIG_DIR_MODE); - mkdir(FLASH_MOUNT "/config/screen", CONFIG_DIR_MODE); - - cJSON *root = cJSON_CreateObject(); - if (root == NULL) { - ESP_LOGE(TAG, "Failed to create JSON object"); - return; - } - - cJSON_AddNumberToObject(root, "header_idx", s_header_idx); - cJSON_AddBoolToObject(root, "hide_footer", s_hide_footer); - cJSON_AddNumberToObject(root, "lang_idx", s_lang_idx); - - char *out = cJSON_PrintUnformatted(root); - if (out != NULL) { - FILE *f = fopen(INTERFACE_CONFIG_PATH, "w"); - if (f != NULL) { - fputs(out, f); - fclose(f); - } else { - ESP_LOGE(TAG, "Failed to open config for writing: %s", INTERFACE_CONFIG_PATH); - } - cJSON_free(out); - } else { - ESP_LOGE(TAG, "Failed to serialize config JSON"); - } - - cJSON_Delete(root); -} - -void interface_load_config(void) { - if (!storage_assets_is_mounted()) - return; - - FILE *f = fopen(INTERFACE_CONFIG_PATH, "r"); - if (f == NULL) - return; - - fseek(f, 0, SEEK_END); - int32_t fsize = (int32_t)ftell(f); - fseek(f, 0, SEEK_SET); - - if (fsize <= 0 || fsize > CONFIG_FILE_MAX_BYTES) { - ESP_LOGE(TAG, "Invalid config file size: %ld", (long)fsize); - fclose(f); - return; - } - - char *data = malloc((size_t)fsize + 1); - if (data == NULL) { - ESP_LOGE(TAG, "Failed to allocate config read buffer"); - fclose(f); - return; - } - - size_t read = fread(data, 1, (size_t)fsize, f); - fclose(f); - - if ((int32_t)read != fsize) { - ESP_LOGE(TAG, "Short read on config file: expected %ld, got %zu", (long)fsize, read); - free(data); - return; - } - - data[fsize] = '\0'; - - cJSON *root = cJSON_Parse(data); - free(data); - - if (root == NULL) { - ESP_LOGE(TAG, "Failed to parse config JSON"); - return; - } - - cJSON *h = cJSON_GetObjectItem(root, "header_idx"); - cJSON *fsw = cJSON_GetObjectItem(root, "hide_footer"); - cJSON *l = cJSON_GetObjectItem(root, "lang_idx"); - - if (cJSON_IsNumber(h)) - s_header_idx = h->valueint; - if (cJSON_IsBool(fsw)) - s_hide_footer = cJSON_IsTrue(fsw); - if (cJSON_IsNumber(l)) - s_lang_idx = l->valueint; - - cJSON_Delete(root); -} - -static void nav_timer_cb(lv_timer_t *t) { - if (lv_screen_active() != s_screen) { - lv_timer_delete(t); - s_nav_timer = NULL; - return; - } - if (ui_input_is_locked()) - return; - - bool up = up_button_is_down(); - bool down = down_button_is_down(); - bool left = left_button_is_down(); - bool right = right_button_is_down(); - bool ok = ok_button_is_down(); - bool back = back_button_is_down(); - - if (down && !s_btn_down_last) { - menu_component_next(&s_menu); - } - if (up && !s_btn_up_last) { - menu_component_prev(&s_menu); - } - - if (back && !s_btn_back_last) { - s_btn_back_last = back; - ui_switch_screen(SCREEN_SETTINGS); - return; - } - - if (ok && !s_btn_ok_last) { - int sel = menu_component_get_selected(&s_menu); - if (sel == ITEM_THEME) { - s_btn_ok_last = ok; - ui_switch_screen(SCREEN_THEME_SELECTOR); - return; - } - } - - if ((left && !s_btn_left_last) || (right && !s_btn_right_last)) { - int sel = menu_component_get_selected(&s_menu); - int dir = (right && !s_btn_right_last) ? 1 : -1; - - switch (sel) { - case ITEM_THEME: - break; - - case ITEM_HEADER: - s_header_idx = (s_header_idx + dir + HEADER_COUNT) % HEADER_COUNT; - menu_component_set_selector_value(&s_menu, ITEM_HEADER, HEADER_OPTIONS[s_header_idx]); - interface_save_config(); - break; - - case ITEM_FOOTER: - menu_component_toggle_item(&s_menu, ITEM_FOOTER); - s_hide_footer = !menu_component_get_toggle(&s_menu, ITEM_FOOTER); - interface_save_config(); - break; - - default: - break; - } - } - - s_btn_up_last = up; - s_btn_down_last = down; - s_btn_left_last = left; - s_btn_right_last = right; - s_btn_ok_last = ok; - s_btn_back_last = back; -} - -void ui_interface_settings_open(void) { - interface_load_config(); - - if (s_screen != NULL) { - lv_obj_del(s_screen); - s_screen = NULL; - } - - s_screen = lv_obj_create(NULL); - lv_obj_set_style_bg_color(s_screen, current_theme.screen_base, 0); - lv_obj_set_style_bg_opa(s_screen, LV_OPA_COVER, 0); - lv_obj_remove_flag(s_screen, LV_OBJ_FLAG_SCROLLABLE); - - s_menu = menu_component_create(s_screen, "INTERFACE", NULL); - - menu_component_add_item(&s_menu, "/assets/icons/theme_menu_icon.bin", "THEME"); - menu_component_add_selector( - &s_menu, "/assets/icons/header_menu_icon.bin", "HEADER", HEADER_OPTIONS[s_header_idx]); - menu_component_add_toggle(&s_menu, NULL, "FOOTER", !s_hide_footer); - - if (s_nav_timer == NULL) { - s_nav_timer = lv_timer_create(nav_timer_cb, NAV_TIMER_PERIOD_MS, NULL); - } - - lv_screen_load(s_screen); -} \ No newline at end of file diff --git a/firmware_p4/components/Applications/ui/screens/lora/include/lora_channels_ui.h b/firmware_p4/components/Applications/ui/screens/lora/include/lora_channels_ui.h new file mode 100644 index 000000000..04798d77c --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/lora/include/lora_channels_ui.h @@ -0,0 +1,35 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#ifndef LORA_CHANNELS_UI_H +#define LORA_CHANNELS_UI_H + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * @brief Open the LoRa channels screen — a cipher rack of key slots. Each + * filled slot is a tiny cipher card (role dot, masked key stub and an + * entropy-bar signature); empty slots read as dashed blanks. UP/DOWN + * move the selection, OK edits, BACK returns to the mesh chat. + */ +void ui_lora_channels_open(void); + +#ifdef __cplusplus +} +#endif + +#endif // LORA_CHANNELS_UI_H diff --git a/firmware_p4/components/Applications/ui/screens/lora/include/lora_chat_ui.h b/firmware_p4/components/Applications/ui/screens/lora/include/lora_chat_ui.h new file mode 100644 index 000000000..a02377a21 --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/lora/include/lora_chat_ui.h @@ -0,0 +1,40 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#ifndef LORA_CHAT_UI_H +#define LORA_CHAT_UI_H + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * @brief Open the LoRa chat screen — peer-to-peer text messaging between + * HighBoy units over the SX1262 radio. OK composes a message, BACK + * exits, UP/DOWN scroll the conversation. + */ +void ui_lora_chat_open(void); + +/** + * @brief Open the LoRa screen directly on the chat conversation view (linked), + * skipping the protocol picker. Used as the boot landing screen. + */ +void ui_lora_chat_open_chat(void); + +#ifdef __cplusplus +} +#endif + +#endif // LORA_CHAT_UI_H diff --git a/firmware_p4/components/Applications/ui/screens/wifi/include/wifi_deauth_ui.h b/firmware_p4/components/Applications/ui/screens/lora/include/lora_mqtt_ui.h similarity index 66% rename from firmware_p4/components/Applications/ui/screens/wifi/include/wifi_deauth_ui.h rename to firmware_p4/components/Applications/ui/screens/lora/include/lora_mqtt_ui.h index ed6b72984..f8ae8c253 100644 --- a/firmware_p4/components/Applications/ui/screens/wifi/include/wifi_deauth_ui.h +++ b/firmware_p4/components/Applications/ui/screens/lora/include/lora_mqtt_ui.h @@ -13,29 +13,23 @@ // You should have received a copy of the GNU General Public License // along with TentacleOS. If not, see . -#ifndef WIFI_DEAUTH_UI_H -#define WIFI_DEAUTH_UI_H - -#include "esp_wifi_types.h" +#ifndef LORA_MQTT_UI_H +#define LORA_MQTT_UI_H #ifdef __cplusplus extern "C" { #endif /** - * @brief Set the target AP for deauth attack. - * - * @param ap Pointer to the target AP record. Must not be NULL. - */ -void ui_wifi_deauth_set_target(wifi_ap_record_t *ap); - -/** - * @brief Open the Wi-Fi deauth screen. + * @brief Open the LoRa-to-MQTT bridge config screen — a menu form to edit the + * broker host, user and password (OK on a field opens the keyboard), + * with a live Status row and a Connect/Disconnect action row. BACK or + * LEFT returns to the LoRa chat hub. */ -void ui_wifi_deauth_open(void); +void ui_lora_mqtt_open(void); #ifdef __cplusplus } #endif -#endif // WIFI_DEAUTH_UI_H +#endif // LORA_MQTT_UI_H diff --git a/firmware_p4/components/Applications/ui/screens/lora/include/lora_position_ui.h b/firmware_p4/components/Applications/ui/screens/lora/include/lora_position_ui.h new file mode 100644 index 000000000..255aac0cd --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/lora/include/lora_position_ui.h @@ -0,0 +1,35 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#ifndef LORA_POSITION_UI_H +#define LORA_POSITION_UI_H + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * @brief Open the LoRa position screen — a coordinate editor for the manual + * fix. LAT/LON/ALT rows read out the fixed position with the active + * field lit and a caret; a broadcast toggle and peer count sit below. + * UP/DOWN pick the field, OK toggles broadcast, BACK returns to chat. + */ +void ui_lora_position_open(void); + +#ifdef __cplusplus +} +#endif + +#endif // LORA_POSITION_UI_H diff --git a/firmware_p4/components/Applications/ui/screens/lora/include/lora_rnode_ui.h b/firmware_p4/components/Applications/ui/screens/lora/include/lora_rnode_ui.h new file mode 100644 index 000000000..7a30e8c52 --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/lora/include/lora_rnode_ui.h @@ -0,0 +1,35 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#ifndef LORA_RNODE_UI_H +#define LORA_RNODE_UI_H + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * @brief Open the RNode / KISS status screen — a lit readout card of the radio + * parameters (Freq / SF / BW / CR / TX power) plus live RX/TX frame + * counters and the last received RSSI/SNR, all ticking as mock traffic + * arrives. BACK or LEFT returns to the LoRa chat hub. + */ +void ui_lora_rnode_open(void); + +#ifdef __cplusplus +} +#endif + +#endif // LORA_RNODE_UI_H diff --git a/firmware_p4/components/Applications/ui/screens/lora/include/lora_securedm_ui.h b/firmware_p4/components/Applications/ui/screens/lora/include/lora_securedm_ui.h new file mode 100644 index 000000000..6a0893c57 --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/lora/include/lora_securedm_ui.h @@ -0,0 +1,35 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#ifndef LORA_SECUREDM_UI_H +#define LORA_SECUREDM_UI_H + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * @brief Open the LoRa secure DM screen — an open end-to-end encrypted thread. + * A verified-key banner pins the peer's X25519 fingerprint, sealed + * message bubbles carry a lock, and a sealed-input strip sits at the + * bottom. UP/DOWN scroll the thread, OK opens the composer, BACK exits. + */ +void ui_lora_securedm_open(void); + +#ifdef __cplusplus +} +#endif + +#endif // LORA_SECUREDM_UI_H diff --git a/firmware_p4/components/Applications/ui/screens/lora/include/lora_telemetry_ui.h b/firmware_p4/components/Applications/ui/screens/lora/include/lora_telemetry_ui.h new file mode 100644 index 000000000..71ba4029f --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/lora/include/lora_telemetry_ui.h @@ -0,0 +1,35 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#ifndef LORA_TELEMETRY_UI_H +#define LORA_TELEMETRY_UI_H + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * @brief Open the LoRa telemetry screen — a live radio monitor. Streaming + * sparklines breathe for channel-util and air-TX-util, a slim battery + * bar shows charge, and neighbours reduce to SNR chips. UP/DOWN pick a + * neighbour, OK opens the node, BACK returns to the mesh chat. + */ +void ui_lora_telemetry_open(void); + +#ifdef __cplusplus +} +#endif + +#endif // LORA_TELEMETRY_UI_H diff --git a/firmware_p4/components/Applications/ui/screens/lora/include/lora_traceroute_ui.h b/firmware_p4/components/Applications/ui/screens/lora/include/lora_traceroute_ui.h new file mode 100644 index 000000000..f0a81e743 --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/lora/include/lora_traceroute_ui.h @@ -0,0 +1,35 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#ifndef LORA_TRACEROUTE_UI_H +#define LORA_TRACEROUTE_UI_H + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * @brief Open the LoRa traceroute screen — briefly pulses while "tracing" the + * mesh, then reveals the vertical hop path (Base Camp, Gateway-1, + * Relay-7, Trekker), each hop row annotated with its link SNR. BACK or + * LEFT returns to the LoRa chat hub. + */ +void ui_lora_traceroute_open(void); + +#ifdef __cplusplus +} +#endif + +#endif // LORA_TRACEROUTE_UI_H diff --git a/firmware_p4/components/Applications/ui/screens/lora/lora_channels_ui.c b/firmware_p4/components/Applications/ui/screens/lora/lora_channels_ui.c new file mode 100644 index 000000000..ac530b28a --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/lora/lora_channels_ui.c @@ -0,0 +1,468 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#include "lora_channels_ui.h" + +#include +#include +#include + +#include "esp_random.h" + +#include "lvgl.h" +#include "st7789.h" + +#include "keyboard_ui.h" +#include "lora_session.h" +#include "meshcore.h" +#include "meshtastic_channels.h" +#include "notify_ui.h" +#include "ui_chrome.h" +#include "ui_feedback.h" +#include "ui_manager.h" +#include "ui_theme.h" + +#define HDR_TITLE "CHANNELS" +#define HDR_ICON NULL +#define FOOTER_HINT "UP/DN OK RENAME RIGHT OFF BACK" + +#define BODY_TOP UI_CHROME_HEADER_H +#define BODY_H (LCD_V_RES - UI_CHROME_HEADER_H - UI_CHROME_FOOTER_H) + +#define GRID_PAD 7 +#define GRID_GAP 6 + +#define TILE_W 110 +#define TILE_H 80 +#define TILE_PAD 6 +#define TILE_GAP 2 +#define TILE_RAD 9 + +#define DOT_SZ 6 +#define KEY_SZ 8 +#define KEY_RAD 2 + +#define BAR_CNT 8 +#define BAR_W 3 +#define BAR_GAP 2 +#define BAR_MAX_H 14 + +#define GLOW_W 12 +#define GLOW_SPR -2 + +#define TILE_CNT 8 + +#define KEY_LEN 16 +#define BAR_BASE 35 +#define BAR_SPAN 66 + +#define COL_ACC2 0xB89AFF +#define COL_CYAN 0x37E0A8 +#define COL_DIM 0x8A8594 +#define COL_SLOT 0x4A4556 + +typedef struct { + bool used; + bool primary; + char name[32]; + char role[16]; + uint8_t bars[BAR_CNT]; +} ch_slot_t; + +static ch_slot_t s_slot[TILE_CNT]; + +static lv_obj_t *s_screen = NULL; +static lv_obj_t *s_grid = NULL; +static lv_obj_t *s_tiles[TILE_CNT]; +static lv_obj_t *s_empty_msg = NULL; + +static lora_proto_t s_proto = LORA_PROTO_NONE; +static int s_sel = 0; + +static void fill_bars(uint8_t *bars, const uint8_t *src, int len) { + for (int b = 0; b < BAR_CNT; b++) { + if (src == NULL || len <= 0) { + bars[b] = BAR_BASE; + continue; + } + uint8_t a = src[b % len]; + uint8_t c = src[(len - 1 - b + len) % len]; + bars[b] = (uint8_t)(BAR_BASE + ((a ^ c) % BAR_SPAN)); + } +} + +static void load_slots(void) { + memset(s_slot, 0, sizeof(s_slot)); + s_proto = lora_session_active(); + + if (s_proto == LORA_PROTO_MESHTASTIC) { + for (int i = 0; i < TILE_CNT; i++) { + const mt_channel_t *ch = mt_channel_get((uint8_t)i); + if (ch == NULL || !ch->is_used || ch->role == MT_CH_DISABLED) + continue; + s_slot[i].used = true; + s_slot[i].primary = (ch->role == MT_CH_PRIMARY); + snprintf(s_slot[i].name, + sizeof(s_slot[i].name), + "%s", + (ch->name[0] != '\0') ? ch->name : "channel"); + snprintf(s_slot[i].role, + sizeof(s_slot[i].role), + "%s", + (ch->role == MT_CH_PRIMARY) ? "PRIMARY" : "SECONDARY"); + fill_bars(s_slot[i].bars, ch->psk, MT_PSK_SIZE); + } + } else if (s_proto == LORA_PROTO_MESHCORE) { + for (int i = 0; i < TILE_CNT; i++) { + const meshcore_channel_t *ch = meshcore_channel_get((uint8_t)i); + if (ch == NULL || !ch->is_used) + continue; + s_slot[i].used = true; + s_slot[i].primary = (i == MESHCORE_PUBLIC_CHANNEL); + snprintf(s_slot[i].name, + sizeof(s_slot[i].name), + "%s", + (ch->name[0] != '\0') ? ch->name : "channel"); + snprintf(s_slot[i].role, + sizeof(s_slot[i].role), + "%s", + (i == MESHCORE_PUBLIC_CHANNEL) ? "PUBLIC" : "GROUP"); + fill_bars(s_slot[i].bars, ch->secret, KEY_LEN); + } + } +} + +static lv_obj_t *bare_box(lv_obj_t *parent, int w, int h) { + lv_obj_t *o = lv_obj_create(parent); + lv_obj_remove_flag(o, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_remove_flag(o, LV_OBJ_FLAG_CLICKABLE); + lv_obj_set_size(o, w, h); + lv_obj_set_style_bg_opa(o, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(o, 0, 0); + lv_obj_set_style_radius(o, 0, 0); + lv_obj_set_style_pad_all(o, 0, 0); + return o; +} + +static void build_filled_tile(lv_obj_t *tile, int i) { + bool primary = s_slot[i].primary; + lv_color_t sig = primary ? current_theme.border_accent : lv_color_hex(COL_CYAN); + lv_color_t dot = primary ? lv_color_hex(COL_ACC2) : lv_color_hex(COL_CYAN); + + lv_obj_t *head = bare_box(tile, lv_pct(100), LV_SIZE_CONTENT); + lv_obj_set_flex_flow(head, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(head, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_set_style_pad_column(head, 5, 0); + + lv_obj_t *idx = lv_label_create(head); + lv_label_set_text_fmt(idx, "%d", i); + lv_obj_set_style_text_font(idx, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color( + idx, primary ? current_theme.border_accent : lv_color_hex(COL_DIM), 0); + + lv_obj_t *d = lv_obj_create(head); + lv_obj_remove_flag(d, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(d, DOT_SZ, DOT_SZ); + lv_obj_set_style_radius(d, LV_RADIUS_CIRCLE, 0); + lv_obj_set_style_border_width(d, 0, 0); + lv_obj_set_style_bg_color(d, dot, 0); + lv_obj_set_style_bg_opa(d, LV_OPA_COVER, 0); + if (primary) { + lv_obj_set_style_shadow_color(d, current_theme.border_accent, 0); + lv_obj_set_style_shadow_width(d, 5, 0); + lv_obj_set_style_shadow_opa(d, LV_OPA_COVER, 0); + } + + lv_obj_t *spacer = bare_box(head, 1, 1); + lv_obj_set_flex_grow(spacer, 1); + + lv_obj_t *key = lv_obj_create(head); + lv_obj_remove_flag(key, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(key, KEY_SZ, KEY_SZ); + lv_obj_set_style_radius(key, KEY_RAD, 0); + lv_obj_set_style_border_width(key, 0, 0); + lv_obj_set_style_bg_color(key, current_theme.border_accent, 0); + lv_obj_set_style_bg_opa(key, primary ? LV_OPA_COVER : LV_OPA_40, 0); + + lv_obj_t *name = lv_label_create(tile); + lv_label_set_long_mode(name, LV_LABEL_LONG_DOT); + lv_obj_set_width(name, lv_pct(100)); + lv_label_set_text(name, s_slot[i].name); + lv_obj_set_style_text_font(name, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(name, current_theme.text_main, 0); + + lv_obj_t *stub = lv_label_create(tile); + lv_label_set_text(stub, s_slot[i].role); + lv_obj_set_style_text_font(stub, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(stub, lv_color_hex(COL_DIM), 0); + + lv_obj_t *bars = bare_box(tile, lv_pct(100), BAR_MAX_H); + lv_obj_set_flex_flow(bars, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(bars, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_END, LV_FLEX_ALIGN_END); + lv_obj_set_style_pad_column(bars, BAR_GAP, 0); + for (int b = 0; b < BAR_CNT; b++) { + int h = s_slot[i].bars[b] * BAR_MAX_H / 100; + if (h < 3) + h = 3; + lv_obj_t *bar = lv_obj_create(bars); + lv_obj_remove_flag(bar, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(bar, BAR_W, h); + lv_obj_set_style_radius(bar, 1, 0); + lv_obj_set_style_border_width(bar, 0, 0); + lv_obj_set_style_bg_color(bar, sig, 0); + lv_obj_set_style_bg_opa(bar, LV_OPA_COVER, 0); + } +} + +static void build_empty_tile(lv_obj_t *tile, int i) { + (void)i; + lv_obj_set_flex_align(tile, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + + lv_obj_t *key = lv_obj_create(tile); + lv_obj_remove_flag(key, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(key, KEY_SZ + 4, KEY_SZ + 4); + lv_obj_set_style_radius(key, KEY_RAD, 0); + lv_obj_set_style_border_width(key, 1, 0); + lv_obj_set_style_border_color(key, lv_color_hex(COL_SLOT), 0); + lv_obj_set_style_bg_opa(key, LV_OPA_TRANSP, 0); + + lv_obj_t *lbl = lv_label_create(tile); + lv_label_set_text(lbl, "(empty)"); + lv_obj_set_style_text_font(lbl, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(lbl, lv_color_hex(COL_SLOT), 0); +} + +static lv_obj_t *make_tile(lv_obj_t *grid, int i) { + bool filled = s_slot[i].used; + + lv_obj_t *tile = lv_obj_create(grid); + lv_obj_remove_flag(tile, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_remove_flag(tile, LV_OBJ_FLAG_CLICKABLE); + lv_obj_set_size(tile, TILE_W, TILE_H); + lv_obj_set_style_radius(tile, TILE_RAD, 0); + lv_obj_set_style_pad_all(tile, TILE_PAD, 0); + lv_obj_set_style_pad_row(tile, TILE_GAP, 0); + lv_obj_set_style_border_width(tile, 1, 0); + lv_obj_set_style_bg_grad_dir(tile, LV_GRAD_DIR_NONE, 0); + lv_obj_set_flex_flow(tile, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(tile, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START); + + if (filled) { + lv_obj_set_style_bg_color(tile, current_theme.bg_secondary, 0); + lv_obj_set_style_bg_opa(tile, LV_OPA_COVER, 0); + build_filled_tile(tile, i); + } else { + lv_obj_set_style_bg_color(tile, current_theme.bg_primary, 0); + lv_obj_set_style_bg_opa(tile, LV_OPA_COVER, 0); + build_empty_tile(tile, i); + } + return tile; +} + +static void refresh_selection(void) { + for (int i = 0; i < TILE_CNT; i++) { + if (s_tiles[i] == NULL) + continue; + bool sel = (i == s_sel); + lv_color_t base = + s_slot[i].primary ? current_theme.border_accent : current_theme.border_inactive; + lv_obj_set_style_border_color(s_tiles[i], sel ? current_theme.border_accent : base, 0); + lv_obj_set_style_border_width(s_tiles[i], sel ? 2 : 1, 0); + lv_obj_set_style_shadow_color(s_tiles[i], current_theme.border_accent, 0); + lv_obj_set_style_shadow_width(s_tiles[i], sel ? GLOW_W : 0, 0); + lv_obj_set_style_shadow_opa(s_tiles[i], sel ? LV_OPA_40 : LV_OPA_TRANSP, 0); + lv_obj_set_style_shadow_spread(s_tiles[i], sel ? GLOW_SPR : 0, 0); + } +} + +static void build_grid_content(void) { + for (int i = 0; i < TILE_CNT; i++) + s_tiles[i] = NULL; + s_empty_msg = NULL; + if (s_grid != NULL) + lv_obj_clean(s_grid); + + if (s_proto == LORA_PROTO_NONE) { + s_empty_msg = lv_label_create(s_grid); + lv_obj_add_flag(s_empty_msg, LV_OBJ_FLAG_FLOATING); + lv_label_set_text(s_empty_msg, "Start a protocol first"); + lv_obj_set_style_text_font(s_empty_msg, &lv_font_montserrat_14, 0); + lv_obj_set_style_text_color(s_empty_msg, lv_color_hex(COL_DIM), 0); + lv_obj_center(s_empty_msg); + return; + } + + for (int i = 0; i < TILE_CNT; i++) + s_tiles[i] = make_tile(s_grid, i); + refresh_selection(); +} + +static void on_kb_rename(const char *text, void *user_data) { + int idx = (int)(intptr_t)user_data; + if (text == NULL || text[0] == '\0') + return; + if (idx < 0 || idx >= TILE_CNT) + return; + + lora_proto_t proto = lora_session_active(); + if (proto == LORA_PROTO_MESHTASTIC) { + const mt_channel_t *ch = mt_channel_get((uint8_t)idx); + uint8_t psk[MT_PSK_SIZE]; + mt_channel_role_t role; + if (ch != NULL && ch->is_used && ch->role != MT_CH_DISABLED) { + memcpy(psk, ch->psk, sizeof(psk)); + role = ch->role; + } else { + esp_fill_random(psk, sizeof(psk)); + role = MT_CH_SECONDARY; + } + mt_channel_set((uint8_t)idx, text, psk, role); + } else if (proto == LORA_PROTO_MESHCORE) { + const meshcore_channel_t *ch = meshcore_channel_get((uint8_t)idx); + uint8_t secret[KEY_LEN]; + if (ch != NULL && ch->is_used) { + memcpy(secret, ch->secret, sizeof(secret)); + } else { + esp_fill_random(secret, sizeof(secret)); + } + meshcore_channel_set((uint8_t)idx, text, secret); + } else { + return; + } + + ui_feedback(UI_FB_WRITE); + notify(NOTIFY_SAVED, "Channel saved"); + load_slots(); + build_grid_content(); +} + +static void disable_slot(int idx) { + if (idx < 0 || idx >= TILE_CNT) + return; + if (!s_slot[idx].used) { + ui_feedback(UI_FB_SELECT); + return; + } + if (s_proto == LORA_PROTO_MESHTASTIC) + mt_channel_disable((uint8_t)idx); + else if (s_proto == LORA_PROTO_MESHCORE) + meshcore_channel_set((uint8_t)idx, NULL, NULL); + else + return; + + ui_feedback(UI_FB_WRITE); + notify(NOTIFY_SAVED, "Channel disabled"); + load_slots(); + build_grid_content(); +} + +static void lora_channels_input(const input_event_t *ev, void *ctx) { + (void)ctx; + const bool press = (ev->action == INPUT_ACTION_PRESS); + const bool nav = press || (ev->action == INPUT_ACTION_REPEAT); + const bool active = (s_proto != LORA_PROTO_NONE); + + switch (ev->button) { + case INPUT_BTN_BACK: + case INPUT_BTN_LEFT: + if (press) + ui_switch_screen(SCREEN_LORA_CHAT); + break; + case INPUT_BTN_DOWN: + if (nav && active) { + s_sel = (s_sel + 1) % TILE_CNT; + refresh_selection(); + if (s_tiles[s_sel] != NULL) + lv_obj_scroll_to_view(s_tiles[s_sel], LV_ANIM_ON); + ui_feedback(UI_FB_NAV); + } + break; + case INPUT_BTN_UP: + if (nav && active) { + s_sel = (s_sel - 1 + TILE_CNT) % TILE_CNT; + refresh_selection(); + if (s_tiles[s_sel] != NULL) + lv_obj_scroll_to_view(s_tiles[s_sel], LV_ANIM_ON); + ui_feedback(UI_FB_NAV); + } + break; + case INPUT_BTN_OK: + if (press) { + if (active) { + ui_feedback(UI_FB_SELECT); + keyboard_open(NULL, on_kb_rename, (void *)(intptr_t)s_sel); + } else { + ui_feedback(UI_FB_SELECT); + } + } + break; + case INPUT_BTN_RIGHT: + if (press && active) + disable_slot(s_sel); + break; + default: + break; + } +} + +void ui_lora_channels_open(void) { + if (s_screen != NULL) { + lv_obj_del(s_screen); + s_screen = NULL; + } + s_grid = NULL; + s_empty_msg = NULL; + for (int i = 0; i < TILE_CNT; i++) + s_tiles[i] = NULL; + s_sel = 0; + + s_screen = lv_obj_create(NULL); + lv_obj_set_style_bg_color(s_screen, current_theme.screen_base, 0); + lv_obj_set_style_bg_opa(s_screen, LV_OPA_COVER, 0); + lv_obj_remove_flag(s_screen, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_border_width(s_screen, 0, 0); + lv_obj_set_style_pad_all(s_screen, 0, 0); + + ui_chrome_header(s_screen, HDR_TITLE, HDR_ICON); + + lv_obj_t *grid = lv_obj_create(s_screen); + lv_obj_set_size(grid, LCD_H_RES, BODY_H); + lv_obj_align(grid, LV_ALIGN_TOP_MID, 0, BODY_TOP); + lv_obj_set_style_bg_opa(grid, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(grid, 0, 0); + lv_obj_set_style_pad_all(grid, GRID_PAD, 0); + lv_obj_set_style_pad_row(grid, GRID_GAP, 0); + lv_obj_set_style_pad_column(grid, GRID_GAP, 0); + lv_obj_set_flex_flow(grid, LV_FLEX_FLOW_ROW_WRAP); + lv_obj_set_flex_align(grid, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START); + lv_obj_set_scroll_dir(grid, LV_DIR_VER); + lv_obj_set_scrollbar_mode(grid, LV_SCROLLBAR_MODE_AUTO); + lv_obj_remove_flag(grid, LV_OBJ_FLAG_SCROLL_ELASTIC); + lv_obj_remove_flag(grid, LV_OBJ_FLAG_SCROLL_MOMENTUM); + lv_obj_set_style_bg_color(grid, current_theme.border_accent, LV_PART_SCROLLBAR); + lv_obj_set_style_bg_opa(grid, LV_OPA_COVER, LV_PART_SCROLLBAR); + lv_obj_set_style_width(grid, 4, LV_PART_SCROLLBAR); + lv_obj_set_style_radius(grid, 2, LV_PART_SCROLLBAR); + s_grid = grid; + + load_slots(); + build_grid_content(); + + ui_chrome_footer(s_screen, FOOTER_HINT); + + ui_input_set_screen_handler(lora_channels_input, NULL); + + ui_screen_load_owned(&s_screen, s_screen); +} diff --git a/firmware_p4/components/Applications/ui/screens/lora/lora_chat_ui.c b/firmware_p4/components/Applications/ui/screens/lora/lora_chat_ui.c new file mode 100644 index 000000000..d4a65826d --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/lora/lora_chat_ui.c @@ -0,0 +1,1267 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#include "lora_chat_ui.h" + +#include +#include +#include + +#include "esp_log.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" + +#include "assets_manager.h" +#include "keyboard_ui.h" +#include "lora_session.h" +#include "menu_component_ui.h" +#include "meshtastic_presets.h" +#include "meshtastic_regions.h" +#include "meshtastic_roles.h" +#include "notify_ui.h" +#include "st7789.h" +#include "sys_prio.h" +#include "ui_chrome.h" +#include "ui_feedback.h" +#include "ui_manager.h" +#include "ui_theme.h" +#include "waves_ui.h" + +static const char *TAG = "LORA_MESH"; + +#define CONNECT_POLL_MS 1000 +#define CHAT_POLL_MS 500 +#define CHAT_BATCH 8 +#define LORA_START_TASK_STACK 12288 +#define SIG_GREEN 0x00E676 +#define COL_DIM 0x8A8594 +#define ENTRY_MS 220 + +#define BADGE_SIZE 28 +#define BADGE_ICON_PX 18 + +#define DOTS_STEP_MS 340 +#define DOTS_MAX 3 + +#define BUBBLE_MAX_W 184 + +#define LORA_AMBER 0xF5B13D +#define MAP_CENTER_X 120 +#define MAP_CENTER_Y 150 +#define YOU_PIN_SZ 32 +#define MESH_CAP_Y 46 +#define MESH_INFO_W 210 +#define MESH_INFO_H 36 +#define MESH_INFO_BOT 26 +#define MESH_GLOW_W 16 +#define SNR_RSSI_FLOOR -100 +#define SNR_RSSI_SPAN 70 +#define RSSI_STRONG -55 +#define RSSI_GOOD -70 + +#define LIST_ROW_H 44 +#define LIST_ROW_RADIUS 10 +#define LIST_ROW_PAD_H 10 +#define LIST_ROW_COL_GAP 10 +#define LIST_PAD_SIDE 8 +#define LIST_ROW_GAP 6 +#define LIST_SB_W 4 +#define LIST_SB_RADIUS 2 +#define LIST_ROW_GLOW_W 14 +#define LIST_SLOT_W 28 +#define LIST_DOT_SIZE 12 +#define LIST_DOT_GLOW_W 8 + +static const char *PROTOS[] = {"MeshCore", "Meshtastic"}; +#define PROTO_COUNT ((int)(sizeof(PROTOS) / sizeof(PROTOS[0]))) + +static const char *PROTO_ICONS[] = {"/assets/icons/hub.bin", "/assets/icons/lan.bin"}; +static const char *PROTO_BANDS[] = {"868 MHz", "915 MHz"}; + +#define NODE_COUNT 5 + +typedef struct { + char name[32]; + int rssi; + bool strong; +} node_row_t; + +static node_row_t s_nodes[NODE_COUNT]; +static int s_rnode_count = 0; + +static const struct { + int x; + int y; +} NODE_POS[NODE_COUNT] = { + {62, 90}, + {178, 94}, + {56, 206}, + {186, 198}, + {120, 234}, +}; + +enum { CFG_REGION = 0, CFG_PRESET, CFG_ROUTER, CFG_COUNT }; + +typedef enum { + VIEW_PROTO = 0, + VIEW_HOME, + VIEW_CONNECT, + VIEW_NODES, + VIEW_CHAT, + VIEW_CONFIGS, +} view_t; + +static lv_obj_t *s_screen = NULL; +static menu_component_t s_menu; +static lv_obj_t *s_chat_list = NULL; +static lv_obj_t *s_status_label = NULL; +static lv_obj_t *s_hint = NULL; +static lv_timer_t *s_connect_timer = NULL; +static lv_timer_t *s_chat_poll = NULL; +static view_t s_view = VIEW_PROTO; +static int s_proto = 0; +static int s_home_sel = 0; +static int s_node = 0; +static bool s_linked = false; +static bool s_starting = false; +static int s_cfg_region = 0, s_cfg_preset = 0; +static uint32_t s_chat_seq = 0; + +static lv_obj_t *s_node_pins[NODE_COUNT]; +static lv_obj_t *s_node_names[NODE_COUNT]; +static lv_obj_t *s_info_name = NULL; +static lv_obj_t *s_info_meta = NULL; +static lv_obj_t *s_info_bar = NULL; +static lv_point_precise_t s_link_pts[NODE_COUNT][2]; + +static lv_obj_t *s_node_rows[NODE_COUNT]; +static lv_obj_t *s_list_name[NODE_COUNT]; +static lv_obj_t *s_list_val[NODE_COUNT]; +static lv_obj_t *s_node_list = NULL; +static bool s_nodes_list = false; +static bool s_nodes_ok_fired = false; +static bool s_nodes_ok_active = false; + +static void lora_chat_input(const input_event_t *ev, void *ctx); +static void build_screen(void); +static void add_bubble(bool outgoing, const char *who, const char *text); + +static void stop_timers(void) { + if (s_connect_timer != NULL) { + lv_timer_delete(s_connect_timer); + s_connect_timer = NULL; + } + if (s_chat_poll != NULL) { + lv_timer_delete(s_chat_poll); + s_chat_poll = NULL; + } +} + +static lora_proto_t proto_for_sel(int sel) { + return (sel == 0) ? LORA_PROTO_MESHCORE : LORA_PROTO_MESHTASTIC; +} + +static int sel_for_proto(lora_proto_t proto) { + return (proto == LORA_PROTO_MESHTASTIC) ? 1 : 0; +} + +static void lora_start_task(void *pv) { + lora_proto_t proto = (lora_proto_t)(intptr_t)pv; + esp_err_t err = lora_session_start(proto); + if (err != ESP_OK) + ESP_LOGE(TAG, "start proto %d: %s", (int)proto, esp_err_to_name(err)); + s_starting = false; + vTaskDelete(NULL); +} + +static void start_active_proto(lora_proto_t proto) { + if (s_starting || lora_session_active() != LORA_PROTO_NONE) + return; + s_starting = true; + if (xTaskCreatePinnedToCore(lora_start_task, + "lora_start", + LORA_START_TASK_STACK, + (void *)(intptr_t)proto, + SYS_PRIO_BACKGROUND, + NULL, + SYS_CORE_RADIO) != pdPASS) { + s_starting = false; + ESP_LOGE(TAG, "failed to spawn start task"); + } +} + +static void refresh_nodes(void) { + uint16_t total = lora_session_node_count(); + int n = 0; + for (uint16_t i = 0; i < total && n < NODE_COUNT; i++) { + lora_node_t nd; + if (!lora_session_node_get(i, &nd)) + continue; + snprintf( + s_nodes[n].name, sizeof(s_nodes[n].name), "%s", (nd.name[0] != '\0') ? nd.name : "node"); + s_nodes[n].rssi = nd.rssi; + s_nodes[n].strong = (nd.rssi == 0) || (nd.rssi >= RSSI_GOOD); + n++; + } + s_rnode_count = n; + if (s_node >= s_rnode_count) + s_node = (s_rnode_count > 0) ? s_rnode_count - 1 : 0; +} + +static void fmt_rssi(int rssi, char *buf, size_t n) { + if (rssi == 0) + snprintf(buf, n, "-- dBm"); + else + snprintf(buf, n, "%d dBm", rssi); +} + +static void transy_cb(void *var, int32_t v) { + lv_obj_set_style_translate_y((lv_obj_t *)var, v, 0); +} + +static void card_rise(lv_obj_t *o) { + lv_anim_t a; + lv_anim_init(&a); + lv_anim_set_var(&a, o); + lv_anim_set_exec_cb(&a, transy_cb); + lv_anim_set_values(&a, 26, 0); + lv_anim_set_duration(&a, 300); + lv_anim_set_path_cb(&a, lv_anim_path_ease_out); + lv_anim_start(&a); +} + +static lv_obj_t *lit_panel(lv_obj_t *parent, int w, int h, lv_color_t accent) { + lv_obj_t *p = lv_obj_create(parent); + lv_obj_remove_flag(p, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(p, w, h); + lv_obj_set_style_radius(p, 13, 0); + lv_obj_set_style_bg_color(p, current_theme.bg_secondary, 0); + lv_obj_set_style_bg_opa(p, LV_OPA_COVER, 0); + lv_obj_set_style_bg_grad_dir(p, LV_GRAD_DIR_NONE, 0); + lv_obj_set_style_border_width(p, 1, 0); + lv_obj_set_style_border_color(p, accent, 0); + lv_obj_set_style_shadow_color(p, accent, 0); + lv_obj_set_style_shadow_width(p, 14, 0); + lv_obj_set_style_shadow_opa(p, LV_OPA_40, 0); + lv_obj_set_style_shadow_spread(p, -3, 0); + return p; +} + +static lv_obj_t *make_badge(lv_obj_t *parent, const char *path, lv_color_t accent) { + lv_obj_t *badge = lv_obj_create(parent); + lv_obj_remove_flag(badge, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(badge, BADGE_SIZE, BADGE_SIZE); + lv_obj_set_style_radius(badge, 8, 0); + lv_obj_set_style_pad_all(badge, 0, 0); + lv_obj_set_style_bg_color(badge, current_theme.bg_primary, 0); + lv_obj_set_style_bg_opa(badge, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(badge, 1, 0); + lv_obj_set_style_border_color(badge, accent, 0); + + lv_image_dsc_t *dsc = assets_get(path); + if (dsc != NULL) { + lv_obj_t *img = lv_image_create(badge); + lv_image_set_src(img, dsc); + int32_t longest = dsc->header.w > dsc->header.h ? dsc->header.w : dsc->header.h; + if (longest > 0) + lv_image_set_scale(img, BADGE_ICON_PX * 256 / longest); + lv_obj_set_style_image_recolor(img, current_theme.text_main, 0); + lv_obj_set_style_image_recolor_opa(img, LV_OPA_COVER, 0); + lv_obj_center(img); + } + return badge; +} + +static void build_proto_view(void) { + s_menu = menu_component_create(s_screen, "SELECT NETWORK", "/assets/icons/lan.bin"); + menu_component_add_section(&s_menu, "MESH PROTOCOL"); + for (int i = 0; i < PROTO_COUNT; i++) { + char label[48]; + snprintf(label, sizeof(label), "%s %s", PROTOS[i], PROTO_BANDS[i]); + menu_component_add_item(&s_menu, PROTO_ICONS[i], label); + } + menu_component_select(&s_menu, s_proto); + menu_component_set_hint(&s_menu, LV_SYMBOL_UP LV_SYMBOL_DOWN " choose OK enter BACK exit"); +} + +static void build_home_view(void) { + s_menu = menu_component_create(s_screen, PROTOS[s_proto], "/assets/icons/hub.bin"); + + if (lora_session_active() == LORA_PROTO_MESHTASTIC) { + const mt_region_info_t *rg = mt_region_info(mt_region_current()); + const mt_preset_info_t *pr = mt_preset_info(mt_preset_current()); + char band[64]; + snprintf( + band, sizeof(band), "%s %s", (rg != NULL) ? rg->name : "?", (pr != NULL) ? pr->name : "?"); + menu_component_add_section(&s_menu, band); + } + menu_component_add_section(&s_menu, s_linked ? "[ MESH ONLINE ]" : "[ STANDALONE ]"); + + menu_component_add_item(&s_menu, "/assets/icons/bluetooth.bin", "Connect App"); + menu_component_add_item(&s_menu, "/assets/icons/hub.bin", "Nodes"); + menu_component_add_item(&s_menu, "/assets/icons/tune.bin", "Configs"); + menu_component_add_item(&s_menu, "/assets/icons/swap_horiz.bin", "Channels"); + menu_component_add_item(&s_menu, "/assets/icons/sensors.bin", "Position"); + menu_component_add_item(&s_menu, "/assets/icons/monitoring.bin", "Telemetry"); + menu_component_add_item(&s_menu, "/assets/icons/vpn_key.bin", "Secure DM"); + menu_component_add_item(&s_menu, "/assets/icons/lan.bin", "Traceroute"); + + if (s_linked) + menu_component_set_item_label_color(&s_menu, 0, lv_color_hex(SIG_GREEN)); + + menu_component_select(&s_menu, s_home_sel); + menu_component_set_hint(&s_menu, + LV_SYMBOL_UP LV_SYMBOL_DOWN " open OK enter BACK protocols"); +} + +static void link_style(int i, lv_color_t *col, int *w, bool *dashed) { + if (!s_nodes[i].strong) { + *col = lv_color_hex(COL_DIM); + *w = 1; + *dashed = true; + } else if (s_nodes[i].rssi >= RSSI_STRONG) { + *col = lv_color_hex(SIG_GREEN); + *w = 3; + *dashed = false; + } else if (s_nodes[i].rssi >= RSSI_GOOD) { + *col = lv_color_hex(SIG_GREEN); + *w = 2; + *dashed = false; + } else { + *col = lv_color_hex(LORA_AMBER); + *w = 1; + *dashed = false; + } +} + +static void node_style_pin(int i, bool selected) { + lv_obj_t *pin = s_node_pins[i]; + if (pin == NULL) + return; + bool online = s_nodes[i].strong; + lv_color_t edge = selected ? current_theme.border_accent + : (online ? lv_color_hex(SIG_GREEN) : lv_color_hex(COL_DIM)); + lv_obj_set_style_border_color(pin, edge, 0); + lv_obj_set_style_border_width(pin, selected ? 2 : 1, 0); + lv_obj_set_style_opa(pin, (online || selected) ? LV_OPA_COVER : LV_OPA_50, 0); + if (selected) { + lv_obj_set_style_shadow_color(pin, edge, 0); + lv_obj_set_style_shadow_width(pin, MESH_GLOW_W, 0); + lv_obj_set_style_shadow_opa(pin, LV_OPA_40, 0); + lv_obj_set_style_shadow_spread(pin, -2, 0); + } else { + lv_obj_set_style_shadow_width(pin, 0, 0); + lv_obj_set_style_shadow_opa(pin, LV_OPA_TRANSP, 0); + } + if (s_node_names[i] != NULL) + lv_obj_set_style_text_color(s_node_names[i], + selected + ? current_theme.border_accent + : (online ? current_theme.text_main : lv_color_hex(COL_DIM)), + 0); +} + +static void node_update_info(int i) { + if (i >= s_rnode_count) + return; + bool online = s_nodes[i].strong; + if (s_info_name != NULL) { + lv_label_set_text(s_info_name, s_nodes[i].name); + lv_obj_set_style_text_color( + s_info_name, online ? current_theme.text_main : lv_color_hex(COL_DIM), 0); + } + if (s_info_meta != NULL) { + char meta[40]; + char rssi_s[16]; + fmt_rssi(s_nodes[i].rssi, rssi_s, sizeof(rssi_s)); + snprintf(meta, sizeof(meta), "%s \xC2\xB7 %s", rssi_s, online ? "ONLINE" : "OFFLINE"); + lv_label_set_text(s_info_meta, meta); + lv_obj_set_style_text_color( + s_info_meta, online ? lv_color_hex(SIG_GREEN) : lv_color_hex(COL_DIM), 0); + } + if (s_info_bar != NULL) { + int pct = (s_nodes[i].rssi - SNR_RSSI_FLOOR) * 100 / SNR_RSSI_SPAN; + if (pct < 5) + pct = 5; + if (pct > 100) + pct = 100; + lv_bar_set_value(s_info_bar, pct, LV_ANIM_OFF); + lv_color_t col; + int w; + bool dashed; + link_style(i, &col, &w, &dashed); + (void)w; + (void)dashed; + lv_obj_set_style_bg_color(s_info_bar, col, LV_PART_INDICATOR); + } +} + +static void node_style_row(int i, bool selected) { + lv_obj_t *row = s_node_rows[i]; + if (row == NULL) + return; + lv_obj_set_style_bg_color( + row, selected ? current_theme.bg_secondary : current_theme.bg_primary, 0); + lv_obj_set_style_bg_opa(row, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(row, 1, 0); + lv_obj_set_style_border_color( + row, selected ? current_theme.border_accent : current_theme.border_inactive, 0); + if (selected) { + lv_obj_set_style_shadow_color(row, current_theme.border_accent, 0); + lv_obj_set_style_shadow_width(row, LIST_ROW_GLOW_W, 0); + lv_obj_set_style_shadow_opa(row, LV_OPA_40, 0); + lv_obj_set_style_shadow_spread(row, -2, 0); + } else { + lv_obj_set_style_shadow_width(row, 0, 0); + lv_obj_set_style_shadow_opa(row, LV_OPA_TRANSP, 0); + } + if (s_list_name[i] != NULL) + lv_obj_set_style_text_color( + s_list_name[i], selected ? current_theme.text_main : lv_color_hex(COL_DIM), 0); + if (s_list_val[i] != NULL) + lv_obj_set_style_text_color( + s_list_val[i], selected ? current_theme.border_accent : lv_color_hex(COL_DIM), 0); +} + +static void node_select(int sel) { + if (s_nodes_list) { + for (int i = 0; i < NODE_COUNT; i++) + node_style_row(i, i == sel); + if (s_node_list != NULL && s_node_rows[sel] != NULL) { + lv_obj_update_layout(s_node_list); + lv_obj_scroll_to_view(s_node_rows[sel], LV_ANIM_ON); + } + return; + } + for (int i = 0; i < NODE_COUNT; i++) + node_style_pin(i, i == sel); + node_update_info(sel); +} + +static void build_nodes_list(void) { + int online = 0; + for (int i = 0; i < s_rnode_count; i++) + if (s_nodes[i].strong) + online++; + + s_node_list = lv_obj_create(s_screen); + lv_obj_set_size(s_node_list, lv_pct(100), LCD_V_RES - UI_CHROME_HEADER_H - UI_CHROME_FOOTER_H); + lv_obj_align(s_node_list, LV_ALIGN_TOP_MID, 0, UI_CHROME_HEADER_H); + lv_obj_set_style_bg_opa(s_node_list, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(s_node_list, 0, 0); + lv_obj_set_style_pad_all(s_node_list, 0, 0); + lv_obj_set_style_pad_left(s_node_list, LIST_PAD_SIDE, 0); + lv_obj_set_style_pad_right(s_node_list, LIST_PAD_SIDE, 0); + lv_obj_set_style_pad_row(s_node_list, LIST_ROW_GAP, 0); + lv_obj_set_flex_flow(s_node_list, LV_FLEX_FLOW_COLUMN); + lv_obj_set_scroll_dir(s_node_list, LV_DIR_VER); + lv_obj_set_scrollbar_mode(s_node_list, LV_SCROLLBAR_MODE_ON); + lv_obj_remove_flag(s_node_list, LV_OBJ_FLAG_SCROLL_ELASTIC); + lv_obj_remove_flag(s_node_list, LV_OBJ_FLAG_SCROLL_MOMENTUM); + lv_obj_set_style_bg_color(s_node_list, current_theme.border_accent, LV_PART_SCROLLBAR); + lv_obj_set_style_bg_opa(s_node_list, LV_OPA_COVER, LV_PART_SCROLLBAR); + lv_obj_set_style_width(s_node_list, LIST_SB_W, LV_PART_SCROLLBAR); + lv_obj_set_style_radius(s_node_list, LIST_SB_RADIUS, LV_PART_SCROLLBAR); + + lv_obj_t *cap = lv_label_create(s_node_list); + lv_label_set_text_fmt(cap, "%d / %d s_nodes ONLINE", online, s_rnode_count); + lv_obj_set_style_text_color(cap, lv_color_hex(COL_DIM), 0); + lv_obj_set_style_text_font(cap, &lv_font_montserrat_12, 0); + + if (s_rnode_count == 0) { + lv_obj_t *empty = lv_label_create(s_node_list); + lv_label_set_text(empty, "Listening for nodes..."); + lv_obj_set_style_text_color(empty, lv_color_hex(COL_DIM), 0); + lv_obj_set_style_text_font(empty, &lv_font_montserrat_14, 0); + } + + for (int i = 0; i < s_rnode_count; i++) { + bool node_online = s_nodes[i].strong; + + lv_obj_t *row = lv_obj_create(s_node_list); + lv_obj_remove_flag(row, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_remove_flag(row, LV_OBJ_FLAG_CLICKABLE); + lv_obj_set_width(row, lv_pct(100)); + lv_obj_set_height(row, LIST_ROW_H); + lv_obj_set_style_radius(row, LIST_ROW_RADIUS, 0); + lv_obj_set_style_pad_hor(row, LIST_ROW_PAD_H, 0); + lv_obj_set_style_pad_ver(row, 0, 0); + lv_obj_set_style_pad_column(row, LIST_ROW_COL_GAP, 0); + lv_obj_set_style_bg_grad_dir(row, LV_GRAD_DIR_NONE, 0); + 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_t *slot = lv_obj_create(row); + lv_obj_remove_flag(slot, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(slot, LIST_SLOT_W, LIST_SLOT_W); + lv_obj_set_style_bg_opa(slot, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(slot, 0, 0); + lv_obj_set_style_pad_all(slot, 0, 0); + + lv_obj_t *dot = lv_obj_create(slot); + lv_obj_remove_flag(dot, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(dot, LIST_DOT_SIZE, LIST_DOT_SIZE); + lv_obj_center(dot); + lv_obj_set_style_radius(dot, LV_RADIUS_CIRCLE, 0); + lv_obj_set_style_border_width(dot, 0, 0); + lv_obj_set_style_bg_color( + dot, node_online ? lv_color_hex(SIG_GREEN) : lv_color_hex(COL_DIM), 0); + lv_obj_set_style_bg_opa(dot, LV_OPA_COVER, 0); + if (node_online) { + lv_obj_set_style_shadow_color(dot, lv_color_hex(SIG_GREEN), 0); + lv_obj_set_style_shadow_width(dot, LIST_DOT_GLOW_W, 0); + lv_obj_set_style_shadow_opa(dot, LV_OPA_50, 0); + } + + lv_obj_t *nm = lv_label_create(row); + lv_obj_set_flex_grow(nm, 1); + lv_label_set_long_mode(nm, LV_LABEL_LONG_SCROLL_CIRCULAR); + lv_label_set_text(nm, s_nodes[i].name); + lv_obj_set_style_text_font(nm, &lv_font_montserrat_14, 0); + s_list_name[i] = nm; + + char val[16]; + fmt_rssi(s_nodes[i].rssi, val, sizeof(val)); + lv_obj_t *rssi = lv_label_create(row); + lv_label_set_text(rssi, val); + lv_obj_set_style_text_font(rssi, &lv_font_montserrat_12, 0); + s_list_val[i] = rssi; + + s_node_rows[i] = row; + } + + node_select(s_node); +} + +static void build_nodes_view(void) { + lv_color_t accent = ui_theme_get_accent(); + ui_chrome_header(s_screen, "s_nodes", "/assets/icons/hub.bin"); + + refresh_nodes(); + + if (s_node < 0) + s_node = 0; + + s_nodes_ok_fired = false; + s_nodes_ok_active = false; + + if (s_nodes_list) { + build_nodes_list(); + ui_chrome_footer(s_screen, LV_SYMBOL_UP LV_SYMBOL_DOWN " hop OK chat HOLD map"); + return; + } + + int online = 0; + for (int i = 0; i < s_rnode_count; i++) + if (s_nodes[i].strong) + online++; + lv_obj_t *cap = lv_label_create(s_screen); + lv_label_set_text_fmt(cap, "%d / %d s_nodes ONLINE", online, s_rnode_count); + lv_obj_set_style_text_color(cap, lv_color_hex(COL_DIM), 0); + lv_obj_set_style_text_font(cap, &lv_font_montserrat_12, 0); + lv_obj_align(cap, LV_ALIGN_TOP_MID, 0, MESH_CAP_Y); + + for (int i = 0; i < s_rnode_count; i++) { + lv_color_t col; + int w; + bool dashed; + link_style(i, &col, &w, &dashed); + s_link_pts[i][0].x = MAP_CENTER_X; + s_link_pts[i][0].y = MAP_CENTER_Y; + s_link_pts[i][1].x = NODE_POS[i].x; + s_link_pts[i][1].y = NODE_POS[i].y; + lv_obj_t *ln = lv_line_create(s_screen); + lv_obj_align(ln, LV_ALIGN_TOP_LEFT, 0, 0); + lv_line_set_points(ln, s_link_pts[i], 2); + lv_obj_set_style_line_width(ln, w, 0); + lv_obj_set_style_line_color(ln, col, 0); + lv_obj_set_style_line_opa(ln, dashed ? LV_OPA_40 : LV_OPA_COVER, 0); + lv_obj_set_style_line_rounded(ln, true, 0); + } + + for (int i = 0; i < s_rnode_count; i++) { + lv_obj_t *wrap = lv_obj_create(s_screen); + lv_obj_remove_flag(wrap, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(wrap, LV_SIZE_CONTENT, LV_SIZE_CONTENT); + lv_obj_set_style_bg_opa(wrap, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(wrap, 0, 0); + lv_obj_set_style_pad_all(wrap, 0, 0); + lv_obj_set_style_pad_row(wrap, 1, 0); + lv_obj_set_flex_flow(wrap, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(wrap, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_align( + wrap, LV_ALIGN_CENTER, NODE_POS[i].x - LCD_H_RES / 2, NODE_POS[i].y - LCD_V_RES / 2); + + s_node_pins[i] = make_badge(wrap, "/assets/icons/settings_input_antenna.bin", accent); + + lv_obj_t *nm = lv_label_create(wrap); + lv_label_set_text(nm, s_nodes[i].name); + lv_obj_set_style_text_font(nm, &lv_font_montserrat_12, 0); + s_node_names[i] = nm; + + node_style_pin(i, i == s_node); + } + + lv_obj_t *you_wrap = lv_obj_create(s_screen); + lv_obj_remove_flag(you_wrap, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(you_wrap, LV_SIZE_CONTENT, LV_SIZE_CONTENT); + lv_obj_set_style_bg_opa(you_wrap, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(you_wrap, 0, 0); + lv_obj_set_style_pad_all(you_wrap, 0, 0); + lv_obj_set_style_pad_row(you_wrap, 1, 0); + lv_obj_set_flex_flow(you_wrap, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(you_wrap, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_align( + you_wrap, LV_ALIGN_CENTER, MAP_CENTER_X - LCD_H_RES / 2, MAP_CENTER_Y - LCD_V_RES / 2); + + lv_obj_t *you_pin = make_badge(you_wrap, "/assets/icons/hub.bin", accent); + lv_obj_set_size(you_pin, YOU_PIN_SZ, YOU_PIN_SZ); + lv_obj_set_style_bg_color(you_pin, accent, 0); + lv_obj_set_style_border_width(you_pin, 2, 0); + + lv_obj_t *you_lbl = lv_label_create(you_wrap); + lv_label_set_text(you_lbl, "YOU"); + lv_obj_set_style_text_color(you_lbl, current_theme.text_main, 0); + lv_obj_set_style_text_font(you_lbl, &lv_font_montserrat_12, 0); + + lv_obj_t *info = lit_panel(s_screen, MESH_INFO_W, MESH_INFO_H, accent); + lv_obj_align(info, LV_ALIGN_BOTTOM_MID, 0, -MESH_INFO_BOT); + lv_obj_set_style_pad_all(info, 6, 0); + + s_info_name = lv_label_create(info); + lv_obj_set_style_text_font(s_info_name, &lv_font_montserrat_12, 0); + lv_obj_align(s_info_name, LV_ALIGN_TOP_LEFT, 0, 0); + + s_info_meta = lv_label_create(info); + lv_obj_set_style_text_font(s_info_meta, &lv_font_montserrat_12, 0); + lv_obj_align(s_info_meta, LV_ALIGN_TOP_RIGHT, 0, 0); + + s_info_bar = lv_bar_create(info); + lv_obj_set_size(s_info_bar, MESH_INFO_W - 20, 5); + lv_obj_align(s_info_bar, LV_ALIGN_BOTTOM_MID, 0, 0); + lv_bar_set_range(s_info_bar, 0, 100); + lv_obj_set_style_bg_color(s_info_bar, current_theme.bg_primary, LV_PART_MAIN); + lv_obj_set_style_bg_opa(s_info_bar, LV_OPA_COVER, LV_PART_MAIN); + lv_obj_set_style_radius(s_info_bar, 2, LV_PART_MAIN); + lv_obj_set_style_radius(s_info_bar, 2, LV_PART_INDICATOR); + lv_obj_set_style_bg_opa(s_info_bar, LV_OPA_COVER, LV_PART_INDICATOR); + + if (s_rnode_count == 0) { + if (s_info_name != NULL) + lv_label_set_text(s_info_name, "Listening for nodes..."); + if (s_info_meta != NULL) + lv_label_set_text(s_info_meta, ""); + } else { + node_update_info(s_node); + } + + ui_chrome_footer(s_screen, LV_SYMBOL_UP LV_SYMBOL_DOWN " hop OK chat HOLD list"); +} + +static void build_configs_view(void) { + s_menu = menu_component_create(s_screen, "CONFIGS", "/assets/icons/tune.bin"); + + if (lora_session_active() != LORA_PROTO_MESHTASTIC) { + menu_component_add_section(&s_menu, "RADIO PARAMETERS"); + menu_component_add_section(&s_menu, "Meshtastic only"); + menu_component_set_hint(&s_menu, "BACK home"); + return; + } + + s_cfg_region = (int)mt_region_current(); + s_cfg_preset = (int)mt_preset_current(); + const mt_region_info_t *rg = mt_region_info((mt_region_t)s_cfg_region); + const mt_preset_info_t *pr = mt_preset_info((mt_preset_t)s_cfg_preset); + bool router = (mt_role_current() == MT_ROLE_ROUTER); + + menu_component_add_section(&s_menu, "RADIO (applies on reboot)"); + menu_component_add_selector( + &s_menu, "/assets/icons/public.bin", "Region", (rg != NULL) ? rg->name : "?"); + menu_component_add_selector( + &s_menu, "/assets/icons/speed.bin", "Preset", (pr != NULL) ? pr->name : "?"); + menu_component_add_toggle(&s_menu, "/assets/icons/router.bin", "Router mode", router); + menu_component_set_hint(&s_menu, + LV_SYMBOL_LEFT LV_SYMBOL_RIGHT " change OK toggle BACK home"); +} + +static lv_obj_t *make_companion_card(lv_obj_t *parent, bool linked, lv_color_t accent) { + lv_color_t edge = linked ? lv_color_hex(SIG_GREEN) : accent; + lv_obj_t *card = lit_panel(parent, 200, 60, edge); + lv_obj_set_style_pad_all(card, 8, 0); + lv_obj_set_flex_flow(card, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(card, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_set_style_pad_column(card, 8, 0); + + make_badge(card, "/assets/icons/bluetooth.bin", edge); + + lv_obj_t *txt = lv_obj_create(card); + lv_obj_remove_flag(txt, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(txt, LV_SIZE_CONTENT, LV_SIZE_CONTENT); + lv_obj_set_style_bg_opa(txt, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(txt, 0, 0); + lv_obj_set_style_pad_all(txt, 0, 0); + lv_obj_set_flex_flow(txt, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_grow(txt, 1); + + lv_obj_t *nm = lv_label_create(txt); + lv_label_set_text(nm, "HighBoy Companion"); + lv_obj_set_style_text_color(nm, current_theme.text_main, 0); + lv_obj_set_style_text_font(nm, &lv_font_montserrat_14, 0); + + lv_obj_t *sub = lv_label_create(txt); + lv_label_set_text(sub, linked ? "Linked \xC2\xB7 BLE" : "v1.2 \xC2\xB7 BLE"); + lv_obj_set_style_text_color(sub, linked ? lv_color_hex(SIG_GREEN) : lv_color_hex(COL_DIM), 0); + lv_obj_set_style_text_font(sub, &lv_font_montserrat_12, 0); + + if (linked) { + lv_obj_t *chk = lv_label_create(card); + lv_label_set_text(chk, LV_SYMBOL_OK); + lv_obj_set_style_text_color(chk, lv_color_hex(SIG_GREEN), 0); + lv_obj_set_style_text_font(chk, &lv_font_montserrat_14, 0); + } + return card; +} + +static void conn_dots_cb(void *var, int32_t v) { + lv_obj_t *label = (lv_obj_t *)var; + static const char *DOTS[] = {"", ".", "..", "..."}; + int n = (int)v; + if (n < 0) + n = 0; + if (n > DOTS_MAX) + n = DOTS_MAX; + char buf[40]; + snprintf(buf, sizeof(buf), "Waiting for app%s", DOTS[n]); + lv_label_set_text(label, buf); +} + +static void connect_poll_cb(lv_timer_t *t) { + if (lv_screen_active() != s_screen || s_view != VIEW_CONNECT) { + lv_timer_delete(t); + s_connect_timer = NULL; + return; + } + bool connected = lora_session_app_connected(); + if (connected != s_linked) { + s_linked = connected; + if (connected) { + ui_feedback(UI_FB_EMULATE); + notify(NOTIFY_LORA, "Companion linked"); + } + build_screen(); + } +} + +static void build_connect(void) { + lv_color_t accent = ui_theme_get_accent(); + ui_chrome_header(s_screen, PROTOS[s_proto], "/assets/icons/bluetooth.bin"); + + s_linked = lora_session_app_connected(); + + if (!s_linked) { + lora_session_app_connect(); + + s_status_label = lv_label_create(s_screen); + lv_label_set_text(s_status_label, "Waiting for app"); + lv_obj_set_style_text_color(s_status_label, current_theme.text_main, 0); + lv_obj_set_style_text_font(s_status_label, &lv_font_montserrat_14, 0); + lv_obj_align(s_status_label, LV_ALIGN_TOP_MID, 0, 52); + + lv_anim_t ad; + lv_anim_init(&ad); + lv_anim_set_var(&ad, s_status_label); + lv_anim_set_exec_cb(&ad, conn_dots_cb); + lv_anim_set_values(&ad, 0, DOTS_MAX); + lv_anim_set_duration(&ad, DOTS_STEP_MS * DOTS_MAX); + lv_anim_set_repeat_count(&ad, LV_ANIM_REPEAT_INFINITE); + lv_anim_start(&ad); + + waves_create( + s_screen, LV_ALIGN_CENTER, 0, -18, LV_SYMBOL_BLUETOOTH, "/assets/icons/bluetooth.bin"); + + lv_obj_t *card = make_companion_card(s_screen, false, accent); + lv_obj_align(card, LV_ALIGN_CENTER, 0, 86); + + s_connect_timer = lv_timer_create(connect_poll_cb, CONNECT_POLL_MS, NULL); + + ui_chrome_footer(s_screen, "BACK to cancel"); + } else { + lv_obj_t *st = lv_label_create(s_screen); + lv_label_set_text(st, "Companion linked!"); + lv_obj_set_style_text_color(st, lv_color_hex(SIG_GREEN), 0); + lv_obj_set_style_text_font(st, &lv_font_montserrat_14, 0); + lv_obj_align(st, LV_ALIGN_TOP_MID, 0, 52); + lv_obj_fade_in(st, ENTRY_MS, 0); + + lv_obj_t *card = make_companion_card(s_screen, true, accent); + lv_obj_align(card, LV_ALIGN_CENTER, 0, 6); + lv_obj_fade_in(card, ENTRY_MS, 0); + card_rise(card); + + ui_chrome_footer(s_screen, "BACK = Exit"); + } +} + +static void add_bubble(bool outgoing, const char *who, const char *text) { + if (s_chat_list == NULL) + return; + lv_color_t accent = ui_theme_get_accent(); + + lv_obj_t *row = lv_obj_create(s_chat_list); + lv_obj_set_width(row, LV_PCT(100)); + lv_obj_set_height(row, LV_SIZE_CONTENT); + lv_obj_set_style_bg_opa(row, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(row, 0, 0); + lv_obj_set_style_pad_all(row, 0, 0); + lv_obj_remove_flag(row, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_flex_flow(row, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(row, + outgoing ? LV_FLEX_ALIGN_END : LV_FLEX_ALIGN_START, + LV_FLEX_ALIGN_START, + LV_FLEX_ALIGN_START); + + lv_obj_t *group = lv_obj_create(row); + lv_obj_remove_flag(group, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(group, LV_SIZE_CONTENT, LV_SIZE_CONTENT); + lv_obj_set_style_bg_opa(group, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(group, 0, 0); + lv_obj_set_style_pad_all(group, 2, 0); + lv_obj_set_style_pad_row(group, 2, 0); + lv_obj_set_flex_flow(group, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(group, + LV_FLEX_ALIGN_START, + outgoing ? LV_FLEX_ALIGN_END : LV_FLEX_ALIGN_START, + LV_FLEX_ALIGN_START); + + lv_obj_t *bubble = lv_obj_create(group); + lv_obj_remove_flag(bubble, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(bubble, LV_SIZE_CONTENT, LV_SIZE_CONTENT); + lv_obj_set_style_max_width(bubble, BUBBLE_MAX_W, 0); + lv_obj_set_style_pad_all(bubble, 7, 0); + lv_obj_set_style_radius(bubble, 9, 0); + lv_obj_set_style_bg_opa(bubble, outgoing ? LV_OPA_20 : LV_OPA_COVER, 0); + lv_obj_set_style_bg_color(bubble, outgoing ? accent : current_theme.bg_secondary, 0); + lv_obj_set_style_bg_grad_dir(bubble, LV_GRAD_DIR_NONE, 0); + lv_obj_set_style_border_width(bubble, 1, 0); + lv_obj_set_style_border_color(bubble, outgoing ? accent : current_theme.border_inactive, 0); + lv_obj_set_flex_flow(bubble, LV_FLEX_FLOW_COLUMN); + lv_obj_set_style_pad_row(bubble, 2, 0); + + if (!outgoing && who != NULL) { + lv_obj_t *nm = lv_label_create(bubble); + char sender[48]; + snprintf(sender, sizeof(sender), "> %s", who); + lv_label_set_text(nm, sender); + lv_obj_set_style_text_color(nm, accent, 0); + lv_obj_set_style_text_font(nm, &lv_font_montserrat_12, 0); + } + + lv_obj_t *body = lv_label_create(bubble); + lv_label_set_long_mode(body, LV_LABEL_LONG_WRAP); + lv_obj_set_style_max_width(body, BUBBLE_MAX_W - 16, 0); + lv_label_set_text(body, text); + lv_obj_set_style_text_color(body, current_theme.text_main, 0); + lv_obj_set_style_text_font(body, &lv_font_montserrat_12, 0); + + lv_obj_scroll_to_view(bubble, LV_ANIM_ON); +} + +static void chat_drain(void) { + if (s_chat_list == NULL) + return; + lora_msg_t batch[CHAT_BATCH]; + uint16_t got; + while ((got = lora_session_msg_since(&s_chat_seq, batch, CHAT_BATCH)) > 0) { + for (uint16_t i = 0; i < got; i++) + add_bubble(batch[i].outgoing, batch[i].outgoing ? NULL : batch[i].who, batch[i].text); + } +} + +static void chat_poll_cb(lv_timer_t *t) { + if (lv_screen_active() != s_screen || s_view != VIEW_CHAT) { + lv_timer_delete(t); + s_chat_poll = NULL; + return; + } + chat_drain(); +} + +static void on_kb_submit(const char *text, void *user_data) { + (void)user_data; + if (text == NULL || text[0] == '\0' || s_view != VIEW_CHAT) + return; + esp_err_t err = lora_session_send_text(text); + if (err == ESP_OK) { + ui_feedback(UI_FB_WRITE); + chat_drain(); + } else { + notify(NOTIFY_LORA, "Send failed"); + } +} + +static void build_chat(void) { + const char *title = + (lora_session_active() == LORA_PROTO_MESHTASTIC) ? "MESH BROADCAST" : "PUBLIC CHANNEL"; + ui_chrome_header(s_screen, title, "/assets/icons/hub.bin"); + s_hint = ui_chrome_footer(s_screen, LV_SYMBOL_KEYBOARD " OK write BACK nodes"); + + s_chat_list = lv_obj_create(s_screen); + lv_obj_set_size(s_chat_list, LCD_H_RES, LCD_V_RES - UI_CHROME_HEADER_H - UI_CHROME_FOOTER_H); + lv_obj_align(s_chat_list, LV_ALIGN_TOP_LEFT, 0, UI_CHROME_HEADER_H); + lv_obj_set_style_bg_opa(s_chat_list, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(s_chat_list, 0, 0); + lv_obj_set_style_pad_all(s_chat_list, 6, 0); + lv_obj_set_style_pad_row(s_chat_list, 6, 0); + lv_obj_set_flex_flow(s_chat_list, LV_FLEX_FLOW_COLUMN); + lv_obj_set_scroll_dir(s_chat_list, LV_DIR_VER); + lv_obj_remove_flag(s_chat_list, LV_OBJ_FLAG_SCROLL_ELASTIC); + lv_obj_remove_flag(s_chat_list, LV_OBJ_FLAG_SCROLL_MOMENTUM); + lv_obj_set_scrollbar_mode(s_chat_list, LV_SCROLLBAR_MODE_AUTO); + + s_chat_seq = 0; + chat_drain(); + + s_chat_poll = lv_timer_create(chat_poll_cb, CHAT_POLL_MS, NULL); +} + +static void build_screen(void) { + stop_timers(); + if (s_screen != NULL) { + lv_obj_del(s_screen); + s_screen = NULL; + } + s_chat_list = NULL; + s_status_label = NULL; + s_hint = NULL; + s_info_name = NULL; + s_info_meta = NULL; + s_info_bar = NULL; + s_node_list = NULL; + for (int i = 0; i < NODE_COUNT; i++) { + s_node_pins[i] = NULL; + s_node_names[i] = NULL; + s_node_rows[i] = NULL; + s_list_name[i] = NULL; + s_list_val[i] = NULL; + } + + s_screen = lv_obj_create(NULL); + lv_obj_set_style_bg_color(s_screen, current_theme.screen_base, 0); + lv_obj_set_style_bg_opa(s_screen, LV_OPA_COVER, 0); + lv_obj_remove_flag(s_screen, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_border_width(s_screen, 0, 0); + lv_obj_set_style_pad_all(s_screen, 0, 0); + + lv_obj_t *fade_target = NULL; + + if (s_view == VIEW_PROTO) { + build_proto_view(); + fade_target = s_menu.items_cont; + } else if (s_view == VIEW_HOME) { + build_home_view(); + fade_target = s_menu.items_cont; + } else if (s_view == VIEW_NODES) { + build_nodes_view(); + fade_target = s_screen; + } else if (s_view == VIEW_CONFIGS) { + build_configs_view(); + fade_target = s_menu.items_cont; + } else if (s_view == VIEW_CONNECT) { + build_connect(); + fade_target = s_screen; + } else { + build_chat(); + fade_target = s_chat_list; + } + + if (fade_target) + lv_obj_fade_in(fade_target, ENTRY_MS, 0); + + ui_input_set_screen_handler(lora_chat_input, NULL); + + ui_screen_load_owned(&s_screen, s_screen); +} + +static void cycle_config(int sel, int dir) { + if (sel == CFG_REGION) { + int n = mt_region_count(); + if (n <= 0) + return; + s_cfg_region = (s_cfg_region + dir + n) % n; + mt_region_set((mt_region_t)s_cfg_region); + const mt_region_info_t *rg = mt_region_info((mt_region_t)s_cfg_region); + menu_component_set_selector_value(&s_menu, sel, (rg != NULL) ? rg->name : "?"); + } else if (sel == CFG_PRESET) { + int n = mt_preset_count(); + if (n <= 0) + return; + s_cfg_preset = (s_cfg_preset + dir + n) % n; + mt_preset_set((mt_preset_t)s_cfg_preset); + const mt_preset_info_t *pr = mt_preset_info((mt_preset_t)s_cfg_preset); + menu_component_set_selector_value(&s_menu, sel, (pr != NULL) ? pr->name : "?"); + } +} + +static void exit_to_menu(void) { + ui_theme_set_protocol(PROTOCOL_NONE); + ui_switch_screen(SCREEN_MENU); +} + +static void lora_chat_input(const input_event_t *ev, void *ctx) { + (void)ctx; + const bool press = (ev->action == INPUT_ACTION_PRESS); + const bool nav = press || (ev->action == INPUT_ACTION_REPEAT); + + switch (s_view) { + case VIEW_PROTO: + switch (ev->button) { + case INPUT_BTN_DOWN: + if (nav) + menu_component_next(&s_menu); + break; + case INPUT_BTN_UP: + if (nav) + menu_component_prev(&s_menu); + break; + case INPUT_BTN_OK: + if (press) { + s_proto = menu_component_get_selected(&s_menu); + ui_feedback(UI_FB_SELECT); + lora_proto_t want = proto_for_sel(s_proto); + lora_proto_t cur = lora_session_active(); + if (cur != LORA_PROTO_NONE && cur != want) { + notify(NOTIFY_LORA, "Reboot to switch protocol"); + s_proto = sel_for_proto(cur); + } else { + start_active_proto(want); + } + s_view = VIEW_HOME; + s_home_sel = 0; + build_screen(); + } + break; + case INPUT_BTN_BACK: + if (press) + exit_to_menu(); + break; + default: + break; + } + break; + + case VIEW_HOME: + switch (ev->button) { + case INPUT_BTN_DOWN: + if (nav) + menu_component_next(&s_menu); + break; + case INPUT_BTN_UP: + if (nav) + menu_component_prev(&s_menu); + break; + case INPUT_BTN_OK: + if (press) { + s_home_sel = menu_component_get_selected(&s_menu); + ui_feedback(UI_FB_SELECT); + if (s_home_sel == 3) { + ui_switch_screen(SCREEN_LORA_CHANNELS); + } else if (s_home_sel == 4) { + ui_switch_screen(SCREEN_LORA_POSITION); + } else if (s_home_sel == 5) { + ui_switch_screen(SCREEN_LORA_TELEMETRY); + } else if (s_home_sel == 6) { + ui_switch_screen(SCREEN_LORA_SECURE_DM); + } else if (s_home_sel == 7) { + ui_switch_screen(SCREEN_LORA_TRACEROUTE); + } else { + s_view = (s_home_sel == 0) ? VIEW_CONNECT + : (s_home_sel == 1) ? VIEW_NODES + : VIEW_CONFIGS; + build_screen(); + } + } + break; + case INPUT_BTN_BACK: + if (press) { + s_view = VIEW_PROTO; + build_screen(); + } + break; + default: + break; + } + break; + + case VIEW_CONNECT: + if (ev->button == INPUT_BTN_BACK && press) { + s_view = VIEW_HOME; + build_screen(); + } + break; + + case VIEW_NODES: + switch (ev->button) { + case INPUT_BTN_DOWN: + if (nav && s_rnode_count > 0) { + s_node = (s_node + 1) % s_rnode_count; + node_select(s_node); + } + break; + case INPUT_BTN_UP: + if (nav && s_rnode_count > 0) { + s_node = (s_node - 1 + s_rnode_count) % s_rnode_count; + node_select(s_node); + } + break; + case INPUT_BTN_OK: + if (press) { + s_nodes_ok_active = true; + s_nodes_ok_fired = false; + } else if (ev->action == INPUT_ACTION_LONG_PRESS) { + if (s_nodes_ok_active && !s_nodes_ok_fired) { + s_nodes_ok_fired = true; + s_nodes_list = !s_nodes_list; + ui_feedback(UI_FB_SELECT); + build_screen(); + } + } else if (ev->action == INPUT_ACTION_RELEASE) { + bool short_press = s_nodes_ok_active && !s_nodes_ok_fired; + s_nodes_ok_active = false; + s_nodes_ok_fired = false; + if (short_press) { + ui_feedback(UI_FB_SELECT); + s_view = VIEW_CHAT; + build_screen(); + } + } + break; + case INPUT_BTN_BACK: + if (press) { + s_view = VIEW_HOME; + build_screen(); + } + break; + default: + break; + } + break; + + case VIEW_CONFIGS: { + int sel = menu_component_get_selected(&s_menu); + switch (ev->button) { + case INPUT_BTN_DOWN: + if (nav) + menu_component_next(&s_menu); + break; + case INPUT_BTN_UP: + if (nav) + menu_component_prev(&s_menu); + break; + case INPUT_BTN_LEFT: + if (nav && lora_session_active() == LORA_PROTO_MESHTASTIC) + cycle_config(sel, -1); + break; + case INPUT_BTN_RIGHT: + if (nav && lora_session_active() == LORA_PROTO_MESHTASTIC) + cycle_config(sel, +1); + break; + case INPUT_BTN_OK: + if (press && sel == CFG_ROUTER) { + bool now = (mt_role_current() == MT_ROLE_ROUTER); + mt_role_set(now ? MT_ROLE_CLIENT : MT_ROLE_ROUTER); + menu_component_toggle_item(&s_menu, sel); + } + break; + case INPUT_BTN_BACK: + if (press) { + s_view = VIEW_HOME; + build_screen(); + } + break; + default: + break; + } + break; + } + + case VIEW_CHAT: + switch (ev->button) { + case INPUT_BTN_DOWN: + if (nav && s_chat_list) { + int32_t sb = lv_obj_get_scroll_bottom(s_chat_list); + if (sb > 0) + lv_obj_scroll_by(s_chat_list, 0, -(sb < 36 ? sb : 36), LV_ANIM_OFF); + } + break; + case INPUT_BTN_UP: + if (nav && s_chat_list) { + int32_t st = lv_obj_get_scroll_top(s_chat_list); + if (st > 0) + lv_obj_scroll_by(s_chat_list, 0, (st < 36 ? st : 36), LV_ANIM_OFF); + } + break; + case INPUT_BTN_OK: + if (press) + keyboard_open(NULL, on_kb_submit, NULL); + break; + case INPUT_BTN_BACK: + if (press) { + s_view = VIEW_NODES; + build_screen(); + } + break; + default: + break; + } + break; + + default: + break; + } +} + +void ui_lora_chat_open(void) { + ui_theme_set_protocol(PROTOCOL_LORA); + lora_proto_t cur = lora_session_active(); + s_view = VIEW_PROTO; + s_proto = (cur != LORA_PROTO_NONE) ? sel_for_proto(cur) : 0; + s_home_sel = 0; + s_node = 0; + s_rnode_count = 0; + s_nodes_list = false; + s_linked = false; + s_chat_seq = 0; + s_connect_timer = NULL; + s_chat_poll = NULL; + build_screen(); + ESP_LOGI(TAG, "LoRa mesh opened"); +} + +void ui_lora_chat_open_chat(void) { + ui_theme_set_protocol(PROTOCOL_LORA); + lora_proto_t cur = lora_session_active(); + s_view = VIEW_CHAT; + s_proto = (cur != LORA_PROTO_NONE) ? sel_for_proto(cur) : 0; + s_home_sel = 0; + s_node = 0; + s_rnode_count = 0; + s_nodes_list = false; + s_linked = false; + s_chat_seq = 0; + s_connect_timer = NULL; + s_chat_poll = NULL; + build_screen(); + ESP_LOGI(TAG, "LoRa chat opened"); +} diff --git a/firmware_p4/components/Applications/ui/screens/lora/lora_mqtt_ui.c b/firmware_p4/components/Applications/ui/screens/lora/lora_mqtt_ui.c new file mode 100644 index 000000000..d765b98e9 --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/lora/lora_mqtt_ui.c @@ -0,0 +1,186 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#include "lora_mqtt_ui.h" + +#include + +#include "esp_log.h" + +#include "keyboard_ui.h" +#include "menu_component_ui.h" +#include "notify_ui.h" +#include "ui_feedback.h" +#include "ui_manager.h" +#include "ui_theme.h" + +static const char *TAG = "LORA_MQTT"; + +#define SIG_GREEN 0x00E676 +#define COL_DIM 0x8A8594 + +#define FIELD_MAX 40 +#define PASS_MASK "******" +#define PASS_UNSET "(unset)" +#define HOST_DEFAULT "mqtt.host" +#define USER_DEFAULT "highboy" + +#define ICON_BROKER "/assets/icons/public.bin" +#define ICON_USER "/assets/icons/hub.bin" +#define ICON_PASS "/assets/icons/key.bin" +#define ICON_STATUS "/assets/icons/wifi_tethering.bin" +#define ICON_ACTION "/assets/icons/router.bin" +#define ICON_TITLE "/assets/icons/lan.bin" + +enum { ROW_BROKER = 0, ROW_USER, ROW_PASS, ROW_STATUS, ROW_ACTION, ROW_COUNT }; + +static lv_obj_t *s_screen = NULL; +static menu_component_t s_menu; + +static char s_broker[FIELD_MAX]; +static char s_user[FIELD_MAX]; +static char s_pass[FIELD_MAX]; +static bool s_connected = false; +static int s_edit_field = ROW_BROKER; + +static void lora_mqtt_input(const input_event_t *ev, void *ctx); + +static const char *pass_display(void) { + return (s_pass[0] == '\0') ? PASS_UNSET : PASS_MASK; +} + +static void store_field(char *dst, const char *text) { + strncpy(dst, text, FIELD_MAX - 1); + dst[FIELD_MAX - 1] = '\0'; +} + +static void on_kb_submit(const char *text, void *user_data) { + (void)user_data; + if (text == NULL || text[0] == '\0') + return; + + if (s_edit_field == ROW_BROKER) { + store_field(s_broker, text); + menu_component_set_selector_value(&s_menu, ROW_BROKER, s_broker); + } else if (s_edit_field == ROW_USER) { + store_field(s_user, text); + menu_component_set_selector_value(&s_menu, ROW_USER, s_user); + } else if (s_edit_field == ROW_PASS) { + store_field(s_pass, text); + menu_component_set_selector_value(&s_menu, ROW_PASS, pass_display()); + } + ui_feedback(UI_FB_WRITE); +} + +static void apply_status(void) { + menu_component_set_selector_value( + &s_menu, ROW_STATUS, s_connected ? "Connected" : "Disconnected"); + menu_component_set_selector_value(&s_menu, ROW_ACTION, s_connected ? "Disconnect" : "Connect"); + menu_component_set_item_label_color( + &s_menu, ROW_STATUS, s_connected ? lv_color_hex(SIG_GREEN) : lv_color_hex(COL_DIM)); + menu_component_set_item_label_color( + &s_menu, ROW_ACTION, s_connected ? lv_color_hex(SIG_GREEN) : current_theme.text_main); +} + +static void toggle_connect(void) { + s_connected = !s_connected; + apply_status(); + if (s_connected) { + ui_feedback(UI_FB_EMULATE); + notify(NOTIFY_INFO, "MQTT bridge connected"); + } else { + ui_feedback(UI_FB_SELECT); + notify(NOTIFY_WARNING, "MQTT bridge stopped"); + } +} + +static void build_screen(void) { + s_menu = menu_component_create(s_screen, "MQTT BRIDGE", ICON_TITLE); + menu_component_add_section(&s_menu, "BROKER"); + menu_component_add_selector(&s_menu, ICON_BROKER, "Broker", s_broker); + menu_component_add_selector(&s_menu, ICON_USER, "User", s_user); + menu_component_add_selector(&s_menu, ICON_PASS, "Pass", pass_display()); + menu_component_add_section(&s_menu, "LINK"); + menu_component_add_selector(&s_menu, ICON_STATUS, "Status", "Disconnected"); + menu_component_add_selector(&s_menu, ICON_ACTION, "Action", "Connect"); + menu_component_select(&s_menu, ROW_BROKER); + menu_component_set_hint(&s_menu, LV_SYMBOL_UP LV_SYMBOL_DOWN " move OK edit/run BACK exit"); + apply_status(); +} + +static void lora_mqtt_input(const input_event_t *ev, void *ctx) { + (void)ctx; + const bool press = (ev->action == INPUT_ACTION_PRESS); + const bool nav = press || (ev->action == INPUT_ACTION_REPEAT); + + switch (ev->button) { + case INPUT_BTN_DOWN: + if (nav) + menu_component_next(&s_menu); + break; + case INPUT_BTN_UP: + if (nav) + menu_component_prev(&s_menu); + break; + case INPUT_BTN_OK: + if (press) { + int sel = menu_component_get_selected(&s_menu); + if (sel == ROW_BROKER || sel == ROW_USER || sel == ROW_PASS) { + s_edit_field = sel; + ui_feedback(UI_FB_SELECT); + keyboard_open(NULL, on_kb_submit, NULL); + } else if (sel == ROW_ACTION) { + toggle_connect(); + } + } + break; + case INPUT_BTN_BACK: + case INPUT_BTN_LEFT: + if (press) + ui_switch_screen(SCREEN_LORA_CHAT); + break; + default: + break; + } +} + +void ui_lora_mqtt_open(void) { + ui_theme_set_protocol(PROTOCOL_LORA); + + if (s_screen != NULL) { + lv_obj_del(s_screen); + s_screen = NULL; + } + store_field(s_broker, HOST_DEFAULT); + store_field(s_user, USER_DEFAULT); + s_pass[0] = '\0'; + s_connected = false; + s_edit_field = ROW_BROKER; + + s_screen = lv_obj_create(NULL); + lv_obj_set_style_bg_color(s_screen, current_theme.screen_base, 0); + lv_obj_set_style_bg_opa(s_screen, LV_OPA_COVER, 0); + lv_obj_remove_flag(s_screen, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_border_width(s_screen, 0, 0); + lv_obj_set_style_pad_all(s_screen, 0, 0); + + build_screen(); + + ui_input_set_screen_handler(lora_mqtt_input, NULL); + + ui_screen_load_owned(&s_screen, s_screen); + + ESP_LOGI(TAG, "LoRa MQTT bridge (mock) opened"); +} diff --git a/firmware_p4/components/Applications/ui/screens/lora/lora_position_ui.c b/firmware_p4/components/Applications/ui/screens/lora/lora_position_ui.c new file mode 100644 index 000000000..93a40805c --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/lora/lora_position_ui.c @@ -0,0 +1,498 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#include "lora_position_ui.h" + +#include +#include +#include +#include + +#include "lvgl.h" +#include "st7789.h" + +#include "keyboard_ui.h" +#include "lora_session.h" +#include "meshcore.h" +#include "mt_mod_position.h" +#include "notify_ui.h" +#include "ui_chrome.h" +#include "ui_feedback.h" +#include "ui_manager.h" +#include "ui_theme.h" + +#define HDR_TITLE "POSITION" +#define HDR_ICON NULL +#define FOOTER_HINT "MOVE " LV_SYMBOL_RIGHT " EDIT OK BCAST BACK" + +#define BODY_TOP UI_CHROME_HEADER_H +#define BODY_H (LCD_V_RES - UI_CHROME_HEADER_H - UI_CHROME_FOOTER_H) + +#define ROOT_PAD 8 +#define ROOT_GAP 7 + +#define CARD_PAD 8 +#define CARD_GAP 5 +#define CARD_RAD 12 + +#define FIELD_CNT 3 +#define ROW_H 28 +#define ROW_RAD 7 +#define ROW_PAD_H 8 +#define TAG_W 34 +#define CARET_W 2 +#define CARET_H 16 +#define GLOW_W 10 + +#define BC_H 30 +#define BC_RAD 8 +#define BC_PAD_H 9 + +#define COL_DIM 0x8A8594 +#define COL_OK 0x00E676 +#define COL_BC_BORDER 0x234A3E + +#define LAT_E7_LIMIT 900000000 +#define LON_E7_LIMIT 1800000000 +#define ALT_M_MIN (-1000) +#define ALT_M_MAX 100000 +#define COORD_E7_SCALE 10000000 +#define COORD_FRAC_DIV 1000 + +static const char *F_TAG[FIELD_CNT] = {"LAT", "LON", "ALT"}; + +static lv_obj_t *s_screen = NULL; +static lv_obj_t *s_row[FIELD_CNT]; +static lv_obj_t *s_tag[FIELD_CNT]; +static lv_obj_t *s_val[FIELD_CNT]; +static lv_obj_t *s_caret[FIELD_CNT]; +static lv_obj_t *s_bc_row = NULL; +static lv_obj_t *s_bc_val = NULL; + +static lora_proto_t s_proto = LORA_PROTO_NONE; +static int s_field = 0; +static bool s_bcast = false; + +static int32_t s_lat_e7 = 0; +static int32_t s_lon_e7 = 0; +static int32_t s_alt_m = 0; + +static int32_t clamp_i32(int32_t v, int32_t lo, int32_t hi) { + if (v < lo) + return lo; + if (v > hi) + return hi; + return v; +} + +static int32_t parse_e7(const char *s) { + bool neg = false; + long long ip = 0; + long long fp = 0; + int fdig = 0; + const char *p = s; + while (*p == ' ' || *p == '\t') + p++; + if (*p == '+') { + p++; + } else if (*p == '-') { + neg = true; + p++; + } + while (*p >= '0' && *p <= '9') { + if (ip < 100000) + ip = ip * 10 + (*p - '0'); + p++; + } + if (*p == '.' || *p == ',') { + p++; + while (*p >= '0' && *p <= '9' && fdig < 7) { + fp = fp * 10 + (*p - '0'); + fdig++; + p++; + } + } + while (fdig < 7) { + fp *= 10; + fdig++; + } + long long e7 = ip * (long long)COORD_E7_SCALE + fp; + if (neg) + e7 = -e7; + if (e7 > 2000000000LL) + e7 = 2000000000LL; + if (e7 < -2000000000LL) + e7 = -2000000000LL; + return (int32_t)e7; +} + +static int32_t parse_int(const char *s) { + bool neg = false; + long long v = 0; + const char *p = s; + while (*p == ' ' || *p == '\t') + p++; + if (*p == '+') { + p++; + } else if (*p == '-') { + neg = true; + p++; + } + while (*p >= '0' && *p <= '9') { + if (v < 100000000LL) + v = v * 10 + (*p - '0'); + p++; + } + if (neg) + v = -v; + return (int32_t)v; +} + +static void fmt_latlon(int32_t e7, char *buf, size_t n) { + int32_t v = e7; + const char *sign = ""; + if (v < 0) { + sign = "-"; + v = -v; + } + int32_t deg = v / COORD_E7_SCALE; + int32_t frac = (v % COORD_E7_SCALE) / COORD_FRAC_DIV; + snprintf(buf, n, "%s%ld.%04ld\xC2\xB0", sign, (long)deg, (long)frac); +} + +static void make_field(lv_obj_t *card, int i) { + lv_obj_t *row = lv_obj_create(card); + lv_obj_remove_flag(row, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_remove_flag(row, LV_OBJ_FLAG_CLICKABLE); + lv_obj_set_size(row, lv_pct(100), ROW_H); + lv_obj_set_style_radius(row, ROW_RAD, 0); + lv_obj_set_style_pad_hor(row, ROW_PAD_H, 0); + lv_obj_set_style_pad_ver(row, 0, 0); + lv_obj_set_style_pad_column(row, 4, 0); + lv_obj_set_style_bg_grad_dir(row, LV_GRAD_DIR_NONE, 0); + lv_obj_set_style_border_width(row, 1, 0); + 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_t *tag = lv_label_create(row); + lv_label_set_text(tag, F_TAG[i]); + lv_obj_set_style_text_font(tag, &lv_font_montserrat_12, 0); + lv_obj_set_width(tag, TAG_W); + + lv_obj_t *val = lv_label_create(row); + lv_label_set_text(val, "--"); + lv_obj_set_style_text_font(val, &lv_font_montserrat_14, 0); + lv_obj_set_style_text_color(val, current_theme.text_main, 0); + lv_obj_set_style_text_align(val, LV_TEXT_ALIGN_RIGHT, 0); + lv_obj_set_flex_grow(val, 1); + + lv_obj_t *caret = lv_obj_create(row); + lv_obj_remove_flag(caret, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(caret, CARET_W, CARET_H); + lv_obj_set_style_radius(caret, 0, 0); + lv_obj_set_style_border_width(caret, 0, 0); + lv_obj_set_style_bg_color(caret, current_theme.border_accent, 0); + lv_obj_set_style_bg_opa(caret, LV_OPA_COVER, 0); + + s_row[i] = row; + s_tag[i] = tag; + s_val[i] = val; + s_caret[i] = caret; +} + +static void refresh_fields(void) { + for (int i = 0; i < FIELD_CNT; i++) { + if (s_row[i] == NULL) + continue; + bool act = (i == s_field); + lv_obj_set_style_bg_color( + s_row[i], act ? current_theme.bg_secondary : current_theme.bg_primary, 0); + lv_obj_set_style_bg_opa(s_row[i], LV_OPA_COVER, 0); + lv_obj_set_style_border_color( + s_row[i], act ? current_theme.border_accent : current_theme.border_inactive, 0); + lv_obj_set_style_shadow_color(s_row[i], current_theme.border_accent, 0); + lv_obj_set_style_shadow_width(s_row[i], act ? GLOW_W : 0, 0); + lv_obj_set_style_shadow_opa(s_row[i], act ? LV_OPA_30 : LV_OPA_TRANSP, 0); + lv_obj_set_style_text_color( + s_tag[i], act ? current_theme.border_accent : lv_color_hex(COL_DIM), 0); + if (act) + lv_obj_remove_flag(s_caret[i], LV_OBJ_FLAG_HIDDEN); + else + lv_obj_add_flag(s_caret[i], LV_OBJ_FLAG_HIDDEN); + } +} + +static void refresh_values(void) { + char buf[32]; + if (s_val[0] != NULL) { + fmt_latlon(s_lat_e7, buf, sizeof(buf)); + lv_label_set_text(s_val[0], buf); + } + if (s_val[1] != NULL) { + fmt_latlon(s_lon_e7, buf, sizeof(buf)); + lv_label_set_text(s_val[1], buf); + } + if (s_val[2] != NULL) { + snprintf(buf, sizeof(buf), "%ld m", (long)s_alt_m); + lv_label_set_text(s_val[2], buf); + } +} + +static void refresh_broadcast(void) { + if (s_bc_val == NULL || s_bc_row == NULL) + return; + if (s_bcast) { + lv_label_set_text(s_bc_val, "ON"); + lv_obj_set_style_text_color(s_bc_val, lv_color_hex(COL_OK), 0); + lv_obj_set_style_border_color(s_bc_row, lv_color_hex(COL_BC_BORDER), 0); + } else { + lv_label_set_text(s_bc_val, "OFF"); + lv_obj_set_style_text_color(s_bc_val, lv_color_hex(COL_DIM), 0); + lv_obj_set_style_border_color(s_bc_row, current_theme.border_inactive, 0); + } +} + +static void apply_position(void) { + if (s_proto == LORA_PROTO_MESHTASTIC) { + mt_mod_position_set_fixed(s_lat_e7, s_lon_e7, s_alt_m); + } else if (s_proto == LORA_PROTO_MESHCORE) { + meshcore_set_advert_latlon(s_lat_e7 / 10, s_lon_e7 / 10, true); + } +} + +static void clear_position(void) { + if (s_proto == LORA_PROTO_MESHTASTIC) { + mt_mod_position_remove_fixed(); + } else if (s_proto == LORA_PROTO_MESHCORE) { + meshcore_set_advert_latlon(0, 0, false); + } +} + +static void on_kb_submit(const char *text, void *user_data) { + int field = (int)(intptr_t)user_data; + if (text == NULL || text[0] == '\0') + return; + if (field < 0 || field >= FIELD_CNT) + return; + if (field == 0) { + s_lat_e7 = clamp_i32(parse_e7(text), -LAT_E7_LIMIT, LAT_E7_LIMIT); + } else if (field == 1) { + s_lon_e7 = clamp_i32(parse_e7(text), -LON_E7_LIMIT, LON_E7_LIMIT); + } else { + s_alt_m = clamp_i32(parse_int(text), ALT_M_MIN, ALT_M_MAX); + } + refresh_values(); + ui_feedback(UI_FB_WRITE); + if (s_bcast) + apply_position(); +} + +static void lora_position_input(const input_event_t *ev, void *ctx) { + (void)ctx; + const bool press = (ev->action == INPUT_ACTION_PRESS); + const bool nav = press || (ev->action == INPUT_ACTION_REPEAT); + + if (s_proto == LORA_PROTO_NONE) { + if ((ev->button == INPUT_BTN_BACK || ev->button == INPUT_BTN_LEFT) && press) + ui_switch_screen(SCREEN_LORA_CHAT); + return; + } + + switch (ev->button) { + case INPUT_BTN_BACK: + case INPUT_BTN_LEFT: + if (press) + ui_switch_screen(SCREEN_LORA_CHAT); + break; + case INPUT_BTN_DOWN: + if (nav) { + s_field = (s_field + 1) % FIELD_CNT; + refresh_fields(); + ui_feedback(UI_FB_NAV); + } + break; + case INPUT_BTN_UP: + if (nav) { + s_field = (s_field - 1 + FIELD_CNT) % FIELD_CNT; + refresh_fields(); + ui_feedback(UI_FB_NAV); + } + break; + case INPUT_BTN_RIGHT: + if (press) { + if (s_field < 0 || s_field >= FIELD_CNT) + s_field = 0; + keyboard_open(NULL, on_kb_submit, (void *)(intptr_t)s_field); + ui_feedback(UI_FB_SELECT); + } + break; + case INPUT_BTN_OK: + if (press) { + s_bcast = !s_bcast; + if (s_bcast) { + apply_position(); + notify(NOTIFY_LORA, "Position broadcasting"); + } else { + clear_position(); + notify(NOTIFY_LORA, "Position cleared"); + } + refresh_broadcast(); + ui_feedback(UI_FB_SELECT); + } + break; + default: + break; + } +} + +static void build_placeholder(void) { + ui_chrome_header(s_screen, HDR_TITLE, HDR_ICON); + + lv_obj_t *msg = lv_label_create(s_screen); + lv_label_set_long_mode(msg, LV_LABEL_LONG_WRAP); + lv_obj_set_width(msg, LCD_H_RES - 2 * ROOT_PAD); + lv_label_set_text(msg, "Start a protocol first"); + lv_obj_set_style_text_font(msg, &lv_font_montserrat_14, 0); + lv_obj_set_style_text_color(msg, lv_color_hex(COL_DIM), 0); + lv_obj_set_style_text_align(msg, LV_TEXT_ALIGN_CENTER, 0); + lv_obj_center(msg); + + ui_chrome_footer(s_screen, "BACK"); +} + +void ui_lora_position_open(void) { + if (s_screen != NULL) { + lv_obj_del(s_screen); + s_screen = NULL; + } + for (int i = 0; i < FIELD_CNT; i++) { + s_row[i] = NULL; + s_tag[i] = NULL; + s_val[i] = NULL; + s_caret[i] = NULL; + } + s_bc_row = NULL; + s_bc_val = NULL; + + s_proto = lora_session_active(); + s_field = 0; + s_bcast = false; + + s_screen = lv_obj_create(NULL); + lv_obj_set_style_bg_color(s_screen, current_theme.screen_base, 0); + lv_obj_set_style_bg_opa(s_screen, LV_OPA_COVER, 0); + lv_obj_remove_flag(s_screen, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_border_width(s_screen, 0, 0); + lv_obj_set_style_pad_all(s_screen, 0, 0); + + if (s_proto == LORA_PROTO_NONE) { + build_placeholder(); + ui_input_set_screen_handler(lora_position_input, NULL); + ui_screen_load_owned(&s_screen, s_screen); + return; + } + + if (s_proto == LORA_PROTO_MESHCORE) { + int32_t lat_e6 = 0; + int32_t lon_e6 = 0; + bool has = false; + meshcore_get_advert_latlon(&lat_e6, &lon_e6, &has); + s_lat_e7 = clamp_i32((int32_t)((int64_t)lat_e6 * 10), -LAT_E7_LIMIT, LAT_E7_LIMIT); + s_lon_e7 = clamp_i32((int32_t)((int64_t)lon_e6 * 10), -LON_E7_LIMIT, LON_E7_LIMIT); + s_bcast = has; + } + + ui_chrome_header(s_screen, HDR_TITLE, HDR_ICON); + + lv_obj_t *root = lv_obj_create(s_screen); + lv_obj_remove_flag(root, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(root, LCD_H_RES, BODY_H); + lv_obj_align(root, LV_ALIGN_TOP_MID, 0, BODY_TOP); + lv_obj_set_style_bg_opa(root, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(root, 0, 0); + lv_obj_set_style_pad_all(root, ROOT_PAD, 0); + lv_obj_set_style_pad_row(root, ROOT_GAP, 0); + lv_obj_set_flex_flow(root, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(root, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START); + + lv_obj_t *card = lv_obj_create(root); + lv_obj_remove_flag(card, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(card, lv_pct(100), LV_SIZE_CONTENT); + lv_obj_set_style_radius(card, CARD_RAD, 0); + lv_obj_set_style_bg_color(card, current_theme.bg_secondary, 0); + lv_obj_set_style_bg_opa(card, LV_OPA_COVER, 0); + lv_obj_set_style_border_color(card, current_theme.border_inactive, 0); + lv_obj_set_style_border_width(card, 1, 0); + lv_obj_set_style_pad_all(card, CARD_PAD, 0); + lv_obj_set_style_pad_row(card, CARD_GAP, 0); + lv_obj_set_flex_flow(card, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(card, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START); + + lv_obj_t *cap = lv_label_create(card); + lv_label_set_text(cap, "FIXED POSITION"); + lv_obj_set_style_text_font(cap, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(cap, lv_color_hex(COL_DIM), 0); + + for (int i = 0; i < FIELD_CNT; i++) + make_field(card, i); + + s_bc_row = lv_obj_create(root); + lv_obj_remove_flag(s_bc_row, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(s_bc_row, lv_pct(100), BC_H); + lv_obj_set_style_radius(s_bc_row, BC_RAD, 0); + lv_obj_set_style_bg_color(s_bc_row, current_theme.bg_primary, 0); + lv_obj_set_style_bg_opa(s_bc_row, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(s_bc_row, 1, 0); + lv_obj_set_style_pad_hor(s_bc_row, BC_PAD_H, 0); + lv_obj_set_style_pad_ver(s_bc_row, 0, 0); + lv_obj_set_style_pad_column(s_bc_row, 6, 0); + lv_obj_set_flex_flow(s_bc_row, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(s_bc_row, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + + lv_obj_t *bc_ic = lv_label_create(s_bc_row); + lv_label_set_text(bc_ic, LV_SYMBOL_WIFI); + lv_obj_set_style_text_font(bc_ic, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(bc_ic, current_theme.text_main, 0); + + lv_obj_t *bc_lbl = lv_label_create(s_bc_row); + lv_label_set_text(bc_lbl, "Broadcast"); + lv_obj_set_style_text_font(bc_lbl, &lv_font_montserrat_14, 0); + lv_obj_set_style_text_color(bc_lbl, current_theme.text_main, 0); + lv_obj_set_flex_grow(bc_lbl, 1); + + s_bc_val = lv_label_create(s_bc_row); + lv_obj_set_style_text_font(s_bc_val, &lv_font_montserrat_12, 0); + + lv_obj_t *cap2 = lv_label_create(root); + lv_label_set_long_mode(cap2, LV_LABEL_LONG_WRAP); + lv_obj_set_width(cap2, lv_pct(100)); + lv_label_set_text(cap2, + (s_proto == LORA_PROTO_MESHTASTIC) + ? "manual fix (no GPS) \xE2\x80\xA2 rebroadcast every 15 min" + : "manual fix (no GPS) \xE2\x80\xA2 sent in each advert"); + lv_obj_set_style_text_font(cap2, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(cap2, lv_color_hex(COL_DIM), 0); + lv_obj_set_style_text_align(cap2, LV_TEXT_ALIGN_CENTER, 0); + + refresh_fields(); + refresh_values(); + refresh_broadcast(); + + ui_chrome_footer(s_screen, FOOTER_HINT); + + ui_input_set_screen_handler(lora_position_input, NULL); + + ui_screen_load_owned(&s_screen, s_screen); +} diff --git a/firmware_p4/components/Applications/ui/screens/lora/lora_rnode_ui.c b/firmware_p4/components/Applications/ui/screens/lora/lora_rnode_ui.c new file mode 100644 index 000000000..8b1709923 --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/lora/lora_rnode_ui.c @@ -0,0 +1,247 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#include "lora_rnode_ui.h" + +#include + +#include "esp_log.h" +#include "esp_random.h" + +#include "ui_chrome.h" +#include "ui_manager.h" +#include "ui_theme.h" + +static const char *TAG = "LORA_RNODE"; + +#define DATA_TICK_MS 700 +#define ENTRY_MS 220 + +#define SIG_GREEN 0x00E676 +#define COL_DIM 0x8A8594 + +#define CARD_W 216 +#define CFG_CARD_H 110 +#define CNT_CARD_H 82 +#define CARD_RADIUS 13 +#define CARD_PAD 12 +#define CARD_GAP 12 +#define BODY_TOP_Y 50 + +#define PANEL_SHADOW_W 14 +#define PANEL_SHADOW_SPREAD (-3) + +#define DOT_SIZE 9 +#define DOT_MS 680 + +#define ICON_ANTENNA "/assets/icons/settings_input_antenna.bin" + +#define RX_STEP_MAX 3 +#define TX_STEP_MAX 2 +#define TX_CHANCE 3 +#define LAST_EVERY 4 + +#define RSSI_BASE (-92) +#define RSSI_SPAN 9 +#define SNR_BASE 85 +#define SNR_SPAN 21 +#define SNR_BIAS 10 + +static lv_obj_t *s_screen = NULL; +static lv_obj_t *s_counter_lbl = NULL; +static lv_obj_t *s_last_lbl = NULL; +static lv_timer_t *s_data_timer = NULL; + +static unsigned long s_rx = 0; +static unsigned long s_tx = 0; +static int s_tick = 0; +static int s_last_rssi = RSSI_BASE; +static int s_last_snr = SNR_BASE; + +static void opa_cb(void *var, int32_t v) { + lv_obj_set_style_opa((lv_obj_t *)var, (lv_opa_t)v, 0); +} + +static lv_obj_t *lit_panel(lv_obj_t *parent, int w, int h, lv_color_t accent) { + lv_obj_t *p = lv_obj_create(parent); + lv_obj_remove_flag(p, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(p, w, h); + lv_obj_set_style_radius(p, CARD_RADIUS, 0); + lv_obj_set_style_bg_color(p, current_theme.bg_secondary, 0); + lv_obj_set_style_bg_opa(p, LV_OPA_COVER, 0); + lv_obj_set_style_bg_grad_dir(p, LV_GRAD_DIR_NONE, 0); + lv_obj_set_style_border_width(p, 1, 0); + lv_obj_set_style_border_color(p, accent, 0); + lv_obj_set_style_shadow_color(p, accent, 0); + lv_obj_set_style_shadow_width(p, PANEL_SHADOW_W, 0); + lv_obj_set_style_shadow_opa(p, LV_OPA_40, 0); + lv_obj_set_style_shadow_spread(p, PANEL_SHADOW_SPREAD, 0); + lv_obj_set_style_pad_all(p, CARD_PAD, 0); + lv_obj_set_flex_flow(p, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(p, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START); + lv_obj_set_style_pad_row(p, 3, 0); + return p; +} + +static lv_obj_t *readout_line(lv_obj_t *parent, const char *text, lv_color_t color) { + lv_obj_t *l = lv_label_create(parent); + lv_label_set_text(l, text); + lv_obj_set_style_text_color(l, color, 0); + lv_obj_set_style_text_font(l, &lv_font_montserrat_12, 0); + return l; +} + +static void build_config_card(lv_obj_t *parent, lv_color_t accent) { + lv_obj_t *card = lit_panel(parent, CARD_W, CFG_CARD_H, accent); + lv_obj_align(card, LV_ALIGN_TOP_MID, 0, BODY_TOP_Y); + + lv_obj_t *head = lv_obj_create(card); + lv_obj_remove_flag(head, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(head, LV_PCT(100), LV_SIZE_CONTENT); + lv_obj_set_style_bg_opa(head, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(head, 0, 0); + lv_obj_set_style_pad_all(head, 0, 0); + lv_obj_set_flex_flow(head, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(head, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_set_style_pad_column(head, 7, 0); + + lv_obj_t *dot = lv_obj_create(head); + lv_obj_remove_flag(dot, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(dot, DOT_SIZE, DOT_SIZE); + lv_obj_set_style_radius(dot, LV_RADIUS_CIRCLE, 0); + lv_obj_set_style_border_width(dot, 0, 0); + lv_obj_set_style_bg_color(dot, lv_color_hex(SIG_GREEN), 0); + lv_obj_set_style_bg_opa(dot, LV_OPA_COVER, 0); + + lv_anim_t a; + lv_anim_init(&a); + lv_anim_set_var(&a, dot); + lv_anim_set_exec_cb(&a, opa_cb); + lv_anim_set_values(&a, LV_OPA_30, LV_OPA_COVER); + lv_anim_set_duration(&a, DOT_MS); + lv_anim_set_playback_duration(&a, DOT_MS); + lv_anim_set_repeat_count(&a, LV_ANIM_REPEAT_INFINITE); + lv_anim_set_path_cb(&a, lv_anim_path_ease_in_out); + lv_anim_start(&a); + + lv_obj_t *title = lv_label_create(head); + lv_label_set_text(title, "RNode / KISS"); + lv_obj_set_style_text_color(title, accent, 0); + lv_obj_set_style_text_font(title, &lv_font_montserrat_14, 0); + + readout_line(card, "Freq 915.0 MHz", current_theme.text_main); + readout_line(card, "SF7 BW 125k CR 4:5", current_theme.text_main); + readout_line(card, "TX 17 dBm", current_theme.text_main); +} + +static void build_counter_card(lv_obj_t *parent, lv_color_t accent) { + lv_obj_t *card = lit_panel(parent, CARD_W, CNT_CARD_H, accent); + lv_obj_align(card, LV_ALIGN_TOP_MID, 0, BODY_TOP_Y + CFG_CARD_H + CARD_GAP); + + s_counter_lbl = lv_label_create(card); + lv_label_set_text_fmt(s_counter_lbl, "RX %lu TX %lu", s_rx, s_tx); + lv_obj_set_style_text_color(s_counter_lbl, lv_color_hex(SIG_GREEN), 0); + lv_obj_set_style_text_font(s_counter_lbl, &lv_font_montserrat_14, 0); + + s_last_lbl = lv_label_create(card); + lv_label_set_text_fmt( + s_last_lbl, "Last: RSSI %d SNR %d.%d", s_last_rssi, s_last_snr / 10, s_last_snr % 10); + lv_obj_set_style_text_color(s_last_lbl, lv_color_hex(COL_DIM), 0); + lv_obj_set_style_text_font(s_last_lbl, &lv_font_montserrat_12, 0); +} + +static void data_tick_cb(lv_timer_t *t) { + if (lv_screen_active() != s_screen) { + lv_timer_delete(t); + if (s_data_timer == t) + s_data_timer = NULL; + return; + } + if (s_counter_lbl == NULL) + return; + + s_rx += (esp_random() % (RX_STEP_MAX + 1)); + if ((esp_random() % TX_CHANCE) == 0) + s_tx += (esp_random() % (TX_STEP_MAX + 1)); + lv_label_set_text_fmt(s_counter_lbl, "RX %lu TX %lu", s_rx, s_tx); + + s_tick++; + if (s_tick % LAST_EVERY == 0 && s_last_lbl != NULL) { + s_last_rssi = RSSI_BASE + (int)(esp_random() % RSSI_SPAN) - (RSSI_SPAN / 2); + s_last_snr = SNR_BASE + (int)(esp_random() % SNR_SPAN) - SNR_BIAS; + lv_label_set_text_fmt( + s_last_lbl, "Last: RSSI %d SNR %d.%d", s_last_rssi, s_last_snr / 10, s_last_snr % 10); + } +} + +static void build_screen(void) { + s_screen = lv_obj_create(NULL); + lv_obj_set_style_bg_color(s_screen, current_theme.screen_base, 0); + lv_obj_set_style_bg_opa(s_screen, LV_OPA_COVER, 0); + lv_obj_remove_flag(s_screen, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_border_width(s_screen, 0, 0); + lv_obj_set_style_pad_all(s_screen, 0, 0); + + ui_chrome_header(s_screen, "RNODE", ICON_ANTENNA); + + lv_color_t accent = ui_theme_get_accent(); + build_config_card(s_screen, accent); + build_counter_card(s_screen, accent); + + ui_chrome_footer(s_screen, LV_SYMBOL_LEFT " BACK to LoRa"); + + lv_obj_fade_in(s_screen, ENTRY_MS, 0); +} + +static void lora_rnode_input(const input_event_t *ev, void *ctx) { + (void)ctx; + const bool press = (ev->action == INPUT_ACTION_PRESS); + + switch (ev->button) { + case INPUT_BTN_BACK: + case INPUT_BTN_LEFT: + if (press) + ui_switch_screen(SCREEN_LORA_CHAT); + break; + default: + break; + } +} + +void ui_lora_rnode_open(void) { + ui_theme_set_protocol(PROTOCOL_LORA); + + if (s_screen != NULL) { + lv_obj_del(s_screen); + s_screen = NULL; + } + s_counter_lbl = NULL; + s_last_lbl = NULL; + s_rx = 0; + s_tx = 0; + s_tick = 0; + s_last_rssi = RSSI_BASE; + s_last_snr = SNR_BASE; + + build_screen(); + + ui_input_set_screen_handler(lora_rnode_input, NULL); + if (s_data_timer == NULL) + s_data_timer = lv_timer_create(data_tick_cb, DATA_TICK_MS, NULL); + + ui_screen_load_owned(&s_screen, s_screen); + + ESP_LOGI(TAG, "LoRa RNode (mock) opened"); +} diff --git a/firmware_p4/components/Applications/ui/screens/lora/lora_securedm_ui.c b/firmware_p4/components/Applications/ui/screens/lora/lora_securedm_ui.c new file mode 100644 index 000000000..59a63eee0 --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/lora/lora_securedm_ui.c @@ -0,0 +1,682 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#include "lora_securedm_ui.h" + +#include +#include + +#include "esp_err.h" +#include "lvgl.h" +#include "st7789.h" + +#include "keyboard_ui.h" +#include "lora_session.h" +#include "meshcore.h" +#include "meshtastic_mesh.h" +#include "meshtastic_nodedb.h" +#include "meshtastic_pki.h" +#include "notify_ui.h" +#include "ui_chrome.h" +#include "ui_feedback.h" +#include "ui_manager.h" +#include "ui_theme.h" + +#define HDR_TITLE "SECURE DM" +#define HDR_ICON NULL +#define FOOTER_LIST "UP/DOWN OK OPEN BACK" +#define FOOTER_CHAT "OK WRITE UP/DOWN SCROLL BACK" + +#define BODY_TOP UI_CHROME_HEADER_H +#define BODY_BOT (LCD_V_RES - UI_CHROME_FOOTER_H) + +#define BANNER_H 36 +#define INPUT_H 32 + +#define BANNER_PAD_H 8 +#define BANNER_GAP 6 + +#define LIST_PAD 8 +#define LIST_GAP 6 + +#define BUBBLE_MAX_W 186 +#define BUBBLE_PAD 7 +#define BUBBLE_RAD 9 + +#define LOCK_W 12 +#define LOCK_H 14 +#define LOCK_SH_W 8 +#define LOCK_SH_H 6 +#define LOCK_BD_W 12 +#define LOCK_BD_H 8 +#define LOCK_LINE 2 + +#define SEAL_SZ 7 + +#define INPUT_PAD_H 8 +#define INPUT_GAP 6 +#define PILL_RAD 12 +#define PILL_PAD_H 9 + +#define SCROLL_STEP 36 + +#define ROW_GAP 8 +#define ROW_GLOW_W 14 +#define POLL_MS 1000 + +#define DM_MAX_CONTACTS 32 + +#define COL_DIM 0x8A8594 +#define COL_OK 0x00E676 +#define COL_OUTTX 0xF2EEFF +#define COL_OUTGR 0x2A1F52 + +typedef enum { + DM_VIEW_CONTACTS = 0, + DM_VIEW_THREAD, +} dm_view_t; + +typedef struct { + char name[MESHCORE_NAME_MAX]; + char fp[16]; + uint8_t pub_key[32]; + uint32_t num; + bool has_key; +} dm_contact_t; + +static lv_obj_t *s_screen = NULL; +static lv_obj_t *s_list = NULL; +static lv_obj_t *s_rows[DM_MAX_CONTACTS]; +static lv_obj_t *s_row_name[DM_MAX_CONTACTS]; +static lv_timer_t *s_poll = NULL; + +static dm_view_t s_view = DM_VIEW_CONTACTS; +static lora_proto_t s_proto = LORA_PROTO_NONE; +static dm_contact_t s_contacts[DM_MAX_CONTACTS]; +static int s_contact_count = 0; +static int s_sel = 0; +static dm_contact_t s_peer; +static bool s_have_peer = false; + +static void lora_securedm_input(const input_event_t *ev, void *ctx); +static void build_screen(void); + +static void stop_timers(void) { + if (s_poll != NULL) { + lv_timer_delete(s_poll); + s_poll = NULL; + } +} + +static void fp_from_key(const uint8_t *key, char *out, size_t n) { + if (key == NULL) { + snprintf(out, n, "----"); + return; + } + snprintf(out, n, "%02X%02X %02X%02X", key[0], key[1], key[2], key[3]); +} + +static void load_contacts(void) { + s_contact_count = 0; + s_proto = lora_session_active(); + + if (s_proto == LORA_PROTO_MESHCORE) { + const meshcore_contact_t *arr = meshcore_contacts_array(); + if (arr != NULL) { + for (size_t i = 0; i < MESHCORE_MAX_CONTACTS && s_contact_count < DM_MAX_CONTACTS; i++) { + if (!arr[i].is_used) + continue; + dm_contact_t *c = &s_contacts[s_contact_count]; + snprintf(c->name, sizeof(c->name), "%s", arr[i].name[0] != '\0' ? arr[i].name : "contact"); + memcpy(c->pub_key, arr[i].pub_key, sizeof(c->pub_key)); + c->num = 0; + c->has_key = true; + fp_from_key(arr[i].pub_key, c->fp, sizeof(c->fp)); + s_contact_count++; + } + } + } else if (s_proto == LORA_PROTO_MESHTASTIC) { + uint16_t total = mt_nodedb_count(); + for (uint16_t i = 0; i < total && s_contact_count < DM_MAX_CONTACTS; i++) { + const mt_node_entry_t *n = mt_nodedb_get_by_index(i); + if (n == NULL || !n->in_use || !n->has_public_key) + continue; + dm_contact_t *c = &s_contacts[s_contact_count]; + const char *nm = (n->long_name[0] != '\0') + ? n->long_name + : ((n->short_name[0] != '\0') ? n->short_name : "node"); + snprintf(c->name, sizeof(c->name), "%s", nm); + memset(c->pub_key, 0, sizeof(c->pub_key)); + c->num = n->num; + c->has_key = true; + fp_from_key(n->public_key, c->fp, sizeof(c->fp)); + s_contact_count++; + } + } + + if (s_sel >= s_contact_count) + s_sel = (s_contact_count > 0) ? s_contact_count - 1 : 0; +} + +static void own_identity(char *name, size_t nmax, char *sub, size_t smax) { + if (s_proto == LORA_PROTO_MESHCORE) { + meshcore_get_name(name, nmax); + if (name[0] == '\0') + snprintf(name, nmax, "You"); + uint8_t k[32]; + meshcore_get_pub_key(k); + fp_from_key(k, sub, smax); + } else if (s_proto == LORA_PROTO_MESHTASTIC) { + snprintf(name, nmax, "You"); + fp_from_key(mt_pki_get_pubkey(), sub, smax); + } else { + snprintf(name, nmax, "No protocol"); + snprintf(sub, smax, "offline"); + } +} + +static lv_obj_t *bare_box(lv_obj_t *parent, int w, int h) { + lv_obj_t *o = lv_obj_create(parent); + lv_obj_remove_flag(o, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_remove_flag(o, LV_OBJ_FLAG_CLICKABLE); + lv_obj_set_size(o, w, h); + lv_obj_set_style_bg_opa(o, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(o, 0, 0); + lv_obj_set_style_radius(o, 0, 0); + lv_obj_set_style_pad_all(o, 0, 0); + return o; +} + +static lv_obj_t *make_lock(lv_obj_t *parent, lv_color_t col) { + lv_obj_t *box = bare_box(parent, LOCK_W, LOCK_H); + + lv_obj_t *sh = lv_obj_create(box); + lv_obj_remove_flag(sh, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(sh, LOCK_SH_W, LOCK_SH_H); + lv_obj_align(sh, LV_ALIGN_TOP_MID, 0, 0); + lv_obj_set_style_bg_opa(sh, LV_OPA_TRANSP, 0); + lv_obj_set_style_pad_all(sh, 0, 0); + lv_obj_set_style_radius(sh, 4, 0); + lv_obj_set_style_border_width(sh, LOCK_LINE, 0); + lv_obj_set_style_border_color(sh, col, 0); + lv_obj_set_style_border_side( + sh, LV_BORDER_SIDE_TOP | LV_BORDER_SIDE_LEFT | LV_BORDER_SIDE_RIGHT, 0); + + lv_obj_t *bd = lv_obj_create(box); + lv_obj_remove_flag(bd, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(bd, LOCK_BD_W, LOCK_BD_H); + lv_obj_align(bd, LV_ALIGN_BOTTOM_MID, 0, 0); + lv_obj_set_style_bg_opa(bd, LV_OPA_TRANSP, 0); + lv_obj_set_style_pad_all(bd, 0, 0); + lv_obj_set_style_radius(bd, 2, 0); + lv_obj_set_style_border_width(bd, LOCK_LINE, 0); + lv_obj_set_style_border_color(bd, col, 0); + return box; +} + +static lv_obj_t *make_seal(lv_obj_t *parent, lv_color_t col) { + lv_obj_t *s = lv_obj_create(parent); + lv_obj_remove_flag(s, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(s, SEAL_SZ, SEAL_SZ); + lv_obj_set_style_radius(s, 2, 0); + lv_obj_set_style_border_width(s, 0, 0); + lv_obj_set_style_bg_color(s, col, 0); + lv_obj_set_style_bg_opa(s, LV_OPA_COVER, 0); + return s; +} + +static void build_banner(lv_obj_t *parent, const char *name, const char *sub, bool secure) { + lv_color_t acc = secure ? lv_color_hex(COL_OK) : lv_color_hex(COL_DIM); + + lv_obj_t *banner = lv_obj_create(parent); + lv_obj_remove_flag(banner, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(banner, LCD_H_RES, BANNER_H); + lv_obj_align(banner, LV_ALIGN_TOP_MID, 0, BODY_TOP); + lv_obj_set_style_radius(banner, 0, 0); + lv_obj_set_style_bg_color(banner, acc, 0); + lv_obj_set_style_bg_opa(banner, 30, 0); + lv_obj_set_style_border_width(banner, 1, 0); + lv_obj_set_style_border_color(banner, acc, 0); + lv_obj_set_style_border_side(banner, LV_BORDER_SIDE_BOTTOM, 0); + lv_obj_set_style_border_opa(banner, LV_OPA_50, 0); + lv_obj_set_style_pad_hor(banner, BANNER_PAD_H, 0); + lv_obj_set_style_pad_ver(banner, 0, 0); + lv_obj_set_style_pad_column(banner, BANNER_GAP, 0); + lv_obj_set_flex_flow(banner, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(banner, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + + make_lock(banner, acc); + + lv_obj_t *col = bare_box(banner, LV_SIZE_CONTENT, LV_SIZE_CONTENT); + lv_obj_set_flex_flow(col, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(col, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START); + lv_obj_set_style_pad_row(col, 1, 0); + lv_obj_set_flex_grow(col, 1); + + lv_obj_t *nm = lv_label_create(col); + lv_label_set_long_mode(nm, LV_LABEL_LONG_DOT); + lv_obj_set_width(nm, lv_pct(100)); + lv_label_set_text(nm, name); + lv_obj_set_style_text_font(nm, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(nm, current_theme.text_main, 0); + + lv_obj_t *fp = lv_label_create(col); + lv_label_set_text(fp, sub); + lv_obj_set_style_text_font(fp, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(fp, acc, 0); + + lv_obj_t *e2ee = lv_label_create(banner); + lv_label_set_text(e2ee, "E2EE"); + lv_obj_set_style_text_font(e2ee, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(e2ee, acc, 0); +} + +static void add_bubble(lv_obj_t *list, bool outgoing, const char *text, const char *ts) { + lv_obj_t *row = lv_obj_create(list); + lv_obj_remove_flag(row, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_width(row, lv_pct(100)); + lv_obj_set_height(row, LV_SIZE_CONTENT); + lv_obj_set_style_bg_opa(row, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(row, 0, 0); + lv_obj_set_style_pad_all(row, 0, 0); + lv_obj_set_flex_flow(row, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(row, + outgoing ? LV_FLEX_ALIGN_END : LV_FLEX_ALIGN_START, + LV_FLEX_ALIGN_START, + LV_FLEX_ALIGN_START); + + lv_obj_t *bubble = lv_obj_create(row); + lv_obj_remove_flag(bubble, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(bubble, LV_SIZE_CONTENT, LV_SIZE_CONTENT); + lv_obj_set_style_max_width(bubble, BUBBLE_MAX_W, 0); + lv_obj_set_style_pad_all(bubble, BUBBLE_PAD, 0); + lv_obj_set_style_radius(bubble, BUBBLE_RAD, 0); + lv_obj_set_style_pad_row(bubble, 3, 0); + lv_obj_set_flex_flow(bubble, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(bubble, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START); + lv_obj_set_style_border_width(bubble, 1, 0); + if (outgoing) { + lv_obj_set_style_bg_color(bubble, current_theme.border_accent, 0); + lv_obj_set_style_bg_grad_color(bubble, lv_color_hex(COL_OUTGR), 0); + lv_obj_set_style_bg_grad_dir(bubble, LV_GRAD_DIR_VER, 0); + lv_obj_set_style_bg_opa(bubble, LV_OPA_COVER, 0); + lv_obj_set_style_border_color(bubble, current_theme.border_accent, 0); + } else { + lv_obj_set_style_bg_color(bubble, current_theme.bg_primary, 0); + lv_obj_set_style_bg_grad_dir(bubble, LV_GRAD_DIR_NONE, 0); + lv_obj_set_style_bg_opa(bubble, LV_OPA_COVER, 0); + lv_obj_set_style_border_color(bubble, current_theme.border_inactive, 0); + } + + lv_obj_t *body = lv_label_create(bubble); + lv_label_set_long_mode(body, LV_LABEL_LONG_WRAP); + lv_obj_set_style_max_width(body, BUBBLE_MAX_W - 2 * BUBBLE_PAD, 0); + lv_label_set_text(body, text); + lv_obj_set_style_text_font(body, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color( + body, outgoing ? lv_color_hex(COL_OUTTX) : current_theme.text_main, 0); + + lv_obj_t *meta = bare_box(bubble, LV_SIZE_CONTENT, LV_SIZE_CONTENT); + lv_obj_set_flex_flow(meta, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(meta, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_set_style_pad_column(meta, 4, 0); + if (outgoing) { + lv_obj_set_flex_align(meta, LV_FLEX_ALIGN_END, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + } else { + make_seal(meta, lv_color_hex(COL_OK)); + } + + lv_obj_t *tlbl = lv_label_create(meta); + if (outgoing) { + char buf[24]; + lv_snprintf(buf, sizeof(buf), "%s %s%s", ts, LV_SYMBOL_OK, LV_SYMBOL_OK); + lv_label_set_text(tlbl, buf); + lv_obj_set_style_text_color(tlbl, lv_color_hex(COL_OUTTX), 0); + lv_obj_set_style_text_opa(tlbl, LV_OPA_70, 0); + } else { + lv_label_set_text(tlbl, ts); + lv_obj_set_style_text_color(tlbl, lv_color_hex(COL_DIM), 0); + } + lv_obj_set_style_text_font(tlbl, &lv_font_montserrat_12, 0); + + lv_obj_scroll_to_view(row, LV_ANIM_ON); +} + +static void build_list_container(lv_obj_t *parent) { + s_list = lv_obj_create(parent); + lv_obj_set_size(s_list, LCD_H_RES, BODY_BOT - INPUT_H - (BODY_TOP + BANNER_H)); + lv_obj_align(s_list, LV_ALIGN_TOP_MID, 0, BODY_TOP + BANNER_H); + lv_obj_set_style_bg_opa(s_list, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(s_list, 0, 0); + lv_obj_set_style_pad_all(s_list, LIST_PAD, 0); + lv_obj_set_style_pad_row(s_list, LIST_GAP, 0); + lv_obj_set_flex_flow(s_list, LV_FLEX_FLOW_COLUMN); + lv_obj_set_scroll_dir(s_list, LV_DIR_VER); + lv_obj_set_scrollbar_mode(s_list, LV_SCROLLBAR_MODE_AUTO); + lv_obj_remove_flag(s_list, LV_OBJ_FLAG_SCROLL_ELASTIC); + lv_obj_remove_flag(s_list, LV_OBJ_FLAG_SCROLL_MOMENTUM); + lv_obj_set_style_bg_color(s_list, current_theme.border_accent, LV_PART_SCROLLBAR); + lv_obj_set_style_bg_opa(s_list, LV_OPA_COVER, LV_PART_SCROLLBAR); + lv_obj_set_style_width(s_list, 4, LV_PART_SCROLLBAR); + lv_obj_set_style_radius(s_list, 2, LV_PART_SCROLLBAR); +} + +static void add_placeholder(lv_obj_t *list, const char *txt) { + lv_obj_t *l = lv_label_create(list); + lv_obj_set_width(l, lv_pct(100)); + lv_label_set_long_mode(l, LV_LABEL_LONG_WRAP); + lv_label_set_text(l, txt); + lv_obj_set_style_text_align(l, LV_TEXT_ALIGN_CENTER, 0); + lv_obj_set_style_text_color(l, lv_color_hex(COL_DIM), 0); + lv_obj_set_style_text_font(l, &lv_font_montserrat_14, 0); +} + +static void make_contact_row(lv_obj_t *list, int i, const dm_contact_t *c) { + lv_obj_t *row = lv_obj_create(list); + lv_obj_remove_flag(row, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_remove_flag(row, LV_OBJ_FLAG_CLICKABLE); + lv_obj_set_width(row, lv_pct(100)); + lv_obj_set_height(row, LV_SIZE_CONTENT); + lv_obj_set_style_bg_color(row, current_theme.bg_primary, 0); + lv_obj_set_style_bg_opa(row, LV_OPA_COVER, 0); + lv_obj_set_style_bg_grad_dir(row, LV_GRAD_DIR_NONE, 0); + lv_obj_set_style_radius(row, BUBBLE_RAD, 0); + lv_obj_set_style_pad_all(row, BUBBLE_PAD, 0); + lv_obj_set_style_pad_column(row, ROW_GAP, 0); + lv_obj_set_style_border_width(row, 1, 0); + lv_obj_set_style_border_color(row, current_theme.border_inactive, 0); + 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); + + make_lock(row, lv_color_hex(COL_OK)); + + lv_obj_t *col = bare_box(row, LV_SIZE_CONTENT, LV_SIZE_CONTENT); + lv_obj_set_flex_flow(col, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(col, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START); + lv_obj_set_style_pad_row(col, 1, 0); + lv_obj_set_flex_grow(col, 1); + + lv_obj_t *nm = lv_label_create(col); + lv_label_set_long_mode(nm, LV_LABEL_LONG_DOT); + lv_obj_set_width(nm, lv_pct(100)); + lv_label_set_text(nm, c->name); + lv_obj_set_style_text_font(nm, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(nm, lv_color_hex(COL_DIM), 0); + + lv_obj_t *fp = lv_label_create(col); + char sub[24]; + lv_snprintf(sub, sizeof(sub), "key %s", c->fp); + lv_label_set_text(fp, sub); + lv_obj_set_style_text_font(fp, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(fp, lv_color_hex(COL_OK), 0); + + s_rows[i] = row; + s_row_name[i] = nm; +} + +static void row_style(int i, bool selected) { + lv_obj_t *row = s_rows[i]; + if (row == NULL) + return; + lv_obj_set_style_bg_color( + row, selected ? current_theme.bg_secondary : current_theme.bg_primary, 0); + lv_obj_set_style_border_color( + row, selected ? current_theme.border_accent : current_theme.border_inactive, 0); + lv_obj_set_style_border_width(row, selected ? 2 : 1, 0); + if (selected) { + lv_obj_set_style_shadow_color(row, current_theme.border_accent, 0); + lv_obj_set_style_shadow_width(row, ROW_GLOW_W, 0); + lv_obj_set_style_shadow_opa(row, LV_OPA_40, 0); + lv_obj_set_style_shadow_spread(row, -2, 0); + } else { + lv_obj_set_style_shadow_width(row, 0, 0); + lv_obj_set_style_shadow_opa(row, LV_OPA_TRANSP, 0); + } + if (s_row_name[i] != NULL) + lv_obj_set_style_text_color( + s_row_name[i], selected ? current_theme.text_main : lv_color_hex(COL_DIM), 0); +} + +static void select_contact(int sel) { + if (s_contact_count <= 0) + return; + if (sel < 0) + sel = 0; + if (sel >= s_contact_count) + sel = s_contact_count - 1; + s_sel = sel; + for (int i = 0; i < s_contact_count; i++) + row_style(i, i == sel); + if (s_list != NULL && s_rows[sel] != NULL) { + lv_obj_update_layout(s_list); + lv_obj_scroll_to_view(s_rows[sel], LV_ANIM_ON); + } +} + +static void build_contacts_list(lv_obj_t *parent) { + build_list_container(parent); + if (s_proto == LORA_PROTO_NONE) { + add_placeholder(s_list, "Start a protocol first"); + return; + } + if (s_contact_count == 0) { + add_placeholder(s_list, "No contacts yet"); + return; + } + for (int i = 0; i < s_contact_count; i++) + make_contact_row(s_list, i, &s_contacts[i]); + select_contact(s_sel); +} + +static void build_thread_list(lv_obj_t *parent) { + build_list_container(parent); + add_placeholder(s_list, "End-to-end encrypted. OK to write."); +} + +static void build_input(lv_obj_t *parent, const char *placeholder) { + lv_obj_t *strip = lv_obj_create(parent); + lv_obj_remove_flag(strip, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(strip, LCD_H_RES, INPUT_H); + lv_obj_align(strip, LV_ALIGN_TOP_MID, 0, BODY_BOT - INPUT_H); + lv_obj_set_style_radius(strip, 0, 0); + lv_obj_set_style_bg_opa(strip, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(strip, 1, 0); + lv_obj_set_style_border_color(strip, current_theme.border_inactive, 0); + lv_obj_set_style_border_side(strip, LV_BORDER_SIDE_TOP, 0); + lv_obj_set_style_pad_hor(strip, INPUT_PAD_H, 0); + lv_obj_set_style_pad_ver(strip, 0, 0); + lv_obj_set_style_pad_column(strip, INPUT_GAP, 0); + lv_obj_set_flex_flow(strip, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(strip, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + + make_lock(strip, lv_color_hex(COL_OK)); + + lv_obj_t *pill = lv_obj_create(strip); + lv_obj_remove_flag(pill, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_height(pill, 22); + lv_obj_set_flex_grow(pill, 1); + lv_obj_set_style_radius(pill, PILL_RAD, 0); + lv_obj_set_style_bg_color(pill, current_theme.bg_primary, 0); + lv_obj_set_style_bg_opa(pill, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(pill, 1, 0); + lv_obj_set_style_border_color(pill, current_theme.border_inactive, 0); + lv_obj_set_style_pad_hor(pill, PILL_PAD_H, 0); + lv_obj_set_style_pad_ver(pill, 0, 0); + lv_obj_set_flex_flow(pill, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(pill, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + + lv_obj_t *ph = lv_label_create(pill); + lv_label_set_text(ph, placeholder); + lv_obj_set_style_text_font(ph, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(ph, lv_color_hex(COL_DIM), 0); +} + +static void on_kb_submit(const char *text, void *user_data) { + (void)user_data; + if (text == NULL || text[0] == '\0') + return; + if (s_view != DM_VIEW_THREAD || s_list == NULL || !s_have_peer) + return; + + esp_err_t err = ESP_ERR_INVALID_STATE; + if (s_proto == LORA_PROTO_MESHCORE) { + uint32_t ack = 0; + err = meshcore_send_direct_msg(s_peer.pub_key, text, 0, 0, &ack); + } else if (s_proto == LORA_PROTO_MESHTASTIC) { + err = meshtastic_mesh_send_text(text, s_peer.num); + } + + if (err == ESP_OK) { + ui_feedback(UI_FB_WRITE); + add_bubble(s_list, true, text, "sent"); + notify(NOTIFY_LORA, "Encrypted DM sent"); + } else { + notify(NOTIFY_LORA, "Send failed"); + } +} + +static void contacts_poll_cb(lv_timer_t *t) { + if (lv_screen_active() != s_screen || s_view != DM_VIEW_CONTACTS) { + lv_timer_delete(t); + s_poll = NULL; + return; + } + int prev_count = s_contact_count; + lora_proto_t prev_proto = s_proto; + load_contacts(); + if (s_contact_count != prev_count || s_proto != prev_proto) + build_screen(); +} + +static void build_screen(void) { + stop_timers(); + if (s_screen != NULL) { + lv_obj_del(s_screen); + s_screen = NULL; + } + s_list = NULL; + for (int i = 0; i < DM_MAX_CONTACTS; i++) { + s_rows[i] = NULL; + s_row_name[i] = NULL; + } + + s_screen = lv_obj_create(NULL); + lv_obj_set_style_bg_color(s_screen, current_theme.screen_base, 0); + lv_obj_set_style_bg_opa(s_screen, LV_OPA_COVER, 0); + lv_obj_remove_flag(s_screen, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_border_width(s_screen, 0, 0); + lv_obj_set_style_pad_all(s_screen, 0, 0); + + ui_chrome_header(s_screen, HDR_TITLE, HDR_ICON); + + if (s_view == DM_VIEW_CONTACTS) { + load_contacts(); + char name[MESHCORE_NAME_MAX]; + char sub[24]; + own_identity(name, sizeof(name), sub, sizeof(sub)); + build_banner(s_screen, name, sub, s_proto != LORA_PROTO_NONE); + build_contacts_list(s_screen); + build_input(s_screen, "select a contact..."); + ui_chrome_footer(s_screen, FOOTER_LIST); + s_poll = lv_timer_create(contacts_poll_cb, POLL_MS, NULL); + } else { + char sub[24]; + lv_snprintf(sub, sizeof(sub), "key %s", s_peer.fp); + build_banner(s_screen, s_peer.name, sub, s_peer.has_key); + build_thread_list(s_screen); + build_input(s_screen, "sealed message..."); + ui_chrome_footer(s_screen, FOOTER_CHAT); + } + + ui_input_set_screen_handler(lora_securedm_input, NULL); + + ui_screen_load_owned(&s_screen, s_screen); +} + +static void lora_securedm_input(const input_event_t *ev, void *ctx) { + (void)ctx; + const bool press = (ev->action == INPUT_ACTION_PRESS); + const bool nav = press || (ev->action == INPUT_ACTION_REPEAT); + + if (s_view == DM_VIEW_CONTACTS) { + switch (ev->button) { + case INPUT_BTN_BACK: + case INPUT_BTN_LEFT: + if (press) + ui_switch_screen(SCREEN_LORA_CHAT); + break; + case INPUT_BTN_DOWN: + if (nav && s_contact_count > 0) + select_contact((s_sel + 1) % s_contact_count); + break; + case INPUT_BTN_UP: + if (nav && s_contact_count > 0) + select_contact((s_sel - 1 + s_contact_count) % s_contact_count); + break; + case INPUT_BTN_OK: + if (press && s_contact_count > 0 && s_sel >= 0 && s_sel < s_contact_count) { + s_peer = s_contacts[s_sel]; + s_have_peer = true; + ui_feedback(UI_FB_SELECT); + s_view = DM_VIEW_THREAD; + build_screen(); + } + break; + default: + break; + } + return; + } + + switch (ev->button) { + case INPUT_BTN_BACK: + case INPUT_BTN_LEFT: + if (press) { + s_view = DM_VIEW_CONTACTS; + build_screen(); + } + break; + case INPUT_BTN_DOWN: + if (nav && s_list != NULL) { + int32_t sb = lv_obj_get_scroll_bottom(s_list); + if (sb > 0) + lv_obj_scroll_by(s_list, 0, -(sb < SCROLL_STEP ? sb : SCROLL_STEP), LV_ANIM_ON); + } + break; + case INPUT_BTN_UP: + if (nav && s_list != NULL) { + int32_t st = lv_obj_get_scroll_top(s_list); + if (st > 0) + lv_obj_scroll_by(s_list, 0, (st < SCROLL_STEP ? st : SCROLL_STEP), LV_ANIM_ON); + } + break; + case INPUT_BTN_OK: + if (press) { + ui_feedback(UI_FB_SELECT); + keyboard_open(NULL, on_kb_submit, NULL); + } + break; + default: + break; + } +} + +void ui_lora_securedm_open(void) { + s_view = DM_VIEW_CONTACTS; + s_sel = 0; + s_contact_count = 0; + s_have_peer = false; + build_screen(); +} diff --git a/firmware_p4/components/Applications/ui/screens/lora/lora_telemetry_ui.c b/firmware_p4/components/Applications/ui/screens/lora/lora_telemetry_ui.c new file mode 100644 index 000000000..0205b03b7 --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/lora/lora_telemetry_ui.c @@ -0,0 +1,561 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#include "lora_telemetry_ui.h" + +#include +#include + +#include "lvgl.h" +#include "st7789.h" + +#include "lora_session.h" +#include "sx1262.h" +#include "ui_chrome.h" +#include "ui_feedback.h" +#include "ui_manager.h" +#include "ui_theme.h" + +#define SPARK_MS 700 + +#define HDR_TITLE "TELEMETRY" +#define HDR_ICON NULL +#define FOOTER_HINT "UP/DOWN OK NODE BACK" + +#define BODY_TOP UI_CHROME_HEADER_H +#define BODY_H (LCD_V_RES - UI_CHROME_HEADER_H - UI_CHROME_FOOTER_H) + +#define ROOT_PAD 8 +#define ROOT_GAP 7 + +#define PANEL_RAD 8 +#define PANEL_PAD_H 7 +#define PANEL_PAD_T 6 +#define PANEL_PAD_B 4 +#define PANEL_GAP 2 + +#define SPARK_W 200 +#define SPARK_H 28 +#define SPARK_PTS 13 +#define SPARK_MG 2 + +#define BATT_H 20 +#define BAR_H 8 +#define BAR_RAD 4 + +#define SNR_OK_DB 5.0f +#define SNR_WARN_DB 0.0f +#define RSSI_OK_DBM (-70) +#define RSSI_WARN_DBM (-90) + +#define NB_MAX 4 +#define CHIP_H 22 +#define CHIP_RAD 10 +#define CHIP_PAD_H 8 +#define CHIP_GAP 5 +#define GLOW_W 10 + +#define RSSI_AMP_LO -120 +#define RSSI_AMP_HI -30 +#define RX_FULL 4 + +#define COL_ACC2 0xB89AFF +#define COL_CYAN 0x37E0A8 +#define COL_DIM 0x8A8594 +#define COL_OK 0x00E676 +#define COL_WARN 0xFFC23D +#define COL_BAD 0xFF5470 +#define COL_TRACK 0x241F31 + +static lv_obj_t *s_screen = NULL; +static lv_obj_t *s_ch_line = NULL; +static lv_obj_t *s_air_line = NULL; +static lv_obj_t *s_ch_val = NULL; +static lv_obj_t *s_air_val = NULL; +static lv_obj_t *s_link_bar = NULL; +static lv_obj_t *s_link_pct = NULL; +static lv_obj_t *s_link_sub = NULL; +static lv_obj_t *s_chips[NB_MAX]; +static lv_obj_t *s_chip_name[NB_MAX]; +static lv_obj_t *s_chip_val[NB_MAX]; +static lv_obj_t *s_nb_empty = NULL; +static lv_timer_t *s_spark_timer = NULL; + +static uint8_t s_ch_buf[SPARK_PTS]; +static uint8_t s_air_buf[SPARK_PTS]; +static lv_point_precise_t s_ch_pts[SPARK_PTS]; +static lv_point_precise_t s_air_pts[SPARK_PTS]; + +static int s_nb = 0; +static int s_nb_count = 0; +static uint16_t s_prev_rx = 0; + +static int spark_x(int i) { + return i * SPARK_W / (SPARK_PTS - 1); +} + +static int spark_y(uint8_t amp) { + if (amp > 100) + amp = 100; + return SPARK_MG + (100 - amp) * (SPARK_H - 2 * SPARK_MG) / 100; +} + +static void rebuild_points(void) { + for (int i = 0; i < SPARK_PTS; i++) { + s_ch_pts[i].x = spark_x(i); + s_ch_pts[i].y = spark_y(s_ch_buf[i]); + s_air_pts[i].x = spark_x(i); + s_air_pts[i].y = spark_y(s_air_buf[i]); + } + if (s_ch_line) + lv_line_set_points(s_ch_line, s_ch_pts, SPARK_PTS); + if (s_air_line) + lv_line_set_points(s_air_line, s_air_pts, SPARK_PTS); +} + +static void push_sample(uint8_t *buf, uint8_t s) { + for (int i = 0; i < SPARK_PTS - 1; i++) + buf[i] = buf[i + 1]; + buf[SPARK_PTS - 1] = s; +} + +static uint8_t rssi_to_amp(int16_t rssi) { + int v = rssi; + if (v < RSSI_AMP_LO) + v = RSSI_AMP_LO; + if (v > RSSI_AMP_HI) + v = RSSI_AMP_HI; + return (uint8_t)((v - RSSI_AMP_LO) * 100 / (RSSI_AMP_HI - RSSI_AMP_LO)); +} + +static void sample_once(bool *running, bool *has_rssi, int16_t *rssi, sx1262_stats_t *st) { + memset(st, 0, sizeof(*st)); + *rssi = 0; + *has_rssi = false; + *running = sx1262_is_running(); + if (*running) { + if (sx1262_get_stats(st) != ESP_OK) + memset(st, 0, sizeof(*st)); + int16_t r; + if (sx1262_get_rssi_inst(&r) == ESP_OK) { + *rssi = r; + *has_rssi = true; + } + } +} + +static void paint_stats(bool running, bool has_rssi, int16_t rssi, const sx1262_stats_t *st) { + if (s_ch_val) { + if (has_rssi) { + char b[16]; + snprintf(b, sizeof(b), "%d dBm", rssi); + lv_label_set_text(s_ch_val, b); + } else { + lv_label_set_text(s_ch_val, "--"); + } + } + if (s_air_val) { + if (running) { + char b[16]; + snprintf(b, sizeof(b), "%u pkts", (unsigned)st->nb_pkt_received); + lv_label_set_text(s_air_val, b); + } else { + lv_label_set_text(s_air_val, "--"); + } + } + + uint32_t total = st->nb_pkt_received; + uint32_t errs = (uint32_t)st->nb_crc_error + (uint32_t)st->nb_header_error; + uint32_t denom = total + errs; + int ratio = (running && denom > 0) ? (int)(total * 100 / denom) : 0; + + if (s_link_bar) + lv_bar_set_value(s_link_bar, running ? ratio : 0, LV_ANIM_OFF); + if (s_link_pct) { + if (running) { + char b[12]; + snprintf(b, sizeof(b), "%d%%", ratio); + lv_label_set_text(s_link_pct, b); + } else { + lv_label_set_text(s_link_pct, "--"); + } + } + if (s_link_sub) { + if (running) { + char b[16]; + snprintf(b, sizeof(b), "CRC %u", (unsigned)st->nb_crc_error); + lv_label_set_text(s_link_sub, b); + } else { + lv_label_set_text(s_link_sub, "--"); + } + } +} + +static uint32_t nb_fill(const lora_node_t *nd, char *buf, size_t n) { + if (nd->snr != 0.0f) { + int t = (int)(nd->snr * 10.0f + (nd->snr >= 0.0f ? 0.5f : -0.5f)); + char sign = (t < 0) ? '-' : '+'; + int at = (t < 0) ? -t : t; + snprintf(buf, n, "%c%d.%d", sign, at / 10, at % 10); + if (nd->snr >= SNR_OK_DB) + return COL_OK; + if (nd->snr >= SNR_WARN_DB) + return COL_WARN; + return COL_BAD; + } + if (nd->rssi != 0) { + snprintf(buf, n, "%d", nd->rssi); + if (nd->rssi >= RSSI_OK_DBM) + return COL_OK; + if (nd->rssi >= RSSI_WARN_DBM) + return COL_WARN; + return COL_BAD; + } + snprintf(buf, n, "--"); + return COL_DIM; +} + +static void refresh_chips(void) { + for (int i = 0; i < NB_MAX; i++) { + if (s_chips[i] == NULL) + continue; + bool sel = (i == s_nb) && (i < s_nb_count); + lv_obj_set_style_border_color( + s_chips[i], sel ? current_theme.border_accent : current_theme.border_inactive, 0); + lv_obj_set_style_border_width(s_chips[i], sel ? 2 : 1, 0); + lv_obj_set_style_shadow_color(s_chips[i], current_theme.border_accent, 0); + lv_obj_set_style_shadow_width(s_chips[i], sel ? GLOW_W : 0, 0); + lv_obj_set_style_shadow_opa(s_chips[i], sel ? LV_OPA_40 : LV_OPA_TRANSP, 0); + lv_obj_set_style_shadow_spread(s_chips[i], sel ? -2 : 0, 0); + lv_obj_set_style_text_color( + s_chip_name[i], sel ? current_theme.text_main : lv_color_hex(COL_DIM), 0); + } +} + +static void refresh_neighbors(void) { + uint16_t count = lora_session_node_count(); + if (count > NB_MAX) + count = NB_MAX; + + for (int i = 0; i < NB_MAX; i++) { + if (s_chips[i] == NULL) + continue; + lora_node_t nd; + if (i < (int)count && lora_session_node_get((uint16_t)i, &nd)) { + lv_label_set_text(s_chip_name[i], (nd.name[0] != '\0') ? nd.name : "node"); + char v[16]; + uint32_t col = nb_fill(&nd, v, sizeof(v)); + lv_label_set_text(s_chip_val[i], v); + lv_obj_set_style_text_color(s_chip_val[i], lv_color_hex(col), 0); + lv_obj_remove_flag(s_chips[i], LV_OBJ_FLAG_HIDDEN); + } else { + lv_obj_add_flag(s_chips[i], LV_OBJ_FLAG_HIDDEN); + } + } + + s_nb_count = (int)count; + if (s_nb >= s_nb_count) + s_nb = (s_nb_count > 0) ? s_nb_count - 1 : 0; + + if (s_nb_empty) { + if (s_nb_count == 0) + lv_obj_remove_flag(s_nb_empty, LV_OBJ_FLAG_HIDDEN); + else + lv_obj_add_flag(s_nb_empty, LV_OBJ_FLAG_HIDDEN); + } + + refresh_chips(); +} + +static void spark_tick_cb(lv_timer_t *t) { + if (lv_screen_active() != s_screen) { + lv_timer_delete(t); + s_spark_timer = NULL; + return; + } + + bool running; + bool has_rssi; + int16_t rssi; + sx1262_stats_t st; + sample_once(&running, &has_rssi, &rssi, &st); + + uint8_t ch = has_rssi ? rssi_to_amp(rssi) : 0; + uint16_t delta = 0; + if (running) { + delta = (uint16_t)(st.nb_pkt_received - s_prev_rx); + s_prev_rx = st.nb_pkt_received; + } + uint32_t a = (uint32_t)delta * 100 / RX_FULL; + if (a > 100) + a = 100; + + push_sample(s_ch_buf, ch); + push_sample(s_air_buf, (uint8_t)a); + rebuild_points(); + paint_stats(running, has_rssi, rssi, &st); + refresh_neighbors(); +} + +static lv_obj_t *make_panel(lv_obj_t *root, + const char *title, + lv_color_t line_col, + lv_obj_t **out_val, + lv_obj_t **out_line, + lv_point_precise_t *pts) { + lv_obj_t *panel = lv_obj_create(root); + lv_obj_remove_flag(panel, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(panel, lv_pct(100), LV_SIZE_CONTENT); + lv_obj_set_style_radius(panel, PANEL_RAD, 0); + lv_obj_set_style_bg_color(panel, current_theme.bg_primary, 0); + lv_obj_set_style_bg_opa(panel, LV_OPA_COVER, 0); + lv_obj_set_style_border_color(panel, current_theme.border_inactive, 0); + lv_obj_set_style_border_width(panel, 1, 0); + lv_obj_set_style_pad_left(panel, PANEL_PAD_H, 0); + lv_obj_set_style_pad_right(panel, PANEL_PAD_H, 0); + lv_obj_set_style_pad_top(panel, PANEL_PAD_T, 0); + lv_obj_set_style_pad_bottom(panel, PANEL_PAD_B, 0); + lv_obj_set_style_pad_row(panel, PANEL_GAP, 0); + lv_obj_set_flex_flow(panel, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(panel, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START); + + lv_obj_t *head = lv_obj_create(panel); + lv_obj_remove_flag(head, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(head, lv_pct(100), LV_SIZE_CONTENT); + lv_obj_set_style_bg_opa(head, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(head, 0, 0); + lv_obj_set_style_pad_all(head, 0, 0); + lv_obj_set_flex_flow(head, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align( + head, LV_FLEX_ALIGN_SPACE_BETWEEN, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + + lv_obj_t *cap = lv_label_create(head); + lv_label_set_text(cap, title); + lv_obj_set_style_text_font(cap, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(cap, lv_color_hex(COL_DIM), 0); + + lv_obj_t *val = lv_label_create(head); + lv_label_set_text(val, "--"); + lv_obj_set_style_text_font(val, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(val, current_theme.text_main, 0); + *out_val = val; + + lv_obj_t *box = lv_obj_create(panel); + lv_obj_remove_flag(box, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(box, SPARK_W, SPARK_H); + lv_obj_set_style_bg_opa(box, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(box, 0, 0); + lv_obj_set_style_pad_all(box, 0, 0); + + lv_obj_t *line = lv_line_create(box); + lv_obj_align(line, LV_ALIGN_TOP_LEFT, 0, 0); + lv_line_set_points(line, pts, SPARK_PTS); + lv_obj_set_style_line_width(line, 2, 0); + lv_obj_set_style_line_color(line, line_col, 0); + lv_obj_set_style_line_opa(line, LV_OPA_COVER, 0); + lv_obj_set_style_line_rounded(line, true, 0); + *out_line = line; + + return panel; +} + +static void lora_telemetry_input(const input_event_t *ev, void *ctx) { + (void)ctx; + const bool press = (ev->action == INPUT_ACTION_PRESS); + const bool nav = press || (ev->action == INPUT_ACTION_REPEAT); + + switch (ev->button) { + case INPUT_BTN_BACK: + case INPUT_BTN_LEFT: + if (press) + ui_switch_screen(SCREEN_LORA_CHAT); + break; + case INPUT_BTN_DOWN: + if (nav && s_nb_count > 0) { + s_nb = (s_nb + 1) % s_nb_count; + refresh_chips(); + ui_feedback(UI_FB_NAV); + } + break; + case INPUT_BTN_UP: + if (nav && s_nb_count > 0) { + s_nb = (s_nb - 1 + s_nb_count) % s_nb_count; + refresh_chips(); + ui_feedback(UI_FB_NAV); + } + break; + case INPUT_BTN_OK: + if (press) + ui_feedback(UI_FB_SELECT); + break; + default: + break; + } +} + +void ui_lora_telemetry_open(void) { + if (s_screen != NULL) { + lv_obj_del(s_screen); + s_screen = NULL; + } + if (s_spark_timer != NULL) { + lv_timer_delete(s_spark_timer); + s_spark_timer = NULL; + } + s_nb = 0; + s_nb_count = 0; + for (int i = 0; i < NB_MAX; i++) { + s_chips[i] = NULL; + s_chip_name[i] = NULL; + s_chip_val[i] = NULL; + } + s_nb_empty = NULL; + + bool running; + bool has_rssi; + int16_t rssi; + sx1262_stats_t st; + sample_once(&running, &has_rssi, &rssi, &st); + s_prev_rx = running ? st.nb_pkt_received : 0; + uint8_t amp0 = has_rssi ? rssi_to_amp(rssi) : 0; + for (int i = 0; i < SPARK_PTS; i++) { + s_ch_buf[i] = amp0; + s_air_buf[i] = 0; + } + + s_screen = lv_obj_create(NULL); + lv_obj_set_style_bg_color(s_screen, current_theme.screen_base, 0); + lv_obj_set_style_bg_opa(s_screen, LV_OPA_COVER, 0); + lv_obj_remove_flag(s_screen, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_border_width(s_screen, 0, 0); + lv_obj_set_style_pad_all(s_screen, 0, 0); + + ui_chrome_header(s_screen, HDR_TITLE, HDR_ICON); + + lv_obj_t *root = lv_obj_create(s_screen); + lv_obj_remove_flag(root, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(root, LCD_H_RES, BODY_H); + lv_obj_align(root, LV_ALIGN_TOP_MID, 0, BODY_TOP); + lv_obj_set_style_bg_opa(root, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(root, 0, 0); + lv_obj_set_style_pad_all(root, ROOT_PAD, 0); + lv_obj_set_style_pad_row(root, ROOT_GAP, 0); + lv_obj_set_flex_flow(root, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(root, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START); + + make_panel(root, "Channel RSSI", lv_color_hex(COL_ACC2), &s_ch_val, &s_ch_line, s_ch_pts); + make_panel(root, "RX packets", lv_color_hex(COL_CYAN), &s_air_val, &s_air_line, s_air_pts); + + lv_obj_t *batt = lv_obj_create(root); + lv_obj_remove_flag(batt, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(batt, lv_pct(100), BATT_H); + lv_obj_set_style_bg_opa(batt, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(batt, 0, 0); + lv_obj_set_style_pad_all(batt, 0, 0); + lv_obj_set_style_pad_column(batt, 7, 0); + lv_obj_set_flex_flow(batt, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(batt, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + + lv_obj_t *bic = lv_label_create(batt); + lv_label_set_text(bic, LV_SYMBOL_WIFI); + lv_obj_set_style_text_font(bic, &lv_font_montserrat_14, 0); + lv_obj_set_style_text_color(bic, current_theme.text_main, 0); + + s_link_bar = lv_bar_create(batt); + lv_obj_set_height(s_link_bar, BAR_H); + lv_obj_set_flex_grow(s_link_bar, 1); + lv_bar_set_range(s_link_bar, 0, 100); + lv_bar_set_value(s_link_bar, 0, LV_ANIM_OFF); + lv_obj_set_style_bg_color(s_link_bar, lv_color_hex(COL_TRACK), LV_PART_MAIN); + lv_obj_set_style_bg_opa(s_link_bar, LV_OPA_COVER, LV_PART_MAIN); + lv_obj_set_style_radius(s_link_bar, BAR_RAD, LV_PART_MAIN); + lv_obj_set_style_bg_color(s_link_bar, current_theme.border_accent, LV_PART_INDICATOR); + lv_obj_set_style_bg_grad_color(s_link_bar, lv_color_hex(COL_ACC2), LV_PART_INDICATOR); + lv_obj_set_style_bg_grad_dir(s_link_bar, LV_GRAD_DIR_HOR, LV_PART_INDICATOR); + lv_obj_set_style_bg_opa(s_link_bar, LV_OPA_COVER, LV_PART_INDICATOR); + lv_obj_set_style_radius(s_link_bar, BAR_RAD, LV_PART_INDICATOR); + + s_link_pct = lv_label_create(batt); + lv_label_set_text(s_link_pct, "--"); + lv_obj_set_style_text_font(s_link_pct, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(s_link_pct, current_theme.text_main, 0); + + s_link_sub = lv_label_create(batt); + lv_label_set_text(s_link_sub, "--"); + lv_obj_set_style_text_font(s_link_sub, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(s_link_sub, lv_color_hex(COL_DIM), 0); + + lv_obj_t *nlbl = lv_label_create(root); + lv_label_set_text(nlbl, "NEIGHBORS"); + lv_obj_set_style_text_font(nlbl, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(nlbl, lv_color_hex(COL_DIM), 0); + + lv_obj_t *chips = lv_obj_create(root); + lv_obj_remove_flag(chips, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(chips, lv_pct(100), LV_SIZE_CONTENT); + lv_obj_set_style_bg_opa(chips, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(chips, 0, 0); + lv_obj_set_style_pad_all(chips, 0, 0); + lv_obj_set_style_pad_column(chips, CHIP_GAP, 0); + lv_obj_set_style_pad_row(chips, CHIP_GAP, 0); + lv_obj_set_flex_flow(chips, LV_FLEX_FLOW_ROW_WRAP); + lv_obj_set_flex_align(chips, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_START); + + for (int i = 0; i < NB_MAX; i++) { + lv_obj_t *chip = lv_obj_create(chips); + lv_obj_remove_flag(chip, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(chip, LV_SIZE_CONTENT, CHIP_H); + lv_obj_set_style_radius(chip, CHIP_RAD, 0); + lv_obj_set_style_bg_color(chip, current_theme.bg_primary, 0); + lv_obj_set_style_bg_opa(chip, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(chip, 1, 0); + lv_obj_set_style_pad_hor(chip, CHIP_PAD_H, 0); + lv_obj_set_style_pad_ver(chip, 0, 0); + lv_obj_set_style_pad_column(chip, 5, 0); + lv_obj_set_flex_flow(chip, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(chip, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_add_flag(chip, LV_OBJ_FLAG_HIDDEN); + + lv_obj_t *nm = lv_label_create(chip); + lv_label_set_text(nm, "node"); + lv_obj_set_style_text_font(nm, &lv_font_montserrat_12, 0); + s_chip_name[i] = nm; + + lv_obj_t *sv = lv_label_create(chip); + lv_label_set_text(sv, "--"); + lv_obj_set_style_text_font(sv, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(sv, lv_color_hex(COL_DIM), 0); + s_chip_val[i] = sv; + + s_chips[i] = chip; + } + + s_nb_empty = lv_label_create(chips); + lv_label_set_text(s_nb_empty, "Listening for nodes..."); + lv_obj_set_style_text_font(s_nb_empty, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(s_nb_empty, lv_color_hex(COL_DIM), 0); + lv_obj_add_flag(s_nb_empty, LV_OBJ_FLAG_HIDDEN); + + rebuild_points(); + paint_stats(running, has_rssi, rssi, &st); + refresh_neighbors(); + + ui_chrome_footer(s_screen, FOOTER_HINT); + + ui_input_set_screen_handler(lora_telemetry_input, NULL); + s_spark_timer = lv_timer_create(spark_tick_cb, SPARK_MS, NULL); + + ui_screen_load_owned(&s_screen, s_screen); +} diff --git a/firmware_p4/components/Applications/ui/screens/lora/lora_traceroute_ui.c b/firmware_p4/components/Applications/ui/screens/lora/lora_traceroute_ui.c new file mode 100644 index 000000000..def47b2ee --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/lora/lora_traceroute_ui.c @@ -0,0 +1,553 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#include "lora_traceroute_ui.h" + +#include +#include +#include + +#include "esp_log.h" + +#include "lora_session.h" +#include "meshtastic_nodedb.h" +#include "mt_mod_traceroute.h" +#include "st7789.h" +#include "ui_chrome.h" +#include "ui_feedback.h" +#include "ui_manager.h" +#include "ui_theme.h" +#include "waves_ui.h" + +static const char *TAG = "LORA_TRACERT"; + +#define POLL_MS 500 +#define TIMEOUT_TICKS 30 + +#define ENTRY_MS 220 +#define ROW_STAGGER_MS 90 + +#define COL_DIM 0x8A8594 + +#define BODY_TOP_Y 46 +#define HOP_LIST_W 228 +#define HOP_ROW_W 214 +#define HOP_ROW_H 46 +#define HOP_ROW_GAP 8 +#define HOP_RADIUS 12 +#define HOP_PAD 9 + +#define PANEL_SHADOW_W 14 +#define PANEL_SHADOW_SPREAD (-3) + +#define IDX_BADGE 26 +#define IDX_RADIUS 8 + +#define ICON_HUB "/assets/icons/hub.bin" +#define WAVES_Y_OFS (-10) +#define STATUS_Y_OFS 96 +#define RISE_PX 24 + +#define PICK_MAX 64 + +typedef enum { + TR_UNSUPPORTED = 0, + TR_PICKER, + TR_TRACING, + TR_RESULT, + TR_TIMEOUT, +} tr_view_t; + +typedef struct { + uint32_t num; + char name[MT_NODEDB_LONG_NAME_LEN]; +} pick_entry_t; + +static lv_obj_t *s_screen = NULL; +static lv_timer_t *s_poll_timer = NULL; +static tr_view_t s_view = TR_PICKER; +static int s_poll_ticks = 0; + +static pick_entry_t s_picks[PICK_MAX]; +static int s_pick_count = 0; +static int s_pick_sel = 0; +static lv_obj_t *s_pick_list = NULL; +static lv_obj_t *s_pick_rows[PICK_MAX]; +static lv_obj_t *s_pick_names[PICK_MAX]; + +static uint32_t s_target_num = 0; +static char s_target_name[MT_NODEDB_LONG_NAME_LEN]; + +static uint32_t s_hops[MT_TRACE_MAX_HOPS]; +static int s_hop_count = 0; + +static void lora_traceroute_input(const input_event_t *ev, void *ctx); +static void build_screen(void); + +static void stop_poll_timer(void) { + if (s_poll_timer != NULL) { + lv_timer_delete(s_poll_timer); + s_poll_timer = NULL; + } +} + +static void opa_cb(void *var, int32_t v) { + lv_obj_set_style_opa((lv_obj_t *)var, (lv_opa_t)v, 0); +} + +static void transy_cb(void *var, int32_t v) { + lv_obj_set_style_translate_y((lv_obj_t *)var, v, 0); +} + +static void reveal_row(lv_obj_t *o, int idx) { + if (o == NULL) + return; + lv_obj_set_style_opa(o, LV_OPA_TRANSP, 0); + + lv_anim_t ao; + lv_anim_init(&ao); + lv_anim_set_var(&ao, o); + lv_anim_set_exec_cb(&ao, opa_cb); + lv_anim_set_values(&ao, LV_OPA_TRANSP, LV_OPA_COVER); + lv_anim_set_duration(&ao, ENTRY_MS); + lv_anim_set_delay(&ao, idx * ROW_STAGGER_MS); + lv_anim_start(&ao); + + lv_anim_t ay; + lv_anim_init(&ay); + lv_anim_set_var(&ay, o); + lv_anim_set_exec_cb(&ay, transy_cb); + lv_anim_set_values(&ay, RISE_PX, 0); + lv_anim_set_duration(&ay, ENTRY_MS); + lv_anim_set_delay(&ay, idx * ROW_STAGGER_MS); + lv_anim_set_path_cb(&ay, lv_anim_path_ease_out); + lv_anim_start(&ay); +} + +static const char *best_name(const mt_node_entry_t *e) { + if (e == NULL) + return NULL; + if (e->long_name[0] != '\0') + return e->long_name; + if (e->short_name[0] != '\0') + return e->short_name; + if (e->id[0] != '\0') + return e->id; + return NULL; +} + +static void resolve_hop_name(uint32_t num, char *buf, size_t len) { + const char *nm = best_name(mt_nodedb_get(num)); + if (nm != NULL) + snprintf(buf, len, "%s", nm); + else + snprintf(buf, len, "!%08lx", (unsigned long)num); +} + +static const char *hop_role(int idx, int count) { + if (idx >= count - 1) + return "target"; + if (idx == 0) + return "origin"; + return "relay"; +} + +static lv_obj_t *lit_panel(lv_obj_t *parent, int w, int h, lv_color_t accent) { + lv_obj_t *p = lv_obj_create(parent); + lv_obj_remove_flag(p, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(p, w, h); + lv_obj_set_style_radius(p, HOP_RADIUS, 0); + lv_obj_set_style_bg_color(p, current_theme.bg_secondary, 0); + lv_obj_set_style_bg_opa(p, LV_OPA_COVER, 0); + lv_obj_set_style_bg_grad_dir(p, LV_GRAD_DIR_NONE, 0); + lv_obj_set_style_border_width(p, 1, 0); + lv_obj_set_style_border_color(p, accent, 0); + lv_obj_set_style_shadow_color(p, accent, 0); + lv_obj_set_style_shadow_width(p, PANEL_SHADOW_W, 0); + lv_obj_set_style_shadow_opa(p, LV_OPA_40, 0); + lv_obj_set_style_shadow_spread(p, PANEL_SHADOW_SPREAD, 0); + return p; +} + +static lv_obj_t *make_index_badge(lv_obj_t *parent, int number, lv_color_t accent) { + lv_obj_t *badge = lv_obj_create(parent); + lv_obj_remove_flag(badge, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(badge, IDX_BADGE, IDX_BADGE); + lv_obj_set_style_radius(badge, IDX_RADIUS, 0); + lv_obj_set_style_pad_all(badge, 0, 0); + lv_obj_set_style_bg_color(badge, current_theme.bg_primary, 0); + lv_obj_set_style_bg_opa(badge, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(badge, 1, 0); + lv_obj_set_style_border_color(badge, accent, 0); + + lv_obj_t *l = lv_label_create(badge); + lv_label_set_text_fmt(l, "%d", number); + lv_obj_set_style_text_color(l, accent, 0); + lv_obj_set_style_text_font(l, &lv_font_montserrat_14, 0); + lv_obj_center(l); + return badge; +} + +static void make_hop_row(lv_obj_t *parent, int idx, int count, lv_color_t accent) { + lv_obj_t *row = lit_panel(parent, HOP_ROW_W, HOP_ROW_H, accent); + lv_obj_set_style_pad_all(row, HOP_PAD, 0); + 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_column(row, 9, 0); + + make_index_badge(row, idx + 1, accent); + + lv_obj_t *txt = lv_obj_create(row); + lv_obj_remove_flag(txt, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(txt, LV_SIZE_CONTENT, LV_SIZE_CONTENT); + lv_obj_set_style_bg_opa(txt, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(txt, 0, 0); + lv_obj_set_style_pad_all(txt, 0, 0); + lv_obj_set_flex_flow(txt, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_grow(txt, 1); + + lv_obj_t *nm = lv_label_create(txt); + char name[40]; + resolve_hop_name(s_hops[idx], name, sizeof(name)); + lv_label_set_long_mode(nm, LV_LABEL_LONG_DOT); + lv_obj_set_width(nm, HOP_ROW_W - IDX_BADGE - (HOP_PAD * 2) - 18); + lv_label_set_text(nm, name); + lv_obj_set_style_text_color(nm, current_theme.text_main, 0); + lv_obj_set_style_text_font(nm, &lv_font_montserrat_14, 0); + + lv_obj_t *sub = lv_label_create(txt); + lv_label_set_text(sub, hop_role(idx, count)); + lv_obj_set_style_text_color(sub, lv_color_hex(COL_DIM), 0); + lv_obj_set_style_text_font(sub, &lv_font_montserrat_12, 0); +} + +static void collect_nodes(void) { + s_pick_count = 0; + uint16_t total = mt_nodedb_count(); + for (uint16_t i = 0; i < total && s_pick_count < PICK_MAX; i++) { + const mt_node_entry_t *e = mt_nodedb_get_by_index(i); + if (e == NULL || !e->in_use) + continue; + const char *nm = best_name(e); + if (nm != NULL) + snprintf(s_picks[s_pick_count].name, sizeof(s_picks[s_pick_count].name), "%s", nm); + else + snprintf(s_picks[s_pick_count].name, + sizeof(s_picks[s_pick_count].name), + "!%08lx", + (unsigned long)e->num); + s_picks[s_pick_count].num = e->num; + s_pick_count++; + } + if (s_pick_sel >= s_pick_count) + s_pick_sel = (s_pick_count > 0) ? s_pick_count - 1 : 0; + if (s_pick_sel < 0) + s_pick_sel = 0; +} + +static void pick_style_row(int i, bool selected) { + if (i < 0 || i >= s_pick_count) + return; + lv_obj_t *row = s_pick_rows[i]; + if (row == NULL) + return; + lv_color_t accent = ui_theme_get_accent(); + lv_obj_set_style_border_color(row, selected ? accent : current_theme.border_inactive, 0); + lv_obj_set_style_border_width(row, 1, 0); + if (selected) { + lv_obj_set_style_shadow_color(row, accent, 0); + lv_obj_set_style_shadow_width(row, PANEL_SHADOW_W, 0); + lv_obj_set_style_shadow_opa(row, LV_OPA_40, 0); + lv_obj_set_style_shadow_spread(row, PANEL_SHADOW_SPREAD, 0); + } else { + lv_obj_set_style_shadow_width(row, 0, 0); + lv_obj_set_style_shadow_opa(row, LV_OPA_TRANSP, 0); + } + if (s_pick_names[i] != NULL) + lv_obj_set_style_text_color( + s_pick_names[i], selected ? current_theme.text_main : lv_color_hex(COL_DIM), 0); +} + +static void pick_select(int sel) { + for (int i = 0; i < s_pick_count; i++) + pick_style_row(i, i == sel); + if (s_pick_list != NULL && sel >= 0 && sel < s_pick_count && s_pick_rows[sel] != NULL) { + lv_obj_update_layout(s_pick_list); + lv_obj_scroll_to_view(s_pick_rows[sel], LV_ANIM_ON); + } +} + +static void centered_note(const char *text) { + lv_obj_t *msg = lv_label_create(s_screen); + lv_label_set_text(msg, text); + lv_obj_set_style_text_color(msg, lv_color_hex(COL_DIM), 0); + lv_obj_set_style_text_font(msg, &lv_font_montserrat_14, 0); + lv_obj_center(msg); +} + +static void build_unsupported(void) { + ui_chrome_header(s_screen, "TRACEROUTE", ICON_HUB); + centered_note("Meshtastic only"); + ui_chrome_footer(s_screen, LV_SYMBOL_LEFT " BACK to LoRa"); +} + +static void build_picker(void) { + ui_chrome_header(s_screen, "TRACEROUTE", ICON_HUB); + collect_nodes(); + + if (s_pick_count == 0) { + centered_note("No nodes discovered"); + ui_chrome_footer(s_screen, LV_SYMBOL_LEFT " BACK to LoRa"); + return; + } + + s_pick_list = lv_obj_create(s_screen); + lv_obj_set_size(s_pick_list, LCD_H_RES, LCD_V_RES - UI_CHROME_HEADER_H - UI_CHROME_FOOTER_H); + lv_obj_align(s_pick_list, LV_ALIGN_TOP_MID, 0, UI_CHROME_HEADER_H); + lv_obj_set_style_bg_opa(s_pick_list, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(s_pick_list, 0, 0); + lv_obj_set_style_pad_all(s_pick_list, 0, 0); + lv_obj_set_style_pad_top(s_pick_list, 6, 0); + lv_obj_set_style_pad_bottom(s_pick_list, 6, 0); + lv_obj_set_style_pad_row(s_pick_list, HOP_ROW_GAP, 0); + lv_obj_set_flex_flow(s_pick_list, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align( + s_pick_list, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_set_scroll_dir(s_pick_list, LV_DIR_VER); + lv_obj_set_scrollbar_mode(s_pick_list, LV_SCROLLBAR_MODE_AUTO); + lv_obj_remove_flag(s_pick_list, LV_OBJ_FLAG_SCROLL_ELASTIC); + lv_obj_remove_flag(s_pick_list, LV_OBJ_FLAG_SCROLL_MOMENTUM); + + lv_color_t accent = ui_theme_get_accent(); + for (int i = 0; i < s_pick_count; i++) { + lv_obj_t *row = lit_panel(s_pick_list, HOP_ROW_W, HOP_ROW_H, current_theme.border_inactive); + lv_obj_set_style_pad_all(row, HOP_PAD, 0); + 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_column(row, 9, 0); + + make_index_badge(row, i + 1, accent); + + lv_obj_t *nm = lv_label_create(row); + lv_obj_set_flex_grow(nm, 1); + lv_label_set_long_mode(nm, LV_LABEL_LONG_DOT); + lv_label_set_text(nm, s_picks[i].name); + lv_obj_set_style_text_font(nm, &lv_font_montserrat_14, 0); + + s_pick_rows[i] = row; + s_pick_names[i] = nm; + } + + pick_select(s_pick_sel); + ui_chrome_footer(s_screen, LV_SYMBOL_UP LV_SYMBOL_DOWN " pick OK trace BACK LoRa"); +} + +static void build_tracing(void) { + ui_chrome_header(s_screen, "TRACEROUTE", ICON_HUB); + + waves_create(s_screen, LV_ALIGN_CENTER, 0, WAVES_Y_OFS, LV_SYMBOL_GPS, ICON_HUB); + + lv_obj_t *status = lv_label_create(s_screen); + char buf[64]; + snprintf(buf, sizeof(buf), "Tracing to %s...", s_target_name); + lv_label_set_text(status, buf); + lv_obj_set_style_text_color(status, current_theme.text_main, 0); + lv_obj_set_style_text_font(status, &lv_font_montserrat_14, 0); + lv_obj_align(status, LV_ALIGN_CENTER, 0, STATUS_Y_OFS); + + ui_chrome_footer(s_screen, "BACK to cancel"); +} + +static void build_result(void) { + ui_chrome_header(s_screen, "TRACEROUTE", ICON_HUB); + + int n = s_hop_count; + if (n < 0) + n = 0; + if (n > MT_TRACE_MAX_HOPS) + n = MT_TRACE_MAX_HOPS; + + if (n == 0) { + centered_note("Empty route"); + ui_chrome_footer(s_screen, LV_SYMBOL_LEFT " BACK to LoRa"); + return; + } + + lv_obj_t *list = lv_obj_create(s_screen); + lv_obj_remove_flag(list, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(list, HOP_LIST_W, LV_SIZE_CONTENT); + lv_obj_align(list, LV_ALIGN_TOP_MID, 0, BODY_TOP_Y); + lv_obj_set_style_bg_opa(list, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(list, 0, 0); + lv_obj_set_style_pad_all(list, 0, 0); + lv_obj_set_style_pad_row(list, HOP_ROW_GAP, 0); + lv_obj_set_flex_flow(list, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(list, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + + lv_color_t accent = ui_theme_get_accent(); + for (int i = 0; i < n; i++) + make_hop_row(list, i, n, accent); + for (int i = 0; i < n; i++) + reveal_row(lv_obj_get_child(list, i), i); + + ui_chrome_footer(s_screen, LV_SYMBOL_LEFT " BACK to LoRa"); +} + +static void build_timeout(void) { + ui_chrome_header(s_screen, "TRACEROUTE", ICON_HUB); + centered_note("No response"); + ui_chrome_footer(s_screen, LV_SYMBOL_LEFT " BACK to LoRa"); +} + +static void trace_poll_cb(lv_timer_t *t) { + if (lv_screen_active() != s_screen || s_view != TR_TRACING) { + lv_timer_delete(t); + s_poll_timer = NULL; + return; + } + + uint32_t hops[MT_TRACE_MAX_HOPS]; + int n = 0; + uint32_t target = 0; + if (mt_mod_traceroute_get_result(hops, &n, &target)) { + if (n < 0) + n = 0; + if (n > MT_TRACE_MAX_HOPS) + n = MT_TRACE_MAX_HOPS; + for (int i = 0; i < n; i++) + s_hops[i] = hops[i]; + s_hop_count = n; + s_view = TR_RESULT; + ui_feedback(UI_FB_READ); + build_screen(); + return; + } + + if (++s_poll_ticks >= TIMEOUT_TICKS) { + s_view = TR_TIMEOUT; + build_screen(); + } +} + +static void build_screen(void) { + stop_poll_timer(); + if (s_screen != NULL) { + lv_obj_del(s_screen); + s_screen = NULL; + } + s_pick_list = NULL; + for (int i = 0; i < PICK_MAX; i++) { + s_pick_rows[i] = NULL; + s_pick_names[i] = NULL; + } + + s_screen = lv_obj_create(NULL); + lv_obj_set_style_bg_color(s_screen, current_theme.screen_base, 0); + lv_obj_set_style_bg_opa(s_screen, LV_OPA_COVER, 0); + lv_obj_remove_flag(s_screen, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_border_width(s_screen, 0, 0); + lv_obj_set_style_pad_all(s_screen, 0, 0); + + switch (s_view) { + case TR_UNSUPPORTED: + build_unsupported(); + break; + case TR_TRACING: + build_tracing(); + s_poll_ticks = 0; + s_poll_timer = lv_timer_create(trace_poll_cb, POLL_MS, NULL); + break; + case TR_RESULT: + build_result(); + break; + case TR_TIMEOUT: + build_timeout(); + break; + case TR_PICKER: + default: + build_picker(); + break; + } + + ui_input_set_screen_handler(lora_traceroute_input, NULL); + + ui_screen_load_owned(&s_screen, s_screen); +} + +static void lora_traceroute_input(const input_event_t *ev, void *ctx) { + (void)ctx; + const bool press = (ev->action == INPUT_ACTION_PRESS); + const bool nav = press || (ev->action == INPUT_ACTION_REPEAT); + + if ((ev->button == INPUT_BTN_BACK || ev->button == INPUT_BTN_LEFT) && press) { + ui_switch_screen(SCREEN_LORA_CHAT); + return; + } + + if (s_view != TR_PICKER || s_pick_count <= 0) + return; + + switch (ev->button) { + case INPUT_BTN_DOWN: + if (nav) { + s_pick_sel = (s_pick_sel + 1) % s_pick_count; + pick_select(s_pick_sel); + } + break; + case INPUT_BTN_UP: + if (nav) { + s_pick_sel = (s_pick_sel - 1 + s_pick_count) % s_pick_count; + pick_select(s_pick_sel); + } + break; + case INPUT_BTN_OK: + if (press) { + if (s_pick_sel < 0 || s_pick_sel >= s_pick_count) + break; + s_target_num = s_picks[s_pick_sel].num; + snprintf(s_target_name, sizeof(s_target_name), "%s", s_picks[s_pick_sel].name); + ui_feedback(UI_FB_SELECT); + mt_mod_traceroute_start(s_target_num); + s_view = TR_TRACING; + build_screen(); + } + break; + default: + break; + } +} + +void ui_lora_traceroute_open(void) { + ui_theme_set_protocol(PROTOCOL_LORA); + + s_poll_timer = NULL; + s_poll_ticks = 0; + s_pick_count = 0; + s_pick_sel = 0; + s_pick_list = NULL; + s_hop_count = 0; + s_target_num = 0; + s_target_name[0] = '\0'; + for (int i = 0; i < PICK_MAX; i++) { + s_pick_rows[i] = NULL; + s_pick_names[i] = NULL; + } + + lora_proto_t proto = lora_session_active(); + s_view = (proto == LORA_PROTO_MESHTASTIC) ? TR_PICKER : TR_UNSUPPORTED; + + build_screen(); + ESP_LOGI(TAG, "LoRa traceroute opened (proto=%d)", (int)proto); +} diff --git a/firmware_p4/components/Applications/ui/screens/menu/include/menu_ui.h b/firmware_p4/components/Applications/ui/screens/menu/include/menu_ui.h index c96077035..cb74271d4 100644 --- a/firmware_p4/components/Applications/ui/screens/menu/include/menu_ui.h +++ b/firmware_p4/components/Applications/ui/screens/menu/include/menu_ui.h @@ -20,9 +20,20 @@ extern "C" { #endif +#include "ui_manager.h" + /** @brief Open the main menu screen. */ void ui_menu_open(void); +/** @brief Number of apps in the menu catalog. */ +int menu_catalog_count(void); + +/** @brief App name at @p index, or NULL if out of range. */ +const char *menu_catalog_name(int index); + +/** @brief Target screen of the app at @p index (SCREEN_HOME if out of range). */ +screen_id_t menu_catalog_target(int index); + #ifdef __cplusplus } #endif diff --git a/firmware_p4/components/Applications/ui/screens/menu/menu_ui.c b/firmware_p4/components/Applications/ui/screens/menu/menu_ui.c index 7325727f4..ca9d07d84 100644 --- a/firmware_p4/components/Applications/ui/screens/menu/menu_ui.c +++ b/firmware_p4/components/Applications/ui/screens/menu/menu_ui.c @@ -20,14 +20,25 @@ #include "esp_log.h" #include "lvgl.h" -#include "home_ui.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" + +#include "assets_manager.h" +#include "bluetooth_service.h" +#include "favorites.h" #include "header_ui.h" -#include "ui_theme.h" -#include "ui_manager.h" +#include "home_ui.h" #include "lv_port_indev.h" -#include "assets_manager.h" +#include "notify_ui.h" #include "page_dots_ui.h" #include "st7789.h" +#include "sys_prio.h" +#include "tos_config.h" +#include "tos_storage_paths.h" +#include "ui_feedback.h" +#include "ui_manager.h" +#include "ui_theme.h" +#include "wifi_service.h" static const char *TAG = "UI_MENU"; @@ -39,11 +50,13 @@ static const char *TAG = "UI_MENU"; #define ICON_CENTER_OFFSET_Y (-10) #define LABEL_OFFSET_Y (-40) #define DOTS_OFFSET_Y (-20) +#define FAV_BADGE_Y 34 +#define FAV_ACCENT 0xF5B13D +#define FAV_HINT_DIM 0x6D7A75 -// Carousel position table: far-left, left, center, right, far-right static const int32_t CAROUSEL_PX[] = {-120, -75, 0, 75, 120}; static const int32_t CAROUSEL_PY[] = {-25, -12, 0, -12, -25}; -static const int32_t CAROUSEL_SC[] = {128, 184, 280, 184, 128}; +static const int32_t CAROUSEL_SC[] = {128, 184, 256, 184, 128}; static const int32_t CAROUSEL_OP[] = {LV_OPA_40, LV_OPA_70, LV_OPA_COVER, LV_OPA_70, LV_OPA_40}; static const int32_t CAROUSEL_Z[] = {0, 1, 2, 1, 0}; #define CAROUSEL_SLOTS 5 @@ -55,105 +68,126 @@ typedef struct { const char *base_frames[MENU_ITEM_FRAME_COUNT]; lv_image_dsc_t *icon_dscs[MENU_ITEM_FRAME_COUNT]; lv_image_dsc_t *base_dscs[MENU_ITEM_FRAME_COUNT]; + screen_id_t target; } menu_ui_item_t; +#define BASE_FRAMES \ + {"/assets/frames/base_frame_0.bin", \ + "/assets/frames/base_frame_1.bin", \ + "/assets/frames/base_frame_2.bin"} + static menu_ui_item_t s_menu_data[] = { {"WIFI", {"/assets/frames/wifi_frame_0.bin", "/assets/frames/wifi_frame_1.bin", "/assets/frames/wifi_frame_2.bin"}, - {"/assets/frames/base_frame_0.bin", - "/assets/frames/base_frame_1.bin", - "/assets/frames/base_frame_2.bin"}, + BASE_FRAMES, {NULL}, - {NULL}}, + {NULL}, + SCREEN_WIFI_MENU}, {"BLUETOOTH", {"/assets/frames/ble_frame_0.bin", "/assets/frames/ble_frame_1.bin", "/assets/frames/ble_frame_2.bin"}, - {"/assets/frames/base_frame_0.bin", - "/assets/frames/base_frame_1.bin", - "/assets/frames/base_frame_2.bin"}, + BASE_FRAMES, + {NULL}, {NULL}, - {NULL}}, + SCREEN_BLE_MENU}, {"NFC", {"/assets/frames/nfc_frame_0.bin", "/assets/frames/nfc_frame_1.bin", "/assets/frames/nfc_frame_2.bin"}, - {"/assets/frames/base_frame_0.bin", - "/assets/frames/base_frame_1.bin", - "/assets/frames/base_frame_2.bin"}, + BASE_FRAMES, + {NULL}, {NULL}, - {NULL}}, + SCREEN_NFC_MENU}, + {"RFID", + {"/assets/frames/rfid_frame_0.bin", + "/assets/frames/rfid_frame_1.bin", + "/assets/frames/rfid_frame_2.bin"}, + BASE_FRAMES, + {NULL}, + {NULL}, + SCREEN_RFID_MENU}, {"INFRARED", {"/assets/frames/ir_frame_0.bin", "/assets/frames/ir_frame_1.bin", "/assets/frames/ir_frame_2.bin"}, - {"/assets/frames/base_frame_0.bin", - "/assets/frames/base_frame_1.bin", - "/assets/frames/base_frame_2.bin"}, + BASE_FRAMES, + {NULL}, {NULL}, - {NULL}}, + SCREEN_IR_MENU}, {"SUB-GHZ", {"/assets/frames/subghz_frame_0.bin", "/assets/frames/subghz_frame_1.bin", "/assets/frames/subghz_frame_2.bin"}, - {"/assets/frames/base_frame_0.bin", - "/assets/frames/base_frame_1.bin", - "/assets/frames/base_frame_2.bin"}, + BASE_FRAMES, {NULL}, - {NULL}}, + {NULL}, + SCREEN_SUBGHZ_MENU}, {"LORA", {"/assets/frames/lora_frame_0.bin", "/assets/frames/lora_frame_1.bin", "/assets/frames/lora_frame_2.bin"}, - {"/assets/frames/base_frame_0.bin", - "/assets/frames/base_frame_1.bin", - "/assets/frames/base_frame_2.bin"}, + BASE_FRAMES, + {NULL}, + {NULL}, + SCREEN_LORA_CHAT}, + {"BADUSB", + {"/assets/frames/usb_frame_0.bin", + "/assets/frames/usb_frame_1.bin", + "/assets/frames/usb_frame_2.bin"}, + BASE_FRAMES, {NULL}, - {NULL}}, + {NULL}, + SCREEN_BADUSB_MENU}, {"GPIO", {"/assets/frames/gpios_frame_0.bin", "/assets/frames/gpios_frame_1.bin", "/assets/frames/gpios_frame_2.bin"}, - {"/assets/frames/base_frame_0.bin", - "/assets/frames/base_frame_1.bin", - "/assets/frames/base_frame_2.bin"}, + BASE_FRAMES, + {NULL}, {NULL}, - {NULL}}, + SCREEN_GPIO}, {"CONFIGURATION", {"/assets/frames/config_frame_0.bin", "/assets/frames/config_frame_1.bin", "/assets/frames/config_frame_2.bin"}, - {"/assets/frames/base_frame_0.bin", - "/assets/frames/base_frame_1.bin", - "/assets/frames/base_frame_2.bin"}, + BASE_FRAMES, + {NULL}, {NULL}, - {NULL}}, + SCREEN_SETTINGS}, {"FILES", {"/assets/frames/file_frame_0.bin", "/assets/frames/file_frame_1.bin", "/assets/frames/file_frame_2.bin"}, - {"/assets/frames/base_frame_0.bin", - "/assets/frames/base_frame_1.bin", - "/assets/frames/base_frame_2.bin"}, + BASE_FRAMES, + {NULL}, + {NULL}, + SCREEN_FILES}, + {"PLAYER", + {"/assets/frames/player_glyph.bin", + "/assets/frames/player_glyph.bin", + "/assets/frames/player_glyph.bin"}, + BASE_FRAMES, + {NULL}, {NULL}, - {NULL}}, - {"APPS", - {"/assets/frames/apps_frame_0.bin", - "/assets/frames/apps_frame_1.bin", - "/assets/frames/apps_frame_2.bin"}, - {"/assets/frames/base_frame_0.bin", - "/assets/frames/base_frame_1.bin", - "/assets/frames/base_frame_2.bin"}, + SCREEN_PLAYER}, + {"DEV", + {"/assets/frames/dev_glyph.bin", + "/assets/frames/dev_glyph.bin", + "/assets/frames/dev_glyph.bin"}, + BASE_FRAMES, {NULL}, - {NULL}}, + {NULL}, + SCREEN_DEV_MENU}, }; extern lv_group_t *main_group; static lv_obj_t *s_screen = NULL; static lv_obj_t *s_label = NULL; +static lv_obj_t *s_fav_badge = NULL; static lv_obj_t *s_base_imgs[MENU_ITEM_COUNT]; static lv_obj_t *s_icon_imgs[MENU_ITEM_COUNT]; static page_dots_t s_page_dots; @@ -179,13 +213,16 @@ static int32_t carousel_slot(size_t item_idx) { } static void on_anim_done(lv_anim_t *a) { + (void)a; s_is_animating = false; } static void load_item_frame(size_t item_idx, int frame) { - if (s_menu_data[item_idx].icon_dscs[frame] == NULL) + if (s_menu_data[item_idx].icon_frames[frame] != NULL && + s_menu_data[item_idx].icon_dscs[frame] == NULL) s_menu_data[item_idx].icon_dscs[frame] = assets_get(s_menu_data[item_idx].icon_frames[frame]); - if (s_menu_data[item_idx].base_dscs[frame] == NULL) + if (s_menu_data[item_idx].base_frames[frame] != NULL && + s_menu_data[item_idx].base_dscs[frame] == NULL) s_menu_data[item_idx].base_dscs[frame] = assets_get(s_menu_data[item_idx].base_frames[frame]); } @@ -295,7 +332,6 @@ static void fix_z_order(void) { } } - // Insertion sort by z ascending for (size_t i = 0; i < count - 1; i++) { for (size_t j = i + 1; j < count; j++) { if (visible[i].z > visible[j].z) { @@ -316,6 +352,12 @@ static void update_view(bool anim) { lv_label_set_text_fmt( s_label, LV_SYMBOL_LEFT " %s " LV_SYMBOL_RIGHT, s_menu_data[s_selected].name); + if (s_fav_badge != NULL) { + bool fav = favorites_is(s_menu_data[s_selected].target); + lv_label_set_text(s_fav_badge, fav ? LV_SYMBOL_OK " FAVORITED" : ""); + lv_obj_set_style_text_color(s_fav_badge, lv_color_hex(FAV_ACCENT), 0); + } + if (anim) { lv_anim_t a; lv_anim_init(&a); @@ -334,6 +376,35 @@ static void update_view(bool anim) { fix_z_order(); } +static void wifi_enable_task(void *arg) { + (void)arg; + wifi_service_start(); + vTaskDelete(NULL); +} + +static void ble_enable_task(void *arg) { + (void)arg; + bluetooth_service_init(); + bluetooth_service_start(); + vTaskDelete(NULL); +} + +static void ensure_radio_on(screen_id_t target) { + if (target == SCREEN_WIFI_MENU && !wifi_service_is_active()) { + g_config_wifi.enabled = true; + tos_config_save(TOS_PATH_CONFIG_WIFI, "wifi"); + xTaskCreatePinnedToCore( + wifi_enable_task, "wifi_on", 4096, NULL, SYS_PRIO_SERVICE_LO, NULL, SYS_CORE_RADIO); + notify(NOTIFY_INFO, "Wi-Fi on"); + } else if (target == SCREEN_BLE_MENU && !bluetooth_service_is_running_cached()) { + g_config_ble.enabled = true; + tos_config_save(TOS_PATH_CONFIG_BLE, "ble"); + xTaskCreatePinnedToCore( + ble_enable_task, "ble_on", 4096, NULL, SYS_PRIO_SERVICE_LO, NULL, SYS_CORE_RADIO); + notify(NOTIFY_INFO, "BLE on"); + } +} + static void on_key_event(lv_event_t *e) { if (lv_event_get_code(e) != LV_EVENT_KEY) return; @@ -351,42 +422,46 @@ static void on_key_event(lv_event_t *e) { else s_selected = (s_selected == 0) ? (uint8_t)(n - 1) : s_selected - 1; + ui_feedback(UI_FB_NAV); update_view(true); return; } + if (k == LV_KEY_DOWN) { + favorites_toggle(s_menu_data[s_selected].target); + ui_feedback(UI_FB_SELECT); + update_view(false); + return; + } + if (k == LV_KEY_ESC) { ui_switch_screen(SCREEN_HOME); return; } if (k == LV_KEY_ENTER) { - switch (s_selected) { - case 0: - ui_switch_screen(SCREEN_WIFI_MENU); - break; - case 1: - ui_switch_screen(SCREEN_BLE_MENU); - break; - case 2: - ui_switch_screen(SCREEN_NFC_MENU); - break; - case 3: - ui_switch_screen(SCREEN_IR_MENU); - break; - case 7: - ui_switch_screen(SCREEN_SETTINGS); - break; - case 8: - ui_switch_screen(SCREEN_FILES); - break; - default: - ESP_LOGW(TAG, "No screen mapped for menu item %u", (unsigned)s_selected); - break; - } + screen_id_t target = s_menu_data[s_selected].target; + ensure_radio_on(target); + ui_switch_screen(target); } } +int menu_catalog_count(void) { + return (int)MENU_ITEM_COUNT; +} + +const char *menu_catalog_name(int index) { + if (index < 0 || index >= (int)MENU_ITEM_COUNT) + return NULL; + return s_menu_data[index].name; +} + +screen_id_t menu_catalog_target(int index) { + if (index < 0 || index >= (int)MENU_ITEM_COUNT) + return SCREEN_HOME; + return s_menu_data[index].target; +} + void ui_menu_open(void) { if (s_screen != NULL) { lv_obj_del(s_screen); @@ -395,7 +470,6 @@ void ui_menu_open(void) { s_is_animating = false; - // Invalidate cached asset pointers — may be stale after a theme change for (size_t i = 0; i < MENU_ITEM_COUNT; i++) { for (int f = 0; f < MENU_ITEM_FRAME_COUNT; f++) { s_menu_data[i].icon_dscs[f] = NULL; @@ -404,7 +478,8 @@ void ui_menu_open(void) { } s_screen = lv_obj_create(NULL); - lv_obj_set_style_bg_color(s_screen, current_theme.screen_base, 0); + + lv_obj_set_style_bg_color(s_screen, lv_color_hex(0x000000), 0); lv_obj_set_style_bg_opa(s_screen, LV_OPA_COVER, 0); lv_obj_remove_flag(s_screen, LV_OBJ_FLAG_SCROLLABLE); @@ -414,17 +489,24 @@ void ui_menu_open(void) { for (size_t i = 0; i < MENU_ITEM_COUNT; i++) { s_base_imgs[i] = lv_image_create(s_screen); lv_obj_align(s_base_imgs[i], LV_ALIGN_CENTER, 0, ICON_CENTER_OFFSET_Y); + lv_image_set_antialias(s_base_imgs[i], false); s_icon_imgs[i] = lv_image_create(s_screen); lv_obj_align(s_icon_imgs[i], LV_ALIGN_CENTER, 0, ICON_CENTER_OFFSET_Y); + lv_image_set_antialias(s_icon_imgs[i], false); } header_ui_create(s_screen); s_label = lv_label_create(s_screen); lv_obj_align(s_label, LV_ALIGN_BOTTOM_MID, 0, LABEL_OFFSET_Y); + lv_obj_set_style_text_color(s_label, current_theme.text_main, 0); - lv_obj_set_style_text_font(s_label, s_font != NULL ? s_font : &lv_font_montserrat_14, 0); + lv_obj_set_style_text_font(s_label, &lv_font_montserrat_14, 0); + + s_fav_badge = lv_label_create(s_screen); + lv_obj_align(s_fav_badge, LV_ALIGN_TOP_MID, 0, FAV_BADGE_Y); + lv_obj_set_style_text_font(s_fav_badge, &lv_font_montserrat_12, 0); s_page_dots = page_dots_create(s_screen, MENU_ITEM_COUNT, LV_ALIGN_BOTTOM_MID, 0, DOTS_OFFSET_Y); @@ -437,5 +519,5 @@ void ui_menu_open(void) { lv_group_focus_obj(s_screen); } - lv_screen_load(s_screen); -} \ No newline at end of file + ui_screen_load_owned(&s_screen, s_screen); +} diff --git a/firmware_p4/components/Applications/ui/screens/nfc/card_emu_ui.c b/firmware_p4/components/Applications/ui/screens/nfc/card_emu_ui.c new file mode 100644 index 000000000..ca5f24970 --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/nfc/card_emu_ui.c @@ -0,0 +1,501 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#include "card_emu_ui.h" + +#include + +#include "lvgl.h" + +#include "nfc_sim.h" +#include "nfc_ui_common.h" +#include "page_dots_ui.h" +#include "ui_chrome.h" +#include "ui_manager.h" +#include "ui_theme.h" + +#define REFRESH_MS 33 +#define FIELD_GREEN 0x00E676 +#define SCALE_FWD 285 +#define DOTS_Y_OFS (-28) + +enum { CE_BROWSE, CE_EDIT, CE_EMULATE }; + +static lv_obj_t *s_screen = NULL; +static lv_obj_t *s_panel = NULL; +static lv_obj_t *s_status = NULL; +static lv_obj_t *s_hint = NULL; +static lv_timer_t *s_timer = NULL; +static page_dots_t s_dots; +static bool s_has_dots = false; + +static lv_obj_t *s_ov = NULL; +static lv_obj_t *s_field_box = NULL; +static nfc_ui_field_t s_field; +static bool s_field_ready = false; +static char s_emu_name[NFC_SIM_NAME_LEN]; +static lv_draw_buf_t *s_snap = NULL; +static lv_obj_t *s_card_img = NULL; +static int s_card_top_y = 0; +static lv_obj_t *s_glow = NULL; + +static int s_state = CE_BROWSE; +static int s_idx = 0; +static int s_edit_type = 0; +static nfc_sim_card_t s_edit; + +static lv_obj_t *make_new_panel(lv_obj_t *parent) { + lv_obj_t *p = lv_obj_create(parent); + lv_obj_remove_flag(p, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(p, 210, 122); + lv_obj_set_style_radius(p, 14, 0); + lv_obj_set_style_bg_color(p, lv_color_hex(0x140828), 0); + lv_obj_set_style_bg_opa(p, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(p, 2, 0); + lv_obj_set_style_border_color(p, ui_theme_get_accent(), 0); + lv_obj_t *l = lv_label_create(p); + lv_label_set_text(l, LV_SYMBOL_PLUS " New Card"); + lv_obj_set_style_text_color(l, ui_theme_get_accent(), 0); + lv_obj_set_style_text_font(l, &lv_font_montserrat_14, 0); + lv_obj_center(l); + return p; +} + +static void rebuild_panel(void) { + if (s_panel) { + lv_obj_del(s_panel); + s_panel = NULL; + } + int saved = nfc_sim_saved_count(); + if (s_state == CE_EDIT) + s_panel = nfc_ui_card_panel(s_screen, &s_edit); + else if (s_idx < saved) + s_panel = nfc_ui_card_panel(s_screen, nfc_sim_saved_get(s_idx)); + else + s_panel = make_new_panel(s_screen); + lv_obj_align(s_panel, LV_ALIGN_CENTER, 0, 8); +} + +static void refresh_text(void) { + int saved = nfc_sim_saved_count(); + if (s_state == CE_EDIT) { + lv_label_set_text_fmt( + s_status, "New card (type %d/%d)", s_edit_type + 1, nfc_sim_template_count()); + ui_chrome_footer_set_text( + s_hint, LV_SYMBOL_LEFT LV_SYMBOL_RIGHT " type " LV_SYMBOL_UP " UID OK Save+Emu BACK"); + } else if (s_idx < saved) { + lv_label_set_text_fmt(s_status, "Card %d / %d", s_idx + 1, saved); + ui_chrome_footer_set_text(s_hint, LV_SYMBOL_LEFT LV_SYMBOL_RIGHT " flip OK Emulate BACK"); + } else { + lv_label_set_text(s_status, "Create a card"); + ui_chrome_footer_set_text(s_hint, LV_SYMBOL_LEFT LV_SYMBOL_RIGHT " flip OK Create BACK"); + } +} + +static void dots_rebuild(void) { + if (s_has_dots) { + lv_obj_del(s_dots.container); + s_has_dots = false; + } + int slots = nfc_sim_saved_count() + 1; + s_dots = page_dots_create(s_screen, slots, LV_ALIGN_BOTTOM_MID, 0, DOTS_Y_OFS); + s_has_dots = true; + page_dots_set(&s_dots, s_idx); +} + +static void anim_img_y_cb(void *var, int32_t v) { + lv_obj_set_y((lv_obj_t *)var, v); +} +static void anim_img_scale_cb(void *var, int32_t v) { + lv_image_set_scale((lv_obj_t *)var, (uint32_t)v); +} + +static void anim_flip_x_cb(void *var, int32_t v) { + lv_obj_align((lv_obj_t *)var, LV_ALIGN_CENTER, v, 8); +} +static void anim_flip_opa_cb(void *var, int32_t v) { + lv_obj_set_style_opa((lv_obj_t *)var, (lv_opa_t)v, 0); +} +static void flip_done(lv_anim_t *a) { + (void)a; + if (s_panel) { + lv_obj_set_style_shadow_width(s_panel, 24, 0); + lv_obj_set_style_opa(s_panel, LV_OPA_COVER, 0); + } +} + +static void do_flip(int new_idx, int dir) { + s_idx = new_idx; + rebuild_panel(); + refresh_text(); + if (s_has_dots) + page_dots_set(&s_dots, s_idx); + lv_obj_set_style_shadow_width(s_panel, 0, 0); + lv_obj_set_style_opa(s_panel, LV_OPA_TRANSP, 0); + lv_obj_align(s_panel, LV_ALIGN_CENTER, dir * 60, 8); + + lv_anim_t ax; + lv_anim_init(&ax); + lv_anim_set_var(&ax, s_panel); + lv_anim_set_values(&ax, dir * 60, 0); + lv_anim_set_duration(&ax, 200); + lv_anim_set_path_cb(&ax, lv_anim_path_ease_out); + lv_anim_set_exec_cb(&ax, anim_flip_x_cb); + lv_anim_set_completed_cb(&ax, flip_done); + lv_anim_start(&ax); + + lv_anim_t ao; + lv_anim_init(&ao); + lv_anim_set_var(&ao, s_panel); + lv_anim_set_values(&ao, 0, 255); + lv_anim_set_duration(&ao, 200); + lv_anim_set_exec_cb(&ao, anim_flip_opa_cb); + lv_anim_start(&ao); +} +static void anim_card_settled(lv_anim_t *a) { + (void)a; + s_field_ready = true; + if (s_field_box) + lv_obj_fade_in(s_field_box, 300, 0); + if (s_glow) + lv_obj_fade_in(s_glow, 360, 0); + if (s_card_img == NULL) + return; + + lv_anim_t bob; + lv_anim_init(&bob); + lv_anim_set_var(&bob, s_card_img); + lv_anim_set_values(&bob, s_card_top_y, s_card_top_y - 8); + lv_anim_set_duration(&bob, 1600); + lv_anim_set_playback_duration(&bob, 1600); + lv_anim_set_repeat_count(&bob, LV_ANIM_REPEAT_INFINITE); + lv_anim_set_path_cb(&bob, lv_anim_path_ease_in_out); + lv_anim_set_exec_cb(&bob, anim_img_y_cb); + lv_anim_start(&bob); + + lv_anim_t br; + lv_anim_init(&br); + lv_anim_set_var(&br, s_card_img); + lv_anim_set_values(&br, SCALE_FWD, SCALE_FWD + 14); + lv_anim_set_duration(&br, 1600); + lv_anim_set_playback_duration(&br, 1600); + lv_anim_set_repeat_count(&br, LV_ANIM_REPEAT_INFINITE); + lv_anim_set_path_cb(&br, lv_anim_path_ease_in_out); + lv_anim_set_exec_cb(&br, anim_img_scale_cb); + lv_anim_start(&br); +} + +static void emulate_close(void) { + s_field_ready = false; + if (s_field_box) { + lv_obj_del(s_field_box); + s_field_box = NULL; + } + if (s_ov) { + lv_obj_del(s_ov); + s_ov = NULL; + } + if (s_card_img) { + lv_obj_del(s_card_img); + s_card_img = NULL; + } + if (s_glow) { + lv_obj_del(s_glow); + s_glow = NULL; + } + if (s_snap) { + lv_draw_buf_destroy(s_snap); + s_snap = NULL; + } + for (int i = 0; i < 3; i++) + s_field.ring[i] = NULL; + if (s_status) + lv_obj_remove_flag(s_status, LV_OBJ_FLAG_HIDDEN); + if (s_hint) + lv_obj_remove_flag(s_hint, LV_OBJ_FLAG_HIDDEN); + s_state = CE_BROWSE; + rebuild_panel(); + refresh_text(); + dots_rebuild(); +} + +static void emulate_start(const nfc_sim_card_t *card) { + strncpy(s_emu_name, card->name, sizeof(s_emu_name) - 1); + s_emu_name[sizeof(s_emu_name) - 1] = '\0'; + lv_color_t col = nfc_ui_card_color(card); + + int H = lv_display_get_vertical_resolution(NULL); + if (H < 200) + H = 320; + int top_y = H * 8 / 100; + if (top_y < 4) + top_y = 4; + s_card_top_y = top_y; + + s_ov = lv_obj_create(s_screen); + lv_obj_set_size(s_ov, lv_pct(100), lv_pct(100)); + lv_obj_center(s_ov); + lv_obj_remove_flag(s_ov, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_border_width(s_ov, 0, 0); + lv_obj_set_style_radius(s_ov, 0, 0); + lv_obj_set_style_bg_color(s_ov, lv_color_black(), 0); + lv_obj_set_style_bg_opa(s_ov, LV_OPA_COVER, 0); + lv_obj_fade_in(s_ov, 220, 0); + + if (s_status) + lv_obj_add_flag(s_status, LV_OBJ_FLAG_HIDDEN); + if (s_hint) + lv_obj_add_flag(s_hint, LV_OBJ_FLAG_HIDDEN); + if (s_has_dots) + page_dots_hide(&s_dots); + + s_glow = lv_obj_create(s_screen); + lv_obj_remove_flag(s_glow, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_remove_flag(s_glow, LV_OBJ_FLAG_CLICKABLE); + lv_obj_set_size(s_glow, 226, 152); + lv_obj_align(s_glow, LV_ALIGN_CENTER, 0, top_y + 61 - H / 2); + lv_obj_set_style_radius(s_glow, 38, 0); + lv_obj_set_style_border_width(s_glow, 0, 0); + lv_obj_set_style_bg_color(s_glow, col, 0); + lv_obj_set_style_bg_opa(s_glow, LV_OPA_30, 0); + lv_obj_set_style_opa(s_glow, LV_OPA_TRANSP, 0); + + s_field_box = lv_obj_create(s_screen); + lv_obj_set_size(s_field_box, lv_pct(100), H * 44 / 100); + lv_obj_align(s_field_box, LV_ALIGN_BOTTOM_MID, 0, 0); + lv_obj_remove_flag(s_field_box, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_bg_opa(s_field_box, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(s_field_box, 0, 0); + lv_obj_set_style_opa(s_field_box, LV_OPA_TRANSP, 0); + + lv_obj_t *prompt = lv_label_create(s_field_box); + lv_label_set_text(prompt, "Hold Near Reader"); + lv_obj_set_style_text_color(prompt, current_theme.text_main, 0); + lv_obj_align(prompt, LV_ALIGN_TOP_MID, 0, 2); + + nfc_ui_field_create(&s_field, s_field_box, col); + + lv_obj_t *hint = lv_label_create(s_field_box); + lv_label_set_text(hint, "BACK to stop"); + lv_obj_set_style_text_color(hint, current_theme.text_main, 0); + lv_obj_set_style_text_opa(hint, LV_OPA_60, 0); + lv_obj_set_style_text_font(hint, &lv_font_montserrat_12, 0); + lv_obj_align(hint, LV_ALIGN_BOTTOM_MID, 0, -4); + + lv_obj_set_style_shadow_width(s_panel, 0, 0); + lv_obj_update_layout(s_screen); + int cx = lv_obj_get_x(s_panel); + int cy = lv_obj_get_y(s_panel); + s_snap = lv_snapshot_take(s_panel, LV_COLOR_FORMAT_ARGB8888); + + s_field_ready = false; + if (s_snap != NULL) { + s_card_img = lv_image_create(s_screen); + lv_image_set_src(s_card_img, s_snap); + lv_obj_set_pos(s_card_img, cx, cy); + lv_image_set_pivot(s_card_img, 105, 61); + lv_image_set_scale(s_card_img, 256); + lv_obj_set_style_opa(s_card_img, LV_OPA_COVER, 0); + lv_obj_move_foreground(s_card_img); + lv_obj_add_flag(s_panel, LV_OBJ_FLAG_HIDDEN); + + lv_anim_t ay; + lv_anim_init(&ay); + lv_anim_set_var(&ay, s_card_img); + lv_anim_set_values(&ay, cy, top_y); + lv_anim_set_duration(&ay, 640); + lv_anim_set_path_cb(&ay, lv_anim_path_overshoot); + lv_anim_set_exec_cb(&ay, anim_img_y_cb); + lv_anim_set_completed_cb(&ay, anim_card_settled); + lv_anim_start(&ay); + + lv_anim_t as; + lv_anim_init(&as); + lv_anim_set_var(&as, s_card_img); + lv_anim_set_values(&as, 256, SCALE_FWD); + lv_anim_set_duration(&as, 560); + lv_anim_set_path_cb(&as, lv_anim_path_overshoot); + lv_anim_set_exec_cb(&as, anim_img_scale_cb); + lv_anim_start(&as); + } else { + lv_obj_set_align(s_panel, LV_ALIGN_TOP_LEFT); + lv_obj_set_pos(s_panel, cx, top_y); + lv_obj_set_style_transform_pivot_x(s_panel, 105, 0); + lv_obj_set_style_transform_pivot_y(s_panel, 61, 0); + lv_obj_set_style_shadow_width(s_panel, 0, 0); + lv_obj_set_style_transform_scale_x(s_panel, SCALE_FWD, 0); + lv_obj_set_style_transform_scale_y(s_panel, SCALE_FWD, 0); + lv_obj_move_foreground(s_panel); + s_field_ready = true; + lv_obj_fade_in(s_field_box, 300, 0); + if (s_glow) + lv_obj_fade_in(s_glow, 360, 0); + } + + s_state = CE_EMULATE; +} + +static void enter_edit(void) { + if (s_has_dots) + page_dots_hide(&s_dots); + s_state = CE_EDIT; + s_edit_type = 0; + nfc_sim_make_card(s_edit_type, &s_edit); + rebuild_panel(); + refresh_text(); +} + +static void card_emu_input(const input_event_t *ev, void *ctx) { + (void)ctx; + const bool press = (ev->action == INPUT_ACTION_PRESS); + const bool nav = press || (ev->action == INPUT_ACTION_REPEAT); + + if (s_state == CE_EMULATE) { + if (ev->button == INPUT_BTN_BACK && press) + emulate_close(); + return; + } + + if (ev->button == INPUT_BTN_BACK) { + if (press) { + if (s_state == CE_EDIT) { + s_state = CE_BROWSE; + rebuild_panel(); + refresh_text(); + dots_rebuild(); + } else { + ui_switch_screen(SCREEN_NFC_MENU); + } + } + return; + } + + if (s_state == CE_BROWSE) { + int slots = nfc_sim_saved_count() + 1; + switch (ev->button) { + case INPUT_BTN_RIGHT: + if (nav) + do_flip((s_idx + 1) % slots, +1); + break; + case INPUT_BTN_LEFT: + if (nav) + do_flip((s_idx - 1 + slots) % slots, -1); + break; + case INPUT_BTN_OK: + if (press) { + int saved = nfc_sim_saved_count(); + if (s_idx < saved) + emulate_start(nfc_sim_saved_get(s_idx)); + else + enter_edit(); + } + break; + default: + break; + } + } else { + int nt = nfc_sim_template_count(); + switch (ev->button) { + case INPUT_BTN_RIGHT: + if (nav) { + s_edit_type = (s_edit_type + 1) % nt; + nfc_sim_make_card(s_edit_type, &s_edit); + rebuild_panel(); + refresh_text(); + } + break; + case INPUT_BTN_LEFT: + if (nav) { + s_edit_type = (s_edit_type - 1 + nt) % nt; + nfc_sim_make_card(s_edit_type, &s_edit); + rebuild_panel(); + refresh_text(); + } + break; + case INPUT_BTN_UP: + if (press) { + nfc_sim_make_card(s_edit_type, &s_edit); + rebuild_panel(); + } + break; + case INPUT_BTN_OK: + if (press) { + if (nfc_sim_add(&s_edit)) + nfc_ui_play_sound(NFC_SND_SAVE); + s_idx = nfc_sim_saved_count() - 1; + if (s_idx < 0) + s_idx = 0; + emulate_start(&s_edit); + } + break; + default: + break; + } + } +} + +static void refresh_cb(lv_timer_t *t) { + if (lv_screen_active() != s_screen) { + lv_timer_delete(t); + s_timer = NULL; + return; + } + if (s_state == CE_EMULATE && s_field_ready) + nfc_ui_field_tick(&s_field, lv_tick_get()); +} + +void ui_card_emu_open(void) { + nfc_sim_init(); + if (s_screen != NULL) { + lv_obj_del(s_screen); + s_screen = NULL; + } + s_panel = NULL; + s_ov = NULL; + s_field_box = NULL; + s_card_img = NULL; + s_glow = NULL; + s_snap = NULL; + s_field_ready = false; + s_has_dots = false; + for (int i = 0; i < 3; i++) + s_field.ring[i] = NULL; + s_state = CE_BROWSE; + s_idx = 0; + + s_screen = lv_obj_create(NULL); + lv_obj_set_style_bg_color(s_screen, current_theme.screen_base, 0); + lv_obj_set_style_bg_opa(s_screen, LV_OPA_COVER, 0); + lv_obj_remove_flag(s_screen, LV_OBJ_FLAG_SCROLLABLE); + + ui_chrome_header(s_screen, "CARD EMU", "/assets/icons/contactless.bin"); + + s_status = lv_label_create(s_screen); + lv_obj_set_style_text_color(s_status, current_theme.text_main, 0); + lv_obj_align(s_status, LV_ALIGN_TOP_MID, 0, 48); + + s_hint = ui_chrome_footer(s_screen, ""); + + rebuild_panel(); + refresh_text(); + dots_rebuild(); + + if (s_timer == NULL) + s_timer = lv_timer_create(refresh_cb, REFRESH_MS, NULL); + + ui_input_set_screen_handler(card_emu_input, NULL); + + ui_screen_load_owned(&s_screen, s_screen); +} diff --git a/firmware_p4/components/Applications/ui/screens/nfc/include/card_emu_ui.h b/firmware_p4/components/Applications/ui/screens/nfc/include/card_emu_ui.h new file mode 100644 index 000000000..1e9d3b0f8 --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/nfc/include/card_emu_ui.h @@ -0,0 +1,25 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#ifndef CARD_EMU_UI_H +#define CARD_EMU_UI_H + +/** + * @brief Advanced card emulator (main-menu CARD EMU): flip through saved cards + * as big card panels, create a custom card, and emulate the selected one. + */ +void ui_card_emu_open(void); + +#endif // CARD_EMU_UI_H diff --git a/firmware_p4/components/Applications/ui/screens/nfc/include/nfc_bankcard_ui.h b/firmware_p4/components/Applications/ui/screens/nfc/include/nfc_bankcard_ui.h new file mode 100644 index 000000000..ed064845a --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/nfc/include/nfc_bankcard_ui.h @@ -0,0 +1,30 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#ifndef NFC_BANKCARD_UI_H +#define NFC_BANKCARD_UI_H + +#ifdef __cplusplus +extern "C" { +#endif + +/** @brief Open the EMV bank-card face screen (scheme, chip, masked PAN, AID). */ +void ui_nfc_bankcard_open(void); + +#ifdef __cplusplus +} +#endif + +#endif // NFC_BANKCARD_UI_H diff --git a/firmware_p4/components/Applications/ui/screens/nfc/include/nfc_config_ui.h b/firmware_p4/components/Applications/ui/screens/nfc/include/nfc_config_ui.h new file mode 100644 index 000000000..9ab9c4f66 --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/nfc/include/nfc_config_ui.h @@ -0,0 +1,24 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#ifndef NFC_CONFIG_UI_H +#define NFC_CONFIG_UI_H + +/** + * @brief Simulated NFC settings + an honest SPI-bus diagnostic. + */ +void ui_nfc_config_open(void); + +#endif // NFC_CONFIG_UI_H diff --git a/firmware_p4/components/Applications/ui/screens/nfc/include/nfc_desfire_ui.h b/firmware_p4/components/Applications/ui/screens/nfc/include/nfc_desfire_ui.h new file mode 100644 index 000000000..920c57db5 --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/nfc/include/nfc_desfire_ui.h @@ -0,0 +1,30 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#ifndef NFC_DESFIRE_UI_H +#define NFC_DESFIRE_UI_H + +#ifdef __cplusplus +extern "C" { +#endif + +/** @brief Open the DESFire auth-result + file byte-dump screen. */ +void ui_nfc_desfire_open(void); + +#ifdef __cplusplus +} +#endif + +#endif // NFC_DESFIRE_UI_H diff --git a/firmware_p4/components/Applications/ui/screens/nfc/include/nfc_emulate_ui.h b/firmware_p4/components/Applications/ui/screens/nfc/include/nfc_emulate_ui.h new file mode 100644 index 000000000..6008b6114 --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/nfc/include/nfc_emulate_ui.h @@ -0,0 +1,30 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#ifndef NFC_EMULATE_UI_H +#define NFC_EMULATE_UI_H + +#ifdef __cplusplus +extern "C" { +#endif + +/** @brief Open the NFC Emulate screen (stub list of saved cards). */ +void ui_nfc_emulate_open(void); + +#ifdef __cplusplus +} +#endif + +#endif // NFC_EMULATE_UI_H diff --git a/firmware_p4/components/Applications/ui/screens/badusb/include/ui_badusb_layout.h b/firmware_p4/components/Applications/ui/screens/nfc/include/nfc_felica_ui.h similarity index 81% rename from firmware_p4/components/Applications/ui/screens/badusb/include/ui_badusb_layout.h rename to firmware_p4/components/Applications/ui/screens/nfc/include/nfc_felica_ui.h index 9b5abc2dc..d3da35b38 100644 --- a/firmware_p4/components/Applications/ui/screens/badusb/include/ui_badusb_layout.h +++ b/firmware_p4/components/Applications/ui/screens/nfc/include/nfc_felica_ui.h @@ -13,18 +13,18 @@ // You should have received a copy of the GNU General Public License // along with TentacleOS. If not, see . -#ifndef UI_BADUSB_LAYOUT_H -#define UI_BADUSB_LAYOUT_H +#ifndef NFC_FELICA_UI_H +#define NFC_FELICA_UI_H #ifdef __cplusplus extern "C" { #endif -/** @brief Open the BadUSB layout selection screen. */ -void ui_badusb_layout_open(void); +/** @brief Open the FeliCa service-list + decoded blocks screen. */ +void ui_nfc_felica_open(void); #ifdef __cplusplus } #endif -#endif // UI_BADUSB_LAYOUT_H +#endif // NFC_FELICA_UI_H diff --git a/firmware_p4/components/Applications/ui/screens/nfc/include/nfc_iso15693_ui.h b/firmware_p4/components/Applications/ui/screens/nfc/include/nfc_iso15693_ui.h new file mode 100644 index 000000000..759b1b516 --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/nfc/include/nfc_iso15693_ui.h @@ -0,0 +1,30 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#ifndef NFC_ISO15693_UI_H +#define NFC_ISO15693_UI_H + +#ifdef __cplusplus +extern "C" { +#endif + +/** @brief Open the NFC-V / ISO15693 block memory-map grid screen. */ +void ui_nfc_iso15693_open(void); + +#ifdef __cplusplus +} +#endif + +#endif // NFC_ISO15693_UI_H diff --git a/firmware_p4/components/Applications/ui/screens/nfc/include/nfc_keydict_ui.h b/firmware_p4/components/Applications/ui/screens/nfc/include/nfc_keydict_ui.h new file mode 100644 index 000000000..21511c52a --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/nfc/include/nfc_keydict_ui.h @@ -0,0 +1,30 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#ifndef NFC_KEYDICT_UI_H +#define NFC_KEYDICT_UI_H + +#ifdef __cplusplus +extern "C" { +#endif + +/** @brief Open the NFC key-dictionary screen (6-byte hex keys tagged by origin). */ +void ui_nfc_keydict_open(void); + +#ifdef __cplusplus +} +#endif + +#endif // NFC_KEYDICT_UI_H diff --git a/firmware_p4/components/Applications/ui/screens/nfc/include/nfc_ndef_ui.h b/firmware_p4/components/Applications/ui/screens/nfc/include/nfc_ndef_ui.h new file mode 100644 index 000000000..a7ebbf5be --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/nfc/include/nfc_ndef_ui.h @@ -0,0 +1,30 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#ifndef NFC_NDEF_UI_H +#define NFC_NDEF_UI_H + +#ifdef __cplusplus +extern "C" { +#endif + +/** @brief Open the NDEF record-list screen (typed records + payload preview). */ +void ui_nfc_ndef_open(void); + +#ifdef __cplusplus +} +#endif + +#endif // NFC_NDEF_UI_H diff --git a/firmware_p4/components/Applications/ui/screens/nfc/include/nfc_p2p_ui.h b/firmware_p4/components/Applications/ui/screens/nfc/include/nfc_p2p_ui.h new file mode 100644 index 000000000..3b4088c7a --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/nfc/include/nfc_p2p_ui.h @@ -0,0 +1,30 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#ifndef NFC_P2P_UI_H +#define NFC_P2P_UI_H + +#ifdef __cplusplus +extern "C" { +#endif + +/** @brief Open the NFC P2P share screen (link diagram + SNEP handshake ladder). */ +void ui_nfc_p2p_open(void); + +#ifdef __cplusplus +} +#endif + +#endif // NFC_P2P_UI_H diff --git a/firmware_p4/components/Applications/ui/screens/nfc/include/nfc_read_ui.h b/firmware_p4/components/Applications/ui/screens/nfc/include/nfc_read_ui.h new file mode 100644 index 000000000..f963e8a95 --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/nfc/include/nfc_read_ui.h @@ -0,0 +1,25 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#ifndef NFC_READ_UI_H +#define NFC_READ_UI_H + +/** + * @brief Simulated tag reader: animated field scan that "finds" a tag and shows + * its UID/type/ATQA/SAK; OK saves it to the library. + */ +void ui_nfc_read_open(void); + +#endif // NFC_READ_UI_H diff --git a/firmware_p4/components/Applications/ui/screens/nfc/include/nfc_saved_ui.h b/firmware_p4/components/Applications/ui/screens/nfc/include/nfc_saved_ui.h new file mode 100644 index 000000000..0b5a20a4f --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/nfc/include/nfc_saved_ui.h @@ -0,0 +1,24 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#ifndef NFC_SAVED_UI_H +#define NFC_SAVED_UI_H + +/** + * @brief Saved-card library: lists cards; OK shows details + delete (confirm). + */ +void ui_nfc_saved_open(void); + +#endif // NFC_SAVED_UI_H diff --git a/firmware_p4/components/Applications/ui/screens/nfc/include/nfc_scan_ui.h b/firmware_p4/components/Applications/ui/screens/nfc/include/nfc_scan_ui.h new file mode 100644 index 000000000..919158b6b --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/nfc/include/nfc_scan_ui.h @@ -0,0 +1,34 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#ifndef NFC_SCAN_UI_H +#define NFC_SCAN_UI_H + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * @brief Simulated NFC "Identify" screen (MOCK): a lit card lists the NFC-A/B/F/V + * technologies while a sweeping highlight polls each row, marking one + * present (ISO14443A + UID) and the rest absent, ending on a summary. + */ +void ui_nfc_scan_open(void); + +#ifdef __cplusplus +} +#endif + +#endif // NFC_SCAN_UI_H diff --git a/firmware_p4/components/Applications/ui/screens/nfc/include/nfc_sim.h b/firmware_p4/components/Applications/ui/screens/nfc/include/nfc_sim.h new file mode 100644 index 000000000..9b3194fe6 --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/nfc/include/nfc_sim.h @@ -0,0 +1,123 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +/** + * @file nfc_sim.h + * @brief NFC SIMULATION model and saved-card library. + * + * The real ST25R3916 reader is on the shared SPI3 bus, whose MISO line is tied + * to LCD-RST by a board jumper and cannot be read reliably, so the NFC submenu + * runs as a faithful SIMULATION instead of touching the radio. This module is + * the shared card model plus a small saved "library" persisted in NVS, used by + * the Read / Saved / Write / Emulate screens. No hardware is accessed. + */ + +#ifndef NFC_SIM_H +#define NFC_SIM_H + +#ifdef __cplusplus +extern "C" { +#endif + +#include +#include + +#define NFC_SIM_MAX_SAVED 16 +#define NFC_SIM_NAME_LEN 20 +#define NFC_SIM_TYPE_LEN 24 + +/** + * @brief A simulated NFC card / tag record. + */ +typedef struct { + char name[NFC_SIM_NAME_LEN]; + char type[NFC_SIM_TYPE_LEN]; + uint8_t uid[7]; + uint8_t uid_len; ///< UID length in bytes (4 or 7) + uint16_t atqa; + uint8_t sak; +} nfc_sim_card_t; + +/** + * @brief Load the saved library from NVS (idempotent; seeds presets on first run). + */ +void nfc_sim_init(void); + +/** + * @brief Get the number of cards currently in the saved library. + * + * @return Count of saved cards. + */ +int nfc_sim_saved_count(void); + +/** + * @brief Get a saved card by index. + * + * @param index Zero-based index into the saved library. + * @return Pointer to the card, or NULL if @p index is out of range. + */ +const nfc_sim_card_t *nfc_sim_saved_get(int index); + +/** + * @brief Append a card to the library and persist. + * + * @param card Card to append. Caller retains ownership. + * @return true on success, false if the library is full. + */ +bool nfc_sim_add(const nfc_sim_card_t *card); + +/** + * @brief Remove a card by index and persist. + * + * @param index Zero-based index of the card to remove. + */ +void nfc_sim_remove(int index); + +/** + * @brief Synthesize a fresh "discovered" tag (random UID from a realistic template). + * + * @param[out] out Destination card record. + */ +void nfc_sim_random_card(nfc_sim_card_t *out); + +/** + * @brief Number of card templates (for the editor's type cycler). + * + * @return Template count. + */ +int nfc_sim_template_count(void); + +/** + * @brief Build a card of a SPECIFIC template @p tmpl with a random UID (editor). + * + * @param tmpl Template index in range [0, nfc_sim_template_count()). + * @param[out] out Destination card record. + */ +void nfc_sim_make_card(int tmpl, nfc_sim_card_t *out); + +/** + * @brief Format a card UID as "DE:AD:BE:EF" into @p buf. + * + * @param card Card whose UID to format. + * @param[out] buf Destination buffer. + * @param buflen Size of @p buf in bytes. + */ +void nfc_sim_format_uid(const nfc_sim_card_t *card, char *buf, int buflen); + +#ifdef __cplusplus +} +#endif + +#endif // NFC_SIM_H diff --git a/firmware_p4/components/Applications/ui/screens/nfc/include/nfc_ui_common.h b/firmware_p4/components/Applications/ui/screens/nfc/include/nfc_ui_common.h new file mode 100644 index 000000000..f76358974 --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/nfc/include/nfc_ui_common.h @@ -0,0 +1,102 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +/** + * @file nfc_ui_common.h + * @brief Shared visual kit for the NFC simulation screens. + * + * A styled header bar, a credit-card-style panel that renders a card, and an + * expanding-ring "field" animation. Keeps Read/Saved/Write/Emulate consistent + * and polished. + */ + +#ifndef NFC_UI_COMMON_H +#define NFC_UI_COMMON_H + +#include "lvgl.h" + +#include "nfc_sim.h" + +/** + * @brief Accent title + underline at the top of @p parent. + * + * @param parent Parent object to attach the header to. + * @param title Title text. + * @return The title label object. + */ +lv_obj_t *nfc_ui_header(lv_obj_t *parent, const char *title); + +/** + * @brief A card-style panel rendering @p card. Caller aligns the returned object. + * + * @param parent Parent object to attach the panel to. + * @param card Card to render. + * @return The created panel object. + */ +lv_obj_t *nfc_ui_card_panel(lv_obj_t *parent, const nfc_sim_card_t *card); + +/** + * @brief The accent colour of @p card's palette (for matching rings/glow to a card). + * + * @param card Card whose palette accent to return. + * @return The accent colour. + */ +lv_color_t nfc_ui_card_color(const nfc_sim_card_t *card); + +/** + * @brief Expanding concentric-ring field animation (a "broadcasting" NFC field). + */ +typedef struct { + lv_obj_t *ring[3]; +} nfc_ui_field_t; + +/** + * @brief Create the 3 rings centered in @p parent in @p color. + * + * @param[out] f Field animation state to initialize. + * @param parent Parent object to attach the rings to. + * @param color Ring colour. + */ +void nfc_ui_field_create(nfc_ui_field_t *f, lv_obj_t *parent, lv_color_t color); + +/** + * @brief Advance the ring animation; call each frame with elapsed-since-start ms. + * + * @param f Field animation state. + * @param elapsed_ms Milliseconds elapsed since the animation start. + */ +void nfc_ui_field_tick(nfc_ui_field_t *f, uint32_t elapsed_ms); + +/** + * @brief Short, pleasant speaker cues for the NFC screens. + * + * Fire-and-forget: each call spawns a tiny worker that renders the tones via the + * self-contained audio_i2s_play_song() (opens/closes its own I2S channel, so it + * works even though the persistent audio task in kernel.c is disabled). Safe to + * call from the LVGL thread - the blocking playback runs off-thread. + */ +typedef enum { + NFC_SND_FOUND, ///< rising two-note blip - a tag was detected + NFC_SND_SAVE, ///< short confirm tick - a card was saved +} nfc_ui_sound_t; + +/** + * @brief Play a one-shot NFC cue on the speaker. No-op if a cue is already playing. + * + * @param kind Which cue to play. + */ +void nfc_ui_play_sound(nfc_ui_sound_t kind); + +#endif // NFC_UI_COMMON_H diff --git a/firmware_p4/components/Applications/ui/screens/nfc/include/nfc_ultralight_ui.h b/firmware_p4/components/Applications/ui/screens/nfc/include/nfc_ultralight_ui.h new file mode 100644 index 000000000..f9e708872 --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/nfc/include/nfc_ultralight_ui.h @@ -0,0 +1,30 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#ifndef NFC_ULTRALIGHT_UI_H +#define NFC_ULTRALIGHT_UI_H + +#ifdef __cplusplus +extern "C" { +#endif + +/** @brief Open the Ultralight/NTAG page hex-dump screen. */ +void ui_nfc_ultralight_open(void); + +#ifdef __cplusplus +} +#endif + +#endif // NFC_ULTRALIGHT_UI_H diff --git a/firmware_p4/components/Applications/ui/screens/nfc/include/nfc_write_ui.h b/firmware_p4/components/Applications/ui/screens/nfc/include/nfc_write_ui.h new file mode 100644 index 000000000..8e09c878c --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/nfc/include/nfc_write_ui.h @@ -0,0 +1,24 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#ifndef NFC_WRITE_UI_H +#define NFC_WRITE_UI_H + +/** + * @brief Simulated tag writer: pick a saved card, animate place->write->done. + */ +void ui_nfc_write_open(void); + +#endif // NFC_WRITE_UI_H diff --git a/firmware_p4/components/Applications/ui/screens/nfc/nfc_bankcard_ui.c b/firmware_p4/components/Applications/ui/screens/nfc/nfc_bankcard_ui.c new file mode 100644 index 000000000..fb8ad8b1b --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/nfc/nfc_bankcard_ui.c @@ -0,0 +1,196 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#include "nfc_bankcard_ui.h" + +#include "lvgl.h" +#include "st7789.h" + +#include "ui_chrome.h" +#include "ui_manager.h" +#include "ui_theme.h" + +#define MX 8 +#define BODY_H (LCD_V_RES - UI_CHROME_HEADER_H - UI_CHROME_FOOTER_H) +#define CONTENT_W (LCD_H_RES - 2 * MX) +#define ROW_GAP 6 + +#define CARD_H 120 +#define CARD_RAD 12 +#define CARD_PAD_X 14 + +#define CHIP_X 14 +#define CHIP_Y 40 +#define CHIP_W 26 +#define CHIP_H 20 + +#define PAN_Y 66 +#define VISA_X (-12) +#define VISA_Y 12 +#define EDGE_Y (-12) + +#define COL_DIM 0x8A8594 +#define COL_WHITE 0xFFFFFF +#define COL_GOLD 0xD9A521 +#define CARD_TOP 0x3A2F6A +#define CARD_BOT 0x211A4E + +#define HDR_TITLE "BANK CARD" +#define HDR_ICON "/assets/icons/nfc.bin" +#define FOOTER_HINT "OK read UP/DOWN aid BACK" + +#define TXT_VISA "VISA" +#define TXT_PAN "4085 **** **** 6027" +#define TXT_VALID "VALID THRU 08/27" +#define TXT_CREDIT "CREDIT" + +#define KV_APP_K "App label" +#define KV_APP_V "VISA CREDIT" +#define KV_AID_K "AID" +#define KV_AID_V "A0000000031010" +#define KV_CTY_K "Country" +#define KV_CTY_V "076 - BRL" + +static lv_obj_t *s_screen = NULL; + +static void make_kv(lv_obj_t *parent, const char *k, const char *v, lv_color_t vcol) { + lv_obj_t *row = lv_obj_create(parent); + lv_obj_remove_flag(row, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_remove_flag(row, LV_OBJ_FLAG_CLICKABLE); + lv_obj_set_width(row, lv_pct(100)); + lv_obj_set_height(row, LV_SIZE_CONTENT); + lv_obj_set_style_bg_opa(row, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(row, 0, 0); + lv_obj_set_style_pad_all(row, 0, 0); + lv_obj_set_flex_flow(row, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align( + row, LV_FLEX_ALIGN_SPACE_BETWEEN, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + + lv_obj_t *kl = lv_label_create(row); + lv_label_set_text(kl, k); + lv_obj_set_style_text_font(kl, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(kl, lv_color_hex(COL_DIM), 0); + + lv_obj_t *vl = lv_label_create(row); + lv_label_set_text(vl, v); + lv_obj_set_style_text_font(vl, &lv_font_montserrat_14, 0); + lv_obj_set_style_text_color(vl, vcol, 0); +} + +static void build_card(lv_obj_t *parent) { + lv_obj_t *card = lv_obj_create(parent); + lv_obj_remove_flag(card, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_remove_flag(card, LV_OBJ_FLAG_CLICKABLE); + lv_obj_set_size(card, CONTENT_W, CARD_H); + lv_obj_set_style_radius(card, CARD_RAD, 0); + lv_obj_set_style_bg_color(card, lv_color_hex(CARD_TOP), 0); + lv_obj_set_style_bg_grad_color(card, lv_color_hex(CARD_BOT), 0); + lv_obj_set_style_bg_grad_dir(card, LV_GRAD_DIR_VER, 0); + lv_obj_set_style_bg_opa(card, LV_OPA_COVER, 0); + lv_obj_set_style_border_color(card, current_theme.border_accent, 0); + lv_obj_set_style_border_width(card, 1, 0); + lv_obj_set_style_pad_all(card, 0, 0); + lv_obj_set_style_shadow_width(card, 16, 0); + lv_obj_set_style_shadow_color(card, lv_color_black(), 0); + lv_obj_set_style_shadow_opa(card, LV_OPA_40, 0); + + lv_obj_t *visa = lv_label_create(card); + lv_label_set_text(visa, TXT_VISA); + lv_obj_set_style_text_font(visa, &lv_font_montserrat_16, 0); + lv_obj_set_style_text_color(visa, lv_color_hex(COL_WHITE), 0); + lv_obj_align(visa, LV_ALIGN_TOP_RIGHT, VISA_X, VISA_Y); + + lv_obj_t *chip = lv_obj_create(card); + lv_obj_remove_flag(chip, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(chip, CHIP_W, CHIP_H); + lv_obj_align(chip, LV_ALIGN_TOP_LEFT, CHIP_X, CHIP_Y); + lv_obj_set_style_radius(chip, 3, 0); + lv_obj_set_style_bg_color(chip, lv_color_hex(COL_GOLD), 0); + lv_obj_set_style_bg_opa(chip, LV_OPA_80, 0); + lv_obj_set_style_border_width(chip, 0, 0); + lv_obj_set_style_pad_all(chip, 0, 0); + + lv_obj_t *pan = lv_label_create(card); + lv_label_set_text(pan, TXT_PAN); + lv_obj_set_style_text_font(pan, &lv_font_montserrat_14, 0); + lv_obj_set_style_text_color(pan, lv_color_hex(COL_WHITE), 0); + lv_obj_align(pan, LV_ALIGN_TOP_LEFT, CHIP_X, PAN_Y); + + lv_obj_t *valid = lv_label_create(card); + lv_label_set_text(valid, TXT_VALID); + lv_obj_set_style_text_font(valid, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(valid, lv_color_hex(COL_WHITE), 0); + lv_obj_set_style_text_opa(valid, LV_OPA_70, 0); + lv_obj_align(valid, LV_ALIGN_BOTTOM_LEFT, CHIP_X, EDGE_Y); + + lv_obj_t *credit = lv_label_create(card); + lv_label_set_text(credit, TXT_CREDIT); + lv_obj_set_style_text_font(credit, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(credit, lv_color_hex(COL_WHITE), 0); + lv_obj_align(credit, LV_ALIGN_BOTTOM_RIGHT, VISA_X, EDGE_Y); +} + +static void nfc_bankcard_input(const input_event_t *ev, void *ctx) { + (void)ctx; + const bool press = (ev->action == INPUT_ACTION_PRESS); + + switch (ev->button) { + case INPUT_BTN_BACK: + case INPUT_BTN_LEFT: + if (press) + ui_switch_screen(SCREEN_NFC_MENU); + break; + default: + break; + } +} + +void ui_nfc_bankcard_open(void) { + if (s_screen != NULL) { + lv_obj_del(s_screen); + s_screen = NULL; + } + + s_screen = lv_obj_create(NULL); + lv_obj_set_style_bg_color(s_screen, current_theme.screen_base, 0); + lv_obj_set_style_bg_opa(s_screen, LV_OPA_COVER, 0); + lv_obj_remove_flag(s_screen, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_border_width(s_screen, 0, 0); + lv_obj_set_style_pad_all(s_screen, 0, 0); + + ui_chrome_header(s_screen, HDR_TITLE, HDR_ICON); + + lv_obj_t *body = lv_obj_create(s_screen); + lv_obj_remove_flag(body, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_remove_flag(body, LV_OBJ_FLAG_CLICKABLE); + lv_obj_set_size(body, LCD_H_RES, BODY_H); + lv_obj_align(body, LV_ALIGN_TOP_MID, 0, UI_CHROME_HEADER_H); + lv_obj_set_style_bg_opa(body, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(body, 0, 0); + lv_obj_set_style_pad_all(body, MX, 0); + lv_obj_set_style_pad_row(body, ROW_GAP, 0); + lv_obj_set_flex_flow(body, LV_FLEX_FLOW_COLUMN); + + build_card(body); + make_kv(body, KV_APP_K, KV_APP_V, current_theme.text_main); + make_kv(body, KV_AID_K, KV_AID_V, current_theme.border_accent); + make_kv(body, KV_CTY_K, KV_CTY_V, current_theme.text_main); + + ui_chrome_footer(s_screen, FOOTER_HINT); + + ui_input_set_screen_handler(nfc_bankcard_input, NULL); + + ui_screen_load_owned(&s_screen, s_screen); +} diff --git a/firmware_p4/components/Applications/ui/screens/nfc/nfc_config_ui.c b/firmware_p4/components/Applications/ui/screens/nfc/nfc_config_ui.c new file mode 100644 index 000000000..bac33f478 --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/nfc/nfc_config_ui.c @@ -0,0 +1,111 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#include "nfc_config_ui.h" + +#include "lvgl.h" + +#include "menu_component_ui.h" +#include "msgbox_ui.h" +#include "notify_ui.h" +#include "ui_manager.h" +#include "ui_theme.h" + +enum { CFG_FIELD, CFG_POLL, CFG_AAT, CFG_DIAG, CFG_COUNT }; +static const char *const POLL_NAMES[] = {"Slow", "Normal", "Fast"}; +#define POLL_N 3 + +static lv_obj_t *s_screen = NULL; +static menu_component_t s_menu; +static int s_poll = 1; + +static void nfc_config_input(const input_event_t *ev, void *ctx) { + (void)ctx; + const bool press = (ev->action == INPUT_ACTION_PRESS); + const bool nav = press || (ev->action == INPUT_ACTION_REPEAT); + int sel = menu_component_get_selected(&s_menu); + + switch (ev->button) { + case INPUT_BTN_BACK: + if (press) + ui_switch_screen(SCREEN_NFC_MENU); + break; + case INPUT_BTN_LEFT: + if (press) { + if (sel == CFG_POLL) { + s_poll = (s_poll - 1 + POLL_N) % POLL_N; + menu_component_set_selector_value(&s_menu, CFG_POLL, POLL_NAMES[s_poll]); + } else { + ui_switch_screen(SCREEN_NFC_MENU); + } + } + break; + case INPUT_BTN_RIGHT: + if (press && sel == CFG_POLL) { + s_poll = (s_poll + 1) % POLL_N; + menu_component_set_selector_value(&s_menu, CFG_POLL, POLL_NAMES[s_poll]); + } + break; + case INPUT_BTN_DOWN: + if (nav) + menu_component_next(&s_menu); + break; + case INPUT_BTN_UP: + if (nav) + menu_component_prev(&s_menu); + break; + case INPUT_BTN_OK: + if (press) { + if (sel == CFG_FIELD) { + menu_component_toggle_item(&s_menu, CFG_FIELD); + } else if (sel == CFG_AAT) { + notify(NOTIFY_SAVED, "Antenna tuned"); + } else if (sel == CFG_DIAG) { + msgbox_open( + LV_SYMBOL_WARNING, + "ST25R3916: no reply\nSPI3 MISO blocked\n(GPIO36 jumper) —\nrunning simulated", + NULL, + NULL, + NULL); + } + } + break; + default: + break; + } +} + +void ui_nfc_config_open(void) { + if (s_screen != NULL) { + lv_obj_del(s_screen); + s_screen = NULL; + } + s_screen = lv_obj_create(NULL); + lv_obj_set_style_bg_color(s_screen, current_theme.screen_base, 0); + lv_obj_set_style_bg_opa(s_screen, LV_OPA_COVER, 0); + lv_obj_remove_flag(s_screen, LV_OBJ_FLAG_SCROLLABLE); + + s_menu = menu_component_create(s_screen, "NFC Config", "/assets/icons/settings.bin"); + menu_component_add_toggle( + &s_menu, "/assets/icons/power_settings_new.bin", "Field on boot", false); + menu_component_add_selector( + &s_menu, "/assets/icons/autorenew.bin", "Poll rate", POLL_NAMES[s_poll]); + menu_component_add_item(&s_menu, "/assets/icons/settings_input_antenna.bin", "Antenna Tune"); + menu_component_add_item(&s_menu, "/assets/icons/troubleshoot.bin", "Bus Diagnostic"); + + ui_input_set_screen_handler(nfc_config_input, NULL); + + ui_screen_load_owned(&s_screen, s_screen); +} diff --git a/firmware_p4/components/Applications/ui/screens/nfc/nfc_desfire_ui.c b/firmware_p4/components/Applications/ui/screens/nfc/nfc_desfire_ui.c new file mode 100644 index 000000000..271fa82da --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/nfc/nfc_desfire_ui.c @@ -0,0 +1,231 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#include "nfc_desfire_ui.h" + +#include "lvgl.h" +#include "st7789.h" + +#include "ui_chrome.h" +#include "ui_manager.h" +#include "ui_theme.h" + +#define MX 8 +#define BODY_H (LCD_V_RES - UI_CHROME_HEADER_H - UI_CHROME_FOOTER_H) +#define CONTENT_W (LCD_H_RES - 2 * MX) +#define ROW_GAP 6 + +#define CARD_H 74 +#define CARD_PAD 8 +#define CARD_RAD 8 + +#define CHIP_W 58 +#define CHIP_H 16 +#define CHIP_RAD 8 + +#define DUMP_RAD 8 +#define DUMP_PAD 8 + +#define COL_DIM 0x8A8594 +#define COL_OK 0x00E676 +#define COL_CYAN 0x37E0A8 +#define COL_PANEL2 0x1A1626 +#define COL_LINE 0x2A2636 + +#define ACC_HEX "B89AFF" +#define DIM_HEX "8A8594" + +#define HDR_TITLE "DESFIRE" +#define HDR_ICON "/assets/icons/nfc.bin" +#define FOOTER_HINT "OK open UP/DOWN nav BACK" + +#define TXT_AUTH "AUTH OK" +#define TXT_ALG "AES-128" +#define KV_KEY_K "Key" +#define KV_KEY_V "#00 master" +#define KV_SES_K "Session" +#define KV_SES_V "CMAC" +#define TXT_FILE "FILE F1 - 48 B" + +#define DUMP_L0 "04 D3 A1 22 55 6A 90 00" +#define DUMP_L1 "1A FF 00 12 08 27 03 E8" +#define DUMP_L2 "00 00 00 64 00 00 27 10" +#define DUMP_L3 "#" DIM_HEX " ... +24 B#" + +static const char *const DUMP_LINES[] = {DUMP_L0, DUMP_L1, DUMP_L2, DUMP_L3}; +#define DUMP_LINE_COUNT ((int)(sizeof(DUMP_LINES) / sizeof(DUMP_LINES[0]))) + +static lv_obj_t *s_screen = NULL; + +static void make_kv(lv_obj_t *parent, const char *k, const char *v) { + lv_obj_t *row = lv_obj_create(parent); + lv_obj_remove_flag(row, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_remove_flag(row, LV_OBJ_FLAG_CLICKABLE); + lv_obj_set_width(row, lv_pct(100)); + lv_obj_set_height(row, LV_SIZE_CONTENT); + lv_obj_set_style_bg_opa(row, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(row, 0, 0); + lv_obj_set_style_pad_all(row, 0, 0); + lv_obj_set_flex_flow(row, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align( + row, LV_FLEX_ALIGN_SPACE_BETWEEN, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + + lv_obj_t *kl = lv_label_create(row); + lv_label_set_text(kl, k); + lv_obj_set_style_text_font(kl, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(kl, lv_color_hex(COL_DIM), 0); + + lv_obj_t *vl = lv_label_create(row); + lv_label_set_text(vl, v); + lv_obj_set_style_text_font(vl, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(vl, current_theme.text_main, 0); +} + +static void build_auth_card(lv_obj_t *parent) { + lv_obj_t *card = lv_obj_create(parent); + lv_obj_remove_flag(card, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_remove_flag(card, LV_OBJ_FLAG_CLICKABLE); + lv_obj_set_size(card, CONTENT_W, CARD_H); + lv_obj_set_style_radius(card, CARD_RAD, 0); + lv_obj_set_style_bg_color(card, current_theme.bg_secondary, 0); + lv_obj_set_style_bg_opa(card, LV_OPA_COVER, 0); + lv_obj_set_style_border_color(card, current_theme.border_accent, 0); + lv_obj_set_style_border_width(card, 1, 0); + lv_obj_set_style_pad_all(card, CARD_PAD, 0); + lv_obj_set_style_pad_row(card, 4, 0); + lv_obj_set_flex_flow(card, LV_FLEX_FLOW_COLUMN); + + lv_obj_t *top = lv_obj_create(card); + lv_obj_remove_flag(top, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_width(top, lv_pct(100)); + lv_obj_set_height(top, LV_SIZE_CONTENT); + lv_obj_set_style_bg_opa(top, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(top, 0, 0); + lv_obj_set_style_pad_all(top, 0, 0); + lv_obj_set_flex_flow(top, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(top, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_set_style_pad_column(top, 6, 0); + + lv_obj_t *ok = lv_label_create(top); + lv_label_set_text(ok, LV_SYMBOL_OK " " TXT_AUTH); + lv_obj_set_style_text_font(ok, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(ok, lv_color_hex(COL_OK), 0); + + lv_obj_t *spacer = lv_obj_create(top); + lv_obj_remove_flag(spacer, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_height(spacer, 1); + lv_obj_set_style_bg_opa(spacer, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(spacer, 0, 0); + lv_obj_set_style_pad_all(spacer, 0, 0); + lv_obj_set_flex_grow(spacer, 1); + + lv_obj_t *chip = lv_obj_create(top); + lv_obj_remove_flag(chip, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(chip, CHIP_W, CHIP_H); + lv_obj_set_style_radius(chip, CHIP_RAD, 0); + lv_obj_set_style_bg_color(chip, current_theme.border_accent, 0); + lv_obj_set_style_bg_opa(chip, LV_OPA_20, 0); + lv_obj_set_style_border_color(chip, current_theme.border_accent, 0); + lv_obj_set_style_border_width(chip, 1, 0); + lv_obj_set_style_pad_all(chip, 0, 0); + lv_obj_t *cl = lv_label_create(chip); + lv_label_set_text(cl, TXT_ALG); + lv_obj_set_style_text_font(cl, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(cl, current_theme.border_accent, 0); + lv_obj_center(cl); + + make_kv(card, KV_KEY_K, KV_KEY_V); + make_kv(card, KV_SES_K, KV_SES_V); +} + +static void build_dump(lv_obj_t *parent) { + lv_obj_t *lbl = lv_label_create(parent); + lv_label_set_text(lbl, TXT_FILE); + lv_obj_set_style_text_font(lbl, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(lbl, lv_color_hex(COL_DIM), 0); + + lv_obj_t *box = lv_obj_create(parent); + lv_obj_remove_flag(box, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_remove_flag(box, LV_OBJ_FLAG_CLICKABLE); + lv_obj_set_width(box, CONTENT_W); + lv_obj_set_height(box, LV_SIZE_CONTENT); + lv_obj_set_style_radius(box, DUMP_RAD, 0); + lv_obj_set_style_bg_color(box, lv_color_hex(COL_PANEL2), 0); + lv_obj_set_style_bg_opa(box, LV_OPA_COVER, 0); + lv_obj_set_style_border_color(box, lv_color_hex(COL_LINE), 0); + lv_obj_set_style_border_width(box, 1, 0); + lv_obj_set_style_pad_all(box, DUMP_PAD, 0); + lv_obj_set_style_pad_row(box, 4, 0); + lv_obj_set_flex_flow(box, LV_FLEX_FLOW_COLUMN); + + for (int i = 0; i < DUMP_LINE_COUNT; i++) { + lv_obj_t *ln = lv_label_create(box); + lv_label_set_recolor(ln, true); + lv_label_set_text(ln, DUMP_LINES[i]); + lv_obj_set_style_text_font(ln, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(ln, lv_color_hex(COL_CYAN), 0); + } +} + +static void nfc_desfire_input(const input_event_t *ev, void *ctx) { + (void)ctx; + const bool press = (ev->action == INPUT_ACTION_PRESS); + + switch (ev->button) { + case INPUT_BTN_BACK: + case INPUT_BTN_LEFT: + if (press) + ui_switch_screen(SCREEN_NFC_MENU); + break; + default: + break; + } +} + +void ui_nfc_desfire_open(void) { + if (s_screen != NULL) { + lv_obj_del(s_screen); + s_screen = NULL; + } + + s_screen = lv_obj_create(NULL); + lv_obj_set_style_bg_color(s_screen, current_theme.screen_base, 0); + lv_obj_set_style_bg_opa(s_screen, LV_OPA_COVER, 0); + lv_obj_remove_flag(s_screen, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_border_width(s_screen, 0, 0); + lv_obj_set_style_pad_all(s_screen, 0, 0); + + ui_chrome_header(s_screen, HDR_TITLE, HDR_ICON); + + lv_obj_t *body = lv_obj_create(s_screen); + lv_obj_remove_flag(body, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_remove_flag(body, LV_OBJ_FLAG_CLICKABLE); + lv_obj_set_size(body, LCD_H_RES, BODY_H); + lv_obj_align(body, LV_ALIGN_TOP_MID, 0, UI_CHROME_HEADER_H); + lv_obj_set_style_bg_opa(body, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(body, 0, 0); + lv_obj_set_style_pad_all(body, MX, 0); + lv_obj_set_style_pad_row(body, ROW_GAP, 0); + lv_obj_set_flex_flow(body, LV_FLEX_FLOW_COLUMN); + + build_auth_card(body); + build_dump(body); + + ui_chrome_footer(s_screen, FOOTER_HINT); + + ui_input_set_screen_handler(nfc_desfire_input, NULL); + + ui_screen_load_owned(&s_screen, s_screen); +} diff --git a/firmware_p4/components/Applications/ui/screens/nfc/nfc_emulate_ui.c b/firmware_p4/components/Applications/ui/screens/nfc/nfc_emulate_ui.c new file mode 100644 index 000000000..c1e432b00 --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/nfc/nfc_emulate_ui.c @@ -0,0 +1,392 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#include "nfc_emulate_ui.h" + +#include "lvgl.h" + +#include "st7789.h" + +#include "assets_manager.h" +#include "keyboard_ui.h" +#include "msgbox_ui.h" +#include "nfc_sim.h" +#include "nfc_ui_common.h" +#include "page_dots_ui.h" +#include "ui_chrome.h" +#include "ui_feedback.h" +#include "ui_manager.h" +#include "ui_theme.h" + +#define TICK_MS 33 +#define CARD_ICON "/assets/icons/contactless.bin" +#define COL_DIM 0x8A8594 +#define SIG_GREEN 0x00E676 +#define TX_DOTS 3 +#define CARD_Y_OFS (-24) +#define FIELD_BOX_W 210 +#define FIELD_BOX_H 122 + +#define MAX_CARDS 10 +#define WALLET_FRONT_Y (-24) +#define WALLET_STEP 22 +#define WALLET_TAP_Y 96 +#define BACK1_W 196 +#define BACK2_W 182 +#define BACK_H 118 + +enum { EM_NONE, EM_RUN }; + +static lv_obj_t *s_screen = NULL; +static lv_obj_t *s_body = NULL; +static page_dots_t s_dots; +static int s_sel = 0; +static int s_count = 0; +static lv_timer_t *s_tick_timer = NULL; +static bool s_empty = false; + +static lv_obj_t *s_ov = NULL; +static nfc_ui_field_t s_ov_field; +static nfc_sim_card_t s_card; +static int s_em = EM_NONE; +static uint32_t s_em_start = 0; + +static void opa_cb(void *var, int32_t v) { + lv_obj_set_style_opa((lv_obj_t *)var, (lv_opa_t)v, 0); +} + +static void transy_cb(void *var, int32_t v) { + lv_obj_set_style_translate_y((lv_obj_t *)var, v, 0); +} + +static void blink_loop(lv_obj_t *o, uint32_t period, int32_t delay) { + lv_anim_t a; + lv_anim_init(&a); + lv_anim_set_var(&a, o); + lv_anim_set_exec_cb(&a, opa_cb); + lv_anim_set_values(&a, 255, 110); + lv_anim_set_duration(&a, period / 2); + lv_anim_set_playback_duration(&a, period / 2); + lv_anim_set_delay(&a, delay); + lv_anim_set_repeat_count(&a, LV_ANIM_REPEAT_INFINITE); + lv_anim_start(&a); +} + +static void card_rise(lv_obj_t *o) { + lv_anim_t a; + lv_anim_init(&a); + lv_anim_set_var(&a, o); + lv_anim_set_exec_cb(&a, transy_cb); + lv_anim_set_values(&a, 26, 0); + lv_anim_set_duration(&a, 300); + lv_anim_set_path_cb(&a, lv_anim_path_ease_out); + lv_anim_start(&a); +} + +static lv_obj_t *lit_panel(lv_obj_t *parent, int w, int h) { + lv_obj_t *p = lv_obj_create(parent); + lv_obj_remove_flag(p, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(p, w, h); + lv_obj_set_style_radius(p, 13, 0); + lv_obj_set_style_bg_color(p, current_theme.bg_secondary, 0); + lv_obj_set_style_bg_opa(p, LV_OPA_COVER, 0); + lv_obj_set_style_bg_grad_dir(p, LV_GRAD_DIR_NONE, 0); + lv_obj_set_style_border_width(p, 1, 0); + lv_obj_set_style_border_color(p, current_theme.border_accent, 0); + lv_obj_set_style_shadow_color(p, current_theme.border_accent, 0); + lv_obj_set_style_shadow_width(p, 16, 0); + lv_obj_set_style_shadow_opa(p, LV_OPA_40, 0); + lv_obj_set_style_shadow_spread(p, -4, 0); + return p; +} + +static void build_empty(const char *icon, const char *title, const char *sub) { + lv_obj_t *card = lit_panel(s_screen, 200, 104); + lv_obj_align(card, LV_ALIGN_CENTER, 0, 6); + lv_obj_set_style_pad_all(card, 10, 0); + lv_obj_set_flex_flow(card, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(card, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_set_style_pad_row(card, 6, 0); + + lv_image_dsc_t *dsc = assets_get(icon); + if (dsc != NULL) { + lv_obj_t *img = lv_image_create(card); + lv_image_set_src(img, dsc); + lv_obj_set_style_image_recolor(img, current_theme.text_main, 0); + lv_obj_set_style_image_recolor_opa(img, LV_OPA_COVER, 0); + } + + lv_obj_t *t = lv_label_create(card); + lv_label_set_text(t, title); + lv_obj_set_style_text_font(t, &lv_font_montserrat_14, 0); + lv_obj_set_style_text_color(t, current_theme.text_main, 0); + + lv_obj_t *s = lv_label_create(card); + lv_label_set_text(s, sub); + lv_obj_set_style_text_font(s, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(s, lv_color_hex(COL_DIM), 0); +} + +static lv_obj_t * +wallet_back(lv_obj_t *parent, const nfc_sim_card_t *c, int w, int y_ofs, lv_opa_t opa) { + lv_color_t edge = nfc_ui_card_color(c); + lv_obj_t *b = lv_obj_create(parent); + lv_obj_remove_flag(b, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_remove_flag(b, LV_OBJ_FLAG_CLICKABLE); + lv_obj_set_size(b, w, BACK_H); + lv_obj_set_style_radius(b, 14, 0); + lv_obj_set_style_bg_color(b, current_theme.bg_secondary, 0); + lv_obj_set_style_bg_opa(b, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(b, 1, 0); + lv_obj_set_style_border_color(b, edge, 0); + lv_obj_set_style_shadow_color(b, edge, 0); + lv_obj_set_style_shadow_width(b, 12, 0); + lv_obj_set_style_shadow_opa(b, LV_OPA_30, 0); + lv_obj_set_style_shadow_spread(b, -4, 0); + lv_obj_set_style_opa(b, opa, 0); + lv_obj_align(b, LV_ALIGN_CENTER, 0, y_ofs); + return b; +} + +static void wallet_build(void) { + if (s_body != NULL) { + lv_obj_del(s_body); + s_body = NULL; + } + + s_body = lv_obj_create(s_screen); + lv_obj_remove_flag(s_body, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_remove_flag(s_body, LV_OBJ_FLAG_CLICKABLE); + lv_obj_set_size(s_body, lv_pct(100), LCD_V_RES - UI_CHROME_HEADER_H - UI_CHROME_FOOTER_H); + lv_obj_align(s_body, LV_ALIGN_TOP_MID, 0, UI_CHROME_HEADER_H); + lv_obj_set_style_bg_opa(s_body, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(s_body, 0, 0); + lv_obj_set_style_pad_all(s_body, 0, 0); + + if (s_count >= 3) + wallet_back(s_body, + nfc_sim_saved_get((s_sel + 2) % s_count), + BACK2_W, + WALLET_FRONT_Y + 2 * WALLET_STEP, + LV_OPA_40); + if (s_count >= 2) + wallet_back(s_body, + nfc_sim_saved_get((s_sel + 1) % s_count), + BACK1_W, + WALLET_FRONT_Y + WALLET_STEP, + LV_OPA_70); + + lv_obj_t *front = nfc_ui_card_panel(s_body, nfc_sim_saved_get(s_sel)); + lv_obj_align(front, LV_ALIGN_CENTER, 0, WALLET_FRONT_Y); + card_rise(front); + + lv_obj_t *tap = lv_obj_create(s_body); + lv_obj_remove_flag(tap, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_remove_flag(tap, LV_OBJ_FLAG_CLICKABLE); + lv_obj_set_size(tap, LV_SIZE_CONTENT, LV_SIZE_CONTENT); + lv_obj_set_style_bg_opa(tap, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(tap, 0, 0); + lv_obj_set_style_pad_all(tap, 0, 0); + lv_obj_set_style_pad_column(tap, 7, 0); + lv_obj_set_flex_flow(tap, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(tap, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_align(tap, LV_ALIGN_CENTER, 0, WALLET_TAP_Y); + + lv_obj_t *dot = lv_obj_create(tap); + lv_obj_remove_flag(dot, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(dot, 8, 8); + lv_obj_set_style_radius(dot, LV_RADIUS_CIRCLE, 0); + lv_obj_set_style_border_width(dot, 0, 0); + lv_obj_set_style_bg_color(dot, lv_color_hex(SIG_GREEN), 0); + lv_obj_set_style_bg_opa(dot, LV_OPA_COVER, 0); + blink_loop(dot, 900, 0); + + lv_obj_t *txt = lv_label_create(tap); + lv_label_set_text(txt, "tap to broadcast"); + lv_obj_set_style_text_font(txt, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(txt, lv_color_hex(SIG_GREEN), 0); + + s_dots = page_dots_create(s_body, s_count, LV_ALIGN_BOTTOM_MID, 0, -4); + page_dots_set(&s_dots, s_sel); +} + +static void overlay_close(void) { + if (s_ov) { + lv_obj_del(s_ov); + s_ov = NULL; + } + for (int i = 0; i < 3; i++) + s_ov_field.ring[i] = NULL; + s_em = EM_NONE; +} + +static void overlay_start(const nfc_sim_card_t *c) { + s_card = *c; + + s_ov = lv_obj_create(s_screen); + lv_obj_set_size(s_ov, lv_pct(100), lv_pct(100)); + lv_obj_center(s_ov); + lv_obj_remove_flag(s_ov, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_bg_color(s_ov, lv_color_black(), 0); + lv_obj_set_style_bg_opa(s_ov, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(s_ov, 0, 0); + lv_obj_set_style_pad_all(s_ov, 0, 0); + + ui_chrome_header_overlay(s_ov, "EMULATE", CARD_ICON); + ui_chrome_footer(s_ov, "BACK Stop"); + + lv_obj_t *field_box = lv_obj_create(s_ov); + lv_obj_remove_flag(field_box, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_remove_flag(field_box, LV_OBJ_FLAG_CLICKABLE); + lv_obj_set_size(field_box, FIELD_BOX_W, FIELD_BOX_H); + lv_obj_align(field_box, LV_ALIGN_CENTER, 0, CARD_Y_OFS); + lv_obj_set_style_bg_opa(field_box, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(field_box, 0, 0); + lv_obj_set_style_pad_all(field_box, 0, 0); + + nfc_ui_field_create(&s_ov_field, field_box, ui_theme_get_accent()); + + lv_obj_t *card = nfc_ui_card_panel(s_ov, &s_card); + lv_obj_align(card, LV_ALIGN_CENTER, 0, CARD_Y_OFS); + lv_obj_fade_in(card, 280, 0); + card_rise(card); + + lv_obj_t *bc = lv_label_create(s_ov); + lv_label_set_text(bc, "Broadcasting"); + lv_obj_set_style_text_font(bc, &lv_font_montserrat_14, 0); + lv_obj_set_style_text_color(bc, current_theme.border_accent, 0); + lv_obj_align(bc, LV_ALIGN_BOTTOM_MID, 0, -46); + blink_loop(bc, 900, 0); + + lv_obj_t *dots = lv_obj_create(s_ov); + lv_obj_remove_flag(dots, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(dots, 44, 12); + lv_obj_align(dots, LV_ALIGN_BOTTOM_MID, 0, -30); + lv_obj_set_style_bg_opa(dots, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(dots, 0, 0); + lv_obj_set_style_pad_all(dots, 0, 0); + lv_obj_set_flex_flow(dots, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(dots, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_set_style_pad_column(dots, 6, 0); + for (int i = 0; i < TX_DOTS; i++) { + lv_obj_t *d = lv_obj_create(dots); + lv_obj_remove_flag(d, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(d, 6, 6); + lv_obj_set_style_radius(d, LV_RADIUS_CIRCLE, 0); + lv_obj_set_style_border_width(d, 0, 0); + lv_obj_set_style_bg_color(d, current_theme.border_accent, 0); + lv_obj_set_style_bg_opa(d, LV_OPA_COVER, 0); + blink_loop(d, 900, (int32_t)i * 300); + } + + s_em = EM_RUN; + s_em_start = lv_tick_get(); + ui_feedback(UI_FB_EMULATE); +} + +static void field_tick_cb(lv_timer_t *t) { + if (lv_screen_active() != s_screen) { + lv_timer_delete(t); + s_tick_timer = NULL; + return; + } + if (s_em == EM_RUN) + nfc_ui_field_tick(&s_ov_field, lv_tick_get() - s_em_start); +} + +static void nfc_emulate_input(const input_event_t *ev, void *ctx) { + (void)ctx; + const bool press = (ev->action == INPUT_ACTION_PRESS); + const bool nav = press || (ev->action == INPUT_ACTION_REPEAT); + + if (s_em == EM_RUN) { + if (ev->button == INPUT_BTN_BACK && press) + overlay_close(); + return; + } + + switch (ev->button) { + case INPUT_BTN_BACK: + case INPUT_BTN_LEFT: + if (press) + ui_switch_screen(SCREEN_NFC_MENU); + break; + case INPUT_BTN_DOWN: + if (nav && !s_empty) { + s_sel = (s_sel + 1) % s_count; + wallet_build(); + ui_feedback(UI_FB_NAV); + } + break; + case INPUT_BTN_UP: + if (nav && !s_empty) { + s_sel = (s_sel == 0) ? s_count - 1 : s_sel - 1; + wallet_build(); + ui_feedback(UI_FB_NAV); + } + break; + case INPUT_BTN_OK: + case INPUT_BTN_RIGHT: + if (press && !s_empty) { + const nfc_sim_card_t *c = nfc_sim_saved_get(s_sel); + if (c != NULL) + overlay_start(c); + } + break; + default: + break; + } +} + +void ui_nfc_emulate_open(void) { + nfc_sim_init(); + if (s_screen != NULL) { + lv_obj_del(s_screen); + s_screen = NULL; + } + s_ov = NULL; + s_body = NULL; + s_sel = 0; + s_count = 0; + for (int i = 0; i < 3; i++) + s_ov_field.ring[i] = NULL; + s_em = EM_NONE; + + s_screen = lv_obj_create(NULL); + lv_obj_set_style_bg_color(s_screen, current_theme.screen_base, 0); + lv_obj_set_style_bg_opa(s_screen, LV_OPA_COVER, 0); + lv_obj_remove_flag(s_screen, LV_OBJ_FLAG_SCROLLABLE); + + int n = nfc_sim_saved_count(); + s_empty = (n == 0); + + if (s_empty) { + ui_chrome_header(s_screen, "EMULATE", CARD_ICON); + ui_chrome_footer(s_screen, "BACK Back"); + build_empty(CARD_ICON, "Nothing to emulate", "Read a tag first"); + } else { + ui_chrome_header(s_screen, "EMULATE", CARD_ICON); + ui_chrome_footer(s_screen, LV_SYMBOL_UP LV_SYMBOL_DOWN " Flip OK Emulate BACK Exit"); + s_count = n > MAX_CARDS ? MAX_CARDS : n; + s_sel = 0; + wallet_build(); + } + + ui_input_set_screen_handler(nfc_emulate_input, NULL); + if (s_tick_timer == NULL) + s_tick_timer = lv_timer_create(field_tick_cb, TICK_MS, NULL); + + ui_screen_load_owned(&s_screen, s_screen); +} diff --git a/firmware_p4/components/Applications/ui/screens/nfc/nfc_felica_ui.c b/firmware_p4/components/Applications/ui/screens/nfc/nfc_felica_ui.c new file mode 100644 index 000000000..e952cd4eb --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/nfc/nfc_felica_ui.c @@ -0,0 +1,257 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#include "nfc_felica_ui.h" + +#include "lvgl.h" +#include "st7789.h" + +#include "ui_chrome.h" +#include "ui_feedback.h" +#include "ui_manager.h" +#include "ui_theme.h" + +#define MX 8 +#define BODY_H (LCD_V_RES - UI_CHROME_HEADER_H - UI_CHROME_FOOTER_H) +#define CONTENT_W (LCD_H_RES - 2 * MX) +#define ROW_GAP 6 + +#define SVC_COUNT 3 +#define ROW_H 30 +#define ROW_RAD 8 + +#define CHIP_H 16 +#define CHIP_RAD 8 +#define CHIP_PAD 6 + +#define DUMP_RAD 8 +#define DUMP_PAD 8 + +#define COL_DIM 0x8A8594 +#define COL_CYAN 0x37E0A8 +#define COL_LINE 0x2A2636 +#define COL_PANEL2 0x1A1626 +#define DIM_HEX "8A8594" + +#define HDR_TITLE "FELICA" +#define HDR_ICON "/assets/icons/nfc.bin" +#define FOOTER_HINT "OK read UP/DOWN service BACK" + +#define TXT_SYS "Sys 0003" +#define TXT_NAME "Suica" + +#define BLK0 "#" DIM_HEX " blk0 # 16 00 07 21 0A 3E ..." +#define BLK1 "#" DIM_HEX " blk1 # JPY 2,480 - gate 0A21" + +typedef struct { + const char *code; + const char *desc; +} svc_def_t; + +static const svc_def_t SVCS[SVC_COUNT] = { + {"Svc 090F", "history - ro"}, + {"Svc 1A8B", "balance - ro"}, + {"Svc 004B", "id - ro"}, +}; + +static lv_obj_t *s_screen = NULL; +static lv_obj_t *s_row[SVC_COUNT]; +static lv_obj_t *s_code[SVC_COUNT]; + +static int s_sel = 0; + +static void make_chip(lv_obj_t *parent, const char *txt, bool sel) { + lv_obj_t *chip = lv_obj_create(parent); + lv_obj_remove_flag(chip, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_height(chip, CHIP_H); + lv_obj_set_width(chip, LV_SIZE_CONTENT); + lv_obj_set_style_radius(chip, CHIP_RAD, 0); + lv_obj_set_style_pad_hor(chip, CHIP_PAD, 0); + lv_obj_set_style_pad_ver(chip, 0, 0); + lv_obj_set_style_bg_color(chip, current_theme.border_accent, 0); + lv_obj_set_style_bg_opa(chip, sel ? LV_OPA_30 : LV_OPA_10, 0); + lv_obj_set_style_border_color(chip, current_theme.border_accent, 0); + lv_obj_set_style_border_width(chip, sel ? 1 : 0, 0); + + lv_obj_t *l = lv_label_create(chip); + lv_label_set_text(l, txt); + lv_obj_set_style_text_font(l, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(l, sel ? current_theme.border_accent : lv_color_hex(COL_DIM), 0); + lv_obj_center(l); +} + +static lv_obj_t *make_row(lv_obj_t *parent, int i) { + lv_obj_t *row = lv_obj_create(parent); + lv_obj_remove_flag(row, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_remove_flag(row, LV_OBJ_FLAG_CLICKABLE); + lv_obj_set_width(row, lv_pct(100)); + lv_obj_set_height(row, ROW_H); + lv_obj_set_style_radius(row, ROW_RAD, 0); + lv_obj_set_style_bg_color(row, current_theme.bg_secondary, 0); + lv_obj_set_style_bg_opa(row, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(row, 2, 0); + lv_obj_set_style_pad_left(row, 8, 0); + lv_obj_set_style_pad_right(row, 8, 0); + lv_obj_set_style_pad_ver(row, 0, 0); + lv_obj_set_flex_flow(row, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align( + row, LV_FLEX_ALIGN_SPACE_BETWEEN, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + + lv_obj_t *code = lv_label_create(row); + lv_label_set_text(code, SVCS[i].code); + lv_obj_set_style_text_font(code, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(code, current_theme.text_main, 0); + lv_obj_set_flex_grow(code, 1); + + lv_obj_t *desc = lv_label_create(row); + lv_label_set_text(desc, SVCS[i].desc); + lv_obj_set_style_text_font(desc, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(desc, lv_color_hex(COL_DIM), 0); + + s_code[i] = code; + return row; +} + +static void refresh_selection(void) { + for (int i = 0; i < SVC_COUNT; i++) { + bool sel = (i == s_sel); + lv_obj_set_style_border_color( + s_row[i], sel ? current_theme.border_accent : current_theme.border_inactive, 0); + lv_obj_set_style_border_opa(s_row[i], sel ? LV_OPA_COVER : LV_OPA_TRANSP, 0); + lv_obj_set_style_bg_color( + s_row[i], sel ? current_theme.bg_primary : current_theme.bg_secondary, 0); + lv_obj_set_style_shadow_width(s_row[i], sel ? 12 : 0, 0); + lv_obj_set_style_shadow_color(s_row[i], current_theme.border_accent, 0); + lv_obj_set_style_shadow_spread(s_row[i], sel ? -3 : 0, 0); + lv_obj_set_style_text_color( + s_code[i], sel ? current_theme.border_accent : current_theme.text_main, 0); + } +} + +static void build_dump(lv_obj_t *parent) { + lv_obj_t *box = lv_obj_create(parent); + lv_obj_remove_flag(box, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_remove_flag(box, LV_OBJ_FLAG_CLICKABLE); + lv_obj_set_width(box, CONTENT_W); + lv_obj_set_height(box, LV_SIZE_CONTENT); + lv_obj_set_style_radius(box, DUMP_RAD, 0); + lv_obj_set_style_bg_color(box, lv_color_hex(COL_PANEL2), 0); + lv_obj_set_style_bg_opa(box, LV_OPA_COVER, 0); + lv_obj_set_style_border_color(box, lv_color_hex(COL_LINE), 0); + lv_obj_set_style_border_width(box, 1, 0); + lv_obj_set_style_pad_all(box, DUMP_PAD, 0); + lv_obj_set_style_pad_row(box, 4, 0); + lv_obj_set_flex_flow(box, LV_FLEX_FLOW_COLUMN); + + const char *lines[] = {BLK0, BLK1}; + for (int i = 0; i < 2; i++) { + lv_obj_t *ln = lv_label_create(box); + lv_label_set_recolor(ln, true); + lv_label_set_text(ln, lines[i]); + lv_obj_set_style_text_font(ln, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(ln, lv_color_hex(COL_CYAN), 0); + } +} + +static void build_body(lv_obj_t *parent) { + lv_obj_t *chips = lv_obj_create(parent); + lv_obj_remove_flag(chips, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_width(chips, lv_pct(100)); + lv_obj_set_height(chips, LV_SIZE_CONTENT); + lv_obj_set_style_bg_opa(chips, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(chips, 0, 0); + lv_obj_set_style_pad_all(chips, 0, 0); + lv_obj_set_style_pad_column(chips, 5, 0); + lv_obj_set_flex_flow(chips, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(chips, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + make_chip(chips, TXT_SYS, true); + make_chip(chips, TXT_NAME, false); + + for (int i = 0; i < SVC_COUNT; i++) + s_row[i] = make_row(parent, i); + refresh_selection(); + + build_dump(parent); +} + +static void nfc_felica_input(const input_event_t *ev, void *ctx) { + (void)ctx; + const bool press = (ev->action == INPUT_ACTION_PRESS); + const bool nav = press || (ev->action == INPUT_ACTION_REPEAT); + + switch (ev->button) { + case INPUT_BTN_BACK: + case INPUT_BTN_LEFT: + if (press) + ui_switch_screen(SCREEN_NFC_MENU); + break; + case INPUT_BTN_DOWN: + if (nav) { + s_sel = (s_sel + 1) % SVC_COUNT; + refresh_selection(); + ui_feedback(UI_FB_NAV); + } + break; + case INPUT_BTN_UP: + if (nav) { + s_sel = (s_sel - 1 + SVC_COUNT) % SVC_COUNT; + refresh_selection(); + ui_feedback(UI_FB_NAV); + } + break; + case INPUT_BTN_OK: + if (press) + ui_feedback(UI_FB_SELECT); + break; + default: + break; + } +} + +void ui_nfc_felica_open(void) { + if (s_screen != NULL) { + lv_obj_del(s_screen); + s_screen = NULL; + } + s_sel = 0; + + s_screen = lv_obj_create(NULL); + lv_obj_set_style_bg_color(s_screen, current_theme.screen_base, 0); + lv_obj_set_style_bg_opa(s_screen, LV_OPA_COVER, 0); + lv_obj_remove_flag(s_screen, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_border_width(s_screen, 0, 0); + lv_obj_set_style_pad_all(s_screen, 0, 0); + + ui_chrome_header(s_screen, HDR_TITLE, HDR_ICON); + + lv_obj_t *body = lv_obj_create(s_screen); + lv_obj_remove_flag(body, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_remove_flag(body, LV_OBJ_FLAG_CLICKABLE); + lv_obj_set_size(body, LCD_H_RES, BODY_H); + lv_obj_align(body, LV_ALIGN_TOP_MID, 0, UI_CHROME_HEADER_H); + lv_obj_set_style_bg_opa(body, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(body, 0, 0); + lv_obj_set_style_pad_all(body, MX, 0); + lv_obj_set_style_pad_row(body, ROW_GAP, 0); + lv_obj_set_flex_flow(body, LV_FLEX_FLOW_COLUMN); + + build_body(body); + + ui_chrome_footer(s_screen, FOOTER_HINT); + + ui_input_set_screen_handler(nfc_felica_input, NULL); + + ui_screen_load_owned(&s_screen, s_screen); +} diff --git a/firmware_p4/components/Applications/ui/screens/nfc/nfc_iso15693_ui.c b/firmware_p4/components/Applications/ui/screens/nfc/nfc_iso15693_ui.c new file mode 100644 index 000000000..6a8125f70 --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/nfc/nfc_iso15693_ui.c @@ -0,0 +1,287 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#include "nfc_iso15693_ui.h" + +#include + +#include "lvgl.h" +#include "st7789.h" + +#include "ui_chrome.h" +#include "ui_feedback.h" +#include "ui_manager.h" +#include "ui_theme.h" + +#define MX 8 +#define BODY_H (LCD_V_RES - UI_CHROME_HEADER_H - UI_CHROME_FOOTER_H) +#define CONTENT_W (LCD_H_RES - 2 * MX) +#define ROW_GAP 7 + +#define BLOCK_COUNT 28 +#define GRID_COLS 7 +#define LOCKED_MAX 4 +#define SEL_START 5 + +#define CELL_W 28 +#define CELL_H 24 +#define CELL_GAP 3 +#define CELL_RAD 3 +#define GRID_W (GRID_COLS * CELL_W + (GRID_COLS - 1) * CELL_GAP) + +#define CHIP_H 16 +#define CHIP_RAD 8 +#define CHIP_PAD 6 + +#define COL_DIM 0x8A8594 +#define COL_GOLD 0xD9A521 +#define COL_LINE 0x2A2636 +#define COL_PANEL2 0x1A1626 + +#define HDR_TITLE "NFC-V / 15693" +#define HDR_ICON "/assets/icons/nfc.bin" +#define FOOTER_HINT "OK read/write UP/DOWN block BACK" + +#define TXT_TAG "ICODE SLIX" +#define TXT_GEOM "28x4 B" +#define TXT_READ "READ" +#define TXT_WRITE "WRITE" +#define TXT_LOCK "4 locked" +#define KV_BLOCK "Block" + +#define IDX_BUF 12 +#define VAL_BUF 32 + +static lv_obj_t *s_screen = NULL; +static lv_obj_t *s_cell[BLOCK_COUNT]; +static lv_obj_t *s_cell_lbl[BLOCK_COUNT]; +static lv_obj_t *s_block_key = NULL; +static lv_obj_t *s_block_val = NULL; + +static int s_sel = SEL_START; + +static void format_block_val(int i, char *buf, size_t n) { + snprintf(buf, n, "%02X FF 00 %02X", (unsigned)i, (unsigned)((i * 7 + 3) & 0xFF)); +} + +static lv_obj_t *make_chip(lv_obj_t *parent, const char *txt, bool sel) { + lv_obj_t *chip = lv_obj_create(parent); + lv_obj_remove_flag(chip, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_height(chip, CHIP_H); + lv_obj_set_width(chip, LV_SIZE_CONTENT); + lv_obj_set_style_radius(chip, CHIP_RAD, 0); + lv_obj_set_style_pad_hor(chip, CHIP_PAD, 0); + lv_obj_set_style_pad_ver(chip, 0, 0); + lv_obj_set_style_bg_color(chip, current_theme.border_accent, 0); + lv_obj_set_style_bg_opa(chip, sel ? LV_OPA_30 : LV_OPA_10, 0); + lv_obj_set_style_border_color(chip, current_theme.border_accent, 0); + lv_obj_set_style_border_width(chip, sel ? 1 : 0, 0); + + lv_obj_t *l = lv_label_create(chip); + lv_label_set_text(l, txt); + lv_obj_set_style_text_font(l, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(l, sel ? current_theme.border_accent : lv_color_hex(COL_DIM), 0); + lv_obj_center(l); + return chip; +} + +static void restyle_cells(void) { + for (int i = 0; i < BLOCK_COUNT; i++) { + bool sel = (i == s_sel); + bool locked = (i < LOCKED_MAX); + lv_color_t border = sel ? current_theme.border_accent + : locked ? lv_color_hex(COL_GOLD) + : lv_color_hex(COL_LINE); + lv_color_t txt = sel ? current_theme.text_main + : locked ? lv_color_hex(COL_GOLD) + : lv_color_hex(COL_DIM); + lv_obj_set_style_border_color(s_cell[i], border, 0); + lv_obj_set_style_bg_color( + s_cell[i], sel ? current_theme.bg_primary : lv_color_hex(COL_PANEL2), 0); + lv_obj_set_style_shadow_width(s_cell[i], sel ? 7 : 0, 0); + lv_obj_set_style_shadow_color(s_cell[i], current_theme.border_accent, 0); + lv_obj_set_style_shadow_opa(s_cell[i], sel ? LV_OPA_50 : LV_OPA_TRANSP, 0); + lv_obj_set_style_text_color(s_cell_lbl[i], txt, 0); + } +} + +static void update_block_kv(void) { + static char kbuf[IDX_BUF + 8]; + static char vbuf[VAL_BUF]; + snprintf(kbuf, sizeof(kbuf), "%s %02X", KV_BLOCK, (unsigned)s_sel); + format_block_val(s_sel, vbuf, sizeof(vbuf)); + lv_label_set_text(s_block_key, kbuf); + lv_label_set_text(s_block_val, vbuf); +} + +static void build_grid(lv_obj_t *parent) { + lv_obj_t *grid = lv_obj_create(parent); + lv_obj_remove_flag(grid, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_remove_flag(grid, LV_OBJ_FLAG_CLICKABLE); + lv_obj_set_width(grid, GRID_W); + lv_obj_set_height(grid, LV_SIZE_CONTENT); + lv_obj_set_style_bg_opa(grid, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(grid, 0, 0); + lv_obj_set_style_pad_all(grid, 0, 0); + lv_obj_set_style_pad_row(grid, CELL_GAP, 0); + lv_obj_set_style_pad_column(grid, CELL_GAP, 0); + lv_obj_set_flex_flow(grid, LV_FLEX_FLOW_ROW_WRAP); + + for (int i = 0; i < BLOCK_COUNT; i++) { + lv_obj_t *cell = lv_obj_create(grid); + lv_obj_remove_flag(cell, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_remove_flag(cell, LV_OBJ_FLAG_CLICKABLE); + lv_obj_set_size(cell, CELL_W, CELL_H); + lv_obj_set_style_radius(cell, CELL_RAD, 0); + lv_obj_set_style_bg_opa(cell, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(cell, 1, 0); + lv_obj_set_style_pad_all(cell, 0, 0); + + lv_obj_t *l = lv_label_create(cell); + char b[IDX_BUF]; + snprintf(b, sizeof(b), "%02X", (unsigned)i); + lv_label_set_text(l, b); + lv_obj_set_style_text_font(l, &lv_font_montserrat_12, 0); + lv_obj_center(l); + + s_cell[i] = cell; + s_cell_lbl[i] = l; + } + restyle_cells(); +} + +static void build_body(lv_obj_t *parent) { + lv_obj_t *chips = lv_obj_create(parent); + lv_obj_remove_flag(chips, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_width(chips, lv_pct(100)); + lv_obj_set_height(chips, LV_SIZE_CONTENT); + lv_obj_set_style_bg_opa(chips, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(chips, 0, 0); + lv_obj_set_style_pad_all(chips, 0, 0); + lv_obj_set_style_pad_column(chips, 5, 0); + lv_obj_set_flex_flow(chips, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(chips, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + make_chip(chips, TXT_TAG, true); + make_chip(chips, TXT_GEOM, false); + + build_grid(parent); + + lv_obj_t *kv = lv_obj_create(parent); + lv_obj_remove_flag(kv, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_width(kv, lv_pct(100)); + lv_obj_set_height(kv, LV_SIZE_CONTENT); + lv_obj_set_style_bg_opa(kv, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(kv, 0, 0); + lv_obj_set_style_pad_all(kv, 0, 0); + lv_obj_set_flex_flow(kv, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align( + kv, LV_FLEX_ALIGN_SPACE_BETWEEN, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + s_block_key = lv_label_create(kv); + lv_obj_set_style_text_font(s_block_key, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(s_block_key, lv_color_hex(COL_DIM), 0); + s_block_val = lv_label_create(kv); + lv_obj_set_style_text_font(s_block_val, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(s_block_val, current_theme.text_main, 0); + update_block_kv(); + + lv_obj_t *actions = lv_obj_create(parent); + lv_obj_remove_flag(actions, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_width(actions, lv_pct(100)); + lv_obj_set_height(actions, LV_SIZE_CONTENT); + lv_obj_set_style_bg_opa(actions, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(actions, 0, 0); + lv_obj_set_style_pad_all(actions, 0, 0); + lv_obj_set_style_pad_column(actions, 6, 0); + lv_obj_set_flex_flow(actions, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(actions, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + make_chip(actions, TXT_READ, true); + make_chip(actions, TXT_WRITE, false); + lv_obj_t *lock = lv_label_create(actions); + lv_label_set_text(lock, TXT_LOCK); + lv_obj_set_style_text_font(lock, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(lock, lv_color_hex(COL_GOLD), 0); +} + +static void nfc_iso15693_input(const input_event_t *ev, void *ctx) { + (void)ctx; + const bool press = (ev->action == INPUT_ACTION_PRESS); + const bool nav = press || (ev->action == INPUT_ACTION_REPEAT); + + switch (ev->button) { + case INPUT_BTN_BACK: + if (press) + ui_switch_screen(SCREEN_NFC_MENU); + break; + case INPUT_BTN_DOWN: + if (nav) { + s_sel = (s_sel + 1) % BLOCK_COUNT; + restyle_cells(); + update_block_kv(); + ui_feedback(UI_FB_NAV); + } + break; + case INPUT_BTN_UP: + if (nav) { + s_sel = (s_sel - 1 + BLOCK_COUNT) % BLOCK_COUNT; + restyle_cells(); + update_block_kv(); + ui_feedback(UI_FB_NAV); + } + break; + case INPUT_BTN_OK: + if (press) + ui_feedback(UI_FB_SELECT); + break; + default: + break; + } +} + +void ui_nfc_iso15693_open(void) { + if (s_screen != NULL) { + lv_obj_del(s_screen); + s_screen = NULL; + } + s_sel = SEL_START; + + s_screen = lv_obj_create(NULL); + lv_obj_set_style_bg_color(s_screen, current_theme.screen_base, 0); + lv_obj_set_style_bg_opa(s_screen, LV_OPA_COVER, 0); + lv_obj_remove_flag(s_screen, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_border_width(s_screen, 0, 0); + lv_obj_set_style_pad_all(s_screen, 0, 0); + + ui_chrome_header(s_screen, HDR_TITLE, HDR_ICON); + + lv_obj_t *body = lv_obj_create(s_screen); + lv_obj_remove_flag(body, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_remove_flag(body, LV_OBJ_FLAG_CLICKABLE); + lv_obj_set_size(body, LCD_H_RES, BODY_H); + lv_obj_align(body, LV_ALIGN_TOP_MID, 0, UI_CHROME_HEADER_H); + lv_obj_set_style_bg_opa(body, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(body, 0, 0); + lv_obj_set_style_pad_all(body, MX, 0); + lv_obj_set_style_pad_row(body, ROW_GAP, 0); + lv_obj_set_flex_flow(body, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(body, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_START); + + build_body(body); + + ui_chrome_footer(s_screen, FOOTER_HINT); + + ui_input_set_screen_handler(nfc_iso15693_input, NULL); + + ui_screen_load_owned(&s_screen, s_screen); +} diff --git a/firmware_p4/components/Applications/ui/screens/nfc/nfc_keydict_ui.c b/firmware_p4/components/Applications/ui/screens/nfc/nfc_keydict_ui.c new file mode 100644 index 000000000..de8bcfc9a --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/nfc/nfc_keydict_ui.c @@ -0,0 +1,259 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#include "nfc_keydict_ui.h" + +#include "lvgl.h" +#include "st7789.h" + +#include "ui_chrome.h" +#include "ui_feedback.h" +#include "ui_manager.h" +#include "ui_theme.h" + +#define MX 8 +#define BODY_H (LCD_V_RES - UI_CHROME_HEADER_H - UI_CHROME_FOOTER_H) +#define CONTENT_W (LCD_H_RES - 2 * MX) +#define ROW_GAP 5 + +#define KEY_COUNT 4 +#define ROW_H 28 +#define ROW_RAD 8 + +#define CHIP_H 16 +#define CHIP_RAD 8 +#define CHIP_PAD 6 +#define TAG_H 14 +#define TAG_PAD 5 + +#define COL_DIM 0x8A8594 + +#define HDR_TITLE "KEY DICTIONARY" +#define HDR_ICON "/assets/icons/nfc.bin" +#define FOOTER_HINT "OK load UP/DOWN key +/- edit" + +#define TXT_COUNT "1024 keys" +#define TXT_SRC "mfc_default" + +#define ACT_ADD LV_SYMBOL_PLUS " add" +#define ACT_RM LV_SYMBOL_TRASH " remove" +#define ACT_SD LV_SYMBOL_SD_CARD " SD" + +typedef struct { + const char *hex; + const char *tag; + bool tag_chip; +} key_def_t; + +static const key_def_t KEYS[KEY_COUNT] = { + {"FF FF FF FF FF FF", "default", true}, + {"A0 A1 A2 A3 A4 A5", "MAD", false}, + {"D3 F7 D3 F7 D3 F7", "NDEF", false}, + {"00 00 00 00 00 00", "blank", false}, +}; + +static lv_obj_t *s_screen = NULL; +static lv_obj_t *s_row[KEY_COUNT]; +static lv_obj_t *s_hex[KEY_COUNT]; + +static int s_sel = 0; + +static void make_chip(lv_obj_t *parent, const char *txt, bool sel) { + lv_obj_t *chip = lv_obj_create(parent); + lv_obj_remove_flag(chip, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_height(chip, CHIP_H); + lv_obj_set_width(chip, LV_SIZE_CONTENT); + lv_obj_set_style_radius(chip, CHIP_RAD, 0); + lv_obj_set_style_pad_hor(chip, CHIP_PAD, 0); + lv_obj_set_style_pad_ver(chip, 0, 0); + lv_obj_set_style_bg_color(chip, current_theme.border_accent, 0); + lv_obj_set_style_bg_opa(chip, sel ? LV_OPA_30 : LV_OPA_10, 0); + lv_obj_set_style_border_color(chip, current_theme.border_accent, 0); + lv_obj_set_style_border_width(chip, sel ? 1 : 0, 0); + + lv_obj_t *l = lv_label_create(chip); + lv_label_set_text(l, txt); + lv_obj_set_style_text_font(l, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(l, sel ? current_theme.border_accent : lv_color_hex(COL_DIM), 0); + lv_obj_center(l); +} + +static lv_obj_t *make_row(lv_obj_t *parent, int i) { + lv_obj_t *row = lv_obj_create(parent); + lv_obj_remove_flag(row, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_remove_flag(row, LV_OBJ_FLAG_CLICKABLE); + lv_obj_set_width(row, lv_pct(100)); + lv_obj_set_height(row, ROW_H); + lv_obj_set_style_radius(row, ROW_RAD, 0); + lv_obj_set_style_bg_color(row, current_theme.bg_secondary, 0); + lv_obj_set_style_bg_opa(row, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(row, 2, 0); + lv_obj_set_style_pad_left(row, 8, 0); + lv_obj_set_style_pad_right(row, 8, 0); + lv_obj_set_style_pad_ver(row, 0, 0); + lv_obj_set_flex_flow(row, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align( + row, LV_FLEX_ALIGN_SPACE_BETWEEN, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + + lv_obj_t *hex = lv_label_create(row); + lv_label_set_text(hex, KEYS[i].hex); + lv_obj_set_style_text_font(hex, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(hex, current_theme.text_main, 0); + lv_obj_set_flex_grow(hex, 1); + + if (KEYS[i].tag_chip) { + lv_obj_t *tag = lv_obj_create(row); + lv_obj_remove_flag(tag, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_height(tag, TAG_H); + lv_obj_set_width(tag, LV_SIZE_CONTENT); + lv_obj_set_style_radius(tag, CHIP_RAD, 0); + lv_obj_set_style_pad_hor(tag, TAG_PAD, 0); + lv_obj_set_style_pad_ver(tag, 0, 0); + lv_obj_set_style_bg_color(tag, current_theme.border_accent, 0); + lv_obj_set_style_bg_opa(tag, LV_OPA_20, 0); + lv_obj_set_style_border_width(tag, 0, 0); + lv_obj_t *tl = lv_label_create(tag); + lv_label_set_text(tl, KEYS[i].tag); + lv_obj_set_style_text_font(tl, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(tl, current_theme.border_accent, 0); + lv_obj_center(tl); + } else { + lv_obj_t *tag = lv_label_create(row); + lv_label_set_text(tag, KEYS[i].tag); + lv_obj_set_style_text_font(tag, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(tag, lv_color_hex(COL_DIM), 0); + } + + s_hex[i] = hex; + return row; +} + +static void refresh_selection(void) { + for (int i = 0; i < KEY_COUNT; i++) { + bool sel = (i == s_sel); + lv_obj_set_style_border_color( + s_row[i], sel ? current_theme.border_accent : current_theme.border_inactive, 0); + lv_obj_set_style_border_opa(s_row[i], sel ? LV_OPA_COVER : LV_OPA_TRANSP, 0); + lv_obj_set_style_bg_color( + s_row[i], sel ? current_theme.bg_primary : current_theme.bg_secondary, 0); + lv_obj_set_style_shadow_width(s_row[i], sel ? 12 : 0, 0); + lv_obj_set_style_shadow_color(s_row[i], current_theme.border_accent, 0); + lv_obj_set_style_shadow_spread(s_row[i], sel ? -3 : 0, 0); + lv_obj_set_style_text_color( + s_hex[i], sel ? current_theme.border_accent : current_theme.text_main, 0); + } +} + +static void build_body(lv_obj_t *parent) { + lv_obj_t *chips = lv_obj_create(parent); + lv_obj_remove_flag(chips, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_width(chips, lv_pct(100)); + lv_obj_set_height(chips, LV_SIZE_CONTENT); + lv_obj_set_style_bg_opa(chips, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(chips, 0, 0); + lv_obj_set_style_pad_all(chips, 0, 0); + lv_obj_set_style_pad_column(chips, 5, 0); + lv_obj_set_flex_flow(chips, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(chips, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + make_chip(chips, TXT_COUNT, true); + make_chip(chips, TXT_SRC, false); + + for (int i = 0; i < KEY_COUNT; i++) + s_row[i] = make_row(parent, i); + refresh_selection(); + + lv_obj_t *actions = lv_obj_create(parent); + lv_obj_remove_flag(actions, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_width(actions, lv_pct(100)); + lv_obj_set_height(actions, LV_SIZE_CONTENT); + lv_obj_set_style_bg_opa(actions, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(actions, 0, 0); + lv_obj_set_style_pad_all(actions, 0, 0); + lv_obj_set_style_pad_column(actions, 6, 0); + lv_obj_set_flex_flow(actions, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(actions, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + make_chip(actions, ACT_ADD, false); + make_chip(actions, ACT_RM, false); + make_chip(actions, ACT_SD, false); +} + +static void nfc_keydict_input(const input_event_t *ev, void *ctx) { + (void)ctx; + const bool press = (ev->action == INPUT_ACTION_PRESS); + const bool nav = press || (ev->action == INPUT_ACTION_REPEAT); + + switch (ev->button) { + case INPUT_BTN_BACK: + if (press) + ui_switch_screen(SCREEN_NFC_MENU); + break; + case INPUT_BTN_DOWN: + if (nav) { + s_sel = (s_sel + 1) % KEY_COUNT; + refresh_selection(); + ui_feedback(UI_FB_NAV); + } + break; + case INPUT_BTN_UP: + if (nav) { + s_sel = (s_sel - 1 + KEY_COUNT) % KEY_COUNT; + refresh_selection(); + ui_feedback(UI_FB_NAV); + } + break; + case INPUT_BTN_OK: + if (press) + ui_feedback(UI_FB_SELECT); + break; + default: + break; + } +} + +void ui_nfc_keydict_open(void) { + if (s_screen != NULL) { + lv_obj_del(s_screen); + s_screen = NULL; + } + s_sel = 0; + + s_screen = lv_obj_create(NULL); + lv_obj_set_style_bg_color(s_screen, current_theme.screen_base, 0); + lv_obj_set_style_bg_opa(s_screen, LV_OPA_COVER, 0); + lv_obj_remove_flag(s_screen, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_border_width(s_screen, 0, 0); + lv_obj_set_style_pad_all(s_screen, 0, 0); + + ui_chrome_header(s_screen, HDR_TITLE, HDR_ICON); + + lv_obj_t *body = lv_obj_create(s_screen); + lv_obj_remove_flag(body, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_remove_flag(body, LV_OBJ_FLAG_CLICKABLE); + lv_obj_set_size(body, LCD_H_RES, BODY_H); + lv_obj_align(body, LV_ALIGN_TOP_MID, 0, UI_CHROME_HEADER_H); + lv_obj_set_style_bg_opa(body, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(body, 0, 0); + lv_obj_set_style_pad_all(body, MX, 0); + lv_obj_set_style_pad_row(body, ROW_GAP, 0); + lv_obj_set_flex_flow(body, LV_FLEX_FLOW_COLUMN); + + build_body(body); + + ui_chrome_footer(s_screen, FOOTER_HINT); + + ui_input_set_screen_handler(nfc_keydict_input, NULL); + + ui_screen_load_owned(&s_screen, s_screen); +} diff --git a/firmware_p4/components/Applications/ui/screens/nfc/nfc_menu_ui.c b/firmware_p4/components/Applications/ui/screens/nfc/nfc_menu_ui.c index 412a53d4c..3ca371173 100644 --- a/firmware_p4/components/Applications/ui/screens/nfc/nfc_menu_ui.c +++ b/firmware_p4/components/Applications/ui/screens/nfc/nfc_menu_ui.c @@ -20,12 +20,9 @@ #include "ui_theme.h" #include "menu_component_ui.h" #include "ui_manager.h" -#include "buttons_gpio.h" static const char *TAG = "NFC_MENU_UI"; -#define NAV_TIMER_PERIOD_MS 50 - typedef struct { const char *name; const char *icon; @@ -33,65 +30,60 @@ typedef struct { } nfc_menu_item_t; static const nfc_menu_item_t ITEMS[] = { - {"READ TAG", NULL, -1}, - {"WRITE TAG", NULL, -1}, - {"EMULATE", NULL, -1}, - {"SAVED TAGS", NULL, -1}, + {"READ TAGS", "/assets/icons/contactless.bin", SCREEN_NFC_READ}, + {"SCAN / IDENTIFY", "/assets/icons/sensors.bin", SCREEN_NFC_SCAN}, + {"EMULATE", "/assets/icons/contactless.bin", SCREEN_CARD_EMU}, + {"WRITE", "/assets/icons/edit.bin", SCREEN_NFC_WRITE}, + {"CONFIGURATIONS", "/assets/icons/settings.bin", SCREEN_NFC_CONFIG}, + {"SAVED", "/assets/icons/bookmarks.bin", SCREEN_NFC_SAVED}, + {"BANK CARD", "/assets/icons/contactless.bin", SCREEN_NFC_BANKCARD}, + {"DESFIRE", "/assets/icons/sensors.bin", SCREEN_NFC_DESFIRE}, + {"NFC-V / 15693", "/assets/icons/contactless.bin", SCREEN_NFC_ISO15693}, + {"ULTRALIGHT/NTAG", "/assets/icons/contactless.bin", SCREEN_NFC_ULTRALIGHT}, + {"NDEF", "/assets/icons/description.bin", SCREEN_NFC_NDEF}, + {"FELICA", "/assets/icons/contactless.bin", SCREEN_NFC_FELICA}, + {"SHARE (P2P)", "/assets/icons/podcasts.bin", SCREEN_NFC_P2P}, + {"KEY DICTIONARY", "/assets/icons/settings.bin", SCREEN_NFC_KEYDICT}, }; #define ITEM_COUNT (sizeof(ITEMS) / sizeof(ITEMS[0])) static lv_obj_t *s_screen = NULL; static menu_component_t s_menu; -static lv_timer_t *s_nav_timer = NULL; - -static bool s_btn_up_last = false; -static bool s_btn_down_last = false; -static bool s_btn_left_last = false; -static bool s_btn_right_last = false; -static bool s_btn_ok_last = false; -static bool s_btn_back_last = false; - -static void nav_timer_cb(lv_timer_t *t); - -static void nav_timer_cb(lv_timer_t *t) { - if (lv_screen_active() != s_screen) { - lv_timer_delete(t); - s_nav_timer = NULL; - return; - } - if (ui_input_is_locked()) - return; - - bool up = up_button_is_down(); - bool down = down_button_is_down(); - bool left = left_button_is_down(); - bool right = right_button_is_down(); - bool ok = ok_button_is_down(); - bool back = back_button_is_down(); - if ((back && !s_btn_back_last) || (left && !s_btn_left_last)) { - ui_switch_screen(SCREEN_MENU); - return; +// Event-driven input (see ui_input_set_screen_handler). The central pump only +// calls this while input is unlocked and no modal overlay is up, so the old +// per-frame guards are gone. UP/DOWN also act on REPEAT for held auto-scroll. +static void nfc_menu_input(const input_event_t *ev, void *ctx) { + (void)ctx; + const bool press = (ev->action == INPUT_ACTION_PRESS); + const bool nav = press || (ev->action == INPUT_ACTION_REPEAT); + + switch (ev->button) { + case INPUT_BTN_BACK: + case INPUT_BTN_LEFT: + if (press) + ui_switch_screen(SCREEN_MENU); + break; + case INPUT_BTN_OK: + case INPUT_BTN_RIGHT: + if (press) { + int sel = menu_component_get_selected(&s_menu); + if (sel >= 0 && (size_t)sel < ITEM_COUNT && ITEMS[sel].target >= 0) { + ui_switch_screen(ITEMS[sel].target); + } + } + break; + case INPUT_BTN_DOWN: + if (nav) + menu_component_next(&s_menu); + break; + case INPUT_BTN_UP: + if (nav) + menu_component_prev(&s_menu); + break; + default: + break; } - - if ((ok && !s_btn_ok_last) || (right && !s_btn_right_last)) { - int sel = menu_component_get_selected(&s_menu); - if (sel >= 0 && (size_t)sel < ITEM_COUNT && ITEMS[sel].target >= 0) { - ui_switch_screen(ITEMS[sel].target); - } - } - - if (down && !s_btn_down_last) - menu_component_next(&s_menu); - if (up && !s_btn_up_last) - menu_component_prev(&s_menu); - - s_btn_up_last = up; - s_btn_down_last = down; - s_btn_left_last = left; - s_btn_right_last = right; - s_btn_ok_last = ok; - s_btn_back_last = back; } void ui_nfc_menu_open(void) { @@ -105,14 +97,13 @@ void ui_nfc_menu_open(void) { lv_obj_set_style_bg_opa(s_screen, LV_OPA_COVER, 0); lv_obj_remove_flag(s_screen, LV_OBJ_FLAG_SCROLLABLE); - s_menu = menu_component_create(s_screen, "NFC", NULL); + s_menu = menu_component_create(s_screen, "NFC", "/assets/icons/nfc.bin"); for (size_t i = 0; i < ITEM_COUNT; i++) { menu_component_add_item(&s_menu, ITEMS[i].icon, ITEMS[i].name); } - if (s_nav_timer == NULL) - s_nav_timer = lv_timer_create(nav_timer_cb, NAV_TIMER_PERIOD_MS, NULL); + ui_input_set_screen_handler(nfc_menu_input, NULL); - lv_screen_load(s_screen); -} \ No newline at end of file + ui_screen_load_owned(&s_screen, s_screen); +} diff --git a/firmware_p4/components/Applications/ui/screens/nfc/nfc_ndef_ui.c b/firmware_p4/components/Applications/ui/screens/nfc/nfc_ndef_ui.c new file mode 100644 index 000000000..e9dd7a4b9 --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/nfc/nfc_ndef_ui.c @@ -0,0 +1,243 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#include "nfc_ndef_ui.h" + +#include "lvgl.h" +#include "st7789.h" + +#include "ui_chrome.h" +#include "ui_feedback.h" +#include "ui_manager.h" +#include "ui_theme.h" + +#define MX 8 +#define BODY_H (LCD_V_RES - UI_CHROME_HEADER_H - UI_CHROME_FOOTER_H) +#define CONTENT_W (LCD_H_RES - 2 * MX) +#define ROW_GAP 6 + +#define REC_COUNT 3 +#define ROW_H 38 +#define ROW_RAD 8 +#define ICON_W 22 + +#define CHIP_H 16 +#define CHIP_RAD 8 +#define CHIP_PAD 6 + +#define COL_DIM 0x8A8594 + +#define HDR_TITLE "NDEF" +#define HDR_ICON "/assets/icons/nfc.bin" +#define FOOTER_HINT "OK edit UP/DOWN record BACK" + +#define TXT_COUNT "3 records" +#define TXT_SIZE "86 B" +#define TXT_MODEL "NTAG213" + +typedef struct { + const char *icon; + const char *title; + const char *sub; +} rec_def_t; + +static const rec_def_t RECS[REC_COUNT] = { + {"@", "URI", "high-code.com/hb"}, + {"T", "Text - en", "HighBoy tag #04"}, + {LV_SYMBOL_FILE, "MIME", "text/vcard - 42 B"}, +}; + +static lv_obj_t *s_screen = NULL; +static lv_obj_t *s_row[REC_COUNT]; +static lv_obj_t *s_icon[REC_COUNT]; +static lv_obj_t *s_title[REC_COUNT]; + +static int s_sel = 0; + +static void make_chip(lv_obj_t *parent, const char *txt, bool sel) { + lv_obj_t *chip = lv_obj_create(parent); + lv_obj_remove_flag(chip, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_height(chip, CHIP_H); + lv_obj_set_width(chip, LV_SIZE_CONTENT); + lv_obj_set_style_radius(chip, CHIP_RAD, 0); + lv_obj_set_style_pad_hor(chip, CHIP_PAD, 0); + lv_obj_set_style_pad_ver(chip, 0, 0); + lv_obj_set_style_bg_color(chip, current_theme.border_accent, 0); + lv_obj_set_style_bg_opa(chip, sel ? LV_OPA_30 : LV_OPA_10, 0); + lv_obj_set_style_border_color(chip, current_theme.border_accent, 0); + lv_obj_set_style_border_width(chip, sel ? 1 : 0, 0); + + lv_obj_t *l = lv_label_create(chip); + lv_label_set_text(l, txt); + lv_obj_set_style_text_font(l, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(l, sel ? current_theme.border_accent : lv_color_hex(COL_DIM), 0); + lv_obj_center(l); +} + +static lv_obj_t *make_row(lv_obj_t *parent, int i) { + lv_obj_t *row = lv_obj_create(parent); + lv_obj_remove_flag(row, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_remove_flag(row, LV_OBJ_FLAG_CLICKABLE); + lv_obj_set_width(row, lv_pct(100)); + lv_obj_set_height(row, ROW_H); + lv_obj_set_style_radius(row, ROW_RAD, 0); + lv_obj_set_style_bg_color(row, current_theme.bg_secondary, 0); + lv_obj_set_style_bg_opa(row, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(row, 2, 0); + lv_obj_set_style_pad_left(row, 8, 0); + lv_obj_set_style_pad_right(row, 8, 0); + lv_obj_set_style_pad_ver(row, 0, 0); + 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_column(row, 6, 0); + + lv_obj_t *ic = lv_label_create(row); + lv_label_set_text(ic, RECS[i].icon); + lv_obj_set_style_text_font(ic, &lv_font_montserrat_14, 0); + lv_obj_set_width(ic, ICON_W); + lv_obj_set_style_text_align(ic, LV_TEXT_ALIGN_CENTER, 0); + + lv_obj_t *col = lv_obj_create(row); + lv_obj_remove_flag(col, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_height(col, LV_SIZE_CONTENT); + lv_obj_set_flex_grow(col, 1); + lv_obj_set_style_bg_opa(col, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(col, 0, 0); + lv_obj_set_style_pad_all(col, 0, 0); + lv_obj_set_style_pad_row(col, 1, 0); + lv_obj_set_flex_flow(col, LV_FLEX_FLOW_COLUMN); + + lv_obj_t *title = lv_label_create(col); + lv_label_set_text(title, RECS[i].title); + lv_obj_set_style_text_font(title, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(title, current_theme.text_main, 0); + + lv_obj_t *sub = lv_label_create(col); + lv_label_set_text(sub, RECS[i].sub); + lv_obj_set_style_text_font(sub, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(sub, lv_color_hex(COL_DIM), 0); + + s_icon[i] = ic; + s_title[i] = title; + return row; +} + +static void refresh_selection(void) { + for (int i = 0; i < REC_COUNT; i++) { + bool sel = (i == s_sel); + lv_obj_set_style_border_color( + s_row[i], sel ? current_theme.border_accent : current_theme.border_inactive, 0); + lv_obj_set_style_border_opa(s_row[i], sel ? LV_OPA_COVER : LV_OPA_TRANSP, 0); + lv_obj_set_style_bg_color( + s_row[i], sel ? current_theme.bg_primary : current_theme.bg_secondary, 0); + lv_obj_set_style_shadow_width(s_row[i], sel ? 12 : 0, 0); + lv_obj_set_style_shadow_color(s_row[i], current_theme.border_accent, 0); + lv_obj_set_style_shadow_spread(s_row[i], sel ? -3 : 0, 0); + lv_obj_set_style_text_color( + s_icon[i], sel ? current_theme.border_accent : lv_color_hex(COL_DIM), 0); + lv_obj_set_style_text_color( + s_title[i], sel ? current_theme.text_main : lv_color_hex(COL_DIM), 0); + } +} + +static void build_body(lv_obj_t *parent) { + lv_obj_t *chips = lv_obj_create(parent); + lv_obj_remove_flag(chips, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_width(chips, lv_pct(100)); + lv_obj_set_height(chips, LV_SIZE_CONTENT); + lv_obj_set_style_bg_opa(chips, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(chips, 0, 0); + lv_obj_set_style_pad_all(chips, 0, 0); + lv_obj_set_style_pad_column(chips, 5, 0); + lv_obj_set_flex_flow(chips, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(chips, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + make_chip(chips, TXT_COUNT, true); + make_chip(chips, TXT_SIZE, false); + make_chip(chips, TXT_MODEL, false); + + for (int i = 0; i < REC_COUNT; i++) + s_row[i] = make_row(parent, i); + refresh_selection(); +} + +static void nfc_ndef_input(const input_event_t *ev, void *ctx) { + (void)ctx; + const bool press = (ev->action == INPUT_ACTION_PRESS); + const bool nav = press || (ev->action == INPUT_ACTION_REPEAT); + + switch (ev->button) { + case INPUT_BTN_BACK: + case INPUT_BTN_LEFT: + if (press) + ui_switch_screen(SCREEN_NFC_MENU); + break; + case INPUT_BTN_DOWN: + if (nav) { + s_sel = (s_sel + 1) % REC_COUNT; + refresh_selection(); + ui_feedback(UI_FB_NAV); + } + break; + case INPUT_BTN_UP: + if (nav) { + s_sel = (s_sel - 1 + REC_COUNT) % REC_COUNT; + refresh_selection(); + ui_feedback(UI_FB_NAV); + } + break; + case INPUT_BTN_OK: + if (press) + ui_feedback(UI_FB_SELECT); + break; + default: + break; + } +} + +void ui_nfc_ndef_open(void) { + if (s_screen != NULL) { + lv_obj_del(s_screen); + s_screen = NULL; + } + s_sel = 0; + + s_screen = lv_obj_create(NULL); + lv_obj_set_style_bg_color(s_screen, current_theme.screen_base, 0); + lv_obj_set_style_bg_opa(s_screen, LV_OPA_COVER, 0); + lv_obj_remove_flag(s_screen, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_border_width(s_screen, 0, 0); + lv_obj_set_style_pad_all(s_screen, 0, 0); + + ui_chrome_header(s_screen, HDR_TITLE, HDR_ICON); + + lv_obj_t *body = lv_obj_create(s_screen); + lv_obj_remove_flag(body, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_remove_flag(body, LV_OBJ_FLAG_CLICKABLE); + lv_obj_set_size(body, LCD_H_RES, BODY_H); + lv_obj_align(body, LV_ALIGN_TOP_MID, 0, UI_CHROME_HEADER_H); + lv_obj_set_style_bg_opa(body, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(body, 0, 0); + lv_obj_set_style_pad_all(body, MX, 0); + lv_obj_set_style_pad_row(body, ROW_GAP, 0); + lv_obj_set_flex_flow(body, LV_FLEX_FLOW_COLUMN); + + build_body(body); + + ui_chrome_footer(s_screen, FOOTER_HINT); + + ui_input_set_screen_handler(nfc_ndef_input, NULL); + + ui_screen_load_owned(&s_screen, s_screen); +} diff --git a/firmware_p4/components/Applications/ui/screens/nfc/nfc_p2p_ui.c b/firmware_p4/components/Applications/ui/screens/nfc/nfc_p2p_ui.c new file mode 100644 index 000000000..059907e3e --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/nfc/nfc_p2p_ui.c @@ -0,0 +1,329 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#include "nfc_p2p_ui.h" + +#include + +#include "lvgl.h" +#include "st7789.h" + +#include "ui_chrome.h" +#include "ui_feedback.h" +#include "ui_manager.h" +#include "ui_theme.h" + +#define MX 8 +#define BODY_H (LCD_V_RES - UI_CHROME_HEADER_H - UI_CHROME_FOOTER_H) +#define CONTENT_W (LCD_H_RES - 2 * MX) +#define ROW_GAP 8 + +#define DEV_ROW_H 62 +#define DEV_W 34 +#define DEV_H 44 +#define DEV_RAD 6 +#define DEV_COL_W 58 + +#define CONN_W 70 +#define CONN_H DEV_H +#define CONN_MID 22 +#define DOT_R 3 + +#define STEP_MARK_W 16 +#define STEP_GAP 4 + +#define STREAM_MS 220 +#define STREAM_START 62 +#define STREAM_STEP 4 +#define STREAM_MAX 100 + +#define COL_DIM 0x8A8594 +#define COL_OK 0x00E676 +#define COL_CYAN 0x37E0A8 +#define COL_LINE 0x2A2636 + +#define PCT_BUF 16 + +#define HDR_TITLE "SHARE (P2P)" +#define HDR_ICON "/assets/icons/nfc.bin" +#define FOOTER_HINT "OK send UP/DOWN payload BACK" + +#define DEV_LOCAL "HB" +#define DEV_REMOTE LV_SYMBOL_CALL +#define LBL_LOCAL "HighBoy" +#define LBL_REMOTE "Pixel 8" + +#define STEP1_TXT "LLCP link" +#define STEP1_META "ATR ok" +#define STEP2_TXT "SNEP connect" +#define STEP2_META "port 04" +#define STEP3_TXT "PUT ndef" +#define STEP_DONE "done" + +static lv_obj_t *s_screen = NULL; +static lv_obj_t *s_put_mark = NULL; +static lv_obj_t *s_put_pct = NULL; +static lv_timer_t *s_stream_timer = NULL; +static lv_point_precise_t s_link_pts[2] = {{2, CONN_MID}, {CONN_W - 2, CONN_MID}}; + +static int s_pct = STREAM_START; + +static lv_obj_t *make_device(lv_obj_t *parent, const char *glyph, const char *name, bool active) { + lv_obj_t *col = lv_obj_create(parent); + lv_obj_remove_flag(col, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(col, DEV_COL_W, LV_SIZE_CONTENT); + lv_obj_set_style_bg_opa(col, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(col, 0, 0); + lv_obj_set_style_pad_all(col, 0, 0); + lv_obj_set_style_pad_row(col, 3, 0); + lv_obj_set_flex_flow(col, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(col, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + + lv_obj_t *box = lv_obj_create(col); + lv_obj_remove_flag(box, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(box, DEV_W, DEV_H); + lv_obj_set_style_radius(box, DEV_RAD, 0); + lv_obj_set_style_bg_color(box, current_theme.bg_primary, 0); + lv_obj_set_style_bg_opa(box, LV_OPA_COVER, 0); + lv_obj_set_style_border_color( + box, active ? current_theme.border_accent : lv_color_hex(COL_LINE), 0); + lv_obj_set_style_border_width(box, 1, 0); + lv_obj_set_style_pad_all(box, 0, 0); + + lv_obj_t *g = lv_label_create(box); + lv_label_set_text(g, glyph); + lv_obj_set_style_text_font(g, &lv_font_montserrat_14, 0); + lv_obj_set_style_text_color(g, active ? current_theme.border_accent : current_theme.text_main, 0); + lv_obj_center(g); + + lv_obj_t *nm = lv_label_create(col); + lv_label_set_text(nm, name); + lv_obj_set_style_text_font(nm, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(nm, lv_color_hex(COL_DIM), 0); + return col; +} + +static void make_connector(lv_obj_t *parent) { + lv_obj_t *conn = lv_obj_create(parent); + lv_obj_remove_flag(conn, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(conn, CONN_W, CONN_H); + lv_obj_set_style_bg_opa(conn, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(conn, 0, 0); + lv_obj_set_style_pad_all(conn, 0, 0); + + lv_obj_t *line = lv_line_create(conn); + lv_line_set_points(line, s_link_pts, 2); + lv_obj_set_style_line_color(line, current_theme.border_accent, 0); + lv_obj_set_style_line_width(line, 2, 0); + lv_obj_set_style_line_dash_width(line, 4, 0); + lv_obj_set_style_line_dash_gap(line, 3, 0); + + const int dot_x[3] = {22, 40, 58}; + const lv_color_t dot_c[3] = { + current_theme.border_accent, lv_color_hex(COL_CYAN), current_theme.border_accent}; + for (int i = 0; i < 3; i++) { + lv_obj_t *dot = lv_obj_create(conn); + lv_obj_remove_flag(dot, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(dot, DOT_R * 2, DOT_R * 2); + lv_obj_align(dot, LV_ALIGN_LEFT_MID, dot_x[i] - DOT_R, CONN_MID - CONN_H / 2); + lv_obj_set_style_radius(dot, LV_RADIUS_CIRCLE, 0); + lv_obj_set_style_border_width(dot, 0, 0); + lv_obj_set_style_pad_all(dot, 0, 0); + lv_obj_set_style_bg_color(dot, dot_c[i], 0); + lv_obj_set_style_bg_opa(dot, LV_OPA_COVER, 0); + } +} + +static void make_step(lv_obj_t *parent, + const char *mark, + lv_color_t mark_col, + const char *txt, + const char *meta, + lv_color_t meta_col, + lv_obj_t **out_mark, + lv_obj_t **out_meta) { + lv_obj_t *row = lv_obj_create(parent); + lv_obj_remove_flag(row, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_width(row, lv_pct(100)); + lv_obj_set_height(row, LV_SIZE_CONTENT); + lv_obj_set_style_bg_opa(row, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(row, 0, 0); + lv_obj_set_style_pad_all(row, 0, 0); + lv_obj_set_style_pad_column(row, STEP_GAP, 0); + 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_t *m = lv_label_create(row); + lv_label_set_text(m, mark); + lv_obj_set_style_text_font(m, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(m, mark_col, 0); + lv_obj_set_width(m, STEP_MARK_W); + lv_obj_set_style_text_align(m, LV_TEXT_ALIGN_CENTER, 0); + + lv_obj_t *t = lv_label_create(row); + lv_label_set_text(t, txt); + lv_obj_set_style_text_font(t, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(t, current_theme.text_main, 0); + lv_obj_set_flex_grow(t, 1); + + lv_obj_t *me = lv_label_create(row); + lv_label_set_text(me, meta); + lv_obj_set_style_text_font(me, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(me, meta_col, 0); + + if (out_mark) + *out_mark = m; + if (out_meta) + *out_meta = me; +} + +static void set_pct_text(void) { + static char buf[PCT_BUF]; + snprintf(buf, sizeof(buf), "%d%%", s_pct); + lv_label_set_text(s_put_pct, buf); +} + +static void stream_cb(lv_timer_t *t) { + if (lv_screen_active() != s_screen) { + lv_timer_delete(t); + s_stream_timer = NULL; + return; + } + s_pct += STREAM_STEP; + if (s_pct >= STREAM_MAX) { + s_pct = STREAM_MAX; + lv_label_set_text(s_put_mark, LV_SYMBOL_OK); + lv_obj_set_style_text_color(s_put_mark, lv_color_hex(COL_OK), 0); + lv_label_set_text(s_put_pct, STEP_DONE); + lv_obj_set_style_text_color(s_put_pct, lv_color_hex(COL_OK), 0); + lv_timer_delete(t); + s_stream_timer = NULL; + return; + } + set_pct_text(); +} + +static void build_body(lv_obj_t *parent) { + lv_obj_t *devrow = lv_obj_create(parent); + lv_obj_remove_flag(devrow, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_width(devrow, lv_pct(100)); + lv_obj_set_height(devrow, DEV_ROW_H); + lv_obj_set_style_bg_opa(devrow, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(devrow, 0, 0); + lv_obj_set_style_pad_all(devrow, 0, 0); + lv_obj_set_flex_flow(devrow, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align( + devrow, LV_FLEX_ALIGN_SPACE_BETWEEN, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + + make_device(devrow, DEV_LOCAL, LBL_LOCAL, true); + make_connector(devrow); + make_device(devrow, DEV_REMOTE, LBL_REMOTE, false); + + lv_obj_t *steps = lv_obj_create(parent); + lv_obj_remove_flag(steps, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_width(steps, lv_pct(100)); + lv_obj_set_height(steps, LV_SIZE_CONTENT); + lv_obj_set_style_bg_opa(steps, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(steps, 0, 0); + lv_obj_set_style_pad_all(steps, 0, 0); + lv_obj_set_style_pad_row(steps, STEP_GAP, 0); + lv_obj_set_flex_flow(steps, LV_FLEX_FLOW_COLUMN); + + make_step(steps, + LV_SYMBOL_OK, + lv_color_hex(COL_OK), + STEP1_TXT, + STEP1_META, + lv_color_hex(COL_DIM), + NULL, + NULL); + make_step(steps, + LV_SYMBOL_OK, + lv_color_hex(COL_OK), + STEP2_TXT, + STEP2_META, + lv_color_hex(COL_DIM), + NULL, + NULL); + make_step(steps, + LV_SYMBOL_RIGHT, + current_theme.border_accent, + STEP3_TXT, + "", + current_theme.border_accent, + &s_put_mark, + &s_put_pct); + set_pct_text(); +} + +static void nfc_p2p_input(const input_event_t *ev, void *ctx) { + (void)ctx; + const bool press = (ev->action == INPUT_ACTION_PRESS); + + switch (ev->button) { + case INPUT_BTN_BACK: + case INPUT_BTN_LEFT: + if (press) + ui_switch_screen(SCREEN_NFC_MENU); + break; + case INPUT_BTN_OK: + if (press) + ui_feedback(UI_FB_SELECT); + break; + default: + break; + } +} + +void ui_nfc_p2p_open(void) { + if (s_screen != NULL) { + lv_obj_del(s_screen); + s_screen = NULL; + } + if (s_stream_timer != NULL) { + lv_timer_delete(s_stream_timer); + s_stream_timer = NULL; + } + s_pct = STREAM_START; + + s_screen = lv_obj_create(NULL); + lv_obj_set_style_bg_color(s_screen, current_theme.screen_base, 0); + lv_obj_set_style_bg_opa(s_screen, LV_OPA_COVER, 0); + lv_obj_remove_flag(s_screen, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_border_width(s_screen, 0, 0); + lv_obj_set_style_pad_all(s_screen, 0, 0); + + ui_chrome_header(s_screen, HDR_TITLE, HDR_ICON); + + lv_obj_t *body = lv_obj_create(s_screen); + lv_obj_remove_flag(body, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_remove_flag(body, LV_OBJ_FLAG_CLICKABLE); + lv_obj_set_size(body, LCD_H_RES, BODY_H); + lv_obj_align(body, LV_ALIGN_TOP_MID, 0, UI_CHROME_HEADER_H); + lv_obj_set_style_bg_opa(body, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(body, 0, 0); + lv_obj_set_style_pad_all(body, MX, 0); + lv_obj_set_style_pad_row(body, ROW_GAP, 0); + lv_obj_set_flex_flow(body, LV_FLEX_FLOW_COLUMN); + + build_body(body); + + ui_chrome_footer(s_screen, FOOTER_HINT); + + ui_input_set_screen_handler(nfc_p2p_input, NULL); + s_stream_timer = lv_timer_create(stream_cb, STREAM_MS, NULL); + + ui_screen_load_owned(&s_screen, s_screen); +} diff --git a/firmware_p4/components/Applications/ui/screens/nfc/nfc_read_ui.c b/firmware_p4/components/Applications/ui/screens/nfc/nfc_read_ui.c new file mode 100644 index 000000000..f196474a1 --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/nfc/nfc_read_ui.c @@ -0,0 +1,290 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#include "nfc_read_ui.h" + +#include + +#include "esp_random.h" +#include "lvgl.h" + +#include "capture_result_ui.h" +#include "keyboard_ui.h" +#include "notify_ui.h" +#include "ui_feedback.h" +#include "nfc_sim.h" +#include "nfc_ui_common.h" +#include "ui_chrome.h" +#include "ui_manager.h" +#include "ui_theme.h" + +#define REFRESH_MS 33 +#define REVEAL_MS 3000 + +#define COL_DIM 0x8A8594 +#define DUMP_W 214 +#define DUMP_Y 102 +#define DUMP_ROW_GAP 2 + +enum { ST_SCAN, ST_FOUND, ST_OPTIONS }; + +static const char *const DUMP_LINES[] = { + "Sector 0 KeyA FFFFFFFFFFFF ok", + "Sector 1 KeyB A0A1A2A3A4A5 ok", + "Sectors 16/16 Keys 32/32", +}; +#define DUMP_LINE_COUNT ((int)(sizeof(DUMP_LINES) / sizeof(DUMP_LINES[0]))) + +static lv_obj_t *s_screen = NULL; +static lv_obj_t *s_status = NULL; +static lv_obj_t *s_hint = NULL; +static lv_obj_t *s_card_panel = NULL; +static lv_obj_t *s_dump = NULL; +static nfc_ui_field_t s_field; +static lv_timer_t *s_timer = NULL; +static capture_result_t s_cr = {0}; + +static int s_state = ST_SCAN; +static uint32_t s_scan_start = 0; +static uint32_t s_scan_deadline = 1800; +static uint32_t s_found_at = 0; +static nfc_sim_card_t s_card; + +static void show_rings(bool show) { + for (int i = 0; i < 3; i++) { + if (!s_field.ring[i]) + continue; + if (show) + lv_obj_remove_flag(s_field.ring[i], LV_OBJ_FLAG_HIDDEN); + else + lv_obj_add_flag(s_field.ring[i], LV_OBJ_FLAG_HIDDEN); + } +} + +static void begin_scan(void) { + s_state = ST_SCAN; + s_scan_start = lv_tick_get(); + s_scan_deadline = 1500 + (esp_random() % 1400); + if (s_card_panel) { + lv_obj_del(s_card_panel); + s_card_panel = NULL; + } + if (s_dump) { + lv_obj_del(s_dump); + s_dump = NULL; + } + capture_result_destroy(&s_cr); + show_rings(true); + lv_obj_remove_flag(s_status, LV_OBJ_FLAG_HIDDEN); + lv_obj_set_style_text_color(s_status, current_theme.text_main, 0); + lv_label_set_text(s_status, "Searching"); + ui_chrome_footer_set_text(s_hint, "BACK Exit"); +} + +static void build_dump(void) { + s_dump = lv_obj_create(s_screen); + lv_obj_remove_flag(s_dump, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_width(s_dump, DUMP_W); + lv_obj_set_height(s_dump, LV_SIZE_CONTENT); + lv_obj_align(s_dump, LV_ALIGN_CENTER, 0, DUMP_Y); + lv_obj_set_style_bg_opa(s_dump, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(s_dump, 0, 0); + lv_obj_set_style_pad_all(s_dump, 0, 0); + lv_obj_set_flex_flow(s_dump, LV_FLEX_FLOW_COLUMN); + lv_obj_set_style_pad_row(s_dump, DUMP_ROW_GAP, 0); + for (int i = 0; i < DUMP_LINE_COUNT; i++) { + lv_obj_t *ln = lv_label_create(s_dump); + lv_label_set_text(ln, DUMP_LINES[i]); + lv_obj_set_style_text_color(ln, lv_color_hex(COL_DIM), 0); + lv_obj_set_style_text_font(ln, &lv_font_montserrat_12, 0); + } +} + +static void reveal(void) { + s_state = ST_FOUND; + s_found_at = lv_tick_get(); + nfc_sim_random_card(&s_card); + show_rings(false); + s_card_panel = nfc_ui_card_panel(s_screen, &s_card); + lv_obj_align(s_card_panel, LV_ALIGN_CENTER, 0, 10); + lv_obj_fade_in(s_card_panel, 280, 0); + build_dump(); + lv_obj_fade_in(s_dump, 280, 120); + lv_obj_set_style_text_color(s_status, lv_color_hex(0x00E676), 0); + lv_label_set_text(s_status, "Tag found!"); + nfc_ui_play_sound(NFC_SND_FOUND); + ui_chrome_footer_set_text(s_hint, "BACK Exit"); +} + +static void show_options(void) { + if (s_card_panel) { + lv_obj_del(s_card_panel); + s_card_panel = NULL; + } + if (s_dump) { + lv_obj_del(s_dump); + s_dump = NULL; + } + if (s_status) + lv_obj_add_flag(s_status, LV_OBJ_FLAG_HIDDEN); + + static char uidbuf[40]; + char uid[24]; + nfc_sim_format_uid(&s_card, uid, sizeof(uid)); + snprintf(uidbuf, sizeof(uidbuf), "UID %s", uid); + + capture_result_cfg_t cfg = { + .accent = current_theme.border_accent, + .card_icon = "/assets/icons/nfc.bin", + .card_title = "Tag captured", + .card_sub = s_card.type, + .card_value = uidbuf, + .primary_label = "Emulate", + .again_label = "Read again", + }; + s_cr = capture_result_create(s_screen, &cfg); + s_state = ST_OPTIONS; + ui_chrome_footer_set_text(s_hint, "UP/DOWN choose OK do BACK exit"); +} + +static void on_name_submit(const char *text, void *ud) { + (void)ud; + const char *nm = (text && text[0]) ? text : s_card.type; + snprintf(s_card.name, NFC_SIM_NAME_LEN, "%.*s", NFC_SIM_NAME_LEN - 1, nm); + bool saved = nfc_sim_add(&s_card); + if (saved) { + nfc_ui_play_sound(NFC_SND_SAVE); + capture_result_mark_saved(&s_cr); + notify(NOTIFY_SAVED, "Tag saved to library"); + } else { + notify(NOTIFY_WARNING, "Library full"); + } +} + +static void nfc_read_input(const input_event_t *ev, void *ctx) { + (void)ctx; + const bool press = (ev->action == INPUT_ACTION_PRESS); + const bool nav = press || (ev->action == INPUT_ACTION_REPEAT); + + if (ev->button == INPUT_BTN_BACK) { + if (press) + ui_switch_screen(SCREEN_NFC_MENU); + return; + } + + if (s_state != ST_OPTIONS) + return; + + switch (ev->button) { + case INPUT_BTN_DOWN: + if (nav) { + capture_result_next(&s_cr); + ui_feedback(UI_FB_NAV); + } + break; + case INPUT_BTN_UP: + if (nav) { + capture_result_prev(&s_cr); + ui_feedback(UI_FB_NAV); + } + break; + case INPUT_BTN_OK: + if (press) { + switch (capture_result_selected(&s_cr)) { + case CAP_ACT_PRIMARY: + ui_switch_screen(SCREEN_NFC_EMULATE); + break; + case CAP_ACT_SAVE: + keyboard_open(NULL, on_name_submit, NULL); + break; + case CAP_ACT_AGAIN: + begin_scan(); + break; + case CAP_ACT_DISCARD: + ui_switch_screen(SCREEN_NFC_MENU); + break; + default: + break; + } + } + break; + default: + break; + } +} + +static void refresh_cb(lv_timer_t *t) { + if (lv_screen_active() != s_screen) { + lv_timer_delete(t); + s_timer = NULL; + return; + } + + if (s_state == ST_SCAN) { + uint32_t el = lv_tick_get() - s_scan_start; + nfc_ui_field_tick(&s_field, el); + int dots = (el / 350) % 4; + char buf[20]; + snprintf(buf, + sizeof(buf), + "Searching%s", + dots == 1 ? "." + : dots == 2 ? ".." + : dots == 3 ? "..." + : ""); + lv_label_set_text(s_status, buf); + if (el >= s_scan_deadline) + reveal(); + } else if (s_state == ST_FOUND) { + if (lv_tick_get() - s_found_at >= REVEAL_MS) + show_options(); + } +} + +void ui_nfc_read_open(void) { + nfc_sim_init(); + if (s_screen != NULL) { + lv_obj_del(s_screen); + s_screen = NULL; + } + s_card_panel = NULL; + s_dump = NULL; + s_cr = (capture_result_t){0}; + s_found_at = 0; + + s_screen = lv_obj_create(NULL); + lv_obj_set_style_bg_color(s_screen, current_theme.screen_base, 0); + lv_obj_set_style_bg_opa(s_screen, LV_OPA_COVER, 0); + lv_obj_remove_flag(s_screen, LV_OBJ_FLAG_SCROLLABLE); + + ui_chrome_header(s_screen, "READ TAG", "/assets/icons/contactless.bin"); + nfc_ui_field_create(&s_field, s_screen, ui_theme_get_accent()); + + s_status = lv_label_create(s_screen); + lv_label_set_text(s_status, "Searching"); + lv_obj_set_style_text_color(s_status, current_theme.text_main, 0); + lv_obj_align(s_status, LV_ALIGN_TOP_MID, 0, 52); + + s_hint = ui_chrome_footer(s_screen, "BACK Exit"); + + begin_scan(); + + if (s_timer == NULL) + s_timer = lv_timer_create(refresh_cb, REFRESH_MS, NULL); + + ui_input_set_screen_handler(nfc_read_input, NULL); + + ui_screen_load_owned(&s_screen, s_screen); +} diff --git a/firmware_p4/components/Applications/ui/screens/nfc/nfc_saved_ui.c b/firmware_p4/components/Applications/ui/screens/nfc/nfc_saved_ui.c new file mode 100644 index 000000000..e9c652468 --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/nfc/nfc_saved_ui.c @@ -0,0 +1,384 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#include "nfc_saved_ui.h" + +#include + +#include "lvgl.h" + +#include "st7789.h" + +#include "assets_manager.h" +#include "capture_result_ui.h" +#include "msgbox_ui.h" +#include "nfc_sim.h" +#include "nfc_ui_common.h" +#include "notify_ui.h" +#include "page_dots_ui.h" +#include "ui_chrome.h" +#include "ui_feedback.h" +#include "ui_manager.h" +#include "ui_theme.h" + +#define TICK_MS 50 +#define CARD_H 122 +#define PEEK 60 +#define COL_DIM 0x8A8594 +#define SAVED_ICON "/assets/icons/bookmarks.bin" +#define CARD_ICON "/assets/icons/nfc.bin" +#define CARD_REVEAL_MS 1100 +#define UID_VAL_LEN 40 + +enum { DT_NONE, DT_VIEW }; +enum { DV_CARD, DV_CHOICES }; + +static lv_obj_t *s_screen = NULL; +static lv_obj_t *s_cont = NULL; +static lv_timer_t *s_tick_timer = NULL; +static lv_obj_t *s_cards[NFC_SIM_MAX_SAVED]; +static page_dots_t s_dots; +static bool s_has_dots = false; +static int s_count = 0; +static int s_sel = 0; +static bool s_empty = false; + +static lv_obj_t *s_ov = NULL; +static lv_obj_t *s_ov_card = NULL; +static lv_obj_t *s_ov_footer = NULL; +static capture_result_t s_cr = {0}; +static nfc_sim_card_t s_view_card; +static int s_detail = DT_NONE; +static int s_phase = DV_CARD; +static int s_sel_idx = -1; +static uint32_t s_reveal_start = 0; + +static void rebuild_async(void *p) { + (void)p; + ui_nfc_saved_open(); +} + +static lv_obj_t *lit_panel(lv_obj_t *parent, int w, int h) { + lv_obj_t *p = lv_obj_create(parent); + lv_obj_remove_flag(p, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(p, w, h); + lv_obj_set_style_radius(p, 13, 0); + lv_obj_set_style_bg_color(p, current_theme.bg_secondary, 0); + lv_obj_set_style_bg_opa(p, LV_OPA_COVER, 0); + lv_obj_set_style_bg_grad_dir(p, LV_GRAD_DIR_NONE, 0); + lv_obj_set_style_border_width(p, 1, 0); + lv_obj_set_style_border_color(p, current_theme.border_accent, 0); + lv_obj_set_style_shadow_color(p, current_theme.border_accent, 0); + lv_obj_set_style_shadow_width(p, 16, 0); + lv_obj_set_style_shadow_opa(p, LV_OPA_40, 0); + lv_obj_set_style_shadow_spread(p, -4, 0); + return p; +} + +static void build_empty(void) { + lv_obj_t *card = lit_panel(s_screen, 200, 104); + lv_obj_align(card, LV_ALIGN_CENTER, 0, 6); + lv_obj_set_style_pad_all(card, 10, 0); + lv_obj_set_flex_flow(card, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(card, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_set_style_pad_row(card, 6, 0); + + lv_image_dsc_t *dsc = assets_get(SAVED_ICON); + if (dsc != NULL) { + lv_obj_t *img = lv_image_create(card); + lv_image_set_src(img, dsc); + lv_obj_set_style_image_recolor(img, current_theme.text_main, 0); + lv_obj_set_style_image_recolor_opa(img, LV_OPA_COVER, 0); + } + lv_obj_t *t = lv_label_create(card); + lv_label_set_text(t, "No saved cards"); + lv_obj_set_style_text_font(t, &lv_font_montserrat_14, 0); + lv_obj_set_style_text_color(t, current_theme.text_main, 0); + lv_obj_t *s = lv_label_create(card); + lv_label_set_text(s, "Read a tag first"); + lv_obj_set_style_text_font(s, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(s, lv_color_hex(COL_DIM), 0); +} + +static void relayout(void) { + for (int i = 0; i < s_count; i++) { + int y = i * PEEK; + if (i > s_sel) + y += (CARD_H - PEEK); + lv_obj_align(s_cards[i], LV_ALIGN_TOP_MID, 0, y); + + bool sel = (i == s_sel); + if (sel) { + lv_obj_set_style_border_width(s_cards[i], 3, 0); + lv_obj_set_style_border_color(s_cards[i], current_theme.border_accent, 0); + lv_obj_set_style_bg_opa(s_cards[i], LV_OPA_COVER, 0); + lv_obj_set_style_shadow_color(s_cards[i], current_theme.border_accent, 0); + lv_obj_set_style_shadow_width(s_cards[i], 14, 0); + lv_obj_set_style_shadow_opa(s_cards[i], LV_OPA_50, 0); + lv_obj_set_style_shadow_spread(s_cards[i], -3, 0); + } else { + lv_obj_set_style_border_width(s_cards[i], 1, 0); + lv_obj_set_style_border_color(s_cards[i], nfc_ui_card_color(nfc_sim_saved_get(i)), 0); + lv_obj_set_style_shadow_width(s_cards[i], 0, 0); + lv_obj_set_style_shadow_opa(s_cards[i], LV_OPA_TRANSP, 0); + } + } + int sy = s_sel * PEEK - 6; + if (sy < 0) + sy = 0; + lv_obj_scroll_to_y(s_cont, sy, LV_ANIM_OFF); + if (s_has_dots) + page_dots_set(&s_dots, s_sel); +} + +static void overlay_close(void) { + if (s_ov) { + lv_obj_del(s_ov); + s_ov = NULL; + } + s_cr = (capture_result_t){0}; + s_ov_card = NULL; + s_ov_footer = NULL; + s_detail = DT_NONE; + s_phase = DV_CARD; +} + +static void overlay_open(const nfc_sim_card_t *c, int idx) { + s_view_card = *c; + + s_ov = lv_obj_create(s_screen); + lv_obj_set_size(s_ov, lv_pct(100), lv_pct(100)); + lv_obj_center(s_ov); + lv_obj_remove_flag(s_ov, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_bg_color(s_ov, lv_color_black(), 0); + lv_obj_set_style_bg_opa(s_ov, LV_OPA_90, 0); + lv_obj_set_style_border_width(s_ov, 0, 0); + lv_obj_set_style_pad_all(s_ov, 0, 0); + + // Transient card-detail overlay: snapshot header (no rebind) so closing it never + // dangles the live saved-list's dynamic header underneath. + ui_chrome_header_overlay(s_ov, "CARD", SAVED_ICON); + s_ov_footer = ui_chrome_footer(s_ov, "OK Options BACK Back"); + + s_ov_card = nfc_ui_card_panel(s_ov, &s_view_card); + lv_obj_align(s_ov_card, LV_ALIGN_CENTER, 0, -6); + lv_obj_fade_in(s_ov_card, 220, 0); + + s_detail = DT_VIEW; + s_phase = DV_CARD; + s_sel_idx = idx; + s_reveal_start = lv_tick_get(); + ui_feedback(UI_FB_SELECT); +} + +static void choices_step(bool forward) { + do { + if (forward) + capture_result_next(&s_cr); + else + capture_result_prev(&s_cr); + } while (capture_result_selected(&s_cr) != CAP_ACT_PRIMARY && + capture_result_selected(&s_cr) != CAP_ACT_DISCARD); +} + +static void present_choices(void) { + if (s_ov_card) { + lv_obj_del(s_ov_card); + s_ov_card = NULL; + } + + char uid[24]; + static char valbuf[UID_VAL_LEN]; + nfc_sim_format_uid(&s_view_card, uid, sizeof(uid)); + snprintf(valbuf, sizeof(valbuf), "UID %s", uid); + + capture_result_cfg_t cfg = { + .accent = current_theme.border_accent, + .card_icon = CARD_ICON, + .card_title = s_view_card.name[0] ? s_view_card.name : s_view_card.type, + .card_sub = s_view_card.type, + .card_value = valbuf, + .primary_label = "Emulate", + }; + s_cr = capture_result_create(s_ov, &cfg); + lv_obj_add_flag(s_cr.rows[CAP_ACT_SAVE], LV_OBJ_FLAG_HIDDEN); + lv_obj_add_flag(s_cr.rows[CAP_ACT_AGAIN], LV_OBJ_FLAG_HIDDEN); + + ui_chrome_footer_set_text(s_ov_footer, LV_SYMBOL_UP LV_SYMBOL_DOWN " Choose OK Do BACK Back"); + s_phase = DV_CHOICES; +} + +static void on_del_confirm(bool confirm) { + if (!confirm) + return; + nfc_sim_remove(s_sel_idx); + ui_feedback(UI_FB_WRITE); + notify(NOTIFY_INFO, "Card deleted"); + overlay_close(); + lv_async_call(rebuild_async, NULL); +} + +static void nfc_saved_tick_cb(lv_timer_t *t) { + if (lv_screen_active() != s_screen) { + lv_timer_delete(t); + s_tick_timer = NULL; + return; + } + if (s_detail == DT_VIEW && s_phase == DV_CARD && !(msgbox_is_open() || ui_input_is_locked()) && + lv_tick_get() - s_reveal_start >= CARD_REVEAL_MS) { + present_choices(); + } +} + +static void nfc_saved_input(const input_event_t *ev, void *ctx) { + (void)ctx; + const bool press = (ev->action == INPUT_ACTION_PRESS); + const bool nav = press || (ev->action == INPUT_ACTION_REPEAT); + + if (s_detail == DT_VIEW) { + if (ev->button == INPUT_BTN_BACK) { + if (press) + overlay_close(); + return; + } + if (s_phase == DV_CARD) { + if (ev->button == INPUT_BTN_OK && press) + present_choices(); + return; + } + switch (ev->button) { + case INPUT_BTN_DOWN: + if (nav) { + choices_step(true); + ui_feedback(UI_FB_NAV); + } + break; + case INPUT_BTN_UP: + if (nav) { + choices_step(false); + ui_feedback(UI_FB_NAV); + } + break; + case INPUT_BTN_OK: + if (press) { + if (capture_result_selected(&s_cr) == CAP_ACT_PRIMARY) { + ui_feedback(UI_FB_EMULATE); + ui_switch_screen(SCREEN_NFC_EMULATE); + } else { + msgbox_open(LV_SYMBOL_TRASH, "Delete this card?", "Delete", "Cancel", on_del_confirm); + } + } + break; + default: + break; + } + return; + } + + switch (ev->button) { + case INPUT_BTN_BACK: + case INPUT_BTN_LEFT: + if (press) + ui_switch_screen(SCREEN_NFC_MENU); + break; + case INPUT_BTN_DOWN: + if (nav && !s_empty && s_sel < s_count - 1) { + s_sel++; + relayout(); + ui_feedback(UI_FB_NAV); + } + break; + case INPUT_BTN_UP: + if (nav && !s_empty && s_sel > 0) { + s_sel--; + relayout(); + ui_feedback(UI_FB_NAV); + } + break; + case INPUT_BTN_OK: + case INPUT_BTN_RIGHT: + if (press && !s_empty) { + const nfc_sim_card_t *c = nfc_sim_saved_get(s_sel); + if (c != NULL) + overlay_open(c, s_sel); + } + break; + default: + break; + } +} + +void ui_nfc_saved_open(void) { + nfc_sim_init(); + if (s_tick_timer != NULL) { + lv_timer_delete(s_tick_timer); + s_tick_timer = NULL; + } + if (s_screen != NULL) { + lv_obj_del(s_screen); + s_screen = NULL; + } + s_ov = NULL; + s_ov_card = NULL; + s_ov_footer = NULL; + s_cr = (capture_result_t){0}; + s_has_dots = false; + s_detail = DT_NONE; + s_phase = DV_CARD; + s_sel_idx = -1; + s_sel = 0; + + s_screen = lv_obj_create(NULL); + lv_obj_set_style_bg_color(s_screen, current_theme.screen_base, 0); + lv_obj_set_style_bg_opa(s_screen, LV_OPA_COVER, 0); + lv_obj_remove_flag(s_screen, LV_OBJ_FLAG_SCROLLABLE); + + ui_chrome_header(s_screen, "SAVED", SAVED_ICON); + + s_count = nfc_sim_saved_count(); + if (s_count > NFC_SIM_MAX_SAVED) + s_count = NFC_SIM_MAX_SAVED; + s_empty = (s_count == 0); + + if (s_empty) { + build_empty(); + ui_chrome_footer(s_screen, "BACK Back"); + } else { + s_cont = lv_obj_create(s_screen); + lv_obj_set_size(s_cont, lv_pct(100), LCD_V_RES - UI_CHROME_HEADER_H - UI_CHROME_FOOTER_H); + lv_obj_align(s_cont, LV_ALIGN_TOP_MID, 0, UI_CHROME_HEADER_H); + lv_obj_set_style_bg_opa(s_cont, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(s_cont, 0, 0); + lv_obj_set_style_pad_all(s_cont, 0, 0); + lv_obj_set_scrollbar_mode(s_cont, LV_SCROLLBAR_MODE_OFF); + + for (int i = 0; i < s_count; i++) { + s_cards[i] = nfc_ui_card_panel(s_cont, nfc_sim_saved_get(i)); + lv_obj_set_style_bg_grad_dir(s_cards[i], LV_GRAD_DIR_NONE, 0); + } + lv_obj_update_layout(s_screen); + relayout(); + + s_dots = page_dots_create(s_screen, s_count, LV_ALIGN_BOTTOM_MID, 0, -28); + s_has_dots = true; + page_dots_set(&s_dots, s_sel); + + ui_chrome_footer(s_screen, LV_SYMBOL_UP LV_SYMBOL_DOWN " Browse OK Open BACK Exit"); + } + + ui_input_set_screen_handler(nfc_saved_input, NULL); + s_tick_timer = lv_timer_create(nfc_saved_tick_cb, TICK_MS, NULL); + ui_screen_load_owned(&s_screen, s_screen); +} diff --git a/firmware_p4/components/Applications/ui/screens/nfc/nfc_scan_ui.c b/firmware_p4/components/Applications/ui/screens/nfc/nfc_scan_ui.c new file mode 100644 index 000000000..49c96a18d --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/nfc/nfc_scan_ui.c @@ -0,0 +1,347 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#include "nfc_scan_ui.h" + +#include + +#include "esp_random.h" +#include "lvgl.h" + +#include "ui_chrome.h" +#include "ui_feedback.h" +#include "ui_manager.h" +#include "ui_theme.h" + +#define NFC_ICON "/assets/icons/contactless.bin" + +#define SIG_GREEN 0x00E676 +#define COL_DIM 0x8A8594 +#define COL_RAISE 0x170A28 + +#define SCAN_STEP_MS 480 + +#define STATUS_Y 50 + +#define CARD_W 214 +#define CARD_Y_OFS 8 +#define CARD_RADIUS 14 +#define CARD_PAD 12 +#define CARD_ROW_GAP 7 +#define CARD_BORDER 1 +#define CARD_GLOW_W 16 +#define CARD_GLOW_SPREAD -4 + +#define ROW_H 26 +#define ROW_RADIUS 8 +#define ROW_PAD_HOR 6 +#define ROW_GROUP_GAP 9 + +#define DOT_SZ 9 +#define DOT_RADIUS 5 + +#define STATUS_SCAN "Polling field" +#define STATUS_DONE "Identify complete" + +#define HINT_SCAN "Identifying..." +#define HINT_DONE "BACK Exit" + +#define TITLE_TEXT "Tag technologies" + +#define VAL_QUEUED "queued" +#define VAL_CHECK "checking..." +#define VAL_ABSENT "not present" + +#define UID_BUF 16 +#define VALUE_BUF 40 + +#define UID_HEAD_BYTE 0x04 + +typedef enum { + ROW_PENDING = 0, + ROW_CHECKING, + ROW_PRESENT, + ROW_ABSENT, +} row_state_t; + +static const struct { + const char *tech; + const char *iso; + bool present; +} TECHS[] = { + {"NFC-A", "ISO14443A", true}, + {"NFC-B", "ISO14443B", false}, + {"NFC-F", "FeliCa", false}, + {"NFC-V", "ISO15693", false}, +}; +#define TECH_COUNT ((int)(sizeof(TECHS) / sizeof(TECHS[0]))) + +static lv_obj_t *s_screen = NULL; +static lv_timer_t *s_scan_timer = NULL; + +static lv_obj_t *s_status = NULL; +static lv_obj_t *s_hint = NULL; +static lv_obj_t *s_summary = NULL; +static lv_obj_t *s_row[TECH_COUNT]; +static lv_obj_t *s_dot[TECH_COUNT]; +static lv_obj_t *s_value[TECH_COUNT]; + +static char s_present_value[VALUE_BUF]; +static int s_cursor = 0; + +static void scan_tick_cb(lv_timer_t *t); + +static void stop_timer(lv_timer_t **t) { + if (*t != NULL) { + lv_timer_delete(*t); + *t = NULL; + } +} + +static void build_uid(void) { + uint32_t r = esp_random(); + char uid[UID_BUF]; + snprintf(uid, + sizeof(uid), + "%02X:%02X:%02X:%02X", + UID_HEAD_BYTE, + (unsigned)(r & 0xFF), + (unsigned)((r >> 8) & 0xFF), + (unsigned)((r >> 16) & 0xFF)); + snprintf(s_present_value, sizeof(s_present_value), "%s %s", TECHS[0].iso, uid); +} + +static void set_row_state(int i, row_state_t state) { + if (s_dot[i] == NULL || s_value[i] == NULL || s_row[i] == NULL) + return; + + lv_color_t dot_color = lv_color_hex(COL_DIM); + lv_color_t txt_color = lv_color_hex(COL_DIM); + const char *txt = VAL_QUEUED; + bool raise = false; + + switch (state) { + case ROW_CHECKING: + dot_color = current_theme.border_accent; + txt_color = current_theme.border_accent; + txt = VAL_CHECK; + raise = true; + break; + case ROW_PRESENT: + dot_color = lv_color_hex(SIG_GREEN); + txt_color = lv_color_hex(SIG_GREEN); + txt = s_present_value; + break; + case ROW_ABSENT: + dot_color = lv_color_hex(COL_DIM); + txt_color = lv_color_hex(COL_DIM); + txt = VAL_ABSENT; + break; + case ROW_PENDING: + default: + break; + } + + lv_obj_set_style_bg_color(s_row[i], lv_color_hex(COL_RAISE), 0); + lv_obj_set_style_bg_opa(s_row[i], raise ? LV_OPA_COVER : LV_OPA_TRANSP, 0); + lv_obj_set_style_bg_color(s_dot[i], dot_color, 0); + lv_label_set_text(s_value[i], txt); + lv_obj_set_style_text_color(s_value[i], txt_color, 0); +} + +static void build_card(void) { + lv_obj_t *card = lv_obj_create(s_screen); + lv_obj_remove_flag(card, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_width(card, CARD_W); + lv_obj_set_height(card, LV_SIZE_CONTENT); + lv_obj_align(card, LV_ALIGN_CENTER, 0, CARD_Y_OFS); + lv_obj_set_style_radius(card, CARD_RADIUS, 0); + lv_obj_set_style_bg_color(card, current_theme.bg_secondary, 0); + lv_obj_set_style_bg_opa(card, LV_OPA_COVER, 0); + lv_obj_set_style_bg_grad_dir(card, LV_GRAD_DIR_NONE, 0); + lv_obj_set_style_border_width(card, CARD_BORDER, 0); + lv_obj_set_style_border_color(card, current_theme.border_accent, 0); + lv_obj_set_style_shadow_color(card, current_theme.border_accent, 0); + lv_obj_set_style_shadow_width(card, CARD_GLOW_W, 0); + lv_obj_set_style_shadow_opa(card, LV_OPA_40, 0); + lv_obj_set_style_shadow_spread(card, CARD_GLOW_SPREAD, 0); + lv_obj_set_style_pad_all(card, CARD_PAD, 0); + lv_obj_set_flex_flow(card, LV_FLEX_FLOW_COLUMN); + lv_obj_set_style_pad_row(card, CARD_ROW_GAP, 0); + + lv_obj_t *title = lv_label_create(card); + lv_label_set_text(title, TITLE_TEXT); + lv_obj_set_style_text_color(title, current_theme.border_inactive, 0); + lv_obj_set_style_text_font(title, &lv_font_montserrat_12, 0); + + for (int i = 0; i < TECH_COUNT; i++) { + lv_obj_t *row = lv_obj_create(card); + s_row[i] = row; + lv_obj_remove_flag(row, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_width(row, lv_pct(100)); + lv_obj_set_height(row, ROW_H); + lv_obj_set_style_radius(row, ROW_RADIUS, 0); + lv_obj_set_style_bg_opa(row, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(row, 0, 0); + lv_obj_set_style_pad_ver(row, 0, 0); + lv_obj_set_style_pad_hor(row, ROW_PAD_HOR, 0); + 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_t *group = lv_obj_create(row); + lv_obj_remove_flag(group, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(group, LV_SIZE_CONTENT, LV_SIZE_CONTENT); + lv_obj_set_style_bg_opa(group, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(group, 0, 0); + lv_obj_set_style_pad_all(group, 0, 0); + lv_obj_set_flex_flow(group, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(group, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_set_style_pad_column(group, ROW_GROUP_GAP, 0); + + lv_obj_t *dot = lv_obj_create(group); + s_dot[i] = dot; + lv_obj_remove_flag(dot, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(dot, DOT_SZ, DOT_SZ); + lv_obj_set_style_radius(dot, DOT_RADIUS, 0); + lv_obj_set_style_border_width(dot, 0, 0); + lv_obj_set_style_bg_opa(dot, LV_OPA_COVER, 0); + lv_obj_set_style_bg_color(dot, lv_color_hex(COL_DIM), 0); + + lv_obj_t *tech = lv_label_create(group); + lv_label_set_text(tech, TECHS[i].tech); + lv_obj_set_style_text_color(tech, current_theme.text_main, 0); + lv_obj_set_style_text_font(tech, &lv_font_montserrat_14, 0); + + lv_obj_t *value = lv_label_create(row); + s_value[i] = value; + lv_label_set_long_mode(value, LV_LABEL_LONG_DOT); + lv_obj_set_flex_grow(value, 1); + lv_label_set_text(value, VAL_QUEUED); + lv_obj_set_style_text_color(value, lv_color_hex(COL_DIM), 0); + lv_obj_set_style_text_font(value, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_align(value, LV_TEXT_ALIGN_RIGHT, 0); + } + + s_summary = lv_label_create(card); + lv_label_set_text(s_summary, ""); + lv_obj_set_style_text_color(s_summary, current_theme.border_inactive, 0); + lv_obj_set_style_text_font(s_summary, &lv_font_montserrat_12, 0); +} + +static void resolve_row(int i) { + if (TECHS[i].present) { + set_row_state(i, ROW_PRESENT); + ui_feedback(UI_FB_READ); + } else { + set_row_state(i, ROW_ABSENT); + } +} + +static void finish_scan(void) { + int present = 0; + for (int i = 0; i < TECH_COUNT; i++) { + if (TECHS[i].present) + present++; + } + if (s_summary != NULL) { + char buf[32]; + snprintf(buf, sizeof(buf), "%d of %d present", present, TECH_COUNT); + lv_label_set_text(s_summary, buf); + lv_obj_set_style_text_color( + s_summary, present > 0 ? lv_color_hex(SIG_GREEN) : lv_color_hex(COL_DIM), 0); + } + if (s_status != NULL) { + lv_label_set_text(s_status, STATUS_DONE); + lv_obj_set_style_text_color(s_status, lv_color_hex(SIG_GREEN), 0); + } + if (s_hint != NULL) + ui_chrome_footer_set_text(s_hint, HINT_DONE); +} + +static void scan_tick_cb(lv_timer_t *t) { + if (lv_screen_active() != s_screen) { + lv_timer_delete(t); + s_scan_timer = NULL; + return; + } + if (s_cursor > 0) + resolve_row(s_cursor - 1); + + if (s_cursor < TECH_COUNT) { + set_row_state(s_cursor, ROW_CHECKING); + s_cursor++; + } else { + finish_scan(); + lv_timer_delete(t); + s_scan_timer = NULL; + } +} + +static void nfc_scan_input(const input_event_t *ev, void *ctx) { + (void)ctx; + const bool press = (ev->action == INPUT_ACTION_PRESS); + + switch (ev->button) { + case INPUT_BTN_BACK: + case INPUT_BTN_LEFT: + if (press) + ui_switch_screen(SCREEN_NFC_MENU); + break; + default: + break; + } +} + +void ui_nfc_scan_open(void) { + stop_timer(&s_scan_timer); + if (s_screen != NULL) { + lv_obj_del(s_screen); + s_screen = NULL; + } + s_status = NULL; + s_hint = NULL; + s_summary = NULL; + for (int i = 0; i < TECH_COUNT; i++) { + s_row[i] = NULL; + s_dot[i] = NULL; + s_value[i] = NULL; + } + s_cursor = 0; + build_uid(); + + s_screen = lv_obj_create(NULL); + lv_obj_set_style_bg_color(s_screen, current_theme.screen_base, 0); + lv_obj_set_style_bg_opa(s_screen, LV_OPA_COVER, 0); + lv_obj_remove_flag(s_screen, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_border_width(s_screen, 0, 0); + lv_obj_set_style_pad_all(s_screen, 0, 0); + + ui_chrome_header(s_screen, "IDENTIFY", NFC_ICON); + + s_status = lv_label_create(s_screen); + lv_label_set_text(s_status, STATUS_SCAN); + lv_obj_set_style_text_color(s_status, current_theme.text_main, 0); + lv_obj_set_style_text_font(s_status, &lv_font_montserrat_14, 0); + lv_obj_align(s_status, LV_ALIGN_TOP_MID, 0, STATUS_Y); + + build_card(); + + s_hint = ui_chrome_footer(s_screen, HINT_SCAN); + + s_scan_timer = lv_timer_create(scan_tick_cb, SCAN_STEP_MS, NULL); + ui_input_set_screen_handler(nfc_scan_input, NULL); + + ui_screen_load_owned(&s_screen, s_screen); +} diff --git a/firmware_p4/components/Applications/ui/screens/nfc/nfc_sim.c b/firmware_p4/components/Applications/ui/screens/nfc/nfc_sim.c new file mode 100644 index 000000000..996746a88 --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/nfc/nfc_sim.c @@ -0,0 +1,194 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#include "nfc_sim.h" + +#include +#include + +#include "esp_log.h" +#include "esp_random.h" +#include "nvs.h" + +static const char *TAG = "NFC_SIM"; +#define NVS_NS "nfc_sim" +#define NVS_KEY "cards" + +typedef struct { + const char *type; + uint8_t uid_len; + uint16_t atqa; + uint8_t sak; +} nfc_template_t; + +static const nfc_template_t POOL[] = { + {"Mifare Classic 1K", 4, 0x0004, 0x08}, + {"Mifare Classic 4K", 4, 0x0002, 0x18}, + {"NTAG215", 7, 0x0044, 0x00}, + {"Mifare Ultralight", 7, 0x0044, 0x00}, + {"DESFire EV1", 7, 0x0344, 0x20}, +}; +#define POOL_N ((int)(sizeof(POOL) / sizeof(POOL[0]))) + +typedef struct { + int count; + nfc_sim_card_t cards[NFC_SIM_MAX_SAVED]; +} store_t; + +static store_t s_store; +static bool s_loaded = false; + +static void seed_defaults(void) { + s_store.count = 0; + nfc_sim_card_t a = {0}; + strncpy(a.name, "Office Badge", NFC_SIM_NAME_LEN - 1); + strncpy(a.type, "Mifare Classic 1K", NFC_SIM_TYPE_LEN - 1); + a.uid_len = 4; + a.uid[0] = 0x04; + a.uid[1] = 0xA3; + a.uid[2] = 0x1C; + a.uid[3] = 0x9E; + a.atqa = 0x0004; + a.sak = 0x08; + s_store.cards[s_store.count++] = a; + + nfc_sim_card_t b = {0}; + strncpy(b.name, "Metro Pass", NFC_SIM_NAME_LEN - 1); + strncpy(b.type, "Mifare Ultralight", NFC_SIM_TYPE_LEN - 1); + b.uid_len = 7; + b.uid[0] = 0x04; + b.uid[1] = 0x12; + b.uid[2] = 0x77; + b.uid[3] = 0xAB; + b.uid[4] = 0x33; + b.uid[5] = 0x10; + b.uid[6] = 0x80; + b.atqa = 0x0044; + b.sak = 0x00; + s_store.cards[s_store.count++] = b; +} + +static void persist(void) { + nvs_handle_t h; + if (nvs_open(NVS_NS, NVS_READWRITE, &h) != ESP_OK) + return; + nvs_set_blob(h, NVS_KEY, &s_store, sizeof(s_store)); + nvs_commit(h); + nvs_close(h); +} + +void nfc_sim_init(void) { + if (s_loaded) + return; + s_loaded = true; + nvs_handle_t h; + size_t len = sizeof(s_store); + if (nvs_open(NVS_NS, NVS_READONLY, &h) == ESP_OK) { + esp_err_t r = nvs_get_blob(h, NVS_KEY, &s_store, &len); + nvs_close(h); + if (r == ESP_OK && len == sizeof(s_store) && s_store.count >= 0 && + s_store.count <= NFC_SIM_MAX_SAVED) { + ESP_LOGI(TAG, "loaded %d saved cards", s_store.count); + return; + } + } + seed_defaults(); + persist(); + ESP_LOGI(TAG, "seeded %d default cards", s_store.count); +} + +int nfc_sim_saved_count(void) { + nfc_sim_init(); + return s_store.count; +} + +const nfc_sim_card_t *nfc_sim_saved_get(int index) { + nfc_sim_init(); + if (index < 0 || index >= s_store.count) + return NULL; + return &s_store.cards[index]; +} + +bool nfc_sim_add(const nfc_sim_card_t *card) { + nfc_sim_init(); + if (card == NULL || s_store.count >= NFC_SIM_MAX_SAVED) + return false; + s_store.cards[s_store.count++] = *card; + persist(); + return true; +} + +void nfc_sim_remove(int index) { + nfc_sim_init(); + if (index < 0 || index >= s_store.count) + return; + for (int i = index; i < s_store.count - 1; i++) + s_store.cards[i] = s_store.cards[i + 1]; + s_store.count--; + persist(); +} + +static void fill_card(int tmpl, const char *prefix, nfc_sim_card_t *out) { + if (tmpl < 0 || tmpl >= POOL_N) + tmpl = 0; + const nfc_template_t *t = &POOL[tmpl]; + memset(out, 0, sizeof(*out)); + out->uid_len = t->uid_len; + out->atqa = t->atqa; + out->sak = t->sak; + strncpy(out->type, t->type, NFC_SIM_TYPE_LEN - 1); + for (int i = 0; i < out->uid_len; i++) + out->uid[i] = (uint8_t)(esp_random() & 0xFF); + if (out->uid_len == 7) + out->uid[0] = 0x04; + if (prefix != NULL) + snprintf(out->name, + NFC_SIM_NAME_LEN, + "%s %02X%02X", + prefix, + out->uid[out->uid_len - 2], + out->uid[out->uid_len - 1]); +} + +void nfc_sim_random_card(nfc_sim_card_t *out) { + if (!out) + return; + + fill_card((int)(esp_random() % POOL_N), NULL, out); +} + +int nfc_sim_template_count(void) { + return POOL_N; +} + +void nfc_sim_make_card(int tmpl, nfc_sim_card_t *out) { + if (out) + fill_card(tmpl, "Custom", out); +} + +void nfc_sim_format_uid(const nfc_sim_card_t *card, char *buf, int buflen) { + if (buf == NULL || buflen <= 0) + return; + buf[0] = '\0'; + if (card == NULL) + return; + int off = 0; + for (int i = 0; i < card->uid_len; i++) { + int rem = buflen - off; + if (rem <= 3) + break; + off += snprintf(buf + off, (size_t)rem, i ? ":%02X" : "%02X", card->uid[i]); + } +} diff --git a/firmware_p4/components/Applications/ui/screens/nfc/nfc_ui_common.c b/firmware_p4/components/Applications/ui/screens/nfc/nfc_ui_common.c new file mode 100644 index 000000000..d1a038aea --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/nfc/nfc_ui_common.c @@ -0,0 +1,265 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#include "nfc_ui_common.h" + +#include +#include +#include + +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include "sys_prio.h" + +#include "audio_i2s.h" +#include "drv2605l.h" +#include "ui_theme.h" + +#define RING_MIN 26 +#define RING_MAX 116 +#define RING_PERIOD 1500 + +#define NFC_SND_AMP 0.40f + +#define NFC_SND_TASK_STACK_SIZE 8192 +#define NFC_SND_TASK_PRIORITY SYS_PRIO_SERVICE_LO +#define DRV2605L_EFFECT_DOUBLE_CLICK 10 + +static const audio_note_t SND_FOUND_NOTES[] = { + {1318, 70}, + {1976, 120}, +}; +static const audio_note_t SND_SAVE_NOTES[] = { + {1568, 45}, + {2093, 80}, +}; + +static volatile bool s_snd_busy = false; + +static void nfc_snd_task(void *arg) { + nfc_ui_sound_t kind = (nfc_ui_sound_t)(intptr_t)arg; + if (kind == NFC_SND_SAVE) { + (void)drv2605l_play_effect(DRV2605L_EFFECT_DOUBLE_CLICK); + audio_i2s_play_song(SND_SAVE_NOTES, 2, NFC_SND_AMP); + } else { + (void)drv2605l_play_effect(1); + audio_i2s_play_song(SND_FOUND_NOTES, 2, NFC_SND_AMP); + } + s_snd_busy = false; + vTaskDelete(NULL); +} + +void nfc_ui_play_sound(nfc_ui_sound_t kind) { + if (s_snd_busy) + return; + s_snd_busy = true; + if (xTaskCreatePinnedToCore(nfc_snd_task, + "nfc_snd", + NFC_SND_TASK_STACK_SIZE, + (void *)(intptr_t)kind, + NFC_SND_TASK_PRIORITY, + NULL, + SYS_CORE_UI) != pdPASS) + s_snd_busy = false; +} + +lv_obj_t *nfc_ui_header(lv_obj_t *parent, const char *title) { + lv_obj_t *lbl = lv_label_create(parent); + lv_label_set_text(lbl, title); + lv_obj_set_style_text_color(lbl, ui_theme_get_accent(), 0); + lv_obj_set_style_text_font(lbl, &lv_font_montserrat_14, 0); + lv_obj_align(lbl, LV_ALIGN_TOP_MID, 0, 10); + + lv_obj_t *rule = lv_obj_create(parent); + lv_obj_remove_flag(rule, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_remove_flag(rule, LV_OBJ_FLAG_CLICKABLE); + lv_obj_set_size(rule, lv_pct(70), 2); + lv_obj_align(rule, LV_ALIGN_TOP_MID, 0, 32); + lv_obj_set_style_border_width(rule, 0, 0); + lv_obj_set_style_radius(rule, 1, 0); + lv_obj_set_style_bg_color(rule, ui_theme_get_accent(), 0); + lv_obj_set_style_bg_opa(rule, LV_OPA_40, 0); + return lbl; +} + +typedef struct { + uint32_t top, bot, edge; + bool light; + bool stripe; +} card_style_t; +static const card_style_t STYLES[] = { + {0x3A1170, 0x140230, 0xB060FF, false, false}, + {0xEDEDF2, 0xCFCFD6, 0x6B3FA0, true, false}, + {0x123A78, 0x05122E, 0x4D9BFF, false, false}, + {0x0E5A4A, 0x06241E, 0x37E0A8, false, true}, + {0x6E1430, 0x250410, 0xFF5C7A, false, false}, + {0x6E4A12, 0x281806, 0xFFC23D, false, true}, + {0xE7E2D6, 0xCEC7B6, 0x8A6A22, true, false}, + {0x20242E, 0x0A0C12, 0x9AA6C2, false, false}, +}; +#define N_STYLES ((int)(sizeof(STYLES) / sizeof(STYLES[0]))) + +static const card_style_t *card_style(const nfc_sim_card_t *c) { + if (c == NULL) + return &STYLES[0]; + uint32_t h = 2166136261u; + for (int i = 0; i < c->uid_len; i++) + h = (h ^ c->uid[i]) * 16777619u; + return &STYLES[h % (uint32_t)N_STYLES]; +} + +lv_color_t nfc_ui_card_color(const nfc_sim_card_t *card) { + return lv_color_hex(card_style(card)->edge); +} + +static const char *card_details(const char *type) { + if (strstr(type, "1K")) + return "1 KB 16 sectors"; + if (strstr(type, "4K")) + return "4 KB 40 sectors"; + if (strstr(type, "NTAG215")) + return "504 B NDEF"; + if (strstr(type, "Ultralight")) + return "64 B NDEF"; + if (strstr(type, "DESFire")) + return "AES ISO14443-4"; + return "ISO14443-A"; +} + +lv_obj_t *nfc_ui_card_panel(lv_obj_t *parent, const nfc_sim_card_t *card) { + const card_style_t *st = card_style(card); + lv_color_t text = st->light ? lv_color_hex(0x1A1A22) : lv_color_white(); + lv_color_t edge = lv_color_hex(st->edge); + + lv_obj_t *panel = lv_obj_create(parent); + lv_obj_remove_flag(panel, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(panel, 210, 122); + lv_obj_set_style_radius(panel, 14, 0); + lv_obj_set_style_pad_all(panel, 12, 0); + lv_obj_set_style_bg_color(panel, lv_color_hex(st->top), 0); + lv_obj_set_style_bg_grad_color(panel, lv_color_hex(st->bot), 0); + lv_obj_set_style_bg_grad_dir(panel, LV_GRAD_DIR_VER, 0); + lv_obj_set_style_border_width(panel, 1, 0); + lv_obj_set_style_border_color(panel, edge, 0); + lv_obj_set_style_shadow_color(panel, edge, 0); + lv_obj_set_style_shadow_width(panel, 10, 0); + lv_obj_set_style_shadow_opa(panel, LV_OPA_30, 0); + + lv_obj_t *chip = lv_obj_create(panel); + lv_obj_remove_flag(chip, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(chip, 30, 22); + lv_obj_align(chip, LV_ALIGN_TOP_LEFT, 0, 0); + lv_obj_set_style_radius(chip, 5, 0); + lv_obj_set_style_border_width(chip, 0, 0); + lv_obj_set_style_bg_color(chip, lv_color_hex(0xD9A521), 0); + lv_obj_set_style_bg_grad_color(chip, lv_color_hex(0xF4D36B), 0); + lv_obj_set_style_bg_grad_dir(chip, LV_GRAD_DIR_VER, 0); + for (int i = 0; i < 2; i++) { + lv_obj_t *ln = lv_obj_create(chip); + lv_obj_remove_flag(ln, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(ln, 26, 1); + lv_obj_align(ln, LV_ALIGN_CENTER, 0, i == 0 ? -5 : 5); + lv_obj_set_style_border_width(ln, 0, 0); + lv_obj_set_style_radius(ln, 0, 0); + lv_obj_set_style_bg_color(ln, lv_color_hex(0x7A5A10), 0); + lv_obj_set_style_bg_opa(ln, LV_OPA_70, 0); + } + + if (st->stripe) { + lv_obj_t *line = lv_obj_create(panel); + lv_obj_remove_flag(line, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(line, lv_pct(100), 3); + lv_obj_align(line, LV_ALIGN_TOP_LEFT, 0, 26); + lv_obj_set_style_radius(line, 2, 0); + lv_obj_set_style_border_width(line, 0, 0); + lv_obj_set_style_bg_color(line, edge, 0); + } + + if (card == NULL) + return panel; + + bool has_name = (card->name[0] != '\0'); + int uid_y = has_name ? 66 : 54; + int meta_y = has_name ? 83 : 76; + + lv_obj_t *title = lv_label_create(panel); + lv_obj_set_width(title, lv_pct(100)); + lv_label_set_long_mode(title, LV_LABEL_LONG_DOT); + lv_label_set_text(title, has_name ? card->name : card->type); + lv_obj_set_style_text_color(title, text, 0); + lv_obj_set_style_text_font(title, &lv_font_montserrat_14, 0); + lv_obj_align(title, LV_ALIGN_TOP_LEFT, 0, 30); + + if (has_name) { + lv_obj_t *sub = lv_label_create(panel); + lv_obj_set_width(sub, lv_pct(100)); + lv_label_set_long_mode(sub, LV_LABEL_LONG_DOT); + lv_label_set_text(sub, card->type); + lv_obj_set_style_text_color(sub, text, 0); + lv_obj_set_style_text_opa(sub, LV_OPA_60, 0); + lv_obj_set_style_text_font(sub, &lv_font_montserrat_12, 0); + lv_obj_align(sub, LV_ALIGN_TOP_LEFT, 0, 49); + } + + char uid[24]; + nfc_sim_format_uid(card, uid, sizeof(uid)); + lv_obj_t *uidl = lv_label_create(panel); + lv_obj_set_width(uidl, lv_pct(100)); + lv_label_set_long_mode(uidl, LV_LABEL_LONG_DOT); + lv_label_set_text_fmt(uidl, "UID %s", uid); + lv_obj_set_style_text_color(uidl, edge, 0); + lv_obj_set_style_text_font(uidl, &lv_font_montserrat_12, 0); + lv_obj_align(uidl, LV_ALIGN_TOP_LEFT, 0, uid_y); + + lv_obj_t *meta = lv_label_create(panel); + lv_obj_set_width(meta, lv_pct(100)); + lv_label_set_long_mode(meta, LV_LABEL_LONG_DOT); + lv_label_set_text_fmt(meta, "%s SAK 0x%02X", card_details(card->type), card->sak); + lv_obj_set_style_text_color(meta, text, 0); + lv_obj_set_style_text_opa(meta, LV_OPA_50, 0); + lv_obj_set_style_text_font(meta, &lv_font_montserrat_12, 0); + lv_obj_align(meta, LV_ALIGN_TOP_LEFT, 0, meta_y); + + return panel; +} + +void nfc_ui_field_create(nfc_ui_field_t *f, lv_obj_t *parent, lv_color_t color) { + for (int i = 0; i < 3; i++) { + lv_obj_t *r = lv_obj_create(parent); + lv_obj_remove_flag(r, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_remove_flag(r, LV_OBJ_FLAG_CLICKABLE); + lv_obj_set_style_radius(r, LV_RADIUS_CIRCLE, 0); + lv_obj_set_style_bg_opa(r, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(r, 3, 0); + lv_obj_set_style_border_color(r, color, 0); + lv_obj_set_size(r, RING_MIN, RING_MIN); + lv_obj_align(r, LV_ALIGN_CENTER, 0, 0); + f->ring[i] = r; + } +} + +void nfc_ui_field_tick(nfc_ui_field_t *f, uint32_t elapsed_ms) { + for (int i = 0; i < 3; i++) { + if (f->ring[i] == NULL) + continue; + uint32_t ph = (elapsed_ms + (uint32_t)i * (RING_PERIOD / 3)) % RING_PERIOD; + float t = (float)ph / (float)RING_PERIOD; + int size = RING_MIN + (int)(t * (RING_MAX - RING_MIN)); + lv_opa_t opa = (lv_opa_t)((1.0f - t) * 255.0f); + lv_obj_set_size(f->ring[i], size, size); + lv_obj_align(f->ring[i], LV_ALIGN_CENTER, 0, 0); + lv_obj_set_style_border_opa(f->ring[i], opa, 0); + } +} diff --git a/firmware_p4/components/Applications/ui/screens/nfc/nfc_ultralight_ui.c b/firmware_p4/components/Applications/ui/screens/nfc/nfc_ultralight_ui.c new file mode 100644 index 000000000..0f2ef09fd --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/nfc/nfc_ultralight_ui.c @@ -0,0 +1,175 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#include "nfc_ultralight_ui.h" + +#include "lvgl.h" +#include "st7789.h" + +#include "ui_chrome.h" +#include "ui_manager.h" +#include "ui_theme.h" + +#define MX 8 +#define BODY_H (LCD_V_RES - UI_CHROME_HEADER_H - UI_CHROME_FOOTER_H) +#define CONTENT_W (LCD_H_RES - 2 * MX) +#define ROW_GAP 5 + +#define CHIP_H 16 +#define CHIP_RAD 8 +#define CHIP_PAD 6 + +#define DUMP_RAD 8 +#define DUMP_PAD 8 +#define DUMP_LGAP 3 + +#define COL_DIM 0x8A8594 +#define COL_LINE 0x2A2636 +#define COL_PANEL2 0x1A1626 + +#define ACC_HEX "B89AFF" +#define DIM_HEX "8A8594" +#define GOLD_HEX "D9A521" +#define CYAN_HEX "37E0A8" +#define WARN_HEX "FFC23D" + +#define HDR_TITLE "ULTRALIGHT/NTAG" +#define HDR_ICON "/assets/icons/nfc.bin" +#define FOOTER_HINT "OK write UP/DOWN page BACK" + +#define TXT_MODEL "NTAG215" +#define TXT_PAGES "135 pg" +#define TXT_BYTES "504 B" + +#define L0 "#" DIM_HEX " 00# 04 8F 6A #" ACC_HEX " 2A# #" DIM_HEX " UID#" +#define L1 "#" DIM_HEX " 02# 48 00 00 00 #" GOLD_HEX " lock#" +#define L2 "#" DIM_HEX " 03# E1 10 3E 00 #" CYAN_HEX " CC#" +#define L3 "#" DIM_HEX " 04# 03 21 D1 01 #" DIM_HEX " NDEF#" +#define L4 "#" DIM_HEX " 05# 1D 55 04 68" +#define L5 "#" DIM_HEX " ...#" +#define L6 "#" DIM_HEX " 83# 00 00 00 #" WARN_HEX " BD# #" WARN_HEX " cfg#" + +static const char *const DUMP_LINES[] = {L0, L1, L2, L3, L4, L5, L6}; +#define DUMP_LINE_COUNT ((int)(sizeof(DUMP_LINES) / sizeof(DUMP_LINES[0]))) + +static lv_obj_t *s_screen = NULL; + +static void make_chip(lv_obj_t *parent, const char *txt, bool sel) { + lv_obj_t *chip = lv_obj_create(parent); + lv_obj_remove_flag(chip, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_height(chip, CHIP_H); + lv_obj_set_width(chip, LV_SIZE_CONTENT); + lv_obj_set_style_radius(chip, CHIP_RAD, 0); + lv_obj_set_style_pad_hor(chip, CHIP_PAD, 0); + lv_obj_set_style_pad_ver(chip, 0, 0); + lv_obj_set_style_bg_color(chip, current_theme.border_accent, 0); + lv_obj_set_style_bg_opa(chip, sel ? LV_OPA_30 : LV_OPA_10, 0); + lv_obj_set_style_border_color(chip, current_theme.border_accent, 0); + lv_obj_set_style_border_width(chip, sel ? 1 : 0, 0); + + lv_obj_t *l = lv_label_create(chip); + lv_label_set_text(l, txt); + lv_obj_set_style_text_font(l, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(l, sel ? current_theme.border_accent : lv_color_hex(COL_DIM), 0); + lv_obj_center(l); +} + +static void build_body(lv_obj_t *parent) { + lv_obj_t *chips = lv_obj_create(parent); + lv_obj_remove_flag(chips, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_width(chips, lv_pct(100)); + lv_obj_set_height(chips, LV_SIZE_CONTENT); + lv_obj_set_style_bg_opa(chips, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(chips, 0, 0); + lv_obj_set_style_pad_all(chips, 0, 0); + lv_obj_set_style_pad_column(chips, 5, 0); + lv_obj_set_flex_flow(chips, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(chips, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + make_chip(chips, TXT_MODEL, true); + make_chip(chips, TXT_PAGES, false); + make_chip(chips, TXT_BYTES, false); + + lv_obj_t *box = lv_obj_create(parent); + lv_obj_remove_flag(box, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_remove_flag(box, LV_OBJ_FLAG_CLICKABLE); + lv_obj_set_width(box, CONTENT_W); + lv_obj_set_height(box, LV_SIZE_CONTENT); + lv_obj_set_style_radius(box, DUMP_RAD, 0); + lv_obj_set_style_bg_color(box, lv_color_hex(COL_PANEL2), 0); + lv_obj_set_style_bg_opa(box, LV_OPA_COVER, 0); + lv_obj_set_style_border_color(box, lv_color_hex(COL_LINE), 0); + lv_obj_set_style_border_width(box, 1, 0); + lv_obj_set_style_pad_all(box, DUMP_PAD, 0); + lv_obj_set_style_pad_row(box, DUMP_LGAP, 0); + lv_obj_set_flex_flow(box, LV_FLEX_FLOW_COLUMN); + + for (int i = 0; i < DUMP_LINE_COUNT; i++) { + lv_obj_t *ln = lv_label_create(box); + lv_label_set_recolor(ln, true); + lv_label_set_text(ln, DUMP_LINES[i]); + lv_obj_set_style_text_font(ln, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(ln, current_theme.text_main, 0); + } +} + +static void nfc_ultralight_input(const input_event_t *ev, void *ctx) { + (void)ctx; + const bool press = (ev->action == INPUT_ACTION_PRESS); + + switch (ev->button) { + case INPUT_BTN_BACK: + case INPUT_BTN_LEFT: + if (press) + ui_switch_screen(SCREEN_NFC_MENU); + break; + default: + break; + } +} + +void ui_nfc_ultralight_open(void) { + if (s_screen != NULL) { + lv_obj_del(s_screen); + s_screen = NULL; + } + + s_screen = lv_obj_create(NULL); + lv_obj_set_style_bg_color(s_screen, current_theme.screen_base, 0); + lv_obj_set_style_bg_opa(s_screen, LV_OPA_COVER, 0); + lv_obj_remove_flag(s_screen, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_border_width(s_screen, 0, 0); + lv_obj_set_style_pad_all(s_screen, 0, 0); + + ui_chrome_header(s_screen, HDR_TITLE, HDR_ICON); + + lv_obj_t *body = lv_obj_create(s_screen); + lv_obj_remove_flag(body, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_remove_flag(body, LV_OBJ_FLAG_CLICKABLE); + lv_obj_set_size(body, LCD_H_RES, BODY_H); + lv_obj_align(body, LV_ALIGN_TOP_MID, 0, UI_CHROME_HEADER_H); + lv_obj_set_style_bg_opa(body, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(body, 0, 0); + lv_obj_set_style_pad_all(body, MX, 0); + lv_obj_set_style_pad_row(body, ROW_GAP, 0); + lv_obj_set_flex_flow(body, LV_FLEX_FLOW_COLUMN); + + build_body(body); + + ui_chrome_footer(s_screen, FOOTER_HINT); + + ui_input_set_screen_handler(nfc_ultralight_input, NULL); + + ui_screen_load_owned(&s_screen, s_screen); +} diff --git a/firmware_p4/components/Applications/ui/screens/nfc/nfc_write_ui.c b/firmware_p4/components/Applications/ui/screens/nfc/nfc_write_ui.c new file mode 100644 index 000000000..bffbe8a46 --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/nfc/nfc_write_ui.c @@ -0,0 +1,448 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#include "nfc_write_ui.h" + +#include + +#include "lvgl.h" + +#include "st7789.h" + +#include "assets_manager.h" +#include "keyboard_ui.h" +#include "msgbox_ui.h" +#include "nfc_sim.h" +#include "nfc_ui_common.h" +#include "page_dots_ui.h" +#include "ui_chrome.h" +#include "ui_feedback.h" +#include "ui_manager.h" +#include "ui_theme.h" + +#define WR_TICK_MS 33 +#define SIG_GREEN 0x00E676 +#define COL_DIM 0x8A8594 +#define WRITE_ICON "/assets/icons/edit.bin" + +#define MAX_CARDS 10 +#define CARD_PANEL_H 122 +#define SRC_Y 4 +#define ARROW_Y (SRC_Y + CARD_PANEL_H + 4) +#define SLOT_W 150 +#define SLOT_H 72 +#define SLOT_Y (ARROW_Y + 26) +#define SLOT_X ((LCD_H_RES - SLOT_W) / 2) + +enum { WR_NONE, WR_PLACE, WR_WRITING, WR_DONE }; +#define T_PLACE 1300 +#define T_WRITING 1600 +#define T_DONE 1000 + +static lv_obj_t *s_screen = NULL; +static lv_obj_t *s_body = NULL; +static page_dots_t s_dots; +static int s_sel = 0; +static int s_count = 0; +static lv_timer_t *s_wr_timer = NULL; +static bool s_empty = false; + +static lv_obj_t *s_ov = NULL; +static lv_obj_t *s_ov_status = NULL; +static lv_obj_t *s_ov_footer = NULL; +static lv_obj_t *s_ov_card = NULL; +static lv_obj_t *s_ov_prog = NULL; +static lv_obj_t *s_ov_bar = NULL; +static lv_obj_t *s_ov_ok = NULL; +static nfc_ui_field_t s_ov_field; +static nfc_sim_card_t s_card; +static int s_wr = WR_NONE; +static uint32_t s_wr_start = 0; + +static void transy_cb(void *var, int32_t v) { + lv_obj_set_style_translate_y((lv_obj_t *)var, v, 0); +} + +static void card_rise(lv_obj_t *o) { + lv_anim_t a; + lv_anim_init(&a); + lv_anim_set_var(&a, o); + lv_anim_set_exec_cb(&a, transy_cb); + lv_anim_set_values(&a, 26, 0); + lv_anim_set_duration(&a, 300); + lv_anim_set_path_cb(&a, lv_anim_path_ease_out); + lv_anim_start(&a); +} + +static lv_obj_t *lit_panel(lv_obj_t *parent, int w, int h) { + lv_obj_t *p = lv_obj_create(parent); + lv_obj_remove_flag(p, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(p, w, h); + lv_obj_set_style_radius(p, 13, 0); + lv_obj_set_style_bg_color(p, current_theme.bg_secondary, 0); + lv_obj_set_style_bg_opa(p, LV_OPA_COVER, 0); + lv_obj_set_style_bg_grad_dir(p, LV_GRAD_DIR_NONE, 0); + lv_obj_set_style_border_width(p, 1, 0); + lv_obj_set_style_border_color(p, current_theme.border_accent, 0); + lv_obj_set_style_shadow_color(p, current_theme.border_accent, 0); + lv_obj_set_style_shadow_width(p, 16, 0); + lv_obj_set_style_shadow_opa(p, LV_OPA_40, 0); + lv_obj_set_style_shadow_spread(p, -4, 0); + return p; +} + +static void build_empty(const char *icon, const char *title, const char *sub) { + lv_obj_t *card = lit_panel(s_screen, 200, 104); + lv_obj_align(card, LV_ALIGN_CENTER, 0, 6); + lv_obj_set_style_pad_all(card, 10, 0); + lv_obj_set_flex_flow(card, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(card, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_set_style_pad_row(card, 6, 0); + + lv_image_dsc_t *dsc = assets_get(icon); + if (dsc != NULL) { + lv_obj_t *img = lv_image_create(card); + lv_image_set_src(img, dsc); + lv_obj_set_style_image_recolor(img, current_theme.text_main, 0); + lv_obj_set_style_image_recolor_opa(img, LV_OPA_COVER, 0); + } + + lv_obj_t *t = lv_label_create(card); + lv_label_set_text(t, title); + lv_obj_set_style_text_font(t, &lv_font_montserrat_14, 0); + lv_obj_set_style_text_color(t, current_theme.text_main, 0); + + lv_obj_t *s = lv_label_create(card); + lv_label_set_text(s, sub); + lv_obj_set_style_text_font(s, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(s, lv_color_hex(COL_DIM), 0); +} + +static lv_obj_t *dash_line(lv_obj_t *parent, lv_point_precise_t *pts, int x, int y) { + lv_obj_t *ln = lv_line_create(parent); + lv_line_set_points(ln, pts, 2); + lv_obj_set_pos(ln, x, y); + lv_obj_set_style_line_color(ln, current_theme.border_accent, 0); + lv_obj_set_style_line_opa(ln, LV_OPA_80, 0); + lv_obj_set_style_line_width(ln, 2, 0); + lv_obj_set_style_line_dash_width(ln, 4, 0); + lv_obj_set_style_line_dash_gap(ln, 4, 0); + return ln; +} + +static void bench_build(void) { + static lv_point_precise_t top_pts[2], bot_pts[2], lft_pts[2], rgt_pts[2]; + + if (s_body != NULL) { + lv_obj_del(s_body); + s_body = NULL; + } + + s_body = lv_obj_create(s_screen); + lv_obj_remove_flag(s_body, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_remove_flag(s_body, LV_OBJ_FLAG_CLICKABLE); + lv_obj_set_size(s_body, lv_pct(100), LCD_V_RES - UI_CHROME_HEADER_H - UI_CHROME_FOOTER_H); + lv_obj_align(s_body, LV_ALIGN_TOP_MID, 0, UI_CHROME_HEADER_H); + lv_obj_set_style_bg_opa(s_body, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(s_body, 0, 0); + lv_obj_set_style_pad_all(s_body, 0, 0); + + lv_obj_t *src = nfc_ui_card_panel(s_body, nfc_sim_saved_get(s_sel)); + lv_obj_align(src, LV_ALIGN_TOP_MID, 0, SRC_Y); + card_rise(src); + + lv_obj_t *arrow = lv_label_create(s_body); + lv_label_set_text(arrow, LV_SYMBOL_DOWN); + lv_obj_set_style_text_font(arrow, &lv_font_montserrat_14, 0); + lv_obj_set_style_text_color(arrow, current_theme.border_accent, 0); + lv_obj_align(arrow, LV_ALIGN_TOP_MID, 0, ARROW_Y); + + top_pts[0].x = 0; + top_pts[0].y = 0; + top_pts[1].x = SLOT_W; + top_pts[1].y = 0; + bot_pts[0].x = 0; + bot_pts[0].y = 0; + bot_pts[1].x = SLOT_W; + bot_pts[1].y = 0; + lft_pts[0].x = 0; + lft_pts[0].y = 0; + lft_pts[1].x = 0; + lft_pts[1].y = SLOT_H; + rgt_pts[0].x = 0; + rgt_pts[0].y = 0; + rgt_pts[1].x = 0; + rgt_pts[1].y = SLOT_H; + dash_line(s_body, top_pts, SLOT_X, SLOT_Y); + dash_line(s_body, bot_pts, SLOT_X, SLOT_Y + SLOT_H); + dash_line(s_body, lft_pts, SLOT_X, SLOT_Y); + dash_line(s_body, rgt_pts, SLOT_X + SLOT_W, SLOT_Y); + + lv_obj_t *slot_lbl = lv_label_create(s_body); + lv_label_set_text(slot_lbl, "place blank tag\nto write"); + lv_obj_set_style_text_align(slot_lbl, LV_TEXT_ALIGN_CENTER, 0); + lv_obj_set_style_text_font(slot_lbl, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(slot_lbl, current_theme.border_accent, 0); + lv_obj_align(slot_lbl, LV_ALIGN_TOP_MID, 0, SLOT_Y + 20); + + s_dots = page_dots_create(s_body, s_count, LV_ALIGN_BOTTOM_MID, 0, -2); + page_dots_set(&s_dots, s_sel); +} + +static void rings_show(bool show) { + for (int i = 0; i < 3; i++) { + if (!s_ov_field.ring[i]) + continue; + if (show) + lv_obj_remove_flag(s_ov_field.ring[i], LV_OBJ_FLAG_HIDDEN); + else + lv_obj_add_flag(s_ov_field.ring[i], LV_OBJ_FLAG_HIDDEN); + } +} + +static void overlay_close(void) { + if (s_ov) { + lv_obj_del(s_ov); + s_ov = NULL; + } + s_ov_status = NULL; + s_ov_footer = NULL; + s_ov_card = NULL; + s_ov_prog = NULL; + s_ov_bar = NULL; + s_ov_ok = NULL; + for (int i = 0; i < 3; i++) + s_ov_field.ring[i] = NULL; + s_wr = WR_NONE; +} + +static void overlay_start(const nfc_sim_card_t *card) { + s_card = *card; + + s_ov = lv_obj_create(s_screen); + lv_obj_set_size(s_ov, lv_pct(100), lv_pct(100)); + lv_obj_center(s_ov); + lv_obj_remove_flag(s_ov, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_bg_color(s_ov, lv_color_black(), 0); + lv_obj_set_style_bg_opa(s_ov, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(s_ov, 0, 0); + lv_obj_set_style_pad_all(s_ov, 0, 0); + + ui_chrome_header_overlay(s_ov, "WRITE TAG", WRITE_ICON); + s_ov_footer = ui_chrome_footer(s_ov, "BACK Cancel"); + + s_ov_status = lv_label_create(s_ov); + lv_label_set_text(s_ov_status, "Place blank tag"); + lv_obj_set_style_text_font(s_ov_status, &lv_font_montserrat_14, 0); + lv_obj_set_style_text_color(s_ov_status, current_theme.text_main, 0); + lv_obj_align(s_ov_status, LV_ALIGN_TOP_MID, 0, 52); + + nfc_ui_field_create(&s_ov_field, s_ov, ui_theme_get_accent()); + + s_wr = WR_PLACE; + s_wr_start = lv_tick_get(); +} + +static void begin_writing(void) { + rings_show(false); + + s_ov_card = nfc_ui_card_panel(s_ov, &s_card); + lv_obj_align(s_ov_card, LV_ALIGN_CENTER, 0, -14); + lv_obj_fade_in(s_ov_card, 280, 0); + card_rise(s_ov_card); + + s_ov_prog = lit_panel(s_ov, 200, 28); + lv_obj_align(s_ov_prog, LV_ALIGN_CENTER, 0, 78); + lv_obj_set_style_pad_all(s_ov_prog, 0, 0); + + s_ov_bar = lv_bar_create(s_ov_prog); + lv_obj_set_size(s_ov_bar, 176, 10); + lv_obj_center(s_ov_bar); + lv_bar_set_range(s_ov_bar, 0, 100); + lv_bar_set_value(s_ov_bar, 0, LV_ANIM_OFF); + lv_obj_set_style_bg_color(s_ov_bar, lv_color_hex(0x202028), LV_PART_MAIN); + lv_obj_set_style_bg_color(s_ov_bar, current_theme.border_accent, LV_PART_INDICATOR); + lv_obj_set_style_radius(s_ov_bar, 4, LV_PART_MAIN); + lv_obj_set_style_radius(s_ov_bar, 4, LV_PART_INDICATOR); + + lv_label_set_text(s_ov_status, "Writing"); + ui_chrome_footer_set_text(s_ov_footer, "Writing..."); +} + +static void finish_write(void) { + lv_obj_set_style_text_color(s_ov_status, lv_color_hex(SIG_GREEN), 0); + lv_label_set_text(s_ov_status, "Written!"); + if (s_ov_bar) { + lv_bar_set_value(s_ov_bar, 100, LV_ANIM_OFF); + lv_obj_set_style_bg_color(s_ov_bar, lv_color_hex(SIG_GREEN), LV_PART_INDICATOR); + } + if (s_ov_card) { + s_ov_ok = lv_obj_create(s_ov); + lv_obj_remove_flag(s_ov_ok, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(s_ov_ok, 30, 30); + lv_obj_set_style_radius(s_ov_ok, LV_RADIUS_CIRCLE, 0); + lv_obj_set_style_bg_color(s_ov_ok, lv_color_hex(SIG_GREEN), 0); + lv_obj_set_style_bg_opa(s_ov_ok, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(s_ov_ok, 0, 0); + lv_obj_align_to(s_ov_ok, s_ov_card, LV_ALIGN_TOP_RIGHT, -8, 8); + lv_obj_t *chk = lv_label_create(s_ov_ok); + lv_label_set_text(chk, LV_SYMBOL_OK); + lv_obj_set_style_text_color(chk, lv_color_hex(0x0A0220), 0); + lv_obj_center(chk); + lv_obj_fade_in(s_ov_ok, 200, 0); + } + ui_chrome_footer_set_text(s_ov_footer, "BACK Exit"); + ui_feedback(UI_FB_WRITE); + nfc_ui_play_sound(NFC_SND_SAVE); +} + +static void write_tick(void) { + uint32_t el = lv_tick_get() - s_wr_start; + if (s_wr == WR_PLACE) { + nfc_ui_field_tick(&s_ov_field, el); + int dots = (el / 350) % 4; + char buf[28]; + snprintf(buf, + sizeof(buf), + "Place blank tag%s", + dots == 1 ? "." + : dots == 2 ? ".." + : dots == 3 ? "..." + : ""); + lv_label_set_text(s_ov_status, buf); + if (el >= T_PLACE) { + begin_writing(); + s_wr = WR_WRITING; + s_wr_start = lv_tick_get(); + } + } else if (s_wr == WR_WRITING) { + int pct = (int)((uint64_t)el * 100 / T_WRITING); + if (pct > 100) + pct = 100; + if (s_ov_bar) + lv_bar_set_value(s_ov_bar, pct, LV_ANIM_OFF); + if (el >= T_WRITING) { + finish_write(); + s_wr = WR_DONE; + s_wr_start = lv_tick_get(); + } + } else if (s_wr == WR_DONE) { + if (el >= T_DONE) + overlay_close(); + } +} + +static void write_tick_cb(lv_timer_t *t) { + if (lv_screen_active() != s_screen) { + lv_timer_delete(t); + s_wr_timer = NULL; + return; + } + if (s_wr != WR_NONE && !(msgbox_is_open() || keyboard_is_open() || ui_input_is_locked())) + write_tick(); +} + +static void nfc_write_input(const input_event_t *ev, void *ctx) { + (void)ctx; + const bool press = (ev->action == INPUT_ACTION_PRESS); + const bool nav = press || (ev->action == INPUT_ACTION_REPEAT); + + if (s_wr != WR_NONE) { + if (ev->button == INPUT_BTN_BACK && press && s_wr != WR_DONE) + overlay_close(); + return; + } + + switch (ev->button) { + case INPUT_BTN_BACK: + case INPUT_BTN_LEFT: + if (press) + ui_switch_screen(SCREEN_NFC_MENU); + break; + case INPUT_BTN_DOWN: + if (nav && !s_empty) { + s_sel = (s_sel + 1) % s_count; + bench_build(); + ui_feedback(UI_FB_NAV); + } + break; + case INPUT_BTN_UP: + if (nav && !s_empty) { + s_sel = (s_sel == 0) ? s_count - 1 : s_sel - 1; + bench_build(); + ui_feedback(UI_FB_NAV); + } + break; + case INPUT_BTN_OK: + case INPUT_BTN_RIGHT: + if (press && !s_empty) { + const nfc_sim_card_t *c = nfc_sim_saved_get(s_sel); + if (c != NULL) { + ui_feedback(UI_FB_SELECT); + overlay_start(c); + } + } + break; + default: + break; + } +} + +void ui_nfc_write_open(void) { + nfc_sim_init(); + if (s_screen != NULL) { + lv_obj_del(s_screen); + s_screen = NULL; + } + s_ov = NULL; + s_ov_status = NULL; + s_ov_footer = NULL; + s_ov_card = NULL; + s_ov_prog = NULL; + s_ov_bar = NULL; + s_ov_ok = NULL; + s_body = NULL; + s_sel = 0; + s_count = 0; + for (int i = 0; i < 3; i++) + s_ov_field.ring[i] = NULL; + s_wr = WR_NONE; + + s_screen = lv_obj_create(NULL); + lv_obj_set_style_bg_color(s_screen, current_theme.screen_base, 0); + lv_obj_set_style_bg_opa(s_screen, LV_OPA_COVER, 0); + lv_obj_remove_flag(s_screen, LV_OBJ_FLAG_SCROLLABLE); + + int n = nfc_sim_saved_count(); + s_empty = (n == 0); + + if (s_empty) { + ui_chrome_header(s_screen, "WRITE", WRITE_ICON); + ui_chrome_footer(s_screen, "BACK Back"); + build_empty(WRITE_ICON, "Nothing to write", "Read a tag first"); + } else { + ui_chrome_header(s_screen, "WRITE TAG", WRITE_ICON); + ui_chrome_footer(s_screen, LV_SYMBOL_UP LV_SYMBOL_DOWN " Source OK Write BACK Exit"); + s_count = n > MAX_CARDS ? MAX_CARDS : n; + s_sel = 0; + bench_build(); + } + + if (s_wr_timer == NULL) + s_wr_timer = lv_timer_create(write_tick_cb, WR_TICK_MS, NULL); + ui_input_set_screen_handler(nfc_write_input, NULL); + + ui_screen_load_owned(&s_screen, s_screen); +} diff --git a/firmware_p4/components/Applications/ui/screens/octobit/include/octobit_status_ui.h b/firmware_p4/components/Applications/ui/screens/octobit/include/octobit_status_ui.h new file mode 100644 index 000000000..47c911c86 --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/octobit/include/octobit_status_ui.h @@ -0,0 +1,37 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#ifndef OCTOBIT_STATUS_UI_H +#define OCTOBIT_STATUS_UI_H + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * @brief Open the Octobit status screen (reached with LEFT from home). + * + * Top: the octobit "character sheet" — portrait avatar + level badge + XP bar. + * Below: a statistics selector navigated with UP/DOWN. A subset of the stats is + * live (uptime, battery, heap, storage, boot count); the rest are mock. + * BACK or RIGHT returns to home. + */ +void ui_octobit_status_open(void); + +#ifdef __cplusplus +} +#endif + +#endif // OCTOBIT_STATUS_UI_H diff --git a/firmware_p4/components/Applications/ui/screens/octobit/octobit_status_ui.c b/firmware_p4/components/Applications/ui/screens/octobit/octobit_status_ui.c new file mode 100644 index 000000000..34e2390e4 --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/octobit/octobit_status_ui.c @@ -0,0 +1,435 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#include "octobit_status_ui.h" + +#include + +#include "esp_heap_caps.h" +#include "esp_littlefs.h" +#include "esp_timer.h" + +#include "lvgl.h" +#include "st7789.h" + +#include "assets_manager.h" +#include "boot_report.h" +#include "bq25896.h" +#include "ui_chrome.h" +#include "ui_feedback.h" +#include "ui_manager.h" +#include "ui_theme.h" + +#define MX 8 +#define CONTENT_W (LCD_H_RES - 2 * MX) +#define CARD_Y 46 +#define CARD_H 64 +#define AVA 50 +#define SELHD_Y 116 +#define SELWRAP_Y 134 +#define SELWRAP_H 156 +#define ROW_H 34 +#define ROW_GAP 5 +#define ROW_STEP (ROW_H + ROW_GAP) +#define VIS 4 + +#define AVATAR_ASSET "/assets/img/octobit_portrait.bin" + +#define OCTO_LEVEL 7 +#define OCTO_XP 1240 +#define OCTO_XP_MAX 2000 + +#define COL_RAISE 0x170A28 +#define COL_DIM 0x8A8594 + +#define REFRESH_MS 1000 + +enum { + ST_UPTIME = 0, + ST_SCANS, + ST_CARDS, + ST_SIGNALS, + ST_BOOTS, + ST_BATTERY, + ST_STORAGE, + ST_HEAP, + ST_COUNT, +}; + +typedef struct { + const char *sym; + const char *name; +} stat_def_t; + +static const stat_def_t STAT_DEFS[ST_COUNT] = { + {LV_SYMBOL_REFRESH, "Uptime"}, + {LV_SYMBOL_EYE_OPEN, "Scans"}, + {LV_SYMBOL_SD_CARD, "Cards saved"}, + {LV_SYMBOL_WIFI, "Signals"}, + {LV_SYMBOL_POWER, "Boots"}, + {LV_SYMBOL_CHARGE, "Battery"}, + {LV_SYMBOL_DRIVE, "Storage"}, + {LV_SYMBOL_BARS, "Heap free"}, +}; + +static lv_obj_t *s_screen = NULL; +static lv_obj_t *s_sellist = NULL; +static lv_obj_t *s_counter = NULL; +static lv_obj_t *s_row[ST_COUNT]; +static lv_obj_t *s_icon_lbl[ST_COUNT]; +static lv_obj_t *s_val_lbl[ST_COUNT]; +static lv_timer_t *s_timer = NULL; + +static int s_sel = 0; + +static uint32_t s_boots = 0; +static bool s_boots_read = false; + +static void read_boots_once(void) { + if (s_boots_read) + return; + s_boots_read = true; + s_boots = boot_report_boot_count(); +} + +static void set_val(int i, const char *text) { + if (s_val_lbl[i]) + lv_label_set_text(s_val_lbl[i], text); +} + +static void refresh_values(void) { + char b[64]; + + uint32_t s = (uint32_t)(esp_timer_get_time() / 1000000LL); + if (s >= 86400) + snprintf(b, + sizeof(b), + "%lud %02luh", + (unsigned long)(s / 86400), + (unsigned long)((s % 86400) / 3600)); + else if (s >= 3600) + snprintf( + b, sizeof(b), "%luh %02lum", (unsigned long)(s / 3600), (unsigned long)((s % 3600) / 60)); + else + snprintf(b, sizeof(b), "%lum %02lus", (unsigned long)(s / 60), (unsigned long)(s % 60)); + set_val(ST_UPTIME, b); + + bq25896_telem_t t; + if (bq25896_read_telemetry(&t) == ESP_OK) + snprintf(b, sizeof(b), "%d%%", t.soc); + else + snprintf(b, sizeof(b), "--"); + set_val(ST_BATTERY, b); + + snprintf( + b, sizeof(b), "%lu KB", (unsigned long)(heap_caps_get_free_size(MALLOC_CAP_DEFAULT) / 1024)); + set_val(ST_HEAP, b); + + size_t total = 0, used = 0; + if (esp_littlefs_info("assets", &total, &used) == ESP_OK) { + uint32_t u10 = (uint32_t)((uint64_t)used * 10 / (1024 * 1024)); + uint32_t t10 = (uint32_t)((uint64_t)total * 10 / (1024 * 1024)); + snprintf(b, + sizeof(b), + "%lu.%lu/%lu.%lu MB", + (unsigned long)(u10 / 10), + (unsigned long)(u10 % 10), + (unsigned long)(t10 / 10), + (unsigned long)(t10 % 10)); + } else { + snprintf(b, sizeof(b), "-- MB"); + } + set_val(ST_STORAGE, b); +} + +static void refresh_selection(void) { + const lv_color_t accent = current_theme.border_accent; + const lv_color_t dim = lv_color_hex(COL_DIM); + for (int i = 0; i < ST_COUNT; i++) { + bool sel = (i == s_sel); + lv_obj_set_style_border_color(s_row[i], sel ? accent : current_theme.border_inactive, 0); + lv_obj_set_style_border_opa(s_row[i], sel ? LV_OPA_COVER : LV_OPA_TRANSP, 0); + lv_obj_set_style_bg_color( + s_row[i], sel ? lv_color_hex(COL_RAISE) : current_theme.bg_secondary, 0); + lv_obj_set_style_shadow_width(s_row[i], sel ? 14 : 0, 0); + lv_obj_set_style_shadow_color(s_row[i], accent, 0); + lv_obj_set_style_shadow_spread(s_row[i], sel ? -3 : 0, 0); + lv_obj_set_style_text_color(s_icon_lbl[i], sel ? accent : dim, 0); + lv_obj_set_style_text_color(s_val_lbl[i], sel ? accent : dim, 0); + } + int top = s_sel - 1; + if (top < 0) + top = 0; + if (top > ST_COUNT - VIS) + top = ST_COUNT - VIS; + lv_obj_set_style_translate_y(s_sellist, -top * ROW_STEP, 0); + + if (s_counter) + lv_label_set_text_fmt(s_counter, "%d/%d", s_sel + 1, ST_COUNT); +} + +static lv_obj_t *make_row(lv_obj_t *parent, int i) { + lv_obj_t *row = lv_obj_create(parent); + lv_obj_remove_flag(row, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_remove_flag(row, LV_OBJ_FLAG_CLICKABLE); + lv_obj_set_size(row, lv_pct(100), ROW_H); + lv_obj_set_style_radius(row, 9, 0); + lv_obj_set_style_bg_opa(row, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(row, 2, 0); + lv_obj_set_style_pad_left(row, 10, 0); + lv_obj_set_style_pad_right(row, 10, 0); + lv_obj_set_style_pad_top(row, 0, 0); + lv_obj_set_style_pad_bottom(row, 0, 0); + 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_t *ic = lv_label_create(row); + lv_label_set_text(ic, STAT_DEFS[i].sym); + lv_obj_set_style_text_font(ic, &lv_font_montserrat_16, 0); + lv_obj_set_width(ic, 20); + lv_obj_set_style_text_align(ic, LV_TEXT_ALIGN_CENTER, 0); + + lv_obj_t *name = lv_label_create(row); + lv_label_set_text(name, STAT_DEFS[i].name); + lv_obj_set_style_text_font(name, &lv_font_montserrat_14, 0); + lv_obj_set_style_text_color(name, current_theme.text_main, 0); + lv_obj_set_style_pad_left(name, 9, 0); + lv_obj_set_flex_grow(name, 1); + + lv_obj_t *val = lv_label_create(row); + lv_label_set_text(val, "--"); + lv_obj_set_style_text_font(val, &lv_font_montserrat_14, 0); + + s_icon_lbl[i] = ic; + s_val_lbl[i] = val; + return row; +} + +static void build_top_card(void) { + lv_obj_t *card = lv_obj_create(s_screen); + lv_obj_remove_flag(card, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_remove_flag(card, LV_OBJ_FLAG_CLICKABLE); + lv_obj_set_size(card, CONTENT_W, CARD_H); + lv_obj_align(card, LV_ALIGN_TOP_MID, 0, CARD_Y); + lv_obj_set_style_radius(card, 12, 0); + lv_obj_set_style_bg_color(card, current_theme.bg_secondary, 0); + lv_obj_set_style_bg_opa(card, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(card, 1, 0); + lv_obj_set_style_border_color(card, current_theme.border_interface, 0); + lv_obj_set_style_border_opa(card, LV_OPA_50, 0); + lv_obj_set_style_pad_all(card, 0, 0); + lv_obj_add_flag(card, LV_OBJ_FLAG_OVERFLOW_VISIBLE); + + lv_obj_t *ava = lv_obj_create(card); + lv_obj_remove_flag(ava, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_remove_flag(ava, LV_OBJ_FLAG_CLICKABLE); + lv_obj_set_size(ava, AVA, AVA); + lv_obj_align(ava, LV_ALIGN_LEFT_MID, 8, 0); + lv_obj_set_style_radius(ava, LV_RADIUS_CIRCLE, 0); + lv_obj_set_style_bg_color(ava, current_theme.bg_primary, 0); + lv_obj_set_style_bg_grad_color(ava, current_theme.screen_base, 0); + lv_obj_set_style_bg_grad_dir(ava, LV_GRAD_DIR_VER, 0); + lv_obj_set_style_bg_opa(ava, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(ava, 2, 0); + lv_obj_set_style_border_color(ava, current_theme.border_accent, 0); + lv_obj_set_style_shadow_width(ava, 14, 0); + lv_obj_set_style_shadow_color(ava, current_theme.border_accent, 0); + lv_obj_set_style_shadow_spread(ava, -4, 0); + lv_obj_set_style_pad_all(ava, 0, 0); + lv_obj_set_style_clip_corner(ava, true, 0); + + lv_image_dsc_t *portrait = assets_get(AVATAR_ASSET); + if (portrait != NULL) { + lv_obj_t *img = lv_image_create(ava); + lv_image_set_src(img, portrait); + lv_obj_set_size(img, AVA - 8, AVA - 8); + lv_image_set_inner_align(img, LV_IMAGE_ALIGN_CONTAIN); + lv_obj_center(img); + } + + lv_obj_t *badge = lv_label_create(card); + lv_label_set_text_fmt(badge, "Lv %d", OCTO_LEVEL); + lv_obj_set_style_text_font(badge, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(badge, current_theme.screen_base, 0); + lv_obj_set_style_bg_color(badge, current_theme.border_accent, 0); + lv_obj_set_style_bg_opa(badge, LV_OPA_COVER, 0); + lv_obj_set_style_radius(badge, 7, 0); + lv_obj_set_style_pad_hor(badge, 5, 0); + lv_obj_set_style_pad_ver(badge, 1, 0); + lv_obj_update_layout(s_screen); + lv_obj_align_to(badge, ava, LV_ALIGN_BOTTOM_RIGHT, 6, 5); + + const int col_x = 8 + AVA + 12; + const int col_w = CONTENT_W - col_x - 12; + + lv_obj_t *xp_tag = lv_label_create(card); + lv_label_set_text(xp_tag, "XP"); + lv_obj_set_style_text_font(xp_tag, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(xp_tag, lv_color_hex(COL_DIM), 0); + lv_obj_align(xp_tag, LV_ALIGN_LEFT_MID, col_x, -12); + + lv_obj_t *xp_val = lv_label_create(card); + lv_label_set_text_fmt(xp_val, "%d / %d", OCTO_XP, OCTO_XP_MAX); + lv_obj_set_style_text_font(xp_val, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(xp_val, lv_color_hex(COL_DIM), 0); + lv_obj_set_width(xp_val, col_w); + lv_obj_set_style_text_align(xp_val, LV_TEXT_ALIGN_RIGHT, 0); + lv_obj_align(xp_val, LV_ALIGN_LEFT_MID, col_x, -12); + + lv_obj_t *bar = lv_obj_create(card); + lv_obj_remove_flag(bar, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_remove_flag(bar, LV_OBJ_FLAG_CLICKABLE); + lv_obj_set_size(bar, col_w, 8); + lv_obj_align(bar, LV_ALIGN_LEFT_MID, col_x, 6); + lv_obj_set_style_radius(bar, 4, 0); + lv_obj_set_style_bg_color(bar, current_theme.screen_base, 0); + lv_obj_set_style_bg_opa(bar, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(bar, 1, 0); + lv_obj_set_style_border_color(bar, current_theme.border_inactive, 0); + lv_obj_set_style_pad_all(bar, 0, 0); + lv_obj_set_style_clip_corner(bar, true, 0); + + lv_obj_t *fill = lv_obj_create(bar); + lv_obj_remove_flag(fill, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_remove_flag(fill, LV_OBJ_FLAG_CLICKABLE); + int pct = OCTO_XP_MAX > 0 ? (OCTO_XP * 100 / OCTO_XP_MAX) : 0; + lv_obj_set_size(fill, lv_pct(pct), lv_pct(100)); + lv_obj_align(fill, LV_ALIGN_LEFT_MID, 0, 0); + lv_obj_set_style_radius(fill, 4, 0); + lv_obj_set_style_border_width(fill, 0, 0); + lv_obj_set_style_bg_color(fill, current_theme.border_interface, 0); + lv_obj_set_style_bg_grad_color(fill, current_theme.border_accent, 0); + lv_obj_set_style_bg_grad_dir(fill, LV_GRAD_DIR_HOR, 0); + lv_obj_set_style_bg_opa(fill, LV_OPA_COVER, 0); +} + +static void build_selector(void) { + lv_obj_t *tag = lv_label_create(s_screen); + lv_label_set_text(tag, "STATISTICS"); + lv_obj_set_style_text_font(tag, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(tag, lv_color_hex(COL_DIM), 0); + lv_obj_align(tag, LV_ALIGN_TOP_LEFT, MX + 4, SELHD_Y); + + s_counter = lv_label_create(s_screen); + lv_label_set_text(s_counter, "1/8"); + lv_obj_set_style_text_font(s_counter, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(s_counter, lv_color_hex(COL_DIM), 0); + lv_obj_align(s_counter, LV_ALIGN_TOP_RIGHT, -(MX + 4), SELHD_Y); + + lv_obj_t *wrap = lv_obj_create(s_screen); + lv_obj_remove_flag(wrap, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_remove_flag(wrap, LV_OBJ_FLAG_CLICKABLE); + lv_obj_set_size(wrap, CONTENT_W, SELWRAP_H); + lv_obj_align(wrap, LV_ALIGN_TOP_MID, 0, SELWRAP_Y); + lv_obj_set_style_bg_opa(wrap, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(wrap, 0, 0); + lv_obj_set_style_pad_all(wrap, 0, 0); + lv_obj_set_style_clip_corner(wrap, true, 0); + + s_sellist = lv_obj_create(wrap); + lv_obj_remove_flag(s_sellist, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_remove_flag(s_sellist, LV_OBJ_FLAG_CLICKABLE); + lv_obj_set_width(s_sellist, lv_pct(100)); + lv_obj_set_height(s_sellist, LV_SIZE_CONTENT); + lv_obj_align(s_sellist, LV_ALIGN_TOP_MID, 0, 0); + lv_obj_set_style_bg_opa(s_sellist, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(s_sellist, 0, 0); + lv_obj_set_style_pad_all(s_sellist, 0, 0); + lv_obj_set_style_pad_row(s_sellist, ROW_GAP, 0); + lv_obj_set_flex_flow(s_sellist, LV_FLEX_FLOW_COLUMN); + + for (int i = 0; i < ST_COUNT; i++) + s_row[i] = make_row(s_sellist, i); +} + +static void octobit_status_input(const input_event_t *ev, void *ctx) { + (void)ctx; + const bool press = (ev->action == INPUT_ACTION_PRESS); + const bool nav = press || (ev->action == INPUT_ACTION_REPEAT); + + switch (ev->button) { + case INPUT_BTN_BACK: + case INPUT_BTN_RIGHT: + if (press) + ui_switch_screen(SCREEN_HOME); + break; + case INPUT_BTN_DOWN: + if (nav) { + s_sel = (s_sel + 1) % ST_COUNT; + refresh_selection(); + ui_feedback(UI_FB_NAV); + } + break; + case INPUT_BTN_UP: + if (nav) { + s_sel = (s_sel - 1 + ST_COUNT) % ST_COUNT; + refresh_selection(); + ui_feedback(UI_FB_NAV); + } + break; + default: + break; + } +} + +static void refresh_cb(lv_timer_t *t) { + if (lv_screen_active() != s_screen) { + lv_timer_delete(t); + s_timer = NULL; + return; + } + refresh_values(); +} + +void ui_octobit_status_open(void) { + bq25896_init(); + + if (s_screen != NULL) { + lv_obj_del(s_screen); + s_screen = NULL; + } + s_sel = 0; + + s_screen = lv_obj_create(NULL); + lv_obj_set_style_bg_color(s_screen, current_theme.screen_base, 0); + lv_obj_set_style_bg_opa(s_screen, LV_OPA_COVER, 0); + lv_obj_remove_flag(s_screen, LV_OBJ_FLAG_SCROLLABLE); + + ui_chrome_header(s_screen, "Octobit", NULL); + + build_top_card(); + build_selector(); + + ui_chrome_footer(s_screen, "UP/DOWN navigate BACK home"); + + set_val(ST_SCANS, "1,204"); + set_val(ST_CARDS, "37"); + set_val(ST_SIGNALS, "82"); + read_boots_once(); + { + char b[16]; + snprintf(b, sizeof(b), "#%lu", (unsigned long)s_boots); + set_val(ST_BOOTS, b); + } + refresh_values(); + refresh_selection(); + + if (s_timer == NULL) + s_timer = lv_timer_create(refresh_cb, REFRESH_MS, NULL); + + ui_input_set_screen_handler(octobit_status_input, NULL); + + ui_screen_load_owned(&s_screen, s_screen); +} diff --git a/firmware_p4/components/Applications/ui/screens/power/include/power_ui.h b/firmware_p4/components/Applications/ui/screens/power/include/power_ui.h new file mode 100644 index 000000000..7200fc80e --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/power/include/power_ui.h @@ -0,0 +1,27 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#ifndef POWER_UI_H +#define POWER_UI_H + +/** + * @brief Open the power / battery (BQ25896) screen. + * + * Live telemetry, charge toggle, I2C scan, and software power-off (BATFET ship + * mode). + */ +void ui_power_open(void); + +#endif // POWER_UI_H diff --git a/firmware_p4/components/Applications/ui/screens/power/power_ui.c b/firmware_p4/components/Applications/ui/screens/power/power_ui.c new file mode 100644 index 000000000..17f11dead --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/power/power_ui.c @@ -0,0 +1,421 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#include "power_ui.h" + +#include + +#include "i2c_init.h" +#include "lvgl.h" + +#include "bq25896.h" +#include "msgbox_ui.h" +#include "ui_chrome.h" +#include "ui_manager.h" +#include "ui_theme.h" + +#define REFRESH_MS 700 + +#define HEADER_ICON "/assets/icons/power_settings_new.bin" + +#define HERO_Y 46 +#define HERO_H 74 +#define HERO_W_PCT 92 +#define HERO_RADIUS 12 +#define HERO_BORDER_W 1 +#define GLOW_W 14 +#define GLOW_SPREAD -3 +#define CHIP_PAD_H 9 +#define CHIP_PAD_V 2 +#define CHIP_BORDER_W 1 + +#define PCT_X 16 +#define PCT_Y -11 +#define VOLT_Y 13 +#define STATE_X -12 +#define STATE_Y -11 +#define SRC_X -12 +#define SRC_Y 13 + +#define LIST_Y 128 +#define LIST_W_PCT 92 +#define ROW_H 34 +#define ROW_GAP 5 +#define ROW_RADIUS 9 +#define ROW_BORDER_W 2 +#define ROW_PAD_H 10 +#define ICON_W 20 +#define NAME_PAD_L 9 + +#define SUCCESS_COLOR 0x00E676 +#define COL_DIM 0x8A8594 +#define COL_RAISE 0x170A28 + +enum { ACT_CHARGE, ACT_SCAN, ACT_REGS, ACT_OFF, ACT_COUNT }; + +static const char *const ACT_ICON[ACT_COUNT] = { + LV_SYMBOL_CHARGE, LV_SYMBOL_LIST, LV_SYMBOL_SETTINGS, LV_SYMBOL_POWER}; +static const char *const ACT_NAMES[ACT_COUNT] = {"Charging", "I2C Scan", "Registers", "Power Off"}; + +static lv_obj_t *s_screen = NULL; +static lv_obj_t *s_pct = NULL; +static lv_obj_t *s_volt = NULL; +static lv_obj_t *s_state = NULL; +static lv_obj_t *s_src = NULL; +static lv_obj_t *s_row[ACT_COUNT]; +static lv_obj_t *s_row_icon[ACT_COUNT]; +static lv_obj_t *s_row_val[ACT_COUNT]; +static lv_timer_t *s_timer = NULL; +static int s_sel = 0; +static char s_scan_msg[96]; + +static const char *chg_name(bq25896_charge_status_t s) { + switch (s) { + case CHARGE_STATUS_PRECHARGE: + return "Pre-charge"; + case CHARGE_STATUS_FAST_CHARGE: + return "Fast charge"; + case CHARGE_STATUS_CHARGE_DONE: + return "Charge done"; + default: + return "Not charging"; + } +} +static const char *vbus_name(bq25896_vbus_status_t s) { + switch (s) { + case VBUS_STATUS_USB_HOST: + return "USB"; + case VBUS_STATUS_ADAPTER_PORT: + return "Adapter"; + case VBUS_STATUS_OTG: + return "OTG"; + default: + return "None"; + } +} + +static void style_state_chip(bool charging) { + if (charging) { + lv_obj_set_style_text_color(s_state, lv_color_hex(SUCCESS_COLOR), 0); + lv_obj_set_style_bg_color(s_state, lv_color_hex(SUCCESS_COLOR), 0); + lv_obj_set_style_bg_opa(s_state, LV_OPA_20, 0); + lv_obj_set_style_border_width(s_state, 0, 0); + } else { + lv_obj_set_style_text_color(s_state, lv_color_hex(COL_DIM), 0); + lv_obj_set_style_bg_color(s_state, current_theme.bg_secondary, 0); + lv_obj_set_style_bg_opa(s_state, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(s_state, CHIP_BORDER_W, 0); + lv_obj_set_style_border_color(s_state, current_theme.border_inactive, 0); + } +} + +static void refresh_telem(void) { + bq25896_telem_t t; + if (bq25896_read_telemetry(&t) != ESP_OK) { + lv_label_set_text(s_pct, "--"); + lv_label_set_text(s_volt, "-- V"); + lv_label_set_text(s_src, "no charger IC"); + lv_label_set_text(s_state, "Offline"); + style_state_chip(false); + lv_label_set_text(s_row_val[ACT_CHARGE], "--"); + return; + } + + const char *status; + if (t.charging) { + status = chg_name(t.chg); + } else if (!t.power_good) { + status = "On battery"; + } else { + uint8_t r00 = bq25896_reg_raw(0x00); + uint8_t f = t.fault; + if (r00 & 0x80) + status = "Idle (HiZ)"; + else if (!bq25896_get_charge_enable()) + status = "Idle (off)"; + else if (f & 0x80) + status = "Fault: WD"; + else if (f & 0x40) + status = "Fault: boost"; + else if ((f & 0x30) == 0x10) + status = "Fault: input"; + else if ((f & 0x30) == 0x20) + status = "Fault: thermal"; + else if ((f & 0x30) == 0x30) + status = "Fault: timer"; + else if (f & 0x08) + status = "Fault: batt OVP"; + else if (f & 0x07) + status = "Fault: NTC"; + else + status = "Idle (full?)"; + } + + lv_label_set_text_fmt(s_pct, "%d%%", t.soc); + lv_label_set_text_fmt(s_volt, "%u.%02u V", t.vbat_mv / 1000, (t.vbat_mv % 1000) / 10); + + if (t.power_good) + lv_label_set_text_fmt( + s_src, "%s %u.%02u V", vbus_name(t.vbus), t.vbus_mv / 1000, (t.vbus_mv % 1000) / 10); + else + lv_label_set_text(s_src, "on battery"); + + lv_label_set_text(s_state, status); + style_state_chip(t.charging); + + lv_label_set_text(s_row_val[ACT_CHARGE], bq25896_get_charge_enable() ? "ON" : "OFF"); +} + +static void refresh_selection(void) { + const lv_color_t accent = current_theme.border_accent; + const lv_color_t dim = lv_color_hex(COL_DIM); + for (int i = 0; i < ACT_COUNT; i++) { + bool sel = (i == s_sel); + lv_obj_set_style_border_color(s_row[i], sel ? accent : current_theme.border_inactive, 0); + lv_obj_set_style_border_opa(s_row[i], sel ? LV_OPA_COVER : LV_OPA_TRANSP, 0); + lv_obj_set_style_bg_color( + s_row[i], sel ? lv_color_hex(COL_RAISE) : current_theme.bg_secondary, 0); + lv_obj_set_style_shadow_width(s_row[i], sel ? GLOW_W : 0, 0); + lv_obj_set_style_shadow_spread(s_row[i], sel ? GLOW_SPREAD : 0, 0); + lv_obj_set_style_text_color(s_row_icon[i], sel ? accent : dim, 0); + lv_obj_set_style_text_color(s_row_val[i], sel ? accent : dim, 0); + } +} + +static void build_hero(void) { + lv_obj_t *card = lv_obj_create(s_screen); + lv_obj_remove_flag(card, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_remove_flag(card, LV_OBJ_FLAG_CLICKABLE); + lv_obj_set_width(card, lv_pct(HERO_W_PCT)); + lv_obj_set_height(card, HERO_H); + lv_obj_align(card, LV_ALIGN_TOP_MID, 0, HERO_Y); + lv_obj_set_style_radius(card, HERO_RADIUS, 0); + lv_obj_set_style_bg_color(card, current_theme.bg_secondary, 0); + lv_obj_set_style_bg_opa(card, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(card, HERO_BORDER_W, 0); + lv_obj_set_style_border_color(card, current_theme.border_accent, 0); + lv_obj_set_style_shadow_width(card, GLOW_W, 0); + lv_obj_set_style_shadow_color(card, current_theme.border_accent, 0); + lv_obj_set_style_shadow_spread(card, GLOW_SPREAD, 0); + lv_obj_set_style_pad_all(card, 0, 0); + + s_pct = lv_label_create(card); + lv_label_set_text(s_pct, "--"); + lv_obj_set_style_text_font(s_pct, &lv_font_montserrat_14, 0); + lv_obj_set_style_text_color(s_pct, current_theme.border_accent, 0); + lv_obj_align(s_pct, LV_ALIGN_LEFT_MID, PCT_X, PCT_Y); + + s_volt = lv_label_create(card); + lv_label_set_text(s_volt, "-- V"); + lv_obj_set_style_text_font(s_volt, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(s_volt, current_theme.text_main, 0); + lv_obj_align(s_volt, LV_ALIGN_LEFT_MID, PCT_X, VOLT_Y); + + s_state = lv_label_create(card); + lv_label_set_text(s_state, "--"); + lv_obj_set_style_text_font(s_state, &lv_font_montserrat_12, 0); + lv_obj_set_style_radius(s_state, LV_RADIUS_CIRCLE, 0); + lv_obj_set_style_pad_hor(s_state, CHIP_PAD_H, 0); + lv_obj_set_style_pad_ver(s_state, CHIP_PAD_V, 0); + lv_obj_align(s_state, LV_ALIGN_RIGHT_MID, STATE_X, STATE_Y); + style_state_chip(false); + + s_src = lv_label_create(card); + lv_label_set_text(s_src, ""); + lv_obj_set_style_text_font(s_src, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(s_src, lv_color_hex(COL_DIM), 0); + lv_obj_align(s_src, LV_ALIGN_RIGHT_MID, SRC_X, SRC_Y); +} + +static lv_obj_t *make_row(lv_obj_t *parent, int i) { + lv_obj_t *row = lv_obj_create(parent); + lv_obj_remove_flag(row, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_remove_flag(row, LV_OBJ_FLAG_CLICKABLE); + lv_obj_set_size(row, lv_pct(100), ROW_H); + lv_obj_set_style_radius(row, ROW_RADIUS, 0); + lv_obj_set_style_bg_opa(row, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(row, ROW_BORDER_W, 0); + lv_obj_set_style_pad_left(row, ROW_PAD_H, 0); + lv_obj_set_style_pad_right(row, ROW_PAD_H, 0); + lv_obj_set_style_pad_top(row, 0, 0); + lv_obj_set_style_pad_bottom(row, 0, 0); + lv_obj_set_style_shadow_color(row, current_theme.border_accent, 0); + 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_t *ic = lv_label_create(row); + lv_label_set_text(ic, ACT_ICON[i]); + lv_obj_set_style_text_font(ic, &lv_font_montserrat_14, 0); + lv_obj_set_width(ic, ICON_W); + lv_obj_set_style_text_align(ic, LV_TEXT_ALIGN_CENTER, 0); + + lv_obj_t *name = lv_label_create(row); + lv_label_set_text(name, ACT_NAMES[i]); + lv_obj_set_style_text_font(name, &lv_font_montserrat_14, 0); + lv_obj_set_style_text_color(name, current_theme.text_main, 0); + lv_obj_set_style_pad_left(name, NAME_PAD_L, 0); + lv_obj_set_flex_grow(name, 1); + + lv_obj_t *val = lv_label_create(row); + lv_label_set_text(val, i == ACT_CHARGE ? "--" : LV_SYMBOL_RIGHT); + lv_obj_set_style_text_font(val, &lv_font_montserrat_14, 0); + + s_row_icon[i] = ic; + s_row_val[i] = val; + return row; +} + +static void build_actions(void) { + lv_obj_t *list = lv_obj_create(s_screen); + lv_obj_remove_flag(list, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_remove_flag(list, LV_OBJ_FLAG_CLICKABLE); + lv_obj_set_width(list, lv_pct(LIST_W_PCT)); + lv_obj_set_height(list, LV_SIZE_CONTENT); + lv_obj_align(list, LV_ALIGN_TOP_MID, 0, LIST_Y); + lv_obj_set_style_bg_opa(list, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(list, 0, 0); + lv_obj_set_style_pad_all(list, 0, 0); + lv_obj_set_style_pad_row(list, ROW_GAP, 0); + lv_obj_set_flex_flow(list, LV_FLEX_FLOW_COLUMN); + + for (int i = 0; i < ACT_COUNT; i++) + s_row[i] = make_row(list, i); +} + +static void i2c_scan_fill(void) { + int n = 0; + s_scan_msg[0] = '\0'; + for (uint8_t a = 0x08; a <= 0x77; a++) { + esp_err_t r = i2c_master_probe(i2c_get_bus(), a, 20); + if (r == ESP_OK && n < (int)sizeof(s_scan_msg) - 7) + n += snprintf(s_scan_msg + n, sizeof(s_scan_msg) - n, "0x%02X ", a); + } + if (n == 0) + snprintf(s_scan_msg, sizeof(s_scan_msg), "No I2C devices found"); +} + +static void poweroff_cb(bool confirm) { + if (confirm) + bq25896_power_off(); +} + +static void do_action(int act) { + if (act == ACT_CHARGE) { + bq25896_set_charge_enable(!bq25896_get_charge_enable()); + refresh_telem(); + } else if (act == ACT_SCAN) { + i2c_scan_fill(); + msgbox_open(LV_SYMBOL_LIST, s_scan_msg, NULL, NULL, NULL); + } else if (act == ACT_REGS) { + uint8_t ts = bq25896_reg_raw(0x10) & 0x7F; + int tsx10 = 210 + ts * 465 / 100; + uint8_t f = bq25896_reg_raw(0x0C); + const char *ts_hint = (tsx10 < 344) ? "TS low: short? (Hot)" + : (tsx10 > 732) ? "TS high: open? (Cold)" + : "TS in range (OK)"; + snprintf(s_scan_msg, + sizeof(s_scan_msg), + "TS %d.%d%% (~50 ok)\n%s\n0B:%02X 0C:%02X NTC:%X\n00:%02X 04:%02X 0D:%02X", + tsx10 / 10, + tsx10 % 10, + ts_hint, + bq25896_reg_raw(0x0B), + f, + f & 0x07, + bq25896_reg_raw(0x00), + bq25896_reg_raw(0x04), + bq25896_reg_raw(0x0D)); + msgbox_open(LV_SYMBOL_SETTINGS, s_scan_msg, NULL, NULL, NULL); + } else if (act == ACT_OFF) { + msgbox_open(LV_SYMBOL_POWER, + "Power off the HighBoy?\n(unplug USB to stay off)", + "Off", + "Cancel", + poweroff_cb); + } +} + +static void power_input(const input_event_t *ev, void *ctx) { + (void)ctx; + const bool press = (ev->action == INPUT_ACTION_PRESS); + const bool nav = press || (ev->action == INPUT_ACTION_REPEAT); + + switch (ev->button) { + case INPUT_BTN_BACK: + case INPUT_BTN_LEFT: + if (press) + ui_switch_screen(SCREEN_SETTINGS); + break; + case INPUT_BTN_DOWN: + if (nav) { + s_sel = (s_sel + 1) % ACT_COUNT; + refresh_selection(); + } + break; + case INPUT_BTN_UP: + if (nav) { + s_sel = (s_sel - 1 + ACT_COUNT) % ACT_COUNT; + refresh_selection(); + } + break; + case INPUT_BTN_OK: + case INPUT_BTN_RIGHT: + if (press) + do_action(s_sel); + break; + default: + break; + } +} + +static void refresh_cb(lv_timer_t *t) { + if (lv_screen_active() != s_screen) { + lv_timer_delete(t); + s_timer = NULL; + return; + } + refresh_telem(); +} + +void ui_power_open(void) { + bq25896_init(); + if (s_screen != NULL) { + lv_obj_del(s_screen); + s_screen = NULL; + } + s_sel = 0; + + s_screen = lv_obj_create(NULL); + lv_obj_set_style_bg_color(s_screen, current_theme.screen_base, 0); + lv_obj_set_style_bg_opa(s_screen, LV_OPA_COVER, 0); + lv_obj_remove_flag(s_screen, LV_OBJ_FLAG_SCROLLABLE); + + ui_chrome_header(s_screen, "Power", HEADER_ICON); + + build_hero(); + build_actions(); + + refresh_selection(); + refresh_telem(); + + ui_chrome_footer(s_screen, "UP/DOWN select OK do BACK exit"); + + if (s_timer == NULL) + s_timer = lv_timer_create(refresh_cb, REFRESH_MS, NULL); + + ui_input_set_screen_handler(power_input, NULL); + + ui_screen_load_owned(&s_screen, s_screen); +} diff --git a/firmware_p4/components/Applications/ui/screens/rfid/include/rfid_menu_ui.h b/firmware_p4/components/Applications/ui/screens/rfid/include/rfid_menu_ui.h new file mode 100644 index 000000000..0fadd15d1 --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/rfid/include/rfid_menu_ui.h @@ -0,0 +1,30 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#ifndef RFID_MENU_UI_H +#define RFID_MENU_UI_H + +#ifdef __cplusplus +extern "C" { +#endif + +/** @brief Open the RFID menu screen. */ +void ui_rfid_menu_open(void); + +#ifdef __cplusplus +} +#endif + +#endif // RFID_MENU_UI_H diff --git a/firmware_p4/components/Applications/ui/screens/rfid/rfid_menu_ui.c b/firmware_p4/components/Applications/ui/screens/rfid/rfid_menu_ui.c new file mode 100644 index 000000000..365a1d478 --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/rfid/rfid_menu_ui.c @@ -0,0 +1,1600 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#include "rfid_menu_ui.h" + +#include + +#include "esp_log.h" +#include "lvgl.h" + +#include "st7789.h" + +#include "assets_manager.h" +#include "button_ui.h" +#include "capture_result_ui.h" +#include "keyboard_ui.h" +#include "menu_component_ui.h" +#include "msgbox_ui.h" +#include "notify_ui.h" +#include "page_dots_ui.h" +#include "ui_chrome.h" +#include "ui_feedback.h" +#include "ui_manager.h" +#include "ui_theme.h" +#include "waves_ui.h" + +static const char *TAG = "RFID_UI"; + +#define TICK_MS 50 +#define REVEAL_MS 3000 + +#define SIG_GREEN 0x00E676 +#define COL_DIM 0x8A8594 +#define COL_DANGER 0xFF5470 + +#define LIST_TITLE_ICON "/assets/icons/contactless.bin" +#define CARD_ICON "/assets/icons/badge.bin" +#define RADAR_ICON "/assets/icons/sensors.bin" +#define FILE_ICON "/assets/icons/description.bin" +#define EMULATE_ICON "/assets/icons/contactless.bin" +#define WRITE_ICON "/assets/icons/edit.bin" +#define SAVED_ICON "/assets/icons/bookmarks.bin" +#define COPY_ICON "/assets/icons/content_copy.bin" + +#define FADE_IN_MS 200 + +#define SCAN_MS 2600 +#define DOT_CYCLE_MS 350 + +#define STATUS_Y 50 +#define FREQ_Y 68 + +#define PAD_W 154 +#define PAD_H 86 +#define PAD_RADIUS 12 +#define PAD_BORDER 2 +#define PAD_Y_OFS -12 +#define PAD_DIM_OPA LV_OPA_50 + +#define SIL_W 96 +#define SIL_H 58 +#define SIL_RADIUS 8 +#define SIL_OPA LV_OPA_20 + +#define BEAM_W (PAD_W - PAD_BORDER * 2) +#define BEAM_H 3 +#define BEAM_MARGIN 8 +#define BEAM_TRAVEL (PAD_H - PAD_BORDER * 2 - BEAM_MARGIN * 2 - BEAM_H) +#define BEAM_MS 820 +#define BEAM_GLOW_W 10 + +#define HEX_BYTES 6 +#define HEX_BUF_LEN 24 +#define HEX_Y_OFS 46 +#define HEX_UPDATE_MS 80 + +#define PROG_W 154 +#define PROG_H 4 +#define PROG_Y_OFS 66 + +#define CARD_W 210 +#define CARD_H 120 +#define CARD_RADIUS 14 +#define CARD_PAD 12 +#define CARD_BORDER 1 +#define CARD_SHADOW_W 12 +#define CARD_Y_OFS 18 +#define CARD_RISE_PX 26 +#define CARD_RISE_MS 300 + +#define CARD_H_WIDE 162 +#define CARD_Y_READ 8 + +#define CARD_TOP 0x3A1170 +#define CARD_BOT 0x140230 +#define CARD_EDGE 0xB060FF +#define CARD_TXT 0xFFFFFF +#define CHIP_W 30 +#define CHIP_H 22 +#define CHIP_RADIUS 5 +#define CHIP_LINE_W 26 +#define CHIP_GOLD_TOP 0xD9A521 +#define CHIP_GOLD_BOT 0xF4D36B +#define CHIP_LINE_COL 0x7A5A10 +#define SAVED_CARD_Y (-28) +#define SAVED_ACT_Y (-24) + +#define HINT_SAVED "UP/DOWN choose OK open BACK exit" + +#define SAVED_CF_CARD_W 210 +#define SAVED_CF_CARD_H 122 +#define SAVED_CF_PEEK 60 +#define SAVED_CF_RADIUS 14 +#define SAVED_CF_SEL_BORDER 3 +#define SAVED_CF_GLOW_W 14 +#define SAVED_CF_SCROLL_OFS 6 +#define SAVED_CF_DOTS_Y (-28) +#define SAVED_CF_TOP 0x3A1170 +#define SAVED_CF_BOT 0x140230 +#define SAVED_CF_EDGE 0xB060FF +#define SAVED_CF_CHIP_TOP 0xD9A521 +#define SAVED_CF_CHIP_BOT 0xF4D36B +#define SAVED_CF_CHIP_LINE 0x7A5A10 +#define SAVED_DETAIL_BUF 48 + +#define WIEGAND_FC_SHIFT 16 +#define WIEGAND_FC_MASK 0xFF +#define WIEGAND_CN_MASK 0xFFFF + +#define WIEGAND_ROWS 3 +#define WIEGAND_Y0 100 +#define WIEGAND_STEP 15 + +#define FIELD_FADE_MS 220 +#define FIELD_STAGGER_MS 70 + +#define CARD_TITLE_Y 30 +#define CARD_SUB_Y 49 +#define CARD_LINE_Y 66 +#define CARD_META_Y 83 + +#define TX_DOT_SIZE 12 +#define TX_DOT_GAP 4 +#define TX_DOT_COUNT 3 +#define TX_DOT_BLINK_MS 400 +#define TX_DOT_Y_OFS -30 + +#define EMU_TX_LABEL_Y -46 +#define EMU_TX_BLINK_MS 600 +#define STATUS_BLINK_LO LV_OPA_40 + +#define CLONE_REVEAL_MS 1200 +#define CLONE_WRITE_MS 2200 +#define CLONE_BAR_Y_OFS 50 + +#define READ_STATUS_BUSY "Reading" +#define EMU_TX_BUSY "Transmitting" + +#define HINT_SCAN "BACK Cancel" +#define HINT_SHOW "BACK Exit" +#define HINT_MENU "UP/DOWN choose OK do BACK exit" +#define HINT_ADD "OK edit L/R change BACK exit" + +#define CARD_TITLE "EM4100" +#define CARD_SUBTITLE "Low-Frequency 125 kHz" +#define CARD_LINE "UID 1A 2B 3C 4D 55" +#define CARD_META "64-bit · Read-only" + +static const char *const WIEGAND_LINES[] = { + "Format: HID 26-bit", + "Facility: 123", + "Card: 45678", +}; + +static const struct { + const char *name; + const char *icon; +} RFID_ITEMS[] = { + {"Read", RADAR_ICON}, + {"Saved", SAVED_ICON}, + {"Emulate", EMULATE_ICON}, + {"Clone", COPY_ICON}, + {"Add Manually", WRITE_ICON}, +}; +#define RFID_ITEM_COUNT ((int)(sizeof(RFID_ITEMS) / sizeof(RFID_ITEMS[0]))) + +#define IDX_READ 0 +#define IDX_SAVED 1 +#define IDX_EMULATE 2 +#define IDX_CLONE 3 +#define IDX_ADD 4 + +typedef struct { + char name[24]; + char proto[16]; + char uid[24]; + char bits[28]; +} rfid_card_t; + +static const rfid_card_t SAVED_CARDS[] = { + {"Office_Badge", "EM4100", "1A 2B 3C 4D 55", "64-bit · Read-only"}, + {"Garage_Fob", "HIDProx", "20 06 EC 0C 86", "44-bit · Read-only"}, + {"Gym_Tag", "Indala", "A0 00 1C FE 49", "64-bit · Read-only"}, + {"Locker_03", "EM4100", "09 FB 2D 77 11", "64-bit · Read-only"}, +}; +#define SAVED_CARD_COUNT ((int)(sizeof(SAVED_CARDS) / sizeof(SAVED_CARDS[0]))) +#define RFID_MAX_CARDS 16 + +static rfid_card_t s_cards[RFID_MAX_CARDS]; +static int s_card_count = 0; +static bool s_store_ready = false; + +static const char *ADD_PROTOS[] = {"EM4100", "HIDProx", "Indala", "T5577"}; +#define ADD_PROTO_COUNT ((int)(sizeof(ADD_PROTOS) / sizeof(ADD_PROTOS[0]))) +static const char *ADD_BITS[] = {"44-bit", "64-bit", "128-bit"}; +#define ADD_BITS_COUNT ((int)(sizeof(ADD_BITS) / sizeof(ADD_BITS[0]))) + +typedef enum { + VIEW_LIST = 0, + VIEW_READ, + VIEW_OPTIONS, + VIEW_SAVED, + VIEW_SAVED_INFO, + VIEW_EMULATE, + VIEW_CLONE, + VIEW_ADD, + VIEW_COUNT +} rfid_view_t; + +enum { CL_READ, CL_REVEAL, CL_WRITE, CL_DONE }; + +static lv_obj_t *s_screen = NULL; +static menu_component_t s_menu; +static rfid_view_t s_view = VIEW_LIST; +static int s_saved_sel = 0; +static int s_pending_view = -1; + +static lv_timer_t *s_tick_timer = NULL; +static lv_timer_t *s_scan_timer = NULL; + +static lv_obj_t *s_status_lbl = NULL; +static lv_obj_t *s_scan_group = NULL; +static lv_obj_t *s_hex_lbl = NULL; +static lv_obj_t *s_waves = NULL; +static lv_obj_t *s_hint = NULL; +static uint32_t s_scan_start = 0; +static uint32_t s_hex_stamp = 0; +static bool s_card_revealed = false; +static bool s_saved = false; +static capture_result_t s_cr = {0}; +static uint32_t s_revealed_at = 0; + +static button_ui_t s_info_emulate; +static button_ui_t s_info_delete; +static int s_info_sel = 0; + +static lv_obj_t *s_fob[RFID_MAX_CARDS]; +static lv_obj_t *s_fob_name[RFID_MAX_CARDS]; +static lv_obj_t *s_fob_id[RFID_MAX_CARDS]; +static lv_obj_t *s_saved_cont = NULL; +static page_dots_t s_dots; +static bool s_has_dots = false; + +static char s_emu_title[24]; +static char s_emu_sub[28]; +static char s_emu_line[40]; +static char s_emu_meta[28]; +static char s_emu_freq[40]; + +static int s_clone_stage = CL_READ; +static uint32_t s_clone_start = 0; +static lv_obj_t *s_clone_card = NULL; +static lv_obj_t *s_clone_freq = NULL; +static lv_obj_t *s_clone_bar = NULL; + +static int s_add_proto = 0; +static int s_add_bits = 1; +static char s_add_uid[24] = ""; + +static void rfid_tick_cb(lv_timer_t *t); +static void rfid_menu_input(const input_event_t *ev, void *ctx); +static void build_screen(void); + +static void store_init(void) { + if (s_store_ready) + return; + s_card_count = 0; + for (int i = 0; i < SAVED_CARD_COUNT && s_card_count < RFID_MAX_CARDS; i++) + s_cards[s_card_count++] = SAVED_CARDS[i]; + s_store_ready = true; +} + +static void stop_scan_timers(void) { + if (s_scan_timer != NULL) { + lv_timer_delete(s_scan_timer); + s_scan_timer = NULL; + } +} + +static void opa_cb(void *var, int32_t v) { + lv_obj_set_style_opa((lv_obj_t *)var, (lv_opa_t)v, 0); +} + +static void translate_y_cb(void *var, int32_t v) { + lv_obj_set_style_translate_y((lv_obj_t *)var, v, 0); +} + +static void bar_value_cb(void *var, int32_t v) { + lv_bar_set_value((lv_obj_t *)var, v, LV_ANIM_OFF); +} + +static void fade_in(lv_obj_t *obj, uint32_t duration_ms, uint32_t delay_ms) { + lv_obj_set_style_opa(obj, LV_OPA_TRANSP, 0); + lv_anim_t a; + lv_anim_init(&a); + lv_anim_set_var(&a, obj); + lv_anim_set_exec_cb(&a, opa_cb); + lv_anim_set_values(&a, LV_OPA_TRANSP, LV_OPA_COVER); + lv_anim_set_duration(&a, duration_ms); + lv_anim_set_delay(&a, delay_ms); + lv_anim_set_path_cb(&a, lv_anim_path_ease_out); + lv_anim_start(&a); +} + +static void blink_loop(lv_obj_t *obj, lv_opa_t low, uint32_t half_ms) { + lv_anim_t a; + lv_anim_init(&a); + lv_anim_set_var(&a, obj); + lv_anim_set_exec_cb(&a, opa_cb); + lv_anim_set_values(&a, low, LV_OPA_COVER); + lv_anim_set_duration(&a, half_ms); + lv_anim_set_playback_duration(&a, half_ms); + lv_anim_set_repeat_count(&a, LV_ANIM_REPEAT_INFINITE); + lv_anim_set_path_cb(&a, lv_anim_path_ease_in_out); + lv_anim_start(&a); +} + +static void scramble_hex(char *out, size_t n, uint32_t seed) { + static const char H[] = "0123456789ABCDEF"; + size_t p = 0; + for (int i = 0; i < HEX_BYTES && p + 3 < n; i++) { + seed = seed * 1103515245u + 12345u; + uint8_t b = (uint8_t)((seed >> 16) & 0xFF); + out[p++] = H[(b >> 4) & 0xF]; + out[p++] = H[b & 0xF]; + if (i < HEX_BYTES - 1) + out[p++] = ' '; + } + out[p] = '\0'; +} + +static lv_obj_t *build_data_card(lv_obj_t *parent, + const char *title_txt, + const char *sub_txt, + const char *line_txt, + const char *meta_txt, + bool assemble, + bool wiegand) { + lv_color_t edge = lv_color_hex(CARD_EDGE); + lv_color_t text = lv_color_hex(CARD_TXT); + uint32_t delay = assemble ? FIELD_STAGGER_MS : 0; + + lv_obj_t *card = lv_obj_create(parent); + lv_obj_remove_flag(card, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(card, CARD_W, wiegand ? CARD_H_WIDE : CARD_H); + lv_obj_set_style_radius(card, CARD_RADIUS, 0); + lv_obj_set_style_pad_all(card, CARD_PAD, 0); + lv_obj_set_style_bg_color(card, lv_color_hex(CARD_TOP), 0); + lv_obj_set_style_bg_grad_color(card, lv_color_hex(CARD_BOT), 0); + lv_obj_set_style_bg_grad_dir(card, LV_GRAD_DIR_VER, 0); + lv_obj_set_style_bg_opa(card, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(card, CARD_BORDER, 0); + lv_obj_set_style_border_color(card, edge, 0); + lv_obj_set_style_shadow_color(card, edge, 0); + lv_obj_set_style_shadow_width(card, CARD_SHADOW_W, 0); + lv_obj_set_style_shadow_opa(card, LV_OPA_30, 0); + + lv_obj_t *chip = lv_obj_create(card); + lv_obj_remove_flag(chip, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(chip, CHIP_W, CHIP_H); + lv_obj_align(chip, LV_ALIGN_TOP_LEFT, 0, 0); + lv_obj_set_style_radius(chip, CHIP_RADIUS, 0); + lv_obj_set_style_pad_all(chip, 0, 0); + lv_obj_set_style_border_width(chip, 0, 0); + lv_obj_set_style_bg_color(chip, lv_color_hex(CHIP_GOLD_TOP), 0); + lv_obj_set_style_bg_grad_color(chip, lv_color_hex(CHIP_GOLD_BOT), 0); + lv_obj_set_style_bg_grad_dir(chip, LV_GRAD_DIR_VER, 0); + for (int i = 0; i < 2; i++) { + lv_obj_t *ln = lv_obj_create(chip); + lv_obj_remove_flag(ln, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(ln, CHIP_LINE_W, 1); + lv_obj_align(ln, LV_ALIGN_CENTER, 0, i == 0 ? -5 : 5); + lv_obj_set_style_border_width(ln, 0, 0); + lv_obj_set_style_radius(ln, 0, 0); + lv_obj_set_style_bg_color(ln, lv_color_hex(CHIP_LINE_COL), 0); + lv_obj_set_style_bg_opa(ln, LV_OPA_70, 0); + } + if (assemble) + fade_in(chip, FIELD_FADE_MS, 0); + + lv_obj_t *title = lv_label_create(card); + lv_obj_set_width(title, lv_pct(100)); + lv_label_set_long_mode(title, LV_LABEL_LONG_DOT); + lv_label_set_text(title, title_txt); + lv_obj_set_style_text_color(title, text, 0); + lv_obj_set_style_text_font(title, &lv_font_montserrat_14, 0); + lv_obj_align(title, LV_ALIGN_TOP_LEFT, 0, CARD_TITLE_Y); + if (assemble) + fade_in(title, FIELD_FADE_MS, delay); + + lv_obj_t *sub = lv_label_create(card); + lv_obj_set_width(sub, lv_pct(100)); + lv_label_set_long_mode(sub, LV_LABEL_LONG_DOT); + lv_label_set_text(sub, sub_txt); + lv_obj_set_style_text_color(sub, text, 0); + lv_obj_set_style_text_opa(sub, LV_OPA_60, 0); + lv_obj_set_style_text_font(sub, &lv_font_montserrat_12, 0); + lv_obj_align(sub, LV_ALIGN_TOP_LEFT, 0, CARD_SUB_Y); + if (assemble) + fade_in(sub, FIELD_FADE_MS, delay * 2); + + lv_obj_t *line = lv_label_create(card); + lv_obj_set_width(line, lv_pct(100)); + lv_label_set_long_mode(line, LV_LABEL_LONG_DOT); + lv_label_set_text(line, line_txt); + lv_obj_set_style_text_color(line, edge, 0); + lv_obj_set_style_text_font(line, &lv_font_montserrat_12, 0); + lv_obj_align(line, LV_ALIGN_TOP_LEFT, 0, CARD_LINE_Y); + if (assemble) + fade_in(line, FIELD_FADE_MS, delay * 3); + + lv_obj_t *meta = lv_label_create(card); + lv_obj_set_width(meta, lv_pct(100)); + lv_label_set_long_mode(meta, LV_LABEL_LONG_DOT); + lv_label_set_text(meta, meta_txt); + lv_obj_set_style_text_color(meta, text, 0); + lv_obj_set_style_text_opa(meta, LV_OPA_50, 0); + lv_obj_set_style_text_font(meta, &lv_font_montserrat_12, 0); + lv_obj_align(meta, LV_ALIGN_TOP_LEFT, 0, CARD_META_Y); + if (assemble) + fade_in(meta, FIELD_FADE_MS, delay * 4); + + if (wiegand) { + for (int i = 0; i < WIEGAND_ROWS; i++) { + lv_obj_t *w = lv_label_create(card); + lv_label_set_text(w, WIEGAND_LINES[i]); + lv_obj_set_style_text_color(w, text, 0); + lv_obj_set_style_text_opa(w, LV_OPA_50, 0); + lv_obj_set_style_text_font(w, &lv_font_montserrat_12, 0); + lv_obj_align(w, LV_ALIGN_TOP_LEFT, 0, WIEGAND_Y0 + i * WIEGAND_STEP); + if (assemble) + fade_in(w, FIELD_FADE_MS, delay * (5 + i)); + } + } + + return card; +} + +static void card_rise(lv_obj_t *card) { + lv_obj_set_style_translate_y(card, CARD_RISE_PX, 0); + lv_anim_t a; + lv_anim_init(&a); + lv_anim_set_var(&a, card); + lv_anim_set_exec_cb(&a, translate_y_cb); + lv_anim_set_values(&a, CARD_RISE_PX, 0); + lv_anim_set_duration(&a, CARD_RISE_MS); + lv_anim_set_path_cb(&a, lv_anim_path_ease_out); + lv_anim_start(&a); +} + +static void build_scan_field(void) { + s_scan_group = lv_obj_create(s_screen); + lv_obj_remove_flag(s_scan_group, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(s_scan_group, lv_pct(100), lv_pct(100)); + lv_obj_align(s_scan_group, LV_ALIGN_CENTER, 0, 0); + lv_obj_set_style_bg_opa(s_scan_group, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(s_scan_group, 0, 0); + lv_obj_set_style_pad_all(s_scan_group, 0, 0); + + lv_obj_t *pad = lv_obj_create(s_scan_group); + lv_obj_remove_flag(pad, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(pad, PAD_W, PAD_H); + lv_obj_align(pad, LV_ALIGN_CENTER, 0, PAD_Y_OFS); + lv_obj_set_style_radius(pad, PAD_RADIUS, 0); + lv_obj_set_style_pad_all(pad, 0, 0); + lv_obj_set_style_bg_color(pad, current_theme.bg_primary, 0); + lv_obj_set_style_bg_opa(pad, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(pad, PAD_BORDER, 0); + lv_obj_set_style_border_color(pad, current_theme.border_accent, 0); + lv_obj_set_style_border_opa(pad, PAD_DIM_OPA, 0); + lv_obj_set_style_clip_corner(pad, true, 0); + + lv_obj_t *sil = lv_obj_create(pad); + lv_obj_remove_flag(sil, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(sil, SIL_W, SIL_H); + lv_obj_center(sil); + lv_obj_set_style_radius(sil, SIL_RADIUS, 0); + lv_obj_set_style_bg_opa(sil, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(sil, 1, 0); + lv_obj_set_style_border_color(sil, current_theme.border_accent, 0); + lv_obj_set_style_border_opa(sil, SIL_OPA, 0); + + lv_obj_t *beam = lv_obj_create(pad); + lv_obj_remove_flag(beam, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(beam, BEAM_W, BEAM_H); + lv_obj_align(beam, LV_ALIGN_TOP_MID, 0, BEAM_MARGIN); + lv_obj_set_style_radius(beam, 0, 0); + lv_obj_set_style_border_width(beam, 0, 0); + lv_obj_set_style_bg_color(beam, current_theme.border_accent, 0); + lv_obj_set_style_bg_opa(beam, LV_OPA_COVER, 0); + lv_obj_set_style_shadow_color(beam, current_theme.border_accent, 0); + lv_obj_set_style_shadow_width(beam, BEAM_GLOW_W, 0); + lv_obj_set_style_shadow_opa(beam, LV_OPA_50, 0); + + lv_anim_t sweep; + lv_anim_init(&sweep); + lv_anim_set_var(&sweep, beam); + lv_anim_set_exec_cb(&sweep, translate_y_cb); + lv_anim_set_values(&sweep, 0, BEAM_TRAVEL); + lv_anim_set_duration(&sweep, BEAM_MS); + lv_anim_set_playback_duration(&sweep, BEAM_MS); + lv_anim_set_repeat_count(&sweep, LV_ANIM_REPEAT_INFINITE); + lv_anim_set_path_cb(&sweep, lv_anim_path_ease_in_out); + lv_anim_start(&sweep); + + s_hex_lbl = lv_label_create(s_scan_group); + lv_label_set_text(s_hex_lbl, "-- -- -- -- -- --"); + lv_obj_set_style_text_color(s_hex_lbl, current_theme.border_inactive, 0); + lv_obj_set_style_text_font(s_hex_lbl, &lv_font_montserrat_14, 0); + lv_obj_align(s_hex_lbl, LV_ALIGN_CENTER, 0, HEX_Y_OFS); + + lv_obj_t *prog = lv_bar_create(s_scan_group); + lv_obj_set_size(prog, PROG_W, PROG_H); + lv_obj_align(prog, LV_ALIGN_CENTER, 0, PROG_Y_OFS); + lv_bar_set_range(prog, 0, 100); + lv_bar_set_value(prog, 0, LV_ANIM_OFF); + lv_obj_set_style_bg_color(prog, current_theme.bg_secondary, LV_PART_MAIN); + lv_obj_set_style_bg_opa(prog, LV_OPA_COVER, LV_PART_MAIN); + lv_obj_set_style_radius(prog, PROG_H / 2, LV_PART_MAIN); + lv_obj_set_style_bg_color(prog, current_theme.border_accent, LV_PART_INDICATOR); + lv_obj_set_style_bg_opa(prog, LV_OPA_COVER, LV_PART_INDICATOR); + lv_obj_set_style_radius(prog, PROG_H / 2, LV_PART_INDICATOR); + + lv_anim_t pa; + lv_anim_init(&pa); + lv_anim_set_var(&pa, prog); + lv_anim_set_exec_cb(&pa, bar_value_cb); + lv_anim_set_values(&pa, 0, 100); + lv_anim_set_duration(&pa, SCAN_MS); + lv_anim_set_path_cb(&pa, lv_anim_path_linear); + lv_anim_start(&pa); +} + +static void status_dots(lv_obj_t *lbl, const char *base, uint32_t elapsed) { + int dots = (elapsed / DOT_CYCLE_MS) % 4; + char buf[28]; + snprintf(buf, + sizeof(buf), + "%s%s", + base, + dots == 1 ? "." + : dots == 2 ? ".." + : dots == 3 ? "..." + : ""); + lv_label_set_text(lbl, buf); +} + +static void reveal_captured_card(void) { + if (s_scan_group != NULL) { + lv_obj_del(s_scan_group); + s_scan_group = NULL; + s_hex_lbl = NULL; + } + if (s_status_lbl != NULL) { + lv_anim_delete(s_status_lbl, opa_cb); + lv_obj_set_style_opa(s_status_lbl, LV_OPA_COVER, 0); + lv_label_set_text(s_status_lbl, "Tag detected!"); + lv_obj_set_style_text_color(s_status_lbl, lv_color_hex(SIG_GREEN), 0); + } + + lv_obj_t *card = + build_data_card(s_screen, CARD_TITLE, CARD_SUBTITLE, CARD_LINE, CARD_META, true, true); + lv_obj_align(card, LV_ALIGN_CENTER, 0, CARD_Y_READ); + card_rise(card); +} + +static void scan_done_cb(lv_timer_t *t) { + (void)t; + s_scan_timer = NULL; + if (lv_screen_active() != s_screen) + return; + + s_card_revealed = true; + s_revealed_at = lv_tick_get(); + reveal_captured_card(); + ESP_LOGI(TAG, "mock rfid capture: %s %s", CARD_TITLE, CARD_LINE); + ui_feedback(UI_FB_READ); + + if (s_hint != NULL) + ui_chrome_footer_set_text(s_hint, HINT_SHOW); +} + +static void build_read(void) { + ui_chrome_header(s_screen, "READ", RADAR_ICON); + + s_card_revealed = false; + s_saved = false; + s_scan_start = lv_tick_get(); + s_hex_stamp = s_scan_start; + + s_status_lbl = lv_label_create(s_screen); + lv_label_set_text(s_status_lbl, READ_STATUS_BUSY); + lv_obj_set_style_text_color(s_status_lbl, current_theme.text_main, 0); + lv_obj_set_style_text_font(s_status_lbl, &lv_font_montserrat_14, 0); + lv_obj_align(s_status_lbl, LV_ALIGN_TOP_MID, 0, STATUS_Y); + + lv_obj_t *freq = lv_label_create(s_screen); + lv_label_set_text(freq, "125 kHz LF"); + lv_obj_set_style_text_color(freq, current_theme.border_accent, 0); + lv_obj_set_style_text_font(freq, &lv_font_montserrat_12, 0); + lv_obj_align(freq, LV_ALIGN_TOP_MID, 0, FREQ_Y); + + build_scan_field(); + + s_hint = ui_chrome_footer(s_screen, HINT_SCAN); + + s_scan_timer = lv_timer_create(scan_done_cb, SCAN_MS, NULL); + lv_timer_set_repeat_count(s_scan_timer, 1); +} + +static void build_saved_empty(void) { + ui_chrome_header(s_screen, "SAVED CARDS", SAVED_ICON); + + lv_obj_t *card = lv_obj_create(s_screen); + lv_obj_remove_flag(card, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(card, 200, 104); + lv_obj_align(card, LV_ALIGN_CENTER, 0, 6); + lv_obj_set_style_radius(card, 13, 0); + lv_obj_set_style_bg_color(card, current_theme.bg_secondary, 0); + lv_obj_set_style_bg_grad_dir(card, LV_GRAD_DIR_NONE, 0); + lv_obj_set_style_bg_opa(card, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(card, 1, 0); + lv_obj_set_style_border_color(card, current_theme.border_accent, 0); + lv_obj_set_style_shadow_color(card, current_theme.border_accent, 0); + lv_obj_set_style_shadow_width(card, 16, 0); + lv_obj_set_style_shadow_opa(card, LV_OPA_40, 0); + lv_obj_set_style_shadow_spread(card, -4, 0); + lv_obj_set_style_pad_all(card, 10, 0); + lv_obj_set_flex_flow(card, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(card, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_set_style_pad_row(card, 6, 0); + + lv_image_dsc_t *dsc = assets_get(SAVED_ICON); + if (dsc != NULL) { + lv_obj_t *img = lv_image_create(card); + lv_image_set_src(img, dsc); + lv_obj_set_style_image_recolor(img, current_theme.text_main, 0); + lv_obj_set_style_image_recolor_opa(img, LV_OPA_COVER, 0); + } + lv_obj_t *t = lv_label_create(card); + lv_label_set_text(t, "No saved cards"); + lv_obj_set_style_text_font(t, &lv_font_montserrat_14, 0); + lv_obj_set_style_text_color(t, current_theme.text_main, 0); + lv_obj_t *s = lv_label_create(card); + lv_label_set_text(s, "Read or add a tag first"); + lv_obj_set_style_text_font(s, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(s, lv_color_hex(COL_DIM), 0); + + s_hint = ui_chrome_footer(s_screen, HINT_SHOW); +} + +static void uid_wiegand(const char *uid, int *fc, int *cn) { + uint32_t v = 0; + for (const char *c = uid; c != NULL && *c != '\0'; c++) { + int nib = -1; + if (*c >= '0' && *c <= '9') + nib = *c - '0'; + else if (*c >= 'a' && *c <= 'f') + nib = 10 + (*c - 'a'); + else if (*c >= 'A' && *c <= 'F') + nib = 10 + (*c - 'A'); + if (nib < 0) + continue; + v = (v << 4) | (uint32_t)nib; + } + *fc = (int)((v >> WIEGAND_FC_SHIFT) & WIEGAND_FC_MASK); + *cn = (int)(v & WIEGAND_CN_MASK); +} + +static void saved_row_detail(const rfid_card_t *c, char *out, size_t n) { + int fc = 0; + int cn = 0; + uid_wiegand(c->uid, &fc, &cn); + snprintf(out, n, "%s · FC %d CN %d", c->proto, fc, cn); +} + +static void saved_apply_selection(void) { + lv_color_t accent = current_theme.border_accent; + lv_color_t edge = lv_color_hex(SAVED_CF_EDGE); + for (int i = 0; i < s_card_count; i++) { + lv_obj_t *f = s_fob[i]; + int y = i * SAVED_CF_PEEK; + if (i > s_saved_sel) + y += (SAVED_CF_CARD_H - SAVED_CF_PEEK); + lv_obj_align(f, LV_ALIGN_TOP_MID, 0, y); + + bool sel = (i == s_saved_sel); + if (sel) { + lv_obj_set_style_border_width(f, SAVED_CF_SEL_BORDER, 0); + lv_obj_set_style_border_color(f, accent, 0); + lv_obj_set_style_shadow_color(f, accent, 0); + lv_obj_set_style_shadow_width(f, SAVED_CF_GLOW_W, 0); + lv_obj_set_style_shadow_opa(f, LV_OPA_50, 0); + lv_obj_set_style_shadow_spread(f, -3, 0); + } else { + lv_obj_set_style_border_width(f, 1, 0); + lv_obj_set_style_border_color(f, edge, 0); + lv_obj_set_style_shadow_width(f, 0, 0); + lv_obj_set_style_shadow_opa(f, LV_OPA_TRANSP, 0); + } + } + + int sy = s_saved_sel * SAVED_CF_PEEK - SAVED_CF_SCROLL_OFS; + if (sy < 0) + sy = 0; + if (s_saved_cont != NULL) + lv_obj_scroll_to_y(s_saved_cont, sy, LV_ANIM_OFF); + if (s_has_dots) + page_dots_set(&s_dots, s_saved_sel); +} + +static void build_saved_card(int i) { + lv_color_t edge = lv_color_hex(SAVED_CF_EDGE); + lv_color_t text = lv_color_white(); + + lv_obj_t *card = lv_obj_create(s_saved_cont); + s_fob[i] = card; + lv_obj_remove_flag(card, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_remove_flag(card, LV_OBJ_FLAG_CLICKABLE); + lv_obj_set_size(card, SAVED_CF_CARD_W, SAVED_CF_CARD_H); + lv_obj_set_style_radius(card, SAVED_CF_RADIUS, 0); + lv_obj_set_style_pad_all(card, 12, 0); + lv_obj_set_style_bg_color(card, lv_color_hex(SAVED_CF_TOP), 0); + lv_obj_set_style_bg_grad_color(card, lv_color_hex(SAVED_CF_BOT), 0); + lv_obj_set_style_bg_grad_dir(card, LV_GRAD_DIR_VER, 0); + lv_obj_set_style_bg_opa(card, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(card, 1, 0); + lv_obj_set_style_border_color(card, edge, 0); + lv_obj_set_style_shadow_color(card, edge, 0); + lv_obj_set_style_shadow_width(card, 10, 0); + lv_obj_set_style_shadow_opa(card, LV_OPA_30, 0); + + lv_obj_t *chip = lv_obj_create(card); + lv_obj_remove_flag(chip, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(chip, 30, 22); + lv_obj_align(chip, LV_ALIGN_TOP_LEFT, 0, 0); + lv_obj_set_style_radius(chip, 5, 0); + lv_obj_set_style_border_width(chip, 0, 0); + lv_obj_set_style_bg_color(chip, lv_color_hex(SAVED_CF_CHIP_TOP), 0); + lv_obj_set_style_bg_grad_color(chip, lv_color_hex(SAVED_CF_CHIP_BOT), 0); + lv_obj_set_style_bg_grad_dir(chip, LV_GRAD_DIR_VER, 0); + for (int k = 0; k < 2; k++) { + lv_obj_t *ln = lv_obj_create(chip); + lv_obj_remove_flag(ln, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(ln, 26, 1); + lv_obj_align(ln, LV_ALIGN_CENTER, 0, k == 0 ? -5 : 5); + lv_obj_set_style_border_width(ln, 0, 0); + lv_obj_set_style_radius(ln, 0, 0); + lv_obj_set_style_bg_color(ln, lv_color_hex(SAVED_CF_CHIP_LINE), 0); + lv_obj_set_style_bg_opa(ln, LV_OPA_70, 0); + } + + lv_obj_t *name = lv_label_create(card); + s_fob_name[i] = name; + lv_obj_set_width(name, lv_pct(100)); + lv_label_set_long_mode(name, LV_LABEL_LONG_DOT); + lv_label_set_text(name, s_cards[i].name); + lv_obj_set_style_text_color(name, text, 0); + lv_obj_set_style_text_font(name, &lv_font_montserrat_14, 0); + lv_obj_align(name, LV_ALIGN_TOP_LEFT, 0, 30); + + lv_obj_t *sub = lv_label_create(card); + lv_obj_set_width(sub, lv_pct(100)); + lv_label_set_long_mode(sub, LV_LABEL_LONG_DOT); + lv_label_set_text(sub, s_cards[i].proto); + lv_obj_set_style_text_color(sub, text, 0); + lv_obj_set_style_text_opa(sub, LV_OPA_60, 0); + lv_obj_set_style_text_font(sub, &lv_font_montserrat_12, 0); + lv_obj_align(sub, LV_ALIGN_TOP_LEFT, 0, 49); + + lv_obj_t *uidl = lv_label_create(card); + s_fob_id[i] = uidl; + lv_obj_set_width(uidl, lv_pct(100)); + lv_label_set_long_mode(uidl, LV_LABEL_LONG_DOT); + lv_label_set_text_fmt(uidl, "UID %s", s_cards[i].uid); + lv_obj_set_style_text_color(uidl, edge, 0); + lv_obj_set_style_text_font(uidl, &lv_font_montserrat_12, 0); + lv_obj_align(uidl, LV_ALIGN_TOP_LEFT, 0, 66); + + char detail[SAVED_DETAIL_BUF]; + saved_row_detail(&s_cards[i], detail, sizeof(detail)); + lv_obj_t *meta = lv_label_create(card); + lv_obj_set_width(meta, lv_pct(100)); + lv_label_set_long_mode(meta, LV_LABEL_LONG_DOT); + lv_label_set_text(meta, detail); + lv_obj_set_style_text_color(meta, text, 0); + lv_obj_set_style_text_opa(meta, LV_OPA_50, 0); + lv_obj_set_style_text_font(meta, &lv_font_montserrat_12, 0); + lv_obj_align(meta, LV_ALIGN_TOP_LEFT, 0, 83); +} + +static void build_saved_list(void) { + if (s_card_count <= 0) { + build_saved_empty(); + return; + } + ui_chrome_header(s_screen, "SAVED CARDS", SAVED_ICON); + + if (s_saved_sel < 0) + s_saved_sel = 0; + if (s_saved_sel >= s_card_count) + s_saved_sel = s_card_count - 1; + + s_saved_cont = lv_obj_create(s_screen); + lv_obj_set_size(s_saved_cont, lv_pct(100), LCD_V_RES - UI_CHROME_HEADER_H - UI_CHROME_FOOTER_H); + lv_obj_align(s_saved_cont, LV_ALIGN_TOP_MID, 0, UI_CHROME_HEADER_H); + lv_obj_set_style_bg_opa(s_saved_cont, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(s_saved_cont, 0, 0); + lv_obj_set_style_pad_all(s_saved_cont, 0, 0); + lv_obj_set_scrollbar_mode(s_saved_cont, LV_SCROLLBAR_MODE_OFF); + + for (int i = 0; i < s_card_count; i++) + build_saved_card(i); + + lv_obj_update_layout(s_screen); + saved_apply_selection(); + + s_dots = page_dots_create(s_screen, s_card_count, LV_ALIGN_BOTTOM_MID, 0, SAVED_CF_DOTS_Y); + s_has_dots = true; + page_dots_set(&s_dots, s_saved_sel); + + s_hint = ui_chrome_footer(s_screen, HINT_SAVED); +} + +static void info_update_selection(void) { + button_ui_set_selected(&s_info_emulate, s_info_sel == 0); + button_ui_set_selected(&s_info_delete, s_info_sel == 1); +} + +static void build_saved_info(void) { + ui_chrome_header(s_screen, "CARD INFO", CARD_ICON); + + char line[40]; + snprintf(line, sizeof(line), "UID %s", s_cards[s_saved_sel].uid); + + lv_obj_t *card = build_data_card(s_screen, + s_cards[s_saved_sel].name, + s_cards[s_saved_sel].proto, + line, + s_cards[s_saved_sel].bits, + true, + true); + lv_obj_align(card, LV_ALIGN_CENTER, 0, SAVED_CARD_Y); + card_rise(card); + + lv_obj_t *actions = lv_obj_create(s_screen); + lv_obj_remove_flag(actions, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(actions, 210, LV_SIZE_CONTENT); + lv_obj_align(actions, LV_ALIGN_BOTTOM_MID, 0, SAVED_ACT_Y); + lv_obj_set_style_bg_opa(actions, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(actions, 0, 0); + lv_obj_set_style_pad_all(actions, 0, 0); + lv_obj_set_flex_flow(actions, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(actions, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_set_style_pad_row(actions, 8, 0); + + lv_color_t danger = lv_color_hex(COL_DANGER); + s_info_emulate = button_ui_create(actions, 200, 34, "Emulate", EMULATE_ICON, NULL); + s_info_delete = button_ui_create(actions, 200, 34, "Delete", NULL, &danger); + s_info_sel = 0; + info_update_selection(); + + s_hint = ui_chrome_footer(s_screen, HINT_MENU); +} + +static void seed_emulate_default(void) { + snprintf(s_emu_title, sizeof(s_emu_title), "%s", CARD_TITLE); + snprintf(s_emu_sub, sizeof(s_emu_sub), "%s", CARD_SUBTITLE); + snprintf(s_emu_line, sizeof(s_emu_line), "%s", CARD_LINE); + snprintf(s_emu_meta, sizeof(s_emu_meta), "%s", CARD_META); + snprintf(s_emu_freq, sizeof(s_emu_freq), "EM4100 · 125 kHz LF"); +} + +static void seed_emulate_from_card(const rfid_card_t *c) { + snprintf(s_emu_title, sizeof(s_emu_title), "%s", c->name); + snprintf(s_emu_sub, sizeof(s_emu_sub), "%s", c->proto); + snprintf(s_emu_line, sizeof(s_emu_line), "UID %s", c->uid); + snprintf(s_emu_meta, sizeof(s_emu_meta), "%s", c->bits); + snprintf(s_emu_freq, sizeof(s_emu_freq), "%s · 125 kHz LF", c->proto); +} + +static void emu_tick(void) { + if (s_status_lbl == NULL) + return; + status_dots(s_status_lbl, "Emulating", lv_tick_get() - s_scan_start); +} + +static void build_emulate(void) { + ui_chrome_header(s_screen, "EMULATE", EMULATE_ICON); + + s_scan_start = lv_tick_get(); + + s_status_lbl = lv_label_create(s_screen); + lv_label_set_text(s_status_lbl, "Emulating"); + lv_obj_set_style_text_color(s_status_lbl, lv_color_hex(SIG_GREEN), 0); + lv_obj_set_style_text_font(s_status_lbl, &lv_font_montserrat_14, 0); + lv_obj_align(s_status_lbl, LV_ALIGN_TOP_MID, 0, STATUS_Y); + + lv_obj_t *freq = lv_label_create(s_screen); + lv_label_set_text(freq, s_emu_freq); + lv_obj_set_style_text_color(freq, current_theme.border_accent, 0); + lv_obj_set_style_text_font(freq, &lv_font_montserrat_12, 0); + lv_obj_align(freq, LV_ALIGN_TOP_MID, 0, FREQ_Y); + + s_waves = waves_create(s_screen, LV_ALIGN_CENTER, 0, -6, NULL, NULL); + + lv_obj_t *card = + build_data_card(s_screen, s_emu_title, s_emu_sub, s_emu_line, s_emu_meta, false, false); + lv_obj_align(card, LV_ALIGN_CENTER, 0, 2); + + lv_obj_t *tx = lv_label_create(s_screen); + lv_label_set_text(tx, EMU_TX_BUSY); + lv_obj_set_style_text_color(tx, current_theme.border_accent, 0); + lv_obj_set_style_text_font(tx, &lv_font_montserrat_12, 0); + lv_obj_align(tx, LV_ALIGN_BOTTOM_MID, 0, EMU_TX_LABEL_Y); + blink_loop(tx, STATUS_BLINK_LO, EMU_TX_BLINK_MS); + + int total_w = TX_DOT_COUNT * TX_DOT_SIZE + (TX_DOT_COUNT - 1) * TX_DOT_GAP; + int x0 = -(total_w / 2) + TX_DOT_SIZE / 2; + for (int i = 0; i < TX_DOT_COUNT; i++) { + lv_obj_t *dot = lv_obj_create(s_screen); + lv_obj_remove_flag(dot, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(dot, TX_DOT_SIZE, TX_DOT_SIZE); + lv_obj_align(dot, LV_ALIGN_BOTTOM_MID, x0 + i * (TX_DOT_SIZE + TX_DOT_GAP), TX_DOT_Y_OFS); + lv_obj_set_style_radius(dot, TX_DOT_SIZE / 2, 0); + lv_obj_set_style_border_width(dot, 0, 0); + lv_obj_set_style_bg_opa(dot, LV_OPA_COVER, 0); + lv_obj_set_style_bg_color(dot, current_theme.border_accent, 0); + + lv_anim_t a; + lv_anim_init(&a); + lv_anim_set_var(&a, dot); + lv_anim_set_exec_cb(&a, opa_cb); + lv_anim_set_values(&a, STATUS_BLINK_LO, LV_OPA_COVER); + lv_anim_set_duration(&a, TX_DOT_BLINK_MS); + lv_anim_set_playback_duration(&a, TX_DOT_BLINK_MS); + lv_anim_set_delay(&a, i * TX_DOT_BLINK_MS / TX_DOT_COUNT); + lv_anim_set_repeat_count(&a, LV_ANIM_REPEAT_INFINITE); + lv_anim_set_path_cb(&a, lv_anim_path_ease_in_out); + lv_anim_start(&a); + } + + ui_chrome_footer(s_screen, "BACK Stop"); + ui_feedback(UI_FB_EMULATE); +} + +static void build_clone(void) { + ui_chrome_header(s_screen, "CLONE", COPY_ICON); + + s_clone_stage = CL_READ; + s_clone_card = NULL; + s_clone_bar = NULL; + s_scan_start = lv_tick_get(); + s_hex_stamp = s_scan_start; + s_clone_start = s_scan_start; + + s_status_lbl = lv_label_create(s_screen); + lv_label_set_text(s_status_lbl, "Reading source"); + lv_obj_set_style_text_color(s_status_lbl, current_theme.text_main, 0); + lv_obj_set_style_text_font(s_status_lbl, &lv_font_montserrat_14, 0); + lv_obj_align(s_status_lbl, LV_ALIGN_TOP_MID, 0, STATUS_Y); + + s_clone_freq = lv_label_create(s_screen); + lv_label_set_text(s_clone_freq, "Present source tag"); + lv_obj_set_style_text_color(s_clone_freq, current_theme.border_accent, 0); + lv_obj_set_style_text_font(s_clone_freq, &lv_font_montserrat_12, 0); + lv_obj_align(s_clone_freq, LV_ALIGN_TOP_MID, 0, FREQ_Y); + + build_scan_field(); + + s_hint = ui_chrome_footer(s_screen, HINT_SCAN); +} + +static void clone_begin_write(void) { + if (s_clone_card != NULL) { + lv_obj_del(s_clone_card); + s_clone_card = NULL; + } + if (s_clone_freq != NULL) { + lv_obj_del(s_clone_freq); + s_clone_freq = NULL; + } + if (s_status_lbl != NULL) { + lv_label_set_text(s_status_lbl, "Writing copy"); + lv_obj_set_style_text_color(s_status_lbl, current_theme.text_main, 0); + } + + s_waves = waves_create(s_screen, LV_ALIGN_CENTER, 0, -6, NULL, WRITE_ICON); + + s_clone_bar = lv_bar_create(s_screen); + lv_obj_set_size(s_clone_bar, PROG_W, PROG_H); + lv_obj_align(s_clone_bar, LV_ALIGN_CENTER, 0, CLONE_BAR_Y_OFS); + lv_bar_set_range(s_clone_bar, 0, 100); + lv_bar_set_value(s_clone_bar, 0, LV_ANIM_OFF); + lv_obj_set_style_bg_color(s_clone_bar, current_theme.bg_secondary, LV_PART_MAIN); + lv_obj_set_style_bg_opa(s_clone_bar, LV_OPA_COVER, LV_PART_MAIN); + lv_obj_set_style_radius(s_clone_bar, PROG_H / 2, LV_PART_MAIN); + lv_obj_set_style_bg_color(s_clone_bar, current_theme.border_accent, LV_PART_INDICATOR); + lv_obj_set_style_bg_opa(s_clone_bar, LV_OPA_COVER, LV_PART_INDICATOR); + lv_obj_set_style_radius(s_clone_bar, PROG_H / 2, LV_PART_INDICATOR); + + lv_anim_t pa; + lv_anim_init(&pa); + lv_anim_set_var(&pa, s_clone_bar); + lv_anim_set_exec_cb(&pa, bar_value_cb); + lv_anim_set_values(&pa, 0, 100); + lv_anim_set_duration(&pa, CLONE_WRITE_MS); + lv_anim_set_path_cb(&pa, lv_anim_path_linear); + lv_anim_start(&pa); +} + +static void clone_tick(void) { + uint32_t now = lv_tick_get(); + uint32_t el = now - s_clone_start; + + if (s_clone_stage == CL_READ) { + if (s_status_lbl != NULL) + status_dots(s_status_lbl, "Reading source", now - s_scan_start); + if (s_hex_lbl != NULL && (now - s_hex_stamp) >= HEX_UPDATE_MS) { + s_hex_stamp = now; + char hex[HEX_BUF_LEN]; + scramble_hex(hex, sizeof(hex), now); + lv_label_set_text(s_hex_lbl, hex); + } + if (el >= SCAN_MS) { + if (s_scan_group != NULL) { + lv_obj_del(s_scan_group); + s_scan_group = NULL; + s_hex_lbl = NULL; + } + if (s_clone_freq != NULL) { + lv_obj_del(s_clone_freq); + s_clone_freq = NULL; + } + if (s_status_lbl != NULL) { + lv_label_set_text(s_status_lbl, "Source captured!"); + lv_obj_set_style_text_color(s_status_lbl, lv_color_hex(SIG_GREEN), 0); + } + s_clone_card = + build_data_card(s_screen, CARD_TITLE, CARD_SUBTITLE, CARD_LINE, CARD_META, true, false); + lv_obj_align(s_clone_card, LV_ALIGN_CENTER, 0, CARD_Y_OFS); + card_rise(s_clone_card); + ui_feedback(UI_FB_READ); + ESP_LOGI(TAG, "mock rfid clone source: %s %s", CARD_TITLE, CARD_LINE); + s_clone_stage = CL_REVEAL; + s_clone_start = now; + } + } else if (s_clone_stage == CL_REVEAL) { + if (el >= CLONE_REVEAL_MS) { + clone_begin_write(); + s_clone_stage = CL_WRITE; + s_clone_start = now; + } + } else if (s_clone_stage == CL_WRITE) { + if (s_status_lbl != NULL) + status_dots(s_status_lbl, "Writing copy", el); + if (el >= CLONE_WRITE_MS) { + if (s_status_lbl != NULL) { + lv_label_set_text(s_status_lbl, "Cloned!"); + lv_obj_set_style_text_color(s_status_lbl, lv_color_hex(SIG_GREEN), 0); + } + ui_feedback(UI_FB_WRITE); + ESP_LOGI(TAG, "mock rfid clone written: %s", CARD_TITLE); + notify(NOTIFY_SAVED, "Tag cloned"); + msgbox_open_info(CARD_ICON, + "Clone complete", + "Source tag written to a blank card", + current_theme.border_accent); + s_clone_stage = CL_DONE; + s_pending_view = VIEW_LIST; + } + } +} + +static void format_uid(const char *text, char *out, size_t n) { + static const char H[] = "0123456789ABCDEF"; + size_t p = 0; + int nib = 0; + for (const char *c = text; c != NULL && *c != '\0'; c++) { + int v = -1; + if (*c >= '0' && *c <= '9') + v = *c - '0'; + else if (*c >= 'a' && *c <= 'f') + v = 10 + (*c - 'a'); + else if (*c >= 'A' && *c <= 'F') + v = 10 + (*c - 'A'); + if (v < 0) + continue; + if (nib > 0 && (nib % 2) == 0) { + if (p + 1 >= n) + break; + out[p++] = ' '; + } + if (p + 1 >= n || nib >= HEX_BYTES * 2) + break; + out[p++] = H[v]; + nib++; + } + out[p] = '\0'; +} + +static void add_uid_submit(const char *text, void *ud) { + (void)ud; + format_uid(text, s_add_uid, sizeof(s_add_uid)); + menu_component_set_selector_value(&s_menu, 1, s_add_uid[0] ? s_add_uid : "—"); +} + +static void build_add_manual(void) { + s_menu = menu_component_create(s_screen, "ADD MANUALLY", WRITE_ICON); + menu_component_add_selector(&s_menu, CARD_ICON, "Protocol", ADD_PROTOS[s_add_proto]); + menu_component_add_selector(&s_menu, COPY_ICON, "UID", s_add_uid[0] ? s_add_uid : "—"); + menu_component_add_selector(&s_menu, FILE_ICON, "Bits", ADD_BITS[s_add_bits]); + menu_component_add_item(&s_menu, SAVED_ICON, "Save card"); + menu_component_set_hint(&s_menu, HINT_ADD); + menu_component_select(&s_menu, 0); + + fade_in(s_menu.items_cont, FADE_IN_MS, 0); + fade_in(s_menu.title_bar, FADE_IN_MS, 0); +} + +static void add_manual_commit(void) { + if (!s_add_uid[0]) { + notify(NOTIFY_WARNING, "Enter a UID first"); + return; + } + if (s_card_count >= RFID_MAX_CARDS) { + notify(NOTIFY_WARNING, "Library full"); + return; + } + rfid_card_t *c = &s_cards[s_card_count]; + snprintf(c->name, sizeof(c->name), "Manual_%02d", s_card_count + 1); + snprintf(c->proto, sizeof(c->proto), "%s", ADD_PROTOS[s_add_proto]); + snprintf(c->uid, sizeof(c->uid), "%s", s_add_uid); + snprintf(c->bits, sizeof(c->bits), "%s · Read-only", ADD_BITS[s_add_bits]); + s_saved_sel = s_card_count; + s_card_count++; + + ui_feedback(UI_FB_WRITE); + ESP_LOGI(TAG, "mock rfid manual add: %s %s", c->name, c->uid); + notify(NOTIFY_SAVED, "Card added"); + msgbox_open_info(CARD_ICON, "Saved", "Card added to library", current_theme.border_accent); + s_pending_view = VIEW_SAVED; +} + +static void on_saved_delete_confirm(bool confirm) { + if (!confirm) + return; + for (int i = s_saved_sel; i < s_card_count - 1; i++) + s_cards[i] = s_cards[i + 1]; + if (s_card_count > 0) + s_card_count--; + if (s_saved_sel >= s_card_count) + s_saved_sel = s_card_count > 0 ? s_card_count - 1 : 0; + + ui_feedback(UI_FB_WRITE); + notify(NOTIFY_SAVED, "Card deleted"); + + s_pending_view = VIEW_SAVED; +} + +static void build_screen(void) { + stop_scan_timers(); + if (s_screen != NULL) { + lv_obj_del(s_screen); + s_screen = NULL; + } + s_status_lbl = NULL; + s_scan_group = NULL; + s_hex_lbl = NULL; + s_waves = NULL; + s_hint = NULL; + s_clone_card = NULL; + s_clone_freq = NULL; + s_clone_bar = NULL; + s_saved_cont = NULL; + s_has_dots = false; + s_cr = (capture_result_t){0}; + + s_screen = lv_obj_create(NULL); + lv_obj_set_style_bg_color(s_screen, current_theme.screen_base, 0); + lv_obj_set_style_bg_opa(s_screen, LV_OPA_COVER, 0); + lv_obj_remove_flag(s_screen, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_border_width(s_screen, 0, 0); + lv_obj_set_style_pad_all(s_screen, 0, 0); + + switch (s_view) { + case VIEW_READ: + build_read(); + break; + case VIEW_OPTIONS: { + ui_chrome_header(s_screen, "READ", RADAR_ICON); + capture_result_cfg_t cfg = { + .accent = current_theme.border_accent, + .card_icon = CARD_ICON, + .card_title = "Tag captured", + .card_sub = CARD_TITLE, + .card_value = CARD_LINE, + .primary_label = "Emulate", + .again_label = "Read again", + }; + s_cr = capture_result_create(s_screen, &cfg); + s_hint = ui_chrome_footer(s_screen, HINT_MENU); + break; + } + case VIEW_SAVED: + build_saved_list(); + break; + case VIEW_SAVED_INFO: + build_saved_info(); + break; + case VIEW_EMULATE: + build_emulate(); + break; + case VIEW_CLONE: + build_clone(); + break; + case VIEW_ADD: + build_add_manual(); + break; + case VIEW_LIST: + default: + s_menu = menu_component_create(s_screen, "RFID", LIST_TITLE_ICON); + for (int i = 0; i < RFID_ITEM_COUNT; i++) + menu_component_add_item(&s_menu, RFID_ITEMS[i].icon, RFID_ITEMS[i].name); + fade_in(s_menu.items_cont, FADE_IN_MS, 0); + fade_in(s_menu.title_bar, FADE_IN_MS, 0); + break; + } + + ui_input_set_screen_handler(rfid_menu_input, NULL); + if (s_tick_timer == NULL) + s_tick_timer = lv_timer_create(rfid_tick_cb, TICK_MS, NULL); + + ui_screen_load_owned(&s_screen, s_screen); +} + +static void read_tick(void) { + uint32_t now = lv_tick_get(); + if (!s_card_revealed) { + if (s_status_lbl != NULL) + status_dots(s_status_lbl, READ_STATUS_BUSY, now - s_scan_start); + if (s_hex_lbl != NULL && (now - s_hex_stamp) >= HEX_UPDATE_MS) { + s_hex_stamp = now; + char hex[HEX_BUF_LEN]; + scramble_hex(hex, sizeof(hex), now); + lv_label_set_text(s_hex_lbl, hex); + } + } +} + +static void rfid_tick_cb(lv_timer_t *t) { + if (lv_screen_active() != s_screen) { + lv_timer_delete(t); + s_tick_timer = NULL; + return; + } + + if (s_pending_view >= 0) { + if (!msgbox_is_open()) { + rfid_view_t v = (rfid_view_t)s_pending_view; + s_pending_view = -1; + s_view = v; + build_screen(); + } + return; + } + + if (msgbox_is_open() || keyboard_is_open() || ui_input_is_locked()) + return; + + if (s_view == VIEW_READ) { + read_tick(); + if (s_card_revealed && lv_tick_get() - s_revealed_at >= REVEAL_MS) { + s_view = VIEW_OPTIONS; + build_screen(); + } + } else if (s_view == VIEW_EMULATE) { + emu_tick(); + } else if (s_view == VIEW_CLONE) { + clone_tick(); + } +} + +static void rfid_menu_input(const input_event_t *ev, void *ctx) { + (void)ctx; + const bool press = (ev->action == INPUT_ACTION_PRESS); + const bool nav = press || (ev->action == INPUT_ACTION_REPEAT); + + if (s_pending_view >= 0) + return; + + switch (s_view) { + case VIEW_LIST: + switch (ev->button) { + case INPUT_BTN_DOWN: + if (nav) + menu_component_next(&s_menu); + break; + case INPUT_BTN_UP: + if (nav) + menu_component_prev(&s_menu); + break; + case INPUT_BTN_OK: + if (press) { + int sel = menu_component_get_selected(&s_menu); + if (sel == IDX_READ) { + s_view = VIEW_READ; + build_screen(); + } else if (sel == IDX_SAVED) { + s_saved_sel = 0; + s_view = VIEW_SAVED; + build_screen(); + } else if (sel == IDX_EMULATE) { + seed_emulate_default(); + s_view = VIEW_EMULATE; + build_screen(); + } else if (sel == IDX_CLONE) { + s_view = VIEW_CLONE; + build_screen(); + } else if (sel == IDX_ADD) { + s_add_proto = 0; + s_add_bits = 1; + s_add_uid[0] = '\0'; + s_view = VIEW_ADD; + build_screen(); + } + } + break; + case INPUT_BTN_BACK: + if (press) + ui_switch_screen(SCREEN_MENU); + break; + default: + break; + } + break; + + case VIEW_READ: + if (ev->button == INPUT_BTN_BACK && press) { + s_view = VIEW_LIST; + build_screen(); + } + break; + + case VIEW_OPTIONS: + switch (ev->button) { + case INPUT_BTN_DOWN: + if (nav) { + capture_result_next(&s_cr); + ui_feedback(UI_FB_NAV); + } + break; + case INPUT_BTN_UP: + if (nav) { + capture_result_prev(&s_cr); + ui_feedback(UI_FB_NAV); + } + break; + case INPUT_BTN_OK: + if (press) { + switch (capture_result_selected(&s_cr)) { + case CAP_ACT_PRIMARY: + seed_emulate_default(); + s_view = VIEW_EMULATE; + build_screen(); + break; + case CAP_ACT_SAVE: + if (!s_saved) { + s_saved = true; + capture_result_mark_saved(&s_cr); + ESP_LOGI(TAG, "mock rfid saved: %s", CARD_TITLE); + ui_feedback(UI_FB_WRITE); + notify(NOTIFY_SAVED, "RFID tag saved"); + } + break; + case CAP_ACT_AGAIN: + s_view = VIEW_READ; + build_screen(); + break; + case CAP_ACT_DISCARD: + s_view = VIEW_LIST; + build_screen(); + break; + default: + break; + } + } + break; + case INPUT_BTN_BACK: + if (press) { + s_view = VIEW_LIST; + build_screen(); + } + break; + default: + break; + } + break; + + case VIEW_SAVED: + switch (ev->button) { + case INPUT_BTN_DOWN: + case INPUT_BTN_RIGHT: + if (nav && s_saved_sel < s_card_count - 1) { + s_saved_sel++; + saved_apply_selection(); + ui_feedback(UI_FB_NAV); + } + break; + case INPUT_BTN_UP: + case INPUT_BTN_LEFT: + if (nav && s_saved_sel > 0) { + s_saved_sel--; + saved_apply_selection(); + ui_feedback(UI_FB_NAV); + } + break; + case INPUT_BTN_OK: + if (press && s_card_count > 0) { + s_view = VIEW_SAVED_INFO; + build_screen(); + } + break; + case INPUT_BTN_BACK: + if (press) { + s_view = VIEW_LIST; + build_screen(); + } + break; + default: + break; + } + break; + + case VIEW_SAVED_INFO: + switch (ev->button) { + case INPUT_BTN_DOWN: + if (nav && s_info_sel < 1) { + s_info_sel++; + info_update_selection(); + ui_feedback(UI_FB_NAV); + } + break; + case INPUT_BTN_UP: + if (nav && s_info_sel > 0) { + s_info_sel--; + info_update_selection(); + ui_feedback(UI_FB_NAV); + } + break; + case INPUT_BTN_OK: + if (press) { + ui_feedback(UI_FB_SELECT); + if (s_info_sel == 0) { + seed_emulate_from_card(&s_cards[s_saved_sel]); + s_view = VIEW_EMULATE; + build_screen(); + } else { + char msg[40]; + snprintf(msg, sizeof(msg), "Delete %s?", s_cards[s_saved_sel].name); + msgbox_open(LV_SYMBOL_TRASH, msg, "Delete", "Cancel", on_saved_delete_confirm); + } + } + break; + case INPUT_BTN_BACK: + if (press) { + s_view = VIEW_SAVED; + build_screen(); + } + break; + default: + break; + } + break; + + case VIEW_EMULATE: + if (ev->button == INPUT_BTN_BACK && press) { + s_view = VIEW_LIST; + build_screen(); + } + break; + + case VIEW_CLONE: + if (ev->button == INPUT_BTN_BACK && press) { + s_view = VIEW_LIST; + build_screen(); + } + break; + + case VIEW_ADD: { + int sel = menu_component_get_selected(&s_menu); + switch (ev->button) { + case INPUT_BTN_DOWN: + if (nav) { + menu_component_next(&s_menu); + ui_feedback(UI_FB_NAV); + } + break; + case INPUT_BTN_UP: + if (nav) { + menu_component_prev(&s_menu); + ui_feedback(UI_FB_NAV); + } + break; + case INPUT_BTN_RIGHT: + if (nav) { + if (sel == 0) { + s_add_proto = (s_add_proto + 1) % ADD_PROTO_COUNT; + menu_component_set_selector_value(&s_menu, 0, ADD_PROTOS[s_add_proto]); + ui_feedback(UI_FB_NAV); + } else if (sel == 2) { + s_add_bits = (s_add_bits + 1) % ADD_BITS_COUNT; + menu_component_set_selector_value(&s_menu, 2, ADD_BITS[s_add_bits]); + ui_feedback(UI_FB_NAV); + } + } + break; + case INPUT_BTN_LEFT: + if (nav) { + if (sel == 0) { + s_add_proto = (s_add_proto + ADD_PROTO_COUNT - 1) % ADD_PROTO_COUNT; + menu_component_set_selector_value(&s_menu, 0, ADD_PROTOS[s_add_proto]); + ui_feedback(UI_FB_NAV); + } else if (sel == 2) { + s_add_bits = (s_add_bits + ADD_BITS_COUNT - 1) % ADD_BITS_COUNT; + menu_component_set_selector_value(&s_menu, 2, ADD_BITS[s_add_bits]); + ui_feedback(UI_FB_NAV); + } + } + break; + case INPUT_BTN_OK: + if (press) { + if (sel == 1) { + ui_feedback(UI_FB_SELECT); + keyboard_open(NULL, add_uid_submit, NULL); + } else if (sel == 3) { + ui_feedback(UI_FB_SELECT); + add_manual_commit(); + } + } + break; + case INPUT_BTN_BACK: + if (press) { + s_view = VIEW_LIST; + build_screen(); + } + break; + default: + break; + } + break; + } + + default: + break; + } +} + +void ui_rfid_menu_open(void) { + store_init(); + stop_scan_timers(); + s_view = VIEW_LIST; + s_saved_sel = 0; + s_pending_view = -1; + s_card_revealed = false; + s_saved = false; + build_screen(); +} diff --git a/firmware_p4/components/Service/lvgl_port/include/lv_port_disp.h b/firmware_p4/components/Applications/ui/screens/safe_mode/include/safe_mode_ui.h similarity index 64% rename from firmware_p4/components/Service/lvgl_port/include/lv_port_disp.h rename to firmware_p4/components/Applications/ui/screens/safe_mode/include/safe_mode_ui.h index 2a1424a52..c844198cf 100644 --- a/firmware_p4/components/Service/lvgl_port/include/lv_port_disp.h +++ b/firmware_p4/components/Applications/ui/screens/safe_mode/include/safe_mode_ui.h @@ -13,27 +13,25 @@ // You should have received a copy of the GNU General Public License // along with TentacleOS. If not, see . -#ifndef LV_PORT_DISP_H -#define LV_PORT_DISP_H +#ifndef SAFE_MODE_UI_H +#define SAFE_MODE_UI_H #ifdef __cplusplus extern "C" { #endif -#include "lvgl.h" - /** - * @brief Initialize the LVGL display driver. + * @brief Build and load the safe-mode recovery screen. * - * Creates a display instance backed by two DMA-capable draw buffers - * (partial rendering, 1/5 of screen each). Registers the flush - * callback that sends pixels to the ST7789 panel via esp_lcd, and - * optionally mirrors frames to the BLE screen server. + * Minimal recovery UI reached by holding OK + BACK at power-on. Radios, custom + * themes and SD assets are not brought up in this mode. Offers factory-reset of + * configuration, a full reset (config + user data + NVS) and a plain reboot. + * Call under the LVGL lock. */ -void lv_port_disp_init(void); +void ui_safe_mode_open(void); #ifdef __cplusplus } #endif -#endif // LV_PORT_DISP_H +#endif // SAFE_MODE_UI_H diff --git a/firmware_p4/components/Applications/ui/screens/safe_mode/safe_mode_ui.c b/firmware_p4/components/Applications/ui/screens/safe_mode/safe_mode_ui.c new file mode 100644 index 000000000..07a2489ae --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/safe_mode/safe_mode_ui.c @@ -0,0 +1,248 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#include "safe_mode_ui.h" + +#include +#include + +#include "lvgl.h" + +#include "esp_log.h" +#include "esp_system.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" + +#include "boot_map_ui.h" +#include "boot_report.h" +#include "crash_report_ui.h" +#include "menu_component_ui.h" +#include "reboot_ui.h" +#include "sys_prio.h" +#include "tos_factory_reset.h" +#include "ui_feedback.h" +#include "ui_manager.h" +#include "ui_theme.h" + +static const char *TAG = "SAFE_MODE_UI"; + +#define TITLE "SAFE MODE" +#define TITLE_ICON "/assets/icons/troubleshoot.bin" +#define MENU_HINT "UP/DOWN OK select BACK" +#define CONFIRM_HINT "OK confirm BACK cancel" + +#define ROW_RESET_CONFIG 0 +#define ROW_RESET_ALL 1 +#define ROW_VIEW_CRASH 2 +#define ROW_VIEW_BOOTMAP 3 +#define ROW_REBOOT 4 + +#define FACTORY_TASK_STACK 8192 +#define REBOOT_DELAY_MS 1200 + +typedef enum { + SM_MENU, + SM_CONFIRM_CONFIG, + SM_CONFIRM_ALL, + SM_WORKING, +} sm_state_t; + +typedef enum { + ACTION_RESET_CONFIG, + ACTION_RESET_ALL, +} sm_action_t; + +static lv_obj_t *s_screen = NULL; +static menu_component_t s_menu; +static sm_state_t s_state = SM_MENU; + +static void safe_mode_input(const input_event_t *ev, void *ctx); + +static void factory_task(void *pv) { + sm_action_t action = (sm_action_t)(intptr_t)pv; + if (action == ACTION_RESET_ALL) { + tos_factory_reset_all(); + } else { + tos_factory_reset_config(); + } + vTaskDelay(pdMS_TO_TICKS(REBOOT_DELAY_MS)); + esp_restart(); +} + +static lv_obj_t *make_screen_base(void) { + lv_obj_t *scr = lv_obj_create(NULL); + lv_obj_set_style_bg_color(scr, current_theme.screen_base, 0); + lv_obj_set_style_bg_opa(scr, LV_OPA_COVER, 0); + lv_obj_remove_flag(scr, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_border_width(scr, 0, 0); + lv_obj_set_style_pad_all(scr, 0, 0); + return scr; +} + +static void build_menu(void) { + if (s_screen != NULL) { + lv_obj_del(s_screen); + s_screen = NULL; + } + + s_state = SM_MENU; + s_screen = make_screen_base(); + + s_menu = menu_component_create(s_screen, TITLE, TITLE_ICON); + menu_component_add_item(&s_menu, "/assets/icons/settings.bin", "Reset config"); + menu_component_add_item(&s_menu, "/assets/icons/warning.bin", "Reset all"); + menu_component_add_item(&s_menu, "/assets/icons/troubleshoot.bin", "View last crash"); + menu_component_add_item(&s_menu, "/assets/icons/storage.bin", "View boot map"); + menu_component_add_item(&s_menu, "/assets/icons/restart_alt.bin", "Reboot"); + + // When we landed here from a boot loop, show the reset reason so the user sees + // why the device came up degraded. + if (boot_report_in_bootloop()) { + static char loop_hint[64]; + snprintf(loop_hint, + sizeof(loop_hint), + "Boot loop: %s", + boot_report_reason_str(boot_report_crash()->reason)); + menu_component_set_hint(&s_menu, loop_hint); + } else { + menu_component_set_hint(&s_menu, MENU_HINT); + } + + ui_input_set_screen_handler(safe_mode_input, NULL); + ui_screen_load_owned(&s_screen, s_screen); +} + +static void +build_message(const char *title, const char *body, const char *hint, lv_color_t accent) { + if (s_screen != NULL) { + lv_obj_del(s_screen); + s_screen = NULL; + } + + s_screen = make_screen_base(); + + lv_obj_t *title_lbl = lv_label_create(s_screen); + lv_label_set_text(title_lbl, title); + lv_obj_set_style_text_color(title_lbl, accent, 0); + lv_obj_align(title_lbl, LV_ALIGN_TOP_MID, 0, 40); + + lv_obj_t *body_lbl = lv_label_create(s_screen); + lv_label_set_long_mode(body_lbl, LV_LABEL_LONG_WRAP); + lv_obj_set_width(body_lbl, LV_PCT(80)); + lv_label_set_text(body_lbl, body); + lv_obj_set_style_text_color(body_lbl, current_theme.text_main, 0); + lv_obj_set_style_text_align(body_lbl, LV_TEXT_ALIGN_CENTER, 0); + lv_obj_align(body_lbl, LV_ALIGN_CENTER, 0, 0); + + if (hint != NULL) { + lv_obj_t *hint_lbl = lv_label_create(s_screen); + lv_label_set_text(hint_lbl, hint); + lv_obj_set_style_text_color(hint_lbl, current_theme.border_inactive, 0); + lv_obj_align(hint_lbl, LV_ALIGN_BOTTOM_MID, 0, -24); + } + + ui_input_set_screen_handler(safe_mode_input, NULL); + ui_screen_load_owned(&s_screen, s_screen); +} + +static void start_reset(sm_action_t action) { + s_state = SM_WORKING; + build_message("ERASING", + action == ACTION_RESET_ALL ? "Erasing all data.\nThe device will reboot." + : "Resetting configuration.\nThe device will reboot.", + NULL, + ui_theme_get_accent()); + // Run the wipe off the LVGL task so file deletion never stalls the renderer. + xTaskCreatePinnedToCore(factory_task, + "factory_reset", + FACTORY_TASK_STACK, + (void *)(intptr_t)action, + SYS_PRIO_SERVICE_HI, + NULL, + SYS_CORE_UI); +} + +static void safe_mode_input(const input_event_t *ev, void *ctx) { + (void)ctx; + const bool press = (ev->action == INPUT_ACTION_PRESS); + const bool nav = press || (ev->action == INPUT_ACTION_REPEAT); + + switch (s_state) { + case SM_MENU: + switch (ev->button) { + case INPUT_BTN_DOWN: + if (nav) + menu_component_next(&s_menu); + break; + case INPUT_BTN_UP: + if (nav) + menu_component_prev(&s_menu); + break; + case INPUT_BTN_OK: + if (press) { + int sel = menu_component_get_selected(&s_menu); + ui_feedback(UI_FB_SELECT); + if (sel == ROW_RESET_CONFIG) { + s_state = SM_CONFIRM_CONFIG; + build_message("RESET CONFIG", + "Restore all settings to factory defaults?\nUser data is kept.", + CONFIRM_HINT, + ui_theme_get_accent()); + } else if (sel == ROW_RESET_ALL) { + s_state = SM_CONFIRM_ALL; + build_message("RESET ALL", + "Erase config, all captures/loot and NVS?\nThis cannot be undone.", + CONFIRM_HINT, + lv_palette_main(LV_PALETTE_RED)); + } else if (sel == ROW_VIEW_CRASH) { + // Viewer returns to this menu via ui_safe_mode_open (rebuilds it). + ui_crash_report_open_cb(ui_safe_mode_open); + } else if (sel == ROW_VIEW_BOOTMAP) { + ui_boot_map_open_cb(ui_safe_mode_open); + } else if (sel == ROW_REBOOT) { + reboot_ui_reboot(); + } + } + break; + default: + break; + } + break; + + case SM_CONFIRM_CONFIG: + if (press && ev->button == INPUT_BTN_OK) { + start_reset(ACTION_RESET_CONFIG); + } else if (press && ev->button == INPUT_BTN_BACK) { + build_menu(); + } + break; + + case SM_CONFIRM_ALL: + if (press && ev->button == INPUT_BTN_OK) { + start_reset(ACTION_RESET_ALL); + } else if (press && ev->button == INPUT_BTN_BACK) { + build_menu(); + } + break; + + case SM_WORKING: + break; // ignore input while the wipe runs + } +} + +void ui_safe_mode_open(void) { + ESP_LOGW(TAG, "Entering safe mode UI"); + build_menu(); +} diff --git a/firmware_p4/components/Applications/ui/screens/settings/about_settings_ui.c b/firmware_p4/components/Applications/ui/screens/settings/about_settings_ui.c new file mode 100644 index 000000000..c95a26e6a --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/settings/about_settings_ui.c @@ -0,0 +1,171 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#include "about_settings_ui.h" + +#include +#include + +#include "esp_mac.h" +#include "esp_timer.h" + +#include "assets_manager.h" +#include "ota_service.h" +#include "ui_chrome.h" +#include "ui_manager.h" +#include "ui_theme.h" + +static const char *TAG = "ABOUT_SETTINGS_UI"; + +#define ENTRY_FADE_MS 240 +#define HERO_RING 86 +#define CHIP_RADIUS 10 +#define CONTENT_WIDTH 214 +#define CHIP_ROW_WIDTH 210 + +static lv_obj_t *s_screen = NULL; +static lv_font_t *s_title_font = NULL; + +static void add_chip(lv_obj_t *parent, const char *text, bool accent) { + lv_obj_t *chip = lv_label_create(parent); + lv_label_set_text(chip, text); + lv_obj_set_style_text_font(chip, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color( + chip, accent ? current_theme.border_accent : current_theme.text_main, 0); + lv_obj_set_style_bg_color(chip, current_theme.bg_secondary, 0); + lv_obj_set_style_bg_opa(chip, LV_OPA_COVER, 0); + lv_obj_set_style_radius(chip, CHIP_RADIUS, 0); + lv_obj_set_style_pad_hor(chip, 9, 0); + lv_obj_set_style_pad_ver(chip, 4, 0); + lv_obj_set_style_border_width(chip, 1, 0); + lv_obj_set_style_border_color(chip, current_theme.border_inactive, 0); +} + +static void about_settings_input(const input_event_t *ev, void *ctx) { + (void)ctx; + const bool press = (ev->action == INPUT_ACTION_PRESS); + + switch (ev->button) { + case INPUT_BTN_BACK: + if (press) + ui_switch_screen(SCREEN_SETTINGS); + break; + default: + break; + } +} + +void ui_about_settings_open(void) { + if (s_screen != NULL) { + lv_obj_del(s_screen); + s_screen = NULL; + } + (void)TAG; + + if (s_title_font == NULL) + s_title_font = lv_binfont_create("A:assets/fonts/Inter.bin"); + + s_screen = lv_obj_create(NULL); + lv_obj_set_style_bg_color(s_screen, current_theme.screen_base, 0); + lv_obj_set_style_bg_opa(s_screen, LV_OPA_COVER, 0); + lv_obj_remove_flag(s_screen, LV_OBJ_FLAG_SCROLLABLE); + + ui_chrome_header(s_screen, "ABOUT", "/assets/icons/info.bin"); + ui_chrome_footer(s_screen, "BACK: EXIT"); + + lv_obj_t *col = lv_obj_create(s_screen); + lv_obj_remove_style_all(col); + lv_obj_set_size(col, CONTENT_WIDTH, LV_SIZE_CONTENT); + lv_obj_align(col, LV_ALIGN_CENTER, 0, (UI_CHROME_HEADER_H - UI_CHROME_FOOTER_H) / 2); + lv_obj_remove_flag(col, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_flex_flow(col, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(col, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_set_style_pad_row(col, 4, 0); + + lv_obj_t *ring = lv_obj_create(col); + lv_obj_remove_style_all(ring); + lv_obj_set_size(ring, HERO_RING, HERO_RING); + lv_obj_remove_flag(ring, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_radius(ring, LV_RADIUS_CIRCLE, 0); + lv_obj_set_style_bg_color(ring, current_theme.bg_secondary, 0); + lv_obj_set_style_bg_opa(ring, LV_OPA_40, 0); + lv_obj_set_style_border_width(ring, 2, 0); + lv_obj_set_style_border_color(ring, current_theme.border_accent, 0); + + lv_image_dsc_t *octobit = assets_get("/assets/img/octobit_portrait.bin"); + if (octobit != NULL) { + lv_obj_t *img = lv_image_create(ring); + lv_image_set_src(img, octobit); + lv_image_set_inner_align(img, LV_IMAGE_ALIGN_CONTAIN); + lv_obj_set_size(img, 48, 48); + lv_obj_center(img); + } + + lv_obj_t *name = lv_label_create(col); + lv_label_set_text(name, "HighBoy V2"); + lv_obj_set_style_text_font(name, s_title_font ? s_title_font : &lv_font_montserrat_16, 0); + lv_obj_set_style_text_color(name, current_theme.text_main, 0); + lv_obj_set_style_pad_top(name, 4, 0); + + char fwbuf[32]; + snprintf(fwbuf, sizeof(fwbuf), "FW %s", ota_get_current_version()); + lv_obj_t *fw = lv_label_create(col); + lv_label_set_text(fw, fwbuf); + lv_obj_set_style_text_font(fw, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(fw, current_theme.border_accent, 0); + lv_obj_set_style_pad_bottom(fw, 4, 0); + + lv_obj_t *chips = lv_obj_create(col); + lv_obj_remove_style_all(chips); + lv_obj_set_size(chips, CHIP_ROW_WIDTH, LV_SIZE_CONTENT); + lv_obj_remove_flag(chips, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_flex_flow(chips, LV_FLEX_FLOW_ROW_WRAP); + lv_obj_set_flex_align(chips, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_set_style_pad_row(chips, 6, 0); + lv_obj_set_style_pad_column(chips, 6, 0); + + uint8_t mac[6] = {0}; + esp_read_mac(mac, ESP_MAC_WIFI_STA); + char macbuf[32]; + snprintf(macbuf, + sizeof(macbuf), + "MAC %02X:%02X:%02X:%02X:%02X:%02X", + mac[0], + mac[1], + mac[2], + mac[3], + mac[4], + mac[5]); + + uint32_t up_s = (uint32_t)(esp_timer_get_time() / 1000000); + char upbuf[32]; + snprintf(upbuf, + sizeof(upbuf), + "Uptime %u:%02u:%02u", + (unsigned)(up_s / 3600), + (unsigned)((up_s / 60) % 60), + (unsigned)(up_s % 60)); + + add_chip(chips, "ESP32-P4", true); + add_chip(chips, macbuf, false); + add_chip(chips, upbuf, false); + add_chip(chips, LV_SYMBOL_COPY " 2025 HIGH CODE", true); + + lv_obj_fade_in(col, ENTRY_FADE_MS, 0); + + ui_input_set_screen_handler(about_settings_input, NULL); + + ui_screen_load_owned(&s_screen, s_screen); +} diff --git a/firmware_p4/components/Applications/ui/screens/settings/battery_settings_ui.c b/firmware_p4/components/Applications/ui/screens/settings/battery_settings_ui.c new file mode 100644 index 000000000..0e40cdaac --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/settings/battery_settings_ui.c @@ -0,0 +1,229 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#include "battery_settings_ui.h" + +#include + +#include "battery_service.h" +#include "ui_chrome.h" +#include "ui_manager.h" +#include "ui_theme.h" + +static const char *TAG = "BATTERY_SETTINGS_UI"; + +#define ARC_SIZE 124 +#define ARC_WIDTH 13 +#define ARC_ROTATION 270 +#define ARC_TOP_Y 46 +#define GAUGE_ANIM_MS 700 +#define ENTRY_FADE_MS 240 +#define STAT_CARD_W 66 +#define STAT_CARD_H 46 +#define LOW_COLOR 0xE53935 +#define MID_COLOR 0xFFB300 +#define OK_COLOR 0x00E676 +#define TITLE_FONT "A:assets/fonts/Inter.bin" + +static lv_obj_t *s_screen = NULL; +static lv_obj_t *s_arc = NULL; +static lv_font_t *s_big_font = NULL; + +static lv_color_t level_color(int pct) { + if (pct < 25) + return lv_color_hex(LOW_COLOR); + if (pct < 60) + return lv_color_hex(MID_COLOR); + return lv_color_hex(OK_COLOR); +} + +static void arc_anim_exec_cb(void *obj, int32_t v) { + lv_arc_set_value((lv_obj_t *)obj, v); +} + +static void add_stat_card(lv_obj_t *parent, const char *value, const char *unit, lv_color_t color) { + lv_obj_t *card = lv_obj_create(parent); + lv_obj_set_size(card, STAT_CARD_W, STAT_CARD_H); + lv_obj_remove_flag(card, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_radius(card, 11, 0); + lv_obj_set_style_bg_color(card, current_theme.bg_secondary, 0); + lv_obj_set_style_bg_opa(card, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(card, 1, 0); + lv_obj_set_style_border_color(card, current_theme.border_inactive, 0); + lv_obj_set_style_pad_all(card, 2, 0); + lv_obj_set_flex_flow(card, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(card, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_set_style_pad_row(card, 1, 0); + + lv_obj_t *v = lv_label_create(card); + lv_label_set_text(v, value); + lv_obj_set_style_text_font(v, &lv_font_montserrat_14, 0); + lv_obj_set_style_text_color(v, color, 0); + + lv_obj_t *u = lv_label_create(card); + lv_label_set_text(u, unit); + lv_obj_set_style_text_font(u, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(u, current_theme.text_main, 0); + lv_obj_set_style_text_opa(u, LV_OPA_50, 0); +} + +static void battery_settings_input(const input_event_t *ev, void *ctx) { + (void)ctx; + const bool press = (ev->action == INPUT_ACTION_PRESS); + + switch (ev->button) { + case INPUT_BTN_BACK: + if (press) + ui_switch_screen(SCREEN_SETTINGS); + break; + default: + break; + } +} + +void ui_battery_settings_open(void) { + if (s_screen != NULL) { + lv_obj_del(s_screen); + s_screen = NULL; + s_arc = NULL; + } + (void)TAG; + + if (s_big_font == NULL) + s_big_font = lv_binfont_create(TITLE_FONT); + + battery_snapshot_t bs = {0}; + bool have = battery_service_get(&bs); + int soc = have ? bs.soc : 0; + bool charging = have && bs.charging; + bool on_usb = have && bs.vbus_present; + + lv_color_t accent = level_color(soc); + + s_screen = lv_obj_create(NULL); + lv_obj_set_style_bg_color(s_screen, current_theme.screen_base, 0); + lv_obj_set_style_bg_opa(s_screen, LV_OPA_COVER, 0); + lv_obj_remove_flag(s_screen, LV_OBJ_FLAG_SCROLLABLE); + + ui_chrome_header(s_screen, "BATTERY", "/assets/icons/battery_full.bin"); + ui_chrome_footer(s_screen, "BACK: Exit"); + + s_arc = lv_arc_create(s_screen); + lv_obj_set_size(s_arc, ARC_SIZE, ARC_SIZE); + lv_obj_align(s_arc, LV_ALIGN_TOP_MID, 0, ARC_TOP_Y); + lv_arc_set_rotation(s_arc, ARC_ROTATION); + lv_arc_set_bg_angles(s_arc, 0, 360); + lv_arc_set_range(s_arc, 0, 100); + lv_arc_set_value(s_arc, 0); + lv_obj_remove_flag(s_arc, LV_OBJ_FLAG_CLICKABLE); + lv_obj_remove_style(s_arc, NULL, LV_PART_KNOB); + lv_obj_set_style_arc_width(s_arc, ARC_WIDTH, LV_PART_MAIN); + lv_obj_set_style_arc_color(s_arc, current_theme.bg_secondary, LV_PART_MAIN); + lv_obj_set_style_arc_rounded(s_arc, true, LV_PART_MAIN); + lv_obj_set_style_arc_width(s_arc, ARC_WIDTH, LV_PART_INDICATOR); + lv_obj_set_style_arc_color(s_arc, accent, LV_PART_INDICATOR); + lv_obj_set_style_arc_rounded(s_arc, true, LV_PART_INDICATOR); + + lv_anim_t ga; + lv_anim_init(&ga); + lv_anim_set_var(&ga, s_arc); + lv_anim_set_exec_cb(&ga, arc_anim_exec_cb); + lv_anim_set_values(&ga, 0, soc); + lv_anim_set_duration(&ga, GAUGE_ANIM_MS); + lv_anim_set_path_cb(&ga, lv_anim_path_ease_out); + lv_anim_start(&ga); + + lv_obj_t *pct = lv_label_create(s_screen); + if (have) + lv_label_set_text_fmt(pct, "%d%%", soc); + else + lv_label_set_text(pct, "--"); + lv_obj_set_style_text_font(pct, s_big_font ? s_big_font : &lv_font_montserrat_16, 0); + lv_obj_set_style_text_color(pct, current_theme.text_main, 0); + lv_obj_align_to(pct, s_arc, LV_ALIGN_CENTER, 0, -10); + + lv_obj_t *chg = lv_label_create(s_screen); + uint32_t chip_color; + if (charging) { + lv_label_set_text(chg, LV_SYMBOL_CHARGE " CHARGING"); + chip_color = OK_COLOR; + } else if (on_usb) { + lv_label_set_text(chg, LV_SYMBOL_CHARGE " ON USB"); + chip_color = MID_COLOR; + } else { + lv_label_set_text(chg, LV_SYMBOL_BATTERY_FULL " ON BATTERY"); + chip_color = (soc < 25) ? LOW_COLOR : 0x8A8594; + } + lv_obj_set_style_text_font(chg, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(chg, lv_color_hex(chip_color), 0); + lv_obj_set_style_bg_color(chg, lv_color_hex(chip_color), 0); + lv_obj_set_style_bg_opa(chg, LV_OPA_20, 0); + lv_obj_set_style_radius(chg, LV_RADIUS_CIRCLE, 0); + lv_obj_set_style_pad_hor(chg, 9, 0); + lv_obj_set_style_pad_ver(chg, 2, 0); + lv_obj_align_to(chg, s_arc, LV_ALIGN_CENTER, 0, 14); + + lv_obj_t *stats = lv_obj_create(s_screen); + lv_obj_remove_style_all(stats); + lv_obj_set_size(stats, 216, LV_SIZE_CONTENT); + lv_obj_align(stats, LV_ALIGN_TOP_MID, 0, ARC_TOP_Y + ARC_SIZE + 16); + lv_obj_remove_flag(stats, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_flex_flow(stats, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(stats, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_set_style_pad_column(stats, 8, 0); + + char vbat_s[12]; + snprintf(vbat_s, sizeof(vbat_s), "%u.%02u", bs.vbat_mv / 1000, (bs.vbat_mv % 1000) / 10); + add_stat_card(stats, have ? vbat_s : "--", "VBAT", current_theme.text_main); + add_stat_card(stats, + on_usb ? "USB" : "BATT", + "SOURCE", + on_usb ? lv_color_hex(OK_COLOR) : current_theme.text_main); + + const char *st = "IDLE"; + lv_color_t st_col = current_theme.border_accent; + if (charging) { + st = (bs.chg == CHARGE_STATUS_PRECHARGE) ? "PRE" : "FAST"; + st_col = lv_color_hex(OK_COLOR); + } else if (bs.chg == CHARGE_STATUS_CHARGE_DONE) { + st = "FULL"; + st_col = lv_color_hex(OK_COLOR); + } + add_stat_card(stats, st, "STATUS", st_col); + + lv_obj_t *eta = lv_label_create(s_screen); + if (charging) + lv_label_set_text(eta, LV_SYMBOL_CHARGE " Charging"); + else if (on_usb) + lv_label_set_text(eta, LV_SYMBOL_CHARGE " Fully charged / on USB"); + else + lv_label_set_text_fmt(eta, LV_SYMBOL_BATTERY_FULL " %d%% remaining", soc); + lv_obj_set_style_text_font(eta, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(eta, current_theme.border_accent, 0); + lv_obj_set_style_bg_color(eta, current_theme.bg_secondary, 0); + lv_obj_set_style_bg_opa(eta, LV_OPA_COVER, 0); + lv_obj_set_style_radius(eta, LV_RADIUS_CIRCLE, 0); + lv_obj_set_style_border_width(eta, 1, 0); + lv_obj_set_style_border_color(eta, current_theme.border_inactive, 0); + lv_obj_set_style_pad_hor(eta, 12, 0); + lv_obj_set_style_pad_ver(eta, 4, 0); + lv_obj_align_to(eta, stats, LV_ALIGN_OUT_BOTTOM_MID, 0, 12); + + lv_obj_fade_in(s_screen, ENTRY_FADE_MS, 0); + + ui_input_set_screen_handler(battery_settings_input, NULL); + + ui_screen_load_owned(&s_screen, s_screen); +} diff --git a/firmware_p4/components/Applications/ui/screens/settings/c5_status_ui.c b/firmware_p4/components/Applications/ui/screens/settings/c5_status_ui.c new file mode 100644 index 000000000..a958eff71 --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/settings/c5_status_ui.c @@ -0,0 +1,241 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#include "c5_status_ui.h" + +#include +#include + +#include "st7789.h" + +#include "notify_ui.h" +#include "ota_version.h" +#include "spi_bridge.h" +#include "spi_protocol.h" +#include "ui_chrome.h" +#include "ui_feedback.h" +#include "ui_manager.h" +#include "ui_theme.h" + +#define MX 8 +#define CONTENT_W (LCD_H_RES - 2 * MX) +#define INFO_Y 50 +#define INFO_H 98 +#define INFO_ROW_GAP 26 +#define ACT_Y 158 +#define ROW_H 36 +#define ROW_GAP 6 +#define ROW_STEP (ROW_H + ROW_GAP) + +#define COL_SUCCESS 0x00E676 +#define COL_DIM 0x8A8594 +#define COL_RAISE 0x170A28 + +#define HDR_ICON "/assets/icons/developer_board.bin" +#define HDR_TITLE "C5 STATUS" + +#define C5_VER_TIMEOUT_MS 500 +#define C5_VER_BUF_LEN 24 + +enum { + ACT_DOWNLOAD = 0, + ACT_PING, + ACT_INFO, + ACT_COUNT, +}; + +typedef struct { + const char *sym; + const char *name; + notify_type_t toast_type; + const char *toast_text; +} action_def_t; + +static const action_def_t ACTIONS[ACT_COUNT] = { + {LV_SYMBOL_DOWNLOAD, "Enter Download", NOTIFY_INFO, "C5 entering download mode"}, + {LV_SYMBOL_REFRESH, "Legacy Ping", NOTIFY_INFO, "C5 ping: pong in 12 ms"}, + {LV_SYMBOL_LIST, "Get Info", NOTIFY_SAVED, "C5 v0.9.1 heap 41 KB free"}, +}; + +static lv_obj_t *s_screen = NULL; +static lv_obj_t *s_act_row[ACT_COUNT]; +static lv_obj_t *s_act_icon[ACT_COUNT]; +static lv_obj_t *s_act_name[ACT_COUNT]; + +static int s_sel = 0; + +static lv_obj_t *info_row(lv_obj_t *card, int index, const char *tag_txt, const char *val_txt) { + lv_obj_t *tag = lv_label_create(card); + lv_label_set_text(tag, tag_txt); + lv_obj_set_style_text_font(tag, &lv_font_montserrat_14, 0); + lv_obj_set_style_text_color(tag, current_theme.text_main, 0); + lv_obj_align(tag, LV_ALIGN_TOP_LEFT, 0, index * INFO_ROW_GAP); + + lv_obj_t *val = lv_label_create(card); + lv_label_set_text(val, val_txt); + lv_obj_set_style_text_font(val, &lv_font_montserrat_14, 0); + lv_obj_set_style_text_color(val, lv_color_hex(COL_DIM), 0); + lv_obj_align(val, LV_ALIGN_TOP_RIGHT, 0, index * INFO_ROW_GAP); + return val; +} + +static void build_info_card(void) { + lv_obj_t *card = lv_obj_create(s_screen); + lv_obj_remove_flag(card, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_remove_flag(card, LV_OBJ_FLAG_CLICKABLE); + lv_obj_set_size(card, CONTENT_W, INFO_H); + lv_obj_align(card, LV_ALIGN_TOP_MID, 0, INFO_Y); + lv_obj_set_style_radius(card, 12, 0); + lv_obj_set_style_bg_color(card, current_theme.bg_secondary, 0); + lv_obj_set_style_bg_opa(card, LV_OPA_COVER, 0); + lv_obj_set_style_border_color(card, current_theme.border_accent, 0); + lv_obj_set_style_border_width(card, 1, 0); + lv_obj_set_style_pad_hor(card, 14, 0); + lv_obj_set_style_pad_ver(card, 8, 0); + lv_obj_set_style_shadow_width(card, 16, 0); + lv_obj_set_style_shadow_color(card, current_theme.border_accent, 0); + lv_obj_set_style_shadow_opa(card, LV_OPA_30, 0); + + bool alive = spi_bridge_is_alive(); + char cver[C5_VER_BUF_LEN] = "?"; + if (alive) { + spi_header_t hdr; + char ver[C5_VER_BUF_LEN] = {0}; + if (spi_bridge_send_command( + SPI_ID_SYSTEM_VERSION, NULL, 0, &hdr, (uint8_t *)ver, sizeof(ver), C5_VER_TIMEOUT_MS) == + ESP_OK && + ver[0] != '\0') + strlcpy(cver, ver, sizeof(cver)); + } + + lv_obj_t *link_val = info_row(card, 0, "Link", alive ? "ALIVE" : "DOWN"); + if (alive) + lv_obj_set_style_text_color(link_val, lv_color_hex(COL_SUCCESS), 0); + info_row(card, 1, "C5 FW", cver); + info_row(card, 2, "Expected", FIRMWARE_VERSION); +} + +static lv_obj_t *make_action_row(lv_obj_t *parent, int i) { + lv_obj_t *row = lv_obj_create(parent); + lv_obj_remove_flag(row, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_remove_flag(row, LV_OBJ_FLAG_CLICKABLE); + lv_obj_set_size(row, CONTENT_W, ROW_H); + lv_obj_align(row, LV_ALIGN_TOP_MID, 0, ACT_Y + i * ROW_STEP); + lv_obj_set_style_radius(row, 9, 0); + lv_obj_set_style_bg_color(row, current_theme.bg_secondary, 0); + lv_obj_set_style_bg_opa(row, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(row, 2, 0); + lv_obj_set_style_pad_left(row, 12, 0); + lv_obj_set_style_pad_right(row, 12, 0); + lv_obj_set_style_pad_top(row, 0, 0); + lv_obj_set_style_pad_bottom(row, 0, 0); + 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_t *ic = lv_label_create(row); + lv_label_set_text(ic, ACTIONS[i].sym); + lv_obj_set_style_text_font(ic, &lv_font_montserrat_16, 0); + lv_obj_set_width(ic, 22); + lv_obj_set_style_text_align(ic, LV_TEXT_ALIGN_CENTER, 0); + + lv_obj_t *name = lv_label_create(row); + lv_label_set_text(name, ACTIONS[i].name); + lv_obj_set_style_text_font(name, &lv_font_montserrat_14, 0); + lv_obj_set_style_pad_left(name, 10, 0); + lv_obj_set_flex_grow(name, 1); + + s_act_icon[i] = ic; + s_act_name[i] = name; + return row; +} + +static void refresh_selection(void) { + const lv_color_t accent = current_theme.border_accent; + const lv_color_t dim = lv_color_hex(COL_DIM); + for (int i = 0; i < ACT_COUNT; i++) { + bool sel = (i == s_sel); + lv_obj_set_style_border_color(s_act_row[i], sel ? accent : current_theme.border_inactive, 0); + lv_obj_set_style_border_opa(s_act_row[i], sel ? LV_OPA_COVER : LV_OPA_TRANSP, 0); + lv_obj_set_style_bg_color( + s_act_row[i], sel ? lv_color_hex(COL_RAISE) : current_theme.bg_secondary, 0); + lv_obj_set_style_shadow_width(s_act_row[i], sel ? 14 : 0, 0); + lv_obj_set_style_shadow_color(s_act_row[i], accent, 0); + lv_obj_set_style_shadow_spread(s_act_row[i], sel ? -3 : 0, 0); + lv_obj_set_style_text_color(s_act_icon[i], sel ? accent : dim, 0); + lv_obj_set_style_text_color(s_act_name[i], sel ? current_theme.text_main : dim, 0); + } +} + +static void c5_status_input(const input_event_t *ev, void *ctx) { + (void)ctx; + const bool press = (ev->action == INPUT_ACTION_PRESS); + const bool nav = press || (ev->action == INPUT_ACTION_REPEAT); + + switch (ev->button) { + case INPUT_BTN_BACK: + case INPUT_BTN_LEFT: + if (press) + ui_switch_screen(SCREEN_SETTINGS_DEV); + break; + case INPUT_BTN_DOWN: + if (nav) { + s_sel = (s_sel + 1) % ACT_COUNT; + refresh_selection(); + ui_feedback(UI_FB_NAV); + } + break; + case INPUT_BTN_UP: + if (nav) { + s_sel = (s_sel - 1 + ACT_COUNT) % ACT_COUNT; + refresh_selection(); + ui_feedback(UI_FB_NAV); + } + break; + case INPUT_BTN_OK: + if (press) { + notify(ACTIONS[s_sel].toast_type, ACTIONS[s_sel].toast_text); + ui_feedback(UI_FB_SELECT); + } + break; + default: + break; + } +} + +void ui_c5_status_open(void) { + if (s_screen != NULL) { + lv_obj_del(s_screen); + s_screen = NULL; + } + s_sel = 0; + + s_screen = lv_obj_create(NULL); + lv_obj_set_style_bg_color(s_screen, current_theme.screen_base, 0); + lv_obj_set_style_bg_opa(s_screen, LV_OPA_COVER, 0); + lv_obj_remove_flag(s_screen, LV_OBJ_FLAG_SCROLLABLE); + + ui_chrome_header(s_screen, HDR_TITLE, HDR_ICON); + + build_info_card(); + for (int i = 0; i < ACT_COUNT; i++) + s_act_row[i] = make_action_row(s_screen, i); + refresh_selection(); + + ui_chrome_footer(s_screen, "UP/DOWN select OK run BACK exit"); + + ui_input_set_screen_handler(c5_status_input, NULL); + + ui_screen_load_owned(&s_screen, s_screen); +} diff --git a/firmware_p4/components/Applications/ui/screens/settings/display_settings_ui.c b/firmware_p4/components/Applications/ui/screens/settings/display_settings_ui.c new file mode 100644 index 000000000..ccec15e81 --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/settings/display_settings_ui.c @@ -0,0 +1,193 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#include "display_settings_ui.h" + +#include "lvgl_glue.h" +#include "menu_component_ui.h" +#include "notify_ui.h" +#include "st7789.h" +#include "tos_config.h" +#include "tos_storage_paths.h" +#include "ui_manager.h" +#include "ui_theme.h" + +#define ENTRY_FADE_MS 200 + +#define ROW_BRIGHTNESS 0 +#define ROW_ROTATION 1 +#define ROW_TIMEOUT 2 +#define ROW_AUTODIM 3 +#define ROW_INVERT 4 + +// Brightness maps the 5-step intensity bar to a percentage (level * 20). +#define BRIGHTNESS_STEP_PCT 20 + +static const char *const ROTATION_OPTS[] = {"Portrait", "Landscape"}; +#define ROTATION_COUNT ((int)(sizeof(ROTATION_OPTS) / sizeof(ROTATION_OPTS[0]))) + +// Labels and their auto_lock_seconds values (0 = never sleep). +static const char *const TIMEOUT_OPTS[] = {"15s", "30s", "1m", "5m", "Off"}; +static const int TIMEOUT_SECS[] = {15, 30, 60, 300, 0}; +#define TIMEOUT_COUNT ((int)(sizeof(TIMEOUT_OPTS) / sizeof(TIMEOUT_OPTS[0]))) + +static int s_rotation_idx = 0; +static int s_timeout_idx = 1; + +// Pick the timeout option index matching a saved auto_lock_seconds value. +static int timeout_idx_for_seconds(int seconds) { + for (int i = 0; i < TIMEOUT_COUNT; i++) { + if (TIMEOUT_SECS[i] == seconds) { + return i; + } + } + return TIMEOUT_COUNT - 1; // fall back to "Off" +} + +static lv_obj_t *s_screen = NULL; +static menu_component_t s_menu; + +static bool s_changed = false; + +static void cycle_selector(int sel, int dir) { + if (sel == ROW_ROTATION) { + s_rotation_idx = (s_rotation_idx + dir + ROTATION_COUNT) % ROTATION_COUNT; + menu_component_set_selector_value(&s_menu, sel, ROTATION_OPTS[s_rotation_idx]); + bool want_landscape = (s_rotation_idx == 1); + if (want_landscape != lvgl_glue_is_landscape()) + lvgl_glue_toggle_rotation(); + s_changed = true; + } else if (sel == ROW_TIMEOUT) { + s_timeout_idx = (s_timeout_idx + dir + TIMEOUT_COUNT) % TIMEOUT_COUNT; + menu_component_set_selector_value(&s_menu, sel, TIMEOUT_OPTS[s_timeout_idx]); + s_changed = true; + } +} + +static void display_settings_input(const input_event_t *ev, void *ctx) { + (void)ctx; + const bool press = (ev->action == INPUT_ACTION_PRESS); + const bool nav = press || (ev->action == INPUT_ACTION_REPEAT); + + switch (ev->button) { + case INPUT_BTN_DOWN: + if (nav) + menu_component_next(&s_menu); + break; + case INPUT_BTN_UP: + if (nav) + menu_component_prev(&s_menu); + break; + case INPUT_BTN_OK: + if (press) { + int sel = menu_component_get_selected(&s_menu); + if (sel >= 0 && s_menu.has_toggle[sel]) { + menu_component_toggle_item(&s_menu, sel); + s_changed = true; + } + } + break; + case INPUT_BTN_LEFT: + if (nav) { + int sel = menu_component_get_selected(&s_menu); + if (sel >= 0) { + if (s_menu.has_intensity[sel]) { + menu_component_intensity_dec(&s_menu, sel); + if (sel == ROW_BRIGHTNESS) + lcd_apply_brightness(menu_component_get_intensity(&s_menu, sel) * + BRIGHTNESS_STEP_PCT); + s_changed = true; + } else if (s_menu.val_labels[sel] != NULL) { + cycle_selector(sel, -1); + } + } + } + break; + case INPUT_BTN_RIGHT: + if (nav) { + int sel = menu_component_get_selected(&s_menu); + if (sel >= 0) { + if (s_menu.has_intensity[sel]) { + menu_component_intensity_inc(&s_menu, sel); + if (sel == ROW_BRIGHTNESS) + lcd_apply_brightness(menu_component_get_intensity(&s_menu, sel) * + BRIGHTNESS_STEP_PCT); + s_changed = true; + } else if (s_menu.val_labels[sel] != NULL) { + cycle_selector(sel, +1); + } + } + } + break; + case INPUT_BTN_BACK: + if (press) { + if (s_changed) { + g_config_screen.brightness = + menu_component_get_intensity(&s_menu, ROW_BRIGHTNESS) * BRIGHTNESS_STEP_PCT; + g_config_screen.rotation = (s_rotation_idx == 1) ? 2 : 1; + g_config_screen.auto_lock_seconds = TIMEOUT_SECS[s_timeout_idx]; + g_config_screen.auto_dim = menu_component_get_toggle(&s_menu, ROW_AUTODIM); + if (ui_sd_ready()) { + tos_config_save(TOS_PATH_CONFIG_SCREEN, "screen"); + notify(NOTIFY_SAVED, "Display settings saved"); + } + } + ui_switch_screen(SCREEN_SETTINGS); + } + break; + default: + break; + } +} + +void ui_display_settings_open(void) { + if (s_screen != NULL) { + lv_obj_del(s_screen); + s_screen = NULL; + } + + s_rotation_idx = lvgl_glue_is_landscape() ? 1 : 0; + s_timeout_idx = timeout_idx_for_seconds(g_config_screen.auto_lock_seconds); + s_changed = false; + + int bright_level = lcd_get_brightness() / BRIGHTNESS_STEP_PCT; + if (bright_level < 0) + bright_level = 0; + if (bright_level > 5) + bright_level = 5; + + s_screen = lv_obj_create(NULL); + lv_obj_set_style_bg_color(s_screen, current_theme.screen_base, 0); + lv_obj_set_style_bg_opa(s_screen, LV_OPA_COVER, 0); + lv_obj_remove_flag(s_screen, LV_OBJ_FLAG_SCROLLABLE); + + s_menu = menu_component_create(s_screen, "DISPLAY", "/assets/icons/display_settings.bin"); + menu_component_add_intensity( + &s_menu, "/assets/icons/brightness_6.bin", "Brightness", bright_level); + menu_component_add_selector( + &s_menu, "/assets/icons/screen_rotation.bin", "Rotation", ROTATION_OPTS[s_rotation_idx]); + menu_component_add_selector( + &s_menu, "/assets/icons/timer.bin", "Timeout", TIMEOUT_OPTS[s_timeout_idx]); + menu_component_add_toggle( + &s_menu, "/assets/icons/brightness_auto.bin", "Auto-dim", g_config_screen.auto_dim); + menu_component_add_toggle(&s_menu, "/assets/icons/invert_colors.bin", "Invert", false); + + if (s_menu.items_cont != NULL) + lv_obj_fade_in(s_menu.items_cont, ENTRY_FADE_MS, 0); + + ui_input_set_screen_handler(display_settings_input, NULL); + + ui_screen_load_owned(&s_screen, s_screen); +} diff --git a/firmware_p4/components/Applications/ui/screens/about_settings/include/about_settings_ui.h b/firmware_p4/components/Applications/ui/screens/settings/include/about_settings_ui.h similarity index 91% rename from firmware_p4/components/Applications/ui/screens/about_settings/include/about_settings_ui.h rename to firmware_p4/components/Applications/ui/screens/settings/include/about_settings_ui.h index 805d336a0..3f66b533c 100644 --- a/firmware_p4/components/Applications/ui/screens/about_settings/include/about_settings_ui.h +++ b/firmware_p4/components/Applications/ui/screens/settings/include/about_settings_ui.h @@ -20,11 +20,11 @@ extern "C" { #endif -/** @brief Open the about settings screen. */ +/** @brief Open the about info screen (mock). */ void ui_about_settings_open(void); #ifdef __cplusplus } #endif -#endif // ABOUT_SETTINGS_UI_H \ No newline at end of file +#endif // ABOUT_SETTINGS_UI_H diff --git a/firmware_p4/components/Applications/ui/screens/battery_settings/include/battery_settings_ui.h b/firmware_p4/components/Applications/ui/screens/settings/include/battery_settings_ui.h similarity index 91% rename from firmware_p4/components/Applications/ui/screens/battery_settings/include/battery_settings_ui.h rename to firmware_p4/components/Applications/ui/screens/settings/include/battery_settings_ui.h index a221fcfb3..cece54220 100644 --- a/firmware_p4/components/Applications/ui/screens/battery_settings/include/battery_settings_ui.h +++ b/firmware_p4/components/Applications/ui/screens/settings/include/battery_settings_ui.h @@ -20,11 +20,11 @@ extern "C" { #endif -/** @brief Open the battery settings screen. */ +/** @brief Open the battery info screen (mock). */ void ui_battery_settings_open(void); #ifdef __cplusplus } #endif -#endif // BATTERY_SETTINGS_UI_H \ No newline at end of file +#endif // BATTERY_SETTINGS_UI_H diff --git a/firmware_p4/components/Applications/ui/screens/settings/include/c5_status_ui.h b/firmware_p4/components/Applications/ui/screens/settings/include/c5_status_ui.h new file mode 100644 index 000000000..dcb4505d8 --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/settings/include/c5_status_ui.h @@ -0,0 +1,30 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#ifndef C5_STATUS_UI_H +#define C5_STATUS_UI_H + +#ifdef __cplusplus +extern "C" { +#endif + +/** @brief Open the C5 co-processor status screen (mock link + action rows). */ +void ui_c5_status_open(void); + +#ifdef __cplusplus +} +#endif + +#endif // C5_STATUS_UI_H diff --git a/firmware_p4/components/Applications/ui/screens/display_settings/include/display_settings_ui.h b/firmware_p4/components/Applications/ui/screens/settings/include/display_settings_ui.h similarity index 90% rename from firmware_p4/components/Applications/ui/screens/display_settings/include/display_settings_ui.h rename to firmware_p4/components/Applications/ui/screens/settings/include/display_settings_ui.h index 3fee98702..a2f07d37d 100644 --- a/firmware_p4/components/Applications/ui/screens/display_settings/include/display_settings_ui.h +++ b/firmware_p4/components/Applications/ui/screens/settings/include/display_settings_ui.h @@ -20,11 +20,11 @@ extern "C" { #endif -/** @brief Open the display settings screen. */ +/** @brief Open the display settings screen (mock). */ void ui_display_settings_open(void); #ifdef __cplusplus } #endif -#endif // DISPLAY_SETTINGS_UI_H \ No newline at end of file +#endif // DISPLAY_SETTINGS_UI_H diff --git a/firmware_p4/components/Applications/ui/screens/interface_settings/include/interface_settings_ui.h b/firmware_p4/components/Applications/ui/screens/settings/include/interface_settings_ui.h similarity index 90% rename from firmware_p4/components/Applications/ui/screens/interface_settings/include/interface_settings_ui.h rename to firmware_p4/components/Applications/ui/screens/settings/include/interface_settings_ui.h index 0f82fb123..57a627d62 100644 --- a/firmware_p4/components/Applications/ui/screens/interface_settings/include/interface_settings_ui.h +++ b/firmware_p4/components/Applications/ui/screens/settings/include/interface_settings_ui.h @@ -20,11 +20,11 @@ extern "C" { #endif -/** @brief Open the interface settings screen. */ +/** @brief Open the interface settings screen (mock). */ void ui_interface_settings_open(void); #ifdef __cplusplus } #endif -#endif // INTERFACE_SETTINGS_UI_H \ No newline at end of file +#endif // INTERFACE_SETTINGS_UI_H diff --git a/firmware_p4/components/Applications/ui/screens/settings/include/led_ctrl_ui.h b/firmware_p4/components/Applications/ui/screens/settings/include/led_ctrl_ui.h new file mode 100644 index 000000000..5c690fab0 --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/settings/include/led_ctrl_ui.h @@ -0,0 +1,30 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#ifndef LED_CTRL_UI_H +#define LED_CTRL_UI_H + +#ifdef __cplusplus +extern "C" { +#endif + +/** @brief Open the RGB LED control screen (mock preview, presets, brightness). */ +void ui_led_ctrl_open(void); + +#ifdef __cplusplus +} +#endif + +#endif // LED_CTRL_UI_H diff --git a/firmware_p4/components/Applications/ui/screens/settings/include/sd_health_ui.h b/firmware_p4/components/Applications/ui/screens/settings/include/sd_health_ui.h new file mode 100644 index 000000000..6d85e20ae --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/settings/include/sd_health_ui.h @@ -0,0 +1,30 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#ifndef SD_HEALTH_UI_H +#define SD_HEALTH_UI_H + +#ifdef __cplusplus +extern "C" { +#endif + +/** @brief Open the SD health screen (card detail, capacity bar, R/W tiles). */ +void ui_sd_health_open(void); + +#ifdef __cplusplus +} +#endif + +#endif // SD_HEALTH_UI_H diff --git a/firmware_p4/components/Applications/ui/screens/settings/include/settings_ui.h b/firmware_p4/components/Applications/ui/screens/settings/include/settings_ui.h index 5258c9ba6..618adb150 100644 --- a/firmware_p4/components/Applications/ui/screens/settings/include/settings_ui.h +++ b/firmware_p4/components/Applications/ui/screens/settings/include/settings_ui.h @@ -23,6 +23,9 @@ extern "C" { /** @brief Open the settings screen. */ void ui_settings_open(void); +/** @brief Open the settings screen directly on the Developer sub-view. */ +void ui_settings_open_dev(void); + #ifdef __cplusplus } #endif diff --git a/firmware_p4/components/Applications/ui/screens/sound_settings/include/sound_settings_ui.h b/firmware_p4/components/Applications/ui/screens/settings/include/sound_settings_ui.h similarity index 91% rename from firmware_p4/components/Applications/ui/screens/sound_settings/include/sound_settings_ui.h rename to firmware_p4/components/Applications/ui/screens/settings/include/sound_settings_ui.h index f3afd09ec..268b96208 100644 --- a/firmware_p4/components/Applications/ui/screens/sound_settings/include/sound_settings_ui.h +++ b/firmware_p4/components/Applications/ui/screens/settings/include/sound_settings_ui.h @@ -20,11 +20,11 @@ extern "C" { #endif -/** @brief Open the sound settings screen. */ +/** @brief Open the sound settings screen (mock). */ void ui_sound_settings_open(void); #ifdef __cplusplus } #endif -#endif // SOUND_SETTINGS_UI_H \ No newline at end of file +#endif // SOUND_SETTINGS_UI_H diff --git a/firmware_p4/components/Applications/ui/screens/settings/include/storage_settings_ui.h b/firmware_p4/components/Applications/ui/screens/settings/include/storage_settings_ui.h new file mode 100644 index 000000000..f5b1e57e2 --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/settings/include/storage_settings_ui.h @@ -0,0 +1,30 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#ifndef STORAGE_SETTINGS_UI_H +#define STORAGE_SETTINGS_UI_H + +#ifdef __cplusplus +extern "C" { +#endif + +/** @brief Open the Storage settings screen (SD + internal usage, eject, format). */ +void ui_storage_settings_open(void); + +#ifdef __cplusplus +} +#endif + +#endif // STORAGE_SETTINGS_UI_H diff --git a/firmware_p4/components/Applications/ui/screens/settings/include/system_update_ui.h b/firmware_p4/components/Applications/ui/screens/settings/include/system_update_ui.h new file mode 100644 index 000000000..7ec9d35fd --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/settings/include/system_update_ui.h @@ -0,0 +1,30 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#ifndef SYSTEM_UPDATE_UI_H +#define SYSTEM_UPDATE_UI_H + +#ifdef __cplusplus +extern "C" { +#endif + +/** @brief Open the system update screen (mock SD firmware image flash). */ +void ui_system_update_open(void); + +#ifdef __cplusplus +} +#endif + +#endif // SYSTEM_UPDATE_UI_H diff --git a/firmware_p4/components/Applications/ui/screens/wifi/include/wifi_scan_ap_ui.h b/firmware_p4/components/Applications/ui/screens/settings/include/time_settings_ui.h similarity index 82% rename from firmware_p4/components/Applications/ui/screens/wifi/include/wifi_scan_ap_ui.h rename to firmware_p4/components/Applications/ui/screens/settings/include/time_settings_ui.h index f7c698964..cde1c92d9 100644 --- a/firmware_p4/components/Applications/ui/screens/wifi/include/wifi_scan_ap_ui.h +++ b/firmware_p4/components/Applications/ui/screens/settings/include/time_settings_ui.h @@ -13,18 +13,18 @@ // You should have received a copy of the GNU General Public License // along with TentacleOS. If not, see . -#ifndef WIFI_SCAN_AP_UI_H -#define WIFI_SCAN_AP_UI_H +#ifndef TIME_SETTINGS_UI_H +#define TIME_SETTINGS_UI_H #ifdef __cplusplus extern "C" { #endif -/** @brief Open the Wi-Fi AP scan screen. */ -void ui_wifi_scan_ap_open(void); +/** @brief Open the date & time editor screen. */ +void ui_time_open(void); #ifdef __cplusplus } #endif -#endif // WIFI_SCAN_AP_UI_H +#endif // TIME_SETTINGS_UI_H diff --git a/firmware_p4/components/Applications/ui/screens/settings/interface_settings_ui.c b/firmware_p4/components/Applications/ui/screens/settings/interface_settings_ui.c new file mode 100644 index 000000000..8b24a080f --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/settings/interface_settings_ui.c @@ -0,0 +1,144 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#include "interface_settings_ui.h" + +#include "esp_log.h" + +#include "menu_component_ui.h" +#include "notify_ui.h" +#include "ui_manager.h" +#include "ui_theme.h" + +static const char *TAG = "INTERFACE_SETTINGS_UI"; + +#define ENTRY_FADE_MS 200 + +#define ROW_ANIMATIONS 0 +#define ROW_HAPTICS 1 +#define ROW_SOUNDFX 2 +#define ROW_THEME 3 +#define ROW_LANGUAGE 4 +#define ROW_LED 5 + +static const char *const LANGUAGE_OPTS[] = {"EN", "PT", "ES"}; +#define LANGUAGE_COUNT ((int)(sizeof(LANGUAGE_OPTS) / sizeof(LANGUAGE_OPTS[0]))) + +static int s_language_idx = 0; + +static lv_obj_t *s_screen = NULL; +static menu_component_t s_menu; +static bool s_changed = false; + +static void cycle_selector(int sel, int dir) { + if (sel == ROW_LANGUAGE) { + s_language_idx = (s_language_idx + dir + LANGUAGE_COUNT) % LANGUAGE_COUNT; + menu_component_set_selector_value(&s_menu, sel, LANGUAGE_OPTS[s_language_idx]); + s_changed = true; + } +} + +static void interface_settings_input(const input_event_t *ev, void *ctx) { + (void)ctx; + const bool press = (ev->action == INPUT_ACTION_PRESS); + const bool nav = press || (ev->action == INPUT_ACTION_REPEAT); + + switch (ev->button) { + case INPUT_BTN_DOWN: + if (nav) + menu_component_next(&s_menu); + break; + case INPUT_BTN_UP: + if (nav) + menu_component_prev(&s_menu); + break; + case INPUT_BTN_OK: + if (press) { + int sel = menu_component_get_selected(&s_menu); + if (sel == ROW_THEME) { + ui_switch_screen(SCREEN_THEME_SELECTOR); + } else if (sel == ROW_LED) { + ui_switch_screen(SCREEN_LED_CTRL); + } else if (sel >= 0 && s_menu.has_toggle[sel]) { + menu_component_toggle_item(&s_menu, sel); + s_changed = true; + ESP_LOGI(TAG, "mock toggle row %d -> %d", sel, menu_component_get_toggle(&s_menu, sel)); + } + } + break; + case INPUT_BTN_LEFT: + if (press) { + int sel = menu_component_get_selected(&s_menu); + if (sel >= 0) { + if (s_menu.has_intensity[sel]) + menu_component_intensity_dec(&s_menu, sel); + else if (s_menu.val_labels[sel] != NULL) + cycle_selector(sel, -1); + } + } + break; + case INPUT_BTN_RIGHT: + if (press) { + int sel = menu_component_get_selected(&s_menu); + if (sel >= 0) { + if (s_menu.has_intensity[sel]) + menu_component_intensity_inc(&s_menu, sel); + else if (s_menu.val_labels[sel] != NULL) + cycle_selector(sel, +1); + } + } + break; + case INPUT_BTN_BACK: + if (press) { + if (s_changed) + notify(NOTIFY_SAVED, "Interface settings saved"); + ui_switch_screen(SCREEN_SETTINGS); + } + break; + default: + break; + } +} + +void ui_interface_settings_open(void) { + if (s_screen != NULL) { + lv_obj_del(s_screen); + s_screen = NULL; + } + + s_language_idx = 0; + s_changed = false; + + s_screen = lv_obj_create(NULL); + lv_obj_set_style_bg_color(s_screen, current_theme.screen_base, 0); + lv_obj_set_style_bg_opa(s_screen, LV_OPA_COVER, 0); + lv_obj_remove_flag(s_screen, LV_OBJ_FLAG_SCROLLABLE); + + s_menu = menu_component_create(s_screen, "INTERFACE", "/assets/icons/tune.bin"); + menu_component_add_toggle(&s_menu, "/assets/icons/animation.bin", "Animations", true); + menu_component_add_toggle(&s_menu, "/assets/icons/vibration.bin", "Haptics", true); + menu_component_add_toggle(&s_menu, "/assets/icons/volume_up.bin", "Sound FX", true); + menu_component_add_item(&s_menu, "/assets/icons/palette.bin", "Theme"); + menu_component_add_selector( + &s_menu, "/assets/icons/language.bin", "Language", LANGUAGE_OPTS[s_language_idx]); + menu_component_add_item(&s_menu, "/assets/icons/sensors.bin", "LED / Notify"); + + if (s_menu.items_cont != NULL) + lv_obj_fade_in(s_menu.items_cont, ENTRY_FADE_MS, 0); + + ui_input_set_screen_handler(interface_settings_input, NULL); + + ui_screen_load_owned(&s_screen, s_screen); +} diff --git a/firmware_p4/components/Applications/ui/screens/settings/led_ctrl_ui.c b/firmware_p4/components/Applications/ui/screens/settings/led_ctrl_ui.c new file mode 100644 index 000000000..83122465d --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/settings/led_ctrl_ui.c @@ -0,0 +1,464 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#include "led_ctrl_ui.h" + +#include "st7789.h" + +#include "led_control.h" +#include "notify_ui.h" +#include "tos_config.h" +#include "tos_storage_paths.h" +#include "ui_chrome.h" +#include "ui_feedback.h" +#include "ui_manager.h" +#include "ui_theme.h" + +#define MX 8 +#define CONTENT_W (LCD_H_RES - 2 * MX) + +#define PREVIEW_Y 50 +#define PREVIEW_D 72 +#define CTRL_Y 134 +#define CTRL_GAP 8 +#define CARD_LABEL_W 66 +#define SWATCH_CARD_H 46 +#define BRIGHT_CARD_H 42 +#define STEALTH_CARD_H 40 +#define SWATCH_D 22 +#define BAR_H 12 +#define PILL_W 52 +#define PILL_H 24 + +#define BRIGHT_MIN 5 // lowest level the LED still lights at +#define BRIGHT_MAX 100 +#define BRIGHT_STEP 5 +#define BRIGHT_DEFAULT 80 + +#define COL_DIM 0x8A8594 +#define COL_OFF 0x101018 +#define COL_TRACK 0x202028 + +#define HDR_ICON "/assets/icons/palette.bin" +#define HDR_TITLE "LED SIGNALS" + +// The three semantic signals whose color is being configured. +enum { SIG_INFO = 0, SIG_WARNING, SIG_ERROR, SIG_COUNT }; +static const char *SIG_NAMES[SIG_COUNT] = {"INFO", "WARNING", "ERROR"}; + +enum { + FOCUS_SIGNAL = 0, // which signal to edit (info / warning / error) + FOCUS_COLOR, // color assigned to the selected signal + FOCUS_BRIGHT, // global intensity (one value for all signals) + FOCUS_COUNT, +}; + +typedef struct { + const char *name; + uint32_t hex; +} preset_t; + +// Saturated primaries: these drive a physical RGB LED, so pale/pastel values +// (lots of all three channels) wash out to white. Keep the channels pure. The +// three signal defaults (purple/yellow/red) are all present so they map back. +static const preset_t PRESETS[] = { + {"Red", 0xFF0000}, + {"Orange", 0xFF6000}, + {"Yellow", 0xFFFF00}, + {"Green", 0x00FF00}, + {"Blue", 0x0000FF}, + {"Purple", 0xFF00FF}, +}; +#define PRESET_COUNT ((int)(sizeof(PRESETS) / sizeof(PRESETS[0]))) + +static lv_obj_t *s_screen = NULL; +static lv_obj_t *s_preview = NULL; +static lv_obj_t *s_card[FOCUS_COUNT]; +static lv_obj_t *s_swatch[PRESET_COUNT]; +static lv_obj_t *s_bright_fill = NULL; +static lv_obj_t *s_bright_val = NULL; +static lv_obj_t *s_sig_val = NULL; + +static int s_focus = FOCUS_SIGNAL; +static int s_signal = SIG_INFO; +static int s_color_idx[SIG_COUNT]; // selected preset index per signal +static int s_bright = BRIGHT_DEFAULT; +static bool s_changed = false; + +// Scale one 8-bit channel by a percentage, clamped to [0, 255] so an out-of-range +// percentage can never overflow the byte and wrap the color to a wrong hue. +static uint8_t scale_channel(uint8_t chan, int pct) { + if (pct < 0) + pct = 0; + uint32_t v = (uint32_t)chan * (uint32_t)pct / 100; + return (uint8_t)(v > 255 ? 255 : v); +} + +static lv_color_t scaled_color(uint32_t hex, int pct) { + uint8_t r = scale_channel((hex >> 16) & 0xFF, pct); + uint8_t g = scale_channel((hex >> 8) & 0xFF, pct); + uint8_t b = scale_channel(hex & 0xFF, pct); + return lv_color_make(r, g, b); +} + +static uint32_t current_hex(void) { + return PRESETS[s_color_idx[s_signal]].hex; +} + +static void update_preview(void) { + if (s_preview == NULL) + return; + uint32_t hex = current_hex(); + lv_color_t c = scaled_color(hex, s_bright); + lv_obj_set_style_bg_color(s_preview, c, 0); + lv_obj_set_style_border_color(s_preview, lv_color_hex(hex), 0); + lv_obj_set_style_border_width(s_preview, 2, 0); + lv_obj_set_style_shadow_width(s_preview, 8 + s_bright / 4, 0); + lv_obj_set_style_shadow_color(s_preview, lv_color_hex(hex), 0); + lv_obj_set_style_shadow_opa(s_preview, LV_OPA_60, 0); + + // Drive the physical RGB LED (LP5816) to match the preview: selected signal's + // color scaled by the brightness percentage. + led_set_color(scale_channel((hex >> 16) & 0xFF, s_bright), + scale_channel((hex >> 8) & 0xFF, s_bright), + scale_channel(hex & 0xFF, s_bright)); +} + +static void update_swatches(void) { + for (int i = 0; i < PRESET_COUNT; i++) { + bool sel = (i == s_color_idx[s_signal]); + lv_obj_set_style_border_color( + s_swatch[i], sel ? current_theme.border_accent : current_theme.border_inactive, 0); + lv_obj_set_style_border_width(s_swatch[i], sel ? 3 : 1, 0); + lv_obj_set_style_shadow_width(s_swatch[i], sel ? 10 : 0, 0); + lv_obj_set_style_shadow_color(s_swatch[i], current_theme.border_accent, 0); + lv_obj_set_style_shadow_opa(s_swatch[i], sel ? LV_OPA_60 : LV_OPA_TRANSP, 0); + } +} + +static void update_bright(void) { + if (s_bright_fill) + lv_obj_set_width(s_bright_fill, lv_pct(s_bright)); + if (s_bright_val) + lv_label_set_text_fmt(s_bright_val, "%d%%", s_bright); +} + +static void update_signal(void) { + if (s_sig_val == NULL) + return; + lv_label_set_text(s_sig_val, SIG_NAMES[s_signal]); + lv_obj_set_style_text_color(s_sig_val, lv_color_hex(current_hex()), 0); +} + +static void update_focus(void) { + for (int i = 0; i < FOCUS_COUNT; i++) { + bool f = (i == s_focus); + lv_obj_set_style_border_color( + s_card[i], f ? current_theme.border_accent : current_theme.border_inactive, 0); + lv_obj_set_style_shadow_width(s_card[i], f ? 14 : 0, 0); + lv_obj_set_style_shadow_color(s_card[i], current_theme.border_accent, 0); + lv_obj_set_style_shadow_spread(s_card[i], f ? -3 : 0, 0); + lv_obj_set_style_shadow_opa(s_card[i], f ? LV_OPA_50 : LV_OPA_TRANSP, 0); + } +} + +static lv_obj_t *make_ctrl_card(lv_obj_t *parent, const char *label, int h) { + lv_obj_t *card = lv_obj_create(parent); + lv_obj_remove_flag(card, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_remove_flag(card, LV_OBJ_FLAG_CLICKABLE); + lv_obj_set_size(card, CONTENT_W, h); + lv_obj_set_style_radius(card, 10, 0); + lv_obj_set_style_bg_color(card, current_theme.bg_secondary, 0); + lv_obj_set_style_bg_opa(card, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(card, 2, 0); + lv_obj_set_style_border_color(card, current_theme.border_inactive, 0); + lv_obj_set_style_pad_hor(card, 10, 0); + lv_obj_set_style_pad_ver(card, 0, 0); + lv_obj_set_flex_flow(card, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(card, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_set_style_pad_column(card, 8, 0); + + lv_obj_t *lbl = lv_label_create(card); + lv_label_set_text(lbl, label); + lv_obj_set_style_text_font(lbl, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(lbl, lv_color_hex(COL_DIM), 0); + lv_obj_set_width(lbl, CARD_LABEL_W); + return card; +} + +static void build_preview(void) { + s_preview = lv_obj_create(s_screen); + lv_obj_remove_flag(s_preview, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_remove_flag(s_preview, LV_OBJ_FLAG_CLICKABLE); + lv_obj_set_size(s_preview, PREVIEW_D, PREVIEW_D); + lv_obj_align(s_preview, LV_ALIGN_TOP_MID, 0, PREVIEW_Y); + lv_obj_set_style_radius(s_preview, LV_RADIUS_CIRCLE, 0); + lv_obj_set_style_pad_all(s_preview, 0, 0); +} + +static void build_color_card(lv_obj_t *parent) { + lv_obj_t *card = make_ctrl_card(parent, "COLOR", SWATCH_CARD_H); + s_card[FOCUS_COLOR] = card; + + lv_obj_t *row = lv_obj_create(card); + lv_obj_remove_flag(row, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_remove_flag(row, LV_OBJ_FLAG_CLICKABLE); + lv_obj_set_size(row, LV_SIZE_CONTENT, LV_SIZE_CONTENT); + lv_obj_set_style_bg_opa(row, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(row, 0, 0); + lv_obj_set_style_pad_all(row, 0, 0); + lv_obj_set_style_pad_column(row, 6, 0); + lv_obj_set_flex_flow(row, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(row, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_set_flex_grow(row, 1); + + for (int i = 0; i < PRESET_COUNT; i++) { + lv_obj_t *dot = lv_obj_create(row); + lv_obj_remove_flag(dot, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_remove_flag(dot, LV_OBJ_FLAG_CLICKABLE); + lv_obj_set_size(dot, SWATCH_D, SWATCH_D); + lv_obj_set_style_radius(dot, LV_RADIUS_CIRCLE, 0); + lv_obj_set_style_bg_color(dot, lv_color_hex(PRESETS[i].hex), 0); + lv_obj_set_style_bg_opa(dot, LV_OPA_COVER, 0); + lv_obj_set_style_pad_all(dot, 0, 0); + s_swatch[i] = dot; + } +} + +static void build_bright_card(lv_obj_t *parent) { + lv_obj_t *card = make_ctrl_card(parent, "BRIGHT", BRIGHT_CARD_H); + s_card[FOCUS_BRIGHT] = card; + + lv_obj_t *track = lv_obj_create(card); + lv_obj_remove_flag(track, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_remove_flag(track, LV_OBJ_FLAG_CLICKABLE); + lv_obj_set_height(track, BAR_H); + lv_obj_set_flex_grow(track, 1); + lv_obj_set_style_radius(track, 4, 0); + lv_obj_set_style_bg_color(track, lv_color_hex(COL_TRACK), 0); + lv_obj_set_style_bg_opa(track, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(track, 1, 0); + lv_obj_set_style_border_color(track, current_theme.border_inactive, 0); + lv_obj_set_style_pad_all(track, 0, 0); + lv_obj_set_style_clip_corner(track, true, 0); + + s_bright_fill = lv_obj_create(track); + lv_obj_remove_flag(s_bright_fill, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_remove_flag(s_bright_fill, LV_OBJ_FLAG_CLICKABLE); + lv_obj_set_height(s_bright_fill, lv_pct(100)); + lv_obj_set_width(s_bright_fill, lv_pct(s_bright)); + lv_obj_align(s_bright_fill, LV_ALIGN_LEFT_MID, 0, 0); + lv_obj_set_style_radius(s_bright_fill, 4, 0); + lv_obj_set_style_border_width(s_bright_fill, 0, 0); + lv_obj_set_style_bg_color(s_bright_fill, current_theme.border_interface, 0); + lv_obj_set_style_bg_grad_color(s_bright_fill, current_theme.border_accent, 0); + lv_obj_set_style_bg_grad_dir(s_bright_fill, LV_GRAD_DIR_HOR, 0); + lv_obj_set_style_bg_opa(s_bright_fill, LV_OPA_COVER, 0); + + s_bright_val = lv_label_create(card); + lv_label_set_text_fmt(s_bright_val, "%d%%", s_bright); + lv_obj_set_style_text_font(s_bright_val, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(s_bright_val, current_theme.text_main, 0); + lv_obj_set_width(s_bright_val, 36); + lv_obj_set_style_text_align(s_bright_val, LV_TEXT_ALIGN_RIGHT, 0); +} + +static void build_signal_card(lv_obj_t *parent) { + lv_obj_t *card = make_ctrl_card(parent, "SIGNAL", STEALTH_CARD_H); + s_card[FOCUS_SIGNAL] = card; + + s_sig_val = lv_label_create(card); + lv_label_set_text(s_sig_val, SIG_NAMES[s_signal]); + lv_obj_set_style_text_font(s_sig_val, &lv_font_montserrat_16, 0); + lv_obj_set_style_text_color(s_sig_val, current_theme.text_main, 0); + lv_obj_set_flex_grow(s_sig_val, 1); + lv_obj_set_style_text_align(s_sig_val, LV_TEXT_ALIGN_RIGHT, 0); +} + +static void build_controls(void) { + lv_obj_t *col = lv_obj_create(s_screen); + lv_obj_remove_flag(col, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_remove_flag(col, LV_OBJ_FLAG_CLICKABLE); + lv_obj_set_size(col, CONTENT_W, LV_SIZE_CONTENT); + lv_obj_align(col, LV_ALIGN_TOP_MID, 0, CTRL_Y); + lv_obj_set_style_bg_opa(col, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(col, 0, 0); + lv_obj_set_style_pad_all(col, 0, 0); + lv_obj_set_style_pad_row(col, CTRL_GAP, 0); + lv_obj_set_flex_flow(col, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(col, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + + build_signal_card(col); + build_color_card(col); + build_bright_card(col); +} + +static int preset_index_of(uint32_t hex) { + for (int i = 0; i < PRESET_COUNT; i++) { + if (PRESETS[i].hex == (hex & 0xFFFFFF)) + return i; + } + return 0; // custom/unknown color falls back to the first preset +} + +static void save_config(void) { + g_config_led.brightness = s_bright; + g_config_led.info_color = PRESETS[s_color_idx[SIG_INFO]].hex; + g_config_led.warning_color = PRESETS[s_color_idx[SIG_WARNING]].hex; + g_config_led.error_color = PRESETS[s_color_idx[SIG_ERROR]].hex; + + // Apply live so subsequent signals use the new colors/brightness immediately. + led_set_signal_config(g_config_led.info_color, + g_config_led.warning_color, + g_config_led.error_color, + g_config_led.brightness); + + // Persist to SD only (matches the storage policy: no SD -> keep in RAM for this + // session but do not write anything). + if (tos_config_save(TOS_PATH_CONFIG_LED, "led") == ESP_OK) { + notify(NOTIFY_SAVED, "LED settings saved"); + } else { + notify(NOTIFY_WARNING, "No SD: not saved"); + } +} + +static void led_ctrl_input(const input_event_t *ev, void *ctx) { + (void)ctx; + const bool press = (ev->action == INPUT_ACTION_PRESS); + const bool nav = press || (ev->action == INPUT_ACTION_REPEAT); + + switch (ev->button) { + case INPUT_BTN_BACK: + if (press) { + if (s_changed) + save_config(); + led_clear(); + ui_switch_screen(SCREEN_INTERFACE_SETTINGS); + } + break; + case INPUT_BTN_DOWN: + if (nav) { + s_focus = (s_focus + 1) % FOCUS_COUNT; + update_focus(); + ui_feedback(UI_FB_NAV); + } + break; + case INPUT_BTN_UP: + if (nav) { + s_focus = (s_focus - 1 + FOCUS_COUNT) % FOCUS_COUNT; + update_focus(); + ui_feedback(UI_FB_NAV); + } + break; + case INPUT_BTN_LEFT: + if (press) { + if (s_focus == FOCUS_SIGNAL) { + s_signal = (s_signal - 1 + SIG_COUNT) % SIG_COUNT; + update_signal(); + update_swatches(); + update_preview(); + } else if (s_focus == FOCUS_COLOR) { + s_color_idx[s_signal] = (s_color_idx[s_signal] - 1 + PRESET_COUNT) % PRESET_COUNT; + s_changed = true; + update_swatches(); + update_signal(); + update_preview(); + } else if (s_focus == FOCUS_BRIGHT) { + int nb = s_bright - BRIGHT_STEP; + if (nb < BRIGHT_MIN) + nb = BRIGHT_MIN; + if (nb != s_bright) { + s_bright = nb; + s_changed = true; + update_bright(); + update_preview(); + } + } + } + break; + case INPUT_BTN_RIGHT: + if (press) { + if (s_focus == FOCUS_SIGNAL) { + s_signal = (s_signal + 1) % SIG_COUNT; + update_signal(); + update_swatches(); + update_preview(); + } else if (s_focus == FOCUS_COLOR) { + s_color_idx[s_signal] = (s_color_idx[s_signal] + 1) % PRESET_COUNT; + s_changed = true; + update_swatches(); + update_signal(); + update_preview(); + } else if (s_focus == FOCUS_BRIGHT) { + int nb = s_bright + BRIGHT_STEP; + if (nb > BRIGHT_MAX) + nb = BRIGHT_MAX; + if (nb != s_bright) { + s_bright = nb; + s_changed = true; + update_bright(); + update_preview(); + } + } + } + break; + case INPUT_BTN_OK: + if (press) + ui_feedback(UI_FB_SELECT); + break; + default: + break; + } +} + +void ui_led_ctrl_open(void) { + if (s_screen != NULL) { + lv_obj_del(s_screen); + s_screen = NULL; + } + s_focus = FOCUS_SIGNAL; + s_signal = SIG_INFO; + s_color_idx[SIG_INFO] = preset_index_of(g_config_led.info_color); + s_color_idx[SIG_WARNING] = preset_index_of(g_config_led.warning_color); + s_color_idx[SIG_ERROR] = preset_index_of(g_config_led.error_color); + s_bright = g_config_led.brightness; + if (s_bright < BRIGHT_MIN) + s_bright = BRIGHT_MIN; + if (s_bright > BRIGHT_MAX) + s_bright = BRIGHT_MAX; + s_changed = false; + + s_screen = lv_obj_create(NULL); + lv_obj_set_style_bg_color(s_screen, current_theme.screen_base, 0); + lv_obj_set_style_bg_opa(s_screen, LV_OPA_COVER, 0); + lv_obj_remove_flag(s_screen, LV_OBJ_FLAG_SCROLLABLE); + + ui_chrome_header(s_screen, HDR_TITLE, HDR_ICON); + + build_preview(); + build_controls(); + + update_signal(); + update_swatches(); + update_bright(); + update_preview(); + update_focus(); + + ui_chrome_footer(s_screen, "UP/DOWN pick L/R adjust BACK save"); + + ui_input_set_screen_handler(led_ctrl_input, NULL); + + ui_screen_load_owned(&s_screen, s_screen); +} diff --git a/firmware_p4/components/Applications/ui/screens/settings/sd_health_ui.c b/firmware_p4/components/Applications/ui/screens/settings/sd_health_ui.c new file mode 100644 index 000000000..e22782ed2 --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/settings/sd_health_ui.c @@ -0,0 +1,366 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#include "sd_health_ui.h" + +#include +#include +#include + +#include "lvgl.h" + +#include "notify_ui.h" +#include "ui_chrome.h" +#include "ui_feedback.h" +#include "ui_manager.h" +#include "ui_theme.h" +#include "vfs_core.h" +#include "vfs_sdcard.h" + +#define RETEST_TICK_MS 90 +#define RETEST_TICKS 16 + +#define HDR_TITLE "SD HEALTH" +#define HDR_ICON "/assets/icons/sd_card.bin" +#define FOOTER_TXT "OK REMOUNT R RETEST BACK" + +#define MX 8 +#define CONTENT_W (240 - 2 * MX) + +#define COL_DIM 0x8A8594 +#define COL_SUCCESS 0x00E676 +#define COL_ACC2 0xB89AFF + +#define HERO_Y 48 +#define HERO_H 108 +#define TILE_Y 164 +#define TILE_H 66 +#define TILE_GAP 8 +#define TILE_W ((CONTENT_W - TILE_GAP) / 2) +#define CTA_Y 238 +#define CTA_H 34 + +#define HERO_PAD 10 +#define HERO_INNER_W (CONTENT_W - 2 * HERO_PAD - 2) + +#define GLYPH_W 34 +#define GLYPH_H 44 +#define INFO_X (GLYPH_W + 10) +#define BAR_Y 54 +#define BAR_H 10 +#define KV_Y 70 + +#define SD_PATH "/sdcard" +#define BYTES_PER_GB 1000000000ULL +#define CTA_TXT "Remount & retest" + +#define READ_VAL "21.4" +#define WRITE_VAL "12.8" +#define UNIT_OK "MB/s OK" +#define UNIT_TEST "MB/s ..." + +static lv_obj_t *s_screen = NULL; +static lv_timer_t *s_retest_timer = NULL; + +static lv_obj_t *s_read_val = NULL; +static lv_obj_t *s_write_val = NULL; +static lv_obj_t *s_read_sub = NULL; +static lv_obj_t *s_write_sub = NULL; + +static int s_retest_left = 0; + +static lv_obj_t *make_card(lv_obj_t *parent, int w, int h) { + lv_obj_t *card = lv_obj_create(parent); + lv_obj_remove_flag(card, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_remove_flag(card, LV_OBJ_FLAG_CLICKABLE); + lv_obj_set_size(card, w, h); + lv_obj_set_style_radius(card, 10, 0); + lv_obj_set_style_bg_color(card, current_theme.bg_secondary, 0); + lv_obj_set_style_bg_opa(card, LV_OPA_COVER, 0); + lv_obj_set_style_border_color(card, current_theme.border_accent, 0); + lv_obj_set_style_border_width(card, 1, 0); + lv_obj_set_style_pad_all(card, 0, 0); + return card; +} + +static void build_glyph(lv_obj_t *card) { + lv_obj_t *body = lv_obj_create(card); + lv_obj_remove_flag(body, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(body, GLYPH_W, GLYPH_H); + lv_obj_align(body, LV_ALIGN_TOP_LEFT, 0, 0); + lv_obj_set_style_radius(body, 3, 0); + lv_obj_set_style_pad_all(body, 0, 0); + lv_obj_set_style_bg_color(body, current_theme.bg_primary, 0); + lv_obj_set_style_bg_opa(body, LV_OPA_COVER, 0); + lv_obj_set_style_border_color(body, current_theme.border_accent, 0); + lv_obj_set_style_border_width(body, 2, 0); + + for (int i = 0; i < 3; i++) { + lv_obj_t *pin = lv_obj_create(body); + lv_obj_remove_flag(pin, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(pin, 2, 6); + lv_obj_align(pin, LV_ALIGN_TOP_LEFT, 8 + i * 6, 3); + lv_obj_set_style_border_width(pin, 0, 0); + lv_obj_set_style_radius(pin, 1, 0); + lv_obj_set_style_bg_color(pin, current_theme.border_accent, 0); + lv_obj_set_style_bg_opa(pin, LV_OPA_COVER, 0); + } +} + +static void build_hero(void) { + bool mounted = vfs_sdcard_is_mounted(); + char namebuf[24] = "No SD card"; + char sizebuf[16] = "--"; + char usedbuf[24] = "Insert an SD card"; + char freebuf[24] = ""; + unsigned pct = 0; + if (mounted) { + if (!(vfs_sdcard_get_name(namebuf, sizeof(namebuf)) && namebuf[0])) + strlcpy(namebuf, "SD Card", sizeof(namebuf)); + vfs_statvfs_t st = {0}; + if (vfs_statvfs(SD_PATH, &st) == ESP_OK && st.total_bytes > 0) { + pct = (unsigned)((st.used_bytes * 100ULL) / st.total_bytes); + unsigned used_tenths = (unsigned)((st.used_bytes * 10ULL) / BYTES_PER_GB); + unsigned free_tenths = (unsigned)((st.free_bytes * 10ULL) / BYTES_PER_GB); + unsigned total_gb = (unsigned)((st.total_bytes + BYTES_PER_GB / 2) / BYTES_PER_GB); + snprintf(sizebuf, sizeof(sizebuf), "%uGB", total_gb); + snprintf(usedbuf, sizeof(usedbuf), "%u.%u GB used", used_tenths / 10, used_tenths % 10); + snprintf(freebuf, sizeof(freebuf), "%u.%u GB free", free_tenths / 10, free_tenths % 10); + } + } + + lv_obj_t *card = make_card(s_screen, CONTENT_W, HERO_H); + lv_obj_align(card, LV_ALIGN_TOP_MID, 0, HERO_Y); + lv_obj_set_style_pad_all(card, HERO_PAD, 0); + + build_glyph(card); + + lv_obj_t *name = lv_label_create(card); + lv_label_set_text(name, namebuf); + lv_obj_set_style_text_font(name, &lv_font_montserrat_14, 0); + lv_obj_set_style_text_color(name, current_theme.text_main, 0); + lv_obj_align(name, LV_ALIGN_TOP_LEFT, INFO_X, 4); + + lv_obj_t *spec = lv_label_create(card); + lv_label_set_text(spec, mounted ? "FAT32" : "Not mounted"); + lv_obj_set_style_text_font(spec, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(spec, lv_color_hex(COL_DIM), 0); + lv_obj_align(spec, LV_ALIGN_TOP_LEFT, INFO_X, 26); + + lv_obj_t *chip = lv_obj_create(card); + lv_obj_remove_flag(chip, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(chip, 46, 22); + lv_obj_align(chip, LV_ALIGN_TOP_RIGHT, 0, 10); + lv_obj_set_style_radius(chip, 6, 0); + lv_obj_set_style_pad_all(chip, 0, 0); + lv_obj_set_style_bg_color(chip, current_theme.border_accent, 0); + lv_obj_set_style_bg_opa(chip, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(chip, 0, 0); + lv_obj_t *chip_lbl = lv_label_create(chip); + lv_label_set_text(chip_lbl, sizebuf); + lv_obj_set_style_text_font(chip_lbl, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(chip_lbl, current_theme.screen_base, 0); + lv_obj_center(chip_lbl); + + lv_obj_t *bar = lv_bar_create(card); + lv_obj_set_size(bar, HERO_INNER_W, BAR_H); + lv_obj_align(bar, LV_ALIGN_TOP_LEFT, 0, BAR_Y); + lv_bar_set_range(bar, 0, 100); + lv_bar_set_value(bar, (int32_t)pct, LV_ANIM_OFF); + lv_obj_set_style_radius(bar, 5, LV_PART_MAIN); + lv_obj_set_style_bg_color(bar, current_theme.bg_primary, LV_PART_MAIN); + lv_obj_set_style_bg_opa(bar, LV_OPA_COVER, LV_PART_MAIN); + lv_obj_set_style_border_color(bar, lv_color_hex(COL_DIM), LV_PART_MAIN); + lv_obj_set_style_border_opa(bar, LV_OPA_40, LV_PART_MAIN); + lv_obj_set_style_border_width(bar, 1, LV_PART_MAIN); + lv_obj_set_style_radius(bar, 5, LV_PART_INDICATOR); + lv_obj_set_style_bg_color(bar, current_theme.border_accent, LV_PART_INDICATOR); + lv_obj_set_style_bg_grad_color(bar, lv_color_hex(COL_ACC2), LV_PART_INDICATOR); + lv_obj_set_style_bg_grad_dir(bar, LV_GRAD_DIR_HOR, LV_PART_INDICATOR); + lv_obj_set_style_bg_opa(bar, LV_OPA_COVER, LV_PART_INDICATOR); + + lv_obj_t *used = lv_label_create(card); + lv_label_set_text(used, usedbuf); + lv_obj_set_style_text_font(used, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(used, lv_color_hex(COL_DIM), 0); + lv_obj_align(used, LV_ALIGN_TOP_LEFT, 0, KV_Y); + + lv_obj_t *free_lbl = lv_label_create(card); + lv_label_set_text(free_lbl, freebuf); + lv_obj_set_style_text_font(free_lbl, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(free_lbl, current_theme.text_main, 0); + lv_obj_align(free_lbl, LV_ALIGN_TOP_RIGHT, 0, KV_Y); +} + +static void +build_tile(int x, const char *caption, const char *value, lv_obj_t **val_out, lv_obj_t **sub_out) { + lv_obj_t *card = make_card(s_screen, TILE_W, TILE_H); + lv_obj_align(card, LV_ALIGN_TOP_LEFT, x, TILE_Y); + lv_obj_set_style_pad_ver(card, 4, 0); + lv_obj_set_style_pad_row(card, 0, 0); + lv_obj_set_flex_flow(card, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(card, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + + lv_obj_t *cap = lv_label_create(card); + lv_label_set_text(cap, caption); + lv_obj_set_style_text_font(cap, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(cap, lv_color_hex(COL_DIM), 0); + + lv_obj_t *val = lv_label_create(card); + lv_label_set_text(val, value); + lv_obj_set_style_text_font(val, &lv_font_montserrat_16, 0); + lv_obj_set_style_text_color(val, lv_color_hex(COL_SUCCESS), 0); + + lv_obj_t *sub = lv_label_create(card); + lv_label_set_text(sub, UNIT_OK); + lv_obj_set_style_text_font(sub, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(sub, lv_color_hex(COL_DIM), 0); + + *val_out = val; + *sub_out = sub; +} + +static void build_cta(void) { + lv_obj_t *row = lv_obj_create(s_screen); + lv_obj_remove_flag(row, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(row, CONTENT_W, CTA_H); + lv_obj_align(row, LV_ALIGN_TOP_MID, 0, CTA_Y); + lv_obj_set_style_radius(row, 9, 0); + lv_obj_set_style_pad_all(row, 0, 0); + lv_obj_set_style_bg_color(row, current_theme.bg_secondary, 0); + lv_obj_set_style_bg_opa(row, LV_OPA_COVER, 0); + lv_obj_set_style_border_color(row, current_theme.border_accent, 0); + lv_obj_set_style_border_width(row, 2, 0); + lv_obj_set_style_shadow_width(row, 14, 0); + lv_obj_set_style_shadow_color(row, current_theme.border_accent, 0); + lv_obj_set_style_shadow_spread(row, -3, 0); + + lv_obj_t *lbl = lv_label_create(row); + lv_label_set_text(lbl, CTA_TXT); + lv_obj_set_style_text_font(lbl, &lv_font_montserrat_14, 0); + lv_obj_set_style_text_color(lbl, current_theme.border_accent, 0); + lv_obj_center(lbl); +} + +static void set_tiles_testing(bool testing) { + const char *unit = testing ? UNIT_TEST : UNIT_OK; + if (s_read_sub) + lv_label_set_text(s_read_sub, unit); + if (s_write_sub) + lv_label_set_text(s_write_sub, unit); +} + +static void retest_tick_cb(lv_timer_t *t) { + if (lv_screen_active() != s_screen) { + lv_timer_delete(t); + s_retest_timer = NULL; + return; + } + s_retest_left--; + if (s_retest_left <= 0) { + if (s_read_val) + lv_label_set_text(s_read_val, READ_VAL); + if (s_write_val) + lv_label_set_text(s_write_val, WRITE_VAL); + set_tiles_testing(false); + lv_timer_delete(t); + s_retest_timer = NULL; + ui_feedback(UI_FB_READ); + notify(NOTIFY_SAVED, "SD retest OK"); + return; + } + char rbuf[8]; + char wbuf[8]; + int rv = 180 + ((s_retest_left * 37) % 60); + int wv = 100 + ((s_retest_left * 29) % 50); + lv_snprintf(rbuf, sizeof(rbuf), "%d.%d", rv / 10, rv % 10); + lv_snprintf(wbuf, sizeof(wbuf), "%d.%d", wv / 10, wv % 10); + if (s_read_val) + lv_label_set_text(s_read_val, rbuf); + if (s_write_val) + lv_label_set_text(s_write_val, wbuf); +} + +static void start_retest(void) { + if (s_retest_timer != NULL) + return; + s_retest_left = RETEST_TICKS; + set_tiles_testing(true); + s_retest_timer = lv_timer_create(retest_tick_cb, RETEST_TICK_MS, NULL); + ui_feedback(UI_FB_SELECT); +} + +static void sd_health_input(const input_event_t *ev, void *ctx) { + (void)ctx; + const bool press = (ev->action == INPUT_ACTION_PRESS); + + switch (ev->button) { + case INPUT_BTN_BACK: + case INPUT_BTN_LEFT: + if (press) + ui_switch_screen(SCREEN_STORAGE); + break; + case INPUT_BTN_OK: + if (press) { + ui_feedback(UI_FB_SELECT); + if (vfs_sdcard_is_mounted()) + vfs_sdcard_deinit(); + esp_err_t r = vfs_sdcard_init(); + notify(r == ESP_OK ? NOTIFY_SAVED : NOTIFY_WARNING, + r == ESP_OK ? "SD remounted" : "No SD card"); + ui_sd_health_open(); + } + break; + case INPUT_BTN_RIGHT: + if (press) + start_retest(); + break; + default: + break; + } +} + +void ui_sd_health_open(void) { + if (s_retest_timer != NULL) { + lv_timer_delete(s_retest_timer); + s_retest_timer = NULL; + } + if (s_screen != NULL) { + lv_obj_del(s_screen); + s_screen = NULL; + } + s_read_val = s_write_val = s_read_sub = s_write_sub = NULL; + s_retest_left = 0; + + s_screen = lv_obj_create(NULL); + lv_obj_set_style_bg_color(s_screen, current_theme.screen_base, 0); + lv_obj_set_style_bg_opa(s_screen, LV_OPA_COVER, 0); + lv_obj_remove_flag(s_screen, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_border_width(s_screen, 0, 0); + lv_obj_set_style_pad_all(s_screen, 0, 0); + + ui_chrome_header(s_screen, HDR_TITLE, HDR_ICON); + + build_hero(); + build_tile(MX, "READ", READ_VAL, &s_read_val, &s_read_sub); + build_tile(MX + TILE_W + TILE_GAP, "WRITE", WRITE_VAL, &s_write_val, &s_write_sub); + build_cta(); + + ui_chrome_footer(s_screen, FOOTER_TXT); + + ui_input_set_screen_handler(sd_health_input, NULL); + + ui_screen_load_owned(&s_screen, s_screen); +} diff --git a/firmware_p4/components/Applications/ui/screens/settings/settings_ui.c b/firmware_p4/components/Applications/ui/screens/settings/settings_ui.c index 78cbd5258..52717a0b6 100644 --- a/firmware_p4/components/Applications/ui/screens/settings/settings_ui.c +++ b/firmware_p4/components/Applications/ui/screens/settings/settings_ui.c @@ -15,17 +15,63 @@ #include "settings_ui.h" +#include + #include "esp_log.h" +#include "assets_manager.h" +#include "msgbox_ui.h" +#include "ui_chrome.h" +#include "ui_feedback.h" #include "ui_theme.h" #include "menu_component_ui.h" +#include "reboot_ui.h" #include "ui_manager.h" #include "lv_port_indev.h" -#include "buttons_gpio.h" +#include "c5_flasher.h" +#include "spi_protocol.h" +#include "esp_system.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include "sys_prio.h" static const char *TAG = "SETTINGS_UI"; -#define NAV_TIMER_PERIOD_MS 50 +#define ENTRY_FADE_MS 180 + +#define C5_PROGRESS_TICK_MS 200 +#define C5_DISMISS_DELAY_MS 2000 +#define C5_FLASH_TASK_STACK 8192 +#define C5_FLASH_TASK_PRIO SYS_PRIO_SERVICE_HI +#define C5_ROM_FLASH_TASK_STACK 8192 +#define C5_ROM_FLASH_TASK_PRIO SYS_PRIO_SERVICE_HI +#define C5_PASSTHROUGH_TASK_STACK 8192 +#define C5_PASSTHROUGH_TASK_PRIO SYS_PRIO_SERVICE_HI +#define REBOOT_DELAY_MS 80 + +#define ACTION_TOGGLE_ROTATION (-1) +#define ACTION_FLASH_C5 (-2) +#define ACTION_C5_PASSTHROUGH (-3) +#define ACTION_REBOOT_P4 (-4) +#define ACTION_FLASH_C5_ROM (-5) +#define ACTION_RELEASE_C5_UART (-6) + +#define GOTO_LAB (-20) +#define GOTO_DEV (-21) + +#define DEV_GRID_WIDTH 216 +#define DEV_GRID_COLS 2 +#define DEV_TILE_W 100 +#define DEV_TILE_H 66 +#define DEV_TILE_GAP 8 +#define DEV_TILE_RAD 12 +#define DEV_DANGER_COL 0xFF5470 + +typedef enum { + VIEW_MAIN = 0, + VIEW_LAB, + VIEW_DEV, +} settings_view_t; typedef struct { const char *name; @@ -33,89 +79,619 @@ typedef struct { int target; } settings_item_t; -static const settings_item_t ITEMS[] = { - {"CONNECTION", "/assets/icons/wifi_menu_icon.bin", SCREEN_CONNECTION_SETTINGS}, - {"INTERFACE", "/assets/icons/interface_menu_icon.bin", SCREEN_INTERFACE_SETTINGS}, - {"DISPLAY", "/assets/icons/display_menu_icon.bin", SCREEN_DISPLAY_SETTINGS}, - {"SOUND", NULL, SCREEN_SOUND_SETTINGS}, - {"BATTERY", "/assets/icons/battery_menu_icon.bin", SCREEN_BATTERY_SETTINGS}, - {"ABOUT", "/assets/icons/about_menu_icon.bin", SCREEN_ABOUT_SETTINGS}, +static const settings_item_t MAIN_ITEMS[] = { + {"CONNECTION", "/assets/icons/wifi.bin", SCREEN_CONNECTION_SETTINGS}, + {"DISPLAY", "/assets/icons/display_settings.bin", SCREEN_DISPLAY_SETTINGS}, + {"INTERFACE", "/assets/icons/tune.bin", SCREEN_INTERFACE_SETTINGS}, + {"THEME", "/assets/icons/palette.bin", SCREEN_THEME_SELECTOR}, + {"ROTATE SCREEN", "/assets/icons/screen_rotation.bin", ACTION_TOGGLE_ROTATION}, + {"SOUND", "/assets/icons/volume_up.bin", SCREEN_SOUND_SETTINGS}, + {"AUDIO & HAPTICS", "/assets/icons/graphic_eq.bin", GOTO_LAB}, + {"BATTERY", "/assets/icons/battery_full.bin", SCREEN_BATTERY_SETTINGS}, + {"POWER", "/assets/icons/power_settings_new.bin", SCREEN_POWER}, + {"STORAGE", "/assets/icons/storage.bin", SCREEN_STORAGE}, + {"FIRMWARE", "/assets/icons/developer_board.bin", GOTO_DEV}, + {"ABOUT", "/assets/icons/info.bin", SCREEN_ABOUT_SETTINGS}, + {"TIME", "/assets/icons/timer.bin", SCREEN_TIME}, + {"RESTART P4", "/assets/icons/restart_alt.bin", ACTION_REBOOT_P4}, }; -#define ITEM_COUNT (sizeof(ITEMS) / sizeof(ITEMS[0])) +#define MAIN_COUNT ((int)(sizeof(MAIN_ITEMS) / sizeof(MAIN_ITEMS[0]))) + +typedef struct { + int before; + const char *title; +} settings_section_t; +static const settings_section_t MAIN_SECTIONS[] = { + {0, "Connectivity"}, + {1, "Interface"}, + {5, "Sound & Haptics"}, + {7, "Power"}, + {9, "System"}, +}; +#define MAIN_SECTION_COUNT ((int)(sizeof(MAIN_SECTIONS) / sizeof(MAIN_SECTIONS[0]))) + +static const settings_item_t LAB_ITEMS[] = { + {"VIBRATION", "/assets/icons/vibration.bin", SCREEN_HAPTIC}, + {"SPEAKER", "/assets/icons/speaker.bin", SCREEN_SPEAKER}, + {"MIC", "/assets/icons/mic.bin", SCREEN_MIC_REC}, + {"SPECTRUM", "/assets/icons/graphic_eq.bin", SCREEN_SPECTRUM}, + {"MOTION / IMU", "/assets/icons/screen_rotation.bin", SCREEN_IMU_MONITOR}, +}; +#define LAB_COUNT ((int)(sizeof(LAB_ITEMS) / sizeof(LAB_ITEMS[0]))) + +static const settings_item_t DEV_ITEMS[] = { + {"UPDATE C5", "/assets/icons/system_update.bin", ACTION_FLASH_C5}, + {"FLASH C5 (ROM)", "/assets/icons/usb.bin", ACTION_FLASH_C5_ROM}, + {"RELEASE UART", "/assets/icons/cable.bin", ACTION_RELEASE_C5_UART}, + {"C5 BRIDGE", "/assets/icons/swap_horiz.bin", ACTION_C5_PASSTHROUGH}, + {"C5 STATUS", "/assets/icons/troubleshoot.bin", SCREEN_C5_STATUS}, +}; +#define DEV_COUNT ((int)(sizeof(DEV_ITEMS) / sizeof(DEV_ITEMS[0]))) static lv_obj_t *s_screen = NULL; static menu_component_t s_menu; -static lv_timer_t *s_nav_timer = NULL; +static settings_view_t s_view = VIEW_MAIN; -static bool s_btn_up_last = false; -static bool s_btn_down_last = false; -static bool s_btn_left_last = false; -static bool s_btn_right_last = false; -static bool s_btn_ok_last = false; -static bool s_btn_back_last = false; +static lv_obj_t *s_dev_tiles[DEV_COUNT]; +static int s_dev_sel = 0; -static void nav_timer_cb(lv_timer_t *t); +static lv_obj_t *s_c5_overlay = NULL; +static lv_obj_t *s_c5_status_label = NULL; +static lv_obj_t *s_c5_bar = NULL; +static lv_timer_t *s_c5_prog_timer = NULL; +static bool s_c5_in_progress = false; -static void nav_timer_cb(lv_timer_t *t) { - if (lv_screen_active() != s_screen) { - lv_timer_delete(t); - s_nav_timer = NULL; - return; +static void build_settings_view(settings_view_t view); + +static const settings_item_t * +view_table(settings_view_t view, int *count, const char **title, const char **icon) { + switch (view) { + case VIEW_LAB: + if (count) + *count = LAB_COUNT; + if (title) + *title = "AUDIO & HAPTICS"; + if (icon) + *icon = "/assets/icons/graphic_eq.bin"; + return LAB_ITEMS; + case VIEW_DEV: + if (count) + *count = DEV_COUNT; + if (title) + *title = "FIRMWARE"; + if (icon) + *icon = "/assets/icons/developer_board.bin"; + return DEV_ITEMS; + case VIEW_MAIN: + default: + if (count) + *count = MAIN_COUNT; + if (title) + *title = "SETTINGS"; + if (icon) + *icon = "/assets/icons/settings.bin"; + return MAIN_ITEMS; } - if (ui_input_is_locked()) +} + +static void rotation_confirm_cb(bool confirm) { + if (confirm) + ui_manager_relayout_current(); +} + +static void show_c5_progress(const char *msg) { + if (s_c5_overlay == NULL) { + s_c5_overlay = lv_obj_create(s_screen); + lv_obj_set_size(s_c5_overlay, LV_PCT(100), LV_PCT(100)); + lv_obj_center(s_c5_overlay); + lv_obj_remove_flag(s_c5_overlay, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_bg_color(s_c5_overlay, lv_color_black(), 0); + lv_obj_set_style_bg_opa(s_c5_overlay, LV_OPA_90, 0); + lv_obj_set_style_border_width(s_c5_overlay, 0, 0); + + lv_obj_t *box = lv_obj_create(s_c5_overlay); + lv_obj_set_size(box, 204, 134); + lv_obj_center(box); + lv_obj_remove_flag(box, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_bg_color(box, current_theme.bg_secondary, 0); + lv_obj_set_style_bg_opa(box, LV_OPA_COVER, 0); + lv_obj_set_style_border_color(box, current_theme.border_accent, 0); + lv_obj_set_style_border_width(box, 2, 0); + lv_obj_set_style_radius(box, 14, 0); + lv_obj_set_style_pad_all(box, 12, 0); + lv_obj_set_style_shadow_width(box, 26, 0); + lv_obj_set_style_shadow_color(box, current_theme.border_accent, 0); + lv_obj_set_style_shadow_opa(box, LV_OPA_40, 0); + + lv_obj_t *title = lv_label_create(box); + lv_label_set_text(title, LV_SYMBOL_DOWNLOAD " UPDATING C5"); + lv_obj_set_style_text_font(title, &lv_font_montserrat_14, 0); + lv_obj_set_style_text_color(title, current_theme.border_accent, 0); + lv_obj_align(title, LV_ALIGN_TOP_MID, 0, 0); + + s_c5_status_label = lv_label_create(box); + lv_label_set_text(s_c5_status_label, msg ? msg : "Starting..."); + lv_obj_set_style_text_color(s_c5_status_label, current_theme.text_main, 0); + lv_obj_set_style_text_align(s_c5_status_label, LV_TEXT_ALIGN_CENTER, 0); + lv_obj_align(s_c5_status_label, LV_ALIGN_CENTER, 0, -4); + lv_label_set_long_mode(s_c5_status_label, LV_LABEL_LONG_WRAP); + lv_obj_set_width(s_c5_status_label, 180); + + s_c5_bar = lv_bar_create(box); + lv_obj_set_size(s_c5_bar, 170, 12); + lv_obj_align(s_c5_bar, LV_ALIGN_BOTTOM_MID, 0, 0); + lv_bar_set_range(s_c5_bar, 0, 100); + lv_bar_set_value(s_c5_bar, 0, LV_ANIM_OFF); + lv_obj_set_style_bg_color(s_c5_bar, lv_color_hex(0x202028), LV_PART_MAIN); + lv_obj_set_style_bg_opa(s_c5_bar, LV_OPA_COVER, LV_PART_MAIN); + lv_obj_set_style_radius(s_c5_bar, 4, LV_PART_MAIN); + lv_obj_set_style_bg_color(s_c5_bar, lv_color_hex(0x00E676), LV_PART_INDICATOR); + lv_obj_set_style_bg_opa(s_c5_bar, LV_OPA_COVER, LV_PART_INDICATOR); + lv_obj_set_style_radius(s_c5_bar, 4, LV_PART_INDICATOR); + lv_obj_add_flag(s_c5_bar, LV_OBJ_FLAG_HIDDEN); + } else if (s_c5_status_label && msg) { + lv_label_set_text(s_c5_status_label, msg); + } +} + +static void hide_c5_progress(void) { + if (s_c5_prog_timer) { + lv_timer_delete(s_c5_prog_timer); + s_c5_prog_timer = NULL; + } + if (s_c5_overlay) { + lv_obj_del(s_c5_overlay); + s_c5_overlay = NULL; + s_c5_status_label = NULL; + s_c5_bar = NULL; + } + s_c5_in_progress = false; +} + +static void c5_flash_done_on_lvgl(void *data) { + esp_err_t r = (esp_err_t)(intptr_t)data; + hide_c5_progress(); + + if (r == ESP_OK) + msgbox_open_info("/assets/icons/system_update.bin", + "C5 UPDATED", + "C5 rebooted into the new firmware.", + lv_color_hex(0x00E676)); + else + msgbox_open_info("/assets/icons/error.bin", + "UPDATE FAILED", + "Check the serial log for details.", + lv_color_hex(0xFF5470)); +} + +static void c5_flash_task(void *arg) { + (void)arg; + esp_err_t init_r = c5_flasher_init(); + esp_err_t r = (init_r != ESP_OK) ? init_r : c5_flasher_update(NULL, 0, SPI_OTA_TRANSPORT_SPI); + ESP_LOGI(TAG, "c5_flasher result: %s", esp_err_to_name(r)); + + lv_async_call(c5_flash_done_on_lvgl, (void *)(intptr_t)r); + vTaskDelete(NULL); +} + +static void c5_progress_tick(lv_timer_t *t) { + (void)t; + if (s_c5_bar == NULL) return; + uint32_t sent = 0, total = 0; + c5_flasher_progress(&sent, &total); + int pct = total ? (int)((uint64_t)sent * 100 / total) : 0; + lv_bar_set_value(s_c5_bar, pct, LV_ANIM_OFF); +} - bool up = up_button_is_down(); - bool down = down_button_is_down(); - bool left = left_button_is_down(); - bool right = right_button_is_down(); - bool ok = ok_button_is_down(); - bool back = back_button_is_down(); +static void start_c5_flash(void) { + if (s_c5_in_progress) + return; + s_c5_in_progress = true; + show_c5_progress("Updating C5 (OTA)...\nDo not power off."); + + if (s_c5_bar) + lv_obj_remove_flag(s_c5_bar, LV_OBJ_FLAG_HIDDEN); + if (s_c5_prog_timer == NULL) + s_c5_prog_timer = lv_timer_create(c5_progress_tick, C5_PROGRESS_TICK_MS, NULL); - if (down && !s_btn_down_last) - menu_component_next(&s_menu); + xTaskCreatePinnedToCore(c5_flash_task, + "c5_flash", + C5_FLASH_TASK_STACK, + NULL, + C5_FLASH_TASK_PRIO, + NULL, + SYS_CORE_RADIO); +} - if (up && !s_btn_up_last) - menu_component_prev(&s_menu); +static void c5_rom_flash_task(void *arg) { + (void)arg; + esp_err_t r = c5_flasher_rom_flash(); + ESP_LOGI(TAG, "c5_rom_flash result: %s", esp_err_to_name(r)); + lv_async_call(c5_flash_done_on_lvgl, (void *)(intptr_t)r); + vTaskDelete(NULL); +} - if ((back && !s_btn_back_last) || (left && !s_btn_left_last)) { - ui_switch_screen(SCREEN_MENU); +static void start_c5_rom_flash(void) { + if (s_c5_in_progress) + return; + s_c5_in_progress = true; + show_c5_progress( + "ROM flash (blank C5).\nC5 must be in download\nmode. ~2-3 min. Don't\npower off."); + xTaskCreatePinnedToCore(c5_rom_flash_task, + "c5_rom_flash", + C5_ROM_FLASH_TASK_STACK, + NULL, + C5_ROM_FLASH_TASK_PRIO, + NULL, + SYS_CORE_RADIO); +} + +static void c5_passthrough_task(void *arg) { + (void)arg; + c5_passthrough_run(); + vTaskDelete(NULL); +} + +static void start_c5_passthrough(void) { + msgbox_open_info("/assets/icons/swap_horiz.bin", + "C5 PASSTHROUGH", + "Bridge active. Strap C5 GPIO28 to GND, power-cycle, then flash from " + "your PC with esptool (chip esp32c5).", + current_theme.border_accent); + xTaskCreatePinnedToCore(c5_passthrough_task, + "c5_passthru", + C5_PASSTHROUGH_TASK_STACK, + NULL, + C5_PASSTHROUGH_TASK_PRIO, + NULL, + SYS_CORE_RADIO); +} + +#define SHUT_TICK_MS 150 +#define SHUT_LOG_COLOR 0x00E676 +#define SHUT_BUF_LEN 360 + +static const char *SHUTDOWN_STEPS[] = { + "ui manager", + "lvgl", + "audio i2s", + "led rgb", + "buttons", + "sd card", + "console", + "c5 bridge", + "storage", +}; +#define SHUTDOWN_STEP_COUNT ((int)(sizeof(SHUTDOWN_STEPS) / sizeof(SHUTDOWN_STEPS[0]))) + +static lv_obj_t *s_shut_log = NULL; +static int s_shut_i = 0; + +static lv_obj_t *make_blank_screen(void) { + lv_obj_t *scr = lv_obj_create(NULL); + lv_obj_set_style_bg_color(scr, lv_color_black(), 0); + lv_obj_set_style_bg_opa(scr, LV_OPA_COVER, 0); + lv_obj_remove_flag(scr, LV_OBJ_FLAG_SCROLLABLE); + return scr; +} + +static void shut_tick_cb(lv_timer_t *t) { + if (s_shut_i < SHUTDOWN_STEP_COUNT) { + char buf[SHUT_BUF_LEN]; + snprintf(buf, sizeof(buf), "> closing %s", SHUTDOWN_STEPS[s_shut_i]); + if (s_shut_log != NULL) + lv_label_set_text(s_shut_log, buf); + s_shut_i++; return; } + lv_timer_delete(t); + s_shut_log = NULL; + reboot_ui_reboot(); +} + +static void start_reboot_p4(void) { + s_shut_i = 0; + lv_obj_t *scr = make_blank_screen(); + + lv_obj_t *lbl = lv_label_create(scr); + lv_label_set_text(lbl, LV_SYMBOL_POWER " Restarting..."); + lv_obj_set_style_text_color(lbl, current_theme.text_main, 0); + lv_obj_set_style_text_font(lbl, &lv_font_montserrat_14, 0); + lv_obj_align(lbl, LV_ALIGN_TOP_MID, 0, 44); + + s_shut_log = lv_label_create(scr); + lv_label_set_text(s_shut_log, ""); + lv_obj_set_style_text_color(s_shut_log, lv_color_hex(SHUT_LOG_COLOR), 0); + lv_obj_set_style_text_font(s_shut_log, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_align(s_shut_log, LV_TEXT_ALIGN_CENTER, 0); + lv_obj_align(s_shut_log, LV_ALIGN_TOP_MID, 0, 76); - if ((ok && !s_btn_ok_last) || (right && !s_btn_right_last)) { - int sel = menu_component_get_selected(&s_menu); - if (sel >= 0 && (size_t)sel < ITEM_COUNT) - ui_switch_screen(ITEMS[sel].target); + ui_screen_load(scr); + lv_timer_create(shut_tick_cb, SHUT_TICK_MS, NULL); +} + +static bool run_action(int target) { + switch (target) { + case ACTION_TOGGLE_ROTATION: + + msgbox_open("/assets/icons/screen_rotation.bin", + "Switch portrait/landscape now?", + "YES", + "NO", + rotation_confirm_cb); + return true; + case ACTION_FLASH_C5: + + start_c5_flash(); + return true; + case ACTION_FLASH_C5_ROM: + + start_c5_rom_flash(); + return true; + case ACTION_RELEASE_C5_UART: + + c5_flasher_release_uart(); + msgbox_open_info("/assets/icons/cable.bin", + "UART RELEASED", + "GPIO38/39 hi-Z. Use an external serial adapter. Reboot P4 to restore.", + current_theme.border_accent); + return true; + case ACTION_C5_PASSTHROUGH: + + start_c5_passthrough(); + return true; + case ACTION_REBOOT_P4: + + ESP_LOGW(TAG, "User-requested P4 reboot from Settings."); + start_reboot_p4(); + return true; + default: + if (target >= 0) + ui_switch_screen((screen_id_t)target); + return true; } +} - s_btn_up_last = up; - s_btn_down_last = down; - s_btn_left_last = left; - s_btn_right_last = right; - s_btn_ok_last = ok; - s_btn_back_last = back; +static bool dev_is_danger(int idx) { + return DEV_ITEMS[idx].target == ACTION_REBOOT_P4; } -void ui_settings_open(void) { - if (s_screen != NULL) { - lv_obj_del(s_screen); - s_screen = NULL; +static lv_color_t dev_tile_border(int idx, bool selected) { + if (selected) + return current_theme.border_accent; + if (dev_is_danger(idx)) + return lv_color_hex(DEV_DANGER_COL); + return current_theme.border_inactive; +} + +static void update_dev_selection(void) { + for (int i = 0; i < DEV_COUNT; i++) { + if (s_dev_tiles[i] == NULL) + continue; + bool sel = (i == s_dev_sel); + lv_obj_set_style_border_color(s_dev_tiles[i], dev_tile_border(i, sel), 0); + lv_obj_set_style_bg_opa(s_dev_tiles[i], sel ? LV_OPA_COVER : LV_OPA_80, 0); + if (sel) { + lv_color_t glow = + dev_is_danger(i) ? lv_color_hex(DEV_DANGER_COL) : current_theme.border_accent; + lv_obj_set_style_shadow_width(s_dev_tiles[i], 14, 0); + lv_obj_set_style_shadow_color(s_dev_tiles[i], glow, 0); + lv_obj_set_style_shadow_opa(s_dev_tiles[i], LV_OPA_50, 0); + } else { + lv_obj_set_style_shadow_width(s_dev_tiles[i], 0, 0); + } } +} + +static lv_obj_t *make_dev_tile(lv_obj_t *parent, int idx) { + lv_obj_t *tile = lv_obj_create(parent); + lv_obj_set_size(tile, DEV_TILE_W, DEV_TILE_H); + lv_obj_remove_flag(tile, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_radius(tile, DEV_TILE_RAD, 0); + lv_obj_set_style_bg_color(tile, current_theme.bg_secondary, 0); + lv_obj_set_style_bg_opa(tile, LV_OPA_80, 0); + lv_obj_set_style_border_width(tile, 2, 0); + lv_obj_set_style_pad_all(tile, 4, 0); + lv_obj_set_flex_flow(tile, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(tile, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_set_style_pad_row(tile, 3, 0); + + lv_image_dsc_t *dsc = assets_get(DEV_ITEMS[idx].icon); + if (dsc != NULL) { + lv_obj_t *img = lv_image_create(tile); + lv_image_set_src(img, dsc); + lv_obj_set_size(img, 22, 22); + lv_image_set_inner_align(img, LV_IMAGE_ALIGN_CONTAIN); + } + + lv_obj_t *lbl = lv_label_create(tile); + lv_label_set_text(lbl, DEV_ITEMS[idx].name); + lv_label_set_long_mode(lbl, LV_LABEL_LONG_WRAP); + lv_obj_set_width(lbl, DEV_TILE_W - 12); + lv_obj_set_style_text_align(lbl, LV_TEXT_ALIGN_CENTER, 0); + lv_obj_set_style_text_font(lbl, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color( + lbl, dev_is_danger(idx) ? lv_color_hex(DEV_DANGER_COL) : current_theme.text_main, 0); + + return tile; +} + +static void build_dev_grid(void) { + ui_chrome_header(s_screen, "FIRMWARE", "/assets/icons/developer_board.bin"); + ui_chrome_footer(s_screen, "OK: RUN BACK: EXIT"); + + lv_obj_t *grid = lv_obj_create(s_screen); + lv_obj_remove_style_all(grid); + lv_obj_set_size(grid, DEV_GRID_WIDTH, LV_SIZE_CONTENT); + lv_obj_align(grid, LV_ALIGN_CENTER, 0, (UI_CHROME_HEADER_H - UI_CHROME_FOOTER_H) / 2); + lv_obj_remove_flag(grid, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_flex_flow(grid, LV_FLEX_FLOW_ROW_WRAP); + lv_obj_set_flex_align(grid, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_set_style_pad_row(grid, DEV_TILE_GAP, 0); + lv_obj_set_style_pad_column(grid, DEV_TILE_GAP, 0); + + for (int i = 0; i < DEV_COUNT; i++) + s_dev_tiles[i] = make_dev_tile(grid, i); + + update_dev_selection(); + lv_obj_fade_in(grid, ENTRY_FADE_MS, 0); +} + +static void dev_grid_input(const input_event_t *ev) { + const bool press = (ev->action == INPUT_ACTION_PRESS); + const bool nav = press || (ev->action == INPUT_ACTION_REPEAT); + + if (s_c5_overlay != NULL) { + if (press && ev->button == INPUT_BTN_BACK) + hide_c5_progress(); + return; + } + + switch (ev->button) { + case INPUT_BTN_BACK: + if (press) + build_settings_view(VIEW_MAIN); + break; + case INPUT_BTN_RIGHT: + if (nav) { + s_dev_sel = (s_dev_sel + 1) % DEV_COUNT; + update_dev_selection(); + ui_feedback(UI_FB_NAV); + } + break; + case INPUT_BTN_LEFT: + if (nav) { + s_dev_sel = (s_dev_sel == 0) ? DEV_COUNT - 1 : s_dev_sel - 1; + update_dev_selection(); + ui_feedback(UI_FB_NAV); + } + break; + case INPUT_BTN_DOWN: + if (nav && s_dev_sel + DEV_GRID_COLS < DEV_COUNT) { + s_dev_sel += DEV_GRID_COLS; + update_dev_selection(); + ui_feedback(UI_FB_NAV); + } + break; + case INPUT_BTN_UP: + if (nav && s_dev_sel - DEV_GRID_COLS >= 0) { + s_dev_sel -= DEV_GRID_COLS; + update_dev_selection(); + ui_feedback(UI_FB_NAV); + } + break; + case INPUT_BTN_OK: + if (press) + run_action(DEV_ITEMS[s_dev_sel].target); + break; + default: + break; + } +} + +static void settings_input(const input_event_t *ev, void *ctx) { + (void)ctx; + const bool press = (ev->action == INPUT_ACTION_PRESS); + const bool nav = press || (ev->action == INPUT_ACTION_REPEAT); + + if (s_view == VIEW_DEV) { + dev_grid_input(ev); + return; + } + + switch (ev->button) { + case INPUT_BTN_DOWN: + if (nav) + menu_component_next(&s_menu); + break; + case INPUT_BTN_UP: + if (nav) + menu_component_prev(&s_menu); + break; + case INPUT_BTN_BACK: + case INPUT_BTN_LEFT: + if (press) { + if (s_view != VIEW_MAIN) + build_settings_view(VIEW_MAIN); + else + ui_switch_screen(SCREEN_MENU); + } + break; + case INPUT_BTN_OK: + case INPUT_BTN_RIGHT: + if (press) { + int count = 0; + const settings_item_t *items = view_table(s_view, &count, NULL, NULL); + int sel = menu_component_get_selected(&s_menu); + if (sel >= 0 && sel < count) { + int target = items[sel].target; + if (target == GOTO_LAB) { + build_settings_view(VIEW_LAB); + } else if (target == GOTO_DEV) { + build_settings_view(VIEW_DEV); + } else if (!run_action(target)) { + ui_switch_screen(target); + } + } + } + break; + default: + break; + } +} + +static void build_settings_view(settings_view_t view) { + lv_obj_t *prev = s_screen; + s_view = view; s_screen = lv_obj_create(NULL); lv_obj_set_style_bg_color(s_screen, current_theme.screen_base, 0); lv_obj_set_style_bg_opa(s_screen, LV_OPA_COVER, 0); lv_obj_remove_flag(s_screen, LV_OBJ_FLAG_SCROLLABLE); - s_menu = menu_component_create(s_screen, "SETTINGS", NULL); + if (view == VIEW_DEV) { + s_dev_sel = 0; + build_dev_grid(); + } else { + int count = 0; + const char *title = NULL; + const char *icon = NULL; + const settings_item_t *items = view_table(view, &count, &title, &icon); + + s_menu = menu_component_create(s_screen, title, icon); + for (int i = 0; i < count; i++) { + if (view == VIEW_MAIN) { + for (int s = 0; s < MAIN_SECTION_COUNT; s++) + if (MAIN_SECTIONS[s].before == i) + menu_component_add_section(&s_menu, MAIN_SECTIONS[s].title); + } + menu_component_add_item(&s_menu, items[i].icon, items[i].name); + } - for (size_t i = 0; i < ITEM_COUNT; i++) { - menu_component_add_item(&s_menu, ITEMS[i].icon, ITEMS[i].name); + if (s_menu.items_cont != NULL) + lv_obj_fade_in(s_menu.items_cont, ENTRY_FADE_MS, 0); } - if (s_nav_timer == NULL) - s_nav_timer = lv_timer_create(nav_timer_cb, NAV_TIMER_PERIOD_MS, NULL); + ui_input_set_screen_handler(settings_input, NULL); - lv_screen_load(s_screen); -} \ No newline at end of file + ui_screen_load_owned(&s_screen, s_screen); + if (prev != NULL) + lv_obj_del(prev); +} + +void ui_settings_open(void) { + if (s_screen != NULL) { + lv_obj_del(s_screen); + s_screen = NULL; + } + build_settings_view(VIEW_MAIN); +} + +void ui_settings_open_dev(void) { + if (s_screen != NULL) { + lv_obj_del(s_screen); + s_screen = NULL; + } + build_settings_view(VIEW_DEV); +} diff --git a/firmware_p4/components/Applications/ui/screens/settings/sound_settings_ui.c b/firmware_p4/components/Applications/ui/screens/settings/sound_settings_ui.c new file mode 100644 index 000000000..701fe2bcf --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/settings/sound_settings_ui.c @@ -0,0 +1,183 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#include "sound_settings_ui.h" + +#include "esp_log.h" + +#include "audio_i2s.h" +#include "intensity_bar_ui.h" +#include "menu_component_ui.h" +#include "notify_ui.h" +#include "tos_config.h" +#include "tos_storage_paths.h" +#include "ui_manager.h" +#include "ui_theme.h" + +static const char *TAG = "SOUND_SETTINGS_UI"; + +#define ENTRY_FADE_MS 200 + +#define ROW_VOLUME 0 +#define ROW_ALERT 1 +#define ROW_KEYBEEP 2 +#define ROW_STARTUP 3 + +#define VOL_STEPS INTENSITY_BAR_STEPS + +static int vol_level_from_pct(int pct) { + int lvl = (pct * VOL_STEPS + 50) / 100; + if (lvl < 0) + lvl = 0; + if (lvl > VOL_STEPS) + lvl = VOL_STEPS; + return lvl; +} + +static int vol_pct_from_level(int lvl) { + if (lvl < 0) + lvl = 0; + if (lvl > VOL_STEPS) + lvl = VOL_STEPS; + return lvl * 100 / VOL_STEPS; +} + +static const char *const ALERT_OPTS[] = {"Beep", "Chirp", "Blip", "Off"}; +#define ALERT_COUNT ((int)(sizeof(ALERT_OPTS) / sizeof(ALERT_OPTS[0]))) + +static int s_alert_idx = 0; +static bool s_vol_changed = false; + +static lv_obj_t *s_screen = NULL; +static menu_component_t s_menu; +static bool s_changed = false; + +static void cycle_selector(int sel, int dir) { + if (sel == ROW_ALERT) { + s_alert_idx = (s_alert_idx + dir + ALERT_COUNT) % ALERT_COUNT; + menu_component_set_selector_value(&s_menu, sel, ALERT_OPTS[s_alert_idx]); + s_changed = true; + } +} + +static void sound_settings_input(const input_event_t *ev, void *ctx) { + (void)ctx; + const bool press = (ev->action == INPUT_ACTION_PRESS); + const bool nav = press || (ev->action == INPUT_ACTION_REPEAT); + + switch (ev->button) { + case INPUT_BTN_DOWN: + if (nav) + menu_component_next(&s_menu); + break; + case INPUT_BTN_UP: + if (nav) + menu_component_prev(&s_menu); + break; + case INPUT_BTN_OK: + if (press) { + int sel = menu_component_get_selected(&s_menu); + if (sel >= 0 && s_menu.has_toggle[sel]) { + menu_component_toggle_item(&s_menu, sel); + s_changed = true; + } + } + break; + case INPUT_BTN_LEFT: + if (press) { + int sel = menu_component_get_selected(&s_menu); + if (sel >= 0) { + if (s_menu.has_intensity[sel]) { + menu_component_intensity_dec(&s_menu, sel); + s_changed = true; + if (sel == ROW_VOLUME) { + audio_i2s_set_volume( + (uint8_t)vol_pct_from_level(menu_component_get_intensity(&s_menu, ROW_VOLUME))); + s_vol_changed = true; + } + } else if (s_menu.val_labels[sel] != NULL) { + cycle_selector(sel, -1); + } + } + } + break; + case INPUT_BTN_RIGHT: + if (press) { + int sel = menu_component_get_selected(&s_menu); + if (sel >= 0) { + if (s_menu.has_intensity[sel]) { + menu_component_intensity_inc(&s_menu, sel); + s_changed = true; + if (sel == ROW_VOLUME) { + audio_i2s_set_volume( + (uint8_t)vol_pct_from_level(menu_component_get_intensity(&s_menu, ROW_VOLUME))); + s_vol_changed = true; + } + } else if (s_menu.val_labels[sel] != NULL) { + cycle_selector(sel, +1); + } + } + } + break; + case INPUT_BTN_BACK: + if (press) { + if (s_vol_changed) { + g_config_system.volume = + vol_pct_from_level(menu_component_get_intensity(&s_menu, ROW_VOLUME)); + if (tos_config_save(TOS_PATH_CONFIG_SYSTEM, "system") == ESP_OK) + notify(NOTIFY_SAVED, "Sound settings saved"); + else + notify(NOTIFY_WARNING, "Save failed (no SD?)"); + } else if (s_changed) { + notify(NOTIFY_INFO, "Sound settings applied"); + } + ui_switch_screen(SCREEN_SETTINGS); + } + break; + default: + break; + } +} + +void ui_sound_settings_open(void) { + if (s_screen != NULL) { + lv_obj_del(s_screen); + s_screen = NULL; + } + + s_alert_idx = 0; + s_changed = false; + s_vol_changed = false; + + s_screen = lv_obj_create(NULL); + lv_obj_set_style_bg_color(s_screen, current_theme.screen_base, 0); + lv_obj_set_style_bg_opa(s_screen, LV_OPA_COVER, 0); + lv_obj_remove_flag(s_screen, LV_OBJ_FLAG_SCROLLABLE); + + s_menu = menu_component_create(s_screen, "SOUND", "/assets/icons/volume_up.bin"); + menu_component_add_intensity( + &s_menu, "/assets/icons/volume_up.bin", "Volume", vol_level_from_pct(g_config_system.volume)); + menu_component_add_selector( + &s_menu, "/assets/icons/notifications_active.bin", "Alert tone", ALERT_OPTS[s_alert_idx]); + menu_component_add_toggle(&s_menu, "/assets/icons/keyboard.bin", "Key beeps", true); + menu_component_add_toggle(&s_menu, "/assets/icons/music_note.bin", "Startup sound", false); + + if (s_menu.items_cont != NULL) + lv_obj_fade_in(s_menu.items_cont, ENTRY_FADE_MS, 0); + + ui_input_set_screen_handler(sound_settings_input, NULL); + + ui_screen_load_owned(&s_screen, s_screen); +} diff --git a/firmware_p4/components/Applications/ui/screens/settings/storage_settings_ui.c b/firmware_p4/components/Applications/ui/screens/settings/storage_settings_ui.c new file mode 100644 index 000000000..06436881d --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/settings/storage_settings_ui.c @@ -0,0 +1,495 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#include "storage_settings_ui.h" + +#include + +#include "esp_littlefs.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include "sys_prio.h" + +#include "assets_manager.h" +#include "header_ui.h" +#include "msgbox_ui.h" +#include "notify_ui.h" +#include "ui_chrome.h" +#include "ui_feedback.h" +#include "ui_manager.h" +#include "ui_theme.h" +#include "vfs_core.h" +#include "vfs_sdcard.h" + +static const char *TAG = "STORAGE_UI"; + +#define SD_PATH "/sdcard" +#define ASSETS_LABEL "assets" +#define DATA_LABEL "storage" +#define CONTENT_W 204 +#define BAR_H 9 +#define G1 0x7A52D6 +#define G2 0xB89AFF +#define CYAN 0x00E5D0 +#define OK_COLOR 0x00E676 +#define DANGER_COLOR 0xFF5470 +#define ACT_EJECT 0 +#define ACT_FORMAT 1 +#define ACT_HEALTH 2 +#define ACT_COUNT 3 +#define FMT_TASK_STACK 8192 +#define FMT_TASK_PRIO SYS_PRIO_SERVICE_HI + +#define LIST_LEFT 6 +#define LIST_TOP_Y 46 +#define LIST_W 218 +#define LIST_H 248 +#define SB_TRACK_X 227 +#define SB_TRACK_Y 54 +#define SB_TRACK_LEN 232 +#define SB_THUMB_H 45 +#define SB_THUMB_ICON "/assets/icons/drag_indicator.bin" + +static lv_obj_t *s_screen = NULL; +static lv_obj_t *s_act[ACT_COUNT]; +static lv_obj_t *s_col = NULL; +static lv_obj_t *s_thumb = NULL; +static lv_obj_t *s_fmt_overlay = NULL; +static int s_sel = 0; +static volatile bool s_formatting = false; + +static void build_screen(void); + +static void fmt_size(char *out, size_t n, uint64_t bytes) { + const uint64_t gb = 1024ULL * 1024 * 1024; + const uint64_t mb = 1024ULL * 1024; + const uint64_t kb = 1024ULL; + if (bytes >= gb) { + uint64_t t = (bytes * 10) / gb; + snprintf(out, n, "%llu.%llu GB", (unsigned long long)(t / 10), (unsigned long long)(t % 10)); + } else if (bytes >= mb) { + uint64_t t = (bytes * 10) / mb; + snprintf(out, n, "%llu.%llu MB", (unsigned long long)(t / 10), (unsigned long long)(t % 10)); + } else { + snprintf(out, n, "%llu KB", (unsigned long long)(bytes / kb)); + } +} + +static void add_meta_row(lv_obj_t *parent, const char *left, const char *right, lv_color_t rc) { + lv_obj_t *row = lv_obj_create(parent); + lv_obj_remove_style_all(row); + lv_obj_set_size(row, lv_pct(100), LV_SIZE_CONTENT); + lv_obj_remove_flag(row, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_flex_flow(row, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align( + row, LV_FLEX_ALIGN_SPACE_BETWEEN, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + + lv_obj_t *l = lv_label_create(row); + lv_label_set_text(l, left); + lv_obj_set_style_text_font(l, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(l, current_theme.text_main, 0); + lv_obj_set_style_text_opa(l, LV_OPA_60, 0); + + lv_obj_t *r = lv_label_create(row); + lv_label_set_text(r, right); + lv_obj_set_style_text_font(r, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(r, rc, 0); +} + +static void add_volume(lv_obj_t *parent, + const char *icon, + const char *name, + const char *tag, + uint64_t used, + uint64_t total) { + lv_obj_t *card = lv_obj_create(parent); + lv_obj_remove_style_all(card); + lv_obj_set_size(card, CONTENT_W, LV_SIZE_CONTENT); + lv_obj_remove_flag(card, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_bg_color(card, current_theme.bg_secondary, 0); + lv_obj_set_style_bg_opa(card, LV_OPA_COVER, 0); + lv_obj_set_style_bg_grad_dir(card, LV_GRAD_DIR_NONE, 0); + lv_obj_set_style_radius(card, 11, 0); + lv_obj_set_style_border_width(card, 1, 0); + lv_obj_set_style_border_color(card, current_theme.border_inactive, 0); + lv_obj_set_style_pad_all(card, 8, 0); + lv_obj_set_style_pad_row(card, 6, 0); + lv_obj_set_flex_flow(card, LV_FLEX_FLOW_COLUMN); + + lv_obj_t *hdr = lv_obj_create(card); + lv_obj_remove_style_all(hdr); + lv_obj_set_size(hdr, lv_pct(100), LV_SIZE_CONTENT); + lv_obj_remove_flag(hdr, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_flex_flow(hdr, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(hdr, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_set_style_pad_column(hdr, 7, 0); + + lv_image_dsc_t *dsc = assets_get(icon); + if (dsc != NULL) { + lv_obj_t *img = lv_image_create(hdr); + lv_image_set_src(img, dsc); + lv_obj_set_size(img, 18, 18); + lv_image_set_inner_align(img, LV_IMAGE_ALIGN_CONTAIN); + } + lv_obj_t *nm = lv_label_create(hdr); + lv_label_set_text(nm, name); + lv_obj_set_style_text_font(nm, &lv_font_montserrat_14, 0); + lv_obj_set_style_text_color(nm, current_theme.text_main, 0); + lv_obj_set_flex_grow(nm, 1); + if (tag != NULL) { + lv_obj_t *tg = lv_label_create(hdr); + lv_label_set_text(tg, tag); + lv_obj_set_style_text_font(tg, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(tg, lv_color_hex(CYAN), 0); + } + + int pct = (total > 0) ? (int)((used * 100) / total) : 0; + if (pct > 100) + pct = 100; + lv_obj_t *bar = lv_obj_create(card); + lv_obj_remove_style_all(bar); + lv_obj_set_size(bar, lv_pct(100), BAR_H); + lv_obj_remove_flag(bar, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_radius(bar, 5, 0); + lv_obj_set_style_bg_color(bar, current_theme.screen_base, 0); + lv_obj_set_style_bg_opa(bar, LV_OPA_COVER, 0); + + lv_obj_t *fill = lv_obj_create(bar); + lv_obj_remove_style_all(fill); + lv_obj_set_size(fill, lv_pct(pct < 4 ? 4 : pct), lv_pct(100)); + lv_obj_align(fill, LV_ALIGN_LEFT_MID, 0, 0); + lv_obj_set_style_radius(fill, 5, 0); + lv_obj_set_style_bg_color(fill, lv_color_hex(G1), 0); + lv_obj_set_style_bg_grad_color(fill, lv_color_hex(G2), 0); + lv_obj_set_style_bg_grad_dir(fill, LV_GRAD_DIR_HOR, 0); + lv_obj_set_style_bg_opa(fill, LV_OPA_COVER, 0); + + char lu[24], lf[24]; + fmt_size(lu, sizeof(lu), used); + fmt_size(lf, sizeof(lf), total > used ? total - used : 0); + char used_s[32], free_s[32]; + snprintf(used_s, sizeof(used_s), "%s used", lu); + snprintf(free_s, sizeof(free_s), "%s free", lf); + add_meta_row(card, used_s, free_s, lv_color_hex(CYAN)); +} + +static lv_obj_t *make_action(lv_obj_t *parent, const char *icon, const char *label, bool danger) { + lv_obj_t *row = lv_obj_create(parent); + lv_obj_set_size(row, CONTENT_W, 30); + lv_obj_remove_flag(row, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_radius(row, 9, 0); + lv_obj_set_style_bg_color(row, current_theme.bg_secondary, 0); + lv_obj_set_style_bg_opa(row, LV_OPA_80, 0); + lv_obj_set_style_bg_grad_dir(row, LV_GRAD_DIR_NONE, 0); + lv_obj_set_style_border_width(row, 2, 0); + lv_obj_set_style_border_color( + row, danger ? lv_color_hex(DANGER_COLOR) : current_theme.border_inactive, 0); + lv_obj_set_style_pad_left(row, 10, 0); + 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_column(row, 8, 0); + + lv_image_dsc_t *dsc = assets_get(icon); + if (dsc != NULL) { + lv_obj_t *img = lv_image_create(row); + lv_image_set_src(img, dsc); + lv_obj_set_size(img, 16, 16); + lv_image_set_inner_align(img, LV_IMAGE_ALIGN_CONTAIN); + } + lv_obj_t *lbl = lv_label_create(row); + lv_label_set_text(lbl, label); + lv_obj_set_style_text_font(lbl, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color( + lbl, danger ? lv_color_hex(DANGER_COLOR) : current_theme.text_main, 0); + return row; +} + +static void move_thumb(void) { + if (s_thumb == NULL || s_col == NULL) + return; + int sy = lv_obj_get_scroll_y(s_col); + int total = sy + lv_obj_get_scroll_bottom(s_col); + int thumb_h = lv_obj_get_height(s_thumb); + if (thumb_h <= 0) + thumb_h = SB_THUMB_H; + int travel = SB_TRACK_LEN - thumb_h; + if (travel < 0) + travel = 0; + int pos = SB_TRACK_Y; + if (total > 0) + pos = SB_TRACK_Y + (int)((long)sy * travel / total); + lv_obj_set_y(s_thumb, pos); +} + +static void update_selection(void) { + for (int i = 0; i < ACT_COUNT; i++) { + if (s_act[i] == NULL) + continue; + bool sel = (i == s_sel); + lv_obj_set_style_border_color( + s_act[i], + sel ? current_theme.border_accent + : (i == ACT_FORMAT ? lv_color_hex(DANGER_COLOR) : current_theme.border_inactive), + 0); + lv_obj_set_style_bg_opa(s_act[i], sel ? LV_OPA_COVER : LV_OPA_80, 0); + } + if (s_col != NULL && s_act[s_sel] != NULL) { + lv_obj_update_layout(s_col); + lv_obj_scroll_to_view(s_act[s_sel], LV_ANIM_OFF); + } + move_thumb(); +} + +static void show_fmt_overlay(void) { + if (s_fmt_overlay != NULL) + return; + s_fmt_overlay = lv_obj_create(s_screen); + lv_obj_set_size(s_fmt_overlay, LV_PCT(100), LV_PCT(100)); + lv_obj_center(s_fmt_overlay); + lv_obj_remove_flag(s_fmt_overlay, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_bg_color(s_fmt_overlay, lv_color_black(), 0); + lv_obj_set_style_bg_opa(s_fmt_overlay, LV_OPA_80, 0); + lv_obj_set_style_border_width(s_fmt_overlay, 0, 0); + + lv_obj_t *box = lv_obj_create(s_fmt_overlay); + lv_obj_set_size(box, 190, 96); + lv_obj_center(box); + lv_obj_remove_flag(box, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_bg_color(box, current_theme.bg_secondary, 0); + lv_obj_set_style_bg_opa(box, LV_OPA_COVER, 0); + lv_obj_set_style_radius(box, 14, 0); + lv_obj_set_style_border_width(box, 2, 0); + lv_obj_set_style_border_color(box, current_theme.border_accent, 0); + lv_obj_set_flex_flow(box, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(box, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_set_style_pad_all(box, 10, 0); + lv_obj_set_style_pad_row(box, 6, 0); + + lv_obj_t *t = lv_label_create(box); + lv_label_set_text(t, LV_SYMBOL_SD_CARD " FORMATTING"); + lv_obj_set_style_text_font(t, &lv_font_montserrat_14, 0); + lv_obj_set_style_text_color(t, current_theme.border_accent, 0); + + lv_obj_t *m = lv_label_create(box); + lv_label_set_text(m, "Do not remove the card."); + lv_obj_set_style_text_font(m, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(m, current_theme.text_main, 0); +} + +static void format_done_cb(void *data) { + esp_err_t r = (esp_err_t)(intptr_t)data; + s_formatting = false; + if (s_fmt_overlay != NULL) { + lv_obj_del(s_fmt_overlay); + s_fmt_overlay = NULL; + } + if (r == ESP_OK) + notify(NOTIFY_SAVED, "SD formatted (FAT32)"); + else + notify(NOTIFY_WARNING, "Format failed"); + build_screen(); +} + +static void format_task(void *arg) { + (void)arg; + esp_err_t r = vfs_sdcard_format(); + if (r == ESP_OK && !vfs_sdcard_is_mounted()) + vfs_sdcard_init(); + lv_async_call(format_done_cb, (void *)(intptr_t)r); + vTaskDelete(NULL); +} + +static void fmt_confirm_cb(bool confirm) { + if (!confirm || s_formatting) + return; + s_formatting = true; + show_fmt_overlay(); + xTaskCreatePinnedToCore( + format_task, "sd_format", FMT_TASK_STACK, NULL, FMT_TASK_PRIO, NULL, SYS_CORE_RADIO); +} + +static void fire_action(int idx) { + if (idx == ACT_HEALTH) { + ui_feedback(UI_FB_SELECT); + ui_switch_screen(SCREEN_SD_HEALTH); + return; + } + if (!vfs_sdcard_is_mounted()) { + notify(NOTIFY_WARNING, "No SD card"); + return; + } + if (idx == ACT_EJECT) { + header_ui_sd_eject(); + ui_feedback(UI_FB_SELECT); + notify(NOTIFY_INFO, "SD card ejected"); + build_screen(); + } else if (idx == ACT_FORMAT) { + msgbox_open("/assets/icons/warning.bin", + "Format SD as FAT32?\nAll files will be erased.", + "FORMAT", + "Cancel", + fmt_confirm_cb); + } +} + +static void storage_settings_input(const input_event_t *ev, void *ctx) { + (void)ctx; + if (s_formatting) + return; + const bool press = (ev->action == INPUT_ACTION_PRESS); + const bool nav = press || (ev->action == INPUT_ACTION_REPEAT); + + switch (ev->button) { + case INPUT_BTN_BACK: + if (press) + ui_switch_screen(SCREEN_SETTINGS); + break; + case INPUT_BTN_DOWN: + if (nav) { + s_sel = (s_sel + 1) % ACT_COUNT; + update_selection(); + ui_feedback(UI_FB_NAV); + } + break; + case INPUT_BTN_UP: + if (nav) { + s_sel = (s_sel == 0) ? ACT_COUNT - 1 : s_sel - 1; + update_selection(); + ui_feedback(UI_FB_NAV); + } + break; + case INPUT_BTN_OK: + if (press) + fire_action(s_sel); + break; + default: + break; + } +} + +static void build_screen(void) { + lv_obj_t *prev = s_screen; + (void)TAG; + + s_screen = lv_obj_create(NULL); + lv_obj_set_style_bg_color(s_screen, current_theme.screen_base, 0); + lv_obj_set_style_bg_opa(s_screen, LV_OPA_COVER, 0); + lv_obj_remove_flag(s_screen, LV_OBJ_FLAG_SCROLLABLE); + + ui_chrome_header(s_screen, "STORAGE", "/assets/icons/storage.bin"); + ui_chrome_footer(s_screen, + LV_SYMBOL_UP LV_SYMBOL_DOWN " Nav " LV_SYMBOL_OK " Run " LV_SYMBOL_LEFT + " Back"); + + lv_obj_t *col = lv_obj_create(s_screen); + s_col = col; + lv_obj_remove_style_all(col); + lv_obj_set_size(col, LIST_W, LIST_H); + lv_obj_align(col, LV_ALIGN_TOP_LEFT, LIST_LEFT, LIST_TOP_Y); + lv_obj_add_flag(col, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_scroll_dir(col, LV_DIR_VER); + lv_obj_set_scrollbar_mode(col, LV_SCROLLBAR_MODE_OFF); + lv_obj_clear_flag(col, LV_OBJ_FLAG_SCROLL_ELASTIC | LV_OBJ_FLAG_SCROLL_MOMENTUM); + lv_obj_set_style_pad_all(col, 2, 0); + lv_obj_set_flex_flow(col, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(col, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_START); + lv_obj_set_style_pad_row(col, 8, 0); + + if (vfs_sdcard_is_mounted()) { + vfs_statvfs_t st = {0}; + uint64_t total = 0, used = 0; + if (vfs_statvfs(SD_PATH, &st) == ESP_OK) { + total = st.total_bytes; + used = st.used_bytes; + } + char name[24]; + const char *nm = vfs_sdcard_get_name(name, sizeof(name)) && name[0] ? name : "SD Card"; + add_volume(col, "/assets/icons/sd_card.bin", nm, "FAT32", used, total); + } else { + lv_obj_t *card = lv_obj_create(col); + lv_obj_remove_style_all(card); + lv_obj_set_size(card, CONTENT_W, 44); + lv_obj_remove_flag(card, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_bg_color(card, current_theme.bg_secondary, 0); + lv_obj_set_style_bg_opa(card, LV_OPA_COVER, 0); + lv_obj_set_style_bg_grad_dir(card, LV_GRAD_DIR_NONE, 0); + lv_obj_set_style_radius(card, 11, 0); + lv_obj_set_style_border_width(card, 1, 0); + lv_obj_set_style_border_color(card, current_theme.border_inactive, 0); + lv_obj_t *l = lv_label_create(card); + lv_label_set_text(l, LV_SYMBOL_SD_CARD " No SD card"); + lv_obj_set_style_text_font(l, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(l, current_theme.text_main, 0); + lv_obj_set_style_text_opa(l, LV_OPA_60, 0); + lv_obj_center(l); + } + + size_t at = 0, au = 0, dt = 0, du = 0; + esp_littlefs_info(ASSETS_LABEL, &at, &au); + esp_littlefs_info(DATA_LABEL, &dt, &du); + add_volume(col, + "/assets/icons/developer_board.bin", + "Internal", + "flash", + (uint64_t)au + du, + (uint64_t)at + dt); + + s_act[ACT_EJECT] = make_action(col, "/assets/icons/eject.bin", "Eject SD", false); + s_act[ACT_FORMAT] = make_action(col, "/assets/icons/warning.bin", "Format SD", true); + s_act[ACT_HEALTH] = make_action(col, "/assets/icons/troubleshoot.bin", "SD Health", false); + + static lv_point_precise_t sb_pts[2]; + sb_pts[0].x = 0; + sb_pts[0].y = 0; + sb_pts[1].x = 0; + sb_pts[1].y = SB_TRACK_LEN; + lv_obj_t *track = lv_line_create(s_screen); + lv_line_set_points(track, sb_pts, 2); + lv_obj_set_pos(track, SB_TRACK_X, SB_TRACK_Y); + lv_obj_set_style_line_color(track, current_theme.border_inactive, 0); + lv_obj_set_style_line_opa(track, LV_OPA_COVER, 0); + lv_obj_set_style_line_width(track, 3, 0); + lv_obj_set_style_line_dash_width(track, 4, 0); + lv_obj_set_style_line_dash_gap(track, 4, 0); + + lv_image_dsc_t *thumb_dsc = assets_get(SB_THUMB_ICON); + s_thumb = lv_image_create(s_screen); + if (thumb_dsc != NULL) + lv_image_set_src(s_thumb, thumb_dsc); + lv_obj_set_pos(s_thumb, SB_TRACK_X - 4, SB_TRACK_Y); + lv_obj_move_foreground(s_thumb); + + lv_obj_update_layout(col); + update_selection(); + + ui_input_set_screen_handler(storage_settings_input, NULL); + + ui_screen_load_owned(&s_screen, s_screen); + if (prev != NULL) + lv_obj_del(prev); +} + +void ui_storage_settings_open(void) { + s_sel = 0; + s_formatting = false; + s_fmt_overlay = NULL; + s_col = NULL; + s_thumb = NULL; + if (s_screen != NULL) { + lv_obj_del(s_screen); + s_screen = NULL; + } + build_screen(); +} diff --git a/firmware_p4/components/Applications/ui/screens/settings/system_update_ui.c b/firmware_p4/components/Applications/ui/screens/settings/system_update_ui.c new file mode 100644 index 000000000..8052ec319 --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/settings/system_update_ui.c @@ -0,0 +1,466 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#include "system_update_ui.h" + +#include + +#include "lvgl.h" + +#include "reboot_ui.h" +#include "ui_chrome.h" +#include "ui_feedback.h" +#include "ui_manager.h" +#include "ui_theme.h" +#include "waves_ui.h" + +#define SEARCH_MS 2600 +#define APPLY_TICK_MS 45 +#define APPLY_STEP 2 +#define PROG_MAX 100 + +#define CONTENT_Y_OFS ((UI_CHROME_HEADER_H - UI_CHROME_FOOTER_H) / 2) + +#define BLINK_MS 620 +#define GLOW_MS 900 +#define GLOW_MIN 16 +#define GLOW_MAX 34 + +#define DOT_COUNT 3 +#define DOT_SIZE 8 +#define DOT_GAP 16 +#define DOT_PULSE_MS 480 +#define DOT_STAGGER_MS 150 + +#define SCAN_W 176 +#define SCAN_H 8 +#define SCAN_HI_W 56 +#define SCAN_MS 1050 +#define SCAN_RAD 4 + +#define SEARCH_WAVES_Y -46 +#define SEARCH_LBL_Y 48 +#define SEARCH_DOTS_Y 78 +#define SEARCH_SCAN_Y 108 + +#define FOUND_CARD_W 208 +#define FOUND_CARD_H 160 +#define APPLY_CARD_H 120 +#define CARD_RADIUS 14 +#define CARD_PAD 14 + +#define PILL_W 96 +#define PILL_H 30 +#define PILL_RAD 10 + +#define BAR_W 176 +#define BAR_H 14 +#define BAR_RAD 5 + +#define STEP_LBL_Y -22 +#define APPLY_BAR_Y 18 + +#define PH_VERIFY_MAX 18 +#define PH_ERASE_MAX 42 +#define PH_WRITE_MAX 96 + +#define COL_SUCCESS 0x00E676 +#define COL_DIM 0x8A8594 +#define COL_TRACK 0x202028 + +#define NEW_VERSION "v2.1.0" +#define INSTALLED_VERSION "v2.0.0" + +#define HDR_ICON "/assets/icons/system_update.bin" +#define HDR_TITLE "P4 UPDATE" + +#define FOOTER_SEARCH "BACK Cancel" +#define FOOTER_FOUND "OK Update BACK Cancel" +#define FOOTER_APPLY "Updating - do not power off" + +#define SEARCH_TEXT "Searching for updates..." +#define CARD_TITLE LV_SYMBOL_DOWNLOAD " Update available" +#define CAPTION_NEW "New version" +#define INSTALLED_ROW "Installed " INSTALLED_VERSION + +#define STEP_VERIFY "Verifying image" +#define STEP_ERASE "Erasing" +#define STEP_FINALIZE "Finalizing" +#define STEP_WRITE_FMT "Writing %d%%" +#define STEP_BUF_LEN 24 + +typedef enum { + ST_SEARCHING = 0, + ST_FOUND, + ST_APPLYING, +} update_state_t; + +static lv_obj_t *s_screen = NULL; +static lv_obj_t *s_bar = NULL; +static lv_obj_t *s_step_lbl = NULL; +static lv_timer_t *s_phase_timer = NULL; +static lv_timer_t *s_apply_timer = NULL; + +static update_state_t s_state = ST_SEARCHING; +static int s_pct = 0; + +static void system_update_input(const input_event_t *ev, void *ctx); +static void build_screen(void); + +static void stop_phase_timer(void) { + if (s_phase_timer != NULL) { + lv_timer_delete(s_phase_timer); + s_phase_timer = NULL; + } +} + +static void stop_apply_timer(void) { + if (s_apply_timer != NULL) { + lv_timer_delete(s_apply_timer); + s_apply_timer = NULL; + } +} + +static void opa_anim_cb(void *var, int32_t v) { + lv_obj_set_style_opa((lv_obj_t *)var, (lv_opa_t)v, 0); +} + +static void translate_x_cb(void *var, int32_t v) { + lv_obj_set_style_translate_x((lv_obj_t *)var, v, 0); +} + +static void glow_anim_cb(void *var, int32_t v) { + lv_obj_set_style_shadow_width((lv_obj_t *)var, v, 0); +} + +static void start_blink(lv_obj_t *o, uint32_t ms, lv_opa_t lo, lv_opa_t hi) { + lv_anim_t a; + lv_anim_init(&a); + lv_anim_set_var(&a, o); + lv_anim_set_exec_cb(&a, opa_anim_cb); + lv_anim_set_values(&a, lo, hi); + lv_anim_set_duration(&a, ms); + lv_anim_set_playback_duration(&a, ms); + lv_anim_set_repeat_count(&a, LV_ANIM_REPEAT_INFINITE); + lv_anim_set_path_cb(&a, lv_anim_path_ease_in_out); + lv_anim_start(&a); +} + +static lv_obj_t *make_lit_card(int h) { + lv_obj_t *card = lv_obj_create(s_screen); + lv_obj_remove_flag(card, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_remove_flag(card, LV_OBJ_FLAG_CLICKABLE); + lv_obj_set_size(card, FOUND_CARD_W, h); + lv_obj_align(card, LV_ALIGN_CENTER, 0, CONTENT_Y_OFS); + lv_obj_set_style_radius(card, CARD_RADIUS, 0); + lv_obj_set_style_bg_color(card, current_theme.bg_secondary, 0); + lv_obj_set_style_bg_opa(card, LV_OPA_COVER, 0); + lv_obj_set_style_border_color(card, current_theme.border_accent, 0); + lv_obj_set_style_border_width(card, 1, 0); + lv_obj_set_style_pad_all(card, CARD_PAD, 0); + lv_obj_set_style_shadow_width(card, GLOW_MIN, 0); + lv_obj_set_style_shadow_color(card, current_theme.border_accent, 0); + lv_obj_set_style_shadow_opa(card, LV_OPA_40, 0); + return card; +} + +static void build_searching(void) { + ui_chrome_header(s_screen, HDR_TITLE, HDR_ICON); + ui_chrome_footer(s_screen, FOOTER_SEARCH); + + waves_create(s_screen, LV_ALIGN_CENTER, 0, SEARCH_WAVES_Y, LV_SYMBOL_DOWNLOAD, NULL); + + lv_obj_t *lbl = lv_label_create(s_screen); + lv_label_set_text(lbl, SEARCH_TEXT); + lv_obj_set_style_text_font(lbl, &lv_font_montserrat_14, 0); + lv_obj_set_style_text_color(lbl, current_theme.border_accent, 0); + lv_obj_align(lbl, LV_ALIGN_CENTER, 0, SEARCH_LBL_Y); + start_blink(lbl, BLINK_MS, LV_OPA_40, LV_OPA_COVER); + + lv_obj_t *dots = lv_obj_create(s_screen); + lv_obj_remove_flag(dots, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(dots, DOT_COUNT * DOT_SIZE + (DOT_COUNT - 1) * DOT_GAP, DOT_SIZE); + lv_obj_align(dots, LV_ALIGN_CENTER, 0, SEARCH_DOTS_Y); + lv_obj_set_style_bg_opa(dots, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(dots, 0, 0); + lv_obj_set_style_pad_all(dots, 0, 0); + + int x0 = -(DOT_COUNT * DOT_SIZE + (DOT_COUNT - 1) * DOT_GAP) / 2 + DOT_SIZE / 2; + for (int i = 0; i < DOT_COUNT; i++) { + lv_obj_t *dot = lv_obj_create(dots); + lv_obj_remove_flag(dot, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(dot, DOT_SIZE, DOT_SIZE); + lv_obj_align(dot, LV_ALIGN_CENTER, x0 + i * (DOT_SIZE + DOT_GAP), 0); + lv_obj_set_style_radius(dot, LV_RADIUS_CIRCLE, 0); + lv_obj_set_style_border_width(dot, 0, 0); + lv_obj_set_style_bg_opa(dot, LV_OPA_COVER, 0); + lv_obj_set_style_bg_color(dot, current_theme.border_accent, 0); + + lv_anim_t a; + lv_anim_init(&a); + lv_anim_set_var(&a, dot); + lv_anim_set_exec_cb(&a, opa_anim_cb); + lv_anim_set_values(&a, LV_OPA_30, LV_OPA_COVER); + lv_anim_set_duration(&a, DOT_PULSE_MS); + lv_anim_set_playback_duration(&a, DOT_PULSE_MS); + lv_anim_set_delay(&a, i * DOT_STAGGER_MS); + lv_anim_set_repeat_count(&a, LV_ANIM_REPEAT_INFINITE); + lv_anim_set_path_cb(&a, lv_anim_path_ease_in_out); + lv_anim_start(&a); + } + + lv_obj_t *track = lv_obj_create(s_screen); + lv_obj_remove_flag(track, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(track, SCAN_W, SCAN_H); + lv_obj_align(track, LV_ALIGN_CENTER, 0, SEARCH_SCAN_Y); + lv_obj_set_style_radius(track, SCAN_RAD, 0); + lv_obj_set_style_border_width(track, 0, 0); + lv_obj_set_style_pad_all(track, 0, 0); + lv_obj_set_style_bg_color(track, lv_color_hex(COL_TRACK), 0); + lv_obj_set_style_bg_opa(track, LV_OPA_COVER, 0); + lv_obj_set_style_clip_corner(track, true, 0); + + lv_obj_t *hi = lv_obj_create(track); + lv_obj_remove_flag(hi, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(hi, SCAN_HI_W, SCAN_H); + lv_obj_align(hi, LV_ALIGN_LEFT_MID, 0, 0); + lv_obj_set_style_radius(hi, SCAN_RAD, 0); + lv_obj_set_style_border_width(hi, 0, 0); + lv_obj_set_style_bg_color(hi, current_theme.border_accent, 0); + lv_obj_set_style_bg_opa(hi, LV_OPA_COVER, 0); + lv_obj_set_style_shadow_color(hi, current_theme.border_accent, 0); + lv_obj_set_style_shadow_width(hi, 10, 0); + lv_obj_set_style_shadow_opa(hi, LV_OPA_50, 0); + + lv_anim_t sa; + lv_anim_init(&sa); + lv_anim_set_var(&sa, hi); + lv_anim_set_exec_cb(&sa, translate_x_cb); + lv_anim_set_values(&sa, 0, SCAN_W - SCAN_HI_W); + lv_anim_set_duration(&sa, SCAN_MS); + lv_anim_set_playback_duration(&sa, SCAN_MS); + lv_anim_set_repeat_count(&sa, LV_ANIM_REPEAT_INFINITE); + lv_anim_set_path_cb(&sa, lv_anim_path_ease_in_out); + lv_anim_start(&sa); +} + +static void phase_advance_cb(lv_timer_t *t) { + (void)t; + s_phase_timer = NULL; + if (lv_screen_active() != s_screen || s_state != ST_SEARCHING) + return; + s_state = ST_FOUND; + build_screen(); +} + +static void build_found(void) { + ui_chrome_header(s_screen, HDR_TITLE, HDR_ICON); + ui_chrome_footer(s_screen, FOOTER_FOUND); + + lv_obj_t *card = make_lit_card(FOUND_CARD_H); + + lv_obj_t *title = lv_label_create(card); + lv_label_set_text(title, CARD_TITLE); + lv_obj_set_style_text_font(title, &lv_font_montserrat_14, 0); + lv_obj_set_style_text_color(title, current_theme.border_accent, 0); + lv_obj_align(title, LV_ALIGN_TOP_MID, 0, 0); + + lv_obj_t *cap = lv_label_create(card); + lv_label_set_text(cap, CAPTION_NEW); + lv_obj_set_style_text_font(cap, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(cap, lv_color_hex(COL_DIM), 0); + lv_obj_align(cap, LV_ALIGN_CENTER, 0, -20); + + lv_obj_t *pill = lv_obj_create(card); + lv_obj_remove_flag(pill, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(pill, PILL_W, PILL_H); + lv_obj_align(pill, LV_ALIGN_CENTER, 0, 10); + lv_obj_set_style_radius(pill, PILL_RAD, 0); + lv_obj_set_style_pad_all(pill, 0, 0); + lv_obj_set_style_bg_color(pill, current_theme.border_accent, 0); + lv_obj_set_style_bg_opa(pill, LV_OPA_20, 0); + lv_obj_set_style_border_color(pill, current_theme.border_accent, 0); + lv_obj_set_style_border_width(pill, 1, 0); + + lv_obj_t *ver = lv_label_create(pill); + lv_label_set_text(ver, NEW_VERSION); + lv_obj_set_style_text_font(ver, &lv_font_montserrat_14, 0); + lv_obj_set_style_text_color(ver, current_theme.border_accent, 0); + lv_obj_center(ver); + + lv_obj_t *inst = lv_label_create(card); + lv_label_set_text(inst, INSTALLED_ROW); + lv_obj_set_style_text_font(inst, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(inst, lv_color_hex(COL_DIM), 0); + lv_obj_align(inst, LV_ALIGN_BOTTOM_MID, 0, 0); +} + +static void set_step_label(void) { + if (s_step_lbl == NULL) + return; + if (s_pct < PH_VERIFY_MAX) { + lv_label_set_text(s_step_lbl, STEP_VERIFY); + lv_obj_set_style_text_color(s_step_lbl, current_theme.border_accent, 0); + } else if (s_pct < PH_ERASE_MAX) { + lv_label_set_text(s_step_lbl, STEP_ERASE); + lv_obj_set_style_text_color(s_step_lbl, current_theme.border_accent, 0); + } else if (s_pct < PH_WRITE_MAX) { + char buf[STEP_BUF_LEN]; + snprintf(buf, sizeof(buf), STEP_WRITE_FMT, s_pct); + lv_label_set_text(s_step_lbl, buf); + lv_obj_set_style_text_color(s_step_lbl, lv_color_hex(COL_SUCCESS), 0); + } else { + lv_label_set_text(s_step_lbl, STEP_FINALIZE); + lv_obj_set_style_text_color(s_step_lbl, lv_color_hex(COL_SUCCESS), 0); + } +} + +static void apply_tick_cb(lv_timer_t *t) { + if (lv_screen_active() != s_screen) { + lv_timer_delete(t); + s_apply_timer = NULL; + return; + } + + s_pct += APPLY_STEP; + if (s_pct >= PROG_MAX) + s_pct = PROG_MAX; + + if (s_bar) + lv_bar_set_value(s_bar, s_pct, LV_ANIM_OFF); + set_step_label(); + + if (s_pct >= PROG_MAX) { + lv_timer_delete(t); + s_apply_timer = NULL; + reboot_ui_reboot(); + } +} + +static void build_applying(void) { + ui_chrome_header(s_screen, HDR_TITLE, HDR_ICON); + ui_chrome_footer(s_screen, FOOTER_APPLY); + + lv_obj_t *card = make_lit_card(APPLY_CARD_H); + + lv_anim_t ga; + lv_anim_init(&ga); + lv_anim_set_var(&ga, card); + lv_anim_set_exec_cb(&ga, glow_anim_cb); + lv_anim_set_values(&ga, GLOW_MIN, GLOW_MAX); + lv_anim_set_duration(&ga, GLOW_MS); + lv_anim_set_playback_duration(&ga, GLOW_MS); + lv_anim_set_repeat_count(&ga, LV_ANIM_REPEAT_INFINITE); + lv_anim_set_path_cb(&ga, lv_anim_path_ease_in_out); + lv_anim_start(&ga); + + s_step_lbl = lv_label_create(card); + lv_obj_set_style_text_font(s_step_lbl, &lv_font_montserrat_14, 0); + lv_obj_set_style_text_color(s_step_lbl, current_theme.border_accent, 0); + lv_obj_set_style_text_align(s_step_lbl, LV_TEXT_ALIGN_CENTER, 0); + lv_obj_align(s_step_lbl, LV_ALIGN_CENTER, 0, STEP_LBL_Y); + + s_bar = lv_bar_create(card); + lv_obj_set_size(s_bar, BAR_W, BAR_H); + lv_obj_align(s_bar, LV_ALIGN_CENTER, 0, APPLY_BAR_Y); + lv_bar_set_range(s_bar, 0, PROG_MAX); + lv_bar_set_value(s_bar, 0, LV_ANIM_OFF); + lv_obj_set_style_bg_color(s_bar, lv_color_hex(COL_TRACK), LV_PART_MAIN); + lv_obj_set_style_bg_opa(s_bar, LV_OPA_COVER, LV_PART_MAIN); + lv_obj_set_style_radius(s_bar, BAR_RAD, LV_PART_MAIN); + lv_obj_set_style_bg_color(s_bar, current_theme.border_accent, LV_PART_INDICATOR); + lv_obj_set_style_bg_grad_color(s_bar, lv_color_hex(COL_SUCCESS), LV_PART_INDICATOR); + lv_obj_set_style_bg_grad_dir(s_bar, LV_GRAD_DIR_HOR, LV_PART_INDICATOR); + lv_obj_set_style_bg_opa(s_bar, LV_OPA_COVER, LV_PART_INDICATOR); + lv_obj_set_style_radius(s_bar, BAR_RAD, LV_PART_INDICATOR); + + s_pct = 0; + set_step_label(); + ui_feedback(UI_FB_WRITE); + + s_apply_timer = lv_timer_create(apply_tick_cb, APPLY_TICK_MS, NULL); +} + +static void build_screen(void) { + stop_phase_timer(); + stop_apply_timer(); + if (s_screen != NULL) { + lv_obj_del(s_screen); + s_screen = NULL; + } + s_bar = NULL; + s_step_lbl = NULL; + + s_screen = lv_obj_create(NULL); + lv_obj_set_style_bg_color(s_screen, current_theme.screen_base, 0); + lv_obj_set_style_bg_opa(s_screen, LV_OPA_COVER, 0); + lv_obj_remove_flag(s_screen, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_border_width(s_screen, 0, 0); + lv_obj_set_style_pad_all(s_screen, 0, 0); + + switch (s_state) { + case ST_FOUND: + build_found(); + break; + case ST_APPLYING: + build_applying(); + break; + case ST_SEARCHING: + default: + build_searching(); + s_phase_timer = lv_timer_create(phase_advance_cb, SEARCH_MS, NULL); + lv_timer_set_repeat_count(s_phase_timer, 1); + break; + } + + ui_input_set_screen_handler(system_update_input, NULL); + + ui_screen_load_owned(&s_screen, s_screen); +} + +static void system_update_input(const input_event_t *ev, void *ctx) { + (void)ctx; + if (ev->action != INPUT_ACTION_PRESS) + return; + if (s_state == ST_APPLYING) + return; + + switch (ev->button) { + case INPUT_BTN_BACK: + case INPUT_BTN_LEFT: + ui_switch_screen(SCREEN_DEV_MENU); + break; + case INPUT_BTN_OK: + if (s_state == ST_FOUND) { + ui_feedback(UI_FB_SELECT); + s_state = ST_APPLYING; + build_screen(); + } + break; + default: + break; + } +} + +void ui_system_update_open(void) { + s_phase_timer = NULL; + s_apply_timer = NULL; + s_bar = NULL; + s_step_lbl = NULL; + s_state = ST_SEARCHING; + s_pct = 0; + + build_screen(); +} diff --git a/firmware_p4/components/Applications/ui/screens/settings/time_settings_ui.c b/firmware_p4/components/Applications/ui/screens/settings/time_settings_ui.c new file mode 100644 index 000000000..35b3a228c --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/settings/time_settings_ui.c @@ -0,0 +1,312 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#include "time_settings_ui.h" + +#include +#include +#include + +#include "lvgl.h" + +#include "notify_ui.h" +#include "sys_time.h" +#include "ui_chrome.h" +#include "ui_feedback.h" +#include "ui_manager.h" +#include "ui_theme.h" + +#define TIME_ICON "/assets/icons/timer.bin" +#define TIME_FONT "A:assets/fonts/Inter.bin" + +#define YEAR_MIN 2020 +#define YEAR_MAX 2099 + +#define FIELD_YEAR 0 +#define FIELD_MONTH 1 +#define FIELD_DAY 2 +#define FIELD_HOUR 3 +#define FIELD_MIN 4 +#define FIELD_COUNT 5 + +#define COL_DIM 0x6D7A75 +#define CARD_W 208 + +static lv_obj_t *s_screen = NULL; +static lv_obj_t *s_fields[FIELD_COUNT]; +static lv_obj_t *s_wkday = NULL; +static lv_font_t *s_big_font = NULL; +static struct tm s_tm; +static int s_focus = 0; + +static void str_upper(char *s) { + for (; *s != '\0'; s++) + if (*s >= 'a' && *s <= 'z') + *s = (char)(*s - 'a' + 'A'); +} + +static int days_in_month(int year, int mon0) { + static const int base[12] = {31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31}; + if (mon0 == 1) { + bool leap = (year % 4 == 0 && (year % 100 != 0 || year % 400 == 0)); + return leap ? 29 : 28; + } + return base[mon0]; +} + +static void clamp_day(void) { + int dim = days_in_month(s_tm.tm_year + 1900, s_tm.tm_mon); + if (s_tm.tm_mday > dim) + s_tm.tm_mday = dim; + if (s_tm.tm_mday < 1) + s_tm.tm_mday = 1; +} + +static void field_adjust(int delta) { + switch (s_focus) { + case FIELD_YEAR: { + int y = s_tm.tm_year + 1900 + delta; + if (y < YEAR_MIN) + y = YEAR_MAX; + if (y > YEAR_MAX) + y = YEAR_MIN; + s_tm.tm_year = y - 1900; + clamp_day(); + break; + } + case FIELD_MONTH: + s_tm.tm_mon += delta; + if (s_tm.tm_mon < 0) + s_tm.tm_mon = 11; + if (s_tm.tm_mon > 11) + s_tm.tm_mon = 0; + clamp_day(); + break; + case FIELD_DAY: { + int dim = days_in_month(s_tm.tm_year + 1900, s_tm.tm_mon); + s_tm.tm_mday += delta; + if (s_tm.tm_mday < 1) + s_tm.tm_mday = dim; + if (s_tm.tm_mday > dim) + s_tm.tm_mday = 1; + break; + } + case FIELD_HOUR: + s_tm.tm_hour += delta; + if (s_tm.tm_hour < 0) + s_tm.tm_hour = 23; + if (s_tm.tm_hour > 23) + s_tm.tm_hour = 0; + break; + case FIELD_MIN: + s_tm.tm_min += delta; + if (s_tm.tm_min < 0) + s_tm.tm_min = 59; + if (s_tm.tm_min > 59) + s_tm.tm_min = 0; + break; + default: + break; + } +} + +static void update_display(void) { + char buf[16]; + snprintf(buf, sizeof(buf), "%04d", s_tm.tm_year + 1900); + lv_label_set_text(s_fields[FIELD_YEAR], buf); + snprintf(buf, sizeof(buf), "%02d", s_tm.tm_mon + 1); + lv_label_set_text(s_fields[FIELD_MONTH], buf); + snprintf(buf, sizeof(buf), "%02d", s_tm.tm_mday); + lv_label_set_text(s_fields[FIELD_DAY], buf); + snprintf(buf, sizeof(buf), "%02d", s_tm.tm_hour); + lv_label_set_text(s_fields[FIELD_HOUR], buf); + snprintf(buf, sizeof(buf), "%02d", s_tm.tm_min); + lv_label_set_text(s_fields[FIELD_MIN], buf); + + for (int i = 0; i < FIELD_COUNT; i++) { + bool sel = (i == s_focus); + lv_obj_set_style_text_color( + s_fields[i], sel ? current_theme.border_accent : current_theme.text_main, 0); + lv_obj_set_style_border_width(s_fields[i], sel ? 2 : 0, 0); + } + + struct tm t = s_tm; + t.tm_sec = 0; + t.tm_isdst = 0; + mktime(&t); + char wk[16]; + if (strftime(wk, sizeof(wk), "%A", &t) == 0) + wk[0] = '\0'; + str_upper(wk); + lv_label_set_text(s_wkday, wk); +} + +static void apply_and_save(void) { + struct tm t = s_tm; + t.tm_sec = 0; + t.tm_isdst = 0; + time_t epoch = mktime(&t); + if (epoch == (time_t)-1) { + notify(NOTIFY_WARNING, "Invalid date"); + return; + } + if (sys_time_set(epoch, SYS_TIME_SOURCE_MANUAL) == ESP_OK) + notify(NOTIFY_SAVED, "Time saved"); + else + notify(NOTIFY_WARNING, "Could not set time"); +} + +static lv_obj_t *make_field(lv_obj_t *parent, const lv_font_t *font) { + lv_obj_t *l = lv_label_create(parent); + lv_obj_set_style_text_font(l, font, 0); + lv_obj_set_style_text_color(l, current_theme.text_main, 0); + lv_obj_set_style_border_color(l, current_theme.border_accent, 0); + lv_obj_set_style_border_side(l, LV_BORDER_SIDE_BOTTOM, 0); + lv_obj_set_style_border_width(l, 0, 0); + lv_obj_set_style_pad_left(l, 3, 0); + lv_obj_set_style_pad_right(l, 3, 0); + lv_obj_set_style_pad_bottom(l, 2, 0); + return l; +} + +static void make_sep(lv_obj_t *parent, const char *txt, const lv_font_t *font) { + lv_obj_t *l = lv_label_create(parent); + lv_label_set_text(l, txt); + lv_obj_set_style_text_font(l, font, 0); + lv_obj_set_style_text_color(l, lv_color_hex(COL_DIM), 0); +} + +static lv_obj_t *make_row(lv_obj_t *parent) { + lv_obj_t *row = lv_obj_create(parent); + lv_obj_remove_style_all(row); + lv_obj_set_size(row, LV_SIZE_CONTENT, LV_SIZE_CONTENT); + lv_obj_set_flex_flow(row, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(row, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_set_style_pad_column(row, 4, 0); + return row; +} + +static void time_input(const input_event_t *ev, void *ctx) { + (void)ctx; + const bool press = (ev->action == INPUT_ACTION_PRESS); + const bool nav = press || (ev->action == INPUT_ACTION_REPEAT); + + switch (ev->button) { + case INPUT_BTN_LEFT: + if (nav) { + s_focus = (s_focus == 0) ? FIELD_COUNT - 1 : s_focus - 1; + ui_feedback(UI_FB_NAV); + update_display(); + } + break; + case INPUT_BTN_RIGHT: + if (nav) { + s_focus = (s_focus + 1) % FIELD_COUNT; + ui_feedback(UI_FB_NAV); + update_display(); + } + break; + case INPUT_BTN_UP: + if (nav) { + field_adjust(+1); + ui_feedback(UI_FB_NAV); + update_display(); + } + break; + case INPUT_BTN_DOWN: + if (nav) { + field_adjust(-1); + ui_feedback(UI_FB_NAV); + update_display(); + } + break; + case INPUT_BTN_OK: + if (press) { + apply_and_save(); + ui_feedback(UI_FB_SELECT); + } + break; + case INPUT_BTN_BACK: + if (press) + ui_switch_screen(SCREEN_SETTINGS); + break; + default: + break; + } +} + +void ui_time_open(void) { + if (s_screen != NULL) { + lv_obj_del(s_screen); + s_screen = NULL; + } + + time_t now = sys_time_now(); + localtime_r(&now, &s_tm); + s_focus = 0; + + if (s_big_font == NULL) + s_big_font = lv_binfont_create(TIME_FONT); + const lv_font_t *bigf = s_big_font != NULL ? s_big_font : &lv_font_montserrat_16; + + s_screen = lv_obj_create(NULL); + lv_obj_set_style_bg_color(s_screen, current_theme.screen_base, 0); + lv_obj_set_style_bg_opa(s_screen, LV_OPA_COVER, 0); + lv_obj_remove_flag(s_screen, LV_OBJ_FLAG_SCROLLABLE); + + ui_chrome_header(s_screen, "DATE & TIME", TIME_ICON); + ui_chrome_footer(s_screen, + LV_SYMBOL_OK " save " LV_SYMBOL_UP LV_SYMBOL_DOWN + " edit " LV_SYMBOL_LEFT LV_SYMBOL_RIGHT " field"); + + lv_obj_t *card = lv_obj_create(s_screen); + lv_obj_set_size(card, CARD_W, LV_SIZE_CONTENT); + lv_obj_align(card, LV_ALIGN_CENTER, 0, (UI_CHROME_HEADER_H - UI_CHROME_FOOTER_H) / 2); + lv_obj_remove_flag(card, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_bg_color(card, current_theme.bg_secondary, 0); + lv_obj_set_style_bg_opa(card, LV_OPA_COVER, 0); + lv_obj_set_style_border_color(card, current_theme.border_accent, 0); + lv_obj_set_style_border_width(card, 1, 0); + lv_obj_set_style_radius(card, 16, 0); + lv_obj_set_style_pad_all(card, 16, 0); + lv_obj_set_style_pad_row(card, 10, 0); + lv_obj_set_style_shadow_width(card, 22, 0); + lv_obj_set_style_shadow_color(card, current_theme.border_accent, 0); + lv_obj_set_style_shadow_opa(card, LV_OPA_30, 0); + lv_obj_set_flex_flow(card, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(card, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + + lv_obj_t *drow = make_row(card); + s_fields[FIELD_YEAR] = make_field(drow, &lv_font_montserrat_16); + make_sep(drow, "-", &lv_font_montserrat_16); + s_fields[FIELD_MONTH] = make_field(drow, &lv_font_montserrat_16); + make_sep(drow, "-", &lv_font_montserrat_16); + s_fields[FIELD_DAY] = make_field(drow, &lv_font_montserrat_16); + + s_wkday = lv_label_create(card); + lv_obj_set_style_text_font(s_wkday, &lv_font_montserrat_14, 0); + lv_obj_set_style_text_color(s_wkday, lv_color_hex(COL_DIM), 0); + lv_obj_set_style_text_letter_space(s_wkday, 2, 0); + + lv_obj_t *trow = make_row(card); + s_fields[FIELD_HOUR] = make_field(trow, bigf); + make_sep(trow, ":", bigf); + s_fields[FIELD_MIN] = make_field(trow, bigf); + + update_display(); + + ui_input_set_screen_handler(time_input, NULL); + ui_screen_load_owned(&s_screen, s_screen); +} diff --git a/firmware_p4/components/Applications/ui/screens/sound_settings/sound_settings_ui.c b/firmware_p4/components/Applications/ui/screens/sound_settings/sound_settings_ui.c deleted file mode 100644 index ecf61cdd6..000000000 --- a/firmware_p4/components/Applications/ui/screens/sound_settings/sound_settings_ui.c +++ /dev/null @@ -1,117 +0,0 @@ -// Copyright (c) 2025 HIGH CODE LLC -// -// TentacleOS is free software: you can redistribute it and/or modify -// it under the terms of the GNU General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// TentacleOS is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU General Public License for more details. -// -// You should have received a copy of the GNU General Public License -// along with TentacleOS. If not, see . - -#include "sound_settings_ui.h" - -#include "ui_theme.h" -#include "menu_component_ui.h" -#include "ui_manager.h" -#include "buttons_gpio.h" - -#define NAV_TIMER_PERIOD_MS 50 -#define VOLUME_DEFAULT 3 - -typedef enum { - SOUND_ITEM_VOLUME = 0, - SOUND_ITEM_BUZZER = 1, -} sound_item_t; - -static lv_obj_t *s_screen = NULL; -static menu_component_t s_menu; -static lv_timer_t *s_nav_timer = NULL; - -static bool s_btn_up_last = false; -static bool s_btn_down_last = false; -static bool s_btn_left_last = false; -static bool s_btn_right_last = false; -static bool s_btn_back_last = false; - -static int s_volume_val = VOLUME_DEFAULT; - -static void nav_timer_cb(lv_timer_t *t); - -static void nav_timer_cb(lv_timer_t *t) { - if (lv_screen_active() != s_screen) { - lv_timer_delete(t); - s_nav_timer = NULL; - return; - } - if (ui_input_is_locked()) - return; - - bool up = up_button_is_down(); - bool down = down_button_is_down(); - bool left = left_button_is_down(); - bool right = right_button_is_down(); - bool back = back_button_is_down(); - - if (down && !s_btn_down_last) - menu_component_next(&s_menu); - - if (up && !s_btn_up_last) - menu_component_prev(&s_menu); - - if (back && !s_btn_back_last) { - ui_switch_screen(SCREEN_SETTINGS); - return; - } - - int sel = menu_component_get_selected(&s_menu); - - if (left && !s_btn_left_last) { - if (sel == SOUND_ITEM_VOLUME) { - menu_component_intensity_dec(&s_menu, SOUND_ITEM_VOLUME); - s_volume_val = menu_component_get_intensity(&s_menu, SOUND_ITEM_VOLUME); - } else if (sel == SOUND_ITEM_BUZZER) { - menu_component_toggle_item(&s_menu, SOUND_ITEM_BUZZER); - } - } - - if (right && !s_btn_right_last) { - if (sel == SOUND_ITEM_VOLUME) { - menu_component_intensity_inc(&s_menu, SOUND_ITEM_VOLUME); - s_volume_val = menu_component_get_intensity(&s_menu, SOUND_ITEM_VOLUME); - } else if (sel == SOUND_ITEM_BUZZER) { - menu_component_toggle_item(&s_menu, SOUND_ITEM_BUZZER); - } - } - - s_btn_up_last = up; - s_btn_down_last = down; - s_btn_left_last = left; - s_btn_right_last = right; - s_btn_back_last = back; -} - -void ui_sound_settings_open(void) { - if (s_screen != NULL) { - lv_obj_del(s_screen); - s_screen = NULL; - } - - s_screen = lv_obj_create(NULL); - lv_obj_set_style_bg_color(s_screen, current_theme.screen_base, 0); - lv_obj_set_style_bg_opa(s_screen, LV_OPA_COVER, 0); - lv_obj_remove_flag(s_screen, LV_OBJ_FLAG_SCROLLABLE); - - s_menu = menu_component_create(s_screen, "SOUND", NULL); - menu_component_add_intensity(&s_menu, NULL, "VOLUME", s_volume_val); - menu_component_add_toggle(&s_menu, NULL, "BUZZER", false); - - if (s_nav_timer == NULL) - s_nav_timer = lv_timer_create(nav_timer_cb, NAV_TIMER_PERIOD_MS, NULL); - - lv_screen_load(s_screen); -} \ No newline at end of file diff --git a/firmware_p4/components/Applications/ui/screens/sub_example/sub_example_ui.c b/firmware_p4/components/Applications/ui/screens/sub_example/sub_example_ui.c deleted file mode 100644 index 1f55609f6..000000000 --- a/firmware_p4/components/Applications/ui/screens/sub_example/sub_example_ui.c +++ /dev/null @@ -1,155 +0,0 @@ -// Copyright (c) 2025 HIGH CODE LLC -// -// TentacleOS is free software: you can redistribute it and/or modify -// it under the terms of the GNU General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// TentacleOS is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU General Public License for more details. -// -// You should have received a copy of the GNU General Public License -// along with TentacleOS. If not, see . - -#include "sub_example_ui.h" - -#include "ui_theme.h" -#include "header_ui.h" -#include "footer_ui.h" - -#include "core/lv_group.h" -#include "ui_manager.h" -#include "lv_port_indev.h" -#include "esp_log.h" - -#define BG_COLOR current_theme.screen_base -#define COLOR_BORDER 0x834EC6 -#define COLOR_GRADIENT_TOP 0x000000 -#define COLOR_GRADIENT_BOT 0x2E0157 - -static lv_obj_t *screen_sub_example = NULL; -static lv_style_t style_menu; -static lv_style_t style_btn; -static bool styles_initialized = false; - -static void init_styles(void) { - if (styles_initialized) - return; - - lv_style_init(&style_menu); - lv_style_set_bg_opa(&style_menu, LV_OPA_TRANSP); - lv_style_set_border_width(&style_menu, 2); - lv_style_set_border_color(&style_menu, lv_color_hex(COLOR_BORDER)); - lv_style_set_radius(&style_menu, 6); - lv_style_set_pad_all(&style_menu, 10); - lv_style_set_pad_row(&style_menu, 10); - - lv_style_init(&style_btn); - lv_style_set_bg_color(&style_btn, lv_color_hex(COLOR_GRADIENT_BOT)); - lv_style_set_bg_grad_color(&style_btn, lv_color_hex(COLOR_GRADIENT_TOP)); - lv_style_set_bg_grad_dir(&style_btn, LV_GRAD_DIR_VER); - lv_style_set_border_width(&style_btn, 2); - lv_style_set_border_color(&style_btn, lv_color_hex(COLOR_BORDER)); - lv_style_set_radius(&style_btn, 6); - - styles_initialized = true; -} - -static void menu_item_event_cb(lv_event_t *e) { - lv_obj_t *img_sel = lv_event_get_user_data(e); - lv_event_code_t code = lv_event_get_code(e); - - if (code == LV_EVENT_FOCUSED) { - lv_obj_clear_flag(img_sel, LV_OBJ_FLAG_HIDDEN); - } else if (code == LV_EVENT_DEFOCUSED) { - lv_obj_add_flag(img_sel, LV_OBJ_FLAG_HIDDEN); - } -} - -static void create_menu(lv_obj_t *parent) { - init_styles(); - - lv_coord_t menu_h = 240 - 24 - 20; - - lv_obj_t *menu = lv_obj_create(parent); - lv_obj_set_size(menu, 240, menu_h); - lv_obj_align(menu, LV_ALIGN_CENTER, 0, 2); - lv_obj_add_style(menu, &style_menu, 0); - lv_obj_set_scroll_dir(menu, LV_DIR_VER); - lv_obj_set_scrollbar_mode(menu, LV_SCROLLBAR_MODE_OFF); - lv_obj_set_flex_flow(menu, LV_FLEX_FLOW_COLUMN); - - static const void *wifi_icon = NULL; - static const void *select_icon = NULL; - - if (!wifi_icon) - wifi_icon = "A:/icons/WIFI_ICON_MENU.png"; - if (!select_icon) - select_icon = "A:/UI/MENU_SELECT.png"; - - for (int i = 0; i < 4; i++) { - lv_obj_t *btn = lv_btn_create(menu); - lv_obj_set_size(btn, lv_pct(100), 40); - lv_obj_add_style(btn, &style_btn, 0); - lv_obj_set_style_anim_time(btn, 0, 0); - - lv_obj_t *img_left = lv_img_create(btn); - lv_img_set_src(img_left, wifi_icon); - lv_obj_align(img_left, LV_ALIGN_LEFT_MID, 8, 0); - lv_obj_set_style_img_recolor_opa(img_left, LV_OPA_0, 0); - - lv_obj_t *lbl = lv_label_create(btn); - lv_label_set_text_static(lbl, "EXAMPLE"); - lv_obj_center(lbl); - - lv_obj_t *img_sel = lv_img_create(btn); - lv_img_set_src(img_sel, select_icon); - lv_obj_align(img_sel, LV_ALIGN_RIGHT_MID, -8, 0); - lv_obj_add_flag(img_sel, LV_OBJ_FLAG_HIDDEN); - lv_obj_set_style_img_recolor_opa(img_sel, LV_OPA_0, 0); - - lv_obj_add_event_cb(btn, menu_item_event_cb, LV_EVENT_FOCUSED, img_sel); - lv_obj_add_event_cb(btn, menu_item_event_cb, LV_EVENT_DEFOCUSED, img_sel); - - if (main_group) { - lv_group_add_obj(main_group, btn); - } - } -} - -static void sub_example_event_cb(lv_event_t *e) { - if (lv_event_get_code(e) == LV_EVENT_KEY) { - if (lv_event_get_key(e) == LV_KEY_LEFT) { - ui_switch_screen(SCREEN_HOME); - } - } -} - -void ui_sub_example_open(void) { - if (screen_sub_example) { - lv_obj_del(screen_sub_example); - screen_sub_example = NULL; - } - - screen_sub_example = lv_obj_create(NULL); - lv_obj_set_style_bg_color(screen_sub_example, BG_COLOR, 0); - lv_obj_remove_flag(screen_sub_example, LV_OBJ_FLAG_SCROLLABLE); - - header_ui_create(screen_sub_example); - footer_ui_create(screen_sub_example); - create_menu(screen_sub_example); - - lv_obj_add_event_cb(screen_sub_example, sub_example_event_cb, LV_EVENT_KEY, NULL); - - lv_screen_load(screen_sub_example); -} - -void ui_sub_example_cleanup(void) { - if (styles_initialized) { - lv_style_reset(&style_menu); - lv_style_reset(&style_btn); - styles_initialized = false; - } -} \ No newline at end of file diff --git a/firmware_p4/components/Applications/ui/screens/subghz/include/subghz_brute_ui.h b/firmware_p4/components/Applications/ui/screens/subghz/include/subghz_brute_ui.h new file mode 100644 index 000000000..2fd469fe3 --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/subghz/include/subghz_brute_ui.h @@ -0,0 +1,30 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#ifndef SUBGHZ_BRUTE_UI_H +#define SUBGHZ_BRUTE_UI_H + +#ifdef __cplusplus +extern "C" { +#endif + +/** @brief Open the Sub-GHz brute-force transmit screen. */ +void ui_subghz_brute_open(void); + +#ifdef __cplusplus +} +#endif + +#endif // SUBGHZ_BRUTE_UI_H diff --git a/firmware_p4/components/Applications/ui/screens/subghz/include/subghz_config_ui.h b/firmware_p4/components/Applications/ui/screens/subghz/include/subghz_config_ui.h new file mode 100644 index 000000000..d96324d90 --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/subghz/include/subghz_config_ui.h @@ -0,0 +1,30 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#ifndef SUBGHZ_CONFIG_UI_H +#define SUBGHZ_CONFIG_UI_H + +#ifdef __cplusplus +extern "C" { +#endif + +/** @brief Open the Sub-GHz radio configuration screen (tuner dashboard mock). */ +void ui_subghz_config_open(void); + +#ifdef __cplusplus +} +#endif + +#endif // SUBGHZ_CONFIG_UI_H diff --git a/firmware_p4/components/Applications/ui/screens/badusb/include/ui_badusb_running.h b/firmware_p4/components/Applications/ui/screens/subghz/include/subghz_menu_ui.h similarity index 65% rename from firmware_p4/components/Applications/ui/screens/badusb/include/ui_badusb_running.h rename to firmware_p4/components/Applications/ui/screens/subghz/include/subghz_menu_ui.h index 1addf2ad7..4405ffabb 100644 --- a/firmware_p4/components/Applications/ui/screens/badusb/include/ui_badusb_running.h +++ b/firmware_p4/components/Applications/ui/screens/subghz/include/subghz_menu_ui.h @@ -13,21 +13,25 @@ // You should have received a copy of the GNU General Public License // along with TentacleOS. If not, see . -#ifndef UI_BADUSB_RUNNING_H -#define UI_BADUSB_RUNNING_H +#ifndef UI_SUBGHZ_MENU_H +#define UI_SUBGHZ_MENU_H #ifdef __cplusplus extern "C" { #endif -/** @brief Open the BadUSB running screen. */ -void ui_badusb_running_open(void); +/** @brief Open the Sub-GHz menu screen (MOCK): Read / Read RAW / Analyzer / Brute / Saved. No + * radio. */ +void ui_subghz_menu_open(void); -/** @brief Set the script name displayed on the running screen. */ -void ui_badusb_running_set_script(const char *name); +/** + * @brief Open the Sub-GHz "Read" capture screen (MOCK): scanning waves -> canned + * captured signal -> save prompt. No radio. + */ +void ui_subghz_read_open(void); #ifdef __cplusplus } #endif -#endif // UI_BADUSB_RUNNING_H +#endif // UI_SUBGHZ_MENU_H diff --git a/firmware_p4/components/Applications/ui/screens/subghz/include/subghz_send_ui.h b/firmware_p4/components/Applications/ui/screens/subghz/include/subghz_send_ui.h new file mode 100644 index 000000000..6c42ca243 --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/subghz/include/subghz_send_ui.h @@ -0,0 +1,34 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#ifndef UI_SUBGHZ_SEND_H +#define UI_SUBGHZ_SEND_H + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * @brief Open the Sub-GHz "Send" screen (MOCK): pick a saved signal from a list, + * then a pulse-train transmit animation that auto-confirms "Sent!". No + * radio. + */ +void ui_subghz_send_open(void); + +#ifdef __cplusplus +} +#endif + +#endif // UI_SUBGHZ_SEND_H diff --git a/firmware_p4/components/Applications/ui/screens/subghz/subghz_brute_ui.c b/firmware_p4/components/Applications/ui/screens/subghz/subghz_brute_ui.c new file mode 100644 index 000000000..395490e66 --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/subghz/subghz_brute_ui.c @@ -0,0 +1,523 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#include "subghz_brute_ui.h" + +#include + +#include "esp_log.h" +#include "lvgl.h" + +#include "capture_result_ui.h" +#include "msgbox_ui.h" +#include "notify_ui.h" +#include "ui_chrome.h" +#include "ui_feedback.h" +#include "ui_manager.h" +#include "ui_theme.h" + +static const char *TAG = "SUBGHZ_BF"; + +#define TICK_MS 33 +#define REVEAL_MS 3000 +#define BRUTE_MS 4200 +#define SCOPE_TICK_MS 38 +#define DOT_CYCLE_MS 350 + +#define SIG_GREEN 0x00E676 + +#define STATUS_Y 48 +#define FREQ_Y 68 + +#define SCOPE_W 208 +#define SCOPE_H 84 +#define SCOPE_Y_OFS -22 +#define SCOPE_PAD 6 +#define SCOPE_RADIUS 8 +#define SCOPE_BORDER 2 +#define SCOPE_BG 0x0A0614 + +#define WAVE_POINTS 49 +#define WAVE_W (SCOPE_W - SCOPE_PAD * 2 - SCOPE_BORDER * 2) +#define WAVE_H (SCOPE_H - SCOPE_PAD * 2 - SCOPE_BORDER * 2) +#define WAVE_CY (WAVE_H / 2) +#define WAVE_LINE_W 2 + +#define AMP_SCAN (WAVE_H / 2 - 4) +#define AMP_VAR (WAVE_H / 6) +#define AMP_LOCK (WAVE_H / 3) +#define ANGLE_STEP_BASE 15 +#define ANGLE_VAR 9 +#define ANGLE_STEP_LOCK 15 +#define PHASE_STEP_SCAN 34 +#define MOD_STEP 6 +#define NOISE_SPREAD 7 + +#define OOK_SYNC_T 2 +#define OOK_HI_WIDE 3 +#define OOK_HI_NARROW 1 +#define OOK_MAX_PTS 64 + +#define GRID_OPA LV_OPA_20 + +#define BAR_W 192 +#define BAR_H 8 +#define BAR_Y 210 +#define CODES_Y 234 +#define HIT_Y 258 +#define TRACK_COL 0x202028 + +#define CARD_W 210 +#define CARD_H 96 +#define CARD_Y 190 +#define CARD_RADIUS 12 +#define CARD_SHADOW_W 14 +#define CARD_SHADOW_SPREAD -3 + +#define STATUS_RUN "Transmitting" +#define HINT_RUN "BACK to stop" +#define HINT_SHOW "BACK = Exit" +#define HINT_MENU "UP/DOWN choose OK do BACK exit" + +#define BRUTE_PROTO "Princeton" +#define BRUTE_FREQ "433.92 MHz" +#define BRUTE_HIT "0x1A2B3C" +#define BRUTE_TOTAL 4096 +#define BRUTE_HIT_N (BRUTE_TOTAL / 3) + +static const uint8_t OOK_BITS[] = {0, 0, 0, 1, 1, 0}; +#define OOK_BIT_COUNT ((int)(sizeof(OOK_BITS) / sizeof(OOK_BITS[0]))) + +static lv_obj_t *s_screen = NULL; +static lv_timer_t *s_tick_timer = NULL; +static lv_timer_t *s_scope_timer = NULL; + +static lv_obj_t *s_status = NULL; +static lv_obj_t *s_freq = NULL; +static lv_obj_t *s_scope = NULL; +static lv_obj_t *s_wave = NULL; +static lv_obj_t *s_card = NULL; +static lv_obj_t *s_bar = NULL; +static lv_obj_t *s_codes = NULL; +static lv_obj_t *s_hit = NULL; +static lv_obj_t *s_hint = NULL; + +static capture_result_t s_cr = {0}; +static lv_point_precise_t s_wave_pts[WAVE_POINTS]; +static lv_point_precise_t s_ook_pts[OOK_MAX_PTS]; +static int s_phase = 0; +static int s_mod = 0; +static int s_code_count = 0; +static uint32_t s_run_start = 0; +static uint32_t s_locked_at = 0; +static bool s_locked = false; +static bool s_options = false; +static bool s_saved = false; + +static void brute_tick_cb(lv_timer_t *t); +static void scope_tick_cb(lv_timer_t *t); +static void subghz_brute_input(const input_event_t *ev, void *ctx); + +static void stop_timer(lv_timer_t **t) { + if (*t != NULL) { + lv_timer_delete(*t); + *t = NULL; + } +} + +static int clamp_y(int y) { + if (y < 0) + return 0; + if (y > WAVE_H) + return WAVE_H; + return y; +} + +static void fill_wave(bool noisy) { + int step = ANGLE_STEP_LOCK; + int amp = AMP_LOCK; + if (noisy) { + step = ANGLE_STEP_BASE + (ANGLE_VAR * lv_trigo_sin((int16_t)(s_mod % 360))) / 32767; + amp = AMP_SCAN - (AMP_VAR * lv_trigo_sin((int16_t)((s_mod * 2) % 360))) / 32767; + } + for (int i = 0; i < WAVE_POINTS; i++) { + int ang = (s_phase + i * step) % 360; + if (ang < 0) + ang += 360; + int s = lv_trigo_sin((int16_t)ang); + int y = WAVE_CY - (amp * s) / 32767; + if (noisy) + y += ((i * 13 + s_phase) % NOISE_SPREAD) - NOISE_SPREAD / 2; + s_wave_pts[i].x = i * WAVE_W / (WAVE_POINTS - 1); + s_wave_pts[i].y = clamp_y(y); + } + if (s_wave != NULL) + lv_line_set_points(s_wave, s_wave_pts, WAVE_POINTS); +} + +static void fill_ook(void) { + if (s_wave == NULL) + return; + int total_t = OOK_SYNC_T + OOK_BIT_COUNT * (OOK_HI_WIDE + OOK_HI_NARROW); + int unit = WAVE_W / total_t; + if (unit < 1) + unit = 1; + int hi = WAVE_CY - AMP_LOCK; + int lo = WAVE_CY + AMP_LOCK; + int n = 0; + int x = 0; + s_ook_pts[n].x = x; + s_ook_pts[n].y = lo; + n++; + x += OOK_SYNC_T * unit; + s_ook_pts[n].x = x; + s_ook_pts[n].y = lo; + n++; + for (int b = 0; b < OOK_BIT_COUNT && n + 4 <= OOK_MAX_PTS; b++) { + int hw = (OOK_BITS[b] ? OOK_HI_WIDE : OOK_HI_NARROW) * unit; + int lw = (OOK_BITS[b] ? OOK_HI_NARROW : OOK_HI_WIDE) * unit; + s_ook_pts[n].x = x; + s_ook_pts[n].y = hi; + n++; + x += hw; + s_ook_pts[n].x = x; + s_ook_pts[n].y = hi; + n++; + s_ook_pts[n].x = x; + s_ook_pts[n].y = lo; + n++; + x += lw; + s_ook_pts[n].x = x; + s_ook_pts[n].y = lo; + n++; + } + lv_line_set_points(s_wave, s_ook_pts, n); +} + +static void build_scope(void) { + lv_obj_t *frame = lv_obj_create(s_screen); + s_scope = frame; + lv_obj_remove_flag(frame, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(frame, SCOPE_W, SCOPE_H); + lv_obj_align(frame, LV_ALIGN_CENTER, 0, SCOPE_Y_OFS); + lv_obj_set_style_radius(frame, SCOPE_RADIUS, 0); + lv_obj_set_style_pad_all(frame, SCOPE_PAD, 0); + lv_obj_set_style_bg_color(frame, lv_color_hex(SCOPE_BG), 0); + lv_obj_set_style_bg_opa(frame, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(frame, SCOPE_BORDER, 0); + lv_obj_set_style_border_color(frame, current_theme.border_accent, 0); + lv_obj_set_style_border_opa(frame, LV_OPA_70, 0); + + lv_obj_t *grid = lv_obj_create(frame); + lv_obj_remove_flag(grid, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(grid, WAVE_W, 1); + lv_obj_align(grid, LV_ALIGN_CENTER, 0, 0); + lv_obj_set_style_border_width(grid, 0, 0); + lv_obj_set_style_radius(grid, 0, 0); + lv_obj_set_style_bg_color(grid, current_theme.border_accent, 0); + lv_obj_set_style_bg_opa(grid, GRID_OPA, 0); + + s_wave = lv_line_create(frame); + lv_obj_align(s_wave, LV_ALIGN_TOP_LEFT, 0, 0); + lv_obj_set_style_line_width(s_wave, WAVE_LINE_W, 0); + lv_obj_set_style_line_color(s_wave, current_theme.border_accent, 0); + lv_obj_set_style_line_rounded(s_wave, true, 0); + + s_phase = 0; + s_mod = 0; + fill_wave(true); +} + +static void build_readout(void) { + s_card = lv_obj_create(s_screen); + lv_obj_remove_flag(s_card, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(s_card, CARD_W, CARD_H); + lv_obj_align(s_card, LV_ALIGN_TOP_MID, 0, CARD_Y); + lv_obj_set_style_radius(s_card, CARD_RADIUS, 0); + lv_obj_set_style_bg_color(s_card, current_theme.bg_secondary, 0); + lv_obj_set_style_bg_opa(s_card, LV_OPA_COVER, 0); + lv_obj_set_style_bg_grad_dir(s_card, LV_GRAD_DIR_NONE, 0); + lv_obj_set_style_border_width(s_card, 1, 0); + lv_obj_set_style_border_color(s_card, current_theme.border_accent, 0); + lv_obj_set_style_shadow_color(s_card, current_theme.border_accent, 0); + lv_obj_set_style_shadow_width(s_card, CARD_SHADOW_W, 0); + lv_obj_set_style_shadow_opa(s_card, LV_OPA_40, 0); + lv_obj_set_style_shadow_spread(s_card, CARD_SHADOW_SPREAD, 0); + lv_obj_set_style_pad_all(s_card, 0, 0); + + s_bar = lv_bar_create(s_screen); + lv_obj_set_size(s_bar, BAR_W, BAR_H); + lv_obj_align(s_bar, LV_ALIGN_TOP_MID, 0, BAR_Y); + lv_obj_set_style_radius(s_bar, 4, 0); + lv_obj_set_style_bg_color(s_bar, lv_color_hex(TRACK_COL), 0); + lv_obj_set_style_bg_opa(s_bar, LV_OPA_COVER, 0); + lv_obj_set_style_radius(s_bar, 4, LV_PART_INDICATOR); + lv_obj_set_style_bg_color(s_bar, current_theme.border_accent, LV_PART_INDICATOR); + lv_obj_set_style_bg_opa(s_bar, LV_OPA_COVER, LV_PART_INDICATOR); + lv_bar_set_range(s_bar, 0, BRUTE_TOTAL); + lv_bar_set_value(s_bar, 0, LV_ANIM_OFF); + + s_codes = lv_label_create(s_screen); + lv_label_set_text_fmt(s_codes, "Codes 0 / %d", BRUTE_TOTAL); + lv_obj_set_style_text_color(s_codes, current_theme.border_accent, 0); + lv_obj_set_style_text_font(s_codes, &lv_font_montserrat_12, 0); + lv_obj_align(s_codes, LV_ALIGN_TOP_MID, 0, CODES_Y); + + s_hit = lv_label_create(s_screen); + lv_label_set_text(s_hit, BRUTE_PROTO " " BRUTE_HIT); + lv_obj_set_style_text_color(s_hit, current_theme.text_main, 0); + lv_obj_set_style_text_font(s_hit, &lv_font_montserrat_12, 0); + lv_obj_align(s_hit, LV_ALIGN_TOP_MID, 0, HIT_Y); + lv_obj_add_flag(s_hit, LV_OBJ_FLAG_HIDDEN); +} + +static void resolve(void) { + s_locked = true; + stop_timer(&s_scope_timer); + if (s_wave != NULL) + lv_obj_set_style_line_rounded(s_wave, false, 0); + fill_ook(); + + s_code_count = BRUTE_HIT_N; + if (s_bar != NULL) + lv_bar_set_value(s_bar, s_code_count, LV_ANIM_OFF); + if (s_codes != NULL) + lv_label_set_text_fmt(s_codes, "Hit at code %d", s_code_count); + if (s_hit != NULL) + lv_obj_remove_flag(s_hit, LV_OBJ_FLAG_HIDDEN); + if (s_status != NULL) { + lv_label_set_text(s_status, "Code found!"); + lv_obj_set_style_text_color(s_status, lv_color_hex(SIG_GREEN), 0); + } + if (s_hint != NULL) + ui_chrome_footer_set_text(s_hint, HINT_SHOW); + s_locked_at = lv_tick_get(); + + ESP_LOGI(TAG, "mock subghz brute hit: %s %s", BRUTE_PROTO, BRUTE_HIT); + ui_feedback(UI_FB_WRITE); +} + +static void show_options(void) { + if (s_scope != NULL) { + lv_obj_del(s_scope); + s_scope = NULL; + s_wave = NULL; + } + if (s_bar != NULL) { + lv_obj_del(s_bar); + s_bar = NULL; + } + if (s_codes != NULL) { + lv_obj_del(s_codes); + s_codes = NULL; + } + if (s_hit != NULL) { + lv_obj_del(s_hit); + s_hit = NULL; + } + if (s_card != NULL) { + lv_obj_del(s_card); + s_card = NULL; + } + if (s_status != NULL) + lv_obj_add_flag(s_status, LV_OBJ_FLAG_HIDDEN); + if (s_freq != NULL) + lv_obj_add_flag(s_freq, LV_OBJ_FLAG_HIDDEN); + + capture_result_cfg_t cfg = { + .accent = current_theme.border_accent, + .card_icon = "/assets/icons/bolt.bin", + .card_title = "Code found", + .card_sub = BRUTE_PROTO " (OOK)", + .card_value = BRUTE_HIT, + .primary_label = "Send", + .again_label = "Run again", + }; + s_cr = capture_result_create(s_screen, &cfg); + s_options = true; + if (s_hint != NULL) + ui_chrome_footer_set_text(s_hint, HINT_MENU); +} + +void ui_subghz_brute_open(void) { + stop_timer(&s_scope_timer); + stop_timer(&s_tick_timer); + if (s_screen != NULL) { + lv_obj_del(s_screen); + s_screen = NULL; + } + s_status = NULL; + s_freq = NULL; + s_scope = NULL; + s_wave = NULL; + s_card = NULL; + s_bar = NULL; + s_codes = NULL; + s_hit = NULL; + s_hint = NULL; + s_cr = (capture_result_t){0}; + s_phase = 0; + s_mod = 0; + s_code_count = 0; + s_locked = false; + s_options = false; + s_saved = false; + s_locked_at = 0; + + s_screen = lv_obj_create(NULL); + lv_obj_set_style_bg_color(s_screen, current_theme.screen_base, 0); + lv_obj_set_style_bg_opa(s_screen, LV_OPA_COVER, 0); + lv_obj_remove_flag(s_screen, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_border_width(s_screen, 0, 0); + lv_obj_set_style_pad_all(s_screen, 0, 0); + + ui_chrome_header(s_screen, "BRUTE FORCE", "/assets/icons/bolt.bin"); + + s_status = lv_label_create(s_screen); + lv_label_set_text(s_status, STATUS_RUN); + lv_obj_set_style_text_color(s_status, current_theme.text_main, 0); + lv_obj_set_style_text_font(s_status, &lv_font_montserrat_14, 0); + lv_obj_align(s_status, LV_ALIGN_TOP_MID, 0, STATUS_Y); + + s_freq = lv_label_create(s_screen); + lv_label_set_text(s_freq, BRUTE_FREQ); + lv_obj_set_style_text_color(s_freq, current_theme.border_accent, 0); + lv_obj_set_style_text_font(s_freq, &lv_font_montserrat_12, 0); + lv_obj_align(s_freq, LV_ALIGN_TOP_MID, 0, FREQ_Y); + + build_scope(); + build_readout(); + + s_hint = ui_chrome_footer(s_screen, HINT_RUN); + + s_run_start = lv_tick_get(); + s_scope_timer = lv_timer_create(scope_tick_cb, SCOPE_TICK_MS, NULL); + s_tick_timer = lv_timer_create(brute_tick_cb, TICK_MS, NULL); + + ui_input_set_screen_handler(subghz_brute_input, NULL); + + ui_screen_load_owned(&s_screen, s_screen); +} + +static void scope_tick_cb(lv_timer_t *t) { + if (lv_screen_active() != s_screen) { + lv_timer_delete(t); + s_scope_timer = NULL; + return; + } + s_phase = (s_phase + PHASE_STEP_SCAN) % 360; + s_mod = (s_mod + MOD_STEP) % 360; + fill_wave(true); +} + +static void brute_tick_cb(lv_timer_t *t) { + if (lv_screen_active() != s_screen) { + lv_timer_delete(t); + s_tick_timer = NULL; + return; + } + + if (!s_locked) { + uint32_t el = lv_tick_get() - s_run_start; + if (s_status != NULL) { + int dots = (el / DOT_CYCLE_MS) % 4; + char buf[24]; + snprintf(buf, + sizeof(buf), + "%s%s", + STATUS_RUN, + dots == 1 ? "." + : dots == 2 ? ".." + : dots == 3 ? "..." + : ""); + lv_label_set_text(s_status, buf); + } + int count = (int)((uint64_t)el * BRUTE_TOTAL / BRUTE_MS); + if (count > BRUTE_TOTAL) + count = BRUTE_TOTAL; + s_code_count = count; + if (s_bar != NULL) + lv_bar_set_value(s_bar, count, LV_ANIM_OFF); + if (s_codes != NULL) + lv_label_set_text_fmt(s_codes, "Codes %d / %d", count, BRUTE_TOTAL); + } + + if (!s_locked) { + if (lv_tick_get() - s_run_start >= BRUTE_MS) + resolve(); + } else if (!s_options) { + if (lv_tick_get() - s_locked_at >= REVEAL_MS) + show_options(); + } +} + +static void subghz_brute_input(const input_event_t *ev, void *ctx) { + (void)ctx; + const bool press = (ev->action == INPUT_ACTION_PRESS); + const bool nav = press || (ev->action == INPUT_ACTION_REPEAT); + + if (ev->button == INPUT_BTN_BACK) { + if (press) + ui_switch_screen(SCREEN_SUBGHZ_MENU); + return; + } + + if (!s_options) + return; + + switch (ev->button) { + case INPUT_BTN_DOWN: + if (nav) { + capture_result_next(&s_cr); + ui_feedback(UI_FB_NAV); + } + break; + case INPUT_BTN_UP: + if (nav) { + capture_result_prev(&s_cr); + ui_feedback(UI_FB_NAV); + } + break; + case INPUT_BTN_OK: + if (press) { + switch (capture_result_selected(&s_cr)) { + case CAP_ACT_PRIMARY: + ui_feedback(UI_FB_EMULATE); + notify(NOTIFY_INFO, BRUTE_FREQ " sent"); + break; + case CAP_ACT_SAVE: + if (!s_saved) { + s_saved = true; + capture_result_mark_saved(&s_cr); + ESP_LOGI(TAG, "mock subghz brute saved: %s", BRUTE_HIT); + ui_feedback(UI_FB_WRITE); + notify(NOTIFY_SAVED, "Sub-GHz code saved"); + } + break; + case CAP_ACT_AGAIN: + ui_subghz_brute_open(); + return; + case CAP_ACT_DISCARD: + ui_switch_screen(SCREEN_SUBGHZ_MENU); + return; + default: + break; + } + } + break; + default: + break; + } +} diff --git a/firmware_p4/components/Applications/ui/screens/subghz/subghz_config_ui.c b/firmware_p4/components/Applications/ui/screens/subghz/subghz_config_ui.c new file mode 100644 index 000000000..fee85d7cd --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/subghz/subghz_config_ui.c @@ -0,0 +1,331 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#include "subghz_config_ui.h" + +#include "lvgl.h" +#include "st7789.h" + +#include "notify_ui.h" +#include "ui_chrome.h" +#include "ui_feedback.h" +#include "ui_manager.h" +#include "ui_theme.h" + +#define HDR_TITLE "RADIO CONFIG" +#define HDR_ICON "/assets/icons/tune.bin" +#define FOOTER "UP/DN pick L/R adjust OK set" + +#define MX 8 +#define CONTENT_W (LCD_H_RES - 2 * MX) + +#define CARD1_Y 50 +#define CARD1_H 84 +#define CARD_RADIUS 12 +#define CARD_GLOW_W 12 + +#define FREQ_CAP_Y 8 +#define FREQ_VAL_Y 24 +#define FREQ_GAP 3 + +#define BAND_Y 50 +#define BAND_W 200 +#define BAND_H 7 +#define BAND_RADIUS 4 +#define MARKER_PCT 21 +#define MARKER_W 3 +#define MARKER_H 13 +#define MARKER_GLOW 8 +#define SCALE_Y 62 + +#define GRID_Y (CARD1_Y + CARD1_H + 8) +#define TILE_W 108 +#define TILE_H 52 +#define TILE_GAP 8 +#define TILE_X_L MX +#define TILE_X_R (MX + TILE_W + TILE_GAP) +#define ROW2_Y (GRID_Y + TILE_H + 8) + +#define TILE_RADIUS 9 +#define TILE_PAD_L 9 +#define TILE_CAP_Y 7 +#define TILE_VAL_Y 25 +#define TILE_GLOW_W 12 + +#define COL_DIM 0x8A8594 +#define COL_BAND_A 0x221F2E +#define COL_BAND_B 0x3A2F55 + +#define FREQ_NUM "433.920" +#define FREQ_UNIT "MHz" + +#define FIELD_COUNT 4 +#define VAL_MAX 4 + +typedef struct { + const char *name; + const char *unit; + const char *values[VAL_MAX]; + int count; + int def; +} rf_field_t; + +static const rf_field_t FIELDS[FIELD_COUNT] = { + {"MODULATION", NULL, {"2FSK", "OOK", "GFSK", "4FSK"}, 4, 0}, + {"BANDWIDTH", "kHz", {"58", "135", "270", "812"}, 4, 1}, + {"DATA RATE", "kb/s", {"2.4", "4.8", "9.6", "38.4"}, 4, 1}, + {"PRESET", NULL, {"FM238", "FM476", "OOK270", "AM650"}, 4, 0}, +}; + +static const int TILE_X[FIELD_COUNT] = {TILE_X_L, TILE_X_R, TILE_X_L, TILE_X_R}; +static const int TILE_Y[FIELD_COUNT] = {GRID_Y, GRID_Y, ROW2_Y, ROW2_Y}; + +static const char *SCALE_LABELS[4] = {"300", "433", "700", "928"}; + +static lv_obj_t *s_screen = NULL; +static lv_obj_t *s_tile[FIELD_COUNT]; +static lv_obj_t *s_tile_num[FIELD_COUNT]; + +static int s_sel = 0; +static int s_val_idx[FIELD_COUNT]; + +static void build_freq_card(void) { + lv_obj_t *card = lv_obj_create(s_screen); + lv_obj_remove_flag(card, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_remove_flag(card, LV_OBJ_FLAG_CLICKABLE); + lv_obj_set_size(card, CONTENT_W, CARD1_H); + lv_obj_align(card, LV_ALIGN_TOP_MID, 0, CARD1_Y); + lv_obj_set_style_radius(card, CARD_RADIUS, 0); + lv_obj_set_style_pad_all(card, 0, 0); + lv_obj_set_style_bg_color(card, current_theme.bg_secondary, 0); + lv_obj_set_style_bg_opa(card, LV_OPA_COVER, 0); + lv_obj_set_style_border_color(card, current_theme.border_accent, 0); + lv_obj_set_style_border_width(card, 1, 0); + lv_obj_set_style_shadow_width(card, CARD_GLOW_W, 0); + lv_obj_set_style_shadow_color(card, current_theme.border_accent, 0); + lv_obj_set_style_shadow_opa(card, LV_OPA_30, 0); + + lv_obj_t *cap = lv_label_create(card); + lv_label_set_text(cap, "TUNED FREQUENCY"); + lv_obj_set_style_text_font(cap, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(cap, lv_color_hex(COL_DIM), 0); + lv_obj_align(cap, LV_ALIGN_TOP_MID, 0, FREQ_CAP_Y); + + lv_obj_t *grp = lv_obj_create(card); + lv_obj_remove_flag(grp, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(grp, CONTENT_W, 24); + lv_obj_align(grp, LV_ALIGN_TOP_MID, 0, FREQ_VAL_Y); + lv_obj_set_style_bg_opa(grp, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(grp, 0, 0); + lv_obj_set_style_pad_all(grp, 0, 0); + lv_obj_set_style_pad_column(grp, FREQ_GAP, 0); + lv_obj_set_flex_flow(grp, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(grp, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + + lv_obj_t *num = lv_label_create(grp); + lv_label_set_text(num, FREQ_NUM); + lv_obj_set_style_text_font(num, &lv_font_montserrat_16, 0); + lv_obj_set_style_text_color(num, current_theme.text_main, 0); + + lv_obj_t *unit = lv_label_create(grp); + lv_label_set_text(unit, FREQ_UNIT); + lv_obj_set_style_text_font(unit, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(unit, current_theme.border_accent, 0); + + lv_obj_t *band = lv_obj_create(card); + lv_obj_remove_flag(band, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(band, BAND_W, BAND_H); + lv_obj_align(band, LV_ALIGN_TOP_MID, 0, BAND_Y); + lv_obj_set_style_radius(band, BAND_RADIUS, 0); + lv_obj_set_style_border_width(band, 0, 0); + lv_obj_set_style_pad_all(band, 0, 0); + lv_obj_set_style_bg_color(band, lv_color_hex(COL_BAND_A), 0); + lv_obj_set_style_bg_grad_color(band, lv_color_hex(COL_BAND_B), 0); + lv_obj_set_style_bg_grad_dir(band, LV_GRAD_DIR_HOR, 0); + lv_obj_set_style_bg_opa(band, LV_OPA_COVER, 0); + + lv_obj_t *marker = lv_obj_create(band); + lv_obj_remove_flag(marker, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(marker, MARKER_W, MARKER_H); + lv_obj_align(marker, LV_ALIGN_LEFT_MID, (BAND_W * MARKER_PCT) / 100, 0); + lv_obj_set_style_radius(marker, 2, 0); + lv_obj_set_style_border_width(marker, 0, 0); + lv_obj_set_style_pad_all(marker, 0, 0); + lv_obj_set_style_bg_color(marker, current_theme.border_accent, 0); + lv_obj_set_style_bg_opa(marker, LV_OPA_COVER, 0); + lv_obj_set_style_shadow_width(marker, MARKER_GLOW, 0); + lv_obj_set_style_shadow_color(marker, current_theme.border_accent, 0); + lv_obj_set_style_shadow_opa(marker, LV_OPA_COVER, 0); + + lv_obj_t *scale = lv_obj_create(card); + lv_obj_remove_flag(scale, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(scale, BAND_W, 16); + lv_obj_align(scale, LV_ALIGN_TOP_MID, 0, SCALE_Y); + lv_obj_set_style_bg_opa(scale, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(scale, 0, 0); + lv_obj_set_style_pad_all(scale, 0, 0); + lv_obj_set_flex_flow(scale, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align( + scale, LV_FLEX_ALIGN_SPACE_BETWEEN, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + for (int i = 0; i < 4; i++) { + lv_obj_t *lbl = lv_label_create(scale); + lv_label_set_text(lbl, SCALE_LABELS[i]); + lv_obj_set_style_text_font(lbl, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(lbl, lv_color_hex(COL_DIM), 0); + } +} + +static lv_obj_t *build_tile(int i) { + lv_obj_t *tile = lv_obj_create(s_screen); + lv_obj_remove_flag(tile, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_remove_flag(tile, LV_OBJ_FLAG_CLICKABLE); + lv_obj_set_size(tile, TILE_W, TILE_H); + lv_obj_set_pos(tile, TILE_X[i], TILE_Y[i]); + lv_obj_set_style_radius(tile, TILE_RADIUS, 0); + lv_obj_set_style_pad_all(tile, 0, 0); + lv_obj_set_style_bg_opa(tile, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(tile, 1, 0); + + lv_obj_t *cap = lv_label_create(tile); + lv_label_set_text(cap, FIELDS[i].name); + lv_obj_set_style_text_font(cap, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(cap, lv_color_hex(COL_DIM), 0); + lv_obj_align(cap, LV_ALIGN_TOP_LEFT, TILE_PAD_L, TILE_CAP_Y); + + lv_obj_t *grp = lv_obj_create(tile); + lv_obj_remove_flag(grp, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(grp, TILE_W - 2 * TILE_PAD_L, 22); + lv_obj_align(grp, LV_ALIGN_TOP_LEFT, TILE_PAD_L, TILE_VAL_Y); + lv_obj_set_style_bg_opa(grp, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(grp, 0, 0); + lv_obj_set_style_pad_all(grp, 0, 0); + lv_obj_set_style_pad_column(grp, 3, 0); + lv_obj_set_flex_flow(grp, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(grp, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + + lv_obj_t *num = lv_label_create(grp); + lv_label_set_text(num, FIELDS[i].values[s_val_idx[i]]); + lv_obj_set_style_text_font(num, &lv_font_montserrat_16, 0); + lv_obj_set_style_text_color(num, current_theme.text_main, 0); + s_tile_num[i] = num; + + if (FIELDS[i].unit != NULL) { + lv_obj_t *unit = lv_label_create(grp); + lv_label_set_text(unit, FIELDS[i].unit); + lv_obj_set_style_text_font(unit, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(unit, lv_color_hex(COL_DIM), 0); + } + + return tile; +} + +static void refresh_selection(void) { + for (int i = 0; i < FIELD_COUNT; i++) { + bool sel = (i == s_sel); + lv_obj_set_style_bg_color( + s_tile[i], sel ? current_theme.bg_secondary : current_theme.bg_primary, 0); + lv_obj_set_style_border_color( + s_tile[i], sel ? current_theme.border_accent : current_theme.border_inactive, 0); + lv_obj_set_style_shadow_width(s_tile[i], sel ? TILE_GLOW_W : 0, 0); + lv_obj_set_style_shadow_color(s_tile[i], current_theme.border_accent, 0); + lv_obj_set_style_shadow_opa(s_tile[i], sel ? LV_OPA_40 : LV_OPA_TRANSP, 0); + lv_obj_set_style_shadow_spread(s_tile[i], sel ? -2 : 0, 0); + lv_obj_set_style_text_color( + s_tile_num[i], sel ? current_theme.border_accent : current_theme.text_main, 0); + } +} + +static void adjust_value(int dir) { + int n = FIELDS[s_sel].count; + s_val_idx[s_sel] = (s_val_idx[s_sel] + dir + n) % n; + lv_label_set_text(s_tile_num[s_sel], FIELDS[s_sel].values[s_val_idx[s_sel]]); +} + +static void subghz_config_input(const input_event_t *ev, void *ctx) { + (void)ctx; + const bool press = (ev->action == INPUT_ACTION_PRESS); + const bool nav = press || (ev->action == INPUT_ACTION_REPEAT); + + switch (ev->button) { + case INPUT_BTN_BACK: + if (press) + ui_switch_screen(SCREEN_SUBGHZ_MENU); + break; + case INPUT_BTN_DOWN: + if (nav) { + s_sel = (s_sel + 1) % FIELD_COUNT; + refresh_selection(); + ui_feedback(UI_FB_NAV); + } + break; + case INPUT_BTN_UP: + if (nav) { + s_sel = (s_sel - 1 + FIELD_COUNT) % FIELD_COUNT; + refresh_selection(); + ui_feedback(UI_FB_NAV); + } + break; + case INPUT_BTN_RIGHT: + if (nav) { + adjust_value(1); + ui_feedback(UI_FB_NAV); + } + break; + case INPUT_BTN_LEFT: + if (nav) { + adjust_value(-1); + ui_feedback(UI_FB_NAV); + } + break; + case INPUT_BTN_OK: + if (press) { + notify(NOTIFY_SAVED, "Radio config applied"); + ui_feedback(UI_FB_SELECT); + } + break; + default: + break; + } +} + +void ui_subghz_config_open(void) { + if (s_screen != NULL) { + lv_obj_del(s_screen); + s_screen = NULL; + } + s_sel = 0; + for (int i = 0; i < FIELD_COUNT; i++) + s_val_idx[i] = FIELDS[i].def; + + s_screen = lv_obj_create(NULL); + lv_obj_set_style_bg_color(s_screen, current_theme.screen_base, 0); + lv_obj_set_style_bg_opa(s_screen, LV_OPA_COVER, 0); + lv_obj_remove_flag(s_screen, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_border_width(s_screen, 0, 0); + lv_obj_set_style_pad_all(s_screen, 0, 0); + + ui_chrome_header(s_screen, HDR_TITLE, HDR_ICON); + + build_freq_card(); + for (int i = 0; i < FIELD_COUNT; i++) + s_tile[i] = build_tile(i); + refresh_selection(); + + ui_chrome_footer(s_screen, FOOTER); + + ui_input_set_screen_handler(subghz_config_input, NULL); + + ui_screen_load_owned(&s_screen, s_screen); +} diff --git a/firmware_p4/components/Applications/ui/screens/subghz/subghz_menu_ui.c b/firmware_p4/components/Applications/ui/screens/subghz/subghz_menu_ui.c new file mode 100644 index 000000000..4a3ff0c00 --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/subghz/subghz_menu_ui.c @@ -0,0 +1,876 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#include "subghz_menu_ui.h" + +#include + +#include "esp_log.h" +#include "lvgl.h" +#include "st7789.h" + +#include "assets_manager.h" +#include "menu_component_ui.h" +#include "msgbox_ui.h" +#include "notify_ui.h" +#include "octobit_ui.h" +#include "sigwave_ui.h" +#include "subghz_scope_ui.h" +#include "ui_chrome.h" +#include "ui_feedback.h" +#include "ui_manager.h" +#include "ui_theme.h" + +static const char *TAG = "SUBGHZ_UI"; + +#define FADE_MS 200 + +#define OUTER_BORDER 4 +#define TOP_BORDER_H 46 +#define TOP_AREA_BORDER_WIDTH 3 +#define TITLE_BAR_W 170 +#define TITLE_BAR_H 30 +#define TITLE_BAR_RADIUS 12 +#define TITLE_BAR_BORDER_WIDTH 2 + +#define BAR_COUNT 16 +#define BAR_W 8 +#define BAR_GAP 4 +#define BAR_MIN_H 6 +#define BAR_MAX_H 200 +#define BAR_BASELINE_Y 288 +#define FREQ_CYCLE_MS 400 + +#define CARD_W 200 +#define CARD_H 108 +#define CARD_CTR_Y -34 +#define CARD_RISE_PX 70 +#define CARD_RISE_MS 450 + +#define INFO_ACT_BOTTOM_Y -30 +#define INFO_ACT_W 200 +#define INFO_ACT_H 32 +#define INFO_ACT_GAP 8 + +#define HINT_ANALYZER "BACK exit" + +#define SGC_LEFT 6 +#define SGC_GUTTER 16 +#define SGC_TOP_Y 46 +#define SGC_LIST_PAD 2 +#define SGC_LIST_ROW 8 +#define SGC_CARD_H 86 +#define SGC_CARD_RADIUS 12 +#define SGC_CARD_PAD 10 +#define SGC_GLOW_W 14 +#define SGC_BARS 8 +#define SGC_BARS_H 16 +#define SGC_BAR_W 6 +#define SGC_BAR_MIN 3 +#define SGC_BAR_SPAN 12 +#define SGC_TRACK_X 227 +#define SGC_TRACK_Y 54 +#define SGC_TRACK_LEN 232 +#define SGC_THUMB_H 45 +#define SGC_THUMB_ICON "/assets/icons/drag_indicator.bin" + +#define COL_DIM 0x8A8594 +#define COL_RAISE 0x170A28 + +#define HINT_SAVED "UP/DOWN choose OK open BACK exit" +#define HINT_INFO "UP/DOWN choose OK do BACK exit" + +#define SEND_ANIM_MS 1500 +#define SEND_DONE_MS 750 +#define SEND_STATUS_Y 48 +#define SEND_FREQ_Y 68 +#define SEND_SCOPE_Y -8 + +static const struct { + const char *name; + const char *icon; + bool capture; +} ITEMS[] = { + {"Read", "/assets/icons/sensors.bin", true}, + {"Read RAW", "/assets/icons/raw_on.bin", true}, + {"Frequency Analyzer", "/assets/icons/graphic_eq.bin", false}, + {"Brute Force", "/assets/icons/bolt.bin", true}, + {"Saved", "/assets/icons/folder.bin", false}, + {"Send", "/assets/icons/sensors.bin", false}, + {"Radio Config", "/assets/icons/tune.bin", false}, +}; +#define ITEM_COUNT ((int)(sizeof(ITEMS) / sizeof(ITEMS[0]))) + +#define IDX_ANALYZER 2 +#define IDX_BRUTE 3 +#define IDX_SAVED 4 +#define IDX_SEND 5 +#define IDX_CONFIG 6 + +static const struct { + const char *name; + const char *freq; + const char *proto; +} SAVED_SIGS[] = { + {"Gate_433", "433.92 MHz", "Princeton"}, + {"Doorbell", "433.92 MHz", "CAME"}, + {"TPMS_FL", "315.00 MHz", "FSK TPMS"}, + {"Garage", "868.30 MHz", "Nice FLO"}, + {"Car_Fob", "433.92 MHz", "KeeLoq"}, + {"Barrier", "868.30 MHz", "BFT Mitto"}, + {"Weather", "433.92 MHz", "Oregon v3"}, + {"Remote_2", "315.00 MHz", "Holtek"}, + {"Sensor_A", "433.92 MHz", "Princeton"}, + {"Gate_868", "868.30 MHz", "Nice FLO"}, +}; +#define SAVED_COUNT ((int)(sizeof(SAVED_SIGS) / sizeof(SAVED_SIGS[0]))) + +static const struct { + const char *label; + const char *value; +} DETAIL_ROWS[] = { + {"Protocol", "Princeton"}, + {"Key", "0x1A2B3C"}, + {"Frequency", "433.92 MHz"}, +}; +#define DETAIL_ROW_COUNT ((int)(sizeof(DETAIL_ROWS) / sizeof(DETAIL_ROWS[0]))) + +static const struct { + const char *icon; + const char *label; +} INFO_ACTIONS[] = { + {LV_SYMBOL_UPLOAD, "Send"}, + {LV_SYMBOL_TRASH, "Delete"}, +}; +#define INFO_ACTION_COUNT ((int)(sizeof(INFO_ACTIONS) / sizeof(INFO_ACTIONS[0]))) + +static const char *ANALYZER_FREQS[] = {"433.92 MHz", "868.30 MHz", "315.00 MHz"}; +#define ANALYZER_FREQ_COUNT ((int)(sizeof(ANALYZER_FREQS) / sizeof(ANALYZER_FREQS[0]))) + +typedef enum { + VIEW_LIST = 0, + VIEW_ANALYZER, + VIEW_SAVED, + VIEW_SAVED_INFO, +} view_t; + +static lv_obj_t *s_screen = NULL; +static menu_component_t s_menu; +static lv_timer_t *s_freq_timer = NULL; +static view_t s_view = VIEW_LIST; +static int s_saved_sel = 0; + +static lv_obj_t *s_freq_lbl = NULL; +static int s_freq_idx = 0; + +static lv_obj_t *s_info_rows[INFO_ACTION_COUNT]; +static lv_obj_t *s_info_icons[INFO_ACTION_COUNT]; +static lv_obj_t *s_info_labels[INFO_ACTION_COUNT]; +static int s_info_sel = 0; + +static lv_obj_t *s_sig_cont = NULL; +static lv_obj_t *s_sig_row[SAVED_COUNT]; +static lv_obj_t *s_sig_name[SAVED_COUNT]; +static lv_obj_t *s_sig_val[SAVED_COUNT]; +static lv_obj_t *s_sig_thumb = NULL; + +static lv_obj_t *s_send_overlay = NULL; +static lv_obj_t *s_send_status = NULL; +static lv_timer_t *s_send_t1 = NULL; +static lv_timer_t *s_send_t2 = NULL; + +static void subghz_menu_input(const input_event_t *ev, void *ctx); +static void build_screen(void); + +static void rebuild_async(void *p) { + (void)p; + build_screen(); +} + +static void stop_freq_timer(void) { + if (s_freq_timer != NULL) { + lv_timer_delete(s_freq_timer); + s_freq_timer = NULL; + } +} + +static void opa_anim_cb(void *var, int32_t v) { + lv_obj_set_style_opa((lv_obj_t *)var, (lv_opa_t)v, 0); +} + +static void fade_in(lv_obj_t *obj, uint32_t duration_ms) { + lv_obj_set_style_opa(obj, LV_OPA_TRANSP, 0); + lv_anim_t a; + lv_anim_init(&a); + lv_anim_set_var(&a, obj); + lv_anim_set_exec_cb(&a, opa_anim_cb); + lv_anim_set_values(&a, LV_OPA_TRANSP, LV_OPA_COVER); + lv_anim_set_duration(&a, duration_ms); + lv_anim_set_path_cb(&a, lv_anim_path_ease_out); + lv_anim_start(&a); +} + +static void bar_height_cb(void *var, int32_t v) { + lv_obj_t *bar = (lv_obj_t *)var; + lv_obj_set_height(bar, v); + lv_obj_set_y(bar, BAR_BASELINE_Y - v); +} + +static void freq_cycle_cb(lv_timer_t *t) { + if (lv_screen_active() != s_screen || s_view != VIEW_ANALYZER) { + lv_timer_delete(t); + s_freq_timer = NULL; + return; + } + s_freq_idx = (s_freq_idx + 1) % ANALYZER_FREQ_COUNT; + if (s_freq_lbl) + lv_label_set_text(s_freq_lbl, ANALYZER_FREQS[s_freq_idx]); +} + +#define SIGNAL_STRONG_COLOR 0x00E676 + +static void build_analyzer(void) { + ui_chrome_header(s_screen, "ANALYZER", "/assets/icons/graphic_eq.bin"); + + s_freq_idx = 0; + s_freq_lbl = lv_label_create(s_screen); + lv_label_set_text(s_freq_lbl, ANALYZER_FREQS[0]); + lv_obj_set_style_text_color(s_freq_lbl, current_theme.border_accent, 0); + lv_obj_set_style_text_font(s_freq_lbl, &lv_font_montserrat_14, 0); + lv_obj_align(s_freq_lbl, LV_ALIGN_TOP_MID, 0, UI_CHROME_HEADER_H + 14); + + int total_w = BAR_COUNT * BAR_W + (BAR_COUNT - 1) * BAR_GAP; + int x0 = (LCD_H_RES - total_w) / 2; + + static lv_point_precise_t base_pts[2]; + base_pts[0].x = 0; + base_pts[0].y = 0; + base_pts[1].x = total_w; + base_pts[1].y = 0; + lv_obj_t *baseline = lv_line_create(s_screen); + lv_line_set_points(baseline, base_pts, 2); + lv_obj_set_pos(baseline, x0, BAR_BASELINE_Y); + lv_obj_set_style_line_color(baseline, current_theme.border_inactive, 0); + lv_obj_set_style_line_opa(baseline, LV_OPA_50, 0); + lv_obj_set_style_line_width(baseline, 2, 0); + lv_obj_set_style_line_dash_width(baseline, 4, 0); + lv_obj_set_style_line_dash_gap(baseline, 4, 0); + + for (int i = 0; i < BAR_COUNT; i++) { + int peak = BAR_MAX_H - (i % 5) * 12; + + lv_obj_t *bar = lv_obj_create(s_screen); + lv_obj_remove_flag(bar, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(bar, BAR_W, BAR_MIN_H); + lv_obj_set_style_radius(bar, 2, 0); + lv_obj_set_style_bg_opa(bar, LV_OPA_COVER, 0); + + lv_color_t bar_color; + if (peak >= (BAR_MAX_H - 12)) + bar_color = lv_color_hex(SIGNAL_STRONG_COLOR); + else if (peak >= (BAR_MAX_H - 36)) + bar_color = current_theme.border_accent; + else + bar_color = current_theme.border_inactive; + lv_obj_set_style_bg_color(bar, bar_color, 0); + lv_obj_set_style_bg_grad_color(bar, current_theme.bg_secondary, 0); + lv_obj_set_style_bg_grad_dir(bar, LV_GRAD_DIR_VER, 0); + lv_obj_set_style_border_width(bar, 0, 0); + + lv_obj_set_pos(bar, x0 + i * (BAR_W + BAR_GAP), BAR_BASELINE_Y - BAR_MIN_H); + + lv_anim_t a; + lv_anim_init(&a); + lv_anim_set_var(&a, bar); + lv_anim_set_exec_cb(&a, bar_height_cb); + lv_anim_set_values(&a, BAR_MIN_H, peak); + lv_anim_set_duration(&a, 420 + (i % 4) * 90); + lv_anim_set_playback_duration(&a, 420 + (i % 3) * 80); + lv_anim_set_delay(&a, i * 55); + lv_anim_set_repeat_count(&a, LV_ANIM_REPEAT_INFINITE); + lv_anim_set_path_cb(&a, lv_anim_path_ease_in_out); + lv_anim_start(&a); + } + + fade_in(s_freq_lbl, FADE_MS); + s_freq_timer = lv_timer_create(freq_cycle_cb, FREQ_CYCLE_MS, NULL); + + ui_chrome_footer(s_screen, HINT_ANALYZER); +} + +static void build_saved_empty(void) { + ui_chrome_header(s_screen, "SAVED", "/assets/icons/folder.bin"); + octobit_create(s_screen, "No saved signals yet"); + ui_chrome_footer(s_screen, "BACK exit"); +} + +static void move_sig_thumb(void) { + if (s_sig_thumb == NULL || SAVED_COUNT <= 1) + return; + int thumb_h = lv_obj_get_height(s_sig_thumb); + if (thumb_h <= 0) + thumb_h = SGC_THUMB_H; + int travel = SGC_TRACK_LEN - thumb_h; + if (travel < 0) + travel = 0; + int pos = SGC_TRACK_Y + (s_saved_sel * travel) / (SAVED_COUNT - 1); + lv_obj_set_y(s_sig_thumb, pos); +} + +static void style_sig_row(int i, bool sel) { + lv_obj_t *card = s_sig_row[i]; + if (sel) { + lv_obj_set_style_border_color(card, current_theme.border_accent, 0); + lv_obj_set_style_border_opa(card, LV_OPA_COVER, 0); + lv_obj_set_style_shadow_color(card, current_theme.border_accent, 0); + lv_obj_set_style_shadow_width(card, SGC_GLOW_W, 0); + lv_obj_set_style_shadow_opa(card, LV_OPA_40, 0); + lv_obj_set_style_shadow_spread(card, -2, 0); + lv_obj_set_style_text_color(s_sig_val[i], current_theme.border_accent, 0); + } else { + lv_obj_set_style_border_color(card, current_theme.border_inactive, 0); + lv_obj_set_style_border_opa(card, LV_OPA_60, 0); + lv_obj_set_style_shadow_width(card, 0, 0); + lv_obj_set_style_shadow_opa(card, LV_OPA_TRANSP, 0); + lv_obj_set_style_text_color(s_sig_val[i], lv_color_hex(COL_DIM), 0); + } +} + +static void update_sig_selection(void) { + for (int i = 0; i < SAVED_COUNT; i++) + style_sig_row(i, i == s_saved_sel); + if (s_sig_cont != NULL && s_sig_row[s_saved_sel] != NULL) { + lv_obj_update_layout(s_sig_cont); + lv_obj_scroll_to_view(s_sig_row[s_saved_sel], LV_ANIM_ON); + } + move_sig_thumb(); +} + +static void build_saved_list(void) { + if (SAVED_COUNT == 0) { + build_saved_empty(); + return; + } + ui_chrome_header(s_screen, "SAVED", "/assets/icons/folder.bin"); + + if (s_saved_sel < 0) + s_saved_sel = 0; + if (s_saved_sel >= SAVED_COUNT) + s_saved_sel = SAVED_COUNT - 1; + + lv_obj_t *cont = lv_obj_create(s_screen); + s_sig_cont = cont; + lv_obj_set_size( + cont, LCD_H_RES - SGC_LEFT - SGC_GUTTER, LCD_V_RES - SGC_TOP_Y - UI_CHROME_FOOTER_H - 4); + lv_obj_align(cont, LV_ALIGN_TOP_LEFT, SGC_LEFT, SGC_TOP_Y); + lv_obj_set_style_bg_opa(cont, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(cont, 0, 0); + lv_obj_set_style_pad_all(cont, SGC_LIST_PAD, 0); + lv_obj_set_style_pad_row(cont, SGC_LIST_ROW, 0); + lv_obj_set_flex_flow(cont, LV_FLEX_FLOW_COLUMN); + lv_obj_add_flag(cont, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_scroll_dir(cont, LV_DIR_VER); + lv_obj_set_scrollbar_mode(cont, LV_SCROLLBAR_MODE_OFF); + lv_obj_clear_flag(cont, LV_OBJ_FLAG_SCROLL_ELASTIC | LV_OBJ_FLAG_SCROLL_MOMENTUM); + + for (int i = 0; i < SAVED_COUNT; i++) { + lv_obj_t *card = lv_obj_create(cont); + s_sig_row[i] = card; + lv_obj_remove_flag(card, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_remove_flag(card, LV_OBJ_FLAG_CLICKABLE); + lv_obj_set_size(card, lv_pct(100), SGC_CARD_H); + lv_obj_set_style_radius(card, SGC_CARD_RADIUS, 0); + lv_obj_set_style_pad_all(card, SGC_CARD_PAD, 0); + lv_obj_set_style_border_width(card, 1, 0); + lv_obj_set_style_bg_color(card, current_theme.bg_secondary, 0); + lv_obj_set_style_bg_grad_color(card, current_theme.bg_primary, 0); + lv_obj_set_style_bg_grad_dir(card, LV_GRAD_DIR_VER, 0); + lv_obj_set_style_bg_opa(card, LV_OPA_COVER, 0); + + lv_obj_t *name = lv_label_create(card); + s_sig_name[i] = name; + lv_obj_set_width(name, lv_pct(68)); + lv_label_set_long_mode(name, LV_LABEL_LONG_DOT); + lv_label_set_text(name, SAVED_SIGS[i].name); + lv_obj_set_style_text_font(name, &lv_font_montserrat_14, 0); + lv_obj_set_style_text_color(name, current_theme.text_main, 0); + lv_obj_align(name, LV_ALIGN_TOP_LEFT, 0, 0); + + lv_obj_t *val = lv_label_create(card); + s_sig_val[i] = val; + lv_label_set_text(val, SAVED_SIGS[i].freq); + lv_obj_set_style_text_font(val, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(val, current_theme.border_accent, 0); + lv_obj_align(val, LV_ALIGN_TOP_RIGHT, 0, 2); + + lv_obj_t *proto = lv_label_create(card); + lv_obj_set_width(proto, lv_pct(100)); + lv_label_set_long_mode(proto, LV_LABEL_LONG_DOT); + lv_label_set_text(proto, SAVED_SIGS[i].proto); + lv_obj_set_style_text_font(proto, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_color(proto, lv_color_hex(COL_DIM), 0); + lv_obj_align(proto, LV_ALIGN_TOP_LEFT, 0, 20); + + lv_obj_t *bars = lv_obj_create(card); + lv_obj_remove_flag(bars, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_remove_flag(bars, LV_OBJ_FLAG_CLICKABLE); + lv_obj_set_size(bars, lv_pct(100), SGC_BARS_H); + lv_obj_align(bars, LV_ALIGN_BOTTOM_LEFT, 0, 0); + lv_obj_set_style_bg_opa(bars, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(bars, 0, 0); + lv_obj_set_style_pad_all(bars, 0, 0); + lv_obj_set_flex_flow(bars, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(bars, LV_FLEX_ALIGN_SPACE_BETWEEN, LV_FLEX_ALIGN_END, LV_FLEX_ALIGN_END); + for (int k = 0; k < SGC_BARS; k++) { + lv_obj_t *bar = lv_obj_create(bars); + lv_obj_remove_flag(bar, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_remove_flag(bar, LV_OBJ_FLAG_CLICKABLE); + int h = SGC_BAR_MIN + ((i * 7 + k * 13) % SGC_BAR_SPAN); + lv_obj_set_size(bar, SGC_BAR_W, h); + lv_obj_set_style_radius(bar, 1, 0); + lv_obj_set_style_border_width(bar, 0, 0); + lv_obj_set_style_bg_color(bar, current_theme.border_accent, 0); + lv_obj_set_style_bg_grad_color(bar, lv_color_hex(0xB89AFF), 0); + lv_obj_set_style_bg_grad_dir(bar, LV_GRAD_DIR_VER, 0); + lv_obj_set_style_bg_opa(bar, LV_OPA_COVER, 0); + } + } + + static lv_point_precise_t sg_track_pts[2]; + sg_track_pts[0].x = 0; + sg_track_pts[0].y = 0; + sg_track_pts[1].x = 0; + sg_track_pts[1].y = SGC_TRACK_LEN; + lv_obj_t *track = lv_line_create(s_screen); + lv_line_set_points(track, sg_track_pts, 2); + lv_obj_set_pos(track, SGC_TRACK_X, SGC_TRACK_Y); + lv_obj_set_style_line_color(track, current_theme.border_inactive, 0); + lv_obj_set_style_line_opa(track, LV_OPA_COVER, 0); + lv_obj_set_style_line_width(track, 3, 0); + lv_obj_set_style_line_dash_width(track, 4, 0); + lv_obj_set_style_line_dash_gap(track, 4, 0); + + lv_image_dsc_t *thumb = assets_get(SGC_THUMB_ICON); + s_sig_thumb = lv_image_create(s_screen); + if (thumb != NULL) + lv_image_set_src(s_sig_thumb, thumb); + lv_obj_set_pos(s_sig_thumb, SGC_TRACK_X - 4, SGC_TRACK_Y); + lv_obj_move_foreground(s_sig_thumb); + + lv_obj_update_layout(cont); + update_sig_selection(); + fade_in(cont, FADE_MS); + + ui_chrome_footer(s_screen, HINT_SAVED); +} + +static void card_rise_cb(void *var, int32_t v) { + lv_obj_set_style_translate_y((lv_obj_t *)var, v, 0); +} + +static void style_info_row(int idx, bool sel) { + lv_obj_t *row = s_info_rows[idx]; + if (sel) { + lv_obj_set_style_bg_color(row, lv_color_hex(COL_RAISE), 0); + lv_obj_set_style_bg_opa(row, LV_OPA_COVER, 0); + lv_obj_set_style_border_color(row, current_theme.border_accent, 0); + lv_obj_set_style_border_opa(row, LV_OPA_COVER, 0); + lv_obj_set_style_shadow_color(row, current_theme.border_accent, 0); + lv_obj_set_style_shadow_width(row, 14, 0); + lv_obj_set_style_shadow_opa(row, LV_OPA_50, 0); + lv_obj_set_style_shadow_spread(row, -3, 0); + lv_obj_set_style_text_color(s_info_icons[idx], current_theme.border_accent, 0); + lv_obj_set_style_text_color(s_info_labels[idx], current_theme.text_main, 0); + } else { + lv_obj_set_style_bg_color(row, current_theme.bg_secondary, 0); + lv_obj_set_style_bg_opa(row, LV_OPA_80, 0); + lv_obj_set_style_border_color(row, current_theme.border_inactive, 0); + lv_obj_set_style_border_opa(row, LV_OPA_COVER, 0); + lv_obj_set_style_shadow_width(row, 0, 0); + lv_obj_set_style_shadow_opa(row, LV_OPA_TRANSP, 0); + lv_obj_set_style_text_color(s_info_icons[idx], lv_color_hex(COL_DIM), 0); + lv_obj_set_style_text_color(s_info_labels[idx], current_theme.text_main, 0); + } +} + +static void update_info_selection(void) { + for (int i = 0; i < INFO_ACTION_COUNT; i++) + style_info_row(i, i == s_info_sel); +} + +static void build_saved_info(void) { + ui_chrome_header(s_screen, "SAVED", "/assets/icons/folder.bin"); + + lv_obj_t *card = lv_obj_create(s_screen); + lv_obj_remove_flag(card, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(card, CARD_W, CARD_H); + lv_obj_align(card, LV_ALIGN_CENTER, 0, CARD_CTR_Y); + lv_obj_set_style_radius(card, 12, 0); + lv_obj_set_style_bg_color(card, current_theme.bg_secondary, 0); + lv_obj_set_style_bg_opa(card, LV_OPA_COVER, 0); + lv_obj_set_style_bg_grad_dir(card, LV_GRAD_DIR_NONE, 0); + lv_obj_set_style_border_width(card, 1, 0); + lv_obj_set_style_border_color(card, current_theme.border_accent, 0); + lv_obj_set_style_shadow_color(card, current_theme.border_accent, 0); + lv_obj_set_style_shadow_width(card, 14, 0); + lv_obj_set_style_shadow_opa(card, LV_OPA_40, 0); + lv_obj_set_style_shadow_spread(card, -3, 0); + lv_obj_set_style_pad_all(card, 10, 0); + lv_obj_set_flex_flow(card, LV_FLEX_FLOW_COLUMN); + lv_obj_set_style_pad_row(card, 4, 0); + + lv_obj_t *name = lv_label_create(card); + lv_label_set_text(name, SAVED_SIGS[s_saved_sel].name); + lv_obj_set_style_text_color(name, current_theme.text_main, 0); + lv_obj_set_style_text_font(name, &lv_font_montserrat_14, 0); + + for (int i = 0; i < DETAIL_ROW_COUNT; i++) { + lv_obj_t *row = lv_obj_create(card); + lv_obj_remove_flag(row, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_width(row, lv_pct(100)); + lv_obj_set_height(row, LV_SIZE_CONTENT); + lv_obj_set_style_bg_opa(row, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(row, 0, 0); + lv_obj_set_style_pad_all(row, 0, 0); + lv_obj_set_flex_flow(row, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align( + row, LV_FLEX_ALIGN_SPACE_BETWEEN, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + + lv_obj_t *label = lv_label_create(row); + lv_label_set_text(label, DETAIL_ROWS[i].label); + lv_obj_set_style_text_color(label, current_theme.border_inactive, 0); + lv_obj_set_style_text_font(label, &lv_font_montserrat_12, 0); + + lv_obj_t *value = lv_label_create(row); + lv_label_set_text(value, DETAIL_ROWS[i].value); + lv_obj_set_style_text_color(value, current_theme.border_accent, 0); + lv_obj_set_style_text_font(value, &lv_font_montserrat_12, 0); + } + + lv_obj_t *acts = lv_obj_create(s_screen); + lv_obj_remove_flag(acts, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(acts, INFO_ACT_W, LV_SIZE_CONTENT); + lv_obj_align(acts, LV_ALIGN_BOTTOM_MID, 0, INFO_ACT_BOTTOM_Y); + lv_obj_set_style_bg_opa(acts, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(acts, 0, 0); + lv_obj_set_style_pad_all(acts, 0, 0); + lv_obj_set_flex_flow(acts, LV_FLEX_FLOW_COLUMN); + lv_obj_set_style_pad_row(acts, INFO_ACT_GAP, 0); + + for (int i = 0; i < INFO_ACTION_COUNT; i++) { + lv_obj_t *row = lv_obj_create(acts); + s_info_rows[i] = row; + lv_obj_remove_flag(row, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(row, lv_pct(100), INFO_ACT_H); + lv_obj_set_style_radius(row, 10, 0); + lv_obj_set_style_border_width(row, 1, 0); + lv_obj_set_style_bg_grad_dir(row, LV_GRAD_DIR_NONE, 0); + lv_obj_set_style_pad_hor(row, 12, 0); + lv_obj_set_style_pad_ver(row, 0, 0); + 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_column(row, 10, 0); + + lv_obj_t *icon = lv_label_create(row); + s_info_icons[i] = icon; + lv_label_set_text(icon, INFO_ACTIONS[i].icon); + lv_obj_set_style_text_font(icon, &lv_font_montserrat_14, 0); + + lv_obj_t *label = lv_label_create(row); + s_info_labels[i] = label; + lv_label_set_text(label, INFO_ACTIONS[i].label); + lv_obj_set_style_text_font(label, &lv_font_montserrat_14, 0); + } + s_info_sel = 0; + update_info_selection(); + + ui_chrome_footer(s_screen, HINT_INFO); + + lv_anim_t a; + lv_anim_init(&a); + lv_anim_set_var(&a, card); + lv_anim_set_exec_cb(&a, card_rise_cb); + lv_anim_set_values(&a, CARD_RISE_PX, 0); + lv_anim_set_duration(&a, CARD_RISE_MS); + lv_anim_set_path_cb(&a, lv_anim_path_ease_out); + lv_anim_start(&a); +} + +static void info_del_confirm(bool confirm) { + if (!confirm) + return; + ESP_LOGI(TAG, "mock saved delete: %s", SAVED_SIGS[s_saved_sel].name); + ui_feedback(UI_FB_WRITE); + notify(NOTIFY_INFO, "Signal deleted"); + s_view = VIEW_SAVED; + lv_async_call(rebuild_async, NULL); +} + +static void build_screen(void) { + stop_freq_timer(); + if (s_screen != NULL) { + lv_obj_del(s_screen); + s_screen = NULL; + } + s_freq_lbl = NULL; + s_sig_cont = NULL; + s_sig_thumb = NULL; + if (s_send_t1 != NULL) { + lv_timer_delete(s_send_t1); + s_send_t1 = NULL; + } + if (s_send_t2 != NULL) { + lv_timer_delete(s_send_t2); + s_send_t2 = NULL; + } + subghz_scope_stop(); + s_send_overlay = NULL; + s_send_status = NULL; + + s_screen = lv_obj_create(NULL); + lv_obj_set_style_bg_color(s_screen, current_theme.screen_base, 0); + lv_obj_set_style_bg_opa(s_screen, LV_OPA_COVER, 0); + lv_obj_remove_flag(s_screen, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_border_width(s_screen, 0, 0); + lv_obj_set_style_pad_all(s_screen, 0, 0); + + switch (s_view) { + case VIEW_ANALYZER: + build_analyzer(); + break; + case VIEW_SAVED: + build_saved_list(); + break; + case VIEW_SAVED_INFO: + build_saved_info(); + break; + case VIEW_LIST: + default: + s_menu = + menu_component_create(s_screen, "SUB-GHZ", "/assets/icons/settings_input_antenna.bin"); + for (int i = 0; i < ITEM_COUNT; i++) + menu_component_add_item(&s_menu, ITEMS[i].icon, ITEMS[i].name); + fade_in(s_menu.items_cont, FADE_MS); + fade_in(s_menu.title_bar, FADE_MS); + break; + } + + ui_input_set_screen_handler(subghz_menu_input, NULL); + + ui_screen_load_owned(&s_screen, s_screen); +} + +static void send_finish_cb(lv_timer_t *t) { + lv_timer_delete(t); + s_send_t2 = NULL; + subghz_scope_stop(); + if (s_send_overlay != NULL) { + lv_obj_del(s_send_overlay); + s_send_overlay = NULL; + s_send_status = NULL; + } + notify(NOTIFY_INFO, "Signal sent"); +} + +static void send_lock_cb(lv_timer_t *t) { + lv_timer_delete(t); + s_send_t1 = NULL; + if (lv_screen_active() != s_screen) { + subghz_scope_stop(); + return; + } + subghz_scope_lock(); + if (s_send_status != NULL) { + lv_label_set_text(s_send_status, "Signal sent!"); + lv_obj_set_style_text_color(s_send_status, lv_color_hex(SIGNAL_STRONG_COLOR), 0); + } + ui_feedback(UI_FB_WRITE); + s_send_t2 = lv_timer_create(send_finish_cb, SEND_DONE_MS, NULL); + lv_timer_set_repeat_count(s_send_t2, 1); +} + +static void start_saved_send(void) { + s_send_overlay = lv_obj_create(s_screen); + lv_obj_set_size(s_send_overlay, lv_pct(100), lv_pct(100)); + lv_obj_center(s_send_overlay); + lv_obj_remove_flag(s_send_overlay, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_bg_color(s_send_overlay, current_theme.screen_base, 0); + lv_obj_set_style_bg_opa(s_send_overlay, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(s_send_overlay, 0, 0); + lv_obj_set_style_pad_all(s_send_overlay, 0, 0); + + // Transient transmit overlay: snapshot header (no rebind) so freeing it never + // dangles the live parent screen's dynamic header. + ui_chrome_header_overlay(s_send_overlay, "SEND", "/assets/icons/settings_input_antenna.bin"); + + s_send_status = lv_label_create(s_send_overlay); + lv_label_set_text(s_send_status, "Transmitting..."); + lv_obj_set_style_text_color(s_send_status, current_theme.text_main, 0); + lv_obj_set_style_text_font(s_send_status, &lv_font_montserrat_14, 0); + lv_obj_align(s_send_status, LV_ALIGN_TOP_MID, 0, SEND_STATUS_Y); + + lv_obj_t *freq = lv_label_create(s_send_overlay); + lv_label_set_text(freq, SAVED_SIGS[s_saved_sel].freq); + lv_obj_set_style_text_color(freq, current_theme.border_accent, 0); + lv_obj_set_style_text_font(freq, &lv_font_montserrat_12, 0); + lv_obj_align(freq, LV_ALIGN_TOP_MID, 0, SEND_FREQ_Y); + + subghz_scope_create(s_send_overlay, LV_ALIGN_CENTER, 0, SEND_SCOPE_Y); + ui_chrome_footer(s_send_overlay, "Transmitting..."); + + ui_feedback(UI_FB_EMULATE); + s_send_t1 = lv_timer_create(send_lock_cb, SEND_ANIM_MS, NULL); + lv_timer_set_repeat_count(s_send_t1, 1); +} + +static void subghz_menu_input(const input_event_t *ev, void *ctx) { + (void)ctx; + const bool press = (ev->action == INPUT_ACTION_PRESS); + const bool nav = press || (ev->action == INPUT_ACTION_REPEAT); + + if (s_send_overlay != NULL) + return; + + switch (s_view) { + case VIEW_LIST: + switch (ev->button) { + case INPUT_BTN_DOWN: + if (nav) + menu_component_next(&s_menu); + break; + case INPUT_BTN_UP: + if (nav) + menu_component_prev(&s_menu); + break; + case INPUT_BTN_OK: + if (press) { + int sel = menu_component_get_selected(&s_menu); + if (sel == IDX_ANALYZER) { + s_view = VIEW_ANALYZER; + build_screen(); + } else if (sel == IDX_SAVED) { + s_saved_sel = 0; + s_view = VIEW_SAVED; + build_screen(); + } else if (sel == IDX_BRUTE) { + ui_switch_screen(SCREEN_SUBGHZ_BRUTE); + } else if (sel == IDX_SEND) { + ui_switch_screen(SCREEN_SUBGHZ_SEND); + } else if (sel == IDX_CONFIG) { + ui_switch_screen(SCREEN_SUBGHZ_CONFIG); + } else if (sel >= 0 && sel < ITEM_COUNT && ITEMS[sel].capture) { + ui_switch_screen(SCREEN_SUBGHZ_READ); + } + } + break; + case INPUT_BTN_BACK: + if (press) + ui_switch_screen(SCREEN_MENU); + break; + default: + break; + } + break; + + case VIEW_ANALYZER: + switch (ev->button) { + case INPUT_BTN_BACK: + if (press) { + s_view = VIEW_LIST; + build_screen(); + } + break; + default: + break; + } + break; + + case VIEW_SAVED: + switch (ev->button) { + case INPUT_BTN_DOWN: + if (nav && s_saved_sel < SAVED_COUNT - 1) { + s_saved_sel++; + update_sig_selection(); + ui_feedback(UI_FB_NAV); + } + break; + case INPUT_BTN_UP: + if (nav && s_saved_sel > 0) { + s_saved_sel--; + update_sig_selection(); + ui_feedback(UI_FB_NAV); + } + break; + case INPUT_BTN_OK: + if (press) { + ESP_LOGI(TAG, "mock saved open: %s", SAVED_SIGS[s_saved_sel].name); + s_view = VIEW_SAVED_INFO; + build_screen(); + } + break; + case INPUT_BTN_BACK: + if (press) { + s_view = VIEW_LIST; + build_screen(); + } + break; + default: + break; + } + break; + + case VIEW_SAVED_INFO: + switch (ev->button) { + case INPUT_BTN_DOWN: + if (nav && s_info_sel < INFO_ACTION_COUNT - 1) { + s_info_sel++; + update_info_selection(); + ui_feedback(UI_FB_NAV); + } + break; + case INPUT_BTN_UP: + if (nav && s_info_sel > 0) { + s_info_sel--; + update_info_selection(); + ui_feedback(UI_FB_NAV); + } + break; + case INPUT_BTN_OK: + if (press) { + if (s_info_sel == 0) { + start_saved_send(); + } else { + msgbox_open(LV_SYMBOL_TRASH, "Delete signal?", "Delete", "Cancel", info_del_confirm); + } + } + break; + case INPUT_BTN_BACK: + if (press) { + s_view = VIEW_SAVED; + build_screen(); + } + break; + default: + break; + } + break; + } +} + +void ui_subghz_menu_open(void) { + s_view = VIEW_LIST; + s_saved_sel = 0; + build_screen(); +} diff --git a/firmware_p4/components/Applications/ui/screens/subghz/subghz_read_ui.c b/firmware_p4/components/Applications/ui/screens/subghz/subghz_read_ui.c new file mode 100644 index 000000000..4d9b25635 --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/subghz/subghz_read_ui.c @@ -0,0 +1,588 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#include "subghz_menu_ui.h" + +#include + +#include "esp_log.h" +#include "lvgl.h" + +#include "capture_result_ui.h" +#include "cc1101.h" +#include "msgbox_ui.h" +#include "notify_ui.h" +#include "subghz_receiver.h" +#include "ui_chrome.h" +#include "ui_feedback.h" +#include "ui_manager.h" +#include "ui_theme.h" + +static const char *TAG = "SUBGHZ_RD"; + +#define RX_PRESET CC1101_PRESET_OOK_800KHZ +#define RX_FREQ_HOPPING 0 + +#define TICK_MS 33 +#define REVEAL_MS 3000 +#define SCAN_MS 2600 +#define FREQ_CYCLE_MS 420 +#define SCOPE_TICK_MS 38 +#define DOT_CYCLE_MS 350 + +#define SIG_GREEN 0x00E676 + +#define HEADER_TITLE_Y 10 +#define HEADER_RULE_Y 32 +#define HEADER_RULE_W 70 +#define HEADER_RULE_H 2 + +#define STATUS_Y 48 +#define FREQ_Y 68 + +#define SCOPE_W 208 +#define SCOPE_H 84 +#define SCOPE_Y_OFS -22 +#define SCOPE_PAD 6 +#define SCOPE_RADIUS 8 +#define SCOPE_BORDER 2 +#define SCOPE_BG 0x0A0614 + +#define WAVE_POINTS 49 +#define WAVE_W (SCOPE_W - SCOPE_PAD * 2 - SCOPE_BORDER * 2) +#define WAVE_H (SCOPE_H - SCOPE_PAD * 2 - SCOPE_BORDER * 2) +#define WAVE_CY (WAVE_H / 2) +#define WAVE_LINE_W 2 + +#define AMP_SCAN (WAVE_H / 2 - 4) +#define AMP_VAR (WAVE_H / 6) +#define AMP_LOCK (WAVE_H / 3) +#define ANGLE_STEP_BASE 15 +#define ANGLE_VAR 9 +#define ANGLE_STEP_LOCK 15 +#define PHASE_STEP_SCAN 34 +#define MOD_STEP 6 +#define NOISE_SPREAD 7 + +#define OOK_SYNC_T 2 +#define OOK_HI_WIDE 3 +#define OOK_HI_NARROW 1 +#define OOK_MAX_PTS 64 + +#define GRID_OPA LV_OPA_20 + +#define READOUT_W 192 +#define READOUT_Y 198 +#define READOUT_ROW_GAP 5 +#define READOUT_FADE_MS 240 +#define READOUT_STAGGER 70 + +#define HINT_Y_OFS -6 + +#define STATUS_SCAN "Scanning" +#define HINT_SCAN "BACK to cancel" +#define HINT_SHOW "BACK = Exit" +#define HINT_MENU "UP/DOWN choose OK do BACK exit" + +#define SIG_PROTO "Princeton" +#define SIG_LOCK_FREQ "433.92 MHz" + +static const char *SCAN_FREQS[] = { + "433.92 MHz", + "868.30 MHz", + "315.00 MHz", + "915.00 MHz", +}; +#define SCAN_FREQ_COUNT ((int)(sizeof(SCAN_FREQS) / sizeof(SCAN_FREQS[0]))) + +static const struct { + const char *label; + const char *value; +} SIG_ROWS[] = { + {"Protocol", SIG_PROTO}, + {"Modulation", "OOK"}, + {"Bitrate", "4.8 kb/s"}, + {"Key", "0x1A2B3C"}, +}; +#define SIG_ROW_COUNT ((int)(sizeof(SIG_ROWS) / sizeof(SIG_ROWS[0]))) + +static const uint8_t OOK_BITS[] = {0, 0, 0, 1, 1, 0}; +#define OOK_BIT_COUNT ((int)(sizeof(OOK_BITS) / sizeof(OOK_BITS[0]))) + +static lv_obj_t *s_screen = NULL; +static lv_timer_t *s_tick_timer = NULL; +static lv_timer_t *s_scan_timer = NULL; +static lv_timer_t *s_freq_timer = NULL; +static lv_timer_t *s_scope_timer = NULL; + +static lv_obj_t *s_status = NULL; +static lv_obj_t *s_freq = NULL; +static lv_obj_t *s_wave = NULL; +static lv_obj_t *s_scope = NULL; +static lv_obj_t *s_readout = NULL; +static lv_obj_t *s_hint = NULL; +static capture_result_t s_cr = {0}; +static uint32_t s_locked_at = 0; +static bool s_options = false; + +static lv_point_precise_t s_wave_pts[WAVE_POINTS]; +static lv_point_precise_t s_ook_pts[OOK_MAX_PTS]; +static int s_phase = 0; +static int s_mod = 0; +static int s_freq_idx = 0; +static uint32_t s_scan_start = 0; +static bool s_locked = false; +static bool s_saved = false; + +static void read_tick_cb(lv_timer_t *t); +static void scan_done_cb(lv_timer_t *t); +static void freq_cycle_cb(lv_timer_t *t); +static void scope_tick_cb(lv_timer_t *t); +static void subghz_read_input(const input_event_t *ev, void *ctx); + +static void stop_timer(lv_timer_t **t) { + if (*t != NULL) { + lv_timer_delete(*t); + *t = NULL; + } +} + +static void stop_rx(void) { + if (subghz_receiver_is_running()) + subghz_receiver_stop(); +} + +static void opa_cb(void *var, int32_t v) { + lv_obj_set_style_opa((lv_obj_t *)var, (lv_opa_t)v, 0); +} + +static void fade_in(lv_obj_t *obj, uint32_t duration_ms, uint32_t delay_ms) { + lv_obj_set_style_opa(obj, LV_OPA_TRANSP, 0); + lv_anim_t a; + lv_anim_init(&a); + lv_anim_set_var(&a, obj); + lv_anim_set_exec_cb(&a, opa_cb); + lv_anim_set_values(&a, LV_OPA_TRANSP, LV_OPA_COVER); + lv_anim_set_duration(&a, duration_ms); + lv_anim_set_delay(&a, delay_ms); + lv_anim_set_path_cb(&a, lv_anim_path_ease_out); + lv_anim_start(&a); +} + +static void build_header(const char *text) { + lv_obj_t *title = lv_label_create(s_screen); + lv_label_set_text(title, text); + lv_obj_set_style_text_color(title, current_theme.border_accent, 0); + lv_obj_set_style_text_font(title, &lv_font_montserrat_14, 0); + lv_obj_align(title, LV_ALIGN_TOP_MID, 0, HEADER_TITLE_Y); + + lv_obj_t *rule = lv_obj_create(s_screen); + lv_obj_remove_flag(rule, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_remove_flag(rule, LV_OBJ_FLAG_CLICKABLE); + lv_obj_set_size(rule, lv_pct(HEADER_RULE_W), HEADER_RULE_H); + lv_obj_align(rule, LV_ALIGN_TOP_MID, 0, HEADER_RULE_Y); + lv_obj_set_style_border_width(rule, 0, 0); + lv_obj_set_style_radius(rule, 1, 0); + lv_obj_set_style_bg_color(rule, current_theme.border_accent, 0); + lv_obj_set_style_bg_opa(rule, LV_OPA_40, 0); +} + +static lv_obj_t *make_hint(const char *text) { + lv_obj_t *hint = lv_label_create(s_screen); + lv_label_set_text(hint, text); + lv_obj_set_style_text_color(hint, current_theme.text_main, 0); + lv_obj_set_style_text_opa(hint, LV_OPA_60, 0); + lv_obj_set_style_text_font(hint, &lv_font_montserrat_12, 0); + lv_obj_align(hint, LV_ALIGN_BOTTOM_MID, 0, HINT_Y_OFS); + return hint; +} + +static int clamp_y(int y) { + if (y < 0) + return 0; + if (y > WAVE_H) + return WAVE_H; + return y; +} + +static void fill_wave(bool noisy) { + int step = ANGLE_STEP_LOCK; + int amp = AMP_LOCK; + if (noisy) { + step = ANGLE_STEP_BASE + (ANGLE_VAR * lv_trigo_sin((int16_t)(s_mod % 360))) / 32767; + amp = AMP_SCAN - (AMP_VAR * lv_trigo_sin((int16_t)((s_mod * 2) % 360))) / 32767; + } + for (int i = 0; i < WAVE_POINTS; i++) { + int ang = (s_phase + i * step) % 360; + if (ang < 0) + ang += 360; + int s = lv_trigo_sin((int16_t)ang); + int y = WAVE_CY - (amp * s) / 32767; + if (noisy) + y += ((i * 13 + s_phase) % NOISE_SPREAD) - NOISE_SPREAD / 2; + s_wave_pts[i].x = i * WAVE_W / (WAVE_POINTS - 1); + s_wave_pts[i].y = clamp_y(y); + } + if (s_wave != NULL) + lv_line_set_points(s_wave, s_wave_pts, WAVE_POINTS); +} + +static void fill_ook(void) { + if (s_wave == NULL) + return; + int total_t = OOK_SYNC_T + OOK_BIT_COUNT * (OOK_HI_WIDE + OOK_HI_NARROW); + int unit = WAVE_W / total_t; + if (unit < 1) + unit = 1; + int hi = WAVE_CY - AMP_LOCK; + int lo = WAVE_CY + AMP_LOCK; + int n = 0; + int x = 0; + s_ook_pts[n].x = x; + s_ook_pts[n].y = lo; + n++; + x += OOK_SYNC_T * unit; + s_ook_pts[n].x = x; + s_ook_pts[n].y = lo; + n++; + for (int b = 0; b < OOK_BIT_COUNT && n + 4 <= OOK_MAX_PTS; b++) { + int hw = (OOK_BITS[b] ? OOK_HI_WIDE : OOK_HI_NARROW) * unit; + int lw = (OOK_BITS[b] ? OOK_HI_NARROW : OOK_HI_WIDE) * unit; + s_ook_pts[n].x = x; + s_ook_pts[n].y = hi; + n++; + x += hw; + s_ook_pts[n].x = x; + s_ook_pts[n].y = hi; + n++; + s_ook_pts[n].x = x; + s_ook_pts[n].y = lo; + n++; + x += lw; + s_ook_pts[n].x = x; + s_ook_pts[n].y = lo; + n++; + } + lv_line_set_points(s_wave, s_ook_pts, n); +} + +static void build_scope(void) { + lv_obj_t *frame = lv_obj_create(s_screen); + s_scope = frame; + lv_obj_remove_flag(frame, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(frame, SCOPE_W, SCOPE_H); + lv_obj_align(frame, LV_ALIGN_CENTER, 0, SCOPE_Y_OFS); + lv_obj_set_style_radius(frame, SCOPE_RADIUS, 0); + lv_obj_set_style_pad_all(frame, SCOPE_PAD, 0); + lv_obj_set_style_bg_color(frame, lv_color_hex(SCOPE_BG), 0); + lv_obj_set_style_bg_opa(frame, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(frame, SCOPE_BORDER, 0); + lv_obj_set_style_border_color(frame, current_theme.border_accent, 0); + lv_obj_set_style_border_opa(frame, LV_OPA_70, 0); + + lv_obj_t *grid = lv_obj_create(frame); + lv_obj_remove_flag(grid, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(grid, WAVE_W, 1); + lv_obj_align(grid, LV_ALIGN_CENTER, 0, 0); + lv_obj_set_style_border_width(grid, 0, 0); + lv_obj_set_style_radius(grid, 0, 0); + lv_obj_set_style_bg_color(grid, current_theme.border_accent, 0); + lv_obj_set_style_bg_opa(grid, GRID_OPA, 0); + + s_wave = lv_line_create(frame); + lv_obj_align(s_wave, LV_ALIGN_TOP_LEFT, 0, 0); + lv_obj_set_style_line_width(s_wave, WAVE_LINE_W, 0); + lv_obj_set_style_line_color(s_wave, current_theme.border_accent, 0); + lv_obj_set_style_line_rounded(s_wave, true, 0); + + s_phase = 0; + s_mod = 0; + fill_wave(true); +} + +void ui_subghz_read_open(void) { + stop_rx(); + stop_timer(&s_scan_timer); + stop_timer(&s_freq_timer); + stop_timer(&s_scope_timer); + stop_timer(&s_tick_timer); + if (s_screen != NULL) { + lv_obj_del(s_screen); + s_screen = NULL; + } + s_status = NULL; + s_freq = NULL; + s_wave = NULL; + s_scope = NULL; + s_readout = NULL; + s_hint = NULL; + s_cr = (capture_result_t){0}; + s_options = false; + s_locked_at = 0; + s_freq_idx = 0; + s_locked = false; + s_saved = false; + + s_screen = lv_obj_create(NULL); + lv_obj_set_style_bg_color(s_screen, current_theme.screen_base, 0); + lv_obj_set_style_bg_opa(s_screen, LV_OPA_COVER, 0); + lv_obj_remove_flag(s_screen, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_border_width(s_screen, 0, 0); + lv_obj_set_style_pad_all(s_screen, 0, 0); + + ui_chrome_header(s_screen, "READ", "/assets/icons/sensors.bin"); + + s_status = lv_label_create(s_screen); + lv_label_set_text(s_status, STATUS_SCAN); + lv_obj_set_style_text_color(s_status, current_theme.text_main, 0); + lv_obj_set_style_text_font(s_status, &lv_font_montserrat_14, 0); + lv_obj_align(s_status, LV_ALIGN_TOP_MID, 0, STATUS_Y); + + s_freq = lv_label_create(s_screen); + lv_label_set_text(s_freq, SCAN_FREQS[0]); + lv_obj_set_style_text_color(s_freq, current_theme.border_accent, 0); + lv_obj_set_style_text_font(s_freq, &lv_font_montserrat_12, 0); + lv_obj_align(s_freq, LV_ALIGN_TOP_MID, 0, FREQ_Y); + + build_scope(); + + s_hint = ui_chrome_footer(s_screen, HINT_SCAN); + + s_scan_start = lv_tick_get(); + s_scan_timer = lv_timer_create(scan_done_cb, SCAN_MS, NULL); + lv_timer_set_repeat_count(s_scan_timer, 1); + s_freq_timer = lv_timer_create(freq_cycle_cb, FREQ_CYCLE_MS, NULL); + s_scope_timer = lv_timer_create(scope_tick_cb, SCOPE_TICK_MS, NULL); + s_tick_timer = lv_timer_create(read_tick_cb, TICK_MS, NULL); + + ui_input_set_screen_handler(subghz_read_input, NULL); + + esp_err_t rx = subghz_receiver_start(SUBGHZ_MODE_SCAN, RX_PRESET, RX_FREQ_HOPPING); + if (rx != ESP_OK) + ESP_LOGE(TAG, "subghz_receiver_start failed: %s", esp_err_to_name(rx)); + + ui_screen_load_owned(&s_screen, s_screen); +} + +static void scope_tick_cb(lv_timer_t *t) { + if (lv_screen_active() != s_screen) { + lv_timer_delete(t); + s_scope_timer = NULL; + return; + } + s_phase = (s_phase + PHASE_STEP_SCAN) % 360; + s_mod = (s_mod + MOD_STEP) % 360; + fill_wave(true); +} + +static void freq_cycle_cb(lv_timer_t *t) { + if (lv_screen_active() != s_screen) { + lv_timer_delete(t); + s_freq_timer = NULL; + return; + } + s_freq_idx = (s_freq_idx + 1) % SCAN_FREQ_COUNT; + if (s_freq) + lv_label_set_text(s_freq, SCAN_FREQS[s_freq_idx]); +} + +static void build_signal_readout(void) { + lv_obj_t *col = lv_obj_create(s_screen); + s_readout = col; + lv_obj_remove_flag(col, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_width(col, READOUT_W); + lv_obj_set_height(col, LV_SIZE_CONTENT); + lv_obj_align(col, LV_ALIGN_TOP_MID, 0, READOUT_Y); + lv_obj_set_style_bg_opa(col, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(col, 0, 0); + lv_obj_set_style_pad_all(col, 0, 0); + lv_obj_set_flex_flow(col, LV_FLEX_FLOW_COLUMN); + lv_obj_set_style_pad_row(col, READOUT_ROW_GAP, 0); + + for (int i = 0; i < SIG_ROW_COUNT; i++) { + lv_obj_t *row = lv_obj_create(col); + lv_obj_remove_flag(row, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_width(row, lv_pct(100)); + lv_obj_set_height(row, LV_SIZE_CONTENT); + lv_obj_set_style_bg_opa(row, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(row, 0, 0); + lv_obj_set_style_pad_all(row, 0, 0); + lv_obj_set_flex_flow(row, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align( + row, LV_FLEX_ALIGN_SPACE_BETWEEN, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + + lv_obj_t *label = lv_label_create(row); + lv_label_set_text(label, SIG_ROWS[i].label); + lv_obj_set_style_text_color(label, current_theme.border_inactive, 0); + lv_obj_set_style_text_font(label, &lv_font_montserrat_12, 0); + + lv_obj_t *value = lv_label_create(row); + lv_label_set_text(value, SIG_ROWS[i].value); + lv_obj_set_style_text_color(value, current_theme.border_accent, 0); + lv_obj_set_style_text_font(value, &lv_font_montserrat_12, 0); + + fade_in(row, READOUT_FADE_MS, i * READOUT_STAGGER); + } +} + +static void scan_done_cb(lv_timer_t *t) { + (void)t; + s_scan_timer = NULL; + stop_timer(&s_freq_timer); + stop_timer(&s_scope_timer); + stop_rx(); + if (lv_screen_active() != s_screen) + return; + + s_locked = true; + if (s_wave != NULL) + lv_obj_set_style_line_rounded(s_wave, false, 0); + fill_ook(); + + if (s_status) { + lv_label_set_text(s_status, "Signal locked!"); + lv_obj_set_style_text_color(s_status, lv_color_hex(SIG_GREEN), 0); + } + if (s_freq) + lv_label_set_text(s_freq, SIG_LOCK_FREQ); + + build_signal_readout(); + + if (s_hint != NULL) + ui_chrome_footer_set_text(s_hint, HINT_SHOW); + s_locked_at = lv_tick_get(); + + ESP_LOGI(TAG, "mock subghz capture: %s %s", SIG_PROTO, SIG_LOCK_FREQ); + ui_feedback(UI_FB_READ); +} + +static void show_options(void) { + if (s_scope) { + lv_obj_del(s_scope); + s_scope = NULL; + s_wave = NULL; + } + if (s_readout) { + lv_obj_del(s_readout); + s_readout = NULL; + } + if (s_status) + lv_obj_add_flag(s_status, LV_OBJ_FLAG_HIDDEN); + if (s_freq) + lv_obj_add_flag(s_freq, LV_OBJ_FLAG_HIDDEN); + + capture_result_cfg_t cfg = { + .accent = current_theme.border_accent, + .card_icon = "/assets/icons/graphic_eq.bin", + .card_title = "Signal captured", + .card_sub = SIG_PROTO " (OOK)", + .card_value = SIG_LOCK_FREQ, + .primary_label = "Send", + .again_label = "Capture again", + }; + s_cr = capture_result_create(s_screen, &cfg); + s_options = true; + if (s_hint) + ui_chrome_footer_set_text(s_hint, HINT_MENU); +} + +static void read_tick_cb(lv_timer_t *t) { + if (lv_screen_active() != s_screen) { + lv_timer_delete(t); + s_tick_timer = NULL; + return; + } + + if (!s_locked && s_status != NULL) { + int dots = ((lv_tick_get() - s_scan_start) / DOT_CYCLE_MS) % 4; + char buf[20]; + snprintf(buf, + sizeof(buf), + "%s%s", + STATUS_SCAN, + dots == 1 ? "." + : dots == 2 ? ".." + : dots == 3 ? "..." + : ""); + lv_label_set_text(s_status, buf); + } + + if (s_locked && !s_options) { + if (lv_tick_get() - s_locked_at >= REVEAL_MS) + show_options(); + } +} + +static void subghz_read_input(const input_event_t *ev, void *ctx) { + (void)ctx; + const bool press = (ev->action == INPUT_ACTION_PRESS); + const bool nav = press || (ev->action == INPUT_ACTION_REPEAT); + + if (ev->button == INPUT_BTN_BACK) { + if (press) { + stop_rx(); + ui_switch_screen(SCREEN_SUBGHZ_MENU); + } + return; + } + + if (!s_options) + return; + + switch (ev->button) { + case INPUT_BTN_DOWN: + if (nav) { + capture_result_next(&s_cr); + ui_feedback(UI_FB_NAV); + } + break; + case INPUT_BTN_UP: + if (nav) { + capture_result_prev(&s_cr); + ui_feedback(UI_FB_NAV); + } + break; + case INPUT_BTN_OK: + if (press) { + switch (capture_result_selected(&s_cr)) { + case CAP_ACT_PRIMARY: + ui_feedback(UI_FB_EMULATE); + notify(NOTIFY_INFO, SIG_LOCK_FREQ " sent"); + break; + case CAP_ACT_SAVE: + if (!s_saved) { + s_saved = true; + capture_result_mark_saved(&s_cr); + ESP_LOGI(TAG, "mock subghz saved: %s", SIG_PROTO); + ui_feedback(UI_FB_WRITE); + notify(NOTIFY_SAVED, "Sub-GHz signal saved"); + } + break; + case CAP_ACT_AGAIN: + ui_subghz_read_open(); + return; + case CAP_ACT_DISCARD: + stop_rx(); + ui_switch_screen(SCREEN_SUBGHZ_MENU); + return; + default: + break; + } + } + break; + default: + break; + } +} diff --git a/firmware_p4/components/Applications/ui/screens/subghz/subghz_send_ui.c b/firmware_p4/components/Applications/ui/screens/subghz/subghz_send_ui.c new file mode 100644 index 000000000..1863deba9 --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/subghz/subghz_send_ui.c @@ -0,0 +1,533 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#include "subghz_send_ui.h" + +#include + +#include "esp_log.h" +#include "lvgl.h" +#include "st7789.h" + +#include "assets_manager.h" +#include "capture_result_ui.h" +#include "notify_ui.h" +#include "subghz_scope_ui.h" +#include "ui_chrome.h" +#include "ui_feedback.h" +#include "ui_manager.h" +#include "ui_theme.h" + +static const char *TAG = "SUBGHZ_SEND_UI"; + +#define SIG_GREEN 0x00E676 +#define ANTENNA_ICON "/assets/icons/settings_input_antenna.bin" +#define SIGNAL_ICON "/assets/icons/graphic_eq.bin" + +#define REFRESH_TICK_MS 50 +#define SENDING_MS 1600 +#define DOT_CYCLE_MS 350 +#define REVEAL_MS 2600 + +#define STATUS_Y 48 +#define FREQ_Y 68 +#define SCOPE_Y_OFS -8 + +#define WAVES_ICON "/assets/icons/waves.bin" +#define DIM_COLOR 0x8A8594 + +#define LIST_PAD_SIDE 8 +#define LIST_PAD_ROW 6 +#define LIST_SB_W 4 +#define LIST_SB_RADIUS 2 +#define ROW_H 44 +#define ROW_RADIUS 10 +#define ROW_PAD_H 10 +#define ROW_COL_GAP 10 +#define ROW_GLYPH_SLOT 28 +#define ROW_GLOW_W 14 +#define ROW_GLOW_SPREAD -2 + +#define STATUS_SENDING "Transmitting" +#define STATUS_SENT "Signal sent!" + +#define HINT_SENDING "Transmitting..." +#define HINT_SENT "BACK = Exit" +#define HINT_LIST "UP/DOWN choose OK send BACK exit" +#define HINT_OPTIONS "UP/DOWN choose OK do BACK exit" + +static const struct { + const char *name; + const char *freq; + const char *proto; +} SIGNALS[] = { + {"Gate_433", "433.92 MHz", "Princeton"}, + {"Doorbell", "433.92 MHz", "CAME"}, + {"TPMS_FL", "315.00 MHz", "TPMS"}, + {"Garage", "868.30 MHz", "Nice FLO"}, +}; +#define SIGNAL_COUNT ((int)(sizeof(SIGNALS) / sizeof(SIGNALS[0]))) + +typedef enum { + VIEW_LIST = 0, + VIEW_SENDING, + VIEW_SENT, +} send_view_t; + +static lv_obj_t *s_screen = NULL; +static lv_obj_t *s_list = NULL; +static lv_obj_t *s_rows[SIGNAL_COUNT]; +static lv_obj_t *s_row_name[SIGNAL_COUNT]; +static lv_obj_t *s_row_val[SIGNAL_COUNT]; +static send_view_t s_view = VIEW_LIST; +static int s_sel = 0; + +static lv_timer_t *s_refresh_timer = NULL; +static lv_timer_t *s_send_timer = NULL; + +static lv_obj_t *s_status_label = NULL; +static lv_obj_t *s_freq_label = NULL; +static lv_obj_t *s_hint_label = NULL; +static lv_obj_t *s_sig = NULL; +static capture_result_t s_cr = {0}; +static bool s_options = false; +static bool s_saved = false; +static uint32_t s_send_start = 0; +static uint32_t s_sent_at = 0; + +static void subghz_send_input(const input_event_t *ev, void *ctx); +static void refresh_tick_cb(lv_timer_t *t); +static void build_list(void); +static void build_sending(void); +static void send_done_cb(lv_timer_t *t); + +static void stop_send_timer(void) { + if (s_send_timer != NULL) { + lv_timer_delete(s_send_timer); + s_send_timer = NULL; + } +} + +static lv_obj_t *new_screen(void) { + lv_obj_t *screen = lv_obj_create(NULL); + lv_obj_set_style_bg_color(screen, current_theme.screen_base, 0); + lv_obj_set_style_bg_opa(screen, LV_OPA_COVER, 0); + lv_obj_remove_flag(screen, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_border_width(screen, 0, 0); + lv_obj_set_style_pad_all(screen, 0, 0); + return screen; +} + +static void set_status(const char *text, bool success) { + if (s_status_label == NULL) + return; + lv_label_set_text(s_status_label, text); + lv_obj_set_style_text_color( + s_status_label, success ? lv_color_hex(SIG_GREEN) : current_theme.text_main, 0); +} + +static void set_hint(const char *text) { + if (s_hint_label != NULL) + ui_chrome_footer_set_text(s_hint_label, text); +} + +static void style_row(int i, bool sel) { + lv_obj_t *r = s_rows[i]; + if (r == NULL) + return; + if (sel) { + lv_obj_set_style_bg_color(r, current_theme.bg_secondary, 0); + lv_obj_set_style_border_color(r, current_theme.border_accent, 0); + lv_obj_set_style_shadow_color(r, current_theme.border_accent, 0); + lv_obj_set_style_shadow_width(r, ROW_GLOW_W, 0); + lv_obj_set_style_shadow_opa(r, LV_OPA_40, 0); + lv_obj_set_style_shadow_spread(r, ROW_GLOW_SPREAD, 0); + lv_obj_set_style_text_color(s_row_name[i], current_theme.text_main, 0); + lv_obj_set_style_text_color(s_row_val[i], current_theme.border_accent, 0); + } else { + lv_obj_set_style_bg_color(r, current_theme.bg_primary, 0); + lv_obj_set_style_border_color(r, current_theme.border_inactive, 0); + lv_obj_set_style_shadow_width(r, 0, 0); + lv_obj_set_style_text_color(s_row_name[i], lv_color_hex(DIM_COLOR), 0); + lv_obj_set_style_text_color(s_row_val[i], lv_color_hex(DIM_COLOR), 0); + } +} + +static void update_selection(void) { + for (int i = 0; i < SIGNAL_COUNT; i++) + style_row(i, i == s_sel); + if (s_list != NULL && s_rows[s_sel] != NULL) { + lv_obj_update_layout(s_list); + lv_obj_scroll_to_view(s_rows[s_sel], LV_ANIM_ON); + } +} + +static void build_list(void) { + subghz_scope_stop(); + if (s_screen != NULL) { + lv_obj_del(s_screen); + s_screen = NULL; + } + s_status_label = NULL; + s_freq_label = NULL; + s_hint_label = NULL; + s_sig = NULL; + s_list = NULL; + s_cr = (capture_result_t){0}; + s_options = false; + + if (s_sel < 0) + s_sel = 0; + if (s_sel >= SIGNAL_COUNT) + s_sel = SIGNAL_COUNT - 1; + + s_screen = new_screen(); + ui_chrome_header(s_screen, "SEND", ANTENNA_ICON); + + lv_obj_t *cont = lv_obj_create(s_screen); + s_list = cont; + lv_obj_set_size(cont, lv_pct(100), LCD_V_RES - UI_CHROME_HEADER_H - UI_CHROME_FOOTER_H); + lv_obj_align(cont, LV_ALIGN_TOP_MID, 0, UI_CHROME_HEADER_H); + lv_obj_set_style_bg_opa(cont, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(cont, 0, 0); + lv_obj_set_style_pad_all(cont, 0, 0); + lv_obj_set_style_pad_left(cont, LIST_PAD_SIDE, 0); + lv_obj_set_style_pad_right(cont, LIST_PAD_SIDE, 0); + lv_obj_set_style_pad_row(cont, LIST_PAD_ROW, 0); + lv_obj_set_flex_flow(cont, LV_FLEX_FLOW_COLUMN); + lv_obj_add_flag(cont, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_scroll_dir(cont, LV_DIR_VER); + lv_obj_set_scrollbar_mode(cont, LV_SCROLLBAR_MODE_ON); + lv_obj_clear_flag(cont, LV_OBJ_FLAG_SCROLL_ELASTIC | LV_OBJ_FLAG_SCROLL_MOMENTUM); + lv_obj_set_style_bg_color(cont, current_theme.border_accent, LV_PART_SCROLLBAR); + lv_obj_set_style_bg_opa(cont, LV_OPA_COVER, LV_PART_SCROLLBAR); + lv_obj_set_style_width(cont, LIST_SB_W, LV_PART_SCROLLBAR); + lv_obj_set_style_radius(cont, LIST_SB_RADIUS, LV_PART_SCROLLBAR); + + for (int i = 0; i < SIGNAL_COUNT; i++) { + lv_obj_t *r = lv_obj_create(cont); + s_rows[i] = r; + lv_obj_remove_flag(r, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_width(r, lv_pct(100)); + lv_obj_set_height(r, ROW_H); + lv_obj_set_style_radius(r, ROW_RADIUS, 0); + lv_obj_set_style_bg_opa(r, LV_OPA_COVER, 0); + lv_obj_set_style_bg_grad_dir(r, LV_GRAD_DIR_NONE, 0); + lv_obj_set_style_border_width(r, 1, 0); + lv_obj_set_style_pad_hor(r, ROW_PAD_H, 0); + lv_obj_set_style_pad_ver(r, 0, 0); + lv_obj_set_flex_flow(r, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(r, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_set_style_pad_column(r, ROW_COL_GAP, 0); + + lv_obj_t *slot = lv_obj_create(r); + lv_obj_remove_flag(slot, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(slot, ROW_GLYPH_SLOT, ROW_GLYPH_SLOT); + lv_obj_set_style_bg_opa(slot, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(slot, 0, 0); + lv_obj_set_style_pad_all(slot, 0, 0); + + lv_image_dsc_t *dsc = assets_get(WAVES_ICON); + if (dsc != NULL) { + lv_obj_t *img = lv_image_create(slot); + lv_image_set_src(img, dsc); + lv_image_set_inner_align(img, LV_IMAGE_ALIGN_CONTAIN); + lv_obj_set_size(img, ROW_GLYPH_SLOT, ROW_GLYPH_SLOT); + lv_obj_center(img); + lv_obj_set_style_image_recolor(img, current_theme.border_accent, 0); + lv_obj_set_style_image_recolor_opa(img, LV_OPA_COVER, 0); + } + + lv_obj_t *name = lv_label_create(r); + s_row_name[i] = name; + lv_label_set_long_mode(name, LV_LABEL_LONG_SCROLL_CIRCULAR); + lv_obj_set_flex_grow(name, 1); + lv_label_set_text(name, SIGNALS[i].name); + lv_obj_set_style_text_font(name, &lv_font_montserrat_14, 0); + + lv_obj_t *val = lv_label_create(r); + s_row_val[i] = val; + lv_label_set_text(val, SIGNALS[i].freq); + lv_obj_set_style_text_font(val, &lv_font_montserrat_12, 0); + } + + s_hint_label = ui_chrome_footer(s_screen, HINT_LIST); + update_selection(); + + ui_screen_load_owned(&s_screen, s_screen); +} + +static void build_sending(void) { + subghz_scope_stop(); + if (s_screen != NULL) { + lv_obj_del(s_screen); + s_screen = NULL; + } + s_list = NULL; + s_cr = (capture_result_t){0}; + s_options = false; + + s_screen = new_screen(); + ui_chrome_header(s_screen, "SEND", ANTENNA_ICON); + + s_status_label = lv_label_create(s_screen); + lv_label_set_text(s_status_label, STATUS_SENDING); + lv_obj_set_style_text_color(s_status_label, current_theme.text_main, 0); + lv_obj_set_style_text_font(s_status_label, &lv_font_montserrat_14, 0); + lv_obj_set_style_text_align(s_status_label, LV_TEXT_ALIGN_CENTER, 0); + lv_obj_align(s_status_label, LV_ALIGN_TOP_MID, 0, STATUS_Y); + + s_freq_label = lv_label_create(s_screen); + lv_label_set_text(s_freq_label, SIGNALS[s_sel].freq); + lv_obj_set_style_text_color(s_freq_label, current_theme.border_accent, 0); + lv_obj_set_style_text_font(s_freq_label, &lv_font_montserrat_12, 0); + lv_obj_align(s_freq_label, LV_ALIGN_TOP_MID, 0, FREQ_Y); + + s_sig = subghz_scope_create(s_screen, LV_ALIGN_CENTER, 0, SCOPE_Y_OFS); + + s_hint_label = ui_chrome_footer(s_screen, HINT_SENDING); + + ui_screen_load_owned(&s_screen, s_screen); +} + +static void show_options(void) { + subghz_scope_stop(); + if (s_sig != NULL) { + lv_obj_del(s_sig); + s_sig = NULL; + } + if (s_status_label != NULL) + lv_obj_add_flag(s_status_label, LV_OBJ_FLAG_HIDDEN); + if (s_freq_label != NULL) + lv_obj_add_flag(s_freq_label, LV_OBJ_FLAG_HIDDEN); + + capture_result_cfg_t cfg = { + .accent = current_theme.border_accent, + .card_icon = SIGNAL_ICON, + .card_title = SIGNALS[s_sel].name, + .card_sub = SIGNALS[s_sel].proto, + .card_value = SIGNALS[s_sel].freq, + .primary_label = "Send again", + .again_label = "Pick another", + }; + s_cr = capture_result_create(s_screen, &cfg); + s_options = true; + s_saved = false; + set_hint(HINT_OPTIONS); +} + +static void start_send(void) { + stop_send_timer(); + s_view = VIEW_SENDING; + s_send_start = lv_tick_get(); + ESP_LOGI(TAG, "mock send: %s %s", SIGNALS[s_sel].name, SIGNALS[s_sel].freq); + build_sending(); + ui_feedback(UI_FB_SELECT); + s_send_timer = lv_timer_create(send_done_cb, SENDING_MS, NULL); + lv_timer_set_repeat_count(s_send_timer, 1); +} + +static void send_done_cb(lv_timer_t *t) { + (void)t; + s_send_timer = NULL; + if (lv_screen_active() != s_screen) + return; + + s_view = VIEW_SENT; + s_options = false; + set_status(STATUS_SENT, true); + + subghz_scope_lock(); + + s_sent_at = lv_tick_get(); + set_hint(HINT_SENT); + ui_feedback(UI_FB_WRITE); +} + +static void sending_tick(void) { + if (s_status_label == NULL) + return; + int dots = ((lv_tick_get() - s_send_start) / DOT_CYCLE_MS) % 4; + char buf[24]; + snprintf(buf, + sizeof(buf), + "%s%s", + STATUS_SENDING, + dots == 1 ? "." + : dots == 2 ? ".." + : dots == 3 ? "..." + : ""); + lv_label_set_text(s_status_label, buf); +} + +static void refresh_tick_cb(lv_timer_t *t) { + if (lv_screen_active() != s_screen) { + lv_timer_delete(t); + s_refresh_timer = NULL; + return; + } + + if (s_view == VIEW_SENDING) { + sending_tick(); + } else if (s_view == VIEW_SENT && !s_options) { + if (lv_tick_get() - s_sent_at >= REVEAL_MS) + show_options(); + } +} + +static void subghz_send_input(const input_event_t *ev, void *ctx) { + (void)ctx; + const bool press = (ev->action == INPUT_ACTION_PRESS); + const bool nav = press || (ev->action == INPUT_ACTION_REPEAT); + + switch (s_view) { + case VIEW_LIST: + switch (ev->button) { + case INPUT_BTN_DOWN: + if (nav && s_sel < SIGNAL_COUNT - 1) { + s_sel++; + update_selection(); + ui_feedback(UI_FB_NAV); + } + break; + case INPUT_BTN_UP: + if (nav && s_sel > 0) { + s_sel--; + update_selection(); + ui_feedback(UI_FB_NAV); + } + break; + case INPUT_BTN_OK: + case INPUT_BTN_RIGHT: + if (press) + start_send(); + break; + case INPUT_BTN_BACK: + case INPUT_BTN_LEFT: + if (press) + ui_switch_screen(SCREEN_SUBGHZ_MENU); + break; + default: + break; + } + break; + + case VIEW_SENDING: + switch (ev->button) { + case INPUT_BTN_BACK: + if (press) { + stop_send_timer(); + s_view = VIEW_LIST; + build_list(); + } + break; + default: + break; + } + break; + + case VIEW_SENT: + if (!s_options) { + switch (ev->button) { + case INPUT_BTN_BACK: + if (press) { + s_view = VIEW_LIST; + build_list(); + } + break; + default: + break; + } + } else { + switch (ev->button) { + case INPUT_BTN_DOWN: + if (nav) { + capture_result_next(&s_cr); + ui_feedback(UI_FB_NAV); + } + break; + case INPUT_BTN_UP: + if (nav) { + capture_result_prev(&s_cr); + ui_feedback(UI_FB_NAV); + } + break; + case INPUT_BTN_OK: + if (press) { + switch (capture_result_selected(&s_cr)) { + case CAP_ACT_PRIMARY: + start_send(); + break; + case CAP_ACT_SAVE: + if (!s_saved) { + s_saved = true; + capture_result_mark_saved(&s_cr); + ESP_LOGI(TAG, "mock signal saved: %s", SIGNALS[s_sel].name); + ui_feedback(UI_FB_WRITE); + notify(NOTIFY_SAVED, "Sub-GHz signal saved"); + } + break; + case CAP_ACT_AGAIN: + s_view = VIEW_LIST; + build_list(); + break; + case CAP_ACT_DISCARD: + ui_switch_screen(SCREEN_SUBGHZ_MENU); + break; + default: + break; + } + } + break; + case INPUT_BTN_BACK: + if (press) + ui_switch_screen(SCREEN_SUBGHZ_MENU); + break; + default: + break; + } + } + break; + + default: + break; + } +} + +void ui_subghz_send_open(void) { + stop_send_timer(); + subghz_scope_stop(); + if (s_screen != NULL) { + lv_obj_del(s_screen); + s_screen = NULL; + } + s_status_label = NULL; + s_freq_label = NULL; + s_hint_label = NULL; + s_sig = NULL; + s_list = NULL; + s_cr = (capture_result_t){0}; + s_options = false; + s_saved = false; + s_view = VIEW_LIST; + s_sel = 0; + + build_list(); + + if (s_refresh_timer == NULL) + s_refresh_timer = lv_timer_create(refresh_tick_cb, REFRESH_TICK_MS, NULL); + + ui_input_set_screen_handler(subghz_send_input, NULL); +} diff --git a/firmware_p4/components/Applications/ui/screens/theme_selector/include/theme_selector_ui.h b/firmware_p4/components/Applications/ui/screens/theme/include/theme_selector_ui.h similarity index 91% rename from firmware_p4/components/Applications/ui/screens/theme_selector/include/theme_selector_ui.h rename to firmware_p4/components/Applications/ui/screens/theme/include/theme_selector_ui.h index cd25c823d..1e1c91d35 100644 --- a/firmware_p4/components/Applications/ui/screens/theme_selector/include/theme_selector_ui.h +++ b/firmware_p4/components/Applications/ui/screens/theme/include/theme_selector_ui.h @@ -20,7 +20,7 @@ extern "C" { #endif -/** @brief Open the theme selector screen. */ +/** @brief Open the theme selector screen (live-applies the chosen palette). */ void ui_theme_selector_open(void); #ifdef __cplusplus diff --git a/firmware_p4/components/Applications/ui/screens/theme/theme_selector_ui.c b/firmware_p4/components/Applications/ui/screens/theme/theme_selector_ui.c new file mode 100644 index 000000000..4d65516b6 --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/theme/theme_selector_ui.c @@ -0,0 +1,282 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#include "theme_selector_ui.h" + +#include + +#include "esp_log.h" + +#include "assets_manager.h" +#include "notify_ui.h" +#include "page_dots_ui.h" +#include "st7789.h" +#include "tos_config.h" +#include "tos_storage_paths.h" +#include "ui_chrome.h" +#include "ui_feedback.h" +#include "ui_manager.h" +#include "ui_theme.h" + +static const char *TAG = "THEME_SELECTOR_UI"; + +#define TITLE_ICON "/assets/icons/palette.bin" +#define BASE_FRAME "/assets/frames/base_frame_0.bin" +#define CARD_Y_BIAS (-18) +#define ANIM_MS 220 + +extern int theme_idx; +extern const char *theme_names[]; + +typedef struct { + const char *label; + uint32_t accent; +} theme_face_t; + +static const theme_face_t THEMES[] = { + {"Default", 0x834EC6}, + {"Cyber Blue", 0x00D9FF}, +}; +#define THEME_COUNT ((int)(sizeof(THEMES) / sizeof(THEMES[0]))) + +static const int32_t CAR_PX[] = {-94, -50, 0, 50, 94}; +static const int32_t CAR_PY[] = {-14, -6, 0, -6, -14}; +static const int32_t CAR_SC[] = {117, 161, 234, 161, 117}; +static const int32_t CAR_OP[] = {LV_OPA_50, LV_OPA_80, LV_OPA_COVER, LV_OPA_80, LV_OPA_50}; +static const int32_t CAR_Z[] = {0, 1, 2, 1, 0}; +#define CAR_SLOTS 5 +#define CAR_CENTER 2 + +static lv_obj_t *s_screen = NULL; +static lv_obj_t *s_cards[THEME_COUNT]; +static lv_obj_t *s_label = NULL; +static lv_obj_t *s_active = NULL; +static page_dots_t s_dots; +static lv_image_dsc_t *s_base_dsc = NULL; +static int s_sel = 0; +static bool s_animating = false; + +static void build_screen(void); + +// Animation exec wrappers (explicit, so we avoid the cast-function-type idiom). +static void anim_set_x(void *o, int32_t v) { + lv_obj_set_x((lv_obj_t *)o, v); +} +static void anim_set_y(void *o, int32_t v) { + lv_obj_set_y((lv_obj_t *)o, v); +} +static void anim_set_scale(void *o, int32_t v) { + lv_image_set_scale((lv_obj_t *)o, (uint32_t)v); +} +static void anim_set_opa(void *o, int32_t v) { + lv_obj_set_style_opa((lv_obj_t *)o, (lv_opa_t)v, 0); +} +static void anim_done_cb(lv_anim_t *a) { + (void)a; + s_animating = false; +} + +static int32_t carousel_slot(int item_idx) { + int32_t n = THEME_COUNT; + int32_t d = (item_idx - s_sel + n) % n; + if (d > n / 2) + d -= n; + int32_t slot = CAR_CENTER + d; + return (slot >= 0 && slot < CAR_SLOTS) ? slot : -1; +} + +static lv_obj_t *make_card(lv_obj_t *parent, int i) { + lv_obj_t *card = lv_image_create(parent); + if (s_base_dsc) + lv_image_set_src(card, s_base_dsc); + lv_image_set_antialias(card, false); + lv_obj_align(card, LV_ALIGN_CENTER, 0, CARD_Y_BIAS); + lv_obj_set_style_image_recolor(card, lv_color_hex(THEMES[i].accent), 0); + lv_obj_set_style_image_recolor_opa(card, LV_OPA_COVER, 0); + return card; +} + +static void place_card(int i, bool anim) { + lv_obj_t *card = s_cards[i]; + if (card == NULL) + return; + int32_t slot = carousel_slot(i); + + if (slot < 0) { + lv_obj_set_style_opa(card, LV_OPA_TRANSP, 0); + lv_obj_add_flag(card, LV_OBJ_FLAG_HIDDEN); + return; + } + lv_obj_remove_flag(card, LV_OBJ_FLAG_HIDDEN); + + int32_t tx = CAR_PX[slot]; + int32_t ty = CAR_PY[slot] + CARD_Y_BIAS; + int32_t ts = CAR_SC[slot]; + int32_t to = CAR_OP[slot]; + + if (!anim) { + lv_obj_align(card, LV_ALIGN_CENTER, tx, ty); + lv_image_set_scale(card, ts); + lv_obj_set_style_opa(card, to, 0); + return; + } + + // Slide/scale/fade toward the target slot (mirrors the shipped menu_ui pattern: + // decoded frames are cached, so this no longer re-decodes per navigation). + lv_anim_t a; + lv_anim_init(&a); + lv_anim_set_duration(&a, ANIM_MS); + lv_anim_set_path_cb(&a, lv_anim_path_ease_in_out); + lv_anim_set_var(&a, card); + + lv_anim_set_values(&a, lv_obj_get_x_aligned(card), tx); + lv_anim_set_exec_cb(&a, anim_set_x); + if (slot == CAR_CENTER) + lv_anim_set_completed_cb(&a, anim_done_cb); // one card clears the guard + lv_anim_start(&a); + lv_anim_set_completed_cb(&a, NULL); + + lv_anim_set_values(&a, lv_obj_get_y_aligned(card), ty); + lv_anim_set_exec_cb(&a, anim_set_y); + lv_anim_start(&a); + + lv_anim_set_values(&a, lv_image_get_scale(card), ts); + lv_anim_set_exec_cb(&a, anim_set_scale); + lv_anim_start(&a); + + lv_anim_set_values(&a, lv_obj_get_style_opa(card, 0), to); + lv_anim_set_exec_cb(&a, anim_set_opa); + lv_anim_start(&a); +} + +static void fix_z_order(void) { + for (int z = 0; z <= CAR_CENTER; z++) { + for (int i = 0; i < THEME_COUNT; i++) { + int32_t slot = carousel_slot(i); + if (slot >= 0 && CAR_Z[slot] == z) + lv_obj_move_foreground(s_cards[i]); + } + } +} + +static void update_view(bool anim) { + lv_label_set_text_fmt(s_label, LV_SYMBOL_LEFT " %s " LV_SYMBOL_RIGHT, THEMES[s_sel].label); + lv_label_set_text(s_active, s_sel == theme_idx ? LV_SYMBOL_OK " APPLIED" : "OK to apply"); + lv_obj_set_style_text_color( + s_active, s_sel == theme_idx ? current_theme.border_accent : current_theme.text_main, 0); + lv_obj_set_style_text_opa(s_active, s_sel == theme_idx ? LV_OPA_COVER : LV_OPA_50, 0); + + page_dots_set(&s_dots, s_sel); + for (int i = 0; i < THEME_COUNT; i++) + place_card(i, anim); + fix_z_order(); +} + +static void theme_selector_input(const input_event_t *ev, void *ctx) { + (void)ctx; + const bool press = (ev->action == INPUT_ACTION_PRESS); + const bool nav = press || (ev->action == INPUT_ACTION_REPEAT); + + switch (ev->button) { + case INPUT_BTN_BACK: + if (press) + ui_switch_screen(SCREEN_SETTINGS); + break; + case INPUT_BTN_OK: + if (press && s_sel != theme_idx) { + theme_idx = s_sel; + ui_theme_load_idx(s_sel); + strlcpy(g_config_screen.theme, theme_names[s_sel], sizeof(g_config_screen.theme)); + if (ui_sd_ready()) { + tos_config_save(TOS_PATH_CONFIG_SCREEN, "screen"); + notify(NOTIFY_SAVED, "Theme saved"); + } + ESP_LOGI(TAG, "applied theme %d (%s)", s_sel, THEMES[s_sel].label); + build_screen(); + } + break; + case INPUT_BTN_RIGHT: + case INPUT_BTN_DOWN: + if (nav && !s_animating) { + s_sel = (s_sel + 1) % THEME_COUNT; + s_animating = true; + ui_feedback(UI_FB_NAV); + update_view(true); + } + break; + case INPUT_BTN_LEFT: + case INPUT_BTN_UP: + if (nav && !s_animating) { + s_sel = (s_sel == 0) ? THEME_COUNT - 1 : s_sel - 1; + s_animating = true; + ui_feedback(UI_FB_NAV); + update_view(true); + } + break; + default: + break; + } +} + +static void build_screen(void) { + lv_obj_t *prev = s_screen; + s_animating = false; + + if (s_base_dsc == NULL) + s_base_dsc = assets_get(BASE_FRAME); + + s_screen = lv_obj_create(NULL); + lv_obj_set_style_bg_color(s_screen, current_theme.screen_base, 0); + lv_obj_set_style_bg_opa(s_screen, LV_OPA_COVER, 0); + lv_obj_remove_flag(s_screen, LV_OBJ_FLAG_SCROLLABLE); + + ui_chrome_header(s_screen, "THEME", TITLE_ICON); + ui_chrome_footer(s_screen, LV_SYMBOL_LEFT LV_SYMBOL_RIGHT " Browse " LV_SYMBOL_OK " Apply"); + + for (int i = 0; i < THEME_COUNT; i++) + s_cards[i] = make_card(s_screen, i); + + s_label = lv_label_create(s_screen); + lv_obj_set_style_text_font(s_label, &lv_font_montserrat_14, 0); + lv_obj_set_style_text_color(s_label, current_theme.border_accent, 0); + lv_obj_align(s_label, LV_ALIGN_CENTER, 0, 52); + + s_active = lv_label_create(s_screen); + lv_obj_set_style_text_font(s_active, &lv_font_montserrat_12, 0); + lv_obj_align(s_active, LV_ALIGN_BOTTOM_MID, 0, -42); + + s_dots = page_dots_create(s_screen, THEME_COUNT, LV_ALIGN_BOTTOM_MID, 0, -26); + + if (s_sel < 0) + s_sel = 0; + if (s_sel >= THEME_COUNT) + s_sel = THEME_COUNT - 1; + update_view(false); + + ui_input_set_screen_handler(theme_selector_input, NULL); + + ui_screen_load_owned(&s_screen, s_screen); + if (prev != NULL) + lv_obj_del(prev); +} + +void ui_theme_selector_open(void) { + s_sel = (theme_idx >= 0 && theme_idx < THEME_COUNT) ? theme_idx : 0; + if (s_screen != NULL) { + lv_obj_del(s_screen); + s_screen = NULL; + } + build_screen(); +} diff --git a/firmware_p4/components/Applications/ui/screens/theme_selector/theme_selector_ui.c b/firmware_p4/components/Applications/ui/screens/theme_selector/theme_selector_ui.c deleted file mode 100644 index 1bf7c2a43..000000000 --- a/firmware_p4/components/Applications/ui/screens/theme_selector/theme_selector_ui.c +++ /dev/null @@ -1,551 +0,0 @@ -// Copyright (c) 2025 HIGH CODE LLC -// -// TentacleOS is free software: you can redistribute it and/or modify -// it under the terms of the GNU General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// TentacleOS is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU General Public License for more details. -// -// You should have received a copy of the GNU General Public License -// along with TentacleOS. If not, see . - -#include "theme_selector_ui.h" - -#include -#include -#include -#include - -#include "esp_log.h" -#include "cJSON.h" - -#include "ui_theme.h" -#include "ui_manager.h" -#include "tos_config.h" -#include "tos_storage_paths.h" -#include "tos_flash_paths.h" -#include "buttons_gpio.h" -#include "assets_manager.h" -#include "storage_impl.h" -#include "st7789.h" - -static const char *TAG = "THEME_SELECTOR_UI"; - -#define TITLE_W 170 -#define TITLE_H 30 -#define TITLE_RADIUS 12 -#define TITLE_BORDER_W 2 -#define ITEM_W 210 -#define ITEM_H 47 -#define ITEM_RADIUS 10 -#define ITEM_BORDER_NORMAL 1 -#define ITEM_BORDER_SELECTED 3 -#define ITEM_PAD_H 6 -#define ITEM_PAD_COL 4 -#define OUTER_BORDER 4 -#define TOP_BORDER_H (TITLE_H + 16) -#define TOP_AREA_BORDER_W 3 - -#define SWATCH_SIZE 12 -#define SWATCH_RADIUS 2 -#define SWATCH_BORDER_W 1 -#define SWATCH_COUNT 5 - -#define DOT_SIZE 8 -#define DOT_OFFSET_X (-12) -#define PTR_OFFSET_X (-6) - -#define ITEMS_Y_OFFSET 4 -#define ITEMS_CONT_PAD 2 -#define ITEMS_CONT_PAD_ROW 6 -#define ITEMS_CONT_X_OFFSET 4 - -#define SCROLL_TRACK_W 3 -#define SCROLL_TRACK_DASH_W 4 -#define SCROLL_TRACK_DASH_GAP 4 -#define SCROLL_TRACK_X_MARGIN 9 -#define SCROLL_TRACK_Y_MARGIN 10 -#define SCROLL_BAR_X_OFFSET (-4) -#define SCROLL_BAR_THUMB_H 20 -#define SCROLL_BAR_ANIM_MS 200 - -#define THEME_NAME_MAX_LEN 32 -#define THEME_COLOR_COUNT 5 -#define MAX_THEMES 24 -#define BUILTIN_THEME_COUNT 12 - -#define THEME_CONF_PATH_FMT TOS_PATH_THEMES "/%.30s/theme.conf" -#define THEME_CONF_PATH_SIZE 96 - -#define NAV_TIMER_PERIOD_MS 50 - -typedef struct { - char name[THEME_NAME_MAX_LEN]; - uint32_t - colors[THEME_COLOR_COUNT]; // bg_primary, bg_secondary, border_accent, text_main, screen_base -} theme_selector_entry_t; - -static const char *BUILTIN_THEMES[BUILTIN_THEME_COUNT] = {"default", - "matrix", - "cyber_blue", - "blood", - "toxic", - "ghost", - "neon_pink", - "amber", - "terminal", - "ice", - "deep_purple", - "midnight"}; - -static const char *COLOR_KEYS[THEME_COLOR_COUNT] = { - "bg_primary", "bg_secondary", "border_accent", "text_main", "screen_base"}; - -static lv_obj_t *s_screen = NULL; -static lv_obj_t *s_items_cont = NULL; -static lv_obj_t *s_items[MAX_THEMES]; -static lv_obj_t *s_sel_dots[MAX_THEMES]; -static lv_obj_t *s_active_icons[MAX_THEMES]; -static lv_obj_t *s_scroll_bar = NULL; -static lv_timer_t *s_nav_timer = NULL; - -static theme_selector_entry_t s_themes[MAX_THEMES]; -static int s_theme_count = 0; -static int s_selected = 0; -static int s_track_y_start = 0; -static int s_track_h = 0; -static bool s_rebuilding = false; - -static bool s_btn_up_last = false; -static bool s_btn_down_last = false; -static bool s_btn_ok_last = false; -static bool s_btn_back_last = false; - -static uint32_t hex_str_to_u32(const char *s); -static char *read_file_alloc(const char *path); -static bool parse_conf_colors(const char *data, uint32_t out[THEME_COLOR_COUNT]); -static bool parse_json_colors(const char *data, const char *name, uint32_t out[THEME_COLOR_COUNT]); -static void scan_themes(void); -static void update_scroll_bar(void); -static void update_selection(void); -static void create_theme_item(lv_obj_t *parent, int idx); -static void apply_theme(int idx); -static void nav_timer_cb(lv_timer_t *t); - -static uint32_t hex_str_to_u32(const char *s) { - if (s == NULL) - return 0; - return (uint32_t)strtol(s, NULL, 16); -} - -static char *read_file_alloc(const char *path) { - FILE *f = fopen(path, "r"); - if (f == NULL) - return NULL; - - fseek(f, 0, SEEK_END); - int32_t sz = (int32_t)ftell(f); - fseek(f, 0, SEEK_SET); - - if (sz <= 0) { - fclose(f); - return NULL; - } - - char *buf = malloc((size_t)sz + 1); - if (buf == NULL) { - ESP_LOGE(TAG, "Failed to allocate read buffer for %s", path); - fclose(f); - return NULL; - } - - size_t read = fread(buf, 1, (size_t)sz, f); - fclose(f); - - if ((int32_t)read != sz) { - ESP_LOGE(TAG, "Short read on %s: expected %ld, got %zu", path, (long)sz, read); - free(buf); - return NULL; - } - - buf[sz] = '\0'; - return buf; -} - -static bool parse_conf_colors(const char *data, uint32_t out[THEME_COLOR_COUNT]) { - int found = 0; - for (int k = 0; k < THEME_COLOR_COUNT; k++) { - const char *p = strstr(data, COLOR_KEYS[k]); - if (p == NULL) - continue; - const char *eq = strchr(p, '='); - if (eq == NULL) - continue; - eq++; - while (*eq == ' ') - eq++; - out[k] = hex_str_to_u32(eq); - found++; - } - return found >= 3; -} - -static bool parse_json_colors(const char *data, const char *name, uint32_t out[THEME_COLOR_COUNT]) { - cJSON *root = cJSON_Parse(data); - if (root == NULL) - return false; - - cJSON *theme = cJSON_GetObjectItem(root, name); - if (theme == NULL) { - cJSON_Delete(root); - return false; - } - - for (int k = 0; k < THEME_COLOR_COUNT; k++) { - cJSON *v = cJSON_GetObjectItem(theme, COLOR_KEYS[k]); - out[k] = (cJSON_IsString(v) && v->valuestring != NULL) ? hex_str_to_u32(v->valuestring) : 0; - } - - cJSON_Delete(root); - return true; -} - -static void scan_themes(void) { - s_theme_count = 0; - - DIR *d = opendir(TOS_PATH_THEMES); - if (d != NULL) { - struct dirent *ent; - while ((ent = readdir(d)) != NULL && s_theme_count < MAX_THEMES) { - if (ent->d_type != DT_DIR || ent->d_name[0] == '.') - continue; - if (strlen(ent->d_name) > THEME_NAME_MAX_LEN - 2) - continue; - - char path[THEME_CONF_PATH_SIZE]; - snprintf(path, sizeof(path), THEME_CONF_PATH_FMT, ent->d_name); - - char *data = read_file_alloc(path); - if (data == NULL) - continue; - - theme_selector_entry_t *t = &s_themes[s_theme_count]; - strncpy(t->name, ent->d_name, sizeof(t->name) - 1); - t->name[sizeof(t->name) - 1] = '\0'; - - if (parse_conf_colors(data, t->colors)) - s_theme_count++; - - free(data); - } - closedir(d); - } - - if (s_theme_count == 0) { - char *data = read_file_alloc(FLASH_CONFIG_THEMES); - if (data != NULL) { - for (int i = 0; i < BUILTIN_THEME_COUNT && s_theme_count < MAX_THEMES; i++) { - theme_selector_entry_t *t = &s_themes[s_theme_count]; - strncpy(t->name, BUILTIN_THEMES[i], sizeof(t->name) - 1); - t->name[sizeof(t->name) - 1] = '\0'; - if (parse_json_colors(data, BUILTIN_THEMES[i], t->colors)) - s_theme_count++; - } - free(data); - } - } - - s_selected = 0; - for (int i = 0; i < s_theme_count; i++) { - if (strcmp(s_themes[i].name, g_config_screen.theme) == 0) { - s_selected = i; - break; - } - } -} - -static void update_scroll_bar(void) { - if (s_scroll_bar == NULL || s_theme_count <= 1) - return; - - int32_t pos = - s_track_y_start + (s_selected * (s_track_h - SCROLL_BAR_THUMB_H)) / (s_theme_count - 1); - - if (s_rebuilding) { - lv_obj_set_y(s_scroll_bar, pos); - return; - } - - lv_anim_t a; - lv_anim_init(&a); - lv_anim_set_var(&a, s_scroll_bar); - lv_anim_set_values(&a, lv_obj_get_y(s_scroll_bar), pos); - lv_anim_set_duration(&a, SCROLL_BAR_ANIM_MS); - lv_anim_set_path_cb(&a, lv_anim_path_ease_in_out); - lv_anim_set_exec_cb(&a, (lv_anim_exec_xcb_t)lv_obj_set_y); - lv_anim_start(&a); -} - -static void update_selection(void) { - for (int i = 0; i < s_theme_count; i++) { - bool is_active = (strcmp(s_themes[i].name, g_config_screen.theme) == 0); - - if (i == s_selected) { - lv_obj_set_style_border_color(s_items[i], current_theme.border_accent, 0); - lv_obj_set_style_border_width(s_items[i], ITEM_BORDER_SELECTED, 0); - if (s_sel_dots[i] != NULL) - lv_obj_remove_flag(s_sel_dots[i], LV_OBJ_FLAG_HIDDEN); - } else { - lv_obj_set_style_border_color(s_items[i], current_theme.border_interface, 0); - lv_obj_set_style_border_width(s_items[i], ITEM_BORDER_NORMAL, 0); - if (s_sel_dots[i] != NULL) - lv_obj_add_flag(s_sel_dots[i], LV_OBJ_FLAG_HIDDEN); - } - - if (s_active_icons[i] != NULL) { - if (is_active) - lv_obj_remove_flag(s_active_icons[i], LV_OBJ_FLAG_HIDDEN); - else - lv_obj_add_flag(s_active_icons[i], LV_OBJ_FLAG_HIDDEN); - } - } - - if (s_items[s_selected] != NULL) - lv_obj_scroll_to_view(s_items[s_selected], s_rebuilding ? LV_ANIM_OFF : LV_ANIM_ON); - - update_scroll_bar(); -} - -static void create_theme_item(lv_obj_t *parent, int idx) { - theme_selector_entry_t *t = &s_themes[idx]; - - lv_obj_t *item = lv_obj_create(parent); - lv_obj_set_size(item, ITEM_W, ITEM_H); - lv_obj_remove_flag(item, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_set_style_radius(item, ITEM_RADIUS, 0); - lv_obj_set_style_bg_opa(item, LV_OPA_COVER, 0); - lv_obj_set_style_bg_color(item, current_theme.bg_primary, 0); - lv_obj_set_style_bg_grad_color(item, current_theme.bg_secondary, 0); - lv_obj_set_style_bg_grad_dir(item, LV_GRAD_DIR_HOR, 0); - lv_obj_set_style_border_width(item, ITEM_BORDER_NORMAL, 0); - lv_obj_set_style_border_color(item, current_theme.border_interface, 0); - lv_obj_set_style_pad_left(item, ITEM_PAD_H, 0); - lv_obj_set_style_pad_right(item, ITEM_PAD_H, 0); - lv_obj_set_flex_flow(item, LV_FLEX_FLOW_ROW); - lv_obj_set_flex_align(item, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); - lv_obj_set_style_pad_column(item, ITEM_PAD_COL, 0); - - for (int c = 0; c < SWATCH_COUNT; c++) { - lv_obj_t *sw = lv_obj_create(item); - lv_obj_set_size(sw, SWATCH_SIZE, SWATCH_SIZE); - lv_obj_set_style_radius(sw, SWATCH_RADIUS, 0); - lv_obj_set_style_bg_color(sw, lv_color_hex(t->colors[c]), 0); - lv_obj_set_style_bg_opa(sw, LV_OPA_COVER, 0); - lv_obj_set_style_border_color(sw, lv_color_hex(t->colors[2]), 0); - lv_obj_set_style_border_width(sw, SWATCH_BORDER_W, 0); - lv_obj_remove_flag(sw, LV_OBJ_FLAG_SCROLLABLE); - } - - char upper[THEME_NAME_MAX_LEN]; - strncpy(upper, t->name, sizeof(upper) - 1); - upper[sizeof(upper) - 1] = '\0'; - for (int c = 0; upper[c]; c++) { - if (upper[c] >= 'a' && upper[c] <= 'z') - upper[c] -= 32; - } - - lv_obj_t *lbl = lv_label_create(item); - lv_label_set_text(lbl, upper); - lv_obj_set_style_text_color(lbl, current_theme.text_main, 0); - lv_obj_set_style_text_font(lbl, &lv_font_montserrat_12, 0); - lv_obj_set_flex_grow(lbl, 1); - - lv_obj_t *dot = lv_obj_create(item); - lv_obj_set_size(dot, DOT_SIZE, DOT_SIZE); - lv_obj_set_style_radius(dot, LV_RADIUS_CIRCLE, 0); - lv_obj_set_style_bg_color(dot, current_theme.text_main, 0); - lv_obj_set_style_bg_opa(dot, LV_OPA_COVER, 0); - lv_obj_set_style_border_width(dot, 0, 0); - lv_obj_remove_flag(dot, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_add_flag(dot, LV_OBJ_FLAG_FLOATING); - lv_obj_align(dot, LV_ALIGN_RIGHT_MID, DOT_OFFSET_X, 0); - s_active_icons[idx] = dot; - - lv_image_dsc_t *pointer_dsc = assets_get("/assets/icons/pointer.bin"); - lv_obj_t *ptr = lv_image_create(item); - if (pointer_dsc != NULL) - lv_image_set_src(ptr, pointer_dsc); - lv_obj_add_flag(ptr, LV_OBJ_FLAG_HIDDEN | LV_OBJ_FLAG_FLOATING); - lv_obj_align(ptr, LV_ALIGN_RIGHT_MID, PTR_OFFSET_X, 0); - s_sel_dots[idx] = ptr; - - s_items[idx] = item; - - if (idx == s_selected) { - lv_obj_set_style_border_width(item, ITEM_BORDER_SELECTED, 0); - lv_obj_set_style_border_color(item, current_theme.border_accent, 0); - lv_obj_remove_flag(ptr, LV_OBJ_FLAG_HIDDEN); - } -} - -static void apply_theme(int idx) { - if (idx < 0 || idx >= s_theme_count) - return; - - ESP_LOGI(TAG, "Applying theme: %s", s_themes[idx].name); - - ui_theme_load_from_name(s_themes[idx].name); - strncpy(g_config_screen.theme, s_themes[idx].name, sizeof(g_config_screen.theme) - 1); - g_config_screen.theme[sizeof(g_config_screen.theme) - 1] = '\0'; - tos_config_save(TOS_PATH_CONFIG_SCREEN, "screen"); - - s_rebuilding = true; - ui_theme_selector_open(); - s_rebuilding = false; -} - -static void nav_timer_cb(lv_timer_t *t) { - if (lv_screen_active() != s_screen) { - lv_timer_delete(t); - s_nav_timer = NULL; - return; - } - if (ui_input_is_locked()) - return; - - bool up = up_button_is_down(); - bool down = down_button_is_down(); - bool ok = ok_button_is_down(); - bool back = back_button_is_down(); - - if (up && !s_btn_up_last && s_theme_count > 0) { - s_selected = (s_selected == 0) ? s_theme_count - 1 : s_selected - 1; - update_selection(); - } - if (down && !s_btn_down_last && s_theme_count > 0) { - s_selected = (s_selected + 1) % s_theme_count; - update_selection(); - } - if (ok && !s_btn_ok_last) { - apply_theme(s_selected); - } - if (back && !s_btn_back_last) { - s_btn_back_last = back; - ui_switch_screen(SCREEN_INTERFACE_SETTINGS); - return; - } - - s_btn_up_last = up; - s_btn_down_last = down; - s_btn_ok_last = ok; - s_btn_back_last = back; -} - -void ui_theme_selector_open(void) { - if (s_screen != NULL) { - lv_obj_del(s_screen); - s_screen = NULL; - } - - memset(s_items, 0, sizeof(s_items)); - memset(s_sel_dots, 0, sizeof(s_sel_dots)); - memset(s_active_icons, 0, sizeof(s_active_icons)); - s_scroll_bar = NULL; - - scan_themes(); - - s_screen = lv_obj_create(NULL); - lv_obj_set_size(s_screen, LCD_H_RES, LCD_V_RES); - lv_obj_remove_flag(s_screen, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_set_style_bg_color(s_screen, current_theme.screen_base, 0); - lv_obj_set_style_bg_opa(s_screen, LV_OPA_COVER, 0); - lv_obj_set_style_pad_all(s_screen, 0, 0); - lv_obj_set_style_border_width(s_screen, OUTER_BORDER, 0); - lv_obj_set_style_border_color(s_screen, current_theme.border_interface, 0); - lv_obj_set_style_radius(s_screen, 0, 0); - - lv_obj_t *top_area = lv_obj_create(s_screen); - lv_obj_set_size(top_area, LCD_H_RES - OUTER_BORDER * 2, TOP_BORDER_H); - lv_obj_align(top_area, LV_ALIGN_TOP_MID, 0, 0); - lv_obj_remove_flag(top_area, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_set_style_bg_opa(top_area, LV_OPA_TRANSP, 0); - lv_obj_set_style_border_width(top_area, TOP_AREA_BORDER_W, 0); - lv_obj_set_style_border_color(top_area, current_theme.border_interface, 0); - lv_obj_set_style_border_side(top_area, LV_BORDER_SIDE_BOTTOM, 0); - lv_obj_set_style_radius(top_area, 0, 0); - lv_obj_set_style_pad_all(top_area, 0, 0); - - lv_obj_t *title_bar = lv_obj_create(top_area); - lv_obj_set_size(title_bar, TITLE_W, TITLE_H); - lv_obj_align(title_bar, LV_ALIGN_CENTER, 0, 0); - lv_obj_remove_flag(title_bar, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_set_style_radius(title_bar, TITLE_RADIUS, 0); - lv_obj_set_style_bg_opa(title_bar, LV_OPA_COVER, 0); - lv_obj_set_style_bg_color(title_bar, current_theme.bg_primary, 0); - lv_obj_set_style_bg_grad_color(title_bar, current_theme.bg_secondary, 0); - lv_obj_set_style_bg_grad_dir(title_bar, LV_GRAD_DIR_HOR, 0); - lv_obj_set_style_border_width(title_bar, TITLE_BORDER_W, 0); - lv_obj_set_style_border_color(title_bar, current_theme.border_accent, 0); - lv_obj_set_style_pad_all(title_bar, 0, 0); - - lv_obj_t *title_lbl = lv_label_create(title_bar); - lv_label_set_text(title_lbl, "THEMES"); - lv_obj_set_style_text_color(title_lbl, current_theme.text_main, 0); - lv_obj_set_style_text_font(title_lbl, &lv_font_montserrat_14, 0); - lv_obj_center(title_lbl); - - int items_y = TOP_BORDER_H + ITEMS_Y_OFFSET; - int items_h = LCD_V_RES - items_y - OUTER_BORDER - ITEMS_Y_OFFSET; - - s_items_cont = lv_obj_create(s_screen); - lv_obj_set_size(s_items_cont, ITEM_W + 8, items_h); - lv_obj_align(s_items_cont, LV_ALIGN_TOP_LEFT, ITEMS_CONT_X_OFFSET, items_y); - lv_obj_set_style_bg_opa(s_items_cont, LV_OPA_TRANSP, 0); - lv_obj_set_style_border_width(s_items_cont, 0, 0); - lv_obj_set_style_pad_all(s_items_cont, ITEMS_CONT_PAD, 0); - lv_obj_set_style_pad_row(s_items_cont, ITEMS_CONT_PAD_ROW, 0); - lv_obj_set_flex_flow(s_items_cont, LV_FLEX_FLOW_COLUMN); - lv_obj_set_scrollbar_mode(s_items_cont, LV_SCROLLBAR_MODE_OFF); - lv_obj_set_scroll_snap_y(s_items_cont, LV_SCROLL_SNAP_START); - - int track_x = LCD_H_RES - OUTER_BORDER - SCROLL_TRACK_X_MARGIN; - s_track_y_start = items_y + SCROLL_TRACK_Y_MARGIN; - s_track_h = items_h - SCROLL_TRACK_Y_MARGIN * 2; - - // Points must outlive this function (used by lv_line) - static lv_point_precise_t track_pts[2]; - track_pts[0].x = 0; - track_pts[0].y = 0; - track_pts[1].x = 0; - track_pts[1].y = s_track_h; - - lv_obj_t *track = lv_line_create(s_screen); - lv_line_set_points(track, track_pts, 2); - lv_obj_set_pos(track, track_x, s_track_y_start); - lv_obj_set_style_line_color(track, current_theme.border_inactive, 0); - lv_obj_set_style_line_opa(track, LV_OPA_COVER, 0); - lv_obj_set_style_line_width(track, SCROLL_TRACK_W, 0); - lv_obj_set_style_line_dash_width(track, SCROLL_TRACK_DASH_W, 0); - lv_obj_set_style_line_dash_gap(track, SCROLL_TRACK_DASH_GAP, 0); - - lv_image_dsc_t *slide_dsc = assets_get("/assets/icons/slide_bar_v.bin"); - s_scroll_bar = lv_image_create(s_screen); - if (slide_dsc != NULL) - lv_image_set_src(s_scroll_bar, slide_dsc); - lv_obj_set_pos(s_scroll_bar, track_x + SCROLL_BAR_X_OFFSET, s_track_y_start); - lv_obj_move_foreground(s_scroll_bar); - - for (int i = 0; i < s_theme_count; i++) - create_theme_item(s_items_cont, i); - - update_selection(); - - if (s_nav_timer == NULL) - s_nav_timer = lv_timer_create(nav_timer_cb, NAV_TIMER_PERIOD_MS, NULL); - - lv_screen_load(s_screen); -} \ No newline at end of file diff --git a/firmware_p4/components/Applications/ui/screens/wifi/include/wifi_attack_menu_ui.h b/firmware_p4/components/Applications/ui/screens/wifi/include/wifi_attack_menu_ui.h deleted file mode 100644 index bd78b42f1..000000000 --- a/firmware_p4/components/Applications/ui/screens/wifi/include/wifi_attack_menu_ui.h +++ /dev/null @@ -1,30 +0,0 @@ -// Copyright (c) 2025 HIGH CODE LLC -// -// TentacleOS is free software: you can redistribute it and/or modify -// it under the terms of the GNU General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// TentacleOS is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU General Public License for more details. -// -// You should have received a copy of the GNU General Public License -// along with TentacleOS. If not, see . - -#ifndef WIFI_ATTACK_MENU_UI_H -#define WIFI_ATTACK_MENU_UI_H - -#ifdef __cplusplus -extern "C" { -#endif - -/** @brief Open the Wi-Fi attack menu screen. */ -void ui_wifi_attack_menu_open(void); - -#ifdef __cplusplus -} -#endif - -#endif // WIFI_ATTACK_MENU_UI_H diff --git a/firmware_p4/components/Applications/ui/screens/wifi/include/wifi_attack_ui.h b/firmware_p4/components/Applications/ui/screens/wifi/include/wifi_attack_ui.h new file mode 100644 index 000000000..c3afbd63e --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/wifi/include/wifi_attack_ui.h @@ -0,0 +1,32 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#ifndef WIFI_ATTACK_UI_H +#define WIFI_ATTACK_UI_H + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * @brief Open the Wi-Fi attacks submenu. + */ +void ui_wifi_attack_open(void); + +#ifdef __cplusplus +} +#endif + +#endif // WIFI_ATTACK_UI_H diff --git a/firmware_p4/components/Applications/ui/screens/wifi/include/wifi_auth_flood_ui.h b/firmware_p4/components/Applications/ui/screens/wifi/include/wifi_auth_flood_ui.h deleted file mode 100644 index 861877b74..000000000 --- a/firmware_p4/components/Applications/ui/screens/wifi/include/wifi_auth_flood_ui.h +++ /dev/null @@ -1,30 +0,0 @@ -// Copyright (c) 2025 HIGH CODE LLC -// -// TentacleOS is free software: you can redistribute it and/or modify -// it under the terms of the GNU General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// TentacleOS is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU General Public License for more details. -// -// You should have received a copy of the GNU General Public License -// along with TentacleOS. If not, see . - -#ifndef WIFI_AUTH_FLOOD_UI_H -#define WIFI_AUTH_FLOOD_UI_H - -#ifdef __cplusplus -extern "C" { -#endif - -/** @brief Open the Wi-Fi auth flood screen. */ -void ui_wifi_auth_flood_open(void); - -#ifdef __cplusplus -} -#endif - -#endif // WIFI_AUTH_FLOOD_UI_H diff --git a/firmware_p4/components/Applications/ui/screens/wifi/include/wifi_beacon_spam_simple_ui.h b/firmware_p4/components/Applications/ui/screens/wifi/include/wifi_beacon_spam_simple_ui.h deleted file mode 100644 index ad5102f59..000000000 --- a/firmware_p4/components/Applications/ui/screens/wifi/include/wifi_beacon_spam_simple_ui.h +++ /dev/null @@ -1,30 +0,0 @@ -// Copyright (c) 2025 HIGH CODE LLC -// -// TentacleOS is free software: you can redistribute it and/or modify -// it under the terms of the GNU General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// TentacleOS is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU General Public License for more details. -// -// You should have received a copy of the GNU General Public License -// along with TentacleOS. If not, see . - -#ifndef WIFI_BEACON_SPAM_SIMPLE_UI_H -#define WIFI_BEACON_SPAM_SIMPLE_UI_H - -#ifdef __cplusplus -extern "C" { -#endif - -/** @brief Open the Wi-Fi beacon spam simple screen. */ -void ui_wifi_beacon_spam_simple_open(void); - -#ifdef __cplusplus -} -#endif - -#endif // WIFI_BEACON_SPAM_SIMPLE_UI_H diff --git a/firmware_p4/components/Applications/ui/screens/wifi/include/wifi_beacon_spam_ui.h b/firmware_p4/components/Applications/ui/screens/wifi/include/wifi_beacon_spam_ui.h deleted file mode 100644 index d1abd1d6e..000000000 --- a/firmware_p4/components/Applications/ui/screens/wifi/include/wifi_beacon_spam_ui.h +++ /dev/null @@ -1,30 +0,0 @@ -// Copyright (c) 2025 HIGH CODE LLC -// -// TentacleOS is free software: you can redistribute it and/or modify -// it under the terms of the GNU General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// TentacleOS is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU General Public License for more details. -// -// You should have received a copy of the GNU General Public License -// along with TentacleOS. If not, see . - -#ifndef WIFI_BEACON_SPAM_UI_H -#define WIFI_BEACON_SPAM_UI_H - -#ifdef __cplusplus -extern "C" { -#endif - -/** @brief Open the Wi-Fi beacon spam screen. */ -void ui_wifi_beacon_spam_open(void); - -#ifdef __cplusplus -} -#endif - -#endif // WIFI_BEACON_SPAM_UI_H diff --git a/firmware_p4/components/Applications/ui/screens/wifi/include/wifi_channel_ui.h b/firmware_p4/components/Applications/ui/screens/wifi/include/wifi_channel_ui.h new file mode 100644 index 000000000..3a3fe73ea --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/wifi/include/wifi_channel_ui.h @@ -0,0 +1,34 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#ifndef WIFI_CHANNEL_UI_H +#define WIFI_CHANNEL_UI_H + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * @brief Open the Wi-Fi channel-occupancy analysis screen. Runs a real scan + * via the C5 bridge and aggregates the discovered APs per 2.4 GHz + * channel (count + strongest signal), colour-coded by congestion. + */ +void ui_wifi_channel_open(void); + +#ifdef __cplusplus +} +#endif + +#endif // WIFI_CHANNEL_UI_H diff --git a/firmware_p4/components/Applications/ui/screens/wifi/include/wifi_client_ui.h b/firmware_p4/components/Applications/ui/screens/wifi/include/wifi_client_ui.h new file mode 100644 index 000000000..7cbb08d30 --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/wifi/include/wifi_client_ui.h @@ -0,0 +1,34 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#ifndef WIFI_CLIENT_UI_H +#define WIFI_CLIENT_UI_H + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * @brief Open the Wi-Fi client/station scan screen. The C5 sweeps channels in + * promiscuous mode and reports the MACs of nearby client devices + * (probe-request transmitters). + */ +void ui_wifi_client_open(void); + +#ifdef __cplusplus +} +#endif + +#endif // WIFI_CLIENT_UI_H diff --git a/firmware_p4/components/Applications/ui/screens/wifi/include/wifi_deauth_attack_ui.h b/firmware_p4/components/Applications/ui/screens/wifi/include/wifi_deauth_attack_ui.h deleted file mode 100644 index 447e314d6..000000000 --- a/firmware_p4/components/Applications/ui/screens/wifi/include/wifi_deauth_attack_ui.h +++ /dev/null @@ -1,30 +0,0 @@ -// Copyright (c) 2025 HIGH CODE LLC -// -// TentacleOS is free software: you can redistribute it and/or modify -// it under the terms of the GNU General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// TentacleOS is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU General Public License for more details. -// -// You should have received a copy of the GNU General Public License -// along with TentacleOS. If not, see . - -#ifndef WIFI_DEAUTH_ATTACK_UI_H -#define WIFI_DEAUTH_ATTACK_UI_H - -#ifdef __cplusplus -extern "C" { -#endif - -/** @brief Open the Wi-Fi deauth attack screen. */ -void ui_wifi_deauth_attack_open(void); - -#ifdef __cplusplus -} -#endif - -#endif // WIFI_DEAUTH_ATTACK_UI_H diff --git a/firmware_p4/components/Applications/ui/screens/wifi/include/wifi_deauth_detector_ui.h b/firmware_p4/components/Applications/ui/screens/wifi/include/wifi_deauth_detector_ui.h new file mode 100644 index 000000000..eb6a8feb3 --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/wifi/include/wifi_deauth_detector_ui.h @@ -0,0 +1,33 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#ifndef WIFI_DEAUTH_DETECTOR_UI_H +#define WIFI_DEAUTH_DETECTOR_UI_H + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * @brief Open the Wi-Fi deauth detector screen — a defensive monitor that + * flips from a calm green idle to a red ALERT state. + */ +void ui_wifi_deauth_detector_open(void); + +#ifdef __cplusplus +} +#endif + +#endif // WIFI_DEAUTH_DETECTOR_UI_H diff --git a/firmware_p4/components/Applications/ui/screens/wifi/include/wifi_evil_twin_ui.h b/firmware_p4/components/Applications/ui/screens/wifi/include/wifi_evil_twin_ui.h index b80db7372..47c81e739 100644 --- a/firmware_p4/components/Applications/ui/screens/wifi/include/wifi_evil_twin_ui.h +++ b/firmware_p4/components/Applications/ui/screens/wifi/include/wifi_evil_twin_ui.h @@ -20,10 +20,9 @@ extern "C" { #endif -/** @brief Set the SSID for the evil twin attack. */ -void ui_wifi_evil_twin_set_ssid(const char *ssid); - -/** @brief Open the Wi-Fi evil twin screen. */ +/** + * @brief Open the Wi-Fi evil-twin running screen. + */ void ui_wifi_evil_twin_open(void); #ifdef __cplusplus diff --git a/firmware_p4/components/Applications/ui/screens/wifi/include/wifi_handshake_ui.h b/firmware_p4/components/Applications/ui/screens/wifi/include/wifi_handshake_ui.h new file mode 100644 index 000000000..a1b137c68 --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/wifi/include/wifi_handshake_ui.h @@ -0,0 +1,34 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#ifndef WIFI_HANDSHAKE_UI_H +#define WIFI_HANDSHAKE_UI_H + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * @brief Open the Wi-Fi handshake capture monitor screen — a target AP/STA + * card, an EAPOL pulse-train waveform, and M1-M4 frame badges that + * light green as the 4-way handshake completes. + */ +void ui_wifi_handshake_open(void); + +#ifdef __cplusplus +} +#endif + +#endif // WIFI_HANDSHAKE_UI_H diff --git a/firmware_p4/components/Applications/ui/screens/wifi/include/wifi_hotspot_ui.h b/firmware_p4/components/Applications/ui/screens/wifi/include/wifi_hotspot_ui.h new file mode 100644 index 000000000..797b1efdb --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/wifi/include/wifi_hotspot_ui.h @@ -0,0 +1,34 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#ifndef WIFI_HOTSPOT_UI_H +#define WIFI_HOTSPOT_UI_H + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * @brief Open the Wi-Fi hotspot (AP) dashboard screen — an online toggle pill, + * a client-count ring gauge, and metric tiles for channel, gateway and + * TX/RX throughput. + */ +void ui_wifi_hotspot_open(void); + +#ifdef __cplusplus +} +#endif + +#endif // WIFI_HOTSPOT_UI_H diff --git a/firmware_p4/components/Applications/ui/screens/wifi/include/wifi_names.h b/firmware_p4/components/Applications/ui/screens/wifi/include/wifi_names.h new file mode 100644 index 000000000..87d929dba --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/wifi/include/wifi_names.h @@ -0,0 +1,77 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +/** + * @file wifi_names.h + * @brief User-editable list of SSIDs Wi-Fi lists draws from. + * + * Persisted in NVS so edits survive reboots. Seeded with a few realistic + * defaults on first run. When the list is empty the scan falls back to its + * built-in name pool. + */ + +#ifndef WIFI_NAMES_H +#define WIFI_NAMES_H + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +#define WIFI_NAMES_MAX 16 ///< maximum number of stored SSID names +#define WIFI_NAME_LEN 24 ///< max chars (excl. NUL); fits an SSID label row + +/** @brief Load from NVS, seeding defaults on first run. Idempotent. */ +void wifi_names_init(void); + +/** + * @brief Get the number of stored names. + * @return Count in the range 0..WIFI_NAMES_MAX. + */ +int wifi_names_count(void); + +/** + * @brief Get the name at a given index. + * @param index Zero-based index into the stored list. + * @return Pointer to the name, or NULL if out of range. + */ +const char *wifi_names_get(int index); + +/** + * @brief Append a name to the list. + * @param name Null-terminated name to add. + * @return true on success, false if the list is full or @p name is empty. + */ +bool wifi_names_add(const char *name); + +/** + * @brief Replace the name at a given index. + * @param index Zero-based index into the stored list. + * @param name Replacement name; no-op if out of range or empty. + */ +void wifi_names_set(int index, const char *name); + +/** + * @brief Remove the name at a given index. + * @param index Zero-based index into the stored list; no-op if out of range. + */ +void wifi_names_remove(int index); + +#ifdef __cplusplus +} +#endif + +#endif // WIFI_NAMES_H diff --git a/firmware_p4/components/Applications/ui/screens/wifi/include/wifi_names_ui.h b/firmware_p4/components/Applications/ui/screens/wifi/include/wifi_names_ui.h new file mode 100644 index 000000000..2ddeb458f --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/wifi/include/wifi_names_ui.h @@ -0,0 +1,30 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#ifndef WIFI_NAMES_UI_H +#define WIFI_NAMES_UI_H + +#ifdef __cplusplus +extern "C" { +#endif + +/** @brief Open the editor for the saved network name list. */ +void ui_wifi_names_open(void); + +#ifdef __cplusplus +} +#endif + +#endif // WIFI_NAMES_UI_H diff --git a/firmware_p4/components/Applications/ui/screens/wifi/include/wifi_packets_menu_ui.h b/firmware_p4/components/Applications/ui/screens/wifi/include/wifi_packets_menu_ui.h deleted file mode 100644 index 347fb2a9b..000000000 --- a/firmware_p4/components/Applications/ui/screens/wifi/include/wifi_packets_menu_ui.h +++ /dev/null @@ -1,30 +0,0 @@ -// Copyright (c) 2025 HIGH CODE LLC -// -// TentacleOS is free software: you can redistribute it and/or modify -// it under the terms of the GNU General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// TentacleOS is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU General Public License for more details. -// -// You should have received a copy of the GNU General Public License -// along with TentacleOS. If not, see . - -#ifndef WIFI_PACKETS_MENU_UI_H -#define WIFI_PACKETS_MENU_UI_H - -#ifdef __cplusplus -extern "C" { -#endif - -/** @brief Open the Wi-Fi packets menu screen. */ -void ui_wifi_packets_menu_open(void); - -#ifdef __cplusplus -} -#endif - -#endif // WIFI_PACKETS_MENU_UI_H diff --git a/firmware_p4/components/Applications/ui/screens/wifi/include/wifi_packets_ui.h b/firmware_p4/components/Applications/ui/screens/wifi/include/wifi_packets_ui.h new file mode 100644 index 000000000..6ce308b7f --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/wifi/include/wifi_packets_ui.h @@ -0,0 +1,32 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#ifndef WIFI_PACKETS_UI_H +#define WIFI_PACKETS_UI_H + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * @brief Open the Wi-Fi packet capture submenu. + */ +void ui_wifi_packets_open(void); + +#ifdef __cplusplus +} +#endif + +#endif // WIFI_PACKETS_UI_H diff --git a/firmware_p4/components/Applications/ui/screens/wifi/include/wifi_port_scan_ui.h b/firmware_p4/components/Applications/ui/screens/wifi/include/wifi_port_scan_ui.h new file mode 100644 index 000000000..1c284c778 --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/wifi/include/wifi_port_scan_ui.h @@ -0,0 +1,33 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#ifndef WIFI_PORT_SCAN_UI_H +#define WIFI_PORT_SCAN_UI_H + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * @brief Open the WiFi port-scanner screen: a target host is scanned and + * the open TCP/UDP ports are listed with their service and banner. + */ +void ui_wifi_port_scan_open(void); + +#ifdef __cplusplus +} +#endif + +#endif // WIFI_PORT_SCAN_UI_H diff --git a/firmware_p4/components/Applications/ui/screens/wifi/include/wifi_probe_flood_ui.h b/firmware_p4/components/Applications/ui/screens/wifi/include/wifi_probe_flood_ui.h deleted file mode 100644 index c8299dd0b..000000000 --- a/firmware_p4/components/Applications/ui/screens/wifi/include/wifi_probe_flood_ui.h +++ /dev/null @@ -1,30 +0,0 @@ -// Copyright (c) 2025 HIGH CODE LLC -// -// TentacleOS is free software: you can redistribute it and/or modify -// it under the terms of the GNU General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// TentacleOS is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU General Public License for more details. -// -// You should have received a copy of the GNU General Public License -// along with TentacleOS. If not, see . - -#ifndef WIFI_PROBE_FLOOD_UI_H -#define WIFI_PROBE_FLOOD_UI_H - -#ifdef __cplusplus -extern "C" { -#endif - -/** @brief Open the Wi-Fi probe flood screen. */ -void ui_wifi_probe_flood_open(void); - -#ifdef __cplusplus -} -#endif - -#endif // WIFI_PROBE_FLOOD_UI_H diff --git a/firmware_p4/components/Applications/ui/screens/wifi/include/wifi_probe_mon_ui.h b/firmware_p4/components/Applications/ui/screens/wifi/include/wifi_probe_mon_ui.h new file mode 100644 index 000000000..1ed756d7e --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/wifi/include/wifi_probe_mon_ui.h @@ -0,0 +1,33 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#ifndef WIFI_PROBE_MON_UI_H +#define WIFI_PROBE_MON_UI_H + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * @brief Open the WiFi probe-request monitor: a live-growing list of the + * client MACs nearby and the SSIDs they are probing for. + */ +void ui_wifi_probe_mon_open(void); + +#ifdef __cplusplus +} +#endif + +#endif // WIFI_PROBE_MON_UI_H diff --git a/firmware_p4/components/Applications/ui/screens/wifi/include/wifi_scan_menu_ui.h b/firmware_p4/components/Applications/ui/screens/wifi/include/wifi_scan_menu_ui.h deleted file mode 100644 index 7e1981d0e..000000000 --- a/firmware_p4/components/Applications/ui/screens/wifi/include/wifi_scan_menu_ui.h +++ /dev/null @@ -1,30 +0,0 @@ -// Copyright (c) 2025 HIGH CODE LLC -// -// TentacleOS is free software: you can redistribute it and/or modify -// it under the terms of the GNU General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// TentacleOS is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU General Public License for more details. -// -// You should have received a copy of the GNU General Public License -// along with TentacleOS. If not, see . - -#ifndef WIFI_SCAN_MENU_UI_H -#define WIFI_SCAN_MENU_UI_H - -#ifdef __cplusplus -extern "C" { -#endif - -/** @brief Open the Wi-Fi scan menu screen. */ -void ui_wifi_scan_menu_open(void); - -#ifdef __cplusplus -} -#endif - -#endif // WIFI_SCAN_MENU_UI_H diff --git a/firmware_p4/components/Applications/ui/screens/wifi/include/wifi_scan_monitor_ui.h b/firmware_p4/components/Applications/ui/screens/wifi/include/wifi_scan_monitor_ui.h deleted file mode 100644 index 9704a4a6e..000000000 --- a/firmware_p4/components/Applications/ui/screens/wifi/include/wifi_scan_monitor_ui.h +++ /dev/null @@ -1,30 +0,0 @@ -// Copyright (c) 2025 HIGH CODE LLC -// -// TentacleOS is free software: you can redistribute it and/or modify -// it under the terms of the GNU General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// TentacleOS is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU General Public License for more details. -// -// You should have received a copy of the GNU General Public License -// along with TentacleOS. If not, see . - -#ifndef WIFI_SCAN_MONITOR_UI_H -#define WIFI_SCAN_MONITOR_UI_H - -#ifdef __cplusplus -extern "C" { -#endif - -/** @brief Open the Wi-Fi scan monitor screen. */ -void ui_wifi_scan_monitor_open(void); - -#ifdef __cplusplus -} -#endif - -#endif // WIFI_SCAN_MONITOR_UI_H diff --git a/firmware_p4/components/Applications/ui/screens/wifi/include/wifi_scan_probe_ui.h b/firmware_p4/components/Applications/ui/screens/wifi/include/wifi_scan_probe_ui.h deleted file mode 100644 index c95181ae5..000000000 --- a/firmware_p4/components/Applications/ui/screens/wifi/include/wifi_scan_probe_ui.h +++ /dev/null @@ -1,30 +0,0 @@ -// Copyright (c) 2025 HIGH CODE LLC -// -// TentacleOS is free software: you can redistribute it and/or modify -// it under the terms of the GNU General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// TentacleOS is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU General Public License for more details. -// -// You should have received a copy of the GNU General Public License -// along with TentacleOS. If not, see . - -#ifndef WIFI_SCAN_PROBE_UI_H -#define WIFI_SCAN_PROBE_UI_H - -#ifdef __cplusplus -extern "C" { -#endif - -/** @brief Open the Wi-Fi probe scan screen. */ -void ui_wifi_scan_probe_open(void); - -#ifdef __cplusplus -} -#endif - -#endif // WIFI_SCAN_PROBE_UI_H diff --git a/firmware_p4/components/Applications/ui/screens/wifi/include/wifi_scan_stations_ui.h b/firmware_p4/components/Applications/ui/screens/wifi/include/wifi_scan_stations_ui.h deleted file mode 100644 index db7402d7e..000000000 --- a/firmware_p4/components/Applications/ui/screens/wifi/include/wifi_scan_stations_ui.h +++ /dev/null @@ -1,30 +0,0 @@ -// Copyright (c) 2025 HIGH CODE LLC -// -// TentacleOS is free software: you can redistribute it and/or modify -// it under the terms of the GNU General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// TentacleOS is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU General Public License for more details. -// -// You should have received a copy of the GNU General Public License -// along with TentacleOS. If not, see . - -#ifndef WIFI_SCAN_STATIONS_UI_H -#define WIFI_SCAN_STATIONS_UI_H - -#ifdef __cplusplus -extern "C" { -#endif - -/** @brief Open the Wi-Fi stations scan screen. */ -void ui_wifi_scan_stations_open(void); - -#ifdef __cplusplus -} -#endif - -#endif // WIFI_SCAN_STATIONS_UI_H diff --git a/firmware_p4/components/Applications/ui/screens/wifi/include/wifi_scan_target_ui.h b/firmware_p4/components/Applications/ui/screens/wifi/include/wifi_scan_target_ui.h deleted file mode 100644 index ebd682ec6..000000000 --- a/firmware_p4/components/Applications/ui/screens/wifi/include/wifi_scan_target_ui.h +++ /dev/null @@ -1,30 +0,0 @@ -// Copyright (c) 2025 HIGH CODE LLC -// -// TentacleOS is free software: you can redistribute it and/or modify -// it under the terms of the GNU General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// TentacleOS is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU General Public License for more details. -// -// You should have received a copy of the GNU General Public License -// along with TentacleOS. If not, see . - -#ifndef WIFI_SCAN_TARGET_UI_H -#define WIFI_SCAN_TARGET_UI_H - -#ifdef __cplusplus -extern "C" { -#endif - -/** @brief Open the Wi-Fi target scan screen. */ -void ui_wifi_scan_target_open(void); - -#ifdef __cplusplus -} -#endif - -#endif // WIFI_SCAN_TARGET_UI_H diff --git a/firmware_p4/components/Applications/ui/screens/wifi/include/wifi_scan_ui.h b/firmware_p4/components/Applications/ui/screens/wifi/include/wifi_scan_ui.h index a231c87a6..b3fa7b7d0 100644 --- a/firmware_p4/components/Applications/ui/screens/wifi/include/wifi_scan_ui.h +++ b/firmware_p4/components/Applications/ui/screens/wifi/include/wifi_scan_ui.h @@ -20,7 +20,7 @@ extern "C" { #endif -/** @brief Open the Wi-Fi scan screen. */ +/** @brief Open the Wi-Fi scan screen (live scan via the C5 over the SPI bridge). */ void ui_wifi_scan_open(void); #ifdef __cplusplus diff --git a/firmware_p4/components/Applications/ui/screens/wifi/include/wifi_signal_locator_ui.h b/firmware_p4/components/Applications/ui/screens/wifi/include/wifi_signal_locator_ui.h new file mode 100644 index 000000000..3c1fc9870 --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/wifi/include/wifi_signal_locator_ui.h @@ -0,0 +1,33 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#ifndef WIFI_SIGNAL_LOCATOR_UI_H +#define WIFI_SIGNAL_LOCATOR_UI_H + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * @brief Open the Wi-Fi signal locator screen — a hot/cold RSSI meter that + * drifts with a warmer/colder hint to home in on a target. + */ +void ui_wifi_signal_locator_open(void); + +#ifdef __cplusplus +} +#endif + +#endif // WIFI_SIGNAL_LOCATOR_UI_H diff --git a/firmware_p4/components/Applications/ui/screens/wifi/include/wifi_sniffer_attack_ui.h b/firmware_p4/components/Applications/ui/screens/wifi/include/wifi_sniffer_attack_ui.h deleted file mode 100644 index 0a32abf41..000000000 --- a/firmware_p4/components/Applications/ui/screens/wifi/include/wifi_sniffer_attack_ui.h +++ /dev/null @@ -1,30 +0,0 @@ -// Copyright (c) 2025 HIGH CODE LLC -// -// TentacleOS is free software: you can redistribute it and/or modify -// it under the terms of the GNU General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// TentacleOS is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU General Public License for more details. -// -// You should have received a copy of the GNU General Public License -// along with TentacleOS. If not, see . - -#ifndef WIFI_SNIFFER_ATTACK_UI_H -#define WIFI_SNIFFER_ATTACK_UI_H - -#ifdef __cplusplus -extern "C" { -#endif - -/** @brief Open the Wi-Fi sniffer attack screen. */ -void ui_wifi_sniffer_attack_open(void); - -#ifdef __cplusplus -} -#endif - -#endif // WIFI_SNIFFER_ATTACK_UI_H diff --git a/firmware_p4/components/Applications/ui/screens/wifi/include/wifi_sniffer_handshake_ui.h b/firmware_p4/components/Applications/ui/screens/wifi/include/wifi_sniffer_handshake_ui.h deleted file mode 100644 index c3dee5bad..000000000 --- a/firmware_p4/components/Applications/ui/screens/wifi/include/wifi_sniffer_handshake_ui.h +++ /dev/null @@ -1,30 +0,0 @@ -// Copyright (c) 2025 HIGH CODE LLC -// -// TentacleOS is free software: you can redistribute it and/or modify -// it under the terms of the GNU General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// TentacleOS is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU General Public License for more details. -// -// You should have received a copy of the GNU General Public License -// along with TentacleOS. If not, see . - -#ifndef WIFI_SNIFFER_HANDSHAKE_UI_H -#define WIFI_SNIFFER_HANDSHAKE_UI_H - -#ifdef __cplusplus -extern "C" { -#endif - -/** @brief Open the Wi-Fi handshake sniffer screen. */ -void ui_wifi_sniffer_handshake_open(void); - -#ifdef __cplusplus -} -#endif - -#endif // WIFI_SNIFFER_HANDSHAKE_UI_H diff --git a/firmware_p4/components/Applications/ui/screens/wifi/include/wifi_sniffer_raw_ui.h b/firmware_p4/components/Applications/ui/screens/wifi/include/wifi_sniffer_raw_ui.h deleted file mode 100644 index 73a5361ab..000000000 --- a/firmware_p4/components/Applications/ui/screens/wifi/include/wifi_sniffer_raw_ui.h +++ /dev/null @@ -1,30 +0,0 @@ -// Copyright (c) 2025 HIGH CODE LLC -// -// TentacleOS is free software: you can redistribute it and/or modify -// it under the terms of the GNU General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// TentacleOS is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU General Public License for more details. -// -// You should have received a copy of the GNU General Public License -// along with TentacleOS. If not, see . - -#ifndef WIFI_SNIFFER_RAW_UI_H -#define WIFI_SNIFFER_RAW_UI_H - -#ifdef __cplusplus -extern "C" { -#endif - -/** @brief Open the Wi-Fi raw sniffer screen. */ -void ui_wifi_sniffer_raw_open(void); - -#ifdef __cplusplus -} -#endif - -#endif // WIFI_SNIFFER_RAW_UI_H diff --git a/firmware_p4/components/Applications/ui/screens/wifi/include/wifi_target_clients_ui.h b/firmware_p4/components/Applications/ui/screens/wifi/include/wifi_target_clients_ui.h new file mode 100644 index 000000000..70a897e20 --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/wifi/include/wifi_target_clients_ui.h @@ -0,0 +1,33 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#ifndef WIFI_TARGET_CLIENTS_UI_H +#define WIFI_TARGET_CLIENTS_UI_H + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * @brief Open the WiFi target-clients screen: the stations associated to a + * chosen target access point, with their RSSI. + */ +void ui_wifi_target_clients_open(void); + +#ifdef __cplusplus +} +#endif + +#endif // WIFI_TARGET_CLIENTS_UI_H diff --git a/firmware_p4/components/Applications/ui/screens/wifi/wifi_ap_list_ui.c b/firmware_p4/components/Applications/ui/screens/wifi/wifi_ap_list_ui.c deleted file mode 100644 index d833f1d27..000000000 --- a/firmware_p4/components/Applications/ui/screens/wifi/wifi_ap_list_ui.c +++ /dev/null @@ -1,121 +0,0 @@ -// Copyright (c) 2025 HIGH CODE LLC -// -// TentacleOS is free software: you can redistribute it and/or modify -// it under the terms of the GNU General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// TentacleOS is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU General Public License for more details. -// -// You should have received a copy of the GNU General Public License -// along with TentacleOS. If not, see . - -#include "wifi_ap_list_ui.h" - -#include - -#include "esp_log.h" - -#include "ui_manager.h" -#include "header_ui.h" -#include "footer_ui.h" -#include "ui_theme.h" -#include "lv_port_indev.h" -#include "ap_scanner.h" -#include "wifi_deauth_ui.h" -#include "wifi_evil_twin_ui.h" - -static const char *TAG = "WIFI_AP_LIST_UI"; - -#define LIST_W 220 -#define LIST_H 150 -#define LIST_OFFSET_Y 10 -#define TITLE_OFFSET_Y 30 -#define AP_LABEL_BUF_SIZE 64 - -extern lv_group_t *main_group; - -static lv_obj_t *s_screen = NULL; -static lv_obj_t *s_list = NULL; -static wifi_ap_record_t *s_results = NULL; -static uint16_t s_result_count = 0; - -static void on_ap_key_event(lv_event_t *e); -static void on_screen_key_event(lv_event_t *e); - -static void on_ap_key_event(lv_event_t *e) { - if (lv_event_get_code(e) != LV_EVENT_KEY) - return; - if (lv_event_get_key(e) != LV_KEY_ENTER) - return; - - wifi_ap_record_t *ap = (wifi_ap_record_t *)lv_event_get_user_data(e); - ESP_LOGI(TAG, "Selected AP: %s", ap->ssid); - ui_wifi_deauth_set_target(ap); - ui_switch_screen(SCREEN_WIFI_DEAUTH); -} - -static void on_screen_key_event(lv_event_t *e) { - if (lv_event_get_code(e) != LV_EVENT_KEY) - return; - if (lv_event_get_key(e) == LV_KEY_ESC) - ui_switch_screen(SCREEN_WIFI_MENU); -} - -void ui_wifi_ap_list_open(void) { - if (s_screen != NULL) { - lv_obj_del(s_screen); - s_screen = NULL; - } - - s_screen = lv_obj_create(NULL); - lv_obj_set_style_bg_color(s_screen, current_theme.screen_base, 0); - lv_obj_remove_flag(s_screen, LV_OBJ_FLAG_SCROLLABLE); - - header_ui_create(s_screen); - footer_ui_create(s_screen); - - lv_obj_t *title = lv_label_create(s_screen); - lv_label_set_text(title, "Select Target"); - lv_obj_set_style_text_color(title, current_theme.text_main, 0); - lv_obj_align(title, LV_ALIGN_TOP_MID, 0, TITLE_OFFSET_Y); - - s_list = lv_list_create(s_screen); - lv_obj_set_size(s_list, LIST_W, LIST_H); - lv_obj_align(s_list, LV_ALIGN_CENTER, 0, LIST_OFFSET_Y); - lv_obj_set_style_bg_color(s_list, current_theme.screen_base, 0); - lv_obj_set_style_border_color(s_list, ui_theme_get_accent(), 0); - - s_results = ap_scanner_get_results(&s_result_count); - - if (s_results != NULL && s_result_count > 0) { - for (uint16_t i = 0; i < s_result_count; i++) { - char buf[AP_LABEL_BUF_SIZE]; - snprintf(buf, - sizeof(buf), - "%s (%d) %ddBm", - s_results[i].ssid, - s_results[i].primary, - s_results[i].rssi); - - lv_obj_t *btn = lv_list_add_button(s_list, LV_SYMBOL_WIFI, buf); - lv_obj_set_style_text_color(btn, current_theme.text_main, 0); - lv_obj_set_style_bg_color(btn, current_theme.screen_base, 0); - lv_obj_add_event_cb(btn, on_ap_key_event, LV_EVENT_KEY, &s_results[i]); - } - } else { - lv_list_add_text(s_list, "No networks found."); - } - - lv_obj_add_event_cb(s_screen, on_screen_key_event, LV_EVENT_KEY, NULL); - - if (main_group != NULL) { - lv_group_add_obj(main_group, s_list); - lv_group_focus_obj(s_list); - } - - lv_screen_load(s_screen); -} \ No newline at end of file diff --git a/firmware_p4/components/Applications/ui/screens/wifi/wifi_attack_menu_ui.c b/firmware_p4/components/Applications/ui/screens/wifi/wifi_attack_menu_ui.c deleted file mode 100644 index 80c5df6b5..000000000 --- a/firmware_p4/components/Applications/ui/screens/wifi/wifi_attack_menu_ui.c +++ /dev/null @@ -1,120 +0,0 @@ -// Copyright (c) 2025 HIGH CODE LLC -// -// TentacleOS is free software: you can redistribute it and/or modify -// it under the terms of the GNU General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// TentacleOS is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU General Public License for more details. -// -// You should have received a copy of the GNU General Public License -// along with TentacleOS. If not, see . - -#include "wifi_attack_menu_ui.h" - -#include "esp_log.h" - -#include "ui_theme.h" -#include "menu_component_ui.h" -#include "ui_manager.h" -#include "lv_port_indev.h" -#include "buttons_gpio.h" - -static const char *TAG = "WIFI_ATTACK_MENU_UI"; - -#define NAV_TIMER_PERIOD_MS 50 -#define MENU_ICON_PATH "/assets/icons/wifi_menu_icon.bin" - -typedef struct { - const char *name; - const char *icon; - int target; -} wifi_attack_item_t; - -static const wifi_attack_item_t ITEMS[] = { - {"DEAUTH ATTACK", NULL, SCREEN_WIFI_DEAUTH_ATTACK}, - {"BEACON SPAM", NULL, SCREEN_WIFI_BEACON_SPAM_SIMPLE}, - {"PROBE FLOOD", NULL, SCREEN_WIFI_PROBE_FLOOD}, - {"AUTH FLOOD", NULL, SCREEN_WIFI_AUTH_FLOOD}, -}; -#define ITEM_COUNT (sizeof(ITEMS) / sizeof(ITEMS[0])) - -static lv_obj_t *s_screen = NULL; -static menu_component_t s_menu; -static lv_timer_t *s_nav_timer = NULL; - -static bool s_btn_up_last = false; -static bool s_btn_down_last = false; -static bool s_btn_left_last = false; -static bool s_btn_right_last = false; -static bool s_btn_ok_last = false; -static bool s_btn_back_last = false; - -static void nav_timer_cb(lv_timer_t *t); - -static void nav_timer_cb(lv_timer_t *t) { - if (lv_screen_active() != s_screen) { - lv_timer_delete(t); - s_nav_timer = NULL; - return; - } - if (ui_input_is_locked()) - return; - - bool up = up_button_is_down(); - bool down = down_button_is_down(); - bool left = left_button_is_down(); - bool right = right_button_is_down(); - bool ok = ok_button_is_down(); - bool back = back_button_is_down(); - - if (down && !s_btn_down_last) - menu_component_next(&s_menu); - - if (up && !s_btn_up_last) - menu_component_prev(&s_menu); - - if ((back && !s_btn_back_last) || (left && !s_btn_left_last)) { - ui_switch_screen(SCREEN_WIFI_MENU); - return; - } - - if ((ok && !s_btn_ok_last) || (right && !s_btn_right_last)) { - int sel = menu_component_get_selected(&s_menu); - if (sel >= 0 && (size_t)sel < ITEM_COUNT) - ui_switch_screen(ITEMS[sel].target); - } - - s_btn_up_last = up; - s_btn_down_last = down; - s_btn_left_last = left; - s_btn_right_last = right; - s_btn_ok_last = ok; - s_btn_back_last = back; -} - -void ui_wifi_attack_menu_open(void) { - if (s_screen != NULL) { - lv_obj_del(s_screen); - s_screen = NULL; - } - - s_screen = lv_obj_create(NULL); - lv_obj_set_style_bg_color(s_screen, current_theme.screen_base, 0); - lv_obj_set_style_bg_opa(s_screen, LV_OPA_COVER, 0); - lv_obj_remove_flag(s_screen, LV_OBJ_FLAG_SCROLLABLE); - - s_menu = menu_component_create(s_screen, "ATTACKS", MENU_ICON_PATH); - - for (size_t i = 0; i < ITEM_COUNT; i++) { - menu_component_add_item(&s_menu, MENU_ICON_PATH, ITEMS[i].name); - } - - if (s_nav_timer == NULL) - s_nav_timer = lv_timer_create(nav_timer_cb, NAV_TIMER_PERIOD_MS, NULL); - - lv_screen_load(s_screen); -} \ No newline at end of file diff --git a/firmware_p4/components/Applications/ui/screens/wifi/wifi_attack_ui.c b/firmware_p4/components/Applications/ui/screens/wifi/wifi_attack_ui.c new file mode 100644 index 000000000..64556f0da --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/wifi/wifi_attack_ui.c @@ -0,0 +1,568 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#include "wifi_attack_ui.h" + +#include +#include + +#include "esp_log.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include "sys_prio.h" +#include "lvgl.h" + +#include "menu_component_ui.h" +#include "msgbox_ui.h" +#include "ui_chrome.h" +#include "ui_feedback.h" +#include "ui_manager.h" +#include "ui_theme.h" +#include "waves_ui.h" + +#include "ap_scanner.h" +#include "beacon_spam.h" +#include "wifi_deauther.h" +#include "wifi_flood.h" +#include "wifi_service.h" + +static const char *TAG = "WIFI_ATTACK_UI"; + +#define AP_PICK_MAX 20 +#define SSID_BUF_LEN 33 +#define AP_ROW_EXTRA 16 +#define ELAPSED_TICK_MS 250 +#define TASK_STACK_SIZE 8192 +#define TASK_PRIORITY SYS_PRIO_SERVICE_LO +#define DEAUTH_FRAME_TYPE WIFI_DEAUTHER_TYPE_CLASS3 +#define DEAUTH_BROADCAST true + +#define BOLT_ICON "/assets/icons/bolt.bin" +#define PICK_ICON "/assets/icons/wifi_find.bin" + +typedef enum { + ATTACK_DEAUTH, + ATTACK_BEACON_SPAM, + ATTACK_FLOOD_PROBE, + ATTACK_FLOOD_AUTH, + ATTACK_FLOOD_ASSOC, +} attack_kind_t; + +typedef struct { + const char *name; + attack_kind_t kind; + bool needs_target; +} attack_def_t; + +static const attack_def_t ATTACKS[] = { + {"Deauth", ATTACK_DEAUTH, true}, + {"Beacon Spam", ATTACK_BEACON_SPAM, false}, + {"Probe Flood", ATTACK_FLOOD_PROBE, true}, + {"Auth Flood", ATTACK_FLOOD_AUTH, true}, + {"Assoc Flood", ATTACK_FLOOD_ASSOC, true}, +}; +#define ATTACKS_COUNT (sizeof(ATTACKS) / sizeof(ATTACKS[0])) + +static const char *const ATTACK_ICONS[] = { + "/assets/icons/wifi_off.bin", + "/assets/icons/wifi_tethering.bin", + "/assets/icons/wifi_find.bin", + "/assets/icons/bolt.bin", + "/assets/icons/wifi_tethering.bin", +}; + +typedef enum { + VIEW_LIST, + VIEW_PICK_SCANNING, + VIEW_PICK_LIST, + VIEW_RUNNING, +} view_t; + +typedef struct { + char ssid[SSID_BUF_LEN]; + uint8_t bssid[6]; + uint8_t channel; + int8_t rssi; +} pick_ap_t; + +static lv_obj_t *s_screen = NULL; +static menu_component_t s_menu; +static lv_timer_t *s_elapsed_timer = NULL; +static lv_obj_t *s_elapsed_label = NULL; +static uint32_t s_start_tick = 0; + +static view_t s_view = VIEW_LIST; +static int s_attack_idx = 0; + +static int s_ap_count = 0; +static pick_ap_t s_aps[AP_PICK_MAX]; +static bool s_is_pick_scanning = false; + +static attack_kind_t s_pending_kind = ATTACK_DEAUTH; +static attack_kind_t s_running_kind = ATTACK_DEAUTH; +static bool s_is_target_required = false; +static uint8_t s_sel_bssid[6]; +static uint8_t s_sel_channel = 0; +static char s_sel_ssid[SSID_BUF_LEN] = {0}; + +static bool s_is_attack_starting = false; +static bool s_is_attack_started = false; +static bool s_is_attack_stopping = false; +static volatile bool s_is_stop_requested = false; + +static void stop_attack_backend(attack_kind_t kind) { + switch (kind) { + case ATTACK_DEAUTH: + wifi_deauther_stop(); + break; + case ATTACK_BEACON_SPAM: + beacon_spam_stop(); + break; + default: + wifi_flood_stop(); + break; + } +} + +static void wifi_attack_input(const input_event_t *ev, void *ctx); +static void build_list_view(void); +static void build_pick_scanning(void); +static void build_pick_list(void); +static void build_running_view(void); +static void stop_elapsed_timer(void); +static void stop_running_attack(void); + +static void fade_in(lv_obj_t *obj, uint32_t ms) { + if (obj != NULL) + lv_obj_fade_in(obj, ms, 0); +} + +static void ap_row_text(const pick_ap_t *ap, char *dst, size_t n) { + if (ap->ssid[0] != '\0') + snprintf(dst, n, "%s", ap->ssid); + else + snprintf(dst, n, "%02X:%02X:%02X CH%d", ap->bssid[3], ap->bssid[4], ap->bssid[5], ap->channel); +} + +static void clear_screen_children(void) { + stop_elapsed_timer(); + lv_obj_clean(s_screen); + s_menu = (menu_component_t){0}; + s_elapsed_label = NULL; +} + +static void build_list_view(void) { + clear_screen_children(); + lv_obj_set_style_border_width(s_screen, 0, 0); + lv_obj_set_style_pad_all(s_screen, 0, 0); + + s_menu = menu_component_create(s_screen, "ATTACKS", BOLT_ICON); + for (size_t i = 0; i < ATTACKS_COUNT; i++) + menu_component_add_item(&s_menu, ATTACK_ICONS[i], ATTACKS[i].name); + + fade_in(s_menu.title_bar, 200); + fade_in(s_menu.items_cont, 200); + + s_view = VIEW_LIST; +} + +static void build_pick_scanning(void) { + clear_screen_children(); + lv_obj_set_style_border_width(s_screen, 0, 0); + lv_obj_set_style_pad_all(s_screen, 0, 0); + + ui_chrome_header(s_screen, "Target", PICK_ICON); + waves_create(s_screen, LV_ALIGN_CENTER, 0, -6, LV_SYMBOL_WIFI, PICK_ICON); + + lv_obj_t *caption = lv_label_create(s_screen); + lv_label_set_text(caption, "Scanning..."); + lv_obj_set_style_text_color(caption, current_theme.text_main, 0); + lv_obj_set_style_text_font(caption, &lv_font_montserrat_14, 0); + lv_obj_set_style_text_align(caption, LV_TEXT_ALIGN_CENTER, 0); + lv_obj_align(caption, LV_ALIGN_CENTER, 0, 78); + + ui_chrome_footer(s_screen, LV_SYMBOL_LEFT " Back"); + + s_view = VIEW_PICK_SCANNING; +} + +static void build_pick_list(void) { + clear_screen_children(); + lv_obj_set_style_border_width(s_screen, 0, 0); + lv_obj_set_style_pad_all(s_screen, 0, 0); + + s_menu = menu_component_create(s_screen, "Target", PICK_ICON); + if (s_ap_count == 0) { + menu_component_add_item(&s_menu, PICK_ICON, "No networks found"); + } else { + for (int i = 0; i < s_ap_count; i++) { + char row[SSID_BUF_LEN + AP_ROW_EXTRA]; + ap_row_text(&s_aps[i], row, sizeof(row)); + menu_component_add_item(&s_menu, PICK_ICON, row); + } + } + menu_component_set_hint(&s_menu, "OK target BACK exit"); + + fade_in(s_menu.title_bar, 200); + fade_in(s_menu.items_cont, 200); + + s_view = VIEW_PICK_LIST; +} + +static void build_running_view(void) { + clear_screen_children(); + lv_obj_set_style_border_width(s_screen, 0, 0); + lv_obj_set_style_pad_all(s_screen, 0, 0); + + lv_obj_t *header = ui_chrome_header(s_screen, ATTACKS[s_attack_idx].name, BOLT_ICON); + + lv_obj_t *status_label = lv_label_create(s_screen); + lv_label_set_text(status_label, "ACTIVE"); + lv_obj_set_style_text_color(status_label, current_theme.border_accent, 0); + lv_obj_set_style_text_font(status_label, &lv_font_montserrat_16, 0); + lv_obj_set_style_text_align(status_label, LV_TEXT_ALIGN_CENTER, 0); + lv_obj_align(status_label, LV_ALIGN_TOP_MID, 0, UI_CHROME_HEADER_H + 8); + + lv_obj_t *target_label = lv_label_create(s_screen); + if (s_is_target_required) + lv_label_set_text_fmt(target_label, "AP: %s CH %d", s_sel_ssid, s_sel_channel); + else + lv_label_set_text(target_label, "Target: broadcast"); + lv_obj_set_style_text_color(target_label, current_theme.text_main, 0); + lv_obj_set_style_text_font(target_label, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_align(target_label, LV_TEXT_ALIGN_CENTER, 0); + lv_obj_align(target_label, LV_ALIGN_TOP_MID, 0, UI_CHROME_HEADER_H + 32); + + waves_create(s_screen, LV_ALIGN_CENTER, 0, 14, LV_SYMBOL_WIFI, NULL); + + s_elapsed_label = lv_label_create(s_screen); + lv_label_set_text(s_elapsed_label, "00:00"); + lv_obj_set_style_text_color(s_elapsed_label, current_theme.border_accent, 0); + lv_obj_set_style_text_font(s_elapsed_label, &lv_font_montserrat_16, 0); + lv_obj_set_style_text_align(s_elapsed_label, LV_TEXT_ALIGN_CENTER, 0); + lv_obj_align(s_elapsed_label, LV_ALIGN_BOTTOM_MID, 0, -42); + + ui_chrome_footer(s_screen, "BACK to stop"); + + fade_in(header, 200); + fade_in(status_label, 200); + fade_in(target_label, 200); + fade_in(s_elapsed_label, 200); + + s_view = VIEW_RUNNING; + s_start_tick = lv_tick_get(); +} + +static void stop_elapsed_timer(void) { + if (s_elapsed_timer != NULL) { + lv_timer_delete(s_elapsed_timer); + s_elapsed_timer = NULL; + } +} + +static void elapsed_tick_cb(lv_timer_t *timer) { + if (lv_screen_active() != s_screen) { + stop_running_attack(); + lv_timer_delete(timer); + if (s_elapsed_timer == timer) + s_elapsed_timer = NULL; + return; + } + if (s_view != VIEW_RUNNING || s_elapsed_label == NULL) + return; + uint32_t secs = (lv_tick_get() - s_start_tick) / 1000; + lv_label_set_text_fmt(s_elapsed_label, "%02u:%02u", (unsigned)(secs / 60), (unsigned)(secs % 60)); +} + +static void attack_started_cb(void *unused) { + (void)unused; + if (ui_current_screen() != SCREEN_WIFI_ATTACK_MENU || s_view != VIEW_RUNNING) { + if (s_is_attack_started) + stop_running_attack(); + return; + } + if (!s_is_attack_started) { + msgbox_open(BOLT_ICON, "Attack start failed (C5?)", "OK", NULL, NULL); + build_list_view(); + ui_input_set_screen_handler(wifi_attack_input, NULL); + return; + } + ui_feedback(UI_FB_EMULATE); + stop_elapsed_timer(); + s_start_tick = lv_tick_get(); + s_elapsed_timer = lv_timer_create(elapsed_tick_cb, ELAPSED_TICK_MS, NULL); +} + +static void attack_start_task(void *arg) { + (void)arg; + attack_kind_t kind = s_pending_kind; + bool ok = false; + + switch (kind) { + case ATTACK_DEAUTH: { + wifi_ap_record_t rec; + memset(&rec, 0, sizeof(rec)); + memcpy(rec.bssid, s_sel_bssid, sizeof(rec.bssid)); + rec.primary = s_sel_channel; + ok = wifi_deauther_start(&rec, DEAUTH_FRAME_TYPE, DEAUTH_BROADCAST); + break; + } + case ATTACK_BEACON_SPAM: + ok = beacon_spam_start_random(); + break; + case ATTACK_FLOOD_PROBE: + ok = wifi_flood_probe_start(s_sel_bssid, s_sel_channel); + break; + case ATTACK_FLOOD_AUTH: + ok = wifi_flood_auth_start(s_sel_bssid, s_sel_channel); + break; + case ATTACK_FLOOD_ASSOC: + ok = wifi_flood_assoc_start(s_sel_bssid, s_sel_channel); + break; + } + + s_running_kind = kind; + if (ok && s_is_stop_requested) { + stop_attack_backend(kind); + ok = false; + } + s_is_attack_started = ok; + s_is_attack_starting = false; + lv_async_call(attack_started_cb, NULL); + vTaskDelete(NULL); +} + +static void attack_stop_task(void *arg) { + (void)arg; + stop_attack_backend(s_running_kind); + s_is_attack_started = false; + s_is_attack_stopping = false; + vTaskDelete(NULL); +} + +static void begin_attack(attack_kind_t kind) { + if (s_is_attack_starting) + return; + s_pending_kind = kind; + s_running_kind = kind; + s_is_stop_requested = false; + s_is_attack_starting = true; + s_is_attack_started = false; + + build_running_view(); + ui_input_set_screen_handler(wifi_attack_input, NULL); + + if (xTaskCreatePinnedToCore(attack_start_task, + "atk_start", + TASK_STACK_SIZE, + NULL, + TASK_PRIORITY, + NULL, + SYS_CORE_RADIO) != pdPASS) { + s_is_attack_starting = false; + s_is_attack_started = false; + build_list_view(); + ui_input_set_screen_handler(wifi_attack_input, NULL); + } +} + +static void stop_running_attack(void) { + ESP_LOGI(TAG, "attack stop: %s", ATTACKS[s_attack_idx].name); + s_is_stop_requested = true; + if (!s_is_attack_stopping) { + s_is_attack_stopping = true; + if (xTaskCreatePinnedToCore(attack_stop_task, + "atk_stop", + TASK_STACK_SIZE, + NULL, + TASK_PRIORITY, + NULL, + SYS_CORE_RADIO) != pdPASS) { + s_is_attack_stopping = false; + } + } +} + +static void ap_pick_done_cb(void *unused) { + (void)unused; + if (ui_current_screen() != SCREEN_WIFI_ATTACK_MENU || s_view != VIEW_PICK_SCANNING) + return; + build_pick_list(); + ui_input_set_screen_handler(wifi_attack_input, NULL); + if (s_ap_count > 0) + ui_feedback(UI_FB_READ); +} + +static void ap_pick_task(void *arg) { + (void)arg; + int n = 0; + + wifi_service_start(); + if (ap_scanner_start()) { + uint16_t count = 0; + wifi_ap_record_t *recs = ap_scanner_get_results(&count); + if (recs != NULL) { + for (uint16_t i = 0; i < count && n < AP_PICK_MAX; i++) { + pick_ap_t *ap = &s_aps[n++]; + strncpy(ap->ssid, (const char *)recs[i].ssid, sizeof(ap->ssid) - 1); + ap->ssid[sizeof(ap->ssid) - 1] = '\0'; + memcpy(ap->bssid, recs[i].bssid, sizeof(ap->bssid)); + ap->channel = recs[i].primary; + ap->rssi = recs[i].rssi; + } + } + ap_scanner_free_results(); + } + + s_ap_count = n; + s_is_pick_scanning = false; + lv_async_call(ap_pick_done_cb, NULL); + vTaskDelete(NULL); +} + +static void start_ap_pick(attack_kind_t kind) { + s_pending_kind = kind; + s_ap_count = 0; + build_pick_scanning(); + ui_input_set_screen_handler(wifi_attack_input, NULL); + + if (!s_is_pick_scanning) { + s_is_pick_scanning = true; + if (xTaskCreatePinnedToCore( + ap_pick_task, "atk_pick", TASK_STACK_SIZE, NULL, TASK_PRIORITY, NULL, SYS_CORE_RADIO) != + pdPASS) { + s_is_pick_scanning = false; + build_pick_list(); + ui_input_set_screen_handler(wifi_attack_input, NULL); + } + } +} + +static void select_target_and_run(int idx) { + if (idx < 0 || idx >= s_ap_count) + return; + const pick_ap_t *ap = &s_aps[idx]; + memcpy(s_sel_bssid, ap->bssid, sizeof(s_sel_bssid)); + s_sel_channel = ap->channel; + strncpy(s_sel_ssid, ap->ssid, sizeof(s_sel_ssid) - 1); + s_sel_ssid[sizeof(s_sel_ssid) - 1] = '\0'; + s_is_target_required = true; + begin_attack(s_pending_kind); +} + +static void wifi_attack_input(const input_event_t *ev, void *ctx) { + (void)ctx; + const bool press = (ev->action == INPUT_ACTION_PRESS); + const bool nav = press || (ev->action == INPUT_ACTION_REPEAT); + + switch (s_view) { + case VIEW_LIST: + switch (ev->button) { + case INPUT_BTN_DOWN: + if (nav) + menu_component_next(&s_menu); + break; + case INPUT_BTN_UP: + if (nav) + menu_component_prev(&s_menu); + break; + case INPUT_BTN_BACK: + case INPUT_BTN_LEFT: + if (press) + ui_switch_screen(SCREEN_WIFI_MENU); + break; + case INPUT_BTN_OK: + if (press) { + int sel = menu_component_get_selected(&s_menu); + if (sel >= 0 && sel < (int)ATTACKS_COUNT) { + s_attack_idx = sel; + if (ATTACKS[sel].needs_target) { + start_ap_pick(ATTACKS[sel].kind); + } else { + s_is_target_required = false; + begin_attack(ATTACKS[sel].kind); + } + } + } + break; + default: + break; + } + break; + + case VIEW_PICK_SCANNING: + if ((ev->button == INPUT_BTN_BACK || ev->button == INPUT_BTN_LEFT) && press) + build_list_view(); + break; + + case VIEW_PICK_LIST: + switch (ev->button) { + case INPUT_BTN_DOWN: + if (nav) + menu_component_next(&s_menu); + break; + case INPUT_BTN_UP: + if (nav) + menu_component_prev(&s_menu); + break; + case INPUT_BTN_BACK: + case INPUT_BTN_LEFT: + if (press) + build_list_view(); + break; + case INPUT_BTN_OK: + case INPUT_BTN_RIGHT: + if (press && s_ap_count > 0) + select_target_and_run(menu_component_get_selected(&s_menu)); + break; + default: + break; + } + break; + + case VIEW_RUNNING: + if ((ev->button == INPUT_BTN_BACK || ev->button == INPUT_BTN_LEFT) && press) { + stop_running_attack(); + build_list_view(); + } + break; + + default: + break; + } +} + +void ui_wifi_attack_open(void) { + if (s_screen != NULL) { + lv_obj_del(s_screen); + s_screen = NULL; + } + stop_elapsed_timer(); + s_view = VIEW_LIST; + s_is_attack_starting = false; + s_is_attack_started = false; + s_is_attack_stopping = false; + + s_screen = lv_obj_create(NULL); + lv_obj_set_style_bg_color(s_screen, current_theme.screen_base, 0); + lv_obj_set_style_bg_opa(s_screen, LV_OPA_COVER, 0); + lv_obj_remove_flag(s_screen, LV_OBJ_FLAG_SCROLLABLE); + + build_list_view(); + + ui_input_set_screen_handler(wifi_attack_input, NULL); + + ui_screen_load_owned(&s_screen, s_screen); +} diff --git a/firmware_p4/components/Applications/ui/screens/wifi/wifi_auth_flood_ui.c b/firmware_p4/components/Applications/ui/screens/wifi/wifi_auth_flood_ui.c deleted file mode 100644 index 6644e7ab5..000000000 --- a/firmware_p4/components/Applications/ui/screens/wifi/wifi_auth_flood_ui.c +++ /dev/null @@ -1,387 +0,0 @@ -// Copyright (c) 2025 HIGH CODE LLC -// -// TentacleOS is free software: you can redistribute it and/or modify -// it under the terms of the GNU General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// TentacleOS is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU General Public License for more details. -// -// You should have received a copy of the GNU General Public License -// along with TentacleOS. If not, see . - -#include "wifi_auth_flood_ui.h" - -#include "lvgl.h" -#include "core/lv_group.h" - -#include "ui_theme.h" -#include "header_ui.h" -#include "footer_ui.h" -#include "ui_manager.h" -#include "lv_port_indev.h" -#include "wifi_service.h" -#include "wifi_flood.h" -#include "button_ui.h" - -#define LIST_CONT_W 230 -#define LIST_CONT_H 160 -#define LIST_CONT_OFFSET_Y 10 -#define LIST_ITEM_H 40 -#define LIST_ITEM_BORDER_W 1 -#define LIST_ITEM_BORDER_SEL 2 -#define LIST_ITEM_LABEL_MARGIN_L 8 - -#define ATTACK_BTN_W 170 -#define ATTACK_BTN_H 45 -#define ATTACK_BTN_OFFSET_Y 10 -#define TARGET_LABEL_OFFSET_Y 30 -#define ATTEMPTS_LABEL_OFFSET_Y (-35) - -#define ATTEMPTS_TICK_MS 200 -#define ATTEMPTS_PER_TICK 10 - -#define TARGET_LABEL_FMT "Target: %s CH:%d" -#define ATTEMPTS_LABEL_FMT "Attempts: %lu" -#define BTN_LABEL_RUNNING "FLOODING..." -#define BTN_LABEL_IDLE "START FLOOD" - -typedef enum { - AUTH_FLOOD_VIEW_APS = 0, - AUTH_FLOOD_VIEW_ATTACK = 1, -} auth_flood_view_t; - -extern lv_group_t *main_group; - -static lv_obj_t *s_screen = NULL; -static lv_obj_t *s_list_cont = NULL; -static lv_obj_t *s_loading_label = NULL; -static lv_obj_t *s_lbl_target = NULL; -static lv_obj_t *s_btn_attack = NULL; -static lv_obj_t *s_lbl_attempts = NULL; -static lv_style_t s_style_menu; -static lv_style_t s_style_item; -static bool s_styles_initialized = false; - -static auth_flood_view_t s_current_view = AUTH_FLOOD_VIEW_APS; -static wifi_ap_record_t s_selected_ap; -static bool s_is_running = false; -static uint32_t s_attempts_count = 0; -static lv_timer_t *s_attempts_timer = NULL; - -static void list_event_cb(lv_event_t *e); -static void init_styles(void); -static void clear_list(void); -static void set_loading(const char *text); -static void clear_loading(void); -static void update_attack_labels(void); -static void attempts_tick_cb(lv_timer_t *t); -static void stop_attack(void); -static void start_attack(void); -static void show_attack_view(void); -static void on_item_event(lv_event_t *e); -static void populate_ap_list(wifi_ap_record_t *results, uint16_t count); -static void scan_and_populate(void); - -static void init_styles(void) { - if (s_styles_initialized) - return; - - lv_style_init(&s_style_menu); - lv_style_set_bg_color(&s_style_menu, current_theme.screen_base); - lv_style_set_bg_opa(&s_style_menu, LV_OPA_COVER); - lv_style_set_border_width(&s_style_menu, 2); - lv_style_set_border_color(&s_style_menu, current_theme.border_interface); - lv_style_set_radius(&s_style_menu, 0); - lv_style_set_pad_all(&s_style_menu, 4); - - lv_style_init(&s_style_item); - lv_style_set_bg_color(&s_style_item, current_theme.bg_item_bot); - lv_style_set_bg_grad_color(&s_style_item, current_theme.bg_item_top); - lv_style_set_bg_grad_dir(&s_style_item, LV_GRAD_DIR_VER); - lv_style_set_border_width(&s_style_item, LIST_ITEM_BORDER_W); - lv_style_set_border_color(&s_style_item, current_theme.border_inactive); - lv_style_set_radius(&s_style_item, 0); - - s_styles_initialized = true; -} - -static void clear_list(void) { - if (s_list_cont == NULL) - return; - - uint32_t count = lv_obj_get_child_count(s_list_cont); - for (uint32_t i = 0; i < count; i++) - lv_obj_del(lv_obj_get_child(s_list_cont, 0)); - - if (main_group != NULL) - lv_group_remove_all_objs(main_group); -} - -static void set_loading(const char *text) { - if (s_loading_label == NULL) { - s_loading_label = lv_label_create(s_screen); - lv_obj_set_style_text_color(s_loading_label, current_theme.text_main, 0); - lv_obj_center(s_loading_label); - } - lv_label_set_text(s_loading_label, text); -} - -static void clear_loading(void) { - if (s_loading_label != NULL) { - lv_obj_del(s_loading_label); - s_loading_label = NULL; - } -} - -static void update_attack_labels(void) { - if (s_lbl_target != NULL) - lv_label_set_text_fmt( - s_lbl_target, TARGET_LABEL_FMT, s_selected_ap.ssid, s_selected_ap.primary); - - if (s_lbl_attempts != NULL) - lv_label_set_text_fmt(s_lbl_attempts, ATTEMPTS_LABEL_FMT, (unsigned long)s_attempts_count); - - if (s_btn_attack != NULL) { - lv_label_set_text(lv_obj_get_child(s_btn_attack, 0), - s_is_running ? BTN_LABEL_RUNNING : BTN_LABEL_IDLE); - lv_obj_set_style_bg_color(s_btn_attack, current_theme.border_accent, 0); - } -} - -static void attempts_tick_cb(lv_timer_t *t) { - if (!s_is_running) - return; - s_attempts_count += ATTEMPTS_PER_TICK; - update_attack_labels(); -} - -static void stop_attack(void) { - if (s_is_running) { - wifi_flood_stop(); - s_is_running = false; - } - if (s_attempts_timer != NULL) { - lv_timer_del(s_attempts_timer); - s_attempts_timer = NULL; - } -} - -static void start_attack(void) { - if (!wifi_flood_auth_start(s_selected_ap.bssid, s_selected_ap.primary)) - return; - - s_is_running = true; - - if (s_attempts_timer != NULL) - lv_timer_del(s_attempts_timer); - - s_attempts_timer = lv_timer_create(attempts_tick_cb, ATTEMPTS_TICK_MS, NULL); -} - -static void show_attack_view(void) { - clear_list(); - clear_loading(); - - s_current_view = AUTH_FLOOD_VIEW_ATTACK; - s_is_running = false; - s_attempts_count = 0; - - if (s_lbl_target != NULL) { - lv_obj_del(s_lbl_target); - s_lbl_target = NULL; - } - if (s_btn_attack != NULL) { - lv_obj_del(s_btn_attack); - s_btn_attack = NULL; - } - if (s_lbl_attempts != NULL) { - lv_obj_del(s_lbl_attempts); - s_lbl_attempts = NULL; - } - - s_lbl_target = lv_label_create(s_screen); - lv_obj_set_style_text_color(s_lbl_target, current_theme.text_main, 0); - lv_obj_align(s_lbl_target, LV_ALIGN_TOP_MID, 0, TARGET_LABEL_OFFSET_Y); - - button_ui_t btn_ui = - button_ui_create(s_screen, ATTACK_BTN_W, ATTACK_BTN_H, BTN_LABEL_IDLE, NULL, NULL); - s_btn_attack = btn_ui.obj; - lv_obj_align(s_btn_attack, LV_ALIGN_CENTER, 0, ATTACK_BTN_OFFSET_Y); - lv_obj_add_event_cb(s_btn_attack, list_event_cb, LV_EVENT_KEY, NULL); - - s_lbl_attempts = lv_label_create(s_screen); - lv_obj_set_style_text_color(s_lbl_attempts, current_theme.text_main, 0); - lv_obj_align(s_lbl_attempts, LV_ALIGN_BOTTOM_MID, 0, ATTEMPTS_LABEL_OFFSET_Y); - - update_attack_labels(); - - if (main_group != NULL) { - lv_group_remove_all_objs(main_group); - lv_group_add_obj(main_group, s_btn_attack); - lv_group_focus_obj(s_btn_attack); - } -} - -static void on_item_event(lv_event_t *e) { - lv_event_code_t code = lv_event_get_code(e); - lv_obj_t *item = lv_event_get_target(e); - - if (code == LV_EVENT_FOCUSED) { - lv_obj_set_style_border_color(item, ui_theme_get_accent(), 0); - lv_obj_set_style_border_width(item, LIST_ITEM_BORDER_SEL, 0); - lv_obj_scroll_to_view(item, LV_ANIM_ON); - } else if (code == LV_EVENT_DEFOCUSED) { - lv_obj_set_style_border_color(item, current_theme.border_inactive, 0); - lv_obj_set_style_border_width(item, LIST_ITEM_BORDER_W, 0); - } else if (code == LV_EVENT_KEY) { - list_event_cb(e); - } -} - -static void populate_ap_list(wifi_ap_record_t *results, uint16_t count) { - if (results == NULL || count == 0) { - lv_obj_t *empty = lv_label_create(s_list_cont); - lv_label_set_text(empty, "NO APS FOUND"); - lv_obj_set_style_text_color(empty, current_theme.text_main, 0); - if (main_group != NULL) - lv_group_add_obj(main_group, empty); - return; - } - - for (uint16_t i = 0; i < count; i++) { - wifi_ap_record_t *ap = &results[i]; - lv_obj_t *item = lv_obj_create(s_list_cont); - lv_obj_set_size(item, lv_pct(100), LIST_ITEM_H); - lv_obj_add_style(item, &s_style_item, 0); - lv_obj_set_flex_flow(item, LV_FLEX_FLOW_ROW); - lv_obj_set_flex_align(item, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); - lv_obj_clear_flag(item, LV_OBJ_FLAG_SCROLLABLE); - - lv_obj_t *icon = lv_label_create(item); - lv_label_set_text(icon, LV_SYMBOL_WIFI); - lv_obj_set_style_text_color(icon, current_theme.text_main, 0); - - lv_obj_t *lbl = lv_label_create(item); - lv_label_set_text(lbl, (char *)ap->ssid); - lv_obj_set_style_text_color(lbl, current_theme.text_main, 0); - lv_obj_set_flex_grow(lbl, 1); - lv_obj_set_style_margin_left(lbl, LIST_ITEM_LABEL_MARGIN_L, 0); - - lv_obj_set_user_data(item, ap); - lv_obj_add_event_cb(item, on_item_event, LV_EVENT_ALL, NULL); - - if (main_group != NULL) - lv_group_add_obj(main_group, item); - } - - if (main_group != NULL) { - lv_obj_t *first = lv_obj_get_child(s_list_cont, 0); - if (first != NULL) - lv_group_focus_obj(first); - } -} - -static void scan_and_populate(void) { - set_loading("SCANNING APS..."); - lv_refr_now(NULL); - - if (!wifi_service_is_active()) { - set_loading("WIFI OFF"); - return; - } - - wifi_service_scan(); - clear_loading(); - - uint16_t count = wifi_service_get_ap_count(); - wifi_ap_record_t *results = (count > 0) ? wifi_service_get_ap_record(0) : NULL; - populate_ap_list(results, count); -} - -static void list_event_cb(lv_event_t *e) { - if (lv_event_get_code(e) != LV_EVENT_KEY) - return; - - uint32_t key = lv_event_get_key(e); - - if (key == LV_KEY_ESC || key == LV_KEY_LEFT) { - if (s_current_view == AUTH_FLOOD_VIEW_ATTACK) { - stop_attack(); - s_current_view = AUTH_FLOOD_VIEW_APS; - - if (s_lbl_target != NULL) { - lv_obj_del(s_lbl_target); - s_lbl_target = NULL; - } - if (s_btn_attack != NULL) { - lv_obj_del(s_btn_attack); - s_btn_attack = NULL; - } - if (s_lbl_attempts != NULL) { - lv_obj_del(s_lbl_attempts); - s_lbl_attempts = NULL; - } - - clear_list(); - scan_and_populate(); - } else { - stop_attack(); - ui_switch_screen(SCREEN_WIFI_ATTACK_MENU); - } - return; - } - - if (key == LV_KEY_ENTER) { - if (s_current_view == AUTH_FLOOD_VIEW_APS) { - if (main_group == NULL) - return; - lv_obj_t *focused = lv_group_get_focused(main_group); - if (focused == NULL) - return; - wifi_ap_record_t *ap = (wifi_ap_record_t *)lv_obj_get_user_data(focused); - if (ap == NULL) - return; - s_selected_ap = *ap; - show_attack_view(); - } else { - if (!s_is_running) - start_attack(); - else - stop_attack(); - update_attack_labels(); - } - } -} - -void ui_wifi_auth_flood_open(void) { - init_styles(); - - if (s_screen != NULL) { - lv_obj_del(s_screen); - s_screen = NULL; - } - - s_screen = lv_obj_create(NULL); - lv_obj_set_style_bg_color(s_screen, current_theme.screen_base, 0); - lv_obj_clear_flag(s_screen, LV_OBJ_FLAG_SCROLLABLE); - - header_ui_create(s_screen); - footer_ui_create(s_screen); - - s_list_cont = lv_obj_create(s_screen); - lv_obj_set_size(s_list_cont, LIST_CONT_W, LIST_CONT_H); - lv_obj_align(s_list_cont, LV_ALIGN_CENTER, 0, LIST_CONT_OFFSET_Y); - lv_obj_add_style(s_list_cont, &s_style_menu, 0); - lv_obj_set_flex_flow(s_list_cont, LV_FLEX_FLOW_COLUMN); - lv_obj_set_scrollbar_mode(s_list_cont, LV_SCROLLBAR_MODE_OFF); - lv_obj_add_flag(s_list_cont, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_set_scroll_dir(s_list_cont, LV_DIR_VER); - lv_obj_add_event_cb(s_list_cont, list_event_cb, LV_EVENT_KEY, NULL); - - lv_screen_load(s_screen); - scan_and_populate(); -} \ No newline at end of file diff --git a/firmware_p4/components/Applications/ui/screens/wifi/wifi_beacon_spam_simple_ui.c b/firmware_p4/components/Applications/ui/screens/wifi/wifi_beacon_spam_simple_ui.c deleted file mode 100644 index d0093f2a0..000000000 --- a/firmware_p4/components/Applications/ui/screens/wifi/wifi_beacon_spam_simple_ui.c +++ /dev/null @@ -1,103 +0,0 @@ -// Copyright (c) 2025 HIGH CODE LLC -// -// TentacleOS is free software: you can redistribute it and/or modify -// it under the terms of the GNU General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// TentacleOS is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU General Public License for more details. -// -// You should have received a copy of the GNU General Public License -// along with TentacleOS. If not, see . - -#include "wifi_beacon_spam_simple_ui.h" - -#include "esp_log.h" -#include "lvgl.h" - -#include "beacon_spam.h" -#include "footer_ui.h" -#include "header_ui.h" -#include "lv_port_indev.h" -#include "ui_manager.h" -#include "ui_theme.h" - -static const char *TAG = "UI_BEACON_SPAM_SIMPLE"; - -#define STATUS_OFFSET_Y (-10) -#define COUNT_OFFSET_Y 20 -#define COUNT_INCREMENT 10 -#define TIMER_PERIOD_MS 1000 - -static lv_obj_t *s_screen = NULL; -static lv_obj_t *s_lbl_status = NULL; -static lv_obj_t *s_lbl_count = NULL; -static lv_timer_t *s_update_timer = NULL; -static uint32_t s_spam_count = 0; - -extern lv_group_t *main_group; - -static void update_count_cb(lv_timer_t *t) { - (void)t; - if (!beacon_spam_is_running()) - return; - s_spam_count += COUNT_INCREMENT; - if (s_lbl_count != NULL) { - lv_label_set_text_fmt(s_lbl_count, "Created: %lu", (unsigned long)s_spam_count); - } -} - -static void screen_event_cb(lv_event_t *e) { - if (lv_event_get_code(e) != LV_EVENT_KEY) - return; - uint32_t key = lv_event_get_key(e); - if (key == LV_KEY_ESC || key == LV_KEY_LEFT) { - if (s_update_timer != NULL) { - lv_timer_del(s_update_timer); - s_update_timer = NULL; - } - beacon_spam_stop(); - ui_switch_screen(SCREEN_WIFI_ATTACK_MENU); - } -} - -void ui_wifi_beacon_spam_simple_open(void) { - if (s_screen != NULL) - lv_obj_del(s_screen); - - s_screen = lv_obj_create(NULL); - lv_obj_set_style_bg_color(s_screen, current_theme.screen_base, 0); - lv_obj_remove_flag(s_screen, LV_OBJ_FLAG_SCROLLABLE); - - header_ui_create(s_screen); - footer_ui_create(s_screen); - - s_lbl_status = lv_label_create(s_screen); - lv_label_set_text(s_lbl_status, "Spamming Random SSIDs..."); - lv_obj_set_style_text_color(s_lbl_status, current_theme.text_main, 0); - lv_obj_align(s_lbl_status, LV_ALIGN_CENTER, 0, STATUS_OFFSET_Y); - - s_lbl_count = lv_label_create(s_screen); - lv_label_set_text(s_lbl_count, "Created: 0"); - lv_obj_set_style_text_color(s_lbl_count, current_theme.text_main, 0); - lv_obj_align(s_lbl_count, LV_ALIGN_CENTER, 0, COUNT_OFFSET_Y); - - s_spam_count = 0; - beacon_spam_start_random(); - - if (s_update_timer != NULL) - lv_timer_del(s_update_timer); - s_update_timer = lv_timer_create(update_count_cb, TIMER_PERIOD_MS, NULL); - - lv_obj_add_event_cb(s_screen, screen_event_cb, LV_EVENT_KEY, NULL); - - if (main_group != NULL) { - lv_group_add_obj(main_group, s_screen); - lv_group_focus_obj(s_screen); - } - - lv_screen_load(s_screen); -} diff --git a/firmware_p4/components/Applications/ui/screens/wifi/wifi_beacon_spam_ui.c b/firmware_p4/components/Applications/ui/screens/wifi/wifi_beacon_spam_ui.c deleted file mode 100644 index 775cbe2aa..000000000 --- a/firmware_p4/components/Applications/ui/screens/wifi/wifi_beacon_spam_ui.c +++ /dev/null @@ -1,153 +0,0 @@ -// Copyright (c) 2025 HIGH CODE LLC -// -// TentacleOS is free software: you can redistribute it and/or modify -// it under the terms of the GNU General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// TentacleOS is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU General Public License for more details. -// -// You should have received a copy of the GNU General Public License -// along with TentacleOS. If not, see . -#include "wifi_beacon_spam_ui.h" - -#include "esp_log.h" - -#include "beacon_spam.h" -#include "footer_ui.h" -#include "header_ui.h" -#include "lv_port_indev.h" -#include "tos_flash_paths.h" -#include "ui_manager.h" -#include "ui_theme.h" - -static const char *TAG = "UI_BEACON_SPAM"; - -#define DEFAULT_LIST_PATH FLASH_STORAGE_WIFI_BEACONS -#define BTN_WIDTH 160 -#define BTN_HEIGHT 40 -#define TITLE_OFFSET_Y 30 -#define BTN_MODE_OFFSET_Y (-20) -#define BTN_START_OFFSET_Y 30 -#define STATUS_OFFSET_Y (-30) - -static lv_obj_t *s_screen = NULL; -static lv_obj_t *s_btn_mode = NULL; -static lv_obj_t *s_btn_start = NULL; -static lv_obj_t *s_lbl_status = NULL; -static bool s_is_random_mode = true; - -extern lv_group_t *main_group; - -static void update_mode_label(void) { - if (s_btn_mode != NULL) { - lv_label_set_text_fmt( - lv_obj_get_child(s_btn_mode, 0), "Mode: %s", s_is_random_mode ? "RANDOM" : "LIST"); - } -} - -static void toggle_mode_handler(lv_event_t *e) { - if (lv_event_get_code(e) == LV_EVENT_KEY && - (lv_event_get_key(e) == LV_KEY_ENTER || lv_event_get_key(e) == LV_KEY_RIGHT || - lv_event_get_key(e) == LV_KEY_LEFT)) { - if (!beacon_spam_is_running()) { - s_is_random_mode = !s_is_random_mode; - update_mode_label(); - } - } -} - -static void toggle_start_handler(lv_event_t *e) { - if (lv_event_get_code(e) == LV_EVENT_KEY && lv_event_get_key(e) == LV_KEY_ENTER) { - if (beacon_spam_is_running()) { - beacon_spam_stop(); - lv_label_set_text(lv_obj_get_child(s_btn_start, 0), "START SPAM"); - lv_obj_set_style_bg_color(s_btn_start, current_theme.bg_item_top, 0); - lv_label_set_text(s_lbl_status, "Status: STOPPED"); - if (s_btn_mode != NULL) - lv_obj_clear_state(s_btn_mode, LV_STATE_DISABLED); - } else { - bool is_success = false; - if (s_is_random_mode) { - is_success = beacon_spam_start_random(); - } else { - is_success = beacon_spam_start_custom(DEFAULT_LIST_PATH); - } - - if (is_success) { - lv_label_set_text(lv_obj_get_child(s_btn_start, 0), "STOP SPAM"); - lv_obj_set_style_bg_color(s_btn_start, current_theme.bg_item_bot, 0); - lv_label_set_text(s_lbl_status, "Status: SPAMMING..."); - if (s_btn_mode != NULL) - lv_obj_add_state(s_btn_mode, LV_STATE_DISABLED); - } else { - lv_label_set_text(s_lbl_status, "Failed to start!"); - } - } - } -} - -static void screen_event_cb(lv_event_t *e) { - if (lv_event_get_code(e) == LV_EVENT_KEY) { - if (lv_event_get_key(e) == LV_KEY_ESC) { - if (beacon_spam_is_running()) { - beacon_spam_stop(); - } - ui_switch_screen(SCREEN_WIFI_MENU); - } - } -} - -void ui_wifi_beacon_spam_open(void) { - if (s_screen != NULL) - lv_obj_del(s_screen); - - s_screen = lv_obj_create(NULL); - lv_obj_set_style_bg_color(s_screen, current_theme.screen_base, 0); - lv_obj_remove_flag(s_screen, LV_OBJ_FLAG_SCROLLABLE); - - header_ui_create(s_screen); - footer_ui_create(s_screen); - - lv_obj_t *title = lv_label_create(s_screen); - lv_label_set_text(title, "BEACON SPAM"); - lv_obj_set_style_text_color(title, current_theme.text_main, 0); - lv_obj_align(title, LV_ALIGN_TOP_MID, 0, TITLE_OFFSET_Y); - - s_btn_mode = lv_btn_create(s_screen); - lv_obj_set_size(s_btn_mode, BTN_WIDTH, BTN_HEIGHT); - lv_obj_align(s_btn_mode, LV_ALIGN_CENTER, 0, BTN_MODE_OFFSET_Y); - - lv_obj_t *lbl_mode = lv_label_create(s_btn_mode); - lv_obj_center(lbl_mode); - update_mode_label(); - - s_btn_start = lv_btn_create(s_screen); - lv_obj_set_size(s_btn_start, BTN_WIDTH, BTN_HEIGHT); - lv_obj_align(s_btn_start, LV_ALIGN_CENTER, 0, BTN_START_OFFSET_Y); - lv_obj_set_style_bg_color(s_btn_start, current_theme.bg_item_top, 0); - - lv_obj_t *lbl_btn = lv_label_create(s_btn_start); - lv_label_set_text(lbl_btn, "START SPAM"); - lv_obj_center(lbl_btn); - - s_lbl_status = lv_label_create(s_screen); - lv_label_set_text(s_lbl_status, "Status: READY"); - lv_obj_set_style_text_color(s_lbl_status, current_theme.text_main, 0); - lv_obj_align(s_lbl_status, LV_ALIGN_BOTTOM_MID, 0, STATUS_OFFSET_Y); - - lv_obj_add_event_cb(s_btn_mode, toggle_mode_handler, LV_EVENT_KEY, NULL); - lv_obj_add_event_cb(s_btn_start, toggle_start_handler, LV_EVENT_KEY, NULL); - lv_obj_add_event_cb(s_screen, screen_event_cb, LV_EVENT_KEY, NULL); - - if (main_group != NULL) { - lv_group_add_obj(main_group, s_btn_mode); - lv_group_add_obj(main_group, s_btn_start); - lv_group_focus_obj(s_btn_mode); - } - - lv_screen_load(s_screen); -} diff --git a/firmware_p4/components/Applications/ui/screens/wifi/wifi_channel_ui.c b/firmware_p4/components/Applications/ui/screens/wifi/wifi_channel_ui.c new file mode 100644 index 000000000..5aeb3fcd7 --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/wifi/wifi_channel_ui.c @@ -0,0 +1,381 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#include "wifi_channel_ui.h" + +#include + +#include "esp_log.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include "sys_prio.h" + +#include "st7789.h" + +#include "menu_component_ui.h" +#include "ui_chrome.h" +#include "ui_manager.h" +#include "ui_theme.h" +#include "waves_ui.h" +#include "wifi_service.h" + +static const char *TAG = "WIFI_CHAN_UI"; + +#define MAX_ROWS 12 +#define CHAN_MAX 14 +#define TASK_STACK_SIZE 8192 +#define TASK_PRIORITY SYS_PRIO_SERVICE_LO + +#define COLOR_QUIET_HEX 0x00E676 +#define COLOR_BUSY_HEX 0xFFC107 +#define COLOR_CROWDED_HEX 0xF44336 +#define COLOR_DIM_HEX 0x8A8594 + +#define OCCUPANCY_FULL_APS 6 + +#define SCAN_WAVES_Y_OFS -6 +#define SCAN_CAPTION_Y_OFS 78 + +#define SPEC_PANEL_TOP (UI_CHROME_HEADER_H + 8) +#define SPEC_PANEL_MARGIN 8 +#define SPEC_PANEL_W (LCD_H_RES - SPEC_PANEL_MARGIN * 2) +#define SPEC_PANEL_H 210 +#define SPEC_PANEL_PAD 8 +#define SPEC_PANEL_RADIUS 10 +#define SPEC_PANEL_BG_HEX 0x0A0614 +#define SPEC_INNER_W (SPEC_PANEL_W - SPEC_PANEL_PAD * 2) +#define SPEC_INNER_H (SPEC_PANEL_H - SPEC_PANEL_PAD * 2) +#define SPEC_BASELINE_Y (SPEC_INNER_H - 22) +#define SPEC_HUMP_MAX_H (SPEC_BASELINE_Y - 6) +#define SPEC_CHAN_SPAN 12 +#define SPEC_HUMP_HALF_NUM 5 +#define SPEC_HUMP_HALF_DEN 24 +#define SPEC_HUMP_PTS 15 +#define SPEC_LINE_W 2 +#define SPEC_LABEL_W 30 +#define SPEC_LABEL_Y_OFS 4 +#define SPEC_GLOW_W 14 +#define SPEC_HINT_Y 266 + +#define REC_CANDIDATE_SPAN 2 + +typedef enum { SCAN_RUNNING, SCAN_DONE, SCAN_FAIL } scan_state_t; + +static const uint8_t REC_CANDIDATES[] = {1, 6, 11}; +#define REC_CANDIDATE_COUNT (sizeof(REC_CANDIDATES) / sizeof(REC_CANDIDATES[0])) + +static lv_obj_t *s_screen = NULL; +static menu_component_t s_menu; + +static scan_state_t s_scan_state = SCAN_RUNNING; +static bool s_scanning = false; +static int s_row_count = 0; +static uint8_t s_row_channel[MAX_ROWS]; +static uint8_t s_row_ap_count[MAX_ROWS]; +static int8_t s_row_rssi[MAX_ROWS]; +static uint32_t s_row_color[MAX_ROWS]; + +static lv_point_precise_t s_hump_pts[MAX_ROWS][SPEC_HUMP_PTS]; + +static void wifi_channel_input(const input_event_t *ev, void *ctx); + +static uint32_t color_for_count(int count) { + if (count <= 2) + return COLOR_QUIET_HEX; + if (count <= 4) + return COLOR_BUSY_HEX; + return COLOR_CROWDED_HEX; +} + +static int channel_center_x(int channel) { + int cx = (channel - 1) * SPEC_INNER_W / SPEC_CHAN_SPAN; + if (cx < 0) + cx = 0; + if (cx > SPEC_INNER_W) + cx = SPEC_INNER_W; + return cx; +} + +static int crowded_row(void) { + int best = 0; + for (int i = 1; i < s_row_count; i++) { + if (s_row_ap_count[i] > s_row_ap_count[best]) + best = i; + } + return best; +} + +static int recommended_channel(void) { + int best_ch = REC_CANDIDATES[0]; + int best_load = -1; + for (size_t c = 0; c < REC_CANDIDATE_COUNT; c++) { + int cand = REC_CANDIDATES[c]; + int load = 0; + for (int i = 0; i < s_row_count; i++) { + int d = (int)s_row_channel[i] - cand; + if (d < 0) + d = -d; + if (d <= REC_CANDIDATE_SPAN) + load += s_row_ap_count[i]; + } + if (best_load < 0 || load < best_load) { + best_load = load; + best_ch = cand; + } + } + return best_ch; +} + +static void build_hump(lv_obj_t *panel, int row) { + int cx = channel_center_x(s_row_channel[row]); + int hw = SPEC_INNER_W * SPEC_HUMP_HALF_NUM / SPEC_HUMP_HALF_DEN; + int level = s_row_ap_count[row]; + if (level > OCCUPANCY_FULL_APS) + level = OCCUPANCY_FULL_APS; + int h = SPEC_HUMP_MAX_H * level / OCCUPANCY_FULL_APS; + + for (int k = 0; k < SPEC_HUMP_PTS; k++) { + int t = k * 1000 / (SPEC_HUMP_PTS - 1); + int px = cx - hw + (2 * hw * t) / 1000; + int u = 2 * t - 1000; + int f = 1000 - (u * u) / 1000; + if (f < 0) + f = 0; + if (px < 0) + px = 0; + if (px > SPEC_INNER_W) + px = SPEC_INNER_W; + s_hump_pts[row][k].x = px; + s_hump_pts[row][k].y = SPEC_BASELINE_Y - h * f / 1000; + } + + lv_obj_t *line = lv_line_create(panel); + lv_obj_set_size(line, SPEC_INNER_W, SPEC_INNER_H); + lv_obj_align(line, LV_ALIGN_TOP_LEFT, 0, 0); + lv_obj_set_style_line_width(line, SPEC_LINE_W, 0); + lv_obj_set_style_line_color(line, lv_color_hex(s_row_color[row]), 0); + lv_obj_set_style_line_rounded(line, true, 0); + lv_line_set_points(line, s_hump_pts[row], SPEC_HUMP_PTS); + + lv_obj_t *num = lv_label_create(panel); + lv_label_set_text_fmt(num, "%d", s_row_channel[row]); + lv_obj_set_style_text_color(num, lv_color_hex(s_row_color[row]), 0); + lv_obj_set_style_text_font(num, &lv_font_montserrat_12, 0); + lv_obj_set_width(num, SPEC_LABEL_W); + lv_obj_set_style_text_align(num, LV_TEXT_ALIGN_CENTER, 0); + int lx = cx - SPEC_LABEL_W / 2; + if (lx < 0) + lx = 0; + if (lx > SPEC_INNER_W - SPEC_LABEL_W) + lx = SPEC_INNER_W - SPEC_LABEL_W; + lv_obj_align(num, LV_ALIGN_TOP_LEFT, lx, SPEC_BASELINE_Y + SPEC_LABEL_Y_OFS); +} + +static void build_spectrum(void) { + ui_chrome_header(s_screen, "Channels", "/assets/icons/graphic_eq.bin"); + + lv_obj_t *panel = lv_obj_create(s_screen); + lv_obj_set_size(panel, SPEC_PANEL_W, SPEC_PANEL_H); + lv_obj_align(panel, LV_ALIGN_TOP_MID, 0, SPEC_PANEL_TOP); + lv_obj_remove_flag(panel, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_radius(panel, SPEC_PANEL_RADIUS, 0); + lv_obj_set_style_bg_color(panel, lv_color_hex(SPEC_PANEL_BG_HEX), 0); + lv_obj_set_style_bg_opa(panel, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(panel, 1, 0); + lv_obj_set_style_border_color(panel, current_theme.border_accent, 0); + lv_obj_set_style_pad_all(panel, SPEC_PANEL_PAD, 0); + lv_obj_set_style_shadow_width(panel, SPEC_GLOW_W, 0); + lv_obj_set_style_shadow_color(panel, current_theme.border_accent, 0); + lv_obj_set_style_shadow_opa(panel, LV_OPA_30, 0); + + lv_obj_t *base = lv_obj_create(panel); + lv_obj_remove_flag(base, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(base, SPEC_INNER_W, 2); + lv_obj_align(base, LV_ALIGN_TOP_LEFT, 0, SPEC_BASELINE_Y); + lv_obj_set_style_border_width(base, 0, 0); + lv_obj_set_style_radius(base, 0, 0); + lv_obj_set_style_bg_color(base, current_theme.border_accent, 0); + lv_obj_set_style_bg_opa(base, LV_OPA_40, 0); + + for (int i = 0; i < s_row_count; i++) + build_hump(panel, i); + + int cr = crowded_row(); + int rec = recommended_channel(); + lv_obj_t *hint = lv_label_create(s_screen); + lv_label_set_text_fmt( + hint, "Ch %d crowded %d AP pick Ch %d", s_row_channel[cr], s_row_ap_count[cr], rec); + lv_obj_set_style_text_color(hint, lv_color_hex(COLOR_DIM_HEX), 0); + lv_obj_set_style_text_font(hint, &lv_font_montserrat_12, 0); + lv_obj_set_style_text_align(hint, LV_TEXT_ALIGN_CENTER, 0); + lv_obj_align(hint, LV_ALIGN_TOP_MID, 0, SPEC_HINT_Y); + + ui_chrome_footer(s_screen, LV_SYMBOL_LEFT " Back " LV_SYMBOL_OK " Rescan"); +} + +static void build_screen(void) { + if (s_screen != NULL) { + lv_obj_del(s_screen); + s_screen = NULL; + } + + s_screen = lv_obj_create(NULL); + lv_obj_set_style_bg_color(s_screen, current_theme.screen_base, 0); + lv_obj_set_style_bg_opa(s_screen, LV_OPA_COVER, 0); + lv_obj_remove_flag(s_screen, LV_OBJ_FLAG_SCROLLABLE); + + s_menu = (menu_component_t){0}; + + if (s_scan_state == SCAN_RUNNING) { + ui_chrome_header(s_screen, "Channels", "/assets/icons/graphic_eq.bin"); + waves_create(s_screen, + LV_ALIGN_CENTER, + 0, + SCAN_WAVES_Y_OFS, + LV_SYMBOL_WIFI, + "/assets/icons/wifi_find.bin"); + lv_obj_t *caption = lv_label_create(s_screen); + lv_label_set_text(caption, "Scanning..."); + lv_obj_set_style_text_color(caption, current_theme.text_main, 0); + lv_obj_set_style_text_font(caption, &lv_font_montserrat_14, 0); + lv_obj_set_style_text_align(caption, LV_TEXT_ALIGN_CENTER, 0); + lv_obj_align(caption, LV_ALIGN_CENTER, 0, SCAN_CAPTION_Y_OFS); + ui_chrome_footer(s_screen, LV_SYMBOL_LEFT " Back"); + } else if (s_scan_state == SCAN_FAIL) { + s_menu = menu_component_create(s_screen, "Channels", "/assets/icons/graphic_eq.bin"); + menu_component_add_item(&s_menu, "/assets/icons/wifi_find.bin", "Scan failed (C5?)"); + } else if (s_row_count == 0) { + s_menu = menu_component_create(s_screen, "Channels", "/assets/icons/graphic_eq.bin"); + menu_component_add_item(&s_menu, "/assets/icons/wifi_find.bin", "No networks found"); + } else { + build_spectrum(); + } + + ui_input_set_screen_handler(wifi_channel_input, NULL); + + ui_screen_load_owned(&s_screen, s_screen); +} + +static void scan_done_cb(void *unused) { + (void)unused; + if (ui_current_screen() != SCREEN_WIFI_CHANNELS) + return; + build_screen(); + ESP_LOGI( + TAG, "channel analysis: state=%d, %d occupied channel(s)", (int)s_scan_state, s_row_count); +} + +static void wifi_channel_task(void *arg) { + (void)arg; + + wifi_service_start(); + esp_err_t err = wifi_service_scan(); + if (err != ESP_OK) { + s_row_count = 0; + s_scan_state = SCAN_FAIL; + s_scanning = false; + lv_async_call(scan_done_cb, NULL); + vTaskDelete(NULL); + return; + } + + uint8_t chan_ap_count[CHAN_MAX + 1] = {0}; + int8_t chan_best_rssi[CHAN_MAX + 1]; + for (int c = 0; c <= CHAN_MAX; c++) + chan_best_rssi[c] = -128; + + uint16_t count = wifi_service_get_ap_count(); + for (uint16_t i = 0; i < count; i++) { + wifi_ap_record_t *rec = wifi_service_get_ap_record(i); + if (rec == NULL) + continue; + uint8_t ch = rec->primary; + if (ch < 1 || ch > CHAN_MAX) + continue; + if (chan_ap_count[ch] < 255) + chan_ap_count[ch]++; + if (rec->rssi > chan_best_rssi[ch]) + chan_best_rssi[ch] = rec->rssi; + } + + int rows = 0; + for (int ch = 1; ch <= CHAN_MAX && rows < MAX_ROWS; ch++) { + if (chan_ap_count[ch] == 0) + continue; + s_row_channel[rows] = (uint8_t)ch; + s_row_ap_count[rows] = chan_ap_count[ch]; + s_row_rssi[rows] = chan_best_rssi[ch]; + s_row_color[rows] = color_for_count(chan_ap_count[ch]); + rows++; + } + + s_row_count = rows; + s_scan_state = SCAN_DONE; + s_scanning = false; + lv_async_call(scan_done_cb, NULL); + vTaskDelete(NULL); +} + +static void wifi_channel_input(const input_event_t *ev, void *ctx) { + (void)ctx; + const bool press = (ev->action == INPUT_ACTION_PRESS); + const bool nav = press || (ev->action == INPUT_ACTION_REPEAT); + + switch (ev->button) { + case INPUT_BTN_DOWN: + if (nav) + menu_component_next(&s_menu); + break; + case INPUT_BTN_UP: + if (nav) + menu_component_prev(&s_menu); + break; + case INPUT_BTN_BACK: + case INPUT_BTN_LEFT: + if (press) + ui_switch_screen(SCREEN_WIFI_MENU); + break; + case INPUT_BTN_OK: + case INPUT_BTN_RIGHT: + if (press && !s_scanning) + ui_wifi_channel_open(); + break; + default: + break; + } +} + +void ui_wifi_channel_open(void) { + s_scan_state = SCAN_RUNNING; + s_row_count = 0; + build_screen(); + + if (!s_scanning) { + s_scanning = true; + if (xTaskCreatePinnedToCore(wifi_channel_task, + "wifi_chan", + TASK_STACK_SIZE, + NULL, + TASK_PRIORITY, + NULL, + SYS_CORE_RADIO) != pdPASS) { + s_scanning = false; + s_scan_state = SCAN_FAIL; + build_screen(); + } + } + + ESP_LOGI(TAG, "Channel analysis screen opened (live scan)"); +} diff --git a/firmware_p4/components/Applications/ui/screens/wifi/wifi_client_ui.c b/firmware_p4/components/Applications/ui/screens/wifi/wifi_client_ui.c new file mode 100644 index 000000000..d03151d31 --- /dev/null +++ b/firmware_p4/components/Applications/ui/screens/wifi/wifi_client_ui.c @@ -0,0 +1,396 @@ +// Copyright (c) 2025 HIGH CODE LLC +// +// TentacleOS is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TentacleOS is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TentacleOS. If not, see . + +#include "wifi_client_ui.h" + +#include +#include + +#include "esp_log.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include "sys_prio.h" + +#include "st7789.h" + +#include "client_scanner.h" +#include "menu_component_ui.h" +#include "msgbox_ui.h" +#include "ui_chrome.h" +#include "ui_manager.h" +#include "ui_theme.h" +#include "waves_ui.h" +#include "wifi_service.h" + +static const char *TAG = "WIFI_CLI_UI"; + +#define CLI_HEADER_ICON "/assets/icons/devices.bin" +#define CLI_STATUS_ICON "/assets/icons/wifi_find.bin" +#define CLI_MAX 12 +#define TASK_STACK_SIZE 8192 +#define TASK_PRIORITY SYS_PRIO_SERVICE_LO + +#define COLOR_STRONG_HEX 0x00E676 +#define COLOR_WEAK_HEX 0xF5B13D +#define COLOR_DIM_HEX 0x8A8594 +#define RSSI_STRONG_DBM -60 + +#define SCAN_WAVES_Y_OFS -6 +#define SCAN_CAPTION_Y_OFS 78 + +#define MAP_BODY_H (LCD_V_RES - UI_CHROME_HEADER_H - UI_CHROME_FOOTER_H) +#define MAP_PAD 10 +#define MAP_AP_X 8 +#define MAP_AP_W 96 +#define MAP_AP_H 44 +#define MAP_CLI_X 140 +#define MAP_CLI_W 92 +#define MAP_CLI_H 40 +#define MAP_LINK_START (MAP_AP_X + MAP_AP_W) +#define MAP_LINK_END MAP_CLI_X +#define MAP_CARD_RADIUS 8 +#define MAP_GLOW_W 12 +#define MAP_LINK_W_SEL 3 +#define MAP_LINK_W 1 + +typedef enum { SCAN_RUNNING, SCAN_DONE, SCAN_FAIL } scan_state_t; + +static lv_obj_t *s_screen = NULL; +static menu_component_t s_menu; + +static scan_state_t s_scan_state = SCAN_RUNNING; +static bool s_scanning = false; + +static int s_cli_count = 0; +static uint8_t s_cli_addr[CLI_MAX][6]; +static uint8_t s_cli_bssid[CLI_MAX][6]; +static uint8_t s_cli_channel[CLI_MAX]; +static int8_t s_cli_rssi[CLI_MAX]; +static int s_cli_ap[CLI_MAX]; + +static int s_ap_count = 0; +static uint8_t s_ap_channel[CLI_MAX]; +static int s_ap_sta[CLI_MAX]; + +static int s_sel = 0; +static lv_obj_t *s_cli_card[CLI_MAX]; +static lv_obj_t *s_ap_card[CLI_MAX]; +static lv_obj_t *s_link[CLI_MAX]; +static lv_point_precise_t s_link_pts[CLI_MAX][2]; + +static void wifi_client_input(const input_event_t *ev, void *ctx); + +static uint32_t link_color(int8_t rssi) { + return rssi >= RSSI_STRONG_DBM ? COLOR_STRONG_HEX : COLOR_WEAK_HEX; +} + +static void derive_aps(void) { + s_ap_count = 0; + for (int i = 0; i < s_cli_count; i++) { + int found = -1; + for (int a = 0; a < s_ap_count; a++) { + if (s_ap_channel[a] == s_cli_channel[i]) { + found = a; + break; + } + } + if (found < 0) { + found = s_ap_count; + s_ap_channel[found] = s_cli_channel[i]; + s_ap_sta[found] = 0; + s_ap_count++; + } + s_cli_ap[i] = found; + s_ap_sta[found]++; + } +} + +static lv_obj_t *make_card(lv_obj_t *parent, int x, int y, int w, int h) { + lv_obj_t *card = lv_obj_create(parent); + lv_obj_remove_flag(card, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(card, w, h); + lv_obj_set_pos(card, x, y); + lv_obj_set_style_radius(card, MAP_CARD_RADIUS, 0); + lv_obj_set_style_bg_color(card, current_theme.bg_secondary, 0); + lv_obj_set_style_bg_opa(card, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(card, 1, 0); + lv_obj_set_style_border_color(card, current_theme.border_inactive, 0); + lv_obj_set_style_shadow_color(card, current_theme.border_accent, 0); + lv_obj_set_style_pad_all(card, 0, 0); + return card; +} + +static void +add_card_text(lv_obj_t *card, const char *title, lv_color_t title_color, const char *sub) { + lv_obj_t *t = lv_label_create(card); + lv_label_set_text(t, title); + lv_obj_set_style_text_color(t, title_color, 0); + lv_obj_set_style_text_font(t, &lv_font_montserrat_14, 0); + lv_obj_align(t, LV_ALIGN_TOP_MID, 0, 5); + + lv_obj_t *s = lv_label_create(card); + lv_label_set_text(s, sub); + lv_obj_set_style_text_color(s, lv_color_hex(COLOR_DIM_HEX), 0); + lv_obj_set_style_text_font(s, &lv_font_montserrat_12, 0); + lv_obj_align(s, LV_ALIGN_BOTTOM_MID, 0, -4); +} + +static int ap_center_y(int idx) { + int usable = MAP_BODY_H - MAP_PAD * 2; + return MAP_PAD + (idx * 2 + 1) * usable / (s_ap_count * 2); +} + +static int cli_center_y(int idx) { + int usable = MAP_BODY_H - MAP_PAD * 2; + return MAP_PAD + (idx * 2 + 1) * usable / (s_cli_count * 2); +} + +static void apply_selection(void) { + int sel_ap = s_cli_ap[s_sel]; + for (int a = 0; a < s_ap_count; a++) { + bool on = (a == sel_ap); + lv_obj_set_style_border_color( + s_ap_card[a], on ? current_theme.border_accent : current_theme.border_inactive, 0); + lv_obj_set_style_shadow_width(s_ap_card[a], on ? MAP_GLOW_W : 0, 0); + lv_obj_set_style_shadow_opa(s_ap_card[a], on ? LV_OPA_50 : LV_OPA_TRANSP, 0); + } + for (int i = 0; i < s_cli_count; i++) { + bool on = (i == s_sel); + lv_obj_set_style_border_color( + s_cli_card[i], on ? current_theme.border_accent : current_theme.border_inactive, 0); + lv_obj_set_style_shadow_width(s_cli_card[i], on ? MAP_GLOW_W : 0, 0); + lv_obj_set_style_shadow_opa(s_cli_card[i], on ? LV_OPA_50 : LV_OPA_TRANSP, 0); + lv_obj_set_style_line_width(s_link[i], on ? MAP_LINK_W_SEL : MAP_LINK_W, 0); + lv_obj_set_style_line_color( + s_link[i], on ? current_theme.border_accent : lv_color_hex(link_color(s_cli_rssi[i])), 0); + } +} + +static void build_map(void) { + ui_chrome_header(s_screen, "Clients", CLI_HEADER_ICON); + + lv_obj_t *body = lv_obj_create(s_screen); + lv_obj_set_size(body, LCD_H_RES, MAP_BODY_H); + lv_obj_align(body, LV_ALIGN_TOP_MID, 0, UI_CHROME_HEADER_H); + lv_obj_remove_flag(body, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_bg_opa(body, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(body, 0, 0); + lv_obj_set_style_pad_all(body, 0, 0); + + for (int i = 0; i < s_cli_count; i++) { + int ay = ap_center_y(s_cli_ap[i]); + int cy = cli_center_y(i); + s_link_pts[i][0].x = MAP_LINK_START; + s_link_pts[i][0].y = ay; + s_link_pts[i][1].x = MAP_LINK_END; + s_link_pts[i][1].y = cy; + lv_obj_t *line = lv_line_create(body); + lv_obj_align(line, LV_ALIGN_TOP_LEFT, 0, 0); + lv_obj_set_style_line_rounded(line, true, 0); + lv_line_set_points(line, s_link_pts[i], 2); + s_link[i] = line; + } + + for (int a = 0; a < s_ap_count; a++) { + int y = ap_center_y(a) - MAP_AP_H / 2; + lv_obj_t *card = make_card(body, MAP_AP_X, y, MAP_AP_W, MAP_AP_H); + char title[12]; + char sub[16]; + snprintf(title, sizeof(title), "CH %d", s_ap_channel[a]); + snprintf(sub, sizeof(sub), "%d sta", s_ap_sta[a]); + add_card_text(card, title, current_theme.text_main, sub); + s_ap_card[a] = card; + } + + for (int i = 0; i < s_cli_count; i++) { + int y = cli_center_y(i) - MAP_CLI_H / 2; + lv_obj_t *card = make_card(body, MAP_CLI_X, y, MAP_CLI_W, MAP_CLI_H); + char title[12]; + char sub[12]; + snprintf(title, sizeof(title), "STA %02X", s_cli_addr[i][5]); + snprintf(sub, sizeof(sub), "%d dBm", s_cli_rssi[i]); + add_card_text(card, title, lv_color_hex(link_color(s_cli_rssi[i])), sub); + s_cli_card[i] = card; + } + + apply_selection(); + ui_chrome_footer(s_screen, + LV_SYMBOL_UP LV_SYMBOL_DOWN " Sel " LV_SYMBOL_OK " Detail " LV_SYMBOL_LEFT + " Back"); +} + +static void open_detail(void) { + char msg[96]; + snprintf(msg, + sizeof(msg), + "STA %02X:%02X:%02X:%02X:%02X:%02X\nAP %02X:%02X:%02X:%02X:%02X:%02X\nCH %d %d dBm", + s_cli_addr[s_sel][0], + s_cli_addr[s_sel][1], + s_cli_addr[s_sel][2], + s_cli_addr[s_sel][3], + s_cli_addr[s_sel][4], + s_cli_addr[s_sel][5], + s_cli_bssid[s_sel][0], + s_cli_bssid[s_sel][1], + s_cli_bssid[s_sel][2], + s_cli_bssid[s_sel][3], + s_cli_bssid[s_sel][4], + s_cli_bssid[s_sel][5], + s_cli_channel[s_sel], + s_cli_rssi[s_sel]); + msgbox_open_info( + "/assets/icons/smartphone.bin", "Client", msg, lv_color_hex(link_color(s_cli_rssi[s_sel]))); +} + +static void build_screen(void) { + if (s_screen != NULL) { + lv_obj_del(s_screen); + s_screen = NULL; + } + + s_screen = lv_obj_create(NULL); + lv_obj_set_style_bg_color(s_screen, current_theme.screen_base, 0); + lv_obj_set_style_bg_opa(s_screen, LV_OPA_COVER, 0); + lv_obj_remove_flag(s_screen, LV_OBJ_FLAG_SCROLLABLE); + + s_menu = (menu_component_t){0}; + + if (s_scan_state == SCAN_RUNNING) { + ui_chrome_header(s_screen, "Clients", CLI_HEADER_ICON); + waves_create(s_screen, LV_ALIGN_CENTER, 0, SCAN_WAVES_Y_OFS, LV_SYMBOL_WIFI, CLI_STATUS_ICON); + lv_obj_t *caption = lv_label_create(s_screen); + lv_label_set_text(caption, "Scanning..."); + lv_obj_set_style_text_color(caption, current_theme.text_main, 0); + lv_obj_set_style_text_font(caption, &lv_font_montserrat_14, 0); + lv_obj_set_style_text_align(caption, LV_TEXT_ALIGN_CENTER, 0); + lv_obj_align(caption, LV_ALIGN_CENTER, 0, SCAN_CAPTION_Y_OFS); + ui_chrome_footer(s_screen, LV_SYMBOL_LEFT " Back"); + } else if (s_scan_state == SCAN_FAIL) { + s_menu = menu_component_create(s_screen, "Clients", CLI_HEADER_ICON); + menu_component_add_item(&s_menu, CLI_STATUS_ICON, "Scan failed (C5?)"); + } else if (s_cli_count == 0) { + s_menu = menu_component_create(s_screen, "Clients", CLI_HEADER_ICON); + menu_component_add_item(&s_menu, CLI_STATUS_ICON, "No clients found"); + } else { + build_map(); + } + + ui_input_set_screen_handler(wifi_client_input, NULL); + + ui_screen_load_owned(&s_screen, s_screen); +} + +static void scan_done_cb(void *unused) { + (void)unused; + if (ui_current_screen() != SCREEN_WIFI_CLIENTS) + return; + build_screen(); + ESP_LOGI(TAG, "client scan finished: state=%d, %d client(s)", (int)s_scan_state, s_cli_count); +} + +static void wifi_client_task(void *arg) { + (void)arg; + int count = 0; + + wifi_service_start(); + if (client_scanner_start()) { + uint16_t found = 0; + client_scanner_record_t *recs = client_scanner_get_results(&found); + if (recs != NULL) { + for (uint16_t i = 0; i < found && count < CLI_MAX; i++) { + memcpy(s_cli_addr[count], recs[i].client_mac, sizeof(s_cli_addr[count])); + memcpy(s_cli_bssid[count], recs[i].bssid, sizeof(s_cli_bssid[count])); + s_cli_channel[count] = recs[i].channel; + s_cli_rssi[count] = recs[i].rssi; + count++; + } + } + client_scanner_free_results(); + } + + s_cli_count = count; + derive_aps(); + s_scan_state = SCAN_DONE; + s_scanning = false; + lv_async_call(scan_done_cb, NULL); + vTaskDelete(NULL); +} + +static void wifi_client_input(const input_event_t *ev, void *ctx) { + (void)ctx; + const bool press = (ev->action == INPUT_ACTION_PRESS); + const bool nav = press || (ev->action == INPUT_ACTION_REPEAT); + const bool map = (s_scan_state == SCAN_DONE && s_cli_count > 0); + + switch (ev->button) { + case INPUT_BTN_DOWN: + if (nav) { + if (map) { + s_sel = (s_sel + 1) % s_cli_count; + apply_selection(); + } else { + menu_component_next(&s_menu); + } + } + break; + case INPUT_BTN_UP: + if (nav) { + if (map) { + s_sel = (s_sel - 1 + s_cli_count) % s_cli_count; + apply_selection(); + } else { + menu_component_prev(&s_menu); + } + } + break; + case INPUT_BTN_BACK: + case INPUT_BTN_LEFT: + if (press) + ui_switch_screen(SCREEN_WIFI_MENU); + break; + case INPUT_BTN_OK: + case INPUT_BTN_RIGHT: + if (press && map) + open_detail(); + break; + default: + break; + } +} + +void ui_wifi_client_open(void) { + s_scan_state = SCAN_RUNNING; + s_cli_count = 0; + s_ap_count = 0; + s_sel = 0; + build_screen(); + + if (!s_scanning) { + s_scanning = true; + if (xTaskCreatePinnedToCore(wifi_client_task, + "wifi_cli", + TASK_STACK_SIZE, + NULL, + TASK_PRIORITY, + NULL, + SYS_CORE_RADIO) != pdPASS) { + s_scanning = false; + s_scan_state = SCAN_FAIL; + build_screen(); + } + } + + ESP_LOGI(TAG, "Client scan screen opened"); +} diff --git a/firmware_p4/components/Applications/ui/screens/wifi/wifi_deauth_attack_ui.c b/firmware_p4/components/Applications/ui/screens/wifi/wifi_deauth_attack_ui.c deleted file mode 100644 index e7c5487c4..000000000 --- a/firmware_p4/components/Applications/ui/screens/wifi/wifi_deauth_attack_ui.c +++ /dev/null @@ -1,567 +0,0 @@ -// Copyright (c) 2025 HIGH CODE LLC -// -// TentacleOS is free software: you can redistribute it and/or modify -// it under the terms of the GNU General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// TentacleOS is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU General Public License for more details. -// -// You should have received a copy of the GNU General Public License -// along with TentacleOS. If not, see . - -#include "wifi_deauth_attack_ui.h" - -#include - -#include "esp_log.h" -#include "lvgl.h" - -#include "footer_ui.h" -#include "header_ui.h" -#include "lv_port_indev.h" -#include "msgbox_ui.h" -#include "target_scanner.h" -#include "ui_manager.h" -#include "ui_theme.h" -#include "wifi_deauther.h" -#include "wifi_service.h" - -static const char *TAG = "UI_DEAUTH_ATTACK"; - -/* ---- Layout constants ---- */ -#define LABEL_TARGET_Y 30 -#define LABEL_MODE_Y 55 -#define LABEL_CLIENT_Y 80 -#define BTN_ATTACK_W 170 -#define BTN_ATTACK_H 45 -#define BTN_ATTACK_Y 10 -#define LABEL_PACKETS_Y (-35) -#define LIST_W 230 -#define LIST_H 160 -#define LIST_Y 10 -#define ITEM_H 40 -#define ITEM_MARGIN_LEFT 8 - -/* ---- Style constants ---- */ -#define STYLE_BORDER_W 2 -#define STYLE_BORDER_W_ITEM 1 -#define STYLE_PAD 4 - -/* ---- Timer periods ---- */ -#define ATTACK_TICK_MS 200 -#define CLIENT_SCAN_MS 500 -#define PACKET_INCREMENT 10 - -typedef enum { - DEAUTH_VIEW_APS = 0, - DEAUTH_VIEW_ATTACK = 1, - DEAUTH_VIEW_CLIENTS = 2, -} deauth_view_t; - -static lv_obj_t *s_screen = NULL; -static lv_obj_t *s_list_cont = NULL; -static lv_obj_t *s_loading_label = NULL; -static lv_obj_t *s_lbl_target = NULL; -static lv_obj_t *s_lbl_mode = NULL; -static lv_obj_t *s_lbl_client = NULL; -static lv_obj_t *s_btn_attack = NULL; -static lv_obj_t *s_lbl_packets = NULL; -static lv_style_t s_style_menu; -static lv_style_t s_style_item; -static bool s_is_styles_init = false; - -static deauth_view_t s_current_view = DEAUTH_VIEW_APS; -static wifi_ap_record_t s_selected_ap; -static bool s_is_broadcast_mode = true; -static bool s_is_attacking = false; -static uint32_t s_packet_count = 0; -static lv_timer_t *s_attack_timer = NULL; -static lv_timer_t *s_client_timer = NULL; -static bool s_has_client = false; -static uint8_t s_selected_client[6]; -static uint16_t s_last_client_count = 0; - -extern lv_group_t *main_group; - -static void list_event_cb(lv_event_t *e); -static void show_client_view(void); - -static void init_styles(void) { - if (s_is_styles_init) - return; - - lv_style_init(&s_style_menu); - lv_style_set_bg_color(&s_style_menu, current_theme.screen_base); - lv_style_set_bg_opa(&s_style_menu, LV_OPA_COVER); - lv_style_set_border_width(&s_style_menu, STYLE_BORDER_W); - lv_style_set_border_color(&s_style_menu, current_theme.border_interface); - lv_style_set_radius(&s_style_menu, 0); - lv_style_set_pad_all(&s_style_menu, STYLE_PAD); - - lv_style_init(&s_style_item); - lv_style_set_bg_color(&s_style_item, current_theme.bg_item_bot); - lv_style_set_bg_grad_color(&s_style_item, current_theme.bg_item_top); - lv_style_set_bg_grad_dir(&s_style_item, LV_GRAD_DIR_VER); - lv_style_set_border_width(&s_style_item, STYLE_BORDER_W_ITEM); - lv_style_set_border_color(&s_style_item, current_theme.border_inactive); - lv_style_set_radius(&s_style_item, 0); - - s_is_styles_init = true; -} - -static void clear_list(void) { - if (s_list_cont == NULL) - return; - uint32_t child_count = lv_obj_get_child_count(s_list_cont); - for (uint32_t i = 0; i < child_count; i++) { - lv_obj_del(lv_obj_get_child(s_list_cont, 0)); - } - if (main_group != NULL) - lv_group_remove_all_objs(main_group); -} - -static void set_loading(const char *text) { - if (s_loading_label == NULL) { - s_loading_label = lv_label_create(s_screen); - lv_obj_set_style_text_color(s_loading_label, current_theme.text_main, 0); - lv_obj_center(s_loading_label); - } - lv_label_set_text(s_loading_label, text); -} - -static void clear_loading(void) { - if (s_loading_label != NULL) { - lv_obj_del(s_loading_label); - s_loading_label = NULL; - } -} - -static void item_focus_cb(lv_event_t *e) { - lv_event_code_t code = lv_event_get_code(e); - lv_obj_t *item = lv_event_get_target(e); - if (code == LV_EVENT_FOCUSED) { - lv_obj_set_style_border_color(item, ui_theme_get_accent(), 0); - lv_obj_set_style_border_width(item, STYLE_BORDER_W, 0); - lv_obj_scroll_to_view(item, LV_ANIM_ON); - } else if (code == LV_EVENT_DEFOCUSED) { - lv_obj_set_style_border_color(item, current_theme.border_inactive, 0); - lv_obj_set_style_border_width(item, STYLE_BORDER_W_ITEM, 0); - } else if (code == LV_EVENT_KEY) { - list_event_cb(e); - } -} - -static void update_attack_labels(void) { - if (s_lbl_target != NULL) { - lv_label_set_text_fmt( - s_lbl_target, "Target: %s CH:%d", s_selected_ap.ssid, s_selected_ap.primary); - } - if (s_lbl_mode != NULL) { - lv_label_set_text_fmt(s_lbl_mode, "Mode: %s", s_is_broadcast_mode ? "Broadcast" : "Targeted"); - } - if (s_lbl_client != NULL) { - if (!s_is_broadcast_mode && s_has_client) { - lv_label_set_text_fmt(s_lbl_client, - "Client: %02X:%02X:%02X:%02X:%02X:%02X", - s_selected_client[0], - s_selected_client[1], - s_selected_client[2], - s_selected_client[3], - s_selected_client[4], - s_selected_client[5]); - } else if (!s_is_broadcast_mode) { - lv_label_set_text(s_lbl_client, "Client: