Compare commits

..
Author SHA1 Message Date
mattandClaude 3aabcac90a Add thermostat control for heat and fan with cycle protection
Turns the weather dashboard into an actual thermostat. Relays are not
fitted yet -- the GPIOs are driven and can be metered at the UART1-OUT
plug (idle 3.3V, energized 0V, active-low module assumed).

Control loop (main/control/thermostat.c) is pure logic: no ESP-IDF, no
network, no clock of its own. The caller passes a temperature and a
millisecond timestamp, which is what lets the simulator and the test
harness run the identical decision code.

Equipment protections, all verified by tools/thermostat_test.c:

  deadband      1.0F    stops the burner chattering at the setpoint
  minimum on    3 min   a shorter call is all ignition wear, no heat
  minimum off   5 min   back-to-back ignitions are what kill igniters
  cycles/hour   5       standard gas default; catches a flapping sensor
  boot lockout  60 s    a boot loop must not machine-gun the gas valve

That test drives the state machine with synthetic time, so two hours of
furnace behaviour runs in milliseconds. It exits non-zero on failure and
is worth re-running after any edit to thermostat.c -- this switches a gas
valve.

Two deliberate decisions:

  - G is never energized during a heat call. The furnace runs its own
    blower with a fan-on delay that waits for the heat exchanger and a
    fan-off delay that scavenges residual heat; driving G ourselves would
    blow cold air at people and throw that heat away. G is manual
    circulation only.
  - The control loop starts before every early return in app_main. Those
    returns are all "no WiFi" or "no HA token", and none of them is a
    reason to leave a house unheated.

Relay pins are IO20 heat / IO19 fan on the UART1-OUT plug, measured on
this board since Elecrow documents the GPIO numbers but not the connector
pin order. Heat is on IO20 because it is USB D+, the line carrying the
hardware pull-up -- with an active-low module it fails toward "no heat"
rather than "heat stuck on" if the USB PHY is ever re-enabled. Both pins
were confirmed free of the onboard mic before use.
CONFIG_USJ_ENABLE_USB_SERIAL_JTAG and CONFIG_ESP_PHY_ENABLE_USB are now
off to release them; the console is on UART0 via the CH340 regardless.

UI: the indoor card gains a status dot, mode and fan buttons, and a
setpoint column with finger-sized up/down targets. The reading grid drops
from six cells to four to make room -- wind was the one to cut, since the
station anemometer has been dead since 2026-07-27 and that cell has never
shown a number. Wind now rides on the outdoor cell's sub-line and will
reappear on its own if the sensor is fixed. Sunrise and sunset share a
cell, showing whichever comes next.

Known weakness, documented in README and top of TODO: indoor temperature
comes from Home Assistant over the network, so an HA outage ages the
reading until the 15-minute failsafe forces heat off. A local I2C sensor
removes the failure mode entirely and is the next thing to add. The
setpoint also resets to 68F on reboot, because persisting it means an NVS
write and a flash write while the panel scans faults the bounce-buffer
ISR.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-05 20:09:36 -07:00
matt ae5d12b3a2 next iteration on display, still some artifacting issues, and ux with buttons states and updating 2026-08-04 19:38:58 -07:00
matt 45998b6176 Updated icons, buttons, and display; issues still with Main Room button, and sliders. 2026-08-04 18:40:46 -07:00
matt b5d6b4a897 New panel design 2026-08-04 11:55:02 -07:00
43 changed files with 7286 additions and 192 deletions

No files matched your search

