refactor: run apps on the shared esp32-lua-api runtime

Replaces the firmware's own lua_State, bindings, module loader, node tree
and navigation history with lib/esp32-lua-api. What is left in src/host is
the hardware behind the provider interfaces plus the chrome the firmware
owns: the viewport, the status bar and touch polling.

src/lua became src/host because src is on the include path, so a directory
named lua shadowed the library's <lua/providers.h> and #pragma once then
silently skipped it.
This commit is contained in:
2026-08-03 18:12:33 -04:00
parent a40d2441f6
commit 45abf8a767
23 changed files with 1223 additions and 2901 deletions
+269
View File
@@ -0,0 +1,269 @@
#include "lua_host.h"
#include <algorithm>
#include "../settings.h"
extern "C" {
#include <lauxlib.h>
#include <lua.h>
}
namespace {
esp32lua::Paths slatePaths() {
esp32lua::Paths paths;
paths.apps = "/.lua/apps";
paths.data = "/.lua/data";
paths.lib = "/.lua/lib";
paths.home = "Home";
return paths;
}
// Reads an optional positive integer field off the bar module on the stack top.
lua_Integer barField(lua_State* state, const char* key, lua_Integer fallback) {
lua_getfield(state, -1, key);
const lua_Integer value = lua_isinteger(state, -1) ? lua_tointeger(state, -1) : fallback;
lua_pop(state, 1);
return value > 0 ? value : fallback;
}
} // namespace
// Called from the initializer list once every provider member exists, which declaration order
// guarantees: the runtime holds pointers to them for its whole life.
esp32lua::Providers LuaHost::wire() {
esp32lua::Providers providers;
providers.log = &logProvider;
providers.settings = &settingsProvider;
providers.sys = &sysProvider;
providers.fs = &fsProvider;
providers.gui = &guiProvider;
providers.http = &httpProvider;
providers.timer = &timerProvider;
providers.wifi = &wifiProvider;
providers.ble = &bleProvider;
providers.touch = &touchProvider;
// No buttons on this board, so sys.hasFeature("buttons") is false and input gains nothing.
return providers;
}
LuaHost::LuaHost(TFT_eSPI& tft, XPT2046_Touchscreen& touch)
: tft(tft),
touchPanel(touch),
settingsProvider(*this),
guiProvider(tft, *this),
touchProvider(touch, *this),
runtime(wire(), slatePaths()) {}
void LuaHost::mapTouch(const TS_Point& point, int16_t& x, int16_t& y) const {
const int16_t nx = constrain(map(point.y, settings.touchX0, settings.touchX1, 0, PANEL_W), 0, PANEL_W - 1);
const int16_t ny = constrain(map(point.x, settings.touchY0, settings.touchY1, 0, PANEL_H), 0, PANEL_H - 1);
switch (tft.getRotation() & 3) {
case 1:
x = ny;
y = PANEL_W - 1 - nx;
break;
case 2:
x = PANEL_W - 1 - nx;
y = PANEL_H - 1 - ny;
break;
case 3:
x = PANEL_H - 1 - ny;
y = nx;
break;
default:
x = nx;
y = ny;
break;
}
// App space starts below the bar, matching the viewport apps draw into. A tap on the bar
// itself lands at a negative y, which pollTouch() drops.
y -= barInset();
}
// resetViewport() first: width()/height() report the viewport once one is set, so re-applying
// over an existing one would shrink the app area again every time. setRotation() leaves the old
// viewport metrics behind, so every rotation needs this too.
void LuaHost::applyViewport() {
const int16_t inset = barInset();
tft.resetViewport();
if (inset > 0) tft.setViewport(0, inset, tft.width(), tft.height() - inset, true);
}
void LuaHost::applyRotation() {
tft.setRotation(settings.rotationIndex());
applyViewport();
refreshStatusBar(); // every slot in the bar just moved
}
void LuaHost::setFullscreen(bool on) {
fullscreen = on;
applyViewport();
nextBarMs = 0; // leaving fullscreen left the bar's rows painted by the app
}
bool LuaHost::begin() { return startApp("Home", ""); }
bool LuaHost::startApp(const std::string& path, const std::string& arg) {
tft.setRotation(settings.rotationIndex()); // the previous app may have rotated the frame
fullscreen = false;
// Applied before the app loads, because init() measures the panel it was given. The height
// is last app's, which is the same module, and loadStatusBar() corrects it if that changes.
applyViewport();
Serial.printf("[lua] launching %s free=%u largest=%u\n", path.c_str(), ESP.getFreeHeap(), ESP.getMaxAllocHeap());
lastTouched = true; // the tap that launched this app may still be down
ignoreRelease = true; // and its release is not this app's gesture
backArmed = false;
if (!runtime.startApp(path, arg)) {
fail(("could not start " + path).c_str());
return false;
}
hasBack = runtime.canGoBack(); // the runtime keeps the history; the bar only offers the control
loadStatusBar();
applyViewport();
if (barInset() > 0 && !barBroken) drawStatusBar();
const uint32_t now = millis();
nextDrawMs = now;
lastDrawMs = now;
return true;
}
// The bar is a Lua module like any other, loaded per app because the state is too.
void LuaHost::loadStatusBar() {
lua_State* state = runtime.state();
barBroken = false;
barIntervalMs = BAR_INTERVAL_MS;
lua_getglobal(state, "require");
lua_pushstring(state, "statusbar");
if (lua_pcall(state, 1, 1, 0) != LUA_OK || !lua_istable(state, -1)) {
Serial.printf("[statusbar] unavailable: %s\n", luaL_tolstring(state, -1, nullptr));
// Nothing has measured the panel yet, so surrendering the strip here is free.
barBroken = true;
barHeight = 0;
lua_pop(state, 2);
return;
}
barHeight = barField(state, "height", DEFAULT_BAR_H);
barIntervalMs = std::max<lua_Integer>(DRAW_INTERVAL_MS, barField(state, "interval", BAR_INTERVAL_MS));
lua_setglobal(state, "__statusbar");
nextBarMs = 0;
}
// Errors here disable the bar rather than killing the app: chrome that fails should not take
// the running program with it.
void LuaHost::drawStatusBar() {
lua_State* state = runtime.state();
lua_getglobal(state, "__statusbar");
lua_getfield(state, -1, "draw");
lua_remove(state, -2);
lua_pushboolean(state, hasBack);
tft.resetViewport(); // the bar paints in panel coordinates, the app does not
if (lua_pcall(state, 1, 0, 0) != LUA_OK) {
Serial.printf("[statusbar] %s\n", luaL_tolstring(state, -1, nullptr));
lua_pop(state, 2);
barBroken = true;
}
applyViewport();
}
// Deliberately not themed: this can fire when the settings naming the theme are themselves
// unreadable, so it stays high contrast.
void LuaHost::fail(const char* message) {
Serial.printf("[lua] %s\n", message ? message : "(unknown)");
tft.resetViewport();
tft.fillScreen(TFT_WHITE);
tft.setTextSize(2);
tft.setTextColor(TFT_RED, TFT_WHITE);
tft.drawString("Lua error:", 8, 8);
tft.setTextColor(TFT_BLACK, TFT_WHITE);
tft.drawString(message ? message : "(unknown)", 8, 28);
}
void LuaHost::pollTouch() {
bool touched = touchPanel.touched();
if (touched) mapTouch(touchPanel.getPoint(), lastX, lastY);
// The bar is the host's: apps never see a touch in it, and its one control acts on release,
// so a press that slides off into the app cancels like any other button.
if (touched && lastY < 0) {
backArmed = backArmed || inBackButton();
touched = false;
} else if (backArmed) {
backArmed = false;
if (inBackButton()) {
runtime.requestBack();
return;
}
}
if (touched && !lastTouched) {
movedX = lastX;
movedY = lastY;
runtime.callTouch(esp32lua::TouchPhase::Down, lastX, lastY);
} else if (touched && lastTouched) {
// The XPT2046 jitters a pixel or two under a still finger. Below this every poll would
// fire a move, and a painting app would draw a blur where nothing moved.
if (abs(lastX - movedX) + abs(lastY - movedY) >= MOVE_EPSILON_PX) {
movedX = lastX;
movedY = lastY;
runtime.callTouch(esp32lua::TouchPhase::Move, lastX, lastY);
}
} else if (!touched && lastTouched && ignoreRelease) {
ignoreRelease = false;
} else if (!touched && lastTouched) {
// The runtime fires the on_touch tap alias after the release.
runtime.callTouch(esp32lua::TouchPhase::Up, lastX, lastY);
}
lastTouched = touched;
}
void LuaHost::pollTimers() {
std::vector<esp32lua::TimerId> due;
timerProvider.collectDue(millis(), due);
for (size_t at = 0; at < due.size(); at++) {
runtime.callTimer(due[at]);
if (runtime.hasPendingNavigation()) return; // the app is leaving; its other timers can wait
}
}
void LuaHost::navigate() {
if (!runtime.applyPendingNavigation()) {
fail("app failed to start");
return;
}
hasBack = runtime.canGoBack();
loadStatusBar();
applyViewport();
if (barInset() > 0 && !barBroken) drawStatusBar();
const uint32_t now = millis();
nextDrawMs = now;
lastDrawMs = now;
}
void LuaHost::loop() {
if (!runtime.hasApp()) return;
pollTouch();
if (!runtime.hasPendingNavigation()) pollTimers();
const uint32_t now = millis();
if (!runtime.hasPendingNavigation() && now >= nextDrawMs) {
nextDrawMs = now + DRAW_INTERVAL_MS;
runtime.callDraw(static_cast<int32_t>(now - lastDrawMs));
lastDrawMs = now;
}
if (!runtime.hasPendingNavigation() && barInset() > 0 && !barBroken && now >= nextBarMs) {
nextBarMs = now + barIntervalMs;
drawStatusBar();
}
// Between batches, never inside one: swapping the lua_State mid-callback would free the VM
// that is still executing.
if (runtime.hasPendingNavigation()) navigate();
}
+82
View File
@@ -0,0 +1,82 @@
#pragma once
// The firmware side of running a Lua app: the panel, the touch controller, the status bar
// and the frame pacing. The lua_State, the bindings, the node tree, app loading and
// navigation history all belong to esp32lua::Runtime.
#include <TFT_eSPI.h>
#include <XPT2046_Touchscreen.h>
#include <lua/runtime.h>
#include "providers.h"
class LuaHost {
public:
LuaHost(TFT_eSPI& tft, XPT2046_Touchscreen& touch);
// Starts the launcher, or reports the failure that stopped it.
bool begin();
void loop();
bool running() const { return runtime.hasApp(); }
// Called by the providers, which is why they hold a reference to the host.
void applyViewport();
void applyRotation();
void setFullscreen(bool on);
void refreshStatusBar() { nextBarMs = 0; }
void mapTouch(const TS_Point& point, int16_t& x, int16_t& y) const;
private:
static constexpr uint32_t DRAW_INTERVAL_MS = 33;
static constexpr uint32_t BAR_INTERVAL_MS = 1000;
static constexpr int16_t DEFAULT_BAR_H = 22;
static constexpr int16_t PANEL_W = 320;
static constexpr int16_t PANEL_H = 480;
static constexpr int16_t MOVE_EPSILON_PX = 2;
static constexpr uint32_t ERROR_HOLD_MS = 5000;
int16_t barInset() const { return fullscreen ? 0 : barHeight; }
bool inBackButton() const {
return hasBack && lastY < 0 && lastY >= -barInset() && lastX >= 0 && lastX < barInset();
}
esp32lua::Providers wire();
bool startApp(const std::string& path, const std::string& arg);
void navigate();
void pollTouch();
void pollTimers();
void loadStatusBar();
void drawStatusBar();
void fail(const char* message);
TFT_eSPI& tft;
XPT2046_Touchscreen& touchPanel;
slate::Log logProvider;
slate::Settings settingsProvider;
slate::Sys sysProvider;
slate::Fs fsProvider;
slate::Gui guiProvider;
slate::Http httpProvider;
slate::Timer timerProvider;
slate::Wifi wifiProvider;
slate::Ble bleProvider;
slate::Touch touchProvider;
esp32lua::Runtime runtime;
uint32_t nextDrawMs = 0;
uint32_t lastDrawMs = 0;
uint32_t nextBarMs = 0;
uint32_t barIntervalMs = BAR_INTERVAL_MS;
// Kept across apps: the bar is the same module for every one of them, and the viewport has
// to be right before init() measures the panel it was given.
int16_t barHeight = DEFAULT_BAR_H;
bool barBroken = false;
bool fullscreen = false;
bool hasBack = false;
bool backArmed = false;
bool lastTouched = false;
bool ignoreRelease = false;
int16_t lastX = 0, lastY = 0;
int16_t movedX = 0, movedY = 0;
};
+166
View File
@@ -0,0 +1,166 @@
#pragma once
// The firmware half of the shared Lua platform: hardware behind the interfaces in
// <lua/providers.h>, and nothing else. Everything above these -- bindings, argument
// checking, the node tree, app loading, navigation -- lives in lib/esp32-lua-api.
#include <TFT_eSPI.h>
#include <XPT2046_Touchscreen.h>
#include <lua/providers.h>
class LuaHost;
namespace slate {
class Log : public esp32lua::LogProvider {
public:
void write(esp32lua::LogLevel level, const std::string& message) override;
};
class Settings : public esp32lua::SettingsProvider {
public:
explicit Settings(LuaHost& host) : host(host) {}
int32_t rotation() const override;
esp32lua::Status setRotation(int32_t degrees) override;
std::string timezone() const override;
esp32lua::Status setTimezone(const std::string& timezone) override;
private:
LuaHost& host;
};
class Sys : public esp32lua::SysProvider {
public:
int32_t millis() const override;
esp32lua::MemoryInfo memory() const override;
bool isClockSynced() const override;
};
class Fs : public esp32lua::FsProvider {
public:
esp32lua::FileReader* openRead(const std::string& path) override;
bool exists(const std::string& path) const override;
esp32lua::Status fileSize(const std::string& path, int32_t& size) const override;
esp32lua::Status listDirs(const std::string& path, std::vector<std::string>& names) const override;
esp32lua::Status listFiles(const std::string& path, std::vector<std::string>& names) const override;
esp32lua::Status mkdir(const std::string& path) override;
esp32lua::Status readFile(const std::string& path, int32_t maxBytes, std::string& content) const override;
esp32lua::Status readLineAt(const std::string& path, int32_t offset, int32_t maxBytes, bool& found,
std::string& line, int32_t& nextOffset) const override;
esp32lua::Status remove(const std::string& path) override;
esp32lua::Status removeTree(const std::string& path) override;
esp32lua::Status rename(const std::string& source, const std::string& destination) override;
esp32lua::Status writeFile(const std::string& path, const std::string& content) override;
};
// Font roles map onto the one built-in font at whole-number scales, which is all TFT_eSPI
// offers here; STYLE_BOLD has no glyphs of its own and renders as normal.
class Gui : public esp32lua::GuiProvider {
public:
Gui(TFT_eSPI& tft, LuaHost& host) : tft(tft), host(host) {}
esp32lua::FontIds fonts() const override;
int32_t width() const override;
int32_t height() const override;
int32_t rotation() const override;
void setRotation(int32_t degrees) override;
int32_t color(int32_t r, int32_t g, int32_t b) const override;
void clear(int32_t color) override;
void fillRect(int32_t x, int32_t y, int32_t w, int32_t h, int32_t color) override;
void drawRect(int32_t x, int32_t y, int32_t w, int32_t h, int32_t color) override;
void drawLine(int32_t x1, int32_t y1, int32_t x2, int32_t y2, int32_t color, int32_t width) override;
void drawPixel(int32_t x, int32_t y, int32_t color) override;
void drawCircle(int32_t x, int32_t y, int32_t radius, int32_t color, int32_t width) override;
void fillCircle(int32_t x, int32_t y, int32_t radius, int32_t color, const int32_t* background) override;
void roundRect(int32_t x, int32_t y, int32_t w, int32_t h, int32_t radius, int32_t background, const int32_t* top,
const int32_t* bottom, const int32_t* border) override;
void fillPolygon(const int32_t* xs, const int32_t* ys, size_t count, int32_t color) override;
esp32lua::Status drawBmp(const std::string& path, const int32_t* x, const int32_t* y, const int32_t* maxWidth,
const int32_t* maxHeight) override;
int32_t textWidth(int32_t font, const std::string& text, int32_t style) const override;
int32_t fontHeight(int32_t font, int32_t style) const override;
void drawText(int32_t font, int32_t x, int32_t y, const std::string& text, int32_t color, int32_t style,
const int32_t* background) override;
void setFullscreen(bool on) override;
// The panel is live, so there is nothing pending to apply.
void commit() override {}
private:
TFT_eSPI& tft;
LuaHost& host;
};
class Http : public esp32lua::HttpProvider {
public:
esp32lua::Status request(const std::string& method, const std::string& url, const std::string& body,
const std::vector<esp32lua::HttpHeader>& headers, int32_t maxBytes,
esp32lua::HttpResponse& response) override;
esp32lua::Status download(const std::string& url, const std::string& destination,
const esp32lua::HttpDownload& options, int32_t& bytesWritten) override;
};
class Wifi : public esp32lua::WifiProvider {
public:
esp32lua::Status scan(std::vector<esp32lua::WifiNetwork>& networks) override;
esp32lua::Status connect(const std::string* ssid, const std::string* password) override;
esp32lua::WifiStatus status() const override;
void disconnect() override;
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<esp32lua::BleDevice>&) 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 {}
private:
static esp32lua::Status unsupported() { return esp32lua::Status::failure("this device has no Bluetooth"); }
};
class Touch : public esp32lua::TouchProvider {
public:
Touch(XPT2046_Touchscreen& panel, LuaHost& host) : panel(panel), host(host) {}
bool touch(int32_t& x, int32_t& y) const override;
bool rawTouch(int32_t& x, int32_t& y) const override;
bool isTouched() const override;
esp32lua::Status setCalibration(int32_t x0, int32_t y0, int32_t x1, int32_t y1) override;
private:
XPT2046_Touchscreen& panel;
LuaHost& host;
};
// Deadlines only: identity and the retained Lua callbacks belong to the runtime, which is
// called back through Runtime::callTimer() from the firmware loop.
class Timer : public esp32lua::TimerProvider {
public:
static constexpr int SLOTS = 8;
esp32lua::Status schedule(esp32lua::TimerId id, int32_t intervalMs, bool repeating) override;
void cancel(esp32lua::TimerId id) override;
// Ids whose deadline has passed, rescheduling the repeating ones.
void collectDue(uint32_t nowMs, std::vector<esp32lua::TimerId>& due);
private:
struct Slot {
esp32lua::TimerId id = 0;
uint32_t dueMs = 0;
int32_t intervalMs = 0;
bool repeating = false;
};
Slot slots[SLOTS];
};
} // namespace slate
+193
View File
@@ -0,0 +1,193 @@
#include <SD.h>
#include "providers.h"
namespace slate {
namespace {
using esp32lua::Status;
// Streamed rather than slurped: holding a whole module as one buffer needs that many bytes
// contiguous, and once WiFi is up the largest free block is far below the free heap.
class SdReader : public esp32lua::FileReader {
public:
explicit SdReader(File file) : file(file) {}
~SdReader() override {
if (file) file.close();
}
int32_t read(char* out, int32_t maxBytes) override {
return file.read(reinterpret_cast<uint8_t*>(out), maxBytes);
}
private:
File file;
};
Status listEntries(const std::string& path, bool directories, std::vector<std::string>& names) {
File dir = SD.open(path.c_str());
if (!dir || !dir.isDirectory()) {
if (dir) dir.close();
return Status::failure("not a directory");
}
for (File entry = dir.openNextFile(); entry; entry = dir.openNextFile()) {
if (entry.isDirectory() == directories && entry.name()[0] != '.') names.push_back(entry.name());
entry.close();
}
dir.close();
std::sort(names.begin(), names.end());
return Status::success();
}
bool removeRecursive(const String& path) {
File entry = SD.open(path);
if (!entry) return false;
if (!entry.isDirectory()) {
entry.close();
return SD.remove(path);
}
for (File child = entry.openNextFile(); child; child = entry.openNextFile()) {
const String childPath = path + "/" + child.name();
const bool isDirectory = child.isDirectory();
child.close();
if (!(isDirectory ? removeRecursive(childPath) : SD.remove(childPath))) {
entry.close();
return false;
}
}
entry.close();
return SD.rmdir(path);
}
} // namespace
esp32lua::FileReader* Fs::openRead(const std::string& path) {
// Probed first: a missed SD.open logs a VFS error, and the module searcher misses by design.
if (!SD.exists(path.c_str())) return nullptr;
File file = SD.open(path.c_str());
if (!file || file.isDirectory()) {
if (file) file.close();
return nullptr;
}
return new SdReader(file);
}
bool Fs::exists(const std::string& path) const { return SD.exists(path.c_str()); }
Status Fs::fileSize(const std::string& path, int32_t& size) const {
File file = SD.open(path.c_str());
if (!file || file.isDirectory()) {
if (file) file.close();
return Status::failure("not a file");
}
size = static_cast<int32_t>(file.size());
file.close();
return Status::success();
}
Status Fs::listDirs(const std::string& path, std::vector<std::string>& names) const {
return listEntries(path, true, names);
}
Status Fs::listFiles(const std::string& path, std::vector<std::string>& names) const {
return listEntries(path, false, names);
}
Status Fs::mkdir(const std::string& path) {
if (SD.exists(path.c_str())) return Status::success();
return SD.mkdir(path.c_str()) ? Status::success() : Status::failure("cannot create directory");
}
Status Fs::readFile(const std::string& path, int32_t maxBytes, std::string& content) const {
File file = SD.open(path.c_str());
if (!file || file.isDirectory()) {
if (file) file.close();
return Status::failure("not a file");
}
const size_t size = file.size();
if (size > static_cast<size_t>(maxBytes)) {
file.close();
return Status::failure("file exceeds maxBytes");
}
content.resize(size);
const size_t read = size ? file.read(reinterpret_cast<uint8_t*>(&content[0]), size) : 0;
file.close();
if (read != size) return Status::failure("short read");
return Status::success();
}
Status Fs::readLineAt(const std::string& path, int32_t offset, int32_t maxBytes, bool& found, std::string& line,
int32_t& nextOffset) const {
File file = SD.open(path.c_str());
if (!file || file.isDirectory()) {
if (file) file.close();
return Status::failure("not a file");
}
if (!file.seek(offset)) {
file.close();
return Status::failure("cannot seek");
}
if (offset > 0) {
// A mid-line offset advances to the next line, so a caller can resume from any byte.
while (file.available() && file.peek() != '\n') file.read();
if (file.available()) file.read();
}
if (!file.available()) {
file.close();
found = false;
return Status::success();
}
line.clear();
while (file.available()) {
const int c = file.read();
if (c == '\n') break;
if (c != '\r') line.push_back(static_cast<char>(c));
if (static_cast<int32_t>(line.size()) > maxBytes) {
file.close();
return Status::failure("line exceeds maxBytes");
}
}
nextOffset = static_cast<int32_t>(file.position());
file.close();
found = true;
return Status::success();
}
Status Fs::remove(const std::string& path) {
return SD.remove(path.c_str()) ? Status::success() : Status::failure("cannot remove");
}
Status Fs::removeTree(const std::string& path) {
return removeRecursive(String(path.c_str())) ? Status::success() : Status::failure("cannot remove tree");
}
Status Fs::rename(const std::string& source, const std::string& destination) {
if (SD.exists(destination.c_str())) return Status::failure("destination exists");
return SD.rename(source.c_str(), destination.c_str()) ? Status::success() : Status::failure("cannot rename");
}
// Written to a temporary first: a half-written file that replaced a good one is worse than a
// failed write that left the old contents alone.
Status Fs::writeFile(const std::string& path, const std::string& content) {
const String target(path.c_str());
const String temporary = target + ".tmp";
SD.remove(temporary);
File file = SD.open(temporary, FILE_WRITE);
if (!file) return Status::failure("cannot open for writing");
const size_t written = content.empty() ? 0 : file.write(reinterpret_cast<const uint8_t*>(content.data()),
content.size());
file.close();
if (written != content.size()) {
SD.remove(temporary);
return Status::failure("short write");
}
SD.remove(target);
if (!SD.rename(temporary, target)) {
SD.remove(temporary);
return Status::failure("cannot replace destination");
}
return Status::success();
}
} // namespace slate
+172
View File
@@ -0,0 +1,172 @@
#include <Arduino.h>
#include <SD.h>
#include "../gfx/round_rect.h"
#include "lua_host.h"
#include "providers.h"
namespace slate {
namespace {
constexpr int MAX_SPAN = 480; // longest panel edge, so one row buffer covers any shape
// One primitive draws the whole surface: fill (solid or vertical gradient) and border derive
// from the same distance field, so they cannot disagree at the corners the way two separate
// rounded-rect algorithms did. The panel has no alpha, so edge pixels blend against `surface`.
void paintRoundRect(TFT_eSPI& tft, int x, int y, int w, int h, float radius, uint16_t surface, bool hasFill,
uint16_t top, uint16_t bottom, bool hasBorder, uint16_t border) {
if (w <= 0 || h <= 0 || w > MAX_SPAN) return;
float halfWidth = w * 0.5f, halfHeight = h * 0.5f;
if (radius < 0.0f) radius = 0.0f;
const float limit = (w < h ? w : h) / 2.0f;
if (radius > limit) radius = limit;
static uint16_t span[MAX_SPAN];
// pushImage sends the buffer verbatim, but the panel wants each colour big-endian.
const bool previousSwap = tft.getSwapBytes();
tft.setSwapBytes(true);
for (int row = 0; row < h; row++) {
const uint16_t fill = hasFill ? gfx::lerp565(top, bottom, row, h - 1) : 0;
const float py = row + 0.5f - halfHeight;
for (int column = 0; column < w; column++) {
const float distance = gfx::roundRectDistance(column + 0.5f - halfWidth, py, halfWidth, halfHeight, radius);
const float outer = gfx::coverage(distance);
// The border is the ring between the shape and the same shape inset by its width.
const float inner = hasBorder ? gfx::coverage(distance + 1.0f) : outer;
uint16_t pixel = surface;
if (hasFill) pixel = gfx::blend565(pixel, fill, inner);
if (hasBorder) pixel = gfx::blend565(pixel, border, outer - inner);
span[column] = pixel;
}
tft.pushImage(x, y + row, w, 1, span);
}
tft.setSwapBytes(previousSwap);
}
// Font roles are whole-number scales of the one built-in font, which is what this driver has.
uint8_t scaleFor(int32_t font) { return static_cast<uint8_t>(font < 1 ? 1 : (font > 8 ? 8 : font)); }
} // namespace
esp32lua::FontIds Gui::fonts() const {
esp32lua::FontIds ids;
ids.small = 1;
ids.ui = 2;
ids.body = 2;
ids.large = 3;
ids.styleNormal = 0;
ids.styleBold = 0; // no bold glyphs, so bold text is normal text rather than a different metric
return ids;
}
int32_t Gui::width() const { return tft.width(); }
int32_t Gui::height() const { return tft.height(); }
int32_t Gui::rotation() const { return tft.getRotation() * 90; }
void Gui::setRotation(int32_t degrees) {
tft.setRotation((degrees / 90) & 3);
host.applyViewport();
host.refreshStatusBar(); // every slot in the bar just moved
}
int32_t Gui::color(int32_t r, int32_t g, int32_t b) const { return tft.color565(r, g, b); }
void Gui::clear(int32_t color) { tft.fillScreen(color); }
void Gui::fillRect(int32_t x, int32_t y, int32_t w, int32_t h, int32_t color) { tft.fillRect(x, y, w, h, color); }
void Gui::drawRect(int32_t x, int32_t y, int32_t w, int32_t h, int32_t color) { tft.drawRect(x, y, w, h, color); }
void Gui::drawPixel(int32_t x, int32_t y, int32_t color) { tft.drawPixel(x, y, color); }
void Gui::drawLine(int32_t x1, int32_t y1, int32_t x2, int32_t y2, int32_t color, int32_t width) {
if (width <= 1) {
tft.drawLine(x1, y1, x2, y2, color);
return;
}
tft.drawWideLine(x1, y1, x2, y2, static_cast<float>(width), color, color);
}
void Gui::drawCircle(int32_t x, int32_t y, int32_t radius, int32_t color, int32_t width) {
for (int32_t ring = 0; ring < (width < 1 ? 1 : width); ring++) {
const int32_t r = radius - ring;
if (r > 0) tft.drawCircle(x, y, r, color);
}
}
void Gui::fillCircle(int32_t x, int32_t y, int32_t radius, int32_t color, const int32_t* background) {
tft.fillSmoothCircle(x, y, radius, color, background ? *background : TFT_WHITE);
}
void Gui::roundRect(int32_t x, int32_t y, int32_t w, int32_t h, int32_t radius, int32_t background,
const int32_t* top, const int32_t* bottom, const int32_t* border) {
const uint16_t fillTop = top ? static_cast<uint16_t>(*top) : 0;
const uint16_t fillBottom = bottom ? static_cast<uint16_t>(*bottom) : fillTop;
paintRoundRect(tft, x, y, w, h, static_cast<float>(radius), static_cast<uint16_t>(background), top != nullptr,
fillTop, fillBottom, border != nullptr, border ? static_cast<uint16_t>(*border) : 0);
}
// Scanline fill: TFT_eSPI only offers triangles, and fanning a concave shape into triangles
// paints outside it.
void Gui::fillPolygon(const int32_t* xs, const int32_t* ys, size_t count, int32_t color) {
if (count < 3) return;
int32_t top = ys[0], bottom = ys[0];
for (size_t at = 1; at < count; at++) {
if (ys[at] < top) top = ys[at];
if (ys[at] > bottom) bottom = ys[at];
}
for (int32_t y = top; y <= bottom; y++) {
int32_t crossings[16];
size_t found = 0;
for (size_t at = 0; at < count && found < 16; at++) {
const size_t next = (at + 1) % count;
const int32_t y0 = ys[at], y1 = ys[next];
if ((y0 <= y && y1 > y) || (y1 <= y && y0 > y)) {
crossings[found++] = xs[at] + (y - y0) * (xs[next] - xs[at]) / (y1 - y0);
}
}
for (size_t a = 0; a + 1 < found; a++) {
for (size_t b = a + 1; b < found; b++) {
if (crossings[b] < crossings[a]) {
const int32_t swap = crossings[a];
crossings[a] = crossings[b];
crossings[b] = swap;
}
}
}
for (size_t at = 0; at + 1 < found; at += 2) {
tft.drawFastHLine(crossings[at], y, crossings[at + 1] - crossings[at] + 1, color);
}
}
}
// ponytail: no BMP decoder on this panel yet. Apps that need artwork draw it; wire a decoder
// in when one actually ships a bitmap.
esp32lua::Status Gui::drawBmp(const std::string& path, const int32_t*, const int32_t*, const int32_t*,
const int32_t*) {
if (!SD.exists(path.c_str())) return esp32lua::Status::failure("no such file");
return esp32lua::Status::failure("BMP drawing is not implemented on this device");
}
int32_t Gui::textWidth(int32_t font, const std::string& text, int32_t) const {
tft.setTextSize(scaleFor(font));
return tft.textWidth(text.c_str());
}
int32_t Gui::fontHeight(int32_t font, int32_t) const {
tft.setTextSize(scaleFor(font));
return tft.fontHeight();
}
void Gui::drawText(int32_t font, int32_t x, int32_t y, const std::string& text, int32_t color, int32_t,
const int32_t* background) {
tft.setTextSize(scaleFor(font));
if (background) {
tft.setTextColor(color, *background);
} else {
tft.setTextColor(color);
}
tft.drawString(text.c_str(), x, y);
}
void Gui::setFullscreen(bool on) { host.setFullscreen(on); }
} // namespace slate
+214
View File
@@ -0,0 +1,214 @@
#include <HTTPClient.h>
#include <SD.h>
#include <WiFi.h>
#include <WiFiClient.h>
#include <WiFiClientSecure.h>
#include <mbedtls/sha256.h>
#include "../settings.h"
#include "providers.h"
extern const uint8_t rootca_crt_bundle_start[] asm("_binary_x509_crt_bundle_start");
namespace slate {
namespace {
using esp32lua::Status;
constexpr uint32_t TIMEOUT_MS = 60000;
constexpr int REDIRECT_LIMIT = 5;
constexpr size_t DOWNLOAD_CHUNK = 2048;
bool isHttps(const std::string& url) { return url.rfind("https://", 0) == 0; }
// One session owns both clients: the secure one carries the CA bundle, and HTTPClient keeps a
// reference to whichever it was handed, so neither may outlive the request.
struct Session {
WiFiClient plain;
WiFiClientSecure secure;
HTTPClient http;
bool begin(const std::string& url) {
if (isHttps(url)) {
secure.setCACertBundle(rootca_crt_bundle_start);
secure.setTimeout(TIMEOUT_MS / 1000);
if (!http.begin(secure, url.c_str())) return false;
} else if (!http.begin(plain, url.c_str())) {
return false;
}
http.setTimeout(TIMEOUT_MS);
http.setConnectTimeout(TIMEOUT_MS);
http.setFollowRedirects(HTTPC_STRICT_FOLLOW_REDIRECTS);
http.setRedirectLimit(REDIRECT_LIMIT);
http.setUserAgent("slate32");
return true;
}
};
bool hashFile(const char* path, uint8_t digest[32]) {
File file = SD.open(path);
if (!file) return false;
mbedtls_sha256_context sha;
mbedtls_sha256_init(&sha);
mbedtls_sha256_starts_ret(&sha, 0);
static uint8_t chunk[DOWNLOAD_CHUNK];
while (file.available()) {
const int read = file.read(chunk, sizeof(chunk));
if (read <= 0) break;
mbedtls_sha256_update_ret(&sha, chunk, read);
}
file.close();
mbedtls_sha256_finish_ret(&sha, digest);
mbedtls_sha256_free(&sha);
return true;
}
std::string hex(const uint8_t* digest, size_t length) {
std::string out;
for (size_t at = 0; at < length; at++) {
char pair[3];
snprintf(pair, sizeof(pair), "%02x", digest[at]);
out += pair;
}
return out;
}
bool equalsIgnoreCase(const std::string& a, const std::string& b) {
if (a.size() != b.size()) return false;
for (size_t at = 0; at < a.size(); at++) {
if (tolower(static_cast<unsigned char>(a[at])) != tolower(static_cast<unsigned char>(b[at]))) return false;
}
return true;
}
} // namespace
Status Http::request(const std::string& method, const std::string& url, const std::string& body,
const std::vector<esp32lua::HttpHeader>& headers, int32_t maxBytes,
esp32lua::HttpResponse& response) {
Session session;
if (!session.begin(url)) return Status::failure("cannot reach " + url);
for (size_t at = 0; at < headers.size(); at++) {
session.http.addHeader(headers[at].name.c_str(), headers[at].value.c_str());
}
const int status = session.http.sendRequest(method.c_str(), reinterpret_cast<uint8_t*>(const_cast<char*>(
body.data())),
body.size());
if (status <= 0) return Status::failure(HTTPClient::errorToString(status).c_str());
// Refused on the declared size where there is one, so an oversized body is never allocated.
const int declared = session.http.getSize();
if (declared > maxBytes) return Status::failure("response exceeds maxBytes");
const String payload = session.http.getString();
if (static_cast<int32_t>(payload.length()) > maxBytes) return Status::failure("response exceeds maxBytes");
response.status = status;
response.body.assign(payload.c_str(), payload.length());
return Status::success();
}
Status Http::download(const std::string& url, const std::string& destination, const esp32lua::HttpDownload& options,
int32_t& bytesWritten) {
if (!isHttps(url)) return Status::failure("download requires an HTTPS URL");
if (SD.exists(destination.c_str())) return Status::failure("destination exists");
Session session;
if (!session.begin(url)) return Status::failure("cannot reach " + url);
if (session.http.GET() != HTTP_CODE_OK) return Status::failure("download failed");
const int declared = session.http.getSize();
if (declared > 0 && declared > options.maxBytes) return Status::failure("download exceeds maxBytes");
File out = SD.open(destination.c_str(), FILE_WRITE);
if (!out) return Status::failure("cannot open destination");
const int result = session.http.writeToStream(&out);
out.close();
const int32_t written = result > 0 ? result : 0;
const char* failure = nullptr;
if (result < 0) {
failure = "download failed";
} else if (written == 0) {
failure = "download aborted";
} else if (written > options.maxBytes) {
failure = "download exceeds maxBytes";
} else if (options.expectedSize && written != options.expectedSize) {
failure = "size did not match expectedSize";
}
if (!failure && !options.sha256.empty()) {
uint8_t digest[32];
if (!hashFile(destination.c_str(), digest) || !equalsIgnoreCase(options.sha256, hex(digest, sizeof(digest)))) {
failure = "SHA-256 did not match";
}
}
// Partial or unverified output never survives, so a retry starts clean.
if (failure) {
SD.remove(destination.c_str());
return Status::failure(failure);
}
bytesWritten = written;
return Status::success();
}
Status Wifi::scan(std::vector<esp32lua::WifiNetwork>& networks) {
const int found = WiFi.scanNetworks();
if (found < 0) return Status::failure("scan failed");
for (int at = 0; at < found; at++) {
esp32lua::WifiNetwork network;
network.ssid = WiFi.SSID(at).c_str();
network.rssi = WiFi.RSSI(at);
network.secure = WiFi.encryptionType(at) != WIFI_AUTH_OPEN;
if (!network.ssid.empty()) networks.push_back(network);
}
WiFi.scanDelete();
return Status::success();
}
Status Wifi::connect(const std::string* ssid, const std::string* password) {
if (ssid) {
if (ssid->empty()) return Status::failure("ssid is empty");
settings.wifiSsid = ssid->c_str();
settings.wifiPassword = password ? password->c_str() : "";
if (!settings.save()) return Status::failure("cannot save credentials");
}
if (settings.wifiSsid.isEmpty()) return Status::failure("no saved network");
WiFi.mode(WIFI_STA);
WiFi.begin(settings.wifiSsid.c_str(), settings.wifiPassword.c_str());
return Status::success();
}
esp32lua::WifiStatus Wifi::status() const {
esp32lua::WifiStatus status;
status.ssid = WiFi.SSID().c_str();
status.ip = WiFi.localIP().toString().c_str();
status.rssi = WiFi.RSSI();
switch (WiFi.status()) {
case WL_CONNECTED:
status.state = "connected";
break;
case WL_IDLE_STATUS:
case WL_DISCONNECTED:
status.state = settings.wifiSsid.isEmpty() ? "disconnected" : "connecting";
break;
case WL_NO_SSID_AVAIL:
status.state = "not_found";
break;
default:
status.state = "failed";
break;
}
if (status.state != "connected") status.ip = "0.0.0.0";
return status;
}
void Wifi::disconnect() { WiFi.disconnect(); }
Status Wifi::forget() {
WiFi.disconnect(true);
settings.wifiSsid = "";
settings.wifiPassword = "";
return settings.save() ? Status::success() : Status::failure("cannot save settings");
}
} // namespace slate
+112
View File
@@ -0,0 +1,112 @@
#include <Arduino.h>
#include <esp_heap_caps.h>
#include "../net.h"
#include "../settings.h"
#include "lua_host.h"
#include "providers.h"
namespace slate {
using esp32lua::Status;
void Log::write(esp32lua::LogLevel level, const std::string& message) {
const char* tag = level == esp32lua::LogLevel::Error ? "error"
: (level == esp32lua::LogLevel::Info ? "info" : "debug");
Serial.printf("[lua] %s: %s\n", tag, message.c_str());
}
int32_t Settings::rotation() const { return settings.rotation; }
// Applies the rotation itself, because a setter that needs a follow-up call is a bug in the
// setter: the frame, the viewport and the bar all move together or not at all.
Status Settings::setRotation(int32_t degrees) {
if (!settings.setRotation(degrees)) return Status::failure("rotation must be 0, 90, 180 or 270");
host.applyRotation();
return settings.save() ? Status::success() : Status::failure("cannot save settings");
}
std::string Settings::timezone() const { return settings.timezone.c_str(); }
Status Settings::setTimezone(const std::string& timezone) {
if (timezone.empty() || timezone.size() > 48) return Status::failure("timezone must be 1 to 48 characters");
settings.timezone = timezone.c_str();
net::applyTimezone();
return settings.save() ? Status::success() : Status::failure("cannot save settings");
}
int32_t Sys::millis() const { return static_cast<int32_t>(::millis()); }
esp32lua::MemoryInfo Sys::memory() const {
esp32lua::MemoryInfo info;
info.freeBytes = static_cast<int32_t>(ESP.getFreeHeap());
info.totalBytes = static_cast<int32_t>(ESP.getHeapSize());
info.largestFreeBlock = static_cast<int32_t>(heap_caps_get_largest_free_block(MALLOC_CAP_DEFAULT));
return info;
}
bool Sys::isClockSynced() const { return net::clockSynced(); }
bool Touch::touch(int32_t& x, int32_t& y) const {
if (!panel.touched()) return false;
int16_t mappedX = 0, mappedY = 0;
host.mapTouch(panel.getPoint(), mappedX, mappedY);
x = mappedX;
y = mappedY;
return true;
}
bool Touch::rawTouch(int32_t& x, int32_t& y) const {
if (!panel.touched()) return false;
const TS_Point point = panel.getPoint();
// The controller's axes are swapped relative to the panel, which calibration undoes.
x = point.y;
y = point.x;
return true;
}
bool Touch::isTouched() const { return panel.touched(); }
Status Touch::setCalibration(int32_t x0, int32_t y0, int32_t x1, int32_t y1) {
settings.touchX0 = x0;
settings.touchY0 = y0;
settings.touchX1 = x1;
settings.touchY1 = y1;
return settings.save() ? Status::success() : Status::failure("cannot save settings");
}
// A fixed slot table rather than a heap list: the whole point of the arena elsewhere is that
// a Lua app cannot fragment the heap, and timers are created from Lua.
Status Timer::schedule(esp32lua::TimerId id, int32_t intervalMs, bool repeating) {
for (int at = 0; at < SLOTS; at++) {
if (slots[at].id != 0) continue;
slots[at].id = id;
slots[at].dueMs = ::millis() + intervalMs;
slots[at].intervalMs = intervalMs;
slots[at].repeating = repeating;
return Status::success();
}
return Status::failure("no timer slot available");
}
void Timer::cancel(esp32lua::TimerId id) {
for (int at = 0; at < SLOTS; at++) {
if (slots[at].id == id) slots[at] = Slot();
}
}
void Timer::collectDue(uint32_t nowMs, std::vector<esp32lua::TimerId>& due) {
for (int at = 0; at < SLOTS; at++) {
if (slots[at].id == 0) continue;
// Signed comparison, so a deadline still pending across the millis() wrap is not "due".
if (static_cast<int32_t>(nowMs - slots[at].dueMs) < 0) continue;
due.push_back(slots[at].id);
if (slots[at].repeating) {
slots[at].dueMs = nowMs + slots[at].intervalMs;
} else {
slots[at] = Slot();
}
}
}
} // namespace slate
-33
View File
@@ -1,33 +0,0 @@
#pragma once
// Shared internals for the binding translation units. Each `gui`, `sys`, ... table
// lives in its own file so that adding a binding touches one small file rather than
// the runtime everything else also edits.
extern "C" {
#include <lauxlib.h>
#include <lua.h>
}
#include <Arduino.h>
class LuaApp;
// The owning app, recovered from the state itself rather than a file-static, so a
// second lua_State (a coroutine, or a future second app) cannot reach the wrong one.
LuaApp* app(lua_State* L);
void bindApp(lua_State* L, LuaApp* owner);
void registerGui(lua_State* L);
void registerNode(lua_State* L);
void registerSys(lua_State* L);
void registerSettings(lua_State* L);
void registerInput(lua_State* L);
void registerFs(lua_State* L);
void registerWifi(lua_State* L);
void registerHttp(lua_State* L);
// Reads a script through the SD mount; Lua's stock loaders use stdio, which cannot
// see it. Shared because both `require` and the app launcher need it.
bool readScript(const char* path, String& out);
int loadScript(lua_State* L, const char* path);
-91
View File
@@ -1,91 +0,0 @@
// SD card access. Paths are absolute and rooted at the card.
#include <SD.h>
#include "../bindings.h"
#include "../lua_app.h"
static int l_fs_readFile(lua_State* L) {
File file = SD.open(luaL_checkstring(L, 1));
if (!file || file.isDirectory()) {
lua_pushnil(L);
return 1;
}
// ponytail: whole-file reads only, capped at 64KB
String content = file.readString();
file.close();
if (content.length() > LuaApp::READ_CAP) content.remove(LuaApp::READ_CAP);
lua_pushlstring(L, content.c_str(), content.length());
return 1;
}
static int l_fs_writeFile(lua_State* L) {
File file = SD.open(luaL_checkstring(L, 1), FILE_WRITE);
if (!file) {
lua_pushboolean(L, false);
return 1;
}
size_t length;
const char* data = luaL_checklstring(L, 2, &length);
bool ok = file.write(reinterpret_cast<const uint8_t*>(data), length) == length;
file.close();
lua_pushboolean(L, ok);
return 1;
}
static int l_fs_exists(lua_State* L) {
lua_pushboolean(L, SD.exists(luaL_checkstring(L, 1)));
return 1;
}
// Directories and files list through the same walk, differing only in what they keep.
static int listEntries(lua_State* L, bool directories) {
File dir = SD.open(luaL_checkstring(L, 1));
lua_newtable(L);
if (!dir || !dir.isDirectory()) {
if (dir) dir.close();
return 1;
}
int index = 1;
for (File entry = dir.openNextFile(); entry; entry = dir.openNextFile()) {
if (entry.isDirectory() == directories && entry.name()[0] != '.') {
lua_pushstring(L, entry.name());
lua_rawseti(L, -2, index++);
}
entry.close();
}
dir.close();
return 1;
}
static int l_fs_listDirs(lua_State* L) { return listEntries(L, true); }
static int l_fs_listFiles(lua_State* L) { return listEntries(L, false); }
void registerFs(lua_State* L) {
static const luaL_Reg lib[] = {
// --- Reads a whole file from the SD card.
// @param path string Absolute path.
// @return string|nil Contents truncated to 65536 bytes, or nil when missing.
{"readFile", l_fs_readFile},
// --- Writes a whole file to the SD card, replacing it if it exists.
// @param path string Absolute path.
// @param content string
// @return boolean
{"writeFile", l_fs_writeFile},
// --- Whether a path exists.
// @param path string Absolute path.
// @return boolean
{"exists", l_fs_exists},
// --- Names of the files in a directory, excluding dotfiles.
// @param path string Absolute path.
// @return string[]
{"listFiles", l_fs_listFiles},
// --- Names of the subdirectories in a directory, excluding dotfiles.
// @param path string Absolute path.
// @return string[]
{"listDirs", l_fs_listDirs},
{nullptr, nullptr}};
luaL_newlib(L, lib);
lua_setglobal(L, "fs");
}
-215
View File
@@ -1,215 +0,0 @@
// Drawing primitives. Colours are RGB565 integers throughout.
#include <TFT_eSPI.h>
#include "../../ui/paint.h"
#include "../bindings.h"
#include "../lua_app.h"
static int l_gui_width(lua_State* L) {
lua_pushinteger(L, app(L)->tft.width());
return 1;
}
static int l_gui_height(lua_State* L) {
lua_pushinteger(L, app(L)->tft.height());
return 1;
}
static int l_gui_clear(lua_State* L) {
app(L)->tft.fillScreen(luaL_optinteger(L, 1, TFT_WHITE));
return 0;
}
static int l_gui_fillRect(lua_State* L) {
app(L)->tft.fillRect(luaL_checkinteger(L, 1), luaL_checkinteger(L, 2), luaL_checkinteger(L, 3),
luaL_checkinteger(L, 4), luaL_checkinteger(L, 5));
return 0;
}
static int l_gui_drawRect(lua_State* L) {
app(L)->tft.drawRect(luaL_checkinteger(L, 1), luaL_checkinteger(L, 2), luaL_checkinteger(L, 3),
luaL_checkinteger(L, 4), luaL_checkinteger(L, 5));
return 0;
}
// Anti-aliased, so it needs the surface behind it to blend the rim against.
static int l_gui_fillCircle(lua_State* L) {
app(L)->tft.fillSmoothCircle(luaL_checkinteger(L, 1), luaL_checkinteger(L, 2),
luaL_checkinteger(L, 3), luaL_checkinteger(L, 4),
luaL_optinteger(L, 5, TFT_WHITE));
return 0;
}
static int l_gui_drawLine(lua_State* L) {
app(L)->tft.drawLine(luaL_checkinteger(L, 1), luaL_checkinteger(L, 2), luaL_checkinteger(L, 3),
luaL_checkinteger(L, 4), luaL_checkinteger(L, 5));
return 0;
}
// The same primitive the node tree paints buttons and cards with, so a Lua custom
// painter and a built-in widget cannot render a different rounded rect.
static int l_gui_roundRect(lua_State* L) {
bool hasFill = !lua_isnoneornil(L, 7);
bool hasBorder = !lua_isnoneornil(L, 9);
uint16_t top = hasFill ? luaL_checkinteger(L, 7) : 0;
uint16_t bottom = lua_isnoneornil(L, 8) ? top : luaL_checkinteger(L, 8);
ui::drawRoundRect(app(L)->tft, luaL_checkinteger(L, 1), luaL_checkinteger(L, 2),
luaL_checkinteger(L, 3), luaL_checkinteger(L, 4), luaL_checkinteger(L, 5),
luaL_checkinteger(L, 6), hasFill, top, bottom, hasBorder,
hasBorder ? luaL_checkinteger(L, 9) : 0);
return 0;
}
static int l_gui_fontHeight(lua_State* L) {
lua_pushinteger(L, app(L)->tft.fontHeight());
return 1;
}
// Integer multiples of the one built-in font, which is all the driver offers. fontHeight()
// and textWidth() follow it, so layout keeps working at any size.
static int l_gui_setTextSize(lua_State* L) {
app(L)->tft.setTextSize(constrain(luaL_checkinteger(L, 1), 1, 8));
return 0;
}
static int l_gui_textWidth(lua_State* L) {
lua_pushinteger(L, app(L)->tft.textWidth(luaL_checkstring(L, 1)));
return 1;
}
static int l_gui_drawText(lua_State* L) {
const char* text = luaL_checkstring(L, 1);
int x = luaL_checkinteger(L, 2);
int y = luaL_checkinteger(L, 3);
// No background colour means transparent glyphs. An opaque fill is one flat colour, so
// text over a gradient shows its own patch; the caller that repaints the surface behind
// the text first does not need the fill at all.
if (lua_isnoneornil(L, 5)) {
app(L)->tft.setTextColor(luaL_optinteger(L, 4, TFT_BLACK));
} else {
app(L)->tft.setTextColor(luaL_optinteger(L, 4, TFT_BLACK), lua_tointeger(L, 5));
}
app(L)->tft.drawString(text, x, y);
return 0;
}
// Only the current frame; settings.setRotation() is the persisted one. Degrees, matching
// settings: the 0-3 quarter turn index is TFT_eSPI's unit and stops at this boundary.
static int l_gui_setRotation(lua_State* L) {
lua_Integer degrees = luaL_checkinteger(L, 1);
luaL_argcheck(L, degrees % 90 == 0 && degrees >= 0 && degrees <= 270, 1, "0, 90, 180 or 270");
app(L)->tft.setRotation((degrees / 90) & 3);
app(L)->applyViewport();
app(L)->refreshStatusBar(); // every slot in the bar just moved
return 0;
}
// The frame being drawn right now, which is not settings.getRotation(): an app may rotate
// the panel transiently, and chrome has to follow the pixels rather than the preference.
static int l_gui_getRotation(lua_State* L) {
lua_pushinteger(L, app(L)->tft.getRotation() * 90);
return 1;
}
// Escape hatch for an app that must reach the physical edges, like touch calibration:
// the status bar and the viewport that keeps apps out of it both go away.
static int l_gui_fullscreen(lua_State* L) {
app(L)->setFullscreen(lua_toboolean(L, 1));
return 0;
}
// ponytail: RGB565 hex constants (0xF800 etc.) live in Lua; apps do gui.color(255,0,0) here.
static int l_gui_color(lua_State* L) {
lua_pushinteger(L, app(L)->tft.color565(luaL_checkinteger(L, 1), luaL_checkinteger(L, 2),
luaL_checkinteger(L, 3)));
return 1;
}
void registerGui(lua_State* L) {
static const luaL_Reg lib[] = {
// --- Panel width in pixels, for the current rotation.
// @return integer
{"getWidth", l_gui_width},
// --- Hands the app the whole panel, hiding the status bar, until it is turned off.
// @param on boolean
{"setFullscreen", l_gui_fullscreen},
// --- Panel height in pixels, for the current rotation.
// @return integer
{"getHeight", l_gui_height},
// --- Fills the whole panel with one color.
// @param color integer|nil Defaults to white.
{"clear", l_gui_clear},
// --- Fills a rectangle.
// @param x integer
// @param y integer
// @param w integer
// @param h integer
// @param color integer
{"fillRect", l_gui_fillRect},
// --- Strokes a one pixel rectangle outline.
// @param x integer
// @param y integer
// @param w integer
// @param h integer
// @param color integer
{"drawRect", l_gui_drawRect},
// --- Fills an anti-aliased circle, blending its rim against the surface behind it.
// @param x integer Center.
// @param y integer Center.
// @param radius integer
// @param color integer
// @param bg integer|nil Surface color to blend against, defaults to white.
{"fillCircle", l_gui_fillCircle},
// --- Strokes a line.
// @param x1 integer
// @param y1 integer
// @param x2 integer
// @param y2 integer
// @param color integer
{"drawLine", l_gui_drawLine},
// --- Draws text with an opaque background behind its glyphs.
// @param text string
// @param x integer
// @param y integer
// @param color integer|nil Defaults to black.
// @param bg integer|nil Omitted draws transparent glyphs, over whatever is behind them.
{"drawText", l_gui_drawText},
// --- Draws a rounded rectangle: fill, vertical gradient and border from one
// --- distance field, so the edges cannot disagree.
// @param x integer
// @param y integer
// @param w integer
// @param h integer
// @param radius integer Clamped to half the shorter side.
// @param bg integer Surface color the anti-aliased edge blends against.
// @param top integer|nil Fill color, or the top of the gradient.
// @param bottom integer|nil Bottom of the gradient, defaults to top.
// @param border integer|nil Border color; omitted draws no border.
{"roundRect", l_gui_roundRect},
// --- Scales the built-in font by a whole number, for this app only.
// @param size integer 1 to 8.
{"setTextSize", l_gui_setTextSize},
// --- Height of the current font in pixels, at the current text size.
// @return integer
{"getFontHeight", l_gui_fontHeight},
// --- Width the given text would occupy in pixels.
// @param text string
// @return integer
{"getTextWidth", l_gui_textWidth},
// --- Rotates the frame for this draw only; settings.setRotation persists it.
// @param degrees integer 0, 90, 180 or 270.
{"setRotation", l_gui_setRotation},
// --- Rotation of the frame being drawn, which is not always the saved preference.
// @return integer Degrees clockwise.
{"getRotation", l_gui_getRotation},
// --- Packs 8 bit channels into the panel's RGB565 color format.
// @param r integer
// @param g integer
// @param b integer
// @return integer
{"color", l_gui_color},
{nullptr, nullptr}};
luaL_newlib(L, lib);
lua_setglobal(L, "gui");
}
-322
View File
@@ -1,322 +0,0 @@
// HTTP client. Certificates are verified against the root bundle already embedded in
// the framework, and nothing here lets a script turn that off: the signatures have no
// options table to hang it on, and the one caller that would want it most -- a firmware
// update -- is the one that can least afford an unauthenticated peer.
#include <HTTPClient.h>
#include <SD.h>
#include <WiFiClient.h>
#include <WiFiClientSecure.h>
#include <mbedtls/sha256.h>
#include "../bindings.h"
#include "../lua_app.h"
extern const uint8_t rootca_crt_bundle_start[] asm("_binary_x509_crt_bundle_start");
namespace {
constexpr size_t MAX_RESPONSE = 50000;
constexpr uint32_t TIMEOUT_MS = 60000;
constexpr int REDIRECT_LIMIT = 5;
constexpr size_t DOWNLOAD_CHUNK = 2048;
constexpr uint32_t MAX_DOWNLOAD_BYTES = 16 * 1024 * 1024;
constexpr int STATUS_UNSENT = -1; // the request never left the device
bool isHttps(const char* url) { return strncmp(url, "https://", 8) == 0; }
// Returned by value so the TLS client's ~4KB of state is freed with the request rather
// than held for the life of the app.
struct Session {
WiFiClient plain;
WiFiClientSecure secure;
HTTPClient http;
bool begin(const char* url) {
if (isHttps(url)) {
secure.setCACertBundle(rootca_crt_bundle_start);
secure.setTimeout(TIMEOUT_MS / 1000);
if (!http.begin(secure, url)) return false;
} else if (!http.begin(plain, url)) {
return false;
}
http.setTimeout(TIMEOUT_MS);
http.setConnectTimeout(TIMEOUT_MS);
http.setFollowRedirects(HTTPC_STRICT_FOLLOW_REDIRECTS);
http.setRedirectLimit(REDIRECT_LIMIT);
http.setUserAgent("slate32");
return true;
}
};
void applyHeaders(lua_State* L, int index, HTTPClient& http) {
if (!lua_istable(L, index)) return;
lua_pushnil(L);
while (lua_next(L, index) != 0) {
if (lua_isstring(L, -2) && lua_isstring(L, -1)) {
http.addHeader(lua_tostring(L, -2), lua_tostring(L, -1));
}
lua_pop(L, 1);
}
}
// get/head/delete take headers in slot 2; post/patch take a body there and headers in 3.
// A string in slot 2 of a bodyless method is rejected rather than reinterpreted, since
// a mistyped headers table would otherwise become a silent protocol error.
int request(lua_State* L, const char* method, bool bodyExpected) {
const char* url = luaL_checkstring(L, 1);
const char* body = "";
int headerIndex = 2;
if (bodyExpected) {
body = luaL_optstring(L, 2, "");
headerIndex = 3;
} else if (lua_isstring(L, 2)) {
return luaL_error(L, "%s takes a headers table, not a string", method);
}
if (!lua_isnoneornil(L, headerIndex)) luaL_checktype(L, headerIndex, LUA_TTABLE);
Session session;
if (!session.begin(url)) {
lua_pushnil(L);
lua_pushinteger(L, STATUS_UNSENT);
return 2;
}
applyHeaders(L, headerIndex, session.http);
int status = session.http.sendRequest(method, (uint8_t*)body, strlen(body));
if (status <= 0) {
lua_pushnil(L);
lua_pushinteger(L, status == 0 ? STATUS_UNSENT : status);
return 2;
}
int size = session.http.getSize();
if (size > (int)MAX_RESPONSE) {
lua_pushnil(L);
lua_pushinteger(L, status);
return 2;
}
String payload = session.http.getString();
if (payload.length() > MAX_RESPONSE) {
lua_pushnil(L);
lua_pushinteger(L, status);
return 2;
}
lua_pushlstring(L, payload.c_str(), payload.length());
lua_pushinteger(L, status);
return 2;
}
int l_http_get(lua_State* L) { return request(L, "GET", false); }
int l_http_head(lua_State* L) { return request(L, "HEAD", false); }
int l_http_delete(lua_State* L) { return request(L, "DELETE", false); }
int l_http_post(lua_State* L) { return request(L, "POST", true); }
int l_http_patch(lua_State* L) { return request(L, "PATCH", true); }
int fail(lua_State* L, const char* message) {
lua_pushnil(L);
lua_pushstring(L, message);
return 2;
}
// Unknown keys are rejected: a misspelled `sha256` that was quietly ignored would
// report a verified download that verified nothing.
bool readOptions(lua_State* L, int index, uint32_t& maxBytes, uint32_t& expectedSize,
String& sha256, const char*& error) {
luaL_checktype(L, index, LUA_TTABLE);
lua_pushnil(L);
while (lua_next(L, index) != 0) {
const char* key = lua_isstring(L, -2) ? lua_tostring(L, -2) : "";
if (strcmp(key, "maxBytes") == 0) {
maxBytes = lua_tointeger(L, -1);
} else if (strcmp(key, "expectedSize") == 0) {
expectedSize = lua_tointeger(L, -1);
} else if (strcmp(key, "sha256") == 0) {
sha256 = lua_isstring(L, -1) ? lua_tostring(L, -1) : "";
} else {
lua_pop(L, 2);
error = "Unknown http.download option";
return false;
}
lua_pop(L, 1);
}
if (maxBytes < 1 || maxBytes > MAX_DOWNLOAD_BYTES) {
error = "Download maxBytes must be between 1 and 16777216";
return false;
}
if (expectedSize > maxBytes) {
error = "Download expectedSize exceeds maxBytes";
return false;
}
if (sha256.length()) {
if (sha256.length() != 64) {
error = "Download sha256 must be 64 hex characters";
return false;
}
for (size_t i = 0; i < 64; i++) {
if (!isxdigit((unsigned char)sha256[i])) {
error = "Download sha256 must be 64 hex characters";
return false;
}
}
}
return true;
}
// Hashed by re-reading rather than while streaming, because the write is HTTPClient's
// loop now. A firmware image costs about a second of SD reads, once, on the path that
// is already downloading megabytes.
bool hashFile(const char* path, uint8_t digest[32]) {
File file = SD.open(path);
if (!file) return false;
mbedtls_sha256_context sha;
mbedtls_sha256_init(&sha);
mbedtls_sha256_starts_ret(&sha, 0);
// Static: the loop task's stack is mostly spoken for by TLS.
static uint8_t chunk[DOWNLOAD_CHUNK];
while (file.available()) {
int read = file.read(chunk, sizeof(chunk));
if (read <= 0) break;
mbedtls_sha256_update_ret(&sha, chunk, read);
}
file.close();
mbedtls_sha256_finish_ret(&sha, digest);
mbedtls_sha256_free(&sha);
return true;
}
String hex(const uint8_t* digest, size_t length) {
String out;
for (size_t i = 0; i < length; i++) {
char pair[3];
snprintf(pair, sizeof(pair), "%02x", digest[i]);
out += pair;
}
return out;
}
// Streams to the card: a firmware image cannot fit in a Lua string, which is the whole
// reason this exists next to http.get.
int l_http_download(lua_State* L) {
const char* url = luaL_checkstring(L, 1);
const char* destination = luaL_checkstring(L, 2);
uint32_t maxBytes = 0, expectedSize = 0;
String expectedSha;
const char* error = nullptr;
if (!readOptions(L, 3, maxBytes, expectedSize, expectedSha, error)) return fail(L, error);
if (!isHttps(url)) return fail(L, "Download requires a valid HTTPS URL");
if (SD.exists(destination)) return fail(L, "Download destination failed");
Session session;
if (!session.begin(url)) return fail(L, "HTTPS download failed");
int status = session.http.GET();
if (status != HTTP_CODE_OK) return fail(L, "HTTPS download failed");
// Refused before a byte is written when the server declares the size up front.
int declared = session.http.getSize();
if (declared > 0 && (uint32_t)declared > maxBytes) return fail(L, "Download exceeded maxBytes");
File out = SD.open(destination, FILE_WRITE);
if (!out) return fail(L, "Download destination failed");
// HTTPClient's own reader handles chunked encoding, short reads and idle timeouts.
// A hand-rolled loop here spun forever on a stream that stopped producing.
int result = session.http.writeToStream(&out);
out.close();
uint32_t written = result > 0 ? (uint32_t)result : 0;
const char* failure = nullptr;
if (result < 0) failure = "HTTPS download failed";
if (!failure && written > maxBytes) failure = "Download exceeded maxBytes";
uint8_t digest[32];
if (!failure && expectedSha.length()) hashFile(destination, digest);
if (!failure && expectedSize && written != expectedSize) {
failure = "Downloaded size did not match expectedSize";
}
if (!failure && expectedSha.length() && !expectedSha.equalsIgnoreCase(hex(digest, sizeof(digest)))) {
failure = "Downloaded SHA-256 did not match";
}
if (!failure && written == 0) failure = "Download aborted";
// A partial or unverified file must not survive: the next boot would treat it as good.
if (failure) {
SD.remove(destination);
return fail(L, failure);
}
lua_pushinteger(L, written);
return 1;
}
int l_http_urlencode(lua_State* L) {
size_t length;
const char* input = luaL_checklstring(L, 1, &length);
String out;
for (size_t i = 0; i < length; i++) {
char c = input[i];
if (isalnum((unsigned char)c) || c == '-' || c == '_' || c == '.' || c == '~') {
out += c;
} else {
char escaped[4];
snprintf(escaped, sizeof(escaped), "%%%02X", (unsigned char)c);
out += escaped;
}
}
lua_pushlstring(L, out.c_str(), out.length());
return 1;
}
} // namespace
void registerHttp(lua_State* L) {
static const luaL_Reg lib[] = {
// --- Fetches a URL. Certificates are verified against the embedded root bundle.
// @param url string
// @param headers table|nil Header name to value.
// @return string|nil Response body, or nil when the status is not 2xx or the body exceeds 50000 bytes.
// @return integer HTTP status, or -1 when the request could not be sent.
{"get", l_http_get},
// --- Fetches a URL, discarding the body.
// @param url string
// @param headers table|nil Header name to value.
// @return string|nil Response body, always empty on success.
// @return integer HTTP status, or -1 when the request could not be sent.
{"head", l_http_head},
// --- Deletes a resource.
// @param url string
// @param headers table|nil Header name to value.
// @return string|nil Response body.
// @return integer HTTP status, or -1 when the request could not be sent.
{"delete", l_http_delete},
// --- Posts a body to a URL.
// @param url string
// @param body string|nil Request body, empty when omitted.
// @param headers table|nil Header name to value.
// @return string|nil Response body.
// @return integer HTTP status, or -1 when the request could not be sent.
{"post", l_http_post},
// --- Patches a resource.
// @param url string
// @param body string|nil Request body, empty when omitted.
// @param headers table|nil Header name to value.
// @return string|nil Response body.
// @return integer HTTP status, or -1 when the request could not be sent.
{"patch", l_http_patch},
// --- Streams an HTTPS URL to a file, checking size and digest before keeping it.
// @param url string Must be https.
// @param destination string Absolute path that must not already exist.
// @param options table maxBytes is required; expectedSize and sha256 are optional.
// @return integer|nil Bytes written, or nil on failure.
// @return string|nil Error message when the download failed.
{"download", l_http_download},
// --- Percent-encodes a string, keeping the RFC 3986 unreserved characters.
// @param input string
// @return string
{"urlencode", l_http_urlencode},
{nullptr, nullptr}};
luaL_newlib(L, lib);
lua_setglobal(L, "http");
}
-54
View File
@@ -1,54 +0,0 @@
// Touch panel reads. Coordinates are calibrated and rotated by LuaApp::mapTouch;
// the raw ADC pair is exposed separately because calibration cannot use itself.
#include "../bindings.h"
#include "../lua_app.h"
static int l_input_getTouch(lua_State* L) {
LuaApp* owner = app(L);
if (!owner->touch.touched()) {
lua_pushnil(L);
return 1;
}
TS_Point point = owner->touch.getPoint();
int16_t x, y;
owner->mapTouch(point, x, y);
lua_pushinteger(L, x);
lua_pushinteger(L, y);
return 2;
}
static int l_input_getRawTouch(lua_State* L) {
LuaApp* owner = app(L);
if (!owner->touch.touched()) {
lua_pushnil(L);
return 1;
}
TS_Point point = owner->touch.getPoint();
lua_pushinteger(L, point.y);
lua_pushinteger(L, point.x);
return 2;
}
static int l_input_touched(lua_State* L) {
lua_pushboolean(L, app(L)->touch.touched());
return 1;
}
void registerInput(lua_State* L) {
static const luaL_Reg lib[] = {
// --- Current touch point, calibrated and rotated.
// @return integer|nil X, or nil when the panel is not touched.
// @return integer|nil Y.
{"getTouch", l_input_getTouch},
// --- Current touch point as raw ADC readings, for calibration.
// @return integer|nil X, or nil when the panel is not touched.
// @return integer|nil Y.
{"getRawTouch", l_input_getRawTouch},
// --- Whether the panel is being touched.
// @return boolean
{"isTouched", l_input_touched},
{nullptr, nullptr}};
luaL_newlib(L, lib);
lua_setglobal(L, "input");
}
-469
View File
@@ -1,469 +0,0 @@
// The retained widget tree. Lua describes a screen once and gets integer handles back;
// the nodes themselves live in a flat C++ arena at 16 bytes each, where the same tree
// as Lua tables cost roughly forty times that.
//
// This namespace owns structure and geometry only. Composition (ui.confirm, ui.label),
// the palette, and press callbacks stay in /lib/ui.lua -- a callback keyed by node id in
// an ordinary Lua table is cheaper than a registry reference and far less bookkeeping.
#include <TFT_eSPI.h>
#include "../../ui/paint.h"
#include "../bindings.h"
#include "../lua_app.h"
// The Lua function that paints CUSTOM nodes, if the toolkit installed one. A single
// registry slot for the whole tree: Lua keys its own painters by node id.
static const char* PAINTER_KEY = "slate32.node.painter";
static ui::Tree& tree(lua_State* L) { return app(L)->tree; }
// Every id crosses from Lua, so every id is checked: a stale handle must raise a Lua
// error, not index past the end of the arena.
static uint16_t checkId(lua_State* L, int index) {
lua_Integer id = luaL_checkinteger(L, index);
luaL_argcheck(L, id >= 0 && static_cast<size_t>(id) < tree(L).nodes.size(), index,
"no such node");
return static_cast<uint16_t>(id);
}
// Pixels (>= 1), a fraction of the parent content box (< 1), "fill" for all of it, or
// "auto" to size to content. Fractions are carried as per-mille so layout stays integral.
static ui::Size decodeSize(lua_State* L, int index) {
if (lua_isnumber(L, index)) {
double value = lua_tonumber(L, index);
return value < 1.0 ? ui::Size::fraction(static_cast<int16_t>(value * 1000.0 + 0.5))
: ui::Size::px(static_cast<int16_t>(value));
}
if (lua_isstring(L, index) && strcmp(lua_tostring(L, index), "fill") == 0) {
return ui::Size::fill();
}
return ui::Size();
}
static ui::Size readSize(lua_State* L, int table, const char* key) {
lua_getfield(L, table, key);
ui::Size size = decodeSize(L, -1);
lua_pop(L, 1);
return size;
}
static int readInt(lua_State* L, int table, const char* key, int fallback = 0) {
lua_getfield(L, table, key);
int value = lua_isnumber(L, -1) ? static_cast<int>(lua_tointeger(L, -1)) : fallback;
lua_pop(L, 1);
return value;
}
static bool readBool(lua_State* L, int table, const char* key) {
lua_getfield(L, table, key);
bool value = lua_toboolean(L, -1);
lua_pop(L, 1);
return value;
}
static ui::Align readAlign(lua_State* L, int table, const char* key) {
lua_getfield(L, table, key);
const char* name = lua_isstring(L, -1) ? lua_tostring(L, -1) : "start";
ui::Align align = ui::START;
if (strcmp(name, "center") == 0) {
align = ui::CENTER;
} else if (strcmp(name, "end") == 0) {
align = ui::END;
} else if (strcmp(name, "between") == 0) {
align = ui::BETWEEN;
}
lua_pop(L, 1);
return align;
}
// A number pads all four sides; a table names the ones it wants.
static void readPad(lua_State* L, int table, ui::Spec& spec) {
lua_getfield(L, table, "pad");
if (lua_isnumber(L, -1)) {
uint8_t all = static_cast<uint8_t>(lua_tointeger(L, -1));
spec.padT = spec.padR = spec.padB = spec.padL = all;
} else if (lua_istable(L, -1)) {
int pad = lua_gettop(L);
spec.padT = static_cast<uint8_t>(readInt(L, pad, "t"));
spec.padR = static_cast<uint8_t>(readInt(L, pad, "r"));
spec.padB = static_cast<uint8_t>(readInt(L, pad, "b"));
spec.padL = static_cast<uint8_t>(readInt(L, pad, "l"));
}
lua_pop(L, 1);
}
static uint8_t readType(lua_State* L, int table) {
lua_getfield(L, table, "type");
const char* name = lua_isstring(L, -1) ? lua_tostring(L, -1) : "box";
uint8_t type = ui::BOX;
if (strcmp(name, "text") == 0) {
type = ui::TEXT;
} else if (strcmp(name, "button") == 0) {
type = ui::BUTTON;
} else if (strcmp(name, "custom") == 0) {
type = ui::CUSTOM;
}
lua_pop(L, 1);
return type;
}
static int l_node_reset(lua_State* L) {
tree(L).reset();
return 0;
}
static int l_node_create(lua_State* L) {
uint16_t parent = lua_isnoneornil(L, 1) ? ui::NONE : checkId(L, 1);
luaL_checktype(L, 2, LUA_TTABLE);
int spec_index = 2;
ui::Spec spec;
spec.w = readSize(L, spec_index, "w");
spec.h = readSize(L, spec_index, "h");
spec.gap = static_cast<uint8_t>(readInt(L, spec_index, "gap"));
spec.align = readAlign(L, spec_index, "align");
spec.justify = readAlign(L, spec_index, "justify");
readPad(L, spec_index, spec);
lua_getfield(L, spec_index, "at");
if (lua_istable(L, -1)) {
spec.absolute = true;
spec.atX = readSize(L, lua_gettop(L), "x");
spec.atY = readSize(L, lua_gettop(L), "y");
}
lua_pop(L, 1);
uint8_t flags = 0;
if (readBool(L, spec_index, "row")) flags |= ui::ROW;
if (readBool(L, spec_index, "capture")) flags |= ui::CAPTURE;
if (readBool(L, spec_index, "interactive")) flags |= ui::INTERACTIVE;
// Text is measured here because the panel owns the font: a node sized in Lua would
// need the metrics bindings for every label on every build.
uint8_t type = readType(L, spec_index);
lua_getfield(L, spec_index, "label");
const char* label = lua_tostring(L, -1);
if (label) {
int previous = app(L)->tft.textsize;
app(L)->tft.setTextSize(readInt(L, spec_index, "size", 1));
spec.intrinsicW = static_cast<int16_t>(app(L)->tft.textWidth(label));
spec.intrinsicH = static_cast<int16_t>(app(L)->tft.fontHeight());
app(L)->tft.setTextSize(previous);
}
uint16_t id = tree(L).add(parent, spec, type, flags);
if (label) tree(L).setLabel(id, label);
lua_pop(L, 1);
lua_pushinteger(L, id);
return 1;
}
static int l_node_attach(lua_State* L) {
uint16_t parent = checkId(L, 1);
uint16_t child = checkId(L, 2);
luaL_argcheck(L, child != parent, 2, "a node cannot hold itself");
luaL_argcheck(L, tree(L).nodes[child].parent == ui::NONE, 2, "node already has a parent");
tree(L).attach(parent, child);
return 0;
}
// Only fills in what a node left to "auto", which is how a screen root claims the panel
// without overriding a size its caller actually asked for.
static int l_node_setSize(lua_State* L) {
uint16_t id = checkId(L, 1);
luaL_argcheck(L, id < tree(L).specs.size(), 1, "tree already laid out");
ui::Spec& spec = tree(L).specs[id];
if (spec.w.mode == ui::AUTO) spec.w = decodeSize(L, 2);
if (spec.h.mode == ui::AUTO) spec.h = decodeSize(L, 3);
return 0;
}
static int l_node_layout(lua_State* L) {
uint16_t root = checkId(L, 1);
bool ok = tree(L).layout(root, luaL_checkinteger(L, 2), luaL_checkinteger(L, 3),
luaL_checkinteger(L, 4), luaL_checkinteger(L, 5));
lua_pushboolean(L, ok);
if (ok) return 1;
lua_pushstring(L, tree(L).error ? tree(L).error : "layout failed");
return 2;
}
static int l_node_dropScratch(lua_State* L) {
tree(L).dropScratch();
return 0;
}
static int l_node_hit(lua_State* L) {
uint16_t root = checkId(L, 1);
uint16_t found = tree(L).hit(root, luaL_checkinteger(L, 2), luaL_checkinteger(L, 3));
if (found == ui::NONE) return 0;
lua_pushinteger(L, found);
return 1;
}
static int l_node_getRect(lua_State* L) {
const ui::Node& n = tree(L).nodes[checkId(L, 1)];
lua_pushinteger(L, n.x);
lua_pushinteger(L, n.y);
lua_pushinteger(L, n.w);
lua_pushinteger(L, n.h);
return 4;
}
static int l_node_setLabel(lua_State* L) {
uint16_t id = checkId(L, 1);
tree(L).setLabel(id, luaL_checkstring(L, 2));
return 0;
}
static int l_node_getLabel(lua_State* L) {
const char* label = tree(L).label(checkId(L, 1));
if (!label) return 0;
lua_pushstring(L, label);
return 1;
}
static int l_node_getParent(lua_State* L) {
uint16_t parent = tree(L).nodes[checkId(L, 1)].parent;
if (parent == ui::NONE) return 0;
lua_pushinteger(L, parent);
return 1;
}
static int l_node_getCount(lua_State* L) {
lua_pushinteger(L, static_cast<lua_Integer>(tree(L).nodes.size()));
return 1;
}
static int l_node_setStyle(lua_State* L) {
uint16_t id = checkId(L, 1);
luaL_checktype(L, 2, LUA_TTABLE);
ui::Style& style = tree(L).styleFor(id);
struct Role {
const char* key;
uint16_t field;
uint16_t ui::Style::*slot;
};
static const Role roles[] = {
{"color", ui::S_FG, &ui::Style::fg},
{"bg", ui::S_BG, &ui::Style::bg},
{"fill", ui::S_FILL, &ui::Style::fill},
{"border", ui::S_BORDER, &ui::Style::border},
{"press_color", ui::S_PRESS_FG, &ui::Style::pressFg},
};
for (const Role& role : roles) {
lua_getfield(L, 2, role.key);
if (lua_isnumber(L, -1)) {
style.*(role.slot) = static_cast<uint16_t>(lua_tointeger(L, -1));
style.set |= role.field;
}
lua_pop(L, 1);
}
// Gradients arrive as the {top, bottom} pair the palette derives them in, so a theme
// cannot set half of one.
struct Pair {
const char* key;
uint16_t field;
uint16_t ui::Style::*top;
uint16_t ui::Style::*bottom;
};
static const Pair pairs[] = {
{"face", ui::S_FACE, &ui::Style::faceTop, &ui::Style::faceBottom},
{"face_pressed", ui::S_PRESSED, &ui::Style::pressTop, &ui::Style::pressBottom},
};
for (const Pair& pair : pairs) {
lua_getfield(L, 2, pair.key);
if (lua_istable(L, -1)) {
lua_rawgeti(L, -1, 1);
lua_rawgeti(L, -2, 2);
style.*(pair.top) = static_cast<uint16_t>(lua_tointeger(L, -2));
style.*(pair.bottom) = static_cast<uint16_t>(lua_tointeger(L, -1));
style.set |= pair.field;
lua_pop(L, 2);
}
lua_pop(L, 1);
}
lua_getfield(L, 2, "radius");
if (lua_isnumber(L, -1)) {
style.radius = static_cast<uint8_t>(lua_tointeger(L, -1));
style.set |= ui::S_RADIUS;
}
lua_pop(L, 1);
lua_getfield(L, 2, "size");
if (lua_isnumber(L, -1)) {
style.size = static_cast<uint8_t>(lua_tointeger(L, -1));
style.set |= ui::S_SIZE;
}
lua_pop(L, 1);
lua_getfield(L, 2, "text_align");
bool hasAlign = lua_isstring(L, -1);
lua_pop(L, 1);
if (hasAlign) {
style.textAlign = readAlign(L, 2, "text_align");
style.set |= ui::S_TEXT_ALIGN;
}
return 0;
}
static int l_node_invalidate(lua_State* L) {
tree(L).nodes[checkId(L, 1)].flags |= ui::DIRTY;
return 0;
}
static int l_node_setPressed(lua_State* L) {
ui::Node& n = tree(L).nodes[checkId(L, 1)];
if (lua_toboolean(L, 2)) {
n.flags |= ui::PRESSED;
} else {
n.flags &= ~ui::PRESSED;
}
n.flags |= ui::DIRTY;
return 0;
}
static int l_node_isPressed(lua_State* L) {
lua_pushboolean(L, (tree(L).nodes[checkId(L, 1)].flags & ui::PRESSED) != 0);
return 1;
}
static int l_node_setPainter(lua_State* L) {
luaL_checktype(L, 1, LUA_TFUNCTION);
lua_pushvalue(L, 1);
lua_setfield(L, LUA_REGISTRYINDEX, PAINTER_KEY);
return 0;
}
static void callPainter(void* context, uint16_t id, int x, int y, int w, int h) {
lua_State* L = static_cast<lua_State*>(context);
lua_getfield(L, LUA_REGISTRYINDEX, PAINTER_KEY);
if (!lua_isfunction(L, -1)) {
lua_pop(L, 1);
return;
}
lua_pushinteger(L, id);
lua_pushinteger(L, x);
lua_pushinteger(L, y);
lua_pushinteger(L, w);
lua_pushinteger(L, h);
// A painter that errors must not abandon the rest of the tree unpainted, which is why
// this is pcall: one broken widget leaves a hole, not a blank screen.
if (lua_pcall(L, 5, 0, 0) != LUA_OK) {
log_e("node painter: %s", lua_tostring(L, -1));
lua_pop(L, 1);
}
}
static int l_node_draw(lua_State* L) {
uint16_t root = checkId(L, 1);
ui::Painter painter(app(L)->tft, tree(L));
painter.custom = callPainter;
painter.context = L;
painter.draw(root);
return 0;
}
static int l_node_getFootprint(lua_State* L) {
lua_pushinteger(L, static_cast<lua_Integer>(tree(L).footprint()));
return 1;
}
void registerNode(lua_State* L) {
static const luaL_Reg lib[] = {
// --- Drops the whole tree. Every screen is built from scratch, so this is what a
// --- rebuild starts with; existing ids are invalid afterwards.
{"reset", l_node_reset},
// --- Adds a node and returns its handle.
// @param parent integer|nil Nil creates a root.
// @param spec table Fields: type ("box", "text", "button", "custom"), w, h, pad,
// @param spec table gap, align, justify, row, at, capture, interactive, label, size.
// @return integer
{"create", l_node_create},
// --- Adopts an existing root node as a child, so a container can be built after
// --- the things it holds. The child must not already have a parent.
// @param parent integer
// @param child integer
{"attach", l_node_attach},
// --- Fills in sizes a node left as "auto". Only meaningful before layout.
// @param id integer
// @param w number|string|nil
// @param h number|string|nil
{"setSize", l_node_setSize},
// --- Measures and places a tree into the given rectangle.
// @param root integer
// @param x integer
// @param y integer
// @param w integer
// @param h integer
// @return boolean True on success; false plus a message when a size cannot resolve.
{"layout", l_node_layout},
// --- Frees the layout inputs, which nothing reads once a tree is placed. A screen
// --- calls this after layout and rebuilds from Lua if it ever needs placing again.
{"dropScratch", l_node_dropScratch},
// --- Deepest interactive node covering the point, or nil.
// @param root integer
// @param x integer
// @param y integer
// @return integer|nil
{"hit", l_node_hit},
// --- Placed rectangle of a node.
// @param id integer
// @return integer x
// @return integer y
// @return integer w
// @return integer h
{"getRect", l_node_getRect},
// --- Replaces a node's text. Re-measuring is the caller's business: the node keeps
// --- the box it was placed with until the screen is rebuilt.
// @param id integer
// @param text string
{"setLabel", l_node_setLabel},
// --- A node's text, or nil if it has none.
// @param id integer
// @return string|nil
{"getLabel", l_node_getLabel},
// --- The node that contains this one, or nil at the root.
// @param id integer
// @return integer|nil
{"getParent", l_node_getParent},
// --- Sets the colors and metrics a node states for itself. Anything left out is
// --- answered by the nearest ancestor that states it, so a node that names nothing
// --- costs nothing.
// @param id integer
// @param style table Fields: color, bg, fill, border, press_color, radius, size,
// @param style table and the {top, bottom} pairs face and face_pressed.
{"setStyle", l_node_setStyle},
// --- Marks a node for repainting on the next draw, along with its children.
// @param id integer
{"invalidate", l_node_invalidate},
// --- Shows or clears a node's pressed face.
// @param id integer
// @param on boolean
{"setPressed", l_node_setPressed},
// --- Whether a node is currently showing its pressed face.
// @param id integer
// @return boolean
{"isPressed", l_node_isPressed},
// --- Installs the function that paints "custom" nodes, called with the node id and
// --- its placed rectangle. One painter serves the whole tree.
// @param painter function
{"setPainter", l_node_setPainter},
// --- Repaints every node marked dirty, and everything inside one.
// @param root integer
{"draw", l_node_draw},
// --- How many nodes the current tree holds.
// @return integer
{"getCount", l_node_getCount},
// --- Bytes the current tree occupies, for the memory the design exists to save.
// @return integer
{"getFootprint", l_node_getFootprint},
{nullptr, nullptr}};
luaL_newlib(L, lib);
lua_setglobal(L, "node");
}
-112
View File
@@ -1,112 +0,0 @@
// Persisted device preferences. The firmware is a first-class reader of these -- rotation
// and calibration are needed before any Lua state exists, and on every touch after that --
// so C++ owns the store and Lua reaches it through here rather than parsing the file.
//
// Every setter persists *and* applies its effect, so no caller has to remember a second
// step. The one exception is the theme, whose palette lives in /lib/ui.lua and can only be
// reloaded from Lua; ui.setTheme() is the seam that pairs the two.
#include "../../net.h"
#include "../../settings.h"
#include "../bindings.h"
#include "../lua_app.h"
static int l_settings_getRotation(lua_State* L) {
lua_pushinteger(L, settings.rotation);
return 1;
}
// Persisted, unlike gui.setRotation() which only changes the current frame.
static int l_settings_setRotation(lua_State* L) {
if (!settings.setRotation(luaL_checkinteger(L, 1))) {
lua_pushboolean(L, false);
return 1;
}
app(L)->tft.setRotation(settings.rotationIndex());
app(L)->applyViewport();
app(L)->refreshStatusBar(); // every slot in the bar just moved
lua_pushboolean(L, settings.save());
return 1;
}
static int l_settings_getTheme(lua_State* L) {
lua_pushstring(L, settings.theme.c_str());
return 1;
}
// The firmware only stores the name; /lib/theme.lua decides what it looks like.
static int l_settings_setTheme(lua_State* L) {
size_t length;
const char* name = luaL_checklstring(L, 1, &length);
if (!length || length > 32) {
lua_pushboolean(L, false);
return 1;
}
settings.theme = String(name, length);
lua_pushboolean(L, settings.save());
return 1;
}
static int l_settings_getTimezone(lua_State* L) {
lua_pushstring(L, settings.timezone.c_str());
return 1;
}
// Takes a POSIX TZ rule, not a zone name: /lib/timezones.lua is only a picker, so a
// zone missing from that list is still reachable by writing the rule.
static int l_settings_setTimezone(lua_State* L) {
size_t length;
const char* tz = luaL_checklstring(L, 1, &length);
if (!length || length > 48) {
lua_pushboolean(L, false);
return 1;
}
settings.timezone = String(tz, length);
net::applyTimezone();
lua_pushboolean(L, settings.save());
return 1;
}
static int l_settings_setCalibration(lua_State* L) {
settings.touchX0 = luaL_checkinteger(L, 1);
settings.touchY0 = luaL_checkinteger(L, 2);
settings.touchX1 = luaL_checkinteger(L, 3);
settings.touchY1 = luaL_checkinteger(L, 4);
lua_pushboolean(L, settings.save());
return 1;
}
void registerSettings(lua_State* L) {
static const luaL_Reg lib[] = {
// --- Saved screen rotation in degrees clockwise.
// @return integer
{"getRotation", l_settings_getRotation},
// --- Rotates the screen and saves it.
// @param degrees integer 0, 90, 180 or 270.
// @return boolean Whether the setting was saved.
{"setRotation", l_settings_setRotation},
// --- Name of the active theme in /lib/theme.lua.
// @return string
{"getTheme", l_settings_getTheme},
// --- Saves the theme name. Apps call ui.setTheme(), which also reloads the palette.
// @param name string
// @return boolean Whether the setting was saved.
{"setTheme", l_settings_setTheme},
// --- Active POSIX timezone rule.
// @return string
{"getTimezone", l_settings_getTimezone},
// --- Sets the timezone from a POSIX TZ rule and saves it.
// @param tz string For example EST5EDT,M3.2.0,M11.1.0.
// @return boolean Whether the setting was saved.
{"setTimezone", l_settings_setTimezone},
// --- Stores touch calibration, in the panel's unrotated frame.
// @param x0 integer Raw reading at the left edge.
// @param y0 integer Raw reading at the top edge.
// @param x1 integer Raw reading at the right edge.
// @param y1 integer Raw reading at the bottom edge.
// @return boolean Whether the setting was saved.
{"setCalibration", l_settings_setCalibration},
{nullptr, nullptr}};
luaL_newlib(L, lib);
lua_setglobal(L, "settings");
}
-151
View File
@@ -1,151 +0,0 @@
// Process control and runtime state, plus the one-function `log` table. Persisted
// preferences live in the `settings` table, not here.
#include "../../net.h"
#include "../bindings.h"
#include "../lua_app.h"
static constexpr size_t MAX_APP_NAME = 32;
static constexpr size_t MAX_LAUNCH_ARG = 256;
static int l_sys_millis(lua_State* L) {
lua_pushinteger(L, millis());
return 1;
}
static int l_sys_back(lua_State* L) {
app(L)->requestBack();
return 0;
}
static int l_sys_appName(lua_State* L) {
lua_pushstring(L, app(L)->name().c_str());
return 1;
}
static int navigate(lua_State* L, bool replace) {
const char* arg = lua_isnoneornil(L, 2) ? nullptr : luaL_checkstring(L, 2);
if (arg && strlen(arg) > MAX_LAUNCH_ARG) return luaL_error(L, "launch argument too long");
app(L)->requestLaunch(luaL_checkstring(L, 1), arg, replace);
return 0;
}
static int l_sys_launch(lua_State* L) { return navigate(L, false); }
static int l_sys_replace(lua_State* L) { return navigate(L, true); }
// Renames the running app for the status bar, which is the only thing that reads it. Kept
// as a setter rather than a constant the chunk declares: a screen that retitles itself
// ("settings" to "settings - wifi") needs it to change after init().
static int l_sys_setAppName(lua_State* L) {
size_t length;
const char* name = luaL_checklstring(L, 1, &length);
if (!length || length > MAX_APP_NAME) return luaL_error(L, "app name must be 1 to 32 bytes");
app(L)->setName(String(name, length));
app(L)->refreshStatusBar();
return 0;
}
// Lua's os.time()/os.date() work off the same system clock, so this is the only
// binding a clock UI needs: it says whether that clock means anything yet.
static int l_sys_clockSynced(lua_State* L) {
lua_pushboolean(L, net::clockSynced());
return 1;
}
static int l_sys_memory(lua_State* L) {
lua_pushinteger(L, ESP.getFreeHeap());
lua_pushinteger(L, ESP.getHeapSize());
lua_pushinteger(L, ESP.getMaxAllocHeap());
return 3;
}
static int logAt(lua_State* L, const char* level) {
Serial.printf("[lua:%s] %s\n", level, luaL_checkstring(L, 1));
return 0;
}
// Rejected rather than ignored when on_tick is absent: the chunk body has already run
// by the time init() calls this, so a missing callback is a typo, not a race.
static int l_sys_setTickInterval(lua_State* L) {
lua_Integer requested = luaL_checkinteger(L, 1);
if (requested <= 0) {
app(L)->setTickInterval(0);
return 0;
}
lua_getglobal(L, "on_tick");
bool hasTick = lua_isfunction(L, -1);
lua_pop(L, 1);
if (!hasTick) return luaL_error(L, "sys.setTickInterval() requires on_tick()");
uint32_t interval = requested < (lua_Integer)LuaApp::MIN_TICK_MS ? LuaApp::MIN_TICK_MS
: requested > (lua_Integer)LuaApp::MAX_TICK_MS ? LuaApp::MAX_TICK_MS
: (uint32_t)requested;
app(L)->setTickInterval(interval);
return 0;
}
static int l_log_debug(lua_State* L) { return logAt(L, "debug"); }
static int l_log_info(lua_State* L) { return logAt(L, "info"); }
static int l_log_error(lua_State* L) { return logAt(L, "error"); }
// Sleeping is the one thing a script cannot express itself, since the runtime owns
// the loop.
static int l_sys_delay(lua_State* L) {
delay(luaL_checkinteger(L, 1));
return 0;
}
void registerSys(lua_State* L) {
static const luaL_Reg lib[] = {
// --- Milliseconds since boot.
// @return integer
{"getMillis", l_sys_millis},
// --- Blocks for the given time.
// @param ms integer
{"delay", l_sys_delay},
// --- Returns to the previous app, or Home when there is no history.
{"back", l_sys_back},
// --- Name of the running app, its directory name until sys.setAppName() changes it.
// @return string
{"getAppName", l_sys_appName},
// --- Sets how often on_tick() runs. Errors when on_tick is not defined.
// @param intervalMs integer 0 stops ticking; anything else is clamped to 33..3600000.
{"setTickInterval", l_sys_setTickInterval},
// --- Ends this app and starts another one.
// @param path string Absolute path to the app's main.lua.
// @param arg string|nil Passed to the new app's init(); at most 256 bytes.
{"launch", l_sys_launch},
// --- Starts another app without adding this route to history.
// @param path string Absolute path to the app's main.lua.
// @param arg string|nil Passed to the new app's init(); at most 256 bytes.
{"replace", l_sys_replace},
// --- Renames the running app in the status bar.
// @param name string 1 to 32 bytes.
{"setAppName", l_sys_setAppName},
// --- Free and total heap, in bytes.
// @return integer Free bytes.
// @return integer Total bytes.
// @return integer Largest contiguous free block.
{"getMemory", l_sys_memory},
// --- Whether SNTP has answered. Until it has, os.time() is only a build-time floor.
// @return boolean
{"isClockSynced", l_sys_clockSynced},
{nullptr, nullptr}};
luaL_newlib(L, lib);
lua_setglobal(L, "sys");
// Too small to deserve its own translation unit.
static const luaL_Reg logLib[] = {
// --- Writes a debug line to the serial log.
// @param message string
{"debug", l_log_debug},
// --- Writes an info line to the serial log.
// @param message string
{"info", l_log_info},
// --- Writes an error line to the serial log.
// @param message string
{"error", l_log_error},
{nullptr, nullptr}};
luaL_newlib(L, logLib);
lua_setglobal(L, "log");
}
-142
View File
@@ -1,142 +0,0 @@
// Station-mode WiFi. Credentials live in the same settings file as everything else,
// so connecting persists them and a saved network reconnects at boot.
#include <WiFi.h>
#include "../../settings.h"
#include "../bindings.h"
#include "../lua_app.h"
static constexpr uint32_t CONNECT_TIMEOUT_MS = 15000;
static constexpr size_t MAX_SSID = 32;
static constexpr size_t MAX_PASSWORD = 64;
// Arduino reports WL_DISCONNECTED both before an attempt and after a failed one, so
// the attempt start is tracked here to tell "still trying" from "gave up".
static uint32_t connectStartedAt;
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 > MAX_SSID || passwordLength > MAX_PASSWORD) {
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());
connectStartedAt = millis();
lua_pushboolean(L, true);
return 1;
}
static const char* connectionState(wl_status_t status) {
if (status == WL_CONNECTED) {
connectStartedAt = 0;
return "connected";
}
if (status == WL_NO_SSID_AVAIL) return "not_found";
if (status == WL_CONNECT_FAILED) return "failed";
if (!connectStartedAt) return "disconnected";
return millis() - connectStartedAt >= CONNECT_TIMEOUT_MS ? "failed" : "connecting";
}
static int l_wifi_status(lua_State* L) {
wl_status_t status = WiFi.status();
// A boot-time reconnect starts before any Lua call, so adopt it as an attempt.
if (!connectStartedAt && settings.wifiSsid.length() && status != WL_CONNECTED) {
connectStartedAt = millis();
}
lua_newtable(L);
lua_pushstring(L, connectionState(status));
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;
}
// Shorthands for two fields status() already carries, for scripts that want one answer
// without unpacking a table.
static int l_wifi_isConnected(lua_State* L) {
lua_pushboolean(L, WiFi.status() == WL_CONNECTED);
return 1;
}
static int l_wifi_localIP(lua_State* L) {
lua_pushstring(L, WiFi.status() == WL_CONNECTED ? WiFi.localIP().toString().c_str() : "0.0.0.0");
return 1;
}
static int l_wifi_disconnect(lua_State* L) {
WiFi.disconnect(false, false); // keeps the saved credentials, unlike forget()
return 0;
}
static int l_wifi_forget(lua_State* L) {
WiFi.disconnect(true, false);
settings.wifiSsid = "";
settings.wifiPassword = "";
connectStartedAt = 0;
lua_pushboolean(L, settings.save());
return 1;
}
void registerWifi(lua_State* L) {
static const luaL_Reg lib[] = {
// --- Scans for networks, blocking until the sweep finishes.
// @return table[] Each entry has ssid, rssi and secure.
{"scan", l_wifi_scan},
// --- Saves credentials and starts connecting. Poll status() for the outcome.
// @param ssid string
// @param password string|nil Omitted for an open network.
// @return boolean Whether the credentials were saved and the attempt started.
{"connect", l_wifi_connect},
// --- Current connection state.
// @return table Fields state, ssid, ip and rssi. state is one of disconnected,
// --- connecting, connected, not_found or failed.
{"getStatus", l_wifi_status},
// --- Whether the station is associated.
// @return boolean
{"isConnected", l_wifi_isConnected},
// --- Current IPv4 address.
// @return string The address, or 0.0.0.0 when not connected.
{"getLocalIP", l_wifi_localIP},
// --- Drops the connection but keeps the saved credentials.
{"disconnect", l_wifi_disconnect},
// --- Drops the connection and erases the saved credentials.
// @return boolean Whether the settings were saved.
{"forget", l_wifi_forget},
{nullptr, nullptr}};
luaL_newlib(L, lib);
lua_setglobal(L, "wifi");
}
-337
View File
@@ -1,337 +0,0 @@
// App lifecycle: own the lua_State, dispatch the callbacks, and tear down safely.
// The bindings themselves live in bindings/, the loader in module_loader.cpp.
#include "lua_app.h"
#include <algorithm>
#include "../settings.h"
#include "bindings.h"
extern "C" {
#include <lauxlib.h>
#include <lualib.h>
}
// LUA_EXTRASPACE is a pointer-sized block ahead of every lua_State, and Lua copies it
// into new coroutines, so the owner is reachable from any state without a global.
LuaApp* app(lua_State* L) { return *static_cast<LuaApp**>(lua_getextraspace(L)); }
void bindApp(lua_State* L, LuaApp* owner) { *static_cast<LuaApp**>(lua_getextraspace(L)) = owner; }
void LuaApp::mapTouch(const TS_Point& p, int16_t& x, int16_t& y) const {
int16_t nx = constrain(map(p.y, settings.touchX0, settings.touchX1, 0, PANEL_W), 0, PANEL_W - 1);
int16_t ny = constrain(map(p.x, settings.touchY0, settings.touchY1, 0, PANEL_H), 0, PANEL_H - 1);
switch (tft.getRotation() & 3) {
case 1:
x = ny;
y = PANEL_W - 1 - nx;
break;
case 2:
x = PANEL_W - 1 - nx;
y = PANEL_H - 1 - ny;
break;
case 3:
x = PANEL_H - 1 - ny;
y = nx;
break;
default:
x = nx;
y = ny;
break;
}
// App space starts below the bar, matching the viewport apps draw into. A tap on the
// bar itself lands at a negative y, which loop() drops.
y -= barInset();
}
// resetViewport() first: width()/height() report the *viewport* once one is set, so
// re-applying over an existing one would shrink the app area again every time.
// setRotation() leaves the old viewport metrics behind, so every rotation needs this too.
void LuaApp::applyViewport() {
int16_t inset = barInset();
tft.resetViewport();
if (inset > 0) tft.setViewport(0, inset, tft.width(), tft.height() - inset, true);
}
void LuaApp::setFullscreen(bool on) {
fullscreen = on;
applyViewport();
nextBarMs = 0; // leaving fullscreen left the bar's rows painted by the app
}
LuaApp::LuaApp(TFT_eSPI& tft, XPT2046_Touchscreen& touch) : tft(tft), touch(touch) {}
LuaApp::~LuaApp() {
if (state) lua_close(state);
}
bool LuaApp::hasGlobal(const char* name) {
lua_getglobal(state, name);
bool found = lua_isfunction(state, -1);
lua_pop(state, 1);
return found;
}
bool LuaApp::callGlobal(const char* name, int nargs) {
if (lua_pcall(state, nargs, 0, 0) != LUA_OK) {
fail(lua_tostring(state, -1));
return false;
}
return true;
}
void LuaApp::fireTouch(const char* name, int16_t x, int16_t y) {
if (!hasGlobal(name)) return;
lua_getglobal(state, name);
lua_pushinteger(state, x);
lua_pushinteger(state, y);
callGlobal(name, 2);
}
// Deliberately not themed: this can fire when the settings naming the theme are
// themselves unreadable, so it stays high contrast.
void LuaApp::fail(const char* message) {
Serial.printf("[lua] error: %s\n", message ? message : "(unknown)");
tft.fillScreen(TFT_WHITE);
tft.setTextColor(TFT_RED, TFT_WHITE);
tft.drawString("Lua error:", 8, 8);
tft.drawString(message ? message : "(unknown)", 8, 28);
failed = true;
requestExit();
}
bool LuaApp::takeFailure() {
bool value = failed;
failed = false;
return value;
}
bool LuaApp::load(const char* path, bool hasBack, const char* arg) {
closeState();
this->hasBack = hasBack;
backArmed = false;
// Cleared per app: the interval outlives the app that set it, so a ticking app
// followed by one that never ticks would keep calling a nil global.
tickIntervalMs = 0;
// Same reason, and the arena is the bigger one: node handles mean nothing to the next
// lua_State, so an app that inherited the last one's tree would build onto its nodes.
tree.reset();
// The tap that launched this app may still be down; swallow that gesture's release.
lastTouched = true;
ignoreRelease = true;
state = luaL_newstate();
if (!state) return false;
bindApp(state, this);
luaL_openlibs(state);
registerBindings();
String appDir = path;
int slash = appDir.lastIndexOf('/');
appDir = slash > 0 ? appDir.substring(0, slash) : String("/");
installLoader(appDir.c_str());
appName = appDir.substring(appDir.lastIndexOf('/') + 1);
// Before the chunk runs: the bar's height sets the viewport, and an app measuring
// gui.height() at the top of init() has to see the area it actually owns.
loadStatusBar();
applyViewport();
// Painted before the app's chunk runs, not on the next bar tick: loading a script off
// the SD card takes long enough that the previous app's name would linger visibly.
if (barInset() > 0 && !barBroken) drawStatusBar();
if (loadScript(state, path) != LUA_OK) {
fail(lua_tostring(state, -1));
return false;
}
if (!callGlobal(path)) return false; // run chunk body
// An app whose entry point is misspelled would otherwise start, draw nothing, and
// give no hint why.
if (!hasGlobal("init")) {
fail("Missing init()");
return false;
}
lua_getglobal(state, "init");
if (arg) {
lua_pushstring(state, arg);
} else {
lua_pushnil(state); // pushed either way, so init(arg) reads the same in both cases
}
if (!callGlobal("init", 1)) return false;
return running();
}
// Reads an optional positive integer field off the bar module on the stack top.
static lua_Integer barField(lua_State* L, const char* key, lua_Integer fallback) {
lua_getfield(L, -1, key);
lua_Integer value = lua_isinteger(L, -1) ? lua_tointeger(L, -1) : fallback;
lua_pop(L, 1);
return value > 0 ? value : fallback;
}
// The bar is a Lua module like any other, loaded per app because the state is too. Its
// own table owns the geometry and the repaint rate: sys.setTickInterval() belongs to the
// app, and there is only one of those per state.
void LuaApp::loadStatusBar() {
barBroken = false;
barHeight = DEFAULT_BAR_H;
barIntervalMs = BAR_INTERVAL_MS;
lua_getglobal(state, "require");
lua_pushstring(state, "statusbar");
if (lua_pcall(state, 1, 1, 0) != LUA_OK || !lua_istable(state, -1)) {
Serial.printf("[statusbar] unavailable: %s\n", luaL_tolstring(state, -1, nullptr));
// Nothing has measured the panel yet, so surrendering the strip here is free. A bar
// that dies later keeps its rows, because the app has already laid itself out.
barBroken = true;
barHeight = 0;
lua_pop(state, 2);
return;
}
barHeight = barField(state, "height", DEFAULT_BAR_H);
barIntervalMs = std::max((lua_Integer)MIN_TICK_MS, barField(state, "interval", BAR_INTERVAL_MS));
lua_setglobal(state, "__statusbar");
nextBarMs = 0;
}
// Errors here are logged and disable the bar rather than killing the app: chrome that
// fails should not take the running program with it.
void LuaApp::drawStatusBar() {
lua_getglobal(state, "__statusbar");
lua_getfield(state, -1, "draw");
lua_remove(state, -2);
lua_pushboolean(state, hasBack); // whether to paint the back button
tft.resetViewport(); // the bar paints in panel coordinates, the app does not
if (lua_pcall(state, 1, 0, 0) != LUA_OK) {
Serial.printf("[statusbar] %s\n", luaL_tolstring(state, -1, nullptr));
lua_pop(state, 2);
barBroken = true;
}
applyViewport();
}
void LuaApp::setTickInterval(uint32_t intervalMs) {
tickIntervalMs = intervalMs;
nextTickMs = millis() + intervalMs;
}
void LuaApp::registerBindings() {
registerGui(state);
registerNode(state);
registerSys(state);
registerSettings(state);
registerInput(state);
registerFs(state);
registerWifi(state);
registerHttp(state);
}
// Deferred: navigation and fail() run inside a lua_pcall, so closing the state here
// would free the VM that is still executing.
void LuaApp::requestExit() { exitRequested = true; }
void LuaApp::requestBack() {
exitAction = ExitAction::Back;
requestExit();
}
void LuaApp::requestLaunch(const char* path, const char* arg, bool replace) {
exitAction = replace ? ExitAction::Replace : ExitAction::Launch;
pendingLaunch = path;
pendingArg = arg ? arg : "";
pendingArgSet = arg != nullptr;
requestExit();
}
LuaApp::ExitAction LuaApp::takeExitAction() {
ExitAction action = exitAction;
exitAction = ExitAction::None;
return action;
}
String LuaApp::takePendingLaunch() {
String path = pendingLaunch;
pendingLaunch = "";
return path;
}
String LuaApp::takePendingArg() {
String arg = pendingArg;
pendingArg = "";
pendingArgSet = false;
return arg;
}
void LuaApp::closeState() {
if (state) {
lua_close(state);
state = nullptr;
}
exitRequested = false;
}
void LuaApp::loop() {
if (exitRequested) closeState();
if (!state) return;
uint32_t now = millis();
bool touched = touch.touched();
if (touched) {
TS_Point p = touch.getPoint();
mapTouch(p, lastX, lastY);
}
// The bar is the host's: apps never see a touch in it, and its one control acts on
// release, so a press that slides off into the app cancels like any other button.
if (touched && lastY < 0) {
backArmed = backArmed || inBackButton();
touched = false;
} else if (backArmed) {
backArmed = false;
if (inBackButton()) {
requestBack();
return;
}
}
if (touched && !lastTouched) {
movedX = lastX;
movedY = lastY;
fireTouch("on_touch_down", lastX, lastY);
if (!running()) return;
} else if (touched && lastTouched) {
// Move Threshold - The XPT2046 jitters a pixel or two under a still finger. Below this
// every poll would fire a move, and a painting app would draw a blur where nothing moved.
if (abs(lastX - movedX) + abs(lastY - movedY) >= MOVE_EPSILON_PX) {
movedX = lastX;
movedY = lastY;
fireTouch("on_touch_move", lastX, lastY);
if (!running()) return;
}
} else if (!touched && lastTouched && ignoreRelease) {
ignoreRelease = false; // the press that launched this app is not its own gesture
} else if (!touched && lastTouched) {
fireTouch("on_touch_up", lastX, lastY);
if (!running()) return;
fireTouch("on_touch", lastX, lastY); // tap alias, fired on release like a click
if (!running()) return;
}
lastTouched = touched;
if (tickIntervalMs > 0 && now >= nextTickMs) {
nextTickMs = now + tickIntervalMs;
lua_getglobal(state, "on_tick");
callGlobal("on_tick");
if (!running()) return;
}
if (now >= nextDrawMs && hasGlobal("draw")) {
nextDrawMs = now + DRAW_INTERVAL_MS;
lua_getglobal(state, "draw");
callGlobal("draw");
if (!running()) return;
}
if (barInset() > 0 && !barBroken && now >= nextBarMs) {
nextBarMs = now + barIntervalMs;
drawStatusBar();
}
}
-142
View File
@@ -1,142 +0,0 @@
#pragma once
extern "C" {
#include <lua.h>
}
#include <TFT_eSPI.h>
#include <XPT2046_Touchscreen.h>
#include "../ui/layout.h"
class LuaApp {
public:
TFT_eSPI& tft; // accessed by the C binding shims in the .cpp
XPT2046_Touchscreen& touch;
// One screen at a time: a rebuild resets the arena, which is why the heap returns to
// the same shape after every one instead of fragmenting.
ui::Tree tree;
LuaApp(TFT_eSPI& tft, XPT2046_Touchscreen& touch);
~LuaApp();
// `hasBack` controls whether the status bar offers navigation to an earlier route.
// `arg` reaches the app as init()'s only parameter, or nil when there is none: states
// share no memory, so a string is the whole handoff between one app and the next.
bool load(const char* path, bool hasBack = false, const char* arg = nullptr);
void loop();
// What the status bar shows. Defaults to the app's directory name ("settings") until
// the app renames itself with sys.setAppName().
const String& name() const { return appName; }
void setName(const String& name) { appName = name; }
// Lets an app own the whole panel: no viewport, no status bar. The touch calibration
// needs the physical edges; almost nothing else should.
void setFullscreen(bool on);
// Re-clips the app out of the status bar. Needed after every setRotation(), which
// leaves the previous viewport metrics behind.
void applyViewport();
// Brings the bar's next repaint forward. Not an invalidation: the bar still decides for
// itself what changed, this only says the answer is worth asking for before the next
// tick, so a rename or a rotation is not left sitting for most of a second.
void refreshStatusBar() { nextBarMs = 0; }
enum class ExitAction { None, Back, Launch, Replace };
// Deferred navigation: bindings and bar touches run inside loop(), so the host handles
// the route change after Lua has returned and the old state can be closed safely.
void requestBack();
void requestLaunch(const char* path, const char* arg, bool replace = false);
ExitAction takeExitAction();
String takePendingLaunch();
String takePendingArg();
bool hasPendingArg() const { return pendingArgSet; }
bool running() const { return state != nullptr && !exitRequested; }
// True once if the app died on an error, so the host
// can leave the message on screen long enough to read.
bool takeFailure();
// Set from Lua by sys.setTickInterval(); 0 stops the ticks.
void setTickInterval(uint32_t intervalMs);
static constexpr uint32_t MIN_TICK_MS = 33; // no point ticking faster than a draw
static constexpr uint32_t MAX_TICK_MS = 3600000; // an hour
// Panel geometry in its rotation-0 frame; calibration is stored in this space
// so rotating the UI never needs a recalibration.
static constexpr int16_t PANEL_W = 320;
static constexpr int16_t PANEL_H = 480;
// App coordinates, so the status bar's rows are subtracted unless the app owns the panel.
void mapTouch(const TS_Point& p, int16_t& x, int16_t& y) const;
static constexpr size_t READ_CAP = 64 * 1024;
private:
lua_State* state = nullptr;
bool exitRequested = false;
bool failed = false;
ExitAction exitAction = ExitAction::None;
String pendingLaunch;
// Tracked apart from the string: an app may legitimately pass "", which is not the same
// as being launched with no argument at all.
String pendingArg;
bool pendingArgSet = false;
bool lastTouched = false;
bool ignoreRelease = false;
bool fullscreen = false;
bool hasBack = false;
bool backArmed = false;
String appName;
// One strike: a bar that failed once fails identically every second, and the serial
// log is the only place anyone would see it. The strip stays reserved either way, so a
// bar that dies mid-run never resizes the app underneath it.
bool barBroken = false;
int16_t barHeight = 0;
uint32_t nextBarMs = 0;
uint32_t barIntervalMs = 0;
// The controller reports no position once the finger lifts, so on_touch_up and the
// tap alias replay the last point seen while it was down.
int16_t lastX = 0, lastY = 0;
// Last point reported to on_touch_move, which is not lastX/lastY: sub-threshold jitter
// must not accumulate into a move one pixel at a time.
int16_t movedX = 0, movedY = 0;
// Noise only. Gesture intent (tap vs drag) is the UI toolkit's threshold, not this one,
// because a painting app's drag starts at the first real pixel.
static constexpr int16_t MOVE_EPSILON_PX = 2;
void closeState();
void installLoader(const char* appDir);
void fireTouch(const char* name, int16_t x, int16_t y);
void loadStatusBar();
void drawStatusBar();
uint32_t tickIntervalMs = 0;
uint32_t nextTickMs = 0;
uint32_t nextDrawMs = 0;
static constexpr uint32_t DRAW_INTERVAL_MS = 33;
// Defaults only: /lib/statusbar.lua overrides both with `interval` and `height` fields.
static constexpr uint32_t BAR_INTERVAL_MS = 1000;
static constexpr int16_t DEFAULT_BAR_H = 22;
// Zero whenever the app owns the panel, so one accessor answers both the viewport
// and the touch offset.
int16_t barInset() const { return fullscreen ? 0 : barHeight; }
// The back button is the leading square of the bar, which is what /lib/statusbar.lua
// paints into. Touches are in app space, so the bar is above y = 0.
bool inBackButton() const {
return hasBack && lastY < 0 && lastY >= -barInset() && lastX >= 0 && lastX < barInset();
}
void requestExit();
void registerBindings();
bool callGlobal(const char* name, int nargs = 0);
bool hasGlobal(const char* name);
void fail(const char* message);
};
-134
View File
@@ -1,134 +0,0 @@
// SD-backed `require`. Lua's stock loaders go through stdio, which cannot see the SD
// mount, so every path into the filesystem is replaced with one that reads through SD.
#include <SD.h>
#include "bindings.h"
#include "lua_app.h"
// Streamed rather than slurped: holding a whole module as one String needs that many bytes
// contiguous, and once WiFi is up the largest free block is around 40KB even though far
// more than that is free. Lua only ever wants the next few bytes.
namespace {
struct ChunkReader {
File file;
char buf[512];
};
const char* readChunk(lua_State*, void* ud, size_t* size) {
ChunkReader* reader = static_cast<ChunkReader*>(ud);
int read = reader->file.read((uint8_t*)reader->buf, sizeof(reader->buf));
*size = read > 0 ? (size_t)read : 0;
return read > 0 ? reader->buf : nullptr;
}
} // namespace
// Whole file in one String, for callers that want the bytes themselves. Scripts go through
// loadScript(), which never materializes the file.
bool readScript(const char* path, String& out) {
// Probe first: a missed SD.open logs a VFS error, and the searcher misses by design.
if (!SD.exists(path)) return false;
File file = SD.open(path);
if (!file || file.isDirectory()) {
if (file) file.close();
return false;
}
// readString() reports an allocation failure by silently returning a partial read, so the
// size is verified rather than trusted: a truncated script is a syntax error at a random
// offset, which says nothing about the real cause.
size_t size = file.size();
out = file.readString();
file.close();
if (out.length() != size) {
Serial.printf("[lua] %s: short read, %u of %u bytes\n", path, out.length(), (unsigned)size);
return false;
}
return true;
}
int loadScript(lua_State* L, const char* path) {
ChunkReader reader;
if (SD.exists(path)) reader.file = SD.open(path);
if (!reader.file || reader.file.isDirectory()) {
if (reader.file) reader.file.close();
lua_pushfstring(L, "cannot open %s", path);
return LUA_ERRFILE;
}
String chunkname = String("@") + path;
int status = lua_load(L, readChunk, &reader, chunkname.c_str(), "t");
reader.file.close();
return status;
}
static int l_loadfile(lua_State* L) {
if (loadScript(L, luaL_checkstring(L, 1)) == LUA_OK) return 1;
lua_pushnil(L);
lua_insert(L, -2);
return 2;
}
static int l_dofile(lua_State* L) {
const char* path = luaL_checkstring(L, 1);
if (loadScript(L, path) != LUA_OK) return lua_error(L);
lua_call(L, 0, LUA_MULTRET);
return lua_gettop(L) - 1;
}
// Resolves a module name against package.path, reporting every path tried the way
// the stock searcher does.
static int l_searcher(lua_State* L) {
String name = luaL_checkstring(L, 1);
name.replace('.', '/');
lua_getglobal(L, "package");
lua_getfield(L, -1, "path");
String templates = luaL_optstring(L, -1, "");
lua_pop(L, 2);
String tried;
int start = 0;
while (start <= (int)templates.length()) {
int end = templates.indexOf(';', start);
if (end < 0) end = templates.length();
String candidate = templates.substring(start, end);
start = end + 1;
if (candidate.length() == 0) continue;
candidate.replace("?", name);
if (SD.exists(candidate.c_str())) {
if (loadScript(L, candidate.c_str()) != LUA_OK) {
return luaL_error(L, "error loading module '%s' from '%s':\n\t%s",
luaL_checkstring(L, 1), candidate.c_str(), lua_tostring(L, -1));
}
lua_pushstring(L, candidate.c_str());
return 2;
}
tried += "\n\tno file '" + candidate + "'";
}
lua_pushstring(L, tried.c_str());
return 1;
}
void LuaApp::installLoader(const char* appDir) {
lua_getglobal(state, "package");
String path = String(appDir) + "/?.lua;/lib/?.lua";
lua_pushstring(state, path.c_str());
lua_setfield(state, -2, "path");
// Keep the preload searcher, drop the C loaders: they can only report misleading
// errors about shared objects that never existed here.
lua_getfield(state, -1, "searchers");
lua_pushcfunction(state, l_searcher);
lua_rawseti(state, -2, 2);
for (int i = 3; i <= 4; i++) {
lua_pushnil(state);
lua_rawseti(state, -2, i);
}
lua_pop(state, 2);
lua_pushcfunction(state, l_loadfile);
lua_setglobal(state, "loadfile");
lua_pushcfunction(state, l_dofile);
lua_setglobal(state, "dofile");
}
+14 -93
View File
@@ -1,92 +1,44 @@
// Board bring-up and the loop. Everything about running a Lua app -- the state, the
// bindings, app loading, navigation history -- lives in the shared runtime behind LuaHost.
#include <SD.h>
#include <SPI.h>
#include <TFT_eSPI.h>
#include <WiFi.h>
#include <XPT2046_Touchscreen.h>
#include "lua/lua_app.h"
#include "host/lua_host.h"
#include "net.h"
#include "settings.h"
// SD card is on a separate bus from the display/touch (board schematic)
static constexpr int SD_CS = 5;
static constexpr int SD_SCK = 18;
static constexpr int SD_MOSI = 23;
static constexpr int SD_MISO = 19;
static constexpr int TOUCH_CS = 33;
static const char* HOME = "/apps/Home/main.lua";
static constexpr uint32_t ERROR_HOLD_MS = 5000;
// Arduino's default 8KB loop stack is not enough once a TLS handshake runs inside a
// Lua binding: the first HTTPS download tripped the stack canary. Everything the VM
// calls runs on this task, so the headroom belongs here rather than in each caller.
SET_LOOP_TASK_STACK_SIZE(16 * 1024);
TFT_eSPI tft;
SPIClass touchSpi(HSPI);
SPIClass& sdSpi = SPI;
XPT2046_Touchscreen touch(TOUCH_CS);
LuaApp app(tft, touch);
LuaHost host(tft, touch);
struct Route {
String path;
String arg;
bool hasArg = false;
};
static bool halted = false;
static constexpr size_t MAX_HISTORY = 8;
bool halted = false;
Route currentRoute;
Route pendingRoute;
Route history[MAX_HISTORY];
size_t historyDepth = 0;
LuaApp::ExitAction pendingAction = LuaApp::ExitAction::None;
Route homeRoute() {
Route route;
route.path = HOME;
return route;
}
void pushHistory(const Route& route) {
if (historyDepth == MAX_HISTORY) {
// Home remains the eventual floor; discard the oldest intermediate route.
for (size_t i = 1; i < MAX_HISTORY - 1; ++i) history[i] = history[i + 1];
historyDepth--;
}
history[historyDepth++] = route;
}
Route previousRoute() {
return historyDepth > 0 ? history[--historyDepth] : homeRoute();
}
// Last resort only: the UI lives in Lua, so this exists purely to explain why
// nothing else could run.
void fallbackScreen(const char* message) {
static void fallbackScreen(const char* message) {
tft.resetViewport(); // no app, no status bar: this message owns the panel
tft.setRotation(0);
tft.fillScreen(TFT_WHITE);
tft.setTextSize(2);
tft.setTextColor(TFT_RED, TFT_WHITE);
tft.drawString(message, 10, 10);
tft.setTextColor(TFT_BLACK, TFT_WHITE);
tft.drawString("expected /apps/Home/main.lua", 10, 30);
tft.drawString("expected /.lua/apps/Home/main.lua", 10, 34);
Serial.printf("halted: %s\n", message);
halted = true;
}
void startApp(const Route& route) {
// Full panel until the app's own status bar module reports its height in load().
tft.setRotation(settings.rotationIndex()); // apps may have rotated the frame
tft.resetViewport(); // load() re-clips once the new app's bar reports its height
Serial.printf("launching %s free=%u largest=%u\n", route.path.c_str(), ESP.getFreeHeap(),
ESP.getMaxAllocHeap());
const char* arg = route.hasArg ? route.arg.c_str() : nullptr;
if (app.load(route.path.c_str(), historyDepth > 0, arg)) return;
if (route.path == HOME && historyDepth == 0) fallbackScreen("home failed to start");
}
void setup() {
Serial.begin(115200);
tft.begin();
@@ -100,51 +52,20 @@ void setup() {
fallbackScreen("SD card mount failed");
return;
}
settings.load(); // absent file keeps the built-in defaults
net::begin();
currentRoute = homeRoute();
startApp(currentRoute);
if (!host.begin()) fallbackScreen("home failed to start");
}
void loop() {
// Nothing here changes without a reset, so idle hard rather than spinning on it.
if (halted) {
delay(100);
return;
}
net::loop();
if (app.running()) {
app.loop();
if (!app.running()) {
pendingAction = app.takeExitAction();
pendingRoute.path = app.takePendingLaunch();
pendingRoute.hasArg = app.hasPendingArg();
pendingRoute.arg = app.takePendingArg();
}
} else {
// A failed child returns to its caller after leaving the message readable. Home has
// no caller, so its failure still reaches the last-resort screen in startApp().
if (app.takeFailure()) {
delay(ERROR_HOLD_MS);
if (pendingAction == LuaApp::ExitAction::None) pendingAction = LuaApp::ExitAction::Back;
}
if (pendingAction == LuaApp::ExitAction::Launch) {
pushHistory(currentRoute);
currentRoute = pendingRoute;
} else if (pendingAction == LuaApp::ExitAction::Replace) {
currentRoute = pendingRoute;
} else {
currentRoute = previousRoute();
}
pendingAction = LuaApp::ExitAction::None;
pendingRoute = {};
startApp(currentRoute);
}
// Hands the core to the idle task instead of spinning between polls: touch reads are
// throttled to 3 ms and draws to 33 ms, so a millisecond of sleep costs nothing that
// can be seen or felt.
host.loop();
// The launcher failing to start is the one error no app can recover from.
if (!host.running()) fallbackScreen("home failed to start");
delay(1);
}
-421
View File
@@ -1,421 +0,0 @@
#pragma once
// The widget tree: structure, block-flow layout, labels and style, over a flat node
// arena. Free of Arduino headers so test/ui_layout_test.cpp can exercise it on the host.
// Ported from the Lua toolkit's Component:measure/place, whose semantics the tests
// still describe.
//
// The tree is split across two arenas because the halves have different lifetimes. A
// Node holds what hit testing and repainting need forever: 16 bytes. A Spec holds what
// only measure() and place() read -- requested sizes, padding, gap, alignment -- and is
// dropped when the pass ends. Re-layout rebuilds from Lua rather than retaining ~20
// bytes per node against a rotation nobody measures in milliseconds.
#include <cstddef>
#include <cstdint>
#include <cstring>
#include <utility>
#include <vector>
namespace ui {
constexpr uint16_t NONE = 0xFFFF;
// Distinguishes "no constraint" from a real zero, which a plain int cannot: an auto-sized
// parent genuinely has no width to hand down, and a child asking for a fraction of it is
// an error rather than a zero-width silence.
constexpr int UNKNOWN = INT32_MIN;
enum Type : uint8_t { BOX, TEXT, BUTTON, CUSTOM };
enum Flag : uint8_t {
ROW = 1 << 0, // main axis is horizontal
CAPTURE = 1 << 1, // swallows the taps its children missed
INTERACTIVE = 1 << 2, // has an on_press
DIRTY = 1 << 3,
PRESSED = 1 << 4,
};
enum SizeMode : uint8_t { AUTO, PX, FRACTION, FILL };
enum Align : uint8_t { START, CENTER, END, BETWEEN };
// Which roles a style states for itself. Anything unset is answered by the nearest
// ancestor that does state it, so styling stays in one place and a node that names no
// colours costs no bytes at all.
enum StyleField : uint16_t {
S_FG = 1 << 0,
// The background a node offers its descendants to draw text on, which is not the same
// as the fill it paints: a dialog layer hands the lit palette down while painting
// nothing itself. What is physically behind a node is derived at paint time, never set.
S_BG = 1 << 1,
S_FILL = 1 << 9,
S_BORDER = 1 << 3,
S_FACE = 1 << 4, // button gradient, top and bottom
S_PRESSED = 1 << 5, // pressed gradient
S_PRESS_FG = 1 << 6,
S_RADIUS = 1 << 7,
S_SIZE = 1 << 8,
S_TEXT_ALIGN = 1 << 10,
};
// Colours are RGB565, matching the panel and gui.color().
struct Style {
uint16_t fg = 0x0000;
uint16_t bg = 0xFFFF;
uint16_t fill = 0xFFFF;
uint16_t border = 0x0000;
uint16_t faceTop = 0xFFFF, faceBottom = 0xFFFF;
uint16_t pressTop = 0x0000, pressBottom = 0x0000;
uint16_t pressFg = 0xFFFF;
uint8_t radius = 6;
uint8_t size = 1;
Align textAlign = START; // within the node's own box, which a text node usually fills
uint16_t set = 0;
};
// FRACTION is per-mille rather than a float: 0.85 of 320 is 272 either way, and the
// firmware has no business rounding differently to the host.
struct Size {
SizeMode mode = AUTO;
int16_t value = 0;
static Size make(SizeMode mode, int16_t value) {
Size size;
size.mode = mode;
size.value = value;
return size;
}
static Size px(int16_t v) { return make(PX, v); }
static Size fraction(int16_t permille) { return make(FRACTION, permille); }
static Size fill() { return make(FILL, 0); }
};
// Persistent. w/h hold the measured size between measure() and place(), and the final
// rect afterwards, because the two are never needed at once.
struct Node {
int16_t x = 0, y = 0, w = 0, h = 0;
uint16_t first = NONE, next = NONE, parent = NONE;
uint8_t type = BOX;
uint8_t flags = 0;
};
// Scratch. Lives only for the duration of a build plus its layout pass.
struct Spec {
Size w, h;
Size atX, atY;
bool absolute = false;
int16_t intrinsicW = 0, intrinsicH = 0; // content size of a leaf, e.g. a text run
uint8_t padT = 0, padR = 0, padB = 0, padL = 0;
uint8_t gap = 0;
Align align = START;
Align justify = START;
uint16_t last = NONE; // tail of the child list, so append is not a walk
};
// Resistive panels land a few pixels off, so a hit box is larger than what was painted.
constexpr int SLOP = 4;
constexpr uint16_t NO_LABEL = 0xFFFF;
class Tree {
public:
std::vector<Node> nodes;
std::vector<Spec> specs;
const char* error = nullptr;
void reset() {
nodes.clear();
specs.clear();
labelAt.clear();
labels.clear();
styles.clear();
error = nullptr;
}
uint16_t add(uint16_t parent, const Spec& spec, uint8_t type = BOX, uint8_t flags = 0) {
// A tree whose scratch has been dropped cannot be extended: its layout inputs are
// gone, so building again is a new screen by definition. Self-healing rather than
// advisory, because the alternative is a spec list that no longer indexes the nodes.
if (specs.size() != nodes.size()) reset();
uint16_t id = static_cast<uint16_t>(nodes.size());
nodes.push_back(Node());
specs.push_back(spec);
labelAt.push_back(NO_LABEL);
nodes[id].type = type;
nodes[id].flags = flags;
nodes[id].parent = parent;
if (parent != NONE) attach(parent, id);
return id;
}
// Lua evaluates inner constructors first, so a child exists before the box that holds
// it. Adopting afterwards is what lets each spec table be garbage the moment its node
// is created, instead of a whole screen's worth of them living until the end of a build.
void attach(uint16_t parent, uint16_t child) {
nodes[child].parent = parent;
uint16_t tail = specs[parent].last;
if (tail == NONE) {
nodes[parent].first = child;
} else {
nodes[tail].next = child;
}
specs[parent].last = child;
}
bool layout(uint16_t root, int x, int y, int w, int h) {
error = nullptr;
measure(root, w, h);
if (error) return false;
place(root, x, y, w, h);
return error == nullptr;
}
// Deepest interactive node wins, so a tappable child beats its tappable parent. The
// list is singly linked, so "last match walking forward" stands in for "first match
// walking backward"; they name the same node.
uint16_t hit(uint16_t id, int px, int py) const {
const Node& n = nodes[id];
if (px < n.x - SLOP || px >= n.x + n.w + SLOP) return NONE;
if (py < n.y - SLOP || py >= n.y + n.h + SLOP) return NONE;
uint16_t found = NONE;
for (uint16_t c = n.first; c != NONE; c = nodes[c].next) {
uint16_t inner = hit(c, px, py);
if (inner != NONE) found = inner;
}
if (found != NONE) return found;
if (n.flags & CAPTURE) return id;
return (n.flags & INTERACTIVE) ? id : NONE;
}
// Layout inputs are dead once place() has run. Callers drop them here rather than
// carrying ~20 bytes a node for the lifetime of a screen.
void dropScratch() {
specs.clear();
specs.shrink_to_fit();
}
// Labels share one NUL-separated arena, so a text node costs two bytes plus its
// characters rather than a string object.
//
// ponytail: a longer label appends and abandons the old bytes. The clock repaints
// every second at a fixed width, which overwrites in place, so the arena only grows
// when a label genuinely gets longer. Compact on rebuild if some app proves otherwise.
void setLabel(uint16_t id, const char* text) {
size_t length = strlen(text);
uint16_t at = labelAt[id];
if (at != NO_LABEL && strlen(&labels[at]) >= length) {
memcpy(&labels[at], text, length + 1);
return;
}
labelAt[id] = static_cast<uint16_t>(labels.size());
labels.insert(labels.end(), text, text + length + 1);
}
const char* label(uint16_t id) const {
uint16_t at = labelAt[id];
return at == NO_LABEL ? nullptr : &labels[at];
}
size_t footprint() const {
return nodes.size() * sizeof(Node) + labelAt.size() * sizeof(uint16_t) + labels.size() +
styles.size() * sizeof(styles[0]);
}
// Styles are sparse because inheritance means almost every node states nothing: a
// screen's root carries the palette and a handful of nodes override one role. Ids are
// handed out in increasing order during a build, so appends keep the list sorted and
// lookup is a binary search.
Style& styleFor(uint16_t id) {
size_t low = 0, high = styles.size();
while (low < high) {
size_t mid = (low + high) / 2;
if (styles[mid].first < id) {
low = mid + 1;
} else {
high = mid;
}
}
if (low < styles.size() && styles[low].first == id) return styles[low].second;
return styles.insert(styles.begin() + low, std::make_pair(id, Style()))->second;
}
// The nearest style at or above `id` that states `field`, or a default one if nothing
// does. Returning the whole style lets a caller read the pair a role comes in.
const Style& inherited(uint16_t id, uint16_t field) const {
static const Style fallback;
for (uint16_t n = id; n != NONE; n = nodes[n].parent) {
const Style* style = styleOf(n);
if (style && (style->set & field)) return *style;
}
return fallback;
}
const Style* styleOf(uint16_t id) const {
size_t low = 0, high = styles.size();
while (low < high) {
size_t mid = (low + high) / 2;
if (styles[mid].first < id) {
low = mid + 1;
} else {
high = mid;
}
}
return (low < styles.size() && styles[low].first == id) ? &styles[low].second : nullptr;
}
private:
std::vector<uint16_t> labelAt;
std::vector<char> labels;
std::vector<std::pair<uint16_t, Style> > styles;
void fail(const char* message) {
if (!error) error = message;
}
int resolve(Size size, int span) {
switch (size.mode) {
case AUTO:
return UNKNOWN;
case PX:
return size.value;
case FILL:
if (span == UNKNOWN) {
fail("fill inside an auto-sized parent");
return 0;
}
return span;
case FRACTION:
if (span == UNKNOWN) {
fail("fraction inside an auto-sized parent");
return 0;
}
return span * size.value / 1000;
}
return UNKNOWN;
}
void measure(uint16_t id, int availW, int availH) {
// By value, and re-index after recursion: measuring a child can push nodes, and a
// vector that grows moves every reference taken before it.
const Spec s = specs[id];
const bool row = (nodes[id].flags & ROW) != 0;
int w = resolve(s.w, availW);
int h = resolve(s.h, availH);
int innerW = w != UNKNOWN ? w - s.padL - s.padR
: (availW != UNKNOWN ? availW - s.padL - s.padR : UNKNOWN);
// Height is only handed down when this node was given one. A parent sized by its
// content cannot tell a child what fraction of it to take.
int innerH = h != UNKNOWN ? h - s.padT - s.padB : UNKNOWN;
int main = 0, cross = 0, count = 0;
for (uint16_t c = nodes[id].first; c != NONE; c = nodes[c].next) {
measure(c, innerW, innerH);
// Absolutely placed children are measured, because they still need a size, but they
// take no part in the flow their siblings share. The Lua original counted them here
// and excluded them in place(); nothing depended on the disagreement.
if (specs[c].absolute) continue;
const Node& child = nodes[c];
if (count) main += s.gap;
if (row) {
main += child.w;
if (child.h > cross) cross = child.h;
} else {
main += child.h;
if (child.w > cross) cross = child.w;
}
count++;
}
if (count == 0) {
main = row ? s.intrinsicW : s.intrinsicH;
cross = row ? s.intrinsicH : s.intrinsicW;
}
Node& self = nodes[id];
int along = main + (row ? s.padL + s.padR : s.padT + s.padB);
int across = cross + (row ? s.padT + s.padB : s.padL + s.padR);
if (row) {
self.w = static_cast<int16_t>(w != UNKNOWN ? w : along);
self.h = static_cast<int16_t>(h != UNKNOWN ? h : across);
} else {
self.w = static_cast<int16_t>(w != UNKNOWN ? w : across);
self.h = static_cast<int16_t>(h != UNKNOWN ? h : along);
}
}
void place(uint16_t id, int x, int y, int w, int h) {
const Spec s = specs[id];
bool row = (nodes[id].flags & ROW) != 0;
{
Node& self = nodes[id];
self.x = static_cast<int16_t>(x);
self.y = static_cast<int16_t>(y);
self.w = static_cast<int16_t>(w);
self.h = static_cast<int16_t>(h);
self.flags |= DIRTY;
}
int cx = x + s.padL, cy = y + s.padT;
int cw = w - s.padL - s.padR, ch = h - s.padT - s.padB;
// Main-axis distribution, CSS justify-content minus the modes nothing asks for.
// Absolutely placed children take no part in the flow.
int flowing = 0, used = 0;
for (uint16_t c = nodes[id].first; c != NONE; c = nodes[c].next) {
if (specs[c].absolute) continue;
flowing++;
used += row ? nodes[c].w : nodes[c].h;
}
if (flowing > 1) used += (flowing - 1) * s.gap;
int slack = (row ? cw : ch) - used;
if (slack < 0) slack = 0;
int offset = 0, spread = 0;
if (s.justify == END) {
offset = slack;
} else if (s.justify == CENTER) {
offset = slack / 2;
} else if (s.justify == BETWEEN && flowing > 1) {
spread = slack / (flowing - 1);
}
for (uint16_t c = nodes[id].first; c != NONE; c = nodes[c].next) {
const Spec cs = specs[c];
int childW = nodes[c].w, childH = nodes[c].h;
// Cross axis fills the parent unless the child asked for a size, like CSS blocks.
if (row) {
if (cs.h.mode == AUTO) childH = ch;
} else {
if (cs.w.mode == AUTO) childW = cw;
}
int px, py;
if (cs.absolute) {
px = cx + resolve(cs.atX, cw);
py = cy + resolve(cs.atY, ch);
} else if (row) {
px = cx + offset;
py = cy;
if (s.align == CENTER) {
py += (ch - childH) / 2;
} else if (s.align == END) {
py += ch - childH;
}
offset += childW + s.gap + spread;
} else {
px = cx;
py = cy + offset;
if (s.align == CENTER) {
px += (cw - childW) / 2;
} else if (s.align == END) {
px += cw - childW;
}
offset += childH + s.gap + spread;
}
place(c, px, py, childW, childH);
}
}
};
} // namespace ui
-184
View File
@@ -1,184 +0,0 @@
#pragma once
// Painting the node tree. Needs the panel, so unlike layout.h this is not host-testable;
// keep anything that can be decided without pixels on the other side of that line.
//
// Repainting follows the Lua original: a dirty node paints itself and dirties its
// children, because a parent's fill lands on top of whatever they drew. Nothing tracks
// sub-regions -- a widget that wants to repaint part of itself is a CUSTOM node and does
// it through the gui bindings, which is what the on-screen keyboard already does.
#include <TFT_eSPI.h>
#include "../gfx/round_rect.h"
#include "layout.h"
namespace ui {
constexpr int MAX_SPAN = 480; // longest panel edge, so one row buffer covers any shape
// One primitive draws the whole surface: fill (solid or vertical gradient) and border
// derive from the same distance field, so they cannot disagree at the corners the way
// two separate rounded-rect algorithms did. The panel has no alpha, so edge pixels are
// blended against `surface`, the colour of whatever sits underneath.
inline void drawRoundRect(TFT_eSPI& tft, int x, int y, int w, int h, float radius,
uint16_t surface, bool hasFill, uint16_t top, uint16_t bottom,
bool hasBorder, uint16_t border) {
if (w <= 0 || h <= 0 || w > MAX_SPAN) return;
float halfWidth = w * 0.5f, halfHeight = h * 0.5f;
if (radius < 0.0f) radius = 0.0f;
float limit = (w < h ? w : h) / 2.0f;
if (radius > limit) radius = limit;
static uint16_t span[MAX_SPAN];
// pushImage sends the buffer verbatim, but the panel wants each colour big-endian.
bool previousSwap = tft.getSwapBytes();
tft.setSwapBytes(true);
for (int row = 0; row < h; row++) {
uint16_t fill = hasFill ? gfx::lerp565(top, bottom, row, h - 1) : 0;
float py = row + 0.5f - halfHeight;
for (int column = 0; column < w; column++) {
float distance =
gfx::roundRectDistance(column + 0.5f - halfWidth, py, halfWidth, halfHeight, radius);
float outer = gfx::coverage(distance);
// The border is the ring between the shape and the same shape inset by its width.
float inner = hasBorder ? gfx::coverage(distance + 1.0f) : outer;
uint16_t pixel = surface;
if (hasFill) pixel = gfx::blend565(pixel, fill, inner);
if (hasBorder) pixel = gfx::blend565(pixel, border, outer - inner);
span[column] = pixel;
}
tft.pushImage(x, y + row, w, 1, span);
}
tft.setSwapBytes(previousSwap);
}
// A CUSTOM node paints through Lua, so the walk needs a way back. One dispatcher for the
// whole tree rather than a reference per node: the Lua side already keys its painters by
// node id and can look one up faster than the registry can hand it over.
typedef void (*CustomPainter)(void* context, uint16_t id, int x, int y, int w, int h);
class Painter {
public:
Painter(TFT_eSPI& tft, Tree& tree) : tft(tft), tree(tree) {}
CustomPainter custom = nullptr;
void* context = nullptr;
void draw(uint16_t id) {
Node& n = tree.nodes[id];
if (n.flags & DIRTY) {
paint(id);
n.flags &= ~DIRTY;
for (uint16_t c = tree.nodes[id].first; c != NONE; c = tree.nodes[c].next) {
tree.nodes[c].flags |= DIRTY;
}
}
for (uint16_t c = tree.nodes[id].first; c != NONE; c = tree.nodes[c].next) draw(c);
}
private:
TFT_eSPI& tft;
Tree& tree;
// What a node sits on, which is not what it fills. Derived rather than stored, because
// a node cannot be told what is behind it: a dialog layer paints nothing, so its card
// blends into the dimmed content two levels up, not into the lit palette the layer
// hands its children. Nothing filling means the panel, cleared to the root's colour.
uint16_t surfaceOf(uint16_t id) const {
for (uint16_t n = tree.nodes[id].parent; n != NONE; n = tree.nodes[n].parent) {
const Style* style = tree.styleOf(n);
if (style && (style->set & S_FILL)) return style->fill;
}
uint16_t root = id;
while (tree.nodes[root].parent != NONE) root = tree.nodes[root].parent;
return tree.inherited(root, S_BG).bg;
}
void paint(uint16_t id) {
const Node& n = tree.nodes[id];
switch (n.type) {
case BUTTON:
paintButton(id);
break;
case TEXT:
paintText(id);
break;
case CUSTOM:
// Cleared first, because a custom painter draws what it wants and nothing knows
// what it drew last time. The keyboard's number page is narrower than its letter
// page, and without this the wider row's outer keys survive the repaint.
tft.fillRect(n.x, n.y, n.w, n.h, tree.inherited(id, S_BG).bg);
if (custom) custom(context, id, n.x, n.y, n.w, n.h);
break;
default:
paintBox(id);
break;
}
}
// A bordered box paints its own background as a rounded rect. Filling a square first
// would leave corners outside the border, which is invisible against a matching
// surface and obvious against any other.
void paintBox(uint16_t id) {
const Node& n = tree.nodes[id];
const Style* own = tree.styleOf(id);
bool hasBorder = own && (own->set & S_BORDER);
bool hasFill = own && (own->set & S_FILL);
if (!hasBorder) {
if (hasFill) tft.fillRect(n.x, n.y, n.w, n.h, own->fill);
return;
}
uint16_t fill = hasFill ? own->fill : tree.inherited(id, S_BG).bg;
drawRoundRect(tft, n.x, n.y, n.w, n.h, tree.inherited(id, S_RADIUS).radius, surfaceOf(id),
true, fill, fill, true, own->border);
}
void paintButton(uint16_t id) {
const Node& n = tree.nodes[id];
bool pressed = (n.flags & PRESSED) != 0;
const Style& face = tree.inherited(id, pressed ? S_PRESSED : S_FACE);
uint16_t top = pressed ? face.pressTop : face.faceTop;
uint16_t bottom = pressed ? face.pressBottom : face.faceBottom;
drawRoundRect(tft, n.x, n.y, n.w, n.h, tree.inherited(id, S_RADIUS).radius, surfaceOf(id),
true, top, bottom, true, tree.inherited(id, S_FG).fg);
}
// Glyphs over a button are transparent: an opaque fill is one flat colour, which
// matches only the single row of the gradient it was taken from. The face is repainted
// whenever it changes, so the label has nothing to erase.
void paintText(uint16_t id) {
const Node& n = tree.nodes[id];
const char* label = tree.label(id);
if (!label) return;
uint16_t parent = n.parent;
bool onButton = parent != NONE && tree.nodes[parent].type == BUTTON;
bool pressed = onButton && (tree.nodes[parent].flags & PRESSED);
tft.setTextSize(tree.inherited(id, S_SIZE).size);
// A text node usually fills its parent's width, so alignment is inside its own box.
int x = n.x;
Align align = tree.inherited(id, S_TEXT_ALIGN).textAlign;
if (align == CENTER) {
x += (n.w - static_cast<int>(tft.textWidth(label))) / 2;
} else if (align == END) {
x += n.w - static_cast<int>(tft.textWidth(label));
}
if (pressed) {
tft.setTextColor(tree.inherited(id, S_PRESS_FG).pressFg);
} else if (onButton) {
tft.setTextColor(tree.inherited(id, S_FG).fg);
} else {
// The whole box is cleared, not just the glyphs: a label replaced by a shorter one
// would otherwise leave the tail of the old text standing next to the new.
uint16_t bg = tree.inherited(id, S_BG).bg;
tft.fillRect(n.x, n.y, n.w, n.h, bg);
tft.setTextColor(tree.inherited(id, S_FG).fg, bg);
}
tft.drawString(label, x, n.y);
}
};
} // namespace ui