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