Compare commits
1
Commits
7a58f9838a
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ea1b8b8d15 |
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,
|
||||||
@@ -232,7 +250,18 @@ 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 **20**-scanline bounce buffer stages each chunk in internal RAM (64KB across
|
A **20**-scanline bounce buffer stages each chunk in internal RAM (64KB across
|
||||||
two buffers). This was 16 for a long time because 20 "fails to allocate once
|
two buffers), with `bb_invalidate_cache` enabled.
|
||||||
|
|
||||||
|
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 is up" — which was true when `display_init()` ran after
|
||||||
`wifi_init_sta()`. The two-phase split changed that: `display_alloc()` now
|
`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
|
takes these buffers from a virgin heap with ~200KB free, long before WiFi has
|
||||||
|
|||||||
@@ -85,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 "') }}," \
|
||||||
|
|||||||
@@ -193,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,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+22
-24
@@ -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
|
||||||
@@ -317,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);
|
||||||
|
|||||||
+202
-44
@@ -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.
|
||||||
@@ -767,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);
|
||||||
|
|
||||||
@@ -793,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);
|
||||||
@@ -808,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);
|
||||||
@@ -839,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));
|
||||||
@@ -851,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,
|
||||||
@@ -862,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);
|
||||||
@@ -872,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. */
|
||||||
@@ -941,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));
|
||||||
@@ -972,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';
|
||||||
}
|
}
|
||||||
@@ -1023,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);
|
||||||
|
|
||||||
@@ -1042,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];
|
||||||
@@ -1108,6 +1265,7 @@ void ui_update(const ha_data_t *data)
|
|||||||
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++) {
|
||||||
|
|||||||
+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