Two indoor sensors, wind compass, and stop framebuffer cache thrashing
Downstairs and upstairs are now separate readings. The station names them
backwards -- "temperature_4" is downstairs and "inside_temperature" is
upstairs -- so ha_config.h says which is which. Downstairs is the primary:
big number on the card, and it is what the furnace controls on, because it
is where people are and heat rises. Upstairs sits beside it, obviously
subordinate by size, and costs no vertical space in a layout that has none.
The anemometer came back on 2026-08-07, so wind gets its own column: a dial
whose needle points at the direction the wind is coming FROM (weather-vane
convention, matching the cardinal below it), the speed, and a 5-minute trend
arrow.
- The needle does not animate. A spin would look better, but every animated
frame is a redraw, and redrawing is what starves the bounce refill ISR.
- Below 0.5 mph it shows "Calm" and draws no needle. A vane that is not
being pushed reports whatever it last settled on, which on this station
is a steady 0 degrees -- a confident needle pointing due north at zero
wind. The live snapshot caught exactly that.
- The trend is local, not HA's 10-minute average, so the window is ours to
pick: 1-minute buckets over 5 minutes, threshold 1.5 mph.
That last point needed the trend engine to stop assuming one hour. Window,
bucket size, minimum age and threshold are now per-channel in a table, which
also removes the threshold arguments main.c was passing in.
Grid drops to 117px cells to make room, so the sun sub-line is just which
event it is and "RAIN TODAY" is "RAIN" -- both were overrunning.
On the artefacts: bounce mode is NOT a DMA read of the framebuffer. The
refill is a CPU memcpy in an ISR, which is why the driver skips the usual
write-back in draw_bitmap ("the data gets read by the CPU as well"). So the
panel streams 750KB per frame at ~44Hz through the DCache, evicting the
working set of everything else on the chip, continuously -- which is the real
reason this board is so sensitive to WiFi and to any extra rendering.
bb_invalidate_cache frees each chunk after it is copied. The header warns it
"can be dangerous if data is written from other core(s)"; that does not apply
here, because it invalidates the buffer being scanned and by design LVGL only
draws into the other one. Whether it is enough is for the panel to say.
Also gives tools/ha_snapshot.py a --token-file so the token no longer has to
travel through a shell command line to reach it.
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
1 parent
7a58f9838a
commit
ea1b8b8d15
13 files changed
+435
-155
No files matched your search
+25
-8
@@ -14,21 +14,38 @@
|
||||
|
||||
#define HA_ENT_OUTDOOR_TEMP "sensor.moonlight_beach_temperature"
|
||||
#define HA_ENT_OUTDOOR_HUMIDITY "sensor.moonlight_beach_humidity"
|
||||
#define HA_ENT_INDOOR_TEMP "sensor.moonlight_beach_inside_temperature"
|
||||
#define HA_ENT_INDOOR_HUMIDITY "sensor.moonlight_beach_humidity_indoor"
|
||||
|
||||
/*
|
||||
* 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_PRESSURE "sensor.moonlight_beach_relative_pressure"
|
||||
#define HA_ENT_RAIN_TODAY "sensor.moonlight_beach_daily_rain"
|
||||
#define HA_ENT_RAIN_RATE "sensor.moonlight_beach_precipitation_intensity"
|
||||
|
||||
/*
|
||||
* The anemometer has been offline since 2026-07-27: wind speed, gust and
|
||||
* direction all read "unavailable". The display shows blanks for these by
|
||||
* design -- no substituting forecast wind for station wind.
|
||||
* The anemometer was offline from 2026-07-27 and came back 2026-08-07. All
|
||||
* three read live again. The UI still treats them as optional -- ha_value_t
|
||||
* 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;
|
||||
* it still reports its last value from 2026-07-28 and would render as a
|
||||
* plausible live number. Anything using it needs a staleness check first.
|
||||
* The 10-minute averages are the station's own smoothing. The 5-minute trend
|
||||
* shown on the compass is computed locally from the instantaneous speed
|
||||
* 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_GUST "sensor.moonlight_beach_wind_gust"
|
||||
|
||||
+63
-36
@@ -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_h", &out->outdoor_humidity);
|
||||
take_number(root, "in_t", &out->indoor_temp_f);
|
||||
take_number(root, "in_h", &out->indoor_humidity);
|
||||
take_number(root, "dn_t", &out->indoor_down_temp_f);
|
||||
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, "press", &out->pressure_inhg);
|
||||
take_number(root, "rain_d", &out->rain_today_in);
|
||||
@@ -308,22 +310,42 @@ const char *ha_condition_short(const char *slug)
|
||||
/* ------------------------------------------------------------------ trends */
|
||||
|
||||
/*
|
||||
* 20 slots, one kept per ~4 minutes, covers ~80 minutes. Feeding faster than
|
||||
* that overwrites the newest slot instead of consuming a new one, so a 60s
|
||||
* poll cadence still fills the ring at 4-minute spacing.
|
||||
* 20 slots per channel. How much history that covers depends on the bucket
|
||||
* size, which is per-channel: a room temperature is judged over an hour, but
|
||||
* wind over an hour is meaningless -- it needs minutes.
|
||||
*
|
||||
* Size matters here more than it looks: this array is .bss, which lives in
|
||||
* internal RAM on the S3 -- the same internal RAM the RGB panel's bounce
|
||||
* buffers come from, with only a few KB of slack (README, memory notes). A
|
||||
* naive 72-slot-per-poll ring cost 2.6KB and was enough to tip display_init
|
||||
* into "no mem for bounce buffer" on an unlucky boot. Keep this structure
|
||||
* small; if it ever needs to grow, it must move to PSRAM instead.
|
||||
* buffers come from, with only a few KB of slack (README, memory notes). Keep
|
||||
* this structure small; if it ever needs to grow, move it to PSRAM instead.
|
||||
*
|
||||
* 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_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 {
|
||||
float value;
|
||||
@@ -334,6 +356,12 @@ typedef struct {
|
||||
static trend_sample_t trend_ring[HA_TREND_CHANNELS][TREND_SLOTS];
|
||||
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)
|
||||
{
|
||||
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
|
||||
* 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;
|
||||
return;
|
||||
}
|
||||
@@ -355,41 +384,40 @@ void ha_trend_feed(int channel, float value, uint32_t now_ms)
|
||||
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) {
|
||||
return HA_TREND_UNKNOWN;
|
||||
}
|
||||
|
||||
/* The newest sample is the one just fed; find the usable sample whose
|
||||
* age is closest to the window. Unsigned subtraction keeps this correct
|
||||
* across tick wrap. */
|
||||
const trend_cfg_t *cfg = &TREND_CFG[channel];
|
||||
|
||||
/* Newest usable sample, and the one whose age is closest to the window. */
|
||||
const trend_sample_t *newest = NULL;
|
||||
const trend_sample_t *anchor = NULL;
|
||||
uint32_t best_dist = UINT32_MAX;
|
||||
|
||||
for (int i = 0; i < TREND_SLOTS; i++) {
|
||||
const trend_sample_t *s = &trend_ring[channel][i];
|
||||
if (!s->used) {
|
||||
const trend_sample_t *sm = &trend_ring[channel][i];
|
||||
if (!sm->used) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const uint32_t age = now_ms - s->ms;
|
||||
if (age > HA_TREND_WINDOW_MS + HA_TREND_MIN_AGE_MS) {
|
||||
continue; /* too old even for a sparse ring; ignore */
|
||||
const uint32_t age = trend_elapsed(now_ms, sm->ms);
|
||||
if (age > cfg->window_ms + cfg->min_age_ms) {
|
||||
continue; /* too old even for a sparse ring */
|
||||
}
|
||||
|
||||
if (newest == NULL || age < (now_ms - newest->ms)) {
|
||||
newest = s;
|
||||
if (newest == NULL || age < trend_elapsed(now_ms, newest->ms)) {
|
||||
newest = sm;
|
||||
}
|
||||
|
||||
if (age >= HA_TREND_MIN_AGE_MS) {
|
||||
const uint32_t dist = (age > HA_TREND_WINDOW_MS)
|
||||
? age - HA_TREND_WINDOW_MS
|
||||
: HA_TREND_WINDOW_MS - age;
|
||||
if (age >= cfg->min_age_ms) {
|
||||
const uint32_t dist = (age > cfg->window_ms) ? age - cfg->window_ms
|
||||
: cfg->window_ms - age;
|
||||
if (dist < best_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
|
||||
* a proportionally smaller bar than a full hour would. Without this the
|
||||
* first hour after boot under-reports every trend.
|
||||
* Scale the threshold to the actual span, so a half-window-old anchor
|
||||
* uses a proportionally smaller bar. Without this the period right after
|
||||
* boot under-reports every trend.
|
||||
*/
|
||||
const uint32_t span_ms = anchor->ms == newest->ms
|
||||
? 1
|
||||
: newest->ms - anchor->ms;
|
||||
const float threshold = threshold_per_hour *
|
||||
((float)span_ms / (float)HA_TREND_WINDOW_MS);
|
||||
const uint32_t span_ms = (anchor->ms == newest->ms) ? 1u
|
||||
: newest->ms - anchor->ms;
|
||||
const float threshold = cfg->threshold *
|
||||
((float)span_ms / (float)cfg->window_ms);
|
||||
|
||||
const float delta = newest->value - anchor->value;
|
||||
if (delta > threshold) {
|
||||
|
||||
+12
-8
@@ -71,9 +71,11 @@ typedef enum {
|
||||
} ha_trend_t;
|
||||
|
||||
enum {
|
||||
HA_TREND_CH_INDOOR = 0,
|
||||
HA_TREND_CH_INDOOR_DOWN = 0,
|
||||
HA_TREND_CH_INDOOR_UP,
|
||||
HA_TREND_CH_OUTDOOR,
|
||||
HA_TREND_CH_PRESSURE,
|
||||
HA_TREND_CH_WIND,
|
||||
HA_TREND_CHANNELS,
|
||||
};
|
||||
|
||||
@@ -91,8 +93,11 @@ typedef struct {
|
||||
/* Weather station */
|
||||
ha_value_t outdoor_temp_f;
|
||||
ha_value_t outdoor_humidity;
|
||||
ha_value_t indoor_temp_f;
|
||||
ha_value_t indoor_humidity;
|
||||
/* Downstairs is the primary reading and drives the control loop. */
|
||||
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 pressure_inhg;
|
||||
ha_value_t rain_today_in;
|
||||
@@ -121,9 +126,11 @@ typedef struct {
|
||||
* query: an /api/history call returns hundreds of points per entity,
|
||||
* which is silly when the device already sees every reading each poll.
|
||||
*/
|
||||
ha_trend_t trend_indoor;
|
||||
ha_trend_t trend_indoor_down;
|
||||
ha_trend_t trend_indoor_up;
|
||||
ha_trend_t trend_outdoor;
|
||||
ha_trend_t trend_pressure;
|
||||
ha_trend_t trend_wind;
|
||||
} 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
|
||||
* HA_TREND_MIN_AGE_MS old -- "trending" over 90 seconds is noise.
|
||||
*/
|
||||
#define HA_TREND_WINDOW_MS (60u * 60u * 1000u)
|
||||
#define HA_TREND_MIN_AGE_MS (5u * 60u * 1000u)
|
||||
|
||||
void ha_trend_feed(int channel, float value, uint32_t now_ms);
|
||||
ha_trend_t ha_trend_compute(int channel, float threshold_per_hour, uint32_t now_ms);
|
||||
ha_trend_t ha_trend_compute(int channel, uint32_t now_ms);
|
||||
|
||||
#endif
|
||||
@@ -40,8 +40,10 @@
|
||||
"{" \
|
||||
"\"out_t\":" "{{ num('" HA_ENT_OUTDOOR_TEMP "') }}," \
|
||||
"\"out_h\":" "{{ num('" HA_ENT_OUTDOOR_HUMIDITY "') }}," \
|
||||
"\"in_t\":" "{{ num('" HA_ENT_INDOOR_TEMP "') }}," \
|
||||
"\"in_h\":" "{{ num('" HA_ENT_INDOOR_HUMIDITY "') }}," \
|
||||
"\"dn_t\":" "{{ num('" HA_ENT_INDOOR_DOWN_TEMP "') }}," \
|
||||
"\"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 "') }}," \
|
||||
"\"press\":" "{{ num('" HA_ENT_PRESSURE "') }}," \
|
||||
"\"rain_d\":" "{{ num('" HA_ENT_RAIN_TODAY "') }}," \
|
||||
|
||||
Reference in new issue
Block a user