Files
pigway-console-adapter-rasp…/plugin/ui/hardware.js
T

300 lines
14 KiB
JavaScript
Raw Normal View History

import { state as appState } from "/assets/js/state.js";
import { tr } from "/assets/js/i18n.js";
import { $, esc } from "/assets/js/shared.js";
import { ownerName, rgbName } from "./schema.js";
import { get } from "/assets/js/navigation.js";
import { tokenHeaders } from "./services.js";
import { ensureHosts, loadConfig } from "/assets/js/settings.js";
export function pluginText(zh, en) {
return appState.LANG === "zh" ? zh : en;
}
export function renderPlugins() {
return `<div class="group"><p>${pluginText("仅发现插件不会接管插件的设备能力。接入后发送监控状态;暂停或断开联动不会停止插件的本地功能。", "Discovery does not take control of plugin device capabilities. Connecting sends monitoring state; pausing or disconnecting the link does not stop the plugin’s local functions.")}</p><div class="action-row"><button type="button" id="refreshPlugins" onclick="loadPlugins()">${tr("刷新列表")}</button> <span id="pluginRefreshState" class="hint" role="status" aria-live="polite"></span></div><div id="pluginList"></div></div><div id="pluginEditor"></div>`;
}
export function fanMetric(plugins) {
const fans = Object.entries(plugins)
.filter(
([, p]) =>
p.online && p.capabilities?.fan && p.status?.available !== false,
)
.map(([id, p]) => {
const st = p.status;
let value = "";
if (
p.capabilities.fan.rpm &&
typeof st.fan_rpm === "number" &&
Number.isFinite(st.fan_rpm) &&
st.fan_rpm >= 0
)
value = st.fan_rpm + " RPM";
else if (st.fan_name && st.fan_name !== "UNAVAILABLE")
value =
{
OFF: pluginText("关闭", "Off"),
L1: pluginText("低速", "Low"),
L2: pluginText("中低速", "Medium-low"),
L3: pluginText("中速", "Medium"),
L4: pluginText("高速", "High"),
MAX: pluginText("全速", "Full speed"),
}[st.fan_name] || st.fan_name;
return value ? { name: p.name || id, value } : null;
})
.filter(Boolean);
return fans.length
? `<span> · ${fans.map((f) => esc(pluginText("风扇", "Fan") + (fans.length > 1 ? " " + f.name : "") + " " + f.value)).join(" · ")}</span>`
: "";
}
export function pluginSummary(plugins, host) {
if (!Object.keys(plugins).length)
return `<div class="group"><strong>${pluginText("风扇与硬件插件", "Fan and hardware plugins")}</strong><p class="muted">${pluginText("尚未接入硬件插件,风扇状态不可用。独立安装并启动插件后,可在插件管理中接入。", "No hardware plugin connected; fan status is unavailable. Install and start a plugin independently, then connect it in plugin management.")}</p><button type="button" data-manage-plugin-host="${esc(host)}">${pluginText("管理插件", "Manage plugin")}</button></div>`;
return Object.entries(plugins)
.map(([id, p]) => {
const st = p.status || {},
cap = p.capabilities || {},
details = [];
if (p.online) {
if (cap.display)
details.push("OLED " + esc(ownerName(st.oled_page || "")));
if (cap.rgb) details.push("RGB " + esc(rgbName(st.rgb_mode || "")));
}
return `<div class="group action-row"><span><strong>${esc(p.name || id)}</strong> · ${p.online ? tr("在线") : tr("离线")}${details.length ? " · " + details.join(" · ") : ""}</span><button type="button" data-manage-plugin-host="${esc(host)}">${pluginText("管理插件", "Manage plugin")}</button></div>`;
})
.join("");
}
export async function loadPlugins() {
const list = $("pluginList");
if (!list) return;
const button = $("refreshPlugins"),
feedback = $("pluginRefreshState");
if (button?.disabled) return;
if (button) button.disabled = true;
if (feedback) feedback.textContent = pluginText("正在刷新…", "Refreshing…");
list.textContent = pluginText(
"正在发现本机插件…",
"Discovering local plugins…",
);
try {
const data = await get(
"/api/v1/plugins?host=" + encodeURIComponent(appState.currentConfigHost),
);
appState.pluginRows = data.plugins;
if (feedback)
feedback.textContent =
pluginText(
"刷新完成,发现 " + appState.pluginRows.length + " 个插件 · ",
"Refreshed, " + appState.pluginRows.length + " plugins found · ",
) + new Date().toLocaleTimeString();
list.innerHTML =
appState.pluginRows
.map(
(p) =>
`<div class="group"><h3>${esc(p.descriptor?.name || p.id)}</h3><div class="hint">${esc(p.socket)} · ${p.online ? tr("在线") : tr("离线")} · ${p.linked ? (p.paused ? pluginText("联动已暂停", "Link paused") : pluginText("已接入", "Connected")) : pluginText("尚未接入", "Not connected")}</div>${p.error ? `<p class="error">${esc(p.error)}</p>` : ""}${p.status?.errors && Object.keys(p.status.errors).length ? `<p class="error">${esc(JSON.stringify(p.status.errors))}</p>` : ""}<p>${Object.keys(
p.descriptor?.capabilities || {},
)
.map((k) =>
esc(
{
fan: pluginText("风扇", "Fan"),
display: pluginText("屏幕", "Display"),
rgb: pluginText("灯光", "Lighting"),
}[k] || k,
),
)
.join(
" · ",
)}</p><div class="toolbar">${p.linked ? `<button data-plugin-op="${p.paused ? "resume" : "pause"}" data-plugin-id="${esc(p.id)}">${p.paused ? pluginText("恢复告警联动", "Resume alert link") : pluginText("暂停告警联动", "Pause alert link")}</button><button data-plugin-op="unlink" data-plugin-id="${esc(p.id)}">${pluginText("断开接入", "Disconnect")}</button>` : `<button data-plugin-op="link" data-plugin-id="${esc(p.id)}" ${p.online ? "" : "disabled"}>${pluginText("接入插件", "Connect plugin")}</button>`}<button data-plugin-edit="${esc(p.id)}" ${p.online ? "" : "disabled"}>${pluginText("插件配置", "Plugin settings")}</button></div></div>`,
)
.join("") ||
`<p class="muted">${pluginText("未发现正在运行的硬件插件。刷新只查询插件,不会安装或启动服务。若已安装,请先在目标机器启动插件,再刷新并接入;未接入不影响监控。", "No running hardware plugins found. Refresh only queries plugins; it does not install or start services. Start the installed plugin on the target host, then refresh and connect. Monitoring works independently.")}</p>`;
list
.querySelectorAll("[data-plugin-op]")
.forEach(
(b) =>
(b.onclick = () =>
pluginAction(b.dataset.pluginId, b.dataset.pluginOp)),
);
list
.querySelectorAll("[data-plugin-edit]")
.forEach((b) => (b.onclick = () => editPlugin(b.dataset.pluginEdit)));
} catch (e) {
list.textContent = e.message;
if (feedback)
feedback.textContent = pluginText("刷新失败", "Refresh failed");
} finally {
if (button) button.disabled = false;
}
}
export async function pluginPut(path, payload) {
return get(path, {
method: "PUT",
headers: tokenHeaders(),
body: JSON.stringify({ ...payload, host: appState.currentConfigHost }),
});
}
export async function pluginAction(id, operation) {
try {
await pluginPut("/api/v1/plugins", { id, operation });
await loadPlugins();
} catch (e) {
alert(e.message);
}
}
export function capabilityField(section, key, value, spec) {
const unit =
appState.LANG === "zh"
? { s: "秒", ms: "毫秒" }[spec.unit] || spec.unit
: spec.unit,
label = spec.label?.[appState.LANG] || spec.label?.en || key,
id = `${section}.${key}`,
vis = spec.visible
? ` data-cap-visible="${esc(JSON.stringify(spec.visible))}"`
: "";
return `<label class="field"${vis}>${esc(label)}${spec.type === "enum" ? `<select data-cap-section="${esc(section)}" data-cap-key="${esc(key)}">${spec.choices.map((x) => `<option value="${esc(x)}" ${String(value) === String(x) ? "selected" : ""}>${esc(pluginChoice(x))}</option>`).join("")}</select>` : `<div class="input-unit"><input type="number" step="${section === "rgb" ? 1 : "any"}" min="${esc(spec.min)}" max="${esc(spec.max)}" data-cap-section="${esc(section)}" data-cap-key="${esc(key)}" value="${esc(value)}"><span class="unit">${esc(unit)}</span></div><span class="hint">${pluginText("范围", "Range")} ${spec.min}–${spec.max} ${esc(unit)}</span>`}</label>`;
}
export function pluginChoice(x) {
const zh = {
off: "全灭",
solid: "自定义常亮",
flash: "自定义闪烁(实验性)",
breathe: "内置呼吸",
flow: "流水灯",
marquee: "跑马灯",
rainbow: "彩虹灯",
colorful: "炫彩灯",
red: "红色",
green: "绿色",
blue: "蓝色",
yellow: "黄色",
purple: "紫色",
cyan: "青色",
white: "白色",
};
return appState.LANG === "zh" ? zh[x] || x : x;
}
export function capabilityFields(section, fields, values) {
const seen = new Set();
return Object.entries(fields)
.map(([key, spec]) => {
if (!spec.color_group)
return capabilityField(section, key, values[key], spec);
if (seen.has(spec.color_group)) return "";
seen.add(spec.color_group);
const members = Object.entries(fields).filter(
([, v]) => v.color_group === spec.color_group,
),
hex =
"#" +
["r", "g", "b"]
.map((c) =>
Number(values[members.find(([, v]) => v.channel === c)[0]])
.toString(16)
.padStart(2, "0"),
)
.join("");
return `<div class="group span2" data-cap-visible="${esc(JSON.stringify(spec.visible || {}))}"><div style="display:grid;grid-template-columns:52px repeat(3,minmax(0,1fr));gap:10px;align-items:start"><label style="text-align:center">${pluginText("颜色", "Color")}<input type="color" data-cap-color="${esc(spec.color_group)}" value="${hex}" aria-label="${pluginText("选择颜色", "Choose color")}" style="width:38px;height:34px;padding:0;margin-top:5px"></label>${members.map(([k, v]) => capabilityField(section, k, values[k], { ...v, visible: null })).join("")}</div></div>`;
})
.join("");
}
export async function editPlugin(id) {
try {
appState.pluginSelected = id;
appState.pluginDraft = await get(
"/api/v1/plugin/config?host=" +
encodeURIComponent(appState.currentConfigHost) +
"&id=" +
encodeURIComponent(id),
);
const data = appState.pluginDraft,
editor = $("pluginEditor");
editor.innerHTML = `<div class="group"><h3>${esc(id)}</h3><p class="hint">${pluginText("配置由插件保存并立即应用,不重启监控。", "Settings are saved and applied by the plugin without restarting monitoring.")}</p>${(data.notes || []).map((n) => `<p class="hint">${esc(n[appState.LANG] || n.en || "")}</p>`).join("")}${Object.entries(
data.schema,
)
.map(
([section, fields]) =>
`<div class="group"><h3>${{ fan: pluginText("风扇", "Fan"), oled: pluginText("屏幕", "Display"), rgb: pluginText("灯光", "Lighting") }[section] || esc(section)}</h3><div class="fields">${capabilityFields(section, fields, data.config[section])}</div></div>`,
)
.join(
"",
)}<div class="plugin-actions"><button id="savePlugin">${pluginText("保存插件配置", "Save plugin settings")}</button> <span id="pluginSaveState"></span></div></div>`;
editor.oninput = (e) => {
if (e.target.dataset.capColor) {
const group = e.target.dataset.capColor,
hex = e.target.value;
["r", "g", "b"].forEach((c, i) => {
editor.querySelector(`[data-cap-key="${group}_${c}"]`).value =
parseInt(hex.slice(1 + 2 * i, 3 + 2 * i), 16);
});
}
for (const picker of editor.querySelectorAll("[data-cap-color]")) {
picker.value =
"#" +
["r", "g", "b"]
.map((c) =>
Math.max(
0,
Math.min(
255,
Number(
editor.querySelector(
`[data-cap-key="${picker.dataset.capColor}_${c}"]`,
).value,
),
),
)
.toString(16)
.padStart(2, "0"),
)
.join("");
}
updatePluginVisibility();
};
updatePluginVisibility();
$("savePlugin").onclick = savePluginConfig;
} catch (e) {
$("pluginEditor").textContent = e.message;
}
}
export function updatePluginVisibility() {
document.querySelectorAll("[data-cap-visible]").forEach((el) => {
const rules = JSON.parse(el.dataset.capVisible);
el.classList.toggle(
"hidden",
!Object.entries(rules).every(([key, values]) =>
values.includes(
document.querySelector(`[data-cap-key="${key}"]`)?.value,
),
),
);
});
}
export async function savePluginConfig() {
const updates = {};
for (const el of document.querySelectorAll("[data-cap-key]")) {
if (!el.reportValidity()) return;
const s = el.dataset.capSection,
k = el.dataset.capKey;
(updates[s] ??= {})[k] = el.value;
}
try {
await pluginPut("/api/v1/plugin/config", {
id: appState.pluginSelected,
updates,
});
$("pluginSaveState").textContent = pluginText("保存成功", "Saved");
} catch (e) {
$("pluginSaveState").textContent = e.message;
}
}
document.addEventListener("click", async (e) => {
const b = e.target.closest("[data-manage-plugin-host]");
if (!b) return;
appState.currentConfigHost = b.dataset.managePluginHost;
await ensureHosts();
$("configHost").value = appState.currentConfigHost;
document.querySelector('[data-tab="config"]').click();
await loadConfig();
document.querySelector('[data-config-section="plugins"]')?.click();
});