diff options
29 files changed, 2930 insertions, 167 deletions
diff --git a/DISPLAY_FIX_PLAN.md b/DISPLAY_FIX_PLAN.md new file mode 100644 index 0000000..7fa4882 --- /dev/null +++ b/DISPLAY_FIX_PLAN.md | |||
| @@ -0,0 +1,174 @@ | |||
| 1 | # Display Fix Plan — AXS15231B QSPI Driver | ||
| 2 | |||
| 3 | ## Board Info | ||
| 4 | |||
| 5 | - **Board:** Guition JC3248W535C_I_Y (Board C, `/dev/ttyACM0`) | ||
| 6 | - **Display IC:** AXS15231B (QSPI, 4 data lines) | ||
| 7 | - **Resolution:** 320x480 portrait (native), 480x320 landscape (via rotation) | ||
| 8 | - **Pins:** CS=45, CLK=47, D0=21, D1=48, D2=40, D3=39, BL=1 | ||
| 9 | |||
| 10 | ## QSPI Protocol (from ArduinoGFX source) | ||
| 11 | |||
| 12 | Register writes and pixel data use different QSPI framing: | ||
| 13 | |||
| 14 | | Operation | cmd | addr | flags | Data format | | ||
| 15 | |-----------|-----|------|-------|-------------| | ||
| 16 | | Register write (C8D8) | `0x02` | `LCD_CMD << 8` | `MULTILINE_CMD \| MULTILINE_ADDR` | Big-endian | | ||
| 17 | | Register write (C8D16) | `0x02` | `LCD_CMD << 8` | `MULTILINE_CMD \| MULTILINE_ADDR` | Big-endian | | ||
| 18 | | Register write (C8D16D16) | `0x02` | `LCD_CMD << 8` | `MULTILINE_CMD \| MULTILINE_ADDR` | Big-endian | | ||
| 19 | | Pixel data (first chunk) | `0x32` | `0x003C00` | `SPI_TRANS_MODE_QIO` | Big-endian (byte-swapped) | | ||
| 20 | | Pixel data (continuation) | — | — | `MODE_QIO \| VAR_CMD \| VAR_ADDR \| VAR_DUMMY` | Big-endian (byte-swapped) | | ||
| 21 | |||
| 22 | - CS: Manual GPIO control (`spics_io_num = -1`) | ||
| 23 | - Bus: permanently acquired via `spi_device_acquire_bus()` | ||
| 24 | - SPI config: `command_bits=8, address_bits=24, dummy_bits=0, mode=0, HALFDUPLEX` | ||
| 25 | |||
| 26 | ## Root Cause: Byte-Order Mismatch | ||
| 27 | |||
| 28 | The ESP32-S3 is little-endian. The framebuffer stores RGB565 pixels as `[low_byte, high_byte]`. The AXS15231B expects pixels in big-endian order `[high_byte, low_byte]` over QSPI. | ||
| 29 | |||
| 30 | ArduinoGFX handles this by byte-swapping each pixel in `writePixels()` and `writeRepeat()` using the `MSB_16_SET(var, val)` macro: `var = (val >> 8) | (val << 8)`. | ||
| 31 | |||
| 32 | Our driver was sending raw little-endian pixels, causing the display to interpret the byte-swapped values as colors. Example: | ||
| 33 | |||
| 34 | | Intended color | RGB565 hex | Display sees (no swap) | Display sees (with swap) | | ||
| 35 | |---------------|-----------|----------------------|------------------------| | ||
| 36 | | Pink 0xF79F | `[9F, F7]` | R=19, G=63, B=23 (green) | R=30, G=60, B=31 (pink/white) | | ||
| 37 | | Red 0xF800 | `[00, F8]` | R=0, G=0, B=0 (black!) | R=31, G=0, B=0 (red) | | ||
| 38 | | Cyan 0x07FF | `[FF, 07]` | R=31, G=63, B=7 (yellow) | R=0, G=63, B=31 (cyan) | | ||
| 39 | |||
| 40 | ## Root Cause: PSRAM Cache Coherency | ||
| 41 | |||
| 42 | ArduinoGFX allocates its pixel transfer buffer in **internal DMA SRAM**: | ||
| 43 | ```cpp | ||
| 44 | _buffer = (uint8_t *)heap_caps_aligned_alloc(16, ESP32QSPI_MAX_PIXELS_AT_ONCE * 2, MALLOC_CAP_DMA); | ||
| 45 | ``` | ||
| 46 | |||
| 47 | Our framebuffer lives in PSRAM (8MB). When we modified the PSRAM framebuffer in-place (byte-swap), the CPU cache held the modified values but the SPI DMA controller read stale data from physical PSRAM. Result: black screen. | ||
| 48 | |||
| 49 | A separate allocation (even in PSRAM) works because it gets clean, freshly-written cache lines. | ||
| 50 | |||
| 51 | ## Reference Implementations Studied | ||
| 52 | |||
| 53 | | Repo | Chip | Bus | Notes | | ||
| 54 | |------|------|-----|-------| | ||
| 55 | | [me-processware/JC3248W535-Driver](https://github.com/me-processware/JC3248W535-Driver) | AXS15231B | Arduino_ESP32QSPI | Arduino_Canvas wrapper, same pins | | ||
| 56 | | [F1ATB/JC3248W535-Demo](https://github.com/F1ATB/JC3248W535-Demo) | AXS15231B | Arduino_ESP32QSPI | Minimal demo, rotation=1 landscape | | ||
| 57 | | [AudunKodehode/JC3248W535EN-Touch-LCD](https://github.com/AudunKodehode/JC3248W535EN-Touch-LCD) | AXS15231B | Arduino_ESP32QSPI | Full library, QR codes, JPEG, coordinate transforms | | ||
| 58 | | [ArduinoGFX Arduino_ESP32QSPI.cpp](https://github.com/moononournation/Arduino_GFX) | — | — | Reference QSPI protocol implementation | | ||
| 59 | |||
| 60 | All use identical pin assignments and bus configuration. | ||
| 61 | |||
| 62 | ## Checklist | ||
| 63 | |||
| 64 | ### Done | ||
| 65 | - [x] Created worktree on branch `feature/display-fix` | ||
| 66 | - [x] Tracked untracked display files into branch | ||
| 67 | - [x] Added Board C support to Makefile (`flash-c`, `lock-c`, etc.) | ||
| 68 | - [x] Diagnosed root cause: QSPI protocol, not standard SPI | ||
| 69 | - [x] Fetched and analyzed ArduinoGFX QSPI source code | ||
| 70 | - [x] Discovered correct QSPI framing: `cmd=0x02` for regs, `cmd=0x32/addr=0x003C00` for pixels | ||
| 71 | - [x] Rewrote driver with correct QSPI protocol | ||
| 72 | - [x] Build succeeds, flash succeeds | ||
| 73 | - [x] Display shows recognizable text ("TollGate", "starting") — protocol confirmed working | ||
| 74 | - [x] Identified byte-swap requirement (green text = wrong byte order) | ||
| 75 | - [x] Identified PSRAM cache coherency issue (in-place swap = black screen) | ||
| 76 | - [x] Studied 3 reference implementations + ArduinoGFX source | ||
| 77 | - [x] Text positions adjusted for 320x480 portrait centering | ||
| 78 | - [x] Internal DMA byte-swap buffer (MALLOC_CAP_DMA, 4KB chunks) | ||
| 79 | - [x] **CRITICAL FIX: Added RAMWR (0x2C) before pixel data** — fixed wrapping/double-vision | ||
| 80 | - [x] Display shows correct colors: cyan TollGate + yellow starting... centered on black | ||
| 81 | - [x] Reduced font scale to 2/1 for clean readability | ||
| 82 | - [x] Implemented full UI: BOOT, READY (QR cycling), PAYMENT, ERROR screens | ||
| 83 | - [x] WiFi events trigger display state transitions (READY ↔ ERROR) | ||
| 84 | - [x] Color-coded wallet balance (green/yellow/red) | ||
| 85 | - [x] **ALL SCREENS VERIFIED WORKING ON HARDWARE** | ||
| 86 | |||
| 87 | ### In Progress | ||
| 88 | - [ ] (nothing) | ||
| 89 | |||
| 90 | ### TODO | ||
| 91 | - [ ] Run `make test-unit` to check for regressions | ||
| 92 | - [ ] Commit, push, and prepare for merge to master | ||
| 93 | - [ ] Restore render-on-change logic (proven correct, black screen was from swap not logic) | ||
| 94 | - [ ] Use saturated colors: cyan `0x07FF`, yellow `0xFFE0`, white `0xFFFF` | ||
| 95 | - [ ] Build, flash, verify correct colors and stable text | ||
| 96 | - [ ] Verify QR code rendering in READY state | ||
| 97 | - [ ] Verify payment/error screen states | ||
| 98 | - [ ] Remove debug log from flush | ||
| 99 | - [ ] Run `make test-unit` to check for regressions | ||
| 100 | - [ ] Commit working display driver | ||
| 101 | - [ ] Push to remote | ||
| 102 | |||
| 103 | ## Implementation Plan | ||
| 104 | |||
| 105 | ### 1. Internal DMA swap buffer in `axs15231b.c` | ||
| 106 | |||
| 107 | At init, allocate a static buffer: | ||
| 108 | ```c | ||
| 109 | #define FLUSH_CHUNK_PIXELS 2048 // 4096 bytes, fits in internal DMA RAM | ||
| 110 | static uint8_t *s_swap_buf = NULL; | ||
| 111 | |||
| 112 | // In axs15231b_init(): | ||
| 113 | s_swap_buf = heap_caps_aligned_alloc(16, FLUSH_CHUNK_PIXELS * 2, MALLOC_CAP_DMA); | ||
| 114 | ``` | ||
| 115 | |||
| 116 | ### 2. Byte-swap flush loop | ||
| 117 | |||
| 118 | ```c | ||
| 119 | void axs15231b_flush(void) { | ||
| 120 | // ... CASET, RASET ... | ||
| 121 | |||
| 122 | int total_pixels = s_width * s_height; | ||
| 123 | int offset = 0; | ||
| 124 | bool first = true; | ||
| 125 | |||
| 126 | cs_low(); | ||
| 127 | while (offset < total_pixels) { | ||
| 128 | int chunk = min(FLUSH_CHUNK_PIXELS, total_pixels - offset); | ||
| 129 | |||
| 130 | // Byte-swap from PSRAM framebuffer into DMA buffer | ||
| 131 | uint8_t *src = (uint8_t *)(s_fb + offset); | ||
| 132 | for (int i = 0; i < chunk * 2; i += 2) { | ||
| 133 | s_swap_buf[i] = src[i + 1]; | ||
| 134 | s_swap_buf[i + 1] = src[i]; | ||
| 135 | } | ||
| 136 | |||
| 137 | // Send via QSPI | ||
| 138 | spi_transaction_ext_t t = {0}; | ||
| 139 | if (first) { | ||
| 140 | t.base.flags = SPI_TRANS_MODE_QIO; | ||
| 141 | t.base.cmd = 0x32; | ||
| 142 | t.base.addr = 0x003C00; | ||
| 143 | first = false; | ||
| 144 | } else { | ||
| 145 | t.base.flags = SPI_TRANS_MODE_QIO | SPI_TRANS_VARIABLE_CMD | | ||
| 146 | SPI_TRANS_VARIABLE_ADDR | SPI_TRANS_VARIABLE_DUMMY; | ||
| 147 | } | ||
| 148 | t.base.tx_buffer = s_swap_buf; | ||
| 149 | t.base.length = chunk * 16; | ||
| 150 | spi_device_polling_transmit(s_spi, (spi_transaction_t *)&t); | ||
| 151 | |||
| 152 | offset += chunk; | ||
| 153 | } | ||
| 154 | cs_high(); | ||
| 155 | } | ||
| 156 | ``` | ||
| 157 | |||
| 158 | ### 3. Render-on-change in `display.c` | ||
| 159 | |||
| 160 | Only re-render when: | ||
| 161 | - `s_force_render` is set (state change, init) | ||
| 162 | - QR mode cycles (every 5s in READY state) | ||
| 163 | |||
| 164 | This eliminates the 1Hz full-screen redraw that caused text to "move around." | ||
| 165 | |||
| 166 | ### 4. Color choices | ||
| 167 | |||
| 168 | | Element | Old color | New color | Reason | | ||
| 169 | |---------|-----------|-----------|--------| | ||
| 170 | | Boot title | `0xF79F` (near-white) | `0x07FF` (cyan) | High contrast on black | | ||
| 171 | | Boot subtitle | `0xB5B6` (gray) | `0xFFE0` (yellow) | Visible, warm accent | | ||
| 172 | | Ready label | `0xB5B6` | `0x07FF` | Consistent accent | | ||
| 173 | | Payment bg | `0x07E0` (green) | `0x07E0` | Keep — bright green is clear | | ||
| 174 | | Error bg | `0xF800` (red) | `0xF800` | Keep — bright red is clear | | ||
diff --git a/DISPLAY_UI_PLAN.md b/DISPLAY_UI_PLAN.md new file mode 100644 index 0000000..e78db44 --- /dev/null +++ b/DISPLAY_UI_PLAN.md | |||
| @@ -0,0 +1,165 @@ | |||
| 1 | # TollGate Display UI Design | ||
| 2 | |||
| 3 | ## Display Hardware | ||
| 4 | - **Panel:** 3.5" IPS, 320x480 portrait (AXS15231B QSPI) | ||
| 5 | - **Font:** 8x8 bitmap, scalable (1x=8px, 2x=16px, 3x=24px) | ||
| 6 | - **Capabilities:** Text rendering, QR codes, filled rectangles | ||
| 7 | - **No touch input** — display is output-only signage | ||
| 8 | |||
| 9 | ## Color Palette | ||
| 10 | |||
| 11 | | Color | RGB565 | Usage | | ||
| 12 | |-------|--------|-------| | ||
| 13 | | Black | `0x0000` | Background | | ||
| 14 | | White | `0xFFFF` | Primary text | | ||
| 15 | | Cyan | `0x07FF` | Titles, labels | | ||
| 16 | | Yellow | `0xFFE0` | Price, warnings | | ||
| 17 | | Green | `0x07E0` | Success, wallet OK | | ||
| 18 | | Orange | `0xFD20` | Accent (Bitcoin orange) | | ||
| 19 | | Red | `0xF800` | Errors, alerts | | ||
| 20 | | Dim gray | `0x8410` | Secondary info | | ||
| 21 | |||
| 22 | ## Screen States | ||
| 23 | |||
| 24 | ### 1. BOOT | ||
| 25 | Shown during startup until WiFi connects and services start. | ||
| 26 | |||
| 27 | ``` | ||
| 28 | ┌──────────────────────────┐ | ||
| 29 | │ TollGate │ cyan, scale 2 | ||
| 30 | │ connecting... │ yellow, scale 1 | ||
| 31 | │ WiFi: trying... │ dim, scale 1 | ||
| 32 | └──────────────────────────┘ | ||
| 33 | ``` | ||
| 34 | |||
| 35 | ### 2. READY — QR Cycling | ||
| 36 | Cycles every 5 seconds between WiFi QR and Portal QR. | ||
| 37 | |||
| 38 | **View A — WiFi QR:** | ||
| 39 | ``` | ||
| 40 | ┌──────────────────────────┐ | ||
| 41 | │ ┌──────┐ │ | ||
| 42 | │ │ QR │ │ WIFI:S:<ssid>;T:nopass;; | ||
| 43 | │ └──────┘ │ | ||
| 44 | │ Scan to connect │ cyan | ||
| 45 | │ SSID: TollGate-XXXX │ white | ||
| 46 | │ 21 sats/min │ orange | ||
| 47 | │ Wallet: 420 sats │ green/yellow/red | ||
| 48 | └──────────────────────────┘ | ||
| 49 | ``` | ||
| 50 | |||
| 51 | **View B — Portal QR:** | ||
| 52 | ``` | ||
| 53 | ┌──────────────────────────┐ | ||
| 54 | │ ┌──────┐ │ | ||
| 55 | │ │ QR │ │ http://10.x.x.x/ | ||
| 56 | │ └──────┘ │ | ||
| 57 | │ Portal URL │ cyan | ||
| 58 | │ Mint: testnut... │ orange | ||
| 59 | │ 21 sats/min │ orange | ||
| 60 | │ Clients: 3 │ green | ||
| 61 | └──────────────────────────┘ | ||
| 62 | ``` | ||
| 63 | |||
| 64 | ### 3. PAYMENT_RECEIVED | ||
| 65 | Shows for 3 seconds after payment, then returns to READY. | ||
| 66 | |||
| 67 | ``` | ||
| 68 | ┌──────────────────────────┐ | ||
| 69 | │ ████████████████████ │ green bar | ||
| 70 | │ ACCESS GRANTED │ white on green, scale 2 | ||
| 71 | │ ████████████████████ │ | ||
| 72 | │ Paid: 42 sats │ white | ||
| 73 | │ Time: 2 min │ white | ||
| 74 | │ Wallet: 462 sats │ green | ||
| 75 | └──────────────────────────┘ | ||
| 76 | ``` | ||
| 77 | |||
| 78 | ### 4. ERROR | ||
| 79 | Shown when upstream WiFi is disconnected. | ||
| 80 | |||
| 81 | ``` | ||
| 82 | ┌──────────────────────────┐ | ||
| 83 | │ ████████████████████ │ red bar | ||
| 84 | │ NO UPSTREAM │ white on red, scale 2 | ||
| 85 | │ ████████████████████ │ | ||
| 86 | │ Internet unavailable │ white | ||
| 87 | │ Check WiFi config │ yellow | ||
| 88 | │ AP still active │ green | ||
| 89 | │ SSID: TollGate-XXXX │ dim | ||
| 90 | └──────────────────────────┘ | ||
| 91 | ``` | ||
| 92 | |||
| 93 | ## State Synchronization | ||
| 94 | |||
| 95 | ### Data sources and update triggers | ||
| 96 | |||
| 97 | | Display data | Source function | Update trigger | | ||
| 98 | |-------------|----------------|----------------| | ||
| 99 | | SSID | `config.ap_ssid` | Once at `start_services()` | | ||
| 100 | | Portal URL | `config.ap_ip_str` | Once at `start_services()` | | ||
| 101 | | Mint URL | `config.mint_url` | Once at `start_services()` | | ||
| 102 | | Price | `config.price_per_step` | Once at `start_services()` | | ||
| 103 | | **Wallet balance** | `nucula_wallet_balance()` | **Every 5s in main loop** | | ||
| 104 | | **Client count** | `session_active_count()` | **Every 5s in main loop + AP events** | | ||
| 105 | | **Last payment** | `display_notify_payment()` | **On each payment in API handler** | | ||
| 106 | | WiFi status | Event handler | On STA connect/disconnect | | ||
| 107 | |||
| 108 | ### State transitions | ||
| 109 | |||
| 110 | ``` | ||
| 111 | app_main() | ||
| 112 | └─ display_set_state(BOOT) | ||
| 113 | └─ display_update(price, mint, ssid) ← config data available after config_init | ||
| 114 | |||
| 115 | wifi_event_handler(STA_DISCONNECTED) | ||
| 116 | └─ display_set_state(ERROR) | ||
| 117 | └─ display_update(wifi_status="retrying...") | ||
| 118 | |||
| 119 | ip_event_handler(STA_GOT_IP) | ||
| 120 | └─ start_services() | ||
| 121 | └─ display_set_state(READY) | ||
| 122 | └─ display_update(ssid, portal_url, mint, price) | ||
| 123 | |||
| 124 | tollgate_api (POST / payment) | ||
| 125 | └─ session_create() | ||
| 126 | └─ nucula_wallet_receive() | ||
| 127 | └─ display_notify_payment(amount_sats, allotment) ← NEW | ||
| 128 | └─ display_set_state(PAYMENT_RECEIVED) | ||
| 129 | |||
| 130 | display_task (3s timeout) | ||
| 131 | └─ auto-return PAYMENT_RECEIVED → READY | ||
| 132 | |||
| 133 | app_main() main loop (every 5s) | ||
| 134 | └─ display_update(wallet_balance, client_count) ← NEW periodic refresh | ||
| 135 | ``` | ||
| 136 | |||
| 137 | ### New API: `display_notify_payment()` | ||
| 138 | |||
| 139 | ```c | ||
| 140 | void display_notify_payment(int amount_sats, int64_t allotment_ms); | ||
| 141 | ``` | ||
| 142 | |||
| 143 | Stores the last payment amount and time purchased for the PAYMENT screen to display. | ||
| 144 | |||
| 145 | ## Implementation Checklist | ||
| 146 | |||
| 147 | ### Done | ||
| 148 | - [x] QSPI driver working with correct colors (DMA byte-swap + RAMWR) | ||
| 149 | - [x] BOOT screen with title and WiFi status | ||
| 150 | - [x] READY screen with QR cycling, price, mint, balance, clients | ||
| 151 | - [x] PAYMENT screen layout (green banner, amount, time, wallet) | ||
| 152 | - [x] ERROR screen layout (red banner, guidance, AP status) | ||
| 153 | - [x] WiFi disconnect → ERROR state transition | ||
| 154 | - [x] WiFi connect → READY state transition | ||
| 155 | |||
| 156 | ### TODO — State Sync | ||
| 157 | - [ ] Periodic display data refresh in main loop (wallet balance, client count every 5s) | ||
| 158 | - [ ] `display_notify_payment()` API to pass payment amount and allotment | ||
| 159 | - [ ] Call `display_set_state(PAYMENT_RECEIVED)` from tollgate_api.c after payment | ||
| 160 | - [ ] Pass config data (price, mint) to display during boot phase | ||
| 161 | - [ ] Update client count on AP station connect/disconnect events | ||
| 162 | |||
| 163 | ### TODO — Polish | ||
| 164 | - [ ] Run `make test-unit` to check for regressions | ||
| 165 | - [ ] Commit, push, prepare for merge | ||
diff --git a/TOUCH_WIFI_SETUP_PLAN.md b/TOUCH_WIFI_SETUP_PLAN.md new file mode 100644 index 0000000..f441138 --- /dev/null +++ b/TOUCH_WIFI_SETUP_PLAN.md | |||
| @@ -0,0 +1,92 @@ | |||
| 1 | # Touchscreen WiFi Setup Plan | ||
| 2 | |||
| 3 | ## Overview | ||
| 4 | Add touchscreen WiFi configuration to the TollGate display so users can select a gateway network and enter a password directly on the device. | ||
| 5 | |||
| 6 | ## Hardware | ||
| 7 | |||
| 8 | ### Touch Controller | ||
| 9 | - **IC:** AXS15231B built-in touch (same chip as display, separate I2C interface) | ||
| 10 | - **Bus:** I2C, address `0x3B` | ||
| 11 | - **Pins:** SDA=GPIO4, SCL=GPIO8, RST=GPIO12, INT=GPIO11 | ||
| 12 | - **Protocol:** Write 11 bytes `[0xb5,0xab,0xa5,0x5a,0x00,0x00,0x00,0x08,0x00,0x00,0x00]`, read 8 bytes | ||
| 13 | - **Coordinates:** X/Y from data bytes [2..5], 12-bit | ||
| 14 | - **RST sequence:** LOW 200ms -> HIGH 200ms | ||
| 15 | |||
| 16 | ### No Pin Conflicts | ||
| 17 | | Pin | Display | Touch | Conflict? | | ||
| 18 | |-----|---------|-------|-----------| | ||
| 19 | | 4 | -- | SDA | No | | ||
| 20 | | 8 | -- | SCL | No | | ||
| 21 | | 11 | -- | INT | No | | ||
| 22 | | 12 | -- | RST | No | | ||
| 23 | | 1,21,39,40,45,47,48 | Display QSPI | -- | No | | ||
| 24 | |||
| 25 | ## Trigger Points (all three) | ||
| 26 | |||
| 27 | | Trigger | How | When | | ||
| 28 | |---------|-----|------| | ||
| 29 | | A) Tap on ERROR screen | "Setup WiFi" button | Any time upstream is down | | ||
| 30 | | B) Auto-show on first boot | Check wifi_networks empty | Fresh device with no credentials | | ||
| 31 | | C) Setup button on READY/ERROR | Small gear icon in corner | Always accessible | | ||
| 32 | |||
| 33 | ## UI Screens | ||
| 34 | |||
| 35 | ### 1. Scanning | ||
| 36 | Title + "Scanning..." spinner | ||
| 37 | |||
| 38 | ### 2. Network List | ||
| 39 | Top 8 by RSSI, sorted strongest first, scrollable. Lock icon for secured networks. | ||
| 40 | |||
| 41 | ### 3. Password Entry | ||
| 42 | SSID name, masked password field with eye reveal toggle, QWERTY keyboard. | ||
| 43 | |||
| 44 | ### 4. Connecting | ||
| 45 | "Connecting to SSID..." spinner | ||
| 46 | |||
| 47 | ### 5. Result | ||
| 48 | Green "Connected!" with IP, or red "Failed" with retry options. | ||
| 49 | |||
| 50 | ## New Files | ||
| 51 | |||
| 52 | | File | Purpose | Testable Logic | | ||
| 53 | |------|---------|----------------| | ||
| 54 | | `main/touch.h/c` | AXS15231B I2C touch driver | Coordinate parsing | | ||
| 55 | | `main/keyboard.h/c` | On-screen keyboard rendering + hit detection | Layout, key lookup | | ||
| 56 | | `main/wifi_setup.h/c` | WiFi scan/select/connect flow | State machine | | ||
| 57 | | `tests/unit/test_touch.c` | Touch coordinate decode | Pure math | | ||
| 58 | | `tests/unit/test_keyboard.c` | Key layout + hit detection | Pure logic | | ||
| 59 | | `tests/unit/test_wifi_setup.c` | Setup state machine | State transitions | | ||
| 60 | |||
| 61 | ## Config Extension | ||
| 62 | Add `tollgate_config_add_wifi(const char *ssid, const char *password)` to `config.c` - rewrites `/spiffs/config.json`. | ||
| 63 | |||
| 64 | ## Display Extension | ||
| 65 | Add `DISPLAY_WIFI_SETUP` to `display_state_t`. | ||
| 66 | |||
| 67 | ## Implementation Checklist | ||
| 68 | |||
| 69 | ### Phase 1: Touch Driver | ||
| 70 | - [x] Create `main/touch.h` with API | ||
| 71 | - [x] Create `main/touch.c` with ESP-IDF I2C v5 implementation | ||
| 72 | - [x] Create `tests/unit/test_touch.c` with coordinate parsing tests | ||
| 73 | - [x] Run `make test-unit`, verify all pass | ||
| 74 | |||
| 75 | ### Phase 2: On-Screen Keyboard | ||
| 76 | - [x] Create `main/keyboard.h` with API | ||
| 77 | - [x] Create `main/keyboard.c` with QWERTY layout + hit detection | ||
| 78 | - [x] Create `tests/unit/test_keyboard.c` with layout + key lookup tests | ||
| 79 | - [x] Run `make test-unit`, verify all pass | ||
| 80 | |||
| 81 | ### Phase 3: WiFi Setup Flow | ||
| 82 | - [x] Create `main/wifi_setup.h` with API | ||
| 83 | - [x] Create `main/wifi_setup.c` with scan/list/connect state machine | ||
| 84 | - [x] Add `tollgate_config_add_wifi()` to config.c + config.h | ||
| 85 | - [x] Create `tests/unit/test_wifi_setup.c` with state machine tests | ||
| 86 | - [x] Run `make test-unit`, verify all pass | ||
| 87 | |||
| 88 | ### Phase 4: Integration | ||
| 89 | - [x] Add `DISPLAY_WIFI_SETUP` to display.h | ||
| 90 | - [x] Update display.c with WiFi setup rendering + touch input | ||
| 91 | - [x] Update `main/CMakeLists.txt` with new source files | ||
| 92 | - [ ] Build, flash to Board C, verify full flow | ||
diff --git a/WEB_WIFI_SETUP_PLAN.md b/WEB_WIFI_SETUP_PLAN.md new file mode 100644 index 0000000..12a2fb5 --- /dev/null +++ b/WEB_WIFI_SETUP_PLAN.md | |||
| @@ -0,0 +1,100 @@ | |||
| 1 | # Web WiFi Setup Plan | ||
| 2 | |||
| 3 | ## Overview | ||
| 4 | |||
| 5 | Move WiFi configuration from on-display touchscreen UI to a web-based setup page | ||
| 6 | served by the captive portal. The display becomes portrait-only, showing QR codes | ||
| 7 | and status info. No more landscape rotation, on-screen keyboard, or touch-driven | ||
| 8 | WiFi setup. | ||
| 9 | |||
| 10 | ## Architecture | ||
| 11 | |||
| 12 | ### Display (portrait 320x480 only) | ||
| 13 | |||
| 14 | | State | When | Content | | ||
| 15 | |-------|------|---------| | ||
| 16 | | BOOT | Startup | WiFi QR + "TollGate" title + SSID + status | | ||
| 17 | | READY (unconfigured) | No STA network | AP WiFi QR + SSID + "http://AP_IP/setup" | | ||
| 18 | | READY (configured) | STA connected | QR cycling (WiFi↔Portal) + balance/clients/price | | ||
| 19 | | PAYMENT_RECEIVED | After payment | "ACCESS GRANTED" + amount + time (3s then→READY) | | ||
| 20 | | ERROR | No upstream | WiFi QR + "NO UPSTREAM" + SSID + setup URL | | ||
| 21 | |||
| 22 | ### Captive Portal (new endpoints) | ||
| 23 | |||
| 24 | | Endpoint | Method | Purpose | | ||
| 25 | |----------|--------|---------| | ||
| 26 | | `/setup` | GET | WiFi setup HTML page (only when unconfigured) | | ||
| 27 | | `/wifi/scan` | GET | Trigger scan, return `[{ssid,rssi,secured}]` JSON | | ||
| 28 | | `/wifi/connect` | POST | Take `{ssid,password}`, save config, connect | | ||
| 29 | | `/wifi/status` | GET | Return `{connected,ip,ssid}` | | ||
| 30 | |||
| 31 | ### Files Removed from Build (kept on disk) | ||
| 32 | |||
| 33 | - `main/touch.c` / `touch.h` | ||
| 34 | - `main/keyboard.c` / `keyboard.h` | ||
| 35 | - `main/wifi_setup.c` / `wifi_setup.h` | ||
| 36 | |||
| 37 | ### Files Modified | ||
| 38 | |||
| 39 | - `main/display.c` — Strip WiFi setup state, rotation, offscreen; add setup URL text | ||
| 40 | - `main/display.h` — Remove `DISPLAY_WIFI_SETUP`, `display_enter_wifi_setup()` | ||
| 41 | - `main/tollgate_main.c` — Remove WiFi setup auto-enter, add display state for unconfigured | ||
| 42 | - `main/captive_portal.c` — Add WiFi scan/connect/status endpoints + `/setup` HTML | ||
| 43 | - `main/captive_portal.h` — Expose captive_portal_is_setup_available() | ||
| 44 | - `components/axs15231b/axs15231b.c` — Remove offscreen buffer | ||
| 45 | - `components/axs15231b/include/axs15231b.h` — Remove `axs15231b_set_offscreen()` | ||
| 46 | - `main/CMakeLists.txt` — Remove touch/keyboard/wifi_setup sources | ||
| 47 | |||
| 48 | ## Checklist | ||
| 49 | |||
| 50 | ### Phase 1: Strip display and driver | ||
| 51 | - [x] Remove offscreen buffer from `axs15231b.c` and `axs15231b.h` | ||
| 52 | - [x] Strip `display.c` — remove WiFi setup state, rotation, keyboard/touch imports | ||
| 53 | - [x] Update `display.h` — remove `DISPLAY_WIFI_SETUP`, `display_enter_wifi_setup()` | ||
| 54 | - [x] Add setup URL text to READY (unconfigured) and ERROR screens | ||
| 55 | - [x] Remove WiFi setup auto-enter from `tollgate_main.c` | ||
| 56 | - [x] Add WiFi QR code to BOOT screen (scan to connect) | ||
| 57 | - [x] Add WiFi QR code to ERROR screen | ||
| 58 | |||
| 59 | ### Phase 2: Add web WiFi setup | ||
| 60 | - [x] Add `/wifi/scan` endpoint to `captive_portal.c` | ||
| 61 | - [x] Add `/wifi/connect` endpoint to `captive_portal.c` | ||
| 62 | - [x] Add `/wifi/status` endpoint to `captive_portal.c` | ||
| 63 | - [x] Add `/setup` HTML page with scan list + connect form | ||
| 64 | - [x] Gate `/setup` behind `network_count == 0` | ||
| 65 | |||
| 66 | ### Phase 3: Build configuration | ||
| 67 | - [x] Remove touch.c, keyboard.c, wifi_setup.c from `main/CMakeLists.txt` | ||
| 68 | |||
| 69 | ### Phase 4: Testing and fixes | ||
| 70 | - [x] `make test-unit` passes | ||
| 71 | - [x] Build succeeds (`idf.py build`) | ||
| 72 | - [x] Flash to Board C, verify portrait display shows setup URL | ||
| 73 | - [x] Fix: display stuck at BOOT when WiFi configured but unreachable | ||
| 74 | - Added `s_total_retries` counter (MAX_TOTAL_RETRIES=10) | ||
| 75 | - Transitions to DISPLAY_ERROR after all retries exhausted | ||
| 76 | - [x] WiFi QR code visible on BOOT and ERROR screens (hardware verified) | ||
| 77 | - [x] Write integration test `tests/integration/wifi_setup.mjs` | ||
| 78 | - [x] Pushed to `ngit.orangesync.tech` (commits `aa58b47`..`402f4f2`) | ||
| 79 | |||
| 80 | ### Phase 5: Playwright E2E for `/setup` page | ||
| 81 | - [ ] Plan test scenarios for `/setup` page (requires unconfigured board) | ||
| 82 | - [ ] Write `tests/e2e/wifi-setup.spec.mjs` | ||
| 83 | - Test: `/setup` returns HTML with scan button when `network_count == 0` | ||
| 84 | - Test: `/wifi/scan` returns JSON array of APs | ||
| 85 | - Test: `/wifi/connect` rejects invalid JSON | ||
| 86 | - Test: `/wifi/connect` rejects missing SSID | ||
| 87 | - Test: `/wifi/status` returns `{connected, ip, ssid}` | ||
| 88 | - Test: `/setup` redirects to `/` when WiFi already configured | ||
| 89 | - Test: full flow — scan → select AP → enter password → connect | ||
| 90 | - [ ] Add `make test-wifi-setup` target to Makefile | ||
| 91 | - [ ] Run E2E test against live board with erased SPIFFS | ||
| 92 | |||
| 93 | ## Commits | ||
| 94 | |||
| 95 | | Hash | Message | | ||
| 96 | |------|---------| | ||
| 97 | | `aa58b47` | feat: web-based WiFi setup via captive portal, portrait-only display | | ||
| 98 | | `2e65cdf` | fix: transition display to ERROR after WiFi retries exhausted | | ||
| 99 | | `cf4ac1b` | feat: add WiFi QR code to BOOT and ERROR screens | | ||
| 100 | | `402f4f2` | docs: update web WiFi setup plan checklist with progress | | ||
diff --git a/components/axs15231b/axs15231b.c b/components/axs15231b/axs15231b.c index dd7145a..00e8467 100644 --- a/components/axs15231b/axs15231b.c +++ b/components/axs15231b/axs15231b.c | |||
| @@ -29,10 +29,17 @@ static const char *TAG = "axs15231b"; | |||
| 29 | #define MADCTL_MV 0x20 | 29 | #define MADCTL_MV 0x20 |
| 30 | #define MADCTL_RGB 0x00 | 30 | #define MADCTL_RGB 0x00 |
| 31 | 31 | ||
| 32 | #define QSPI_CMD_REG_WRITE 0x02 | ||
| 33 | #define QSPI_CMD_DATA_WRITE 0x32 | ||
| 34 | #define QSPI_DATA_ADDR 0x003C00 | ||
| 35 | |||
| 32 | static spi_device_handle_t s_spi = NULL; | 36 | static spi_device_handle_t s_spi = NULL; |
| 33 | static uint16_t *s_fb = NULL; | 37 | static uint16_t *s_fb = NULL; |
| 34 | static int s_width = AXS15231B_WIDTH; | 38 | static int s_width = AXS15231B_WIDTH; |
| 35 | static int s_height = AXS15231B_HEIGHT; | 39 | static int s_height = AXS15231B_HEIGHT; |
| 40 | static int s_stride = AXS15231B_WIDTH; | ||
| 41 | static uint8_t *s_swap_buf = NULL; | ||
| 42 | #define SWAP_BUF_PIXELS 2048 | ||
| 36 | 43 | ||
| 37 | typedef struct { | 44 | typedef struct { |
| 38 | uint8_t cmd; | 45 | uint8_t cmd; |
| @@ -41,28 +48,92 @@ typedef struct { | |||
| 41 | uint16_t delay_ms; | 48 | uint16_t delay_ms; |
| 42 | } init_cmd_t; | 49 | } init_cmd_t; |
| 43 | 50 | ||
| 44 | static esp_err_t send_cmd(uint8_t cmd) { | 51 | static inline void cs_low(void) { |
| 45 | spi_transaction_t t = {0}; | 52 | gpio_set_level(AXS15231B_PIN_CS, 0); |
| 46 | t.length = 8; | 53 | } |
| 47 | t.tx_data[0] = cmd; | 54 | |
| 48 | t.flags = SPI_TRANS_USE_TXDATA; | 55 | static inline void cs_high(void) { |
| 49 | return spi_device_polling_transmit(s_spi, &t); | 56 | gpio_set_level(AXS15231B_PIN_CS, 1); |
| 57 | } | ||
| 58 | |||
| 59 | static void cs_init(void) { | ||
| 60 | gpio_config_t cfg = { | ||
| 61 | .pin_bit_mask = (1ULL << AXS15231B_PIN_CS), | ||
| 62 | .mode = GPIO_MODE_OUTPUT, | ||
| 63 | .pull_up_en = GPIO_PULLUP_DISABLE, | ||
| 64 | .pull_down_en = GPIO_PULLDOWN_DISABLE, | ||
| 65 | .intr_type = GPIO_INTR_DISABLE, | ||
| 66 | }; | ||
| 67 | gpio_config(&cfg); | ||
| 68 | gpio_set_level(AXS15231B_PIN_CS, 1); | ||
| 69 | } | ||
| 70 | |||
| 71 | static void qspi_write_command(uint8_t lcd_cmd) { | ||
| 72 | spi_transaction_ext_t t = {0}; | ||
| 73 | t.base.flags = SPI_TRANS_MULTILINE_CMD | SPI_TRANS_MULTILINE_ADDR; | ||
| 74 | t.base.cmd = QSPI_CMD_REG_WRITE; | ||
| 75 | t.base.addr = ((uint32_t)lcd_cmd) << 8; | ||
| 76 | t.base.tx_buffer = NULL; | ||
| 77 | t.base.length = 0; | ||
| 78 | cs_low(); | ||
| 79 | spi_device_polling_transmit(s_spi, (spi_transaction_t *)&t); | ||
| 80 | cs_high(); | ||
| 81 | } | ||
| 82 | |||
| 83 | static void qspi_write_cmd_data8(uint8_t lcd_cmd, uint8_t d) { | ||
| 84 | spi_transaction_ext_t t = {0}; | ||
| 85 | t.base.flags = SPI_TRANS_USE_TXDATA | SPI_TRANS_MULTILINE_CMD | SPI_TRANS_MULTILINE_ADDR; | ||
| 86 | t.base.cmd = QSPI_CMD_REG_WRITE; | ||
| 87 | t.base.addr = ((uint32_t)lcd_cmd) << 8; | ||
| 88 | t.base.tx_data[0] = d; | ||
| 89 | t.base.length = 8; | ||
| 90 | cs_low(); | ||
| 91 | spi_device_polling_transmit(s_spi, (spi_transaction_t *)&t); | ||
| 92 | cs_high(); | ||
| 93 | } | ||
| 94 | |||
| 95 | static void qspi_write_cmd_data16(uint8_t lcd_cmd, uint16_t d) { | ||
| 96 | spi_transaction_ext_t t = {0}; | ||
| 97 | t.base.flags = SPI_TRANS_USE_TXDATA | SPI_TRANS_MULTILINE_CMD | SPI_TRANS_MULTILINE_ADDR; | ||
| 98 | t.base.cmd = QSPI_CMD_REG_WRITE; | ||
| 99 | t.base.addr = ((uint32_t)lcd_cmd) << 8; | ||
| 100 | t.base.tx_data[0] = d >> 8; | ||
| 101 | t.base.tx_data[1] = d & 0xFF; | ||
| 102 | t.base.length = 16; | ||
| 103 | cs_low(); | ||
| 104 | spi_device_polling_transmit(s_spi, (spi_transaction_t *)&t); | ||
| 105 | cs_high(); | ||
| 50 | } | 106 | } |
| 51 | 107 | ||
| 52 | static esp_err_t send_data(const uint8_t *data, int len) { | 108 | static void qspi_write_cmd_bytes(uint8_t lcd_cmd, const uint8_t *data, int len) { |
| 53 | if (len == 0) return ESP_OK; | 109 | if (len == 0) { |
| 54 | spi_transaction_t t = {0}; | 110 | qspi_write_command(lcd_cmd); |
| 55 | t.length = len * 8; | 111 | return; |
| 56 | t.tx_buffer = data; | 112 | } |
| 57 | t.flags = 0; | 113 | spi_transaction_ext_t t = {0}; |
| 58 | return spi_device_polling_transmit(s_spi, &t); | 114 | t.base.flags = SPI_TRANS_MULTILINE_CMD | SPI_TRANS_MULTILINE_ADDR; |
| 115 | t.base.cmd = QSPI_CMD_REG_WRITE; | ||
| 116 | t.base.addr = ((uint32_t)lcd_cmd) << 8; | ||
| 117 | t.base.tx_buffer = data; | ||
| 118 | t.base.length = len * 8; | ||
| 119 | cs_low(); | ||
| 120 | spi_device_polling_transmit(s_spi, (spi_transaction_t *)&t); | ||
| 121 | cs_high(); | ||
| 59 | } | 122 | } |
| 60 | 123 | ||
| 61 | static esp_err_t send_cmd_data(uint8_t cmd, const uint8_t *data, int len) { | 124 | static void qspi_write_cmd_d16d16(uint8_t lcd_cmd, uint16_t d1, uint16_t d2) { |
| 62 | esp_err_t ret = send_cmd(cmd); | 125 | spi_transaction_ext_t t = {0}; |
| 63 | if (ret != ESP_OK) return ret; | 126 | t.base.flags = SPI_TRANS_USE_TXDATA | SPI_TRANS_MULTILINE_CMD | SPI_TRANS_MULTILINE_ADDR; |
| 64 | if (len > 0) ret = send_data(data, len); | 127 | t.base.cmd = QSPI_CMD_REG_WRITE; |
| 65 | return ret; | 128 | t.base.addr = ((uint32_t)lcd_cmd) << 8; |
| 129 | t.base.tx_data[0] = d1 >> 8; | ||
| 130 | t.base.tx_data[1] = d1 & 0xFF; | ||
| 131 | t.base.tx_data[2] = d2 >> 8; | ||
| 132 | t.base.tx_data[3] = d2 & 0xFF; | ||
| 133 | t.base.length = 32; | ||
| 134 | cs_low(); | ||
| 135 | spi_device_polling_transmit(s_spi, (spi_transaction_t *)&t); | ||
| 136 | cs_high(); | ||
| 66 | } | 137 | } |
| 67 | 138 | ||
| 68 | static const uint8_t init_bb[] = {0x00,0x00,0x00,0x00,0x00,0x00,0x5A,0xA5}; | 139 | static const uint8_t init_bb[] = {0x00,0x00,0x00,0x00,0x00,0x00,0x5A,0xA5}; |
| @@ -136,20 +207,23 @@ esp_err_t axs15231b_init(void) { | |||
| 136 | esp_err_t ret; | 207 | esp_err_t ret; |
| 137 | 208 | ||
| 138 | spi_bus_config_t buscfg = { | 209 | spi_bus_config_t buscfg = { |
| 139 | .mosi_io_num = AXS15231B_PIN_D0, | 210 | .data0_io_num = AXS15231B_PIN_D0, |
| 211 | .data1_io_num = AXS15231B_PIN_D1, | ||
| 140 | .sclk_io_num = AXS15231B_PIN_CLK, | 212 | .sclk_io_num = AXS15231B_PIN_CLK, |
| 141 | .miso_io_num = -1, | 213 | .data2_io_num = AXS15231B_PIN_D2, |
| 142 | .quadwp_io_num = -1, | 214 | .data3_io_num = AXS15231B_PIN_D3, |
| 143 | .quadhd_io_num = -1, | ||
| 144 | .max_transfer_sz = 32768, | 215 | .max_transfer_sz = 32768, |
| 145 | }; | 216 | }; |
| 146 | 217 | ||
| 147 | spi_device_interface_config_t devcfg = { | 218 | spi_device_interface_config_t devcfg = { |
| 219 | .command_bits = 8, | ||
| 220 | .address_bits = 24, | ||
| 221 | .dummy_bits = 0, | ||
| 148 | .clock_speed_hz = 40 * 1000 * 1000, | 222 | .clock_speed_hz = 40 * 1000 * 1000, |
| 149 | .mode = 0, | 223 | .mode = 0, |
| 150 | .spics_io_num = AXS15231B_PIN_CS, | 224 | .spics_io_num = -1, |
| 151 | .queue_size = 7, | 225 | .queue_size = 7, |
| 152 | .flags = 0, | 226 | .flags = SPI_DEVICE_HALFDUPLEX, |
| 153 | }; | 227 | }; |
| 154 | 228 | ||
| 155 | ret = spi_bus_initialize(SPI2_HOST, &buscfg, SPI_DMA_CH_AUTO); | 229 | ret = spi_bus_initialize(SPI2_HOST, &buscfg, SPI_DMA_CH_AUTO); |
| @@ -164,6 +238,10 @@ esp_err_t axs15231b_init(void) { | |||
| 164 | return ret; | 238 | return ret; |
| 165 | } | 239 | } |
| 166 | 240 | ||
| 241 | spi_device_acquire_bus(s_spi, portMAX_DELAY); | ||
| 242 | |||
| 243 | cs_init(); | ||
| 244 | |||
| 167 | size_t fb_size = (size_t)s_width * s_height * 2; | 245 | size_t fb_size = (size_t)s_width * s_height * 2; |
| 168 | s_fb = heap_caps_malloc(fb_size, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); | 246 | s_fb = heap_caps_malloc(fb_size, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); |
| 169 | if (!s_fb) { | 247 | if (!s_fb) { |
| @@ -173,6 +251,13 @@ esp_err_t axs15231b_init(void) { | |||
| 173 | memset(s_fb, 0, fb_size); | 251 | memset(s_fb, 0, fb_size); |
| 174 | ESP_LOGI(TAG, "Framebuffer allocated: %zu bytes in PSRAM", fb_size); | 252 | ESP_LOGI(TAG, "Framebuffer allocated: %zu bytes in PSRAM", fb_size); |
| 175 | 253 | ||
| 254 | s_swap_buf = heap_caps_aligned_alloc(16, SWAP_BUF_PIXELS * 2, MALLOC_CAP_DMA); | ||
| 255 | if (!s_swap_buf) { | ||
| 256 | ESP_LOGE(TAG, "Failed to allocate DMA swap buffer (%d bytes)", SWAP_BUF_PIXELS * 2); | ||
| 257 | return ESP_ERR_NO_MEM; | ||
| 258 | } | ||
| 259 | ESP_LOGI(TAG, "DMA swap buffer: %d bytes in internal RAM", SWAP_BUF_PIXELS * 2); | ||
| 260 | |||
| 176 | gpio_config_t bl_cfg = { | 261 | gpio_config_t bl_cfg = { |
| 177 | .pin_bit_mask = (1ULL << AXS15231B_PIN_BL), | 262 | .pin_bit_mask = (1ULL << AXS15231B_PIN_BL), |
| 178 | .mode = GPIO_MODE_OUTPUT, | 263 | .mode = GPIO_MODE_OUTPUT, |
| @@ -182,40 +267,28 @@ esp_err_t axs15231b_init(void) { | |||
| 182 | }; | 267 | }; |
| 183 | gpio_config(&bl_cfg); | 268 | gpio_config(&bl_cfg); |
| 184 | 269 | ||
| 185 | send_cmd(SWRESET); | 270 | qspi_write_command(SWRESET); |
| 186 | vTaskDelay(pdMS_TO_TICKS(200)); | 271 | vTaskDelay(pdMS_TO_TICKS(200)); |
| 187 | 272 | ||
| 188 | for (int i = 0; i < INIT_CMD_COUNT; i++) { | 273 | for (int i = 0; i < INIT_CMD_COUNT; i++) { |
| 189 | ret = send_cmd_data(s_init_cmds[i].cmd, s_init_cmds[i].data, s_init_cmds[i].data_len); | 274 | qspi_write_cmd_bytes(s_init_cmds[i].cmd, s_init_cmds[i].data, s_init_cmds[i].data_len); |
| 190 | if (ret != ESP_OK) { | ||
| 191 | ESP_LOGE(TAG, "Init cmd 0x%02X failed: %s", s_init_cmds[i].cmd, esp_err_to_name(ret)); | ||
| 192 | return ret; | ||
| 193 | } | ||
| 194 | if (s_init_cmds[i].delay_ms > 0) { | 275 | if (s_init_cmds[i].delay_ms > 0) { |
| 195 | vTaskDelay(pdMS_TO_TICKS(s_init_cmds[i].delay_ms)); | 276 | vTaskDelay(pdMS_TO_TICKS(s_init_cmds[i].delay_ms)); |
| 196 | } | 277 | } |
| 197 | } | 278 | } |
| 198 | 279 | ||
| 199 | uint8_t madctl_val = MADCTL_MX | MADCTL_MV | MADCTL_RGB; | 280 | uint8_t madctl_val = MADCTL_RGB; |
| 200 | ret = send_cmd_data(MADCTL, &madctl_val, 1); | 281 | qspi_write_cmd_data8(MADCTL, madctl_val); |
| 201 | if (ret != ESP_OK) { | ||
| 202 | ESP_LOGE(TAG, "Failed to set rotation: %s", esp_err_to_name(ret)); | ||
| 203 | return ret; | ||
| 204 | } | ||
| 205 | 282 | ||
| 206 | uint8_t colmod_val = 0x55; | 283 | uint8_t colmod_val = 0x55; |
| 207 | ret = send_cmd_data(COLMOD, &colmod_val, 1); | 284 | qspi_write_cmd_data8(COLMOD, colmod_val); |
| 208 | if (ret != ESP_OK) { | ||
| 209 | ESP_LOGE(TAG, "Failed to set pixel format: %s", esp_err_to_name(ret)); | ||
| 210 | return ret; | ||
| 211 | } | ||
| 212 | 285 | ||
| 213 | axs15231b_fill_screen(0x0000); | 286 | axs15231b_fill_screen(0x0000); |
| 214 | axs15231b_flush(); | 287 | axs15231b_flush(); |
| 215 | 288 | ||
| 216 | axs15231b_set_backlight(true); | 289 | axs15231b_set_backlight(true); |
| 217 | 290 | ||
| 218 | ESP_LOGI(TAG, "AXS15231B initialized: %dx%d landscape", s_width, s_height); | 291 | ESP_LOGI(TAG, "AXS15231B initialized: %dx%d portrait", s_width, s_height); |
| 219 | return ESP_OK; | 292 | return ESP_OK; |
| 220 | } | 293 | } |
| 221 | 294 | ||
| @@ -224,9 +297,10 @@ void axs15231b_set_backlight(bool on) { | |||
| 224 | } | 297 | } |
| 225 | 298 | ||
| 226 | void axs15231b_fill_screen(uint16_t color) { | 299 | void axs15231b_fill_screen(uint16_t color) { |
| 227 | uint32_t pixels = (uint32_t)s_width * s_height; | 300 | for (int row = 0; row < s_height; row++) { |
| 228 | for (uint32_t i = 0; i < pixels; i++) { | 301 | for (int col = 0; col < s_width; col++) { |
| 229 | s_fb[i] = color; | 302 | s_fb[row * s_stride + col] = color; |
| 303 | } | ||
| 230 | } | 304 | } |
| 231 | } | 305 | } |
| 232 | 306 | ||
| @@ -234,48 +308,51 @@ void axs15231b_fill_rect(int x, int y, int w, int h, uint16_t color) { | |||
| 234 | if (x < 0 || y < 0 || x + w > s_width || y + h > s_height) return; | 308 | if (x < 0 || y < 0 || x + w > s_width || y + h > s_height) return; |
| 235 | for (int row = y; row < y + h; row++) { | 309 | for (int row = y; row < y + h; row++) { |
| 236 | for (int col = x; col < x + w; col++) { | 310 | for (int col = x; col < x + w; col++) { |
| 237 | s_fb[row * s_width + col] = color; | 311 | s_fb[row * s_stride + col] = color; |
| 238 | } | 312 | } |
| 239 | } | 313 | } |
| 240 | } | 314 | } |
| 241 | 315 | ||
| 242 | void axs15231b_flush(void) { | 316 | void axs15231b_flush(void) { |
| 243 | if (!s_spi || !s_fb) return; | 317 | if (!s_spi || !s_fb || !s_swap_buf) return; |
| 244 | 318 | ||
| 245 | uint8_t buf[4]; | 319 | qspi_write_cmd_d16d16(CASET, 0, s_width - 1); |
| 246 | buf[0] = 0; | 320 | qspi_write_cmd_d16d16(RASET, 0, s_height - 1); |
| 247 | buf[1] = 0; | 321 | qspi_write_command(RAMWR); |
| 248 | buf[2] = (s_width - 1) >> 8; | 322 | |
| 249 | buf[3] = (s_width - 1) & 0xFF; | 323 | bool first = true; |
| 250 | send_cmd_data(CASET, buf, 4); | 324 | cs_low(); |
| 251 | 325 | for (int row = 0; row < s_height; row++) { | |
| 252 | buf[0] = 0; | 326 | int chunk_remaining = s_width; |
| 253 | buf[1] = 0; | 327 | int col_offset = 0; |
| 254 | buf[2] = (s_height - 1) >> 8; | 328 | while (chunk_remaining > 0) { |
| 255 | buf[3] = (s_height - 1) & 0xFF; | 329 | int chunk_pixels = chunk_remaining < SWAP_BUF_PIXELS ? chunk_remaining : SWAP_BUF_PIXELS; |
| 256 | send_cmd_data(RASET, buf, 4); | 330 | int chunk_bytes = chunk_pixels * 2; |
| 257 | 331 | ||
| 258 | send_cmd(RAMWR); | 332 | uint8_t *src = (uint8_t *)(s_fb + row * s_stride + col_offset); |
| 259 | 333 | for (int i = 0; i < chunk_bytes; i += 2) { | |
| 260 | int total_bytes = s_width * s_height * 2; | 334 | s_swap_buf[i] = src[i + 1]; |
| 261 | int chunk_size = 32768; | 335 | s_swap_buf[i + 1] = src[i]; |
| 262 | int offset = 0; | 336 | } |
| 263 | uint8_t *fb_bytes = (uint8_t *)s_fb; | 337 | |
| 264 | 338 | spi_transaction_ext_t t = {0}; | |
| 265 | while (offset < total_bytes) { | 339 | if (first) { |
| 266 | int remaining = total_bytes - offset; | 340 | t.base.flags = SPI_TRANS_MODE_QIO; |
| 267 | int this_chunk = remaining < chunk_size ? remaining : chunk_size; | 341 | t.base.cmd = QSPI_CMD_DATA_WRITE; |
| 268 | 342 | t.base.addr = QSPI_DATA_ADDR; | |
| 269 | spi_transaction_t t = {0}; | 343 | first = false; |
| 270 | t.length = this_chunk * 8; | 344 | } else { |
| 271 | t.tx_buffer = fb_bytes + offset; | 345 | t.base.flags = SPI_TRANS_MODE_QIO | SPI_TRANS_VARIABLE_CMD | |
| 272 | esp_err_t ret = spi_device_polling_transmit(s_spi, &t); | 346 | SPI_TRANS_VARIABLE_ADDR | SPI_TRANS_VARIABLE_DUMMY; |
| 273 | if (ret != ESP_OK) { | 347 | } |
| 274 | ESP_LOGE(TAG, "Flush transfer failed at offset %d: %s", offset, esp_err_to_name(ret)); | 348 | t.base.tx_buffer = s_swap_buf; |
| 275 | return; | 349 | t.base.length = chunk_pixels * 16; |
| 350 | spi_device_polling_transmit(s_spi, (spi_transaction_t *)&t); | ||
| 351 | col_offset += chunk_pixels; | ||
| 352 | chunk_remaining -= chunk_pixels; | ||
| 276 | } | 353 | } |
| 277 | offset += this_chunk; | ||
| 278 | } | 354 | } |
| 355 | cs_high(); | ||
| 279 | } | 356 | } |
| 280 | 357 | ||
| 281 | int axs15231b_get_width(void) { return s_width; } | 358 | int axs15231b_get_width(void) { return s_width; } |
diff --git a/components/axs15231b/include/axs15231b.h b/components/axs15231b/include/axs15231b.h index 5ec017c..32c489f 100644 --- a/components/axs15231b/include/axs15231b.h +++ b/components/axs15231b/include/axs15231b.h | |||
| @@ -5,8 +5,8 @@ | |||
| 5 | #include <stdint.h> | 5 | #include <stdint.h> |
| 6 | #include <stdbool.h> | 6 | #include <stdbool.h> |
| 7 | 7 | ||
| 8 | #define AXS15231B_WIDTH 480 | 8 | #define AXS15231B_WIDTH 320 |
| 9 | #define AXS15231B_HEIGHT 320 | 9 | #define AXS15231B_HEIGHT 480 |
| 10 | 10 | ||
| 11 | #define AXS15231B_PIN_CS 45 | 11 | #define AXS15231B_PIN_CS 45 |
| 12 | #define AXS15231B_PIN_CLK 47 | 12 | #define AXS15231B_PIN_CLK 47 |
| @@ -23,5 +23,4 @@ void axs15231b_fill_rect(int x, int y, int w, int h, uint16_t color); | |||
| 23 | void axs15231b_flush(void); | 23 | void axs15231b_flush(void); |
| 24 | int axs15231b_get_width(void); | 24 | int axs15231b_get_width(void); |
| 25 | int axs15231b_get_height(void); | 25 | int axs15231b_get_height(void); |
| 26 | |||
| 27 | #endif | 26 | #endif |
diff --git a/main/CMakeLists.txt b/main/CMakeLists.txt index 2107cf1..9e76f89 100644 --- a/main/CMakeLists.txt +++ b/main/CMakeLists.txt | |||
| @@ -31,6 +31,9 @@ idf_component_register(SRCS "tollgate_main.c" | |||
| 31 | "sw_miner.c" | 31 | "sw_miner.c" |
| 32 | "asic_miner.c" | 32 | "asic_miner.c" |
| 33 | "tollgate_platform.c" | 33 | "tollgate_platform.c" |
| 34 | "touch.c" | ||
| 35 | "keyboard.c" | ||
| 36 | "wifi_setup.c" | ||
| 34 | INCLUDE_DIRS "." | 37 | INCLUDE_DIRS "." |
| 35 | REQUIRES esp_wifi esp_event esp_netif nvs_flash esp_http_server | 38 | REQUIRES esp_wifi esp_event esp_netif nvs_flash esp_http_server |
| 36 | lwip json esp_http_client mbedtls esp-tls log spiffs | 39 | lwip json esp_http_client mbedtls esp-tls log spiffs |
diff --git a/main/captive_portal.c b/main/captive_portal.c index ea83906..6a8c716 100644 --- a/main/captive_portal.c +++ b/main/captive_portal.c | |||
| @@ -6,6 +6,7 @@ | |||
| 6 | #include "stratum_proxy.h" | 6 | #include "stratum_proxy.h" |
| 7 | #include "esp_log.h" | 7 | #include "esp_log.h" |
| 8 | #include "esp_wifi.h" | 8 | #include "esp_wifi.h" |
| 9 | #include "esp_netif.h" | ||
| 9 | #include "cJSON.h" | 10 | #include "cJSON.h" |
| 10 | #include "lwip/sockets.h" | 11 | #include "lwip/sockets.h" |
| 11 | #include "lwip/netdb.h" | 12 | #include "lwip/netdb.h" |
| @@ -13,6 +14,7 @@ | |||
| 13 | #include "freertos/task.h" | 14 | #include "freertos/task.h" |
| 14 | #include <string.h> | 15 | #include <string.h> |
| 15 | #include <sys/param.h> | 16 | #include <sys/param.h> |
| 17 | #include <stdio.h> | ||
| 16 | 18 | ||
| 17 | static const char *TAG = "captive_portal"; | 19 | static const char *TAG = "captive_portal"; |
| 18 | static httpd_handle_t s_server = NULL; | 20 | static httpd_handle_t s_server = NULL; |
| @@ -342,17 +344,19 @@ static esp_err_t redirect_to_portal_handler(httpd_req_t *req) | |||
| 342 | return portal_handler(req); | 344 | return portal_handler(req); |
| 343 | } | 345 | } |
| 344 | 346 | ||
| 345 | static esp_err_t catchall_handler(httpd_req_t *req) | 347 | static esp_err_t catchall_err_handler(httpd_req_t *req, httpd_err_code_t err) |
| 346 | { | 348 | { |
| 347 | ESP_LOGI(TAG, "Catchall: GET %s → 302 → http://%s/", req->uri, s_ap_ip_str); | 349 | if (err == HTTPD_404_NOT_FOUND) { |
| 348 | httpd_resp_set_status(req, "302 Found"); | 350 | ESP_LOGI(TAG, "Catchall 404: GET %s → 302 → http://%s/", req->uri, s_ap_ip_str); |
| 349 | 351 | httpd_resp_set_status(req, "302 Found"); | |
| 350 | char location[64]; | 352 | char location[64]; |
| 351 | snprintf(location, sizeof(location), "http://%s/", s_ap_ip_str); | 353 | snprintf(location, sizeof(location), "http://%s/", s_ap_ip_str); |
| 352 | httpd_resp_set_hdr(req, "Location", location); | 354 | httpd_resp_set_hdr(req, "Location", location); |
| 353 | httpd_resp_set_hdr(req, "Connection", "close"); | 355 | httpd_resp_set_hdr(req, "Connection", "close"); |
| 354 | httpd_resp_send(req, NULL, 0); | 356 | httpd_resp_send(req, NULL, 0); |
| 355 | return ESP_OK; | 357 | return ESP_OK; |
| 358 | } | ||
| 359 | return ESP_FAIL; | ||
| 356 | } | 360 | } |
| 357 | 361 | ||
| 358 | static const httpd_uri_t uri_portal = { .uri = "/", .method = HTTP_GET, .handler = portal_handler }; | 362 | static const httpd_uri_t uri_portal = { .uri = "/", .method = HTTP_GET, .handler = portal_handler }; |
| @@ -368,7 +372,338 @@ static const httpd_uri_t uri_success = { .uri = "/success.txt", .method = HTTP_G | |||
| 368 | static const httpd_uri_t uri_ncsi = { .uri = "/ncsi.txt", .method = HTTP_GET, .handler = redirect_to_portal_handler }; | 372 | static const httpd_uri_t uri_ncsi = { .uri = "/ncsi.txt", .method = HTTP_GET, .handler = redirect_to_portal_handler }; |
| 369 | static const httpd_uri_t uri_connecttest = { .uri = "/connecttest.txt", .method = HTTP_GET, .handler = redirect_to_portal_handler }; | 373 | static const httpd_uri_t uri_connecttest = { .uri = "/connecttest.txt", .method = HTTP_GET, .handler = redirect_to_portal_handler }; |
| 370 | static const httpd_uri_t uri_wpad = { .uri = "/wpad.dat", .method = HTTP_GET, .handler = redirect_to_portal_handler }; | 374 | static const httpd_uri_t uri_wpad = { .uri = "/wpad.dat", .method = HTTP_GET, .handler = redirect_to_portal_handler }; |
| 371 | static const httpd_uri_t uri_catchall = { .uri = "/*", .method = HTTP_GET, .handler = catchall_handler }; | 375 | |
| 376 | static const char SETUP_HTML_TEMPLATE[] = \ | ||
| 377 | "<!DOCTYPE html>" | ||
| 378 | "<html><head>" | ||
| 379 | "<meta charset='utf-8'>" | ||
| 380 | "<meta name='viewport' content='width=device-width, initial-scale=1'>" | ||
| 381 | "<title>TollGate Setup</title>" | ||
| 382 | "<style>" | ||
| 383 | "*{box-sizing:border-box;margin:0;padding:0}" | ||
| 384 | "body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;" | ||
| 385 | "background:#0a0a0a;color:#fff;display:flex;align-items:center;justify-content:center;" | ||
| 386 | "min-height:100vh;padding:20px}" | ||
| 387 | ".card{background:#1a1a1a;border:1px solid #333;border-radius:16px;padding:32px;" | ||
| 388 | "max-width:400px;width:100%;text-align:center}" | ||
| 389 | "h1{font-size:24px;margin-bottom:8px;color:#f7931a}" | ||
| 390 | ".subtitle{color:#888;margin-bottom:20px;font-size:13px}" | ||
| 391 | ".networks{margin-top:16px;text-align:left}" | ||
| 392 | ".net-item{background:#252525;border:1px solid #333;border-radius:8px;" | ||
| 393 | "padding:12px;margin-bottom:8px;cursor:pointer;display:flex;justify-content:space-between;align-items:center}" | ||
| 394 | ".net-item:hover{border-color:#f7931a}" | ||
| 395 | ".net-item:active{background:#333}" | ||
| 396 | ".net-ssid{font-size:14px}" | ||
| 397 | ".net-rssi{font-size:11px;color:#888}" | ||
| 398 | ".net-lock{color:#f7931a;margin-right:4px}" | ||
| 399 | ".manual{margin-top:12px}" | ||
| 400 | "input{width:100%;background:#252525;border:1px solid #333;border-radius:8px;" | ||
| 401 | "color:#fff;padding:12px;font-size:14px;margin-bottom:8px;outline:none}" | ||
| 402 | "input:focus{border-color:#f7931a}" | ||
| 403 | ".btn{background:#f7931a;color:#000;border:none;border-radius:8px;padding:14px 28px;" | ||
| 404 | "font-size:16px;font-weight:bold;cursor:pointer;width:100%;margin-top:8px}" | ||
| 405 | ".btn:hover{background:#e8850f}" | ||
| 406 | ".btn:disabled{background:#333;color:#666;cursor:not-allowed}" | ||
| 407 | "#status{margin-top:12px;padding:10px;border-radius:8px;display:none;font-size:13px}" | ||
| 408 | "#status.success{display:block;background:#1a472a;color:#4caf50}" | ||
| 409 | "#status.error{display:block;background:#471a1a;color:#f44336}" | ||
| 410 | "#status.processing{display:block;background:#1a3a47;color:#2196f3}" | ||
| 411 | ".refresh{background:none;border:1px solid #444;color:#aaa;border-radius:6px;" | ||
| 412 | "padding:6px 12px;font-size:12px;cursor:pointer;margin-top:4px}" | ||
| 413 | ".refresh:hover{border-color:#f7931a;color:#f7931a}" | ||
| 414 | "#manualForm{display:none;margin-top:12px}" | ||
| 415 | "</style>" | ||
| 416 | "</head><body>" | ||
| 417 | "<div class='card'>" | ||
| 418 | "<h1>TollGate Setup</h1>" | ||
| 419 | "<p class='subtitle'>Configure upstream WiFi</p>" | ||
| 420 | "<div id='scanStatus'>Scanning...</div>" | ||
| 421 | "<div class='networks' id='networkList'></div>" | ||
| 422 | "<button class='refresh' onclick='scanWifi()'>Rescan</button>" | ||
| 423 | "<button class='refresh' onclick='showManual()'>Manual entry</button>" | ||
| 424 | "<div id='manualForm'>" | ||
| 425 | "<input id='manualSsid' placeholder='SSID'>" | ||
| 426 | "<input id='manualPass' type='password' placeholder='Password'>" | ||
| 427 | "<button class='btn' onclick='connectManual()'>Connect</button>" | ||
| 428 | "</div>" | ||
| 429 | "<div id='passwordForm' style='display:none'>" | ||
| 430 | "<p style='margin:12px 0 8px;text-align:left' id='selectedNetwork'></p>" | ||
| 431 | "<input id='wifiPass' type='password' placeholder='WiFi password'>" | ||
| 432 | "<button class='btn' onclick='connectSelected()'>Connect</button>" | ||
| 433 | "</div>" | ||
| 434 | "<div id='status'></div>" | ||
| 435 | "</div>" | ||
| 436 | "<script>" | ||
| 437 | "const apIp='__AP_IP__';" | ||
| 438 | "let selectedSsid='';" | ||
| 439 | "function showStatus(msg,type){const s=document.getElementById('status');" | ||
| 440 | "s.textContent=msg;s.className=type;}" | ||
| 441 | "function scanWifi(){" | ||
| 442 | "document.getElementById('scanStatus').textContent='Scanning...';" | ||
| 443 | "document.getElementById('networkList').innerHTML='';" | ||
| 444 | "fetch('/wifi/scan').then(r=>r.json()).then(aps=>{" | ||
| 445 | "document.getElementById('scanStatus').textContent=aps.length+' networks found';" | ||
| 446 | "const list=document.getElementById('networkList');" | ||
| 447 | "aps.forEach(ap=>{" | ||
| 448 | "const div=document.createElement('div');" | ||
| 449 | "div.className='net-item';" | ||
| 450 | "const lock=ap.secured?'<span class=net-lock>🔒</span>':'';" | ||
| 451 | "div.innerHTML='<span class=net-ssid>'+lock+ap.ssid+'</span>" | ||
| 452 | "<span class=net-rssi>'+ap.rssi+' dBm</span>';" | ||
| 453 | "div.onclick=()=>selectNetwork(ap.ssid,ap.secured);" | ||
| 454 | "list.appendChild(div);" | ||
| 455 | "});" | ||
| 456 | "}).catch(e=>{document.getElementById('scanStatus').textContent='Scan failed';});" | ||
| 457 | "}" | ||
| 458 | "function selectNetwork(ssid,secured){" | ||
| 459 | "selectedSsid=ssid;" | ||
| 460 | "document.getElementById('selectedNetwork').textContent='Connect to: '+ssid;" | ||
| 461 | "document.getElementById('passwordForm').style.display='block';" | ||
| 462 | "document.getElementById('scanStatus').style.display='none';" | ||
| 463 | "document.getElementById('networkList').style.display='none';" | ||
| 464 | "document.querySelector('.refresh').style.display='none';" | ||
| 465 | "if(!secured){connectSelected();}" | ||
| 466 | "}" | ||
| 467 | "function showManual(){" | ||
| 468 | "document.getElementById('manualForm').style.display='block';" | ||
| 469 | "}" | ||
| 470 | "function connectSelected(){" | ||
| 471 | "const pass=document.getElementById('wifiPass').value;" | ||
| 472 | "doConnect(selectedSsid,pass);" | ||
| 473 | "}" | ||
| 474 | "function connectManual(){" | ||
| 475 | "const ssid=document.getElementById('manualSsid').value.trim();" | ||
| 476 | "const pass=document.getElementById('manualPass').value;" | ||
| 477 | "if(!ssid){showStatus('Enter SSID','error');return;}" | ||
| 478 | "doConnect(ssid,pass);" | ||
| 479 | "}" | ||
| 480 | "function doConnect(ssid,pass){" | ||
| 481 | "showStatus('Connecting to '+ssid+'...','processing');" | ||
| 482 | "fetch('/wifi/connect',{method:'POST',headers:{'Content-Type':'application/json'}," | ||
| 483 | "body:JSON.stringify({ssid:ssid,password:pass})})" | ||
| 484 | ".then(r=>r.json()).then(d=>{" | ||
| 485 | "if(d.ok){showStatus('Connected! Device is restarting...','success');}" | ||
| 486 | "else{showStatus('Failed: '+(d.error||'unknown'),'error');}" | ||
| 487 | "}).catch(e=>{showStatus('Connection error','error');});" | ||
| 488 | "}" | ||
| 489 | "scanWifi();" | ||
| 490 | "</script>" | ||
| 491 | "</body></html>"; | ||
| 492 | |||
| 493 | static char *template_replace(const char *tpl, const char *key, const char *val) { | ||
| 494 | const char *p; | ||
| 495 | size_t klen = strlen(key); | ||
| 496 | size_t vlen = strlen(val); | ||
| 497 | size_t tlen = strlen(tpl); | ||
| 498 | size_t extra = 0; | ||
| 499 | p = tpl; | ||
| 500 | while ((p = strstr(p, key)) != NULL) { | ||
| 501 | extra += vlen - klen; | ||
| 502 | p += klen; | ||
| 503 | } | ||
| 504 | size_t out_size = tlen + extra + 1; | ||
| 505 | char *out = malloc(out_size); | ||
| 506 | if (!out) return NULL; | ||
| 507 | char *dst = out; | ||
| 508 | p = tpl; | ||
| 509 | while (*p) { | ||
| 510 | const char *found = strstr(p, key); | ||
| 511 | if (found) { | ||
| 512 | memcpy(dst, p, found - p); | ||
| 513 | dst += found - p; | ||
| 514 | memcpy(dst, val, vlen); | ||
| 515 | dst += vlen; | ||
| 516 | p = found + klen; | ||
| 517 | } else { | ||
| 518 | strcpy(dst, p); | ||
| 519 | dst += strlen(p); | ||
| 520 | break; | ||
| 521 | } | ||
| 522 | } | ||
| 523 | *dst = '\0'; | ||
| 524 | return out; | ||
| 525 | } | ||
| 526 | |||
| 527 | static bool is_setup_available(void) { | ||
| 528 | const tollgate_config_t *cfg = tollgate_config_get(); | ||
| 529 | return cfg->network_count == 0; | ||
| 530 | } | ||
| 531 | |||
| 532 | static esp_err_t setup_page_handler(httpd_req_t *req) { | ||
| 533 | if (!is_setup_available()) { | ||
| 534 | httpd_resp_set_status(req, "302 Found"); | ||
| 535 | char location[64]; | ||
| 536 | snprintf(location, sizeof(location), "http://%s/", s_ap_ip_str); | ||
| 537 | httpd_resp_set_hdr(req, "Location", location); | ||
| 538 | httpd_resp_send(req, NULL, 0); | ||
| 539 | return ESP_OK; | ||
| 540 | } | ||
| 541 | |||
| 542 | httpd_resp_set_type(req, "text/html"); | ||
| 543 | char *html = template_replace(SETUP_HTML_TEMPLATE, "__AP_IP__", s_ap_ip_str); | ||
| 544 | if (!html) { | ||
| 545 | httpd_resp_send_500(req); | ||
| 546 | return ESP_OK; | ||
| 547 | } | ||
| 548 | httpd_resp_send(req, html, strlen(html)); | ||
| 549 | free(html); | ||
| 550 | return ESP_OK; | ||
| 551 | } | ||
| 552 | |||
| 553 | static esp_err_t wifi_scan_handler(httpd_req_t *req) { | ||
| 554 | esp_wifi_disconnect(); | ||
| 555 | vTaskDelay(pdMS_TO_TICKS(300)); | ||
| 556 | |||
| 557 | wifi_scan_config_t scan_cfg = {0}; | ||
| 558 | scan_cfg.scan_type = WIFI_SCAN_TYPE_ACTIVE; | ||
| 559 | scan_cfg.scan_time.active.min = 100; | ||
| 560 | scan_cfg.scan_time.active.max = 300; | ||
| 561 | esp_err_t ret = esp_wifi_scan_start(&scan_cfg, true); | ||
| 562 | if (ret != ESP_OK) { | ||
| 563 | httpd_resp_set_type(req, "application/json"); | ||
| 564 | httpd_resp_send(req, "[]", 2); | ||
| 565 | return ESP_OK; | ||
| 566 | } | ||
| 567 | |||
| 568 | uint16_t ap_count = 0; | ||
| 569 | esp_wifi_scan_get_ap_num(&ap_count); | ||
| 570 | if (ap_count > 20) ap_count = 20; | ||
| 571 | wifi_ap_record_t aps[20]; | ||
| 572 | esp_wifi_scan_get_ap_records(&ap_count, aps); | ||
| 573 | |||
| 574 | for (int i = 0; i < (int)ap_count - 1; i++) { | ||
| 575 | for (int j = i + 1; j < (int)ap_count; j++) { | ||
| 576 | if (aps[j].rssi > aps[i].rssi) { | ||
| 577 | wifi_ap_record_t tmp = aps[i]; | ||
| 578 | aps[i] = aps[j]; | ||
| 579 | aps[j] = tmp; | ||
| 580 | } | ||
| 581 | } | ||
| 582 | } | ||
| 583 | |||
| 584 | cJSON *root = cJSON_CreateArray(); | ||
| 585 | for (int i = 0; i < (int)ap_count; i++) { | ||
| 586 | if (aps[i].ssid[0] == '\0') continue; | ||
| 587 | cJSON *ap = cJSON_CreateObject(); | ||
| 588 | cJSON_AddStringToObject(ap, "ssid", (const char *)aps[i].ssid); | ||
| 589 | cJSON_AddNumberToObject(ap, "rssi", aps[i].rssi); | ||
| 590 | cJSON_AddBoolToObject(ap, "secured", aps[i].authmode != WIFI_AUTH_OPEN); | ||
| 591 | cJSON_AddItemToArray(root, ap); | ||
| 592 | } | ||
| 593 | |||
| 594 | char *json = cJSON_PrintUnformatted(root); | ||
| 595 | httpd_resp_set_type(req, "application/json"); | ||
| 596 | httpd_resp_send(req, json, strlen(json)); | ||
| 597 | cJSON_free(json); | ||
| 598 | cJSON_Delete(root); | ||
| 599 | |||
| 600 | const tollgate_config_t *cfg = tollgate_config_get(); | ||
| 601 | if (cfg->network_count > 0) { | ||
| 602 | wifi_config_t wifi_cfg; | ||
| 603 | if (tollgate_config_get_wifi(&wifi_cfg) == ESP_OK) { | ||
| 604 | esp_wifi_set_config(WIFI_IF_STA, &wifi_cfg); | ||
| 605 | esp_wifi_connect(); | ||
| 606 | } | ||
| 607 | } | ||
| 608 | |||
| 609 | return ESP_OK; | ||
| 610 | } | ||
| 611 | |||
| 612 | static esp_err_t wifi_connect_handler(httpd_req_t *req) { | ||
| 613 | int content_len = req->content_len; | ||
| 614 | if (content_len <= 0 || content_len > 1024) { | ||
| 615 | httpd_resp_set_type(req, "application/json"); | ||
| 616 | httpd_resp_send(req, "{\"ok\":false,\"error\":\"invalid request\"}", HTTPD_RESP_USE_STRLEN); | ||
| 617 | return ESP_OK; | ||
| 618 | } | ||
| 619 | |||
| 620 | char *body = malloc(content_len + 1); | ||
| 621 | if (!body) { | ||
| 622 | httpd_resp_send_500(req); | ||
| 623 | return ESP_OK; | ||
| 624 | } | ||
| 625 | int total = 0; | ||
| 626 | while (total < content_len) { | ||
| 627 | int r = httpd_req_recv(req, body + total, content_len - total); | ||
| 628 | if (r <= 0) { free(body); httpd_resp_send_500(req); return ESP_OK; } | ||
| 629 | total += r; | ||
| 630 | } | ||
| 631 | body[total] = '\0'; | ||
| 632 | |||
| 633 | cJSON *json = cJSON_Parse(body); | ||
| 634 | free(body); | ||
| 635 | if (!json) { | ||
| 636 | httpd_resp_set_type(req, "application/json"); | ||
| 637 | httpd_resp_send(req, "{\"ok\":false,\"error\":\"invalid JSON\"}", HTTPD_RESP_USE_STRLEN); | ||
| 638 | return ESP_OK; | ||
| 639 | } | ||
| 640 | |||
| 641 | cJSON *ssid_item = cJSON_GetObjectItem(json, "ssid"); | ||
| 642 | cJSON *pass_item = cJSON_GetObjectItem(json, "password"); | ||
| 643 | if (!ssid_item || !cJSON_IsString(ssid_item)) { | ||
| 644 | cJSON_Delete(json); | ||
| 645 | httpd_resp_set_type(req, "application/json"); | ||
| 646 | httpd_resp_send(req, "{\"ok\":false,\"error\":\"missing ssid\"}", HTTPD_RESP_USE_STRLEN); | ||
| 647 | return ESP_OK; | ||
| 648 | } | ||
| 649 | |||
| 650 | const char *ssid = ssid_item->valuestring; | ||
| 651 | const char *password = (pass_item && cJSON_IsString(pass_item)) ? pass_item->valuestring : ""; | ||
| 652 | |||
| 653 | esp_err_t err = tollgate_config_add_wifi(ssid, password); | ||
| 654 | if (err != ESP_OK) { | ||
| 655 | cJSON_Delete(json); | ||
| 656 | httpd_resp_set_type(req, "application/json"); | ||
| 657 | httpd_resp_send(req, "{\"ok\":false,\"error\":\"save failed\"}", HTTPD_RESP_USE_STRLEN); | ||
| 658 | return ESP_OK; | ||
| 659 | } | ||
| 660 | |||
| 661 | wifi_config_t wifi_cfg = {0}; | ||
| 662 | strncpy((char *)wifi_cfg.sta.ssid, ssid, sizeof(wifi_cfg.sta.ssid) - 1); | ||
| 663 | strncpy((char *)wifi_cfg.sta.password, password, sizeof(wifi_cfg.sta.password) - 1); | ||
| 664 | wifi_cfg.sta.threshold.authmode = WIFI_AUTH_WPA2_PSK; | ||
| 665 | esp_wifi_set_config(WIFI_IF_STA, &wifi_cfg); | ||
| 666 | esp_wifi_connect(); | ||
| 667 | |||
| 668 | cJSON_Delete(json); | ||
| 669 | |||
| 670 | httpd_resp_set_type(req, "application/json"); | ||
| 671 | httpd_resp_send(req, "{\"ok\":true}", HTTPD_RESP_USE_STRLEN); | ||
| 672 | return ESP_OK; | ||
| 673 | } | ||
| 674 | |||
| 675 | static esp_err_t wifi_status_handler(httpd_req_t *req) { | ||
| 676 | wifi_ap_record_t ap_info; | ||
| 677 | bool connected = (esp_wifi_sta_get_ap_info(&ap_info) == ESP_OK); | ||
| 678 | |||
| 679 | cJSON *root = cJSON_CreateObject(); | ||
| 680 | cJSON_AddBoolToObject(root, "connected", connected); | ||
| 681 | |||
| 682 | if (connected) { | ||
| 683 | esp_netif_t *netif = esp_netif_get_handle_from_ifkey("WIFI_STA_DEF"); | ||
| 684 | if (netif) { | ||
| 685 | esp_netif_ip_info_t ip_info; | ||
| 686 | if (esp_netif_get_ip_info(netif, &ip_info) == ESP_OK) { | ||
| 687 | char ip_str[16]; | ||
| 688 | snprintf(ip_str, sizeof(ip_str), IPSTR, IP2STR(&ip_info.ip)); | ||
| 689 | cJSON_AddStringToObject(root, "ip", ip_str); | ||
| 690 | } | ||
| 691 | } | ||
| 692 | cJSON_AddStringToObject(root, "ssid", (const char *)ap_info.ssid); | ||
| 693 | } | ||
| 694 | |||
| 695 | char *json = cJSON_PrintUnformatted(root); | ||
| 696 | httpd_resp_set_type(req, "application/json"); | ||
| 697 | httpd_resp_send(req, json, strlen(json)); | ||
| 698 | cJSON_free(json); | ||
| 699 | cJSON_Delete(root); | ||
| 700 | return ESP_OK; | ||
| 701 | } | ||
| 702 | |||
| 703 | static const httpd_uri_t uri_setup = { .uri = "/setup", .method = HTTP_GET, .handler = setup_page_handler }; | ||
| 704 | static const httpd_uri_t uri_wifi_scan = { .uri = "/wifi/scan", .method = HTTP_GET, .handler = wifi_scan_handler }; | ||
| 705 | static const httpd_uri_t uri_wifi_connect = { .uri = "/wifi/connect", .method = HTTP_POST, .handler = wifi_connect_handler }; | ||
| 706 | static const httpd_uri_t uri_wifi_status = { .uri = "/wifi/status", .method = HTTP_GET, .handler = wifi_status_handler }; | ||
| 372 | 707 | ||
| 373 | esp_err_t captive_portal_start(const char *ap_ip_str) | 708 | esp_err_t captive_portal_start(const char *ap_ip_str) |
| 374 | { | 709 | { |
| @@ -377,7 +712,6 @@ esp_err_t captive_portal_start(const char *ap_ip_str) | |||
| 377 | 712 | ||
| 378 | httpd_config_t config = HTTPD_DEFAULT_CONFIG(); | 713 | httpd_config_t config = HTTPD_DEFAULT_CONFIG(); |
| 379 | config.max_uri_handlers = 20; | 714 | config.max_uri_handlers = 20; |
| 380 | config.uri_match_fn = httpd_uri_match_wildcard; | ||
| 381 | 715 | ||
| 382 | esp_err_t ret = httpd_start(&s_server, &config); | 716 | esp_err_t ret = httpd_start(&s_server, &config); |
| 383 | if (ret != ESP_OK) { | 717 | if (ret != ESP_OK) { |
| @@ -398,7 +732,15 @@ esp_err_t captive_portal_start(const char *ap_ip_str) | |||
| 398 | httpd_register_uri_handler(s_server, &uri_ncsi); | 732 | httpd_register_uri_handler(s_server, &uri_ncsi); |
| 399 | httpd_register_uri_handler(s_server, &uri_connecttest); | 733 | httpd_register_uri_handler(s_server, &uri_connecttest); |
| 400 | httpd_register_uri_handler(s_server, &uri_wpad); | 734 | httpd_register_uri_handler(s_server, &uri_wpad); |
| 401 | httpd_register_uri_handler(s_server, &uri_catchall); | 735 | httpd_register_uri_handler(s_server, &uri_setup); |
| 736 | ret = httpd_register_uri_handler(s_server, &uri_wifi_scan); | ||
| 737 | ESP_LOGI(TAG, "Registered /wifi/scan: %s", esp_err_to_name(ret)); | ||
| 738 | ret = httpd_register_uri_handler(s_server, &uri_wifi_connect); | ||
| 739 | ESP_LOGI(TAG, "Registered /wifi/connect: %s", esp_err_to_name(ret)); | ||
| 740 | ret = httpd_register_uri_handler(s_server, &uri_wifi_status); | ||
| 741 | ESP_LOGI(TAG, "Registered /wifi/status: %s", esp_err_to_name(ret)); | ||
| 742 | |||
| 743 | httpd_register_err_handler(s_server, HTTPD_404_NOT_FOUND, catchall_err_handler); | ||
| 402 | 744 | ||
| 403 | ESP_LOGI(TAG, "Captive portal started on port 80"); | 745 | ESP_LOGI(TAG, "Captive portal started on port 80"); |
| 404 | return ESP_OK; | 746 | return ESP_OK; |
diff --git a/main/captive_portal.h b/main/captive_portal.h index 06eb860..e02a4ce 100644 --- a/main/captive_portal.h +++ b/main/captive_portal.h | |||
| @@ -7,5 +7,6 @@ | |||
| 7 | esp_err_t captive_portal_start(const char *ap_ip_str); | 7 | esp_err_t captive_portal_start(const char *ap_ip_str); |
| 8 | void captive_portal_stop(void); | 8 | void captive_portal_stop(void); |
| 9 | httpd_handle_t captive_portal_get_server(void); | 9 | httpd_handle_t captive_portal_get_server(void); |
| 10 | bool captive_portal_is_setup_available(void); | ||
| 10 | 11 | ||
| 11 | #endif | 12 | #endif |
diff --git a/main/config.h b/main/config.h index 50f7efb..3092306 100644 --- a/main/config.h +++ b/main/config.h | |||
| @@ -105,5 +105,6 @@ esp_err_t tollgate_config_init(void); | |||
| 105 | const tollgate_config_t *tollgate_config_get(void); | 105 | const tollgate_config_t *tollgate_config_get(void); |
| 106 | esp_err_t tollgate_config_get_wifi(wifi_config_t *wifi_config); | 106 | esp_err_t tollgate_config_get_wifi(wifi_config_t *wifi_config); |
| 107 | esp_err_t tollgate_config_get_next_wifi(wifi_config_t *wifi_config); | 107 | esp_err_t tollgate_config_get_next_wifi(wifi_config_t *wifi_config); |
| 108 | esp_err_t tollgate_config_add_wifi(const char *ssid, const char *password); | ||
| 108 | 109 | ||
| 109 | #endif | 110 | #endif |
diff --git a/main/display.c b/main/display.c index 2b6cc88..ccd08b7 100644 --- a/main/display.c +++ b/main/display.c | |||
| @@ -2,7 +2,10 @@ | |||
| 2 | #include "axs15231b.h" | 2 | #include "axs15231b.h" |
| 3 | #include "qrcoded.h" | 3 | #include "qrcoded.h" |
| 4 | #include "font.h" | 4 | #include "font.h" |
| 5 | #include "nucula_wallet.h" | ||
| 6 | #include "config.h" | ||
| 5 | #include "esp_log.h" | 7 | #include "esp_log.h" |
| 8 | #include "esp_wifi.h" | ||
| 6 | #include "freertos/FreeRTOS.h" | 9 | #include "freertos/FreeRTOS.h" |
| 7 | #include "freertos/task.h" | 10 | #include "freertos/task.h" |
| 8 | #include <string.h> | 11 | #include <string.h> |
| @@ -12,15 +15,36 @@ | |||
| 12 | static const char *TAG = "display"; | 15 | static const char *TAG = "display"; |
| 13 | 16 | ||
| 14 | #define QR_CYCLE_MS 5000 | 17 | #define QR_CYCLE_MS 5000 |
| 18 | #define RENDER_INTERVAL_MS 2000 | ||
| 19 | |||
| 20 | #define COLOR_BG 0x0000 | ||
| 21 | #define COLOR_WHITE 0xFFFF | ||
| 22 | #define COLOR_CYAN 0x07FF | ||
| 23 | #define COLOR_YELLOW 0xFFE0 | ||
| 24 | #define COLOR_GREEN 0x07E0 | ||
| 25 | #define COLOR_ORANGE 0xFD20 | ||
| 26 | #define COLOR_RED 0xF800 | ||
| 27 | #define COLOR_DIM 0x8410 | ||
| 15 | 28 | ||
| 16 | static volatile display_state_t s_state = DISPLAY_BOOT; | 29 | static volatile display_state_t s_state = DISPLAY_BOOT; |
| 17 | static char s_ap_ssid[32] = ""; | 30 | static char s_ap_ssid[32] = ""; |
| 18 | static char s_portal_url[256] = ""; | 31 | static char s_portal_url[256] = ""; |
| 32 | static char s_mint_url[256] = ""; | ||
| 33 | static char s_wifi_status[32] = "starting..."; | ||
| 19 | static int s_active_clients = 0; | 34 | static int s_active_clients = 0; |
| 20 | static uint64_t s_wallet_balance = 0; | 35 | static uint64_t s_wallet_balance = 0; |
| 36 | static int s_price_per_step = 0; | ||
| 21 | static bool s_initialized = false; | 37 | static bool s_initialized = false; |
| 22 | static int64_t s_last_qr_switch = 0; | 38 | static int64_t s_last_qr_switch = 0; |
| 23 | static display_qr_mode_t s_qr_mode = DISPLAY_QR_WIFI; | 39 | static display_qr_mode_t s_qr_mode = DISPLAY_QR_WIFI; |
| 40 | static int s_last_payment_sats = 0; | ||
| 41 | static int64_t s_last_allotment_ms = 0; | ||
| 42 | |||
| 43 | static uint16_t wallet_color(void) { | ||
| 44 | if (s_wallet_balance == 0) return COLOR_RED; | ||
| 45 | if (s_wallet_balance < 100) return COLOR_YELLOW; | ||
| 46 | return COLOR_GREEN; | ||
| 47 | } | ||
| 24 | 48 | ||
| 25 | static int qr_version_from_strlen(int len) { | 49 | static int qr_version_from_strlen(int len) { |
| 26 | if (len <= 17) return 1; | 50 | if (len <= 17) return 1; |
| @@ -59,10 +83,60 @@ static int escape_wifi_field(const char *src, char *dst, int dst_size) { | |||
| 59 | return di; | 83 | return di; |
| 60 | } | 84 | } |
| 61 | 85 | ||
| 86 | static void extract_domain(const char *url, char *out, int out_size) { | ||
| 87 | const char *start = url; | ||
| 88 | if (strncmp(url, "https://", 8) == 0) start = url + 8; | ||
| 89 | else if (strncmp(url, "http://", 7) == 0) start = url + 7; | ||
| 90 | strncpy(out, start, out_size - 1); | ||
| 91 | out[out_size - 1] = '\0'; | ||
| 92 | char *slash = strchr(out, '/'); | ||
| 93 | if (slash) *slash = '\0'; | ||
| 94 | } | ||
| 95 | |||
| 62 | static void build_wifi_qr_string(char *out, int out_size) { | 96 | static void build_wifi_qr_string(char *out, int out_size) { |
| 63 | char escaped_ssid[64]; | 97 | char escaped_ssid[64]; |
| 64 | escape_wifi_field(s_ap_ssid, escaped_ssid, sizeof(escaped_ssid)); | 98 | escape_wifi_field(s_ap_ssid, escaped_ssid, sizeof(escaped_ssid)); |
| 65 | snprintf(out, out_size, "WIFI:S:%s;T:nopass;;", escaped_ssid); | 99 | const tollgate_config_t *cfg = tollgate_config_get(); |
| 100 | if (strlen(cfg->ap_password) > 0) { | ||
| 101 | char escaped_pass[128]; | ||
| 102 | escape_wifi_field(cfg->ap_password, escaped_pass, sizeof(escaped_pass)); | ||
| 103 | snprintf(out, out_size, "WIFI:S:%s;T:WPA;P:%s;;", escaped_ssid, escaped_pass); | ||
| 104 | } else { | ||
| 105 | snprintf(out, out_size, "WIFI:S:%s;T:nopass;;", escaped_ssid); | ||
| 106 | } | ||
| 107 | } | ||
| 108 | |||
| 109 | static void render_qr_at(const char *text, int x_off, int y_off, int max_w, int max_h) { | ||
| 110 | int len = strlen(text); | ||
| 111 | int version = qr_version_from_strlen(len); | ||
| 112 | int px = qr_pixel_size(len); | ||
| 113 | |||
| 114 | uint16_t buf_size = qrcode_getBufferSize(version); | ||
| 115 | uint8_t *qr_buf = (uint8_t *)malloc(buf_size); | ||
| 116 | if (!qr_buf) return; | ||
| 117 | |||
| 118 | QRCode qrcode; | ||
| 119 | if (qrcode_initText(&qrcode, qr_buf, version, ECC_LOW, text) != 0) { | ||
| 120 | free(qr_buf); | ||
| 121 | return; | ||
| 122 | } | ||
| 123 | |||
| 124 | int qr_px_w = qrcode.size * px; | ||
| 125 | int qr_px_h = qrcode.size * px; | ||
| 126 | int cx = x_off + (max_w - qr_px_w) / 2; | ||
| 127 | int cy = y_off + (max_h - qr_px_h) / 2; | ||
| 128 | if (cx < 0) cx = 0; | ||
| 129 | if (cy < 0) cy = 0; | ||
| 130 | |||
| 131 | for (int y = 0; y < qrcode.size; y++) { | ||
| 132 | for (int x = 0; x < qrcode.size; x++) { | ||
| 133 | bool mod = qrcode_getModule(&qrcode, x, y); | ||
| 134 | uint16_t color = mod ? COLOR_WHITE : COLOR_BG; | ||
| 135 | axs15231b_fill_rect(cx + x * px, cy + y * px, px, px, color); | ||
| 136 | } | ||
| 137 | } | ||
| 138 | |||
| 139 | free(qr_buf); | ||
| 66 | } | 140 | } |
| 67 | 141 | ||
| 68 | void display_render_text(int x, int y, const char *text, uint16_t fg, uint16_t bg, int scale) { | 142 | void display_render_text(int x, int y, const char *text, uint16_t fg, uint16_t bg, int scale) { |
| @@ -98,99 +172,206 @@ void display_render_text(int x, int y, const char *text, uint16_t fg, uint16_t b | |||
| 98 | } | 172 | } |
| 99 | } | 173 | } |
| 100 | 174 | ||
| 101 | static void render_qr_at(const char *text, int x_off, int y_off, int max_w, int max_h) { | ||
| 102 | int len = strlen(text); | ||
| 103 | int version = qr_version_from_strlen(len); | ||
| 104 | int px = qr_pixel_size(len); | ||
| 105 | |||
| 106 | uint16_t buf_size = qrcode_getBufferSize(version); | ||
| 107 | uint8_t *qr_buf = (uint8_t *)malloc(buf_size); | ||
| 108 | if (!qr_buf) { | ||
| 109 | ESP_LOGE(TAG, "Failed to allocate QR buffer"); | ||
| 110 | return; | ||
| 111 | } | ||
| 112 | |||
| 113 | QRCode qr; | ||
| 114 | if (qrcode_initText(&qr, qr_buf, version, ECC_LOW, text) != 0) { | ||
| 115 | ESP_LOGE(TAG, "QR generation failed"); | ||
| 116 | free(qr_buf); | ||
| 117 | return; | ||
| 118 | } | ||
| 119 | |||
| 120 | int qr_px_w = qr.size * px; | ||
| 121 | int qr_px_h = qr.size * px; | ||
| 122 | int cx = x_off + (max_w - qr_px_w) / 2; | ||
| 123 | int cy = y_off + (max_h - qr_px_h) / 2; | ||
| 124 | if (cx < 0) cx = 0; | ||
| 125 | if (cy < 0) cy = 0; | ||
| 126 | |||
| 127 | for (int y = 0; y < qr.size; y++) { | ||
| 128 | for (int x = 0; x < qr.size; x++) { | ||
| 129 | bool mod = qrcode_getModule(&qr, x, y); | ||
| 130 | uint16_t color = mod ? 0xFFFF : 0x0000; | ||
| 131 | axs15231b_fill_rect(cx + x * px, cy + y * px, px, px, color); | ||
| 132 | } | ||
| 133 | } | ||
| 134 | |||
| 135 | free(qr_buf); | ||
| 136 | } | ||
| 137 | |||
| 138 | void display_render_qr(const char *text) { | 175 | void display_render_qr(const char *text) { |
| 139 | int screen_w = axs15231b_get_width(); | 176 | int screen_w = axs15231b_get_width(); |
| 140 | int screen_h = axs15231b_get_height(); | 177 | int screen_h = axs15231b_get_height(); |
| 141 | axs15231b_fill_screen(0x0000); | 178 | axs15231b_fill_screen(COLOR_BG); |
| 142 | render_qr_at(text, 0, 0, screen_w, screen_h); | 179 | render_qr_at(text, 0, 0, screen_w, screen_h); |
| 143 | axs15231b_flush(); | 180 | axs15231b_flush(); |
| 144 | } | 181 | } |
| 145 | 182 | ||
| 146 | static void render_boot_screen(void) { | 183 | static void render_boot_screen(void) { |
| 147 | axs15231b_fill_screen(0x0000); | 184 | int screen_w = axs15231b_get_width(); |
| 148 | display_render_text(140, 100, "TollGate", 0xF79F, 0x0000, 3); | 185 | axs15231b_fill_screen(COLOR_BG); |
| 149 | display_render_text(140, 140, "starting...", 0xB5B6, 0x0000, 2); | 186 | |
| 187 | char qr_text[320]; | ||
| 188 | build_wifi_qr_string(qr_text, sizeof(qr_text)); | ||
| 189 | render_qr_at(qr_text, 0, 10, screen_w, 220); | ||
| 190 | |||
| 191 | const char *title = "TollGate"; | ||
| 192 | int title_w = strlen(title) * 8 * 2; | ||
| 193 | display_render_text((screen_w - title_w) / 2, 240, title, COLOR_CYAN, COLOR_BG, 2); | ||
| 194 | |||
| 195 | int status_w = strlen(s_wifi_status) * 8; | ||
| 196 | display_render_text((screen_w - status_w) / 2, 268, s_wifi_status, COLOR_YELLOW, COLOR_BG, 1); | ||
| 197 | |||
| 198 | snprintf(qr_text, sizeof(qr_text), "SSID: %s", s_ap_ssid); | ||
| 199 | int ssid_w = strlen(qr_text) * 8; | ||
| 200 | display_render_text((screen_w - ssid_w) / 2, 295, qr_text, COLOR_DIM, COLOR_BG, 1); | ||
| 201 | |||
| 202 | const char *hint = "Scan QR to connect"; | ||
| 203 | int hint_w = strlen(hint) * 8; | ||
| 204 | display_render_text((screen_w - hint_w) / 2, 315, hint, COLOR_DIM, COLOR_BG, 1); | ||
| 205 | |||
| 150 | axs15231b_flush(); | 206 | axs15231b_flush(); |
| 151 | } | 207 | } |
| 152 | 208 | ||
| 153 | static void render_ready_screen(void) { | 209 | static void render_ready_screen(void) { |
| 154 | axs15231b_fill_screen(0x0000); | ||
| 155 | |||
| 156 | int screen_w = axs15231b_get_width(); | 210 | int screen_w = axs15231b_get_width(); |
| 157 | int screen_h = axs15231b_get_height(); | 211 | int text_area_y = 330; |
| 158 | int text_area_y = screen_h - 55; | 212 | axs15231b_fill_screen(COLOR_BG); |
| 159 | 213 | ||
| 160 | char qr_text[320]; | 214 | char qr_text[320]; |
| 161 | const char *label; | ||
| 162 | |||
| 163 | if (s_qr_mode == DISPLAY_QR_WIFI) { | 215 | if (s_qr_mode == DISPLAY_QR_WIFI) { |
| 164 | build_wifi_qr_string(qr_text, sizeof(qr_text)); | 216 | build_wifi_qr_string(qr_text, sizeof(qr_text)); |
| 165 | label = "Scan to connect"; | ||
| 166 | } else { | 217 | } else { |
| 167 | strncpy(qr_text, s_portal_url, sizeof(qr_text) - 1); | 218 | strncpy(qr_text, s_portal_url, sizeof(qr_text) - 1); |
| 168 | qr_text[sizeof(qr_text) - 1] = '\0'; | 219 | qr_text[sizeof(qr_text) - 1] = '\0'; |
| 169 | label = "Portal URL"; | ||
| 170 | } | 220 | } |
| 171 | 221 | ||
| 172 | render_qr_at(qr_text, 0, 0, screen_w, text_area_y - 5); | 222 | render_qr_at(qr_text, 0, 5, screen_w, text_area_y - 10); |
| 223 | |||
| 224 | int y = text_area_y; | ||
| 225 | char line[48]; | ||
| 226 | |||
| 227 | if (s_qr_mode == DISPLAY_QR_WIFI) { | ||
| 228 | snprintf(line, sizeof(line), "Scan to connect"); | ||
| 229 | display_render_text(10, y, line, COLOR_CYAN, COLOR_BG, 1); | ||
| 230 | y += 16; | ||
| 231 | |||
| 232 | snprintf(line, sizeof(line), "SSID: %s", s_ap_ssid); | ||
| 233 | display_render_text(10, y, line, COLOR_WHITE, COLOR_BG, 1); | ||
| 234 | y += 16; | ||
| 235 | } else { | ||
| 236 | snprintf(line, sizeof(line), "Portal URL"); | ||
| 237 | display_render_text(10, y, line, COLOR_CYAN, COLOR_BG, 1); | ||
| 238 | y += 16; | ||
| 239 | |||
| 240 | char domain[48]; | ||
| 241 | extract_domain(s_mint_url, domain, sizeof(domain)); | ||
| 242 | snprintf(line, sizeof(line), "Mint: %.30s", domain); | ||
| 243 | display_render_text(10, y, line, COLOR_ORANGE, COLOR_BG, 1); | ||
| 244 | y += 16; | ||
| 245 | } | ||
| 246 | |||
| 247 | snprintf(line, sizeof(line), "%d sats/min", s_price_per_step); | ||
| 248 | display_render_text(10, y, line, COLOR_ORANGE, COLOR_BG, 1); | ||
| 249 | y += 16; | ||
| 250 | |||
| 251 | snprintf(line, sizeof(line), "Wallet: %llu sats", (unsigned long long)s_wallet_balance); | ||
| 252 | display_render_text(10, y, line, wallet_color(), COLOR_BG, 1); | ||
| 253 | y += 16; | ||
| 254 | |||
| 255 | if (s_active_clients > 0) { | ||
| 256 | snprintf(line, sizeof(line), "Clients: %d", s_active_clients); | ||
| 257 | display_render_text(10, y, line, COLOR_GREEN, COLOR_BG, 1); | ||
| 258 | } | ||
| 259 | |||
| 260 | axs15231b_flush(); | ||
| 261 | } | ||
| 262 | |||
| 263 | static void render_setup_pending_screen(void) { | ||
| 264 | int screen_w = axs15231b_get_width(); | ||
| 265 | axs15231b_fill_screen(COLOR_BG); | ||
| 173 | 266 | ||
| 174 | display_render_text(10, text_area_y, label, 0xB5B6, 0x0000, 2); | 267 | char qr_text[320]; |
| 268 | build_wifi_qr_string(qr_text, sizeof(qr_text)); | ||
| 269 | render_qr_at(qr_text, 0, 5, screen_w, 280); | ||
| 175 | 270 | ||
| 271 | int y = 290; | ||
| 176 | char line[64]; | 272 | char line[64]; |
| 273 | |||
| 274 | const char *title = "WiFi Setup"; | ||
| 275 | int tw = strlen(title) * 8; | ||
| 276 | display_render_text((screen_w - tw) / 2, y, title, COLOR_CYAN, COLOR_BG, 1); | ||
| 277 | y += 20; | ||
| 278 | |||
| 177 | snprintf(line, sizeof(line), "SSID: %s", s_ap_ssid); | 279 | snprintf(line, sizeof(line), "SSID: %s", s_ap_ssid); |
| 178 | display_render_text(10, text_area_y + 20, line, 0xB5B6, 0x0000, 2); | 280 | display_render_text(10, y, line, COLOR_WHITE, COLOR_BG, 1); |
| 281 | y += 18; | ||
| 282 | |||
| 283 | const char *hint1 = "1. Connect to WiFi above"; | ||
| 284 | display_render_text(10, y, hint1, COLOR_DIM, COLOR_BG, 1); | ||
| 285 | y += 16; | ||
| 286 | |||
| 287 | const char *hint2 = "2. Open browser, go to:"; | ||
| 288 | display_render_text(10, y, hint2, COLOR_DIM, COLOR_BG, 1); | ||
| 289 | y += 18; | ||
| 290 | |||
| 291 | const tollgate_config_t *cfg = tollgate_config_get(); | ||
| 292 | snprintf(line, sizeof(line), "http://%s/setup", cfg->ap_ip_str); | ||
| 293 | display_render_text(10, y, line, COLOR_YELLOW, COLOR_BG, 1); | ||
| 294 | y += 22; | ||
| 295 | |||
| 296 | const char *hint3 = "3. Configure upstream WiFi"; | ||
| 297 | display_render_text(10, y, hint3, COLOR_DIM, COLOR_BG, 1); | ||
| 179 | 298 | ||
| 180 | axs15231b_flush(); | 299 | axs15231b_flush(); |
| 181 | } | 300 | } |
| 182 | 301 | ||
| 183 | static void render_payment_screen(void) { | 302 | static void render_payment_screen(void) { |
| 184 | axs15231b_fill_screen(0x07E0); | 303 | int screen_w = axs15231b_get_width(); |
| 185 | display_render_text(140, 100, "Paid!", 0x0000, 0x07E0, 3); | 304 | axs15231b_fill_screen(COLOR_BG); |
| 186 | display_render_text(130, 140, "Access granted", 0x0000, 0x07E0, 2); | 305 | |
| 306 | axs15231b_fill_rect(0, 190, screen_w, 50, COLOR_GREEN); | ||
| 307 | const char *msg = "ACCESS GRANTED"; | ||
| 308 | int msg_w = strlen(msg) * 8 * 2; | ||
| 309 | display_render_text((screen_w - msg_w) / 2, 202, msg, COLOR_WHITE, COLOR_GREEN, 2); | ||
| 310 | |||
| 311 | char line[48]; | ||
| 312 | |||
| 313 | snprintf(line, sizeof(line), "Paid: %d sats", s_last_payment_sats); | ||
| 314 | int lw = strlen(line) * 8; | ||
| 315 | display_render_text((screen_w - lw) / 2, 270, line, COLOR_WHITE, COLOR_BG, 1); | ||
| 316 | |||
| 317 | int64_t secs = s_last_allotment_ms / 1000; | ||
| 318 | if (secs >= 60) { | ||
| 319 | snprintf(line, sizeof(line), "Time: %lld min", (long long)(secs / 60)); | ||
| 320 | } else { | ||
| 321 | snprintf(line, sizeof(line), "Time: %lld sec", (long long)secs); | ||
| 322 | } | ||
| 323 | lw = strlen(line) * 8; | ||
| 324 | display_render_text((screen_w - lw) / 2, 290, line, COLOR_WHITE, COLOR_BG, 1); | ||
| 325 | |||
| 326 | snprintf(line, sizeof(line), "Wallet: %llu sats", (unsigned long long)s_wallet_balance); | ||
| 327 | lw = strlen(line) * 8; | ||
| 328 | display_render_text((screen_w - lw) / 2, 320, line, wallet_color(), COLOR_BG, 1); | ||
| 329 | |||
| 187 | axs15231b_flush(); | 330 | axs15231b_flush(); |
| 188 | } | 331 | } |
| 189 | 332 | ||
| 190 | static void render_error_screen(void) { | 333 | static void render_error_screen(void) { |
| 191 | axs15231b_fill_screen(0xF800); | 334 | int screen_w = axs15231b_get_width(); |
| 192 | display_render_text(120, 100, "No upstream", 0xFFFF, 0xF800, 3); | 335 | axs15231b_fill_screen(COLOR_BG); |
| 193 | display_render_text(130, 140, "Check config", 0xFFFF, 0xF800, 2); | 336 | |
| 337 | char qr_text[320]; | ||
| 338 | build_wifi_qr_string(qr_text, sizeof(qr_text)); | ||
| 339 | render_qr_at(qr_text, 0, 5, screen_w, 150); | ||
| 340 | |||
| 341 | axs15231b_fill_rect(0, 160, screen_w, 36, COLOR_RED); | ||
| 342 | const char *msg = "NO UPSTREAM"; | ||
| 343 | int msg_w = strlen(msg) * 8 * 2; | ||
| 344 | display_render_text((screen_w - msg_w) / 2, 170, msg, COLOR_WHITE, COLOR_RED, 2); | ||
| 345 | |||
| 346 | char line[64]; | ||
| 347 | int lw; | ||
| 348 | int y = 210; | ||
| 349 | |||
| 350 | const char *l1 = "Internet unavailable"; | ||
| 351 | lw = strlen(l1) * 8; | ||
| 352 | display_render_text((screen_w - lw) / 2, y, l1, COLOR_WHITE, COLOR_BG, 1); | ||
| 353 | y += 20; | ||
| 354 | |||
| 355 | const char *l3 = "AP still active"; | ||
| 356 | lw = strlen(l3) * 8; | ||
| 357 | display_render_text((screen_w - lw) / 2, y, l3, COLOR_GREEN, COLOR_BG, 1); | ||
| 358 | y += 20; | ||
| 359 | |||
| 360 | snprintf(line, sizeof(line), "SSID: %s", s_ap_ssid); | ||
| 361 | lw = strlen(line) * 8; | ||
| 362 | display_render_text((screen_w - lw) / 2, y, line, COLOR_DIM, COLOR_BG, 1); | ||
| 363 | y += 20; | ||
| 364 | |||
| 365 | const tollgate_config_t *cfg = tollgate_config_get(); | ||
| 366 | snprintf(line, sizeof(line), "http://%s/setup", cfg->ap_ip_str); | ||
| 367 | lw = strlen(line) * 8; | ||
| 368 | display_render_text((screen_w - lw) / 2, y, line, COLOR_YELLOW, COLOR_BG, 1); | ||
| 369 | y += 16; | ||
| 370 | |||
| 371 | const char *hint = "Scan QR to connect"; | ||
| 372 | lw = strlen(hint) * 8; | ||
| 373 | display_render_text((screen_w - lw) / 2, y, hint, COLOR_DIM, COLOR_BG, 1); | ||
| 374 | |||
| 194 | axs15231b_flush(); | 375 | axs15231b_flush(); |
| 195 | } | 376 | } |
| 196 | 377 | ||
| @@ -200,6 +381,14 @@ static void display_task(void *pvParameters) { | |||
| 200 | while (1) { | 381 | while (1) { |
| 201 | display_state_t state = s_state; | 382 | display_state_t state = s_state; |
| 202 | 383 | ||
| 384 | if (state == DISPLAY_READY) { | ||
| 385 | int64_t now = (int64_t)xTaskGetTickCount() * portTICK_PERIOD_MS; | ||
| 386 | if ((now - s_last_qr_switch) >= QR_CYCLE_MS) { | ||
| 387 | s_qr_mode = (s_qr_mode == DISPLAY_QR_WIFI) ? DISPLAY_QR_PORTAL : DISPLAY_QR_WIFI; | ||
| 388 | s_last_qr_switch = now; | ||
| 389 | } | ||
| 390 | } | ||
| 391 | |||
| 203 | switch (state) { | 392 | switch (state) { |
| 204 | case DISPLAY_BOOT: | 393 | case DISPLAY_BOOT: |
| 205 | render_boot_screen(); | 394 | render_boot_screen(); |
| @@ -209,21 +398,18 @@ static void display_task(void *pvParameters) { | |||
| 209 | break; | 398 | break; |
| 210 | case DISPLAY_PAYMENT_RECEIVED: | 399 | case DISPLAY_PAYMENT_RECEIVED: |
| 211 | render_payment_screen(); | 400 | render_payment_screen(); |
| 212 | vTaskDelay(pdMS_TO_TICKS(2000)); | 401 | vTaskDelay(pdMS_TO_TICKS(3000)); |
| 213 | s_state = DISPLAY_READY; | 402 | s_state = DISPLAY_READY; |
| 214 | break; | 403 | break; |
| 215 | case DISPLAY_ERROR: | 404 | case DISPLAY_ERROR: |
| 216 | render_error_screen(); | 405 | render_error_screen(); |
| 217 | break; | 406 | break; |
| 407 | case DISPLAY_SETUP_PENDING: | ||
| 408 | render_setup_pending_screen(); | ||
| 409 | break; | ||
| 218 | } | 410 | } |
| 219 | 411 | ||
| 220 | int64_t now = (int64_t)xTaskGetTickCount() * portTICK_PERIOD_MS; | 412 | vTaskDelay(pdMS_TO_TICKS(RENDER_INTERVAL_MS)); |
| 221 | if (state == DISPLAY_READY && (now - s_last_qr_switch) >= QR_CYCLE_MS) { | ||
| 222 | s_qr_mode = (s_qr_mode == DISPLAY_QR_WIFI) ? DISPLAY_QR_PORTAL : DISPLAY_QR_WIFI; | ||
| 223 | s_last_qr_switch = now; | ||
| 224 | } | ||
| 225 | |||
| 226 | vTaskDelay(pdMS_TO_TICKS(1000)); | ||
| 227 | } | 413 | } |
| 228 | } | 414 | } |
| 229 | 415 | ||
| @@ -239,7 +425,7 @@ esp_err_t display_init(void) { | |||
| 239 | s_initialized = true; | 425 | s_initialized = true; |
| 240 | s_last_qr_switch = (int64_t)xTaskGetTickCount() * portTICK_PERIOD_MS; | 426 | s_last_qr_switch = (int64_t)xTaskGetTickCount() * portTICK_PERIOD_MS; |
| 241 | 427 | ||
| 242 | xTaskCreatePinnedToCore(display_task, "display", 16384, NULL, 2, NULL, 1); | 428 | xTaskCreatePinnedToCore(display_task, "display", 24576, NULL, 2, NULL, 1); |
| 243 | 429 | ||
| 244 | ESP_LOGI(TAG, "Display initialized"); | 430 | ESP_LOGI(TAG, "Display initialized"); |
| 245 | return ESP_OK; | 431 | return ESP_OK; |
| @@ -250,7 +436,9 @@ void display_set_state(display_state_t state) { | |||
| 250 | } | 436 | } |
| 251 | 437 | ||
| 252 | void display_update(const char *ap_ssid, int active_clients, | 438 | void display_update(const char *ap_ssid, int active_clients, |
| 253 | uint64_t wallet_balance, const char *portal_url) { | 439 | uint64_t wallet_balance, const char *portal_url, |
| 440 | const char *mint_url, int price_per_step, | ||
| 441 | const char *wifi_status) { | ||
| 254 | if (ap_ssid) { | 442 | if (ap_ssid) { |
| 255 | strncpy(s_ap_ssid, ap_ssid, sizeof(s_ap_ssid) - 1); | 443 | strncpy(s_ap_ssid, ap_ssid, sizeof(s_ap_ssid) - 1); |
| 256 | s_ap_ssid[sizeof(s_ap_ssid) - 1] = '\0'; | 444 | s_ap_ssid[sizeof(s_ap_ssid) - 1] = '\0'; |
| @@ -259,6 +447,29 @@ void display_update(const char *ap_ssid, int active_clients, | |||
| 259 | strncpy(s_portal_url, portal_url, sizeof(s_portal_url) - 1); | 447 | strncpy(s_portal_url, portal_url, sizeof(s_portal_url) - 1); |
| 260 | s_portal_url[sizeof(s_portal_url) - 1] = '\0'; | 448 | s_portal_url[sizeof(s_portal_url) - 1] = '\0'; |
| 261 | } | 449 | } |
| 450 | if (mint_url) { | ||
| 451 | strncpy(s_mint_url, mint_url, sizeof(s_mint_url) - 1); | ||
| 452 | s_mint_url[sizeof(s_mint_url) - 1] = '\0'; | ||
| 453 | } | ||
| 454 | if (wifi_status) { | ||
| 455 | strncpy(s_wifi_status, wifi_status, sizeof(s_wifi_status) - 1); | ||
| 456 | s_wifi_status[sizeof(s_wifi_status) - 1] = '\0'; | ||
| 457 | } | ||
| 458 | if (price_per_step > 0) s_price_per_step = price_per_step; | ||
| 262 | s_active_clients = active_clients; | 459 | s_active_clients = active_clients; |
| 263 | s_wallet_balance = wallet_balance; | 460 | s_wallet_balance = wallet_balance; |
| 264 | } | 461 | } |
| 462 | |||
| 463 | void display_notify_payment(int amount_sats, int64_t allotment_ms) { | ||
| 464 | s_last_payment_sats = amount_sats; | ||
| 465 | s_last_allotment_ms = allotment_ms; | ||
| 466 | s_wallet_balance = nucula_wallet_balance(); | ||
| 467 | display_set_state(DISPLAY_PAYMENT_RECEIVED); | ||
| 468 | } | ||
| 469 | |||
| 470 | void display_notify_wifi_connected(const char *ip) { | ||
| 471 | (void)ip; | ||
| 472 | } | ||
| 473 | |||
| 474 | void display_notify_wifi_disconnected(void) { | ||
| 475 | } | ||
diff --git a/main/display.h b/main/display.h index 407521b..ecb76b6 100644 --- a/main/display.h +++ b/main/display.h | |||
| @@ -9,7 +9,8 @@ typedef enum { | |||
| 9 | DISPLAY_BOOT, | 9 | DISPLAY_BOOT, |
| 10 | DISPLAY_READY, | 10 | DISPLAY_READY, |
| 11 | DISPLAY_PAYMENT_RECEIVED, | 11 | DISPLAY_PAYMENT_RECEIVED, |
| 12 | DISPLAY_ERROR | 12 | DISPLAY_ERROR, |
| 13 | DISPLAY_SETUP_PENDING | ||
| 13 | } display_state_t; | 14 | } display_state_t; |
| 14 | 15 | ||
| 15 | typedef enum { | 16 | typedef enum { |
| @@ -20,7 +21,12 @@ typedef enum { | |||
| 20 | esp_err_t display_init(void); | 21 | esp_err_t display_init(void); |
| 21 | void display_set_state(display_state_t state); | 22 | void display_set_state(display_state_t state); |
| 22 | void display_update(const char *ap_ssid, int active_clients, | 23 | void display_update(const char *ap_ssid, int active_clients, |
| 23 | uint64_t wallet_balance, const char *portal_url); | 24 | uint64_t wallet_balance, const char *portal_url, |
| 25 | const char *mint_url, int price_per_step, | ||
| 26 | const char *wifi_status); | ||
| 27 | void display_notify_payment(int amount_sats, int64_t allotment_ms); | ||
| 28 | void display_notify_wifi_connected(const char *ip); | ||
| 29 | void display_notify_wifi_disconnected(void); | ||
| 24 | void display_render_text(int x, int y, const char *text, uint16_t fg, uint16_t bg, int scale); | 30 | void display_render_text(int x, int y, const char *text, uint16_t fg, uint16_t bg, int scale); |
| 25 | void display_render_qr(const char *text); | 31 | void display_render_qr(const char *text); |
| 26 | 32 | ||
diff --git a/main/keyboard.c b/main/keyboard.c new file mode 100644 index 0000000..d16135f --- /dev/null +++ b/main/keyboard.c | |||
| @@ -0,0 +1,186 @@ | |||
| 1 | #include "keyboard.h" | ||
| 2 | #include <string.h> | ||
| 3 | |||
| 4 | static const char *s_alpha_lower[] = { | ||
| 5 | "qwertyuiop", | ||
| 6 | "asdfghjkl", | ||
| 7 | "\001zxcvbnm\b", | ||
| 8 | "\002\003\004" | ||
| 9 | }; | ||
| 10 | |||
| 11 | static const char *s_alpha_upper[] = { | ||
| 12 | "QWERTYUIOP", | ||
| 13 | "ASDFGHJKL", | ||
| 14 | "\001ZXCVBNM\b", | ||
| 15 | "\002\003\004" | ||
| 16 | }; | ||
| 17 | |||
| 18 | static const char *s_numsym[] = { | ||
| 19 | "1234567890", | ||
| 20 | "-/:;()$&@\"", | ||
| 21 | "\001.,?!'\\b", | ||
| 22 | "\002\003\004" | ||
| 23 | }; | ||
| 24 | |||
| 25 | #define CTRL_SHIFT '\001' | ||
| 26 | #define CTRL_LAYER '\002' | ||
| 27 | #define CTRL_SPACE '\003' | ||
| 28 | #define CTRL_DONE '\004' | ||
| 29 | #define CTRL_BS '\b' | ||
| 30 | |||
| 31 | static kb_layout_t s_layout = { | ||
| 32 | .key_w = 28, | ||
| 33 | .key_h = 36, | ||
| 34 | .key_gap = 2, | ||
| 35 | .start_y = 70, | ||
| 36 | .screen_w = 320, | ||
| 37 | .row_count = 4, | ||
| 38 | }; | ||
| 39 | |||
| 40 | void kb_state_init(kb_state_t *st) { | ||
| 41 | if (!st) return; | ||
| 42 | memset(st, 0, sizeof(*st)); | ||
| 43 | st->layer = KB_ALPHA_LOWER; | ||
| 44 | st->reveal = false; | ||
| 45 | } | ||
| 46 | |||
| 47 | void kb_set_layout(const kb_layout_t *layout) { | ||
| 48 | if (layout) s_layout = *layout; | ||
| 49 | } | ||
| 50 | |||
| 51 | const kb_layout_t *kb_get_layout(void) { | ||
| 52 | return &s_layout; | ||
| 53 | } | ||
| 54 | |||
| 55 | static const char **get_layer(kb_layer_t layer) { | ||
| 56 | switch (layer) { | ||
| 57 | case KB_ALPHA_UPPER: return s_alpha_upper; | ||
| 58 | case KB_NUMSYM: return s_numsym; | ||
| 59 | default: return s_alpha_lower; | ||
| 60 | } | ||
| 61 | } | ||
| 62 | |||
| 63 | int kb_get_row_keys(int row, kb_layer_t layer, const char **keys_out) { | ||
| 64 | if (row < 0 || row >= s_layout.row_count) { | ||
| 65 | *keys_out = NULL; | ||
| 66 | return 0; | ||
| 67 | } | ||
| 68 | const char **layer_rows = get_layer(layer); | ||
| 69 | const char *row_str = layer_rows[row]; | ||
| 70 | *keys_out = row_str; | ||
| 71 | return (int)strlen(row_str); | ||
| 72 | } | ||
| 73 | |||
| 74 | static int row_x_offset(int row, int total_keys) { | ||
| 75 | int kw = s_layout.key_w; | ||
| 76 | int gap = s_layout.key_gap; | ||
| 77 | int total_w = total_keys * kw + (total_keys - 1) * gap; | ||
| 78 | int margin = (s_layout.screen_w - total_w) / 2; | ||
| 79 | if (margin < 2) margin = 2; | ||
| 80 | switch (row) { | ||
| 81 | case 0: return margin; | ||
| 82 | case 1: return margin + kw / 2; | ||
| 83 | case 2: return margin + kw; | ||
| 84 | case 3: return margin; | ||
| 85 | default: return margin; | ||
| 86 | } | ||
| 87 | } | ||
| 88 | |||
| 89 | static int key_width_at(int row, int col, int total_keys) { | ||
| 90 | int kw = s_layout.key_w; | ||
| 91 | if (row == 3) { | ||
| 92 | int gap = s_layout.key_gap; | ||
| 93 | int margin = row_x_offset(3, total_keys); | ||
| 94 | int available = s_layout.screen_w - margin * 2; | ||
| 95 | int side_w = (available - gap) / 4; | ||
| 96 | if (col == 0) return side_w; | ||
| 97 | if (col == total_keys - 1) return side_w; | ||
| 98 | return available - side_w * 2 - gap * 2; | ||
| 99 | } | ||
| 100 | return kw; | ||
| 101 | } | ||
| 102 | |||
| 103 | kb_result_t kb_hit_test(int tx, int ty, kb_layer_t layer) { | ||
| 104 | kb_result_t result = {KB_ACTION_NONE, 0}; | ||
| 105 | int sy = s_layout.start_y; | ||
| 106 | int kw = s_layout.key_w; | ||
| 107 | int kh = s_layout.key_h; | ||
| 108 | int gap = s_layout.key_gap; | ||
| 109 | |||
| 110 | if (ty < sy || ty >= sy + s_layout.row_count * (kh + gap)) { | ||
| 111 | return result; | ||
| 112 | } | ||
| 113 | |||
| 114 | int row = (ty - sy) / (kh + gap); | ||
| 115 | if (row < 0 || row >= s_layout.row_count) return result; | ||
| 116 | |||
| 117 | const char *row_str; | ||
| 118 | int total_keys = kb_get_row_keys(row, layer, &row_str); | ||
| 119 | if (total_keys == 0) return result; | ||
| 120 | |||
| 121 | int x_off = row_x_offset(row, total_keys); | ||
| 122 | int cx = x_off; | ||
| 123 | |||
| 124 | for (int col = 0; col < total_keys; col++) { | ||
| 125 | int key_w = key_width_at(row, col, total_keys); | ||
| 126 | if (tx >= cx && tx < cx + key_w) { | ||
| 127 | char c = row_str[col]; | ||
| 128 | if (c == CTRL_SHIFT) { | ||
| 129 | result.action = KB_ACTION_SHIFT; | ||
| 130 | } else if (c == CTRL_LAYER) { | ||
| 131 | result.action = KB_ACTION_LAYER; | ||
| 132 | } else if (c == CTRL_SPACE) { | ||
| 133 | result.action = KB_ACTION_SPACE; | ||
| 134 | result.ch = ' '; | ||
| 135 | } else if (c == CTRL_DONE) { | ||
| 136 | result.action = KB_ACTION_DONE; | ||
| 137 | } else if (c == CTRL_BS) { | ||
| 138 | result.action = KB_ACTION_BACKSPACE; | ||
| 139 | } else { | ||
| 140 | result.action = KB_ACTION_CHAR; | ||
| 141 | result.ch = c; | ||
| 142 | } | ||
| 143 | return result; | ||
| 144 | } | ||
| 145 | cx += key_w + gap; | ||
| 146 | } | ||
| 147 | |||
| 148 | return result; | ||
| 149 | } | ||
| 150 | |||
| 151 | void kb_apply(kb_state_t *st, kb_result_t result) { | ||
| 152 | if (!st || result.action == KB_ACTION_NONE) return; | ||
| 153 | |||
| 154 | switch (result.action) { | ||
| 155 | case KB_ACTION_CHAR: | ||
| 156 | if (st->cursor < KB_INPUT_MAX) { | ||
| 157 | st->input[st->cursor++] = result.ch; | ||
| 158 | st->input[st->cursor] = '\0'; | ||
| 159 | } | ||
| 160 | break; | ||
| 161 | case KB_ACTION_BACKSPACE: | ||
| 162 | if (st->cursor > 0) { | ||
| 163 | st->cursor--; | ||
| 164 | st->input[st->cursor] = '\0'; | ||
| 165 | } | ||
| 166 | break; | ||
| 167 | case KB_ACTION_SHIFT: | ||
| 168 | if (st->layer == KB_ALPHA_LOWER) st->layer = KB_ALPHA_UPPER; | ||
| 169 | else if (st->layer == KB_ALPHA_UPPER) st->layer = KB_ALPHA_LOWER; | ||
| 170 | break; | ||
| 171 | case KB_ACTION_LAYER: | ||
| 172 | if (st->layer == KB_NUMSYM) st->layer = KB_ALPHA_LOWER; | ||
| 173 | else st->layer = KB_NUMSYM; | ||
| 174 | break; | ||
| 175 | case KB_ACTION_SPACE: | ||
| 176 | if (st->cursor < KB_INPUT_MAX) { | ||
| 177 | st->input[st->cursor++] = ' '; | ||
| 178 | st->input[st->cursor] = '\0'; | ||
| 179 | } | ||
| 180 | break; | ||
| 181 | case KB_ACTION_DONE: | ||
| 182 | break; | ||
| 183 | default: | ||
| 184 | break; | ||
| 185 | } | ||
| 186 | } | ||
diff --git a/main/keyboard.h b/main/keyboard.h new file mode 100644 index 0000000..9c4118f --- /dev/null +++ b/main/keyboard.h | |||
| @@ -0,0 +1,53 @@ | |||
| 1 | #ifndef KEYBOARD_H | ||
| 2 | #define KEYBOARD_H | ||
| 3 | |||
| 4 | #include <stdint.h> | ||
| 5 | #include <stdbool.h> | ||
| 6 | |||
| 7 | #define KB_INPUT_MAX 64 | ||
| 8 | |||
| 9 | typedef enum { | ||
| 10 | KB_ALPHA_LOWER, | ||
| 11 | KB_ALPHA_UPPER, | ||
| 12 | KB_NUMSYM | ||
| 13 | } kb_layer_t; | ||
| 14 | |||
| 15 | typedef enum { | ||
| 16 | KB_ACTION_NONE = 0, | ||
| 17 | KB_ACTION_CHAR, | ||
| 18 | KB_ACTION_SHIFT, | ||
| 19 | KB_ACTION_BACKSPACE, | ||
| 20 | KB_ACTION_DONE, | ||
| 21 | KB_ACTION_LAYER, | ||
| 22 | KB_ACTION_SPACE | ||
| 23 | } kb_action_t; | ||
| 24 | |||
| 25 | typedef struct { | ||
| 26 | char input[KB_INPUT_MAX + 1]; | ||
| 27 | int cursor; | ||
| 28 | bool reveal; | ||
| 29 | kb_layer_t layer; | ||
| 30 | } kb_state_t; | ||
| 31 | |||
| 32 | typedef struct { | ||
| 33 | kb_action_t action; | ||
| 34 | char ch; | ||
| 35 | } kb_result_t; | ||
| 36 | |||
| 37 | typedef struct { | ||
| 38 | int key_w; | ||
| 39 | int key_h; | ||
| 40 | int key_gap; | ||
| 41 | int start_y; | ||
| 42 | int screen_w; | ||
| 43 | int row_count; | ||
| 44 | } kb_layout_t; | ||
| 45 | |||
| 46 | void kb_state_init(kb_state_t *st); | ||
| 47 | void kb_set_layout(const kb_layout_t *layout); | ||
| 48 | const kb_layout_t *kb_get_layout(void); | ||
| 49 | int kb_get_row_keys(int row, kb_layer_t layer, const char **keys_out); | ||
| 50 | kb_result_t kb_hit_test(int tx, int ty, kb_layer_t layer); | ||
| 51 | void kb_apply(kb_state_t *st, kb_result_t result); | ||
| 52 | |||
| 53 | #endif | ||
diff --git a/main/touch.c b/main/touch.c new file mode 100644 index 0000000..a28d13e --- /dev/null +++ b/main/touch.c | |||
| @@ -0,0 +1,156 @@ | |||
| 1 | #include "touch.h" | ||
| 2 | #include "esp_log.h" | ||
| 3 | #include "driver/i2c_master.h" | ||
| 4 | #include "driver/gpio.h" | ||
| 5 | #include "freertos/FreeRTOS.h" | ||
| 6 | #include "freertos/task.h" | ||
| 7 | #include <string.h> | ||
| 8 | |||
| 9 | static const char *TAG = "touch"; | ||
| 10 | |||
| 11 | static i2c_master_bus_handle_t s_bus = NULL; | ||
| 12 | static i2c_master_dev_handle_t s_dev = NULL; | ||
| 13 | static bool s_initialized = false; | ||
| 14 | static int s_rotation = 0; | ||
| 15 | |||
| 16 | static const uint8_t s_read_cmd[11] = { | ||
| 17 | 0xb5, 0xab, 0xa5, 0x5a, 0x00, 0x00, 0x00, 0x08, 0x00, 0x00, 0x00 | ||
| 18 | }; | ||
| 19 | |||
| 20 | void touch_parse_raw(const uint8_t *data, touch_point_t *pt) { | ||
| 21 | memset(pt, 0, sizeof(*pt)); | ||
| 22 | |||
| 23 | if (!data || data[0] != 0 || data[1] == 0 || data[1] > 1) { | ||
| 24 | pt->touched = false; | ||
| 25 | return; | ||
| 26 | } | ||
| 27 | |||
| 28 | uint16_t raw_x = ((data[2] & 0x0F) << 8) | data[3]; | ||
| 29 | uint16_t raw_y = ((data[4] & 0x0F) << 8) | data[5]; | ||
| 30 | |||
| 31 | if (raw_x > TOUCH_MAX_X) raw_x = TOUCH_MAX_X; | ||
| 32 | if (raw_y > TOUCH_MAX_Y) raw_y = TOUCH_MAX_Y; | ||
| 33 | |||
| 34 | pt->x = raw_x; | ||
| 35 | pt->y = raw_y; | ||
| 36 | pt->touched = true; | ||
| 37 | } | ||
| 38 | |||
| 39 | esp_err_t touch_init(void) { | ||
| 40 | if (s_initialized) return ESP_OK; | ||
| 41 | |||
| 42 | gpio_config_t rst_conf = { | ||
| 43 | .pin_bit_mask = (1ULL << TOUCH_RST_PIN), | ||
| 44 | .mode = GPIO_MODE_OUTPUT, | ||
| 45 | .pull_up_en = GPIO_PULLUP_DISABLE, | ||
| 46 | .pull_down_en = GPIO_PULLDOWN_DISABLE, | ||
| 47 | .intr_type = GPIO_INTR_DISABLE, | ||
| 48 | }; | ||
| 49 | gpio_config(&rst_conf); | ||
| 50 | |||
| 51 | gpio_set_level(TOUCH_RST_PIN, 0); | ||
| 52 | vTaskDelay(pdMS_TO_TICKS(200)); | ||
| 53 | gpio_set_level(TOUCH_RST_PIN, 1); | ||
| 54 | vTaskDelay(pdMS_TO_TICKS(200)); | ||
| 55 | |||
| 56 | i2c_master_bus_config_t bus_cfg = { | ||
| 57 | .i2c_port = I2C_NUM_0, | ||
| 58 | .sda_io_num = TOUCH_SDA_PIN, | ||
| 59 | .scl_io_num = TOUCH_SCL_PIN, | ||
| 60 | .clk_source = I2C_CLK_SRC_DEFAULT, | ||
| 61 | .glitch_ignore_cnt = 7, | ||
| 62 | .intr_priority = 0, | ||
| 63 | .trans_queue_depth = 0, | ||
| 64 | .flags = { | ||
| 65 | .enable_internal_pullup = 1, | ||
| 66 | .allow_pd = 0, | ||
| 67 | }, | ||
| 68 | }; | ||
| 69 | |||
| 70 | esp_err_t ret = i2c_new_master_bus(&bus_cfg, &s_bus); | ||
| 71 | if (ret != ESP_OK) { | ||
| 72 | ESP_LOGE(TAG, "Failed to create I2C bus: %s", esp_err_to_name(ret)); | ||
| 73 | return ret; | ||
| 74 | } | ||
| 75 | |||
| 76 | i2c_device_config_t dev_cfg = { | ||
| 77 | .dev_addr_length = I2C_ADDR_BIT_LEN_7, | ||
| 78 | .device_address = TOUCH_I2C_ADDR, | ||
| 79 | .scl_speed_hz = 400000, | ||
| 80 | .scl_wait_us = 0, | ||
| 81 | .flags = { | ||
| 82 | .disable_ack_check = 0, | ||
| 83 | }, | ||
| 84 | }; | ||
| 85 | |||
| 86 | ret = i2c_master_bus_add_device(s_bus, &dev_cfg, &s_dev); | ||
| 87 | if (ret != ESP_OK) { | ||
| 88 | ESP_LOGE(TAG, "Failed to add I2C device: %s", esp_err_to_name(ret)); | ||
| 89 | i2c_del_master_bus(s_bus); | ||
| 90 | s_bus = NULL; | ||
| 91 | return ret; | ||
| 92 | } | ||
| 93 | |||
| 94 | s_initialized = true; | ||
| 95 | ESP_LOGI(TAG, "Touch initialized (I2C addr 0x%02X)", TOUCH_I2C_ADDR); | ||
| 96 | return ESP_OK; | ||
| 97 | } | ||
| 98 | |||
| 99 | bool touch_read(touch_point_t *pt) { | ||
| 100 | if (!s_initialized || !s_dev || !pt) { | ||
| 101 | if (pt) pt->touched = false; | ||
| 102 | return false; | ||
| 103 | } | ||
| 104 | |||
| 105 | esp_err_t ret = i2c_master_transmit(s_dev, s_read_cmd, sizeof(s_read_cmd), 100); | ||
| 106 | if (ret != ESP_OK) { | ||
| 107 | pt->touched = false; | ||
| 108 | return false; | ||
| 109 | } | ||
| 110 | |||
| 111 | uint8_t data[8] = {0}; | ||
| 112 | ret = i2c_master_receive(s_dev, data, sizeof(data), 100); | ||
| 113 | if (ret != ESP_OK) { | ||
| 114 | pt->touched = false; | ||
| 115 | return false; | ||
| 116 | } | ||
| 117 | |||
| 118 | touch_parse_raw(data, pt); | ||
| 119 | |||
| 120 | if (pt->touched && s_rotation != 0) { | ||
| 121 | uint16_t raw_x = pt->x; | ||
| 122 | uint16_t raw_y = pt->y; | ||
| 123 | switch (s_rotation) { | ||
| 124 | case 1: | ||
| 125 | pt->x = raw_y; | ||
| 126 | pt->y = TOUCH_MAX_X - raw_x; | ||
| 127 | break; | ||
| 128 | case 2: | ||
| 129 | pt->x = TOUCH_MAX_X - raw_x; | ||
| 130 | pt->y = TOUCH_MAX_Y - raw_y; | ||
| 131 | break; | ||
| 132 | case 3: | ||
| 133 | pt->x = TOUCH_MAX_Y - raw_y; | ||
| 134 | pt->y = raw_x; | ||
| 135 | break; | ||
| 136 | } | ||
| 137 | } | ||
| 138 | |||
| 139 | return pt->touched; | ||
| 140 | } | ||
| 141 | |||
| 142 | void touch_set_rotation(int rotation) { | ||
| 143 | s_rotation = rotation; | ||
| 144 | } | ||
| 145 | |||
| 146 | void touch_deinit(void) { | ||
| 147 | if (s_dev) { | ||
| 148 | i2c_master_bus_rm_device(s_dev); | ||
| 149 | s_dev = NULL; | ||
| 150 | } | ||
| 151 | if (s_bus) { | ||
| 152 | i2c_del_master_bus(s_bus); | ||
| 153 | s_bus = NULL; | ||
| 154 | } | ||
| 155 | s_initialized = false; | ||
| 156 | } | ||
diff --git a/main/touch.h b/main/touch.h new file mode 100644 index 0000000..b9e3ccd --- /dev/null +++ b/main/touch.h | |||
| @@ -0,0 +1,29 @@ | |||
| 1 | #ifndef TOUCH_H | ||
| 2 | #define TOUCH_H | ||
| 3 | |||
| 4 | #include "esp_err.h" | ||
| 5 | #include <stdint.h> | ||
| 6 | #include <stdbool.h> | ||
| 7 | |||
| 8 | #define TOUCH_SDA_PIN 4 | ||
| 9 | #define TOUCH_SCL_PIN 8 | ||
| 10 | #define TOUCH_RST_PIN 12 | ||
| 11 | #define TOUCH_INT_PIN 11 | ||
| 12 | #define TOUCH_I2C_ADDR 0x3B | ||
| 13 | #define TOUCH_MAX_X 319 | ||
| 14 | #define TOUCH_MAX_Y 479 | ||
| 15 | |||
| 16 | typedef struct { | ||
| 17 | uint16_t x; | ||
| 18 | uint16_t y; | ||
| 19 | bool touched; | ||
| 20 | } touch_point_t; | ||
| 21 | |||
| 22 | esp_err_t touch_init(void); | ||
| 23 | bool touch_read(touch_point_t *pt); | ||
| 24 | void touch_deinit(void); | ||
| 25 | void touch_set_rotation(int rotation); | ||
| 26 | |||
| 27 | void touch_parse_raw(const uint8_t *data, touch_point_t *pt); | ||
| 28 | |||
| 29 | #endif | ||
diff --git a/main/wifi_setup.c b/main/wifi_setup.c new file mode 100644 index 0000000..b2669e9 --- /dev/null +++ b/main/wifi_setup.c | |||
| @@ -0,0 +1,89 @@ | |||
| 1 | #include "wifi_setup.h" | ||
| 2 | #include <string.h> | ||
| 3 | |||
| 4 | void wifi_setup_init(wifi_setup_t *setup) { | ||
| 5 | if (!setup) return; | ||
| 6 | memset(setup, 0, sizeof(*setup)); | ||
| 7 | setup->state = SETUP_SCAN; | ||
| 8 | setup->selected_ap = -1; | ||
| 9 | } | ||
| 10 | |||
| 11 | void wifi_setup_set_aps(wifi_setup_t *setup, const wifi_ap_info_t *aps, int count) { | ||
| 12 | if (!setup || !aps) return; | ||
| 13 | if (count > WIFI_SETUP_MAX_APS) count = WIFI_SETUP_MAX_APS; | ||
| 14 | memcpy(setup->aps, aps, count * sizeof(wifi_ap_info_t)); | ||
| 15 | setup->ap_count = count; | ||
| 16 | setup->list_scroll = 0; | ||
| 17 | setup->state = SETUP_LIST; | ||
| 18 | } | ||
| 19 | |||
| 20 | int wifi_setup_visible_count(const wifi_setup_t *setup) { | ||
| 21 | if (!setup) return 0; | ||
| 22 | int remaining = setup->ap_count - setup->list_scroll; | ||
| 23 | if (remaining > WIFI_SETUP_MAX_VISIBLE) remaining = WIFI_SETUP_MAX_VISIBLE; | ||
| 24 | return remaining < 0 ? 0 : remaining; | ||
| 25 | } | ||
| 26 | |||
| 27 | const wifi_ap_info_t *wifi_setup_get_visible(const wifi_setup_t *setup, int idx) { | ||
| 28 | if (!setup || idx < 0 || idx >= wifi_setup_visible_count(setup)) return NULL; | ||
| 29 | return &setup->aps[setup->list_scroll + idx]; | ||
| 30 | } | ||
| 31 | |||
| 32 | setup_state_t wifi_setup_handle_select(wifi_setup_t *setup, int list_idx) { | ||
| 33 | if (!setup || setup->state != SETUP_LIST) return setup ? setup->state : SETUP_CANCELLED; | ||
| 34 | if (list_idx < 0 || list_idx >= wifi_setup_visible_count(setup)) return setup->state; | ||
| 35 | |||
| 36 | int real_idx = setup->list_scroll + list_idx; | ||
| 37 | setup->selected_ap = real_idx; | ||
| 38 | strncpy(setup->selected_ssid, setup->aps[real_idx].ssid, WIFI_SETUP_SSID_LEN - 1); | ||
| 39 | setup->selected_ssid[WIFI_SETUP_SSID_LEN - 1] = '\0'; | ||
| 40 | setup->state = SETUP_PASSWORD; | ||
| 41 | return setup->state; | ||
| 42 | } | ||
| 43 | |||
| 44 | setup_state_t wifi_setup_handle_connect(wifi_setup_t *setup) { | ||
| 45 | if (!setup || setup->state != SETUP_PASSWORD) return setup ? setup->state : SETUP_CANCELLED; | ||
| 46 | setup->state = SETUP_CONNECTING; | ||
| 47 | return setup->state; | ||
| 48 | } | ||
| 49 | |||
| 50 | setup_state_t wifi_setup_handle_connect_result(wifi_setup_t *setup, bool success, const char *ip) { | ||
| 51 | if (!setup) return SETUP_CANCELLED; | ||
| 52 | if (setup->state != SETUP_CONNECTING) return setup->state; | ||
| 53 | |||
| 54 | if (success) { | ||
| 55 | setup->state = SETUP_SUCCESS; | ||
| 56 | if (ip) { | ||
| 57 | strncpy(setup->connect_ip, ip, sizeof(setup->connect_ip) - 1); | ||
| 58 | setup->connect_ip[sizeof(setup->connect_ip) - 1] = '\0'; | ||
| 59 | } | ||
| 60 | setup->connect_failed_auth = false; | ||
| 61 | } else { | ||
| 62 | setup->state = SETUP_FAILED; | ||
| 63 | setup->connect_failed_auth = true; | ||
| 64 | setup->connect_ip[0] = '\0'; | ||
| 65 | } | ||
| 66 | return setup->state; | ||
| 67 | } | ||
| 68 | |||
| 69 | setup_state_t wifi_setup_handle_cancel(wifi_setup_t *setup) { | ||
| 70 | if (!setup) return SETUP_CANCELLED; | ||
| 71 | setup->state = SETUP_CANCELLED; | ||
| 72 | return setup->state; | ||
| 73 | } | ||
| 74 | |||
| 75 | setup_state_t wifi_setup_handle_retry(wifi_setup_t *setup) { | ||
| 76 | if (!setup) return SETUP_CANCELLED; | ||
| 77 | if (setup->state != SETUP_FAILED) return setup->state; | ||
| 78 | setup->state = SETUP_PASSWORD; | ||
| 79 | setup->connect_failed_auth = false; | ||
| 80 | return setup->state; | ||
| 81 | } | ||
| 82 | |||
| 83 | setup_state_t wifi_setup_handle_change_network(wifi_setup_t *setup) { | ||
| 84 | if (!setup) return SETUP_CANCELLED; | ||
| 85 | if (setup->state != SETUP_FAILED) return setup->state; | ||
| 86 | setup->state = SETUP_LIST; | ||
| 87 | setup->connect_failed_auth = false; | ||
| 88 | return setup->state; | ||
| 89 | } | ||
diff --git a/main/wifi_setup.h b/main/wifi_setup.h new file mode 100644 index 0000000..17712d5 --- /dev/null +++ b/main/wifi_setup.h | |||
| @@ -0,0 +1,51 @@ | |||
| 1 | #ifndef WIFI_SETUP_H | ||
| 2 | #define WIFI_SETUP_H | ||
| 3 | |||
| 4 | #include "esp_err.h" | ||
| 5 | #include <stdint.h> | ||
| 6 | #include <stdbool.h> | ||
| 7 | |||
| 8 | #define WIFI_SETUP_MAX_APS 20 | ||
| 9 | #define WIFI_SETUP_MAX_VISIBLE 8 | ||
| 10 | #define WIFI_SETUP_SSID_LEN 33 | ||
| 11 | #define WIFI_SETUP_PASS_LEN 64 | ||
| 12 | |||
| 13 | typedef enum { | ||
| 14 | SETUP_SCAN, | ||
| 15 | SETUP_LIST, | ||
| 16 | SETUP_PASSWORD, | ||
| 17 | SETUP_CONNECTING, | ||
| 18 | SETUP_SUCCESS, | ||
| 19 | SETUP_FAILED, | ||
| 20 | SETUP_CANCELLED | ||
| 21 | } setup_state_t; | ||
| 22 | |||
| 23 | typedef struct { | ||
| 24 | char ssid[WIFI_SETUP_SSID_LEN]; | ||
| 25 | int rssi; | ||
| 26 | bool secured; | ||
| 27 | } wifi_ap_info_t; | ||
| 28 | |||
| 29 | typedef struct { | ||
| 30 | setup_state_t state; | ||
| 31 | wifi_ap_info_t aps[WIFI_SETUP_MAX_APS]; | ||
| 32 | int ap_count; | ||
| 33 | int list_scroll; | ||
| 34 | int selected_ap; | ||
| 35 | char selected_ssid[WIFI_SETUP_SSID_LEN]; | ||
| 36 | char connect_ip[16]; | ||
| 37 | bool connect_failed_auth; | ||
| 38 | } wifi_setup_t; | ||
| 39 | |||
| 40 | void wifi_setup_init(wifi_setup_t *setup); | ||
| 41 | void wifi_setup_set_aps(wifi_setup_t *setup, const wifi_ap_info_t *aps, int count); | ||
| 42 | int wifi_setup_visible_count(const wifi_setup_t *setup); | ||
| 43 | const wifi_ap_info_t *wifi_setup_get_visible(const wifi_setup_t *setup, int idx); | ||
| 44 | setup_state_t wifi_setup_handle_select(wifi_setup_t *setup, int list_idx); | ||
| 45 | setup_state_t wifi_setup_handle_connect(wifi_setup_t *setup); | ||
| 46 | setup_state_t wifi_setup_handle_connect_result(wifi_setup_t *setup, bool success, const char *ip); | ||
| 47 | setup_state_t wifi_setup_handle_cancel(wifi_setup_t *setup); | ||
| 48 | setup_state_t wifi_setup_handle_retry(wifi_setup_t *setup); | ||
| 49 | setup_state_t wifi_setup_handle_change_network(wifi_setup_t *setup); | ||
| 50 | |||
| 51 | #endif | ||
diff --git a/tests/e2e/wifi-setup.spec.mjs b/tests/e2e/wifi-setup.spec.mjs new file mode 100644 index 0000000..31bc2cf --- /dev/null +++ b/tests/e2e/wifi-setup.spec.mjs | |||
| @@ -0,0 +1,440 @@ | |||
| 1 | import { test, expect } from '@playwright/test'; | ||
| 2 | |||
| 3 | const PORTAL_IP = process.env.TOLLGATE_IP || '10.192.45.1'; | ||
| 4 | const PORTAL_URL = `http://${PORTAL_IP}`; | ||
| 5 | |||
| 6 | const SETUP_HTML = `<!DOCTYPE html> | ||
| 7 | <html><head> | ||
| 8 | <meta charset='utf-8'> | ||
| 9 | <meta name='viewport' content='width=device-width, initial-scale=1'> | ||
| 10 | <title>TollGate Setup</title> | ||
| 11 | <style> | ||
| 12 | *{box-sizing:border-box;margin:0;padding:0} | ||
| 13 | body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif; | ||
| 14 | background:#0a0a0a;color:#fff;display:flex;align-items:center;justify-content:center; | ||
| 15 | min-height:100vh;padding:20px} | ||
| 16 | .card{background:#1a1a1a;border:1px solid #333;border-radius:16px;padding:32px; | ||
| 17 | max-width:400px;width:100%;text-align:center} | ||
| 18 | h1{font-size:24px;margin-bottom:8px;color:#f7931a} | ||
| 19 | .subtitle{color:#888;margin-bottom:20px;font-size:13px} | ||
| 20 | .networks{margin-top:16px;text-align:left} | ||
| 21 | .net-item{background:#252525;border:1px solid #333;border-radius:8px; | ||
| 22 | padding:12px;margin-bottom:8px;cursor:pointer;display:flex;justify-content:space-between;align-items:center} | ||
| 23 | .net-item:hover{border-color:#f7931a} | ||
| 24 | .net-item:active{background:#333} | ||
| 25 | .net-ssid{font-size:14px} | ||
| 26 | .net-rssi{font-size:11px;color:#888} | ||
| 27 | .net-lock{color:#f7931a;margin-right:4px} | ||
| 28 | .manual{margin-top:12px} | ||
| 29 | input{width:100%;background:#252525;border:1px solid #333;border-radius:8px; | ||
| 30 | color:#fff;padding:12px;font-size:14px;margin-bottom:8px;outline:none} | ||
| 31 | input:focus{border-color:#f7931a} | ||
| 32 | .btn{background:#f7931a;color:#000;border:none;border-radius:8px;padding:14px 28px; | ||
| 33 | font-size:16px;font-weight:bold;cursor:pointer;width:100%;margin-top:8px} | ||
| 34 | .btn:hover{background:#e8850f} | ||
| 35 | .btn:disabled{background:#333;color:#666;cursor:not-allowed} | ||
| 36 | #status{margin-top:12px;padding:10px;border-radius:8px;display:none;font-size:13px} | ||
| 37 | #status.success{display:block;background:#1a472a;color:#4caf50} | ||
| 38 | #status.error{display:block;background:#471a1a;color:#f44336} | ||
| 39 | #status.processing{display:block;background:#1a3a47;color:#2196f3} | ||
| 40 | .refresh{background:none;border:1px solid #444;color:#aaa;border-radius:6px; | ||
| 41 | padding:6px 12px;font-size:12px;cursor:pointer;margin-top:4px} | ||
| 42 | .refresh:hover{border-color:#f7931a;color:#f7931a} | ||
| 43 | #manualForm{display:none;margin-top:12px} | ||
| 44 | </style> | ||
| 45 | </head><body> | ||
| 46 | <div class='card'> | ||
| 47 | <h1>TollGate Setup</h1> | ||
| 48 | <p class='subtitle'>Configure upstream WiFi</p> | ||
| 49 | <div id='scanStatus'>Scanning...</div> | ||
| 50 | <div class='networks' id='networkList'></div> | ||
| 51 | <button class='refresh' onclick='scanWifi()'>Rescan</button> | ||
| 52 | <button class='refresh' onclick='showManual()'>Manual entry</button> | ||
| 53 | <div id='manualForm'> | ||
| 54 | <input id='manualSsid' placeholder='SSID'> | ||
| 55 | <input id='manualPass' type='password' placeholder='Password'> | ||
| 56 | <button class='btn' onclick='connectManual()'>Connect</button> | ||
| 57 | </div> | ||
| 58 | <div id='passwordForm' style='display:none'> | ||
| 59 | <p style='margin:12px 0 8px;text-align:left' id='selectedNetwork'></p> | ||
| 60 | <input id='wifiPass' type='password' placeholder='WiFi password'> | ||
| 61 | <button class='btn' onclick='connectSelected()'>Connect</button> | ||
| 62 | </div> | ||
| 63 | <div id='status'></div> | ||
| 64 | </div> | ||
| 65 | <script> | ||
| 66 | const apIp='${PORTAL_IP}'; | ||
| 67 | let selectedSsid=''; | ||
| 68 | function showStatus(msg,type){const s=document.getElementById('status'); | ||
| 69 | s.textContent=msg;s.className=type;} | ||
| 70 | function scanWifi(){ | ||
| 71 | document.getElementById('scanStatus').textContent='Scanning...'; | ||
| 72 | document.getElementById('networkList').innerHTML=''; | ||
| 73 | fetch('/wifi/scan').then(r=>r.json()).then(aps=>{ | ||
| 74 | document.getElementById('scanStatus').textContent=aps.length+' networks found'; | ||
| 75 | const list=document.getElementById('networkList'); | ||
| 76 | aps.forEach(ap=>{ | ||
| 77 | const div=document.createElement('div'); | ||
| 78 | div.className='net-item'; | ||
| 79 | const lock=ap.secured?'<span class=net-lock>🔒</span>':''; | ||
| 80 | div.innerHTML='<span class=net-ssid>'+lock+ap.ssid+'</span><span class=net-rssi>'+ap.rssi+' dBm</span>'; | ||
| 81 | div.onclick=()=>selectNetwork(ap.ssid,ap.secured); | ||
| 82 | list.appendChild(div); | ||
| 83 | }); | ||
| 84 | }).catch(e=>{document.getElementById('scanStatus').textContent='Scan failed';}); | ||
| 85 | } | ||
| 86 | function selectNetwork(ssid,secured){ | ||
| 87 | selectedSsid=ssid; | ||
| 88 | document.getElementById('selectedNetwork').textContent='Connect to: '+ssid; | ||
| 89 | document.getElementById('passwordForm').style.display='block'; | ||
| 90 | document.getElementById('scanStatus').style.display='none'; | ||
| 91 | document.getElementById('networkList').style.display='none'; | ||
| 92 | document.querySelector('.refresh').style.display='none'; | ||
| 93 | if(!secured){connectSelected();} | ||
| 94 | } | ||
| 95 | function showManual(){ | ||
| 96 | document.getElementById('manualForm').style.display='block'; | ||
| 97 | } | ||
| 98 | function connectSelected(){ | ||
| 99 | const pass=document.getElementById('wifiPass').value; | ||
| 100 | doConnect(selectedSsid,pass); | ||
| 101 | } | ||
| 102 | function connectManual(){ | ||
| 103 | const ssid=document.getElementById('manualSsid').value.trim(); | ||
| 104 | const pass=document.getElementById('manualPass').value; | ||
| 105 | if(!ssid){showStatus('Enter SSID','error');return;} | ||
| 106 | doConnect(ssid,pass); | ||
| 107 | } | ||
| 108 | function doConnect(ssid,pass){ | ||
| 109 | showStatus('Connecting to '+ssid+'...','processing'); | ||
| 110 | fetch('/wifi/connect',{method:'POST',headers:{'Content-Type':'application/json'}, | ||
| 111 | body:JSON.stringify({ssid:ssid,password:pass})}) | ||
| 112 | .then(r=>r.json()).then(d=>{ | ||
| 113 | if(d.ok){showStatus('Connected! Device is restarting...','success');} | ||
| 114 | else{showStatus('Failed: '+(d.error||'unknown'),'error');} | ||
| 115 | }).catch(e=>{showStatus('Connection error','error');}); | ||
| 116 | } | ||
| 117 | scanWifi(); | ||
| 118 | </script> | ||
| 119 | </body></html>`; | ||
| 120 | |||
| 121 | const MOCK_AP_LIST = [ | ||
| 122 | { ssid: 'HomeNetwork', rssi: -42, secured: true }, | ||
| 123 | { ssid: 'CafeWiFi', rssi: -67, secured: true }, | ||
| 124 | { ssid: 'OpenPublic', rssi: -75, secured: false }, | ||
| 125 | { ssid: 'Neighbor5G', rssi: -81, secured: true }, | ||
| 126 | ]; | ||
| 127 | |||
| 128 | async function setupMockRoutes(page, overrides = {}) { | ||
| 129 | const scanResponse = overrides.scanResponse || MOCK_AP_LIST; | ||
| 130 | const connectHandler = overrides.connectHandler || (() => ({ ok: true })); | ||
| 131 | |||
| 132 | await page.route('**/setup', async route => { | ||
| 133 | await route.fulfill({ | ||
| 134 | status: 200, | ||
| 135 | contentType: 'text/html', | ||
| 136 | body: SETUP_HTML, | ||
| 137 | }); | ||
| 138 | }); | ||
| 139 | |||
| 140 | await page.route('**/wifi/scan', async route => { | ||
| 141 | await route.fulfill({ | ||
| 142 | status: 200, | ||
| 143 | contentType: 'application/json', | ||
| 144 | body: JSON.stringify(scanResponse), | ||
| 145 | }); | ||
| 146 | }); | ||
| 147 | |||
| 148 | await page.route('**/wifi/connect', async route => { | ||
| 149 | const request = route.request(); | ||
| 150 | const body = request.postDataJSON(); | ||
| 151 | const response = connectHandler(body); | ||
| 152 | await route.fulfill({ | ||
| 153 | status: 200, | ||
| 154 | contentType: 'application/json', | ||
| 155 | body: JSON.stringify(response), | ||
| 156 | }); | ||
| 157 | }); | ||
| 158 | } | ||
| 159 | |||
| 160 | async function loadSetupPage(page) { | ||
| 161 | await page.goto('http://tollgate.test/setup', { waitUntil: 'networkidle' }); | ||
| 162 | } | ||
| 163 | |||
| 164 | test.describe('WiFi Setup \u2014 Layer 1: API Endpoints (needs live board)', () => { | ||
| 165 | |||
| 166 | test('GET /setup redirects to portal on configured board', async ({ request }) => { | ||
| 167 | const resp = await request.fetch(`${PORTAL_URL}/setup`, { | ||
| 168 | maxRedirects: 0, | ||
| 169 | }); | ||
| 170 | expect(resp.status()).toBe(302); | ||
| 171 | const location = resp.headers()['location']; | ||
| 172 | expect(location).toContain(PORTAL_IP); | ||
| 173 | expect(location).toMatch(/\/$/); | ||
| 174 | }); | ||
| 175 | |||
| 176 | test('GET /wifi/scan returns JSON array with valid AP objects', async ({ request }) => { | ||
| 177 | const resp = await request.get(`${PORTAL_URL}/wifi/scan`); | ||
| 178 | expect(resp.status()).toBe(200); | ||
| 179 | const data = await resp.json(); | ||
| 180 | expect(Array.isArray(data)).toBe(true); | ||
| 181 | if (data.length > 0) { | ||
| 182 | const ap = data[0]; | ||
| 183 | expect(ap).toHaveProperty('ssid'); | ||
| 184 | expect(typeof ap.ssid).toBe('string'); | ||
| 185 | expect(ap).toHaveProperty('rssi'); | ||
| 186 | expect(typeof ap.rssi).toBe('number'); | ||
| 187 | expect(ap).toHaveProperty('secured'); | ||
| 188 | expect(typeof ap.secured).toBe('boolean'); | ||
| 189 | } | ||
| 190 | }); | ||
| 191 | |||
| 192 | test('GET /wifi/status returns connection state', async ({ request }) => { | ||
| 193 | const resp = await request.get(`${PORTAL_URL}/wifi/status`); | ||
| 194 | expect(resp.status()).toBe(200); | ||
| 195 | const data = await resp.json(); | ||
| 196 | expect(data).toHaveProperty('connected'); | ||
| 197 | expect(typeof data.connected).toBe('boolean'); | ||
| 198 | if (data.connected) { | ||
| 199 | expect(data).toHaveProperty('ip'); | ||
| 200 | expect(data.ip).toMatch(/\d+\.\d+\.\d+\.\d+/); | ||
| 201 | expect(data).toHaveProperty('ssid'); | ||
| 202 | } | ||
| 203 | }); | ||
| 204 | |||
| 205 | test('POST /wifi/connect rejects empty body', async ({ request }) => { | ||
| 206 | const resp = await request.post(`${PORTAL_URL}/wifi/connect`, { | ||
| 207 | data: '', | ||
| 208 | headers: { 'Content-Type': 'application/json' }, | ||
| 209 | }); | ||
| 210 | const data = await resp.json(); | ||
| 211 | expect(data.ok).toBe(false); | ||
| 212 | }); | ||
| 213 | |||
| 214 | test('POST /wifi/connect rejects invalid JSON', async ({ request }) => { | ||
| 215 | const resp = await request.post(`${PORTAL_URL}/wifi/connect`, { | ||
| 216 | data: 'not json at all', | ||
| 217 | headers: { 'Content-Type': 'application/json' }, | ||
| 218 | }); | ||
| 219 | const data = await resp.json(); | ||
| 220 | expect(data.ok).toBe(false); | ||
| 221 | expect(data.error).toBeDefined(); | ||
| 222 | }); | ||
| 223 | |||
| 224 | test('POST /wifi/connect rejects missing ssid', async ({ request }) => { | ||
| 225 | const resp = await request.post(`${PORTAL_URL}/wifi/connect`, { | ||
| 226 | data: JSON.stringify({ password: 'testpass' }), | ||
| 227 | headers: { 'Content-Type': 'application/json' }, | ||
| 228 | }); | ||
| 229 | const data = await resp.json(); | ||
| 230 | expect(data.ok).toBe(false); | ||
| 231 | expect(data.error).toContain('ssid'); | ||
| 232 | }); | ||
| 233 | |||
| 234 | test('POST /wifi/connect with valid SSID returns ok or ECONNRESET', async ({ request }) => { | ||
| 235 | const resp = await request.post(`${PORTAL_URL}/wifi/connect`, { | ||
| 236 | data: JSON.stringify({ ssid: 'TestSetupAP', password: 'testpass123' }), | ||
| 237 | headers: { 'Content-Type': 'application/json' }, | ||
| 238 | maxRedirects: 0, | ||
| 239 | timeout: 10000, | ||
| 240 | }).catch(() => null); | ||
| 241 | |||
| 242 | if (resp) { | ||
| 243 | const text = await resp.text(); | ||
| 244 | try { | ||
| 245 | const data = JSON.parse(text); | ||
| 246 | expect(data.ok).toBe(true); | ||
| 247 | } catch { | ||
| 248 | expect(resp.status()).toBeLessThan(500); | ||
| 249 | } | ||
| 250 | } | ||
| 251 | }); | ||
| 252 | }); | ||
| 253 | |||
| 254 | test.describe('WiFi Setup \u2014 Layer 1.5: Redirect (needs live board)', () => { | ||
| 255 | test('redirect Location header contains correct AP IP', async ({ request }) => { | ||
| 256 | const resp = await request.fetch(`${PORTAL_URL}/setup`, { | ||
| 257 | maxRedirects: 0, | ||
| 258 | }); | ||
| 259 | const location = resp.headers()['location']; | ||
| 260 | expect(location).toBe(`http://${PORTAL_IP}/`); | ||
| 261 | }); | ||
| 262 | }); | ||
| 263 | |||
| 264 | test.describe('WiFi Setup \u2014 Layer 2: HTML UI Interaction', () => { | ||
| 265 | |||
| 266 | test('page renders with title and subtitle', async ({ page }) => { | ||
| 267 | await setupMockRoutes(page); | ||
| 268 | await loadSetupPage(page); | ||
| 269 | await expect(page.locator('h1')).toHaveText('TollGate Setup'); | ||
| 270 | await expect(page.locator('.subtitle')).toHaveText('Configure upstream WiFi'); | ||
| 271 | }); | ||
| 272 | |||
| 273 | test('scan auto-triggers on load and shows network count', async ({ page }) => { | ||
| 274 | await setupMockRoutes(page); | ||
| 275 | await loadSetupPage(page); | ||
| 276 | await expect(page.locator('#scanStatus')).toHaveText(/4 networks found/, { timeout: 5000 }); | ||
| 277 | }); | ||
| 278 | |||
| 279 | test('network list shows SSID and RSSI for each AP', async ({ page }) => { | ||
| 280 | await setupMockRoutes(page); | ||
| 281 | await loadSetupPage(page); | ||
| 282 | await expect(page.locator('.net-item')).toHaveCount(4); | ||
| 283 | await expect(page.locator('.net-ssid').first()).toContainText('HomeNetwork'); | ||
| 284 | await expect(page.locator('.net-rssi').first()).toContainText('-42 dBm'); | ||
| 285 | }); | ||
| 286 | |||
| 287 | test('secured networks show lock icon', async ({ page }) => { | ||
| 288 | await setupMockRoutes(page); | ||
| 289 | await loadSetupPage(page); | ||
| 290 | const securedItems = page.locator('.net-item'); | ||
| 291 | const firstSecured = securedItems.first(); | ||
| 292 | await expect(firstSecured.locator('.net-lock')).toBeVisible(); | ||
| 293 | }); | ||
| 294 | |||
| 295 | test('open networks have no lock icon', async ({ page }) => { | ||
| 296 | await setupMockRoutes(page); | ||
| 297 | await loadSetupPage(page); | ||
| 298 | const openItem = page.locator('.net-item').nth(2); | ||
| 299 | await expect(openItem.locator('.net-lock')).toHaveCount(0); | ||
| 300 | await expect(openItem.locator('.net-ssid')).toContainText('OpenPublic'); | ||
| 301 | }); | ||
| 302 | |||
| 303 | test('clicking secured network shows password form and hides list', async ({ page }) => { | ||
| 304 | await setupMockRoutes(page); | ||
| 305 | await loadSetupPage(page); | ||
| 306 | await expect(page.locator('.net-item').first()).toBeVisible(); | ||
| 307 | await page.locator('.net-item').first().click(); | ||
| 308 | await expect(page.locator('#passwordForm')).toBeVisible(); | ||
| 309 | await expect(page.locator('#selectedNetwork')).toHaveText('Connect to: HomeNetwork'); | ||
| 310 | await expect(page.locator('#networkList')).toBeHidden(); | ||
| 311 | await expect(page.locator('#scanStatus')).toBeHidden(); | ||
| 312 | }); | ||
| 313 | |||
| 314 | test('clicking open network auto-connects without password form', async ({ page }) => { | ||
| 315 | let connectBody = null; | ||
| 316 | await setupMockRoutes(page, { | ||
| 317 | connectHandler: (body) => { | ||
| 318 | connectBody = body; | ||
| 319 | return { ok: true }; | ||
| 320 | }, | ||
| 321 | }); | ||
| 322 | await loadSetupPage(page); | ||
| 323 | const openItem = page.locator('.net-item').nth(2); | ||
| 324 | await openItem.click(); | ||
| 325 | await expect(page.locator('#status')).toHaveClass(/processing|success/, { timeout: 5000 }); | ||
| 326 | expect(connectBody).toBeTruthy(); | ||
| 327 | expect(connectBody.ssid).toBe('OpenPublic'); | ||
| 328 | }); | ||
| 329 | |||
| 330 | test('manual entry button toggles form visibility', async ({ page }) => { | ||
| 331 | await setupMockRoutes(page); | ||
| 332 | await loadSetupPage(page); | ||
| 333 | await expect(page.locator('#manualForm')).toBeHidden(); | ||
| 334 | await page.locator('button:has-text("Manual entry")').click(); | ||
| 335 | await expect(page.locator('#manualForm')).toBeVisible(); | ||
| 336 | await expect(page.locator('#manualSsid')).toBeVisible(); | ||
| 337 | await expect(page.locator('#manualPass')).toBeVisible(); | ||
| 338 | }); | ||
| 339 | |||
| 340 | test('manual connect with empty SSID shows error', async ({ page }) => { | ||
| 341 | await setupMockRoutes(page); | ||
| 342 | await loadSetupPage(page); | ||
| 343 | await page.locator('button:has-text("Manual entry")').click(); | ||
| 344 | await page.locator('#manualSsid').fill(''); | ||
| 345 | await page.locator('#manualForm .btn').click(); | ||
| 346 | await expect(page.locator('#status')).toHaveClass(/error/); | ||
| 347 | await expect(page.locator('#status')).toContainText('Enter SSID'); | ||
| 348 | }); | ||
| 349 | |||
| 350 | test('connect sends correct JSON body to /wifi/connect', async ({ page }) => { | ||
| 351 | let capturedBody = null; | ||
| 352 | await setupMockRoutes(page, { | ||
| 353 | connectHandler: (body) => { | ||
| 354 | capturedBody = body; | ||
| 355 | return { ok: true }; | ||
| 356 | }, | ||
| 357 | }); | ||
| 358 | await loadSetupPage(page); | ||
| 359 | await page.locator('.net-item').first().click(); | ||
| 360 | await page.locator('#wifiPass').fill('mysecretpass'); | ||
| 361 | await page.locator('#passwordForm .btn').click(); | ||
| 362 | await expect(page.locator('#status')).toHaveClass(/success|processing/, { timeout: 5000 }); | ||
| 363 | expect(capturedBody).toEqual({ ssid: 'HomeNetwork', password: 'mysecretpass' }); | ||
| 364 | }); | ||
| 365 | |||
| 366 | test('success response shows green status with Connected message', async ({ page }) => { | ||
| 367 | await setupMockRoutes(page, { | ||
| 368 | connectHandler: () => ({ ok: true }), | ||
| 369 | }); | ||
| 370 | await loadSetupPage(page); | ||
| 371 | await page.locator('.net-item').first().click(); | ||
| 372 | await page.locator('#wifiPass').fill('testpass'); | ||
| 373 | await page.locator('#passwordForm .btn').click(); | ||
| 374 | await expect(page.locator('#status')).toHaveClass(/success/, { timeout: 5000 }); | ||
| 375 | await expect(page.locator('#status')).toContainText('Connected!'); | ||
| 376 | }); | ||
| 377 | |||
| 378 | test('error response shows red status with failure reason', async ({ page }) => { | ||
| 379 | await setupMockRoutes(page, { | ||
| 380 | connectHandler: () => ({ ok: false, error: 'save failed' }), | ||
| 381 | }); | ||
| 382 | await loadSetupPage(page); | ||
| 383 | await page.locator('.net-item').first().click(); | ||
| 384 | await page.locator('#wifiPass').fill('wrongpass'); | ||
| 385 | await page.locator('#passwordForm .btn').click(); | ||
| 386 | await expect(page.locator('#status')).toHaveClass(/error/, { timeout: 5000 }); | ||
| 387 | await expect(page.locator('#status')).toContainText('Failed: save failed'); | ||
| 388 | }); | ||
| 389 | |||
| 390 | test('rescan button clears list and fetches fresh data', async ({ page }) => { | ||
| 391 | let scanCount = 0; | ||
| 392 | await page.route('**/setup', async route => { | ||
| 393 | await route.fulfill({ status: 200, contentType: 'text/html', body: SETUP_HTML }); | ||
| 394 | }); | ||
| 395 | await page.route('**/wifi/scan', async route => { | ||
| 396 | scanCount++; | ||
| 397 | const data = scanCount === 1 ? MOCK_AP_LIST : [ | ||
| 398 | { ssid: 'NewNetwork1', rssi: -30, secured: true }, | ||
| 399 | { ssid: 'NewNetwork2', rssi: -55, secured: false }, | ||
| 400 | ]; | ||
| 401 | await route.fulfill({ | ||
| 402 | status: 200, | ||
| 403 | contentType: 'application/json', | ||
| 404 | body: JSON.stringify(data), | ||
| 405 | }); | ||
| 406 | }); | ||
| 407 | await page.route('**/wifi/connect', async route => { | ||
| 408 | await route.fulfill({ | ||
| 409 | status: 200, | ||
| 410 | contentType: 'application/json', | ||
| 411 | body: JSON.stringify({ ok: true }), | ||
| 412 | }); | ||
| 413 | }); | ||
| 414 | await loadSetupPage(page); | ||
| 415 | await expect(page.locator('.net-item')).toHaveCount(4, { timeout: 5000 }); | ||
| 416 | expect(scanCount).toBe(1); | ||
| 417 | await page.locator('button:has-text("Rescan")').click(); | ||
| 418 | await expect(page.locator('.net-item')).toHaveCount(2, { timeout: 5000 }); | ||
| 419 | await expect(page.locator('.net-ssid').first()).toContainText('NewNetwork1'); | ||
| 420 | expect(scanCount).toBe(2); | ||
| 421 | }); | ||
| 422 | |||
| 423 | }); | ||
| 424 | |||
| 425 | test.describe('WiFi Setup \u2014 Layer 3: Full E2E (needs unconfigured board)', () => { | ||
| 426 | |||
| 427 | test.skip('full phone flow: scan \u2192 select \u2192 password \u2192 connect \u2192 status', async ({ page }) => { | ||
| 428 | await page.goto(`${PORTAL_URL}/setup`); | ||
| 429 | await expect(page.locator('h1')).toHaveText('TollGate Setup'); | ||
| 430 | await expect(page.locator('#scanStatus')).not.toHaveText('Scanning...', { timeout: 10000 }); | ||
| 431 | const networkCount = await page.locator('.net-item').count(); | ||
| 432 | expect(networkCount).toBeGreaterThan(0); | ||
| 433 | const firstSsid = await page.locator('.net-ssid').first().textContent(); | ||
| 434 | await page.locator('.net-item').first().click(); | ||
| 435 | await expect(page.locator('#passwordForm')).toBeVisible(); | ||
| 436 | await page.locator('#wifiPass').fill('test-password'); | ||
| 437 | await page.locator('#passwordForm .btn').click(); | ||
| 438 | await expect(page.locator('#status')).toHaveClass(/success|error|processing/, { timeout: 15000 }); | ||
| 439 | }); | ||
| 440 | }); | ||
diff --git a/tests/integration/wifi_setup.mjs b/tests/integration/wifi_setup.mjs new file mode 100644 index 0000000..a991ba5 --- /dev/null +++ b/tests/integration/wifi_setup.mjs | |||
| @@ -0,0 +1,74 @@ | |||
| 1 | import { execSync } from 'child_process'; | ||
| 2 | |||
| 3 | const IP = process.env.TOLLGATE_IP || '10.192.45.1'; | ||
| 4 | |||
| 5 | console.log(`\n=== WiFi Setup Integration Test ===`); | ||
| 6 | console.log(`Portal IP: ${IP}\n`); | ||
| 7 | |||
| 8 | let passed = 0, failed = 0; | ||
| 9 | function assert(cond, msg) { | ||
| 10 | if (cond) { console.log(` PASS: ${msg}`); passed++; } | ||
| 11 | else { console.log(` FAIL: ${msg}`); failed++; } | ||
| 12 | } | ||
| 13 | |||
| 14 | function run(cmd) { | ||
| 15 | try { return execSync(cmd, { encoding: 'utf8', timeout: 15000 }); } | ||
| 16 | catch { return null; } | ||
| 17 | } | ||
| 18 | |||
| 19 | function fetchJSON(path) { | ||
| 20 | const result = run(`curl -s --connect-timeout 5 http://${IP}${path}`); | ||
| 21 | if (!result) return null; | ||
| 22 | try { return JSON.parse(result); } | ||
| 23 | catch { return null; } | ||
| 24 | } | ||
| 25 | |||
| 26 | // 1. /setup page returns HTML (or redirects if already configured) | ||
| 27 | const setupPage = run(`curl -s -o /dev/null -w "%{http_code}" --connect-timeout 5 http://${IP}/setup`); | ||
| 28 | assert(setupPage === '200' || setupPage === '302', `/setup returns 200 or 302 (got ${setupPage})`); | ||
| 29 | |||
| 30 | // 2. /wifi/status endpoint works | ||
| 31 | const status = fetchJSON('/wifi/status'); | ||
| 32 | assert(status !== null, '/wifi/status returns JSON'); | ||
| 33 | assert(typeof status.connected === 'boolean', '/wifi/status has connected field'); | ||
| 34 | |||
| 35 | // 3. /wifi/scan endpoint returns array | ||
| 36 | console.log('\n (wifi/scan may take a few seconds...)'); | ||
| 37 | const scanResult = run(`curl -s --connect-timeout 15 --max-time 15 http://${IP}/wifi/scan`); | ||
| 38 | let scanData = null; | ||
| 39 | if (scanResult) { | ||
| 40 | try { scanData = JSON.parse(scanResult); } catch {} | ||
| 41 | } | ||
| 42 | assert(scanData !== null, '/wifi/scan returns JSON'); | ||
| 43 | if (scanData && Array.isArray(scanData)) { | ||
| 44 | assert(scanData.length >= 0, `/wifi/scan returns array (${scanData.length} APs)`); | ||
| 45 | if (scanData.length > 0) { | ||
| 46 | const ap = scanData[0]; | ||
| 47 | assert(ap.ssid !== undefined, 'AP has ssid field'); | ||
| 48 | assert(ap.rssi !== undefined, 'AP has rssi field'); | ||
| 49 | assert(ap.secured !== undefined, 'AP has secured field'); | ||
| 50 | console.log(` First AP: "${ap.ssid}" (${ap.rssi} dBm, ${ap.secured ? 'secured' : 'open'})`); | ||
| 51 | } | ||
| 52 | } | ||
| 53 | |||
| 54 | // 4. /wifi/connect rejects invalid JSON | ||
| 55 | const badConnect = run(`curl -s -X POST -d 'not json' --connect-timeout 5 http://${IP}/wifi/connect`); | ||
| 56 | assert(badConnect !== null, '/wifi/connect responds to bad request'); | ||
| 57 | if (badConnect) { | ||
| 58 | try { | ||
| 59 | const err = JSON.parse(badConnect); | ||
| 60 | assert(err.ok === false, '/wifi/connect returns ok:false for bad request'); | ||
| 61 | } catch {} | ||
| 62 | } | ||
| 63 | |||
| 64 | // 5. /wifi/connect rejects missing ssid | ||
| 65 | const noSsid = run(`curl -s -X POST -H 'Content-Type: application/json' -d '{}' --connect-timeout 5 http://${IP}/wifi/connect`); | ||
| 66 | if (noSsid) { | ||
| 67 | try { | ||
| 68 | const err = JSON.parse(noSsid); | ||
| 69 | assert(err.ok === false && err.error, '/wifi/connect returns error for missing ssid'); | ||
| 70 | } catch {} | ||
| 71 | } | ||
| 72 | |||
| 73 | console.log(`\n=== Results: ${passed} passed, ${failed} failed ===`); | ||
| 74 | process.exit(failed > 0 ? 1 : 0); | ||
diff --git a/tests/unit/stubs/driver/gpio.h b/tests/unit/stubs/driver/gpio.h new file mode 100644 index 0000000..d8dda0a --- /dev/null +++ b/tests/unit/stubs/driver/gpio.h | |||
| @@ -0,0 +1,44 @@ | |||
| 1 | #ifndef STUBS_DRIVER_GPIO_H | ||
| 2 | #define STUBS_DRIVER_GPIO_H | ||
| 3 | |||
| 4 | #include <stdint.h> | ||
| 5 | |||
| 6 | typedef enum { | ||
| 7 | GPIO_MODE_DISABLE = 0, | ||
| 8 | GPIO_MODE_INPUT, | ||
| 9 | GPIO_MODE_OUTPUT, | ||
| 10 | GPIO_MODE_OUTPUT_OD, | ||
| 11 | GPIO_MODE_INPUT_OUTPUT_OD, | ||
| 12 | GPIO_MODE_INPUT_OUTPUT, | ||
| 13 | } gpio_mode_t; | ||
| 14 | |||
| 15 | typedef enum { | ||
| 16 | GPIO_INTR_DISABLE = 0, | ||
| 17 | } gpio_int_type_t; | ||
| 18 | |||
| 19 | typedef enum { | ||
| 20 | GPIO_PULLUP_DISABLE = 0, | ||
| 21 | GPIO_PULLUP_ENABLE, | ||
| 22 | } gpio_pullup_t; | ||
| 23 | |||
| 24 | typedef enum { | ||
| 25 | GPIO_PULLDOWN_DISABLE = 0, | ||
| 26 | GPIO_PULLDOWN_ENABLE, | ||
| 27 | } gpio_pulldown_t; | ||
| 28 | |||
| 29 | typedef struct { | ||
| 30 | uint64_t pin_bit_mask; | ||
| 31 | gpio_mode_t mode; | ||
| 32 | gpio_pullup_t pull_up_en; | ||
| 33 | gpio_pulldown_t pull_down_en; | ||
| 34 | gpio_int_type_t intr_type; | ||
| 35 | } gpio_config_t; | ||
| 36 | |||
| 37 | static inline int gpio_config(const gpio_config_t *cfg) { (void)cfg; return 0; } | ||
| 38 | static inline int gpio_set_level(uint32_t gpio_num, uint32_t level) { (void)gpio_num; (void)level; return 0; } | ||
| 39 | |||
| 40 | #define GPIO_INTR_DISABLE 0 | ||
| 41 | #define GPIO_PULLUP_DISABLE 0 | ||
| 42 | #define GPIO_PULLDOWN_DISABLE 0 | ||
| 43 | |||
| 44 | #endif | ||
diff --git a/tests/unit/stubs/driver/i2c_master.h b/tests/unit/stubs/driver/i2c_master.h new file mode 100644 index 0000000..f49eaad --- /dev/null +++ b/tests/unit/stubs/driver/i2c_master.h | |||
| @@ -0,0 +1,39 @@ | |||
| 1 | #ifndef STUBS_DRIVER_I2C_MASTER_H | ||
| 2 | #define STUBS_DRIVER_I2C_MASTER_H | ||
| 3 | |||
| 4 | #include "driver/i2c_types.h" | ||
| 5 | #include "esp_err.h" | ||
| 6 | #include <stdint.h> | ||
| 7 | #include <stddef.h> | ||
| 8 | |||
| 9 | static inline esp_err_t i2c_new_master_bus(const i2c_master_bus_config_t *cfg, i2c_master_bus_handle_t *ret) { | ||
| 10 | (void)cfg; (void)ret; | ||
| 11 | return ESP_OK; | ||
| 12 | } | ||
| 13 | |||
| 14 | static inline esp_err_t i2c_master_bus_add_device(i2c_master_bus_handle_t bus, const i2c_device_config_t *cfg, i2c_master_dev_handle_t *ret) { | ||
| 15 | (void)bus; (void)cfg; (void)ret; | ||
| 16 | return ESP_OK; | ||
| 17 | } | ||
| 18 | |||
| 19 | static inline esp_err_t i2c_master_transmit(i2c_master_dev_handle_t dev, const uint8_t *buf, size_t len, int timeout_ms) { | ||
| 20 | (void)dev; (void)buf; (void)len; (void)timeout_ms; | ||
| 21 | return ESP_OK; | ||
| 22 | } | ||
| 23 | |||
| 24 | static inline esp_err_t i2c_master_receive(i2c_master_dev_handle_t dev, uint8_t *buf, size_t len, int timeout_ms) { | ||
| 25 | (void)dev; (void)buf; (void)len; (void)timeout_ms; | ||
| 26 | return ESP_OK; | ||
| 27 | } | ||
| 28 | |||
| 29 | static inline esp_err_t i2c_master_bus_rm_device(i2c_master_dev_handle_t dev) { | ||
| 30 | (void)dev; | ||
| 31 | return ESP_OK; | ||
| 32 | } | ||
| 33 | |||
| 34 | static inline esp_err_t i2c_del_master_bus(i2c_master_bus_handle_t bus) { | ||
| 35 | (void)bus; | ||
| 36 | return ESP_OK; | ||
| 37 | } | ||
| 38 | |||
| 39 | #endif | ||
diff --git a/tests/unit/stubs/driver/i2c_types.h b/tests/unit/stubs/driver/i2c_types.h new file mode 100644 index 0000000..3590a8b --- /dev/null +++ b/tests/unit/stubs/driver/i2c_types.h | |||
| @@ -0,0 +1,45 @@ | |||
| 1 | #ifndef STUBS_DRIVER_I2C_TYPES_H | ||
| 2 | #define STUBS_DRIVER_I2C_TYPES_H | ||
| 3 | |||
| 4 | #include <stdint.h> | ||
| 5 | #include <stddef.h> | ||
| 6 | |||
| 7 | typedef int i2c_port_num_t; | ||
| 8 | #define I2C_NUM_0 0 | ||
| 9 | |||
| 10 | typedef enum { | ||
| 11 | I2C_ADDR_BIT_LEN_7 = 0, | ||
| 12 | } i2c_addr_bit_len_t; | ||
| 13 | |||
| 14 | typedef enum { | ||
| 15 | I2C_CLK_SRC_DEFAULT = 0, | ||
| 16 | } i2c_clock_source_t; | ||
| 17 | |||
| 18 | typedef struct i2c_master_bus_t *i2c_master_bus_handle_t; | ||
| 19 | typedef struct i2c_master_dev_t *i2c_master_dev_handle_t; | ||
| 20 | |||
| 21 | typedef struct { | ||
| 22 | i2c_port_num_t i2c_port; | ||
| 23 | int sda_io_num; | ||
| 24 | int scl_io_num; | ||
| 25 | i2c_clock_source_t clk_source; | ||
| 26 | uint8_t glitch_ignore_cnt; | ||
| 27 | int intr_priority; | ||
| 28 | size_t trans_queue_depth; | ||
| 29 | struct { | ||
| 30 | uint32_t enable_internal_pullup : 1; | ||
| 31 | uint32_t allow_pd : 1; | ||
| 32 | } flags; | ||
| 33 | } i2c_master_bus_config_t; | ||
| 34 | |||
| 35 | typedef struct { | ||
| 36 | i2c_addr_bit_len_t dev_addr_length; | ||
| 37 | uint16_t device_address; | ||
| 38 | uint32_t scl_speed_hz; | ||
| 39 | uint32_t scl_wait_us; | ||
| 40 | struct { | ||
| 41 | uint32_t disable_ack_check : 1; | ||
| 42 | } flags; | ||
| 43 | } i2c_device_config_t; | ||
| 44 | |||
| 45 | #endif | ||
diff --git a/tests/unit/test_keyboard b/tests/unit/test_keyboard new file mode 100755 index 0000000..61cc9f5 --- /dev/null +++ b/tests/unit/test_keyboard | |||
| Binary files differ | |||
diff --git a/tests/unit/test_keyboard.c b/tests/unit/test_keyboard.c new file mode 100644 index 0000000..81ca328 --- /dev/null +++ b/tests/unit/test_keyboard.c | |||
| @@ -0,0 +1,172 @@ | |||
| 1 | #include "test_framework.h" | ||
| 2 | #include "../../main/keyboard.h" | ||
| 3 | #include <string.h> | ||
| 4 | |||
| 5 | int main(void) | ||
| 6 | { | ||
| 7 | printf("=== test_keyboard ===\n"); | ||
| 8 | |||
| 9 | const char *keys; | ||
| 10 | int count; | ||
| 11 | |||
| 12 | count = kb_get_row_keys(0, KB_ALPHA_LOWER, &keys); | ||
| 13 | ASSERT_EQ_INT(10, count, "Row 0 alpha lower has 10 keys"); | ||
| 14 | ASSERT_EQ_INT('q', keys[0], "Row 0 starts with 'q'"); | ||
| 15 | ASSERT_EQ_INT('p', keys[9], "Row 0 ends with 'p'"); | ||
| 16 | |||
| 17 | count = kb_get_row_keys(1, KB_ALPHA_LOWER, &keys); | ||
| 18 | ASSERT_EQ_INT(9, count, "Row 1 alpha lower has 9 keys"); | ||
| 19 | ASSERT_EQ_INT('a', keys[0], "Row 1 starts with 'a'"); | ||
| 20 | |||
| 21 | count = kb_get_row_keys(2, KB_ALPHA_LOWER, &keys); | ||
| 22 | ASSERT(count > 0, "Row 2 alpha lower has keys"); | ||
| 23 | ASSERT_EQ_INT('\001', keys[0], "Row 2 starts with SHIFT control char"); | ||
| 24 | |||
| 25 | count = kb_get_row_keys(0, KB_ALPHA_UPPER, &keys); | ||
| 26 | ASSERT_EQ_INT(10, count, "Row 0 alpha upper has 10 keys"); | ||
| 27 | ASSERT_EQ_INT('Q', keys[0], "Row 0 upper starts with 'Q'"); | ||
| 28 | |||
| 29 | count = kb_get_row_keys(0, KB_NUMSYM, &keys); | ||
| 30 | ASSERT_EQ_INT(10, count, "Row 0 numsym has 10 keys"); | ||
| 31 | ASSERT_EQ_INT('1', keys[0], "Row 0 numsym starts with '1'"); | ||
| 32 | ASSERT_EQ_INT('0', keys[9], "Row 0 numsym ends with '0'"); | ||
| 33 | |||
| 34 | count = kb_get_row_keys(-1, KB_ALPHA_LOWER, &keys); | ||
| 35 | ASSERT_EQ_INT(0, count, "Invalid row -1 returns 0"); | ||
| 36 | |||
| 37 | count = kb_get_row_keys(99, KB_ALPHA_LOWER, &keys); | ||
| 38 | ASSERT_EQ_INT(0, count, "Invalid row 99 returns 0"); | ||
| 39 | |||
| 40 | { | ||
| 41 | kb_result_t r = kb_hit_test(160, 10, KB_ALPHA_LOWER); | ||
| 42 | ASSERT(r.action == KB_ACTION_NONE, "Touch above keyboard = NONE"); | ||
| 43 | |||
| 44 | r = kb_hit_test(160, 70 + 4 * (36 + 2) + 10, KB_ALPHA_LOWER); | ||
| 45 | ASSERT(r.action == KB_ACTION_NONE, "Touch below keyboard = NONE"); | ||
| 46 | } | ||
| 47 | |||
| 48 | { | ||
| 49 | int margin_r0 = (320 - (10 * 28 + 9 * 2)) / 2; | ||
| 50 | int mid_x = margin_r0 + 28 / 2; | ||
| 51 | int mid_y = 70 + 36 / 2; | ||
| 52 | kb_result_t r = kb_hit_test(mid_x, mid_y, KB_ALPHA_LOWER); | ||
| 53 | ASSERT(r.action == KB_ACTION_CHAR, "Row 0 first key is a char"); | ||
| 54 | ASSERT_EQ_INT('q', r.ch, "Row 0 first key = 'q'"); | ||
| 55 | } | ||
| 56 | |||
| 57 | { | ||
| 58 | int margin_r0 = (320 - (10 * 28 + 9 * 2)) / 2; | ||
| 59 | int x = margin_r0 + 28 + 2 + 28 / 2; | ||
| 60 | int y = 70 + 36 / 2; | ||
| 61 | kb_result_t r = kb_hit_test(x, y, KB_ALPHA_LOWER); | ||
| 62 | ASSERT(r.action == KB_ACTION_CHAR, "Row 0 second key is a char"); | ||
| 63 | ASSERT_EQ_INT('w', r.ch, "Row 0 second key = 'w'"); | ||
| 64 | } | ||
| 65 | |||
| 66 | { | ||
| 67 | int margin_r1 = (320 - (9 * 28 + 8 * 2)) / 2; | ||
| 68 | int y_row1 = 70 + (36 + 2) + 36 / 2; | ||
| 69 | int x_row1 = margin_r1 + 28 / 2 + 28 / 2; | ||
| 70 | kb_result_t r = kb_hit_test(x_row1, y_row1, KB_ALPHA_LOWER); | ||
| 71 | ASSERT(r.action == KB_ACTION_CHAR, "Row 1 first key is a char"); | ||
| 72 | ASSERT_EQ_INT('a', r.ch, "Row 1 first key = 'a'"); | ||
| 73 | } | ||
| 74 | |||
| 75 | { | ||
| 76 | int margin_r2 = (320 - (9 * 28 + 8 * 2)) / 2 + 28; | ||
| 77 | int y_row2 = 70 + 2 * (36 + 2) + 36 / 2; | ||
| 78 | int x_row2 = margin_r2 + 28 / 2; | ||
| 79 | kb_result_t r = kb_hit_test(x_row2, y_row2, KB_ALPHA_LOWER); | ||
| 80 | ASSERT(r.action == KB_ACTION_SHIFT, "Row 2 first key = SHIFT"); | ||
| 81 | } | ||
| 82 | |||
| 83 | { | ||
| 84 | kb_state_t st; | ||
| 85 | kb_state_init(&st); | ||
| 86 | ASSERT_EQ_INT(0, st.cursor, "Initial cursor = 0"); | ||
| 87 | ASSERT_EQ_INT(KB_ALPHA_LOWER, st.layer, "Initial layer = lower"); | ||
| 88 | ASSERT_EQ_STR("", st.input, "Initial input is empty"); | ||
| 89 | |||
| 90 | kb_apply(&st, (kb_result_t){KB_ACTION_CHAR, 'h'}); | ||
| 91 | ASSERT_EQ_STR("h", st.input, "After typing 'h': input='h'"); | ||
| 92 | ASSERT_EQ_INT(1, st.cursor, "After typing 'h': cursor=1"); | ||
| 93 | |||
| 94 | kb_apply(&st, (kb_result_t){KB_ACTION_CHAR, 'i'}); | ||
| 95 | ASSERT_EQ_STR("hi", st.input, "After typing 'i': input='hi'"); | ||
| 96 | |||
| 97 | kb_apply(&st, (kb_result_t){KB_ACTION_BACKSPACE, 0}); | ||
| 98 | ASSERT_EQ_STR("h", st.input, "After backspace: input='h'"); | ||
| 99 | ASSERT_EQ_INT(1, st.cursor, "After backspace: cursor=1"); | ||
| 100 | |||
| 101 | kb_apply(&st, (kb_result_t){KB_ACTION_BACKSPACE, 0}); | ||
| 102 | ASSERT_EQ_STR("", st.input, "After second backspace: empty"); | ||
| 103 | ASSERT_EQ_INT(0, st.cursor, "After second backspace: cursor=0"); | ||
| 104 | |||
| 105 | kb_apply(&st, (kb_result_t){KB_ACTION_BACKSPACE, 0}); | ||
| 106 | ASSERT_EQ_INT(0, st.cursor, "Backspace on empty stays at 0"); | ||
| 107 | } | ||
| 108 | |||
| 109 | { | ||
| 110 | kb_state_t st; | ||
| 111 | kb_state_init(&st); | ||
| 112 | |||
| 113 | kb_apply(&st, (kb_result_t){KB_ACTION_SHIFT, 0}); | ||
| 114 | ASSERT_EQ_INT(KB_ALPHA_UPPER, st.layer, "Shift: lower->upper"); | ||
| 115 | |||
| 116 | kb_apply(&st, (kb_result_t){KB_ACTION_SHIFT, 0}); | ||
| 117 | ASSERT_EQ_INT(KB_ALPHA_LOWER, st.layer, "Shift: upper->lower"); | ||
| 118 | |||
| 119 | kb_apply(&st, (kb_result_t){KB_ACTION_LAYER, 0}); | ||
| 120 | ASSERT_EQ_INT(KB_NUMSYM, st.layer, "Layer: lower->numsym"); | ||
| 121 | |||
| 122 | kb_apply(&st, (kb_result_t){KB_ACTION_LAYER, 0}); | ||
| 123 | ASSERT_EQ_INT(KB_ALPHA_LOWER, st.layer, "Layer: numsym->lower"); | ||
| 124 | } | ||
| 125 | |||
| 126 | { | ||
| 127 | kb_state_t st; | ||
| 128 | kb_state_init(&st); | ||
| 129 | |||
| 130 | for (int i = 0; i < KB_INPUT_MAX; i++) { | ||
| 131 | kb_apply(&st, (kb_result_t){KB_ACTION_CHAR, 'a' + (i % 26)}); | ||
| 132 | } | ||
| 133 | ASSERT_EQ_INT(KB_INPUT_MAX, st.cursor, "Filled to max"); | ||
| 134 | ASSERT_EQ_INT(KB_INPUT_MAX, (int)strlen(st.input), "String length = max"); | ||
| 135 | |||
| 136 | kb_apply(&st, (kb_result_t){KB_ACTION_CHAR, 'Z'}); | ||
| 137 | ASSERT_EQ_INT(KB_INPUT_MAX, st.cursor, "Overflow blocked"); | ||
| 138 | ASSERT_EQ_INT(KB_INPUT_MAX, (int)strlen(st.input), "Length unchanged after overflow"); | ||
| 139 | } | ||
| 140 | |||
| 141 | { | ||
| 142 | kb_state_t st; | ||
| 143 | kb_state_init(&st); | ||
| 144 | |||
| 145 | kb_apply(&st, (kb_result_t){KB_ACTION_SPACE, ' '}); | ||
| 146 | ASSERT_EQ_STR(" ", st.input, "Space adds space char"); | ||
| 147 | ASSERT_EQ_INT(1, st.cursor, "Space increments cursor"); | ||
| 148 | } | ||
| 149 | |||
| 150 | { | ||
| 151 | kb_state_t st; | ||
| 152 | kb_state_init(&st); | ||
| 153 | kb_result_t none = {KB_ACTION_NONE, 0}; | ||
| 154 | kb_apply(&st, none); | ||
| 155 | ASSERT_EQ_STR("", st.input, "NONE action does nothing"); | ||
| 156 | |||
| 157 | kb_apply(NULL, (kb_result_t){KB_ACTION_CHAR, 'x'}); | ||
| 158 | } | ||
| 159 | |||
| 160 | { | ||
| 161 | kb_state_t st; | ||
| 162 | kb_state_init(&st); | ||
| 163 | |||
| 164 | kb_apply(&st, (kb_result_t){KB_ACTION_CHAR, 'P'}); | ||
| 165 | kb_apply(&st, (kb_result_t){KB_ACTION_CHAR, '@'}); | ||
| 166 | kb_apply(&st, (kb_result_t){KB_ACTION_CHAR, 's'}); | ||
| 167 | kb_apply(&st, (kb_result_t){KB_ACTION_CHAR, 's'}); | ||
| 168 | ASSERT_EQ_STR("P@ss", st.input, "Password build: P@ss"); | ||
| 169 | } | ||
| 170 | |||
| 171 | TEST_SUMMARY(); | ||
| 172 | } | ||
diff --git a/tests/unit/test_touch b/tests/unit/test_touch new file mode 100755 index 0000000..43c8790 --- /dev/null +++ b/tests/unit/test_touch | |||
| Binary files differ | |||
diff --git a/tests/unit/test_touch.c b/tests/unit/test_touch.c new file mode 100644 index 0000000..13f04b5 --- /dev/null +++ b/tests/unit/test_touch.c | |||
| @@ -0,0 +1,93 @@ | |||
| 1 | #include "test_framework.h" | ||
| 2 | #include "../../main/touch.h" | ||
| 3 | #include <string.h> | ||
| 4 | |||
| 5 | int main(void) | ||
| 6 | { | ||
| 7 | touch_point_t pt; | ||
| 8 | uint8_t data[8]; | ||
| 9 | |||
| 10 | printf("=== test_touch ===\n"); | ||
| 11 | |||
| 12 | memset(data, 0, sizeof(data)); | ||
| 13 | touch_parse_raw(data, &pt); | ||
| 14 | ASSERT(!pt.touched, "All-zero data = no touch"); | ||
| 15 | |||
| 16 | data[0] = 1; | ||
| 17 | data[1] = 1; | ||
| 18 | data[2] = 0; | ||
| 19 | data[3] = 0; | ||
| 20 | touch_parse_raw(data, &pt); | ||
| 21 | ASSERT(!pt.touched, "data[0]=1 = no touch (gesture byte nonzero)"); | ||
| 22 | |||
| 23 | data[0] = 0; | ||
| 24 | data[1] = 0; | ||
| 25 | touch_parse_raw(data, &pt); | ||
| 26 | ASSERT(!pt.touched, "data[1]=0 = no touch (touch count zero)"); | ||
| 27 | |||
| 28 | data[0] = 0; | ||
| 29 | data[1] = 1; | ||
| 30 | data[2] = 0x00; | ||
| 31 | data[3] = 0x64; | ||
| 32 | data[4] = 0x00; | ||
| 33 | data[5] = 0xC8; | ||
| 34 | data[6] = 0; | ||
| 35 | data[7] = 0; | ||
| 36 | touch_parse_raw(data, &pt); | ||
| 37 | ASSERT(pt.touched, "Valid touch: touched=true"); | ||
| 38 | ASSERT_EQ_INT(100, (int)pt.x, "Valid touch: x=100"); | ||
| 39 | ASSERT_EQ_INT(200, (int)pt.y, "Valid touch: y=200"); | ||
| 40 | |||
| 41 | data[0] = 0; | ||
| 42 | data[1] = 1; | ||
| 43 | data[2] = 0x0F; | ||
| 44 | data[3] = 0xFF; | ||
| 45 | data[4] = 0x0F; | ||
| 46 | data[5] = 0xFF; | ||
| 47 | touch_parse_raw(data, &pt); | ||
| 48 | ASSERT(pt.touched, "Max raw coords: touched=true"); | ||
| 49 | ASSERT_EQ_INT(TOUCH_MAX_X, (int)pt.x, "Max raw coords clamped to 319"); | ||
| 50 | ASSERT_EQ_INT(TOUCH_MAX_Y, (int)pt.y, "Max raw coords clamped to 479"); | ||
| 51 | |||
| 52 | data[0] = 0; | ||
| 53 | data[1] = 1; | ||
| 54 | data[2] = 0x05; | ||
| 55 | data[3] = 0x00; | ||
| 56 | data[4] = 0x08; | ||
| 57 | data[5] = 0x00; | ||
| 58 | touch_parse_raw(data, &pt); | ||
| 59 | ASSERT(pt.touched, "12-bit coords: touched=true"); | ||
| 60 | ASSERT_EQ_INT(TOUCH_MAX_X, (int)pt.x, "12-bit x: (0x05 << 8) | 0x00 = 1280, clamped to 319"); | ||
| 61 | |||
| 62 | data[0] = 0; | ||
| 63 | data[1] = 2; | ||
| 64 | touch_parse_raw(data, &pt); | ||
| 65 | ASSERT(!pt.touched, "data[1]=2 = too many touches, reject"); | ||
| 66 | |||
| 67 | touch_parse_raw(NULL, &pt); | ||
| 68 | ASSERT(!pt.touched, "NULL data = no touch"); | ||
| 69 | |||
| 70 | data[0] = 0; | ||
| 71 | data[1] = 1; | ||
| 72 | data[2] = 0x00; | ||
| 73 | data[3] = 0x00; | ||
| 74 | data[4] = 0x00; | ||
| 75 | data[5] = 0x00; | ||
| 76 | touch_parse_raw(data, &pt); | ||
| 77 | ASSERT(pt.touched, "Origin (0,0): touched=true"); | ||
| 78 | ASSERT_EQ_INT(0, (int)pt.x, "Origin: x=0"); | ||
| 79 | ASSERT_EQ_INT(0, (int)pt.y, "Origin: y=0"); | ||
| 80 | |||
| 81 | data[0] = 0; | ||
| 82 | data[1] = 1; | ||
| 83 | data[2] = 0x01; | ||
| 84 | data[3] = 0x3F; | ||
| 85 | data[4] = 0x01; | ||
| 86 | data[5] = 0xDF; | ||
| 87 | touch_parse_raw(data, &pt); | ||
| 88 | ASSERT(pt.touched, "Mid-screen: touched=true"); | ||
| 89 | ASSERT_EQ_INT(319, (int)pt.x, "Mid-screen: x=0x13F=319"); | ||
| 90 | ASSERT_EQ_INT(479, (int)pt.y, "Mid-screen: y=0x1DF=479"); | ||
| 91 | |||
| 92 | TEST_SUMMARY(); | ||
| 93 | } | ||
diff --git a/tests/unit/test_wifi_setup b/tests/unit/test_wifi_setup new file mode 100755 index 0000000..aa0e0b4 --- /dev/null +++ b/tests/unit/test_wifi_setup | |||
| Binary files differ | |||
diff --git a/tests/unit/test_wifi_setup.c b/tests/unit/test_wifi_setup.c new file mode 100644 index 0000000..5f1b8f0 --- /dev/null +++ b/tests/unit/test_wifi_setup.c | |||
| @@ -0,0 +1,121 @@ | |||
| 1 | #include "test_framework.h" | ||
| 2 | #include "../../main/wifi_setup.h" | ||
| 3 | #include <string.h> | ||
| 4 | |||
| 5 | int main(void) | ||
| 6 | { | ||
| 7 | printf("=== test_wifi_setup ===\n"); | ||
| 8 | |||
| 9 | wifi_setup_t setup; | ||
| 10 | wifi_setup_init(&setup); | ||
| 11 | |||
| 12 | ASSERT_EQ_INT(SETUP_SCAN, (int)setup.state, "Init state = SCAN"); | ||
| 13 | ASSERT_EQ_INT(0, setup.ap_count, "Init ap_count = 0"); | ||
| 14 | ASSERT_EQ_INT(-1, setup.selected_ap, "Init selected_ap = -1"); | ||
| 15 | |||
| 16 | wifi_ap_info_t test_aps[3] = { | ||
| 17 | {"FastNet", -30, true}, | ||
| 18 | {"SlowNet", -70, true}, | ||
| 19 | {"OpenNet", -50, false}, | ||
| 20 | }; | ||
| 21 | wifi_setup_set_aps(&setup, test_aps, 3); | ||
| 22 | |||
| 23 | ASSERT_EQ_INT(SETUP_LIST, (int)setup.state, "After set_aps: state = LIST"); | ||
| 24 | ASSERT_EQ_INT(3, setup.ap_count, "After set_aps: ap_count = 3"); | ||
| 25 | ASSERT_EQ_INT(3, wifi_setup_visible_count(&setup), "Visible count = 3"); | ||
| 26 | |||
| 27 | { | ||
| 28 | const wifi_ap_info_t *ap = wifi_setup_get_visible(&setup, 0); | ||
| 29 | ASSERT(ap != NULL, "Visible AP 0 is not NULL"); | ||
| 30 | ASSERT_EQ_STR("FastNet", ap->ssid, "AP 0 = FastNet"); | ||
| 31 | ASSERT_EQ_INT(-30, ap->rssi, "AP 0 RSSI = -30"); | ||
| 32 | ASSERT(ap->secured, "AP 0 is secured"); | ||
| 33 | } | ||
| 34 | |||
| 35 | { | ||
| 36 | const wifi_ap_info_t *ap = wifi_setup_get_visible(&setup, 2); | ||
| 37 | ASSERT(ap != NULL, "Visible AP 2 is not NULL"); | ||
| 38 | ASSERT_EQ_STR("OpenNet", ap->ssid, "AP 2 = OpenNet"); | ||
| 39 | ASSERT(!ap->secured, "AP 2 is open"); | ||
| 40 | } | ||
| 41 | |||
| 42 | { | ||
| 43 | const wifi_ap_info_t *ap = wifi_setup_get_visible(&setup, 3); | ||
| 44 | ASSERT(ap == NULL, "Out of range returns NULL"); | ||
| 45 | } | ||
| 46 | |||
| 47 | setup_state_t s = wifi_setup_handle_select(&setup, 0); | ||
| 48 | ASSERT_EQ_INT(SETUP_PASSWORD, (int)s, "Select AP 0: state = PASSWORD"); | ||
| 49 | ASSERT_EQ_INT(0, setup.selected_ap, "Selected AP index = 0"); | ||
| 50 | ASSERT_EQ_STR("FastNet", setup.selected_ssid, "Selected SSID = FastNet"); | ||
| 51 | |||
| 52 | wifi_setup_handle_connect(&setup); | ||
| 53 | ASSERT_EQ_INT(SETUP_CONNECTING, (int)setup.state, "Connect: state = CONNECTING"); | ||
| 54 | |||
| 55 | s = wifi_setup_handle_connect_result(&setup, true, "192.168.1.42"); | ||
| 56 | ASSERT_EQ_INT(SETUP_SUCCESS, (int)s, "Connect success: state = SUCCESS"); | ||
| 57 | ASSERT_EQ_STR("192.168.1.42", setup.connect_ip, "Connect IP stored"); | ||
| 58 | |||
| 59 | wifi_setup_init(&setup); | ||
| 60 | wifi_setup_set_aps(&setup, test_aps, 3); | ||
| 61 | wifi_setup_handle_select(&setup, 1); | ||
| 62 | wifi_setup_handle_connect(&setup); | ||
| 63 | |||
| 64 | s = wifi_setup_handle_connect_result(&setup, false, NULL); | ||
| 65 | ASSERT_EQ_INT(SETUP_FAILED, (int)s, "Connect fail: state = FAILED"); | ||
| 66 | ASSERT(setup.connect_failed_auth, "Failed auth flag set"); | ||
| 67 | |||
| 68 | s = wifi_setup_handle_retry(&setup); | ||
| 69 | ASSERT_EQ_INT(SETUP_PASSWORD, (int)s, "Retry: state = PASSWORD"); | ||
| 70 | ASSERT(!setup.connect_failed_auth, "Retry clears auth flag"); | ||
| 71 | |||
| 72 | wifi_setup_init(&setup); | ||
| 73 | wifi_setup_set_aps(&setup, test_aps, 3); | ||
| 74 | wifi_setup_handle_select(&setup, 0); | ||
| 75 | wifi_setup_handle_connect(&setup); | ||
| 76 | wifi_setup_handle_connect_result(&setup, false, NULL); | ||
| 77 | |||
| 78 | s = wifi_setup_handle_change_network(&setup); | ||
| 79 | ASSERT_EQ_INT(SETUP_LIST, (int)s, "Change network: state = LIST"); | ||
| 80 | |||
| 81 | wifi_setup_init(&setup); | ||
| 82 | s = wifi_setup_handle_cancel(&setup); | ||
| 83 | ASSERT_EQ_INT(SETUP_CANCELLED, (int)s, "Cancel: state = CANCELLED"); | ||
| 84 | |||
| 85 | wifi_setup_init(&setup); | ||
| 86 | wifi_ap_info_t many_aps[12]; | ||
| 87 | for (int i = 0; i < 12; i++) { | ||
| 88 | snprintf(many_aps[i].ssid, sizeof(many_aps[i].ssid), "Net%d", i); | ||
| 89 | many_aps[i].rssi = -30 - i * 5; | ||
| 90 | many_aps[i].secured = true; | ||
| 91 | } | ||
| 92 | wifi_setup_set_aps(&setup, many_aps, 12); | ||
| 93 | ASSERT_EQ_INT(12, setup.ap_count, "12 APs stored"); | ||
| 94 | ASSERT_EQ_INT(8, wifi_setup_visible_count(&setup), "Only 8 visible"); | ||
| 95 | |||
| 96 | { | ||
| 97 | const wifi_ap_info_t *ap = wifi_setup_get_visible(&setup, 7); | ||
| 98 | ASSERT(ap != NULL, "8th visible AP exists"); | ||
| 99 | ASSERT_EQ_STR("Net7", ap->ssid, "8th visible = Net7"); | ||
| 100 | } | ||
| 101 | |||
| 102 | wifi_setup_init(&setup); | ||
| 103 | s = wifi_setup_handle_select(&setup, 0); | ||
| 104 | ASSERT_EQ_INT(SETUP_SCAN, (int)s, "Select in SCAN state = no change"); | ||
| 105 | |||
| 106 | s = wifi_setup_handle_select(&setup, -1); | ||
| 107 | ASSERT_EQ_INT(SETUP_SCAN, (int)s, "Select invalid idx = no change"); | ||
| 108 | |||
| 109 | wifi_setup_init(&setup); | ||
| 110 | wifi_setup_set_aps(&setup, test_aps, 3); | ||
| 111 | s = wifi_setup_handle_retry(&setup); | ||
| 112 | ASSERT_EQ_INT(SETUP_LIST, (int)s, "Retry in LIST state = no change"); | ||
| 113 | |||
| 114 | s = wifi_setup_handle_change_network(&setup); | ||
| 115 | ASSERT_EQ_INT(SETUP_LIST, (int)s, "Change network in LIST state = no change"); | ||
| 116 | |||
| 117 | ASSERT_EQ_INT(0, wifi_setup_visible_count(NULL), "NULL setup returns 0"); | ||
| 118 | ASSERT(NULL == wifi_setup_get_visible(NULL, 0), "NULL setup returns NULL AP"); | ||
| 119 | |||
| 120 | TEST_SUMMARY(); | ||
| 121 | } | ||