Smart outlets: the panel can power-cycle a device through a SmartLife (Tuya) plug

settings/outlet.py talks to Tuya's cloud with the campus's own project keys (kept in outlets.json
beside the config, 0600, never in the repo): list, status, on/off, and 'cycle <outlet> --ping
<address>' which leaves a unit that answers alone, cuts power 8 s, and waits for the address to
answer; refused while a HyperDeck is recording. A builder 'Power-cycle a smart outlet' makes the
fix from fields; the TASCAM key's ping check now holds to it (verified live: the hung DA-6400
answered again 26 s after the cycle and Companion reconnected). Settings > Outlets: keys, the
outlet list with live state and on/off, + Outlet from the account's devices. Calendar in 12-hour time.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-10-14 19:14:53 -04:00
co-authored by Claude Fable 5.1
parent 095f20b7a6
commit f61761f9bc
6 changed files with 297 additions and 3 deletions
+55
View File
@@ -38,6 +38,7 @@ import status_panel as sp
from settings import apply as applying
from settings import builders
from settings import scripts
from settings import outlet as outlets
from settings import keyguide, probes
log = logging.getLogger("status-settings")
@@ -114,6 +115,7 @@ class Site:
self.page_cache = {} # name -> (time, value)
self.draft = applying.Draft(data_dir)
self.applier = applying.Applier(self)
outlets.DATA_DIR, outlets.FILE = self.data_dir, os.path.join(self.data_dir, "outlets.json")
def load(self):
st = os.stat(self.config_path)
@@ -666,6 +668,18 @@ class Handler(http.server.BaseHTTPRequestHandler):
return self.send(200, {"local": snap.get("midi"), "remote": snap.get("remote_midi")})
if path == "/api/site/names":
return self.send(200, self.names_check((site.draft.read() or {}).get("config") or site.load()))
if path == "/api/outlets":
d = outlets.load()
t = d.get("tuya") or {}
return self.send(200, {"configured": bool(t.get("access_id") and t.get("access_secret")), "access_id": t.get("access_id", ""), "region": t.get("region", "us"),
"outlets": d.get("outlets") or {}})
if path == "/api/outlets/status":
name = urllib.parse.parse_qs(url.query).get("name", [""])[0]
try:
d = outlets.load(); o = outlets.outlet(d, name); st = outlets.client(d).status(o["id"])
return self.send(200, {"ok": True, "on": any(v for c, v in st.items() if c.startswith("switch")), "status": st})
except RuntimeError as e:
return self.send(200, {"ok": False, "error": str(e)})
if path == "/api/setup":
return self.send(200, {"computers": scripts.setup_all(site.load())})
if path == "/api/program/staged":
@@ -844,6 +858,47 @@ class Handler(http.server.BaseHTTPRequestHandler):
return self.send(200 if ok else 502, {"ok": ok, "sent": text})
if path == "/api/atem-reference":
return self.send(200, self.take_atem(raw))
if path == "/api/outlets/keys":
d = outlets.load()
d["tuya"] = {"access_id": str(body.get("access_id", "")).strip(), "access_secret": str(body.get("access_secret", "")).strip() or (d.get("tuya") or {}).get("access_secret", ""),
"region": str(body.get("region", "us")).strip() or "us"}
try:
n = len(outlets.client(d).devices())
except RuntimeError as e:
return self.send(200, {"ok": False, "error": str(e)})
outlets.save(d)
log.info("%s set the outlet keys (%d devices on the account)", who, n)
return self.send(200, {"ok": True, "devices": n})
if path == "/api/outlets/devices":
try:
devs = [{k: v for k, v in dv.items() if k != "local_key"} for dv in outlets.client().devices()]
return self.send(200, {"ok": True, "devices": devs})
except RuntimeError as e:
return self.send(200, {"ok": False, "error": str(e)})
if path == "/api/outlets":
d = outlets.load()
name = re.sub(r"[^a-z0-9_]+", "_", str(body.get("name", "")).strip().lower()).strip("_")[:40]
if not name:
return self.send(400, {"error": "a short name is needed (letters, digits, _)"})
if body.get("remove"):
d.setdefault("outlets", {}).pop(name, None)
else:
if not body.get("id"):
return self.send(400, {"error": "pick a device"})
d.setdefault("outlets", {})[name] = {"id": str(body["id"])[:64], "name": str(body.get("device_name", ""))[:80]}
outlets.save(d)
log.info("%s %s outlet %s", who, "removed" if body.get("remove") else "set", name)
return self.send(200, {"ok": True, "outlets": d["outlets"]})
if path == "/api/outlets/switch":
live, reason = site.applier.live()
if live:
return self.send(409, {"error": "live now (%s): outlets are not switched during a service" % reason})
try:
d = outlets.load(); o = outlets.outlet(d, str(body.get("name", ""))); outlets.client(d).switch(o["id"], bool(body.get("on")))
log.info("%s switched outlet %s %s", who, body.get("name"), "on" if body.get("on") else "off")
return self.send(200, {"ok": True})
except RuntimeError as e:
return self.send(200, {"ok": False, "error": str(e)})
if path == "/api/program/install":
live, reason = site.applier.live()
if live: