diff --git a/.pi/skills/test-e32r40t-firmware/SKILL.md b/.pi/skills/test-e32r40t-firmware/SKILL.md index ee8e2ff..3da02ff 100644 --- a/.pi/skills/test-e32r40t-firmware/SKILL.md +++ b/.pi/skills/test-e32r40t-firmware/SKILL.md @@ -81,6 +81,10 @@ title 8px tall, buttons 24px tall) rows land at y=28, 60 and 92. Use `touch-hold` plus `capture` plus `touch-release` to photograph a pressed button: `on_press` fires on release, and the pressed style is only visible mid-gesture. +Wi-Fi testing uses the emulator's single open `qemu` AP. In Settings, scan and tap +`qemu`; success shows DHCP address `192.168.4.15`. The AP uses the same QEMU +user-mode NAT backend as Xteink, so it has outbound internet access. + When the firmware runs rotated, convert the UI position `(sx, sy)` to the physical tap: | `rotation` | physical x | physical y | diff --git a/README.md b/README.md index 2d54c53..7473ffa 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,7 @@ Apps define optional callbacks: `setup()`, `draw()` (~30fps cap), `on_touch_down | `input` | `getTouch()` -> `x,y` or nil, `getRawTouch()` -> raw ADC `x,y` or nil, `touched()` | | `fs` | `readFile(path)`, `writeFile(path, data)`, `exists(path)`, `listFiles(path)`, `listDirs(path)` | | `sys` | `millis()`, `exit()`, `launch(path)`, `setCalibration(x0,y0,x1,y1)`, `getRotation()`, `setRotation(deg)` | +| `wifi` | `scan()` -> `{ssid,rssi,secure}[]`, `connect(ssid,password)`, `status()` -> `{state,ssid,ip,rssi}`, `forget()` | | `log` | `info(msg)` (serial) | Colors are RGB565 integers; build them with `gui.color(r, g, b)`. @@ -45,12 +46,17 @@ Lua, so it needs no parser on either side: return { rotation = 90, touch = { x0 = 188, y0 = 232, x1 = 3799, y1 = 3800 }, + wifi = { ssid = "network", password = "secret" }, } ``` Missing file means the built-in defaults are used. `apps/settings` walks two -crosshairs and saves the result via `sys.setCalibration()`, and cycles `rotation` -through 0, 90, 180 and 270 degrees. +crosshairs and saves the result via `sys.setCalibration()`, cycles `rotation` +through 0, 90, 180 and 270 degrees, and scans/selects Wi-Fi networks with an +on-screen password keyboard. A saved network reconnects at boot. + +Wi-Fi credentials are Lua-escaped but stored as plaintext on the SD card. Treat the +card like any other device containing a saved password. Rotation never needs a recalibration: calibration is stored in the panel's rotation-0 frame (320x480 raw ADC space) and the current rotation is applied diff --git a/sdcard/apps/settings/main.lua b/sdcard/apps/settings/main.lua index 6f6ec08..ec128af 100644 --- a/sdcard/apps/settings/main.lua +++ b/sdcard/apps/settings/main.lua @@ -4,13 +4,15 @@ TICK_MS = 50 local INSET = 30 local BLACK, WHITE, RED = gui.color(0, 0, 0), gui.color(255, 255, 255), gui.color(255, 0, 0) -local ACCENT = gui.color(0, 120, 255) +local ACCENT, MUTED = gui.color(0, 120, 255), gui.color(100, 100, 100) local screen, message local mode = "menu" local samples, pending, armed = {}, nil, false +local scanRequested, selectedNetwork, password = false, nil, "" +local keyboardPage = "lower" -local buildMenu, startCalibration, cycleRotation +local buildMenu, buildWifi, buildNetworks, buildKeyboard, startCalibration, cycleRotation -- Two inset targets give a raw-per-pixel slope; extrapolate it to the screen edges. -- Exposed as a global so test/settings_calibration.lua can exercise it. @@ -21,8 +23,13 @@ function computeCalibration(s1, s2, w, h, inset) math.floor(s2.x + sx * inset), math.floor(s2.y + sy * inset) end +local function themed(items) + items.color, items.bg = BLACK, WHITE + items.press_bg, items.press_color = ACCENT, WHITE + screen = ui.screen(ui.box(items)) +end + local function target(n) - -- Calibration always runs in the rotation-0 frame, so use panel geometry. if n == 1 then return INSET, INSET end return 320 - INSET, 480 - INSET end @@ -54,22 +61,149 @@ end function cycleRotation() local ok = sys.setRotation((sys.getRotation() + 90) % 360) message = ok and "rotation saved" or "save failed" - buildMenu() -- the frame changed size, so lay out again + buildMenu() +end + +local function statusLabel(status) + if status.state == "connected" then return "wifi: " .. status.ssid end + if status.ssid ~= "" then return "wifi: " .. status.state end + return "wifi: not configured" end function buildMenu() + mode = "menu" local items = { pad = 12, gap = 8, ui.text("settings"), ui.button{label = "calibrate touch", on_press = startCalibration}, ui.button{label = "rotation: " .. sys.getRotation() .. " deg", on_press = cycleRotation}, + ui.button{label = statusLabel(wifi.status()), on_press = buildWifi}, ui.button{label = "exit", on_press = sys.exit}, } if message then items[#items + 1] = ui.text(message) end - items.color, items.bg = BLACK, WHITE - items.press_bg, items.press_color = ACCENT, WHITE - screen = ui.screen(ui.box(items)) + themed(items) +end + +local function requestScan() + mode = "scanning" + scanRequested = true + themed{pad = 12, gap = 8, ui.text("wifi"), ui.text("scanning...")} +end + +local function forgetNetwork() + message = wifi.forget() and "wifi forgotten" or "save failed" + buildWifi() +end + +function buildWifi() + mode = "wifi" + local status = wifi.status() + local items = {pad = 12, gap = 8, ui.text("wifi"), ui.text(statusLabel(status))} + if status.state == "connected" then + items[#items + 1] = ui.text("ip: " .. status.ip) + end + items[#items + 1] = ui.button{label = "scan networks", on_press = requestScan} + if status.ssid ~= "" then + items[#items + 1] = ui.button{label = "forget network", on_press = forgetNetwork} + end + items[#items + 1] = ui.button{label = "back", on_press = buildMenu} + themed(items) +end + +local function connectSelected() + if not wifi.connect(selectedNetwork.ssid, password) then + message = "could not save wifi" + buildWifi() + return + end + mode = "connecting" + themed{pad = 12, gap = 8, + ui.text("wifi"), + ui.text("connecting to " .. selectedNetwork.ssid .. "..."), + ui.button{label = "back", on_press = buildWifi}, + } +end + +local function chooseNetwork(network) + selectedNetwork, password = network, "" + if network.secure then buildKeyboard() else connectSelected() end +end + +function buildNetworks(networks) + mode = "networks" + local byName = {} + for _, network in ipairs(networks) do + local current = byName[network.ssid] + if network.ssid ~= "" and (not current or network.rssi > current.rssi) then + byName[network.ssid] = network + end + end + local list = {} + for _, network in pairs(byName) do list[#list + 1] = network end + table.sort(list, function(a, b) return a.rssi > b.rssi end) + + local items = {pad = 12, gap = 6, ui.text("wifi networks")} + for i = 1, math.min(#list, 6) do + local network = list[i] + local lock = network.secure and " *" or "" + items[#items + 1] = ui.button{ + label = network.ssid .. lock .. " " .. network.rssi, + on_press = function() chooseNetwork(network) end, + } + end + if #list == 0 then items[#items + 1] = ui.text("no networks found", {color = MUTED}) end + items[#items + 1] = ui.button{label = "rescan", on_press = requestScan} + items[#items + 1] = ui.button{label = "back", on_press = buildWifi} + themed(items) +end + +local function keyButton(label, value, width) + return ui.button{label = label, w = width or 24, h = 28, pad = 4, + on_press = function() + if #password < 64 then password = password .. value end + buildKeyboard() + end, + } +end + +local function keyRow(chars) + local row = {row = true, h = 28, gap = 4} + for char in chars:gmatch(".") do row[#row + 1] = keyButton(char, char) end + return ui.box(row) +end + +function buildKeyboard() + mode = "password" + local shown = string.rep("*", #password) + local rows + if keyboardPage == "symbols" then + rows = {"!@#$%^&*()", "-_=+[]{}", "`~;:'\",.?", "/\\|<>"} + else + rows = {"1234567890", "qwertyuiop", "asdfghjkl", "zxcvbnm,.?"} + if keyboardPage == "upper" then + for i, row in ipairs(rows) do rows[i] = row:upper() end + end + end + local controls = {row = true, h = 28, gap = 4, + ui.button{label = keyboardPage, w = 48, h = 28, pad = 4, + on_press = function() + keyboardPage = keyboardPage == "lower" and "upper" or + (keyboardPage == "upper" and "symbols" or "lower") + buildKeyboard() + end}, + ui.button{label = "backspace", w = 68, h = 28, pad = 4, + on_press = function() password = password:sub(1, -2); buildKeyboard() end}, + keyButton("space", " ", 40), + ui.button{label = "connect", w = 60, h = 28, pad = 4, on_press = connectSelected}, + ui.button{label = "cancel", w = 52, h = 28, pad = 4, on_press = buildWifi}, + } + themed{pad = 8, gap = 5, + ui.text(selectedNetwork.ssid), + ui.text("password: " .. shown), + keyRow(rows[1]), keyRow(rows[2]), keyRow(rows[3]), keyRow(rows[4]), + ui.box(controls), + } end function setup() @@ -77,19 +211,36 @@ function setup() end function draw() - if mode == "menu" then screen:draw() end + if mode ~= "calibrate" then screen:draw() end end function on_touch_down(x, y) - if mode == "menu" then screen:down(x, y) end + if mode ~= "calibrate" then screen:down(x, y) end end function on_touch_up(x, y) - if mode == "menu" then screen:up(x, y) end + if mode ~= "calibrate" then screen:up(x, y) end end function on_tick() + if scanRequested then + scanRequested = false + buildNetworks(wifi.scan()) + return + end + if mode == "connecting" then + local status = wifi.status() + if status.state == "connected" then + message = "connected: " .. status.ip + buildWifi() + elseif status.state == "failed" or status.state == "not_found" then + message = "connection " .. status.state + buildWifi() + end + return + end if mode ~= "calibrate" then return end + local rx, ry = input.getRawTouch() if not armed then if not rx then armed = true end @@ -100,10 +251,6 @@ function on_tick() elseif pending then samples[#samples + 1] = pending pending = nil - if #samples == 1 then - drawTarget(2) - else - finishCalibration() - end + if #samples == 1 then drawTarget(2) else finishCalibration() end end end diff --git a/src/lua/lua_app.cpp b/src/lua/lua_app.cpp index abc38b3..29a2370 100644 --- a/src/lua/lua_app.cpp +++ b/src/lua/lua_app.cpp @@ -1,6 +1,7 @@ #include "lua_app.h" #include +#include #include #include "../settings.h" @@ -11,6 +12,7 @@ extern "C" { } static LuaApp* self; +static uint32_t wifiConnectStartedAt; static LuaApp* app(lua_State* L) { return self; } @@ -301,6 +303,87 @@ static int l_fs_listFiles(lua_State* L) { return 1; } +// ---- wifi ---- + +static int l_wifi_scan(lua_State* L) { + WiFi.mode(WIFI_STA); + int count = WiFi.scanNetworks(false, true); + lua_newtable(L); + if (count < 0) return 1; + for (int i = 0; i < count; i++) { + lua_newtable(L); + lua_pushstring(L, WiFi.SSID(i).c_str()); + lua_setfield(L, -2, "ssid"); + lua_pushinteger(L, WiFi.RSSI(i)); + lua_setfield(L, -2, "rssi"); + lua_pushboolean(L, WiFi.encryptionType(i) != WIFI_AUTH_OPEN); + lua_setfield(L, -2, "secure"); + lua_rawseti(L, -2, i + 1); + } + WiFi.scanDelete(); + return 1; +} + +static int l_wifi_connect(lua_State* L) { + size_t ssidLength, passwordLength; + const char* ssid = luaL_checklstring(L, 1, &ssidLength); + const char* password = luaL_optlstring(L, 2, "", &passwordLength); + if (!ssidLength || ssidLength > 32 || passwordLength > 64) { + lua_pushboolean(L, false); + return 1; + } + settings.wifiSsid = String(ssid, ssidLength); + settings.wifiPassword = String(password, passwordLength); + if (!settings.save()) { + lua_pushboolean(L, false); + return 1; + } + WiFi.mode(WIFI_STA); + WiFi.begin(settings.wifiSsid.c_str(), settings.wifiPassword.c_str()); + wifiConnectStartedAt = millis(); + lua_pushboolean(L, true); + return 1; +} + +static int l_wifi_status(lua_State* L) { + wl_status_t status = WiFi.status(); + if (!wifiConnectStartedAt && settings.wifiSsid.length() && status != WL_CONNECTED) { + wifiConnectStartedAt = millis(); + } + const char* state = "disconnected"; + if (status == WL_CONNECTED) { + state = "connected"; + wifiConnectStartedAt = 0; + } else if (status == WL_NO_SSID_AVAIL) { + state = "not_found"; + } else if (status == WL_CONNECT_FAILED || + (wifiConnectStartedAt && millis() - wifiConnectStartedAt >= 15000)) { + state = "failed"; + } else if (wifiConnectStartedAt) { + state = "connecting"; + } + + lua_newtable(L); + lua_pushstring(L, state); + lua_setfield(L, -2, "state"); + lua_pushstring(L, status == WL_CONNECTED ? WiFi.SSID().c_str() : settings.wifiSsid.c_str()); + lua_setfield(L, -2, "ssid"); + lua_pushstring(L, status == WL_CONNECTED ? WiFi.localIP().toString().c_str() : ""); + lua_setfield(L, -2, "ip"); + lua_pushinteger(L, status == WL_CONNECTED ? WiFi.RSSI() : 0); + lua_setfield(L, -2, "rssi"); + return 1; +} + +static int l_wifi_forget(lua_State* L) { + WiFi.disconnect(true, false); + settings.wifiSsid = ""; + settings.wifiPassword = ""; + wifiConnectStartedAt = 0; + lua_pushboolean(L, settings.save()); + return 1; +} + // ---- log ---- static int l_log(lua_State* L) { @@ -506,6 +589,11 @@ void LuaApp::registerBindings() { static const luaL_Reg fsLib[] = {{"readFile", l_fs_readFile}, {"writeFile", l_fs_writeFile}, {"exists", l_fs_exists}, {"listFiles", l_fs_listFiles}, {"listDirs", l_fs_listDirs}, {nullptr, nullptr}}; + static const luaL_Reg wifiLib[] = {{"scan", l_wifi_scan}, + {"connect", l_wifi_connect}, + {"status", l_wifi_status}, + {"forget", l_wifi_forget}, + {nullptr, nullptr}}; static const luaL_Reg logLib[] = {{"info", l_log}, {nullptr, nullptr}}; luaL_newlib(state, sysLib); @@ -516,6 +604,8 @@ void LuaApp::registerBindings() { lua_setglobal(state, "input"); luaL_newlib(state, fsLib); lua_setglobal(state, "fs"); + luaL_newlib(state, wifiLib); + lua_setglobal(state, "wifi"); luaL_newlib(state, logLib); lua_setglobal(state, "log"); } diff --git a/src/main.cpp b/src/main.cpp index 903b711..ed17c7d 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1,6 +1,7 @@ #include #include #include +#include #include #include "lua/lua_app.h" @@ -58,6 +59,11 @@ void setup() { return; } settings.load(); // absent file keeps the built-in defaults + WiFi.persistent(false); + if (settings.wifiSsid.length()) { + WiFi.mode(WIFI_STA); + WiFi.begin(settings.wifiSsid.c_str(), settings.wifiPassword.c_str()); + } startApp(LAUNCHER); } diff --git a/src/settings.cpp b/src/settings.cpp index bd5cd6f..dc0c146 100644 --- a/src/settings.cpp +++ b/src/settings.cpp @@ -10,6 +10,8 @@ extern "C" { Settings settings; static constexpr const char* PATH = "/settings.lua"; +static constexpr const char* TEMP_PATH = "/settings.tmp"; +static constexpr const char* BACKUP_PATH = "/settings.bak"; static int16_t fieldOr(lua_State* L, const char* key, int16_t fallback) { lua_getfield(L, -1, key); @@ -18,6 +20,32 @@ static int16_t fieldOr(lua_State* L, const char* key, int16_t fallback) { return value; } +static String stringFieldOr(lua_State* L, const char* key, const String& fallback) { + lua_getfield(L, -1, key); + String value = lua_isstring(L, -1) ? lua_tostring(L, -1) : fallback; + lua_pop(L, 1); + return value; +} + +static String luaString(const String& value) { + String out = "\""; + for (size_t i = 0; i < value.length(); i++) { + unsigned char c = value[i]; + if (c == '\\' || c == '\"') { + out += '\\'; + out += (char)c; + } else if (c >= 32 && c < 127) { + out += (char)c; + } else { + char escaped[5]; + snprintf(escaped, sizeof(escaped), "\\%03u", c); + out += escaped; + } + } + out += '\"'; + return out; +} + bool Settings::setRotation(int16_t degrees) { if (degrees % 90 != 0 || degrees < 0 || degrees > 270) return false; rotation = degrees; @@ -44,20 +72,40 @@ bool Settings::load() { touchY1 = fieldOr(L, "y1", touchY1); } lua_pop(L, 1); + lua_getfield(L, -1, "wifi"); + if (lua_istable(L, -1)) { + wifiSsid = stringFieldOr(L, "ssid", wifiSsid); + wifiPassword = stringFieldOr(L, "password", wifiPassword); + } + lua_pop(L, 1); } lua_close(L); return ok; } bool Settings::save() const { - File f = SD.open(PATH, FILE_WRITE); + String source = "return {\n rotation = " + String(rotation) + ",\n"; + source += " touch = { x0 = " + String(touchX0) + ", y0 = " + String(touchY0) + + ", x1 = " + String(touchX1) + ", y1 = " + String(touchY1) + " },\n"; + source += " wifi = { ssid = " + luaString(wifiSsid) + + ", password = " + luaString(wifiPassword) + " },\n}\n"; + + if (SD.exists(TEMP_PATH)) SD.remove(TEMP_PATH); + File f = SD.open(TEMP_PATH, FILE_WRITE); if (!f) return false; - char buf[200]; - int len = snprintf(buf, sizeof(buf), - "return {\n rotation = %d,\n" - " touch = { x0 = %d, y0 = %d, x1 = %d, y1 = %d },\n}\n", - rotation, touchX0, touchY0, touchX1, touchY1); - bool ok = f.write(reinterpret_cast(buf), len) == (size_t)len; + bool ok = f.write(reinterpret_cast(source.c_str()), source.length()) == source.length(); f.close(); - return ok; + if (!ok) { + SD.remove(TEMP_PATH); + return false; + } + if (SD.exists(BACKUP_PATH)) SD.remove(BACKUP_PATH); + bool hadSettings = SD.exists(PATH); + if (hadSettings && !SD.rename(PATH, BACKUP_PATH)) return false; + if (SD.rename(TEMP_PATH, PATH)) { + if (hadSettings) SD.remove(BACKUP_PATH); + return true; + } + if (hadSettings) SD.rename(BACKUP_PATH, PATH); + return false; } diff --git a/src/settings.h b/src/settings.h index 0be4401..3ea5a12 100644 --- a/src/settings.h +++ b/src/settings.h @@ -1,5 +1,6 @@ #pragma once +#include #include // Persisted as a Lua table literal in /settings.lua: the firmware already links @@ -8,6 +9,8 @@ struct Settings { int16_t touchX0 = 200, touchY0 = 240, touchX1 = 3800, touchY1 = 3800; // Degrees clockwise, stored for humans editing the file; TFT_eSPI wants 0-3. int16_t rotation = 0; + String wifiSsid; + String wifiPassword; uint8_t rotationIndex() const { return (rotation / 90) & 3; } bool setRotation(int16_t degrees); diff --git a/test/settings_calibration.lua b/test/settings_calibration.lua index c32bf5d..94d741b 100644 --- a/test/settings_calibration.lua +++ b/test/settings_calibration.lua @@ -5,6 +5,8 @@ package.path = "sdcard/lib/?.lua;" .. package.path local saved local rotation = 0 local now = 0 +local scanned = {} +local connected gui = { color = function() return 0 end, @@ -29,6 +31,12 @@ sys = { setRotation = function(degrees) rotation = degrees return true end, } input = {getRawTouch = function() return nil end} +wifi = { + status = function() return {state = "disconnected", ssid = "", ip = "", rssi = 0} end, + scan = function() return scanned end, + connect = function(ssid, password) connected = {ssid, password} return true end, + forget = function() return true end, +} log = {info = function() end} dofile("sdcard/apps/settings/main.lua") @@ -83,4 +91,24 @@ on_tick() assert(saved, "calibration was not saved") assert(math.abs(saved[1] - 200) <= 1, "saved x0 " .. saved[1]) +-- Open networks connect directly from scan results. +setup() +scanned = {{ssid = "qemu", rssi = -25, secure = false}} +tap{x = 100, y = 104} -- wifi + tap{x = 100, y = 56} -- scan +on_tick() +tap{x = 100, y = 38} -- qemu +assert(connected and connected[1] == "qemu" and connected[2] == "", "open wifi connect") + +-- Secure networks route through the keyboard and preserve typed punctuation. +setup() +scanned = {{ssid = "secure", rssi = -40, secure = true}} +tap{x = 100, y = 104} +tap{x = 100, y = 56} +on_tick() +tap{x = 100, y = 38} +tap{x = 12, y = 80} -- q + tap{x = 200, y = 180} -- connect +assert(connected[1] == "secure" and connected[2] == "q", "secure wifi password") + print("ok")