Files
mattandClaude ea1b8b8d15 Two indoor sensors, wind compass, and stop framebuffer cache thrashing
Downstairs and upstairs are now separate readings. The station names them
backwards -- "temperature_4" is downstairs and "inside_temperature" is
upstairs -- so ha_config.h says which is which. Downstairs is the primary:
big number on the card, and it is what the furnace controls on, because it
is where people are and heat rises. Upstairs sits beside it, obviously
subordinate by size, and costs no vertical space in a layout that has none.

The anemometer came back on 2026-08-07, so wind gets its own column: a dial
whose needle points at the direction the wind is coming FROM (weather-vane
convention, matching the cardinal below it), the speed, and a 5-minute trend
arrow.

  - The needle does not animate. A spin would look better, but every animated
    frame is a redraw, and redrawing is what starves the bounce refill ISR.
  - Below 0.5 mph it shows "Calm" and draws no needle. A vane that is not
    being pushed reports whatever it last settled on, which on this station
    is a steady 0 degrees -- a confident needle pointing due north at zero
    wind. The live snapshot caught exactly that.
  - The trend is local, not HA's 10-minute average, so the window is ours to
    pick: 1-minute buckets over 5 minutes, threshold 1.5 mph.

That last point needed the trend engine to stop assuming one hour. Window,
bucket size, minimum age and threshold are now per-channel in a table, which
also removes the threshold arguments main.c was passing in.

Grid drops to 117px cells to make room, so the sun sub-line is just which
event it is and "RAIN TODAY" is "RAIN" -- both were overrunning.

On the artefacts: bounce mode is NOT a DMA read of the framebuffer. The
refill is a CPU memcpy in an ISR, which is why the driver skips the usual
write-back in draw_bitmap ("the data gets read by the CPU as well"). So the
panel streams 750KB per frame at ~44Hz through the DCache, evicting the
working set of everything else on the chip, continuously -- which is the real
reason this board is so sensitive to WiFi and to any extra rendering.
bb_invalidate_cache frees each chunk after it is copied. The header warns it
"can be dangerous if data is written from other core(s)"; that does not apply
here, because it invalidates the buffer being scanned and by design LVGL only
draws into the other one. Whether it is enough is for the panel to say.

Also gives tools/ha_snapshot.py a --token-file so the token no longer has to
travel through a shell command line to reach it.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-07 19:58:41 -07:00

489 lines
17 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 "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;
}