Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ea1b8b8d15 | ||
|
|
7a58f9838a |
No files matched your search
@@ -140,19 +140,37 @@ 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
|
of the template: HA removed the `forecast` attribute from weather entities in
|
||||||
2024.4, and templates cannot call services.
|
2024.4, and templates cannot call services.
|
||||||
|
|
||||||
### The wind sensors are dead
|
### Wind, and the compass
|
||||||
|
|
||||||
The station's anemometer has been offline since **2026-07-27**: `wind_speed`,
|
The station's anemometer was offline 2026-07-27 to 2026-08-07 and is live
|
||||||
`wind_gust`, `wind_direction` and `feels_like` all read `unavailable`, and the
|
again. It has its own column now: a dial whose needle points at the direction
|
||||||
WIND tile shows `--` by design. It is deliberately *not* backfilled from the
|
the wind is coming **from** (weather-vane convention, matching the cardinal
|
||||||
met.no forecast — regional modelled wind under a heading that means "my yard"
|
label beneath it), the speed in mph, and an arrow for the 5-minute trend.
|
||||||
is worse than showing nothing.
|
|
||||||
|
|
||||||
`sensor.moonlight_beach_max_gust` is a trap: it is **not** unavailable. It
|
Three things worth knowing:
|
||||||
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
|
- **The needle does not animate.** A spin would look better, but every
|
||||||
|
animated frame is a redraw, and redrawing is what starves the bounce refill
|
||||||
|
ISR. It snaps once a minute and the dirty area is just the dial.
|
||||||
|
- **Below 0.5 mph it shows "Calm" and draws no needle.** A vane that is not
|
||||||
|
being pushed reports whatever it last settled on — on this station that is a
|
||||||
|
steady 0°, which would otherwise render as a confident needle pointing due
|
||||||
|
north.
|
||||||
|
- **The trend is computed locally, not taken from HA's 10-minute average.**
|
||||||
|
`HA_TREND_CH_WIND` uses 1-minute buckets over a 5-minute window, which is as
|
||||||
|
fine as a 60-second poll can support. The threshold is 1.5 mph — wind is
|
||||||
|
noisy and a twitchy arrow is worse than none.
|
||||||
|
|
||||||
|
### Two indoor sensors
|
||||||
|
|
||||||
|
`temperature_4` is **downstairs** and `inside_temperature` is **upstairs** —
|
||||||
|
the names come from the Ambient Weather console, not the house, so they read
|
||||||
|
backwards. Downstairs is the primary: big number on the card, and it is what
|
||||||
|
the furnace controls on, because it is where people are and heat rises.
|
||||||
|
`HA_ENT_CONTROL_TEMP` in `ha_config.h` is the single place that choice is
|
||||||
|
made; the big card in `ui_manager.c` should move with it.
|
||||||
|
|
||||||
|
### Fallback### Fallback
|
||||||
|
|
||||||
When HA cannot be reached, `ambient_weather.c` fills in outdoor temperature,
|
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,
|
humidity and wind from the cloud API and the status line says so. Indoor, sun,
|
||||||
@@ -231,9 +249,28 @@ the buffer being displayed — copying into the live framebuffer races the LCD's
|
|||||||
own reads, and the resulting corrupt pixels persist indefinitely because LVGL
|
own reads, and the resulting corrupt pixels persist indefinitely because LVGL
|
||||||
does not know they are wrong and never reinvalidates them.
|
does not know they are wrong and never reinvalidates them.
|
||||||
|
|
||||||
A 16-scanline bounce buffer stages each chunk in internal RAM. 20 scanlines
|
A **20**-scanline bounce buffer stages each chunk in internal RAM (64KB across
|
||||||
would be better but needs 64KB of internal RAM and fails to allocate once
|
two buffers), with `bb_invalidate_cache` enabled.
|
||||||
WiFi is up.
|
|
||||||
|
That flag matters more than it looks. Bounce mode is **not** a DMA read of the
|
||||||
|
framebuffer — the refill is a CPU `memcpy` in an ISR, which is why the driver
|
||||||
|
skips the usual write-back in `draw_bitmap` ("the data gets read by the CPU as
|
||||||
|
well"). So the panel streams 750KB per frame at ~44Hz *through the DCache*,
|
||||||
|
evicting the working set of everything else on the chip, continuously. That is
|
||||||
|
the real reason this board is so sensitive to WiFi activity and to any extra
|
||||||
|
rendering. `bb_invalidate_cache` frees each chunk after it is copied. The
|
||||||
|
header warns it "can be dangerous if data is written from other core(s)" —
|
||||||
|
that does not apply here, because it invalidates the buffer being *scanned*
|
||||||
|
and by design LVGL only ever draws into the other one. This was 16 for a long time because 20 "fails to allocate once
|
||||||
|
WiFi is up" — which was true when `display_init()` ran after
|
||||||
|
`wifi_init_sta()`. The two-phase split changed that: `display_alloc()` now
|
||||||
|
takes these buffers from a virgin heap with ~200KB free, long before WiFi has
|
||||||
|
fragmented it. Bigger chunks give the refill ISR more time to fetch the next
|
||||||
|
one before the LCD needs it, which attacks the artefact mechanism directly.
|
||||||
|
|
||||||
|
That leaves ~24KB of internal heap once everything is up. Watch the
|
||||||
|
"Free internal heap" log line — if it approaches single digits, come back
|
||||||
|
down to 18 or 16 scanlines.
|
||||||
|
|
||||||
### Known limitation: occasional left-edge artefacts
|
### Known limitation: occasional left-edge artefacts
|
||||||
|
|
||||||
@@ -283,6 +320,20 @@ about PSRAM bandwidth or panel timing.
|
|||||||
|
|
||||||
## Gotchas
|
## Gotchas
|
||||||
|
|
||||||
|
- **Setting a style property always invalidates, even to an identical value.**
|
||||||
|
`lv_obj_set_local_style_prop()` calls `lv_obj_refresh_style()`
|
||||||
|
unconditionally — there is no equality short-circuit, unlike the one you
|
||||||
|
might assume by analogy with `label_set_if_changed()`. A periodic update
|
||||||
|
that restyles widgets "harmlessly" is a periodic full repaint of them.
|
||||||
|
This cost a round of debugging: `ui_update_thermostat()` ran at 1Hz writing
|
||||||
|
seven styles, which turned the left-edge artefacts from occasional into
|
||||||
|
permanent. Every update path here is now change-guarded.
|
||||||
|
- **Do not repaint to fix artefacts.** It is tempting, because LVGL never
|
||||||
|
reinvalidates pixels it does not know are wrong. But the corruption scales
|
||||||
|
with how much is written, so a full-screen invalidate is 768KB of exactly
|
||||||
|
the PSRAM contention that causes it. Two attempts at this (post-poll and a
|
||||||
|
post-interaction "repair" timer) both made it visibly worse and were
|
||||||
|
removed. Redraw *less*, not more.
|
||||||
- **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. Light tiles therefore carry their own handlers, and the
|
dead touch zones. Light tiles therefore carry their own handlers, and the
|
||||||
|
|||||||
@@ -27,13 +27,12 @@ no errors), but eyes and fingers haven't confirmed:
|
|||||||
2. Trend arrows appear ~5 minutes after boot (indoor big card, outdoor and
|
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
|
pressure cells) once the local ring has history. They are local-history
|
||||||
based and reset on reboot — that is by design, not a bug.
|
based and reset on reboot — that is by design, not a bug.
|
||||||
3. Artefact lifetime: interactions schedule a one-shot full repaint 600ms
|
3. Artefact lifetime. Two "repaint to clean up" mitigations were tried and
|
||||||
after the last touch, and every poll repaints too. Artefacts should now
|
both made it worse -- see README Gotchas. They are gone, the 1Hz restyle
|
||||||
visibly self-clean in under a second after touching, and within a poll
|
that made the artefacts permanent is fixed, and the bounce buffer is up to
|
||||||
otherwise. If they *persist* beyond that, the repaint isn't reaching
|
20 scanlines. If lines still come and go, the remaining honest levers are
|
||||||
them; if they merely *recur* during interaction, that is the PSRAM
|
fewer redraws still, less WiFi interrupt load during rendering, or 120MHz
|
||||||
bandwidth ceiling (README) and the honest remaining levers are fewer
|
PSRAM on a newer IDF.
|
||||||
redraws or 120MHz PSRAM on a newer IDF.
|
|
||||||
|
|
||||||
## Done
|
## Done
|
||||||
|
|
||||||
@@ -86,12 +85,17 @@ 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 wind sensors are dead (hardware, not software)
|
### 4. Left-edge artefacts, still open
|
||||||
|
|
||||||
The station anemometer has read `unavailable` since 2026-07-27 — wind speed,
|
Three things have been tried. Two "repaint to clean up" mitigations made it
|
||||||
gust, direction and feels-like. The WIND tile shows `--` by design and will
|
worse and are gone (README Gotchas). The 1Hz restyle that made them permanent
|
||||||
keep doing so until the hardware is fixed. Nothing to change in the firmware;
|
is fixed. Now: bounce buffer at 20 scanlines and `bb_invalidate_cache` on, so
|
||||||
noted so it is not re-diagnosed as a parsing bug. See README "Home Assistant".
|
framebuffer streaming no longer thrashes the DCache.
|
||||||
|
|
||||||
|
If they persist after that, the mechanism is the raw PSRAM ceiling and the
|
||||||
|
honest remaining levers are unattractive: 120MHz PSRAM (needs a newer IDF and
|
||||||
|
a DC-aware bootloader), or a lower pixel clock (below ~15MHz the panel stops
|
||||||
|
locking and the image rolls). Do not reach for another repaint.
|
||||||
|
|
||||||
### 5. The actual thermostat
|
### 5. The actual thermostat
|
||||||
|
|
||||||
|
|||||||
+25
-8
@@ -14,21 +14,38 @@
|
|||||||
|
|
||||||
#define HA_ENT_OUTDOOR_TEMP "sensor.moonlight_beach_temperature"
|
#define HA_ENT_OUTDOOR_TEMP "sensor.moonlight_beach_temperature"
|
||||||
#define HA_ENT_OUTDOOR_HUMIDITY "sensor.moonlight_beach_humidity"
|
#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"
|
/*
|
||||||
|
* Two indoor sensors.
|
||||||
|
*
|
||||||
|
* DOWN is the console's "4" channel and UP is the one the station calls
|
||||||
|
* "inside" -- the names come from the Ambient Weather hardware, not from the
|
||||||
|
* house, so they read backwards. Downstairs is the primary: it gets the big
|
||||||
|
* card AND it is what the thermostat controls on, because it is where people
|
||||||
|
* are. Heat rises, so upstairs typically reads several degrees warmer;
|
||||||
|
* controlling on it would under-heat the living space.
|
||||||
|
*
|
||||||
|
* To swap which one drives the furnace, change HA_ENT_CONTROL_TEMP below --
|
||||||
|
* that is the only place the choice is made.
|
||||||
|
*/
|
||||||
|
#define HA_ENT_INDOOR_DOWN_TEMP "sensor.moonlight_beach_temperature_4"
|
||||||
|
#define HA_ENT_INDOOR_DOWN_HUMIDITY "sensor.moonlight_beach_humidity_4"
|
||||||
|
#define HA_ENT_INDOOR_UP_TEMP "sensor.moonlight_beach_inside_temperature"
|
||||||
|
#define HA_ENT_INDOOR_UP_HUMIDITY "sensor.moonlight_beach_humidity_indoor"
|
||||||
#define HA_ENT_DEW_POINT "sensor.moonlight_beach_dew_point"
|
#define HA_ENT_DEW_POINT "sensor.moonlight_beach_dew_point"
|
||||||
#define HA_ENT_PRESSURE "sensor.moonlight_beach_relative_pressure"
|
#define HA_ENT_PRESSURE "sensor.moonlight_beach_relative_pressure"
|
||||||
#define HA_ENT_RAIN_TODAY "sensor.moonlight_beach_daily_rain"
|
#define HA_ENT_RAIN_TODAY "sensor.moonlight_beach_daily_rain"
|
||||||
#define HA_ENT_RAIN_RATE "sensor.moonlight_beach_precipitation_intensity"
|
#define HA_ENT_RAIN_RATE "sensor.moonlight_beach_precipitation_intensity"
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* The anemometer has been offline since 2026-07-27: wind speed, gust and
|
* The anemometer was offline from 2026-07-27 and came back 2026-08-07. All
|
||||||
* direction all read "unavailable". The display shows blanks for these by
|
* three read live again. The UI still treats them as optional -- ha_value_t
|
||||||
* design -- no substituting forecast wind for station wind.
|
* carries its own validity and the compass hides itself when direction is
|
||||||
|
* missing -- because this sensor has now failed once and will again.
|
||||||
*
|
*
|
||||||
* Do not be tempted by sensor.moonlight_beach_max_gust. It is NOT unavailable;
|
* The 10-minute averages are the station's own smoothing. The 5-minute trend
|
||||||
* it still reports its last value from 2026-07-28 and would render as a
|
* shown on the compass is computed locally from the instantaneous speed
|
||||||
* plausible live number. Anything using it needs a staleness check first.
|
* instead, so the trend window is ours to choose; see HA_TREND_CH_WIND.
|
||||||
*/
|
*/
|
||||||
#define HA_ENT_WIND_SPEED "sensor.moonlight_beach_wind_speed"
|
#define HA_ENT_WIND_SPEED "sensor.moonlight_beach_wind_speed"
|
||||||
#define HA_ENT_WIND_GUST "sensor.moonlight_beach_wind_gust"
|
#define HA_ENT_WIND_GUST "sensor.moonlight_beach_wind_gust"
|
||||||
|
|||||||
+62
-35
@@ -127,8 +127,10 @@ bool ha_parse_json(const char *json, ha_data_t *out)
|
|||||||
|
|
||||||
take_number(root, "out_t", &out->outdoor_temp_f);
|
take_number(root, "out_t", &out->outdoor_temp_f);
|
||||||
take_number(root, "out_h", &out->outdoor_humidity);
|
take_number(root, "out_h", &out->outdoor_humidity);
|
||||||
take_number(root, "in_t", &out->indoor_temp_f);
|
take_number(root, "dn_t", &out->indoor_down_temp_f);
|
||||||
take_number(root, "in_h", &out->indoor_humidity);
|
take_number(root, "dn_h", &out->indoor_down_humidity);
|
||||||
|
take_number(root, "up_t", &out->indoor_up_temp_f);
|
||||||
|
take_number(root, "up_h", &out->indoor_up_humidity);
|
||||||
take_number(root, "dew", &out->dew_point_f);
|
take_number(root, "dew", &out->dew_point_f);
|
||||||
take_number(root, "press", &out->pressure_inhg);
|
take_number(root, "press", &out->pressure_inhg);
|
||||||
take_number(root, "rain_d", &out->rain_today_in);
|
take_number(root, "rain_d", &out->rain_today_in);
|
||||||
@@ -308,22 +310,42 @@ const char *ha_condition_short(const char *slug)
|
|||||||
/* ------------------------------------------------------------------ trends */
|
/* ------------------------------------------------------------------ trends */
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* 20 slots, one kept per ~4 minutes, covers ~80 minutes. Feeding faster than
|
* 20 slots per channel. How much history that covers depends on the bucket
|
||||||
* that overwrites the newest slot instead of consuming a new one, so a 60s
|
* size, which is per-channel: a room temperature is judged over an hour, but
|
||||||
* poll cadence still fills the ring at 4-minute spacing.
|
* wind over an hour is meaningless -- it needs minutes.
|
||||||
*
|
*
|
||||||
* Size matters here more than it looks: this array is .bss, which lives in
|
* 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
|
* 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
|
* buffers come from, with only a few KB of slack (README, memory notes). Keep
|
||||||
* naive 72-slot-per-poll ring cost 2.6KB and was enough to tip display_init
|
* this structure small; if it ever needs to grow, move it to PSRAM instead.
|
||||||
* 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
|
* Static per channel; a reboot forgets history, and the display shows no
|
||||||
* arrow until enough accumulates again (see ha_data.h).
|
* arrow until enough accumulates again.
|
||||||
*/
|
*/
|
||||||
#define TREND_SLOTS 20
|
#define TREND_SLOTS 20
|
||||||
#define TREND_BUCKET_MS (4u * 60u * 1000u)
|
|
||||||
|
typedef struct {
|
||||||
|
uint32_t bucket_ms; /* minimum spacing between retained samples */
|
||||||
|
uint32_t window_ms; /* how far back "the trend" looks */
|
||||||
|
uint32_t min_age_ms; /* refuse to judge on less history than this */
|
||||||
|
float threshold; /* change over a full window that counts as movement */
|
||||||
|
} trend_cfg_t;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Thresholds are the difference between a useful arrow and a flickering one.
|
||||||
|
* A thermostat's indoor reading wobbles a couple of tenths continuously, and
|
||||||
|
* wind is noisier still -- hence 1.5 mph over five minutes rather than
|
||||||
|
* anything tighter.
|
||||||
|
*/
|
||||||
|
static const trend_cfg_t TREND_CFG[HA_TREND_CHANNELS] = {
|
||||||
|
[HA_TREND_CH_INDOOR_DOWN] = { 4u * 60000u, 60u * 60000u, 5u * 60000u, 0.5f },
|
||||||
|
[HA_TREND_CH_INDOOR_UP] = { 4u * 60000u, 60u * 60000u, 5u * 60000u, 0.5f },
|
||||||
|
[HA_TREND_CH_OUTDOOR] = { 4u * 60000u, 60u * 60000u, 5u * 60000u, 0.5f },
|
||||||
|
[HA_TREND_CH_PRESSURE] = { 4u * 60000u, 60u * 60000u, 5u * 60000u, 0.02f },
|
||||||
|
/* Wind: 1-minute buckets over a 5-minute window. At a 60s poll that is
|
||||||
|
* five samples, which is the most the poll rate can support. */
|
||||||
|
[HA_TREND_CH_WIND] = { 1u * 60000u, 5u * 60000u, 2u * 60000u, 1.5f },
|
||||||
|
};
|
||||||
|
|
||||||
typedef struct {
|
typedef struct {
|
||||||
float value;
|
float value;
|
||||||
@@ -334,6 +356,12 @@ typedef struct {
|
|||||||
static trend_sample_t trend_ring[HA_TREND_CHANNELS][TREND_SLOTS];
|
static trend_sample_t trend_ring[HA_TREND_CHANNELS][TREND_SLOTS];
|
||||||
static int trend_head[HA_TREND_CHANNELS]; /* slot most recently written */
|
static int trend_head[HA_TREND_CHANNELS]; /* slot most recently written */
|
||||||
|
|
||||||
|
/* Wrap-safe: unsigned subtraction is correct across the 32-bit rollover. */
|
||||||
|
static uint32_t trend_elapsed(uint32_t now_ms, uint32_t then_ms)
|
||||||
|
{
|
||||||
|
return now_ms - then_ms;
|
||||||
|
}
|
||||||
|
|
||||||
void ha_trend_feed(int channel, float value, uint32_t now_ms)
|
void ha_trend_feed(int channel, float value, uint32_t now_ms)
|
||||||
{
|
{
|
||||||
if (channel < 0 || channel >= HA_TREND_CHANNELS) {
|
if (channel < 0 || channel >= HA_TREND_CHANNELS) {
|
||||||
@@ -344,7 +372,8 @@ void ha_trend_feed(int channel, float value, uint32_t now_ms)
|
|||||||
|
|
||||||
/* Inside the current bucket: refresh the newest sample in place, so
|
/* Inside the current bucket: refresh the newest sample in place, so
|
||||||
* compute always sees the latest reading without spending a slot. */
|
* compute always sees the latest reading without spending a slot. */
|
||||||
if (newest->used && (now_ms - newest->ms) < TREND_BUCKET_MS) {
|
if (newest->used &&
|
||||||
|
trend_elapsed(now_ms, newest->ms) < TREND_CFG[channel].bucket_ms) {
|
||||||
newest->value = value;
|
newest->value = value;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -355,41 +384,40 @@ void ha_trend_feed(int channel, float value, uint32_t now_ms)
|
|||||||
trend_head[channel] = next;
|
trend_head[channel] = next;
|
||||||
}
|
}
|
||||||
|
|
||||||
ha_trend_t ha_trend_compute(int channel, float threshold_per_hour, uint32_t now_ms)
|
ha_trend_t ha_trend_compute(int channel, uint32_t now_ms)
|
||||||
{
|
{
|
||||||
if (channel < 0 || channel >= HA_TREND_CHANNELS) {
|
if (channel < 0 || channel >= HA_TREND_CHANNELS) {
|
||||||
return HA_TREND_UNKNOWN;
|
return HA_TREND_UNKNOWN;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The newest sample is the one just fed; find the usable sample whose
|
const trend_cfg_t *cfg = &TREND_CFG[channel];
|
||||||
* age is closest to the window. Unsigned subtraction keeps this correct
|
|
||||||
* across tick wrap. */
|
/* Newest usable sample, and the one whose age is closest to the window. */
|
||||||
const trend_sample_t *newest = NULL;
|
const trend_sample_t *newest = NULL;
|
||||||
const trend_sample_t *anchor = NULL;
|
const trend_sample_t *anchor = NULL;
|
||||||
uint32_t best_dist = UINT32_MAX;
|
uint32_t best_dist = UINT32_MAX;
|
||||||
|
|
||||||
for (int i = 0; i < TREND_SLOTS; i++) {
|
for (int i = 0; i < TREND_SLOTS; i++) {
|
||||||
const trend_sample_t *s = &trend_ring[channel][i];
|
const trend_sample_t *sm = &trend_ring[channel][i];
|
||||||
if (!s->used) {
|
if (!sm->used) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const uint32_t age = now_ms - s->ms;
|
const uint32_t age = trend_elapsed(now_ms, sm->ms);
|
||||||
if (age > HA_TREND_WINDOW_MS + HA_TREND_MIN_AGE_MS) {
|
if (age > cfg->window_ms + cfg->min_age_ms) {
|
||||||
continue; /* too old even for a sparse ring; ignore */
|
continue; /* too old even for a sparse ring */
|
||||||
}
|
}
|
||||||
|
|
||||||
if (newest == NULL || age < (now_ms - newest->ms)) {
|
if (newest == NULL || age < trend_elapsed(now_ms, newest->ms)) {
|
||||||
newest = s;
|
newest = sm;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (age >= HA_TREND_MIN_AGE_MS) {
|
if (age >= cfg->min_age_ms) {
|
||||||
const uint32_t dist = (age > HA_TREND_WINDOW_MS)
|
const uint32_t dist = (age > cfg->window_ms) ? age - cfg->window_ms
|
||||||
? age - HA_TREND_WINDOW_MS
|
: cfg->window_ms - age;
|
||||||
: HA_TREND_WINDOW_MS - age;
|
|
||||||
if (dist < best_dist) {
|
if (dist < best_dist) {
|
||||||
best_dist = dist;
|
best_dist = dist;
|
||||||
anchor = s;
|
anchor = sm;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -399,15 +427,14 @@ ha_trend_t ha_trend_compute(int channel, float threshold_per_hour, uint32_t now_
|
|||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Scale the threshold to the actual span so a 20-minute-old anchor uses
|
* Scale the threshold to the actual span, so a half-window-old anchor
|
||||||
* a proportionally smaller bar than a full hour would. Without this the
|
* uses a proportionally smaller bar. Without this the period right after
|
||||||
* first hour after boot under-reports every trend.
|
* boot under-reports every trend.
|
||||||
*/
|
*/
|
||||||
const uint32_t span_ms = anchor->ms == newest->ms
|
const uint32_t span_ms = (anchor->ms == newest->ms) ? 1u
|
||||||
? 1
|
|
||||||
: newest->ms - anchor->ms;
|
: newest->ms - anchor->ms;
|
||||||
const float threshold = threshold_per_hour *
|
const float threshold = cfg->threshold *
|
||||||
((float)span_ms / (float)HA_TREND_WINDOW_MS);
|
((float)span_ms / (float)cfg->window_ms);
|
||||||
|
|
||||||
const float delta = newest->value - anchor->value;
|
const float delta = newest->value - anchor->value;
|
||||||
if (delta > threshold) {
|
if (delta > threshold) {
|
||||||
|
|||||||
+12
-8
@@ -71,9 +71,11 @@ typedef enum {
|
|||||||
} ha_trend_t;
|
} ha_trend_t;
|
||||||
|
|
||||||
enum {
|
enum {
|
||||||
HA_TREND_CH_INDOOR = 0,
|
HA_TREND_CH_INDOOR_DOWN = 0,
|
||||||
|
HA_TREND_CH_INDOOR_UP,
|
||||||
HA_TREND_CH_OUTDOOR,
|
HA_TREND_CH_OUTDOOR,
|
||||||
HA_TREND_CH_PRESSURE,
|
HA_TREND_CH_PRESSURE,
|
||||||
|
HA_TREND_CH_WIND,
|
||||||
HA_TREND_CHANNELS,
|
HA_TREND_CHANNELS,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -91,8 +93,11 @@ typedef struct {
|
|||||||
/* Weather station */
|
/* Weather station */
|
||||||
ha_value_t outdoor_temp_f;
|
ha_value_t outdoor_temp_f;
|
||||||
ha_value_t outdoor_humidity;
|
ha_value_t outdoor_humidity;
|
||||||
ha_value_t indoor_temp_f;
|
/* Downstairs is the primary reading and drives the control loop. */
|
||||||
ha_value_t indoor_humidity;
|
ha_value_t indoor_down_temp_f;
|
||||||
|
ha_value_t indoor_down_humidity;
|
||||||
|
ha_value_t indoor_up_temp_f;
|
||||||
|
ha_value_t indoor_up_humidity;
|
||||||
ha_value_t dew_point_f;
|
ha_value_t dew_point_f;
|
||||||
ha_value_t pressure_inhg;
|
ha_value_t pressure_inhg;
|
||||||
ha_value_t rain_today_in;
|
ha_value_t rain_today_in;
|
||||||
@@ -121,9 +126,11 @@ typedef struct {
|
|||||||
* query: an /api/history call returns hundreds of points per entity,
|
* query: an /api/history call returns hundreds of points per entity,
|
||||||
* which is silly when the device already sees every reading each poll.
|
* which is silly when the device already sees every reading each poll.
|
||||||
*/
|
*/
|
||||||
ha_trend_t trend_indoor;
|
ha_trend_t trend_indoor_down;
|
||||||
|
ha_trend_t trend_indoor_up;
|
||||||
ha_trend_t trend_outdoor;
|
ha_trend_t trend_outdoor;
|
||||||
ha_trend_t trend_pressure;
|
ha_trend_t trend_pressure;
|
||||||
|
ha_trend_t trend_wind;
|
||||||
} ha_data_t;
|
} ha_data_t;
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@@ -173,10 +180,7 @@ const char *ha_wind_cardinal(float degrees);
|
|||||||
* compute returns UNKNOWN until the oldest usable sample is at least
|
* compute returns UNKNOWN until the oldest usable sample is at least
|
||||||
* HA_TREND_MIN_AGE_MS old -- "trending" over 90 seconds is noise.
|
* 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);
|
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);
|
ha_trend_t ha_trend_compute(int channel, uint32_t now_ms);
|
||||||
|
|
||||||
#endif
|
#endif
|
||||||
@@ -40,8 +40,10 @@
|
|||||||
"{" \
|
"{" \
|
||||||
"\"out_t\":" "{{ num('" HA_ENT_OUTDOOR_TEMP "') }}," \
|
"\"out_t\":" "{{ num('" HA_ENT_OUTDOOR_TEMP "') }}," \
|
||||||
"\"out_h\":" "{{ num('" HA_ENT_OUTDOOR_HUMIDITY "') }}," \
|
"\"out_h\":" "{{ num('" HA_ENT_OUTDOOR_HUMIDITY "') }}," \
|
||||||
"\"in_t\":" "{{ num('" HA_ENT_INDOOR_TEMP "') }}," \
|
"\"dn_t\":" "{{ num('" HA_ENT_INDOOR_DOWN_TEMP "') }}," \
|
||||||
"\"in_h\":" "{{ num('" HA_ENT_INDOOR_HUMIDITY "') }}," \
|
"\"dn_h\":" "{{ num('" HA_ENT_INDOOR_DOWN_HUMIDITY "') }}," \
|
||||||
|
"\"up_t\":" "{{ num('" HA_ENT_INDOOR_UP_TEMP "') }}," \
|
||||||
|
"\"up_h\":" "{{ num('" HA_ENT_INDOOR_UP_HUMIDITY "') }}," \
|
||||||
"\"dew\":" "{{ num('" HA_ENT_DEW_POINT "') }}," \
|
"\"dew\":" "{{ num('" HA_ENT_DEW_POINT "') }}," \
|
||||||
"\"press\":" "{{ num('" HA_ENT_PRESSURE "') }}," \
|
"\"press\":" "{{ num('" HA_ENT_PRESSURE "') }}," \
|
||||||
"\"rain_d\":" "{{ num('" HA_ENT_RAIN_TODAY "') }}," \
|
"\"rain_d\":" "{{ num('" HA_ENT_RAIN_TODAY "') }}," \
|
||||||
|
|||||||
@@ -80,9 +80,22 @@ static const char *TAG = "display_rgb";
|
|||||||
* displays whatever was left in the buffer, showing up as short stale lines
|
* displays whatever was left in the buffer, showing up as short stale lines
|
||||||
* at the left edge where each chunk begins.
|
* at the left edge where each chunk begins.
|
||||||
*/
|
*/
|
||||||
/* 16 is the practical ceiling: two buffers at 20 lines need 64KB of internal
|
/*
|
||||||
* RAM and the allocation fails once WiFi is up. */
|
* 20 lines: two buffers at 800px x 2 bytes = 64KB of internal RAM.
|
||||||
#define BOUNCE_BUFFER_LINES 16
|
*
|
||||||
|
* This used to be 16 because 20 "fails to allocate once WiFi is up" -- true
|
||||||
|
* when display_init() ran after wifi_init_sta(). It no longer does: the
|
||||||
|
* two-phase split means display_alloc() takes these buffers from a virgin
|
||||||
|
* heap before WiFi has touched it (~200KB free at that point), so the extra
|
||||||
|
* 12.8KB is affordable now.
|
||||||
|
*
|
||||||
|
* Bigger directly attacks the artefact mechanism: each chunk buys the refill
|
||||||
|
* ISR that much more time to fetch the next one out of PSRAM before the LCD
|
||||||
|
* needs it. Going further eats the internal RAM WiFi wants at runtime --
|
||||||
|
* watch the "Free internal heap" line, and if it drops near single digits,
|
||||||
|
* come back down.
|
||||||
|
*/
|
||||||
|
#define BOUNCE_BUFFER_LINES 20
|
||||||
|
|
||||||
static esp_lcd_panel_handle_t panel = NULL;
|
static esp_lcd_panel_handle_t panel = NULL;
|
||||||
static lv_display_t *disp = NULL;
|
static lv_display_t *disp = NULL;
|
||||||
@@ -180,6 +193,25 @@ esp_err_t display_alloc(void)
|
|||||||
},
|
},
|
||||||
.flags = {
|
.flags = {
|
||||||
.fb_in_psram = true,
|
.fb_in_psram = true,
|
||||||
|
/*
|
||||||
|
* Free the cache lines the bounce refill just consumed.
|
||||||
|
*
|
||||||
|
* Bounce mode is not a DMA read of the framebuffer -- the refill
|
||||||
|
* is a CPU memcpy in an ISR, which is why the driver skips the
|
||||||
|
* usual write-back in draw_bitmap ("the data gets read by the CPU
|
||||||
|
* as well"). So the panel streams 750KB per frame at ~44Hz
|
||||||
|
* *through the DCache*, evicting the working set of everything
|
||||||
|
* else on the chip continuously. That is the real reason this
|
||||||
|
* board is so sensitive to WiFi activity and to any extra
|
||||||
|
* rendering.
|
||||||
|
*
|
||||||
|
* This flag invalidates each chunk after it has been copied, so
|
||||||
|
* framebuffer traffic stops polluting the cache. The header warns
|
||||||
|
* it "can be dangerous if data is written from other core(s)" --
|
||||||
|
* that does not apply here: it invalidates the buffer being
|
||||||
|
* scanned, and by design LVGL only ever draws into the other one.
|
||||||
|
*/
|
||||||
|
.bb_invalidate_cache = true,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+33
-36
@@ -41,14 +41,6 @@ static const char *TAG = "main";
|
|||||||
/* Consecutive failed polls before the displayed reading is treated as stale. */
|
/* Consecutive failed polls before the displayed reading is treated as stale. */
|
||||||
#define STALE_AFTER_POLLS 2
|
#define STALE_AFTER_POLLS 2
|
||||||
|
|
||||||
/*
|
|
||||||
* 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
|
/* 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. */
|
* be dropped, but a user cannot meaningfully get more than a few ahead. */
|
||||||
#define CMD_QUEUE_DEPTH 8
|
#define CMD_QUEUE_DEPTH 8
|
||||||
@@ -289,20 +281,19 @@ static void ha_task(void *arg)
|
|||||||
lvgl_port_resume();
|
lvgl_port_resume();
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Repaint everything after every fetch window.
|
* No full-screen invalidate here, deliberately.
|
||||||
*
|
*
|
||||||
* The known left-edge artefacts (see README) happen when WiFi
|
* There used to be one, on the theory that repainting would clean up
|
||||||
* interrupt load starves the bounce-refill ISR, and once bad pixels
|
* glitched pixels LVGL does not know are wrong. It made things
|
||||||
* are on screen LVGL has no idea they are wrong -- it never
|
* materially worse, and the README already said why: the corruption
|
||||||
* reinvalidates them, so they sit there indefinitely. A full
|
* scales with how much is written, because LVGL's writes into PSRAM
|
||||||
* invalidate after each poll bounds their lifetime at one poll
|
* compete with the panel's own DMA reads out of it. A full repaint is
|
||||||
* interval. Mitigation, not cure: it cannot prevent the glitch,
|
* 768KB of exactly that contention -- the largest possible dose of
|
||||||
* only clean up after it.
|
* the cause, applied as a cure.
|
||||||
|
*
|
||||||
|
* Redraw as little as possible is the actual lever. See ui_update*(),
|
||||||
|
* which now write nothing when nothing changed.
|
||||||
*/
|
*/
|
||||||
if (lvgl_port_lock(100)) {
|
|
||||||
lv_obj_invalidate(lv_screen_active());
|
|
||||||
lvgl_port_unlock();
|
|
||||||
}
|
|
||||||
|
|
||||||
poll_count++;
|
poll_count++;
|
||||||
|
|
||||||
@@ -318,25 +309,31 @@ static void ha_task(void *arg)
|
|||||||
* lost on reboot; arrows reappear ~5 minutes later.
|
* lost on reboot; arrows reappear ~5 minutes later.
|
||||||
*/
|
*/
|
||||||
const uint32_t now_ms = xTaskGetTickCount() * portTICK_PERIOD_MS;
|
const uint32_t now_ms = xTaskGetTickCount() * portTICK_PERIOD_MS;
|
||||||
if (ha_data.indoor_temp_f.valid) {
|
struct { int ch; const ha_value_t *v; } feeds[] = {
|
||||||
ha_trend_feed(HA_TREND_CH_INDOOR, ha_data.indoor_temp_f.value, now_ms);
|
{ HA_TREND_CH_INDOOR_DOWN, &ha_data.indoor_down_temp_f },
|
||||||
|
{ HA_TREND_CH_INDOOR_UP, &ha_data.indoor_up_temp_f },
|
||||||
|
{ HA_TREND_CH_OUTDOOR, &ha_data.outdoor_temp_f },
|
||||||
|
{ HA_TREND_CH_PRESSURE, &ha_data.pressure_inhg },
|
||||||
|
{ HA_TREND_CH_WIND, &ha_data.wind_mph },
|
||||||
|
};
|
||||||
|
for (size_t i = 0; i < sizeof(feeds) / sizeof(feeds[0]); i++) {
|
||||||
|
if (feeds[i].v->valid) {
|
||||||
|
ha_trend_feed(feeds[i].ch, feeds[i].v->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_data.trend_indoor_down = ha_trend_compute(HA_TREND_CH_INDOOR_DOWN, now_ms);
|
||||||
ha_trend_feed(HA_TREND_CH_PRESSURE, ha_data.pressure_inhg.value, now_ms);
|
ha_data.trend_indoor_up = ha_trend_compute(HA_TREND_CH_INDOOR_UP, now_ms);
|
||||||
}
|
ha_data.trend_outdoor = ha_trend_compute(HA_TREND_CH_OUTDOOR, now_ms);
|
||||||
ha_data.trend_indoor =
|
ha_data.trend_pressure = ha_trend_compute(HA_TREND_CH_PRESSURE, now_ms);
|
||||||
ha_trend_compute(HA_TREND_CH_INDOOR, TREND_TEMP_PER_HOUR_F, now_ms);
|
ha_data.trend_wind = ha_trend_compute(HA_TREND_CH_WIND, 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);
|
|
||||||
|
|
||||||
/* Hand the reading to the control loop, which runs on its own
|
/*
|
||||||
* task and does not care where it came from. */
|
* Downstairs drives the furnace -- it is where people are, and
|
||||||
publish_indoor_temp(&ha_data.indoor_temp_f);
|
* heat rises, so controlling on upstairs would under-heat the
|
||||||
|
* living space. Change this line and the big card in ui_manager
|
||||||
|
* together if that ever needs to swap.
|
||||||
|
*/
|
||||||
|
publish_indoor_temp(&ha_data.indoor_down_temp_f);
|
||||||
|
|
||||||
ui_update(&ha_data);
|
ui_update(&ha_data);
|
||||||
ui_set_stale(false);
|
ui_set_stale(false);
|
||||||
|
|||||||
+310
-102
@@ -94,6 +94,9 @@ static const char *TAG = "ui_manager";
|
|||||||
*/
|
*/
|
||||||
#define NO_VALUE "--"
|
#define NO_VALUE "--"
|
||||||
|
|
||||||
|
/* Compass dial diameter. Sized to the 120px column with its 8px padding. */
|
||||||
|
#define COMPASS_SIZE 96
|
||||||
|
|
||||||
/* Icon fonts, one per size in use. Created in ui_init from the embedded
|
/* Icon fonts, one per size in use. Created in ui_init from the embedded
|
||||||
* subset TTF; NULL if tiny_ttf fails, in which case icons render empty
|
* subset TTF; NULL if tiny_ttf fails, in which case icons render empty
|
||||||
* rather than as tofu. */
|
* rather than as tofu. */
|
||||||
@@ -106,10 +109,25 @@ static lv_obj_t *date_label;
|
|||||||
static lv_obj_t *cond_label;
|
static lv_obj_t *cond_label;
|
||||||
static lv_obj_t *cond_icon;
|
static lv_obj_t *cond_icon;
|
||||||
|
|
||||||
static lv_obj_t *temp_label;
|
static lv_obj_t *temp_label; /* downstairs, the primary reading */
|
||||||
static lv_obj_t *temp_unit_label;
|
static lv_obj_t *temp_unit_label;
|
||||||
static lv_obj_t *indoor_detail_label;
|
static lv_obj_t *indoor_detail_label;
|
||||||
static lv_obj_t *trend_indoor_icon;
|
static lv_obj_t *trend_indoor_icon;
|
||||||
|
static lv_obj_t *upstairs_value_label; /* secondary, deliberately small */
|
||||||
|
static lv_obj_t *upstairs_sub_label;
|
||||||
|
static lv_obj_t *trend_upstairs_icon;
|
||||||
|
|
||||||
|
/* Wind compass. The needle is an lv_line whose two points are recomputed on
|
||||||
|
* each update -- no image asset, no rotation transform, and the dirty area is
|
||||||
|
* just the dial. */
|
||||||
|
static lv_obj_t *compass_dial;
|
||||||
|
static lv_obj_t *compass_needle;
|
||||||
|
static lv_obj_t *compass_north;
|
||||||
|
static lv_obj_t *wind_speed_label;
|
||||||
|
static lv_obj_t *wind_unit_label;
|
||||||
|
static lv_obj_t *wind_dir_label;
|
||||||
|
static lv_obj_t *wind_trend_icon;
|
||||||
|
static lv_point_precise_t compass_pts[2];
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* 2x2 reading grid.
|
* 2x2 reading grid.
|
||||||
@@ -195,33 +213,20 @@ static bool slider_touched[HA_LIGHT_COUNT];
|
|||||||
static int32_t slider_sent_pct[HA_LIGHT_COUNT];
|
static int32_t slider_sent_pct[HA_LIGHT_COUNT];
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* One-shot "repair" repaint, rescheduled by every interaction to fire 600ms
|
* There is deliberately no "repair" repaint here any more.
|
||||||
* after the last one.
|
|
||||||
*
|
*
|
||||||
* Redrawing is itself what glitches the panel: the flush's PSRAM writes
|
* An earlier version scheduled a full-screen invalidate 600ms after each
|
||||||
* contend with the LCD's scan-out, and a burst of interactive redraws is the
|
* interaction, on the theory that repainting would clear glitched pixels
|
||||||
* most likely moment for the bounce ISR to lose (README, PSRAM bandwidth).
|
* LVGL does not know are wrong. It made the artefacts worse and constant
|
||||||
* The glitched lines then persist because LVGL believes those pixels are
|
* rather than occasional, for the reason the README already gave: the
|
||||||
* fine. One full repaint after the burst settles cleans them up within
|
* corruption scales with how much is written, because LVGL's PSRAM writes
|
||||||
* ~0.6s instead of leaving them until the next poll's repaint, up to 60s
|
* compete with the panel's DMA reads out of the same PSRAM. Repainting to
|
||||||
* away. It runs once per burst, not periodically -- a constant repaint loop
|
* fix corruption caused by repainting does not converge.
|
||||||
* would be a constant supply of fresh glitch opportunities.
|
*
|
||||||
|
* The lever that actually works is writing less. Everything below is
|
||||||
|
* change-guarded: if a value has not moved, no style is written and nothing
|
||||||
|
* is invalidated.
|
||||||
*/
|
*/
|
||||||
static lv_timer_t *repair_timer;
|
|
||||||
|
|
||||||
static void repair_timer_cb(lv_timer_t *t)
|
|
||||||
{
|
|
||||||
lv_obj_invalidate(lv_screen_active());
|
|
||||||
lv_timer_pause(t);
|
|
||||||
}
|
|
||||||
|
|
||||||
static void schedule_repair(void)
|
|
||||||
{
|
|
||||||
if (repair_timer != NULL) {
|
|
||||||
lv_timer_reset(repair_timer);
|
|
||||||
lv_timer_resume(repair_timer);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* --------------------------------------------------------- condition icons */
|
/* --------------------------------------------------------- condition icons */
|
||||||
|
|
||||||
@@ -348,18 +353,24 @@ static void trend_render(lv_obj_t *icon, ha_trend_t t)
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
switch (t) {
|
/*
|
||||||
case HA_TREND_RISING:
|
* The colour write is skipped when the glyph did not change. Style writes
|
||||||
label_set_if_changed(icon, ICON_TREND_UP);
|
* invalidate unconditionally, so an unguarded one here would repaint
|
||||||
|
* three arrows on every poll to redraw the same pixels.
|
||||||
|
*/
|
||||||
|
const char *glyph = (t == HA_TREND_RISING) ? ICON_TREND_UP
|
||||||
|
: (t == HA_TREND_FALLING) ? ICON_TREND_DOWN
|
||||||
|
: "";
|
||||||
|
const char *current = lv_label_get_text(icon);
|
||||||
|
if (current != NULL && strcmp(current, glyph) == 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
lv_label_set_text(icon, glyph);
|
||||||
|
if (t == HA_TREND_RISING) {
|
||||||
lv_obj_set_style_text_color(icon, lv_color_hex(COL_ACCENT_WARM), LV_PART_MAIN);
|
lv_obj_set_style_text_color(icon, lv_color_hex(COL_ACCENT_WARM), LV_PART_MAIN);
|
||||||
break;
|
} else if (t == HA_TREND_FALLING) {
|
||||||
case HA_TREND_FALLING:
|
|
||||||
label_set_if_changed(icon, ICON_TREND_DOWN);
|
|
||||||
lv_obj_set_style_text_color(icon, lv_color_hex(COL_ACCENT), LV_PART_MAIN);
|
lv_obj_set_style_text_color(icon, lv_color_hex(COL_ACCENT), LV_PART_MAIN);
|
||||||
break;
|
|
||||||
default:
|
|
||||||
label_set_if_changed(icon, "");
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -409,7 +420,6 @@ static void therm_button_clicked(lv_event_t *e)
|
|||||||
if (therm_cb != NULL) {
|
if (therm_cb != NULL) {
|
||||||
therm_cb(cmd);
|
therm_cb(cmd);
|
||||||
}
|
}
|
||||||
schedule_repair();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ------------------------------------------------------------ light tiles */
|
/* ------------------------------------------------------------ light tiles */
|
||||||
@@ -469,7 +479,6 @@ static void light_tile_clicked(lv_event_t *e)
|
|||||||
: -1,
|
: -1,
|
||||||
};
|
};
|
||||||
light_apply_optimistic(idx, expect);
|
light_apply_optimistic(idx, expect);
|
||||||
schedule_repair();
|
|
||||||
|
|
||||||
/* -1: a tap is on/off only. Brightness is the slider's job, and sending
|
/* -1: a tap is on/off only. Brightness is the slider's job, and sending
|
||||||
* the current brightness here would fight a user who dimmed the light
|
* the current brightness here would fight a user who dimmed the light
|
||||||
@@ -525,7 +534,6 @@ static void light_slider_changed(lv_event_t *e)
|
|||||||
lv_color_hex(pct > 0 ? COL_ON : COL_TEXT_DIM),
|
lv_color_hex(pct > 0 ? COL_ON : COL_TEXT_DIM),
|
||||||
LV_PART_MAIN);
|
LV_PART_MAIN);
|
||||||
|
|
||||||
schedule_repair();
|
|
||||||
|
|
||||||
if (light_cb == NULL || !lv_obj_has_state(slider, LV_STATE_PRESSED)) {
|
if (light_cb == NULL || !lv_obj_has_state(slider, LV_STATE_PRESSED)) {
|
||||||
return;
|
return;
|
||||||
@@ -552,7 +560,6 @@ static void light_slider_released(lv_event_t *e)
|
|||||||
const bool touched = slider_touched[idx];
|
const bool touched = slider_touched[idx];
|
||||||
slider_touched[idx] = false;
|
slider_touched[idx] = false;
|
||||||
|
|
||||||
schedule_repair();
|
|
||||||
|
|
||||||
if (touched && pct == slider_sent_pct[idx]) {
|
if (touched && pct == slider_sent_pct[idx]) {
|
||||||
return; /* tap without drag: the press command already said this */
|
return; /* tap without drag: the press command already said this */
|
||||||
@@ -778,10 +785,10 @@ static void build_body(lv_obj_t *scr)
|
|||||||
lv_obj_remove_flag(row, LV_OBJ_FLAG_SCROLLABLE);
|
lv_obj_remove_flag(row, LV_OBJ_FLAG_SCROLLABLE);
|
||||||
|
|
||||||
/* --- thermostat card: current temperature and what the furnace is doing --- */
|
/* --- thermostat card: current temperature and what the furnace is doing --- */
|
||||||
lv_obj_t *card = panel_create(row, 300, LV_PCT(100), COL_PANEL);
|
lv_obj_t *card = panel_create(row, 296, LV_PCT(100), COL_PANEL);
|
||||||
lv_obj_set_style_pad_all(card, 12, LV_PART_MAIN);
|
lv_obj_set_style_pad_all(card, 12, LV_PART_MAIN);
|
||||||
|
|
||||||
lv_obj_t *heading = label_create(card, "INDOOR", &lv_font_montserrat_14, COL_TEXT_DIM);
|
lv_obj_t *heading = label_create(card, "DOWNSTAIRS", &lv_font_montserrat_14, COL_TEXT_DIM);
|
||||||
lv_obj_set_style_text_letter_space(heading, 2, LV_PART_MAIN);
|
lv_obj_set_style_text_letter_space(heading, 2, LV_PART_MAIN);
|
||||||
lv_obj_align(heading, LV_ALIGN_TOP_LEFT, 0, 0);
|
lv_obj_align(heading, LV_ALIGN_TOP_LEFT, 0, 0);
|
||||||
|
|
||||||
@@ -804,6 +811,25 @@ static void build_body(lv_obj_t *scr)
|
|||||||
indoor_detail_label = label_create(card, "--", &lv_font_montserrat_14, COL_TEXT_DIM);
|
indoor_detail_label = label_create(card, "--", &lv_font_montserrat_14, COL_TEXT_DIM);
|
||||||
lv_obj_align(indoor_detail_label, LV_ALIGN_TOP_LEFT, 0, 78);
|
lv_obj_align(indoor_detail_label, LV_ALIGN_TOP_LEFT, 0, 78);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Upstairs sits beside the big number rather than under it: same card,
|
||||||
|
* obviously subordinate by size, and it costs no vertical space in a
|
||||||
|
* layout that has none to spare.
|
||||||
|
*/
|
||||||
|
lv_obj_t *up_cap = label_create(card, "UPSTAIRS", &lv_font_montserrat_12, COL_TEXT_FAINT);
|
||||||
|
lv_obj_set_style_text_letter_space(up_cap, 1, LV_PART_MAIN);
|
||||||
|
lv_obj_align(up_cap, LV_ALIGN_TOP_RIGHT, 0, 26);
|
||||||
|
|
||||||
|
upstairs_value_label = label_create(card, NO_VALUE, &lv_font_montserrat_24, COL_TEXT_DIM);
|
||||||
|
lv_obj_align(upstairs_value_label, LV_ALIGN_TOP_RIGHT, 0, 42);
|
||||||
|
|
||||||
|
trend_upstairs_icon = icon_create(card, "", icon_font_16, COL_TEXT_DIM);
|
||||||
|
lv_obj_align_to(trend_upstairs_icon, upstairs_value_label,
|
||||||
|
LV_ALIGN_OUT_LEFT_MID, -4, 0);
|
||||||
|
|
||||||
|
upstairs_sub_label = label_create(card, "", &lv_font_montserrat_12, COL_TEXT_FAINT);
|
||||||
|
lv_obj_align(upstairs_sub_label, LV_ALIGN_TOP_RIGHT, 0, 70);
|
||||||
|
|
||||||
/* Status dot plus text: the dot carries the state as colour so it reads
|
/* Status dot plus text: the dot carries the state as colour so it reads
|
||||||
* from across the room without parsing a word. */
|
* from across the room without parsing a word. */
|
||||||
therm_state_dot = lv_obj_create(card);
|
therm_state_dot = lv_obj_create(card);
|
||||||
@@ -819,18 +845,18 @@ static void build_body(lv_obj_t *scr)
|
|||||||
|
|
||||||
/* Two buttons across the bottom of the card. 134px each is a comfortable
|
/* Two buttons across the bottom of the card. 134px each is a comfortable
|
||||||
* finger target; the row fills the card's 276px inner width. */
|
* finger target; the row fills the card's 276px inner width. */
|
||||||
mode_btn = button_create(card, 134, 40, "Heat", &mode_btn_label);
|
mode_btn = button_create(card, 132, 40, "Heat", &mode_btn_label);
|
||||||
lv_obj_align(mode_btn, LV_ALIGN_BOTTOM_LEFT, 0, 0);
|
lv_obj_align(mode_btn, LV_ALIGN_BOTTOM_LEFT, 0, 0);
|
||||||
lv_obj_add_event_cb(mode_btn, therm_button_clicked, LV_EVENT_CLICKED,
|
lv_obj_add_event_cb(mode_btn, therm_button_clicked, LV_EVENT_CLICKED,
|
||||||
(void *)(intptr_t)UI_THERM_MODE_TOGGLE);
|
(void *)(intptr_t)UI_THERM_MODE_TOGGLE);
|
||||||
|
|
||||||
fan_btn = button_create(card, 134, 40, "Fan auto", &fan_btn_label);
|
fan_btn = button_create(card, 132, 40, "Fan auto", &fan_btn_label);
|
||||||
lv_obj_align(fan_btn, LV_ALIGN_BOTTOM_RIGHT, 0, 0);
|
lv_obj_align(fan_btn, LV_ALIGN_BOTTOM_RIGHT, 0, 0);
|
||||||
lv_obj_add_event_cb(fan_btn, therm_button_clicked, LV_EVENT_CLICKED,
|
lv_obj_add_event_cb(fan_btn, therm_button_clicked, LV_EVENT_CLICKED,
|
||||||
(void *)(intptr_t)UI_THERM_FAN_TOGGLE);
|
(void *)(intptr_t)UI_THERM_FAN_TOGGLE);
|
||||||
|
|
||||||
/* --- setpoint column: up, value, down --- */
|
/* --- setpoint column: up, value, down --- */
|
||||||
lv_obj_t *setcol = panel_create(row, 120, LV_PCT(100), COL_PANEL);
|
lv_obj_t *setcol = panel_create(row, 110, LV_PCT(100), COL_PANEL);
|
||||||
lv_obj_set_style_pad_all(setcol, 8, LV_PART_MAIN);
|
lv_obj_set_style_pad_all(setcol, 8, LV_PART_MAIN);
|
||||||
|
|
||||||
lv_obj_t *up_btn = button_create(setcol, LV_PCT(100), 52, "+", NULL);
|
lv_obj_t *up_btn = button_create(setcol, LV_PCT(100), 52, "+", NULL);
|
||||||
@@ -850,6 +876,48 @@ static void build_body(lv_obj_t *scr)
|
|||||||
lv_obj_add_event_cb(down_btn, therm_button_clicked, LV_EVENT_CLICKED,
|
lv_obj_add_event_cb(down_btn, therm_button_clicked, LV_EVENT_CLICKED,
|
||||||
(void *)(intptr_t)UI_THERM_SETPOINT_DOWN);
|
(void *)(intptr_t)UI_THERM_SETPOINT_DOWN);
|
||||||
|
|
||||||
|
/* --- wind compass --- */
|
||||||
|
lv_obj_t *windcol = panel_create(row, 120, LV_PCT(100), COL_PANEL);
|
||||||
|
lv_obj_set_style_pad_all(windcol, 8, LV_PART_MAIN);
|
||||||
|
|
||||||
|
lv_obj_t *wind_cap = label_create(windcol, "WIND", &lv_font_montserrat_12, COL_TEXT_FAINT);
|
||||||
|
lv_obj_set_style_text_letter_space(wind_cap, 1, LV_PART_MAIN);
|
||||||
|
lv_obj_align(wind_cap, LV_ALIGN_TOP_LEFT, 0, 0);
|
||||||
|
|
||||||
|
/* The dial is just a bordered circle; the needle is drawn over it. */
|
||||||
|
compass_dial = lv_obj_create(windcol);
|
||||||
|
lv_obj_set_size(compass_dial, COMPASS_SIZE, COMPASS_SIZE);
|
||||||
|
lv_obj_set_style_radius(compass_dial, COMPASS_SIZE / 2, LV_PART_MAIN);
|
||||||
|
lv_obj_set_style_bg_color(compass_dial, lv_color_hex(COL_TRACK), LV_PART_MAIN);
|
||||||
|
lv_obj_set_style_border_width(compass_dial, 2, LV_PART_MAIN);
|
||||||
|
lv_obj_set_style_border_color(compass_dial, lv_color_hex(COL_PANEL_HI), LV_PART_MAIN);
|
||||||
|
lv_obj_set_style_pad_all(compass_dial, 0, LV_PART_MAIN);
|
||||||
|
lv_obj_remove_flag(compass_dial, LV_OBJ_FLAG_SCROLLABLE);
|
||||||
|
lv_obj_align(compass_dial, LV_ALIGN_TOP_MID, 0, 18);
|
||||||
|
|
||||||
|
compass_north = label_create(compass_dial, "N", &lv_font_montserrat_12, COL_TEXT_FAINT);
|
||||||
|
lv_obj_align(compass_north, LV_ALIGN_TOP_MID, 0, 2);
|
||||||
|
|
||||||
|
compass_needle = lv_line_create(compass_dial);
|
||||||
|
lv_obj_set_style_line_width(compass_needle, 3, LV_PART_MAIN);
|
||||||
|
lv_obj_set_style_line_color(compass_needle, lv_color_hex(COL_ACCENT), LV_PART_MAIN);
|
||||||
|
lv_obj_set_style_line_rounded(compass_needle, true, LV_PART_MAIN);
|
||||||
|
compass_pts[0] = (lv_point_precise_t){ COMPASS_SIZE / 2, COMPASS_SIZE / 2 };
|
||||||
|
compass_pts[1] = (lv_point_precise_t){ COMPASS_SIZE / 2, COMPASS_SIZE / 2 };
|
||||||
|
lv_line_set_points(compass_needle, compass_pts, 2);
|
||||||
|
|
||||||
|
wind_dir_label = label_create(windcol, NO_VALUE, &lv_font_montserrat_14, COL_TEXT_DIM);
|
||||||
|
lv_obj_align(wind_dir_label, LV_ALIGN_TOP_MID, 0, 18 + COMPASS_SIZE + 4);
|
||||||
|
|
||||||
|
wind_speed_label = label_create(windcol, NO_VALUE, &lv_font_montserrat_20, COL_TEXT);
|
||||||
|
lv_obj_align(wind_speed_label, LV_ALIGN_BOTTOM_LEFT, 0, 0);
|
||||||
|
|
||||||
|
wind_unit_label = label_create(windcol, "mph", &lv_font_montserrat_12, COL_TEXT_FAINT);
|
||||||
|
lv_obj_align_to(wind_unit_label, wind_speed_label, LV_ALIGN_OUT_RIGHT_BOTTOM, 3, -3);
|
||||||
|
|
||||||
|
wind_trend_icon = icon_create(windcol, "", icon_font_16, COL_TEXT_DIM);
|
||||||
|
lv_obj_align(wind_trend_icon, LV_ALIGN_BOTTOM_RIGHT, 0, -2);
|
||||||
|
|
||||||
/* --- 2x2 reading grid --- */
|
/* --- 2x2 reading grid --- */
|
||||||
lv_obj_t *grid = lv_obj_create(row);
|
lv_obj_t *grid = lv_obj_create(row);
|
||||||
lv_obj_set_size(grid, LV_GRID_CONTENT, LV_PCT(100));
|
lv_obj_set_size(grid, LV_GRID_CONTENT, LV_PCT(100));
|
||||||
@@ -862,7 +930,7 @@ static void build_body(lv_obj_t *scr)
|
|||||||
lv_obj_remove_flag(grid, LV_OBJ_FLAG_SCROLLABLE);
|
lv_obj_remove_flag(grid, LV_OBJ_FLAG_SCROLLABLE);
|
||||||
|
|
||||||
static const char *const captions[CELL_COUNT] = {
|
static const char *const captions[CELL_COUNT] = {
|
||||||
"OUTDOOR", "RAIN TODAY", "SUN", "PRESSURE",
|
"OUTDOOR", "RAIN", "SUN", "PRESSURE",
|
||||||
};
|
};
|
||||||
static const char *const cell_icons[CELL_COUNT] = {
|
static const char *const cell_icons[CELL_COUNT] = {
|
||||||
ICON_THERMOMETER, ICON_WATER, ICON_SUNSET, ICON_GAUGE,
|
ICON_THERMOMETER, ICON_WATER, ICON_SUNSET, ICON_GAUGE,
|
||||||
@@ -873,8 +941,8 @@ static void build_body(lv_obj_t *scr)
|
|||||||
const int32_t cell_h = (BAND_BODY_H - BAND_GAP) / 2;
|
const int32_t cell_h = (BAND_BODY_H - BAND_GAP) / 2;
|
||||||
|
|
||||||
for (int i = 0; i < CELL_COUNT; i++) {
|
for (int i = 0; i < CELL_COUNT; i++) {
|
||||||
lv_obj_t *cell = panel_create(grid, 172, cell_h, COL_PANEL);
|
lv_obj_t *cell = panel_create(grid, 117, cell_h, COL_PANEL);
|
||||||
lv_obj_set_style_pad_all(cell, 10, LV_PART_MAIN);
|
lv_obj_set_style_pad_all(cell, 8, LV_PART_MAIN);
|
||||||
|
|
||||||
lv_obj_t *cap = label_create(cell, captions[i], &lv_font_montserrat_12, COL_TEXT_FAINT);
|
lv_obj_t *cap = label_create(cell, captions[i], &lv_font_montserrat_12, COL_TEXT_FAINT);
|
||||||
lv_obj_set_style_text_letter_space(cap, 1, LV_PART_MAIN);
|
lv_obj_set_style_text_letter_space(cap, 1, LV_PART_MAIN);
|
||||||
@@ -883,7 +951,7 @@ static void build_body(lv_obj_t *scr)
|
|||||||
lv_obj_t *ic = icon_create(cell, cell_icons[i], icon_font_16, COL_TEXT_FAINT);
|
lv_obj_t *ic = icon_create(cell, cell_icons[i], icon_font_16, COL_TEXT_FAINT);
|
||||||
lv_obj_align(ic, LV_ALIGN_TOP_RIGHT, 0, -1);
|
lv_obj_align(ic, LV_ALIGN_TOP_RIGHT, 0, -1);
|
||||||
|
|
||||||
cell_value[i] = label_create(cell, NO_VALUE, &lv_font_montserrat_24, COL_TEXT);
|
cell_value[i] = label_create(cell, NO_VALUE, &lv_font_montserrat_20, COL_TEXT);
|
||||||
lv_obj_align(cell_value[i], LV_ALIGN_LEFT_MID, 0, 4);
|
lv_obj_align(cell_value[i], LV_ALIGN_LEFT_MID, 0, 4);
|
||||||
|
|
||||||
/* Only readings with a meaningful hourly drift get an arrow slot. */
|
/* Only readings with a meaningful hourly drift get an arrow slot. */
|
||||||
@@ -952,26 +1020,45 @@ static void update_body(const ha_data_t *d)
|
|||||||
char buf[48];
|
char buf[48];
|
||||||
char sub[48];
|
char sub[48];
|
||||||
|
|
||||||
/* Indoor card. Rounded with lroundf, not truncation: below 0 degF a cast
|
/* Downstairs -- the primary reading, and what the furnace controls on. */
|
||||||
* rounds toward zero and turns -10.6 into -10. */
|
if (d->indoor_down_temp_f.valid) {
|
||||||
if (d->indoor_temp_f.valid) {
|
snprintf(buf, sizeof(buf), "%.1f", d->indoor_down_temp_f.value);
|
||||||
snprintf(buf, sizeof(buf), "%.1f", d->indoor_temp_f.value);
|
|
||||||
} else {
|
} else {
|
||||||
snprintf(buf, sizeof(buf), NO_VALUE);
|
snprintf(buf, sizeof(buf), NO_VALUE);
|
||||||
}
|
}
|
||||||
label_set_if_changed(temp_label, buf);
|
label_set_if_changed(temp_label, buf);
|
||||||
/* Both hang off the number, whose width just changed with its digits. */
|
/* Both hang off the number, whose width just changed with its digits. */
|
||||||
lv_obj_align_to(temp_unit_label, temp_label, LV_ALIGN_OUT_RIGHT_TOP, 4, 8);
|
lv_obj_align_to(temp_unit_label, temp_label, LV_ALIGN_OUT_RIGHT_TOP, 4, 8);
|
||||||
trend_render(trend_indoor_icon, d->trend_indoor);
|
trend_render(trend_indoor_icon, d->trend_indoor_down);
|
||||||
lv_obj_align_to(trend_indoor_icon, temp_label, LV_ALIGN_OUT_RIGHT_BOTTOM, 8, -4);
|
lv_obj_align_to(trend_indoor_icon, temp_label, LV_ALIGN_OUT_RIGHT_BOTTOM, 8, -4);
|
||||||
|
|
||||||
if (d->indoor_humidity.valid) {
|
if (d->indoor_down_humidity.valid) {
|
||||||
snprintf(buf, sizeof(buf), "%.0f%% humidity", d->indoor_humidity.value);
|
snprintf(buf, sizeof(buf), "%.0f%% humidity", d->indoor_down_humidity.value);
|
||||||
} else {
|
} else {
|
||||||
snprintf(buf, sizeof(buf), NO_VALUE);
|
snprintf(buf, sizeof(buf), NO_VALUE);
|
||||||
}
|
}
|
||||||
label_set_if_changed(indoor_detail_label, buf);
|
label_set_if_changed(indoor_detail_label, buf);
|
||||||
|
|
||||||
|
/* Upstairs -- same card, smaller, right-aligned. */
|
||||||
|
if (d->indoor_up_temp_f.valid) {
|
||||||
|
snprintf(buf, sizeof(buf), "%.1f°", d->indoor_up_temp_f.value);
|
||||||
|
} else {
|
||||||
|
snprintf(buf, sizeof(buf), NO_VALUE);
|
||||||
|
}
|
||||||
|
label_set_if_changed(upstairs_value_label, buf);
|
||||||
|
lv_obj_align(upstairs_value_label, LV_ALIGN_TOP_RIGHT, 0, 42);
|
||||||
|
trend_render(trend_upstairs_icon, d->trend_indoor_up);
|
||||||
|
lv_obj_align_to(trend_upstairs_icon, upstairs_value_label,
|
||||||
|
LV_ALIGN_OUT_LEFT_MID, -4, 0);
|
||||||
|
|
||||||
|
if (d->indoor_up_humidity.valid) {
|
||||||
|
snprintf(sub, sizeof(sub), "%.0f%% RH", d->indoor_up_humidity.value);
|
||||||
|
} else {
|
||||||
|
sub[0] = '\0';
|
||||||
|
}
|
||||||
|
label_set_if_changed(upstairs_sub_label, sub);
|
||||||
|
lv_obj_align(upstairs_sub_label, LV_ALIGN_TOP_RIGHT, 0, 70);
|
||||||
|
|
||||||
/* Outdoor cell */
|
/* Outdoor cell */
|
||||||
if (d->outdoor_temp_f.valid) {
|
if (d->outdoor_temp_f.valid) {
|
||||||
snprintf(buf, sizeof(buf), "%d°", (int)lroundf(d->outdoor_temp_f.value));
|
snprintf(buf, sizeof(buf), "%d°", (int)lroundf(d->outdoor_temp_f.value));
|
||||||
@@ -983,32 +1070,13 @@ static void update_body(const ha_data_t *d)
|
|||||||
lv_obj_align_to(cell_trend[CELL_OUTDOOR], cell_value[CELL_OUTDOOR],
|
lv_obj_align_to(cell_trend[CELL_OUTDOOR], cell_value[CELL_OUTDOOR],
|
||||||
LV_ALIGN_OUT_RIGHT_MID, 6, 0);
|
LV_ALIGN_OUT_RIGHT_MID, 6, 0);
|
||||||
|
|
||||||
/*
|
/* Wind has its own compass column now, so the outdoor sub-line goes back
|
||||||
* Wind lives on the outdoor cell's sub-line now that it has no cell of
|
* to humidity and dew point. */
|
||||||
* its own -- the station anemometer has been offline since 2026-07-27 and
|
if (d->outdoor_humidity.valid && d->dew_point_f.valid) {
|
||||||
* a dedicated cell showing "--" was not worth the space the thermostat
|
|
||||||
* controls needed. It is still deliberately NOT backfilled from the
|
|
||||||
* met.no forecast: regional modelled wind under a heading that means "my
|
|
||||||
* yard" is worse than showing nothing.
|
|
||||||
*
|
|
||||||
* When the sensor comes back it displaces the dew point here, which is
|
|
||||||
* both a useful reading and a visible sign the hardware is alive again.
|
|
||||||
*/
|
|
||||||
if (d->wind_mph.valid) {
|
|
||||||
if (d->wind_dir_deg.valid) {
|
|
||||||
snprintf(sub, sizeof(sub), "%.0f%% %.0f mph %s",
|
|
||||||
d->outdoor_humidity.valid ? d->outdoor_humidity.value : 0.0f,
|
|
||||||
d->wind_mph.value, ha_wind_cardinal(d->wind_dir_deg.value));
|
|
||||||
} else {
|
|
||||||
snprintf(sub, sizeof(sub), "%.0f%% %.0f mph",
|
|
||||||
d->outdoor_humidity.valid ? d->outdoor_humidity.value : 0.0f,
|
|
||||||
d->wind_mph.value);
|
|
||||||
}
|
|
||||||
} else if (d->outdoor_humidity.valid && d->dew_point_f.valid) {
|
|
||||||
snprintf(sub, sizeof(sub), "%.0f%% dew %.0f°",
|
snprintf(sub, sizeof(sub), "%.0f%% dew %.0f°",
|
||||||
d->outdoor_humidity.value, d->dew_point_f.value);
|
d->outdoor_humidity.value, d->dew_point_f.value);
|
||||||
} else if (d->outdoor_humidity.valid) {
|
} else if (d->outdoor_humidity.valid) {
|
||||||
snprintf(sub, sizeof(sub), "%.0f%% humidity", d->outdoor_humidity.value);
|
snprintf(sub, sizeof(sub), "%.0f%% RH", d->outdoor_humidity.value);
|
||||||
} else {
|
} else {
|
||||||
sub[0] = '\0';
|
sub[0] = '\0';
|
||||||
}
|
}
|
||||||
@@ -1034,13 +1102,11 @@ static void update_body(const ha_data_t *d)
|
|||||||
if (d->sun_up) {
|
if (d->sun_up) {
|
||||||
label_set_if_changed(cell_value[CELL_SUN],
|
label_set_if_changed(cell_value[CELL_SUN],
|
||||||
d->sunset[0] ? d->sunset : NO_VALUE);
|
d->sunset[0] ? d->sunset : NO_VALUE);
|
||||||
snprintf(sub, sizeof(sub), "sets - rose %s",
|
snprintf(sub, sizeof(sub), "sunset");
|
||||||
d->sunrise[0] ? d->sunrise : NO_VALUE);
|
|
||||||
} else {
|
} else {
|
||||||
label_set_if_changed(cell_value[CELL_SUN],
|
label_set_if_changed(cell_value[CELL_SUN],
|
||||||
d->sunrise[0] ? d->sunrise : NO_VALUE);
|
d->sunrise[0] ? d->sunrise : NO_VALUE);
|
||||||
snprintf(sub, sizeof(sub), "rises - set %s",
|
snprintf(sub, sizeof(sub), "sunrise");
|
||||||
d->sunset[0] ? d->sunset : NO_VALUE);
|
|
||||||
}
|
}
|
||||||
label_set_if_changed(cell_sub[CELL_SUN], sub);
|
label_set_if_changed(cell_sub[CELL_SUN], sub);
|
||||||
|
|
||||||
@@ -1053,6 +1119,86 @@ static void update_body(const ha_data_t *d)
|
|||||||
label_set_if_changed(cell_sub[CELL_PRESSURE], "inHg");
|
label_set_if_changed(cell_sub[CELL_PRESSURE], "inHg");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Point the needle at the direction the wind is coming FROM, which is what a
|
||||||
|
* weather vane does and what the cardinal label says. 0 degrees is north, at
|
||||||
|
* the top of the dial, increasing clockwise -- so screen x uses sin and y uses
|
||||||
|
* -cos, because screen y grows downward.
|
||||||
|
*
|
||||||
|
* The needle deliberately does not animate. A spin would look better, but
|
||||||
|
* every animated frame is a redraw, and redrawing is what starves the bounce
|
||||||
|
* refill ISR (README, PSRAM bandwidth). Once a minute it snaps, and the dirty
|
||||||
|
* area is just the dial.
|
||||||
|
*/
|
||||||
|
static void compass_render(const ha_data_t *d)
|
||||||
|
{
|
||||||
|
if (compass_needle == NULL) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const int32_t c = COMPASS_SIZE / 2;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Below this, treat it as calm and draw no needle.
|
||||||
|
*
|
||||||
|
* A vane that is not being pushed reports whatever it last settled on --
|
||||||
|
* with the station becalmed that is a steady 0 degrees, which renders as a
|
||||||
|
* confident needle pointing due north. "Calm" is the honest reading; an
|
||||||
|
* arrow would be inventing information the sensor did not supply.
|
||||||
|
*/
|
||||||
|
const bool calm = d->wind_mph.valid && d->wind_mph.value < 0.5f;
|
||||||
|
|
||||||
|
if (!d->wind_dir_deg.valid || calm) {
|
||||||
|
/* Degenerate zero-length line: nothing drawn, dial stays as a dial. */
|
||||||
|
if (compass_pts[1].x != c || compass_pts[1].y != c) {
|
||||||
|
compass_pts[0] = (lv_point_precise_t){ c, c };
|
||||||
|
compass_pts[1] = (lv_point_precise_t){ c, c };
|
||||||
|
lv_line_set_points(compass_needle, compass_pts, 2);
|
||||||
|
}
|
||||||
|
label_set_if_changed(wind_dir_label, calm ? "Calm" : NO_VALUE);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const float rad = d->wind_dir_deg.value * (float)M_PI / 180.0f;
|
||||||
|
const float head_r = (float)c - 8.0f; /* stop inside the border */
|
||||||
|
const float tail_r = (float)c * 0.35f; /* short tail through centre */
|
||||||
|
|
||||||
|
const int32_t hx = c + (int32_t)lroundf(head_r * sinf(rad));
|
||||||
|
const int32_t hy = c - (int32_t)lroundf(head_r * cosf(rad));
|
||||||
|
const int32_t tx = c - (int32_t)lroundf(tail_r * sinf(rad));
|
||||||
|
const int32_t ty = c + (int32_t)lroundf(tail_r * cosf(rad));
|
||||||
|
|
||||||
|
/* Only rewrite the points when the needle actually moved --
|
||||||
|
* lv_line_set_points() invalidates unconditionally. */
|
||||||
|
if (compass_pts[0].x != tx || compass_pts[0].y != ty ||
|
||||||
|
compass_pts[1].x != hx || compass_pts[1].y != hy) {
|
||||||
|
compass_pts[0] = (lv_point_precise_t){ tx, ty };
|
||||||
|
compass_pts[1] = (lv_point_precise_t){ hx, hy };
|
||||||
|
lv_line_set_points(compass_needle, compass_pts, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
label_set_if_changed(wind_dir_label, ha_wind_cardinal(d->wind_dir_deg.value));
|
||||||
|
}
|
||||||
|
|
||||||
|
static void update_wind(const ha_data_t *d)
|
||||||
|
{
|
||||||
|
char buf[24];
|
||||||
|
|
||||||
|
compass_render(d);
|
||||||
|
|
||||||
|
if (d->wind_mph.valid) {
|
||||||
|
snprintf(buf, sizeof(buf), "%.1f", d->wind_mph.value);
|
||||||
|
} else {
|
||||||
|
snprintf(buf, sizeof(buf), NO_VALUE);
|
||||||
|
}
|
||||||
|
label_set_if_changed(wind_speed_label, buf);
|
||||||
|
lv_obj_align_to(wind_unit_label, wind_speed_label, LV_ALIGN_OUT_RIGHT_BOTTOM, 3, -3);
|
||||||
|
|
||||||
|
/* The arrow is the 5-minute trend in average speed, not the instantaneous
|
||||||
|
* reading -- see HA_TREND_CH_WIND. */
|
||||||
|
trend_render(wind_trend_icon, d->trend_wind);
|
||||||
|
}
|
||||||
|
|
||||||
static void update_forecast(const ha_data_t *d)
|
static void update_forecast(const ha_data_t *d)
|
||||||
{
|
{
|
||||||
char buf[32];
|
char buf[32];
|
||||||
@@ -1070,10 +1216,13 @@ static void update_forecast(const ha_data_t *d)
|
|||||||
|
|
||||||
if (icon_font_26 != NULL) {
|
if (icon_font_26 != NULL) {
|
||||||
const cond_icon_t *ci = cond_icon_lookup(f->condition);
|
const cond_icon_t *ci = cond_icon_lookup(f->condition);
|
||||||
label_set_if_changed(fc_icon[i], ci->icon);
|
const char *current = lv_label_get_text(fc_icon[i]);
|
||||||
|
if (current == NULL || strcmp(current, ci->icon) != 0) {
|
||||||
|
lv_label_set_text(fc_icon[i], ci->icon);
|
||||||
lv_obj_set_style_text_color(fc_icon[i], lv_color_hex(ci->colour),
|
lv_obj_set_style_text_color(fc_icon[i], lv_color_hex(ci->colour),
|
||||||
LV_PART_MAIN);
|
LV_PART_MAIN);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (f->high_f.valid && f->low_f.valid) {
|
if (f->high_f.valid && f->low_f.valid) {
|
||||||
snprintf(buf, sizeof(buf), "%d / %d",
|
snprintf(buf, sizeof(buf), "%d / %d",
|
||||||
@@ -1105,13 +1254,18 @@ void ui_update(const ha_data_t *data)
|
|||||||
if (icon_font_26 != NULL) {
|
if (icon_font_26 != NULL) {
|
||||||
const cond_icon_t *ci = cond_icon_lookup(
|
const cond_icon_t *ci = cond_icon_lookup(
|
||||||
data->condition[0] ? data->condition : NULL);
|
data->condition[0] ? data->condition : NULL);
|
||||||
label_set_if_changed(cond_icon, ci->icon);
|
/* Colour only when the glyph moved -- see trend_render(). */
|
||||||
|
const char *current = lv_label_get_text(cond_icon);
|
||||||
|
if (current == NULL || strcmp(current, ci->icon) != 0) {
|
||||||
|
lv_label_set_text(cond_icon, ci->icon);
|
||||||
lv_obj_set_style_text_color(cond_icon, lv_color_hex(ci->colour), LV_PART_MAIN);
|
lv_obj_set_style_text_color(cond_icon, lv_color_hex(ci->colour), LV_PART_MAIN);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
/* The label's width changed with its words; re-hang it off the icon. */
|
/* The label's width changed with its words; re-hang it off the icon. */
|
||||||
lv_obj_align_to(cond_label, cond_icon, LV_ALIGN_OUT_LEFT_MID, -8, 0);
|
lv_obj_align_to(cond_label, cond_icon, LV_ALIGN_OUT_LEFT_MID, -8, 0);
|
||||||
|
|
||||||
update_body(data);
|
update_body(data);
|
||||||
|
update_wind(data);
|
||||||
update_forecast(data);
|
update_forecast(data);
|
||||||
|
|
||||||
for (int i = 0; i < HA_LIGHT_COUNT; i++) {
|
for (int i = 0; i < HA_LIGHT_COUNT; i++) {
|
||||||
@@ -1138,19 +1292,34 @@ void ui_update(const ha_data_t *data)
|
|||||||
|
|
||||||
void ui_set_stale(bool stale)
|
void ui_set_stale(bool stale)
|
||||||
{
|
{
|
||||||
|
/*
|
||||||
|
* Guarded, because setting an identical style value is NOT free --
|
||||||
|
* lv_obj_set_local_style_prop() calls lv_obj_refresh_style()
|
||||||
|
* unconditionally and invalidates the object. An earlier comment here
|
||||||
|
* claimed the opposite and was wrong; called every poll, it repainted the
|
||||||
|
* temperature for no reason.
|
||||||
|
*/
|
||||||
|
static bool last_stale;
|
||||||
|
static bool last_stale_valid;
|
||||||
|
|
||||||
|
if (last_stale_valid && last_stale == stale) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!lvgl_port_lock(UI_LOCK_TIMEOUT_MS)) {
|
if (!lvgl_port_lock(UI_LOCK_TIMEOUT_MS)) {
|
||||||
ESP_LOGW(TAG, "Dropped stale-state update: LVGL lock timed out");
|
ESP_LOGW(TAG, "Dropped stale-state update: LVGL lock timed out");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (temp_label != NULL) {
|
if (temp_label != NULL) {
|
||||||
/* Unlike lv_label_set_text(), setting an identical style value does
|
|
||||||
* not invalidate, so this is safe to call on every poll. */
|
|
||||||
lv_obj_set_style_text_color(temp_label,
|
lv_obj_set_style_text_color(temp_label,
|
||||||
lv_color_hex(stale ? COL_STALE : COL_TEXT),
|
lv_color_hex(stale ? COL_STALE : COL_TEXT),
|
||||||
LV_PART_MAIN);
|
LV_PART_MAIN);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
last_stale = stale;
|
||||||
|
last_stale_valid = true;
|
||||||
|
|
||||||
lvgl_port_unlock();
|
lvgl_port_unlock();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1180,12 +1349,37 @@ void ui_set_therm_cb(ui_therm_cmd_cb_t cb)
|
|||||||
therm_cb = cb;
|
therm_cb = cb;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Previous state pushed to the thermostat card. This is not an optimisation,
|
||||||
|
* it is the fix for a real bug: this function is called once a second by the
|
||||||
|
* control task, and lv_obj_set_local_style_prop() does NOT skip identical
|
||||||
|
* values -- it calls lv_obj_refresh_style() unconditionally, which
|
||||||
|
* invalidates. Writing seven styles per second was seven redraws per second
|
||||||
|
* forever, which is what turned the left-edge artefacts from occasional into
|
||||||
|
* permanent.
|
||||||
|
*/
|
||||||
|
static therm_status_t last_therm;
|
||||||
|
static bool last_therm_valid;
|
||||||
|
|
||||||
void ui_update_thermostat(const therm_status_t *st)
|
void ui_update_thermostat(const therm_status_t *st)
|
||||||
{
|
{
|
||||||
if (st == NULL) {
|
if (st == NULL) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const bool first = !last_therm_valid;
|
||||||
|
|
||||||
|
/* The common case by far: nothing has moved, so touch nothing and do not
|
||||||
|
* even take the lock. */
|
||||||
|
if (!first &&
|
||||||
|
st->state == last_therm.state &&
|
||||||
|
st->mode == last_therm.mode &&
|
||||||
|
st->fan == last_therm.fan &&
|
||||||
|
st->lockout_remaining_s == last_therm.lockout_remaining_s &&
|
||||||
|
(int)lroundf(st->setpoint_f) == (int)lroundf(last_therm.setpoint_f)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!lvgl_port_lock(UI_LOCK_TIMEOUT_MS)) {
|
if (!lvgl_port_lock(UI_LOCK_TIMEOUT_MS)) {
|
||||||
ESP_LOGW(TAG, "Dropped thermostat update: LVGL lock timed out");
|
ESP_LOGW(TAG, "Dropped thermostat update: LVGL lock timed out");
|
||||||
return;
|
return;
|
||||||
@@ -1193,16 +1387,35 @@ void ui_update_thermostat(const therm_status_t *st)
|
|||||||
|
|
||||||
char buf[32];
|
char buf[32];
|
||||||
|
|
||||||
|
const bool heat_mode = (st->mode == THERM_MODE_HEAT);
|
||||||
|
const bool mode_changed = first || st->mode != last_therm.mode;
|
||||||
|
|
||||||
|
if (first || mode_changed ||
|
||||||
|
(int)lroundf(st->setpoint_f) != (int)lroundf(last_therm.setpoint_f)) {
|
||||||
snprintf(buf, sizeof(buf), "%d°", (int)lroundf(st->setpoint_f));
|
snprintf(buf, sizeof(buf), "%d°", (int)lroundf(st->setpoint_f));
|
||||||
label_set_if_changed(setpoint_label, buf);
|
label_set_if_changed(setpoint_label, buf);
|
||||||
|
|
||||||
|
/* Greys out when the mode is off -- it still adjusts, it just is not
|
||||||
|
* controlling anything. */
|
||||||
|
lv_obj_set_style_text_color(setpoint_label,
|
||||||
|
lv_color_hex(heat_mode ? COL_ACCENT_WARM
|
||||||
|
: COL_TEXT_FAINT),
|
||||||
|
LV_PART_MAIN);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Text changes every second while a lockout counts down, but it is one
|
||||||
|
* small label rather than the whole card. */
|
||||||
|
if (first || st->state != last_therm.state ||
|
||||||
|
st->lockout_remaining_s != last_therm.lockout_remaining_s) {
|
||||||
thermostat_state_text(st, buf, sizeof(buf));
|
thermostat_state_text(st, buf, sizeof(buf));
|
||||||
label_set_if_changed(therm_state_label, buf);
|
label_set_if_changed(therm_state_label, buf);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (first || st->state != last_therm.state) {
|
||||||
/*
|
/*
|
||||||
* The dot colour is the at-a-glance channel: amber means the burner is
|
* The dot colour is the at-a-glance channel: amber means the burner
|
||||||
* lit, red means something is wrong and no heat is coming, dim means
|
* is lit, warm orange means it wants to run but a protection timer
|
||||||
* everything is fine and there is nothing to do.
|
* says not yet, red means no heat is coming at all.
|
||||||
*/
|
*/
|
||||||
uint32_t dot = COL_TEXT_FAINT;
|
uint32_t dot = COL_TEXT_FAINT;
|
||||||
uint32_t text = COL_TEXT_DIM;
|
uint32_t text = COL_TEXT_DIM;
|
||||||
@@ -1224,8 +1437,9 @@ void ui_update_thermostat(const therm_status_t *st)
|
|||||||
}
|
}
|
||||||
lv_obj_set_style_bg_color(therm_state_dot, lv_color_hex(dot), LV_PART_MAIN);
|
lv_obj_set_style_bg_color(therm_state_dot, lv_color_hex(dot), LV_PART_MAIN);
|
||||||
lv_obj_set_style_text_color(therm_state_label, lv_color_hex(text), LV_PART_MAIN);
|
lv_obj_set_style_text_color(therm_state_label, lv_color_hex(text), LV_PART_MAIN);
|
||||||
|
}
|
||||||
|
|
||||||
const bool heat_mode = (st->mode == THERM_MODE_HEAT);
|
if (mode_changed) {
|
||||||
label_set_if_changed(mode_btn_label, heat_mode ? "Heat" : "Off");
|
label_set_if_changed(mode_btn_label, heat_mode ? "Heat" : "Off");
|
||||||
lv_obj_set_style_bg_color(mode_btn,
|
lv_obj_set_style_bg_color(mode_btn,
|
||||||
lv_color_hex(heat_mode ? COL_ACCENT_WARM : COL_PANEL_HI),
|
lv_color_hex(heat_mode ? COL_ACCENT_WARM : COL_PANEL_HI),
|
||||||
@@ -1233,7 +1447,9 @@ void ui_update_thermostat(const therm_status_t *st)
|
|||||||
lv_obj_set_style_text_color(mode_btn_label,
|
lv_obj_set_style_text_color(mode_btn_label,
|
||||||
lv_color_hex(heat_mode ? 0x2A1A05 : COL_TEXT_DIM),
|
lv_color_hex(heat_mode ? 0x2A1A05 : COL_TEXT_DIM),
|
||||||
LV_PART_MAIN);
|
LV_PART_MAIN);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (first || st->fan != last_therm.fan) {
|
||||||
const bool fan_on = (st->fan == THERM_FAN_ON);
|
const bool fan_on = (st->fan == THERM_FAN_ON);
|
||||||
label_set_if_changed(fan_btn_label, fan_on ? "Fan on" : "Fan auto");
|
label_set_if_changed(fan_btn_label, fan_on ? "Fan on" : "Fan auto");
|
||||||
lv_obj_set_style_bg_color(fan_btn,
|
lv_obj_set_style_bg_color(fan_btn,
|
||||||
@@ -1242,12 +1458,10 @@ void ui_update_thermostat(const therm_status_t *st)
|
|||||||
lv_obj_set_style_text_color(fan_btn_label,
|
lv_obj_set_style_text_color(fan_btn_label,
|
||||||
lv_color_hex(fan_on ? 0x05131F : COL_TEXT),
|
lv_color_hex(fan_on ? 0x05131F : COL_TEXT),
|
||||||
LV_PART_MAIN);
|
LV_PART_MAIN);
|
||||||
|
}
|
||||||
|
|
||||||
/* Setpoint greys out when the mode is off -- it still adjusts, it just
|
last_therm = *st;
|
||||||
* is not controlling anything. */
|
last_therm_valid = true;
|
||||||
lv_obj_set_style_text_color(setpoint_label,
|
|
||||||
lv_color_hex(heat_mode ? COL_ACCENT_WARM : COL_TEXT_FAINT),
|
|
||||||
LV_PART_MAIN);
|
|
||||||
|
|
||||||
lvgl_port_unlock();
|
lvgl_port_unlock();
|
||||||
}
|
}
|
||||||
@@ -1280,12 +1494,6 @@ void ui_init(void)
|
|||||||
lv_obj_set_flex_flow(scr, LV_FLEX_FLOW_COLUMN);
|
lv_obj_set_flex_flow(scr, LV_FLEX_FLOW_COLUMN);
|
||||||
lv_obj_remove_flag(scr, LV_OBJ_FLAG_SCROLLABLE);
|
lv_obj_remove_flag(scr, LV_OBJ_FLAG_SCROLLABLE);
|
||||||
|
|
||||||
/* Created paused; interactions reset-and-resume it. See schedule_repair. */
|
|
||||||
repair_timer = lv_timer_create(repair_timer_cb, 600, NULL);
|
|
||||||
if (repair_timer != NULL) {
|
|
||||||
lv_timer_pause(repair_timer);
|
|
||||||
}
|
|
||||||
|
|
||||||
build_header(scr);
|
build_header(scr);
|
||||||
build_body(scr);
|
build_body(scr);
|
||||||
build_lights(scr);
|
build_lights(scr);
|
||||||
|
|||||||
+21
-10
@@ -212,13 +212,17 @@ static void apply_mock_data(void)
|
|||||||
|
|
||||||
sim_data.outdoor_temp_f = (ha_value_t){ 73.0f, true };
|
sim_data.outdoor_temp_f = (ha_value_t){ 73.0f, true };
|
||||||
sim_data.outdoor_humidity = (ha_value_t){ 80.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_down_temp_f = (ha_value_t){ 71.4f, true };
|
||||||
sim_data.indoor_humidity = (ha_value_t){ 70.0f, true };
|
sim_data.indoor_down_humidity = (ha_value_t){ 46.0f, true };
|
||||||
|
sim_data.indoor_up_temp_f = (ha_value_t){ 74.8f, true };
|
||||||
|
sim_data.indoor_up_humidity = (ha_value_t){ 43.0f, true };
|
||||||
sim_data.dew_point_f = (ha_value_t){ 66.0f, true };
|
sim_data.dew_point_f = (ha_value_t){ 66.0f, true };
|
||||||
sim_data.pressure_inhg = (ha_value_t){ 29.89f, 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_today_in = (ha_value_t){ 0.0f, true };
|
||||||
sim_data.rain_rate_inhr = (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 */
|
sim_data.wind_mph = (ha_value_t){ 6.4f, true };
|
||||||
|
sim_data.gust_mph = (ha_value_t){ 11.2f, true };
|
||||||
|
sim_data.wind_dir_deg = (ha_value_t){ 292.0f, true }; /* WNW */
|
||||||
|
|
||||||
snprintf(sim_data.condition, sizeof(sim_data.condition), "cloudy");
|
snprintf(sim_data.condition, sizeof(sim_data.condition), "cloudy");
|
||||||
snprintf(sim_data.sunrise, sizeof(sim_data.sunrise), "6:05 AM");
|
snprintf(sim_data.sunrise, sizeof(sim_data.sunrise), "6:05 AM");
|
||||||
@@ -243,15 +247,22 @@ static void apply_mock_data(void)
|
|||||||
*/
|
*/
|
||||||
const uint32_t now = lv_tick_get();
|
const uint32_t now = lv_tick_get();
|
||||||
const uint32_t hour_ago = now - 55u * 60u * 1000u;
|
const uint32_t hour_ago = now - 55u * 60u * 1000u;
|
||||||
ha_trend_feed(HA_TREND_CH_INDOOR, 77.1f, hour_ago); /* -> rising */
|
const uint32_t min_ago = now - 4u * 60u * 1000u;
|
||||||
ha_trend_feed(HA_TREND_CH_INDOOR, 78.1f, now);
|
ha_trend_feed(HA_TREND_CH_INDOOR_DOWN, 70.6f, hour_ago); /* -> rising */
|
||||||
|
ha_trend_feed(HA_TREND_CH_INDOOR_DOWN, 71.4f, now);
|
||||||
|
ha_trend_feed(HA_TREND_CH_INDOOR_UP, 75.5f, hour_ago); /* -> falling */
|
||||||
|
ha_trend_feed(HA_TREND_CH_INDOOR_UP, 74.8f, now);
|
||||||
ha_trend_feed(HA_TREND_CH_OUTDOOR, 74.6f, hour_ago); /* -> falling */
|
ha_trend_feed(HA_TREND_CH_OUTDOOR, 74.6f, hour_ago); /* -> falling */
|
||||||
ha_trend_feed(HA_TREND_CH_OUTDOOR, 73.0f, now);
|
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.95f, hour_ago); /* -> falling */
|
||||||
ha_trend_feed(HA_TREND_CH_PRESSURE, 29.89f, now);
|
ha_trend_feed(HA_TREND_CH_PRESSURE, 29.89f, now);
|
||||||
sim_data.trend_indoor = ha_trend_compute(HA_TREND_CH_INDOOR, 0.5f, now);
|
ha_trend_feed(HA_TREND_CH_WIND, 3.9f, min_ago); /* -> rising */
|
||||||
sim_data.trend_outdoor = ha_trend_compute(HA_TREND_CH_OUTDOOR, 0.5f, now);
|
ha_trend_feed(HA_TREND_CH_WIND, 6.4f, now);
|
||||||
sim_data.trend_pressure = ha_trend_compute(HA_TREND_CH_PRESSURE, 0.02f, now);
|
sim_data.trend_indoor_down = ha_trend_compute(HA_TREND_CH_INDOOR_DOWN, now);
|
||||||
|
sim_data.trend_indoor_up = ha_trend_compute(HA_TREND_CH_INDOOR_UP, now);
|
||||||
|
sim_data.trend_outdoor = ha_trend_compute(HA_TREND_CH_OUTDOOR, now);
|
||||||
|
sim_data.trend_pressure = ha_trend_compute(HA_TREND_CH_PRESSURE, now);
|
||||||
|
sim_data.trend_wind = ha_trend_compute(HA_TREND_CH_WIND, now);
|
||||||
|
|
||||||
static const char *const days[HA_FORECAST_DAYS] = { "Tue", "Wed", "Thu", "Fri", "Sat" };
|
static const char *const days[HA_FORECAST_DAYS] = { "Tue", "Wed", "Thu", "Fri", "Sat" };
|
||||||
static const char *const cond[HA_FORECAST_DAYS] = {
|
static const char *const cond[HA_FORECAST_DAYS] = {
|
||||||
@@ -286,8 +297,8 @@ static void sim_thermostat_tick(void)
|
|||||||
{
|
{
|
||||||
therm_status_t st;
|
therm_status_t st;
|
||||||
|
|
||||||
const bool have_temp = sim_data.indoor_temp_f.valid;
|
const bool have_temp = sim_data.indoor_down_temp_f.valid;
|
||||||
thermostat_tick(have_temp ? sim_data.indoor_temp_f.value : 0.0f,
|
thermostat_tick(have_temp ? sim_data.indoor_down_temp_f.value : 0.0f,
|
||||||
have_temp, lv_tick_get());
|
have_temp, lv_tick_get());
|
||||||
thermostat_get_status(&st);
|
thermostat_get_status(&st);
|
||||||
|
|
||||||
|
|||||||
@@ -1 +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}]}}}
|
{"changed_states":[],"service_response":{"weather.forecast_home":{"forecast":[{"condition":"sunny","datetime":"2026-08-07T19:00:00+00:00","wind_bearing":264.8,"uv_index":0.1,"temperature":75,"templow":72,"wind_speed":7.15,"precipitation":0.0,"humidity":78},{"condition":"sunny","datetime":"2026-08-08T19:00:00+00:00","wind_bearing":286.5,"uv_index":9.8,"temperature":82,"templow":69,"wind_speed":11.43,"precipitation":0.0,"humidity":74},{"condition":"sunny","datetime":"2026-08-09T19:00:00+00:00","wind_bearing":278.2,"uv_index":9.4,"temperature":82,"templow":69,"wind_speed":10.75,"precipitation":0.0,"humidity":73},{"condition":"clear-night","datetime":"2026-08-10T19:00:00+00:00","wind_bearing":236.1,"uv_index":7.3,"temperature":80,"templow":69,"wind_speed":9.2,"precipitation":0.0,"humidity":74},{"condition":"partlycloudy","datetime":"2026-08-11T19:00:00+00:00","wind_bearing":262.0,"temperature":80,"templow":68,"wind_speed":6.28,"precipitation":0.0,"humidity":71},{"condition":"clear-night","datetime":"2026-08-12T19:00:00+00:00","wind_bearing":235.5,"temperature":78,"templow":68,"wind_speed":8.08,"precipitation":0.0,"humidity":70}]}}}
|
||||||
@@ -1 +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}]}
|
{"out_t":74.3,"out_h":80.0,"dn_t":82.2,"dn_h":61.0,"up_t":85.6,"up_h":61.0,"dew":67.72,"press":29.988,"rain_d":0.0,"rain_r":0.0,"wind":0.0,"gust":2.91,"wdir":0.0,"cond":"sunny","sunrise":"6:07 AM","sunset":"7:41 PM","sun_up":false,"clock":"7:51 PM","date":"Fri Aug 7","lights":[{"s":"on","b":127},{"s":"on","b":255},{"s":"on","b":255},{"s":"on","b":255},{"s":"on","b":255},{"s":"on","b":null}]}
|
||||||
+11
-1
@@ -64,11 +64,21 @@ def main() -> int:
|
|||||||
formatter_class=argparse.RawDescriptionHelpFormatter)
|
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||||
ap.add_argument("--host", default="192.168.1.134")
|
ap.add_argument("--host", default="192.168.1.134")
|
||||||
ap.add_argument("--name", default="live", help="snapshot basename")
|
ap.add_argument("--name", default="live", help="snapshot basename")
|
||||||
|
ap.add_argument("--token-file",
|
||||||
|
default=str(Path.home() / ".config/thermostat-hw/ha_token"),
|
||||||
|
help="file holding the long-lived token; read directly, never printed")
|
||||||
args = ap.parse_args()
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
# Read the token from its file by default. Passing it through the
|
||||||
|
# environment means it appears on a command line, and command lines end up
|
||||||
|
# in shell history and terminal transcripts; reading the path here keeps
|
||||||
|
# the value inside the process that consumes it.
|
||||||
token = os.environ.get("HA_TOKEN")
|
token = os.environ.get("HA_TOKEN")
|
||||||
if not token:
|
if not token:
|
||||||
sys.exit("HA_TOKEN is not set. See tools/ha_discover.py for how to make one.")
|
try:
|
||||||
|
token = Path(args.token_file).read_text().strip()
|
||||||
|
except OSError:
|
||||||
|
sys.exit(f"No token: set HA_TOKEN or create {args.token_file}")
|
||||||
|
|
||||||
SNAP_DIR.mkdir(parents=True, exist_ok=True)
|
SNAP_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user