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
+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); }