diff options
| author | Your Name <you@example.com> | 2026-05-19 04:21:14 +0530 |
|---|---|---|
| committer | Your Name <you@example.com> | 2026-05-19 04:21:14 +0530 |
| commit | aa58b47996083f36e3587b8e10f9bbb681610491 (patch) | |
| tree | 4c845da2ee4b217a2c77deea3b10dd8d288d1b09 | |
| parent | 9f7dd94029c8dc12117494548f5f32221a729307 (diff) | |
feat: web-based WiFi setup via captive portal, portrait-only display
- Remove touchscreen WiFi setup (touch.c, keyboard.c, wifi_setup.c from build)
- Remove offscreen buffer and landscape rotation from axs15231b driver
- Add /setup HTML page with WiFi scan/connect via captive portal
- Add /wifi/scan, /wifi/connect, /wifi/status HTTP endpoints
- Display shows SETUP_PENDING (QR + SSID + setup URL) when unconfigured
- Display shows ERROR with setup URL when upstream is down
- All 101 unit tests pass, builds and flashes to Board C
| -rw-r--r-- | WEB_WIFI_SETUP_PLAN.md | 72 | ||||
| -rw-r--r-- | components/axs15231b/axs15231b.c | 37 | ||||
| -rw-r--r-- | components/axs15231b/include/axs15231b.h | 2 | ||||
| -rw-r--r-- | main/CMakeLists.txt | 3 | ||||
| -rw-r--r-- | main/captive_portal.c | 338 | ||||
| -rw-r--r-- | main/captive_portal.h | 1 | ||||
| -rw-r--r-- | main/display.c | 654 | ||||
| -rw-r--r-- | main/display.h | 3 | ||||
| -rw-r--r-- | main/tollgate_main.c | 7 | ||||
| -rw-r--r-- | tests/integration/wifi_setup.mjs | 74 |
10 files changed, 597 insertions, 594 deletions
diff --git a/WEB_WIFI_SETUP_PLAN.md b/WEB_WIFI_SETUP_PLAN.md new file mode 100644 index 0000000..1a3f742 --- /dev/null +++ b/WEB_WIFI_SETUP_PLAN.md | |||
| @@ -0,0 +1,72 @@ | |||
| 1 | # Web WiFi Setup Plan | ||
| 2 | |||
| 3 | ## Overview | ||
| 4 | |||
| 5 | Move WiFi configuration from on-display touchscreen UI to a web-based setup page | ||
| 6 | served by the captive portal. The display becomes portrait-only, showing QR codes | ||
| 7 | and status info. No more landscape rotation, on-screen keyboard, or touch-driven | ||
| 8 | WiFi setup. | ||
| 9 | |||
| 10 | ## Architecture | ||
| 11 | |||
| 12 | ### Display (portrait 320x480 only) | ||
| 13 | |||
| 14 | | State | When | Content | | ||
| 15 | |-------|------|---------| | ||
| 16 | | BOOT | Startup | "TollGate" title + "starting..." | | ||
| 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 | "NO UPSTREAM" + "http://AP_IP/setup" | | ||
| 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 | |||
| 57 | ### Phase 2: Add web WiFi setup | ||
| 58 | - [x] Add `/wifi/scan` endpoint to `captive_portal.c` | ||
| 59 | - [x] Add `/wifi/connect` endpoint to `captive_portal.c` | ||
| 60 | - [x] Add `/wifi/status` endpoint to `captive_portal.c` | ||
| 61 | - [x] Add `/setup` HTML page with scan list + connect form | ||
| 62 | - [x] Gate `/setup` behind `network_count == 0` | ||
| 63 | |||
| 64 | ### Phase 3: Build configuration | ||
| 65 | - [x] Remove touch.c, keyboard.c, wifi_setup.c from `main/CMakeLists.txt` | ||
| 66 | |||
| 67 | ### Phase 4: Testing | ||
| 68 | - [x] `make test-unit` passes | ||
| 69 | - [x] Build succeeds (`idf.py build`) | ||
| 70 | - [x] Flash to Board C, verify portrait display shows setup URL | ||
| 71 | - [ ] Test `/setup` page from phone browser | ||
| 72 | - [x] Write integration test `tests/integration/wifi_setup.mjs` | ||
diff --git a/components/axs15231b/axs15231b.c b/components/axs15231b/axs15231b.c index ac05ba7..00e8467 100644 --- a/components/axs15231b/axs15231b.c +++ b/components/axs15231b/axs15231b.c | |||
| @@ -37,8 +37,7 @@ static spi_device_handle_t s_spi = NULL; | |||
| 37 | static uint16_t *s_fb = NULL; | 37 | static uint16_t *s_fb = NULL; |
| 38 | static int s_width = AXS15231B_WIDTH; | 38 | static int s_width = AXS15231B_WIDTH; |
| 39 | static int s_height = AXS15231B_HEIGHT; | 39 | static int s_height = AXS15231B_HEIGHT; |
| 40 | static int s_rotation = 0; | 40 | static int s_stride = AXS15231B_WIDTH; |
| 41 | static int s_stride = 480; | ||
| 42 | static uint8_t *s_swap_buf = NULL; | 41 | static uint8_t *s_swap_buf = NULL; |
| 43 | #define SWAP_BUF_PIXELS 2048 | 42 | #define SWAP_BUF_PIXELS 2048 |
| 44 | 43 | ||
| @@ -243,7 +242,7 @@ esp_err_t axs15231b_init(void) { | |||
| 243 | 242 | ||
| 244 | cs_init(); | 243 | cs_init(); |
| 245 | 244 | ||
| 246 | size_t fb_size = (size_t)480 * 480 * 2; | 245 | size_t fb_size = (size_t)s_width * s_height * 2; |
| 247 | 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); |
| 248 | if (!s_fb) { | 247 | if (!s_fb) { |
| 249 | ESP_LOGE(TAG, "Failed to allocate framebuffer (%zu bytes)", fb_size); | 248 | ESP_LOGE(TAG, "Failed to allocate framebuffer (%zu bytes)", fb_size); |
| @@ -322,7 +321,6 @@ void axs15231b_flush(void) { | |||
| 322 | qspi_write_command(RAMWR); | 321 | qspi_write_command(RAMWR); |
| 323 | 322 | ||
| 324 | bool first = true; | 323 | bool first = true; |
| 325 | |||
| 326 | cs_low(); | 324 | cs_low(); |
| 327 | for (int row = 0; row < s_height; row++) { | 325 | for (int row = 0; row < s_height; row++) { |
| 328 | int chunk_remaining = s_width; | 326 | int chunk_remaining = s_width; |
| @@ -359,34 +357,3 @@ void axs15231b_flush(void) { | |||
| 359 | 357 | ||
| 360 | int axs15231b_get_width(void) { return s_width; } | 358 | int axs15231b_get_width(void) { return s_width; } |
| 361 | int axs15231b_get_height(void) { return s_height; } | 359 | int axs15231b_get_height(void) { return s_height; } |
| 362 | |||
| 363 | void axs15231b_set_rotation(int rotation) { | ||
| 364 | uint8_t madctl = MADCTL_RGB; | ||
| 365 | switch (rotation) { | ||
| 366 | case 0: | ||
| 367 | madctl = MADCTL_RGB; | ||
| 368 | s_width = AXS15231B_WIDTH; | ||
| 369 | s_height = AXS15231B_HEIGHT; | ||
| 370 | break; | ||
| 371 | case 1: | ||
| 372 | madctl = MADCTL_MX | MADCTL_MV; | ||
| 373 | s_width = AXS15231B_HEIGHT; | ||
| 374 | s_height = AXS15231B_WIDTH; | ||
| 375 | break; | ||
| 376 | case 2: | ||
| 377 | madctl = MADCTL_MX | MADCTL_MY; | ||
| 378 | s_width = AXS15231B_WIDTH; | ||
| 379 | s_height = AXS15231B_HEIGHT; | ||
| 380 | break; | ||
| 381 | case 3: | ||
| 382 | madctl = MADCTL_MY | MADCTL_MV; | ||
| 383 | s_width = AXS15231B_HEIGHT; | ||
| 384 | s_height = AXS15231B_WIDTH; | ||
| 385 | break; | ||
| 386 | default: | ||
| 387 | return; | ||
| 388 | } | ||
| 389 | s_rotation = rotation; | ||
| 390 | qspi_write_cmd_data8(MADCTL, madctl); | ||
| 391 | ESP_LOGI(TAG, "Rotation set to %d (%dx%d)", rotation, s_width, s_height); | ||
| 392 | } | ||
diff --git a/components/axs15231b/include/axs15231b.h b/components/axs15231b/include/axs15231b.h index a8c1a37..32c489f 100644 --- a/components/axs15231b/include/axs15231b.h +++ b/components/axs15231b/include/axs15231b.h | |||
| @@ -23,6 +23,4 @@ void axs15231b_fill_rect(int x, int y, int w, int h, uint16_t color); | |||
| 23 | void axs15231b_flush(void); | 23 | void axs15231b_flush(void); |
| 24 | int axs15231b_get_width(void); | 24 | int axs15231b_get_width(void); |
| 25 | int axs15231b_get_height(void); | 25 | int axs15231b_get_height(void); |
| 26 | void axs15231b_set_rotation(int rotation); | ||
| 27 | |||
| 28 | #endif | 26 | #endif |
diff --git a/main/CMakeLists.txt b/main/CMakeLists.txt index c87b2b8..7e20128 100644 --- a/main/CMakeLists.txt +++ b/main/CMakeLists.txt | |||
| @@ -18,9 +18,6 @@ idf_component_register(SRCS "tollgate_main.c" | |||
| 18 | "cvm_server.c" | 18 | "cvm_server.c" |
| 19 | "display.c" | 19 | "display.c" |
| 20 | "font.c" | 20 | "font.c" |
| 21 | "touch.c" | ||
| 22 | "keyboard.c" | ||
| 23 | "wifi_setup.c" | ||
| 24 | INCLUDE_DIRS "." | 21 | INCLUDE_DIRS "." |
| 25 | REQUIRES esp_wifi esp_event esp_netif nvs_flash esp_http_server | 22 | REQUIRES esp_wifi esp_event esp_netif nvs_flash esp_http_server |
| 26 | lwip json esp_http_client mbedtls esp-tls log spiffs | 23 | lwip json esp_http_client mbedtls esp-tls log spiffs |
diff --git a/main/captive_portal.c b/main/captive_portal.c index 1a3d5ce..0c1d33b 100644 --- a/main/captive_portal.c +++ b/main/captive_portal.c | |||
| @@ -4,6 +4,7 @@ | |||
| 4 | #include "config.h" | 4 | #include "config.h" |
| 5 | #include "esp_log.h" | 5 | #include "esp_log.h" |
| 6 | #include "esp_wifi.h" | 6 | #include "esp_wifi.h" |
| 7 | #include "esp_netif.h" | ||
| 7 | #include "cJSON.h" | 8 | #include "cJSON.h" |
| 8 | #include "lwip/sockets.h" | 9 | #include "lwip/sockets.h" |
| 9 | #include "lwip/netdb.h" | 10 | #include "lwip/netdb.h" |
| @@ -11,6 +12,7 @@ | |||
| 11 | #include "freertos/task.h" | 12 | #include "freertos/task.h" |
| 12 | #include <string.h> | 13 | #include <string.h> |
| 13 | #include <sys/param.h> | 14 | #include <sys/param.h> |
| 15 | #include <stdio.h> | ||
| 14 | 16 | ||
| 15 | static const char *TAG = "captive_portal"; | 17 | static const char *TAG = "captive_portal"; |
| 16 | static httpd_handle_t s_server = NULL; | 18 | static httpd_handle_t s_server = NULL; |
| @@ -303,6 +305,338 @@ static const httpd_uri_t uri_connecttest = { .uri = "/connecttest.txt", .method | |||
| 303 | static const httpd_uri_t uri_wpad = { .uri = "/wpad.dat", .method = HTTP_GET, .handler = redirect_to_portal_handler }; | 305 | static const httpd_uri_t uri_wpad = { .uri = "/wpad.dat", .method = HTTP_GET, .handler = redirect_to_portal_handler }; |
| 304 | static const httpd_uri_t uri_catchall = { .uri = "/*", .method = HTTP_GET, .handler = catchall_handler }; | 306 | static const httpd_uri_t uri_catchall = { .uri = "/*", .method = HTTP_GET, .handler = catchall_handler }; |
| 305 | 307 | ||
| 308 | static const char SETUP_HTML_TEMPLATE[] = \ | ||
| 309 | "<!DOCTYPE html>" | ||
| 310 | "<html><head>" | ||
| 311 | "<meta charset='utf-8'>" | ||
| 312 | "<meta name='viewport' content='width=device-width, initial-scale=1'>" | ||
| 313 | "<title>TollGate Setup</title>" | ||
| 314 | "<style>" | ||
| 315 | "*{box-sizing:border-box;margin:0;padding:0}" | ||
| 316 | "body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;" | ||
| 317 | "background:#0a0a0a;color:#fff;display:flex;align-items:center;justify-content:center;" | ||
| 318 | "min-height:100vh;padding:20px}" | ||
| 319 | ".card{background:#1a1a1a;border:1px solid #333;border-radius:16px;padding:32px;" | ||
| 320 | "max-width:400px;width:100%;text-align:center}" | ||
| 321 | "h1{font-size:24px;margin-bottom:8px;color:#f7931a}" | ||
| 322 | ".subtitle{color:#888;margin-bottom:20px;font-size:13px}" | ||
| 323 | ".networks{margin-top:16px;text-align:left}" | ||
| 324 | ".net-item{background:#252525;border:1px solid #333;border-radius:8px;" | ||
| 325 | "padding:12px;margin-bottom:8px;cursor:pointer;display:flex;justify-content:space-between;align-items:center}" | ||
| 326 | ".net-item:hover{border-color:#f7931a}" | ||
| 327 | ".net-item:active{background:#333}" | ||
| 328 | ".net-ssid{font-size:14px}" | ||
| 329 | ".net-rssi{font-size:11px;color:#888}" | ||
| 330 | ".net-lock{color:#f7931a;margin-right:4px}" | ||
| 331 | ".manual{margin-top:12px}" | ||
| 332 | "input{width:100%;background:#252525;border:1px solid #333;border-radius:8px;" | ||
| 333 | "color:#fff;padding:12px;font-size:14px;margin-bottom:8px;outline:none}" | ||
| 334 | "input:focus{border-color:#f7931a}" | ||
| 335 | ".btn{background:#f7931a;color:#000;border:none;border-radius:8px;padding:14px 28px;" | ||
| 336 | "font-size:16px;font-weight:bold;cursor:pointer;width:100%;margin-top:8px}" | ||
| 337 | ".btn:hover{background:#e8850f}" | ||
| 338 | ".btn:disabled{background:#333;color:#666;cursor:not-allowed}" | ||
| 339 | "#status{margin-top:12px;padding:10px;border-radius:8px;display:none;font-size:13px}" | ||
| 340 | "#status.success{display:block;background:#1a472a;color:#4caf50}" | ||
| 341 | "#status.error{display:block;background:#471a1a;color:#f44336}" | ||
| 342 | "#status.processing{display:block;background:#1a3a47;color:#2196f3}" | ||
| 343 | ".refresh{background:none;border:1px solid #444;color:#aaa;border-radius:6px;" | ||
| 344 | "padding:6px 12px;font-size:12px;cursor:pointer;margin-top:4px}" | ||
| 345 | ".refresh:hover{border-color:#f7931a;color:#f7931a}" | ||
| 346 | "#manualForm{display:none;margin-top:12px}" | ||
| 347 | "</style>" | ||
| 348 | "</head><body>" | ||
| 349 | "<div class='card'>" | ||
| 350 | "<h1>TollGate Setup</h1>" | ||
| 351 | "<p class='subtitle'>Configure upstream WiFi</p>" | ||
| 352 | "<div id='scanStatus'>Scanning...</div>" | ||
| 353 | "<div class='networks' id='networkList'></div>" | ||
| 354 | "<button class='refresh' onclick='scanWifi()'>Rescan</button>" | ||
| 355 | "<button class='refresh' onclick='showManual()'>Manual entry</button>" | ||
| 356 | "<div id='manualForm'>" | ||
| 357 | "<input id='manualSsid' placeholder='SSID'>" | ||
| 358 | "<input id='manualPass' type='password' placeholder='Password'>" | ||
| 359 | "<button class='btn' onclick='connectManual()'>Connect</button>" | ||
| 360 | "</div>" | ||
| 361 | "<div id='passwordForm' style='display:none'>" | ||
| 362 | "<p style='margin:12px 0 8px;text-align:left' id='selectedNetwork'></p>" | ||
| 363 | "<input id='wifiPass' type='password' placeholder='WiFi password'>" | ||
| 364 | "<button class='btn' onclick='connectSelected()'>Connect</button>" | ||
| 365 | "</div>" | ||
| 366 | "<div id='status'></div>" | ||
| 367 | "</div>" | ||
| 368 | "<script>" | ||
| 369 | "const apIp='__AP_IP__';" | ||
| 370 | "let selectedSsid='';" | ||
| 371 | "function showStatus(msg,type){const s=document.getElementById('status');" | ||
| 372 | "s.textContent=msg;s.className=type;}" | ||
| 373 | "function scanWifi(){" | ||
| 374 | "document.getElementById('scanStatus').textContent='Scanning...';" | ||
| 375 | "document.getElementById('networkList').innerHTML='';" | ||
| 376 | "fetch('/wifi/scan').then(r=>r.json()).then(aps=>{" | ||
| 377 | "document.getElementById('scanStatus').textContent=aps.length+' networks found';" | ||
| 378 | "const list=document.getElementById('networkList');" | ||
| 379 | "aps.forEach(ap=>{" | ||
| 380 | "const div=document.createElement('div');" | ||
| 381 | "div.className='net-item';" | ||
| 382 | "const lock=ap.secured?'<span class=net-lock>🔒</span>':'';" | ||
| 383 | "div.innerHTML='<span class=net-ssid>'+lock+ap.ssid+'</span>" | ||
| 384 | "<span class=net-rssi>'+ap.rssi+' dBm</span>';" | ||
| 385 | "div.onclick=()=>selectNetwork(ap.ssid,ap.secured);" | ||
| 386 | "list.appendChild(div);" | ||
| 387 | "});" | ||
| 388 | "}).catch(e=>{document.getElementById('scanStatus').textContent='Scan failed';});" | ||
| 389 | "}" | ||
| 390 | "function selectNetwork(ssid,secured){" | ||
| 391 | "selectedSsid=ssid;" | ||
| 392 | "document.getElementById('selectedNetwork').textContent='Connect to: '+ssid;" | ||
| 393 | "document.getElementById('passwordForm').style.display='block';" | ||
| 394 | "document.getElementById('scanStatus').style.display='none';" | ||
| 395 | "document.getElementById('networkList').style.display='none';" | ||
| 396 | "document.querySelector('.refresh').style.display='none';" | ||
| 397 | "if(!secured){connectSelected();}" | ||
| 398 | "}" | ||
| 399 | "function showManual(){" | ||
| 400 | "document.getElementById('manualForm').style.display='block';" | ||
| 401 | "}" | ||
| 402 | "function connectSelected(){" | ||
| 403 | "const pass=document.getElementById('wifiPass').value;" | ||
| 404 | "doConnect(selectedSsid,pass);" | ||
| 405 | "}" | ||
| 406 | "function connectManual(){" | ||
| 407 | "const ssid=document.getElementById('manualSsid').value.trim();" | ||
| 408 | "const pass=document.getElementById('manualPass').value;" | ||
| 409 | "if(!ssid){showStatus('Enter SSID','error');return;}" | ||
| 410 | "doConnect(ssid,pass);" | ||
| 411 | "}" | ||
| 412 | "function doConnect(ssid,pass){" | ||
| 413 | "showStatus('Connecting to '+ssid+'...','processing');" | ||
| 414 | "fetch('/wifi/connect',{method:'POST',headers:{'Content-Type':'application/json'}," | ||
| 415 | "body:JSON.stringify({ssid:ssid,password:pass})})" | ||
| 416 | ".then(r=>r.json()).then(d=>{" | ||
| 417 | "if(d.ok){showStatus('Connected! Device is restarting...','success');}" | ||
| 418 | "else{showStatus('Failed: '+(d.error||'unknown'),'error');}" | ||
| 419 | "}).catch(e=>{showStatus('Connection error','error');});" | ||
| 420 | "}" | ||
| 421 | "scanWifi();" | ||
| 422 | "</script>" | ||
| 423 | "</body></html>"; | ||
| 424 | |||
| 425 | static char *template_replace(const char *tpl, const char *key, const char *val) { | ||
| 426 | const char *p; | ||
| 427 | size_t klen = strlen(key); | ||
| 428 | size_t vlen = strlen(val); | ||
| 429 | size_t tlen = strlen(tpl); | ||
| 430 | size_t extra = 0; | ||
| 431 | p = tpl; | ||
| 432 | while ((p = strstr(p, key)) != NULL) { | ||
| 433 | extra += vlen - klen; | ||
| 434 | p += klen; | ||
| 435 | } | ||
| 436 | size_t out_size = tlen + extra + 1; | ||
| 437 | char *out = malloc(out_size); | ||
| 438 | if (!out) return NULL; | ||
| 439 | char *dst = out; | ||
| 440 | p = tpl; | ||
| 441 | while (*p) { | ||
| 442 | const char *found = strstr(p, key); | ||
| 443 | if (found) { | ||
| 444 | memcpy(dst, p, found - p); | ||
| 445 | dst += found - p; | ||
| 446 | memcpy(dst, val, vlen); | ||
| 447 | dst += vlen; | ||
| 448 | p = found + klen; | ||
| 449 | } else { | ||
| 450 | strcpy(dst, p); | ||
| 451 | dst += strlen(p); | ||
| 452 | break; | ||
| 453 | } | ||
| 454 | } | ||
| 455 | *dst = '\0'; | ||
| 456 | return out; | ||
| 457 | } | ||
| 458 | |||
| 459 | static bool is_setup_available(void) { | ||
| 460 | const tollgate_config_t *cfg = tollgate_config_get(); | ||
| 461 | return cfg->network_count == 0; | ||
| 462 | } | ||
| 463 | |||
| 464 | static esp_err_t setup_page_handler(httpd_req_t *req) { | ||
| 465 | if (!is_setup_available()) { | ||
| 466 | httpd_resp_set_status(req, "302 Found"); | ||
| 467 | char location[64]; | ||
| 468 | snprintf(location, sizeof(location), "http://%s/", s_ap_ip_str); | ||
| 469 | httpd_resp_set_hdr(req, "Location", location); | ||
| 470 | httpd_resp_send(req, NULL, 0); | ||
| 471 | return ESP_OK; | ||
| 472 | } | ||
| 473 | |||
| 474 | httpd_resp_set_type(req, "text/html"); | ||
| 475 | char *html = template_replace(SETUP_HTML_TEMPLATE, "__AP_IP__", s_ap_ip_str); | ||
| 476 | if (!html) { | ||
| 477 | httpd_resp_send_500(req); | ||
| 478 | return ESP_OK; | ||
| 479 | } | ||
| 480 | httpd_resp_send(req, html, strlen(html)); | ||
| 481 | free(html); | ||
| 482 | return ESP_OK; | ||
| 483 | } | ||
| 484 | |||
| 485 | static esp_err_t wifi_scan_handler(httpd_req_t *req) { | ||
| 486 | esp_wifi_disconnect(); | ||
| 487 | vTaskDelay(pdMS_TO_TICKS(300)); | ||
| 488 | |||
| 489 | wifi_scan_config_t scan_cfg = {0}; | ||
| 490 | scan_cfg.scan_type = WIFI_SCAN_TYPE_ACTIVE; | ||
| 491 | scan_cfg.scan_time.active.min = 100; | ||
| 492 | scan_cfg.scan_time.active.max = 300; | ||
| 493 | esp_err_t ret = esp_wifi_scan_start(&scan_cfg, true); | ||
| 494 | if (ret != ESP_OK) { | ||
| 495 | httpd_resp_set_type(req, "application/json"); | ||
| 496 | httpd_resp_send(req, "[]", 2); | ||
| 497 | return ESP_OK; | ||
| 498 | } | ||
| 499 | |||
| 500 | uint16_t ap_count = 0; | ||
| 501 | esp_wifi_scan_get_ap_num(&ap_count); | ||
| 502 | if (ap_count > 20) ap_count = 20; | ||
| 503 | wifi_ap_record_t aps[20]; | ||
| 504 | esp_wifi_scan_get_ap_records(&ap_count, aps); | ||
| 505 | |||
| 506 | for (int i = 0; i < (int)ap_count - 1; i++) { | ||
| 507 | for (int j = i + 1; j < (int)ap_count; j++) { | ||
| 508 | if (aps[j].rssi > aps[i].rssi) { | ||
| 509 | wifi_ap_record_t tmp = aps[i]; | ||
| 510 | aps[i] = aps[j]; | ||
| 511 | aps[j] = tmp; | ||
| 512 | } | ||
| 513 | } | ||
| 514 | } | ||
| 515 | |||
| 516 | cJSON *root = cJSON_CreateArray(); | ||
| 517 | for (int i = 0; i < (int)ap_count; i++) { | ||
| 518 | if (aps[i].ssid[0] == '\0') continue; | ||
| 519 | cJSON *ap = cJSON_CreateObject(); | ||
| 520 | cJSON_AddStringToObject(ap, "ssid", (const char *)aps[i].ssid); | ||
| 521 | cJSON_AddNumberToObject(ap, "rssi", aps[i].rssi); | ||
| 522 | cJSON_AddBoolToObject(ap, "secured", aps[i].authmode != WIFI_AUTH_OPEN); | ||
| 523 | cJSON_AddItemToArray(root, ap); | ||
| 524 | } | ||
| 525 | |||
| 526 | char *json = cJSON_PrintUnformatted(root); | ||
| 527 | httpd_resp_set_type(req, "application/json"); | ||
| 528 | httpd_resp_send(req, json, strlen(json)); | ||
| 529 | cJSON_free(json); | ||
| 530 | cJSON_Delete(root); | ||
| 531 | |||
| 532 | const tollgate_config_t *cfg = tollgate_config_get(); | ||
| 533 | if (cfg->network_count > 0) { | ||
| 534 | wifi_config_t wifi_cfg; | ||
| 535 | if (tollgate_config_get_wifi(&wifi_cfg) == ESP_OK) { | ||
| 536 | esp_wifi_set_config(WIFI_IF_STA, &wifi_cfg); | ||
| 537 | esp_wifi_connect(); | ||
| 538 | } | ||
| 539 | } | ||
| 540 | |||
| 541 | return ESP_OK; | ||
| 542 | } | ||
| 543 | |||
| 544 | static esp_err_t wifi_connect_handler(httpd_req_t *req) { | ||
| 545 | int content_len = req->content_len; | ||
| 546 | if (content_len <= 0 || content_len > 1024) { | ||
| 547 | httpd_resp_set_type(req, "application/json"); | ||
| 548 | httpd_resp_send(req, "{\"ok\":false,\"error\":\"invalid request\"}", 33); | ||
| 549 | return ESP_OK; | ||
| 550 | } | ||
| 551 | |||
| 552 | char *body = malloc(content_len + 1); | ||
| 553 | if (!body) { | ||
| 554 | httpd_resp_send_500(req); | ||
| 555 | return ESP_OK; | ||
| 556 | } | ||
| 557 | int total = 0; | ||
| 558 | while (total < content_len) { | ||
| 559 | int r = httpd_req_recv(req, body + total, content_len - total); | ||
| 560 | if (r <= 0) { free(body); httpd_resp_send_500(req); return ESP_OK; } | ||
| 561 | total += r; | ||
| 562 | } | ||
| 563 | body[total] = '\0'; | ||
| 564 | |||
| 565 | cJSON *json = cJSON_Parse(body); | ||
| 566 | free(body); | ||
| 567 | if (!json) { | ||
| 568 | httpd_resp_set_type(req, "application/json"); | ||
| 569 | httpd_resp_send(req, "{\"ok\":false,\"error\":\"invalid JSON\"}", 33); | ||
| 570 | return ESP_OK; | ||
| 571 | } | ||
| 572 | |||
| 573 | cJSON *ssid_item = cJSON_GetObjectItem(json, "ssid"); | ||
| 574 | cJSON *pass_item = cJSON_GetObjectItem(json, "password"); | ||
| 575 | if (!ssid_item || !cJSON_IsString(ssid_item)) { | ||
| 576 | cJSON_Delete(json); | ||
| 577 | httpd_resp_set_type(req, "application/json"); | ||
| 578 | httpd_resp_send(req, "{\"ok\":false,\"error\":\"missing ssid\"}", 32); | ||
| 579 | return ESP_OK; | ||
| 580 | } | ||
| 581 | |||
| 582 | const char *ssid = ssid_item->valuestring; | ||
| 583 | const char *password = (pass_item && cJSON_IsString(pass_item)) ? pass_item->valuestring : ""; | ||
| 584 | |||
| 585 | esp_err_t err = tollgate_config_add_wifi(ssid, password); | ||
| 586 | if (err != ESP_OK) { | ||
| 587 | cJSON_Delete(json); | ||
| 588 | httpd_resp_set_type(req, "application/json"); | ||
| 589 | httpd_resp_send(req, "{\"ok\":false,\"error\":\"save failed\"}", 33); | ||
| 590 | return ESP_OK; | ||
| 591 | } | ||
| 592 | |||
| 593 | wifi_config_t wifi_cfg = {0}; | ||
| 594 | strncpy((char *)wifi_cfg.sta.ssid, ssid, sizeof(wifi_cfg.sta.ssid) - 1); | ||
| 595 | strncpy((char *)wifi_cfg.sta.password, password, sizeof(wifi_cfg.sta.password) - 1); | ||
| 596 | wifi_cfg.sta.threshold.authmode = WIFI_AUTH_WPA2_PSK; | ||
| 597 | esp_wifi_set_config(WIFI_IF_STA, &wifi_cfg); | ||
| 598 | esp_wifi_connect(); | ||
| 599 | |||
| 600 | cJSON_Delete(json); | ||
| 601 | |||
| 602 | httpd_resp_set_type(req, "application/json"); | ||
| 603 | httpd_resp_send(req, "{\"ok\":true}", 10); | ||
| 604 | return ESP_OK; | ||
| 605 | } | ||
| 606 | |||
| 607 | static esp_err_t wifi_status_handler(httpd_req_t *req) { | ||
| 608 | wifi_ap_record_t ap_info; | ||
| 609 | bool connected = (esp_wifi_sta_get_ap_info(&ap_info) == ESP_OK); | ||
| 610 | |||
| 611 | cJSON *root = cJSON_CreateObject(); | ||
| 612 | cJSON_AddBoolToObject(root, "connected", connected); | ||
| 613 | |||
| 614 | if (connected) { | ||
| 615 | esp_netif_t *netif = esp_netif_get_handle_from_ifkey("WIFI_STA_DEF"); | ||
| 616 | if (netif) { | ||
| 617 | esp_netif_ip_info_t ip_info; | ||
| 618 | if (esp_netif_get_ip_info(netif, &ip_info) == ESP_OK) { | ||
| 619 | char ip_str[16]; | ||
| 620 | snprintf(ip_str, sizeof(ip_str), IPSTR, IP2STR(&ip_info.ip)); | ||
| 621 | cJSON_AddStringToObject(root, "ip", ip_str); | ||
| 622 | } | ||
| 623 | } | ||
| 624 | cJSON_AddStringToObject(root, "ssid", (const char *)ap_info.ssid); | ||
| 625 | } | ||
| 626 | |||
| 627 | char *json = cJSON_PrintUnformatted(root); | ||
| 628 | httpd_resp_set_type(req, "application/json"); | ||
| 629 | httpd_resp_send(req, json, strlen(json)); | ||
| 630 | cJSON_free(json); | ||
| 631 | cJSON_Delete(root); | ||
| 632 | return ESP_OK; | ||
| 633 | } | ||
| 634 | |||
| 635 | static const httpd_uri_t uri_setup = { .uri = "/setup", .method = HTTP_GET, .handler = setup_page_handler }; | ||
| 636 | static const httpd_uri_t uri_wifi_scan = { .uri = "/wifi/scan", .method = HTTP_GET, .handler = wifi_scan_handler }; | ||
| 637 | static const httpd_uri_t uri_wifi_connect = { .uri = "/wifi/connect", .method = HTTP_POST, .handler = wifi_connect_handler }; | ||
| 638 | static const httpd_uri_t uri_wifi_status = { .uri = "/wifi/status", .method = HTTP_GET, .handler = wifi_status_handler }; | ||
| 639 | |||
| 306 | esp_err_t captive_portal_start(const char *ap_ip_str) | 640 | esp_err_t captive_portal_start(const char *ap_ip_str) |
| 307 | { | 641 | { |
| 308 | if (s_server) return ESP_OK; | 642 | if (s_server) return ESP_OK; |
| @@ -331,6 +665,10 @@ esp_err_t captive_portal_start(const char *ap_ip_str) | |||
| 331 | httpd_register_uri_handler(s_server, &uri_ncsi); | 665 | httpd_register_uri_handler(s_server, &uri_ncsi); |
| 332 | httpd_register_uri_handler(s_server, &uri_connecttest); | 666 | httpd_register_uri_handler(s_server, &uri_connecttest); |
| 333 | httpd_register_uri_handler(s_server, &uri_wpad); | 667 | httpd_register_uri_handler(s_server, &uri_wpad); |
| 668 | httpd_register_uri_handler(s_server, &uri_setup); | ||
| 669 | httpd_register_uri_handler(s_server, &uri_wifi_scan); | ||
| 670 | httpd_register_uri_handler(s_server, &uri_wifi_connect); | ||
| 671 | httpd_register_uri_handler(s_server, &uri_wifi_status); | ||
| 334 | httpd_register_uri_handler(s_server, &uri_catchall); | 672 | httpd_register_uri_handler(s_server, &uri_catchall); |
| 335 | 673 | ||
| 336 | ESP_LOGI(TAG, "Captive portal started on port 80"); | 674 | ESP_LOGI(TAG, "Captive portal started on port 80"); |
diff --git a/main/captive_portal.h b/main/captive_portal.h index 06eb860..e02a4ce 100644 --- a/main/captive_portal.h +++ b/main/captive_portal.h | |||
| @@ -7,5 +7,6 @@ | |||
| 7 | esp_err_t captive_portal_start(const char *ap_ip_str); | 7 | esp_err_t captive_portal_start(const char *ap_ip_str); |
| 8 | void captive_portal_stop(void); | 8 | void captive_portal_stop(void); |
| 9 | httpd_handle_t captive_portal_get_server(void); | 9 | httpd_handle_t captive_portal_get_server(void); |
| 10 | bool captive_portal_is_setup_available(void); | ||
| 10 | 11 | ||
| 11 | #endif | 12 | #endif |
diff --git a/main/display.c b/main/display.c index 77b911d..53cdd4d 100644 --- a/main/display.c +++ b/main/display.c | |||
| @@ -3,9 +3,6 @@ | |||
| 3 | #include "qrcoded.h" | 3 | #include "qrcoded.h" |
| 4 | #include "font.h" | 4 | #include "font.h" |
| 5 | #include "nucula_wallet.h" | 5 | #include "nucula_wallet.h" |
| 6 | #include "touch.h" | ||
| 7 | #include "keyboard.h" | ||
| 8 | #include "wifi_setup.h" | ||
| 9 | #include "config.h" | 6 | #include "config.h" |
| 10 | #include "esp_log.h" | 7 | #include "esp_log.h" |
| 11 | #include "esp_wifi.h" | 8 | #include "esp_wifi.h" |
| @@ -43,74 +40,10 @@ static display_qr_mode_t s_qr_mode = DISPLAY_QR_WIFI; | |||
| 43 | static int s_last_payment_sats = 0; | 40 | static int s_last_payment_sats = 0; |
| 44 | static int64_t s_last_allotment_ms = 0; | 41 | static int64_t s_last_allotment_ms = 0; |
| 45 | 42 | ||
| 46 | #define COLOR_GRAY 0x8410 | 43 | static uint16_t wallet_color(void) { |
| 47 | #define COLOR_DARKGRAY 0x4208 | 44 | if (s_wallet_balance == 0) return COLOR_RED; |
| 48 | #define COLOR_LIGHTBLUE 0xA5FF | 45 | if (s_wallet_balance < 100) return COLOR_YELLOW; |
| 49 | #define COLOR_HIGHLIGHT 0x07E0 | 46 | return COLOR_GREEN; |
| 50 | |||
| 51 | static wifi_setup_t s_wifi_setup; | ||
| 52 | static kb_state_t s_kb_state; | ||
| 53 | static bool s_wifi_setup_active = false; | ||
| 54 | static bool s_touch_initialized = false; | ||
| 55 | static bool s_wifi_scan_pending = false; | ||
| 56 | static int s_display_rotation = 0; | ||
| 57 | |||
| 58 | #define SETUP_BTN_X 240 | ||
| 59 | #define SETUP_BTN_Y 440 | ||
| 60 | #define SETUP_BTN_W 72 | ||
| 61 | #define SETUP_BTN_H 30 | ||
| 62 | |||
| 63 | static void enter_wifi_setup_rotation(void) { | ||
| 64 | if (s_display_rotation != 1) { | ||
| 65 | s_display_rotation = 1; | ||
| 66 | axs15231b_set_rotation(1); | ||
| 67 | touch_set_rotation(1); | ||
| 68 | kb_layout_t landscape = { | ||
| 69 | .key_w = 38, | ||
| 70 | .key_h = 40, | ||
| 71 | .key_gap = 2, | ||
| 72 | .start_y = 170, | ||
| 73 | .screen_w = 480, | ||
| 74 | .row_count = 4, | ||
| 75 | }; | ||
| 76 | kb_set_layout(&landscape); | ||
| 77 | } | ||
| 78 | } | ||
| 79 | |||
| 80 | static void exit_wifi_setup_rotation(void) { | ||
| 81 | if (s_display_rotation != 0) { | ||
| 82 | s_display_rotation = 0; | ||
| 83 | axs15231b_set_rotation(0); | ||
| 84 | touch_set_rotation(0); | ||
| 85 | kb_layout_t portrait = { | ||
| 86 | .key_w = 28, | ||
| 87 | .key_h = 36, | ||
| 88 | .key_gap = 2, | ||
| 89 | .start_y = 70, | ||
| 90 | .screen_w = 320, | ||
| 91 | .row_count = 4, | ||
| 92 | }; | ||
| 93 | kb_set_layout(&portrait); | ||
| 94 | } | ||
| 95 | } | ||
| 96 | |||
| 97 | static void render_setup_button(int x, int y, int w, int h) { | ||
| 98 | axs15231b_fill_rect(x, y, w, h, COLOR_DARKGRAY); | ||
| 99 | const char *label = "Setup"; | ||
| 100 | int lw = strlen(label) * 8; | ||
| 101 | display_render_text(x + (w - lw) / 2, y + (h - 8) / 2, label, COLOR_WHITE, COLOR_DARKGRAY, 1); | ||
| 102 | } | ||
| 103 | |||
| 104 | static bool touch_in_rect(uint16_t tx, uint16_t ty, int x, int y, int w, int h) { | ||
| 105 | return tx >= x && tx < x + w && ty >= y && ty < y + h; | ||
| 106 | } | ||
| 107 | |||
| 108 | static void highlight_rect(int x, int y, int w, int h) { | ||
| 109 | axs15231b_fill_rect(x, y, w, h, COLOR_HIGHLIGHT); | ||
| 110 | axs15231b_flush(); | ||
| 111 | vTaskDelay(pdMS_TO_TICKS(80)); | ||
| 112 | axs15231b_fill_rect(x, y, w, h, COLOR_DARKGRAY); | ||
| 113 | axs15231b_flush(); | ||
| 114 | } | 47 | } |
| 115 | 48 | ||
| 116 | static int qr_version_from_strlen(int len) { | 49 | static int qr_version_from_strlen(int len) { |
| @@ -150,20 +83,60 @@ static int escape_wifi_field(const char *src, char *dst, int dst_size) { | |||
| 150 | return di; | 83 | return di; |
| 151 | } | 84 | } |
| 152 | 85 | ||
| 86 | static void extract_domain(const char *url, char *out, int out_size) { | ||
| 87 | const char *start = url; | ||
| 88 | if (strncmp(url, "https://", 8) == 0) start = url + 8; | ||
| 89 | else if (strncmp(url, "http://", 7) == 0) start = url + 7; | ||
| 90 | strncpy(out, start, out_size - 1); | ||
| 91 | out[out_size - 1] = '\0'; | ||
| 92 | char *slash = strchr(out, '/'); | ||
| 93 | if (slash) *slash = '\0'; | ||
| 94 | } | ||
| 95 | |||
| 153 | static void build_wifi_qr_string(char *out, int out_size) { | 96 | static void build_wifi_qr_string(char *out, int out_size) { |
| 154 | char escaped_ssid[64]; | 97 | char escaped_ssid[64]; |
| 155 | escape_wifi_field(s_ap_ssid, escaped_ssid, sizeof(escaped_ssid)); | 98 | escape_wifi_field(s_ap_ssid, escaped_ssid, sizeof(escaped_ssid)); |
| 156 | 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 | } | ||
| 157 | } | 107 | } |
| 158 | 108 | ||
| 159 | static void extract_domain(const char *url, char *domain, int domain_size) { | 109 | static void render_qr_at(const char *text, int x_off, int y_off, int max_w, int max_h) { |
| 160 | const char *start = url; | 110 | int len = strlen(text); |
| 161 | if (strncmp(url, "https://", 8) == 0) start = url + 8; | 111 | int version = qr_version_from_strlen(len); |
| 162 | else if (strncmp(url, "http://", 7) == 0) start = url + 7; | 112 | int px = qr_pixel_size(len); |
| 163 | strncpy(domain, start, domain_size - 1); | 113 | |
| 164 | domain[domain_size - 1] = '\0'; | 114 | uint16_t buf_size = qrcode_getBufferSize(version); |
| 165 | char *slash = strchr(domain, '/'); | 115 | uint8_t *qr_buf = (uint8_t *)malloc(buf_size); |
| 166 | if (slash) *slash = '\0'; | 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); | ||
| 167 | } | 140 | } |
| 168 | 141 | ||
| 169 | void display_render_text(int x, int y, const char *text, uint16_t fg, uint16_t bg, int scale) { | 142 | void display_render_text(int x, int y, const char *text, uint16_t fg, uint16_t bg, int scale) { |
| @@ -199,57 +172,14 @@ void display_render_text(int x, int y, const char *text, uint16_t fg, uint16_t b | |||
| 199 | } | 172 | } |
| 200 | } | 173 | } |
| 201 | 174 | ||
| 202 | static void render_qr_at(const char *text, int x_off, int y_off, int max_w, int max_h) { | ||
| 203 | int len = strlen(text); | ||
| 204 | int version = qr_version_from_strlen(len); | ||
| 205 | int px = qr_pixel_size(len); | ||
| 206 | |||
| 207 | uint16_t buf_size = qrcode_getBufferSize(version); | ||
| 208 | uint8_t *qr_buf = (uint8_t *)malloc(buf_size); | ||
| 209 | if (!qr_buf) { | ||
| 210 | ESP_LOGE(TAG, "Failed to allocate QR buffer"); | ||
| 211 | return; | ||
| 212 | } | ||
| 213 | |||
| 214 | QRCode qr; | ||
| 215 | if (qrcode_initText(&qr, qr_buf, version, ECC_LOW, text) != 0) { | ||
| 216 | ESP_LOGE(TAG, "QR generation failed"); | ||
| 217 | free(qr_buf); | ||
| 218 | return; | ||
| 219 | } | ||
| 220 | |||
| 221 | int qr_px_w = qr.size * px; | ||
| 222 | int qr_px_h = qr.size * px; | ||
| 223 | int cx = x_off + (max_w - qr_px_w) / 2; | ||
| 224 | int cy = y_off + (max_h - qr_px_h) / 2; | ||
| 225 | if (cx < 0) cx = 0; | ||
| 226 | if (cy < 0) cy = 0; | ||
| 227 | |||
| 228 | for (int y = 0; y < qr.size; y++) { | ||
| 229 | for (int x = 0; x < qr.size; x++) { | ||
| 230 | bool mod = qrcode_getModule(&qr, x, y); | ||
| 231 | uint16_t color = mod ? 0xFFFF : 0x0000; | ||
| 232 | axs15231b_fill_rect(cx + x * px, cy + y * px, px, px, color); | ||
| 233 | } | ||
| 234 | } | ||
| 235 | |||
| 236 | free(qr_buf); | ||
| 237 | } | ||
| 238 | |||
| 239 | void display_render_qr(const char *text) { | 175 | void display_render_qr(const char *text) { |
| 240 | int screen_w = axs15231b_get_width(); | 176 | int screen_w = axs15231b_get_width(); |
| 241 | int screen_h = axs15231b_get_height(); | 177 | int screen_h = axs15231b_get_height(); |
| 242 | axs15231b_fill_screen(0x0000); | 178 | axs15231b_fill_screen(COLOR_BG); |
| 243 | render_qr_at(text, 0, 0, screen_w, screen_h); | 179 | render_qr_at(text, 0, 0, screen_w, screen_h); |
| 244 | axs15231b_flush(); | 180 | axs15231b_flush(); |
| 245 | } | 181 | } |
| 246 | 182 | ||
| 247 | static uint16_t wallet_color(void) { | ||
| 248 | if (s_wallet_balance == 0) return COLOR_RED; | ||
| 249 | if (s_wallet_balance < 100) return COLOR_YELLOW; | ||
| 250 | return COLOR_GREEN; | ||
| 251 | } | ||
| 252 | |||
| 253 | static void render_boot_screen(void) { | 183 | static void render_boot_screen(void) { |
| 254 | int screen_w = axs15231b_get_width(); | 184 | int screen_w = axs15231b_get_width(); |
| 255 | axs15231b_fill_screen(COLOR_BG); | 185 | axs15231b_fill_screen(COLOR_BG); |
| @@ -315,7 +245,44 @@ static void render_ready_screen(void) { | |||
| 315 | display_render_text(10, y, line, COLOR_GREEN, COLOR_BG, 1); | 245 | display_render_text(10, y, line, COLOR_GREEN, COLOR_BG, 1); |
| 316 | } | 246 | } |
| 317 | 247 | ||
| 318 | render_setup_button(SETUP_BTN_X, SETUP_BTN_Y, SETUP_BTN_W, SETUP_BTN_H); | 248 | axs15231b_flush(); |
| 249 | } | ||
| 250 | |||
| 251 | static void render_setup_pending_screen(void) { | ||
| 252 | int screen_w = axs15231b_get_width(); | ||
| 253 | axs15231b_fill_screen(COLOR_BG); | ||
| 254 | |||
| 255 | char qr_text[320]; | ||
| 256 | build_wifi_qr_string(qr_text, sizeof(qr_text)); | ||
| 257 | render_qr_at(qr_text, 0, 5, screen_w, 280); | ||
| 258 | |||
| 259 | int y = 290; | ||
| 260 | char line[64]; | ||
| 261 | |||
| 262 | const char *title = "WiFi Setup"; | ||
| 263 | int tw = strlen(title) * 8; | ||
| 264 | display_render_text((screen_w - tw) / 2, y, title, COLOR_CYAN, COLOR_BG, 1); | ||
| 265 | y += 20; | ||
| 266 | |||
| 267 | snprintf(line, sizeof(line), "SSID: %s", s_ap_ssid); | ||
| 268 | display_render_text(10, y, line, COLOR_WHITE, COLOR_BG, 1); | ||
| 269 | y += 18; | ||
| 270 | |||
| 271 | const char *hint1 = "1. Connect to WiFi above"; | ||
| 272 | display_render_text(10, y, hint1, COLOR_DIM, COLOR_BG, 1); | ||
| 273 | y += 16; | ||
| 274 | |||
| 275 | const char *hint2 = "2. Open browser, go to:"; | ||
| 276 | display_render_text(10, y, hint2, COLOR_DIM, COLOR_BG, 1); | ||
| 277 | y += 18; | ||
| 278 | |||
| 279 | const tollgate_config_t *cfg = tollgate_config_get(); | ||
| 280 | snprintf(line, sizeof(line), "http://%s/setup", cfg->ap_ip_str); | ||
| 281 | display_render_text(10, y, line, COLOR_YELLOW, COLOR_BG, 1); | ||
| 282 | y += 22; | ||
| 283 | |||
| 284 | const char *hint3 = "3. Configure upstream WiFi"; | ||
| 285 | display_render_text(10, y, hint3, COLOR_DIM, COLOR_BG, 1); | ||
| 319 | 286 | ||
| 320 | axs15231b_flush(); | 287 | axs15231b_flush(); |
| 321 | } | 288 | } |
| @@ -360,324 +327,29 @@ static void render_error_screen(void) { | |||
| 360 | int msg_w = strlen(msg) * 8 * 2; | 327 | int msg_w = strlen(msg) * 8 * 2; |
| 361 | display_render_text((screen_w - msg_w) / 2, 202, msg, COLOR_WHITE, COLOR_RED, 2); | 328 | display_render_text((screen_w - msg_w) / 2, 202, msg, COLOR_WHITE, COLOR_RED, 2); |
| 362 | 329 | ||
| 363 | char line[48]; | 330 | char line[64]; |
| 364 | int lw; | 331 | int lw; |
| 365 | 332 | ||
| 366 | const char *l1 = "Internet unavailable"; | 333 | const char *l1 = "Internet unavailable"; |
| 367 | lw = strlen(l1) * 8; | 334 | lw = strlen(l1) * 8; |
| 368 | display_render_text((screen_w - lw) / 2, 270, l1, COLOR_WHITE, COLOR_BG, 1); | 335 | display_render_text((screen_w - lw) / 2, 270, l1, COLOR_WHITE, COLOR_BG, 1); |
| 369 | 336 | ||
| 370 | const char *l2 = "Check WiFi config"; | ||
| 371 | lw = strlen(l2) * 8; | ||
| 372 | display_render_text((screen_w - lw) / 2, 290, l2, COLOR_YELLOW, COLOR_BG, 1); | ||
| 373 | |||
| 374 | const char *l3 = "AP still active"; | ||
| 375 | lw = strlen(l3) * 8; | ||
| 376 | display_render_text((screen_w - lw) / 2, 320, l3, COLOR_GREEN, COLOR_BG, 1); | ||
| 377 | |||
| 378 | snprintf(line, sizeof(line), "SSID: %s", s_ap_ssid); | 337 | snprintf(line, sizeof(line), "SSID: %s", s_ap_ssid); |
| 379 | lw = strlen(line) * 8; | 338 | lw = strlen(line) * 8; |
| 380 | display_render_text((screen_w - lw) / 2, 340, line, COLOR_DIM, COLOR_BG, 1); | 339 | display_render_text((screen_w - lw) / 2, 300, line, COLOR_DIM, COLOR_BG, 1); |
| 381 | |||
| 382 | render_setup_button(SETUP_BTN_X, SETUP_BTN_Y, SETUP_BTN_W, SETUP_BTN_H); | ||
| 383 | |||
| 384 | axs15231b_flush(); | ||
| 385 | } | ||
| 386 | 340 | ||
| 387 | static void render_wifi_setup_scanning(void) { | 341 | const tollgate_config_t *cfg = tollgate_config_get(); |
| 388 | int screen_w = axs15231b_get_width(); | 342 | snprintf(line, sizeof(line), "Setup: http://%s/setup", cfg->ap_ip_str); |
| 389 | axs15231b_fill_screen(COLOR_BG); | 343 | lw = strlen(line) * 8; |
| 390 | 344 | display_render_text((screen_w - lw) / 2, 330, line, COLOR_YELLOW, COLOR_BG, 1); | |
| 391 | const char *title = "WiFi Setup"; | ||
| 392 | int tw = strlen(title) * 8; | ||
| 393 | display_render_text((screen_w - tw) / 2, 180, title, COLOR_CYAN, COLOR_BG, 1); | ||
| 394 | |||
| 395 | const char *msg = "Scanning..."; | ||
| 396 | int mw = strlen(msg) * 8; | ||
| 397 | display_render_text((screen_w - mw) / 2, 220, msg, COLOR_WHITE, COLOR_BG, 1); | ||
| 398 | |||
| 399 | axs15231b_flush(); | ||
| 400 | } | ||
| 401 | |||
| 402 | static void render_wifi_setup_list(void) { | ||
| 403 | int screen_w = axs15231b_get_width(); | ||
| 404 | int screen_h = axs15231b_get_height(); | ||
| 405 | axs15231b_fill_screen(COLOR_BG); | ||
| 406 | |||
| 407 | const char *title = "Select Network"; | ||
| 408 | int tw = strlen(title) * 8; | ||
| 409 | display_render_text((screen_w - tw) / 2, 5, title, COLOR_CYAN, COLOR_BG, 1); | ||
| 410 | |||
| 411 | int y = 25; | ||
| 412 | int visible = wifi_setup_visible_count(&s_wifi_setup); | ||
| 413 | int item_w = screen_w - 20; | ||
| 414 | int max_y = screen_h - 40; | ||
| 415 | |||
| 416 | for (int i = 0; i < visible && y < max_y; i++) { | ||
| 417 | const wifi_ap_info_t *ap = wifi_setup_get_visible(&s_wifi_setup, i); | ||
| 418 | if (!ap) break; | ||
| 419 | |||
| 420 | int rssi_bars = 0; | ||
| 421 | if (ap->rssi >= -30) rssi_bars = 4; | ||
| 422 | else if (ap->rssi >= -50) rssi_bars = 3; | ||
| 423 | else if (ap->rssi >= -70) rssi_bars = 2; | ||
| 424 | else rssi_bars = 1; | ||
| 425 | |||
| 426 | axs15231b_fill_rect(10, y, item_w, 26, COLOR_DARKGRAY); | ||
| 427 | display_render_text(15, y + 4, ap->ssid, COLOR_WHITE, COLOR_DARKGRAY, 1); | ||
| 428 | |||
| 429 | int bar_x = 10 + item_w - 50; | ||
| 430 | for (int b = 0; b < rssi_bars; b++) { | ||
| 431 | axs15231b_fill_rect(bar_x + b * 10, y + 16 - (b + 1) * 4, 8, (b + 1) * 4, COLOR_GREEN); | ||
| 432 | } | ||
| 433 | |||
| 434 | if (ap->secured) { | ||
| 435 | display_render_text(bar_x - 12, y + 4, "*", COLOR_YELLOW, COLOR_DARKGRAY, 1); | ||
| 436 | } | ||
| 437 | |||
| 438 | y += 30; | ||
| 439 | } | ||
| 440 | |||
| 441 | int btn_y = screen_h - 30; | ||
| 442 | axs15231b_fill_rect(10, btn_y, 50, 26, COLOR_DARKGRAY); | ||
| 443 | display_render_text(25, btn_y + 5, "X", COLOR_WHITE, COLOR_DARKGRAY, 1); | ||
| 444 | |||
| 445 | axs15231b_flush(); | ||
| 446 | } | ||
| 447 | |||
| 448 | static void render_wifi_setup_password(void) { | ||
| 449 | int screen_w = axs15231b_get_width(); | ||
| 450 | const kb_layout_t *kb = kb_get_layout(); | ||
| 451 | axs15231b_fill_screen(COLOR_BG); | ||
| 452 | |||
| 453 | display_render_text(10, 5, s_wifi_setup.selected_ssid, COLOR_CYAN, COLOR_BG, 1); | ||
| 454 | display_render_text(10, 25, "Password:", COLOR_DIM, COLOR_BG, 1); | ||
| 455 | |||
| 456 | int field_w = screen_w - 80; | ||
| 457 | axs15231b_fill_rect(10, 40, field_w, 20, COLOR_DARKGRAY); | ||
| 458 | |||
| 459 | char masked[KB_INPUT_MAX + 1]; | ||
| 460 | if (s_kb_state.reveal) { | ||
| 461 | strncpy(masked, s_kb_state.input, sizeof(masked) - 1); | ||
| 462 | masked[sizeof(masked) - 1] = '\0'; | ||
| 463 | } else { | ||
| 464 | int len = s_kb_state.cursor; | ||
| 465 | int max_chars = (field_w - 8) / 8; | ||
| 466 | if (len > max_chars) len = max_chars; | ||
| 467 | for (int i = 0; i < len; i++) masked[i] = '*'; | ||
| 468 | masked[len] = '\0'; | ||
| 469 | } | ||
| 470 | display_render_text(14, 43, masked, COLOR_WHITE, COLOR_DARKGRAY, 1); | ||
| 471 | |||
| 472 | int eye_x = 10 + field_w + 5; | ||
| 473 | const char *eye_label = s_kb_state.reveal ? "H" : "S"; | ||
| 474 | axs15231b_fill_rect(eye_x, 40, 24, 20, COLOR_GRAY); | ||
| 475 | display_render_text(eye_x + 8, 43, eye_label, COLOR_WHITE, COLOR_GRAY, 1); | ||
| 476 | |||
| 477 | int kb_y = kb->start_y; | ||
| 478 | for (int row = 0; row < kb->row_count; row++) { | ||
| 479 | const char *row_str; | ||
| 480 | int key_count = kb_get_row_keys(row, s_kb_state.layer, &row_str); | ||
| 481 | const char *tmp; | ||
| 482 | int total_keys = kb_get_row_keys(row, s_kb_state.layer, &tmp); | ||
| 483 | int x_off = (row == 0) ? (screen_w - total_keys * kb->key_w - (total_keys - 1) * kb->key_gap) / 2 | ||
| 484 | : (row == 1) ? (screen_w - total_keys * kb->key_w - (total_keys - 1) * kb->key_gap) / 2 + kb->key_w / 2 | ||
| 485 | : (row == 2) ? (screen_w - total_keys * kb->key_w - (total_keys - 1) * kb->key_gap) / 2 + kb->key_w | ||
| 486 | : (screen_w - total_keys * kb->key_w - (total_keys - 1) * kb->key_gap) / 2; | ||
| 487 | |||
| 488 | int cx = x_off; | ||
| 489 | for (int col = 0; col < key_count; col++) { | ||
| 490 | char c = row_str[col]; | ||
| 491 | int kw = kb->key_w; | ||
| 492 | if (row == 3) { | ||
| 493 | int margin = (screen_w - total_keys * kb->key_w - (total_keys - 1) * kb->key_gap) / 2; | ||
| 494 | int available = screen_w - margin * 2; | ||
| 495 | int side_w = (available - kb->key_gap) / 4; | ||
| 496 | if (col == 0) kw = side_w; | ||
| 497 | else if (col == key_count - 1) kw = side_w; | ||
| 498 | else kw = available - side_w * 2 - kb->key_gap * 2; | ||
| 499 | } | ||
| 500 | |||
| 501 | uint16_t bg = COLOR_DARKGRAY; | ||
| 502 | uint16_t fg = COLOR_WHITE; | ||
| 503 | char label[2] = {0, 0}; | ||
| 504 | |||
| 505 | if (c == '\001') { | ||
| 506 | label[0] = s_kb_state.layer == KB_ALPHA_UPPER ? 'A' : 'a'; | ||
| 507 | bg = COLOR_GRAY; | ||
| 508 | } else if (c == '\002') { | ||
| 509 | label[0] = s_kb_state.layer == KB_NUMSYM ? 'a' : '#'; | ||
| 510 | bg = COLOR_GRAY; | ||
| 511 | } else if (c == '\003') { | ||
| 512 | label[0] = '_'; | ||
| 513 | } else if (c == '\004') { | ||
| 514 | label[0] = '>'; | ||
| 515 | fg = COLOR_GREEN; | ||
| 516 | } else if (c == '\b') { | ||
| 517 | label[0] = '<'; | ||
| 518 | bg = COLOR_GRAY; | ||
| 519 | } else { | ||
| 520 | label[0] = c; | ||
| 521 | } | ||
| 522 | |||
| 523 | axs15231b_fill_rect(cx, kb_y, kw, kb->key_h, bg); | ||
| 524 | int lw = 8; | ||
| 525 | display_render_text(cx + (kw - lw) / 2, kb_y + (kb->key_h - 8) / 2, label, fg, bg, 1); | ||
| 526 | cx += kw + kb->key_gap; | ||
| 527 | } | ||
| 528 | kb_y += kb->key_h + kb->key_gap; | ||
| 529 | } | ||
| 530 | |||
| 531 | axs15231b_flush(); | ||
| 532 | } | ||
| 533 | |||
| 534 | static void render_wifi_setup_connecting(void) { | ||
| 535 | int screen_w = axs15231b_get_width(); | ||
| 536 | axs15231b_fill_screen(COLOR_BG); | ||
| 537 | |||
| 538 | const char *msg1 = "Connecting to"; | ||
| 539 | int w1 = strlen(msg1) * 8; | ||
| 540 | display_render_text((screen_w - w1) / 2, 200, msg1, COLOR_WHITE, COLOR_BG, 1); | ||
| 541 | |||
| 542 | int w2 = strlen(s_wifi_setup.selected_ssid) * 8; | ||
| 543 | display_render_text((screen_w - w2) / 2, 225, s_wifi_setup.selected_ssid, COLOR_CYAN, COLOR_BG, 1); | ||
| 544 | |||
| 545 | const char *dots = "..."; | ||
| 546 | int dw = strlen(dots) * 8; | ||
| 547 | display_render_text((screen_w - dw) / 2, 255, dots, COLOR_DIM, COLOR_BG, 1); | ||
| 548 | |||
| 549 | axs15231b_flush(); | ||
| 550 | } | ||
| 551 | |||
| 552 | static void render_wifi_setup_result(void) { | ||
| 553 | int screen_w = axs15231b_get_width(); | ||
| 554 | int screen_h = axs15231b_get_height(); | ||
| 555 | axs15231b_fill_screen(COLOR_BG); | ||
| 556 | |||
| 557 | if (s_wifi_setup.state == SETUP_SUCCESS) { | ||
| 558 | const char *msg = "Connected!"; | ||
| 559 | int mw = strlen(msg) * 8 * 2; | ||
| 560 | display_render_text((screen_w - mw) / 2, screen_h / 2 - 40, msg, COLOR_WHITE, COLOR_GREEN, 2); | ||
| 561 | 345 | ||
| 562 | if (s_wifi_setup.connect_ip[0]) { | 346 | const char *l3 = "AP still active"; |
| 563 | char ip_line[32]; | 347 | lw = strlen(l3) * 8; |
| 564 | snprintf(ip_line, sizeof(ip_line), "IP: %s", s_wifi_setup.connect_ip); | 348 | display_render_text((screen_w - lw) / 2, 360, l3, COLOR_GREEN, COLOR_BG, 1); |
| 565 | int iw = strlen(ip_line) * 8; | ||
| 566 | display_render_text((screen_w - iw) / 2, screen_h / 2, ip_line, COLOR_WHITE, COLOR_BG, 1); | ||
| 567 | } | ||
| 568 | } else { | ||
| 569 | const char *msg = "Connection failed"; | ||
| 570 | int mw = strlen(msg) * 8 * 2; | ||
| 571 | display_render_text((screen_w - mw) / 2, screen_h / 2 - 40, msg, COLOR_WHITE, COLOR_RED, 2); | ||
| 572 | |||
| 573 | const char *hint = "Wrong password?"; | ||
| 574 | int hw = strlen(hint) * 8; | ||
| 575 | display_render_text((screen_w - hw) / 2, screen_h / 2, hint, COLOR_YELLOW, COLOR_BG, 1); | ||
| 576 | |||
| 577 | int btn_y = screen_h / 2 + 30; | ||
| 578 | int btn_w = (screen_w - 40) / 2; | ||
| 579 | axs15231b_fill_rect(10, btn_y, btn_w, 30, COLOR_DARKGRAY); | ||
| 580 | display_render_text(10 + (btn_w - 40) / 2, btn_y + 8, "Retry", COLOR_WHITE, COLOR_DARKGRAY, 1); | ||
| 581 | axs15231b_fill_rect(20 + btn_w, btn_y, btn_w, 30, COLOR_DARKGRAY); | ||
| 582 | display_render_text(20 + btn_w + (btn_w - 50) / 2, btn_y + 8, "Change", COLOR_WHITE, COLOR_DARKGRAY, 1); | ||
| 583 | } | ||
| 584 | 349 | ||
| 585 | axs15231b_flush(); | 350 | axs15231b_flush(); |
| 586 | } | 351 | } |
| 587 | 352 | ||
| 588 | static void handle_wifi_setup_touch(uint16_t tx, uint16_t ty) { | ||
| 589 | int screen_w = axs15231b_get_width(); | ||
| 590 | int screen_h = axs15231b_get_height(); | ||
| 591 | |||
| 592 | switch (s_wifi_setup.state) { | ||
| 593 | case SETUP_SCAN: | ||
| 594 | break; | ||
| 595 | |||
| 596 | case SETUP_LIST: { | ||
| 597 | int btn_y = screen_h - 30; | ||
| 598 | if (touch_in_rect(tx, ty, 10, btn_y, 50, 26)) { | ||
| 599 | highlight_rect(10, btn_y, 50, 26); | ||
| 600 | wifi_setup_handle_cancel(&s_wifi_setup); | ||
| 601 | s_wifi_setup_active = false; | ||
| 602 | exit_wifi_setup_rotation(); | ||
| 603 | s_state = DISPLAY_ERROR; | ||
| 604 | return; | ||
| 605 | } | ||
| 606 | int item_w = screen_w - 20; | ||
| 607 | int y = 25; | ||
| 608 | int max_y = screen_h - 40; | ||
| 609 | int visible = wifi_setup_visible_count(&s_wifi_setup); | ||
| 610 | for (int i = 0; i < visible && y < max_y; i++) { | ||
| 611 | if (touch_in_rect(tx, ty, 10, y, item_w, 26)) { | ||
| 612 | highlight_rect(10, y, item_w, 26); | ||
| 613 | wifi_setup_handle_select(&s_wifi_setup, i); | ||
| 614 | kb_state_init(&s_kb_state); | ||
| 615 | return; | ||
| 616 | } | ||
| 617 | y += 30; | ||
| 618 | } | ||
| 619 | break; | ||
| 620 | } | ||
| 621 | |||
| 622 | case SETUP_PASSWORD: { | ||
| 623 | int field_w = screen_w - 80; | ||
| 624 | int eye_x = 10 + field_w + 5; | ||
| 625 | if (touch_in_rect(tx, ty, eye_x, 40, 24, 20)) { | ||
| 626 | s_kb_state.reveal = !s_kb_state.reveal; | ||
| 627 | return; | ||
| 628 | } | ||
| 629 | kb_result_t r = kb_hit_test(tx, ty, s_kb_state.layer); | ||
| 630 | if (r.action != KB_ACTION_NONE) { | ||
| 631 | axs15231b_fill_rect(tx - 20, ty - 20, 40, 40, COLOR_HIGHLIGHT); | ||
| 632 | axs15231b_flush(); | ||
| 633 | vTaskDelay(pdMS_TO_TICKS(60)); | ||
| 634 | if (r.action == KB_ACTION_DONE && s_kb_state.cursor > 0) { | ||
| 635 | wifi_setup_handle_connect(&s_wifi_setup); | ||
| 636 | wifi_config_t wifi_cfg = {0}; | ||
| 637 | strncpy((char *)wifi_cfg.sta.ssid, s_wifi_setup.selected_ssid, | ||
| 638 | sizeof(wifi_cfg.sta.ssid) - 1); | ||
| 639 | strncpy((char *)wifi_cfg.sta.password, s_kb_state.input, | ||
| 640 | sizeof(wifi_cfg.sta.password) - 1); | ||
| 641 | wifi_cfg.sta.threshold.authmode = WIFI_AUTH_WPA2_PSK; | ||
| 642 | esp_wifi_set_config(WIFI_IF_STA, &wifi_cfg); | ||
| 643 | esp_wifi_connect(); | ||
| 644 | return; | ||
| 645 | } | ||
| 646 | kb_apply(&s_kb_state, r); | ||
| 647 | } | ||
| 648 | break; | ||
| 649 | } | ||
| 650 | |||
| 651 | case SETUP_CONNECTING: | ||
| 652 | break; | ||
| 653 | |||
| 654 | case SETUP_SUCCESS: { | ||
| 655 | wifi_setup_handle_cancel(&s_wifi_setup); | ||
| 656 | s_wifi_setup_active = false; | ||
| 657 | exit_wifi_setup_rotation(); | ||
| 658 | s_state = DISPLAY_READY; | ||
| 659 | return; | ||
| 660 | } | ||
| 661 | |||
| 662 | case SETUP_FAILED: { | ||
| 663 | int btn_y = screen_h / 2 + 30; | ||
| 664 | int btn_w = (screen_w - 40) / 2; | ||
| 665 | if (touch_in_rect(tx, ty, 10, btn_y, btn_w, 30)) { | ||
| 666 | highlight_rect(10, btn_y, btn_w, 30); | ||
| 667 | wifi_setup_handle_retry(&s_wifi_setup); | ||
| 668 | kb_state_init(&s_kb_state); | ||
| 669 | } else if (touch_in_rect(tx, ty, 20 + btn_w, btn_y, btn_w, 30)) { | ||
| 670 | highlight_rect(20 + btn_w, btn_y, btn_w, 30); | ||
| 671 | wifi_setup_handle_change_network(&s_wifi_setup); | ||
| 672 | } | ||
| 673 | break; | ||
| 674 | } | ||
| 675 | |||
| 676 | default: | ||
| 677 | break; | ||
| 678 | } | ||
| 679 | } | ||
| 680 | |||
| 681 | static void display_task(void *pvParameters) { | 353 | static void display_task(void *pvParameters) { |
| 682 | ESP_LOGI(TAG, "Display task started"); | 354 | ESP_LOGI(TAG, "Display task started"); |
| 683 | 355 | ||
| @@ -692,72 +364,6 @@ static void display_task(void *pvParameters) { | |||
| 692 | } | 364 | } |
| 693 | } | 365 | } |
| 694 | 366 | ||
| 695 | touch_point_t tp; | ||
| 696 | if (s_touch_initialized && touch_read(&tp) && tp.touched) { | ||
| 697 | if (state == DISPLAY_WIFI_SETUP) { | ||
| 698 | handle_wifi_setup_touch(tp.x, tp.y); | ||
| 699 | state = s_state; | ||
| 700 | } else if (state == DISPLAY_ERROR || state == DISPLAY_READY) { | ||
| 701 | if (touch_in_rect(tp.x, tp.y, SETUP_BTN_X, SETUP_BTN_Y, SETUP_BTN_W, SETUP_BTN_H)) { | ||
| 702 | enter_wifi_setup_rotation(); | ||
| 703 | s_wifi_setup_active = true; | ||
| 704 | wifi_setup_init(&s_wifi_setup); | ||
| 705 | kb_state_init(&s_kb_state); | ||
| 706 | s_wifi_scan_pending = true; | ||
| 707 | s_state = DISPLAY_WIFI_SETUP; | ||
| 708 | state = DISPLAY_WIFI_SETUP; | ||
| 709 | } | ||
| 710 | } | ||
| 711 | } | ||
| 712 | |||
| 713 | if (s_wifi_scan_pending && s_wifi_setup.state == SETUP_SCAN) { | ||
| 714 | s_wifi_scan_pending = false; | ||
| 715 | esp_wifi_disconnect(); | ||
| 716 | vTaskDelay(pdMS_TO_TICKS(500)); | ||
| 717 | wifi_scan_config_t scan_cfg = {0}; | ||
| 718 | scan_cfg.scan_type = WIFI_SCAN_TYPE_ACTIVE; | ||
| 719 | scan_cfg.scan_time.active.min = 100; | ||
| 720 | scan_cfg.scan_time.active.max = 300; | ||
| 721 | esp_err_t scan_ret = esp_wifi_scan_start(&scan_cfg, true); | ||
| 722 | if (scan_ret != ESP_OK) { | ||
| 723 | ESP_LOGE(TAG, "WiFi scan failed: %s", esp_err_to_name(scan_ret)); | ||
| 724 | wifi_setup_set_aps(&s_wifi_setup, NULL, 0); | ||
| 725 | } else { | ||
| 726 | uint16_t ap_count = 0; | ||
| 727 | esp_wifi_scan_get_ap_num(&ap_count); | ||
| 728 | if (ap_count > WIFI_SETUP_MAX_APS) ap_count = WIFI_SETUP_MAX_APS; | ||
| 729 | wifi_ap_record_t ap_records[WIFI_SETUP_MAX_APS]; | ||
| 730 | esp_wifi_scan_get_ap_records(&ap_count, ap_records); | ||
| 731 | |||
| 732 | wifi_ap_info_t aps[WIFI_SETUP_MAX_APS]; | ||
| 733 | for (int i = 0; i < (int)ap_count; i++) { | ||
| 734 | strncpy(aps[i].ssid, (const char *)ap_records[i].ssid, WIFI_SETUP_SSID_LEN - 1); | ||
| 735 | aps[i].ssid[WIFI_SETUP_SSID_LEN - 1] = '\0'; | ||
| 736 | aps[i].rssi = ap_records[i].rssi; | ||
| 737 | aps[i].secured = (ap_records[i].authmode != WIFI_AUTH_OPEN); | ||
| 738 | } | ||
| 739 | |||
| 740 | for (int i = 0; i < (int)ap_count - 1; i++) { | ||
| 741 | for (int j = i + 1; j < (int)ap_count; j++) { | ||
| 742 | if (aps[j].rssi > aps[i].rssi) { | ||
| 743 | wifi_ap_info_t tmp = aps[i]; | ||
| 744 | aps[i] = aps[j]; | ||
| 745 | aps[j] = tmp; | ||
| 746 | } | ||
| 747 | } | ||
| 748 | } | ||
| 749 | |||
| 750 | wifi_setup_set_aps(&s_wifi_setup, aps, (int)ap_count); | ||
| 751 | } | ||
| 752 | } | ||
| 753 | |||
| 754 | if (s_wifi_setup_active && s_wifi_setup.state == SETUP_CONNECTING) { | ||
| 755 | wifi_ap_record_t ap_info; | ||
| 756 | if (esp_wifi_sta_get_ap_info(&ap_info) == ESP_OK) { | ||
| 757 | wifi_setup_handle_connect_result(&s_wifi_setup, true, NULL); | ||
| 758 | } | ||
| 759 | } | ||
| 760 | |||
| 761 | switch (state) { | 367 | switch (state) { |
| 762 | case DISPLAY_BOOT: | 368 | case DISPLAY_BOOT: |
| 763 | render_boot_screen(); | 369 | render_boot_screen(); |
| @@ -773,34 +379,8 @@ static void display_task(void *pvParameters) { | |||
| 773 | case DISPLAY_ERROR: | 379 | case DISPLAY_ERROR: |
| 774 | render_error_screen(); | 380 | render_error_screen(); |
| 775 | break; | 381 | break; |
| 776 | case DISPLAY_WIFI_SETUP: | 382 | case DISPLAY_SETUP_PENDING: |
| 777 | switch (s_wifi_setup.state) { | 383 | render_setup_pending_screen(); |
| 778 | case SETUP_SCAN: | ||
| 779 | render_wifi_setup_scanning(); | ||
| 780 | break; | ||
| 781 | case SETUP_LIST: | ||
| 782 | render_wifi_setup_list(); | ||
| 783 | break; | ||
| 784 | case SETUP_PASSWORD: | ||
| 785 | render_wifi_setup_password(); | ||
| 786 | break; | ||
| 787 | case SETUP_CONNECTING: | ||
| 788 | render_wifi_setup_connecting(); | ||
| 789 | break; | ||
| 790 | case SETUP_SUCCESS: | ||
| 791 | render_wifi_setup_result(); | ||
| 792 | vTaskDelay(pdMS_TO_TICKS(3000)); | ||
| 793 | wifi_setup_handle_cancel(&s_wifi_setup); | ||
| 794 | s_wifi_setup_active = false; | ||
| 795 | exit_wifi_setup_rotation(); | ||
| 796 | s_state = DISPLAY_READY; | ||
| 797 | break; | ||
| 798 | case SETUP_FAILED: | ||
| 799 | render_wifi_setup_result(); | ||
| 800 | break; | ||
| 801 | default: | ||
| 802 | break; | ||
| 803 | } | ||
| 804 | break; | 384 | break; |
| 805 | } | 385 | } |
| 806 | 386 | ||
| @@ -820,14 +400,6 @@ esp_err_t display_init(void) { | |||
| 820 | s_initialized = true; | 400 | s_initialized = true; |
| 821 | s_last_qr_switch = (int64_t)xTaskGetTickCount() * portTICK_PERIOD_MS; | 401 | s_last_qr_switch = (int64_t)xTaskGetTickCount() * portTICK_PERIOD_MS; |
| 822 | 402 | ||
| 823 | esp_err_t touch_ret = touch_init(); | ||
| 824 | if (touch_ret == ESP_OK) { | ||
| 825 | s_touch_initialized = true; | ||
| 826 | ESP_LOGI(TAG, "Touch controller initialized"); | ||
| 827 | } else { | ||
| 828 | ESP_LOGW(TAG, "Touch init failed (non-fatal): %s", esp_err_to_name(touch_ret)); | ||
| 829 | } | ||
| 830 | |||
| 831 | xTaskCreatePinnedToCore(display_task, "display", 24576, NULL, 2, NULL, 1); | 403 | xTaskCreatePinnedToCore(display_task, "display", 24576, NULL, 2, NULL, 1); |
| 832 | 404 | ||
| 833 | ESP_LOGI(TAG, "Display initialized"); | 405 | ESP_LOGI(TAG, "Display initialized"); |
| @@ -871,26 +443,8 @@ void display_notify_payment(int amount_sats, int64_t allotment_ms) { | |||
| 871 | } | 443 | } |
| 872 | 444 | ||
| 873 | void display_notify_wifi_connected(const char *ip) { | 445 | void display_notify_wifi_connected(const char *ip) { |
| 874 | if (s_wifi_setup_active && s_wifi_setup.state == SETUP_CONNECTING) { | 446 | (void)ip; |
| 875 | wifi_setup_handle_connect_result(&s_wifi_setup, true, ip); | ||
| 876 | if (s_kb_state.cursor > 0) { | ||
| 877 | tollgate_config_add_wifi(s_wifi_setup.selected_ssid, s_kb_state.input); | ||
| 878 | } | ||
| 879 | } | ||
| 880 | } | 447 | } |
| 881 | 448 | ||
| 882 | void display_notify_wifi_disconnected(void) { | 449 | void display_notify_wifi_disconnected(void) { |
| 883 | if (s_wifi_setup_active && s_wifi_setup.state == SETUP_CONNECTING) { | ||
| 884 | wifi_setup_handle_connect_result(&s_wifi_setup, false, NULL); | ||
| 885 | } | ||
| 886 | } | ||
| 887 | |||
| 888 | void display_enter_wifi_setup(void) { | ||
| 889 | if (!s_initialized) return; | ||
| 890 | enter_wifi_setup_rotation(); | ||
| 891 | s_wifi_setup_active = true; | ||
| 892 | wifi_setup_init(&s_wifi_setup); | ||
| 893 | kb_state_init(&s_kb_state); | ||
| 894 | s_wifi_scan_pending = true; | ||
| 895 | s_state = DISPLAY_WIFI_SETUP; | ||
| 896 | } | 450 | } |
diff --git a/main/display.h b/main/display.h index e8824b6..ecb76b6 100644 --- a/main/display.h +++ b/main/display.h | |||
| @@ -10,7 +10,7 @@ typedef enum { | |||
| 10 | DISPLAY_READY, | 10 | DISPLAY_READY, |
| 11 | DISPLAY_PAYMENT_RECEIVED, | 11 | DISPLAY_PAYMENT_RECEIVED, |
| 12 | DISPLAY_ERROR, | 12 | DISPLAY_ERROR, |
| 13 | DISPLAY_WIFI_SETUP | 13 | DISPLAY_SETUP_PENDING |
| 14 | } display_state_t; | 14 | } display_state_t; |
| 15 | 15 | ||
| 16 | typedef enum { | 16 | typedef enum { |
| @@ -27,7 +27,6 @@ void display_update(const char *ap_ssid, int active_clients, | |||
| 27 | void display_notify_payment(int amount_sats, int64_t allotment_ms); | 27 | void display_notify_payment(int amount_sats, int64_t allotment_ms); |
| 28 | void display_notify_wifi_connected(const char *ip); | 28 | void display_notify_wifi_connected(const char *ip); |
| 29 | void display_notify_wifi_disconnected(void); | 29 | void display_notify_wifi_disconnected(void); |
| 30 | void display_enter_wifi_setup(void); | ||
| 31 | void display_render_text(int x, int y, const char *text, uint16_t fg, uint16_t bg, int scale); | 30 | void display_render_text(int x, int y, const char *text, uint16_t fg, uint16_t bg, int scale); |
| 32 | void display_render_qr(const char *text); | 31 | void display_render_qr(const char *text); |
| 33 | 32 | ||
diff --git a/main/tollgate_main.c b/main/tollgate_main.c index 59d25f5..9421e16 100644 --- a/main/tollgate_main.c +++ b/main/tollgate_main.c | |||
| @@ -325,8 +325,11 @@ void app_main(void) | |||
| 325 | tcfg->mint_url, tcfg->price_per_step, "connecting..."); | 325 | tcfg->mint_url, tcfg->price_per_step, "connecting..."); |
| 326 | 326 | ||
| 327 | if (tollgate_config_get_wifi(&(wifi_config_t){0}) != ESP_OK) { | 327 | if (tollgate_config_get_wifi(&(wifi_config_t){0}) != ESP_OK) { |
| 328 | ESP_LOGI(TAG, "No STA network configured, entering WiFi setup"); | 328 | ESP_LOGI(TAG, "No STA network configured, starting setup mode"); |
| 329 | display_enter_wifi_setup(); | 329 | char portal_url[128]; |
| 330 | snprintf(portal_url, sizeof(portal_url), "http://%s/", tcfg->ap_ip_str); | ||
| 331 | display_update(NULL, 0, 0, portal_url, NULL, 0, "setup mode"); | ||
| 332 | display_set_state(DISPLAY_SETUP_PENDING); | ||
| 330 | xTaskCreate(services_start_task, "svc_start", 32768, NULL, 5, NULL); | 333 | xTaskCreate(services_start_task, "svc_start", 32768, NULL, 5, NULL); |
| 331 | } | 334 | } |
| 332 | 335 | ||
diff --git a/tests/integration/wifi_setup.mjs b/tests/integration/wifi_setup.mjs new file mode 100644 index 0000000..a991ba5 --- /dev/null +++ b/tests/integration/wifi_setup.mjs | |||
| @@ -0,0 +1,74 @@ | |||
| 1 | import { execSync } from 'child_process'; | ||
| 2 | |||
| 3 | const IP = process.env.TOLLGATE_IP || '10.192.45.1'; | ||
| 4 | |||
| 5 | console.log(`\n=== WiFi Setup Integration Test ===`); | ||
| 6 | console.log(`Portal IP: ${IP}\n`); | ||
| 7 | |||
| 8 | let passed = 0, failed = 0; | ||
| 9 | function assert(cond, msg) { | ||
| 10 | if (cond) { console.log(` PASS: ${msg}`); passed++; } | ||
| 11 | else { console.log(` FAIL: ${msg}`); failed++; } | ||
| 12 | } | ||
| 13 | |||
| 14 | function run(cmd) { | ||
| 15 | try { return execSync(cmd, { encoding: 'utf8', timeout: 15000 }); } | ||
| 16 | catch { return null; } | ||
| 17 | } | ||
| 18 | |||
| 19 | function fetchJSON(path) { | ||
| 20 | const result = run(`curl -s --connect-timeout 5 http://${IP}${path}`); | ||
| 21 | if (!result) return null; | ||
| 22 | try { return JSON.parse(result); } | ||
| 23 | catch { return null; } | ||
| 24 | } | ||
| 25 | |||
| 26 | // 1. /setup page returns HTML (or redirects if already configured) | ||
| 27 | const setupPage = run(`curl -s -o /dev/null -w "%{http_code}" --connect-timeout 5 http://${IP}/setup`); | ||
| 28 | assert(setupPage === '200' || setupPage === '302', `/setup returns 200 or 302 (got ${setupPage})`); | ||
| 29 | |||
| 30 | // 2. /wifi/status endpoint works | ||
| 31 | const status = fetchJSON('/wifi/status'); | ||
| 32 | assert(status !== null, '/wifi/status returns JSON'); | ||
| 33 | assert(typeof status.connected === 'boolean', '/wifi/status has connected field'); | ||
| 34 | |||
| 35 | // 3. /wifi/scan endpoint returns array | ||
| 36 | console.log('\n (wifi/scan may take a few seconds...)'); | ||
| 37 | const scanResult = run(`curl -s --connect-timeout 15 --max-time 15 http://${IP}/wifi/scan`); | ||
| 38 | let scanData = null; | ||
| 39 | if (scanResult) { | ||
| 40 | try { scanData = JSON.parse(scanResult); } catch {} | ||
| 41 | } | ||
| 42 | assert(scanData !== null, '/wifi/scan returns JSON'); | ||
| 43 | if (scanData && Array.isArray(scanData)) { | ||
| 44 | assert(scanData.length >= 0, `/wifi/scan returns array (${scanData.length} APs)`); | ||
| 45 | if (scanData.length > 0) { | ||
| 46 | const ap = scanData[0]; | ||
| 47 | assert(ap.ssid !== undefined, 'AP has ssid field'); | ||
| 48 | assert(ap.rssi !== undefined, 'AP has rssi field'); | ||
| 49 | assert(ap.secured !== undefined, 'AP has secured field'); | ||
| 50 | console.log(` First AP: "${ap.ssid}" (${ap.rssi} dBm, ${ap.secured ? 'secured' : 'open'})`); | ||
| 51 | } | ||
| 52 | } | ||
| 53 | |||
| 54 | // 4. /wifi/connect rejects invalid JSON | ||
| 55 | const badConnect = run(`curl -s -X POST -d 'not json' --connect-timeout 5 http://${IP}/wifi/connect`); | ||
| 56 | assert(badConnect !== null, '/wifi/connect responds to bad request'); | ||
| 57 | if (badConnect) { | ||
| 58 | try { | ||
| 59 | const err = JSON.parse(badConnect); | ||
| 60 | assert(err.ok === false, '/wifi/connect returns ok:false for bad request'); | ||
| 61 | } catch {} | ||
| 62 | } | ||
| 63 | |||
| 64 | // 5. /wifi/connect rejects missing ssid | ||
| 65 | const noSsid = run(`curl -s -X POST -H 'Content-Type: application/json' -d '{}' --connect-timeout 5 http://${IP}/wifi/connect`); | ||
| 66 | if (noSsid) { | ||
| 67 | try { | ||
| 68 | const err = JSON.parse(noSsid); | ||
| 69 | assert(err.ok === false && err.error, '/wifi/connect returns error for missing ssid'); | ||
| 70 | } catch {} | ||
| 71 | } | ||
| 72 | |||
| 73 | console.log(`\n=== Results: ${passed} passed, ${failed} failed ===`); | ||
| 74 | process.exit(failed > 0 ? 1 : 0); | ||