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();
}