Skip to content

Instantly share code, notes, and snippets.

@tomjn
Created June 25, 2026 11:28
Show Gist options
  • Select an option

  • Save tomjn/1ef6608112c1cdd1d376404a6e9f90b7 to your computer and use it in GitHub Desktop.

Select an option

Save tomjn/1ef6608112c1cdd1d376404a6e9f90b7 to your computer and use it in GitHub Desktop.
#!/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}"
"&current=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