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