Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3aabcac90a | ||
|
|
ae5d12b3a2 | ||
|
|
45998b6176 | ||
|
|
b5d6b4a897 |
No files matched your search
@@ -3,6 +3,9 @@ build/
|
|||||||
managed_components/
|
managed_components/
|
||||||
dependencies.lock
|
dependencies.lock
|
||||||
|
|
||||||
|
# Desktop simulator build output (sim/ sources themselves ARE tracked)
|
||||||
|
sim/build/
|
||||||
|
|
||||||
# Generated config -- sdkconfig.defaults IS tracked, sdkconfig is not
|
# Generated config -- sdkconfig.defaults IS tracked, sdkconfig is not
|
||||||
sdkconfig
|
sdkconfig
|
||||||
sdkconfig.old
|
sdkconfig.old
|
||||||
|
|||||||
@@ -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
|
> **Advance**. Building it against Basic documentation yields a black screen
|
||||||
> and a silent I2C bus.
|
> and a silent I2C bus.
|
||||||
|
|
||||||
Currently displays outdoor conditions from an Ambient Weather personal weather
|
A wall dashboard driven by **Home Assistant** over the LAN: outdoor and indoor
|
||||||
station. Thermostat control (setpoint, HVAC relays, scheduling) is not
|
conditions from an Ambient Weather station, sunrise/sunset, rain, a five-day
|
||||||
implemented yet.
|
forecast, and on/off plus brightness control for six lights. Thermostat control
|
||||||
|
(setpoint, HVAC relays, scheduling) is not implemented yet.
|
||||||
|
|
||||||
## Components
|
## Components
|
||||||
|
|
||||||
@@ -23,17 +24,34 @@ implemented yet.
|
|||||||
| `main/drivers/touch` | GT911 capacitive touch over I2C, registered as an LVGL input device |
|
| `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/lvgl_port.c` | LVGL tick source, service task, and locking |
|
||||||
| `main/ui/ui_manager.c` | Screen layout and update entry points |
|
| `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/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
|
## Setup
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp main/app_config.h.example main/app_config.h
|
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
|
## 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
|
Requires ESP-IDF **v5.3 or newer** (the code uses the `i2c_master` driver API
|
||||||
and `dma_burst_size`).
|
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)
|
## Hardware (Advance)
|
||||||
|
|
||||||
### RGB panel (16-bit RGB565)
|
### 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
|
`0x00`–`0xF5` **inverted** (`0x00` = full) on v1.3; v1.2 uses a narrower
|
||||||
`0x05`–`0x10` range instead.
|
`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
|
## Display architecture
|
||||||
|
|
||||||
Two 800×480×2 framebuffers in PSRAM, LVGL in `RENDER_MODE_DIRECT`. LVGL draws
|
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
|
tables pairing 120MHz PSRAM with 120MHz flash. Flash above 80MHz requires
|
||||||
High Performance Mode and a DC-aware bootloader.
|
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
|
## Gotchas
|
||||||
|
|
||||||
- **LVGL does not bubble events to parents.** An event handler on the screen
|
- **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
|
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
|
dead touch zones. Light tiles therefore carry their own handlers, and the
|
||||||
`lv_timer` instead.
|
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
|
- **`lv_label_set_text()` invalidates unconditionally**, even when the text is
|
||||||
identical — so setting an unchanged value still forces a repaint. All label
|
identical — so setting an unchanged value still forces a repaint. All label
|
||||||
updates go through `label_set_if_changed()`.
|
updates go through `label_set_if_changed()`.
|
||||||
- **WiFi must be brought up before `display_init()`.** The bounce refill ISR
|
- **Display bring-up is two-phase, and both orderings are load-bearing.**
|
||||||
reads PSRAM, which shares the cache with flash; an NVS write while the panel
|
`display_alloc()` runs *before* WiFi: the bounce buffers need two contiguous
|
||||||
is running faults it with *"Cache disabled but cached memory region
|
25.6KB internal DMA blocks, and WiFi's RX buffer churn fragments the heap
|
||||||
accessed"*.
|
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
|
## 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,
|
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
|
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
|
*"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
|
before `display_start()` and `CONFIG_ESP_WIFI_NVS_ENABLED` is off to keep the
|
||||||
WiFi stack from writing later.
|
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).
|
||||||
@@ -1,5 +1,40 @@
|
|||||||
# TODO
|
# 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
|
## Done
|
||||||
|
|
||||||
The six items from the 2026-08-03 review were completed in `d4b2ab7`
|
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
|
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.
|
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
|
Setpoint, HVAC relay control, scheduling and persistence are all still
|
||||||
unimplemented — this remains a weather display. Note a separate project
|
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
|
- The `°` glyph exists in LVGL's built-in Montserrat fonts; the `·` separator
|
||||||
that does not appears only in log strings, never on screen.
|
that does not appears only in log strings, never on screen.
|
||||||
- LVGL recursive-mutex locking is consistent across all UI entry points.
|
- LVGL recursive-mutex locking is consistent across all UI entry points.
|
||||||
- Init order in `main.c` is deliberate and load-bearing: WiFi/NVS before
|
- Init order in `main.c` is deliberate and load-bearing:
|
||||||
`display_init()`, `backlight_init()` before `touch_init()`. Never reorder.
|
`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.
|
- The bounce buffer is enabled and load-bearing.
|
||||||
- Board is N16R8 (16MB flash / 8MB octal PSRAM).
|
- Board is N16R8 (16MB flash / 8MB octal PSRAM).
|
||||||
- `touch_get_indev()`, `display_get_panel()` and `lvgl_port_is_paused()` are
|
- `touch_get_indev()`, `display_get_panel()` and `lvgl_port_is_paused()` are
|
||||||
|
|||||||
@@ -6,9 +6,14 @@ idf_component_register(
|
|||||||
"drivers/backlight/backlight.c"
|
"drivers/backlight/backlight.c"
|
||||||
"drivers/display/display_rgb.c"
|
"drivers/display/display_rgb.c"
|
||||||
"drivers/touch/touch_gt911.c"
|
"drivers/touch/touch_gt911.c"
|
||||||
|
"drivers/relay/relay.c"
|
||||||
|
"control/thermostat.c"
|
||||||
"ui/lvgl_port.c"
|
"ui/lvgl_port.c"
|
||||||
"ui/ui_manager.c"
|
"ui/ui_manager.c"
|
||||||
|
"ui/icon_font.c"
|
||||||
"api/ambient_weather.c"
|
"api/ambient_weather.c"
|
||||||
|
"api/ha_client.c"
|
||||||
|
"api/ha_data.c"
|
||||||
INCLUDE_DIRS
|
INCLUDE_DIRS
|
||||||
"."
|
"."
|
||||||
"wifi"
|
"wifi"
|
||||||
@@ -16,6 +21,8 @@ idf_component_register(
|
|||||||
"drivers/backlight"
|
"drivers/backlight"
|
||||||
"drivers/display"
|
"drivers/display"
|
||||||
"drivers/touch"
|
"drivers/touch"
|
||||||
|
"drivers/relay"
|
||||||
|
"control"
|
||||||
"ui"
|
"ui"
|
||||||
"api"
|
"api"
|
||||||
REQUIRES
|
REQUIRES
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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];
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -5,15 +5,39 @@
|
|||||||
#ifndef APP_CONFIG_H
|
#ifndef APP_CONFIG_H
|
||||||
#define APP_CONFIG_H
|
#define APP_CONFIG_H
|
||||||
|
|
||||||
/* From https://ambientweather.net/account
|
#define WIFI_SSID ""
|
||||||
* DEVICE_ID is the station's MAC address, e.g. "AA:BB:CC:DD:EE:FF" */
|
#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_API_KEY ""
|
||||||
#define AMBIENT_WEATHER_APP_KEY ""
|
#define AMBIENT_WEATHER_APP_KEY ""
|
||||||
#define AMBIENT_WEATHER_DEVICE_ID ""
|
#define AMBIENT_WEATHER_DEVICE_ID ""
|
||||||
|
|
||||||
#define WIFI_SSID ""
|
/* The fallback client's own poll interval name. */
|
||||||
#define WIFI_PASSWORD ""
|
#define WEATHER_UPDATE_INTERVAL_MS HA_UPDATE_INTERVAL_MS
|
||||||
|
|
||||||
#define WEATHER_UPDATE_INTERVAL_MS (1 * 60 * 1000)
|
|
||||||
|
|
||||||
#endif
|
#endif
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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);
|
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)");
|
ESP_LOGI(TAG, "Allocating 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()");
|
|
||||||
|
|
||||||
const esp_lcd_rgb_panel_config_t panel_config = {
|
const esp_lcd_rgb_panel_config_t panel_config = {
|
||||||
.clk_src = LCD_CLK_SRC_DEFAULT,
|
.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_RETURN_ON_ERROR(esp_lcd_new_rgb_panel(&panel_config, &panel), TAG,
|
||||||
"esp_lcd_new_rgb_panel failed");
|
"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_reset(panel), TAG, "panel reset failed");
|
||||||
ESP_RETURN_ON_ERROR(esp_lcd_panel_init(panel), TAG, "panel init failed");
|
ESP_RETURN_ON_ERROR(esp_lcd_panel_init(panel), TAG, "panel init failed");
|
||||||
|
|
||||||
|
|||||||
@@ -9,9 +9,22 @@
|
|||||||
#define DISPLAY_H_RES 800
|
#define DISPLAY_H_RES 800
|
||||||
#define DISPLAY_V_RES 480
|
#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. */
|
* Two-phase bring-up, and the order is load-bearing:
|
||||||
esp_err_t display_init(void);
|
*
|
||||||
|
* 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);
|
lv_display_t *display_get_default(void);
|
||||||
|
|
||||||
|
|||||||
@@ -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 "?";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -4,14 +4,20 @@
|
|||||||
|
|
||||||
#include "esp_log.h"
|
#include "esp_log.h"
|
||||||
#include "freertos/FreeRTOS.h"
|
#include "freertos/FreeRTOS.h"
|
||||||
|
#include "freertos/queue.h"
|
||||||
|
#include "freertos/semphr.h"
|
||||||
#include "freertos/task.h"
|
#include "freertos/task.h"
|
||||||
|
|
||||||
#include "ambient_weather.h"
|
#include "ambient_weather.h"
|
||||||
#include "app_config.h"
|
#include "app_config.h"
|
||||||
#include "backlight.h"
|
#include "backlight.h"
|
||||||
#include "display_rgb.h"
|
#include "display_rgb.h"
|
||||||
|
#include "ha_client.h"
|
||||||
|
#include "ha_data.h"
|
||||||
#include "i2c_bus.h"
|
#include "i2c_bus.h"
|
||||||
#include "lvgl_port.h"
|
#include "lvgl_port.h"
|
||||||
|
#include "relay.h"
|
||||||
|
#include "thermostat.h"
|
||||||
#include "touch_gt911.h"
|
#include "touch_gt911.h"
|
||||||
#include "ui_manager.h"
|
#include "ui_manager.h"
|
||||||
#include "wifi_manager.h"
|
#include "wifi_manager.h"
|
||||||
@@ -19,100 +25,378 @@
|
|||||||
static const char *TAG = "main";
|
static const char *TAG = "main";
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* 6144 bytes. Measured headroom after a fetch including the TLS handshake --
|
* 6144 was measured with the old cloud client, whose TLS handshake was the
|
||||||
* the deepest this task gets -- is ~2400 bytes, so roughly 3.7KB is in use.
|
* deepest this task ever got. The HA path is plain HTTP and shallower, but
|
||||||
* mbedTLS keeps its heap in PSRAM (CONFIG_MBEDTLS_EXTERNAL_MEM_ALLOC), which
|
* ha_data_t is ~600 bytes and the JSON bodies are on the stack, so the
|
||||||
* is why the handshake fits at all. Re-check the high-water-mark log if more
|
* headroom is kept. Re-check the high-water-mark log if more work lands here.
|
||||||
* work lands in this task.
|
|
||||||
*/
|
*/
|
||||||
#define WEATHER_TASK_STACK 6144
|
#define HA_TASK_STACK 6144
|
||||||
#define WEATHER_TASK_PRIO 3
|
#define HA_TASK_PRIO 3
|
||||||
#define WEATHER_FIRST_DELAY_MS 3000
|
#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. */
|
/* 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];
|
char line[96];
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Age is tracked in ticks, not from weather_data_t.last_update. That field
|
* Age is tracked in ticks, not wall time: with no SNTP the system clock
|
||||||
* comes from time(NULL), and with no SNTP client the system clock starts
|
* starts at the epoch. (The clock shown on screen comes from HA, which is
|
||||||
* at the epoch -- an age derived from it would be nonsense. Tick
|
* why the panel can display the time without knowing it.) Tick
|
||||||
* subtraction is also wrap-safe.
|
* subtraction is also wrap-safe.
|
||||||
*/
|
*/
|
||||||
TickType_t last_success = 0;
|
TickType_t last_success = 0;
|
||||||
int consecutive_failures = 0;
|
int consecutive_failures = 0;
|
||||||
bool have_reading = false;
|
bool have_reading = false;
|
||||||
bool stack_logged = 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) {
|
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()) {
|
if (!wifi_is_connected()) {
|
||||||
ui_set_status("WiFi: reconnecting...");
|
ui_set_status("WiFi: reconnecting...");
|
||||||
vTaskDelay(pdMS_TO_TICKS(WEATHER_UPDATE_INTERVAL_MS));
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Freeze rendering for the duration of the fetch. The TLS session,
|
* Freeze rendering for the duration of the network work. The redraw
|
||||||
* the LVGL redraw and the flush into the PSRAM framebuffer all
|
* and the flush into the PSRAM framebuffer compete with the LCD's own
|
||||||
* compete with the LCD's own DMA out of that same PSRAM; letting them
|
* DMA out of that same PSRAM; letting them overlap is what produces
|
||||||
* overlap is what produces visible artefacts. The panel keeps
|
* visible artefacts. The panel keeps scanning the last completed
|
||||||
* scanning the last completed frame, so the screen simply holds still.
|
* frame, so the screen simply holds still.
|
||||||
*/
|
*/
|
||||||
lvgl_port_pause();
|
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();
|
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();
|
last_success = xTaskGetTickCount();
|
||||||
consecutive_failures = 0;
|
consecutive_failures = 0;
|
||||||
have_reading = true;
|
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];
|
/* Hand the reading to the control loop, which runs on its own
|
||||||
snprintf(detail, sizeof(detail), "%.0f%% humidity",
|
* task and does not care where it came from. */
|
||||||
weather.has_humidity ? weather.humidity : 0.0f);
|
publish_indoor_temp(&ha_data.indoor_temp_f);
|
||||||
|
|
||||||
/* lroundf, not +0.5f-and-truncate: the latter rounds toward zero
|
ui_update(&ha_data);
|
||||||
* below 0°F, turning -10.6 into -10. */
|
ui_set_stale(false);
|
||||||
ui_update_weather((int16_t)lroundf(weather.temp_f),
|
|
||||||
weather.has_humidity ? detail : "Outdoor");
|
|
||||||
ui_set_weather_stale(false);
|
|
||||||
|
|
||||||
|
if (on_fallback) {
|
||||||
|
ui_set_status("Home Assistant unreachable - cloud weather only");
|
||||||
|
} else {
|
||||||
wifi_get_status(line, sizeof(line));
|
wifi_get_status(line, sizeof(line));
|
||||||
ui_set_status(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) {
|
if (!stack_logged) {
|
||||||
stack_logged = true;
|
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)));
|
(unsigned)(uxTaskGetStackHighWaterMark(NULL) * sizeof(StackType_t)));
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
consecutive_failures++;
|
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 =
|
const uint32_t age_min =
|
||||||
((xTaskGetTickCount() - last_success) * portTICK_PERIOD_MS) / 60000U;
|
((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);
|
snprintf(line, sizeof(line), "No update for %lum", (unsigned long)age_min);
|
||||||
ui_set_status(line);
|
ui_set_status(line);
|
||||||
} else {
|
} 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)
|
void app_main(void)
|
||||||
@@ -120,17 +404,35 @@ void app_main(void)
|
|||||||
ESP_LOGI(TAG, "Thermostat HMI -- CrowPanel 7.0\" ESP32-S3 (800x480)");
|
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
|
* - display_alloc() before WiFi: the bounce buffers need two contiguous
|
||||||
* PSRAM shares the cache with flash on the ESP32-S3. Bringing up NVS and
|
* 25.6KB internal DMA blocks, and WiFi's RX buffer churn fragments
|
||||||
* the WiFi PHY writes calibration data to flash, which disables the cache
|
* the internal heap within seconds of association. Allocating from
|
||||||
* and faults that ISR. Doing it here means every flash write is finished
|
* the virgin heap is deterministic; observed otherwise as a
|
||||||
* before the panel starts scanning.
|
* 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();
|
const esp_err_t wifi_err = wifi_init_sta();
|
||||||
if (wifi_err != ESP_OK) {
|
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
|
/* LVGL core must exist before the display and touch drivers register
|
||||||
@@ -147,10 +449,12 @@ void app_main(void)
|
|||||||
}
|
}
|
||||||
i2c_bus_scan(TAG);
|
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) {
|
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");
|
ESP_LOGW(TAG, "Touch unavailable -- continuing without input");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -161,29 +465,44 @@ void app_main(void)
|
|||||||
vTaskDelay(pdMS_TO_TICKS(100));
|
vTaskDelay(pdMS_TO_TICKS(100));
|
||||||
ESP_ERROR_CHECK(backlight_set(255));
|
ESP_ERROR_CHECK(backlight_set(255));
|
||||||
|
|
||||||
|
/* Before the WiFi/HA branches below, any of which may return. */
|
||||||
|
start_thermostat();
|
||||||
|
|
||||||
if (wifi_err == ESP_OK) {
|
if (wifi_err == ESP_OK) {
|
||||||
char status[96];
|
char status[96];
|
||||||
wifi_get_status(status, sizeof(status));
|
wifi_get_status(status, sizeof(status));
|
||||||
ui_set_status(status);
|
ui_set_status(status);
|
||||||
} else if (wifi_err == ESP_FAIL || wifi_err == ESP_ERR_TIMEOUT) {
|
} else if (wifi_err == ESP_FAIL || wifi_err == ESP_ERR_TIMEOUT) {
|
||||||
/* The event handler keeps retrying in the background; the weather
|
/* The event handler keeps retrying in the background; the HA task
|
||||||
* task polls wifi_is_connected(), so start it anyway rather than
|
* polls wifi_is_connected(), so start it anyway rather than
|
||||||
* permanently disabling weather because boot raced a slow AP. */
|
* permanently disabling updates because boot raced a slow AP. */
|
||||||
ui_set_status("WiFi: connecting...");
|
ui_set_status("WiFi: connecting...");
|
||||||
} else {
|
} else {
|
||||||
/* No SSID configured (or setup failed outright) -- nothing will ever
|
/* 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");
|
ui_set_status("WiFi: not configured");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (weather_init() != ESP_OK) {
|
if (ha_client_init() != ESP_OK) {
|
||||||
ui_set_status("Weather: no API credentials");
|
ui_set_status("Home Assistant: not configured");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
xTaskCreate(weather_update_task, "weather", WEATHER_TASK_STACK, NULL,
|
/* Optional: only used when HA cannot be reached. A missing cloud API key
|
||||||
WEATHER_TASK_PRIO, NULL);
|
* 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");
|
ESP_LOGI(TAG, "System ready");
|
||||||
}
|
}
|
||||||
@@ -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
@@ -4,7 +4,7 @@
|
|||||||
#include <stdbool.h>
|
#include <stdbool.h>
|
||||||
#include "esp_err.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. */
|
* and touch_init(), which register themselves with LVGL. */
|
||||||
esp_err_t lvgl_port_init(void);
|
esp_err_t lvgl_port_init(void);
|
||||||
|
|
||||||
|
|||||||
@@ -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
File diff suppressed because it is too large.
Load diff
+53
-3
@@ -3,19 +3,69 @@
|
|||||||
|
|
||||||
#include <stdbool.h>
|
#include <stdbool.h>
|
||||||
#include <stdint.h>
|
#include <stdint.h>
|
||||||
|
|
||||||
|
#include "ha_data.h"
|
||||||
#include "lvgl.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);
|
void ui_init(void);
|
||||||
|
|
||||||
/* Safe to call from any task -- these take the LVGL lock internally. */
|
/* 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);
|
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,
|
* so a value that stopped updating is visibly distinct from a live one,
|
||||||
* rather than sitting there at full styling looking current.
|
* 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
|
#endif
|
||||||
+27
-1
@@ -30,9 +30,35 @@ main/
|
|||||||
│ ├── ui_manager.h
|
│ ├── ui_manager.h
|
||||||
│ └── ui_manager.c
|
│ └── ui_manager.c
|
||||||
└── api/
|
└── 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.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.
|
Note: main/ subdirectories are plain source folders, not ESP-IDF components.
|
||||||
IDF only auto-discovers components under a top-level components/ directory,
|
IDF only auto-discovers components under a top-level components/ directory,
|
||||||
so all sources are registered by main/CMakeLists.txt.
|
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.
|
||||||
@@ -76,9 +76,25 @@ CONFIG_MBEDTLS_SSL_OUT_CONTENT_LEN=4096
|
|||||||
CONFIG_LV_COLOR_DEPTH_16=y
|
CONFIG_LV_COLOR_DEPTH_16=y
|
||||||
CONFIG_LV_USE_LOG=y
|
CONFIG_LV_USE_LOG=y
|
||||||
CONFIG_LV_LOG_LEVEL_WARN=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_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_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
|
CONFIG_LV_FONT_MONTSERRAT_48=y
|
||||||
|
|
||||||
# --- Main task needs headroom for driver init ---
|
# --- Main task needs headroom for driver init ---
|
||||||
CONFIG_ESP_MAIN_TASK_STACK_SIZE=8192
|
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
|
||||||
@@ -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
@@ -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`.
|
||||||
@@ -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
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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;
|
||||||
|
}
|
||||||
@@ -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}]}
|
||||||
@@ -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}]}
|
||||||
@@ -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}]}}}
|
||||||
@@ -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}]}
|
||||||
Executable
+115
@@ -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())
|
||||||
Executable
+110
@@ -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())
|
||||||
@@ -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())
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
Reference in new issue
Block a user