upleb.uk

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

summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorYour Name <you@example.com>2026-05-19 04:21:14 +0530
committerYour Name <you@example.com>2026-05-19 04:21:14 +0530
commitaa58b47996083f36e3587b8e10f9bbb681610491 (patch)
tree4c845da2ee4b217a2c77deea3b10dd8d288d1b09
parent9f7dd94029c8dc12117494548f5f32221a729307 (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.md72
-rw-r--r--components/axs15231b/axs15231b.c37
-rw-r--r--components/axs15231b/include/axs15231b.h2
-rw-r--r--main/CMakeLists.txt3
-rw-r--r--main/captive_portal.c338
-rw-r--r--main/captive_portal.h1
-rw-r--r--main/display.c654
-rw-r--r--main/display.h3
-rw-r--r--main/tollgate_main.c7
-rw-r--r--tests/integration/wifi_setup.mjs74
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
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 | "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;
37static uint16_t *s_fb = NULL; 37static uint16_t *s_fb = NULL;
38static int s_width = AXS15231B_WIDTH; 38static int s_width = AXS15231B_WIDTH;
39static int s_height = AXS15231B_HEIGHT; 39static int s_height = AXS15231B_HEIGHT;
40static int s_rotation = 0; 40static int s_stride = AXS15231B_WIDTH;
41static int s_stride = 480;
42static uint8_t *s_swap_buf = NULL; 41static 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
360int axs15231b_get_width(void) { return s_width; } 358int axs15231b_get_width(void) { return s_width; }
361int axs15231b_get_height(void) { return s_height; } 359int axs15231b_get_height(void) { return s_height; }
362
363void 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);
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);
26void 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
15static const char *TAG = "captive_portal"; 17static const char *TAG = "captive_portal";
16static httpd_handle_t s_server = NULL; 18static httpd_handle_t s_server = NULL;
@@ -303,6 +305,338 @@ static const httpd_uri_t uri_connecttest = { .uri = "/connecttest.txt", .method
303static const httpd_uri_t uri_wpad = { .uri = "/wpad.dat", .method = HTTP_GET, .handler = redirect_to_portal_handler }; 305static const httpd_uri_t uri_wpad = { .uri = "/wpad.dat", .method = HTTP_GET, .handler = redirect_to_portal_handler };
304static const httpd_uri_t uri_catchall = { .uri = "/*", .method = HTTP_GET, .handler = catchall_handler }; 306static const httpd_uri_t uri_catchall = { .uri = "/*", .method = HTTP_GET, .handler = catchall_handler };
305 307
308static 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>&#128274;</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
425static 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
459static bool is_setup_available(void) {
460 const tollgate_config_t *cfg = tollgate_config_get();
461 return cfg->network_count == 0;
462}
463
464static 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
485static 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
544static 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
607static 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
635static const httpd_uri_t uri_setup = { .uri = "/setup", .method = HTTP_GET, .handler = setup_page_handler };
636static const httpd_uri_t uri_wifi_scan = { .uri = "/wifi/scan", .method = HTTP_GET, .handler = wifi_scan_handler };
637static const httpd_uri_t uri_wifi_connect = { .uri = "/wifi/connect", .method = HTTP_POST, .handler = wifi_connect_handler };
638static const httpd_uri_t uri_wifi_status = { .uri = "/wifi/status", .method = HTTP_GET, .handler = wifi_status_handler };
639
306esp_err_t captive_portal_start(const char *ap_ip_str) 640esp_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 @@
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/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;
43static int s_last_payment_sats = 0; 40static int s_last_payment_sats = 0;
44static int64_t s_last_allotment_ms = 0; 41static int64_t s_last_allotment_ms = 0;
45 42
46#define COLOR_GRAY 0x8410 43static 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
51static wifi_setup_t s_wifi_setup;
52static kb_state_t s_kb_state;
53static bool s_wifi_setup_active = false;
54static bool s_touch_initialized = false;
55static bool s_wifi_scan_pending = false;
56static 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
63static 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
80static 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
97static 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
104static 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
108static 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
116static int qr_version_from_strlen(int len) { 49static 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
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
153static void build_wifi_qr_string(char *out, int out_size) { 96static 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
159static void extract_domain(const char *url, char *domain, int domain_size) { 109static 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
169void 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) {
@@ -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
202static 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
239void display_render_qr(const char *text) { 175void 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
247static 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
253static void render_boot_screen(void) { 183static 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
251static 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
387static 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
402static 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
448static 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
534static 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
552static 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
588static 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
681static void display_task(void *pvParameters) { 353static 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
873void display_notify_wifi_connected(const char *ip) { 445void 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
882void display_notify_wifi_disconnected(void) { 449void 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
888void 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
16typedef enum { 16typedef enum {
@@ -27,7 +27,6 @@ void display_update(const char *ap_ssid, int active_clients,
27void display_notify_payment(int amount_sats, int64_t allotment_ms); 27void display_notify_payment(int amount_sats, int64_t allotment_ms);
28void display_notify_wifi_connected(const char *ip); 28void display_notify_wifi_connected(const char *ip);
29void display_notify_wifi_disconnected(void); 29void display_notify_wifi_disconnected(void);
30void display_enter_wifi_setup(void);
31void 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);
32void display_render_qr(const char *text); 31void 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 @@
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);