- WHAT
- Uptime Kuma alerts drive a Tuya LED strip on my desk
- STACK
- Python · ntfy · Tuya local API
- COST
- ₹0 (strip already owned)
the itch
My homelab goes down and nothing tells me. Phone notifications get swiped away. The dashboard sits in a tab I never open.
What I do look at all day is my desk. So I pointed the status page at the desk.
I don’t need a graph for this. I need one bit, fine or not fine, and I need it to reach me without me deciding to go look. A colour in the corner of my eye does that. Everything below is plumbing to get that one bit from Uptime Kuma to a strip of LEDs.
how it works
- Kuma checks each service and posts to an ntfy topic whenever something changes state.
- conductor.py listens on that topic, finds the worst current state and picks a colour.
- The strip is driven over the Tuya local API, so nothing leaves my network.
- If conductor goes quiet, a push monitor in Kuma notices and pings me.
Kuma to ntfy
Kuma can already send a notification to an ntfy topic when a monitor changes state, so this hop is configuration, not code.
ntfy is plain HTTP pub/sub. Anything can POST to a topic, and anything can subscribe. A subscriber opens one long request to /<topic>/json and the server streams one JSON object per line as messages arrive. That’s the whole transport: no broker of my own to babysit, no client library. I run it myself on the homelab laptop.
conductor.py
conductor keeps a dictionary of service name to state. Every time a message arrives it updates one entry and recomputes a single colour from the whole dictionary.
The rule is worst state wins. One red service makes the strip red, however many others are green. That’s deliberately dumb: the strip answers “is anything wrong?”, not “what is wrong?”. The details live in Kuma.
The strip
The strip is a Tuya device, and Tuya devices can be controlled locally. You talk to the strip over TCP on your LAN, and the session is encrypted with a per-device local key. You fetch that key once from Tuya’s cloud, usually with a developer account, and after that the commands stay on the LAN.
A few things worth knowing before you try it:
- Give the strip a fixed address with a DHCP reservation. Otherwise conductor spends its life hunting for it.
- Many Tuya devices only accept one local connection at a time, so having the phone app open can lock your script out, so check whether yours does.
- Tuya devices speak different protocol versions (3.3, 3.4 and 3.5 exist). A wrong version often fails quietly instead of loudly, so set it explicitly.
The colour rules
Right now the rules are a handful of if statements: everything up is green, any host down is red. They work, and they’re also the part I like least. More on that at the bottom.
The strip went red at 3 a.m. because Kuma itself had gone down. Every service was fine. The thing watching them wasn’t.
Fix: conductor now pings a Kuma push monitor every minute. No ping means an alert, so the watcher gets watched too.
known gaps
The dead-man switch fixed the 3 a.m. problem. It didn’t make the setup honest about everything. Here’s what’s still open.
- A stuck strip looks healthy. The strip holds whatever colour it was last told. If conductor, the Wi-Fi or the strip’s power dies, a green strip stays green. The dead-man switch catches conductor dying. It doesn’t catch the strip becoming unreachable.
- The monitors live in the lab they monitor. If the whole lab goes dark, Kuma and ntfy may go with it. Kuma, ntfy and conductor all run on the same laptop. A check from outside the network would close that hole, at the price of one more moving part.
- There’s a delay I haven’t measured. With a 60 second check interval, a service can be down for up to a minute before Kuma notices, plus whatever retries you configure, plus the hop through ntfy. I don’t have an end-to-end number yet.
steal this
check.py is the short version of the idea. It pings the hosts itself instead of reading ntfy, which is enough to show the part that matters: set the colour, then tell Kuma conductor is alive.
import subprocess, requests
from strip import strip
KUMA = "http://kuma.lan/api/push/ABC123" # dead-man switch
HOSTS = ["nas.lan", "cam.lan", "pi.lan"]
def is_up(host: str, timeout: int = 2) -> bool:
r = subprocess.run(["ping", "-c", "1", host], timeout=timeout)
return r.returncode == 0
def tick():
down = [h for h in HOSTS if not is_up(h)]
strip.set("red" if down else "green")
requests.get(KUMA) # tell Kuma we're aliveThe ntfy version is a loop over the JSON stream. This one is a sketch, not tested code. The parse function depends on how your Kuma notification template is worded, so it’s left for you to fill in.
import json
import requests
NTFY = "http://ntfy.lan/homelab" # the topic Kuma publishes to
ORDER = {"up": 0, "warn": 1, "down": 2}
COLOURS = {"up": "green", "warn": "amber", "down": "red"}
state = {} # service name -> "up" | "warn" | "down"
def parse(msg: dict) -> tuple[str, str]:
# depends on your Kuma notification template; return (name, status)
raise NotImplementedError
def worst() -> str:
return max(state.values(), key=ORDER.get, default="up")
def run(strip):
# ntfy sends a JSON object per line. The read timeout must be longer than
# its keepalive gap, and you want a reconnect loop around this.
with requests.get(f"{NTFY}/json", stream=True, timeout=90) as r:
for line in r.iter_lines():
if not line:
continue
msg = json.loads(line)
if msg.get("event") != "message":
continue
name, status = parse(msg)
state[name] = status
strip.set(COLOURS[worst()])what i’d do differently
- Add the dead-man switch on day one instead of after the 3 a.m. surprise. Any alerting chain needs something watching its weakest link, and the weakest link is usually the thing you trust most.
- Use a dimmer warning colour. Amber and red fight each other for attention, and at a glance I can’t tell them apart from across the room.
- Make “alive” visible. A slow pulse once a minute would turn a stuck strip into an obvious fault instead of a convincing green.
- Move the colour rules into a small config file instead of a pile of
ifstatements. Something like this, so changing a colour doesn’t mean editing code:
states:
up: { colour: green, rank: 0 }
warn: { colour: amber, rank: 1 }
down: { colour: red, rank: 2 }
heartbeat:
every: 60s
pulse: true