diff --git a/docs/building/compilation-options.md b/docs/building/compilation-options.md index 381367fe638..29d0d770767 100644 --- a/docs/building/compilation-options.md +++ b/docs/building/compilation-options.md @@ -225,6 +225,7 @@ These options are for development and debugging. All default to `OFF`. | `SIMU_DISKIO` | OFF | Enable disk I/O simulation using a `sdcard.image` file | | `SIMU_LUA_COMPILER` | ON | Pre-compile and cache Lua scripts in the simulator | | `SIMU_TARGET` | ON | Build the simulator target | +| `WIDGET_STUDIO` | OFF | Enable native-simulator automation hooks, including command-file steering, deterministic PNG capture and the simulator-only `simu` Lua module | | `DISABLE_COMPANION` | OFF | Skip building Companion and simulators | --- diff --git a/radio/src/gui/colorlcd/LvglWrapper.cpp b/radio/src/gui/colorlcd/LvglWrapper.cpp index 8f0f0d3b03b..4e7e3e4be76 100644 --- a/radio/src/gui/colorlcd/LvglWrapper.cpp +++ b/radio/src/gui/colorlcd/LvglWrapper.cpp @@ -29,6 +29,10 @@ #include "os/time.h" #include "view_main.h" +#if defined(SIMU) && defined(WIDGET_STUDIO) +#include "targets/simu/simulib.h" +#endif + LvglWrapper* LvglWrapper::_instance = nullptr; static lv_indev_drv_t touchDriver; @@ -367,6 +371,13 @@ void LvglWrapper::run() if (!updating) { // Normal UI loop - call lgvl timer handler updating = true; +#if defined(SIMU) && defined(WIDGET_STUDIO) + // A capture armed against an already-static screen would otherwise wait + // forever for a flush that never comes (see simuCaptureArm). + if (simuConsumeCaptureRedraw()) { + lv_obj_invalidate(lv_scr_act()); + } +#endif lv_timer_handler(); updating = false; } else { diff --git a/radio/src/lua/api_simu.cpp b/radio/src/lua/api_simu.cpp new file mode 100644 index 00000000000..2e85e9b222b --- /dev/null +++ b/radio/src/lua/api_simu.cpp @@ -0,0 +1,114 @@ +/* + * Copyright (C) EdgeTX + * + * Widget Studio sim-only Lua module (simu target + WIDGET_STUDIO only). + * Provides the low-level simulator controls the harness cannot reach through + * the public Lua API: + * simu.setSwitch(name, state) - physical switch -1/0/1 + * simu.setAnalog(name, value) - ADC input in 0..4096 + * simu.armCapture(path) - deterministic one-shot PNG of the next frame + * simu.reset() - full simulator reset (hot reload) + * + * Telemetry injection is intentionally NOT here: the public global + * setTelemetryValue(id, subId, instance, value, unit, prec, name) already + * registers real sensors through the real sensor registry, which is what the + * harness needs for truthful getValue()/getFieldInfo()/CELLS behaviour. + * + * License GPLv2: http://www.gnu.org/licenses/gpl-2.0.html + */ + +#include +#include + +#define LUA_LIB +#include "edgetx.h" +#include "lua_api.h" +#include "lua_states.h" +#include "switches.h" +#include "strhelpers.h" +#include "hal/adc_driver.h" + +#if defined(COLORLCD) && defined(SIMU) && defined(WIDGET_STUDIO) + +#include "targets/simu/simulib.h" + +static int luaSimuSetSwitch(lua_State* L) +{ + const char* name = luaL_checkstring(L, 1); + int state = luaL_checkinteger(L, 2); + if (state < -1) state = -1; + if (state > 1) state = 1; + + uint8_t count = getSwitchCount(); + for (uint8_t i = 0; i < count; i++) { + char buf[16]; + const char* sname = getSwitchName(buf, i); + if (sname && !strcmp(sname, name)) { + simuSetSwitch(i, (int8_t)state); + lua_pushboolean(L, true); + return 1; + } + } + + lua_pushboolean(L, false); + return 1; +} + +static int luaSimuSetAnalog(lua_State* L) +{ + const char* name = luaL_checkstring(L, 1); + int value = luaL_checkinteger(L, 2); + if (value < 0) value = 0; + if (value > 4096) value = 4096; + + int idx = adcGetInputIdx(name, strlen(name)); + if (idx < 0) { + lua_pushboolean(L, false); + return 1; + } + + simuSetAnalogValue((uint8_t)idx, (uint16_t)value); + lua_pushboolean(L, true); + return 1; +} + +static int luaSimuArmCapture(lua_State* L) +{ + const char* path = luaL_checkstring(L, 1); + if (path[0] == '\0') { + lua_pushboolean(L, false); + return 1; + } + + // Resolve a radio-style path (for example /SCREENSHOTS/x.png) through the + // simulator's configured SD-card mapping. + std::string real = simuFatfsGetRealPath(path); + if (real.empty()) { + lua_pushboolean(L, false); + return 1; + } + simuCaptureArm(real.c_str()); + + lua_pushboolean(L, true); + return 1; +} + +static int luaSimuReset(lua_State* L) +{ + // Deliberately async: the native simu main loop consumes the request between + // frames, so this is safe to call from inside Lua. + simuRequestReset(); + lua_pushboolean(L, true); + return 1; +} + +extern "C" { +LROT_BEGIN(simulib, NULL, 0) + LROT_FUNCENTRY( setSwitch, luaSimuSetSwitch ) + LROT_FUNCENTRY( setAnalog, luaSimuSetAnalog ) + LROT_FUNCENTRY( armCapture, luaSimuArmCapture ) + LROT_FUNCENTRY( reset, luaSimuReset ) +LROT_END(simulib, NULL, 0) +} + +#endif // COLORLCD && SIMU && WIDGET_STUDIO diff --git a/radio/src/lua/api_simu.h b/radio/src/lua/api_simu.h new file mode 100644 index 00000000000..207c7e4ff16 --- /dev/null +++ b/radio/src/lua/api_simu.h @@ -0,0 +1,16 @@ +/* + * Copyright (C) EdgeTX + * + * Widget Studio sim-only Lua module. Compiled for the simu target only and + * gated by WIDGET_STUDIO (see radio/src/targets/simu/CMakeLists.txt). Keep + * this module limited to development automation; it must not grow into + * a firmware-facing API. + * + * License GPLv2: http://www.gnu.org/licenses/gpl-2.0.html + */ + +#pragma once + +#if defined(COLORLCD) && defined(SIMU) && defined(WIDGET_STUDIO) +extern LROT_TABLE(simulib); +#endif diff --git a/radio/src/targets/simu/CMakeLists.txt b/radio/src/targets/simu/CMakeLists.txt index acf2638230b..51857d9cc4c 100644 --- a/radio/src/targets/simu/CMakeLists.txt +++ b/radio/src/targets/simu/CMakeLists.txt @@ -5,6 +5,15 @@ if(NOT SIMU_TARGET) return() endif() +# Widget Studio dev hooks (simu target only, never release firmware): +# - simu Lua module (api_simu.cpp), gated by WIDGET_STUDIO +# - deterministic PNG LCD capture (simuCaptureArm / simuLcdNotify) +# - analog override + --pipe steering channel (sdl_simu.cpp) +option(WIDGET_STUDIO "Widget Studio dev hooks" OFF) +if(WIDGET_STUDIO) + message(STATUS "Widget Studio dev hooks: ON (simu target only)") +endif() + if(MINGW) # struct packing breaks on MinGW w/out -mno-ms-bitfields: # https://gcc.gnu.org/bugzilla/show_bug.cgi?id=52991 @@ -57,6 +66,28 @@ get_property(SIMU_SRC_OPTIONS target_compile_options(simu_drivers PRIVATE ${SIMU_SRC_OPTIONS}) set_property(TARGET simu_drivers PROPERTY POSITION_INDEPENDENT_CODE ON) +if(WIDGET_STUDIO AND NOT WASI) + # Compile the Lua module and simulator state only when explicitly enabled. + # The PNG backend remains in the interactive simu target below so + # gtests-radio and wasi-module never pull in another stb implementation. + target_sources(radiolib_native PRIVATE + ${RADIO_SRC_DIR}/lua/api_simu.cpp + ) + + # Limit WIDGET_STUDIO to the sources that implement the hooks. This keeps + # the default simulator's preprocessor surface and rebuild footprint intact. + set_property(SOURCE + ${RADIO_SRC_DIR}/lua/api_simu.cpp + ${RADIO_SRC_DIR}/gui/colorlcd/LvglWrapper.cpp + ${RADIO_SRC_DIR}/thirdparty/Lua/src/linit.c + TARGET_DIRECTORY radiolib_native + APPEND PROPERTY COMPILE_DEFINITIONS WIDGET_STUDIO + ) + set_property(SOURCE simulib.cpp + APPEND PROPERTY COMPILE_DEFINITIONS WIDGET_STUDIO + ) +endif() + if(SIMU_AUX) target_compile_definitions(simu_drivers PRIVATE -DSIMU_COM_PORT=${SIMU_COM_PORT}) endif() @@ -136,6 +167,20 @@ else() target_compile_options(simu PUBLIC -DSIMU) + if(WIDGET_STUDIO) + # The SDL main loop (sdl_simu.cpp) owns the --pipe steering channel and + # the reset-request consumption; it lives in the simu executable target. + # simu_capture.cpp (the stb_image_write PNG encoder) is here rather than + # in simu_drivers for the same reason - see the comment above. + target_sources(simu PRIVATE simu_capture.cpp) + set_property(SOURCE + arg_parser.cpp + sdl_simu.cpp + simu_capture.cpp + APPEND PROPERTY COMPILE_DEFINITIONS WIDGET_STUDIO + ) + endif() + add_png_target(image_assets "assets/images/*.png") add_dependencies(simu image_assets) diff --git a/radio/src/targets/simu/arg_parser.cpp b/radio/src/targets/simu/arg_parser.cpp index 747a18afd89..f1a083f18e0 100644 --- a/radio/src/targets/simu/arg_parser.cpp +++ b/radio/src/targets/simu/arg_parser.cpp @@ -24,6 +24,11 @@ bool ArgumentParser::parse(int argc, char *argv[]) { } else if (arg == "--settings") { if (!getNextArg(argc, argv, i, settings_path, "settings")) return false; +#if defined(WIDGET_STUDIO) + } else if (arg == "--pipe") { + if (!getNextArg(argc, argv, i, pipe_path, "pipe")) + return false; +#endif } else if (arg == "-h" || arg == "--help") { help_requested = true; return true; @@ -38,7 +43,11 @@ bool ArgumentParser::parse(int argc, char *argv[]) { void ArgumentParser::printUsage() const { printf("usage: %s [--width width] [--height height] [--storage path] " - "[--settings path] [-h | --help]\n", + "[--settings path] " +#if defined(WIDGET_STUDIO) + "[--pipe path] " +#endif + "[-h | --help]\n", program_name.c_str()); } @@ -49,6 +58,10 @@ void ArgumentParser::printHelp() const { printf(" --height height Set the height (integer)\n"); printf(" --storage path Set the storage path\n"); printf(" --settings path Set the settings path\n"); +#if defined(WIDGET_STUDIO) + printf(" --pipe path Read Widget Studio commands from an\n"); + printf(" append-only file (one command per line)\n"); +#endif printf(" -h, --help Show this help message\n"); } @@ -66,6 +79,10 @@ const std::string &ArgumentParser::getSettingsPath() const { return settings_path; } +#if defined(WIDGET_STUDIO) +const std::string &ArgumentParser::getPipePath() const { return pipe_path; } +#endif + bool ArgumentParser::hasWidth() const { return width != -1; } bool ArgumentParser::hasHeight() const { return height != -1; } @@ -74,6 +91,10 @@ bool ArgumentParser::hasStoragePath() const { return !storage_path.empty(); } bool ArgumentParser::hasSettingsPath() const { return !settings_path.empty(); } +#if defined(WIDGET_STUDIO) +bool ArgumentParser::hasPipePath() const { return !pipe_path.empty(); } +#endif + bool ArgumentParser::getNextArg(int argc, char *argv[], int &i, std::string &value, const std::string &option_name) { diff --git a/radio/src/targets/simu/arg_parser.h b/radio/src/targets/simu/arg_parser.h index 359cc6625e2..374de8f8ca1 100644 --- a/radio/src/targets/simu/arg_parser.h +++ b/radio/src/targets/simu/arg_parser.h @@ -8,6 +8,9 @@ class ArgumentParser { int height = -1; std::string storage_path; std::string settings_path; +#if defined(WIDGET_STUDIO) + std::string pipe_path; +#endif bool help_requested = false; std::string program_name; @@ -24,12 +27,18 @@ class ArgumentParser { int getHeight() const; const std::string &getStoragePath() const; const std::string &getSettingsPath() const; +#if defined(WIDGET_STUDIO) + const std::string &getPipePath() const; +#endif // Check if option was provided bool hasWidth() const; bool hasHeight() const; bool hasStoragePath() const; bool hasSettingsPath() const; +#if defined(WIDGET_STUDIO) + bool hasPipePath() const; +#endif private: bool getNextArg(int argc, char *argv[], int &i, std::string &value, diff --git a/radio/src/targets/simu/sdl_simu.cpp b/radio/src/targets/simu/sdl_simu.cpp index a6ab43e3301..e67011c2899 100644 --- a/radio/src/targets/simu/sdl_simu.cpp +++ b/radio/src/targets/simu/sdl_simu.cpp @@ -32,6 +32,7 @@ #include #include #include +#include #include #include "hal/adc_driver.h" @@ -655,6 +656,107 @@ static void redraw() } } +#if defined(WIDGET_STUDIO) && !defined(__EMSCRIPTEN__) + +// Widget Studio steering channel. The host driver appends newline-delimited +// commands to the file given via --pipe; the simu consumes them from the main +// loop. Commands: +// exit - quit the simulator +// capture - arm a one-shot PNG capture of the next frame +// key <0|1> - physical key up/down (EnumKeys code) +// touch - touch-panel press/drag to (x, y) +// touchup - touch-panel release +// reset - full simu stop/start (used for hot reload) +// reload - reload permanent Lua scripts +static std::string pipe_path; +static std::string pipe_pending; +// Byte offset already consumed from the pipe file. The file is append-only +// on the driver side and is never truncated here: re-reading from offset 0 +// every poll would redispatch every command ever written (a stuck key, or a +// "reset" firing every frame forever), and truncating the file to +// acknowledge it would race the driver's next append with no locking on +// either side. Tracking an offset needs neither. +static std::streamoff pipe_read_offset = 0; + +static void dispatchPipeCommand(const std::string& line) +{ + std::istringstream iss(line); + std::string cmd; + iss >> cmd; + + if (cmd == "exit") { + SDL_Event ev; + ev.type = SDL_QUIT; + SDL_PushEvent(&ev); + } else if (cmd == "capture") { + std::string path; + std::getline(iss >> std::ws, path); + if (!path.empty()) simuCaptureArm(path.c_str()); + } else if (cmd == "key") { + int key = -1, state = 0; + if ((iss >> key >> state) && key >= 0 && key < MAX_KEYS && + (state == 0 || state == 1)) + simuSetKey((uint8_t)key, state != 0); + } else if (cmd == "touch") { + int x = -1, y = -1; + if ((iss >> x >> y) && x >= 0 && x < LCD_W && y >= 0 && y < LCD_H) + simuTouchDown((int16_t)x, (int16_t)y); + } else if (cmd == "touchup") { + simuTouchUp(); + } else if (cmd == "reset") { + simuRequestReset(); + } else if (cmd == "reload") { + simuLuaReloadPermanentScripts(); + } +} + +static void pollPipeCommands() +{ + if (pipe_path.empty()) return; + + std::ifstream f(pipe_path, std::ios::in | std::ios::binary); + if (!f.is_open()) return; + + f.seekg(0, std::ios::end); + std::streamoff size = f.tellg(); + if (size < pipe_read_offset) { + // The driver recreated the file (e.g. a fresh run reusing the same + // path): resync from the start instead of reading a negative range. + pipe_read_offset = 0; + pipe_pending.clear(); + } + if (size <= pipe_read_offset) return; + + f.seekg(pipe_read_offset, std::ios::beg); + std::streamsize requested = size - pipe_read_offset; + std::string appended(static_cast(requested), '\0'); + f.read(appended.data(), requested); + std::streamsize bytes_read = f.gcount(); + if (bytes_read <= 0) return; + appended.resize(static_cast(bytes_read)); + pipe_read_offset += bytes_read; + + // Read only the size observed above. If the host appends concurrently, the + // later bytes remain beyond pipe_read_offset for the next poll instead of + // being dispatched now and then a second time on the next frame. + std::string content = pipe_pending + appended; + pipe_pending.clear(); + + size_t start = 0; + size_t pos; + while ((pos = content.find('\n', start)) != std::string::npos) { + std::string line = content.substr(start, pos - start); + start = pos + 1; + if (!line.empty() && line.back() == '\r') line.pop_back(); + if (!line.empty() && line[0] != '#') dispatchPipeCommand(line); + } + + // Hold an incomplete tail until the driver writes the newline. + pipe_pending = content.substr(start); +} + +#endif // WIDGET_STUDIO && !__EMSCRIPTEN__ + int default_input_mode() { #if defined(DEFAULT_MODE) @@ -790,6 +892,12 @@ int main(int argc, char* argv[]) args.getSettingsPath().c_str()); simuStart(); +#if defined(WIDGET_STUDIO) && !defined(__EMSCRIPTEN__) + if (args.hasPipePath()) { + pipe_path = args.getPipePath(); + } +#endif + // Main loop SDL_SetEventFilter([](void*, SDL_Event* event){ if (event->type == SDL_WINDOWEVENT && @@ -805,6 +913,13 @@ int main(int argc, char* argv[]) #else do { Uint64 start_ts = SDL_GetPerformanceCounter(); +#if defined(WIDGET_STUDIO) && !defined(__EMSCRIPTEN__) + pollPipeCommands(); + if (simuConsumeResetRequest()) { + simuStop(); + simuStart(); + } +#endif if (!handleEvents()) break; Uint64 end_ts = SDL_GetPerformanceCounter(); @@ -839,6 +954,13 @@ int main(int argc, char* argv[]) uint16_t simuGetAnalog(uint8_t idx) { +#if defined(WIDGET_STUDIO) + uint16_t override_val; + if (simuGetAnalogOverride(idx, &override_val)) { + return override_val; + } +#endif + auto max_sticks = adcGetMaxInputs(ADC_INPUT_MAIN); if (idx < max_sticks) { switch(idx) { @@ -873,4 +995,7 @@ uint16_t simuGetAnalog(uint8_t idx) } void simuTrace(const char* text) {} + +#if !defined(WIDGET_STUDIO) void simuLcdNotify() {} +#endif diff --git a/radio/src/targets/simu/simu_capture.cpp b/radio/src/targets/simu/simu_capture.cpp new file mode 100644 index 00000000000..0f4dff2604b --- /dev/null +++ b/radio/src/targets/simu/simu_capture.cpp @@ -0,0 +1,59 @@ +/* + * Copyright (C) EdgeTX + * + * PNG encoder backend for Widget Studio LCD capture (simuCaptureArm / + * simuLcdNotify, see simulib.h). Deliberately kept OUT of simulib.cpp: that + * file lives in the simu_drivers object library, which is also linked into + * gtests-radio and wasi-module, and stb_image_write's single-definition + * pattern would collide with radio/src/tests/lcd_480x272.cpp's own copy. + * This file is compiled only into the interactive `simu` executable and owns + * the PNG encoder plus the native frame-ready callback. + * + * License GPLv2: http://www.gnu.org/licenses/gpl-2.0.html + */ + +#include "simulib.h" + +#if defined(WIDGET_STUDIO) && !defined(__wasm__) + +#include "simulcd.h" + +#include + +#define STB_IMAGE_WRITE_IMPLEMENTATION +#include "stb_image_write.h" + +namespace { + +bool captureDump(const char* path) +{ +#if defined(COLORLCD) + const int w = LCD_W; + const int h = LCD_H; + std::vector rgb(w * h * 3); + const pixel_t* src = simuLcdBuf; + for (int i = 0; i < w * h; i++) { + uint16_t p = src[i]; + uint8_t r5 = (p >> 11) & 0x1F; + uint8_t g6 = (p >> 5) & 0x3F; + uint8_t b5 = p & 0x1F; + rgb[i * 3 + 0] = (r5 << 3) | (r5 >> 2); + rgb[i * 3 + 1] = (g6 << 2) | (g6 >> 4); + rgb[i * 3 + 2] = (b5 << 3) | (b5 >> 2); + } + return stbi_write_png(path, w, h, 3, rgb.data(), w * 3) != 0; +#else + (void)path; + return false; +#endif +} + +} // namespace + +void simuLcdNotify() +{ + std::string path; + if (simuConsumeCapturePath(path)) captureDump(path.c_str()); +} + +#endif // WIDGET_STUDIO && !__wasm__ diff --git a/radio/src/targets/simu/simulib.cpp b/radio/src/targets/simu/simulib.cpp index 6b6c853196c..c0dc3be308f 100644 --- a/radio/src/targets/simu/simulib.cpp +++ b/radio/src/targets/simu/simulib.cpp @@ -48,6 +48,9 @@ #include #include #include +#if defined(WIDGET_STUDIO) +#include +#endif #include // Monotonic event counter incremented each time haptic fires. @@ -268,6 +271,90 @@ uint32_t simuLcdGetDepth() #endif } +// --------------------------------------------------------------------------- +// Widget Studio dev hooks (see simulib.h) +// --------------------------------------------------------------------------- + +#if defined(WIDGET_STUDIO) + +static std::mutex widget_studio_mutex; + +static std::string capture_path; +static bool capture_armed = false; +static bool capture_redraw_pending = false; + +static bool reset_requested = false; + +void simuRequestReset() +{ + std::lock_guard lock(widget_studio_mutex); + reset_requested = true; +} + +bool simuConsumeResetRequest() +{ + std::lock_guard lock(widget_studio_mutex); + bool r = reset_requested; + reset_requested = false; + return r; +} + +static uint16_t analog_override[MAX_STICKS + MAX_POTS + MAX_VBAT + MAX_RTC_BAT]; +static bool analog_override_set[MAX_STICKS + MAX_POTS + MAX_VBAT + MAX_RTC_BAT]; + +void simuCaptureArm(const char* path) +{ + std::lock_guard lock(widget_studio_mutex); + if (path == nullptr || path[0] == '\0') { + capture_path.clear(); + capture_armed = false; + capture_redraw_pending = false; + return; + } + + capture_path = path; + capture_armed = true; + capture_redraw_pending = true; +} + +bool simuConsumeCaptureRedraw() +{ + std::lock_guard lock(widget_studio_mutex); + bool pending = capture_redraw_pending; + capture_redraw_pending = false; + return pending; +} + +bool simuConsumeCapturePath(std::string& path) +{ + std::lock_guard lock(widget_studio_mutex); + if (!capture_armed) return false; + + path = capture_path; + capture_armed = false; + return true; +} + +void simuSetAnalogValue(uint8_t idx, uint16_t value) +{ + if (idx >= (MAX_STICKS + MAX_POTS + MAX_VBAT + MAX_RTC_BAT)) return; + std::lock_guard lock(widget_studio_mutex); + analog_override[idx] = value; + analog_override_set[idx] = true; +} + +bool simuGetAnalogOverride(uint8_t idx, uint16_t* value) +{ + if (idx >= (MAX_STICKS + MAX_POTS + MAX_VBAT + MAX_RTC_BAT)) + return false; + std::lock_guard lock(widget_studio_mutex); + if (!analog_override_set[idx]) return false; + if (value) *value = analog_override[idx]; + return true; +} + +#endif // WIDGET_STUDIO + #if !defined(COLORLCD) void lcdSetRefVolt(uint8_t val) { diff --git a/radio/src/targets/simu/simulib.h b/radio/src/targets/simu/simulib.h index b665dd0b5a9..3e2e90a25ee 100644 --- a/radio/src/targets/simu/simulib.h +++ b/radio/src/targets/simu/simulib.h @@ -197,3 +197,37 @@ std::string simuFatfsGetRealPath(const std::string& p); extern struct TouchState simTouchState; extern bool simTouchOccured; #endif + +// -- Widget Studio dev hooks (WIDGET_STUDIO, simu target only) -------------- +// +#if defined(WIDGET_STUDIO) + +// Deterministic LCD capture. simuCaptureArm(path) arms a one-shot dump: the +// NEXT frame-ready callback (simuLcdNotify) writes the current LCD framebuffer +// to the host `path` as a PNG (RGB565 -> RGB888) and disarms. The native +// implementation lives in simu_capture.cpp, which is linked only into the +// interactive simulator executable. +// +// Arming also raises a force-redraw request (simuConsumeCaptureRedraw): +// simuLcdNotify only fires on the LVGL flush callback, so a capture armed +// against an already-static screen (no pending animation, no dirty region) +// would otherwise wait forever for a frame that never comes. The LVGL task +// loop (LvglWrapper::run(), simu+WIDGET_STUDIO only) consumes the request and +// invalidates the active screen, guaranteeing the next flush happens. +void simuCaptureArm(const char* path); +bool simuConsumeCaptureRedraw(); +bool simuConsumeCapturePath(std::string& path); + +// ADC override: lets the harness force an input (stick/pot) value in ADC range +// (0..4096). Returns false when no override is set for `idx`, in which case +// the normal input path is used. `idx` is the combined input index used by +// simuGetAnalog() (main inputs first, then flex inputs). +void simuSetAnalogValue(uint8_t idx, uint16_t value); +bool simuGetAnalogOverride(uint8_t idx, uint16_t* value); + +// Async full simulator reset (hot reload). Requested from inside Lua (safe), +// consumed by the native simu main loop between frames. +void simuRequestReset(); +bool simuConsumeResetRequest(); + +#endif // WIDGET_STUDIO diff --git a/radio/src/targets/simu/widget_studio.md b/radio/src/targets/simu/widget_studio.md new file mode 100644 index 00000000000..0701c25358a --- /dev/null +++ b/radio/src/targets/simu/widget_studio.md @@ -0,0 +1,82 @@ +# Widget Studio simulator hooks + +Widget Studio adds opt-in automation hooks to the native EdgeTX simulator for +developing and testing visual Lua/LVGL components. It uses the real simulator +framebuffer, firmware UI, Lua runtime, themes and fonts; it is not a separate +renderer. + +The feature is disabled by default and is compiled only when +`WIDGET_STUDIO=ON`. With the option disabled, the `simu` Lua module, +`--pipe` argument, capture backend, input overrides and reset state are absent. +Nothing in this feature is compiled into radio firmware. + +## Build + +From the repository root: + +```sh +cmake --preset simu -DWIDGET_STUDIO=ON +cmake --build --preset simu +``` + +The hooks currently target the native color-LCD simulator. They are not an API +for hardware firmware, Companion embedding or the WASM simulator. + +## Host command file + +Start `simu` with `--pipe `. Despite the historical option name, the path +must identify a regular append-only file, not an operating-system FIFO. The +simulator tolerates a file that does not exist yet and polls only the bytes that +were appended since the previous frame. + +Create or truncate the file before starting a new simulator process, then +append newline-delimited commands while it runs. Blank lines and lines whose +first character is `#` are ignored. Both LF and CRLF line endings are accepted. + +| Command | Effect | +|---------|--------| +| `exit` | Push an SDL quit event | +| `capture ` | Write the next completed LCD frame as a PNG; the path may contain spaces | +| `key <0\|1>` | Set an `EnumKeys` key up or down | +| `touch ` | Press or drag the touch point within the LCD bounds | +| `touchup` | Release the touch point | +| `reset` | Request a full simulator stop/start between frames | +| `reload` | Reload permanent Lua scripts | + +The host owns command-file lifecycle. It should append complete lines and must +not rewrite already-consumed bytes while the simulator is running. If the file +shrinks, the reader safely resynchronizes from byte zero. + +## Simulator-only Lua module + +A color simulator built with the option exposes a global `simu` table: + +```lua +simu.setSwitch(name, state) -- state is clamped to -1, 0 or 1 +simu.setAnalog(name, value) -- ADC value is clamped to 0..4096 +simu.armCapture("/SCREENSHOTS/frame.png") +simu.reset() -- asynchronous; consumed between frames +``` + +`setSwitch` and `setAnalog` return `false` when the named input is unknown. +`armCapture` accepts a radio-style path and resolves it through the simulator's +configured SD-card mapping. Telemetry injection intentionally remains on the +existing public `setTelemetryValue` path so tests exercise the normal sensor +registry. + +## Deterministic capture + +Capture is one-shot. Arming it invalidates the active LVGL screen on the next +UI cycle, which guarantees a flush even when the screen was static. The native +frame-ready callback converts the RGB565 framebuffer to RGB888, writes the PNG +and disarms. + +The PNG encoder is linked only into the interactive `simu` executable. Keeping +it out of the shared simulator object library avoids duplicate stb symbols in +radio tests and WASM builds. + +## Trust boundary + +These are local development hooks. The command file and any host capture paths +must be treated as trusted input; do not expose them to untrusted users or a +network service without an additional validation layer. diff --git a/radio/src/thirdparty/Lua/src/linit.c b/radio/src/thirdparty/Lua/src/linit.c index c7d1772e2de..024b24a64b3 100644 --- a/radio/src/thirdparty/Lua/src/linit.c +++ b/radio/src/thirdparty/Lua/src/linit.c @@ -23,6 +23,10 @@ #include "lua/api_filesystem.h" #include "lua/api_colorlcd.h" +#if defined(COLORLCD) && defined(SIMU) && defined(WIDGET_STUDIO) +#include "lua/api_simu.h" +#endif + extern LROT_TABLE(iolib); extern LROT_TABLE(strlib); extern LROT_TABLE(mathlib); @@ -101,6 +105,9 @@ LROT_BEGIN(rotables, LROT_TABLEREF(rotables_meta), 0) #if defined(COLORLCD) LROT_TABENTRY( lvgl, lvgllib ) LROT_TABENTRY( table, tablib ) +#endif +#if defined(COLORLCD) && defined(SIMU) && defined(WIDGET_STUDIO) + LROT_TABENTRY( simu, simulib ) #endif LROT_TABENTRY( ROM, rotables ) LROT_END(rotables, LROT_TABLEREF(rotables_meta), 0)