Files
thermostat-hw/sim/sim_main.c
T
2026-08-04 11:55:02 -07:00

399 lines
13 KiB
C

/*
* Desktop simulator entry point.
*
* Runs the real firmware UI (main/ui/ui_manager.c) against desktop LVGL at the
* panel's exact geometry and colour depth, so layout work does not need a
* flash cycle. Two modes:
*
* ./thermostat_sim interactive SDL window, mouse = touch
* ./thermostat_sim --png shot.png render one frame headless, write PNG
*
* The PNG mode exists so a layout change can be inspected without a human
* looking at a screen -- useful in CI, and it is how UI changes get checked
* during development.
*
* What this does NOT simulate: PSRAM bandwidth, the bounce-buffer refill ISR,
* or the RGB panel's scan timing. The left-edge artefacts described in the
* README are invisible here by construction. This tool answers "does the
* layout look right", never "does the panel keep up".
*/
#include <stdbool.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
#include <unistd.h>
#include "lvgl.h"
#include "lodepng.h"
#include "ha_data.h"
#include "ha_template.h"
#include "lvgl_port.h"
#include "ui_manager.h"
/* Must match the physical panel: main/drivers/display/display_rgb.h. A
* simulator at a different resolution would validate a layout that does not
* fit the hardware. */
#define PANEL_H_RES 800
#define PANEL_V_RES 480
static const char *TAG = "sim";
/* ---------------------------------------------------------------- headless */
static uint8_t *headless_fb = NULL;
static void headless_flush_cb(lv_display_t *disp, const lv_area_t *area, uint8_t *px_map)
{
/* Rendering happens straight into headless_fb (DIRECT mode), so there is
* nothing to copy -- just tell LVGL the "transfer" is done. */
(void)area;
(void)px_map;
lv_display_flush_ready(disp);
}
static lv_display_t *headless_display_create(void)
{
const size_t fb_bytes = (size_t)PANEL_H_RES * PANEL_V_RES * 2; /* RGB565 */
headless_fb = malloc(fb_bytes);
if (headless_fb == NULL) {
fprintf(stderr, "%s: cannot allocate %zu byte framebuffer\n", TAG, fb_bytes);
return NULL;
}
memset(headless_fb, 0, fb_bytes);
lv_display_t *disp = lv_display_create(PANEL_H_RES, PANEL_V_RES);
if (disp == NULL) {
return NULL;
}
lv_display_set_flush_cb(disp, headless_flush_cb);
/* DIRECT mode with one full-screen buffer, matching the firmware's render
* mode so widget clipping and partial-redraw behaviour line up. */
lv_display_set_buffers(disp, headless_fb, NULL, fb_bytes,
LV_DISPLAY_RENDER_MODE_DIRECT);
return disp;
}
/* RGB565 little-endian -> RGBA8888, then out to a PNG via LVGL's bundled
* lodepng. Channels are scaled (not just shifted) so a saturated 0x1F maps to
* 255 rather than 248 -- otherwise every screenshot reads slightly dark. */
static int headless_write_png(const char *path)
{
const size_t px_count = (size_t)PANEL_H_RES * PANEL_V_RES;
uint8_t *rgba = malloc(px_count * 4);
if (rgba == NULL) {
fprintf(stderr, "%s: cannot allocate RGBA conversion buffer\n", TAG);
return -1;
}
const uint16_t *src = (const uint16_t *)headless_fb;
for (size_t i = 0; i < px_count; i++) {
const uint16_t p = src[i];
const uint8_t r5 = (uint8_t)((p >> 11) & 0x1F);
const uint8_t g6 = (uint8_t)((p >> 5) & 0x3F);
const uint8_t b5 = (uint8_t)(p & 0x1F);
rgba[i * 4 + 0] = (uint8_t)((r5 * 255 + 15) / 31);
rgba[i * 4 + 1] = (uint8_t)((g6 * 255 + 31) / 63);
rgba[i * 4 + 2] = (uint8_t)((b5 * 255 + 15) / 31);
rgba[i * 4 + 3] = 255;
}
/*
* Encode to memory and write with stdio, rather than lodepng_encode32_file().
* LVGL patches its bundled lodepng to do file I/O through lv_fs, which
* would mean registering an LVGL filesystem driver and passing
* drive-letter paths like "A:/tmp/shot.png". Not worth it to save an
* fwrite.
*/
uint8_t *png = NULL;
size_t png_len = 0;
const unsigned err = lodepng_encode32(&png, &png_len, rgba, PANEL_H_RES, PANEL_V_RES);
free(rgba);
if (err != 0) {
fprintf(stderr, "%s: PNG encode failed: %s\n", TAG, lodepng_error_text(err));
return -1;
}
FILE *f = fopen(path, "wb");
if (f == NULL) {
perror(path);
free(png); /* LV_USE_STDLIB_MALLOC is CLIB here, so lv_free == free */
return -1;
}
const size_t written = fwrite(png, 1, png_len, f);
const bool short_write = (written != png_len);
const bool close_failed = (fclose(f) != 0);
free(png);
if (short_write || close_failed) {
fprintf(stderr, "%s: writing %s failed\n", TAG, path);
return -1;
}
printf("wrote %s (%dx%d, %zu bytes)\n", path, PANEL_H_RES, PANEL_V_RES, png_len);
return 0;
}
/* ------------------------------------------------------------------- data */
/* The state the UI is currently showing. Snapshots load into this, and the
* light callback mutates it so the simulator responds to clicks with no Home
* Assistant present. */
static ha_data_t sim_data;
static char *read_file(const char *path)
{
FILE *f = fopen(path, "rb");
if (f == NULL) {
perror(path);
return NULL;
}
fseek(f, 0, SEEK_END);
const long len = ftell(f);
rewind(f);
if (len < 0) {
fclose(f);
return NULL;
}
char *buf = malloc((size_t)len + 1);
if (buf == NULL) {
fclose(f);
return NULL;
}
const size_t got = fread(buf, 1, (size_t)len, f);
fclose(f);
buf[got] = '\0';
return buf;
}
static bool load_snapshot(const char *path, bool forecast)
{
char *json = read_file(path);
if (json == NULL) {
return false;
}
const bool ok = forecast ? ha_parse_forecast_json(json, &sim_data)
: ha_parse_json(json, &sim_data);
free(json);
if (!ok) {
fprintf(stderr, "%s: %s is not valid %s JSON\n", TAG, path,
forecast ? "forecast" : "state");
}
return ok;
}
/*
* Enough of a populated screen to lay out against when no snapshot is given.
* Deliberately includes the awkward cases: the wind sensor is offline (as it
* genuinely is), one light is unavailable, and one is dimmed.
*/
static void apply_mock_data(void)
{
memset(&sim_data, 0, sizeof(sim_data));
sim_data.outdoor_temp_f = (ha_value_t){ 73.0f, true };
sim_data.outdoor_humidity = (ha_value_t){ 80.0f, true };
sim_data.indoor_temp_f = (ha_value_t){ 78.1f, true };
sim_data.indoor_humidity = (ha_value_t){ 70.0f, true };
sim_data.dew_point_f = (ha_value_t){ 66.0f, true };
sim_data.pressure_inhg = (ha_value_t){ 29.89f, true };
sim_data.rain_today_in = (ha_value_t){ 0.0f, true };
sim_data.rain_rate_inhr = (ha_value_t){ 0.0f, true };
/* wind/gust/wdir left invalid on purpose -- that is the real state */
snprintf(sim_data.condition, sizeof(sim_data.condition), "cloudy");
snprintf(sim_data.sunrise, sizeof(sim_data.sunrise), "6:05 AM");
snprintf(sim_data.sunset, sizeof(sim_data.sunset), "7:45 PM");
snprintf(sim_data.clock, sizeof(sim_data.clock), "10:25 AM");
snprintf(sim_data.date, sizeof(sim_data.date), "Tue Aug 4");
sim_data.sun_up = true;
for (int i = 0; i < HA_LIGHT_COUNT; i++) {
sim_data.lights[i].state = HA_LIGHT_OFF;
sim_data.lights[i].brightness = -1;
}
sim_data.lights[0].state = HA_LIGHT_ON;
sim_data.lights[0].brightness = 178; /* 70% */
sim_data.lights[4].state = HA_LIGHT_UNAVAILABLE;
static const char *const days[HA_FORECAST_DAYS] = { "Tue", "Wed", "Thu", "Fri", "Sat" };
static const char *const cond[HA_FORECAST_DAYS] = {
"partlycloudy", "sunny", "sunny", "clear-night", "rainy"
};
static const float hi[HA_FORECAST_DAYS] = { 80, 80, 82, 82, 74 };
static const float lo[HA_FORECAST_DAYS] = { 70, 67, 68, 69, 63 };
for (int i = 0; i < HA_FORECAST_DAYS; i++) {
snprintf(sim_data.forecast[i].day, sizeof(sim_data.forecast[i].day), "%s", days[i]);
snprintf(sim_data.forecast[i].condition, sizeof(sim_data.forecast[i].condition),
"%s", cond[i]);
sim_data.forecast[i].high_f = (ha_value_t){ hi[i], true };
sim_data.forecast[i].low_f = (ha_value_t){ lo[i], true };
}
sim_data.forecast_count = HA_FORECAST_DAYS;
}
/*
* Stands in for the firmware's HTTP command path. Applying the change locally
* means tiles respond to clicks in the simulator; on the device the real state
* comes back from Home Assistant on the next poll instead.
*/
static void sim_light_cmd(int index, bool on, int16_t brightness)
{
if (index < 0 || index >= HA_LIGHT_COUNT) {
return;
}
fprintf(stderr, "light cmd: %-52s %s brightness=%d\n",
HA_LIGHTS[index].entity_id, on ? "ON " : "OFF", brightness);
sim_data.lights[index].state = on ? HA_LIGHT_ON : HA_LIGHT_OFF;
if (brightness >= 0) {
sim_data.lights[index].brightness = brightness;
} else if (on && sim_data.lights[index].brightness < 0) {
/* Turning on without a brightness: HA restores the previous level.
* Full is a reasonable stand-in for that here. */
sim_data.lights[index].brightness = 255;
}
ui_update(&sim_data);
}
/* ------------------------------------------------------------------- main */
static void print_usage(const char *argv0)
{
printf("usage: %s [--png FILE] [--settle-ms N] [--data FILE] [--forecast FILE]\n"
"\n"
" (no arguments) interactive SDL window, mouse acts as touch\n"
" --png FILE render one frame headless and write it to FILE\n"
" --settle-ms N ms of LVGL time before the PNG capture (default 400)\n"
" --data FILE state JSON as returned by HA's /api/template\n"
" --forecast FILE response JSON from weather/get_forecasts\n"
" --print-template emit the Jinja template the firmware uses, then exit\n"
"\n"
"Without --data a built-in mock is used. Capture real snapshots with\n"
"tools/ha_snapshot.py to iterate against states that actually occur.\n",
argv0);
}
int main(int argc, char **argv)
{
const char *png_path = NULL;
const char *data_path = NULL;
const char *forecast_path = NULL;
int settle_ms = 400;
for (int i = 1; i < argc; i++) {
if (strcmp(argv[i], "--png") == 0 && i + 1 < argc) {
png_path = argv[++i];
} else if (strcmp(argv[i], "--settle-ms") == 0 && i + 1 < argc) {
settle_ms = atoi(argv[++i]);
} else if (strcmp(argv[i], "--data") == 0 && i + 1 < argc) {
data_path = argv[++i];
} else if (strcmp(argv[i], "--forecast") == 0 && i + 1 < argc) {
forecast_path = argv[++i];
} else if (strcmp(argv[i], "--print-template") == 0) {
/*
* Lets tools/ha_snapshot.py capture snapshots using the exact
* template the firmware compiles, instead of a hand-copied
* duplicate that silently drifts the first time an entity ID
* changes. The preprocessor is the single source of truth.
*/
printf("%s\n", HA_STATE_TEMPLATE);
return 0;
} else if (strcmp(argv[i], "--help") == 0 || strcmp(argv[i], "-h") == 0) {
print_usage(argv[0]);
return 0;
} else {
fprintf(stderr, "unrecognised argument: %s\n", argv[i]);
print_usage(argv[0]);
return 2;
}
}
ESP_ERROR_CHECK(lvgl_port_init());
lv_display_t *disp = NULL;
if (png_path != NULL) {
disp = headless_display_create();
} else {
disp = lv_sdl_window_create(PANEL_H_RES, PANEL_V_RES);
if (disp != NULL) {
lv_sdl_window_set_title(disp, "thermostat-hw · CrowPanel 7\" 800x480");
lv_sdl_mouse_create(); /* mouse stands in for the GT911 */
}
}
if (disp == NULL) {
fprintf(stderr, "%s: display creation failed\n", TAG);
return 1;
}
ui_init();
ESP_ERROR_CHECK(lvgl_port_start());
ui_set_light_cb(sim_light_cmd);
/*
* A snapshot replaces the mock wholesale rather than layering onto it --
* mixing the two would show a screen that never occurs in reality.
*/
if (data_path != NULL) {
memset(&sim_data, 0, sizeof(sim_data));
if (!load_snapshot(data_path, false)) {
return 1;
}
} else {
apply_mock_data();
}
if (forecast_path != NULL && !load_snapshot(forecast_path, true)) {
return 1;
}
ui_update(&sim_data);
ui_set_status(data_path != NULL ? data_path : "simulator - mock data");
if (png_path != NULL) {
/*
* Let timers and any entry animation settle before capturing,
* otherwise the screenshot catches a half-built frame.
*/
const uint32_t deadline_ms = lv_tick_get() + (uint32_t)settle_ms;
while (lv_tick_get() < deadline_ms) {
lv_timer_handler();
usleep(5000);
}
lv_refr_now(disp); /* force a synchronous full render into headless_fb */
return headless_write_png(png_path) == 0 ? 0 : 1;
}
/* Interactive: LV_SDL_DIRECT_EXIT makes closing the window exit the
* process, so this loop does not need its own termination condition. */
while (true) {
lv_timer_handler();
usleep(5000);
}
return 0;
}