upleb.uk

Public git repos — served from a NIP-34 GRASP relay at git.upleb.uk

summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--DISPLAY_FIX_PLAN.md174
-rw-r--r--DISPLAY_UI_PLAN.md165
-rw-r--r--TOUCH_WIFI_SETUP_PLAN.md92
-rw-r--r--WEB_WIFI_SETUP_PLAN.md100
-rw-r--r--components/axs15231b/axs15231b.c237
-rw-r--r--components/axs15231b/include/axs15231b.h5
-rw-r--r--main/CMakeLists.txt3
-rw-r--r--main/captive_portal.c368
-rw-r--r--main/captive_portal.h1
-rw-r--r--main/config.h1
-rw-r--r--main/display.c349
-rw-r--r--main/display.h10
-rw-r--r--main/keyboard.c186
-rw-r--r--main/keyboard.h53
-rw-r--r--main/touch.c156
-rw-r--r--main/touch.h29
-rw-r--r--main/wifi_setup.c89
-rw-r--r--main/wifi_setup.h51
-rw-r--r--tests/e2e/wifi-setup.spec.mjs440
-rw-r--r--tests/integration/wifi_setup.mjs74
-rw-r--r--tests/unit/stubs/driver/gpio.h44
-rw-r--r--tests/unit/stubs/driver/i2c_master.h39
-rw-r--r--tests/unit/stubs/driver/i2c_types.h45
-rwxr-xr-xtests/unit/test_keyboardbin0 -> 35416 bytes
-rw-r--r--tests/unit/test_keyboard.c172
-rwxr-xr-xtests/unit/test_touchbin0 -> 30304 bytes
-rw-r--r--tests/unit/test_touch.c93
-rwxr-xr-xtests/unit/test_wifi_setupbin0 -> 33336 bytes
-rw-r--r--tests/unit/test_wifi_setup.c121
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
12Register 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
28The 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
30ArduinoGFX 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
32Our 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
42ArduinoGFX 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
47Our 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
49A 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
60All 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
107At init, allocate a static buffer:
108```c
109#define FLUSH_CHUNK_PIXELS 2048 // 4096 bytes, fits in internal DMA RAM
110static uint8_t *s_swap_buf = NULL;
111
112// In axs15231b_init():
113s_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
119void 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
160Only re-render when:
161- `s_force_render` is set (state change, init)
162- QR mode cycles (every 5s in READY state)
163
164This 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
25Shown 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
36Cycles 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
65Shows 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
79Shown 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```
111app_main()
112 └─ display_set_state(BOOT)
113 └─ display_update(price, mint, ssid) ← config data available after config_init
114
115wifi_event_handler(STA_DISCONNECTED)
116 └─ display_set_state(ERROR)
117 └─ display_update(wifi_status="retrying...")
118
119ip_event_handler(STA_GOT_IP)
120 └─ start_services()
121 └─ display_set_state(READY)
122 └─ display_update(ssid, portal_url, mint, price)
123
124tollgate_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
130display_task (3s timeout)
131 └─ auto-return PAYMENT_RECEIVED → READY
132
133app_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
140void display_notify_payment(int amount_sats, int64_t allotment_ms);
141```
142
143Stores 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
4Add 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
36Title + "Scanning..." spinner
37
38### 2. Network List
39Top 8 by RSSI, sorted strongest first, scrollable. Lock icon for secured networks.
40
41### 3. Password Entry
42SSID name, masked password field with eye reveal toggle, QWERTY keyboard.
43
44### 4. Connecting
45"Connecting to SSID..." spinner
46
47### 5. Result
48Green "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
62Add `tollgate_config_add_wifi(const char *ssid, const char *password)` to `config.c` - rewrites `/spiffs/config.json`.
63
64## Display Extension
65Add `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
5Move WiFi configuration from on-display touchscreen UI to a web-based setup page
6served by the captive portal. The display becomes portrait-only, showing QR codes
7and status info. No more landscape rotation, on-screen keyboard, or touch-driven
8WiFi 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
32static spi_device_handle_t s_spi = NULL; 36static spi_device_handle_t s_spi = NULL;
33static uint16_t *s_fb = NULL; 37static uint16_t *s_fb = NULL;
34static int s_width = AXS15231B_WIDTH; 38static int s_width = AXS15231B_WIDTH;
35static int s_height = AXS15231B_HEIGHT; 39static int s_height = AXS15231B_HEIGHT;
40static int s_stride = AXS15231B_WIDTH;
41static uint8_t *s_swap_buf = NULL;
42#define SWAP_BUF_PIXELS 2048
36 43
37typedef struct { 44typedef 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
44static esp_err_t send_cmd(uint8_t cmd) { 51static 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; 55static inline void cs_high(void) {
49 return spi_device_polling_transmit(s_spi, &t); 56 gpio_set_level(AXS15231B_PIN_CS, 1);
57}
58
59static 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
71static 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
83static 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
95static 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
52static esp_err_t send_data(const uint8_t *data, int len) { 108static 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
61static esp_err_t send_cmd_data(uint8_t cmd, const uint8_t *data, int len) { 124static 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
68static const uint8_t init_bb[] = {0x00,0x00,0x00,0x00,0x00,0x00,0x5A,0xA5}; 139static 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
226void axs15231b_fill_screen(uint16_t color) { 299void 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
242void axs15231b_flush(void) { 316void 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
281int axs15231b_get_width(void) { return s_width; } 358int 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);
23void axs15231b_flush(void); 23void axs15231b_flush(void);
24int axs15231b_get_width(void); 24int axs15231b_get_width(void);
25int axs15231b_get_height(void); 25int 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
17static const char *TAG = "captive_portal"; 19static const char *TAG = "captive_portal";
18static httpd_handle_t s_server = NULL; 20static 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
345static esp_err_t catchall_handler(httpd_req_t *req) 347static 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
358static const httpd_uri_t uri_portal = { .uri = "/", .method = HTTP_GET, .handler = portal_handler }; 362static 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
368static const httpd_uri_t uri_ncsi = { .uri = "/ncsi.txt", .method = HTTP_GET, .handler = redirect_to_portal_handler }; 372static const httpd_uri_t uri_ncsi = { .uri = "/ncsi.txt", .method = HTTP_GET, .handler = redirect_to_portal_handler };
369static const httpd_uri_t uri_connecttest = { .uri = "/connecttest.txt", .method = HTTP_GET, .handler = redirect_to_portal_handler }; 373static const httpd_uri_t uri_connecttest = { .uri = "/connecttest.txt", .method = HTTP_GET, .handler = redirect_to_portal_handler };
370static const httpd_uri_t uri_wpad = { .uri = "/wpad.dat", .method = HTTP_GET, .handler = redirect_to_portal_handler }; 374static const httpd_uri_t uri_wpad = { .uri = "/wpad.dat", .method = HTTP_GET, .handler = redirect_to_portal_handler };
371static const httpd_uri_t uri_catchall = { .uri = "/*", .method = HTTP_GET, .handler = catchall_handler }; 375
376static 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>&#128274;</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
493static 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
527static bool is_setup_available(void) {
528 const tollgate_config_t *cfg = tollgate_config_get();
529 return cfg->network_count == 0;
530}
531
532static 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
553static 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
612static 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
675static 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
703static const httpd_uri_t uri_setup = { .uri = "/setup", .method = HTTP_GET, .handler = setup_page_handler };
704static const httpd_uri_t uri_wifi_scan = { .uri = "/wifi/scan", .method = HTTP_GET, .handler = wifi_scan_handler };
705static const httpd_uri_t uri_wifi_connect = { .uri = "/wifi/connect", .method = HTTP_POST, .handler = wifi_connect_handler };
706static const httpd_uri_t uri_wifi_status = { .uri = "/wifi/status", .method = HTTP_GET, .handler = wifi_status_handler };
372 707
373esp_err_t captive_portal_start(const char *ap_ip_str) 708esp_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 @@
7esp_err_t captive_portal_start(const char *ap_ip_str); 7esp_err_t captive_portal_start(const char *ap_ip_str);
8void captive_portal_stop(void); 8void captive_portal_stop(void);
9httpd_handle_t captive_portal_get_server(void); 9httpd_handle_t captive_portal_get_server(void);
10bool 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);
105const tollgate_config_t *tollgate_config_get(void); 105const tollgate_config_t *tollgate_config_get(void);
106esp_err_t tollgate_config_get_wifi(wifi_config_t *wifi_config); 106esp_err_t tollgate_config_get_wifi(wifi_config_t *wifi_config);
107esp_err_t tollgate_config_get_next_wifi(wifi_config_t *wifi_config); 107esp_err_t tollgate_config_get_next_wifi(wifi_config_t *wifi_config);
108esp_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 @@
12static const char *TAG = "display"; 15static 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
16static volatile display_state_t s_state = DISPLAY_BOOT; 29static volatile display_state_t s_state = DISPLAY_BOOT;
17static char s_ap_ssid[32] = ""; 30static char s_ap_ssid[32] = "";
18static char s_portal_url[256] = ""; 31static char s_portal_url[256] = "";
32static char s_mint_url[256] = "";
33static char s_wifi_status[32] = "starting...";
19static int s_active_clients = 0; 34static int s_active_clients = 0;
20static uint64_t s_wallet_balance = 0; 35static uint64_t s_wallet_balance = 0;
36static int s_price_per_step = 0;
21static bool s_initialized = false; 37static bool s_initialized = false;
22static int64_t s_last_qr_switch = 0; 38static int64_t s_last_qr_switch = 0;
23static display_qr_mode_t s_qr_mode = DISPLAY_QR_WIFI; 39static display_qr_mode_t s_qr_mode = DISPLAY_QR_WIFI;
40static int s_last_payment_sats = 0;
41static int64_t s_last_allotment_ms = 0;
42
43static 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
25static int qr_version_from_strlen(int len) { 49static 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
86static 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
62static void build_wifi_qr_string(char *out, int out_size) { 96static 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
109static 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
68void display_render_text(int x, int y, const char *text, uint16_t fg, uint16_t bg, int scale) { 142void 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
101static 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
138void display_render_qr(const char *text) { 175void 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
146static void render_boot_screen(void) { 183static 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
153static void render_ready_screen(void) { 209static 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
263static 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
183static void render_payment_screen(void) { 302static 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
190static void render_error_screen(void) { 333static 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
252void display_update(const char *ap_ssid, int active_clients, 438void 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
463void 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
470void display_notify_wifi_connected(const char *ip) {
471 (void)ip;
472}
473
474void 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
15typedef enum { 16typedef enum {
@@ -20,7 +21,12 @@ typedef enum {
20esp_err_t display_init(void); 21esp_err_t display_init(void);
21void display_set_state(display_state_t state); 22void display_set_state(display_state_t state);
22void display_update(const char *ap_ssid, int active_clients, 23void 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);
27void display_notify_payment(int amount_sats, int64_t allotment_ms);
28void display_notify_wifi_connected(const char *ip);
29void display_notify_wifi_disconnected(void);
24void display_render_text(int x, int y, const char *text, uint16_t fg, uint16_t bg, int scale); 30void display_render_text(int x, int y, const char *text, uint16_t fg, uint16_t bg, int scale);
25void display_render_qr(const char *text); 31void 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
4static const char *s_alpha_lower[] = {
5 "qwertyuiop",
6 "asdfghjkl",
7 "\001zxcvbnm\b",
8 "\002\003\004"
9};
10
11static const char *s_alpha_upper[] = {
12 "QWERTYUIOP",
13 "ASDFGHJKL",
14 "\001ZXCVBNM\b",
15 "\002\003\004"
16};
17
18static 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
31static 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
40void 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
47void kb_set_layout(const kb_layout_t *layout) {
48 if (layout) s_layout = *layout;
49}
50
51const kb_layout_t *kb_get_layout(void) {
52 return &s_layout;
53}
54
55static 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
63int 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
74static 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
89static 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
103kb_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
151void 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
9typedef enum {
10 KB_ALPHA_LOWER,
11 KB_ALPHA_UPPER,
12 KB_NUMSYM
13} kb_layer_t;
14
15typedef 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
25typedef struct {
26 char input[KB_INPUT_MAX + 1];
27 int cursor;
28 bool reveal;
29 kb_layer_t layer;
30} kb_state_t;
31
32typedef struct {
33 kb_action_t action;
34 char ch;
35} kb_result_t;
36
37typedef 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
46void kb_state_init(kb_state_t *st);
47void kb_set_layout(const kb_layout_t *layout);
48const kb_layout_t *kb_get_layout(void);
49int kb_get_row_keys(int row, kb_layer_t layer, const char **keys_out);
50kb_result_t kb_hit_test(int tx, int ty, kb_layer_t layer);
51void 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
9static const char *TAG = "touch";
10
11static i2c_master_bus_handle_t s_bus = NULL;
12static i2c_master_dev_handle_t s_dev = NULL;
13static bool s_initialized = false;
14static int s_rotation = 0;
15
16static const uint8_t s_read_cmd[11] = {
17 0xb5, 0xab, 0xa5, 0x5a, 0x00, 0x00, 0x00, 0x08, 0x00, 0x00, 0x00
18};
19
20void 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
39esp_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
99bool 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
142void touch_set_rotation(int rotation) {
143 s_rotation = rotation;
144}
145
146void 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
16typedef struct {
17 uint16_t x;
18 uint16_t y;
19 bool touched;
20} touch_point_t;
21
22esp_err_t touch_init(void);
23bool touch_read(touch_point_t *pt);
24void touch_deinit(void);
25void touch_set_rotation(int rotation);
26
27void 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
4void 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
11void 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
20int 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
27const 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
32setup_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
44setup_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
50setup_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
69setup_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
75setup_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
83setup_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
13typedef 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
23typedef struct {
24 char ssid[WIFI_SETUP_SSID_LEN];
25 int rssi;
26 bool secured;
27} wifi_ap_info_t;
28
29typedef 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
40void wifi_setup_init(wifi_setup_t *setup);
41void wifi_setup_set_aps(wifi_setup_t *setup, const wifi_ap_info_t *aps, int count);
42int wifi_setup_visible_count(const wifi_setup_t *setup);
43const wifi_ap_info_t *wifi_setup_get_visible(const wifi_setup_t *setup, int idx);
44setup_state_t wifi_setup_handle_select(wifi_setup_t *setup, int list_idx);
45setup_state_t wifi_setup_handle_connect(wifi_setup_t *setup);
46setup_state_t wifi_setup_handle_connect_result(wifi_setup_t *setup, bool success, const char *ip);
47setup_state_t wifi_setup_handle_cancel(wifi_setup_t *setup);
48setup_state_t wifi_setup_handle_retry(wifi_setup_t *setup);
49setup_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 @@
1import { test, expect } from '@playwright/test';
2
3const PORTAL_IP = process.env.TOLLGATE_IP || '10.192.45.1';
4const PORTAL_URL = `http://${PORTAL_IP}`;
5
6const 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}
13body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;
14background:#0a0a0a;color:#fff;display:flex;align-items:center;justify-content:center;
15min-height:100vh;padding:20px}
16.card{background:#1a1a1a;border:1px solid #333;border-radius:16px;padding:32px;
17max-width:400px;width:100%;text-align:center}
18h1{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;
22padding: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}
29input{width:100%;background:#252525;border:1px solid #333;border-radius:8px;
30color:#fff;padding:12px;font-size:14px;margin-bottom:8px;outline:none}
31input:focus{border-color:#f7931a}
32.btn{background:#f7931a;color:#000;border:none;border-radius:8px;padding:14px 28px;
33font-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;
41padding: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>
66const apIp='${PORTAL_IP}';
67let selectedSsid='';
68function showStatus(msg,type){const s=document.getElementById('status');
69s.textContent=msg;s.className=type;}
70function scanWifi(){
71document.getElementById('scanStatus').textContent='Scanning...';
72document.getElementById('networkList').innerHTML='';
73fetch('/wifi/scan').then(r=>r.json()).then(aps=>{
74document.getElementById('scanStatus').textContent=aps.length+' networks found';
75const list=document.getElementById('networkList');
76aps.forEach(ap=>{
77const div=document.createElement('div');
78div.className='net-item';
79const lock=ap.secured?'<span class=net-lock>&#128274;</span>':'';
80div.innerHTML='<span class=net-ssid>'+lock+ap.ssid+'</span><span class=net-rssi>'+ap.rssi+' dBm</span>';
81div.onclick=()=>selectNetwork(ap.ssid,ap.secured);
82list.appendChild(div);
83});
84}).catch(e=>{document.getElementById('scanStatus').textContent='Scan failed';});
85}
86function selectNetwork(ssid,secured){
87selectedSsid=ssid;
88document.getElementById('selectedNetwork').textContent='Connect to: '+ssid;
89document.getElementById('passwordForm').style.display='block';
90document.getElementById('scanStatus').style.display='none';
91document.getElementById('networkList').style.display='none';
92document.querySelector('.refresh').style.display='none';
93if(!secured){connectSelected();}
94}
95function showManual(){
96document.getElementById('manualForm').style.display='block';
97}
98function connectSelected(){
99const pass=document.getElementById('wifiPass').value;
100doConnect(selectedSsid,pass);
101}
102function connectManual(){
103const ssid=document.getElementById('manualSsid').value.trim();
104const pass=document.getElementById('manualPass').value;
105if(!ssid){showStatus('Enter SSID','error');return;}
106doConnect(ssid,pass);
107}
108function doConnect(ssid,pass){
109showStatus('Connecting to '+ssid+'...','processing');
110fetch('/wifi/connect',{method:'POST',headers:{'Content-Type':'application/json'},
111body:JSON.stringify({ssid:ssid,password:pass})})
112.then(r=>r.json()).then(d=>{
113if(d.ok){showStatus('Connected! Device is restarting...','success');}
114else{showStatus('Failed: '+(d.error||'unknown'),'error');}
115}).catch(e=>{showStatus('Connection error','error');});
116}
117scanWifi();
118</script>
119</body></html>`;
120
121const 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
128async 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
160async function loadSetupPage(page) {
161 await page.goto('http://tollgate.test/setup', { waitUntil: 'networkidle' });
162}
163
164test.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
254test.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
264test.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
425test.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 @@
1import { execSync } from 'child_process';
2
3const IP = process.env.TOLLGATE_IP || '10.192.45.1';
4
5console.log(`\n=== WiFi Setup Integration Test ===`);
6console.log(`Portal IP: ${IP}\n`);
7
8let passed = 0, failed = 0;
9function assert(cond, msg) {
10 if (cond) { console.log(` PASS: ${msg}`); passed++; }
11 else { console.log(` FAIL: ${msg}`); failed++; }
12}
13
14function run(cmd) {
15 try { return execSync(cmd, { encoding: 'utf8', timeout: 15000 }); }
16 catch { return null; }
17}
18
19function 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)
27const setupPage = run(`curl -s -o /dev/null -w "%{http_code}" --connect-timeout 5 http://${IP}/setup`);
28assert(setupPage === '200' || setupPage === '302', `/setup returns 200 or 302 (got ${setupPage})`);
29
30// 2. /wifi/status endpoint works
31const status = fetchJSON('/wifi/status');
32assert(status !== null, '/wifi/status returns JSON');
33assert(typeof status.connected === 'boolean', '/wifi/status has connected field');
34
35// 3. /wifi/scan endpoint returns array
36console.log('\n (wifi/scan may take a few seconds...)');
37const scanResult = run(`curl -s --connect-timeout 15 --max-time 15 http://${IP}/wifi/scan`);
38let scanData = null;
39if (scanResult) {
40 try { scanData = JSON.parse(scanResult); } catch {}
41}
42assert(scanData !== null, '/wifi/scan returns JSON');
43if (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
55const badConnect = run(`curl -s -X POST -d 'not json' --connect-timeout 5 http://${IP}/wifi/connect`);
56assert(badConnect !== null, '/wifi/connect responds to bad request');
57if (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
65const noSsid = run(`curl -s -X POST -H 'Content-Type: application/json' -d '{}' --connect-timeout 5 http://${IP}/wifi/connect`);
66if (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
73console.log(`\n=== Results: ${passed} passed, ${failed} failed ===`);
74process.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
6typedef 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
15typedef enum {
16 GPIO_INTR_DISABLE = 0,
17} gpio_int_type_t;
18
19typedef enum {
20 GPIO_PULLUP_DISABLE = 0,
21 GPIO_PULLUP_ENABLE,
22} gpio_pullup_t;
23
24typedef enum {
25 GPIO_PULLDOWN_DISABLE = 0,
26 GPIO_PULLDOWN_ENABLE,
27} gpio_pulldown_t;
28
29typedef 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
37static inline int gpio_config(const gpio_config_t *cfg) { (void)cfg; return 0; }
38static 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
9static 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
14static 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
19static 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
24static 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
29static inline esp_err_t i2c_master_bus_rm_device(i2c_master_dev_handle_t dev) {
30 (void)dev;
31 return ESP_OK;
32}
33
34static 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
7typedef int i2c_port_num_t;
8#define I2C_NUM_0 0
9
10typedef enum {
11 I2C_ADDR_BIT_LEN_7 = 0,
12} i2c_addr_bit_len_t;
13
14typedef enum {
15 I2C_CLK_SRC_DEFAULT = 0,
16} i2c_clock_source_t;
17
18typedef struct i2c_master_bus_t *i2c_master_bus_handle_t;
19typedef struct i2c_master_dev_t *i2c_master_dev_handle_t;
20
21typedef 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
35typedef 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
5int 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
5int 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
5int 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}