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
+11
View File
@@ -195,6 +195,17 @@ TEMPLATES = {
"[ \"$(defaults read \"com.blackmagic-design.ATEM Software Control\" midiEnabled)\" = 0 ]"),
"suggest": lambda p, cfg: "turn off MIDI control in ATEM Software Control (quits and reopens the app; the switcher is not affected)",
},
"outlet_cycle": {
"words": "Power-cycle a smart outlet when its device does not answer",
"os": "self", "closes": True,
"params": [("outlet", "Outlet", "As named under Settings > Outlets.", "str", None),
("address", "Address to watch", "The device's address: no power cycle while it answers; afterwards wait for it to answer.", "str", ""),
("wait_seconds", "Off for", "Seconds the outlet stays off.", "int", 8)],
"render": lambda p, cfg: "/usr/bin/python3 \"$HOME/Library/Application Support/status-panel/settings/outlet.py\" cycle %s%s --wait %d"
% (q(p["outlet"]), (" --ping " + q(p["address"])) if p.get("address") else "", int(p.get("wait_seconds", 8))),
"suggest": lambda p, cfg: "power-cycle the %s outlet when %s does not answer, then wait for it to come back (never while a HyperDeck is recording)"
% (p["outlet"], p.get("address") or "its device"),
},
# kinds without a command: the template is the kind, the params are its fields
"reconnect": {"words": "Reconnect a Companion connection", "kind": "reconnect", "start_only": True, "params": [], "render": None,
"suggest": lambda p, cfg: "reconnect Companion to it"},
+187
View File
@@ -0,0 +1,187 @@
#!/usr/bin/env python3
"""Smart outlets the panel may switch (a hung recorder's mains, say), through Tuya's cloud with the
campus's own project keys. Credentials and the outlet list live in outlets.json beside the config
(0600, never in the repo). Also a command for the watchdog's fixes:
outlet.py list the outlets on the account (name, id, on/off)
outlet.py status <outlet> on / off
outlet.py on|off <outlet>
outlet.py cycle <outlet> [--wait N] [--ping ADDRESS] [--ignore-live]
off, N s (default 8), on; then, with --ping, wait up to 90 s for the address to answer.
With --ping, a unit that answers already is left alone. Refused while a HyperDeck is
recording (live-now.json) unless --ignore-live.
"""
import hashlib
import hmac
import json
import os
import subprocess
import sys
import time
import urllib.error
import urllib.request
import uuid
DATA_DIR = os.environ.get("STATUS_PANEL_DATA") or os.path.expanduser("~/Library/Application Support/status-panel")
FILE = os.path.join(DATA_DIR, "outlets.json")
REGIONS = {"us": "https://openapi.tuyaus.com", "eu": "https://openapi.tuyaeu.com", "cn": "https://openapi.tuyacn.com", "in": "https://openapi.tuyain.com"}
def load():
try:
with open(FILE, encoding="utf-8") as f:
return json.load(f)
except (OSError, ValueError):
return {"tuya": {}, "outlets": {}}
def save(data):
tmp = FILE + ".tmp"
with open(tmp, "w", encoding="utf-8") as f:
json.dump(data, f, indent=1)
os.chmod(tmp, 0o600)
os.replace(tmp, FILE)
class Tuya:
"""The few cloud calls the panel needs, signed the way Tuya's OpenAPI wants (HMAC-SHA256)."""
def __init__(self, access_id, access_secret, region="us"):
self.id, self.secret = access_id, access_secret
self.base = REGIONS.get(region, region)
self.token, self.token_until = None, 0
def _sign(self, method, path, body, token):
t = str(int(time.time() * 1000))
nonce = uuid.uuid4().hex
body_hash = hashlib.sha256((body or "").encode()).hexdigest()
string = "%s\n%s\n\n%s" % (method, body_hash, path)
payload = self.id + (token or "") + t + nonce + string
sign = hmac.new(self.secret.encode(), payload.encode(), hashlib.sha256).hexdigest().upper()
return {"client_id": self.id, "sign": sign, "t": t, "nonce": nonce, "sign_method": "HMAC-SHA256", **({"access_token": token} if token else {})}
def call(self, method, path, body=None, auth=True):
if auth and (not self.token or time.time() > self.token_until - 60):
r = self.call("GET", "/v1.0/token?grant_type=1", auth=False)
self.token, self.token_until = r["access_token"], time.time() + r.get("expire_time", 3600)
data = json.dumps(body) if body is not None else None
headers = dict(self._sign(method, path, data, self.token if auth else None), **{"Content-Type": "application/json"})
req = urllib.request.Request(self.base + path, data=data.encode() if data else None, headers=headers, method=method)
try:
with urllib.request.urlopen(req, timeout=15) as resp:
out = json.loads(resp.read())
except urllib.error.HTTPError as e:
raise RuntimeError("Tuya %s %s: HTTP %s" % (method, path, e.code))
if not out.get("success"):
raise RuntimeError("Tuya %s %s: %s (%s)" % (method, path, out.get("msg"), out.get("code")))
return out["result"]
def devices(self):
"""Every device of the app accounts linked to the project: name, id, local key, address, category."""
out, last = [], ""
while True:
r = self.call("GET", "/v1.0/iot-01/associated-users/devices?size=100" + ("&last_row_key=%s" % last if last else ""))
for d in r.get("devices", []):
out.append({"id": d["id"], "name": d.get("name"), "local_key": d.get("local_key"), "ip": d.get("ip"), "category": d.get("category"),
"online": d.get("online"), "product": d.get("product_name")})
if not r.get("has_more"):
return out
last = r.get("last_row_key", "")
def status(self, device_id):
r = self.call("GET", "/v1.0/iot-03/devices/%s/status" % device_id)
return {x["code"]: x["value"] for x in r}
def switch(self, device_id, on, code=None):
if code is None:
st = self.status(device_id)
code = next((c for c in ("switch_1", "switch", "switch_2") if c in st), None) or next((c for c in st if c.startswith("switch")), "switch_1")
self.call("POST", "/v1.0/iot-03/devices/%s/commands" % device_id, {"commands": [{"code": code, "value": bool(on)}]})
return code
def client(data=None):
data = data or load()
t = data.get("tuya") or {}
if not (t.get("access_id") and t.get("access_secret")):
raise RuntimeError("no Tuya keys in %s (Settings > Outlets)" % FILE)
return Tuya(t["access_id"], t["access_secret"], t.get("region", "us"))
def outlet(data, name):
o = (data.get("outlets") or {}).get(name)
if not o:
raise RuntimeError("no outlet called %s (Settings > Outlets)" % name)
return o
def live_now():
try:
d = json.load(open(os.path.join(DATA_DIR, "live-now.json")))
return bool(d.get("live")) and time.time() - d.get("time", 0) < 60, d.get("reason", "")
except (OSError, ValueError):
return False, ""
def pings(address):
return subprocess.run(["/sbin/ping", "-c", "1", "-W", "1000", address], capture_output=True).returncode == 0
def cycle(name, wait=8, ping=None, ignore_live=False, data=None):
"""off, wait, on; then wait for the address to answer. Returns (ok, words)."""
data = data or load()
if not ignore_live:
live, reason = live_now()
if live:
return False, "not now: %s" % reason
if ping and pings(ping):
return True, "%s answers ping already: no power cycle" % ping
o = outlet(data, name)
tu = client(data)
code = tu.switch(o["id"], False)
time.sleep(wait)
tu.switch(o["id"], True, code)
if not ping:
return True, "%s switched off %d s and on again" % (name, wait)
end = time.time() + 90
while time.time() < end:
if pings(ping):
return True, "%s power-cycled; %s answers again after %d s" % (name, ping, 90 - int(end - time.time()))
time.sleep(3)
return False, "%s power-cycled but %s still does not answer after 90 s" % (name, ping)
def main(argv):
if not argv:
print(__doc__); return 2
verb, args = argv[0], argv[1:]
data = load()
if verb == "list":
for d in client(data).devices():
print("%-24s %s %s online=%s" % (d["name"], d["id"], d.get("product") or d.get("category"), d.get("online")))
return 0
if verb in ("status", "on", "off"):
o = outlet(data, args[0]); tu = client(data)
if verb == "status":
st = tu.status(o["id"]); print("on" if any(v for c, v in st.items() if c.startswith("switch")) else "off"); return 0
tu.switch(o["id"], verb == "on"); print(verb); return 0
if verb == "cycle":
wait, ping, ignore = 8, None, False
i = 1
while i < len(args):
if args[i] == "--wait": wait = int(args[i + 1]); i += 2
elif args[i] == "--ping": ping = args[i + 1]; i += 2
elif args[i] == "--ignore-live": ignore = True; i += 1
else: i += 1
ok, words = cycle(args[0], wait, ping, ignore, data)
print(words)
return 0 if ok else 1
print("unknown verb", verb); return 2
if __name__ == "__main__":
try:
sys.exit(main(sys.argv[1:]))
except RuntimeError as e:
print(e, file=sys.stderr)
sys.exit(1)
+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:
+33 -2
View File
@@ -742,6 +742,7 @@
["computers", "Computers", "Every computer the panel talks to"],
["dante", "Dante", "The devices the DANTE key expects"],
["switcher", "Switcher", "The saved ATEM file the panel keeps the switcher in line with"],
["outlets", "Outlets", "Smart plugs the panel may switch, for a device that only a power cycle brings back"],
["companion", "Companion", "Connections, and names the keys use that are not here"],
["network", "Network", "This Mac's network port and the Dante switches"],
["site", "Site & PIN", "The campus, contacts, this Mac, the PIN"],
@@ -764,6 +765,7 @@
else if (which === "computers") body = computersHtml(c);
else if (which === "dante") body = await danteHtml(c) || "<p class='muted'>No DANTE key.</p>";
else if (which === "switcher") body = await atemHtml(c) + fieldsHtml("ATEM");
else if (which === "outlets") body = await outletsHtml();
else if (which === "companion") body = (await namesHtml()) + (await companionHtml());
else if (which === "network") body = fieldsHtml("Network & Dante");
else if (which === "site") body = siteHtml(c);
@@ -822,15 +824,16 @@
for (const [d, s, e] of runs) { const k = toHHMM(s) + "-" + toHHMM(e >= 1440 ? e - 1440 : e); (byTime[k] = byTime[k] || []).push(DAYS[d]); }
return Object.entries(byTime).map(([k, days]) => { const [start, end] = k.split("-"); return { days: days.sort((a, b) => DAYS.indexOf(a) - DAYS.indexOf(b)), start, end: end === "00:00" ? "24:00" : end }; });
}
const h12 = (hhmm) => { const m = toMin(hhmm); if (m == null) return hhmm || ""; const h = Math.floor(m / 60) % 24, mm = m % 60; const ap = h < 12 ? "am" : "pm"; return `${h % 12 || 12}${mm ? ":" + String(mm).padStart(2, "0") : ""} ${ap}`; };
function calendarHtml(path, windows) {
const on = cellsOf(windows);
const SHOW = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"];
let grid = `<div class="h"></div>` + SHOW.map((d) => `<div class="h">${d[0].toUpperCase() + d.slice(1)}</div>`).join("");
for (let m = 0; m < 1440; m += 30) {
grid += `<div class="t">${m % 120 === 0 ? toHHMM(m) : ""}</div>`;
grid += `<div class="t">${m % 120 === 0 ? h12(toHHMM(m)).replace(" ", "") : ""}</div>`;
for (const name of SHOW) { const d = DAYS.indexOf(name); grid += `<div class="c${on.has(d + ":" + m) ? " on" : ""}${m % 60 === 0 ? " hour" : ""}" data-cell="${d}:${m}"></div>`; }
}
const words = (windows || []).length ? (windows || []).map((w) => `${(w.days || []).map((d) => d[0].toUpperCase() + d.slice(1)).join(", ")} ${esc(w.start)}–${esc(w.end)}`).join(" · ") : "never";
const words = (windows || []).length ? (windows || []).map((w) => `${(w.days || []).map((d) => d[0].toUpperCase() + d.slice(1)).join(", ")} ${esc(h12(w.start))} – ${esc(w.end === "24:00" ? "midnight" : h12(w.end))}`).join(" · ") : "never";
return `<div class="calwrap"><div class="cal ${editing() ? "" : "ro"}" data-calpath="${esc(path)}">${grid}</div><div class="small muted" style="max-width:220px"><b>${words}</b>${editing() ? "<br>Click or drag to paint; drag over painted cells to clear." : ""}</div></div>`;
}
function wireCalendars() {
@@ -883,6 +886,29 @@
${p.log && p.log.length ? `<details><summary>last install log${p.log_when ? " (" + esc(when(p.log_when)) + ")" : ""}</summary><pre class="log small">${esc(p.log.join("\n"))}</pre></details>` : ""}</div>
<div class="card"><div class="row"><a class="btn" href="/api/site/bundle" download data-hint="A zip of the settings, the saved switcher file and the page-key record: drop it in the repo as sites/${esc((cfg().site || {}).id || "site")}/ or feed it to another campus">Export campus bundle</a>${editing() ? `<label class="btn" data-hint="This campus's name, addresses and PIN stay; every copied computer is flagged until you touch it">New campus from a bundle <input type="file" id="bundlefile" accept=".zip,.json" hidden></label>` : ""}</div></div>`;
}
async function outletsHtml() {
const o = await api("/api/outlets");
const dis = editing() ? "" : "disabled";
const rows = Object.entries(o.outlets || {}).map(([n, v]) => `<tr><td><b>${esc(n)}</b></td><td class="small">${esc(v.name || "")}</td><td class="small" id="ost-${esc(n)}"><span class="muted">…</span></td><td>${editing() ? `<button class="small" data-oswitch="${esc(n)}|1" data-hint="Switch the outlet on">On</button> <button class="small danger" data-oswitch="${esc(n)}|0" data-hint="Switch the outlet off: whatever is on it loses power">Off</button> <button class="small" data-oremove="${esc(n)}">Remove</button>` : ""}</td></tr>`).join("");
const used = Object.entries(cfg().fixes).filter(([, f]) => f.template === "outlet_cycle").map(([id, f]) => `<span class="pill" data-hint="${esc(f.label || "")}">${esc(f.short || id)} → ${esc((f.params || {}).outlet || "?")}</span>`).join(" ");
return `<div class="card"><div class="field"><span class="fl">SmartLife / Tuya keys${hint("Access ID and Access Secret of a Cloud Project on iot.tuya.com with your SmartLife app linked to it. They stay on this Mac in a private file, never in the settings file.")}</span><span class="row"><input id="oid" placeholder="Access ID" value="${esc(o.access_id || "")}" ${dis} style="width:14em"><input id="osecret" type="password" placeholder="${o.configured ? "Access Secret (kept)" : "Access Secret"}" ${dis} style="width:16em"><select id="oregion" ${dis}>${["us", "eu", "in", "cn"].map((r) => `<option ${o.region === r ? "selected" : ""}>${r}</option>`).join("")}</select>${editing() ? `<button class="small" id="okeys">Save & test</button>` : ""}</span></div>
${o.configured ? "" : `<details><summary>how to get the keys</summary><ol class="small"><li>iot.tuya.com, free account, Cloud › Create Cloud Project (Smart Home, your region's data center).</li><li>In the project: Devices › Link App Account › scan the code with the SmartLife app (Me › scan icon).</li><li>Overview tab: copy Access ID and Access Secret here.</li></ol></details>`}</div>
<div class="card"><div class="tablewrap"><table><tr><th>outlet</th><th>plug</th><th>now</th><th></th></tr>${rows || "<tr><td colspan=4 class='muted'>none yet</td></tr>"}</table></div>
${editing() && o.configured ? `<div class="row" style="margin-top:8px"><button class="small" id="oadd">+ Outlet</button></div>` : ""}
${used ? `<p class="small" style="margin-top:8px">Used by fixes: ${used}</p>` : ""}
<p class="help">A fix "Power-cycle a smart outlet" (Keys › Fixes) cuts the power only when the device does not answer, never while a HyperDeck is recording.</p></div>`;
}
async function outletDialog() {
let devs = [];
try { const r = await api("/api/outlets/devices"); if (!r.ok) return alertBox(r.error); devs = r.devices; } catch (e) { return alertBox(e.message); }
devs.sort((a, b) => (b.online - a.online) || String(a.name).localeCompare(String(b.name)));
dialog(`<h3>New outlet</h3><select id="odev">${devs.map((d) => `<option value="${esc(d.id)}" data-name="${esc(d.name)}">${esc(d.name)}${d.online ? "" : " (offline)"}</option>`).join("")}</select>
<input id="oname" placeholder="short name the fixes use (tascam)"><div class="row"><button class="primary" id="dok">Add</button><button id="dcancel">Cancel</button></div>`, (b) => {
b.querySelector("#dcancel").onclick = closeDialog;
const sel = b.querySelector("#odev"); sel.onchange = () => { if (!b.querySelector("#oname").value) b.querySelector("#oname").value = slug(sel.selectedOptions[0].dataset.name); }; sel.onchange();
b.querySelector("#dok").onclick = async () => { const name = b.querySelector("#oname").value.trim(); if (!name) return; try { const r = await post("/api/outlets", { name, id: sel.value, device_name: sel.selectedOptions[0].dataset.name }); if (r.error) return alertBox(r.error); closeDialog(); route(); } catch (e) { alertBox(e.message); } };
});
}
async function namesHtml() {
const n = await api("/api/site/names").catch(() => null);
if (!n || !n.missing) return "";
@@ -936,6 +962,11 @@
const q = (s) => main.querySelector(s);
wireCalendars();
for (const b of main.querySelectorAll("[data-job]")) b.onclick = () => job(b.dataset.job);
const ok = q("#okeys"); if (ok) ok.onclick = async () => { ok.disabled = true; ok.textContent = "Testing…"; try { const r = await post("/api/outlets/keys", { access_id: q("#oid").value, access_secret: q("#osecret").value, region: q("#oregion").value }); alertBox(r.ok ? `Works: ${r.devices} devices on the account.` : r.error); route(); } catch (e) { alertBox(e.message); route(); } };
const oa = q("#oadd"); if (oa) oa.onclick = outletDialog;
for (const b of main.querySelectorAll("[data-oswitch]")) b.onclick = () => { const [n, on] = b.dataset.oswitch.split("|"); confirmBox(`Switch the ${n} outlet ${on === "1" ? "on" : "OFF"}?${on === "1" ? "" : " Whatever is plugged into it loses power."}`, on === "1" ? "On" : "Off", async () => { try { const r = await post("/api/outlets/switch", { name: n, on: on === "1" }); if (!r.ok) alertBox(r.error); setTimeout(route, 1500); } catch (e) { alertBox(e.message); } }); };
for (const b of main.querySelectorAll("[data-oremove]")) b.onclick = () => confirmBox(`Remove the ${b.dataset.oremove} outlet from the list? Fixes that use it stop working.`, "Remove", async () => { try { await post("/api/outlets", { name: b.dataset.oremove, remove: true }); route(); } catch (e) { alertBox(e.message); } });
for (const td of main.querySelectorAll("[id^='ost-']")) api("/api/outlets/status?name=" + encodeURIComponent(td.id.slice(4))).then((r) => { td.innerHTML = r.ok ? `<span class="chip ${r.on ? "ok" : ""}">${r.on ? "on" : "off"}</span>` : `<span class="muted" data-hint="${esc(r.error)}">?</span>`; }).catch(() => {});
for (const m of main.querySelectorAll("[data-map]")) m.onchange = () => { if (!m.value || !ensureDraft()) return; const [kind, name] = m.dataset.map.split("|"); (kind === "conn" ? renameConn : renameDante)(name, m.value); saveDraft(); route(); };
for (const b of main.querySelectorAll("[data-addconn]")) b.onclick = () => { const conn = b.dataset.addconn; if (!ensureDraft()) return; const d = state.draft.config; let id = slug(conn) || "key", n = 2; while (d.tiles.some((t) => t.id === id)) id = slug(conn) + "_" + n++; const t = { id, label: conn.toUpperCase().replace(/[_-]+/g, " ").slice(0, 10).trim(), checks: [{ type: "conn", conn, fix: "restart-connection" }] }; const pos = firstEmpty(d, "status"); if (pos) t.pos = pos; d.tiles.push(t); saveDraft(); location.hash = "#keys/" + id; };
for (const cb of main.querySelectorAll("[data-autofix]")) cb.onchange = () => { if (!ensureDraft()) return; const d = state.draft.config, id = cb.dataset.autofix, f = d.fixes[id]; const set = new Set(d.auto_fix.fixes || []); if (cb.checked) { set.add(id); if (closes(f)) f.restart_ok = true; if (f.never_auto) delete f.never_auto; } else set.delete(id); setPath("auto_fix.fixes", [...set]); };
+1 -1
View File
@@ -101,7 +101,7 @@ label.btn, a.btn { display: inline-block; cursor: pointer; border: 1px solid var
@media (max-width: 760px) { .settings { grid-template-columns: 1fr; } .subnav { display: none; } #subsel { display: block; width: 100%; margin-bottom: 8px; padding: 7px; font: inherit; border-radius: 8px; border: 1px solid var(--line); background: var(--surface); color: var(--fg); } }
/* the weekly calendar: paint when the panel may act by itself */
.cal { display: grid; grid-template-columns: 38px repeat(7, 1fr); gap: 1px; user-select: none; max-width: 560px; background: var(--line); border: 1px solid var(--line); border-radius: 8px; overflow: hidden; }
.cal { display: grid; grid-template-columns: 44px repeat(7, 1fr); gap: 1px; user-select: none; max-width: 560px; background: var(--line); border: 1px solid var(--line); border-radius: 8px; overflow: hidden; }
.cal .h { background: var(--surface); font-size: .72rem; color: var(--muted); text-align: center; padding: 3px 0; }
.cal .t { background: var(--surface); font-size: .66rem; color: var(--muted); text-align: right; padding-right: 4px; line-height: 9px; height: 9px; }
.cal .c { background: var(--surface); height: 9px; cursor: crosshair; } .cal .c.on { background: var(--accent); }
+10
View File
@@ -124,3 +124,13 @@ class TestQuotingAndGuards(unittest.TestCase):
if __name__ == "__main__":
unittest.main()
class TestOutlet(unittest.TestCase):
def test_outlet_cycle_renders_and_is_local(self):
f = b.build(CFG, {"template": "outlet_cycle", "params": {"outlet": "tascam", "address": "10.1.11.74", "wait_seconds": 8}})
self.assertEqual(f["run"], '/usr/bin/python3 "$HOME/Library/Application Support/status-panel/settings/outlet.py" cycle tascam --ping 10.1.11.74 --wait 8')
self.assertEqual(f["kind"], "local")
self.assertIn("tascam", b.suggest_label(CFG, {"template": "outlet_cycle", "params": {"outlet": "tascam"}}))
f = b.build(CFG, {"template": "outlet_cycle", "params": {"outlet": "x; rm", "address": "a b"}})
self.assertIn("cycle 'x; rm' --ping 'a b'", f["run"]) # quoted, never run as shell