399 lines
13 KiB
C
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;
|
|
}
|