+3
View File
@@ -3,6 +3,9 @@ build/
managed_components/
dependencies.lock
# Desktop simulator build output (sim/ sources themselves ARE tracked)
sim/build/
# Generated config -- sdkconfig.defaults IS tracked, sdkconfig is not
sdkconfig
sdkconfig.old
+210 -14
View File
@@ -11,9 +11,10 @@ ESP-IDF firmware for the **Elecrow CrowPanel 7" Advance** (ESP32-S3-WROOM-1-N16R
> **Advance**. Building it against Basic documentation yields a black screen
> and a silent I2C bus.
Currently displays outdoor conditions from an Ambient Weather personal weather
station. Thermostat control (setpoint, HVAC relays, scheduling) is not
implemented yet.
A wall dashboard driven by **Home Assistant** over the LAN: outdoor and indoor
conditions from an Ambient Weather station, sunrise/sunset, rain, a five-day
forecast, and on/off plus brightness control for six lights. Thermostat control
(setpoint, HVAC relays, scheduling) is not implemented yet.
## Components
@@ -23,17 +24,34 @@ implemented yet.
| `main/drivers/touch` | GT911 capacitive touch over I2C, registered as an LVGL input device |
| `main/ui/lvgl_port.c` | LVGL tick source, service task, and locking |
| `main/ui/ui_manager.c` | Screen layout and update entry points |
| `main/ui/icon_font.c` | Generated ~6KB MDI icon subset — see `tools/make_icon_font.py` |
| `main/wifi` | WiFi station bring-up |
| `main/api` | Ambient Weather REST client |
| `main/api/ha_client.c` | Home Assistant REST transport (device only) |
| `main/api/ha_data.c` | Response parsing and presentation helpers (**shared with the simulator**) |
| `main/api/ha_config.h` | Entity IDs for this installation |
| `main/api/ambient_weather.c` | Ambient Weather cloud client — **fallback only** |
| `sim/` | Desktop simulator for the UI — see `sim/README.md` |
| `tools/` | `ha_discover.py`, `ha_snapshot.py` |
## Setup
```bash
cp main/app_config.h.example main/app_config.h
# edit main/app_config.h: WiFi credentials + Ambient Weather keys
# edit main/app_config.h: WiFi credentials + HA_TOKEN
```
`main/app_config.h` is gitignored — it holds your WiFi password and API keys.
`main/app_config.h` is gitignored — it holds your WiFi password and the Home
Assistant token. Entity IDs are *not* secrets and live in
`main/api/ha_config.h`, which is tracked.
Confirm the entity IDs still resolve after any change in HA:
```bash
export HA_TOKEN='...' # HA: your user -> Security -> Long-lived tokens
python3 tools/ha_discover.py
```
A wrong entity ID fails **silently** as "unavailable" rather than as an error.
## Build & Flash
@@ -45,6 +63,101 @@ idf.py build flash monitor
Requires ESP-IDF **v5.3 or newer** (the code uses the `i2c_master` driver API
and `dma_burst_size`).
## Thermostat control
Single-stage gas furnace, no A/C. The control loop is in
`main/control/thermostat.c` and is **pure logic** — no ESP-IDF, no network, no
clock of its own. The caller supplies a temperature and a millisecond
timestamp; that is what lets the desktop simulator and the test harness run
the identical decision code.
| Protection | Value | Why |
|---|---|---|
| Deadband | 1.0 °F | stops the burner chattering at the setpoint |
| Minimum on | 3 min | a shorter call delivers almost no heat for a full ignition's wear |
| Minimum off | 5 min | back-to-back ignitions are what kill igniters |
| Cycles/hour | 5 | standard gas default; catches a flapping sensor |
| Boot lockout | 60 s | a boot loop must not machine-gun the gas valve |
| Sensor staleness | 15 min | see below |
Verify after any change to that file — it switches a gas valve:
```bash
gcc -Wall -Wextra -I main/control -o /tmp/therm_test \
tools/thermostat_test.c main/control/thermostat.c -lm && /tmp/therm_test
```
Two decisions worth knowing:
- **G is never energized during a heat call.** The furnace runs its own
blower with a fan-on delay that waits for the heat exchanger and a fan-off
delay that scavenges residual heat. Driving G ourselves would blow cold air
at people and throw that heat away. G is manual circulation only.
- **The control loop starts before any of app_main's early returns.** Every
one of those is about WiFi or HA being unusable, and none of them is a
reason to leave a house unheated.
### The sensor is the weak link
Indoor temperature arrives over the network from Home Assistant. If HA stops
answering, the reading ages; after 15 minutes the state becomes `NO_SENSOR`,
heat is forced off and the card turns red. Running blind on an old number
could overheat the house or let it freeze, and neither is acceptable.
This is a deliberate compromise, not a design. **The real fix is a local
sensor on the I2C bus**, which removes the failure mode entirely — an SHT30
or BME280 at a free address. Note the panel dissipates ~5 W, so it must be
remote from the enclosure or it will read several degrees high.
**Setpoint does not survive a reboot** (defaults to 68 °F). Persisting it
would mean an NVS write, and a flash write while the panel is scanning faults
the bounce-buffer ISR — see Memory notes. Storing it in an HA `input_number`
would work and needs no flash.
## Home Assistant
Everything on screen except the forecast comes from **one** POST to
`/api/template`: about 430 bytes, measured at ~85 ms on the LAN. The Jinja
template lives in `main/api/ha_template.h` and is built by the preprocessor
from the same `HA_LIGHT_LIST` X-macro as the C table, so the request and the
code that reads the reply cannot drift apart.
Three things fall out of doing it this way, and all three are load-bearing:
- **No SNTP, and no timezone database.** HA renders `sunrise`, `sunset`,
`clock` and `date` as local-time strings. The board's own clock still starts
at the epoch and it no longer matters — but note that means *nothing on this
device knows the time*, so anything needing a real clock (scheduling, say)
has to solve that separately. There is an unused RTC at I2C 0x51.
- **No TLS.** Plain HTTP to a box on the same switch. The cloud client pays a
multi-second handshake; this does not, which matters because the caller
pauses rendering for the duration. Pointing `HA_HOST` at an https:// instance
throws that away.
- **Unavailable sensors arrive as JSON `null`,** never a sentinel number. `-1`
mph of wind is impossible, but `-1` °F of dew point is not.
The forecast is a second, slower request (every 30th poll). It cannot be part
of the template: HA removed the `forecast` attribute from weather entities in
2024.4, and templates cannot call services.
### The wind sensors are dead
The station's anemometer has been offline since **2026-07-27**: `wind_speed`,
`wind_gust`, `wind_direction` and `feels_like` all read `unavailable`, and the
WIND tile shows `--` by design. It is deliberately *not* backfilled from the
met.no forecast — regional modelled wind under a heading that means "my yard"
is worse than showing nothing.
`sensor.moonlight_beach_max_gust` is a trap: it is **not** unavailable. It
still reports its last value from 2026-07-28 and will render as a plausible
live number. Anything that uses it needs a staleness check first.
### Fallback
When HA cannot be reached, `ambient_weather.c` fills in outdoor temperature,
humidity and wind from the cloud API and the status line says so. Indoor, sun,
forecast and lights have no cloud equivalent and hold their last values.
## Hardware (Advance)
### RGB panel (16-bit RGB565)
@@ -79,6 +192,36 @@ then `0x10` and `0x18` to configure and release the GT911. Brightness is
`0x00`–`0xF5` **inverted** (`0x00` = full) on v1.3; v1.2 uses a narrower
`0x05`–`0x10` range instead.
### UART1-OUT plug — the only free GPIO on this board
Nearly every pin is spoken for: 20 on the RGB bus, 2 on I2C, IO4/5/6 on
I2S+SD, IO8 buzzer, IO43/44 the console. The UART1-OUT plug (PH2.0-4P) is
what's left, and it is where the furnace relays connect.
| Wire | Pin | |
|------|-----|---|
| black | GND | |
| red | 3.3 V | 1 A budget; two relay coils ≈ 260 mA |
| white | **IO20** | measured, not documented by Elecrow |
| yellow | **IO19** | measured, not documented by Elecrow |
Elecrow publishes the GPIO numbers but **not the pin order**, so the
white/yellow mapping above was measured on this board (2026-08-05) by driving
each pin at a distinct blink rate and metering the plug. Static levels cannot
work for this: a pin held high is indistinguishable from the 3.3 V rail, and
one held low from GND.
Both pins were also confirmed electrically free — each follows an internal
pulldown to 0 and pullup to 1, so nothing else is driving them.
> **IO19/IO20 are shared** with the onboard I2S mic (MIC_CLK/MIC_SD) and are
> the ESP32-S3's native USB D−/D+. DIP switches select the mic away:
> S1=0 S0=1 is *wireless module* mode, which leaves these pins to us. Native
> USB is unused — the board flashes over UART0 through a CH340
> (`/dev/ttyUSB0`), so `CONFIG_USJ_ENABLE_USB_SERIAL_JTAG` and
> `CONFIG_ESP_PHY_ENABLE_USB` can both be turned off to release the pins
> fully.
## Display architecture
Two 800×480×2 framebuffers in PSRAM, LVGL in `RENDER_MODE_DIRECT`. LVGL draws
@@ -109,19 +252,68 @@ selecting it raises the shared MSPI core clock, and IDF only ships flash timing
tables pairing 120MHz PSRAM with 120MHz flash. Flash above 80MHz requires
High Performance Mode and a DC-aware bootloader.
## UI development
`main/ui/ui_manager.c` and `main/api/ha_data.c` are compiled **unmodified**
into a desktop simulator, so layout work does not need a flash cycle:
```bash
cmake -S sim -B sim/build -G Ninja && cmake --build sim/build
./sim/build/thermostat_sim # SDL window, mouse = touch
./sim/build/thermostat_sim --png /tmp/shot.png # headless capture
```
Capture real states to iterate against, instead of waiting for reality:
```bash
export HA_TOKEN=$(cat ~/.config/thermostat-hw/ha_token)
python3 tools/ha_snapshot.py --name rainy
./sim/build/thermostat_sim --data sim/snapshots/rainy.json \
--forecast sim/snapshots/rainy.forecast.json
```
See `sim/README.md` for what it does and does not reproduce. It says nothing
about PSRAM bandwidth or panel timing.
> **The font lists in `sim/lv_conf.h` and `sdkconfig.defaults` must match.**
> A size enabled in one build and not the other makes the simulator lie about
> layout. Worse, `sdkconfig.defaults` only seeds a *new* `sdkconfig` — editing
> it does nothing to an existing one, so change `sdkconfig` too (or delete it
> and let it regenerate).
## Gotchas
- **LVGL does not bubble events to parents.** An event handler on the screen
object never sees taps that land on a child widget, which looks exactly like
dead touch zones. The touch readout polls the input device from an
`lv_timer` instead.
dead touch zones. Light tiles therefore carry their own handlers, and the
labels inside them are left non-clickable so taps reach the tile.
- **Montserrat has no `·` (U+00B7) or `—` (U+2014).** Both render as tofu
boxes. `weather_get_string()` emits a middot, but only into a log line.
Anything reaching a label must stay in Latin-1; the UI uses `--`.
- **`LV_LABEL_LONG_DOT` needs an explicit height** to truncate. With a
content-sized height the label wraps to a second line first and only then
adds dots — landing on top of whatever is below it.
- **Icons are label text**, not images: single glyphs from a subset MDI TTF
rasterised by tiny_ttf (`tools/make_icon_font.py` regenerates
`main/ui/icon_font.c` / `ui_icons.h`; needs network + fonttools, so the
generated files are committed). Codepoints are resolved from the MDI CSS at
generation time — never hardcode one, MDI reassigns them between majors.
- **Light tiles are optimistic.** A tap flips the tile immediately and holds
against poll data for up to 5s until HA confirms (`light_hold_t`); commands
are sent on finger-lift only, and poll updates never touch a pressed
slider. Breaking any of those three rules brings back the "button waits a
second, then the knob jumps out from under your finger" feel.
- **`lv_label_set_text()` invalidates unconditionally**, even when the text is
identical — so setting an unchanged value still forces a repaint. All label
updates go through `label_set_if_changed()`.
- **WiFi must be brought up before `display_init()`.** The bounce refill ISR
reads PSRAM, which shares the cache with flash; an NVS write while the panel
is running faults it with *"Cache disabled but cached memory region
accessed"*.
- **Display bring-up is two-phase, and both orderings are load-bearing.**
`display_alloc()` runs *before* WiFi: the bounce buffers need two contiguous
25.6KB internal DMA blocks, and WiFi's RX buffer churn fragments the heap
within seconds — observed as a boot-loop with 84KB free and no block big
enough. `display_start()` runs *after* WiFi: once scan-out begins, the
bounce refill ISR reads PSRAM, which shares the cache with flash, and an
NVS/PHY write would fault it with *"Cache disabled but cached memory region
accessed"*. Allocate early, scan late.
## Partitions
@@ -149,5 +341,9 @@ enabled (the panel cannot stream straight from PSRAM), which is exactly why
flash writes are dangerous while the panel runs: the refill ISR reads PSRAM,
which shares the cache with flash on the S3, so an NVS write faults it with
*"Cache disabled but cached memory region accessed"*. All flash writes happen
before `display_init()` and `CONFIG_ESP_WIFI_NVS_ENABLED` is off to keep the
WiFi stack from writing later.
before `display_start()` and `CONFIG_ESP_WIFI_NVS_ENABLED` is off to keep the
WiFi stack from writing later. Static buffers beware: `.bss` lives in the
same internal RAM the bounce buffers come from, with only ~33KB of slack
after the panel is up — a few KB of new static array is enough to matter
(the trend ring is deliberately time-bucketed down to ~720 bytes for this
reason).
+47 -3
View File
@@ -1,5 +1,40 @@
# TODO
## Thermostat: next steps
1. **Fit the relay board.** Wiring is in README "UART1-OUT plug". Until then
the GPIOs are the only output — meter them (idle reads 3.3 V, energized
0 V) to confirm the control loop end to end.
2. **Local temperature sensor.** The single biggest weakness: indoor temp
comes from HA over the network, so an HA outage eventually forces heat off.
An SHT30/BME280 on the I2C bus fixes it. Mount it away from the panel,
which self-heats by several degrees.
3. **Persist the setpoint.** Resets to 68 °F on every boot. NVS is not an
option while the panel scans (cache fault); an HA `input_number` is.
4. **Watchdog.** If the firmware hangs mid-call the relay stays latched until
someone notices. The furnace's own high limit makes that uncomfortable
rather than dangerous, but the task watchdog should reboot into the safe
state.
## Not yet verified on hardware
Flashed and booting clean (two-phase display init, icons on, first poll ~9s,
no errors), but eyes and fingers haven't confirmed:
1. Slider feel: press sends one command at the touch position immediately,
drag is display-only tracking the finger, lift sends the final level.
Poll data never moves a pressed knob.
2. Trend arrows appear ~5 minutes after boot (indoor big card, outdoor and
pressure cells) once the local ring has history. They are local-history
based and reset on reboot — that is by design, not a bug.
3. Artefact lifetime: interactions schedule a one-shot full repaint 600ms
after the last touch, and every poll repaints too. Artefacts should now
visibly self-clean in under a second after touching, and within a poll
otherwise. If they *persist* beyond that, the repaint isn't reaching
them; if they merely *recur* during interaction, that is the PSRAM
bandwidth ceiling (README) and the honest remaining levers are fewer
redraws or 120MHz PSRAM on a newer IDF.
## Done
The six items from the 2026-08-03 review were completed in `d4b2ab7`
@@ -51,7 +86,14 @@ touch-to-wake is a natural small feature. Note the STC8H1K28 shares the I2C
bus with the GT911, so brightness writes must stay in task context (never an
ISR) and will contend with touch polls on the same bus.
### 4. The actual thermostat
### 4. The wind sensors are dead (hardware, not software)
The station anemometer has read `unavailable` since 2026-07-27 — wind speed,
gust, direction and feels-like. The WIND tile shows `--` by design and will
keep doing so until the hardware is fixed. Nothing to change in the firmware;
noted so it is not re-diagnosed as a parsing bug. See README "Home Assistant".
### 5. The actual thermostat
Setpoint, HVAC relay control, scheduling and persistence are all still
unimplemented — this remains a weather display. Note a separate project
@@ -71,8 +113,10 @@ hosts the thermostat before building it twice.
- The `°` glyph exists in LVGL's built-in Montserrat fonts; the `·` separator
that does not appears only in log strings, never on screen.
- LVGL recursive-mutex locking is consistent across all UI entry points.
- Init order in `main.c` is deliberate and load-bearing: WiFi/NVS before
`display_init()`, `backlight_init()` before `touch_init()`. Never reorder.
- Init order in `main.c` is deliberate and load-bearing:
`display_alloc()` before WiFi (contiguous bounce buffers from a virgin
heap), WiFi/NVS before `display_start()` (flash writes finished before
scan-out), `backlight_init()` before `touch_init()`. Never reorder.
- The bounce buffer is enabled and load-bearing.
- Board is N16R8 (16MB flash / 8MB octal PSRAM).
- `touch_get_indev()`, `display_get_panel()` and `lvgl_port_is_paused()` are
+7
View File
@@ -6,9 +6,14 @@ idf_component_register(
"drivers/backlight/backlight.c"
"drivers/display/display_rgb.c"
"drivers/touch/touch_gt911.c"
"drivers/relay/relay.c"
"control/thermostat.c"
"ui/lvgl_port.c"
"ui/ui_manager.c"
"ui/icon_font.c"
"api/ambient_weather.c"
"api/ha_client.c"
"api/ha_data.c"
INCLUDE_DIRS
"."
"wifi"
@@ -16,6 +21,8 @@ idf_component_register(
"drivers/backlight"
"drivers/display"
"drivers/touch"
"drivers/relay"
"control"
"ui"
"api"
REQUIRES
+263
View File
@@ -0,0 +1,263 @@
/*
* Home Assistant REST transport. See ha_client.h for the contract and why
* this is plain HTTP.
*/
#include <stdio.h>
#include <string.h>
#include "cJSON.h"
#include "esp_http_client.h"
#include "esp_log.h"
#include "app_config.h"
#include "ha_client.h"
#include "ha_template.h"
static const char *TAG = "ha_client";
#define HA_URL_MAX 128
#define HA_BODY_MAX 1024
/*
* The forecast reply is the big one: six days of conditions and temperatures,
* measured at ~1.1KB. The state template is ~400 bytes. 4KB leaves room for
* HA to grow a field without silently truncating.
*/
#define HA_RESP_MAX 4096
/* Local network, no TLS handshake, so these can be far tighter than the
* cloud client's. A LAN request that has not answered in 5s is not going to. */
#define HA_TIMEOUT_MS 5000
static char resp_buf[HA_RESP_MAX];
static size_t resp_len;
static bool resp_truncated;
/*
* Kept alive between polls. Re-creating the client each time re-opens the TCP
* connection, which is cheap on a LAN but not free, and this runs inside the
* window where rendering is paused.
*/
static esp_http_client_handle_t client;
/*
* Long-lived HA tokens are JWTs -- ~180-200 characters, not the 30-40 an
* "API key" suggests. 320 leaves room for "Bearer " plus a token noticeably
* longer than the ones HA issues today.
*/
static char auth_header[320];
static esp_err_t http_event_handler(esp_http_client_event_t *evt)
{
switch (evt->event_id) {
case HTTP_EVENT_ON_DATA:
/* Responses are chunked, so Content-Length is often -1; accumulate
* into a fixed buffer rather than sizing an allocation from it. */
if (resp_len + evt->data_len < sizeof(resp_buf)) {
memcpy(resp_buf + resp_len, evt->data, evt->data_len);
resp_len += evt->data_len;
} else if (!resp_truncated) {
resp_truncated = true; /* once per response, not per chunk */
ESP_LOGW(TAG, "Response exceeds %d bytes, truncating", HA_RESP_MAX);
}
break;
default:
break;
}
return ESP_OK;
}
static void client_drop(void)
{
if (client != NULL) {
esp_http_client_cleanup(client);
client = NULL;
}
}
esp_err_t ha_client_init(void)
{
if (HA_HOST[0] == '\0' || HA_TOKEN[0] == '\0') {
ESP_LOGW(TAG, "HA_HOST or HA_TOKEN not set in main/app_config.h -- "
"Home Assistant will not be polled");
return ESP_ERR_INVALID_STATE;
}
const int n = snprintf(auth_header, sizeof(auth_header), "Bearer %s", HA_TOKEN);
if (n < 0 || n >= (int)sizeof(auth_header)) {
ESP_LOGE(TAG, "Token too long for the auth header buffer");
return ESP_ERR_INVALID_SIZE;
}
return ESP_OK;
}
/*
* One POST. `path` and `body` are borrowed; the reply lands in resp_buf.
*
* The client is rebuilt whenever the URL changes, because the three endpoints
* this talks to (template, service call, forecast) are different paths on the
* same host and esp_http_client keeps the connection per-handle.
*/
static esp_err_t ha_post(const char *path, const char *body)
{
char url[HA_URL_MAX];
const int n = snprintf(url, sizeof(url), "http://%s:%d%s", HA_HOST, HA_PORT, path);
if (n < 0 || n >= (int)sizeof(url)) {
ESP_LOGE(TAG, "URL truncated for path %s", path);
return ESP_ERR_INVALID_SIZE;
}
if (client == NULL) {
const esp_http_client_config_t config = {
.url = url,
.method = HTTP_METHOD_POST,
.timeout_ms = HA_TIMEOUT_MS,
.event_handler = http_event_handler,
.keep_alive_enable = true,
};
client = esp_http_client_init(&config);
if (client == NULL) {
return ESP_ERR_NO_MEM;
}
esp_http_client_set_header(client, "Authorization", auth_header);
esp_http_client_set_header(client, "Content-Type", "application/json");
} else if (esp_http_client_set_url(client, url) != ESP_OK) {
client_drop();
return ESP_FAIL;
}
esp_http_client_set_method(client, HTTP_METHOD_POST);
esp_http_client_set_post_field(client, body, (int)strlen(body));
resp_len = 0;
resp_truncated = false;
esp_err_t err = esp_http_client_perform(client);
if (err != ESP_OK) {
ESP_LOGE(TAG, "POST %s failed: %s", path, esp_err_to_name(err));
client_drop(); /* a half-open connection would fail every later poll */
return err;
}
const int status = esp_http_client_get_status_code(client);
if (status != 200) {
ESP_LOGE(TAG, "HTTP %d from %s", status, path);
if (status == 401) {
ESP_LOGE(TAG, "Token rejected -- check HA_TOKEN in app_config.h");
}
client_drop();
return ESP_FAIL;
}
if (resp_len == 0) {
/* Service calls legitimately return an empty body. */
resp_buf[0] = '\0';
return ESP_OK;
}
resp_buf[resp_len] = '\0';
return ESP_OK;
}
esp_err_t ha_fetch_state(ha_data_t *data)
{
if (data == NULL) {
return ESP_ERR_INVALID_ARG;
}
/*
* The template is JSON-escaped by cJSON rather than by hand: it is full of
* quotes and braces, and getting the escaping subtly wrong yields a 400
* that looks like a template bug.
*/
cJSON *req = cJSON_CreateObject();
if (req == NULL) {
return ESP_ERR_NO_MEM;
}
if (cJSON_AddStringToObject(req, "template", HA_STATE_TEMPLATE) == NULL) {
cJSON_Delete(req);
return ESP_ERR_NO_MEM;
}
char *body = cJSON_PrintUnformatted(req);
cJSON_Delete(req);
if (body == NULL) {
return ESP_ERR_NO_MEM;
}
const esp_err_t err = ha_post("/api/template", body);
cJSON_free(body);
if (err != ESP_OK) {
return err;
}
if (!ha_parse_json(resp_buf, data)) {
ESP_LOGE(TAG, "State response was not valid JSON");
return ESP_FAIL;
}
return ESP_OK;
}
esp_err_t ha_fetch_forecast(ha_data_t *data)
{
if (data == NULL) {
return ESP_ERR_INVALID_ARG;
}
/* return_response is required: without it HA runs the service and returns
* nothing, which looks like an empty forecast rather than a mistake. */
const esp_err_t err = ha_post(
"/api/services/weather/get_forecasts?return_response", HA_FORECAST_BODY);
if (err != ESP_OK) {
return err;
}
if (!ha_parse_forecast_json(resp_buf, data)) {
ESP_LOGE(TAG, "Forecast response could not be parsed");
return ESP_FAIL;
}
return ESP_OK;
}
esp_err_t ha_light_command(int index, bool on, int16_t brightness)
{
if (index < 0 || index >= HA_LIGHT_COUNT) {
return ESP_ERR_INVALID_ARG;
}
const ha_light_cfg_t *cfg = &HA_LIGHTS[index];
const char *domain = ha_service_domain(cfg->entity_id);
char path[HA_URL_MAX];
int n = snprintf(path, sizeof(path), "/api/services/%s/turn_%s",
domain, on ? "on" : "off");
if (n < 0 || n >= (int)sizeof(path)) {
return ESP_ERR_INVALID_SIZE;
}
char body[HA_BODY_MAX];
if (on && brightness >= 0 && cfg->dimmable) {
n = snprintf(body, sizeof(body),
"{\"entity_id\":\"%s\",\"brightness\":%d}",
cfg->entity_id, brightness);
} else {
/* No brightness key: turning on without one makes HA restore the
* light's previous level, which is what a user expects from a tap. */
n = snprintf(body, sizeof(body), "{\"entity_id\":\"%s\"}", cfg->entity_id);
}
if (n < 0 || n >= (int)sizeof(body)) {
return ESP_ERR_INVALID_SIZE;
}
ESP_LOGI(TAG, "%s -> %s%s", cfg->entity_id, on ? "on" : "off",
(on && brightness >= 0 && cfg->dimmable) ? " (with brightness)" : "");
return ha_post(path, body);
}
+48
View File
@@ -0,0 +1,48 @@
/*
* Home Assistant REST transport (device only).
*
* Everything ESP-IDF-specific about talking to HA lives here; parsing and
* presentation are in ha_data.c, which the desktop simulator also compiles.
*
* Plain HTTP on the LAN, deliberately. The Ambient Weather cloud client needs
* TLS and pays a multi-second handshake for it; this talks to a box on the
* same switch, and the token is not worth a handshake inside the window where
* the UI is frozen. Do not point this at an https:// HA instance without
* revisiting that trade-off.
*/
#ifndef HA_CLIENT_H
#define HA_CLIENT_H
#include <stdbool.h>
#include <stdint.h>
#include "esp_err.h"
#include "ha_data.h"
/* Validates configuration. Does not open a connection. */
esp_err_t ha_client_init(void);
/*
* One request for every sensor, light and sun time (~400 bytes). Leaves
* data->forecast alone -- see ha_fetch_forecast().
*
* Blocks for the round trip, so callers pause LVGL around it.
*/
esp_err_t ha_fetch_state(ha_data_t *data);
/*
* Fills data->forecast. A separate, slower poll: the forecast is a service
* call rather than a template render, and it changes hourly at most.
*/
esp_err_t ha_fetch_forecast(ha_data_t *data);
/*
* Turn a light on or off. `index` indexes HA_LIGHTS; `brightness` is 0-255,
* or -1 to leave the level alone (and always for switch-domain entities).
*
* Blocks. Must not be called from the LVGL task -- see ui_set_light_cb().
*/
esp_err_t ha_light_command(int index, bool on, int16_t brightness);
#endif
+85
View File
@@ -0,0 +1,85 @@
/*
* Home Assistant entity IDs for this installation.
*
* Entity IDs are not secrets, so unlike the token in app_config.h they are
* tracked. They are also not stable: renaming a device or reinstalling an
* integration changes them, and a wrong ID fails silently as "unavailable"
* rather than as an error. Re-check with tools/ha_discover.py after touching
* anything in HA.
*/
#ifndef HA_CONFIG_H
#define HA_CONFIG_H
/* --- Weather station (Ambient Weather, exposed locally through HA) ------- */
#define HA_ENT_OUTDOOR_TEMP "sensor.moonlight_beach_temperature"
#define HA_ENT_OUTDOOR_HUMIDITY "sensor.moonlight_beach_humidity"
#define HA_ENT_INDOOR_TEMP "sensor.moonlight_beach_inside_temperature"
#define HA_ENT_INDOOR_HUMIDITY "sensor.moonlight_beach_humidity_indoor"
#define HA_ENT_DEW_POINT "sensor.moonlight_beach_dew_point"
#define HA_ENT_PRESSURE "sensor.moonlight_beach_relative_pressure"
#define HA_ENT_RAIN_TODAY "sensor.moonlight_beach_daily_rain"
#define HA_ENT_RAIN_RATE "sensor.moonlight_beach_precipitation_intensity"
/*
* The anemometer has been offline since 2026-07-27: wind speed, gust and
* direction all read "unavailable". The display shows blanks for these by
* design -- no substituting forecast wind for station wind.
*
* Do not be tempted by sensor.moonlight_beach_max_gust. It is NOT unavailable;
* it still reports its last value from 2026-07-28 and would render as a
* plausible live number. Anything using it needs a staleness check first.
*/
#define HA_ENT_WIND_SPEED "sensor.moonlight_beach_wind_speed"
#define HA_ENT_WIND_GUST "sensor.moonlight_beach_wind_gust"
#define HA_ENT_WIND_DIR "sensor.moonlight_beach_wind_direction"
/* --- Forecast and sun ---------------------------------------------------- */
/* met.no via HA. Supplies the textual condition and the daily forecast. */
#define HA_ENT_WEATHER "weather.forecast_home"
#define HA_ENT_SUN "sun.sun"
/* --- Lights -------------------------------------------------------------- */
/*
* The X-macro is load-bearing, not decoration.
*
* The JSON template asks HA for these entities as an ordered array, and the
* firmware reads the reply back by index. Two hand-maintained lists would
* drift the first time one is reordered, and the failure mode is silent: the
* Dining Room tile would just start controlling the Blue Lamp. Generating the
* template's entity list and the C table from one source makes that
* impossible.
*
* X(entity_id, label, dimmable, icon)
*
* `dimmable` is false for the patio, which is a TP-Link smart plug in the
* switch domain -- on/off only, and it needs switch.turn_on rather than
* light.turn_on. See ha_service_domain().
*
* `icon` is an ICON_* macro from main/ui/ui_icons.h. It rides along in this
* list (rather than a parallel array in the UI) so a reorder here can never
* put the sofa icon on the patio. Only ui_manager.c expands it; the api-layer
* consumers ignore the argument, so this header does not include ui_icons.h.
*
* light.main_room used to be here and was removed deliberately: it is a
* GROUP of blue_lamp, brown_lamp and landing_chandelier -- three of the
* other tiles. One tap restyled four tiles at once, which both confused the
* optimistic-hold logic and triggered a redraw storm big enough to glitch
* the panel (see README on PSRAM bandwidth). Groups spanning other tiles do
* not belong in this list. light.tv_room is also a group (of 2), but of
* lights that have no tile of their own, which is fine.
*/
/* Labels must fit one line of Montserrat 16 in a ~125px tile -- roughly 11
* characters. Longer ones are truncated with an ellipsis, which reads as a
* mistake. "Dining Room" is one character too wide, hence "Dining". */
#define HA_LIGHT_LIST(X) \
X("light.tv_room", "TV Room", true, ICON_TV) \
X("light.dining_room", "Dining", true, ICON_DINING) \
X("light.blue_lamp", "Blue Lamp", true, ICON_FLOOR_LAMP) \
X("light.brown_lamp", "Brown Lamp", true, ICON_DESK_LAMP) \
X("light.landing_chandelier", "Chandelier", true, ICON_CHANDELIER) \
X("switch.tp_link_smart_plug_f048_outdoor_hanging_lights", "Patio", false, ICON_STRING_LIGHTS)
#endif
+437
View File
@@ -0,0 +1,437 @@
/*
* Parsing and presentation helpers for Home Assistant data.
*
* Deliberately free of ESP-IDF and network dependencies: this file is
* compiled into the desktop simulator as well as the firmware, so the layout
* work in sim/ exercises the same parser the device runs. Keep it that way --
* anything needing esp_* belongs in ha_client.c.
*/
#include <math.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "cJSON.h"
#include "ha_data.h"
/* Build the config table from the same list the JSON template is built from.
* The icon argument belongs to the UI layer and is ignored here. */
#define HA_LIGHT_ENTRY_X(id, label, dimmable, icon) { id, label, dimmable },
const ha_light_cfg_t HA_LIGHTS[HA_LIGHT_COUNT] = {
HA_LIGHT_LIST(HA_LIGHT_ENTRY_X)
};
/* ------------------------------------------------------------------ values */
/*
* A JSON null (or a missing key) means the sensor is unavailable and the
* field stays invalid. Note cJSON_IsNumber() is false for null, so this
* handles both cases without a separate null check.
*/
static void take_number(const cJSON *root, const char *key, ha_value_t *out)
{
const cJSON *item = cJSON_GetObjectItemCaseSensitive(root, key);
if (cJSON_IsNumber(item)) {
out->value = (float)item->valuedouble;
out->valid = true;
}
}
static void take_string(const cJSON *root, const char *key, char *out, size_t len)
{
const cJSON *item = cJSON_GetObjectItemCaseSensitive(root, key);
if (cJSON_IsString(item) && item->valuestring != NULL) {
snprintf(out, len, "%s", item->valuestring);
}
}
/* ------------------------------------------------------------------ lights */
static ha_light_state_t light_state_from_string(const char *s)
{
if (s == NULL) {
return HA_LIGHT_UNAVAILABLE;
}
if (strcmp(s, "on") == 0) {
return HA_LIGHT_ON;
}
if (strcmp(s, "off") == 0) {
return HA_LIGHT_OFF;
}
/* "unavailable", "unknown", or anything unexpected. Treated the same:
* the tile greys out rather than claiming the light is off. */
return HA_LIGHT_UNAVAILABLE;
}
static void parse_lights(const cJSON *root, ha_data_t *out)
{
const cJSON *lights = cJSON_GetObjectItemCaseSensitive(root, "lights");
if (!cJSON_IsArray(lights)) {
return;
}
/*
* Index-aligned with HA_LIGHTS because the template iterates the same
* X-macro list. If HA returns a different count, something is out of sync
* between the template and the table -- take what lines up rather than
* reading past either array.
*/
const int n = cJSON_GetArraySize(lights);
const int count = (n < HA_LIGHT_COUNT) ? n : HA_LIGHT_COUNT;
for (int i = 0; i < count; i++) {
const cJSON *entry = cJSON_GetArrayItem(lights, i);
if (!cJSON_IsObject(entry)) {
continue;
}
const cJSON *state = cJSON_GetObjectItemCaseSensitive(entry, "s");
out->lights[i].state = light_state_from_string(
cJSON_IsString(state) ? state->valuestring : NULL);
const cJSON *bright = cJSON_GetObjectItemCaseSensitive(entry, "b");
out->lights[i].brightness = cJSON_IsNumber(bright)
? (int16_t)bright->valuedouble
: -1;
}
}
/* -------------------------------------------------------------- state JSON */
bool ha_parse_json(const char *json, ha_data_t *out)
{
if (json == NULL || out == NULL) {
return false;
}
cJSON *root = cJSON_Parse(json);
if (root == NULL) {
return false;
}
/*
* Preserve the forecast across the wipe. It arrives from a different,
* slower request; zeroing the whole struct here would blank the forecast
* strip on every sensor poll.
*/
ha_forecast_day_t saved_forecast[HA_FORECAST_DAYS];
memcpy(saved_forecast, out->forecast, sizeof(saved_forecast));
const uint8_t saved_count = out->forecast_count;
memset(out, 0, sizeof(*out));
memcpy(out->forecast, saved_forecast, sizeof(saved_forecast));
out->forecast_count = saved_count;
take_number(root, "out_t", &out->outdoor_temp_f);
take_number(root, "out_h", &out->outdoor_humidity);
take_number(root, "in_t", &out->indoor_temp_f);
take_number(root, "in_h", &out->indoor_humidity);
take_number(root, "dew", &out->dew_point_f);
take_number(root, "press", &out->pressure_inhg);
take_number(root, "rain_d", &out->rain_today_in);
take_number(root, "rain_r", &out->rain_rate_inhr);
take_number(root, "wind", &out->wind_mph);
take_number(root, "gust", &out->gust_mph);
take_number(root, "wdir", &out->wind_dir_deg);
take_string(root, "cond", out->condition, sizeof(out->condition));
take_string(root, "sunrise", out->sunrise, sizeof(out->sunrise));
take_string(root, "sunset", out->sunset, sizeof(out->sunset));
take_string(root, "clock", out->clock, sizeof(out->clock));
take_string(root, "date", out->date, sizeof(out->date));
out->sun_up = cJSON_IsTrue(cJSON_GetObjectItemCaseSensitive(root, "sun_up"));
parse_lights(root, out);
cJSON_Delete(root);
return true;
}
/* ----------------------------------------------------------- forecast JSON */
/*
* Weekday from a "YYYY-MM-DD..." prefix, by Zeller's congruence.
*
* The device has no clock, so the day name cannot come from localtime(). The
* forecast timestamps are UTC; HA returns them at 19:00Z, which is midday
* locally, so the UTC date and the local date agree. That would stop being
* true for a timestamp near midnight UTC -- if HA ever changes the hour, this
* needs the offset applied first.
*/
static void weekday_name(const char *iso, char *out, size_t len)
{
int y = 0, m = 0, d = 0;
if (iso == NULL || sscanf(iso, "%4d-%2d-%2d", &y, &m, &d) != 3) {
snprintf(out, len, "--");
return;
}
/* Zeller treats January and February as months 13 and 14 of the year
* before. */
if (m < 3) {
m += 12;
y -= 1;
}
const int K = y % 100;
const int J = y / 100;
const int h = (d + (13 * (m + 1)) / 5 + K + K / 4 + J / 4 + 5 * J) % 7;
/* Zeller's h: 0 = Saturday. */
static const char *const names[7] = {
"Sat", "Sun", "Mon", "Tue", "Wed", "Thu", "Fri"
};
snprintf(out, len, "%s", names[h]);
}
bool ha_parse_forecast_json(const char *json, ha_data_t *out)
{
if (json == NULL || out == NULL) {
return false;
}
cJSON *root = cJSON_Parse(json);
if (root == NULL) {
return false;
}
bool ok = false;
/* { "service_response": { "<entity>": { "forecast": [ ... ] } } } */
const cJSON *resp = cJSON_GetObjectItemCaseSensitive(root, "service_response");
const cJSON *entity = cJSON_IsObject(resp) ? resp->child : NULL;
const cJSON *list = cJSON_IsObject(entity)
? cJSON_GetObjectItemCaseSensitive(entity, "forecast")
: NULL;
if (cJSON_IsArray(list)) {
const int n = cJSON_GetArraySize(list);
const int count = (n < HA_FORECAST_DAYS) ? n : HA_FORECAST_DAYS;
memset(out->forecast, 0, sizeof(out->forecast));
for (int i = 0; i < count; i++) {
const cJSON *day = cJSON_GetArrayItem(list, i);
if (!cJSON_IsObject(day)) {
continue;
}
ha_forecast_day_t *slot = &out->forecast[i];
const cJSON *dt = cJSON_GetObjectItemCaseSensitive(day, "datetime");
weekday_name(cJSON_IsString(dt) ? dt->valuestring : NULL,
slot->day, sizeof(slot->day));
take_string(day, "condition", slot->condition, sizeof(slot->condition));
take_number(day, "temperature", &slot->high_f);
take_number(day, "templow", &slot->low_f);
}
out->forecast_count = (uint8_t)count;
ok = true;
}
cJSON_Delete(root);
return ok;
}
/* ----------------------------------------------------------- presentation */
const char *ha_service_domain(const char *entity_id)
{
/* The patio plug lives in the switch domain; light.turn_on on a switch
* entity is accepted by HA and silently does nothing. */
if (entity_id != NULL && strncmp(entity_id, "switch.", 7) == 0) {
return "switch";
}
return "light";
}
typedef struct {
const char *slug;
const char *label;
const char *shrt;
} condition_name_t;
/* met.no condition slugs as HA reports them. `shrt` is for the forecast
* strip, where a column is about 150px wide. */
static const condition_name_t CONDITIONS[] = {
{ "clear-night", "Clear", "Clear" },
{ "cloudy", "Cloudy", "Cloudy" },
{ "exceptional", "Exceptional", "Severe" },
{ "fog", "Fog", "Fog" },
{ "hail", "Hail", "Hail" },
{ "lightning", "Thunderstorms", "Storm" },
{ "lightning-rainy", "Thunderstorms", "Storm" },
{ "partlycloudy", "Partly Cloudy", "Partly" },
{ "pouring", "Heavy Rain", "Heavy" },
{ "rainy", "Rain", "Rain" },
{ "snowy", "Snow", "Snow" },
{ "snowy-rainy", "Wintry Mix", "Wintry" },
{ "sunny", "Sunny", "Sunny" },
{ "windy", "Windy", "Windy" },
{ "windy-variant", "Windy", "Windy" },
};
static const condition_name_t *condition_lookup(const char *slug)
{
if (slug == NULL) {
return NULL;
}
for (size_t i = 0; i < sizeof(CONDITIONS) / sizeof(CONDITIONS[0]); i++) {
if (strcmp(CONDITIONS[i].slug, slug) == 0) {
return &CONDITIONS[i];
}
}
return NULL;
}
const char *ha_condition_label(const char *slug)
{
const condition_name_t *c = condition_lookup(slug);
/* Unrecognised slugs pass through rather than becoming "Unknown" -- if
* met.no adds a condition, seeing the raw slug on screen says what to
* add here. */
return (c != NULL) ? c->label : (slug != NULL ? slug : "--");
}
const char *ha_condition_short(const char *slug)
{
const condition_name_t *c = condition_lookup(slug);
return (c != NULL) ? c->shrt : (slug != NULL ? slug : "--");
}
/* ------------------------------------------------------------------ trends */
/*
* 20 slots, one kept per ~4 minutes, covers ~80 minutes. Feeding faster than
* that overwrites the newest slot instead of consuming a new one, so a 60s
* poll cadence still fills the ring at 4-minute spacing.
*
* Size matters here more than it looks: this array is .bss, which lives in
* internal RAM on the S3 -- the same internal RAM the RGB panel's bounce
* buffers come from, with only a few KB of slack (README, memory notes). A
* naive 72-slot-per-poll ring cost 2.6KB and was enough to tip display_init
* into "no mem for bounce buffer" on an unlucky boot. Keep this structure
* small; if it ever needs to grow, it must move to PSRAM instead.
*
* Static per channel; a reboot forgets history, and the display shows no
* arrow until enough accumulates again (see ha_data.h).
*/
#define TREND_SLOTS 20
#define TREND_BUCKET_MS (4u * 60u * 1000u)
typedef struct {
float value;
uint32_t ms;
bool used;
} trend_sample_t;
static trend_sample_t trend_ring[HA_TREND_CHANNELS][TREND_SLOTS];
static int trend_head[HA_TREND_CHANNELS]; /* slot most recently written */
void ha_trend_feed(int channel, float value, uint32_t now_ms)
{
if (channel < 0 || channel >= HA_TREND_CHANNELS) {
return;
}
trend_sample_t *newest = &trend_ring[channel][trend_head[channel]];
/* Inside the current bucket: refresh the newest sample in place, so
* compute always sees the latest reading without spending a slot. */
if (newest->used && (now_ms - newest->ms) < TREND_BUCKET_MS) {
newest->value = value;
return;
}
const int next = newest->used ? (trend_head[channel] + 1) % TREND_SLOTS
: trend_head[channel];
trend_ring[channel][next] = (trend_sample_t){ value, now_ms, true };
trend_head[channel] = next;
}
ha_trend_t ha_trend_compute(int channel, float threshold_per_hour, uint32_t now_ms)
{
if (channel < 0 || channel >= HA_TREND_CHANNELS) {
return HA_TREND_UNKNOWN;
}
/* The newest sample is the one just fed; find the usable sample whose
* age is closest to the window. Unsigned subtraction keeps this correct
* across tick wrap. */
const trend_sample_t *newest = NULL;
const trend_sample_t *anchor = NULL;
uint32_t best_dist = UINT32_MAX;
for (int i = 0; i < TREND_SLOTS; i++) {
const trend_sample_t *s = &trend_ring[channel][i];
if (!s->used) {
continue;
}
const uint32_t age = now_ms - s->ms;
if (age > HA_TREND_WINDOW_MS + HA_TREND_MIN_AGE_MS) {
continue; /* too old even for a sparse ring; ignore */
}
if (newest == NULL || age < (now_ms - newest->ms)) {
newest = s;
}
if (age >= HA_TREND_MIN_AGE_MS) {
const uint32_t dist = (age > HA_TREND_WINDOW_MS)
? age - HA_TREND_WINDOW_MS
: HA_TREND_WINDOW_MS - age;
if (dist < best_dist) {
best_dist = dist;
anchor = s;
}
}
}
if (newest == NULL || anchor == NULL || newest == anchor) {
return HA_TREND_UNKNOWN;
}
/*
* Scale the threshold to the actual span so a 20-minute-old anchor uses
* a proportionally smaller bar than a full hour would. Without this the
* first hour after boot under-reports every trend.
*/
const uint32_t span_ms = anchor->ms == newest->ms
? 1
: newest->ms - anchor->ms;
const float threshold = threshold_per_hour *
((float)span_ms / (float)HA_TREND_WINDOW_MS);
const float delta = newest->value - anchor->value;
if (delta > threshold) {
return HA_TREND_RISING;
}
if (delta < -threshold) {
return HA_TREND_FALLING;
}
return HA_TREND_FLAT;
}
const char *ha_wind_cardinal(float degrees)
{
static const char *const points[16] = {
"N", "NNE", "NE", "ENE", "E", "ESE", "SE", "SSE",
"S", "SSW", "SW", "WSW", "W", "WNW", "NW", "NNW"
};
if (!isfinite(degrees)) {
return "--";
}
/* fmodf twice: a negative bearing would otherwise index out of bounds. */
float d = fmodf(fmodf(degrees, 360.0f) + 360.0f, 360.0f);
const int idx = (int)((d + 11.25f) / 22.5f) % 16;
return points[idx];
}
+182
View File
@@ -0,0 +1,182 @@
/*
* The data contract between "fetching" and "drawing".
*
* ha_data_t is plain data with no ESP-IDF or network dependency, and
* ha_parse_json() is a pure function. That is what lets the desktop simulator
* run the real UI: sim/ supplies a JSON snapshot or a LAN fetch, the firmware
* supplies esp_http_client, and both hand the identical struct to the
* identical drawing code.
*
* The UI layer must never see HTTP, cJSON or an entity ID -- only this.
*/
#ifndef HA_DATA_H
#define HA_DATA_H
#include <stdbool.h>
#include <stdint.h>
#include "ha_config.h"
/* Five days across the bottom strip. HA returns six; the sixth is dropped. */
#define HA_FORECAST_DAYS 5
/* Derived from HA_LIGHT_LIST so the two can never disagree. */
#define HA_LIGHT_COUNT_X(a, b, c, d) +1
#define HA_LIGHT_COUNT (0 HA_LIGHT_LIST(HA_LIGHT_COUNT_X))
/*
* A reading that may not exist.
*
* Sensors go unavailable routinely -- a dead anemometer, a flat battery, an
* integration reloading -- and a float alone cannot say so. Every consumer has
* to check `valid` before touching `value`; there is deliberately no sentinel
* to accidentally render as a real number.
*/
typedef struct {
float value;
bool valid;
} ha_value_t;
typedef enum {
HA_LIGHT_OFF = 0,
HA_LIGHT_ON,
HA_LIGHT_UNAVAILABLE, /* entity exists but HA cannot reach the device */
} ha_light_state_t;
typedef struct {
ha_light_state_t state;
/* 0-255 as HA reports it. Only meaningful when state is HA_LIGHT_ON and
* the entity is dimmable; -1 otherwise. */
int16_t brightness;
} ha_light_status_t;
typedef struct {
const char *entity_id;
const char *label;
bool dimmable;
} ha_light_cfg_t;
/*
* Which way a reading has moved over the last hour.
*
* UNKNOWN is deliberately 0 so a freshly wiped ha_data_t renders no arrow --
* after boot there is no history, and "no arrow" is the honest display until
* at least HA_TREND_MIN_AGE_MS of samples exist.
*/
typedef enum {
HA_TREND_UNKNOWN = 0,
HA_TREND_FLAT,
HA_TREND_RISING,
HA_TREND_FALLING,
} ha_trend_t;
enum {
HA_TREND_CH_INDOOR = 0,
HA_TREND_CH_OUTDOOR,
HA_TREND_CH_PRESSURE,
HA_TREND_CHANNELS,
};
/* Generated from HA_LIGHT_LIST; index-aligned with ha_data_t.lights. */
extern const ha_light_cfg_t HA_LIGHTS[HA_LIGHT_COUNT];
typedef struct {
char day[4]; /* "Mon" -- derived from the ISO date, see below */
char condition[24]; /* met.no condition slug, e.g. "partlycloudy" */
ha_value_t high_f;
ha_value_t low_f;
} ha_forecast_day_t;
typedef struct {
/* Weather station */
ha_value_t outdoor_temp_f;
ha_value_t outdoor_humidity;
ha_value_t indoor_temp_f;
ha_value_t indoor_humidity;
ha_value_t dew_point_f;
ha_value_t pressure_inhg;
ha_value_t rain_today_in;
ha_value_t rain_rate_inhr;
ha_value_t wind_mph;
ha_value_t gust_mph;
ha_value_t wind_dir_deg;
/* Forecast / sun. These strings arrive pre-formatted in local time --
* HA owns the timezone, so the device needs no clock and no SNTP. */
char condition[24];
char sunrise[12]; /* "6:05 AM" */
char sunset[12]; /* "7:45 PM" */
char clock[12]; /* "10:21 AM" */
char date[16]; /* "Tue Aug 4" */
bool sun_up;
ha_light_status_t lights[HA_LIGHT_COUNT];
ha_forecast_day_t forecast[HA_FORECAST_DAYS];
uint8_t forecast_count; /* 0 when no forecast has been fetched yet */
/*
* Filled by the CALLER after parsing, from ha_trend_feed/compute --
* the parser wipes them to UNKNOWN. Local ring-buffer history, not an HA
* query: an /api/history call returns hundreds of points per entity,
* which is silly when the device already sees every reading each poll.
*/
ha_trend_t trend_indoor;
ha_trend_t trend_outdoor;
ha_trend_t trend_pressure;
} ha_data_t;
/*
* Parse the reply to the state template into `out`.
*
* Zeroes `out` first, so a field missing from the response reads as invalid
* rather than keeping a previous poll's value. Returns false only when the
* body is not parseable JSON -- individual missing or unavailable sensors are
* normal and leave their ha_value_t invalid.
*
* Does not touch the forecast fields; those come from ha_parse_forecast_json()
* on a slower poll and would otherwise be wiped every minute.
*/
bool ha_parse_json(const char *json, ha_data_t *out);
/*
* Parse a weather/get_forecasts service response into out->forecast.
* Fills at most HA_FORECAST_DAYS entries and sets forecast_count.
*/
bool ha_parse_forecast_json(const char *json, ha_data_t *out);
/*
* "light" or "switch" for entity_id -- the HA service domain to call. The
* patio plug is a switch, and calling light.turn_on on it silently does
* nothing.
*/
const char *ha_service_domain(const char *entity_id);
/* Human-readable form of a met.no condition slug ("partlycloudy" ->
* "Partly Cloudy"). Returns the slug unchanged if unrecognised, so an
* unhandled condition shows up on screen as the thing to go add. */
const char *ha_condition_label(const char *slug);
/* As above, abbreviated for the forecast strip's narrow columns
* ("partlycloudy" -> "Partly"). */
const char *ha_condition_short(const char *slug);
/* Compass point for a bearing in degrees ("NNE"). */
const char *ha_wind_cardinal(float degrees);
/*
* Trend tracking. Feed one sample per poll; compute compares the newest
* sample against the one closest to an hour old. Timestamps come from the
* caller (lv_tick_get() or xTaskGetTickCount-derived ms) so this file stays
* free of LVGL and FreeRTOS. Wrap-safe on uint32 ms.
*
* compute returns UNKNOWN until the oldest usable sample is at least
* HA_TREND_MIN_AGE_MS old -- "trending" over 90 seconds is noise.
*/
#define HA_TREND_WINDOW_MS (60u * 60u * 1000u)
#define HA_TREND_MIN_AGE_MS (5u * 60u * 1000u)
void ha_trend_feed(int channel, float value, uint32_t now_ms);
ha_trend_t ha_trend_compute(int channel, float threshold_per_hour, uint32_t now_ms);
#endif
+78
View File
@@ -0,0 +1,78 @@
/*
* The Jinja template POSTed to Home Assistant's /api/template.
*
* Why a template rather than plain REST calls:
*
* - One request instead of a dozen. /api/states returns all ~204 entities
* (far too much to buffer); fetching entities one at a time would mean a
* dozen round-trips inside the window where the UI is frozen. This is a
* single ~430 byte reply, measured at ~85ms on the LAN.
* - HA formats the times. sunrise/sunset/clock/date come back as local-time
* strings, so the device needs neither SNTP nor a timezone database, and
* DST is somebody else's problem. The board's clock starts at the epoch
* and that no longer matters.
* - Unavailable sensors arrive as JSON null, not a sentinel number. A
* sentinel would be indistinguishable from a real reading -- -1 mph of
* wind is impossible, but -1 degF of dew point is not.
*
* The reply is compact JSON; see ha_parse_json().
*/
#ifndef HA_TEMPLATE_H
#define HA_TEMPLATE_H
#include "ha_config.h"
/* Emits the entity list for the lights loop, in HA_LIGHT_LIST order. The
* trailing comma is deliberate -- Jinja accepts it, and it avoids a
* last-element special case. The icon argument is the UI layer's. */
#define HA_TMPL_LIGHT_X(id, label, dimmable, icon) "'" id "',"
/*
* num() -- a state as a number, or null if it is not one ("unavailable",
* "unknown", or a non-numeric state).
* attrnum() -- ditto for an attribute; brightness is None when a light is off.
*/
#define HA_STATE_TEMPLATE \
"{%- macro num(e) -%}{%- set v = states(e) -%}" \
"{{ v | float if is_number(v) else 'null' }}{%- endmacro -%}" \
"{%- macro attrnum(e, a) -%}{%- set v = state_attr(e, a) -%}" \
"{{ v if v is number else 'null' }}{%- endmacro -%}" \
"{" \
"\"out_t\":" "{{ num('" HA_ENT_OUTDOOR_TEMP "') }}," \
"\"out_h\":" "{{ num('" HA_ENT_OUTDOOR_HUMIDITY "') }}," \
"\"in_t\":" "{{ num('" HA_ENT_INDOOR_TEMP "') }}," \
"\"in_h\":" "{{ num('" HA_ENT_INDOOR_HUMIDITY "') }}," \
"\"dew\":" "{{ num('" HA_ENT_DEW_POINT "') }}," \
"\"press\":" "{{ num('" HA_ENT_PRESSURE "') }}," \
"\"rain_d\":" "{{ num('" HA_ENT_RAIN_TODAY "') }}," \
"\"rain_r\":" "{{ num('" HA_ENT_RAIN_RATE "') }}," \
"\"wind\":" "{{ num('" HA_ENT_WIND_SPEED "') }}," \
"\"gust\":" "{{ num('" HA_ENT_WIND_GUST "') }}," \
"\"wdir\":" "{{ num('" HA_ENT_WIND_DIR "') }}," \
"\"cond\":\"" "{{ states('" HA_ENT_WEATHER "') }}\"," \
"\"sunrise\":\"{{ (state_attr('" HA_ENT_SUN "','next_rising')" \
" | as_datetime | as_local).strftime('%-I:%M %p') }}\"," \
"\"sunset\":\"{{ (state_attr('" HA_ENT_SUN "','next_setting')" \
" | as_datetime | as_local).strftime('%-I:%M %p') }}\"," \
"\"sun_up\":{{ (states('" HA_ENT_SUN "') == 'above_horizon') | lower }}," \
"\"clock\":\"{{ now().strftime('%-I:%M %p') }}\"," \
"\"date\":\"{{ now().strftime('%a %b %-d') }}\"," \
"\"lights\":[" \
"{%- for e in [" HA_LIGHT_LIST(HA_TMPL_LIGHT_X) "] -%}" \
"{\"s\":\"{{ states(e) }}\",\"b\":{{ attrnum(e,'brightness') }}}" \
"{{ \",\" if not loop.last }}" \
"{%- endfor -%}" \
"]}"
/*
* Forecast body for POST /api/services/weather/get_forecasts?return_response.
*
* The forecast is deliberately NOT part of the template above: HA removed the
* `forecast` attribute from weather entities in 2024.4, and Jinja templates
* cannot call services. It also changes far more slowly than the sensors, so
* it is polled on its own longer interval.
*/
#define HA_FORECAST_BODY \
"{\"entity_id\":\"" HA_ENT_WEATHER "\",\"type\":\"daily\"}"
#endif
+30 -6
View File
@@ -5,15 +5,39 @@
#ifndef APP_CONFIG_H
#define APP_CONFIG_H
/* From https://ambientweather.net/account
* DEVICE_ID is the station's MAC address, e.g. "AA:BB:CC:DD:EE:FF" */
#define WIFI_SSID ""
#define WIFI_PASSWORD ""
/* --- Home Assistant (primary data source) --------------------------------
*
* Plain HTTP on the LAN, so there is no certificate handling and no TLS
* handshake inside the window where rendering is paused. HA_TOKEN is a
* long-lived access token: in Home Assistant, click your user (bottom left)
* -> Security -> Long-lived access tokens -> Create token.
*
* Entity IDs are NOT here -- they are not secrets, so they live in
* main/api/ha_config.h. Re-check them with tools/ha_discover.py.
*/
#define HA_HOST "192.168.1.134"
#define HA_PORT 8123
#define HA_TOKEN ""
#define HA_UPDATE_INTERVAL_MS (1 * 60 * 1000)
/* --- Ambient Weather (fallback only) -------------------------------------
*
* Used only when Home Assistant cannot be reached, and then only for outdoor
* temperature, humidity and wind -- indoor, sun, forecast and lights have no
* cloud equivalent. Leaving these blank is fine; it just means no fallback.
*
* From https://ambientweather.net/account
* DEVICE_ID is the station's MAC address, e.g. "AA:BB:CC:DD:EE:FF"
*/
#define AMBIENT_WEATHER_API_KEY ""
#define AMBIENT_WEATHER_APP_KEY ""
#define AMBIENT_WEATHER_DEVICE_ID ""
#define WIFI_SSID ""
#define WIFI_PASSWORD ""
#define WEATHER_UPDATE_INTERVAL_MS (1 * 60 * 1000)
/* The fallback client's own poll interval name. */
#define WEATHER_UPDATE_INTERVAL_MS HA_UPDATE_INTERVAL_MS
#endif
+308
View File
@@ -0,0 +1,308 @@
/*
* Thermostat control logic. See thermostat.h for the reasoning behind each
* protection; this file is the mechanism.
*
* Pure logic by design -- compiled into the desktop simulator as well as the
* firmware, so the whole control loop can be exercised against a fake furnace
* before it ever switches a gas valve.
*/
#include <stdio.h>
#include <string.h>
#include "thermostat.h"
/* Room for the CPH ceiling plus a couple, so the rolling-hour scan always has
* the full picture even at the limit. */
#define CYCLE_SLOTS (THERM_MAX_CYCLES_PER_HOUR + 3)
#define ONE_HOUR_MS (60u * 60u * 1000u)
static struct {
therm_mode_t mode;
therm_fan_t fan;
float setpoint_f;
bool heat_on;
uint32_t heat_since_ms; /* when the burner last turned on */
uint32_t heat_off_ms; /* when it last turned off */
bool have_off_stamp;
float last_temp_f;
bool have_temp;
uint32_t last_temp_ms;
uint32_t boot_ms;
uint32_t last_tick_ms; /* so get_status() can age the cycle ring */
/* Rolling-hour ring of burner start times. */
uint32_t cycle_ms[CYCLE_SLOTS];
bool cycle_used[CYCLE_SLOTS];
int cycle_head;
therm_state_t state;
uint32_t lockout_remaining_s;
} s;
/* Wrap-safe: unsigned subtraction gives the correct elapsed time across the
* 32-bit rollover at ~49 days. */
static uint32_t elapsed(uint32_t now_ms, uint32_t then_ms)
{
return now_ms - then_ms;
}
static void cycle_record(uint32_t now_ms)
{
s.cycle_ms[s.cycle_head] = now_ms;
s.cycle_used[s.cycle_head] = true;
s.cycle_head = (s.cycle_head + 1) % CYCLE_SLOTS;
}
static uint8_t cycles_in_last_hour(uint32_t now_ms)
{
uint8_t n = 0;
for (int i = 0; i < CYCLE_SLOTS; i++) {
if (s.cycle_used[i] && elapsed(now_ms, s.cycle_ms[i]) < ONE_HOUR_MS) {
n++;
}
}
return n;
}
/* When the oldest cycle inside the window ages out, freeing a slot. */
static uint32_t ms_until_cycle_slot(uint32_t now_ms)
{
uint32_t soonest = UINT32_MAX;
for (int i = 0; i < CYCLE_SLOTS; i++) {
if (!s.cycle_used[i]) {
continue;
}
const uint32_t age = elapsed(now_ms, s.cycle_ms[i]);
if (age < ONE_HOUR_MS) {
const uint32_t remaining = ONE_HOUR_MS - age;
if (remaining < soonest) {
soonest = remaining;
}
}
}
return (soonest == UINT32_MAX) ? 0 : soonest;
}
void thermostat_init(uint32_t now_ms)
{
memset(&s, 0, sizeof(s));
s.mode = THERM_MODE_HEAT;
s.fan = THERM_FAN_AUTO;
s.setpoint_f = THERM_SETPOINT_DEFAULT_F;
s.boot_ms = now_ms;
s.state = THERM_STATE_IDLE;
}
void thermostat_set_mode(therm_mode_t mode)
{
s.mode = mode;
}
void thermostat_set_fan(therm_fan_t fan)
{
s.fan = fan;
}
therm_mode_t thermostat_get_mode(void)
{
return s.mode;
}
therm_fan_t thermostat_get_fan(void)
{
return s.fan;
}
static void clamp_setpoint(void)
{
if (s.setpoint_f < THERM_SETPOINT_MIN_F) {
s.setpoint_f = THERM_SETPOINT_MIN_F;
} else if (s.setpoint_f > THERM_SETPOINT_MAX_F) {
s.setpoint_f = THERM_SETPOINT_MAX_F;
}
}
void thermostat_adjust_setpoint(float delta_f)
{
s.setpoint_f += delta_f;
clamp_setpoint();
}
void thermostat_set_setpoint(float value_f)
{
s.setpoint_f = value_f;
clamp_setpoint();
}
float thermostat_get_setpoint(void)
{
return s.setpoint_f;
}
static void heat_start(uint32_t now_ms)
{
s.heat_on = true;
s.heat_since_ms = now_ms;
cycle_record(now_ms);
}
static void heat_stop(uint32_t now_ms)
{
s.heat_on = false;
s.heat_off_ms = now_ms;
s.have_off_stamp = true;
}
void thermostat_tick(float indoor_f, bool temp_valid, uint32_t now_ms)
{
s.last_tick_ms = now_ms;
if (temp_valid) {
s.last_temp_f = indoor_f;
s.last_temp_ms = now_ms;
s.have_temp = true;
}
s.lockout_remaining_s = 0;
/*
* Mode off, or the reading has gone stale enough that acting on it is
* guesswork. Either way the burner stops -- and note this bypasses the
* minimum-on timer, which only exists to stop rapid restarts. Stopping
* heat is never bad for the furnace.
*/
const bool sensor_dead =
!s.have_temp || elapsed(now_ms, s.last_temp_ms) > THERM_SENSOR_STALE_MS;
if (s.mode == THERM_MODE_OFF || sensor_dead) {
if (s.heat_on) {
heat_stop(now_ms);
}
s.state = (s.mode == THERM_MODE_OFF) ? THERM_STATE_OFF : THERM_STATE_NO_SENSOR;
return;
}
const float half = THERM_DEADBAND_F / 2.0f;
const float temp = s.last_temp_f;
if (s.heat_on) {
/* Running: stop once we are above the top of the deadband, but not
* before the minimum on time is served. */
const bool satisfied = (temp >= s.setpoint_f + half);
const bool min_on_served = elapsed(now_ms, s.heat_since_ms) >= THERM_MIN_ON_MS;
if (satisfied && min_on_served) {
heat_stop(now_ms);
s.state = THERM_STATE_IDLE;
} else {
s.state = THERM_STATE_HEATING;
}
return;
}
/* Idle: does the house want heat at all? */
if (temp > s.setpoint_f - half) {
s.state = THERM_STATE_IDLE;
return;
}
/* It does. Every protection gets a veto, and whichever has the longest
* remaining time is the one the display should report. */
uint32_t wait_ms = 0;
const uint32_t since_boot = elapsed(now_ms, s.boot_ms);
if (since_boot < THERM_BOOT_LOCKOUT_MS) {
wait_ms = THERM_BOOT_LOCKOUT_MS - since_boot;
}
if (s.have_off_stamp) {
const uint32_t off_for = elapsed(now_ms, s.heat_off_ms);
if (off_for < THERM_MIN_OFF_MS) {
const uint32_t remaining = THERM_MIN_OFF_MS - off_for;
if (remaining > wait_ms) {
wait_ms = remaining;
}
}
}
if (cycles_in_last_hour(now_ms) >= THERM_MAX_CYCLES_PER_HOUR) {
const uint32_t remaining = ms_until_cycle_slot(now_ms);
if (remaining > wait_ms) {
wait_ms = remaining;
}
}
if (wait_ms > 0) {
s.state = THERM_STATE_LOCKOUT;
/* Round up, so a display counting down never shows 0 while still
* locked out. */
s.lockout_remaining_s = (wait_ms + 999u) / 1000u;
return;
}
heat_start(now_ms);
s.state = THERM_STATE_HEATING;
}
void thermostat_get_status(therm_status_t *out)
{
if (out == NULL) {
return;
}
out->mode = s.mode;
out->fan = s.fan;
out->setpoint_f = s.setpoint_f;
out->state = s.state;
out->heat_relay = s.heat_on;
out->lockout_remaining_s = s.lockout_remaining_s;
/* Aged against the last tick, not the last temperature: a stale sensor
* must not freeze the cycle count at whatever it was when the reading
* stopped arriving. */
out->cycles_last_hour = cycles_in_last_hour(s.last_tick_ms);
/*
* G is NOT energized during a heat call.
*
* A gas furnace runs its own blower from its control board, with a
* fan-on delay that waits for the heat exchanger to warm and a fan-off
* delay that scavenges the residual heat afterwards. Driving G ourselves
* during heat would start the blower early and blow cold air at people --
* and throw away the post-cycle heat. G is for manual circulation only.
*/
out->fan_relay = (s.fan == THERM_FAN_ON);
}
void thermostat_state_text(const therm_status_t *st, char *buf, size_t len)
{
if (st == NULL || buf == NULL || len == 0) {
return;
}
switch (st->state) {
case THERM_STATE_OFF:
snprintf(buf, len, "Off");
break;
case THERM_STATE_HEATING:
snprintf(buf, len, "Heating");
break;
case THERM_STATE_LOCKOUT:
if (st->lockout_remaining_s >= 60) {
snprintf(buf, len, "Hold %lum", (unsigned long)(st->lockout_remaining_s / 60));
} else {
snprintf(buf, len, "Hold %lus", (unsigned long)st->lockout_remaining_s);
}
break;
case THERM_STATE_NO_SENSOR:
snprintf(buf, len, "No sensor");
break;
case THERM_STATE_IDLE:
default:
snprintf(buf, len, "Idle");
break;
}
}
+144
View File
@@ -0,0 +1,144 @@
/*
* Thermostat control logic for a single-stage gas furnace with no A/C.
*
* Pure logic: no ESP-IDF, no LVGL, no network, no clock of its own. The
* caller supplies the temperature, a monotonic millisecond timestamp, and
* applies the relay outputs. That is what lets the desktop simulator run the
* real control loop against a fake furnace.
*
* Everything here exists to protect the equipment and the occupants:
*
* - hysteresis, so the burner does not chatter around the setpoint
* - minimum on and off times, so a cycle is long enough to be useful and
* the furnace gets a rest between them
* - a cycles-per-hour ceiling, the standard HVAC guard against short
* cycling wearing out the igniter and heat exchanger
* - a sensor-staleness failsafe, because the indoor temperature arrives
* over the network and the network can go away
*/
#ifndef THERMOSTAT_H
#define THERMOSTAT_H
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
/* --- Tunables ----------------------------------------------------------- */
/*
* Total deadband in °F, split either side of the setpoint. Heat starts below
* (setpoint - half) and stops above (setpoint + half). 1.0°F is a normal
* residential value; smaller feels more precise and cycles far more.
*/
#define THERM_DEADBAND_F 1.0f
/*
* Once the burner lights it runs at least this long. A gas furnace spends the
* first minutes bringing the heat exchanger up before the blower even starts,
* so a shorter call delivers almost no heat to the house for a full ignition
* cycle's worth of wear.
*/
#define THERM_MIN_ON_MS (3u * 60u * 1000u)
/*
* And stays off at least this long afterwards. This is the single most
* important protection here -- back-to-back ignitions are what destroy
* igniters.
*/
#define THERM_MIN_OFF_MS (5u * 60u * 1000u)
/*
* Ceiling on burner starts in any rolling hour. 5 is the usual default for
* gas (Honeywell ships 5 CPH for gas, 3 for oil). Min-on plus min-off already
* implies a floor of 8 minutes per cycle, so this mostly catches pathological
* cases -- a flapping sensor, or a setpoint being nudged repeatedly.
*/
#define THERM_MAX_CYCLES_PER_HOUR 5
/*
* How long a stale indoor reading may still be used before heat is forced
* off. The temperature comes from Home Assistant; if HA dies mid-winter,
* running blind on an old number could overheat the house or let it freeze.
*
* The honest fix is a local sensor on the I2C bus, which removes this failure
* mode entirely. Until then this is a deliberate compromise: keep heating on
* the last known value long enough to ride out a restart, then stop and say
* so loudly on screen.
*/
#define THERM_SENSOR_STALE_MS (15u * 60u * 1000u)
/*
* No heat for the first minute after boot, whatever the temperature says. If
* the firmware ever gets into a boot loop, this stops it from machine-gunning
* the gas valve.
*/
#define THERM_BOOT_LOCKOUT_MS (60u * 1000u)
#define THERM_SETPOINT_MIN_F 50.0f
#define THERM_SETPOINT_MAX_F 85.0f
#define THERM_SETPOINT_DEFAULT_F 68.0f
/* --- Types -------------------------------------------------------------- */
typedef enum {
THERM_MODE_OFF = 0,
THERM_MODE_HEAT,
} therm_mode_t;
typedef enum {
THERM_FAN_AUTO = 0, /* furnace runs its own blower on a heat call */
THERM_FAN_ON, /* continuous circulation */
} therm_fan_t;
typedef enum {
THERM_STATE_OFF = 0, /* mode is off */
THERM_STATE_IDLE, /* warm enough, nothing to do */
THERM_STATE_HEATING, /* burner called */
THERM_STATE_LOCKOUT, /* wants heat, held back by a protection timer */
THERM_STATE_NO_SENSOR, /* reading too stale to act on; heat forced off */
} therm_state_t;
typedef struct {
therm_mode_t mode;
therm_fan_t fan;
float setpoint_f;
therm_state_t state;
bool heat_relay;
bool fan_relay;
/* Seconds until the protection timer clears, for the display. 0 unless
* state is THERM_STATE_LOCKOUT. */
uint32_t lockout_remaining_s;
uint8_t cycles_last_hour;
} therm_status_t;
/* --- API ---------------------------------------------------------------- */
/* now_ms seeds the boot lockout, so pass the same clock used for tick(). */
void thermostat_init(uint32_t now_ms);
void thermostat_set_mode(therm_mode_t mode);
void thermostat_set_fan(therm_fan_t fan);
therm_mode_t thermostat_get_mode(void);
therm_fan_t thermostat_get_fan(void);
/* Clamped to THERM_SETPOINT_MIN_F..MAX_F. */
void thermostat_adjust_setpoint(float delta_f);
void thermostat_set_setpoint(float value_f);
float thermostat_get_setpoint(void);
/*
* Run the control loop. Call at roughly 1Hz -- more often is harmless, less
* often makes the lockout countdown coarse.
*
* `temp_valid` false means the reading is unavailable right now; the last
* good value keeps being used until THERM_SENSOR_STALE_MS has passed.
* `now_ms` must be monotonic; wrap is handled.
*/
void thermostat_tick(float indoor_f, bool temp_valid, uint32_t now_ms);
void thermostat_get_status(therm_status_t *out);
/* Human-readable state, for the status line ("Heating", "Locked out 4m"). */
void thermostat_state_text(const therm_status_t *st, char *buf, size_t len);
#endif
+40 -7
View File
@@ -108,14 +108,24 @@ static void rgb_flush_cb(lv_display_t *display, const lv_area_t *area, uint8_t *
lv_display_flush_ready(display);
}
esp_err_t display_init(void)
/*
* Phase 1: allocate everything, scan nothing.
*
* Called BEFORE wifi_init_sta(), on a heap nothing else has touched. The
* bounce buffers need two contiguous 25.6KB DMA-capable internal blocks, and
* WiFi's dynamic RX buffers churn that same pool hard enough to fragment it:
* observed on hardware (2026-08-04) as "no mem for bounce buffer" with 84KB
* of total free heap -- plenty of bytes, no block big enough, and retries
* useless because the fragmentation is not transient.
*
* Allocating first is deterministic where retrying was a dice roll. The
* panel is NOT scanning after this returns -- scan-out (and with it the
* PSRAM-reading refill ISR that flash writes would fault) starts only in
* display_start().
*/
esp_err_t display_alloc(void)
{
ESP_LOGI(TAG, "Initializing 800x480 RGB panel (CrowPanel Advance)");
/* lv_display_create() below allocates from LVGL's heap. Without lv_init()
* that heap is a null pool and the call faults with LoadProhibited. */
ESP_RETURN_ON_FALSE(lv_is_initialized(), ESP_ERR_INVALID_STATE, TAG,
"lv_init() must run before display_init()");
ESP_LOGI(TAG, "Allocating 800x480 RGB panel (CrowPanel Advance)");
const esp_lcd_rgb_panel_config_t panel_config = {
.clk_src = LCD_CLK_SRC_DEFAULT,
@@ -175,6 +185,29 @@ esp_err_t display_init(void)
ESP_RETURN_ON_ERROR(esp_lcd_new_rgb_panel(&panel_config, &panel), TAG,
"esp_lcd_new_rgb_panel failed");
ESP_LOGI(TAG, "Panel buffers allocated; free internal heap: %u KB",
(unsigned)(heap_caps_get_free_size(MALLOC_CAP_INTERNAL) / 1024));
return ESP_OK;
}
/*
* Phase 2: start scan-out and hand the framebuffers to LVGL.
*
* From esp_lcd_panel_init() onward the bounce-refill ISR is reading PSRAM
* continuously, so every flash write (NVS, WiFi PHY calibration) must already
* be done -- see the init-order comment in main.c.
*/
esp_err_t display_start(void)
{
ESP_RETURN_ON_FALSE(panel != NULL, ESP_ERR_INVALID_STATE, TAG,
"display_alloc() must run before display_start()");
/* lv_display_create() below allocates from LVGL's heap. Without lv_init()
* that heap is a null pool and the call faults with LoadProhibited. */
ESP_RETURN_ON_FALSE(lv_is_initialized(), ESP_ERR_INVALID_STATE, TAG,
"lv_init() must run before display_start()");
ESP_RETURN_ON_ERROR(esp_lcd_panel_reset(panel), TAG, "panel reset failed");
ESP_RETURN_ON_ERROR(esp_lcd_panel_init(panel), TAG, "panel init failed");
+16 -3
View File
@@ -9,9 +9,22 @@
#define DISPLAY_H_RES 800
#define DISPLAY_V_RES 480
/* Requires lv_init() to have already run -- call lvgl_port_init() first.
* Backlight is NOT owned here on the Advance; see drivers/backlight. */
esp_err_t display_init(void);
/*
* Two-phase bring-up, and the order is load-bearing:
*
* display_alloc() -- BEFORE wifi_init_sta(). Grabs the framebuffers and
* the two contiguous bounce buffers while the internal
* heap is still unfragmented; WiFi's RX buffer churn
* destroys the needed contiguity within seconds.
* The panel is not scanning after this.
* display_start() -- AFTER WiFi (all flash writes done) and after
* lvgl_port_init(). Starts scan-out and registers the
* display with LVGL.
*
* Backlight is NOT owned here on the Advance; see drivers/backlight.
*/
esp_err_t display_alloc(void);
esp_err_t display_start(void);
lv_display_t *display_get_default(void);
+135
View File
@@ -0,0 +1,135 @@
/*
* Furnace call relays. See relay.h for the pin assignment and why heat is on
* IO20 rather than IO19.
*/
#include "driver/gpio.h"
#include "esp_check.h"
#include "esp_log.h"
#include "relay.h"
static const char *TAG = "relay";
#define RELAY_HEAT_GPIO 20 /* white wire on the UART1-OUT plug */
#define RELAY_FAN_GPIO 19 /* yellow wire */
/*
* Standard opto-isolated relay boards are active low: pulling IN to 0V lights
* the optocoupler and energizes the coil. Set to 0 if you fit an active-high
* module -- and re-check the boot state below, because the safe direction
* flips with it.
*/
#define RELAY_ACTIVE_LOW 1
#if RELAY_ACTIVE_LOW
#define RELAY_LEVEL_ON 0
#define RELAY_LEVEL_OFF 1
#else
#define RELAY_LEVEL_ON 1
#define RELAY_LEVEL_OFF 0
#endif
static const int relay_gpio[RELAY_COUNT] = {
[RELAY_HEAT] = RELAY_HEAT_GPIO,
[RELAY_FAN] = RELAY_FAN_GPIO,
};
static bool relay_state[RELAY_COUNT];
esp_err_t relay_init(void)
{
for (int i = 0; i < RELAY_COUNT; i++) {
const int pin = relay_gpio[i];
/*
* IO19/IO20 are the S3's native USB D-/D+ and are also wired to the
* onboard I2S mic. gpio_reset_pin() detaches whatever peripheral
* currently owns the pad; the DIP switches must be in wireless-module
* mode (S1=0 S0=1) so the mic is not driving them, and
* CONFIG_ESP_PHY_ENABLE_USB / CONFIG_USJ_ENABLE_USB_SERIAL_JTAG are
* off so the USB PHY is not either.
*/
gpio_reset_pin(pin);
/*
* Drive the de-energized level BEFORE the pad becomes an output.
* Configuring the direction first would briefly present whatever was
* in the output register -- and on the heat channel that is a burst
* of gas.
*/
ESP_RETURN_ON_ERROR(gpio_set_level(pin, RELAY_LEVEL_OFF), TAG,
"gpio_set_level(%d) failed", pin);
const gpio_config_t cfg = {
.pin_bit_mask = 1ULL << pin,
/* INPUT_OUTPUT so relay_get() can read the actual pad rather than
* the output latch -- a readback that disagrees means something
* else is driving the line. */
.mode = GPIO_MODE_INPUT_OUTPUT,
#if RELAY_ACTIVE_LOW
/* Holds the de-energized level through reset and reflash, before
* any of our code runs. */
.pull_up_en = GPIO_PULLUP_ENABLE,
.pull_down_en = GPIO_PULLDOWN_DISABLE,
#else
.pull_up_en = GPIO_PULLUP_DISABLE,
.pull_down_en = GPIO_PULLDOWN_ENABLE,
#endif
.intr_type = GPIO_INTR_DISABLE,
};
ESP_RETURN_ON_ERROR(gpio_config(&cfg), TAG, "gpio_config(%d) failed", pin);
ESP_RETURN_ON_ERROR(gpio_set_level(pin, RELAY_LEVEL_OFF), TAG,
"gpio_set_level(%d) failed", pin);
relay_state[i] = false;
}
ESP_LOGI(TAG, "Relays idle. heat=IO%d fan=IO%d, active %s (idle reads %s)",
RELAY_HEAT_GPIO, RELAY_FAN_GPIO,
RELAY_ACTIVE_LOW ? "low" : "high",
RELAY_ACTIVE_LOW ? "3.3V" : "0V");
return ESP_OK;
}
void relay_set(relay_id_t id, bool energized)
{
if (id < 0 || id >= RELAY_COUNT) {
return;
}
if (relay_state[id] == energized) {
return;
}
relay_state[id] = energized;
gpio_set_level(relay_gpio[id], energized ? RELAY_LEVEL_ON : RELAY_LEVEL_OFF);
/*
* Logged on every change, deliberately. With no relay board fitted yet
* this log plus a multimeter on the plug is the whole test rig, and once
* one is fitted it is the record of what the firmware asked the furnace
* to do.
*/
ESP_LOGI(TAG, "%s -> %s (IO%d now %s)",
relay_name(id), energized ? "ON" : "OFF", relay_gpio[id],
(energized ? RELAY_LEVEL_ON : RELAY_LEVEL_OFF) ? "3.3V" : "0V");
}
bool relay_get(relay_id_t id)
{
if (id < 0 || id >= RELAY_COUNT) {
return false;
}
return relay_state[id];
}
const char *relay_name(relay_id_t id)
{
switch (id) {
case RELAY_HEAT: return "heat(W)";
case RELAY_FAN: return "fan(G)";
default: return "?";
}
}
+40
View File
@@ -0,0 +1,40 @@
/*
* Furnace call relays on the UART1-OUT plug.
*
* Pin assignment was measured on this board -- Elecrow documents the GPIO
* numbers but not the connector pin order. See README, "UART1-OUT plug".
*
* heat (W) IO20, white wire
* fan (G) IO19, yellow wire
*
* Heat is on IO20 deliberately. IO20 is the ESP32-S3's USB D+, the line with
* the hardware pull-up; with an active-low relay module a pull-up means
* de-energized, so if the USB PHY is ever switched back on this pin fails
* toward "no heat" rather than "heat stuck on". IO19 (D-) has no such pull-up.
*
* The simulator provides its own implementation of this interface, which is
* why the API mentions no GPIOs.
*/
#ifndef RELAY_H
#define RELAY_H
#include <stdbool.h>
#include "esp_err.h"
typedef enum {
RELAY_HEAT = 0, /* W -- gas valve call */
RELAY_FAN, /* G -- blower, manual circulation only */
RELAY_COUNT,
} relay_id_t;
/* Leaves both relays de-energized. Safe to call before anything else; it does
* not depend on the display or the network. */
esp_err_t relay_init(void);
void relay_set(relay_id_t id, bool energized);
bool relay_get(relay_id_t id);
const char *relay_name(relay_id_t id);
#endif
+377 -58
View File
@@ -4,14 +4,20 @@
#include "esp_log.h"
#include "freertos/FreeRTOS.h"
#include "freertos/queue.h"
#include "freertos/semphr.h"
#include "freertos/task.h"
#include "ambient_weather.h"
#include "app_config.h"
#include "backlight.h"
#include "display_rgb.h"
#include "ha_client.h"
#include "ha_data.h"
#include "i2c_bus.h"
#include "lvgl_port.h"
#include "relay.h"
#include "thermostat.h"
#include "touch_gt911.h"
#include "ui_manager.h"
#include "wifi_manager.h"
@@ -19,100 +25,378 @@
static const char *TAG = "main";
/*
* 6144 bytes. Measured headroom after a fetch including the TLS handshake --
* the deepest this task gets -- is ~2400 bytes, so roughly 3.7KB is in use.
* mbedTLS keeps its heap in PSRAM (CONFIG_MBEDTLS_EXTERNAL_MEM_ALLOC), which
* is why the handshake fits at all. Re-check the high-water-mark log if more
* work lands in this task.
* 6144 was measured with the old cloud client, whose TLS handshake was the
* deepest this task ever got. The HA path is plain HTTP and shallower, but
* ha_data_t is ~600 bytes and the JSON bodies are on the stack, so the
* headroom is kept. Re-check the high-water-mark log if more work lands here.
*/
#define WEATHER_TASK_STACK 6144
#define WEATHER_TASK_PRIO 3
#define WEATHER_FIRST_DELAY_MS 3000
#define HA_TASK_STACK 6144
#define HA_TASK_PRIO 3
#define HA_FIRST_DELAY_MS 3000
/* The forecast changes hourly at most and costs a service call, so it is
* polled far less often than the sensors. */
#define FORECAST_EVERY_N_POLLS 30
/* Consecutive failed polls before the displayed reading is treated as stale. */
#define WEATHER_STALE_AFTER_POLLS 2
#define STALE_AFTER_POLLS 2
static void weather_update_task(void *arg)
/*
* Trend arrow thresholds, per hour. Below these a reading renders as flat:
* a thermostat's indoor temp wobbles a couple of tenths continuously, and an
* arrow that flickers on noise is worse than none.
*/
#define TREND_TEMP_PER_HOUR_F 0.5f
#define TREND_PRESS_PER_HOUR_INHG 0.02f
/* Depth 8: a burst of taps while the network is slow should queue rather than
* be dropped, but a user cannot meaningfully get more than a few ahead. */
#define CMD_QUEUE_DEPTH 8
/* The control loop runs at 1Hz -- fast enough for a live lockout countdown,
* and far faster than a house changes temperature. */
#define THERM_TASK_STACK 4096
#define THERM_TASK_PRIO 4
#define THERM_TICK_MS 1000
typedef struct {
int index;
bool on;
int16_t brightness;
} light_cmd_t;
static QueueHandle_t cmd_queue;
/* The single copy of what is on screen. Only the HA task touches it. */
static ha_data_t ha_data;
/*
* Indoor temperature handed from the HA task to the control task.
*
* Guarded rather than assumed-atomic: a float store is a single word on this
* core, but the value and its timestamp must agree with each other, and the
* staleness failsafe depends on that pairing being consistent.
*/
static SemaphoreHandle_t temp_mutex;
static float indoor_temp_f;
static bool indoor_temp_valid;
static uint32_t indoor_temp_ms;
static uint32_t uptime_ms(void)
{
return (uint32_t)(xTaskGetTickCount() * portTICK_PERIOD_MS);
}
static void publish_indoor_temp(const ha_value_t *v)
{
if (temp_mutex == NULL || xSemaphoreTake(temp_mutex, pdMS_TO_TICKS(100)) != pdTRUE) {
return;
}
if (v->valid) {
indoor_temp_f = v->value;
indoor_temp_valid = true;
indoor_temp_ms = uptime_ms();
}
xSemaphoreGive(temp_mutex);
}
static bool take_indoor_temp(float *out_f)
{
if (temp_mutex == NULL || xSemaphoreTake(temp_mutex, pdMS_TO_TICKS(100)) != pdTRUE) {
return false;
}
const bool valid = indoor_temp_valid;
*out_f = indoor_temp_f;
xSemaphoreGive(temp_mutex);
return valid;
}
/*
* The control loop. Deliberately independent of Home Assistant: it needs only
* a temperature and a clock, so an HA outage cannot stop the house being
* heated -- the reading simply ages, and thermostat_tick() applies its own
* staleness failsafe once it is too old to trust.
*/
static void thermostat_task(void *arg)
{
therm_status_t status;
while (1) {
float temp = 0.0f;
const bool valid = take_indoor_temp(&temp);
thermostat_tick(temp, valid, uptime_ms());
thermostat_get_status(&status);
relay_set(RELAY_HEAT, status.heat_relay);
relay_set(RELAY_FAN, status.fan_relay);
ui_update_thermostat(&status);
vTaskDelay(pdMS_TO_TICKS(THERM_TICK_MS));
}
}
/*
* Runs on the LVGL task with the lock held. No network here, so the change
* applies immediately and the display is refreshed straight back -- the
* button feels instant because it is.
*/
static void therm_cmd_from_ui(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;
}
/*
* Apply the fan immediately rather than waiting up to a second for the
* control task -- a button that lags its own relay feels broken. Heat is
* left to the control task, which is where the protection timers live;
* nothing here may light a burner.
*/
therm_status_t status;
thermostat_get_status(&status);
relay_set(RELAY_FAN, status.fan_relay);
ui_update_thermostat(&status);
}
/*
* Runs on the LVGL task with the LVGL lock held, so it must not block.
* Queueing hands the HTTP request to the HA task; a request from here would
* freeze rendering for the duration and deadlock anything waiting on the lock.
*/
static void light_cmd_from_ui(int index, bool on, int16_t brightness)
{
const light_cmd_t cmd = { .index = index, .on = on, .brightness = brightness };
if (cmd_queue == NULL || xQueueSend(cmd_queue, &cmd, 0) != pdTRUE) {
ESP_LOGW(TAG, "Light command queue full, dropping command for index %d", index);
}
}
/*
* Fall back to the Ambient Weather cloud API when HA is unreachable.
*
* Only fills the outdoor readings -- indoor, sun, lights and forecast have no
* cloud equivalent and stay as they were. That is deliberate: a partial screen
* of live outdoor data beats a blank one, and the status line says the panel
* is running on the fallback.
*/
static bool fill_from_cloud(ha_data_t *data)
{
weather_data_t w;
if (weather_update(&w) != ESP_OK) {
return false;
}
data->outdoor_temp_f = (ha_value_t){ w.temp_f, w.has_temp };
data->outdoor_humidity = (ha_value_t){ w.humidity, w.has_humidity };
data->wind_mph = (ha_value_t){ w.wind_mph, w.has_wind };
return w.has_temp;
}
static void ha_task(void *arg)
{
weather_data_t weather;
char line[96];
/*
* Age is tracked in ticks, not from weather_data_t.last_update. That field
* comes from time(NULL), and with no SNTP client the system clock starts
* at the epoch -- an age derived from it would be nonsense. Tick
* Age is tracked in ticks, not wall time: with no SNTP the system clock
* starts at the epoch. (The clock shown on screen comes from HA, which is
* why the panel can display the time without knowing it.) Tick
* subtraction is also wrap-safe.
*/
TickType_t last_success = 0;
int consecutive_failures = 0;
bool have_reading = false;
bool stack_logged = false;
uint32_t poll_count = 0;
vTaskDelay(pdMS_TO_TICKS(WEATHER_FIRST_DELAY_MS));
vTaskDelay(pdMS_TO_TICKS(HA_FIRST_DELAY_MS));
/*
* Zero on the first pass so the dashboard populates immediately after
* boot. The wait below is what paces the loop, and it sits at the top --
* so a full-interval timeout here would leave the panel showing dashes
* for HA_UPDATE_INTERVAL_MS before the first poll ever ran. Measured on
* hardware as a 63-second blank screen.
*/
TickType_t wait_ticks = 0;
while (1) {
/*
* Wait for a light command, or time out and poll. A command wakes the
* task immediately so a tap feels instant instead of waiting out the
* poll interval.
*/
light_cmd_t cmd;
const bool have_cmd = (cmd_queue != NULL) &&
(xQueueReceive(cmd_queue, &cmd, wait_ticks) == pdTRUE);
wait_ticks = pdMS_TO_TICKS(HA_UPDATE_INTERVAL_MS);
if (!wifi_is_connected()) {
ui_set_status("WiFi: reconnecting...");
vTaskDelay(pdMS_TO_TICKS(WEATHER_UPDATE_INTERVAL_MS));
continue;
}
/*
* Freeze rendering for the duration of the fetch. The TLS session,
* the LVGL redraw and the flush into the PSRAM framebuffer all
* compete with the LCD's own DMA out of that same PSRAM; letting them
* overlap is what produces visible artefacts. The panel keeps
* scanning the last completed frame, so the screen simply holds still.
* Freeze rendering for the duration of the network work. The redraw
* and the flush into the PSRAM framebuffer compete with the LCD's own
* DMA out of that same PSRAM; letting them overlap is what produces
* visible artefacts. The panel keeps scanning the last completed
* frame, so the screen simply holds still.
*/
lvgl_port_pause();
const esp_err_t err = weather_update(&weather);
if (have_cmd) {
const esp_err_t cmd_err = ha_light_command(cmd.index, cmd.on, cmd.brightness);
if (cmd_err != ESP_OK) {
ESP_LOGW(TAG, "Light command failed: %s", esp_err_to_name(cmd_err));
}
}
esp_err_t err = ha_fetch_state(&ha_data);
/* The forecast is a second request, so only fetch it when the first
* one proved HA is reachable. */
if (err == ESP_OK &&
(ha_data.forecast_count == 0 || (poll_count % FORECAST_EVERY_N_POLLS) == 0)) {
if (ha_fetch_forecast(&ha_data) != ESP_OK) {
ESP_LOGW(TAG, "Forecast fetch failed; keeping previous days");
}
}
bool on_fallback = false;
if (err != ESP_OK) {
on_fallback = fill_from_cloud(&ha_data);
}
lvgl_port_resume();
if (err == ESP_OK) {
/*
* Repaint everything after every fetch window.
*
* The known left-edge artefacts (see README) happen when WiFi
* interrupt load starves the bounce-refill ISR, and once bad pixels
* are on screen LVGL has no idea they are wrong -- it never
* reinvalidates them, so they sit there indefinitely. A full
* invalidate after each poll bounds their lifetime at one poll
* interval. Mitigation, not cure: it cannot prevent the glitch,
* only clean up after it.
*/
if (lvgl_port_lock(100)) {
lv_obj_invalidate(lv_screen_active());
lvgl_port_unlock();
}
poll_count++;
if (err == ESP_OK || on_fallback) {
last_success = xTaskGetTickCount();
consecutive_failures = 0;
have_reading = true;
weather_get_string(&weather, line, sizeof(line));
ESP_LOGI(TAG, "Weather: %s", line);
/*
* Trend history is local: the device sees every reading anyway,
* so an /api/history query would be hundreds of JSON points to
* answer a question one ring buffer answers for free. History is
* lost on reboot; arrows reappear ~5 minutes later.
*/
const uint32_t now_ms = xTaskGetTickCount() * portTICK_PERIOD_MS;
if (ha_data.indoor_temp_f.valid) {
ha_trend_feed(HA_TREND_CH_INDOOR, ha_data.indoor_temp_f.value, now_ms);
}
if (ha_data.outdoor_temp_f.valid) {
ha_trend_feed(HA_TREND_CH_OUTDOOR, ha_data.outdoor_temp_f.value, now_ms);
}
if (ha_data.pressure_inhg.valid) {
ha_trend_feed(HA_TREND_CH_PRESSURE, ha_data.pressure_inhg.value, now_ms);
}
ha_data.trend_indoor =
ha_trend_compute(HA_TREND_CH_INDOOR, TREND_TEMP_PER_HOUR_F, now_ms);
ha_data.trend_outdoor =
ha_trend_compute(HA_TREND_CH_OUTDOOR, TREND_TEMP_PER_HOUR_F, now_ms);
ha_data.trend_pressure =
ha_trend_compute(HA_TREND_CH_PRESSURE, TREND_PRESS_PER_HOUR_INHG, now_ms);
char detail[64];
snprintf(detail, sizeof(detail), "%.0f%% humidity",
weather.has_humidity ? weather.humidity : 0.0f);
/* Hand the reading to the control loop, which runs on its own
* task and does not care where it came from. */
publish_indoor_temp(&ha_data.indoor_temp_f);
/* lroundf, not +0.5f-and-truncate: the latter rounds toward zero
* below 0°F, turning -10.6 into -10. */
ui_update_weather((int16_t)lroundf(weather.temp_f),
weather.has_humidity ? detail : "Outdoor");
ui_set_weather_stale(false);
ui_update(&ha_data);
ui_set_stale(false);
if (on_fallback) {
ui_set_status("Home Assistant unreachable - cloud weather only");
} else {
wifi_get_status(line, sizeof(line));
ui_set_status(line);
}
/* One-shot, after a fetch that included a TLS handshake -- that is
* the deepest this task's stack ever gets. */
if (!stack_logged) {
stack_logged = true;
ESP_LOGI(TAG, "weather task stack headroom: %u bytes",
ESP_LOGI(TAG, "HA task stack headroom: %u bytes",
(unsigned)(uxTaskGetStackHighWaterMark(NULL) * sizeof(StackType_t)));
}
} else {
consecutive_failures++;
ESP_LOGW(TAG, "Weather update failed (%d consecutive)", consecutive_failures);
ESP_LOGW(TAG, "Update failed (%d consecutive)", consecutive_failures);
if (have_reading && consecutive_failures >= WEATHER_STALE_AFTER_POLLS) {
if (have_reading && consecutive_failures >= STALE_AFTER_POLLS) {
const uint32_t age_min =
((xTaskGetTickCount() - last_success) * portTICK_PERIOD_MS) / 60000U;
ui_set_weather_stale(true);
ui_set_stale(true);
snprintf(line, sizeof(line), "No update for %lum", (unsigned long)age_min);
ui_set_status(line);
} else {
ui_set_status("Weather update failed");
ui_set_status("Update failed");
}
}
}
}
vTaskDelay(pdMS_TO_TICKS(WEATHER_UPDATE_INTERVAL_MS));
/*
* The furnace must run whether or not anything else does.
*
* Every early return further down app_main() is about WiFi or Home Assistant
* being unusable -- no SSID, no token, no cloud fallback. None of those are
* reasons to leave a house unheated, so the control loop starts before any of
* them can bail out. It needs only a temperature and a clock; where the
* temperature comes from is somebody else's problem.
*/
static void start_thermostat(void)
{
temp_mutex = xSemaphoreCreateMutex();
if (temp_mutex == NULL) {
ESP_LOGE(TAG, "Could not create the temperature mutex");
return;
}
ui_set_therm_cb(therm_cmd_from_ui);
xTaskCreate(thermostat_task, "therm", THERM_TASK_STACK, NULL,
THERM_TASK_PRIO, NULL);
ESP_LOGI(TAG, "Thermostat running: setpoint %.0f F, %d min on / %d min off, "
"max %d cycles/hr",
thermostat_get_setpoint(),
(int)(THERM_MIN_ON_MS / 60000), (int)(THERM_MIN_OFF_MS / 60000),
THERM_MAX_CYCLES_PER_HOUR);
}
void app_main(void)
@@ -120,17 +404,35 @@ void app_main(void)
ESP_LOGI(TAG, "Thermostat HMI -- CrowPanel 7.0\" ESP32-S3 (800x480)");
/*
* WiFi comes up FIRST, before the RGB panel exists.
* Panel BUFFERS first, WiFi second, panel SCAN-OUT third. Both orderings
* are load-bearing:
*
* The panel's bounce-buffer ISR reads the framebuffer out of PSRAM, and
* PSRAM shares the cache with flash on the ESP32-S3. Bringing up NVS and
* the WiFi PHY writes calibration data to flash, which disables the cache
* and faults that ISR. Doing it here means every flash write is finished
* before the panel starts scanning.
* - display_alloc() before WiFi: the bounce buffers need two contiguous
* 25.6KB internal DMA blocks, and WiFi's RX buffer churn fragments
* the internal heap within seconds of association. Allocating from
* the virgin heap is deterministic; observed otherwise as a
* boot-loop with 84KB free and no block big enough.
*
* - display_start() after WiFi: from the moment scan-out begins, the
* bounce-refill ISR reads PSRAM continuously, and PSRAM shares the
* cache with flash on the S3. NVS and WiFi PHY calibration write
* flash; doing all of that before the first scan means the ISR can
* never hit "Cache disabled but cached memory region accessed".
*/
ESP_ERROR_CHECK(display_alloc());
/*
* Relays before anything that can fail or take time. relay_init() only
* touches two GPIOs, and until it runs those pads are whatever reset left
* them -- so the sooner they are pinned to the de-energized level, the
* shorter the window in which a relay board could see an undefined input.
*/
ESP_ERROR_CHECK(relay_init());
thermostat_init(uptime_ms());
const esp_err_t wifi_err = wifi_init_sta();
if (wifi_err != ESP_OK) {
ESP_LOGE(TAG, "WiFi not up yet -- weather starts when it connects");
ESP_LOGE(TAG, "WiFi not up yet -- polling starts when it connects");
}
/* LVGL core must exist before the display and touch drivers register
@@ -147,10 +449,12 @@ void app_main(void)
}
i2c_bus_scan(TAG);
ESP_ERROR_CHECK(display_init());
/* Buffers were allocated before WiFi; this only starts scan-out. */
ESP_ERROR_CHECK(display_start());
if (touch_init() != ESP_OK) {
/* A dead touch panel should not stop the display from working. */
/* A dead touch panel should not stop the display from working -- but
* it does mean the light tiles are unusable. */
ESP_LOGW(TAG, "Touch unavailable -- continuing without input");
}
@@ -161,29 +465,44 @@ void app_main(void)
vTaskDelay(pdMS_TO_TICKS(100));
ESP_ERROR_CHECK(backlight_set(255));
/* Before the WiFi/HA branches below, any of which may return. */
start_thermostat();
if (wifi_err == ESP_OK) {
char status[96];
wifi_get_status(status, sizeof(status));
ui_set_status(status);
} else if (wifi_err == ESP_FAIL || wifi_err == ESP_ERR_TIMEOUT) {
/* The event handler keeps retrying in the background; the weather
* task polls wifi_is_connected(), so start it anyway rather than
* permanently disabling weather because boot raced a slow AP. */
/* The event handler keeps retrying in the background; the HA task
* polls wifi_is_connected(), so start it anyway rather than
* permanently disabling updates because boot raced a slow AP. */
ui_set_status("WiFi: connecting...");
} else {
/* No SSID configured (or setup failed outright) -- nothing will ever
* connect, so there is no point starting the weather task. */
* connect, so there is no point starting the task. */
ui_set_status("WiFi: not configured");
return;
}
if (weather_init() != ESP_OK) {
ui_set_status("Weather: no API credentials");
if (ha_client_init() != ESP_OK) {
ui_set_status("Home Assistant: not configured");
return;
}
xTaskCreate(weather_update_task, "weather", WEATHER_TASK_STACK, NULL,
WEATHER_TASK_PRIO, NULL);
/* Optional: only used when HA cannot be reached. A missing cloud API key
* is not fatal, it just means there is no fallback. */
if (weather_init() != ESP_OK) {
ESP_LOGW(TAG, "Ambient Weather fallback unavailable (no credentials)");
}
cmd_queue = xQueueCreate(CMD_QUEUE_DEPTH, sizeof(light_cmd_t));
if (cmd_queue == NULL) {
ESP_LOGE(TAG, "Could not create the light command queue");
return;
}
ui_set_light_cb(light_cmd_from_ui);
xTaskCreate(ha_task, "ha", HA_TASK_STACK, NULL, HA_TASK_PRIO, NULL);
ESP_LOGI(TAG, "System ready");
}
+399
View File
@@ -0,0 +1,399 @@
/*
* GENERATED by tools/make_icon_font.py -- do not edit by hand.
* Subset of Material Design Icons (Apache-2.0), rasterised at runtime
* by LVGL tiny_ttf. Regeneration instructions are in the script.
*/
#include <stddef.h>
const size_t icon_font_data_size = 6192;
const unsigned char icon_font_data[] = {
0x00, 0x01, 0x00, 0x00, 0x00, 0x0b, 0x00, 0x80, 0x00, 0x03, 0x00, 0x30, 0x47, 0x53, 0x55, 0x42,
0xb8, 0xfa, 0xb8, 0xf4, 0x00, 0x00, 0x18, 0x04, 0x00, 0x00, 0x00, 0x2a, 0x4f, 0x53, 0x2f, 0x32,
0x4f, 0xb3, 0x5b, 0xd3, 0x00, 0x00, 0x01, 0x38, 0x00, 0x00, 0x00, 0x56, 0x63, 0x6d, 0x61, 0x70,
0x02, 0x0e, 0x52, 0xf7, 0x00, 0x00, 0x01, 0xd0, 0x00, 0x00, 0x00, 0xf0, 0x67, 0x6c, 0x79, 0x66,
0x7b, 0x20, 0xfe, 0x3a, 0x00, 0x00, 0x03, 0x00, 0x00, 0x00, 0x14, 0x20, 0x68, 0x65, 0x61, 0x64,
0x59, 0x6d, 0x7c, 0x00, 0x00, 0x00, 0x00, 0xbc, 0x00, 0x00, 0x00, 0x36, 0x68, 0x68, 0x65, 0x61,
0x03, 0xc6, 0x01, 0xbf, 0x00, 0x00, 0x00, 0xf4, 0x00, 0x00, 0x00, 0x24, 0x68, 0x6d, 0x74, 0x78,
0x01, 0xff, 0x00, 0x00, 0x00, 0x00, 0x01, 0x90, 0x00, 0x00, 0x00, 0x40, 0x6c, 0x6f, 0x63, 0x61,
0x4a, 0x8f, 0x44, 0xe6, 0x00, 0x00, 0x02, 0xc0, 0x00, 0x00, 0x00, 0x3e, 0x6d, 0x61, 0x78, 0x70,
0x01, 0x55, 0x01, 0x85, 0x00, 0x00, 0x01, 0x18, 0x00, 0x00, 0x00, 0x20, 0x6e, 0x61, 0x6d, 0x65,
0x0b, 0xcc, 0x24, 0x14, 0x00, 0x00, 0x17, 0x20, 0x00, 0x00, 0x00, 0xc2, 0x70, 0x6f, 0x73, 0x74,
0x00, 0x08, 0x00, 0x00, 0x00, 0x00, 0x17, 0xe4, 0x00, 0x00, 0x00, 0x20, 0x00, 0x01, 0x00, 0x00,
0x00, 0x01, 0x00, 0x00, 0xe4, 0xb3, 0x4f, 0xdb, 0x5f, 0x0f, 0x3c, 0xf5, 0x00, 0x0b, 0x02, 0x00,
0x00, 0x00, 0x00, 0x00, 0x7c, 0x25, 0x9d, 0xc0, 0x00, 0x00, 0x00, 0x00, 0x7c, 0x25, 0x9d, 0xc0,
0xff, 0xff, 0xff, 0xbe, 0x02, 0x04, 0x01, 0xc5, 0x00, 0x00, 0x00, 0x08, 0x00, 0x02, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x01, 0xc0, 0xff, 0xc0, 0x00, 0x00, 0x02, 0x00,
0xff, 0xff, 0xff, 0xfc, 0x02, 0x04, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0x00, 0x01, 0x00, 0x00, 0x00, 0x1e, 0x01, 0x79,
0x00, 0x2d, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x0a, 0x00, 0x0a, 0x00, 0x00,
0x00, 0xff, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x02, 0x00, 0x01, 0x90, 0x00, 0x05,
0x00, 0x00, 0x01, 0x44, 0x01, 0x66, 0x00, 0x00, 0x00, 0x47, 0x01, 0x44, 0x01, 0x66, 0x00, 0x00,
0x00, 0xf5, 0x00, 0x19, 0x00, 0x84, 0x00, 0x00, 0x02, 0x00, 0x05, 0x03, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x50, 0x66, 0x45, 0x64, 0x00, 0x40, 0xff, 0xff, 0xff, 0xff, 0x01, 0xc0, 0xff, 0xc0,
0x00, 0x2e, 0x01, 0xc5, 0x00, 0x42, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0xff, 0x00, 0x00,
0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x04, 0x00, 0x00, 0x00, 0x14, 0x00, 0x03, 0x00, 0x0a,
0x00, 0x00, 0x00, 0x14, 0x00, 0x0c, 0x00, 0x00, 0x00, 0x00, 0x00, 0xdc, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x11, 0x00, 0x0f, 0x02, 0x9a, 0x00, 0x0f, 0x02, 0x9a, 0x00, 0x00, 0x00, 0x01,
0x00, 0x0f, 0x05, 0x02, 0x00, 0x0f, 0x05, 0x02, 0x00, 0x00, 0x00, 0x02, 0x00, 0x0f, 0x05, 0x0f,
0x00, 0x0f, 0x05, 0x0f, 0x00, 0x00, 0x00, 0x03, 0x00, 0x0f, 0x05, 0x33, 0x00, 0x0f, 0x05, 0x33,
0x00, 0x00, 0x00, 0x04, 0x00, 0x0f, 0x05, 0x35, 0x00, 0x0f, 0x05, 0x35, 0x00, 0x00, 0x00, 0x05,
0x00, 0x0f, 0x05, 0x90, 0x00, 0x0f, 0x05, 0x99, 0x00, 0x00, 0x00, 0x06, 0x00, 0x0f, 0x05, 0x9b,
0x00, 0x0f, 0x05, 0x9d, 0x00, 0x00, 0x00, 0x10, 0x00, 0x0f, 0x05, 0xd6, 0x00, 0x0f, 0x05, 0xd6,
0x00, 0x00, 0x00, 0x13, 0x00, 0x0f, 0x06, 0x7e, 0x00, 0x0f, 0x06, 0x7f, 0x00, 0x00, 0x00, 0x14,
0x00, 0x0f, 0x08, 0xdd, 0x00, 0x0f, 0x08, 0xdd, 0x00, 0x00, 0x00, 0x16, 0x00, 0x0f, 0x09, 0x5f,
0x00, 0x0f, 0x09, 0x5f, 0x00, 0x00, 0x00, 0x17, 0x00, 0x0f, 0x0a, 0x70, 0x00, 0x0f, 0x0a, 0x70,
0x00, 0x00, 0x00, 0x18, 0x00, 0x0f, 0x0e, 0x0a, 0x00, 0x0f, 0x0e, 0x0a, 0x00, 0x00, 0x00, 0x19,
0x00, 0x0f, 0x0f, 0x55, 0x00, 0x0f, 0x0f, 0x55, 0x00, 0x00, 0x00, 0x1a, 0x00, 0x0f, 0x12, 0xba,
0x00, 0x0f, 0x12, 0xba, 0x00, 0x00, 0x00, 0x1b, 0x00, 0x0f, 0x15, 0x6d, 0x00, 0x0f, 0x15, 0x6d,
0x00, 0x00, 0x00, 0x1c, 0x00, 0x0f, 0x17, 0x93, 0x00, 0x0f, 0x17, 0x93, 0x00, 0x00, 0x00, 0x1d,
0x00, 0x00, 0x00, 0x00, 0x00, 0x7d, 0x00, 0xa3, 0x00, 0xcb, 0x00, 0xe3, 0x00, 0xfb, 0x01, 0x39,
0x01, 0xae, 0x02, 0x1c, 0x02, 0x71, 0x02, 0xce, 0x03, 0x51, 0x03, 0xde, 0x04, 0x53, 0x04, 0xda,
0x05, 0x33, 0x05, 0x8e, 0x05, 0xe8, 0x06, 0x4a, 0x06, 0x84, 0x06, 0xec, 0x07, 0x86, 0x07, 0x9d,
0x07, 0xd6, 0x08, 0x02, 0x08, 0x47, 0x08, 0x7f, 0x09, 0x37, 0x09, 0xa3, 0x0a, 0x10, 0x00, 0x00,
0x00, 0x06, 0x00, 0x00, 0xff, 0xea, 0x01, 0xd6, 0x01, 0x96, 0x00, 0x10, 0x00, 0x23, 0x00, 0x38,
0x00, 0x41, 0x00, 0x4a, 0x00, 0x53, 0x00, 0x00, 0x01, 0x22, 0x0e, 0x02, 0x14, 0x1e, 0x02, 0x32,
0x3e, 0x02, 0x34, 0x2e, 0x02, 0x07, 0x32, 0x1e, 0x01, 0x15, 0x14, 0x06, 0x07, 0x2e, 0x01, 0x23,
0x22, 0x07, 0x2e, 0x01, 0x35, 0x34, 0x3e, 0x01, 0x17, 0x22, 0x06, 0x0f, 0x01, 0x0e, 0x01, 0x07,
0x06, 0x1e, 0x01, 0x36, 0x37, 0x36, 0x26, 0x27, 0x37, 0x36, 0x26, 0x27, 0x26, 0x07, 0x22, 0x06,
0x14, 0x16, 0x32, 0x36, 0x34, 0x26, 0x07, 0x22, 0x06, 0x14, 0x16, 0x32, 0x36, 0x34, 0x26, 0x33,
0x22, 0x06, 0x14, 0x16, 0x32, 0x36, 0x34, 0x26, 0x01, 0x00, 0x2a, 0x4f, 0x3c, 0x20, 0x20, 0x3c,
0x4f, 0x54, 0x4f, 0x3c, 0x20, 0x20, 0x3c, 0x4f, 0x2a, 0x2e, 0x4f, 0x2e, 0x1e, 0x1c, 0x16, 0x3b,
0x20, 0x40, 0x31, 0x1c, 0x1e, 0x2e, 0x4f, 0x59, 0x06, 0x0b, 0x03, 0x1d, 0x0c, 0x11, 0x05, 0x06,
0x0e, 0x21, 0x20, 0x07, 0x04, 0x04, 0x07, 0x1e, 0x04, 0x07, 0x09, 0x03, 0x5b, 0x09, 0x0c, 0x0c,
0x12, 0x0d, 0x0d, 0x49, 0x09, 0x0c, 0x0c, 0x12, 0x0d, 0x0d, 0xcd, 0x09, 0x0d, 0x0d, 0x12, 0x0c,
0x0c, 0x01, 0x95, 0x20, 0x3c, 0x4f, 0x54, 0x4f, 0x3c, 0x20, 0x20, 0x3c, 0x4f, 0x54, 0x4f, 0x3c,
0x20, 0x2a, 0x2e, 0x4f, 0x2e, 0x26, 0x42, 0x18, 0x15, 0x16, 0x2b, 0x18, 0x42, 0x26, 0x2e, 0x4f,
0x2e, 0x29, 0x07, 0x07, 0x49, 0x02, 0x0f, 0x0a, 0x11, 0x20, 0x0d, 0x0e, 0x10, 0x0b, 0x16, 0x09,
0x4b, 0x08, 0x10, 0x04, 0x01, 0x02, 0x0c, 0x12, 0x0d, 0x0d, 0x12, 0x0c, 0x40, 0x0c, 0x12, 0x0d,
0x0d, 0x12, 0x0c, 0x0c, 0x12, 0x0d, 0x0d, 0x12, 0x0c, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00,
0x01, 0xeb, 0x01, 0x81, 0x00, 0x03, 0x00, 0x17, 0x00, 0x00, 0x25, 0x21, 0x11, 0x21, 0x35, 0x21,
0x22, 0x06, 0x15, 0x11, 0x14, 0x16, 0x3b, 0x01, 0x15, 0x33, 0x35, 0x33, 0x32, 0x36, 0x35, 0x11,
0x34, 0x26, 0x01, 0xc0, 0xfe, 0x80, 0x01, 0x80, 0xfe, 0x80, 0x12, 0x19, 0x19, 0x12, 0x6b, 0xaa,
0x6b, 0x12, 0x19, 0x19, 0x55, 0x01, 0x00, 0x2b, 0x19, 0x12, 0xff, 0x00, 0x11, 0x19, 0x2b, 0x2b,
0x19, 0x11, 0x01, 0x00, 0x12, 0x19, 0x00, 0x02, 0x00, 0x00, 0xff, 0xea, 0x01, 0x71, 0x01, 0x96,
0x00, 0x0f, 0x00, 0x18, 0x00, 0x00, 0x25, 0x35, 0x34, 0x26, 0x22, 0x06, 0x1d, 0x01, 0x0e, 0x01,
0x1e, 0x01, 0x32, 0x3e, 0x01, 0x26, 0x27, 0x32, 0x16, 0x1d, 0x01, 0x23, 0x35, 0x34, 0x36, 0x01,
0x40, 0x25, 0x36, 0x25, 0x1c, 0x14, 0x16, 0x38, 0x44, 0x38, 0x16, 0x14, 0x5c, 0x09, 0x0c, 0x2a,
0x0c, 0xab, 0xaa, 0x1b, 0x25, 0x25, 0x1b, 0xaa, 0x15, 0x42, 0x41, 0x28, 0x28, 0x41, 0x42, 0xd5,
0x0d, 0x09, 0x40, 0x40, 0x09, 0x0d, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x01, 0xd6, 0x01, 0x40,
0x00, 0x0a, 0x00, 0x00, 0x25, 0x37, 0x27, 0x07, 0x27, 0x37, 0x17, 0x37, 0x17, 0x37, 0x15, 0x01,
0x55, 0x31, 0x68, 0x55, 0x9e, 0x1e, 0x80, 0x55, 0x86, 0x31, 0x40, 0x31, 0x68, 0x55, 0x9e, 0x1e,
0x80, 0x55, 0x86, 0x31, 0x80, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x01, 0xd6, 0x01, 0x40,
0x00, 0x0a, 0x00, 0x00, 0x01, 0x17, 0x07, 0x27, 0x07, 0x17, 0x37, 0x17, 0x37, 0x17, 0x35, 0x01,
0x55, 0x31, 0x68, 0x55, 0x9e, 0x1e, 0x80, 0x55, 0x86, 0x31, 0x01, 0x40, 0x31, 0x68, 0x55, 0x9e,
0x1e, 0x80, 0x55, 0x86, 0x31, 0x80, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00, 0x01, 0xeb, 0x01, 0x56,
0x00, 0x14, 0x00, 0x2a, 0x00, 0x00, 0x37, 0x22, 0x2e, 0x01, 0x34, 0x3e, 0x01, 0x33, 0x3e, 0x01,
0x33, 0x32, 0x1e, 0x01, 0x17, 0x33, 0x32, 0x16, 0x14, 0x06, 0x23, 0x35, 0x23, 0x35, 0x34, 0x2e,
0x01, 0x23, 0x22, 0x06, 0x07, 0x26, 0x23, 0x22, 0x06, 0x14, 0x16, 0x33, 0x21, 0x32, 0x36, 0x34,
0x26, 0x80, 0x1d, 0x31, 0x1d, 0x1d, 0x31, 0x1d, 0x10, 0x45, 0x2b, 0x24, 0x3e, 0x26, 0x03, 0x0a,
0x24, 0x32, 0x32, 0x24, 0x2a, 0x1d, 0x31, 0x1d, 0x28, 0x3b, 0x06, 0x0c, 0x0b, 0x1b, 0x25, 0x25,
0x1b, 0x01, 0x15, 0x12, 0x19, 0x19, 0x2b, 0x1c, 0x31, 0x3a, 0x31, 0x1d, 0x26, 0x2f, 0x22, 0x3b,
0x23, 0x32, 0x46, 0x32, 0x80, 0x15, 0x1d, 0x31, 0x1d, 0x33, 0x27, 0x04, 0x25, 0x35, 0x26, 0x19,
0x24, 0x19, 0x00, 0x05, 0x00, 0x00, 0xff, 0xff, 0x01, 0xeb, 0x01, 0x81, 0x00, 0x0b, 0x00, 0x17,
0x00, 0x3c, 0x00, 0x48, 0x00, 0x54, 0x00, 0x00, 0x37, 0x33, 0x32, 0x16, 0x14, 0x06, 0x2b, 0x01,
0x22, 0x26, 0x34, 0x36, 0x21, 0x33, 0x32, 0x16, 0x14, 0x06, 0x2b, 0x01, 0x22, 0x26, 0x34, 0x36,
0x25, 0x34, 0x3e, 0x01, 0x33, 0x3e, 0x01, 0x33, 0x32, 0x1e, 0x01, 0x17, 0x37, 0x32, 0x16, 0x17,
0x23, 0x34, 0x26, 0x2b, 0x01, 0x35, 0x34, 0x2e, 0x01, 0x23, 0x22, 0x06, 0x07, 0x26, 0x23, 0x22,
0x06, 0x15, 0x14, 0x17, 0x23, 0x17, 0x33, 0x32, 0x16, 0x14, 0x06, 0x2b, 0x01, 0x22, 0x26, 0x34,
0x36, 0x33, 0x21, 0x32, 0x16, 0x14, 0x06, 0x23, 0x21, 0x22, 0x26, 0x34, 0x36, 0x40, 0xd5, 0x09,
0x0d, 0x0d, 0x09, 0xd5, 0x09, 0x0c, 0x0c, 0x01, 0x1e, 0x6b, 0x09, 0x0c, 0x0c, 0x09, 0x6b, 0x09,
0x0c, 0x0c, 0xfe, 0xc9, 0x1d, 0x31, 0x1d, 0x10, 0x45, 0x2b, 0x24, 0x3e, 0x26, 0x03, 0x0a, 0x23,
0x32, 0x01, 0x2b, 0x19, 0x12, 0x2a, 0x1d, 0x31, 0x1d, 0x28, 0x3b, 0x06, 0x0c, 0x0b, 0x1b, 0x25,
0x04, 0x2d, 0x29, 0x2b, 0x09, 0x0c, 0x0c, 0x09, 0x2b, 0x09, 0x0c, 0x0c, 0x74, 0x01, 0x15, 0x09,
0x0c, 0x0c, 0x09, 0xfe, 0xeb, 0x09, 0x0d, 0x0d, 0x80, 0x0c, 0x12, 0x0d, 0x0d, 0x12, 0x0c, 0x0c,
0x12, 0x0d, 0x0d, 0x12, 0x0c, 0x40, 0x1d, 0x31, 0x1d, 0x26, 0x2f, 0x22, 0x3b, 0x24, 0x01, 0x32,
0x23, 0x11, 0x19, 0x16, 0x1d, 0x31, 0x1c, 0x33, 0x26, 0x04, 0x25, 0x1b, 0x0b, 0x0a, 0x80, 0x0d,
0x12, 0x0c, 0x0c, 0x12, 0x0d, 0x0d, 0x12, 0x0c, 0x0c, 0x12, 0x0d, 0x00, 0x00, 0x04, 0x00, 0x00,
0xff, 0xea, 0x01, 0xeb, 0x01, 0x96, 0x00, 0x34, 0x00, 0x3d, 0x00, 0x46, 0x00, 0x4f, 0x00, 0x00,
0x37, 0x32, 0x16, 0x14, 0x06, 0x23, 0x22, 0x2e, 0x01, 0x34, 0x3e, 0x01, 0x33, 0x3e, 0x01, 0x33,
0x32, 0x1e, 0x01, 0x17, 0x33, 0x32, 0x16, 0x14, 0x06, 0x2b, 0x01, 0x22, 0x26, 0x34, 0x36, 0x3b,
0x01, 0x32, 0x36, 0x34, 0x26, 0x2b, 0x01, 0x35, 0x34, 0x2e, 0x01, 0x23, 0x22, 0x06, 0x07, 0x26,
0x23, 0x22, 0x06, 0x14, 0x16, 0x17, 0x32, 0x16, 0x14, 0x06, 0x22, 0x26, 0x34, 0x36, 0x37, 0x32,
0x16, 0x14, 0x06, 0x22, 0x26, 0x34, 0x36, 0x27, 0x32, 0x16, 0x14, 0x06, 0x22, 0x26, 0x34, 0x36,
0x80, 0x09, 0x0c, 0x0c, 0x09, 0x1d, 0x31, 0x1d, 0x1d, 0x31, 0x1d, 0x10, 0x45, 0x2b, 0x24, 0x3e,
0x26, 0x03, 0x0a, 0x24, 0x32, 0x32, 0x24, 0x15, 0x09, 0x0c, 0x0c, 0x09, 0x15, 0x12, 0x19, 0x19,
0x12, 0x2a, 0x1d, 0x31, 0x1d, 0x28, 0x3b, 0x06, 0x0c, 0x0b, 0x1b, 0x25, 0x25, 0x70, 0x12, 0x19,
0x19, 0x23, 0x19, 0x19, 0x71, 0x0e, 0x12, 0x12, 0x1b, 0x13, 0x13, 0x48, 0x0d, 0x13, 0x13, 0x1a,
0x13, 0x13, 0x95, 0x0c, 0x12, 0x0c, 0x1c, 0x31, 0x3a, 0x31, 0x1d, 0x26, 0x2f, 0x22, 0x3b, 0x23,
0x32, 0x46, 0x32, 0x0c, 0x12, 0x0c, 0x19, 0x24, 0x19, 0x15, 0x1d, 0x31, 0x1d, 0x33, 0x27, 0x04,
0x25, 0x35, 0x26, 0x55, 0x19, 0x23, 0x19, 0x19, 0x23, 0x19, 0x2b, 0x13, 0x1b, 0x12, 0x12, 0x1b,
0x13, 0x55, 0x13, 0x1a, 0x13, 0x13, 0x1a, 0x13, 0x00, 0x02, 0x00, 0x00, 0xff, 0xea, 0x01, 0xeb,
0x01, 0x96, 0x00, 0x36, 0x00, 0x3d, 0x00, 0x00, 0x37, 0x22, 0x2e, 0x01, 0x34, 0x3e, 0x01, 0x33,
0x3e, 0x01, 0x33, 0x32, 0x1e, 0x01, 0x17, 0x33, 0x32, 0x16, 0x14, 0x06, 0x2b, 0x01, 0x22, 0x26,
0x34, 0x36, 0x3b, 0x01, 0x32, 0x36, 0x34, 0x26, 0x2b, 0x01, 0x35, 0x34, 0x2e, 0x01, 0x23, 0x22,
0x06, 0x07, 0x26, 0x23, 0x22, 0x06, 0x14, 0x16, 0x3b, 0x01, 0x32, 0x16, 0x14, 0x06, 0x23, 0x37,
0x33, 0x07, 0x33, 0x07, 0x37, 0x23, 0x80, 0x1d, 0x31, 0x1d, 0x1d, 0x31, 0x1d, 0x10, 0x45, 0x2b,
0x24, 0x3e, 0x26, 0x03, 0x0a, 0x24, 0x32, 0x32, 0x24, 0x15, 0x09, 0x0c, 0x0c, 0x09, 0x15, 0x12,
0x19, 0x19, 0x12, 0x2a, 0x1d, 0x31, 0x1d, 0x28, 0x3b, 0x06, 0x0c, 0x0b, 0x1b, 0x25, 0x25, 0x1b,
0x15, 0x09, 0x0d, 0x0d, 0x09, 0x6b, 0x40, 0x2b, 0x2b, 0x50, 0x10, 0x35, 0x6b, 0x1c, 0x31, 0x3a,
0x31, 0x1d, 0x26, 0x2f, 0x22, 0x3b, 0x23, 0x32, 0x46, 0x32, 0x0c, 0x12, 0x0c, 0x19, 0x24, 0x19,
0x15, 0x1d, 0x31, 0x1d, 0x33, 0x27, 0x04, 0x25, 0x35, 0x26, 0x0c, 0x12, 0x0c, 0x6a, 0x55, 0x95,
0x6a, 0x00, 0x00, 0x04, 0x00, 0x00, 0xff, 0xe3, 0x01, 0xc6, 0x01, 0xab, 0x00, 0x09, 0x00, 0x13,
0x00, 0x2b, 0x00, 0x37, 0x00, 0x00, 0x01, 0x07, 0x17, 0x27, 0x07, 0x37, 0x27, 0x3f, 0x01, 0x1f,
0x01, 0x07, 0x17, 0x27, 0x07, 0x37, 0x27, 0x3f, 0x01, 0x17, 0x07, 0x36, 0x1e, 0x01, 0x07, 0x06,
0x07, 0x0e, 0x01, 0x2e, 0x02, 0x36, 0x37, 0x36, 0x37, 0x36, 0x1e, 0x01, 0x07, 0x06, 0x1e, 0x02,
0x17, 0x2e, 0x03, 0x27, 0x0e, 0x01, 0x1e, 0x02, 0x36, 0x01, 0x7b, 0x36, 0x13, 0x38, 0x38, 0x13,
0x36, 0x44, 0x17, 0x17, 0x8e, 0x23, 0x0d, 0x24, 0x25, 0x0d, 0x23, 0x2c, 0x0f, 0x0e, 0x04, 0x08,
0x11, 0x05, 0x05, 0x0b, 0x0c, 0x29, 0x6e, 0x6e, 0x52, 0x1c, 0x1c, 0x29, 0x0d, 0x0e, 0x08, 0x14,
0x0d, 0x01, 0x04, 0x1f, 0x45, 0x5a, 0x0c, 0x2e, 0x57, 0x44, 0x26, 0x02, 0x1e, 0x13, 0x18, 0x3f,
0x54, 0x56, 0x01, 0x69, 0x2a, 0x41, 0x27, 0x27, 0x41, 0x2a, 0x02, 0x40, 0x40, 0x96, 0x1a, 0x2b,
0x19, 0x19, 0x2b, 0x1a, 0x01, 0x2a, 0x2a, 0x6a, 0x01, 0x0e, 0x13, 0x08, 0x10, 0x0b, 0x29, 0x1d,
0x1d, 0x52, 0x6e, 0x6e, 0x29, 0x0d, 0x0a, 0x05, 0x06, 0x10, 0x09, 0x2e, 0x5a, 0x45, 0x20, 0x26,
0x02, 0x26, 0x44, 0x56, 0x2e, 0x21, 0x55, 0x54, 0x3f, 0x18, 0x13, 0x00, 0x00, 0x07, 0x00, 0x00,
0xff, 0xea, 0x01, 0xd6, 0x01, 0x9a, 0x00, 0x1b, 0x00, 0x29, 0x00, 0x2f, 0x00, 0x35, 0x00, 0x3b,
0x00, 0x41, 0x00, 0x54, 0x00, 0x00, 0x01, 0x1e, 0x01, 0x07, 0x1e, 0x01, 0x1d, 0x01, 0x36, 0x33,
0x32, 0x16, 0x14, 0x06, 0x23, 0x21, 0x22, 0x26, 0x34, 0x36, 0x3b, 0x01, 0x2e, 0x01, 0x37, 0x3e,
0x02, 0x17, 0x26, 0x06, 0x07, 0x06, 0x16, 0x17, 0x3e, 0x01, 0x33, 0x32, 0x17, 0x2e, 0x01, 0x37,
0x26, 0x27, 0x37, 0x17, 0x26, 0x07, 0x06, 0x07, 0x37, 0x17, 0x06, 0x17, 0x26, 0x27, 0x17, 0x07,
0x36, 0x05, 0x16, 0x17, 0x27, 0x37, 0x06, 0x05, 0x23, 0x35, 0x34, 0x26, 0x22, 0x06, 0x15, 0x23,
0x22, 0x06, 0x14, 0x16, 0x33, 0x21, 0x32, 0x36, 0x34, 0x26, 0x01, 0x10, 0x25, 0x26, 0x07, 0x14,
0x18, 0x0a, 0x0b, 0x1b, 0x25, 0x25, 0x1b, 0xfe, 0xeb, 0x23, 0x32, 0x32, 0x23, 0x06, 0x15, 0x0a,
0x0e, 0x0d, 0x33, 0x3e, 0x0c, 0x1d, 0x3a, 0x0c, 0x07, 0x01, 0x08, 0x12, 0x33, 0x1d, 0x16, 0x15,
0x01, 0x18, 0x0f, 0x11, 0x13, 0x36, 0x13, 0x11, 0xb3, 0x10, 0x0c, 0x03, 0x3f, 0x14, 0xec, 0x03,
0x06, 0x33, 0x2c, 0x04, 0xfe, 0xbf, 0x02, 0x06, 0x32, 0x2b, 0x03, 0x01, 0x56, 0x40, 0x32, 0x46,
0x32, 0x2b, 0x12, 0x19, 0x19, 0x12, 0x01, 0x15, 0x09, 0x0d, 0x0d, 0x01, 0x4b, 0x10, 0x48, 0x27,
0x12, 0x33, 0x1c, 0x04, 0x04, 0x26, 0x35, 0x25, 0x32, 0x46, 0x32, 0x1a, 0x42, 0x1f, 0x1d, 0x24,
0x07, 0x34, 0x0d, 0x16, 0x1c, 0x10, 0x23, 0x0f, 0x15, 0x19, 0x08, 0x15, 0x23, 0x57, 0x08, 0x03,
0x1c, 0x3d, 0x0d, 0x08, 0x0b, 0x0e, 0x3c, 0x0f, 0x06, 0x7e, 0x14, 0x10, 0x20, 0x2f, 0x14, 0x0b,
0x13, 0x11, 0x20, 0x2f, 0x16, 0xa4, 0x2b, 0x23, 0x32, 0x32, 0x23, 0x19, 0x24, 0x19, 0x0d, 0x12,
0x0c, 0x00, 0x00, 0x04, 0x00, 0x00, 0xff, 0xef, 0x01, 0xeb, 0x01, 0x96, 0x00, 0x0c, 0x00, 0x19,
0x00, 0x26, 0x00, 0x5f, 0x00, 0x00, 0x37, 0x1e, 0x01, 0x0f, 0x01, 0x0e, 0x01, 0x2e, 0x01, 0x3f,
0x01, 0x3e, 0x01, 0x17, 0x1e, 0x01, 0x0f, 0x01, 0x0e, 0x01, 0x2e, 0x01, 0x3f, 0x01, 0x3e, 0x01,
0x17, 0x1e, 0x01, 0x0f, 0x01, 0x0e, 0x01, 0x2e, 0x01, 0x3f, 0x01, 0x3e, 0x01, 0x37, 0x35, 0x34,
0x2e, 0x01, 0x23, 0x22, 0x06, 0x07, 0x26, 0x23, 0x22, 0x06, 0x15, 0x14, 0x16, 0x17, 0x31, 0x1e,
0x01, 0x0e, 0x01, 0x27, 0x31, 0x2e, 0x01, 0x34, 0x3e, 0x01, 0x33, 0x3e, 0x01, 0x33, 0x32, 0x1e,
0x01, 0x17, 0x33, 0x32, 0x16, 0x15, 0x14, 0x06, 0x07, 0x31, 0x06, 0x2e, 0x01, 0x36, 0x37, 0x31,
0x3e, 0x01, 0x35, 0x34, 0x26, 0x23, 0xc0, 0x08, 0x09, 0x02, 0x1c, 0x02, 0x0f, 0x11, 0x09, 0x03,
0x1b, 0x02, 0x0f, 0x5e, 0x09, 0x09, 0x03, 0x2c, 0x02, 0x0f, 0x11, 0x09, 0x02, 0x2c, 0x03, 0x0f,
0x5e, 0x08, 0x09, 0x02, 0x1c, 0x02, 0x0f, 0x12, 0x08, 0x02, 0x1c, 0x02, 0x0f, 0x09, 0x1d, 0x31,
0x1d, 0x28, 0x3b, 0x06, 0x0c, 0x0b, 0x1b, 0x25, 0x11, 0x0f, 0x08, 0x04, 0x09, 0x10, 0x08, 0x19,
0x1d, 0x1d, 0x31, 0x1d, 0x10, 0x45, 0x2b, 0x24, 0x3e, 0x26, 0x03, 0x0a, 0x24, 0x32, 0x17, 0x14,
0x08, 0x11, 0x09, 0x05, 0x08, 0x09, 0x0c, 0x19, 0x12, 0xc0, 0x02, 0x10, 0x08, 0x67, 0x09, 0x08,
0x04, 0x0f, 0x09, 0x67, 0x08, 0x09, 0x02, 0x02, 0x10, 0x08, 0xa5, 0x09, 0x08, 0x04, 0x10, 0x08,
0xa5, 0x08, 0x09, 0x02, 0x02, 0x10, 0x08, 0x67, 0x09, 0x08, 0x04, 0x0f, 0x09, 0x67, 0x08, 0x09,
0x29, 0x15, 0x1d, 0x31, 0x1d, 0x33, 0x27, 0x04, 0x25, 0x1b, 0x11, 0x1e, 0x08, 0x04, 0x12, 0x0f,
0x04, 0x04, 0x0e, 0x31, 0x3a, 0x31, 0x1d, 0x26, 0x2f, 0x22, 0x3b, 0x23, 0x32, 0x23, 0x18, 0x27,
0x0b, 0x04, 0x04, 0x10, 0x11, 0x04, 0x06, 0x13, 0x0c, 0x12, 0x19, 0x00, 0x00, 0x03, 0x00, 0x00,
0xff, 0xea, 0x01, 0xeb, 0x01, 0x95, 0x00, 0x34, 0x00, 0x40, 0x00, 0x54, 0x00, 0x00, 0x37, 0x32,
0x16, 0x14, 0x06, 0x23, 0x22, 0x2e, 0x01, 0x34, 0x3e, 0x01, 0x33, 0x3e, 0x01, 0x33, 0x32, 0x1e,
0x01, 0x17, 0x37, 0x32, 0x16, 0x14, 0x06, 0x2b, 0x01, 0x22, 0x26, 0x34, 0x36, 0x3b, 0x01, 0x32,
0x36, 0x34, 0x26, 0x2b, 0x01, 0x35, 0x34, 0x2e, 0x01, 0x23, 0x22, 0x06, 0x07, 0x26, 0x23, 0x22,
0x06, 0x14, 0x16, 0x17, 0x16, 0x17, 0x16, 0x15, 0x14, 0x06, 0x22, 0x26, 0x35, 0x34, 0x3f, 0x01,
0x0f, 0x01, 0x06, 0x07, 0x06, 0x07, 0x06, 0x15, 0x14, 0x16, 0x32, 0x36, 0x35, 0x34, 0x27, 0x2e,
0x01, 0x27, 0x31, 0x80, 0x09, 0x0c, 0x0c, 0x09, 0x1d, 0x31, 0x1d, 0x1d, 0x31, 0x1d, 0x10, 0x45,
0x2b, 0x24, 0x3e, 0x26, 0x03, 0x0a, 0x24, 0x32, 0x32, 0x24, 0x15, 0x09, 0x0c, 0x0c, 0x09, 0x15,
0x12, 0x19, 0x19, 0x12, 0x2a, 0x1d, 0x31, 0x1d, 0x28, 0x3b, 0x06, 0x0b, 0x0c, 0x1b, 0x25, 0x25,
0x9b, 0x05, 0x07, 0x1f, 0x19, 0x24, 0x19, 0x1f, 0x0c, 0x0b, 0x0b, 0x0e, 0x0b, 0x12, 0x08, 0x0c,
0x32, 0x46, 0x32, 0x0c, 0x08, 0x24, 0x12, 0x95, 0x0d, 0x11, 0x0d, 0x1d, 0x31, 0x3a, 0x31, 0x1c,
0x27, 0x2f, 0x23, 0x3a, 0x24, 0x01, 0x32, 0x47, 0x32, 0x0d, 0x11, 0x0d, 0x19, 0x23, 0x19, 0x15,
0x1d, 0x31, 0x1d, 0x33, 0x26, 0x04, 0x26, 0x35, 0x25, 0x03, 0x07, 0x0a, 0x2b, 0x16, 0x12, 0x19,
0x19, 0x12, 0x16, 0x2b, 0x54, 0x0c, 0x0e, 0x11, 0x11, 0x19, 0x12, 0x19, 0x15, 0x23, 0x32, 0x32,
0x23, 0x15, 0x19, 0x12, 0x34, 0x15, 0x00, 0x02, 0x00, 0x00, 0xff, 0xd6, 0x01, 0xeb, 0x01, 0x96,
0x00, 0x34, 0x00, 0x5e, 0x00, 0x00, 0x37, 0x32, 0x16, 0x14, 0x06, 0x23, 0x22, 0x2e, 0x01, 0x34,
0x3e, 0x01, 0x33, 0x3e, 0x01, 0x33, 0x32, 0x1e, 0x01, 0x17, 0x33, 0x32, 0x16, 0x14, 0x06, 0x2b,
0x01, 0x22, 0x26, 0x34, 0x36, 0x3b, 0x01, 0x32, 0x36, 0x34, 0x26, 0x2b, 0x01, 0x35, 0x34, 0x2e,
0x01, 0x23, 0x22, 0x06, 0x07, 0x26, 0x23, 0x22, 0x06, 0x14, 0x16, 0x17, 0x37, 0x27, 0x26, 0x34,
0x36, 0x32, 0x1f, 0x01, 0x37, 0x3e, 0x01, 0x1e, 0x01, 0x0f, 0x01, 0x37, 0x36, 0x1e, 0x01, 0x06,
0x0f, 0x01, 0x17, 0x16, 0x14, 0x06, 0x22, 0x2f, 0x01, 0x07, 0x0e, 0x01, 0x2e, 0x01, 0x3f, 0x01,
0x07, 0x06, 0x2e, 0x01, 0x36, 0x80, 0x09, 0x0c, 0x0c, 0x09, 0x1d, 0x31, 0x1d, 0x1d, 0x31, 0x1d,
0x10, 0x45, 0x2b, 0x24, 0x3e, 0x26, 0x03, 0x0a, 0x24, 0x32, 0x32, 0x24, 0x15, 0x09, 0x0c, 0x0c,
0x09, 0x15, 0x12, 0x19, 0x19, 0x12, 0x2a, 0x1d, 0x31, 0x1d, 0x28, 0x3b, 0x06, 0x0c, 0x0b, 0x1b,
0x25, 0x25, 0x43, 0x2f, 0x23, 0x06, 0x0d, 0x12, 0x06, 0x22, 0x0c, 0x03, 0x0f, 0x11, 0x09, 0x02,
0x0d, 0x2f, 0x08, 0x10, 0x04, 0x09, 0x08, 0x2f, 0x23, 0x06, 0x0d, 0x12, 0x06, 0x22, 0x0c, 0x03,
0x0f, 0x11, 0x09, 0x02, 0x0d, 0x2f, 0x08, 0x10, 0x04, 0x09, 0x95, 0x0c, 0x12, 0x0c, 0x1c, 0x31,
0x3a, 0x31, 0x1d, 0x26, 0x2f, 0x22, 0x3b, 0x23, 0x32, 0x46, 0x32, 0x0c, 0x12, 0x0c, 0x19, 0x24,
0x19, 0x15, 0x1d, 0x31, 0x1d, 0x33, 0x27, 0x04, 0x25, 0x35, 0x26, 0x56, 0x0c, 0x22, 0x06, 0x12,
0x0d, 0x06, 0x23, 0x2f, 0x08, 0x09, 0x04, 0x10, 0x08, 0x2f, 0x0d, 0x02, 0x09, 0x11, 0x0f, 0x03,
0x0c, 0x22, 0x06, 0x12, 0x0d, 0x06, 0x23, 0x2f, 0x08, 0x09, 0x04, 0x10, 0x08, 0x2f, 0x0d, 0x02,
0x09, 0x11, 0x0f, 0x00, 0x00, 0x08, 0x00, 0x00, 0xff, 0xea, 0x01, 0xb9, 0x01, 0x96, 0x00, 0x0c,
0x00, 0x15, 0x00, 0x1a, 0x00, 0x1f, 0x00, 0x24, 0x00, 0x29, 0x00, 0x2e, 0x00, 0x33, 0x00, 0x00,
0x01, 0x32, 0x1e, 0x01, 0x14, 0x0e, 0x01, 0x22, 0x2e, 0x01, 0x34, 0x3e, 0x01, 0x17, 0x22, 0x06,
0x14, 0x16, 0x32, 0x36, 0x34, 0x26, 0x27, 0x17, 0x26, 0x22, 0x0f, 0x01, 0x37, 0x0e, 0x01, 0x0f,
0x01, 0x37, 0x1e, 0x01, 0x17, 0x25, 0x07, 0x2e, 0x01, 0x27, 0x17, 0x07, 0x3e, 0x01, 0x37, 0x07,
0x27, 0x16, 0x32, 0x37, 0x01, 0x00, 0x1d, 0x31, 0x1d, 0x1d, 0x31, 0x3a, 0x31, 0x1d, 0x1d, 0x31,
0x1d, 0x1b, 0x25, 0x25, 0x36, 0x25, 0x25, 0x1b, 0x33, 0x19, 0x34, 0x19, 0x86, 0x59, 0x14, 0x1a,
0x05, 0x25, 0x25, 0x05, 0x1a, 0x14, 0x01, 0x19, 0x26, 0x05, 0x1a, 0x14, 0x58, 0x58, 0x14, 0x1a,
0x05, 0x93, 0x33, 0x19, 0x33, 0x1a, 0x01, 0x2b, 0x1d, 0x31, 0x3a, 0x31, 0x1d, 0x1d, 0x31, 0x3a,
0x31, 0x1d, 0x2b, 0x25, 0x36, 0x25, 0x25, 0x36, 0x25, 0x95, 0x49, 0x09, 0x09, 0x21, 0x07, 0x11,
0x2c, 0x1b, 0x85, 0x51, 0x1a, 0x2d, 0x12, 0xde, 0x51, 0x1a, 0x2e, 0x10, 0xdd, 0x07, 0x11, 0x2d,
0x1a, 0xbb, 0x49, 0x09, 0x09, 0x00, 0x00, 0x06, 0x00, 0x00, 0xff, 0xff, 0x01, 0xd6, 0x01, 0x96,
0x00, 0x13, 0x00, 0x19, 0x00, 0x1e, 0x00, 0x23, 0x00, 0x28, 0x00, 0x39, 0x00, 0x00, 0x37, 0x33,
0x34, 0x3e, 0x01, 0x32, 0x1e, 0x01, 0x15, 0x33, 0x32, 0x16, 0x14, 0x06, 0x23, 0x21, 0x22, 0x26,
0x34, 0x36, 0x21, 0x34, 0x26, 0x22, 0x06, 0x15, 0x37, 0x17, 0x26, 0x22, 0x0f, 0x01, 0x37, 0x0e,
0x01, 0x07, 0x25, 0x07, 0x2e, 0x01, 0x27, 0x03, 0x37, 0x36, 0x34, 0x26, 0x22, 0x0f, 0x01, 0x27,
0x26, 0x22, 0x06, 0x14, 0x1f, 0x01, 0x16, 0x32, 0x40, 0x55, 0x1d, 0x31, 0x3a, 0x31, 0x1d, 0x55,
0x09, 0x0c, 0x0c, 0x09, 0xfe, 0x80, 0x09, 0x0c, 0x0c, 0x01, 0x09, 0x25, 0x36, 0x25, 0x40, 0x33,
0x19, 0x34, 0x19, 0x86, 0x59, 0x14, 0x1a, 0x05, 0x01, 0x4c, 0x26, 0x05, 0x1a, 0x14, 0x51, 0x42,
0x07, 0x0d, 0x11, 0x07, 0x33, 0x33, 0x07, 0x11, 0x0d, 0x07, 0x42, 0x07, 0x10, 0xc0, 0x1d, 0x31,
0x1d, 0x1d, 0x31, 0x1d, 0x0c, 0x12, 0x0d, 0x0d, 0x12, 0x0c, 0x1b, 0x25, 0x25, 0x1b, 0xd5, 0x49,
0x09, 0x09, 0x21, 0x07, 0x11, 0x2c, 0x1b, 0x51, 0x51, 0x1a, 0x2e, 0x10, 0xfe, 0xd4, 0x43, 0x06,
0x12, 0x0c, 0x06, 0x34, 0x34, 0x06, 0x0c, 0x12, 0x06, 0x43, 0x06, 0x00, 0x00, 0x06, 0x00, 0x00,
0xff, 0xfd, 0x01, 0xd6, 0x01, 0x96, 0x00, 0x13, 0x00, 0x19, 0x00, 0x1e, 0x00, 0x23, 0x00, 0x28,
0x00, 0x39, 0x00, 0x00, 0x37, 0x33, 0x34, 0x3e, 0x01, 0x32, 0x1e, 0x01, 0x15, 0x33, 0x32, 0x16,
0x14, 0x06, 0x23, 0x21, 0x22, 0x26, 0x34, 0x36, 0x21, 0x34, 0x26, 0x22, 0x06, 0x15, 0x37, 0x17,
0x26, 0x22, 0x0f, 0x01, 0x37, 0x0e, 0x01, 0x07, 0x25, 0x07, 0x2e, 0x01, 0x27, 0x07, 0x17, 0x16,
0x14, 0x06, 0x22, 0x2f, 0x01, 0x07, 0x06, 0x22, 0x26, 0x34, 0x3f, 0x01, 0x36, 0x32, 0x40, 0x55,
0x1d, 0x31, 0x3a, 0x31, 0x1d, 0x55, 0x09, 0x0c, 0x0c, 0x09, 0xfe, 0x80, 0x09, 0x0c, 0x0c, 0x01,
0x09, 0x25, 0x36, 0x25, 0x40, 0x33, 0x19, 0x34, 0x19, 0x86, 0x59, 0x14, 0x1a, 0x05, 0x01, 0x4c,
0x26, 0x05, 0x1a, 0x14, 0x51, 0x42, 0x07, 0x0d, 0x11, 0x07, 0x33, 0x33, 0x07, 0x11, 0x0d, 0x07,
0x42, 0x07, 0x10, 0xc0, 0x1d, 0x31, 0x1d, 0x1d, 0x31, 0x1d, 0x0c, 0x12, 0x0d, 0x0d, 0x12, 0x0c,
0x1b, 0x25, 0x25, 0x1b, 0xd5, 0x49, 0x09, 0x09, 0x21, 0x07, 0x11, 0x2c, 0x1b, 0x51, 0x51, 0x1a,
0x2e, 0x10, 0xce, 0x42, 0x06, 0x12, 0x0c, 0x06, 0x33, 0x33, 0x06, 0x0c, 0x12, 0x06, 0x42, 0x07,
0x00, 0x03, 0x00, 0x00, 0xff, 0xea, 0x01, 0xd6, 0x01, 0x96, 0x00, 0x17, 0x00, 0x2e, 0x00, 0x45,
0x00, 0x00, 0x37, 0x22, 0x26, 0x34, 0x36, 0x3b, 0x01, 0x32, 0x36, 0x34, 0x26, 0x22, 0x07, 0x06,
0x22, 0x26, 0x34, 0x37, 0x36, 0x32, 0x16, 0x14, 0x06, 0x23, 0x17, 0x32, 0x36, 0x34, 0x26, 0x22,
0x06, 0x22, 0x26, 0x34, 0x37, 0x36, 0x32, 0x16, 0x14, 0x06, 0x23, 0x21, 0x22, 0x26, 0x34, 0x36,
0x33, 0x05, 0x21, 0x22, 0x26, 0x34, 0x36, 0x33, 0x21, 0x32, 0x16, 0x14, 0x06, 0x22, 0x27, 0x26,
0x34, 0x36, 0x32, 0x16, 0x32, 0x36, 0x34, 0x26, 0x55, 0x09, 0x0c, 0x0c, 0x09, 0xab, 0x12, 0x19,
0x19, 0x24, 0x0c, 0x06, 0x12, 0x0d, 0x07, 0x19, 0x46, 0x32, 0x32, 0x23, 0x95, 0x09, 0x0d, 0x0d,
0x12, 0x0c, 0x12, 0x0c, 0x06, 0x13, 0x35, 0x25, 0x25, 0x1b, 0xfe, 0xd6, 0x09, 0x0d, 0x0d, 0x09,
0x01, 0x15, 0xfe, 0xd5, 0x09, 0x0c, 0x0c, 0x09, 0x01, 0x2b, 0x1b, 0x25, 0x25, 0x35, 0x13, 0x06,
0x0c, 0x12, 0x0c, 0x12, 0x0c, 0x0c, 0xeb, 0x0c, 0x12, 0x0c, 0x19, 0x24, 0x19, 0x0d, 0x06, 0x0c,
0x12, 0x06, 0x19, 0x32, 0x46, 0x32, 0x2b, 0x0c, 0x12, 0x0d, 0x0d, 0x0d, 0x11, 0x07, 0x12, 0x25,
0x35, 0x26, 0x0d, 0x12, 0x0c, 0x80, 0x0c, 0x12, 0x0d, 0x26, 0x35, 0x25, 0x12, 0x07, 0x11, 0x0d,
0x0d, 0x0d, 0x12, 0x0c, 0x00, 0x04, 0x00, 0x00, 0xff, 0xea, 0x01, 0xd6, 0x01, 0x96, 0x00, 0x03,
0x00, 0x07, 0x00, 0x18, 0x00, 0x25, 0x00, 0x00, 0x37, 0x33, 0x15, 0x23, 0x35, 0x33, 0x15, 0x23,
0x37, 0x22, 0x0e, 0x01, 0x15, 0x14, 0x1e, 0x02, 0x32, 0x3e, 0x02, 0x34, 0x2e, 0x02, 0x03, 0x22,
0x2e, 0x01, 0x34, 0x3e, 0x01, 0x32, 0x1e, 0x01, 0x14, 0x0e, 0x01, 0xeb, 0x2a, 0x2a, 0x2a, 0x2a,
0x15, 0x3a, 0x62, 0x39, 0x20, 0x3c, 0x4f, 0x54, 0x4f, 0x3c, 0x20, 0x20, 0x3c, 0x4f, 0x2a, 0x2e,
0x4f, 0x2e, 0x2e, 0x4f, 0x5c, 0x4f, 0x2e, 0x2e, 0x4f, 0x80, 0x2b, 0xd6, 0x80, 0xea, 0x39, 0x62,
0x3a, 0x2a, 0x4f, 0x3c, 0x20, 0x20, 0x3c, 0x4f, 0x54, 0x4f, 0x3c, 0x20, 0xfe, 0x80, 0x2e, 0x4f,
0x5c, 0x4f, 0x2e, 0x2e, 0x4f, 0x5c, 0x4f, 0x2e, 0x00, 0x03, 0x00, 0x00, 0xff, 0xea, 0x01, 0xeb,
0x01, 0x96, 0x00, 0x34, 0x00, 0x3b, 0x00, 0x49, 0x00, 0x00, 0x37, 0x1e, 0x01, 0x0e, 0x01, 0x27,
0x31, 0x2e, 0x01, 0x34, 0x3e, 0x01, 0x33, 0x3e, 0x01, 0x33, 0x32, 0x1e, 0x01, 0x17, 0x33, 0x32,
0x16, 0x14, 0x06, 0x23, 0x22, 0x26, 0x34, 0x36, 0x33, 0x32, 0x36, 0x34, 0x26, 0x2b, 0x01, 0x35,
0x34, 0x2e, 0x01, 0x23, 0x22, 0x06, 0x07, 0x26, 0x23, 0x22, 0x06, 0x15, 0x14, 0x16, 0x17, 0x37,
0x33, 0x07, 0x33, 0x07, 0x37, 0x23, 0x17, 0x14, 0x06, 0x22, 0x26, 0x34, 0x37, 0x36, 0x37, 0x31,
0x17, 0x16, 0x17, 0x16, 0x60, 0x08, 0x04, 0x09, 0x10, 0x08, 0x19, 0x1d, 0x1d, 0x31, 0x1d, 0x10,
0x45, 0x2b, 0x24, 0x3e, 0x26, 0x03, 0x0a, 0x24, 0x32, 0x32, 0x24, 0x09, 0x0c, 0x0c, 0x09, 0x12,
0x19, 0x19, 0x12, 0x2a, 0x1d, 0x31, 0x1d, 0x28, 0x3b, 0x06, 0x0c, 0x0b, 0x1b, 0x25, 0x11, 0x0f,
0x6b, 0x40, 0x2b, 0x2b, 0x50, 0x10, 0x36, 0xe0, 0x1c, 0x28, 0x1c, 0x18, 0x0c, 0x0c, 0x0d, 0x0e,
0x09, 0x0c, 0x9e, 0x04, 0x12, 0x0f, 0x04, 0x04, 0x0e, 0x31, 0x3a, 0x31, 0x1d, 0x26, 0x2f, 0x22,
0x3b, 0x23, 0x32, 0x46, 0x32, 0x0c, 0x12, 0x0c, 0x19, 0x24, 0x19, 0x15, 0x1d, 0x31, 0x1d, 0x33,
0x27, 0x04, 0x25, 0x1b, 0x11, 0x1e, 0x08, 0x37, 0x55, 0x95, 0x6a, 0x23, 0x15, 0x1d, 0x1d, 0x29,
0x25, 0x12, 0x0e, 0x10, 0x13, 0x11, 0x17, 0x00, 0x00, 0x03, 0x00, 0x00, 0xff, 0xfd, 0x01, 0xeb,
0x01, 0x96, 0x00, 0x0d, 0x00, 0x38, 0x00, 0x6a, 0x00, 0x00, 0x25, 0x14, 0x06, 0x22, 0x26, 0x34,
0x37, 0x36, 0x37, 0x31, 0x17, 0x16, 0x17, 0x16, 0x25, 0x26, 0x36, 0x3f, 0x01, 0x27, 0x26, 0x34,
0x36, 0x32, 0x1f, 0x01, 0x37, 0x3e, 0x01, 0x1e, 0x01, 0x0f, 0x01, 0x37, 0x36, 0x1e, 0x01, 0x06,
0x0f, 0x01, 0x17, 0x16, 0x14, 0x06, 0x22, 0x2f, 0x01, 0x07, 0x0e, 0x01, 0x2e, 0x01, 0x3f, 0x01,
0x07, 0x06, 0x26, 0x27, 0x34, 0x3e, 0x01, 0x33, 0x3e, 0x01, 0x33, 0x32, 0x1e, 0x01, 0x17, 0x33,
0x32, 0x16, 0x14, 0x06, 0x23, 0x22, 0x26, 0x34, 0x36, 0x33, 0x32, 0x36, 0x34, 0x26, 0x2b, 0x01,
0x35, 0x34, 0x2e, 0x01, 0x23, 0x22, 0x06, 0x07, 0x26, 0x23, 0x22, 0x06, 0x14, 0x17, 0x16, 0x14,
0x06, 0x22, 0x27, 0x2e, 0x01, 0x01, 0x8b, 0x1c, 0x28, 0x1c, 0x18, 0x0c, 0x0c, 0x0c, 0x0e, 0x09,
0x0d, 0xfe, 0xca, 0x02, 0x09, 0x09, 0x30, 0x23, 0x07, 0x0d, 0x13, 0x06, 0x23, 0x0d, 0x02, 0x10,
0x11, 0x09, 0x02, 0x0d, 0x30, 0x09, 0x10, 0x05, 0x09, 0x09, 0x30, 0x23, 0x06, 0x0d, 0x12, 0x06,
0x23, 0x0d, 0x03, 0x0f, 0x12, 0x09, 0x02, 0x0d, 0x30, 0x09, 0x0f, 0x43, 0x1d, 0x31, 0x1d, 0x10,
0x45, 0x2b, 0x24, 0x3e, 0x26, 0x03, 0x0a, 0x24, 0x32, 0x32, 0x24, 0x09, 0x0c, 0x0c, 0x09, 0x12,
0x19, 0x19, 0x12, 0x2a, 0x1d, 0x31, 0x1d, 0x28, 0x3b, 0x06, 0x0c, 0x0b, 0x1b, 0x25, 0x13, 0x06,
0x0c, 0x12, 0x06, 0x0f, 0x11, 0x32, 0x15, 0x1d, 0x1d, 0x29, 0x25, 0x12, 0x0e, 0x10, 0x13, 0x11,
0x17, 0x0e, 0x08, 0x10, 0x02, 0x0d, 0x23, 0x07, 0x12, 0x0d, 0x07, 0x23, 0x30, 0x09, 0x09, 0x04,
0x10, 0x09, 0x30, 0x0c, 0x03, 0x09, 0x12, 0x0f, 0x02, 0x0d, 0x23, 0x07, 0x12, 0x0d, 0x06, 0x24,
0x30, 0x09, 0x09, 0x04, 0x10, 0x09, 0x30, 0x0d, 0x02, 0x09, 0x90, 0x1d, 0x31, 0x1d, 0x26, 0x2f,
0x22, 0x3b, 0x23, 0x32, 0x46, 0x32, 0x0c, 0x12, 0x0c, 0x19, 0x24, 0x19, 0x15, 0x1d, 0x31, 0x1d,
0x33, 0x27, 0x04, 0x25, 0x36, 0x13, 0x06, 0x11, 0x0c, 0x06, 0x0f, 0x27, 0x00, 0x02, 0x00, 0x00,
0xff, 0xea, 0x01, 0x6b, 0x01, 0x96, 0x00, 0x03, 0x00, 0x0b, 0x00, 0x00, 0x01, 0x17, 0x23, 0x37,
0x17, 0x33, 0x15, 0x33, 0x15, 0x23, 0x35, 0x33, 0x01, 0x40, 0x2b, 0xd6, 0x2b, 0x2b, 0x2a, 0x40,
0xaa, 0x40, 0x01, 0x95, 0x95, 0x95, 0xaa, 0xd6, 0x2a, 0x2a, 0x00, 0x02, 0x00, 0x00, 0xff, 0xea,
0x01, 0x98, 0x01, 0x96, 0x00, 0x1d, 0x00, 0x23, 0x00, 0x00, 0x13, 0x07, 0x17, 0x07, 0x23, 0x22,
0x06, 0x15, 0x14, 0x16, 0x1f, 0x01, 0x23, 0x15, 0x33, 0x35, 0x23, 0x27, 0x36, 0x35, 0x34, 0x27,
0x37, 0x06, 0x16, 0x17, 0x37, 0x2e, 0x01, 0x07, 0x17, 0x07, 0x16, 0x3e, 0x01, 0x26, 0xe7, 0x23,
0x18, 0x44, 0x03, 0x11, 0x19, 0x13, 0x0e, 0x57, 0x4e, 0xd6, 0x56, 0x5f, 0x0a, 0x04, 0x44, 0x04,
0x1d, 0x1b, 0x5f, 0x1b, 0x3d, 0x18, 0x64, 0x23, 0x0b, 0x1a, 0x0e, 0x05, 0x01, 0x95, 0x35, 0x10,
0x65, 0x19, 0x12, 0x0f, 0x17, 0x04, 0x81, 0x2a, 0x2a, 0x90, 0x0c, 0x0f, 0x0a, 0x09, 0x65, 0x1b,
0x36, 0x12, 0x8e, 0x12, 0x06, 0x0e, 0x42, 0x36, 0x07, 0x05, 0x16, 0x1a, 0x00, 0x02, 0x00, 0x00,
0xff, 0xea, 0x01, 0xc0, 0x01, 0x96, 0x00, 0x13, 0x00, 0x1d, 0x00, 0x00, 0x13, 0x23, 0x35, 0x23,
0x15, 0x23, 0x35, 0x23, 0x15, 0x14, 0x16, 0x17, 0x15, 0x33, 0x35, 0x3e, 0x01, 0x3d, 0x01, 0x23,
0x17, 0x15, 0x33, 0x15, 0x33, 0x11, 0x22, 0x07, 0x0e, 0x01, 0xeb, 0x2b, 0x2b, 0x2a, 0x2b, 0x2e,
0x22, 0x35, 0x22, 0x2e, 0x2a, 0x6a, 0x36, 0x35, 0x1c, 0x1a, 0x17, 0x1e, 0x01, 0x00, 0x95, 0x95,
0x95, 0x95, 0x22, 0x31, 0x02, 0xc0, 0xc0, 0x02, 0x31, 0x22, 0x95, 0x55, 0xab, 0xaa, 0x01, 0xaa,
0x0d, 0x0d, 0x28, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00, 0x01, 0x81, 0x01, 0x70, 0x00, 0x16,
0x00, 0x2a, 0x00, 0x00, 0x01, 0x0f, 0x01, 0x06, 0x07, 0x06, 0x07, 0x06, 0x15, 0x14, 0x1e, 0x01,
0x32, 0x3e, 0x01, 0x35, 0x34, 0x27, 0x26, 0x27, 0x26, 0x2f, 0x01, 0x07, 0x16, 0x17, 0x16, 0x17,
0x16, 0x17, 0x16, 0x15, 0x14, 0x06, 0x22, 0x26, 0x35, 0x34, 0x37, 0x36, 0x37, 0x36, 0x37, 0x01,
0x00, 0x10, 0x11, 0x15, 0x11, 0x1a, 0x0d, 0x12, 0x22, 0x3b, 0x46, 0x3b, 0x22, 0x12, 0x0d, 0x1a,
0x11, 0x15, 0x11, 0x10, 0x01, 0x03, 0x10, 0x10, 0x14, 0x0e, 0x0f, 0x31, 0x48, 0x31, 0x0f, 0x0e,
0x14, 0x10, 0x10, 0x01, 0x70, 0x12, 0x15, 0x19, 0x19, 0x27, 0x1a, 0x27, 0x1f, 0x22, 0x3b, 0x23,
0x23, 0x3b, 0x22, 0x1f, 0x27, 0x1a, 0x27, 0x19, 0x19, 0x15, 0x31, 0x02, 0x03, 0x13, 0x16, 0x1e,
0x1e, 0x21, 0x12, 0x23, 0x32, 0x32, 0x23, 0x12, 0x21, 0x1e, 0x1e, 0x16, 0x13, 0x00, 0x00, 0x03,
0x00, 0x00, 0xff, 0xea, 0x01, 0xd6, 0x01, 0x80, 0x00, 0x0e, 0x00, 0x17, 0x00, 0x24, 0x00, 0x00,
0x01, 0x32, 0x16, 0x1d, 0x01, 0x16, 0x14, 0x06, 0x22, 0x26, 0x34, 0x37, 0x35, 0x34, 0x36, 0x17,
0x22, 0x06, 0x1d, 0x01, 0x33, 0x35, 0x34, 0x26, 0x27, 0x07, 0x15, 0x33, 0x07, 0x17, 0x23, 0x35,
0x23, 0x37, 0x17, 0x06, 0x07, 0x01, 0x95, 0x12, 0x19, 0x15, 0x25, 0x35, 0x26, 0x16, 0x19, 0x11,
0x08, 0x0d, 0x2b, 0x0d, 0x9e, 0x6b, 0x98, 0x02, 0x02, 0xc2, 0x40, 0xd5, 0x5e, 0x10, 0x09, 0x01,
0x15, 0x19, 0x11, 0x91, 0x13, 0x37, 0x25, 0x25, 0x37, 0x13, 0x91, 0x11, 0x19, 0x15, 0x0d, 0x08,
0x16, 0x16, 0x08, 0x0d, 0x47, 0x60, 0xa7, 0x15, 0x16, 0xab, 0xc0, 0x54, 0x0f, 0x15, 0x00, 0x04,
0x00, 0x00, 0x00, 0x00, 0x01, 0xee, 0x01, 0x56, 0x00, 0x3e, 0x00, 0x52, 0x00, 0x65, 0x00, 0x77,
0x00, 0x00, 0x25, 0x2e, 0x01, 0x2f, 0x01, 0x36, 0x37, 0x35, 0x06, 0x07, 0x06, 0x22, 0x27, 0x26,
0x27, 0x15, 0x16, 0x17, 0x07, 0x0e, 0x01, 0x07, 0x06, 0x17, 0x16, 0x17, 0x16, 0x37, 0x36, 0x37,
0x36, 0x27, 0x37, 0x16, 0x17, 0x15, 0x0e, 0x01, 0x15, 0x14, 0x17, 0x16, 0x33, 0x32, 0x37, 0x36,
0x35, 0x34, 0x26, 0x27, 0x35, 0x36, 0x37, 0x17, 0x06, 0x17, 0x16, 0x17, 0x16, 0x37, 0x36, 0x37,
0x36, 0x25, 0x06, 0x07, 0x06, 0x07, 0x06, 0x27, 0x26, 0x37, 0x36, 0x3f, 0x01, 0x36, 0x37, 0x36,
0x17, 0x16, 0x17, 0x16, 0x07, 0x17, 0x22, 0x27, 0x26, 0x2f, 0x01, 0x37, 0x36, 0x37, 0x36, 0x32,
0x17, 0x16, 0x1f, 0x01, 0x07, 0x06, 0x07, 0x06, 0x37, 0x06, 0x27, 0x26, 0x2f, 0x01, 0x34, 0x37,
0x36, 0x37, 0x36, 0x17, 0x16, 0x1f, 0x01, 0x16, 0x07, 0x06, 0x01, 0xe1, 0x03, 0x11, 0x0c, 0x09,
0x10, 0x0d, 0x23, 0x33, 0x3b, 0x88, 0x3b, 0x33, 0x23, 0x0d, 0x10, 0x09, 0x0c, 0x11, 0x03, 0x0c,
0x03, 0x04, 0x24, 0x18, 0x15, 0x1e, 0x12, 0x07, 0x10, 0x09, 0x1a, 0x20, 0x0a, 0x0b, 0x0b, 0x10,
0x25, 0x19, 0x10, 0x17, 0x0b, 0x0a, 0x20, 0x1a, 0x09, 0x10, 0x07, 0x12, 0x1e, 0x15, 0x18, 0x23,
0x05, 0x03, 0xfe, 0x8f, 0x04, 0x03, 0x09, 0x0a, 0x0e, 0x0e, 0x13, 0x03, 0x02, 0x06, 0x03, 0x05,
0x07, 0x0a, 0x0d, 0x14, 0x04, 0x02, 0x02, 0x84, 0x0e, 0x09, 0x05, 0x03, 0x01, 0x01, 0x03, 0x05,
0x09, 0x1c, 0x09, 0x05, 0x03, 0x01, 0x01, 0x03, 0x05, 0x09, 0xac, 0x13, 0x14, 0x09, 0x06, 0x01,
0x05, 0x06, 0x0e, 0x13, 0x0c, 0x05, 0x02, 0x05, 0x04, 0x01, 0x02, 0xcd, 0x0d, 0x16, 0x07, 0x24,
0x05, 0x06, 0x2f, 0x13, 0x0b, 0x0d, 0x0d, 0x0b, 0x13, 0x2f, 0x06, 0x05, 0x24, 0x07, 0x16, 0x0d,
0x31, 0x21, 0x31, 0x09, 0x06, 0x12, 0x1a, 0x48, 0x1c, 0x17, 0x25, 0x04, 0x02, 0x26, 0x09, 0x1a,
0x0d, 0x32, 0x20, 0x2e, 0x16, 0x20, 0x4a, 0x0d, 0x1a, 0x09, 0x26, 0x02, 0x04, 0x25, 0x17, 0x1c,
0x48, 0x1a, 0x12, 0x06, 0x09, 0x31, 0x21, 0x1a, 0x0c, 0x0c, 0x1b, 0x10, 0x16, 0x03, 0x05, 0x31,
0x18, 0x17, 0x08, 0x09, 0x04, 0x06, 0x03, 0x05, 0x11, 0x09, 0x08, 0x6b, 0x19, 0x11, 0x1d, 0x19,
0x08, 0x09, 0x06, 0x09, 0x09, 0x06, 0x09, 0x08, 0x19, 0x1d, 0x11, 0x19, 0x15, 0x05, 0x2d, 0x17,
0x17, 0x08, 0x0a, 0x07, 0x0a, 0x04, 0x04, 0x0d, 0x07, 0x08, 0x18, 0x1c, 0x13, 0x1a, 0x00, 0x04,
0xff, 0xff, 0xff, 0xea, 0x02, 0x00, 0x01, 0x6b, 0x00, 0x22, 0x00, 0x2f, 0x00, 0x3c, 0x00, 0x52,
0x00, 0x00, 0x01, 0x35, 0x34, 0x26, 0x2b, 0x01, 0x22, 0x07, 0x26, 0x2b, 0x01, 0x22, 0x06, 0x1d,
0x01, 0x22, 0x06, 0x1d, 0x01, 0x14, 0x16, 0x33, 0x15, 0x33, 0x35, 0x21, 0x15, 0x33, 0x35, 0x32,
0x36, 0x3d, 0x01, 0x34, 0x26, 0x27, 0x33, 0x32, 0x16, 0x1d, 0x01, 0x06, 0x1d, 0x01, 0x23, 0x35,
0x34, 0x36, 0x07, 0x34, 0x36, 0x3b, 0x01, 0x32, 0x16, 0x1d, 0x01, 0x23, 0x35, 0x34, 0x27, 0x05,
0x14, 0x06, 0x23, 0x21, 0x22, 0x26, 0x3d, 0x01, 0x34, 0x36, 0x32, 0x16, 0x1d, 0x01, 0x21, 0x35,
0x34, 0x36, 0x32, 0x16, 0x15, 0x01, 0xc0, 0x26, 0x1a, 0x55, 0x19, 0x12, 0x12, 0x19, 0x55, 0x1a,
0x26, 0x1a, 0x26, 0x26, 0x1a, 0x2b, 0x01, 0x2a, 0x2b, 0x1a, 0x26, 0x26, 0xaf, 0x55, 0x09, 0x0c,
0x15, 0x6b, 0x0d, 0xb7, 0x0c, 0x09, 0x55, 0x09, 0x0d, 0x6b, 0x15, 0x01, 0x6a, 0x0c, 0x09, 0xfe,
0x80, 0x09, 0x0c, 0x0c, 0x12, 0x0c, 0x01, 0x56, 0x0c, 0x12, 0x0c, 0x01, 0x00, 0x2b, 0x1a, 0x26,
0x11, 0x11, 0x26, 0x1a, 0x2b, 0x26, 0x1a, 0x6b, 0x1a, 0x26, 0x2a, 0x2a, 0x2a, 0x2a, 0x26, 0x1a,
0x6b, 0x1a, 0x26, 0x40, 0x0d, 0x08, 0x3c, 0x13, 0x1c, 0x2b, 0x96, 0x08, 0x0d, 0x15, 0x08, 0x0d,
0x0d, 0x08, 0x96, 0x2b, 0x1c, 0x13, 0x9a, 0x08, 0x0d, 0x0d, 0x08, 0x6b, 0x09, 0x0c, 0x0c, 0x09,
0x55, 0x55, 0x09, 0x0c, 0x0c, 0x09, 0x00, 0x05, 0x00, 0x00, 0xff, 0xe8, 0x01, 0xeb, 0x01, 0x96,
0x00, 0x08, 0x00, 0x16, 0x00, 0x1f, 0x00, 0x28, 0x00, 0x4c, 0x00, 0x00, 0x25, 0x14, 0x06, 0x22,
0x26, 0x34, 0x36, 0x32, 0x16, 0x27, 0x15, 0x14, 0x16, 0x33, 0x15, 0x36, 0x32, 0x17, 0x35, 0x32,
0x36, 0x3d, 0x01, 0x07, 0x22, 0x06, 0x14, 0x16, 0x32, 0x36, 0x34, 0x26, 0x21, 0x22, 0x06, 0x14,
0x16, 0x32, 0x36, 0x34, 0x26, 0x07, 0x22, 0x27, 0x06, 0x22, 0x26, 0x27, 0x06, 0x22, 0x27, 0x0e,
0x01, 0x22, 0x27, 0x06, 0x23, 0x22, 0x27, 0x1e, 0x01, 0x33, 0x32, 0x37, 0x15, 0x14, 0x16, 0x32,
0x36, 0x3d, 0x01, 0x16, 0x33, 0x32, 0x36, 0x37, 0x06, 0x01, 0x40, 0x25, 0x36, 0x25, 0x25, 0x36,
0x25, 0x80, 0x19, 0x12, 0x0b, 0x14, 0x0b, 0x12, 0x19, 0xeb, 0x1a, 0x26, 0x26, 0x35, 0x25, 0x25,
0x01, 0x3b, 0x1b, 0x25, 0x25, 0x35, 0x26, 0x26, 0x1a, 0x16, 0x13, 0x11, 0x22, 0x1f, 0x0a, 0x11,
0x2a, 0x11, 0x0a, 0x1f, 0x22, 0x11, 0x13, 0x16, 0x07, 0x0a, 0x11, 0x30, 0x1b, 0x2a, 0x21, 0x0c,
0x12, 0x0c, 0x21, 0x2a, 0x1b, 0x30, 0x11, 0x0a, 0xa9, 0x1b, 0x25, 0x25, 0x35, 0x26, 0x26, 0xd2,
0x15, 0x12, 0x19, 0x57, 0x02, 0x02, 0x57, 0x19, 0x12, 0x15, 0xc2, 0x25, 0x35, 0x26, 0x26, 0x35,
0x25, 0x25, 0x35, 0x26, 0x26, 0x35, 0x25, 0x95, 0x08, 0x08, 0x0f, 0x0d, 0x07, 0x07, 0x0d, 0x0f,
0x08, 0x08, 0x02, 0x15, 0x18, 0x1b, 0x30, 0x09, 0x0c, 0x0c, 0x09, 0x30, 0x1b, 0x18, 0x15, 0x02,
0x00, 0x00, 0x00, 0x06, 0x00, 0x4e, 0x00, 0x03, 0x00, 0x01, 0x04, 0x09, 0x00, 0x01, 0x00, 0x2a,
0x00, 0x00, 0x00, 0x03, 0x00, 0x01, 0x04, 0x09, 0x00, 0x02, 0x00, 0x0e, 0x00, 0x2a, 0x00, 0x03,
0x00, 0x01, 0x04, 0x09, 0x00, 0x03, 0x00, 0x2a, 0x00, 0x00, 0x00, 0x03, 0x00, 0x01, 0x04, 0x09,
0x00, 0x04, 0x00, 0x2a, 0x00, 0x00, 0x00, 0x03, 0x00, 0x01, 0x04, 0x09, 0x00, 0x05, 0x00, 0x16,
0x00, 0x38, 0x00, 0x03, 0x00, 0x01, 0x04, 0x09, 0x00, 0x06, 0x00, 0x26, 0x00, 0x4e, 0x00, 0x4d,
0x00, 0x61, 0x00, 0x74, 0x00, 0x65, 0x00, 0x72, 0x00, 0x69, 0x00, 0x61, 0x00, 0x6c, 0x00, 0x20,
0x00, 0x44, 0x00, 0x65, 0x00, 0x73, 0x00, 0x69, 0x00, 0x67, 0x00, 0x6e, 0x00, 0x20, 0x00, 0x49,
0x00, 0x63, 0x00, 0x6f, 0x00, 0x6e, 0x00, 0x73, 0x00, 0x52, 0x00, 0x65, 0x00, 0x67, 0x00, 0x75,
0x00, 0x6c, 0x00, 0x61, 0x00, 0x72, 0x00, 0x56, 0x00, 0x65, 0x00, 0x72, 0x00, 0x73, 0x00, 0x69,
0x00, 0x6f, 0x00, 0x6e, 0x00, 0x20, 0x00, 0x31, 0x00, 0x2e, 0x00, 0x30, 0x00, 0x4d, 0x00, 0x61,
0x00, 0x74, 0x00, 0x65, 0x00, 0x72, 0x00, 0x69, 0x00, 0x61, 0x00, 0x6c, 0x00, 0x44, 0x00, 0x65,
0x00, 0x73, 0x00, 0x69, 0x00, 0x67, 0x00, 0x6e, 0x00, 0x49, 0x00, 0x63, 0x00, 0x6f, 0x00, 0x6e,
0x00, 0x73, 0x00, 0x00, 0x00, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x05, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x0a, 0x00, 0x26, 0x00, 0x28, 0x00, 0x02,
0x44, 0x46, 0x4c, 0x54, 0x00, 0x0e, 0x6c, 0x61, 0x74, 0x6e, 0x00, 0x18, 0x00, 0x04, 0x00, 0x00,
0x00, 0x00, 0xff, 0xff, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
};
+1 -1
View File
@@ -4,7 +4,7 @@
#include <stdbool.h>
#include "esp_err.h"
/* Calls lv_init() and creates the LVGL mutex. Must run before display_init()
/* Calls lv_init() and creates the LVGL mutex. Must run before display_start()
* and touch_init(), which register themselves with LVGL. */
esp_err_t lvgl_port_init(void);
+48
View File
@@ -0,0 +1,48 @@
/*
* GENERATED by tools/make_icon_font.py -- do not edit by hand.
* Subset of Material Design Icons (Apache-2.0), rasterised at runtime
* by LVGL tiny_ttf. Regeneration instructions are in the script.
*/
#ifndef UI_ICONS_H
#define UI_ICONS_H
#include <stddef.h>
/* The subset TTF; feed to lv_tiny_ttf_create_data(). */
extern const unsigned char icon_font_data[];
extern const size_t icon_font_data_size;
/* Each icon is a single glyph as a UTF-8 string -- usable as label text
* in any font created from icon_font_data. */
#define ICON_W_SUNNY "\xf3\xb0\x96\x99" /* U+F0599 */
#define ICON_W_NIGHT "\xf3\xb0\x96\x94" /* U+F0594 */
#define ICON_W_PARTLY "\xf3\xb0\x96\x95" /* U+F0595 */
#define ICON_W_CLOUDY "\xf3\xb0\x96\x90" /* U+F0590 */
#define ICON_W_RAINY "\xf3\xb0\x96\x97" /* U+F0597 */
#define ICON_W_POURING "\xf3\xb0\x96\x96" /* U+F0596 */
#define ICON_W_LIGHTNING "\xf3\xb0\x96\x93" /* U+F0593 */
#define ICON_W_LIGHTNING_RAIN "\xf3\xb0\x99\xbe" /* U+F067E */
#define ICON_W_SNOWY "\xf3\xb0\x96\x98" /* U+F0598 */
#define ICON_W_SNOWY_RAIN "\xf3\xb0\x99\xbf" /* U+F067F */
#define ICON_W_FOG "\xf3\xb0\x96\x91" /* U+F0591 */
#define ICON_W_WINDY "\xf3\xb0\x96\x9d" /* U+F059D */
#define ICON_W_HAIL "\xf3\xb0\x96\x92" /* U+F0592 */
#define ICON_W_ALERT "\xf3\xb0\x97\x96" /* U+F05D6 */
#define ICON_THERMOMETER "\xf3\xb0\x94\x8f" /* U+F050F */
#define ICON_HOME_THERMO "\xf3\xb0\xbd\x95" /* U+F0F55 */
#define ICON_WATER "\xf3\xb0\xb8\x8a" /* U+F0E0A */
#define ICON_SUNRISE "\xf3\xb0\x96\x9c" /* U+F059C */
#define ICON_SUNSET "\xf3\xb0\x96\x9b" /* U+F059B */
#define ICON_GAUGE "\xf3\xb0\x8a\x9a" /* U+F029A */
#define ICON_TREND_UP "\xf3\xb0\x94\xb5" /* U+F0535 */
#define ICON_TREND_DOWN "\xf3\xb0\x94\xb3" /* U+F0533 */
#define ICON_TV "\xf3\xb0\x94\x82" /* U+F0502 */
#define ICON_SOFA "\xf3\xb1\x95\xad" /* U+F156D */
#define ICON_DINING "\xf3\xb0\xa9\xb0" /* U+F0A70 */
#define ICON_FLOOR_LAMP "\xf3\xb0\xa3\x9d" /* U+F08DD */
#define ICON_DESK_LAMP "\xf3\xb0\xa5\x9f" /* U+F095F */
#define ICON_CHANDELIER "\xf3\xb1\x9e\x93" /* U+F1793 */
#define ICON_STRING_LIGHTS "\xf3\xb1\x8a\xba" /* U+F12BA */
#endif
+1211 -96
View File
File diff suppressed because it is too large. Load diff
+53 -3
View File
@@ -3,19 +3,69 @@
#include <stdbool.h>
#include <stdint.h>
#include "ha_data.h"
#include "lvgl.h"
#include "thermostat.h"
/*
* The screen layout.
*
* This file is compiled into the desktop simulator as well as the firmware
* (see sim/README.md), so it must never touch a driver, a socket or ESP-IDF
* directly. Data arrives as ha_data_t; commands leave through the callback
* below. Everything in between is drawing.
*/
void ui_init(void);
/* Safe to call from any task -- these take the LVGL lock internally. */
void ui_update_weather(int16_t temp_f, const char *condition);
/* Redraw every panel from `data`. Cheap to call when nothing changed: every
* label goes through a comparison first, so an unchanged value costs no
* invalidation and therefore no flush. */
void ui_update(const ha_data_t *data);
void ui_set_status(const char *status);
/*
* Mark the displayed reading as no longer trustworthy. Dims the temperature
* Mark the displayed readings as no longer trustworthy. Dims the temperature
* so a value that stopped updating is visibly distinct from a live one,
* rather than sitting there at full styling looking current.
*/
void ui_set_weather_stale(bool stale);
void ui_set_stale(bool stale);
/*
* Called when the user operates a light tile.
*
* `index` indexes HA_LIGHTS. `brightness` is 0-255, or -1 for a plain on/off
* with no brightness change (a tile tap, or any switch-domain entity).
*
* Runs on the LVGL task with the LVGL lock held, so the implementation must
* not block: hand the command to another task rather than doing HTTP here.
*/
typedef void (*ui_light_cmd_cb_t)(int index, bool on, int16_t brightness);
void ui_set_light_cb(ui_light_cmd_cb_t cb);
/* Redraw the thermostat card from `st`. Same cheapness rules as ui_update. */
void ui_update_thermostat(const therm_status_t *st);
typedef enum {
UI_THERM_SETPOINT_UP = 0,
UI_THERM_SETPOINT_DOWN,
UI_THERM_MODE_TOGGLE,
UI_THERM_FAN_TOGGLE,
} ui_therm_cmd_t;
/*
* Called when the user operates a thermostat control.
*
* Runs on the LVGL task with the lock held, so it must not block -- but
* unlike the light commands there is no network here, so the handler can
* apply the change and call ui_update_thermostat() straight back. The LVGL
* lock is recursive, so that re-entry is fine.
*/
typedef void (*ui_therm_cmd_cb_t)(ui_therm_cmd_t cmd);
void ui_set_therm_cb(ui_therm_cmd_cb_t cb);
#endif
+27 -1
View File
@@ -30,9 +30,35 @@ main/
│ ├── ui_manager.h
│ └── ui_manager.c
└── api/
├── ha_config.h (entity IDs -- tracked, not secret)
├── ha_template.h (Jinja template, built from the HA_LIGHT_LIST X-macro)
├── ha_data.h
├── ha_data.c (parsing + presentation -- SHARED with sim/)
├── ha_client.h
├── ha_client.c (esp_http_client transport -- device only)
├── ambient_weather.h
└── ambient_weather.c
└── ambient_weather.c (cloud fallback, used only when HA is unreachable)
sim/ (desktop simulator -- NOT part of the IDF build)
├── CMakeLists.txt (own cmake project; idf.py never looks in here)
├── lv_conf.h (host LVGL config; font list must match sdkconfig.defaults)
├── sim_main.c (SDL window / headless PNG capture, mock data)
├── lvgl_port_sim.c (host implementation of main/ui/lvgl_port.h)
├── README.md
├── snapshots/ (captured HA responses, used as render fixtures)
└── compat/ (ESP-IDF header stand-ins, so main/ui/*.c compiles as-is)
├── esp_err.h
├── esp_log.h
├── esp_check.h
└── esp_compat.c
tools/
├── ha_discover.py (list Home Assistant entity IDs; reads $HA_TOKEN)
└── ha_snapshot.py (capture HA responses into sim/snapshots/)
Note: main/ subdirectories are plain source folders, not ESP-IDF components.
IDF only auto-discovers components under a top-level components/ directory,
so all sources are registered by main/CMakeLists.txt.
The simulator compiles main/ui/ui_manager.c unmodified against desktop LVGL,
which is why that file must stay free of hardware access. See sim/README.md.
+16
View File
@@ -76,9 +76,25 @@ CONFIG_MBEDTLS_SSL_OUT_CONTENT_LEN=4096
CONFIG_LV_COLOR_DEPTH_16=y
CONFIG_LV_USE_LOG=y
CONFIG_LV_LOG_LEVEL_WARN=y
# Must stay identical to the LV_FONT_MONTSERRAT_* set in sim/lv_conf.h. A font
# enabled in one build but not the other makes the simulator lie about layout:
# text silently falls back to a different size and every metric shifts.
CONFIG_LV_FONT_MONTSERRAT_12=y
CONFIG_LV_FONT_MONTSERRAT_14=y
CONFIG_LV_FONT_MONTSERRAT_16=y
CONFIG_LV_FONT_MONTSERRAT_20=y
CONFIG_LV_FONT_MONTSERRAT_24=y
CONFIG_LV_FONT_MONTSERRAT_28=y
CONFIG_LV_FONT_MONTSERRAT_32=y
CONFIG_LV_FONT_MONTSERRAT_36=y
CONFIG_LV_FONT_MONTSERRAT_40=y
CONFIG_LV_FONT_MONTSERRAT_48=y
# --- Main task needs headroom for driver init ---
CONFIG_ESP_MAIN_TASK_STACK_SIZE=8192
CONFIG_LV_USE_TINY_TTF=y
# CONFIG_USJ_ENABLE_USB_SERIAL_JTAG is not set
# CONFIG_ESP_PHY_ENABLE_USB is not set
+95
View File
@@ -0,0 +1,95 @@
# Desktop simulator for the thermostat UI. Not part of the ESP-IDF build --
# `idf.py build` never looks in here, and this never builds firmware sources
# that touch hardware.
#
# cmake -S sim -B sim/build -G Ninja && cmake --build sim/build
#
# See sim/README.md.
cmake_minimum_required(VERSION 3.16)
project(thermostat_sim C)
set(CMAKE_C_STANDARD 11)
set(CMAKE_C_STANDARD_REQUIRED ON)
set(CMAKE_EXPORT_COMPILE_COMMANDS ON)
set(FW_ROOT ${CMAKE_CURRENT_LIST_DIR}/..)
set(LVGL_DIR ${FW_ROOT}/managed_components/lvgl__lvgl)
# LVGL is pulled in by the IDF component manager, so it lives in a gitignored
# directory that only exists after an IDF build. Using it anyway -- rather than
# fetching a second copy -- guarantees the simulator and the firmware render
# with the identical LVGL version. A simulator on a different version is a
# simulator that lies.
if(NOT EXISTS ${LVGL_DIR}/lvgl.h)
message(FATAL_ERROR
"LVGL not found at ${LVGL_DIR}\n"
"It is fetched by the ESP-IDF component manager. Run once:\n"
" . ~/esp/esp-idf/export.sh && idf.py reconfigure")
endif()
# Nothing here uses LVGL's bundled demos or examples, and they add real build
# time. ThorVG is a C++ vector engine we do not use either.
set(CONFIG_LV_BUILD_DEMOS OFF CACHE BOOL "" FORCE)
set(CONFIG_LV_BUILD_EXAMPLES OFF CACHE BOOL "" FORCE)
set(CONFIG_LV_USE_THORVG_INTERNAL OFF CACHE BOOL "" FORCE)
# LVGL's desktop CMake picks up lv_conf.h from CMAKE_SOURCE_DIR, which is this
# directory.
add_subdirectory(${LVGL_DIR} ${CMAKE_BINARY_DIR}/lvgl)
find_package(SDL2 REQUIRED)
# cJSON comes from the ESP-IDF tree rather than the host's libcjson, so the
# simulator parses Home Assistant replies with the exact same parser the
# firmware uses. IDF_PATH is only needed for this one directory -- the rest of
# the simulator builds with no ESP-IDF environment at all.
if(DEFINED ENV{IDF_PATH})
set(IDF_ROOT $ENV{IDF_PATH})
else()
set(IDF_ROOT $ENV{HOME}/esp/esp-idf)
endif()
set(CJSON_DIR ${IDF_ROOT}/components/json/cJSON)
if(NOT EXISTS ${CJSON_DIR}/cJSON.c)
message(FATAL_ERROR
"cJSON not found at ${CJSON_DIR}\n"
"Set IDF_PATH, or install ESP-IDF to ~/esp/esp-idf.")
endif()
add_executable(thermostat_sim
sim_main.c
lvgl_port_sim.c
relay_sim.c
compat/esp_compat.c
# The point of the exercise: real firmware sources, compiled unmodified.
# Anything added here must stay free of hardware access -- drivers and
# HTTP transport stay on the device side.
${FW_ROOT}/main/ui/ui_manager.c
${FW_ROOT}/main/ui/icon_font.c
${FW_ROOT}/main/api/ha_data.c
${FW_ROOT}/main/control/thermostat.c
${CJSON_DIR}/cJSON.c
)
target_include_directories(thermostat_sim PRIVATE
${CMAKE_CURRENT_LIST_DIR}
${CMAKE_CURRENT_LIST_DIR}/compat # ESP-IDF header stand-ins
${FW_ROOT}/main/ui # ui_manager.h, lvgl_port.h
${FW_ROOT}/main/api # ha_data.h, ha_config.h, ha_template.h
${FW_ROOT}/main/control # thermostat.h
${FW_ROOT}/main/drivers/relay # relay.h, implemented by relay_sim.c
${CJSON_DIR}
${LVGL_DIR}/src/libs/lodepng # PNG encoder for --png mode
${SDL2_INCLUDE_DIRS}
)
# SDL2_LIBRARIES can carry trailing whitespace from sdl2-config, which makes
# the link line fail in confusing ways.
string(STRIP "${SDL2_LIBRARIES}" SDL2_LIBRARIES)
target_link_libraries(thermostat_sim PRIVATE lvgl ${SDL2_LIBRARIES} m pthread)
target_compile_options(thermostat_sim PRIVATE -Wall -Wextra -Wno-unused-parameter)
+109
View File
@@ -0,0 +1,109 @@
# Desktop simulator
Runs the firmware's UI on a Linux desktop so layout work does not need a
flash-and-look cycle. `main/ui/ui_manager.c` is compiled **unmodified** — the
same source the device runs.
## Build
```bash
cmake -S sim -B sim/build -G Ninja
cmake --build sim/build
```
LVGL comes from `managed_components/lvgl__lvgl`, which the ESP-IDF component
manager populates. If that directory is missing, CMake says so; run
`. ~/esp/esp-idf/export.sh && idf.py reconfigure` once to fetch it.
No ESP-IDF environment is needed to *build* the simulator — only a host
compiler, CMake, Ninja and `libsdl2-dev`.
## Run
```bash
./sim/build/thermostat_sim # SDL window, mouse acts as touch
./sim/build/thermostat_sim --png shot.png # render one frame, write a PNG, exit
./sim/build/thermostat_sim --png shot.png --settle-ms 1500
```
The `--png` mode is what makes UI review possible without a human at a screen:
it renders headless, so it works over SSH and in CI, and the resulting image
can be inspected directly.
Clicking a light tile in the window calls the same `ui_set_light_cb` path the
firmware uses; the simulator applies the change locally and logs the command,
so tiles respond with no Home Assistant present.
## Snapshots
The interesting screens — rain falling, a light dimmed, a sensor dropping out
— do not happen on demand, and live data changes underneath a before/after
comparison. So capture real responses and render those:
```bash
export HA_TOKEN=$(cat ~/.config/thermostat-hw/ha_token)
python3 tools/ha_snapshot.py --name rainy
./sim/build/thermostat_sim --data sim/snapshots/rainy.json \
--forecast sim/snapshots/rainy.forecast.json
```
Snapshots are plain JSON and are meant to be hand-edited into states that have
not occurred yet — `sim/snapshots/evening.json` was made that way, to check
the on/dimmed/unavailable tile styling while every real light was off.
`ha_snapshot.py` does **not** carry its own copy of the Jinja template. It
reads it from `thermostat_sim --print-template`, which the preprocessor builds
from `main/api/ha_template.h`, so a snapshot cannot be captured with a
template the firmware does not use.
## How it stays honest
A simulator that renders differently from the panel is worse than none, so
these are deliberately pinned to the device:
| Property | Value | Enforced by |
|---|---|---|
| Resolution | 800×480 | `PANEL_H_RES/V_RES` in `sim_main.c` |
| Colour depth | RGB565 | `LV_COLOR_DEPTH 16` in `lv_conf.h` |
| Render mode | `DIRECT` | `lv_conf.h` / `headless_display_create()` |
| LVGL version | whatever IDF resolved | builds the same `managed_components` tree |
| Fonts | Montserrat 12–48 | `lv_conf.h` **and** `sdkconfig.defaults` |
**The font lists must be edited together.** Enabling a size in one build and
not the other makes the simulator lie: text silently falls back to a different
size and every metric shifts.
## What it does not simulate
- **PSRAM bandwidth, the bounce-buffer refill ISR, and RGB scan timing.** The
left-edge artefacts in the root README are invisible here by construction.
This tool answers "does the layout look right", never "does the panel keep
up". Timing-sensitive changes still need hardware.
- **Touch hardware.** The mouse is a stand-in for the GT911: one contact
point, pixel-accurate, no palm rejection, and none of the I2C contention the
real panel has with the backlight controller.
- **Anything in `main/drivers/`.** Those touch real peripherals and are never
compiled here.
## Layout
| Path | Purpose |
|---|---|
| `sim_main.c` | Entry point: display setup, PNG capture, mock data |
| `lvgl_port_sim.c` | Host implementation of the firmware's `lvgl_port.h` |
| `compat/` | Stand-ins for the ESP-IDF headers shared sources include |
| `lv_conf.h` | Host LVGL config, mirroring the device's Kconfig settings |
`compat/` exists so firmware sources compile as-is instead of growing
`#ifdef`s. If a newly shared source fails to compile, extend `compat/` — do
not fork the firmware source.
## Gotchas
- **Montserrat has no `·` (U+00B7).** It renders as a tofu box.
`weather_get_string()` uses one, but its output only ever reaches a log
line. Anything routed to an on-screen label must avoid it.
- **`lodepng_encode32_file()` does not work here.** LVGL patches its bundled
lodepng to do file I/O through `lv_fs`, which needs a registered driver and
drive-letter paths. `sim_main.c` encodes to memory and uses `fwrite`.
+46
View File
@@ -0,0 +1,46 @@
/*
* Host stand-in for ESP-IDF's esp_check.h -- see compat/esp_err.h.
*/
#ifndef ESP_CHECK_H
#define ESP_CHECK_H
#include "esp_err.h"
#include "esp_log.h"
#define ESP_RETURN_ON_ERROR(x, tag, fmt, ...) \
do { \
const esp_err_t err_rc_ = (x); \
if (err_rc_ != ESP_OK) { \
ESP_LOGE(tag, fmt, ##__VA_ARGS__); \
return err_rc_; \
} \
} while (0)
#define ESP_RETURN_ON_FALSE(a, err_code, tag, fmt, ...) \
do { \
if (!(a)) { \
ESP_LOGE(tag, fmt, ##__VA_ARGS__); \
return (err_code); \
} \
} while (0)
#define ESP_GOTO_ON_ERROR(x, goto_tag, tag, fmt, ...) \
do { \
const esp_err_t err_rc_ = (x); \
if (err_rc_ != ESP_OK) { \
ret = err_rc_; \
ESP_LOGE(tag, fmt, ##__VA_ARGS__); \
goto goto_tag; \
} \
} while (0)
#define ESP_GOTO_ON_FALSE(a, err_code, goto_tag, tag, fmt, ...) \
do { \
if (!(a)) { \
ret = (err_code); \
ESP_LOGE(tag, fmt, ##__VA_ARGS__); \
goto goto_tag; \
} \
} while (0)
#endif
+28
View File
@@ -0,0 +1,28 @@
#include <stdio.h>
#include <stdlib.h>
#include "esp_err.h"
const char *esp_err_to_name(esp_err_t code)
{
switch (code) {
case ESP_OK: return "ESP_OK";
case ESP_FAIL: return "ESP_FAIL";
case ESP_ERR_NO_MEM: return "ESP_ERR_NO_MEM";
case ESP_ERR_INVALID_ARG: return "ESP_ERR_INVALID_ARG";
case ESP_ERR_INVALID_STATE: return "ESP_ERR_INVALID_STATE";
case ESP_ERR_INVALID_SIZE: return "ESP_ERR_INVALID_SIZE";
case ESP_ERR_NOT_FOUND: return "ESP_ERR_NOT_FOUND";
case ESP_ERR_NOT_SUPPORTED: return "ESP_ERR_NOT_SUPPORTED";
case ESP_ERR_TIMEOUT: return "ESP_ERR_TIMEOUT";
case ESP_ERR_INVALID_RESPONSE: return "ESP_ERR_INVALID_RESPONSE";
default: return "ESP_ERR_UNKNOWN";
}
}
void esp_error_check_failed(esp_err_t rc, const char *file, int line, const char *expr)
{
fprintf(stderr, "ESP_ERROR_CHECK failed: %s (0x%x) at %s:%d\n expression: %s\n",
esp_err_to_name(rc), rc, file, line, expr);
abort();
}
+45
View File
@@ -0,0 +1,45 @@
/*
* Host stand-in for ESP-IDF's esp_err.h.
*
* The simulator compiles the real firmware UI sources unmodified. Those
* sources include ESP-IDF headers, so the simulator supplies its own versions
* on the include path rather than sprinkling #ifdefs through main/. Only the
* surface the shared sources actually use is defined here -- if a new firmware
* source is added to the simulator build and fails to compile, extend this
* file, do not fork the firmware source.
*/
#ifndef ESP_ERR_H
#define ESP_ERR_H
#include <stdbool.h>
#include <stdint.h>
typedef int esp_err_t;
#define ESP_OK 0
#define ESP_FAIL -1
#define ESP_ERR_NO_MEM 0x101
#define ESP_ERR_INVALID_ARG 0x102
#define ESP_ERR_INVALID_STATE 0x103
#define ESP_ERR_INVALID_SIZE 0x104
#define ESP_ERR_NOT_FOUND 0x105
#define ESP_ERR_NOT_SUPPORTED 0x106
#define ESP_ERR_TIMEOUT 0x107
#define ESP_ERR_INVALID_RESPONSE 0x108
const char *esp_err_to_name(esp_err_t code);
/* Aborts on the host too -- a failed ESP_ERROR_CHECK is a bug worth crashing
* on in the simulator exactly as it is on the device. */
void esp_error_check_failed(esp_err_t rc, const char *file, int line, const char *expr);
#define ESP_ERROR_CHECK(x) \
do { \
const esp_err_t err_rc_ = (x); \
if (err_rc_ != ESP_OK) { \
esp_error_check_failed(err_rc_, __FILE__, __LINE__, #x); \
} \
} while (0)
#endif
+28
View File
@@ -0,0 +1,28 @@
/*
* Host stand-in for ESP-IDF's esp_log.h -- see compat/esp_err.h for why these
* shims exist. Logs go to stderr so that stdout stays clean for anything the
* simulator wants to emit as data.
*/
#ifndef ESP_LOG_H
#define ESP_LOG_H
#include <stdio.h>
/* ANSI colours matched to idf.py monitor, so simulator output reads the same
* way device output does. */
#define ESP_LOG_COLOR_E "\033[0;31m"
#define ESP_LOG_COLOR_W "\033[0;33m"
#define ESP_LOG_COLOR_I "\033[0;32m"
#define ESP_LOG_COLOR_D "\033[0;36m"
#define ESP_LOG_RESET "\033[0m"
#define ESP_LOG_LEVEL_LOCAL(colour, letter, tag, fmt, ...) \
fprintf(stderr, colour letter " (sim) %s: " fmt ESP_LOG_RESET "\n", tag, ##__VA_ARGS__)
#define ESP_LOGE(tag, fmt, ...) ESP_LOG_LEVEL_LOCAL(ESP_LOG_COLOR_E, "E", tag, fmt, ##__VA_ARGS__)
#define ESP_LOGW(tag, fmt, ...) ESP_LOG_LEVEL_LOCAL(ESP_LOG_COLOR_W, "W", tag, fmt, ##__VA_ARGS__)
#define ESP_LOGI(tag, fmt, ...) ESP_LOG_LEVEL_LOCAL(ESP_LOG_COLOR_I, "I", tag, fmt, ##__VA_ARGS__)
#define ESP_LOGD(tag, fmt, ...) ESP_LOG_LEVEL_LOCAL(ESP_LOG_COLOR_D, "D", tag, fmt, ##__VA_ARGS__)
#define ESP_LOGV(tag, fmt, ...) ESP_LOG_LEVEL_LOCAL(ESP_LOG_COLOR_D, "V", tag, fmt, ##__VA_ARGS__)
#endif
+1530
View File
File diff suppressed because it is too large. Load diff
+123
View File
@@ -0,0 +1,123 @@
/*
* Host implementation of the firmware's lvgl_port.h contract.
*
* The firmware version (main/ui/lvgl_port.c) is built on FreeRTOS tasks and
* esp_timer. This file provides the same six entry points on pthreads so that
* main/ui/ui_manager.c -- the file whose layout we actually iterate on --
* compiles and runs unchanged on the desktop.
*
* The one deliberate behavioural difference is lvgl_port_start(): on the
* device it spawns the task that calls lv_timer_handler(), whereas here the
* simulator's own main loop drives LVGL. See the comment on that function.
*/
#include <pthread.h>
#include <stdbool.h>
#include <time.h>
#include "esp_log.h"
#include "lvgl.h"
#include "lvgl_port.h"
static const char *TAG = "lvgl_port_sim";
static pthread_mutex_t lvgl_mutex;
static bool mutex_ready = false;
static volatile bool paused = false;
/* LVGL's millisecond time base. The firmware drives this from a 2ms esp_timer
* calling lv_tick_inc(); on the host a monotonic clock read is both simpler
* and more accurate. */
static uint32_t tick_get_cb(void)
{
struct timespec ts;
clock_gettime(CLOCK_MONOTONIC, &ts);
return (uint32_t)(ts.tv_sec * 1000U + ts.tv_nsec / 1000000U);
}
esp_err_t lvgl_port_init(void)
{
/*
* Recursive, matching the device: lv_timer_handler() runs callbacks that
* may re-enter code which also takes the lock. Keeping the same semantics
* here means a deadlock introduced in shared UI code reproduces in the
* simulator instead of only showing up on hardware.
*/
pthread_mutexattr_t attr;
pthread_mutexattr_init(&attr);
pthread_mutexattr_settype(&attr, PTHREAD_MUTEX_RECURSIVE);
if (pthread_mutex_init(&lvgl_mutex, &attr) != 0) {
pthread_mutexattr_destroy(&attr);
return ESP_ERR_NO_MEM;
}
pthread_mutexattr_destroy(&attr);
mutex_ready = true;
lv_init();
lv_tick_set_cb(tick_get_cb);
ESP_LOGI(TAG, "LVGL %d.%d.%d initialized (simulator)",
lv_version_major(), lv_version_minor(), lv_version_patch());
return ESP_OK;
}
/*
* No-op by design.
*
* On the device this starts the tick timer and the LVGL service task. Here the
* tick is a clock callback installed in lvgl_port_init(), and lv_timer_handler()
* is called from the simulator's main loop -- running it from a second thread
* as well would mean two threads rendering into one framebuffer.
*
* It still exists so that shared start-up code can call it unconditionally.
*/
esp_err_t lvgl_port_start(void)
{
return ESP_OK;
}
void lvgl_port_pause(void)
{
paused = true;
}
void lvgl_port_resume(void)
{
paused = false;
}
bool lvgl_port_is_paused(void)
{
return paused;
}
bool lvgl_port_lock(int timeout_ms)
{
if (!mutex_ready) {
return false;
}
if (timeout_ms < 0) {
return pthread_mutex_lock(&lvgl_mutex) == 0;
}
struct timespec deadline;
clock_gettime(CLOCK_REALTIME, &deadline);
deadline.tv_sec += timeout_ms / 1000;
deadline.tv_nsec += (long)(timeout_ms % 1000) * 1000000L;
if (deadline.tv_nsec >= 1000000000L) {
deadline.tv_sec++;
deadline.tv_nsec -= 1000000000L;
}
return pthread_mutex_timedlock(&lvgl_mutex, &deadline) == 0;
}
void lvgl_port_unlock(void)
{
if (mutex_ready) {
pthread_mutex_unlock(&lvgl_mutex);
}
}
+64
View File
@@ -0,0 +1,64 @@
/*
* Host implementation of relay.h -- a fake furnace.
*
* Lets the real control loop run on the desktop: hysteresis, minimum on and
* off times and the cycles-per-hour ceiling all behave exactly as they will on
* the device, with the relay changes printed instead of switching 24VAC.
*
* Timestamps are wall-clock relative to start-up so a log can be read against
* the protection timings in thermostat.h without arithmetic.
*/
#include <stdbool.h>
#include <stdio.h>
#include <time.h>
#include "relay.h"
static bool relay_state[RELAY_COUNT];
static struct timespec t0;
static double seconds_since_start(void)
{
struct timespec now;
clock_gettime(CLOCK_MONOTONIC, &now);
return (double)(now.tv_sec - t0.tv_sec) +
(double)(now.tv_nsec - t0.tv_nsec) / 1e9;
}
esp_err_t relay_init(void)
{
clock_gettime(CLOCK_MONOTONIC, &t0);
for (int i = 0; i < RELAY_COUNT; i++) {
relay_state[i] = false;
}
fprintf(stderr, "[relay-sim] both relays idle\n");
return ESP_OK;
}
void relay_set(relay_id_t id, bool energized)
{
if (id < 0 || id >= RELAY_COUNT || relay_state[id] == energized) {
return;
}
relay_state[id] = energized;
fprintf(stderr, "[relay-sim] %7.1fs %-8s -> %s\n",
seconds_since_start(), relay_name(id), energized ? "ON" : "OFF");
}
bool relay_get(relay_id_t id)
{
if (id < 0 || id >= RELAY_COUNT) {
return false;
}
return relay_state[id];
}
const char *relay_name(relay_id_t id)
{
switch (id) {
case RELAY_HEAT: return "heat(W)";
case RELAY_FAN: return "fan(G)";
default: return "?";
}
}
+477
View File
@@ -0,0 +1,477 @@
/*
* 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_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;
/*
* 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;
ha_trend_feed(HA_TREND_CH_INDOOR, 77.1f, hour_ago); /* -> rising */
ha_trend_feed(HA_TREND_CH_INDOOR, 78.1f, 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);
sim_data.trend_indoor = ha_trend_compute(HA_TREND_CH_INDOOR, 0.5f, now);
sim_data.trend_outdoor = ha_trend_compute(HA_TREND_CH_OUTDOOR, 0.5f, now);
sim_data.trend_pressure = ha_trend_compute(HA_TREND_CH_PRESSURE, 0.02f, 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_temp_f.valid;
thermostat_tick(have_temp ? sim_data.indoor_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;
}
+1
View File
@@ -0,0 +1 @@
{"out_t": 28.0, "out_h": 71.0, "in_t": 62.4, "in_h": 41.0, "dew": 21.0, "press": 29.899, "rain_d": 0.0, "rain_r": 0.0, "wind": null, "gust": null, "wdir": null, "cond": "snowy", "sunrise": "6:05 AM", "sunset": "7:45 PM", "sun_up": false, "clock": "6:12 AM", "date": "Tue Aug 4", "lights": [{"s": "off", "b": null}, {"s": "off", "b": null}, {"s": "off", "b": null}, {"s": "off", "b": null}, {"s": "unavailable", "b": null}, {"s": "off", "b": null}]}
+1
View File
@@ -0,0 +1 @@
{"out_t": 61.4, "out_h": 94.0, "in_t": 72.3, "in_h": 58.0, "dew": 59.0, "press": 29.71, "rain_d": 0.42, "rain_r": 0.11, "wind": null, "gust": null, "wdir": null, "cond": "rainy", "sunrise": "6:05 AM", "sunset": "7:45 PM", "sun_up": false, "clock": "9:42 PM", "date": "Tue Aug 4", "lights": [{"s": "on", "b": 255}, {"s": "on", "b": 128}, {"s": "on", "b": 40}, {"s": "off", "b": null}, {"s": "unavailable", "b": null}, {"s": "on", "b": null}]}
+1
View File
@@ -0,0 +1 @@
{"changed_states":[],"service_response":{"weather.forecast_home":{"forecast":[{"condition":"partlycloudy","datetime":"2026-08-04T19:00:00+00:00","wind_bearing":248.4,"uv_index":10.5,"temperature":80,"templow":70,"wind_speed":9.63,"precipitation":0.0,"humidity":72},{"condition":"sunny","datetime":"2026-08-05T19:00:00+00:00","wind_bearing":263.9,"uv_index":10.0,"temperature":80,"templow":67,"wind_speed":10.94,"precipitation":0.0,"humidity":70},{"condition":"sunny","datetime":"2026-08-06T19:00:00+00:00","wind_bearing":278.8,"uv_index":9.9,"temperature":82,"templow":68,"wind_speed":10.31,"precipitation":0.0,"humidity":73},{"condition":"clear-night","datetime":"2026-08-07T19:00:00+00:00","wind_bearing":285.6,"temperature":82,"templow":69,"wind_speed":6.28,"precipitation":0.0,"humidity":71},{"condition":"clear-night","datetime":"2026-08-08T19:00:00+00:00","wind_bearing":268.9,"temperature":82,"templow":68,"wind_speed":7.15,"precipitation":0.0,"humidity":72},{"condition":"clear-night","datetime":"2026-08-09T19:00:00+00:00","wind_bearing":212.3,"temperature":81,"templow":69,"wind_speed":8.08,"precipitation":0.0,"humidity":74}]}}}
+1
View File
@@ -0,0 +1 @@
{"out_t":76.6,"out_h":73.0,"in_t":78.6,"in_h":69.0,"dew":67.28,"press":29.899,"rain_d":0.0,"rain_r":0.0,"wind":null,"gust":null,"wdir":null,"cond":"partlycloudy","sunrise":"6:05 AM","sunset":"7:45 PM","sun_up":true,"clock":"11:32 AM","date":"Tue Aug 4","lights":[{"s":"off","b":null},{"s":"off","b":null},{"s":"off","b":null},{"s":"off","b":null},{"s":"unavailable","b":null},{"s":"off","b":null}]}
+115
View File
@@ -0,0 +1,115 @@
#!/usr/bin/env python3
"""
List the Home Assistant entities this project cares about.
Used to pin down entity IDs before wiring them into the firmware, and to
re-check them after changing something in HA -- entity IDs are not stable
across integration renames, and a wrong one fails silently as "unavailable".
export HA_TOKEN='...' # Profile -> Security -> Long-lived access token
python3 tools/ha_discover.py
The token is read from the environment and never printed or written anywhere.
--host HOST default 192.168.1.134
--all every entity, not just the interesting domains
--json raw JSON for the matched entities, including attributes
"""
import argparse
import json
import os
import sys
import urllib.error
import urllib.request
# The domains this display draws from. "sensor" is where a weather station's
# individual measurements land; "weather" is the forecast entity; "sun" gives
# sunrise/sunset; "light" is what we control.
DEFAULT_DOMAINS = ("weather", "sun", "light", "sensor", "binary_sensor", "switch")
def fetch(host: str, token: str, path: str):
req = urllib.request.Request(
f"http://{host}:8123{path}",
headers={
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
},
)
with urllib.request.urlopen(req, timeout=10) as resp:
return json.load(resp)
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--host", default="192.168.1.134")
ap.add_argument("--all", action="store_true", help="include every domain")
ap.add_argument("--json", action="store_true", help="dump raw JSON with attributes")
args = ap.parse_args()
token = os.environ.get("HA_TOKEN")
if not token:
print("HA_TOKEN is not set.\n"
"In Home Assistant: click your user (bottom left) -> Security tab ->\n"
"Long-lived access tokens -> Create token. Then:\n"
" export HA_TOKEN='paste-it-here'",
file=sys.stderr)
return 2
try:
states = fetch(args.host, token, "/api/states")
except urllib.error.HTTPError as e:
detail = "token rejected -- create a new long-lived token" if e.code == 401 else e.reason
print(f"HTTP {e.code} from {args.host}: {detail}", file=sys.stderr)
return 1
except urllib.error.URLError as e:
print(f"Cannot reach Home Assistant at {args.host}:8123 -- {e.reason}", file=sys.stderr)
return 1
matched = [
s for s in states
if args.all or s["entity_id"].split(".", 1)[0] in DEFAULT_DOMAINS
]
matched.sort(key=lambda s: s["entity_id"])
if args.json:
print(json.dumps(matched, indent=2))
return 0
print(f"{len(matched)} entities on {args.host} "
f"(of {len(states)} total)\n")
by_domain: dict[str, list] = {}
for s in matched:
by_domain.setdefault(s["entity_id"].split(".", 1)[0], []).append(s)
for domain in sorted(by_domain):
entities = by_domain[domain]
print(f"=== {domain} ({len(entities)}) " + "=" * (46 - len(domain)))
for s in entities:
attrs = s.get("attributes", {})
name = attrs.get("friendly_name", "")
unit = attrs.get("unit_of_measurement", "")
state = s["state"]
if len(state) > 28:
state = state[:25] + "..."
print(f" {s['entity_id']:<48} {state:>30} {unit:<6} {name}")
print()
# The forecast is not in /api/states -- it moved to a service call that
# returns a response. Flag which entities support it so we know what to ask.
weather = by_domain.get("weather", [])
if weather:
print("Forecast note: weather.* forecasts come from the")
print(" weather.get_forecasts service (POST /api/services/weather/get_forecasts")
print(" with ?return_response), not from /api/states. Candidates:")
for s in weather:
print(f" {s['entity_id']}")
return 0
if __name__ == "__main__":
sys.exit(main())
+110
View File
@@ -0,0 +1,110 @@
#!/usr/bin/env python3
"""
Capture Home Assistant responses to disk for the simulator to render.
Iterating on layout against live data is awkward: the interesting states --
rain falling, a light dimmed, a sensor dropping out -- do not happen on
demand, and live data changes underneath a before/after comparison. Snapshots
are reproducible and can be hand-edited to produce a state that has not
occurred yet.
export HA_TOKEN=$(cat ~/.config/thermostat-hw/ha_token)
python3 tools/ha_snapshot.py # -> sim/snapshots/live.json
python3 tools/ha_snapshot.py --name rainy
./sim/build/thermostat_sim --data sim/snapshots/live.json \
--forecast sim/snapshots/live.forecast.json
The template is not duplicated here. It is read from the simulator binary via
--print-template, which is compiled from main/api/ha_template.h, so a snapshot
can never be captured with a template the firmware does not use.
"""
import argparse
import json
import os
import subprocess
import sys
import urllib.error
import urllib.request
from pathlib import Path
REPO = Path(__file__).resolve().parent.parent
SIM_BIN = REPO / "sim" / "build" / "thermostat_sim"
SNAP_DIR = REPO / "sim" / "snapshots"
def load_template() -> str:
if not SIM_BIN.exists():
sys.exit(f"{SIM_BIN} not built.\n"
" cmake -S sim -B sim/build -G Ninja && cmake --build sim/build")
out = subprocess.run([str(SIM_BIN), "--print-template"],
capture_output=True, text=True, check=True)
return out.stdout.strip()
def call(host: str, token: str, path: str, body: dict | str):
data = body.encode() if isinstance(body, str) else json.dumps(body).encode()
req = urllib.request.Request(
f"http://{host}:8123{path}",
data=data,
headers={"Authorization": f"Bearer {token}",
"Content-Type": "application/json"},
)
try:
return urllib.request.urlopen(req, timeout=20).read().decode()
except urllib.error.HTTPError as e:
sys.exit(f"HTTP {e.code} on {path}: {e.read().decode()[:400]}")
except urllib.error.URLError as e:
sys.exit(f"Cannot reach Home Assistant at {host}:8123 -- {e.reason}")
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--host", default="192.168.1.134")
ap.add_argument("--name", default="live", help="snapshot basename")
args = ap.parse_args()
token = os.environ.get("HA_TOKEN")
if not token:
sys.exit("HA_TOKEN is not set. See tools/ha_discover.py for how to make one.")
SNAP_DIR.mkdir(parents=True, exist_ok=True)
state = call(args.host, token, "/api/template", {"template": load_template()})
# Parse before writing: a template that renders to broken JSON should fail
# here rather than at the point some future run tries to render it.
try:
parsed = json.loads(state)
except json.JSONDecodeError as e:
sys.exit(f"Template rendered invalid JSON ({e}):\n{state[:500]}")
state_path = SNAP_DIR / f"{args.name}.json"
state_path.write_text(state)
forecast = call(args.host, token,
"/api/services/weather/get_forecasts?return_response",
{"entity_id": "weather.forecast_home", "type": "daily"})
fc_path = SNAP_DIR / f"{args.name}.forecast.json"
fc_path.write_text(forecast)
print(f"wrote {state_path.relative_to(REPO)} ({len(state)} bytes)")
print(f"wrote {fc_path.relative_to(REPO)} ({len(forecast)} bytes)")
dead = [k for k, v in parsed.items() if v is None]
if dead:
print(f"\nunavailable sensors in this snapshot: {', '.join(dead)}")
lights = parsed.get("lights", [])
print(f"lights: {sum(1 for l in lights if l.get('s') == 'on')} on, "
f"{sum(1 for l in lights if l.get('s') == 'off')} off, "
f"{sum(1 for l in lights if l.get('s') not in ('on', 'off'))} unavailable")
print(f"\n ./sim/build/thermostat_sim --data {state_path.relative_to(REPO)} \\")
print(f" --forecast {fc_path.relative_to(REPO)}")
return 0
if __name__ == "__main__":
sys.exit(main())
+172
View File
@@ -0,0 +1,172 @@
#!/usr/bin/env python3
"""
Generate the icon font the UI uses: main/ui/icon_font.c + main/ui/ui_icons.h.
The icons are a ~30-glyph subset of Material Design Icons (the same icon set
Home Assistant uses, so the panel and the HA dashboard agree on what "partly
cloudy" looks like). The subset TTF is embedded as a C array and rasterised at
runtime by LVGL's tiny_ttf, which means:
- icons are just label text in a special font -- colourable, sizeable,
no per-icon bitmaps to regenerate when a size changes;
- the whole set costs a few KB of flash, not a 1.3MB font.
Inputs (cached copies are fine; only regeneration needs them):
curl -sfL -o /tmp/mdi.css https://cdn.jsdelivr.net/npm/@mdi/font@7.4.47/css/materialdesignicons.min.css
curl -sfL -o /tmp/mdi.ttf https://cdn.jsdelivr.net/npm/@mdi/font@7.4.47/fonts/materialdesignicons-webfont.ttf
python3 tools/make_icon_font.py /tmp/mdi.css /tmp/mdi.ttf
Codepoints are resolved from the CSS at generation time, never hardcoded --
MDI has re-assigned codepoints between major versions, and a stale codepoint
renders as the wrong icon with no error anywhere.
The generated files are committed, so a normal build needs neither network
nor fonttools.
"""
import io
import re
import sys
from pathlib import Path
try:
from fontTools import subset
except ImportError:
sys.exit("fonttools is required: python3 -m venv /tmp/fontenv && "
"/tmp/fontenv/bin/pip install fonttools (then run with that python)")
REPO = Path(__file__).resolve().parent.parent
OUT_C = REPO / "main" / "ui" / "icon_font.c"
OUT_H = REPO / "main" / "ui" / "ui_icons.h"
# macro name -> candidate MDI icon names, first match wins. Candidates exist
# because MDI occasionally renames icons between versions.
ICONS = {
# met.no condition slugs (see ha_condition_* in ha_data.c)
"ICON_W_SUNNY": ["weather-sunny"],
"ICON_W_NIGHT": ["weather-night"],
"ICON_W_PARTLY": ["weather-partly-cloudy"],
"ICON_W_CLOUDY": ["weather-cloudy"],
"ICON_W_RAINY": ["weather-rainy"],
"ICON_W_POURING": ["weather-pouring"],
"ICON_W_LIGHTNING": ["weather-lightning"],
"ICON_W_LIGHTNING_RAIN":["weather-lightning-rainy"],
"ICON_W_SNOWY": ["weather-snowy"],
"ICON_W_SNOWY_RAIN": ["weather-snowy-rainy"],
"ICON_W_FOG": ["weather-fog"],
"ICON_W_WINDY": ["weather-windy"],
"ICON_W_HAIL": ["weather-hail"],
"ICON_W_ALERT": ["alert-circle-outline"],
# grid cells
"ICON_THERMOMETER": ["thermometer"],
"ICON_HOME_THERMO": ["home-thermometer-outline", "home-thermometer"],
"ICON_WATER": ["water-outline", "water"],
"ICON_SUNRISE": ["weather-sunset-up"],
"ICON_SUNSET": ["weather-sunset-down"],
"ICON_GAUGE": ["gauge"],
# trend arrows for temperature / pressure readings
"ICON_TREND_UP": ["trending-up"],
"ICON_TREND_DOWN": ["trending-down"],
# light tiles (order-independent; ha_config.h references these by name)
"ICON_TV": ["television"],
"ICON_SOFA": ["sofa-outline", "sofa"],
"ICON_DINING": ["silverware-fork-knife", "silverware"],
"ICON_FLOOR_LAMP": ["floor-lamp"],
"ICON_DESK_LAMP": ["desk-lamp"],
"ICON_CHANDELIER": ["chandelier", "ceiling-light"],
"ICON_STRING_LIGHTS": ["string-lights", "outdoor-lamp"],
}
def resolve_codepoints(css: str) -> dict[str, int]:
"""macro -> unicode codepoint, via the .mdi-<name>::before rules."""
cp_by_name: dict[str, int] = {}
for m in re.finditer(r'\.mdi-([a-z0-9-]+)::?before\{content:"\\([0-9A-Fa-f]+)"\}', css):
cp_by_name[m.group(1)] = int(m.group(2), 16)
if not cp_by_name:
sys.exit("No codepoint rules found in the CSS -- wrong file?")
resolved: dict[str, int] = {}
for macro, candidates in ICONS.items():
for name in candidates:
if name in cp_by_name:
resolved[macro] = cp_by_name[name]
print(f" {macro:<24} mdi-{name:<24} U+{cp_by_name[name]:05X}")
break
else:
sys.exit(f"None of {candidates} exist in this MDI release "
f"(wanted for {macro}) -- pick another candidate")
return resolved
def subset_font(ttf_path: Path, codepoints: list[int]) -> bytes:
opts = subset.Options()
opts.flavor = None # plain TTF out
opts.desubroutinize = True
font = subset.load_font(str(ttf_path), opts)
ss = subset.Subsetter(opts)
ss.populate(unicodes=codepoints)
ss.subset(font)
buf = io.BytesIO()
font.save(buf)
return buf.getvalue()
def utf8_escape(cp: int) -> str:
return "".join(f"\\x{b:02x}" for b in chr(cp).encode("utf-8"))
def main() -> int:
if len(sys.argv) != 3:
sys.exit(__doc__)
css_path, ttf_path = Path(sys.argv[1]), Path(sys.argv[2])
print("resolving codepoints from CSS:")
resolved = resolve_codepoints(css_path.read_text())
data = subset_font(ttf_path, sorted(resolved.values()))
print(f"\nsubset TTF: {len(data)} bytes ({len(resolved)} glyphs)")
guard_note = (
"/*\n"
" * GENERATED by tools/make_icon_font.py -- do not edit by hand.\n"
" * Subset of Material Design Icons (Apache-2.0), rasterised at runtime\n"
" * by LVGL tiny_ttf. Regeneration instructions are in the script.\n"
" */\n"
)
lines = [guard_note, "#include <stddef.h>\n"]
lines.append(f"const size_t icon_font_data_size = {len(data)};\n")
lines.append("const unsigned char icon_font_data[] = {")
for i in range(0, len(data), 16):
chunk = ", ".join(f"0x{b:02x}" for b in data[i:i + 16])
lines.append(f" {chunk},")
lines.append("};\n")
OUT_C.write_text("\n".join(lines))
h = [guard_note]
h.append("#ifndef UI_ICONS_H")
h.append("#define UI_ICONS_H\n")
h.append("#include <stddef.h>\n")
h.append("/* The subset TTF; feed to lv_tiny_ttf_create_data(). */")
h.append("extern const unsigned char icon_font_data[];")
h.append("extern const size_t icon_font_data_size;\n")
h.append("/* Each icon is a single glyph as a UTF-8 string -- usable as label text")
h.append(" * in any font created from icon_font_data. */")
for macro, cp in resolved.items():
h.append(f'#define {macro:<24} "{utf8_escape(cp)}" /* U+{cp:05X} */')
h.append("\n#endif")
OUT_H.write_text("\n".join(h) + "\n")
print(f"wrote {OUT_C.relative_to(REPO)}")
print(f"wrote {OUT_H.relative_to(REPO)}")
return 0
if __name__ == "__main__":
sys.exit(main())
+135
View File
@@ -0,0 +1,135 @@
/*
* Drives thermostat.c with synthetic time to prove the protections actually
* fire. Nothing here touches hardware; time is a variable, so two hours of
* furnace behaviour runs in milliseconds.
*
* This logic controls a gas valve, so it gets verified before it gets flashed:
*
* gcc -Wall -Wextra -I main/control -o /tmp/therm_test \
* tools/thermostat_test.c main/control/thermostat.c -lm && /tmp/therm_test
*
* Exits non-zero on failure. Worth re-running after any edit to thermostat.c.
*/
#include <stdio.h>
#include <string.h>
#include "thermostat.h"
static therm_state_t last = (therm_state_t)-1;
static uint32_t t_ms;
static const char *sname(therm_state_t s)
{
switch (s) {
case THERM_STATE_OFF: return "OFF";
case THERM_STATE_IDLE: return "IDLE";
case THERM_STATE_HEATING: return "HEATING";
case THERM_STATE_LOCKOUT: return "LOCKOUT";
case THERM_STATE_NO_SENSOR: return "NO_SENSOR";
}
return "?";
}
/* Advance `secs` seconds at 1Hz, printing only transitions. */
static void run(float temp, int temp_valid, int secs)
{
therm_status_t st;
for (int i = 0; i < secs; i++) {
t_ms += 1000;
thermostat_tick(temp, temp_valid, t_ms);
thermostat_get_status(&st);
if (st.state != last) {
char txt[32];
thermostat_state_text(&st, txt, sizeof(txt));
printf(" %6.1f min %-9s heat=%d fan=%d cycles=%u \"%s\"\n",
t_ms / 60000.0, sname(st.state), st.heat_relay, st.fan_relay,
st.cycles_last_hour, txt);
last = st.state;
}
}
}
int main(void)
{
therm_status_t st;
printf("min_on=%us min_off=%us max_cph=%d deadband=%.1fF\n\n",
THERM_MIN_ON_MS / 1000, THERM_MIN_OFF_MS / 1000,
THERM_MAX_CYCLES_PER_HOUR, THERM_DEADBAND_F);
thermostat_init(0);
thermostat_set_setpoint(68.0f);
printf("1. cold house at 60F, boot lockout then heat\n");
run(60.0f, 1, 120);
printf("\n2. house reaches 69F -- must serve min-on before stopping\n");
run(69.0f, 1, 300);
printf("\n3. cold again immediately -- must serve min-off\n");
run(60.0f, 1, 400);
printf("\n4. hammer it: alternate hot/cold to trip the cycles-per-hour cap\n");
for (int i = 0; i < 12; i++) {
run(69.0f, 1, 200);
run(60.0f, 1, 400);
}
thermostat_get_status(&st);
printf(" -> cycles in last hour: %u (cap %d)\n",
st.cycles_last_hour, THERM_MAX_CYCLES_PER_HOUR);
if (st.cycles_last_hour > THERM_MAX_CYCLES_PER_HOUR) {
printf(" *** FAIL: cap exceeded\n");
return 1;
}
printf("\n5. sensor goes away -- rides it out, then forces heat off\n");
thermostat_init(t_ms);
last = (therm_state_t)-1;
thermostat_set_setpoint(68.0f);
run(60.0f, 1, 120); /* heating */
run(0.0f, 0, 14 * 60); /* stale but within the grace window */
run(0.0f, 0, 3 * 60); /* past it */
thermostat_get_status(&st);
if (st.state != THERM_STATE_NO_SENSOR || st.heat_relay) {
printf(" *** FAIL: expected NO_SENSOR with heat off\n");
return 1;
}
printf("\n6. mode off stops heat immediately, ignoring min-on\n");
thermostat_init(t_ms);
last = (therm_state_t)-1;
thermostat_set_setpoint(68.0f);
run(60.0f, 1, 120);
thermostat_get_status(&st);
if (!st.heat_relay) {
printf(" *** FAIL: expected to be heating first\n");
return 1;
}
thermostat_set_mode(THERM_MODE_OFF);
run(60.0f, 1, 2);
thermostat_get_status(&st);
if (st.heat_relay) {
printf(" *** FAIL: heat still on after mode off\n");
return 1;
}
printf("\n7. fan is independent of heat, and never follows a heat call\n");
thermostat_init(t_ms);
last = (therm_state_t)-1;
thermostat_set_setpoint(68.0f);
run(60.0f, 1, 120);
thermostat_get_status(&st);
if (st.fan_relay) {
printf(" *** FAIL: fan energized during heat (furnace owns the blower)\n");
return 1;
}
thermostat_set_fan(THERM_FAN_ON);
run(60.0f, 1, 2);
thermostat_get_status(&st);
if (!st.fan_relay) {
printf(" *** FAIL: fan did not turn on\n");
return 1;
}
printf("\nall checks passed\n");
return 0;
}