# -*- coding: utf-8 -*- r'''后台「更新」按钮接上:点一下让那台电脑自己静默升级。 == 链路 == 管理页点「更新」 -> POST /v1/admin/update_device {deviceId} -> 中转把 {"cmd":"update","action":"start"} 塞进该设备的 to_agent 队列 -> 被控端下一次长轮询拿到它 -> 走 server.py 的 _do_update:查最新版 -> 下载 -> 校验 -> 静默装 -> 重启 也就是说:**后台和手表用的是同一条命令**,只是入口不同。 这样不用给"后台触发"单独写一套逻辑(少一套就少一处不一致)。 == 为什么只对在线设备开放 == 被控端是靠**长轮询**取命令的。设备离线时命令只会挂在队列里, 等它什么时候上线才执行 —— 而那时用户可能早就忘了自己点过, 突然重启会莫名其妙。 所以离线设备直接拒绝,并在按钮上就置灰(admin.py 里已经这么做了)。 这样"点了没反应"变成"按钮本来就是灰的",用户知道为什么。 == 队列上限 == 中转的队列有字节上限(MAX_QUEUE_BYTES),超了会丢最旧的。 一条更新命令才几十字节,不会撑爆 —— 但**重复点会堆积**, 所以这里先查一下队列里是不是已经有更新命令了,有就不再塞。 ''' from __future__ import annotations import sys from pathlib import Path ROOT = Path(__file__).resolve().parent.parent RELAY = ROOT / "relay" / "relay.py" ADMIN = ROOT / "relay" / "admin.py" RELAY_ANCHOR = ''' self._json(404, {"ok": False, "error": "没有这个管理接口"}) return''' RELAY_ADD = ''' # ---- 让某台电脑自己静默升级 ---- # # 和手表那边用的是**同一条命令**(cmd=update, action=start), # 只是入口不同 —— 不用给"后台触发"单独写一套逻辑。 if path == "/v1/admin/update_device": body = self._json_body() did = str(body.get("deviceId") or "") device = RELAY.device_by_id(did) if device is None: self._json(404, {"ok": False, "error": "找不到这台设备"}) return if not device.online(): # 离线时命令只会挂在队列里,等它上线才执行 —— # 那时用户早忘了,突然重启很莫名其妙。所以直接拒。 self._json(400, {"ok": False, "error": "设备离线,连不上它"}) return # 别重复塞:队列里已经有更新命令就跳过 payload = b'{"cmd":"update","action":"start"}\\n' if payload in bytes(device.to_agent): self._json(200, {"ok": True, "message": "这台电脑的更新已经在排队了"}) return try: device.push_to_agent(payload) except Exception as exc: # noqa: BLE001 self._json(500, {"ok": False, "error": "下发失败:%s" % exc}) return self._json(200, { "ok": True, "message": "已让这台电脑开始更新,它会自己下载、" "静默安装并重启,设置和配对码不变", }) return ''' + RELAY_ANCHOR JS_ANCHOR = """document.querySelectorAll('button.danger[data-device]')""" JS_ADD = '''// ---- 「更新」按钮:让那台电脑自己静默升级 ---- // // 和手表那边走的是同一条命令,只是入口不同。 // 离线设备的按钮在 HTML 里就带 disabled 了,所以这里不用再判。 document.querySelectorAll('button[data-update]').forEach((btn) => { btn.onclick = async () => { const name = btn.dataset.name || '这台电脑'; if (!confirm('让「' + name + '」现在更新到最新版?\\n\\n' + '它会自己下载、静默安装并重启,全程不弹窗口,\\n' + '中转地址、配对码和各项设置都不变。')) { return; } btn.disabled = true; const old = btn.textContent; btn.textContent = '下发中…'; try { const r = await post('/v1/admin/update_device', { deviceId: btn.dataset.update }); toast((r && r.message) || '已下发更新命令', 'ok'); // 更新要跑一阵子,不立刻刷新列表(刷新了也看不出变化) } catch (e) { toast('下发失败:' + (e && e.message ? e.message : e), 'err'); } finally { btn.disabled = false; btn.textContent = old; } }; }); ''' + JS_ANCHOR def patch(path: Path, old: str, new: str, what: str) -> bool: raw = path.read_bytes() bom = raw.startswith(b"\xef\xbb\xbf") text = raw.decode("utf-8-sig") if new.split("\n")[0] in text or "update_device" in text: print(" %s 已经加过了" % what) return True if old not in text: print(" ✗ %s 找不到锚点" % what) return False text = text.replace(old, new, 1) path.write_bytes((b"\xef\xbb\xbf" if bom else b"") + text.encode("utf-8")) print(" ✓ %s" % what) return True def main() -> int: print("=" * 58) print(" 后台「更新」按钮接通") print("=" * 58) print() ok1 = patch(RELAY, RELAY_ANCHOR, RELAY_ADD, "relay.py 加了 /v1/admin/update_device") ok2 = patch(ADMIN, JS_ANCHOR, JS_ADD, "admin.py 加了 data-update 的 JS 处理") return 0 if (ok1 and ok2) else 1 if __name__ == "__main__": sys.exit(main())