Files
mattandClaude ea1b8b8d15 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>
2026-08-07 19:58:41 -07:00

121 lines
4.7 KiB
Python
Executable File

#!/usr/bin/env python3
"""
Capture Home Assistant responses to disk for the simulator to render.
Iterating on layout against live data is awkward: the interesting states --
rain falling, a light dimmed, a sensor dropping out -- do not happen on
demand, and live data changes underneath a before/after comparison. Snapshots
are reproducible and can be hand-edited to produce a state that has not
occurred yet.
export HA_TOKEN=$(cat ~/.config/thermostat-hw/ha_token)
python3 tools/ha_snapshot.py # -> sim/snapshots/live.json
python3 tools/ha_snapshot.py --name rainy
./sim/build/thermostat_sim --data sim/snapshots/live.json \
--forecast sim/snapshots/live.forecast.json
The template is not duplicated here. It is read from the simulator binary via
--print-template, which is compiled from main/api/ha_template.h, so a snapshot
can never be captured with a template the firmware does not use.
"""
import argparse
import json
import os
import subprocess
import sys
import urllib.error
import urllib.request
from pathlib import Path
REPO = Path(__file__).resolve().parent.parent
SIM_BIN = REPO / "sim" / "build" / "thermostat_sim"
SNAP_DIR = REPO / "sim" / "snapshots"
def load_template() -> str:
if not SIM_BIN.exists():
sys.exit(f"{SIM_BIN} not built.\n"
" cmake -S sim -B sim/build -G Ninja && cmake --build sim/build")
out = subprocess.run([str(SIM_BIN), "--print-template"],
capture_output=True, text=True, check=True)
return out.stdout.strip()
def call(host: str, token: str, path: str, body: dict | str):
data = body.encode() if isinstance(body, str) else json.dumps(body).encode()
req = urllib.request.Request(
f"http://{host}:8123{path}",
data=data,
headers={"Authorization": f"Bearer {token}",
"Content-Type": "application/json"},
)
try:
return urllib.request.urlopen(req, timeout=20).read().decode()
except urllib.error.HTTPError as e:
sys.exit(f"HTTP {e.code} on {path}: {e.read().decode()[:400]}")
except urllib.error.URLError as e:
sys.exit(f"Cannot reach Home Assistant at {host}:8123 -- {e.reason}")
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--host", default="192.168.1.134")
ap.add_argument("--name", default="live", help="snapshot basename")
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()
# 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")
if not token:
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)
state = call(args.host, token, "/api/template", {"template": load_template()})
# Parse before writing: a template that renders to broken JSON should fail
# here rather than at the point some future run tries to render it.
try:
parsed = json.loads(state)
except json.JSONDecodeError as e:
sys.exit(f"Template rendered invalid JSON ({e}):\n{state[:500]}")
state_path = SNAP_DIR / f"{args.name}.json"
state_path.write_text(state)
forecast = call(args.host, token,
"/api/services/weather/get_forecasts?return_response",
{"entity_id": "weather.forecast_home", "type": "daily"})
fc_path = SNAP_DIR / f"{args.name}.forecast.json"
fc_path.write_text(forecast)
print(f"wrote {state_path.relative_to(REPO)} ({len(state)} bytes)")
print(f"wrote {fc_path.relative_to(REPO)} ({len(forecast)} bytes)")
dead = [k for k, v in parsed.items() if v is None]
if dead:
print(f"\nunavailable sensors in this snapshot: {', '.join(dead)}")
lights = parsed.get("lights", [])
print(f"lights: {sum(1 for l in lights if l.get('s') == 'on')} on, "
f"{sum(1 for l in lights if l.get('s') == 'off')} off, "
f"{sum(1 for l in lights if l.get('s') not in ('on', 'off'))} unavailable")
print(f"\n ./sim/build/thermostat_sim --data {state_path.relative_to(REPO)} \\")
print(f" --forecast {fc_path.relative_to(REPO)}")
return 0
if __name__ == "__main__":
sys.exit(main())