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:
@@ -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"},
|
||||
|
||||
@@ -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)
|
||||
@@ -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
@@ -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]); };
|
||||
|
||||
@@ -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); }
|
||||
|
||||
Reference in New Issue
Block a user