Created
June 25, 2026 11:28
-
-
Save tomjn/1ef6608112c1cdd1d376404a6e9f90b7 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env python3 | |
| """One-shot indoor-vs-outside temperature/humidity snapshot. | |
| Reads HomeKit sensor values via macOS Shortcuts (`shortcuts run <name>`), | |
| each call bounded by a timeout so a sleeping home hub can't hang the run, | |
| then prints a colour terminal dashboard mirroring the physical room layout. | |
| If the outdoor shortcut fails to return a value, falls back to live Manchester | |
| weather from Open-Meteo (keyless). | |
| """ | |
| import json | |
| import os | |
| import re | |
| import subprocess | |
| import sys | |
| import urllib.request | |
| from datetime import datetime | |
| # room -> shortcut names. Outside is treated as a room for the bottom panel. | |
| SENSORS = { | |
| "Bedroom": {"temp": "Bedroom temperature", "hum": "Bedroom humidity"}, | |
| "Guest room": {"temp": "Guest room temperature", "hum": "Guest room humidity"}, | |
| "Living room": {"temp": "Living room temperature", "hum": "Living room humidity"}, | |
| "Outside": {"temp": "Home outside temperature", "hum": "Home outside humidity"}, | |
| } | |
| # left -> right, matching the physical arrangement. Outside sits below these. | |
| ROW = ["Bedroom", "Guest room", "Living room"] | |
| TIMEOUT = 30 # seconds per shortcut; bounds a hung home-hub round-trip | |
| BOX_W = 16 # inner width of each room column | |
| DOG_WARM = 26.0 # °C; rooms at/above this are "warm" for the dog | |
| DOG_HOT = 28.0 # °C; rooms at/above this are "hot" for the dog | |
| # Manchester, UK — fallback when the outdoor shortcut returns nothing. | |
| MANCHESTER = (53.4808, -2.2426) | |
| USE_COLOR = sys.stdout.isatty() and os.environ.get("NO_COLOR") is None | |
| C = { | |
| "reset": "\033[0m", "bold": "\033[1m", "dim": "\033[2m", "blink": "\033[5m", | |
| "cyan": "\033[36m", "green": "\033[32m", | |
| "yellow": "\033[33m", "red": "\033[31m", "blue": "\033[34m", | |
| } | |
| DOG_LABELS = {"ok": "dog ok", "warm": "! dog warm", "hot": "!! dog hot", "none": "dog --"} | |
| # dog marker text style (foreground only; warm/hot flash where the terminal allows) | |
| DOG_MARK = { | |
| "ok": ("green",), | |
| "warm": ("red", "blink"), | |
| "hot": ("red", "bold", "blink"), | |
| "none": ("dim",), | |
| } | |
| # comfort dot beside each title (traffic light); ● is single-width in this font | |
| DOT = "●" | |
| DOT_COLOR = {"ok": "green", "warm": "yellow", "hot": "red", "none": "dim"} | |
| def paint(s, *names): | |
| if not USE_COLOR: | |
| return s | |
| return "".join(C[n] for n in names) + s + C["reset"] | |
| def read(name): | |
| """Run a shortcut, return (value: float|None, status: str).""" | |
| try: | |
| p = subprocess.run( | |
| ["shortcuts", "run", name], | |
| capture_output=True, text=True, timeout=TIMEOUT, | |
| ) | |
| except subprocess.TimeoutExpired: | |
| return None, "timeout" | |
| if p.returncode != 0: | |
| return None, "error" | |
| m = re.search(r"-?\d+(?:\.\d+)?", p.stdout) | |
| if not m: | |
| return None, "no-value" | |
| return float(m.group()), "ok" | |
| def fetch_manchester(): | |
| """Live Manchester temp/humidity from Open-Meteo. Returns (t, h) or (None, None).""" | |
| lat, lon = MANCHESTER | |
| url = ( | |
| f"https://api.open-meteo.com/v1/forecast?latitude={lat}&longitude={lon}" | |
| "¤t=temperature_2m,relative_humidity_2m" | |
| ) | |
| try: | |
| with urllib.request.urlopen(url, timeout=10) as r: | |
| cur = json.load(r)["current"] | |
| return float(cur["temperature_2m"]), float(cur["relative_humidity_2m"]) | |
| except Exception: | |
| return None, None | |
| def temp_color(v): | |
| if v is None: | |
| return "dim" | |
| if v < 18: | |
| return "cyan" | |
| if v < 24: | |
| return "green" | |
| if v < 27: | |
| return "yellow" | |
| return "red" | |
| def fmt_temp(v): | |
| return "--" if v is None else f"{v:.1f}C" | |
| def fmt_hum(v): | |
| return "--" if v is None else f"{v:.0f}%RH" | |
| def fmt_delta(room_t, out_t): | |
| if room_t is None or out_t is None: | |
| return "", "" | |
| d = room_t - out_t | |
| arrow = "▲" if d >= 0 else "▼" | |
| return f"{arrow} {abs(d):.1f} vs out", ("red" if d >= 0 else "blue") | |
| def cell(text, width, *colors): | |
| """Center plain text to width, THEN colour — keeps alignment honest. | |
| Cell content uses only narrow glyphs (ASCII + units), so code-point | |
| count equals rendered width and the grid aligns in any terminal. | |
| """ | |
| return paint(text.center(width), *colors) if colors else text.center(width) | |
| def hrule(left, mid, right, segs): | |
| """Horizontal border line with `mid` junctions between segments.""" | |
| return left + mid.join("─" * w for w in segs) + right | |
| def crow(cells): | |
| """A content row built from pre-rendered, fixed-width cell strings.""" | |
| return "│" + "│".join(cells) + "│" | |
| def dog_level(v): | |
| if v is None: | |
| return "none" | |
| if v >= DOG_HOT: | |
| return "hot" | |
| if v >= DOG_WARM: | |
| return "warm" | |
| return "ok" | |
| def title_cell(name, level, width): | |
| """Title with a leading comfort dot, centered (all glyphs single-width here).""" | |
| plain = f"{DOT} {name}" | |
| pad = max(width - len(plain), 0) | |
| left, right = pad // 2, pad - pad // 2 | |
| body = paint(DOT, DOT_COLOR[level]) + " " + paint(name, "bold") | |
| return " " * left + body + " " * right | |
| def main(): | |
| readings = {} | |
| for room, sc in SENSORS.items(): | |
| t, ts = read(sc["temp"]) | |
| h, hs = read(sc["hum"]) | |
| readings[room] = {"temp": t, "hum": h, "temp_status": ts, "hum_status": hs} | |
| # Outdoor fallback to Manchester weather if the shortcut gave nothing. | |
| out = readings["Outside"] | |
| out_source = "HomeKit" | |
| if out["temp"] is None or out["hum"] is None: | |
| mt, mh = fetch_manchester() | |
| if out["temp"] is None and mt is not None: | |
| out["temp"] = mt | |
| out_source = "Manchester" | |
| if out["hum"] is None and mh is not None: | |
| out["hum"] = mh | |
| out_source = "Manchester" | |
| out_t = out["temp"] | |
| segs = [BOX_W] * len(ROW) | |
| titles, temps, deltas, hums, dogs = [], [], [], [], [] | |
| for room in ROW: | |
| r = readings[room] | |
| lvl = dog_level(r["temp"]) | |
| dtext, dcolor = fmt_delta(r["temp"], out_t) | |
| titles.append(title_cell(room, lvl, BOX_W)) | |
| temps.append(cell(fmt_temp(r["temp"]), BOX_W, temp_color(r["temp"]))) | |
| deltas.append(cell(dtext, BOX_W, dcolor) if dcolor else cell(dtext, BOX_W)) | |
| hums.append(cell(fmt_hum(r["hum"]), BOX_W)) | |
| dogs.append(cell(DOG_LABELS[lvl], BOX_W, *DOG_MARK[lvl])) | |
| ow = sum(segs) + (len(segs) - 1) # outside spans the room strip (shared borders) | |
| out_lvl = dog_level(out_t) | |
| outside_line = f"{fmt_temp(out_t)} {fmt_hum(out['hum'])}" | |
| grid = [ | |
| hrule("┌", "┬", "┐", segs), | |
| crow(titles), | |
| hrule("├", "┼", "┤", segs), | |
| crow(temps), | |
| crow(deltas), | |
| crow(hums), | |
| crow(dogs), | |
| hrule("├", "┴", "┤", segs), | |
| crow([title_cell(f"Outside ({out_source})", out_lvl, ow)]), | |
| hrule("├", "┼", "┤", [ow]), | |
| crow([cell(outside_line, ow, temp_color(out_t))]), | |
| crow([cell(DOG_LABELS[out_lvl], ow, *DOG_MARK[out_lvl])]), | |
| hrule("└", "┴", "┘", [ow]), | |
| ] | |
| print(f"\nHome temperatures — {datetime.now():%Y-%m-%d %H:%M}\n") | |
| print("\n".join(grid)) | |
| # Dog comfort: flag indoor rooms at/above the warm threshold. | |
| warm = [ | |
| (room, readings[room]["temp"]) | |
| for room in ROW | |
| if readings[room]["temp"] is not None and readings[room]["temp"] >= DOG_WARM | |
| ] | |
| print() | |
| if warm: | |
| names = ", ".join(f"{r} ({t:.1f}°C)" for r, t in warm) | |
| print(paint(f"Dog comfort: warm — {names}. Keep below ~{DOG_WARM:.0f}°C.", "yellow")) | |
| else: | |
| print(paint(f"Dog comfort: all rooms below {DOG_WARM:.0f}°C.", "green")) | |
| # surface any sensor that didn't read cleanly | |
| problems = [] | |
| for room, r in readings.items(): | |
| skip = room == "Outside" and out_source == "Manchester" | |
| if r["temp_status"] != "ok" and not skip: | |
| problems.append(f" {room} temperature: {r['temp_status']}") | |
| if r["hum_status"] != "ok" and not skip: | |
| problems.append(f" {room} humidity: {r['hum_status']}") | |
| if problems: | |
| print("\nUnread sensors:") | |
| print("\n".join(problems)) | |
| if __name__ == "__main__": | |
| sys.exit(main()) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment