diff --git a/AGENTS.md b/AGENTS.md index 2aecc81..9391964 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,7 +26,8 @@ an accessor without a prefix is not. Namespaces have one job each: `gui` device primitives and the live frame, `node` the widget tree, `ui` widgets and palette, `settings` persisted preferences, `sys` process and runtime, -plus `wifi`/`http`/`fs`/`input`/`log`. +plus `wifi`/`http`/`fs`/`input`/`log`. `ble` uses NimBLE-Arduino because Bluedroid cannot +initialize beside the Lua runtime; reserve `bt` and its full-ESP32 backend for future Classic Bluetooth. **A setter that requires a follow-up call is a bug in the setter.** `settings.setTimezone()` applies the TZ itself; `settings.setRotation()` applies the frame and re-clips. The one @@ -39,7 +40,8 @@ the panel transiently (touch calibration does). Never resolve one from the other Settings live in C++ (`src/settings.h`) because the firmware reads rotation and calibration before any `lua_State` exists, and calibration again on every touch. Lua reaches them through -bindings so there is one writer. +bindings so there is one writer. WiFi starts from saved credentials, syncs the clock, then +`src/net.cpp` stops SNTP and powers it off; an explicit `wifi.connect()` re-enables it. ## The Widget Tree diff --git a/Makefile b/Makefile index 63a045f..e6dd170 100644 --- a/Makefile +++ b/Makefile @@ -6,7 +6,7 @@ CXX ?= c++ CXXFLAGS ?= -std=c++11 -Wall -Wextra BUILD_DIR := .pio/build/esp32-32e SHARED_LUA := lib/esp32-lua-api/lua/lib -LUA_TESTS := test/keyboard.lua test/settings_calibration.lua test/statusbar_dirty.lua test/settings_busy.lua +LUA_TESTS := test/keyboard.lua test/settings_calibration.lua test/statusbar_dirty.lua test/settings_busy.lua test/ble.lua .PHONY: test test-lua test-cpp test-shared sdcard compiledb build upload monitor clean diff --git a/docs/lua-api-parity.md b/docs/lua-api-parity.md index 8db4acb..1de809e 100644 --- a/docs/lua-api-parity.md +++ b/docs/lua-api-parity.md @@ -11,7 +11,7 @@ Compared against crosspoint-reader at `src/util/lua/LuaBindings*.cpp`. `fs.listDirs`, `fs.listFiles`, `fs.exists`, `fs.readFile`, `fs.writeFile`, `gui.getWidth`, `gui.getHeight`, `gui.fillRect`, `gui.drawRect`, `gui.drawLine`, -`sys.getMillis`, `sys.delay`, `log.debug/info/error`, the whole `http` table, +`sys.getMillis`, `sys.delay`, `log.debug/info/error`, the whole `http` and `ble` tables, `sys.setTickInterval`, `wifi.getStatus`, `wifi.isConnected`, `wifi.getLocalIP`, and the `draw()` / `on_tick()` callbacks. `init()` is required in both, so a misspelled entry point is an error rather than an app that quietly draws nothing. @@ -37,7 +37,6 @@ strength a status screen wants. Its `state` vocabulary is a subset: crosspoint r | Clock | nothing exposed; UTC offset is a C++ setting | `sys.isClockSynced`, `settings.getTimezone/setTimezone` with POSIX TZ rules | Timezone here is a stored rule, so `os.date()` returns local time with DST handled by libc. | | Launching | launcher is C++ | `sys.launch(path)`, home is a Lua app | The launcher is just another app here, named `home`. | | Modules | single-file apps; `require` unusable | `require` works, `package.searchers` reads the SD card, `/lib` on the path | Shared code such as `ui.lua` needs it. **crosspoint should adopt this.** | -| BLE | `ble.*` | none | No BLE use case here yet. | | TLS memory | `TlsScratchLoan` lends the framebuffer to wolfSSL | none | crosspoint is heap-starved; this device has ~280KB free. | ## Accidental differences — drift, not design diff --git a/lib/esp32-lua-api b/lib/esp32-lua-api index fa35c3c..bf286ee 160000 --- a/lib/esp32-lua-api +++ b/lib/esp32-lua-api @@ -1 +1 @@ -Subproject commit fa35c3c3262bf5270793e9f7c41938180fcb1471 +Subproject commit bf286eefa69d1ac37dc54a82d5a4df5b3422b57e diff --git a/platformio.ini b/platformio.ini index e7a7277..414c9f7 100644 --- a/platformio.ini +++ b/platformio.ini @@ -34,4 +34,5 @@ build_flags = lib_deps = bodmer/TFT_eSPI@^2.5.43 + h2zero/NimBLE-Arduino@2.5.1 https://github.com/PaulStoffregen/XPT2046_Touchscreen.git diff --git a/sdcard/.lua/apps/Settings/main.lua b/sdcard/.lua/apps/Settings/main.lua index 0248d79..b6f3398 100644 --- a/sdcard/.lua/apps/Settings/main.lua +++ b/sdcard/.lua/apps/Settings/main.lua @@ -11,10 +11,14 @@ local screen, message, passwordLabel, passwordRow local mode = "menu" local samples, pending, armed = {}, nil, false local scanRequested, keyboardRequested = false, false +local bleScanRequested, bleConnectRequested = false, false local selectedNetwork, password = nil, "" +local selectedBleDevice +local bleMessage local timezoneCard -local buildMenu, buildWifi, buildNetworks, buildKeyboard, startCalibration, cycleRotation, updatePassword +local buildMenu, buildWifi, buildNetworks, buildKeyboard, buildBle, buildBleDevices +local startCalibration, cycleRotation, updatePassword -- 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. @@ -118,9 +122,7 @@ local function cycleTimezone() end local function wifiValue(status) - if status.state == "connected" then return status.ssid end - if status.ssid ~= "" then return status.state end - return "not set" + return status.state == "connected" and "on" or "off" end local function card(side, title, value, on_click) @@ -128,7 +130,7 @@ local function card(side, title, value, on_click) w = side, h = side, gap = 6, justify = "center", align = "center", on_click = on_click, - ui.label(title, {font = gui.FONT_SMALL, fit = side - 12}), + ui.label(title, {font = gui.FONT_UI, fit = side - 12}), } local valueLabel if value then @@ -144,11 +146,12 @@ function buildMenu() mode = "menu" -- No title: the status bar already names the running app. The status line under the -- grid is worth its rows, so the cards give it room rather than pushing it off screen. - local side, cols = ui.cardSide(5, MENU_PAD, MENU_GAP, message and 20 or 0) + local side, cols = ui.cardSide(6, MENU_PAD, MENU_GAP, message and 20 or 0) local cards = { card(side, "Calibrate", "touch", startCalibration), card(side, "Rotation", settings.getRotation() .. " deg", cycleRotation), card(side, "WiFi", wifiValue(wifi.getStatus()), buildWifi), + card(side, "BLE", ble.isInitialized() and "on" or "off", buildBle), card(side, "Theme", ui.getTheme(), cycleTheme), card(side, "Timezone", zoneLabel(), cycleTimezone), } @@ -201,13 +204,39 @@ local function confirmForget() buildWifi() end +local function connectSavedNetwork() + if not wifi.connect() then + message = "could not connect wifi" + buildWifi() + return + end + mode = "connecting" + themed{pad = 12, gap = 8, + ui.text("wifi"), + ui.text("connecting..."), + ui.button{label = "back", on_click = buildWifi}, + } +end + +local function disconnectWifi() + wifi.disconnect() + message = "wifi off" + buildWifi() +end + function buildWifi() mode = "wifi" local status = wifi.getStatus() - local items = {pad = 12, gap = 8, ui.text("wifi"), ui.text("wifi: " .. wifiValue(status))} + local items = {pad = 12, gap = 8, ui.text("wifi"), ui.text(wifiValue(status))} if status.state == "connected" then items[#items + 1] = ui.text("ip: " .. status.ip) end + if status.ssid ~= "" then + items[#items + 1] = ui.button{ + label = status.state == "connected" and "disconnect" or "connect", + on_click = status.state == "connected" and disconnectWifi or connectSavedNetwork, + } + end items[#items + 1] = ui.button{label = "scan networks", on_click = requestScan} if status.ssid ~= "" then items[#items + 1] = ui.button{label = "forget network", on_click = confirmForget} @@ -280,6 +309,92 @@ function buildNetworks(networks) themed(items) end +local bleDeviceOf = {} + +local function enableBle() + local ok, err = ble.init("Slate32 BLE") + bleMessage = ok and "BLE on" or err + buildBle() +end + +local function disableBle() + ble.deinit() + bleMessage = "BLE off" + buildBle() +end + +local function disconnectBle() + ble.disconnect() + bleMessage = "BLE disconnected" + buildBle() +end + +local function startBleAdvertising() + local ok, err = ble.startAdvertising("Slate32 BLE") + bleMessage = ok and "BLE advertising" or err + buildBle() +end + +local function stopBleAdvertising() + ble.stopAdvertising() + bleMessage = "BLE advertising stopped" + buildBle() +end + +local function requestBleScan() + mode = "ble_scanning" + bleScanRequested = true + busy("scanning BLE...") +end + +local function selectBleDevice(id) + selectedBleDevice = bleDeviceOf[id] + mode = "ble_connecting" + bleConnectRequested = true + busy("connecting BLE...") +end + +function buildBleDevices(devices) + mode = "ble_devices" + bleDeviceOf = {} + local items = {pad = 12, gap = 6, ui.text("BLE devices")} + for index = 1, math.min(#devices, 6) do + local device = devices[index] + local name = device.name ~= "" and device.name or device.address + if #name > 24 then name = name:sub(1, 24) end + local button = ui.button{ + label = name .. " " .. device.rssi, + on_click = selectBleDevice, + } + bleDeviceOf[button] = device + items[#items + 1] = button + end + if #devices == 0 then items[#items + 1] = ui.text("no devices found") end + items[#items + 1] = ui.button{label = "rescan", on_click = requestBleScan} + items[#items + 1] = ui.button{label = "back", on_click = buildBle} + themed(items) +end + +function buildBle() + mode = "ble" + local initialized = ble.isInitialized() + local items = {pad = 12, gap = 7, ui.text("BLE"), ui.text(initialized and "on" or "off")} + if bleMessage then items[#items + 1] = ui.text(bleMessage, {font = gui.FONT_SMALL}) end + if initialized then + items[#items + 1] = ui.button{label = "scan devices", on_click = requestBleScan} + items[#items + 1] = ui.button{label = "start advertising", on_click = startBleAdvertising} + items[#items + 1] = ui.button{label = "stop advertising", on_click = stopBleAdvertising} + if ble.isConnected() then + items[#items + 1] = ui.button{label = "disconnect", on_click = disconnectBle} + end + items[#items + 1] = ui.button{label = "turn off", on_click = disableBle} + else + items[#items + 1] = ui.button{label = "turn on", on_click = enableBle} + end + items[#items + 1] = ui.button{label = "back", on_click = buildMenu} + themed(items) +end + function updatePassword(value) password = value ui.setText(passwordLabel, "password: " .. password) @@ -334,6 +449,25 @@ function tick() buildNetworks(wifi.scan()) return end + if bleScanRequested then + bleScanRequested = false + local devices, err = ble.scan(3000) + if devices then + log.info("BLE scan found " .. #devices .. " devices") + buildBleDevices(devices) + else + bleMessage = err + buildBle() + end + return + end + if bleConnectRequested then + bleConnectRequested = false + local ok, err = ble.connect(selectedBleDevice.address) + bleMessage = ok and "BLE connected" or err + buildBle() + return + end if mode == "connecting" then local status = wifi.getStatus() if status.state == "connected" then diff --git a/src/host/providers.h b/src/host/providers.h index 65a6810..c9f8180 100644 --- a/src/host/providers.h +++ b/src/host/providers.h @@ -9,7 +9,10 @@ #include #include +#include + class LuaHost; +class NimBLEClient; namespace slate { @@ -132,39 +135,36 @@ public: esp32lua::Status forget() override; }; -// This board has no radio for it. The namespace stays core so an app fails with -// a reason rather than a nil global it has to feature-test. class Ble : public esp32lua::BleProvider { public: - esp32lua::Status init(const std::string *) override { return unsupported(); } - void deinit() override {} - esp32lua::Status scan(int32_t, std::vector &) override { - return unsupported(); - } - esp32lua::Status connect(const std::string &) override { - return unsupported(); - } - void disconnect() override {} - bool isConnected() const override { return false; } - esp32lua::Status read(const std::string &, const std::string &, - std::string &) override { - return unsupported(); - } - esp32lua::Status write(const std::string &, const std::string &, - const std::string &) override { - return unsupported(); - } - esp32lua::Status startAdvertising(const std::string *) override { - return unsupported(); - } - void stopAdvertising() override {} + esp32lua::Status init(const std::string *name) override; + void deinit() override; + bool isInitialized() const override; + esp32lua::Status scan( + int32_t durationMs, + std::vector &devices) override; + esp32lua::Status connect(const std::string &address) override; + void disconnect() override; + bool isConnected() const override; + esp32lua::Status read(const std::string &service, + const std::string &characteristic, + std::string &value) override; + esp32lua::Status write(const std::string &service, + const std::string &characteristic, + const std::string &value) override; + esp32lua::Status startAdvertising(const std::string *name) override; + void stopAdvertising() override; private: - static esp32lua::Status unsupported() { - return esp32lua::Status::failure("this device has no Bluetooth"); - } + bool advertising = false; + NimBLEClient *client = nullptr; + std::map addressTypes; }; +// WiFi and the BLE controller cannot both hold their RAM on this board, so +// each powers the other down before initializing. +void bleShutdown(); + class Touch : public esp32lua::TouchProvider { public: Touch(XPT2046_Touchscreen &panel, LuaHost &host) : panel(panel), host(host) {} diff --git a/src/host/providers_ble.cpp b/src/host/providers_ble.cpp new file mode 100644 index 0000000..8b5f1c3 --- /dev/null +++ b/src/host/providers_ble.cpp @@ -0,0 +1,237 @@ +#include "providers.h" + +#include +#include +#include + +#include "../heaplog.h" + +#include + +namespace slate { +namespace { + +using esp32lua::Status; + +constexpr size_t MAX_SCAN_RESULTS = 6; + +struct ScanResult { + char name[32]; + char address[18]; + int32_t rssi; + uint8_t addressType; +}; + +class ScanCallbacks : public NimBLEScanCallbacks { +public: + void reset() { count = 0; } + size_t size() const { return count; } + const ScanResult &result(size_t index) const { return results[index]; } + + void onResult(const NimBLEAdvertisedDevice *device) override { + ScanResult result{}; + const std::string name = device->haveName() ? device->getName() : ""; + const std::string address = device->getAddress().toString(); + std::snprintf(result.name, sizeof(result.name), "%s", name.c_str()); + std::snprintf(result.address, sizeof(result.address), "%s", + address.c_str()); + result.rssi = device->getRSSI(); + result.addressType = device->getAddressType(); + + size_t at; + if (count < MAX_SCAN_RESULTS) { + at = count++; + } else { + if (result.rssi <= results[count - 1].rssi) + return; + at = count - 1; + } + while (at > 0 && result.rssi > results[at - 1].rssi) { + results[at] = results[at - 1]; + at--; + } + results[at] = result; + } + +private: + ScanResult results[MAX_SCAN_RESULTS]{}; + size_t count = 0; +}; + +ScanCallbacks scanCallbacks; + +bool validAddress(const std::string &address) { + if (address.length() != 17) + return false; + for (size_t at = 0; at < address.length(); at++) { + if (at % 3 == 2) { + if (address[at] != ':') + return false; + } else if (!((address[at] >= '0' && address[at] <= '9') || + (address[at] >= 'a' && address[at] <= 'f') || + (address[at] >= 'A' && address[at] <= 'F'))) { + return false; + } + } + return true; +} + +} // namespace + +Status Ble::init(const std::string *name) { + if (isInitialized()) + return Status::success(); + WiFi.mode(WIFI_OFF); + logHeap("pre-ble"); + if (!NimBLEDevice::init(name ? *name : "Slate32")) + return Status::failure("cannot initialize BLE"); + logHeap("ble-on"); + return Status::success(); +} + +void Ble::deinit() { + if (!isInitialized()) + return; + disconnect(); + stopAdvertising(); + NimBLEDevice::deinit(true); + addressTypes.clear(); + logHeap("ble-off"); +} + +bool Ble::isInitialized() const { return NimBLEDevice::isInitialized(); } + +void bleShutdown() { + if (!NimBLEDevice::isInitialized()) + return; + NimBLEDevice::deinit(true); + Serial.println("[ble] released for wifi"); +} + +Status Ble::scan(int32_t durationMs, + std::vector &devices) { + if (!isInitialized()) + return Status::failure("BLE is not initialized"); + + NimBLEScan *scan = NimBLEDevice::getScan(); + scanCallbacks.reset(); + scan->setActiveScan(false); + scan->setMaxResults(0); + scan->setScanCallbacks(&scanCallbacks); + scan->getResults(static_cast(durationMs), false); + scan->setScanCallbacks(nullptr); + scan->clearResults(); + + devices.clear(); + addressTypes.clear(); + for (size_t at = 0; at < scanCallbacks.size(); at++) { + const ScanResult &result = scanCallbacks.result(at); + devices.push_back({result.name, result.address, result.rssi}); + addressTypes[result.address] = result.addressType; + } + return Status::success(); +} + +Status Ble::connect(const std::string &address) { + if (!isInitialized()) + return Status::failure("BLE is not initialized"); + if (!validAddress(address)) + return Status::failure("invalid BLE address"); + + disconnect(); + client = NimBLEDevice::createClient(); + if (!client) + return Status::failure("cannot create BLE client"); + + uint8_t addressType = BLE_ADDR_PUBLIC; + const auto found = addressTypes.find(address); + if (found != addressTypes.end()) + addressType = found->second; + if (!client->connect(NimBLEAddress(address, addressType))) { + NimBLEDevice::deleteClient(client); + client = nullptr; + return Status::failure("cannot connect to BLE device"); + } + return Status::success(); +} + +void Ble::disconnect() { + if (!client) + return; + if (client->isConnected()) + client->disconnect(); + NimBLEDevice::deleteClient(client); + client = nullptr; +} + +bool Ble::isConnected() const { + return isInitialized() && client && client->isConnected(); +} + +Status Ble::read(const std::string &service, + const std::string &characteristic, std::string &value) { + if (!isConnected()) + return Status::failure("BLE device is not connected"); + + NimBLERemoteService *remoteService = client->getService(service.c_str()); + if (!remoteService) + return Status::failure("BLE service not found"); + NimBLERemoteCharacteristic *remoteCharacteristic = + remoteService->getCharacteristic(characteristic.c_str()); + if (!remoteCharacteristic) + return Status::failure("BLE characteristic not found"); + if (!remoteCharacteristic->canRead()) + return Status::failure("BLE characteristic is not readable"); + + value = static_cast(remoteCharacteristic->readValue()); + return Status::success(); +} + +Status Ble::write(const std::string &service, + const std::string &characteristic, + const std::string &value) { + if (!isConnected()) + return Status::failure("BLE device is not connected"); + + NimBLERemoteService *remoteService = client->getService(service.c_str()); + if (!remoteService) + return Status::failure("BLE service not found"); + NimBLERemoteCharacteristic *remoteCharacteristic = + remoteService->getCharacteristic(characteristic.c_str()); + if (!remoteCharacteristic) + return Status::failure("BLE characteristic not found"); + if (!remoteCharacteristic->canWrite() && + !remoteCharacteristic->canWriteNoResponse()) + return Status::failure("BLE characteristic is not writable"); + + if (!remoteCharacteristic->writeValue( + reinterpret_cast(value.data()), value.size(), + remoteCharacteristic->canWrite())) + return Status::failure("BLE write failed"); + return Status::success(); +} + +Status Ble::startAdvertising(const std::string *name) { + if (!isInitialized()) + return Status::failure("BLE is not initialized"); + + NimBLEAdvertising *bleAdvertising = NimBLEDevice::getAdvertising(); + if (advertising) + bleAdvertising->stop(); + bleAdvertising->clearData(); + if (name && !bleAdvertising->setName(*name)) + return Status::failure("BLE advertising name is too long"); + if (!bleAdvertising->start()) + return Status::failure("cannot start BLE advertising"); + advertising = true; + return Status::success(); +} + +void Ble::stopAdvertising() { + if (!isInitialized() || !advertising) + return; + NimBLEDevice::getAdvertising()->stop(); + advertising = false; +} + +} // namespace slate diff --git a/test/ble.lua b/test/ble.lua new file mode 100644 index 0000000..f6a166f --- /dev/null +++ b/test/ble.lua @@ -0,0 +1,37 @@ +-- Run: lua test/ble.lua +package.path = "sdcard/.lua/lib/?.lua;test/?.lua;" .. package.path + +local device = require("fake_device") +device.bleDevices = {{name = "Sensor", address = "aa:bb:cc:dd:ee:ff", rssi = -42}} +device.install() + +dofile("sdcard/.lua/apps/Settings/main.lua") + +init() +device.tap("BLE") +assert(device.labelled("off")) +assert(not device.find("disconnect")) + +device.tap("turn on") +assert(device.bleName == "Slate32 BLE") +device.tap("scan devices") +assert(device.labelled("scanning BLE")) +tick() +assert(device.labelled("Sensor")) + +device.tap("Sensor") +assert(device.labelled("connecting BLE")) +tick() +assert(device.bleConnected == "aa:bb:cc:dd:ee:ff") +assert(device.find("disconnect")) + +device.tap("disconnect") +device.tap("start advertising") +assert(device.bleAdvertising == "Slate32 BLE") +device.tap("stop advertising") +assert(device.bleAdvertising == nil) + +device.tap("turn off") +assert(not device.bleInitialized) + +print("ok") diff --git a/test/fake_device.lua b/test/fake_device.lua index 987eb3c..3f437f4 100644 --- a/test/fake_device.lua +++ b/test/fake_device.lua @@ -20,6 +20,7 @@ local device = { timezone = "UTC0", raw = nil, -- pending raw touch reading, {x, y} or nil networks = {}, -- what wifi.scan() returns + bleDevices = {}, -- what ble.scan() returns status = {state = "disconnected", ssid = "", ip = "", rssi = 0}, painted = {}, -- every drawText call, in order calibration = nil, -- last settings.setCalibration() @@ -38,6 +39,8 @@ end function device.install() device.painted = {} + device.lines = {} + device.roundRects = {} device.files = device.files or {} device.timers = {} device.nextTimer = 1 @@ -86,8 +89,8 @@ function device.install() fillRect = function() end, drawRect = function() end, fillCircle = function() end, - drawLine = function() end, - roundRect = function() end, + drawLine = function(...) device.lines[#device.lines + 1] = {...} end, + roundRect = function(...) device.roundRects[#device.roundRects + 1] = {...} end, drawPixel = function() end, drawCircle = function() end, fillPolygon = function() end, @@ -174,10 +177,41 @@ function device.install() scan = function() return device.networks end, getStatus = function() return device.status end, connect = function(ssid, password) + device.wifiReconnect = ssid == nil device.connected = {ssid, password} + if ssid then device.status.ssid = ssid end + device.status.state = "connecting" return saved() end, - forget = function() device.connected = nil return saved() end, + disconnect = function() device.status.state = "disconnected" end, + forget = function() + device.connected = nil + device.status = {state = "disconnected", ssid = "", ip = "", rssi = 0} + return saved() + end, + } + + ble = { + init = function(name) + device.bleInitialized, device.bleName = true, name + return true + end, + deinit = function() + device.bleInitialized = false + device.bleName, device.bleConnected, device.bleAdvertising = nil, nil, nil + end, + isInitialized = function() return device.bleInitialized == true end, + scan = function() return device.bleDevices end, + connect = function(address) device.bleConnected = address return true end, + disconnect = function() device.bleConnected = nil end, + isConnected = function() return device.bleConnected ~= nil end, + read = function() return "test" end, + write = function(service, characteristic, value) + device.bleWrite = {service, characteristic, value} + return true + end, + startAdvertising = function(name) device.bleAdvertising = name return true end, + stopAdvertising = function() device.bleAdvertising = nil end, } log = {debug = function() end, info = function() end, error = function() end} diff --git a/test/settings_calibration.lua b/test/settings_calibration.lua index 816f277..acf35f5 100644 --- a/test/settings_calibration.lua +++ b/test/settings_calibration.lua @@ -65,6 +65,26 @@ local saved = device.calibration assert(saved, "calibration was not saved") assert(math.abs(saved[1] - 200) <= 1, "saved x0 " .. saved[1]) +-- WiFi controls reflect connection state without exposing configured network details on the card. +device.status = {state = "disconnected", ssid = "", ip = "", rssi = 0} +init() +tapRow("WiFi") +assert(not device.find("connect") and not device.find("disconnect"), "unconfigured wifi has no toggle") + +device.status = {state = "disconnected", ssid = "saved", ip = "", rssi = 0} +init() +tapRow("WiFi") +assert(device.find("connect") and not device.find("disconnect"), "configured wifi can reconnect") +tapRow("connect") +assert(device.wifiReconnect, "wifi reconnect should use saved credentials") + +device.status = {state = "connected", ssid = "saved", ip = "192.168.1.2", rssi = -40} +init() +tapRow("WiFi") +assert(device.find("disconnect") and not device.find("connect"), "connected wifi can disconnect") +tapRow("disconnect") +assert(device.status.state == "disconnected" and device.status.ssid == "saved", "disconnect preserves wifi intent") + -- Open networks connect directly from scan results. init() device.networks = {{ssid = "qemu", rssi = -25, secure = false}}