添加红外扩展

This commit is contained in:
minghanchen
2026-08-28 17:27:43 +08:00
parent fd13d9f1ee
commit 6d75944c0f
11 changed files with 1602 additions and 75 deletions
+397 -9
View File
@@ -2,6 +2,7 @@
"use strict";
const STORAGE_KEY = "cognitive-agent.ir-toy-console.v5";
const SLOT_CATALOG_MIGRATION_KEY = "cognitive-agent.gscu-slot-catalog-migrated.v1";
const MODE_KEY = "cognitive-agent.extension-mode";
const CONVERSATION_KEY = "cognitive-agent.conversation-id";
const SLOT_MIN = 1;
@@ -451,6 +452,7 @@
history: [],
lastProtocol: null,
vehicleExecutions: {},
infraredCommands: [],
};
}
@@ -471,6 +473,18 @@
saved.vehicleExecutions = saved.vehicleExecutions && typeof saved.vehicleExecutions === "object"
? saved.vehicleExecutions
: {};
saved.infraredCommands = Array.isArray(saved.infraredCommands)
? saved.infraredCommands.filter((item) => (
validSlot(item?.slot)
&& typeof item?.name === "string"
&& item.name.trim()
)).map((item) => ({
slot: Number(item.slot),
name: item.name.trim().slice(0, 48),
source: String(item.source || "tcp"),
learnedAt: item.learnedAt || null,
}))
: [];
saved.selectedDeviceByType = saved.selectedDeviceByType || {};
saved.settings = { actionDelayMs: Number(saved.settings?.actionDelayMs) || 520 };
// Learned capabilities are durable configuration; execution lamps, history,
@@ -701,6 +715,39 @@
<label><span>输入“清空”确认操作</span><input id="extensionResetConfirm" autocomplete="off" /></label>
<button class="v2-extension-danger" type="button" id="extensionConfirmReset" disabled>擦除并重新开始</button>
</dialog>
<dialog class="v2-extension-dialog v2-gscu-tcp-dialog" id="extensionGscuTcpDialog">
<header><div><span class="section-kicker">GSCU1B · TCP SERVER</span><h2>红外学习器 TCP 控制</h2><p>Linux 监听连接;GSCU1B 作为 TCP Client 接入。</p></div><button type="button" data-close-extension-dialog="extensionGscuTcpDialog" aria-label="关闭"><i data-lucide="x"></i></button></header>
<section class="v2-gscu-status" id="extensionGscuTcpStatus" data-state="checking"><i data-lucide="radio-tower"></i><div><strong>正在读取连接状态</strong><small>--</small></div><b>CHECK</b></section>
<form id="extensionGscuTcpForm" class="v2-gscu-config-form">
<label><span>本机监听 IP</span><input id="extensionGscuTcpHost" inputmode="decimal" maxlength="45" placeholder="0.0.0.0" required /><small>填写 0.0.0.0 可监听所有网卡;模块端应填写这台 Linux 的实际局域网 IP。</small></label>
<label><span>监听端口</span><input id="extensionGscuTcpPort" type="number" min="1" max="65535" value="4444" required /><small>必须与 GSCU1B 自定义 TCP 服务器端口一致。</small></label>
<button class="v2-extension-primary" type="submit" id="extensionGscuTcpApply">应用并开始监听</button>
</form>
<section class="v2-gscu-actions">
<header><div><strong>TCP 动作调试</strong><small>与 gscu1b_control.py tcp 使用相同 JSON 报文</small></div><label><span>红外槽位</span><input id="extensionGscuSlot" type="number" min="1" max="248" value="1" /></label><label><span>指令名称</span><input id="extensionGscuCommandName" maxlength="48" placeholder="例如:客厅空调开启" /></label></header>
<div class="v2-gscu-action-grid">
<button type="button" data-gscu-action="info">查询设备</button>
<button type="button" data-gscu-action="learn">开始学习</button>
<button type="button" data-gscu-action="cancel">取消学习</button>
<button type="button" data-gscu-action="emit">发射槽位</button>
<button type="button" data-gscu-action="read">读取槽位</button>
<button type="button" data-gscu-action="restart">软重启</button>
</div>
<details class="v2-gscu-advanced">
<summary>高级动作</summary>
<label><span>原始红外码</span><textarea id="extensionGscuIrData" rows="3" placeholder="粘贴 read 返回的 ir_data"></textarea></label>
<button type="button" data-gscu-action="write">写入当前槽位</button>
<label><span>WiFi 配网锁</span><select id="extensionGscuWifiLock"><option value="1">开启(1)</option><option value="0">关闭(0)</option></select></label>
<button type="button" data-gscu-action="wifi-lock">设置配网锁</button>
<label><span>原始 JSON</span><textarea id="extensionGscuRawCommand" rows="4" placeholder='例如 {"type":"info"}'></textarea></label>
<button type="button" data-gscu-action="raw">发送原始 JSON</button>
<label><span>恢复出厂确认</span><input id="extensionGscuDangerConfirm" autocomplete="off" placeholder="恢复出厂请输入 RESET_DEVICE" /></label>
<div><button class="danger" type="button" data-gscu-action="erase">擦除全部红外码</button><button class="danger" type="button" data-gscu-action="reset">恢复出厂设置</button></div>
</details>
<section class="v2-gscu-command-catalog"><header><strong>已学习红外信号</strong><small>槽位与名称保存在本机数据库,并按槽位号排列</small></header><div id="extensionGscuNamedCommands"></div></section>
<pre id="extensionGscuTcpResult">等待操作</pre>
</section>
</dialog>
<div class="v2-extension-toast-stack" id="extensionToastStack" aria-live="polite"></div>`;
document.body.append(layer);
}
@@ -747,6 +794,319 @@
return payload;
}
function renderGscuTcpDialog(health = healthState || {}, refreshControls = true) {
const status = $("#extensionGscuTcpStatus");
if (!status) return;
const connected = Boolean(health.connected || health.peer_connected);
const listening = Boolean(health.listening);
const listenHost = String(health.listen_host || "0.0.0.0");
const listenPort = Number(health.port || 4444);
if (refreshControls) {
$("#extensionGscuTcpHost").value = listenHost;
$("#extensionGscuTcpPort").value = Number.isInteger(listenPort) ? String(listenPort) : "4444";
}
status.dataset.state = connected ? "online" : listening ? "listening" : "offline";
$("strong", status).textContent = connected
? "GSCU1B TCP 已连接"
: listening
? "TCP 监听已就绪"
: "TCP 监听尚未就绪";
$("small", status).textContent = connected
? `${health.peer || "设备会话"} → ${health.listen || `${listenHost}:${listenPort}`}`
: health.message || `等待 GSCU1B 接入 ${listenHost}:${listenPort}`;
$("b", status).textContent = connected ? "READY" : listening ? "WAIT" : "OFFLINE";
if (refreshControls) {
renderGscuNamedCommands();
syncGscuCommandNameFromSlot();
}
}
function namedInfraredCommands() {
state.infraredCommands ||= [];
return [...state.infraredCommands].sort((left, right) => Number(left.slot) - Number(right.slot));
}
function normalizedInfraredSlotRecord(item) {
return {
slot: Number(item.slot),
name: String(item.name || "").trim().slice(0, 48),
source: String(item.source || "tcp"),
learnedAt: item.learned_at || item.learnedAt || null,
updatedAt: item.updated_at || item.updatedAt || null,
};
}
function replaceInfraredSlotCatalog(items) {
state.infraredCommands = (Array.isArray(items) ? items : [])
.filter((item) => validSlot(item?.slot) && String(item?.name || "").trim())
.map(normalizedInfraredSlotRecord)
.sort((left, right) => left.slot - right.slot);
saveState();
renderGscuNamedCommands();
syncGscuCommandNameFromSlot();
}
async function refreshInfraredSlotCatalog({ migrateLegacy = true } = {}) {
const shouldMigrate = migrateLegacy
&& localStorage.getItem(SLOT_CATALOG_MIGRATION_KEY) !== "done";
const legacy = shouldMigrate ? namedInfraredCommands() : [];
let result = await api("gscu/slots");
const serverSlots = Array.isArray(result.slots) ? result.slots : [];
const existingSlots = new Set(serverSlots.map((item) => Number(item.slot)));
const missing = legacy.filter((item) => !existingSlots.has(Number(item.slot)));
if (missing.length) {
await Promise.all(missing.map((item) => api(`gscu/slots/${item.slot}`, {
method: "PUT",
body: JSON.stringify({ name: item.name, source: item.source || "browser-migration" }),
})));
result = await api("gscu/slots");
}
if (shouldMigrate) localStorage.setItem(SLOT_CATALOG_MIGRATION_KEY, "done");
replaceInfraredSlotCatalog(result.slots);
return namedInfraredCommands();
}
async function upsertNamedInfraredCommand(slot, name, source = "tcp") {
const normalizedSlot = Number(slot);
const normalizedName = String(name || "").trim().slice(0, 48);
if (!validSlot(normalizedSlot)) throw new Error("红外槽位范围是 1–248。");
if (!normalizedName) throw new Error("学习红外指令前需要填写指令名称。");
const result = await api(`gscu/slots/${normalizedSlot}`, {
method: "PUT",
body: JSON.stringify({ name: normalizedName, source }),
});
const value = normalizedInfraredSlotRecord(result.slot);
const existing = state.infraredCommands.find((item) => item.slot === normalizedSlot);
if (existing) Object.assign(existing, value);
else state.infraredCommands.push(value);
saveState();
renderGscuNamedCommands();
return value;
}
function renderGscuNamedCommands() {
const container = $("#extensionGscuNamedCommands");
if (!container) return;
const commands = namedInfraredCommands();
container.innerHTML = commands.length
? commands.map((command) => `<article data-gscu-command-slot="${command.slot}"><header><span>红外槽位</span><b>IR ${String(command.slot).padStart(3, "0")}</b></header><label><span>信号名称</span><input maxlength="48" value="${escapeHtml(command.name)}" aria-label="IR ${command.slot} 信号名称" /></label><footer><button type="button" data-gscu-named-emit="${command.slot}">发射</button><button type="button" data-gscu-named-save="${command.slot}">保存名称</button><button class="danger" type="button" data-gscu-named-remove="${command.slot}">删除本地记录</button></footer></article>`).join("")
: "<p>学习成功后将在这里显示编号与指令名称。</p>";
}
function syncGscuCommandNameFromSlot() {
const slot = Number($("#extensionGscuSlot")?.value);
const command = namedInfraredCommands().find((item) => item.slot === slot);
if ($("#extensionGscuCommandName")) {
$("#extensionGscuCommandName").value = command?.name || "";
}
}
function openGscuTcpDialog(health = healthState || {}) {
renderGscuTcpDialog(health);
openDialog("extensionGscuTcpDialog");
void refreshInfraredSlotCatalog().catch((error) => {
showToast(`读取本地红外槽位数据库失败:${error.message}`, "error");
});
window.lucide?.createIcons({ attrs: { "aria-hidden": "true" } });
}
function showGscuResult(value) {
const output = $("#extensionGscuTcpResult");
if (!output) return;
output.textContent = typeof value === "string" ? value : JSON.stringify(value, null, 2);
}
async function configureGscuTcp(event) {
event.preventDefault();
const button = $("#extensionGscuTcpApply");
button.disabled = true;
button.textContent = "正在重启监听…";
try {
const result = await api("gscu/config", {
method: "POST",
body: JSON.stringify({
listen_host: $("#extensionGscuTcpHost").value.trim(),
port: Number($("#extensionGscuTcpPort").value),
}),
});
healthState = result;
renderGscuTcpDialog(result, false);
showGscuResult(result);
showToast(`TCP 已切换到 ${result.listen || `${result.listen_host}:${result.port}`}`, "success");
} catch (error) {
showGscuResult(error.payload || error.message);
showToast(error.message, "error");
} finally {
button.disabled = false;
button.textContent = "应用并开始监听";
}
}
async function runGscuTcpAction(button) {
const action = button.dataset.gscuAction;
const slot = Number($("#extensionGscuSlot").value);
const commandName = $("#extensionGscuCommandName").value.trim();
const payload = {};
if (["learn", "emit", "read", "write"].includes(action)) payload.slot = slot;
if (action === "write") payload.ir_data = $("#extensionGscuIrData").value.trim();
if (action === "wifi-lock") payload.state = Number($("#extensionGscuWifiLock").value);
if (action === "raw") {
try {
payload.command = JSON.parse($("#extensionGscuRawCommand").value);
} catch (_error) {
showToast("原始指令不是有效 JSON。", "error");
return;
}
}
const confirmation = $("#extensionGscuDangerConfirm").value.trim();
if (action === "erase") {
const count = namedInfraredCommands().length;
const confirmed = window.confirm(
`确认擦除 GSCU1B 中的全部红外码吗?本机数据库中的 ${count} 条槽位记录也会被永久清空,操作无法恢复。`,
);
if (!confirmed) return;
payload.confirm = "ERASE_ALL";
}
if (action === "reset") payload.confirm = confirmation;
if (action === "learn" && !commandName) {
showToast("请输入该红外编号对应的指令名称。", "error");
$("#extensionGscuCommandName").focus();
return;
}
button.disabled = true;
showGscuResult(`正在执行 ${action}…`);
try {
const endpoint = action === "erase" ? "gscu/erase-all" : `gscu/${action}`;
let result = await api(endpoint, {
method: "POST",
body: JSON.stringify(payload),
});
if (action === "learn") {
showToast("学习指令已下发,请立即短按遥控器目标按键。", "success");
let readResult = null;
let learnedSignal = null;
let readError = null;
await delay(1200);
for (let attempt = 0; attempt < 6 && !learnedSignal; attempt += 1) {
try {
readResult = await api("gscu/read", {
method: "POST",
body: JSON.stringify({ slot }),
});
learnedSignal = infraredSignalFromResponse(readResult);
} catch (error) {
readError = error;
}
if (!learnedSignal && attempt < 5) await delay(1400);
}
if (!learnedSignal) {
throw new Error(
readError?.message
? `学习指令已下发,但槽位回读失败:${readError.message}`
: "学习指令已下发,但槽位回读为空;请重新学习并短按遥控器按键。",
);
}
await upsertNamedInfraredCommand(slot, commandName, "tcp");
result = {
...result,
learning_capture_confirmed: true,
verification: "slot_readback",
slot_readback: readResult,
};
}
if (action === "erase") {
replaceInfraredSlotCatalog([]);
state.capabilities.forEach((capability) => {
if (!validSlot(capability.slot)) return;
Object.assign(capability, {
slot: null,
status: "pending",
verified: false,
enabled: false,
fingerprint: null,
signalBytes: null,
learnedAt: null,
relearnedAt: null,
});
});
saveState();
renderAll();
void syncPeripheralCatalog();
}
showGscuResult(result);
showToast(
action === "learn" ? `IR ${slot} 学习完成,槽位回读已确认。` : `${action} 指令处理完成。`,
"success",
);
} catch (error) {
showGscuResult(error.payload || error.message);
showToast(error.message, "error");
} finally {
button.disabled = false;
}
}
function bindGscuTcpDialogEvents() {
const dialog = $("#extensionGscuTcpDialog");
if (!dialog || dialog.dataset.bound === "true") return;
dialog.dataset.bound = "true";
$("#extensionGscuTcpForm").addEventListener("submit", configureGscuTcp);
$("#extensionGscuSlot").addEventListener("change", syncGscuCommandNameFromSlot);
dialog.addEventListener("click", (event) => {
if (event.target.closest('[data-close-extension-dialog="extensionGscuTcpDialog"]')) {
closeDialog("extensionGscuTcpDialog");
return;
}
const action = event.target.closest("[data-gscu-action]");
if (action) {
void runGscuTcpAction(action);
return;
}
const emit = event.target.closest("[data-gscu-named-emit]");
if (emit) {
$("#extensionGscuSlot").value = emit.dataset.gscuNamedEmit;
const command = namedInfraredCommands().find(
(item) => item.slot === Number(emit.dataset.gscuNamedEmit),
);
$("#extensionGscuCommandName").value = command?.name || "";
const emitButton = $("[data-gscu-action=\"emit\"]", dialog);
if (emitButton) void runGscuTcpAction(emitButton);
return;
}
const save = event.target.closest("[data-gscu-named-save]");
if (save) {
const card = save.closest("[data-gscu-command-slot]");
save.disabled = true;
void upsertNamedInfraredCommand(
Number(save.dataset.gscuNamedSave),
$("input", card).value,
"tcp",
).then((value) => {
showToast(`IR ${value.slot} 已保存为“${value.name}”。`, "success");
}).catch((error) => {
showToast(error.message, "error");
}).finally(() => { save.disabled = false; });
return;
}
const remove = event.target.closest("[data-gscu-named-remove]");
if (remove) {
const slot = Number(remove.dataset.gscuNamedRemove);
const command = namedInfraredCommands().find((item) => item.slot === slot);
if (!window.confirm(`确认删除“${command?.name || `IR ${slot}`}”的本地数据库记录吗?GSCU1B 设备中的该红外码不会被擦除。`)) return;
remove.disabled = true;
void api(`gscu/slots/${slot}`, { method: "DELETE" }).then((result) => {
state.infraredCommands = namedInfraredCommands().filter((item) => item.slot !== slot);
saveState();
renderGscuNamedCommands();
showToast(result.message || `IR ${slot} 的本地记录已删除。`, "success");
}).catch((error) => {
remove.disabled = false;
showToast(error.message, "error");
});
}
});
}
function sharedConversationId() {
const fromConsole = window.CognitiveAgentConsole?.getConversationId?.();
if (typeof fromConsole === "string" && fromConsole.startsWith("conv-")) {
@@ -1224,22 +1584,22 @@
);
let learnedSignal = null;
let readError = null;
await delay(700);
for (let attempt = 0; attempt < 3 && !learnedSignal; attempt += 1) {
await delay(1200);
for (let attempt = 0; attempt < 6 && !learnedSignal; attempt += 1) {
try {
const readResult = await sendGatewayCommand("read", { slot: learning.draft.slot });
learnedSignal = infraredSignalFromResponse(readResult);
} catch (error) {
readError = error;
}
if (!learnedSignal && attempt < 2) await delay(900);
if (!learnedSignal && attempt < 5) await delay(1400);
}
learnedSignal ||= infraredSignalFromResponse(learnResult);
if (!learnedSignal) {
throw new Error(
readError?.message
? `学习后无法回读槽位:${readError.message}`
: "学习回执成功,但物理槽位回读为空;请对准学习器并短按遥控器按键。",
: "学习指令已下发,但物理槽位回读为空;请对准学习器并短按遥控器按键。",
);
}
learning.fingerprint = await infraredFingerprint(learnedSignal);
@@ -1295,14 +1655,27 @@
}
}
function confirmLearning() {
async function confirmLearning() {
if (!learning.tested || !learning.draft) return;
const capability = state.capabilities.find(
(item) => item.id === learning.draft.capabilityId,
);
if (!capability) return;
const button = $("#extensionConfirmLearn");
button.disabled = true;
const relearning = Boolean(learning.draft.relearning);
const learnedAt = new Date().toISOString();
try {
await upsertNamedInfraredCommand(
Number(learning.draft.slot),
capability.name,
"capability",
);
} catch (error) {
button.disabled = false;
showToast(`保存红外槽位数据库失败:${error.message}`, "error");
return false;
}
Object.assign(capability, {
aliases: [...new Set([capability.name, ...learning.draft.aliases])],
risk: learning.draft.risk,
@@ -1327,6 +1700,7 @@
"success",
);
learning = freshLearningSession();
return true;
}
function forgetCapability(capabilityId) {
@@ -1796,6 +2170,7 @@
try {
await sendGatewayCommand("erase", { confirm: "ERASE_ALL" });
state.capabilities = state.devices.flatMap((device) => seedPresets(device));
state.infraredCommands = [];
state.history = [];
state.sessionStartedAt = new Date().toISOString();
saveState();
@@ -2276,6 +2651,7 @@
}
function bindEvents() {
bindGscuTcpDialogEvents();
$$('[data-extension-mode]').forEach((button) => {
button.addEventListener("click", () => setMode(button.dataset.extensionMode));
});
@@ -2330,7 +2706,7 @@
$("#extensionStartLearn").addEventListener("click", startLearning);
$("#extensionCancelLearn").addEventListener("click", cancelLearning);
$("#extensionTestEmit").addEventListener("click", testLearning);
$("#extensionConfirmLearn").addEventListener("click", confirmLearning);
$("#extensionConfirmLearn").addEventListener("click", () => { void confirmLearning(); });
$("#extensionLearnDialog").addEventListener("cancel", (event) => {
if (learning.phase === "listening") {
event.preventDefault();
@@ -2900,7 +3276,9 @@
function bindUnifiedNfcEvents(mount) {
mount.addEventListener("click", (event) => {
if (event.target.closest("[data-open-nfc-learning]")) openNfcDialog();
if (event.target.closest("[data-open-gscu-tcp]")) openGscuTcpDialog();
});
bindGscuTcpDialogEvents();
const dialog = $("#extensionNfcDialog");
dialog.addEventListener("click", (event) => {
if (event.target.closest("[data-close-nfc-dialog]")) closeDialog("extensionNfcDialog");
@@ -2926,9 +3304,10 @@
$("#extensionStartLearn").addEventListener("click", startLearning);
$("#extensionCancelLearn").addEventListener("click", cancelLearning);
$("#extensionTestEmit").addEventListener("click", testLearning);
$("#extensionConfirmLearn").addEventListener("click", () => {
confirmLearning();
void syncPeripheralCatalog(linkedDeviceForCard(nfcSnapshot.card?.uid));
$("#extensionConfirmLearn").addEventListener("click", async () => {
if (await confirmLearning()) {
void syncPeripheralCatalog(linkedDeviceForCard(nfcSnapshot.card?.uid));
}
});
$("#extensionLearnDialog").addEventListener("cancel", (event) => {
if (learning.phase === "listening") {
@@ -2953,6 +3332,8 @@
const render = () => {
const health = snapshot?.extension_health || {};
healthState = health;
if ($("#extensionGscuTcpDialog")?.open) renderGscuTcpDialog(health, false);
const embeddedNfc = health.nfc;
if (nfcSnapshot.revision < 0 && embeddedNfc) {
nfcSnapshot = {
@@ -3061,6 +3442,7 @@
<span><i data-lucide="usb"></i></span>
<div><strong>${gatewayLabel}</strong><small>${escapeHtml(gatewayNote)}</small></div>
<b>${String(health.transport || "GSCU").toUpperCase()}</b>
<button type="button" data-open-gscu-tcp><i data-lucide="settings-2"></i><span>TCP 设置</span></button>
</div>
<div class="v2-extension-device-list">
${devices.length ? devices.map((device) => `
@@ -3090,6 +3472,9 @@
buildDialogs();
buildUnifiedNfcDialog();
bindUnifiedNfcEvents(mount);
void refreshInfraredSlotCatalog().catch((error) => {
console.warn("读取本地红外槽位数据库失败。", error);
});
render();
renderNfcDialog();
startNfcPolling();
@@ -3109,6 +3494,9 @@
setMode(activeMode);
if (document.body.dataset.module === "self-extension") refreshHealth();
startHealthPolling();
void refreshInfraredSlotCatalog().catch((error) => {
console.warn("读取本地红外槽位数据库失败。", error);
});
void migrateLegacyProfiles();
window.lucide?.createIcons({ attrs: { "aria-hidden": "true" } });
}