/* * 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 #include #include #include #include #include #include #include "lvgl.h" #include "lodepng.h" #include "ha_data.h" #include "ha_template.h" #include "lvgl_port.h" #include "relay.h" #include "thermostat.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_down_temp_f = (ha_value_t){ 71.4f, true }; sim_data.indoor_down_humidity = (ha_value_t){ 46.0f, true }; sim_data.indoor_up_temp_f = (ha_value_t){ 74.8f, true }; sim_data.indoor_up_humidity = (ha_value_t){ 43.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 }; sim_data.wind_mph = (ha_value_t){ 6.4f, true }; sim_data.gust_mph = (ha_value_t){ 11.2f, true }; sim_data.wind_dir_deg = (ha_value_t){ 292.0f, true }; /* WNW */ 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; /* * Synthetic trend history: an "hour-old" anchor per channel so the * arrows render in the mock. Unsigned tick arithmetic makes now-55min * safe even seconds after boot. Snapshot mode gets no history and shows * no arrows -- which is exactly what a freshly booted device does. */ const uint32_t now = lv_tick_get(); const uint32_t hour_ago = now - 55u * 60u * 1000u; const uint32_t min_ago = now - 4u * 60u * 1000u; ha_trend_feed(HA_TREND_CH_INDOOR_DOWN, 70.6f, hour_ago); /* -> rising */ ha_trend_feed(HA_TREND_CH_INDOOR_DOWN, 71.4f, now); ha_trend_feed(HA_TREND_CH_INDOOR_UP, 75.5f, hour_ago); /* -> falling */ ha_trend_feed(HA_TREND_CH_INDOOR_UP, 74.8f, now); ha_trend_feed(HA_TREND_CH_OUTDOOR, 74.6f, hour_ago); /* -> falling */ ha_trend_feed(HA_TREND_CH_OUTDOOR, 73.0f, now); ha_trend_feed(HA_TREND_CH_PRESSURE, 29.95f, hour_ago); /* -> falling */ ha_trend_feed(HA_TREND_CH_PRESSURE, 29.89f, now); ha_trend_feed(HA_TREND_CH_WIND, 3.9f, min_ago); /* -> rising */ ha_trend_feed(HA_TREND_CH_WIND, 6.4f, now); sim_data.trend_indoor_down = ha_trend_compute(HA_TREND_CH_INDOOR_DOWN, now); sim_data.trend_indoor_up = ha_trend_compute(HA_TREND_CH_INDOOR_UP, now); sim_data.trend_outdoor = ha_trend_compute(HA_TREND_CH_OUTDOOR, now); sim_data.trend_pressure = ha_trend_compute(HA_TREND_CH_PRESSURE, now); sim_data.trend_wind = ha_trend_compute(HA_TREND_CH_WIND, now); 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. */ /* * Runs the real control loop against the fake furnace in relay_sim.c. * * Called from the LVGL loop rather than a thread, which keeps the simulator * single-threaded -- the point is to exercise the decision logic, not the * firmware's task structure. */ static void sim_thermostat_tick(void) { therm_status_t st; const bool have_temp = sim_data.indoor_down_temp_f.valid; thermostat_tick(have_temp ? sim_data.indoor_down_temp_f.value : 0.0f, have_temp, lv_tick_get()); thermostat_get_status(&st); relay_set(RELAY_HEAT, st.heat_relay); relay_set(RELAY_FAN, st.fan_relay); ui_update_thermostat(&st); } /* Mirrors therm_cmd_from_ui() in main.c. */ static void sim_therm_cmd(ui_therm_cmd_t cmd) { switch (cmd) { case UI_THERM_SETPOINT_UP: thermostat_adjust_setpoint(1.0f); break; case UI_THERM_SETPOINT_DOWN: thermostat_adjust_setpoint(-1.0f); break; case UI_THERM_MODE_TOGGLE: thermostat_set_mode(thermostat_get_mode() == THERM_MODE_HEAT ? THERM_MODE_OFF : THERM_MODE_HEAT); break; case UI_THERM_FAN_TOGGLE: thermostat_set_fan(thermostat_get_fan() == THERM_FAN_ON ? THERM_FAN_AUTO : THERM_FAN_ON); break; } sim_thermostat_tick(); } 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); ESP_ERROR_CHECK(relay_init()); thermostat_init(lv_tick_get()); ui_set_therm_cb(sim_therm_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); sim_thermostat_tick(); 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. */ uint32_t last_tick = 0; while (true) { lv_timer_handler(); /* Same 1Hz cadence as the firmware's control task, so the lockout * countdown and cycle timing behave identically here. */ const uint32_t now = lv_tick_get(); if (now - last_tick >= 1000) { last_tick = now; sim_thermostat_tick(); } usleep(5000); } return 0; }