feat(n7web): add pet NFC workflow and glass UI

This commit is contained in:
Miayui
2026-09-14 15:16:02 +08:00
parent 812e67422d
commit aa6f59d3a6
20 changed files with 2724 additions and 102 deletions
+30
View File
@@ -1,5 +1,35 @@
# Web Console
## 宠物卡片与 NFC 上报
主页“宠物卡片”区域支持创建多张宠物卡片。`pet_name` 与 `action` 可在卡片中编辑,
`nfc_id` 为只读字段,只能在“NFC 设备授权”弹窗中使用读卡器当前识别到的实体 NFC
完成绑定,不能由浏览器或 API 请求直接填写。同一个 NFC UID 最多绑定一张宠物卡片;重新
绑定时会自动解除旧卡片上的相同 UID。新卡片必须先完成编辑并点击保存,之后才能从
“NFC 设备绑定”或“关联卡片”入口继续绑定;右侧卡片区域不再直接触发 NFC 绑定。
卡片由后端持久化到 `artifacts/server/pet_cards.sqlite3`,网页关闭或服务重启不会丢失。
同源接口如下:
- `GET /api/extension/pets`:读取卡片及发布器状态。
- `POST /api/extension/pets`:创建卡片,只接受 `pet_name`、`action`。
- `PUT /api/extension/pets/{pet_id}`:修改宠物名和动作。
- `DELETE /api/extension/pets/{pet_id}`:删除卡片。
- `POST /api/extension/pets/{pet_id}/bind-current-nfc`:绑定读卡器中当前在场的 NFC。
- `POST /api/extension/pets/{pet_id}/unbind`:解除绑定。
服务端首次检测到已绑定 NFC 进入读卡区时,后台线程复用当前车辆 MQTT Broker 连接,
在 1 秒内向固定话题 `n7/pet` 发布 3 帧非保留消息,随后保持静默。只有将 NFC 移出读卡区
后再次靠近,才会触发下一组 3 帧。消息只包含以下三个字段:
```json
{"pet_name":"团团","nfc_id":"E113B203","action":"摇尾巴"}
```
发布行为独立于网页是否打开。若本次 3 帧中存在 Broker 发布失败,卡片与绑定关系仍会
保存,前端会显示本次突发发送失败;为避免持续发送,服务不会在 NFC 一直停留时无限重试,
移开并重新靠近后才会发起下一组 3 帧。
Web 控制台是车机端的远程自编排与同步显示界面。网页保留一句话指令和预设功能模式入口,请求通过同源服务发送至车机执行;同时同步显示车机任务、编排结果、设备反馈和 USB 扩展能力。真实 CAN 与硬件执行权限仍由服务端安全门禁控制。
当前页面包括:
+45 -43
View File
@@ -1643,37 +1643,37 @@ function createTwinCabin(THREE, scene) {
const TWIN_THEME_PALETTES = {
user: {
accent: 0x2f80ed,
accentStrong: 0x1d5fbf,
accentSoft: 0xdbeeff,
sceneBackground: 0xeff6ff,
floor: 0xe5f0fd,
gridStrong: 0x75a9e8,
gridSoft: 0xc7daf2,
fillLight: 0xb7d9ff,
vehicleBody: 0x91c6f5,
vehicleDetail: 0x77a9d8,
vehicleInterior: 0x4d79a6,
vehicleTrim: 0x284c70,
vehicleMetal: 0x93b9da,
vehicleOutline: 0x2879d8,
vehicleOccluded: 0x9abbdc,
seatShell: 0x2b76c9,
seatInsert: 0xcfe7ff,
seatThermal: 0x72b5f5,
seatBolster: 0x1b5fae,
seatStructure: 0x25476e,
seatEdge: 0x69b4ff,
seatSeam: 0xe4f2ff,
glass: 0x4b9bea,
glassEmissive: 0x164f91,
glassEdge: 0x1b6fc4,
accent: 0x7b70f5,
accentStrong: 0x5d4dd9,
accentSoft: 0x9ba8ff,
sceneBackground: 0x111426,
floor: 0x1a2038,
gridStrong: 0x758cff,
gridSoft: 0x3c496d,
fillLight: 0x93b4ff,
vehicleBody: 0x729bc9,
vehicleDetail: 0x557da8,
vehicleInterior: 0x344f72,
vehicleTrim: 0x1a2943,
vehicleMetal: 0x7896b5,
vehicleOutline: 0x73b8ff,
vehicleOccluded: 0x526583,
seatShell: 0x426fbd,
seatInsert: 0xa9c8ea,
seatThermal: 0x65a9ed,
seatBolster: 0x295694,
seatStructure: 0x263b5e,
seatEdge: 0x76bdff,
seatSeam: 0xcbe4ff,
glass: 0x4d8ed2,
glassEmissive: 0x183f76,
glassEdge: 0x397ac0,
glassGlow: 0x79c7ff,
roofClear: 0x67aef4,
roofDark: 0x183b70,
lamp: 0x2e7fd4,
lampEmissive: 0x48a4f2,
lampEdge: 0x1e6bb8,
roofClear: 0x649cda,
roofDark: 0x1d315c,
lamp: 0x4385cf,
lampEmissive: 0x55acf4,
lampEdge: 0x3476b9,
},
};
@@ -1695,8 +1695,9 @@ function createTwinGroundTexture(THREE, palette, size = 768) {
const context = textureCanvas.getContext("2d");
if (!context) return null;
const accent = new THREE.Color(palette.accent);
const gridColor = accent.clone().lerp(new THREE.Color(0xffffff), 0.52);
const floorColor = new THREE.Color(palette.floor);
const gridStrong = new THREE.Color(palette.gridStrong);
const gridSoft = new THREE.Color(palette.gridSoft);
const rgba = (color, alpha) => {
const red = Math.round(color.r * 255);
const green = Math.round(color.g * 255);
@@ -1704,8 +1705,8 @@ function createTwinGroundTexture(THREE, palette, size = 768) {
return `rgba(${red}, ${green}, ${blue}, ${alpha})`;
};
const center = size / 2;
// The white base is fully opaque beneath the vehicle and fades to completely
// transparent at the outer edge. Theme colors are reserved for the grid.
// Keep the ground dark and translucent so it reads as another layer of the
// glass workspace instead of a white rectangle inside the themed shell.
const outerRadius = size * 0.48;
context.clearRect(0, 0, size, size);
@@ -1717,11 +1718,11 @@ function createTwinGroundTexture(THREE, palette, size = 768) {
center,
outerRadius,
);
surfaceGradient.addColorStop(0, "rgba(255, 255, 255, 1)");
surfaceGradient.addColorStop(0.18, "rgba(255, 255, 255, 1)");
surfaceGradient.addColorStop(0.48, "rgba(255, 255, 255, 0.72)");
surfaceGradient.addColorStop(0.75, "rgba(255, 255, 255, 0.28)");
surfaceGradient.addColorStop(1, "rgba(255, 255, 255, 0)");
surfaceGradient.addColorStop(0, rgba(floorColor, 0.96));
surfaceGradient.addColorStop(0.2, rgba(floorColor, 0.9));
surfaceGradient.addColorStop(0.52, rgba(floorColor, 0.58));
surfaceGradient.addColorStop(0.78, rgba(floorColor, 0.2));
surfaceGradient.addColorStop(1, rgba(floorColor, 0));
context.fillStyle = surfaceGradient;
context.fillRect(0, 0, size, size);
@@ -1733,7 +1734,7 @@ function createTwinGroundTexture(THREE, palette, size = 768) {
for (let index = 0; index <= 40; index += 1) {
const position = index * gridStep;
const major = index % 5 === 0;
gridContext.strokeStyle = rgba(gridColor, major ? 0.32 : 0.16);
gridContext.strokeStyle = rgba(major ? gridStrong : gridSoft, major ? 0.38 : 0.24);
gridContext.lineWidth = major ? 0.9 : 0.45;
gridContext.beginPath();
gridContext.moveTo(position, 0);
@@ -3944,8 +3945,9 @@ function initSeatTwin() {
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
const scene = new THREE.Scene();
scene.background = new THREE.Color(0xeff6ff);
scene.fog = new THREE.Fog(0xeff6ff, 12, 19);
const twinPalette = TWIN_THEME_PALETTES.user;
scene.background = new THREE.Color(twinPalette.sceneBackground);
scene.fog = new THREE.Fog(twinPalette.sceneBackground, 12, 19);
const camera = new THREE.PerspectiveCamera(34, 1, 0.1, 40);
const hemisphereLight = new THREE.HemisphereLight(0xffffff, 0x7d8b90, 2.2);
hemisphereLight.layers.enable(1);
@@ -3960,7 +3962,7 @@ function initSeatTwin() {
keyLight.shadow.camera.top = 6;
keyLight.shadow.camera.bottom = -4;
scene.add(keyLight);
const fillLight = new THREE.DirectionalLight(0xb7d9ff, 1.15);
const fillLight = new THREE.DirectionalLight(twinPalette.fillLight, 1.15);
fillLight.layers.enable(1);
fillLight.position.set(5, 3, -5);
scene.add(fillLight);
+581
View File
@@ -0,0 +1,581 @@
/* Glassmorphism workspace theme -------------------------------------------
Visual direction adapted to the referenced Creative Cloud concept:
a colorful desktop backdrop, one dark translucent app shell, restrained
separators and low-contrast glass cards. The existing N7 information
architecture and interaction contracts remain unchanged. */
body.console-v2 {
color-scheme: dark;
--background: oklch(0.16 0.045 286);
--foreground: oklch(0.97 0.012 274);
--card: oklch(0.25 0.045 282 / 0.68);
--card-foreground: oklch(0.95 0.018 274);
--popover: oklch(0.205 0.048 284 / 0.96);
--popover-foreground: oklch(0.97 0.012 274);
--primary: oklch(0.68 0.19 270);
--primary-foreground: oklch(0.99 0.004 274);
--secondary: oklch(0.31 0.05 281 / 0.72);
--secondary-foreground: oklch(0.94 0.018 274);
--muted: oklch(0.32 0.035 279 / 0.56);
--muted-foreground: oklch(0.78 0.035 276);
--accent: oklch(0.41 0.105 273 / 0.58);
--accent-foreground: oklch(0.98 0.012 274);
--destructive: oklch(0.69 0.2 22);
--border: oklch(0.9 0.035 276 / 0.16);
--input: oklch(0.93 0.025 276 / 0.22);
--ring: oklch(0.75 0.14 265);
--sidebar: oklch(0.19 0.05 286 / 0.64);
--sidebar-foreground: oklch(0.95 0.018 274);
--sidebar-primary: var(--primary);
--sidebar-primary-foreground: var(--primary-foreground);
--sidebar-accent: oklch(0.36 0.08 276 / 0.54);
--sidebar-accent-foreground: var(--foreground);
--sidebar-border: var(--border);
--sidebar-ring: var(--ring);
--heading-foreground: oklch(0.99 0.008 274);
--body-foreground: var(--card-foreground);
--subtle-foreground: var(--muted-foreground);
--status-success: oklch(0.77 0.17 158);
--status-online: var(--status-success);
--status-warning: oklch(0.82 0.16 82);
--status-offline: var(--destructive);
--status-info: oklch(0.76 0.14 247);
--status-success-foreground: oklch(0.86 0.14 158);
--status-warning-foreground: oklch(0.9 0.13 84);
--status-info-foreground: oklch(0.88 0.1 248);
--status-error-foreground: oklch(0.86 0.11 22);
--status-success-soft: oklch(0.67 0.14 158 / 0.15);
--status-warning-soft: oklch(0.72 0.15 82 / 0.16);
--status-info-soft: oklch(0.65 0.15 250 / 0.17);
--status-error-soft: oklch(0.66 0.18 22 / 0.16);
--bg: var(--background);
--surface: var(--card);
--surface-muted: var(--muted);
--ink: var(--foreground);
--line: var(--border);
--line-strong: var(--input);
--teal: var(--primary);
--teal-dark: color-mix(in oklch, var(--primary) 78%, black);
--teal-soft: color-mix(in oklch, var(--primary) 16%, transparent);
--blue: var(--primary);
--blue-soft: color-mix(in oklch, var(--primary) 16%, transparent);
--amber: var(--status-warning);
--amber-soft: var(--status-warning-soft);
--red: var(--destructive);
--red-soft: var(--status-error-soft);
--accent-bright: color-mix(in oklch, var(--primary) 78%, white);
--accent-ink: var(--primary);
--accent-wash: color-mix(in oklch, var(--primary) 14%, transparent);
--v2-accent: var(--primary);
--v2-accent-strong: color-mix(in oklch, var(--primary) 76%, white);
--v2-accent-soft: color-mix(in oklch, var(--primary) 14%, transparent);
--v2-sidebar: var(--sidebar);
--v2-sidebar-deep: oklch(0.15 0.045 287 / 0.72);
--v2-ink: var(--foreground);
--v2-muted: var(--muted-foreground);
--v2-line: var(--border);
--v2-surface: var(--card);
--v2-canvas: transparent;
--glass-shell: oklch(0.14 0.045 286 / 0.62);
--glass-panel: oklch(0.19 0.045 284 / 0.56);
--glass-card: oklch(0.43 0.04 276 / 0.18);
--glass-card-hover: oklch(0.5 0.07 270 / 0.23);
--glass-line: oklch(0.96 0.02 274 / 0.15);
--glass-line-strong: oklch(0.96 0.025 274 / 0.24);
--glass-highlight: oklch(1 0 0 / 0.14);
--glass-shadow: 0 24px 70px oklch(0.06 0.04 285 / 0.42);
--glass-panel-shadow: 0 12px 32px oklch(0.05 0.035 285 / 0.24);
min-width: 1180px;
min-height: 620px;
overflow: hidden;
color: var(--foreground);
background:
radial-gradient(circle at 8% 12%, oklch(0.69 0.2 232 / 0.82), transparent 31%),
radial-gradient(circle at 78% 2%, oklch(0.45 0.22 290 / 0.82), transparent 34%),
radial-gradient(circle at 94% 78%, oklch(0.64 0.25 339 / 0.72), transparent 38%),
radial-gradient(circle at 18% 96%, oklch(0.49 0.22 22 / 0.58), transparent 35%),
linear-gradient(135deg, oklch(0.17 0.075 242), oklch(0.16 0.075 292) 52%, oklch(0.23 0.11 344));
font-family: Inter, "PingFang SC", "Microsoft YaHei", system-ui, sans-serif;
}
body.console-v2::before,
body.console-v2::after {
content: "";
position: fixed;
z-index: 0;
pointer-events: none;
border-radius: 999px;
filter: blur(44px);
opacity: 0.56;
}
body.console-v2::before {
width: 34vw;
height: 34vw;
top: -18vw;
left: 28vw;
background: oklch(0.72 0.2 224 / 0.72);
}
body.console-v2::after {
width: 30vw;
height: 30vw;
right: -10vw;
bottom: -16vw;
background: oklch(0.67 0.25 334 / 0.68);
}
body.console-v2 .v2-boot-screen {
color: var(--foreground);
background:
radial-gradient(circle at 50% 42%, color-mix(in oklch, var(--primary) 32%, transparent), transparent 18rem),
oklch(0.13 0.04 286);
}
body.console-v2.assistant-chat-enabled.v2-passive-display.v2-interactive-sync .workspace,
body.console-v2.assistant-chat-enabled.v2-passive-display.v2-interactive-sync.assistant-drawer-open .workspace {
width: calc(100vw - 28px) !important;
height: calc(100dvh - 28px) !important;
min-height: 0;
position: relative;
z-index: 1;
margin: 14px !important;
overflow: hidden;
color: var(--foreground);
background: var(--glass-shell) !important;
border: 1px solid var(--glass-line-strong);
border-radius: 18px;
box-shadow: var(--glass-shadow), inset 0 1px 0 var(--glass-highlight);
-webkit-backdrop-filter: blur(22px) saturate(1.25);
backdrop-filter: blur(22px) saturate(1.25);
}
body.console-v2.v2-passive-display.v2-interactive-sync .main-panel {
min-height: 0;
color: var(--foreground);
background: transparent !important;
}
body.console-v2.v2-passive-display.v2-interactive-sync .command-panel.v2-sidebar {
height: 100%;
color: var(--sidebar-foreground);
background:
linear-gradient(165deg, oklch(0.22 0.07 284 / 0.38), oklch(0.12 0.04 287 / 0.42)) !important;
border-right: 1px solid var(--glass-line);
-webkit-backdrop-filter: blur(18px) saturate(1.2);
backdrop-filter: blur(18px) saturate(1.2);
}
/* Header / app navigation ------------------------------------------------ */
body.console-v2.assistant-chat-enabled.v2-passive-display.v2-interactive-sync .main-panel > .v2-mode-rail {
color: var(--foreground);
background:
linear-gradient(110deg, oklch(0.32 0.08 274 / 0.26), transparent 42%),
oklch(0.18 0.045 284 / 0.62) !important;
border: 1px solid var(--glass-line);
border-radius: 14px;
box-shadow: inset 0 1px 0 var(--glass-highlight), 0 8px 24px oklch(0.05 0.04 286 / 0.2);
-webkit-backdrop-filter: blur(18px) saturate(1.22);
backdrop-filter: blur(18px) saturate(1.22);
}
body.console-v2 .v2-mode-rail .mode-rail-title,
body.console-v2 .v2-mode-rail .mode-rail-identity small,
body.console-v2 .v2-mode-rail .mode-rail-selection {
color: var(--foreground);
}
body.console-v2 .v2-mode-rail .mode-rail-identity small {
color: var(--muted-foreground);
}
body.console-v2 .v2-mode-rail .mode-rail-mark,
body.console-v2 .v2-panel-control-dock > button,
body.console-v2 .v2-mode-rail .mode-rail-actions > button,
body.console-v2 .v2-mode-rail .mode-rail-toggle {
color: var(--muted-foreground) !important;
background: var(--glass-card) !important;
border-color: var(--glass-line) !important;
box-shadow: inset 0 1px 0 var(--glass-highlight) !important;
-webkit-backdrop-filter: blur(10px) saturate(1.15);
backdrop-filter: blur(10px) saturate(1.15);
}
body.console-v2 .v2-panel-control-dock > button:is(:hover, :focus-visible, .active),
body.console-v2 .v2-mode-rail .mode-rail-actions > button:is(:hover, :focus-visible),
body.console-v2 .v2-mode-rail .mode-rail-toggle:is(:hover, :focus-visible) {
color: var(--foreground) !important;
background: color-mix(in oklch, var(--primary) 58%, transparent) !important;
border-color: color-mix(in oklch, var(--primary) 64%, white 18%) !important;
box-shadow: 0 7px 18px color-mix(in oklch, var(--primary) 24%, transparent), inset 0 1px 0 oklch(1 0 0 / 0.22) !important;
}
body.console-v2.v2-passive-display.v2-interactive-sync .v2-mode-rail .mode-chips {
background: transparent;
}
body.console-v2.v2-passive-display.v2-interactive-sync .v2-mode-rail .mode-chips > button {
color: var(--muted-foreground) !important;
background: transparent !important;
border-color: transparent !important;
box-shadow: none !important;
}
body.console-v2.v2-passive-display.v2-interactive-sync .v2-mode-rail .mode-chips > button:is(:hover, :focus-visible) {
color: var(--foreground) !important;
background: var(--glass-card-hover) !important;
border-color: var(--glass-line) !important;
}
body.console-v2.v2-passive-display.v2-interactive-sync .v2-mode-rail .mode-chips > button::after {
background: var(--primary);
box-shadow: 0 0 14px color-mix(in oklch, var(--primary) 70%, transparent);
}
/* Primary glass surfaces ------------------------------------------------- */
body.console-v2.v2-passive-display.v2-interactive-sync :is(
#assistantDrawer,
.joint-twin-shell > .twin-band,
#v2DevicePanel,
#v2ExtensionPanel .v2-extension-sync-mount
) {
color: var(--card-foreground) !important;
background:
linear-gradient(145deg, oklch(0.36 0.055 278 / 0.12), transparent 44%),
var(--glass-panel) !important;
border: 1px solid var(--glass-line) !important;
border-radius: 14px !important;
box-shadow: var(--glass-panel-shadow), inset 0 1px 0 var(--glass-highlight) !important;
-webkit-backdrop-filter: blur(20px) saturate(1.2);
backdrop-filter: blur(20px) saturate(1.2);
}
body.console-v2.assistant-chat-enabled.v2-passive-display.v2-interactive-sync #assistantDrawer .v2-natural-panel {
color: var(--card-foreground);
background: transparent !important;
}
body.console-v2 :is(
.v2-operation-heading,
.joint-twin-shell .twin-heading,
.v2-device-sync-board > header,
.v2-extension-sync-heading
) {
color: var(--foreground) !important;
background: oklch(0.17 0.045 285 / 0.38) !important;
border-bottom-color: var(--glass-line) !important;
}
body.console-v2 :is(
.v2-operation-heading h2,
.joint-twin-shell .twin-heading h3,
.joint-twin-shell .twin-heading .section-kicker,
.v2-device-sync-board > header h3,
.v2-extension-sync-heading h3
) {
color: var(--heading-foreground) !important;
}
/* Assistant -------------------------------------------------------------- */
body.console-v2 .assistant-message-assistant .assistant-message-content,
body.console-v2 .assistant-message-user .assistant-message-content,
body.console-v2 .assistant-dispatched-controls {
color: var(--card-foreground) !important;
background: var(--glass-card) !important;
border: 1px solid var(--glass-line) !important;
box-shadow: inset 0 1px 0 var(--glass-highlight), 0 8px 20px oklch(0.05 0.035 285 / 0.14) !important;
-webkit-backdrop-filter: blur(12px) saturate(1.16);
backdrop-filter: blur(12px) saturate(1.16);
}
body.console-v2 .assistant-avatar {
color: var(--primary-foreground) !important;
background: linear-gradient(145deg, color-mix(in oklch, var(--primary) 78%, white), var(--primary)) !important;
border-color: oklch(1 0 0 / 0.22) !important;
box-shadow: 0 8px 20px color-mix(in oklch, var(--primary) 24%, transparent) !important;
}
body.console-v2.assistant-chat-enabled.v2-passive-display.v2-interactive-sync .v2-request-form .command-composer {
color: var(--foreground);
background: oklch(0.12 0.035 286 / 0.46) !important;
border-color: var(--glass-line-strong) !important;
box-shadow: inset 0 1px 0 var(--glass-highlight), 0 10px 24px oklch(0.05 0.04 286 / 0.24) !important;
-webkit-backdrop-filter: blur(16px) saturate(1.18);
backdrop-filter: blur(16px) saturate(1.18);
}
body.console-v2.assistant-chat-enabled .v2-request-form #utterance {
color: var(--foreground) !important;
caret-color: var(--primary);
background: transparent !important;
}
body.console-v2.assistant-chat-enabled .v2-request-form #utterance::placeholder {
color: color-mix(in oklch, var(--muted-foreground) 82%, transparent);
}
body.console-v2 .assistant-drawer-actions :is(.context-reset, .assistant-drawer-close),
body.console-v2 .v2-request-form :is(.speech-dictation, .v2-reasoning-trigger) {
color: var(--muted-foreground) !important;
background: transparent !important;
}
body.console-v2 .assistant-drawer-actions :is(.context-reset, .assistant-drawer-close):is(:hover, :focus-visible),
body.console-v2 .v2-request-form :is(.speech-dictation, .v2-reasoning-trigger):is(:hover, :focus-visible) {
color: var(--foreground) !important;
background: var(--glass-card-hover) !important;
}
body.console-v2.assistant-chat-enabled.v2-passive-display.v2-interactive-sync .v2-request-form .primary-action,
body.console-v2 .v2-extension-primary,
body.console-v2 .mode-editor-primary {
color: var(--primary-foreground) !important;
background: linear-gradient(135deg, color-mix(in oklch, var(--primary) 78%, white), var(--primary)) !important;
border-color: color-mix(in oklch, var(--primary) 72%, white 20%) !important;
box-shadow: 0 9px 22px color-mix(in oklch, var(--primary) 28%, transparent), inset 0 1px 0 oklch(1 0 0 / 0.25) !important;
}
/* Vehicle twin ----------------------------------------------------------- */
body.console-v2.v2-passive-display.v2-interactive-sync .joint-twin-shell[data-open-panels] {
color: var(--foreground);
background: transparent !important;
}
body.console-v2.v2-passive-display.v2-interactive-sync .joint-twin-shell .twin-stage {
background:
radial-gradient(ellipse at 53% 58%, color-mix(in oklch, var(--primary) 25%, transparent), transparent 42%),
radial-gradient(circle at 82% 8%, oklch(0.72 0.18 232 / 0.12), transparent 32%),
linear-gradient(160deg, oklch(0.18 0.05 285 / 0.2), oklch(0.09 0.03 286 / 0.32)) !important;
}
body.console-v2 .joint-twin-shell :is(.twin-seat-switch, .twin-seat-switch button, .twin-reset, .twin-focus-badge) {
color: var(--muted-foreground) !important;
background: var(--glass-card) !important;
border-color: var(--glass-line) !important;
box-shadow: inset 0 1px 0 var(--glass-highlight) !important;
}
body.console-v2 .joint-twin-shell .twin-seat-switch button.active,
body.console-v2 .joint-twin-shell :is(.twin-reset, .twin-focus-badge):is(:hover, :focus-visible) {
color: var(--primary-foreground) !important;
background: color-mix(in oklch, var(--primary) 72%, transparent) !important;
border-color: color-mix(in oklch, var(--primary) 70%, white 18%) !important;
}
body.console-v2 .v2-twin-hotspot-label,
body.console-v2 .v2-twin-device-popover {
color: var(--foreground) !important;
background: oklch(0.16 0.045 285 / 0.74) !important;
border-color: var(--glass-line-strong) !important;
box-shadow: 0 10px 28px oklch(0.04 0.035 286 / 0.32), inset 0 1px 0 var(--glass-highlight) !important;
-webkit-backdrop-filter: blur(14px) saturate(1.18);
backdrop-filter: blur(14px) saturate(1.18);
}
/* Reusable cards, fields and side rails --------------------------------- */
body.console-v2 :is(
.v2-sync-transport-field,
.v2-sync-execution-field,
.v2-can-command-panel,
.v2-sync-log,
.v2-nfc-inline,
.v2-extension-gateway,
.v2-extension-synced-device,
.v2-extension-empty,
.v2-pet-card-region,
.v2-pet-card,
.v2-device-card,
.joint-seat-panel,
.domain-panel,
.coverage-card,
.decision-list li,
.frame-summary,
.execution-summary
) {
color: var(--card-foreground) !important;
background: var(--glass-card) !important;
border-color: var(--glass-line) !important;
box-shadow: inset 0 1px 0 var(--glass-highlight), 0 7px 20px oklch(0.05 0.035 286 / 0.15) !important;
-webkit-backdrop-filter: blur(13px) saturate(1.16);
backdrop-filter: blur(13px) saturate(1.16);
}
body.console-v2 :is(
.v2-nfc-inline,
.v2-extension-gateway,
.v2-extension-synced-device,
.v2-pet-card,
.v2-device-card,
.joint-seat-panel,
.domain-panel
):is(:hover, :focus-within) {
background: var(--glass-card-hover) !important;
border-color: var(--glass-line-strong) !important;
}
body.console-v2 .v2-pet-card-region {
--pet-acrylic-surface: var(--glass-card);
--pet-acrylic-surface-strong: var(--glass-card-hover);
--pet-acrylic-border: var(--glass-line);
--pet-acrylic-highlight: var(--glass-highlight);
}
body.console-v2 :is(input, textarea, select),
body.console-v2 .v2-pet-nfc-field output {
color: var(--foreground) !important;
background: oklch(0.11 0.035 286 / 0.42) !important;
border-color: var(--glass-line-strong) !important;
box-shadow: inset 0 1px 0 oklch(1 0 0 / 0.08) !important;
-webkit-backdrop-filter: blur(10px) saturate(1.14);
backdrop-filter: blur(10px) saturate(1.14);
}
body.console-v2 :is(input, textarea, select):focus {
border-color: color-mix(in oklch, var(--primary) 68%, white 16%) !important;
box-shadow: 0 0 0 3px color-mix(in oklch, var(--primary) 22%, transparent), inset 0 1px 0 oklch(1 0 0 / 0.1) !important;
}
body.console-v2 :is(input, textarea)::placeholder {
color: color-mix(in oklch, var(--muted-foreground) 76%, transparent);
}
body.console-v2 :is(
.v2-extension-nfc-launch,
.v2-extension-gateway > button,
.v2-nfc-inline > button,
.v2-pet-card-region button,
.v2-device-adjust-toggle,
.config-action
) {
color: var(--foreground) !important;
background: color-mix(in oklch, var(--primary) 52%, transparent) !important;
border-color: color-mix(in oklch, var(--primary) 54%, white 14%) !important;
box-shadow: inset 0 1px 0 oklch(1 0 0 / 0.18), 0 7px 18px color-mix(in oklch, var(--primary) 18%, transparent) !important;
}
body.console-v2 :is(
.v2-extension-nfc-launch,
.v2-extension-gateway > button,
.v2-nfc-inline > button,
.v2-pet-card-region button,
.v2-device-adjust-toggle,
.config-action
):is(:hover, :focus-visible) {
color: var(--primary-foreground) !important;
background: color-mix(in oklch, var(--primary) 78%, transparent) !important;
border-color: color-mix(in oklch, var(--primary) 76%, white 18%) !important;
}
body.console-v2 .v2-pet-card > header > button {
color: var(--muted-foreground) !important;
background: transparent !important;
border-color: transparent !important;
box-shadow: none !important;
}
body.console-v2 .v2-pet-card > header > button:is(:hover, :focus-visible) {
color: var(--status-error-foreground) !important;
background: var(--status-error-soft) !important;
}
body.console-v2 :is(.v2-nfc-inline-icon, .v2-extension-gateway > span, .v2-pet-card > header > span, .v2-pet-card-region > header > div > span) {
color: color-mix(in oklch, var(--primary) 74%, white) !important;
background: color-mix(in oklch, var(--primary) 18%, transparent) !important;
border-color: color-mix(in oklch, var(--primary) 32%, transparent) !important;
box-shadow: inset 0 1px 0 oklch(1 0 0 / 0.15) !important;
}
body.console-v2 :is(.v2-extension-linkage, .v2-sidebar-footer) {
color: var(--muted-foreground) !important;
border-color: var(--glass-line) !important;
}
/* Dialogs and secondary pages ------------------------------------------- */
body.console-v2 :is(
.mode-gallery-backdrop,
.mode-editor-backdrop,
.v2-extension-dialog::backdrop,
.energy-monitor-layer
) {
background: oklch(0.055 0.025 286 / 0.62) !important;
-webkit-backdrop-filter: blur(14px) saturate(1.12);
backdrop-filter: blur(14px) saturate(1.12);
}
body.console-v2 :is(
.mode-gallery,
.mode-editor,
.v2-nfc-dialog,
.v2-gscu-tcp-dialog,
.energy-monitor-page
) {
color: var(--card-foreground) !important;
background:
linear-gradient(145deg, oklch(0.36 0.065 279 / 0.14), transparent 42%),
oklch(0.15 0.045 285 / 0.88) !important;
border-color: var(--glass-line-strong) !important;
box-shadow: 0 28px 84px oklch(0.03 0.03 286 / 0.58), inset 0 1px 0 var(--glass-highlight) !important;
-webkit-backdrop-filter: blur(24px) saturate(1.24);
backdrop-filter: blur(24px) saturate(1.24);
}
body.console-v2 :is(
.mode-gallery-card,
.mode-editor-flow,
.mode-editor-command-card,
.v2-nfc-body,
.v2-nfc-existing,
.v2-nfc-pet-binding,
.energy-kpi,
.energy-trend-card,
.energy-devices-card
) {
color: var(--card-foreground) !important;
background: var(--glass-card) !important;
border-color: var(--glass-line) !important;
box-shadow: inset 0 1px 0 var(--glass-highlight), 0 9px 24px oklch(0.04 0.03 286 / 0.2) !important;
}
body.console-v2 :is(.mode-gallery-close, .mode-editor-header > button, .v2-extension-dialog [data-dialog-close], #energyMonitorClose) {
color: var(--muted-foreground) !important;
background: var(--glass-card) !important;
border-color: var(--glass-line) !important;
}
body.console-v2 :is(.mode-gallery-close, .mode-editor-header > button, .v2-extension-dialog [data-dialog-close], #energyMonitorClose):is(:hover, :focus-visible) {
color: var(--foreground) !important;
background: var(--glass-card-hover) !important;
}
/* Scrollbars ------------------------------------------------------------- */
body.console-v2 * {
scrollbar-color: color-mix(in oklch, var(--primary) 70%, white 8%) oklch(0.08 0.025 286 / 0.32);
}
body.console-v2 *::-webkit-scrollbar-track {
background: oklch(0.08 0.025 286 / 0.28);
}
body.console-v2 *::-webkit-scrollbar-thumb {
background: color-mix(in oklch, var(--primary) 68%, white 10%);
border: 2px solid transparent;
background-clip: padding-box;
}
@media (max-width: 900px), (max-height: 680px) {
body.console-v2.assistant-chat-enabled.v2-passive-display.v2-interactive-sync .workspace,
body.console-v2.assistant-chat-enabled.v2-passive-display.v2-interactive-sync.assistant-drawer-open .workspace {
width: 100vw !important;
height: 100dvh !important;
margin: 0 !important;
border-width: 0;
border-radius: 0;
}
}
@media (prefers-reduced-motion: reduce) {
body.console-v2::before,
body.console-v2::after {
filter: blur(44px);
}
}
+5 -4
View File
@@ -4,18 +4,19 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="color-scheme" content="light dark" />
<meta name="theme-color" content="oklch(0.57 0.19 258)" />
<meta name="theme-color" content="#17182b" />
<meta name="mobile-web-app-capable" content="yes" />
<link rel="manifest" href="/manifest.webmanifest" />
<link rel="icon" href="/icons/app-icon-192.png" />
<title>511工程-智能座舱</title>
<link rel="stylesheet" href="/styles.css?v=dual-seat-23" />
<link rel="stylesheet" href="/console-v2.css?v=delivery-state-69" />
<link rel="stylesheet" href="/self-extension.css?v=gscu-tcp-43" />
<link rel="stylesheet" href="/self-extension.css?v=pet-acrylic-48" />
<link rel="stylesheet" href="/speech-input.css?v=sensevoice-7" />
<link rel="stylesheet" href="/assistant-chat.css?v=codex-assistant-28" />
<link rel="stylesheet" href="/energy-monitor.css?v=energy-monitor-4" />
<link rel="stylesheet" href="/ui-consistency.css?v=spatial-ui-4" />
<link rel="stylesheet" href="/codepen-glass-theme.css?v=creative-cloud-1" />
<script src="/vendor/lucide/0.468.0/lucide.min.js" defer></script>
<script src="/vendor/vue/3.5.21/vue.runtime.global.prod.js" defer></script>
<script src="/vue-ui.js?v=vue-presentation-3" defer></script>
@@ -23,12 +24,12 @@
<script src="/vendor/three/0.147.0/fflate.min.js" defer></script>
<script src="/vendor/three/0.147.0/FBXLoader.js" defer></script>
<script src="/network.js?v=android-pad-2" defer></script>
<script src="/app.js?v=dual-seat-135" defer></script>
<script src="/app.js?v=dual-seat-136" defer></script>
<script src="/speech-input.js?v=sensevoice-13" defer></script>
<script type="module" src="/gesture-control.js?v=android-pad-9"></script>
<script src="/pwa.js?v=android-pad-3" defer></script>
<script src="/console-v2.js?v=local-first-70" defer></script>
<script src="/self-extension.js?v=gscu-tcp-44" defer></script>
<script src="/self-extension.js?v=pet-acrylic-48" defer></script>
<script src="/assistant-chat.js?v=codex-assistant-32" defer></script>
<script src="/energy-monitor.js?v=energy-monitor-4" defer></script>
</head>
+544
View File
@@ -3982,3 +3982,547 @@ form > .v2-extension-primary {
background: var(--status-error-soft);
border-color: color-mix(in oklch, var(--status-error) 34%, var(--border));
}
/* Durable NFC pet cards ------------------------------------------------- */
.v2-extension-sync-mount {
grid-template-rows: auto auto auto auto minmax(0, 1fr) auto;
}
.console-v2 #v2ExtensionPanel .v2-extension-sync-mount {
overflow-x: hidden;
overflow-y: auto;
overscroll-behavior-y: contain;
scrollbar-gutter: stable;
touch-action: pan-y;
}
.v2-pet-card-region {
--pet-acrylic-surface: color-mix(in oklch, var(--card) 72%, transparent);
--pet-acrylic-surface-strong: color-mix(in oklch, var(--card) 84%, transparent);
--pet-acrylic-border: color-mix(in oklch, var(--primary) 20%, color-mix(in oklch, var(--border) 76%, transparent));
--pet-acrylic-highlight: color-mix(in oklch, white 58%, transparent);
min-width: 0;
position: relative;
isolation: isolate;
display: grid;
gap: 12px;
padding: 12px;
color: var(--card-foreground);
background:
linear-gradient(145deg, var(--pet-acrylic-surface-strong), var(--pet-acrylic-surface)),
color-mix(in oklch, var(--accent) 28%, transparent);
border: 1px solid var(--pet-acrylic-border);
border-radius: var(--ui-radius-section, 14px);
box-shadow:
0 14px 34px color-mix(in oklch, var(--foreground) 9%, transparent),
inset 0 1px 0 var(--pet-acrylic-highlight),
inset 0 -1px 0 color-mix(in oklch, var(--primary) 8%, transparent);
-webkit-backdrop-filter: blur(18px) saturate(1.35);
backdrop-filter: blur(18px) saturate(1.35);
}
.v2-pet-card-region::before {
content: "";
position: absolute;
inset: 0;
z-index: -1;
border-radius: inherit;
pointer-events: none;
background:
radial-gradient(circle at 12% 0%, color-mix(in oklch, white 38%, transparent), transparent 38%),
linear-gradient(115deg, color-mix(in oklch, var(--primary) 7%, transparent), transparent 44%);
}
.v2-pet-card-region > header,
.v2-pet-card-region > header > div,
.v2-pet-card > header,
.v2-pet-card > header > div,
.v2-nfc-pet-binding > div,
.v2-nfc-pet-binding > div:first-child {
min-width: 0;
display: flex;
align-items: center;
}
.v2-pet-card-region > header {
justify-content: space-between;
gap: 10px;
}
.v2-pet-card-region > header > div {
gap: 9px;
}
.v2-pet-card-region > header > div > span,
.v2-pet-card > header > span,
.v2-nfc-pet-binding > div:first-child > span {
flex: none;
display: grid;
place-items: center;
color: var(--primary);
background: var(--accent);
border: 1px solid color-mix(in oklch, var(--primary) 18%, var(--border));
}
.v2-pet-card-region > header > div > span {
width: 34px;
height: 34px;
border-radius: 10px;
}
.v2-pet-card-region > header > div > span svg,
.v2-pet-card-region > header > button svg {
width: 16px;
height: 16px;
}
.v2-pet-card-region > header h4 {
margin: 0;
color: var(--heading-foreground);
font-size: 14px;
font-weight: 850;
}
.v2-pet-card-region > header > button,
.v2-pet-card-empty > button,
.v2-pet-card footer > button,
.v2-pet-nfc-field button,
.v2-nfc-no-pets > button {
min-height: 34px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 6px 11px;
color: var(--primary-foreground);
background: linear-gradient(145deg, color-mix(in oklch, var(--primary) 90%, white), var(--primary));
border: 1px solid color-mix(in oklch, var(--primary) 80%, white);
border-radius: var(--ui-radius-control-inner, 9px);
box-shadow:
0 6px 15px color-mix(in oklch, var(--primary) 24%, transparent),
inset 0 1px 0 color-mix(in oklch, white 34%, transparent);
font-size: 11px;
font-weight: 850;
white-space: nowrap;
cursor: pointer;
}
.v2-pet-card-region button:disabled,
.v2-nfc-pet-binding button:disabled {
opacity: 0.52;
cursor: wait;
}
.v2-pet-card-list {
min-height: 0;
display: grid;
grid-auto-rows: max-content;
align-content: start;
gap: 12px;
overflow: visible;
}
.v2-pet-card {
min-width: 0;
position: relative;
isolation: isolate;
align-self: start;
display: grid;
gap: 12px;
padding: 12px;
background:
linear-gradient(150deg, color-mix(in oklch, var(--card) 88%, transparent), color-mix(in oklch, var(--accent) 24%, transparent));
border: 1px solid var(--pet-acrylic-border);
border-radius: var(--ui-radius-item, 12px);
box-shadow:
0 9px 24px color-mix(in oklch, var(--foreground) 8%, transparent),
inset 0 1px 0 var(--pet-acrylic-highlight);
-webkit-backdrop-filter: blur(14px) saturate(1.22);
backdrop-filter: blur(14px) saturate(1.22);
scroll-margin-block: 12px;
transition: border-color 150ms ease, box-shadow 150ms ease, transform 150ms ease;
}
.v2-pet-card::after {
content: "";
position: absolute;
inset: 0;
z-index: -1;
border-radius: inherit;
pointer-events: none;
background: linear-gradient(118deg, color-mix(in oklch, white 18%, transparent), transparent 34% 72%, color-mix(in oklch, var(--primary) 5%, transparent));
}
.v2-pet-card:focus-within {
border-color: color-mix(in oklch, var(--primary) 42%, var(--border));
box-shadow:
0 12px 28px color-mix(in oklch, var(--foreground) 10%, transparent),
0 0 0 3px color-mix(in oklch, var(--primary) 13%, transparent),
inset 0 1px 0 var(--pet-acrylic-highlight);
}
.v2-pet-card[data-pet-state="bursting"],
.v2-pet-card[data-pet-state="complete"] {
border-color: color-mix(in oklch, var(--status-success) 38%, var(--border));
}
.v2-pet-card[data-pet-state="editing"] {
border-color: color-mix(in oklch, var(--status-warning) 38%, var(--border));
}
.v2-pet-card[data-pet-state="error"] {
border-color: color-mix(in oklch, var(--status-error) 40%, var(--border));
}
.v2-pet-card > header {
display: grid;
grid-template-columns: 36px minmax(0, 1fr) auto 28px;
gap: 9px;
}
.v2-pet-card > header > span {
width: 36px;
height: 36px;
border-radius: 10px;
}
.v2-pet-card > header > span svg,
.v2-pet-card > header > button svg {
width: 16px;
height: 16px;
}
.v2-pet-card > header > strong {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--foreground);
font-size: 14px;
font-weight: 800;
}
.v2-pet-card > header > b {
padding: 4px 7px;
color: var(--muted-foreground);
background: color-mix(in oklch, var(--muted) 72%, transparent);
border: 1px solid color-mix(in oklch, var(--border) 68%, transparent);
border-radius: 999px;
box-shadow: inset 0 1px 0 color-mix(in oklch, white 28%, transparent);
-webkit-backdrop-filter: blur(8px) saturate(1.18);
backdrop-filter: blur(8px) saturate(1.18);
font-size: 10px;
white-space: nowrap;
}
.v2-pet-card > header > b[data-state="bursting"],
.v2-pet-card > header > b[data-state="complete"] {
color: var(--status-success-foreground);
background: var(--status-success-soft);
}
.v2-pet-card > header > b[data-state="editing"] {
color: var(--status-warning-foreground);
background: var(--status-warning-soft);
}
.v2-pet-card > header > b[data-state="error"] {
color: var(--status-error-foreground);
background: var(--status-error-soft);
}
.v2-pet-card > header > button {
width: 28px;
height: 28px;
display: grid;
place-items: center;
padding: 0;
color: var(--muted-foreground);
background: transparent;
border: 0;
border-radius: 6px;
cursor: pointer;
}
.v2-pet-card > header > button:hover {
color: var(--destructive);
background: var(--status-error-soft);
}
.v2-pet-card-fields {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 10px;
}
.v2-pet-card-fields > label {
min-width: 0;
display: grid;
grid-column: 1;
gap: 6px;
}
.v2-pet-card-fields > label > span {
color: var(--muted-foreground);
font-size: 11px;
font-weight: 800;
}
.v2-nfc-pet-binding code {
color: var(--primary);
font: 700 8.5px "SFMono-Regular", Consolas, monospace;
}
.v2-pet-card-fields input,
.v2-pet-card-fields textarea {
width: 100%;
min-width: 0;
padding: 9px 10px;
color: var(--foreground);
background: color-mix(in oklch, var(--background) 68%, transparent);
border: 1px solid color-mix(in oklch, var(--input) 80%, transparent);
border-radius: var(--ui-radius-control-inner, 9px);
box-shadow:
inset 0 1px 0 color-mix(in oklch, white 30%, transparent),
0 3px 10px color-mix(in oklch, var(--foreground) 4%, transparent);
-webkit-backdrop-filter: blur(10px) saturate(1.18);
backdrop-filter: blur(10px) saturate(1.18);
outline: none;
font-family: inherit;
font-size: 12px;
font-weight: 550;
line-height: 1.45;
resize: vertical;
}
.v2-pet-card-fields input {
height: 40px;
}
.v2-pet-card-fields textarea {
height: 58px;
min-height: 58px;
max-height: 96px;
}
.v2-pet-card-fields input:focus,
.v2-pet-card-fields textarea:focus {
border-color: var(--ring);
box-shadow: 0 0 0 2px color-mix(in oklch, var(--ring) 12%, transparent);
}
.v2-pet-nfc-field {
grid-column: 1;
}
.v2-pet-nfc-field > div {
min-width: 0;
display: grid;
grid-template-columns: minmax(0, 1fr);
}
.v2-pet-nfc-field output {
min-width: 0;
min-height: 40px;
display: flex;
align-items: center;
overflow: hidden;
padding: 8px 10px;
color: var(--muted-foreground);
background: color-mix(in oklch, var(--muted) 58%, transparent);
border: 1px dashed color-mix(in oklch, var(--primary) 18%, var(--border));
border-radius: var(--ui-radius-control-inner, 9px);
box-shadow: inset 0 1px 0 color-mix(in oklch, white 25%, transparent);
-webkit-backdrop-filter: blur(9px) saturate(1.15);
backdrop-filter: blur(9px) saturate(1.15);
font: 700 11px/1.35 "SFMono-Regular", Consolas, monospace;
text-overflow: ellipsis;
white-space: nowrap;
}
.v2-pet-card > footer {
min-width: 0;
display: flex;
justify-content: flex-end;
align-items: flex-end;
padding-top: 10px;
border-top: 1px solid color-mix(in oklch, var(--border) 74%, transparent);
}
.v2-pet-card > footer svg {
width: 14px;
height: 14px;
}
.v2-pet-card-empty {
min-height: 112px;
display: grid;
grid-template-columns: 36px minmax(0, 1fr) auto;
align-items: center;
gap: 10px;
padding: 12px;
color: var(--muted-foreground);
background: color-mix(in oklch, var(--muted) 58%, transparent);
border: 1px dashed var(--pet-acrylic-border);
border-radius: var(--ui-radius-item, 12px);
box-shadow: inset 0 1px 0 var(--pet-acrylic-highlight);
-webkit-backdrop-filter: blur(12px) saturate(1.18);
backdrop-filter: blur(12px) saturate(1.18);
}
.v2-pet-card-empty > svg {
width: 24px;
color: var(--primary);
}
.v2-pet-card-empty strong {
color: var(--foreground);
font-size: 12px;
}
.v2-nfc-pet-binding {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 12px;
margin-top: 14px;
padding: 13px;
color: var(--card-foreground);
background: linear-gradient(145deg, color-mix(in oklch, var(--card) 82%, transparent), color-mix(in oklch, var(--primary) 9%, transparent));
border: 1px solid color-mix(in oklch, var(--primary) 24%, color-mix(in oklch, var(--border) 72%, transparent));
border-radius: var(--ui-radius-item, 12px);
box-shadow:
0 8px 22px color-mix(in oklch, var(--foreground) 7%, transparent),
inset 0 1px 0 color-mix(in oklch, white 34%, transparent);
-webkit-backdrop-filter: blur(14px) saturate(1.2);
backdrop-filter: blur(14px) saturate(1.2);
}
.v2-nfc-pet-binding > div:first-child {
gap: 10px;
}
.v2-nfc-pet-binding > div:first-child > span {
width: 38px;
height: 38px;
border-radius: 11px;
}
.v2-nfc-pet-binding > div:first-child > span svg {
width: 18px;
}
.v2-nfc-pet-binding > div:first-child > div {
min-width: 0;
display: grid;
gap: 2px;
}
.v2-nfc-pet-binding small,
.v2-nfc-pet-binding p {
color: var(--muted-foreground);
font-size: 9px;
}
.v2-nfc-pet-binding strong {
color: var(--foreground);
font-size: 13px;
}
.v2-nfc-pet-binding p {
max-width: 420px;
margin: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.v2-nfc-pet-binding > div:last-child {
justify-content: flex-end;
gap: 8px;
}
.v2-nfc-pet-binding > div:last-child > b {
color: var(--status-success-foreground);
font-size: 9px;
white-space: nowrap;
}
.v2-nfc-pet-binding button,
.v2-nfc-no-pets > button {
min-height: 34px;
padding: 0 12px;
color: var(--primary);
background: var(--accent);
border: 1px solid color-mix(in oklch, var(--primary) 22%, var(--border));
border-radius: 9px;
font-size: 9px;
font-weight: 800;
cursor: pointer;
}
.v2-nfc-pet-existing {
background: color-mix(in oklch, var(--primary) 5%, var(--card));
}
.v2-nfc-no-pets {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
}
.v2-nfc-body > .v2-nfc-existing {
box-sizing: border-box;
margin-top: 14px;
padding: 14px 16px;
background: linear-gradient(145deg, color-mix(in oklch, var(--card) 82%, transparent), color-mix(in oklch, var(--primary) 7%, transparent));
border: 1px solid color-mix(in oklch, var(--primary) 18%, var(--border));
border-radius: var(--ui-radius-item, 12px);
box-shadow:
0 8px 22px color-mix(in oklch, var(--foreground) 7%, transparent),
inset 0 1px 0 color-mix(in oklch, white 32%, transparent);
-webkit-backdrop-filter: blur(14px) saturate(1.2);
backdrop-filter: blur(14px) saturate(1.2);
}
.v2-nfc-body > .v2-nfc-existing > div {
grid-template-columns: minmax(0, 1fr) 132px;
align-items: center;
gap: 12px;
}
.v2-nfc-body > .v2-nfc-existing button {
width: 132px;
min-height: 42px;
padding-inline: 12px;
}
@media (max-width: 720px) {
.v2-pet-card-fields {
grid-template-columns: 1fr;
}
.v2-pet-card-fields > label,
.v2-pet-nfc-field {
grid-column: 1;
}
.v2-nfc-pet-binding {
grid-template-columns: 1fr;
}
.v2-nfc-pet-binding > div:last-child {
justify-content: flex-start;
flex-wrap: wrap;
}
.v2-nfc-pet-binding code {
overflow-wrap: anywhere;
}
.v2-nfc-body > .v2-nfc-existing > div {
grid-template-columns: minmax(0, 1fr);
}
.v2-nfc-body > .v2-nfc-existing button {
width: 100%;
}
}
+384 -19
View File
@@ -373,6 +373,21 @@
card: null,
legacyProfiles: [],
};
let petCardSnapshot = {
revision: -1,
cards: [],
publisher: {
state: "stopped",
topic: "n7/pet",
burst_count: 3,
burst_window_ms: 1000,
burst_attempt_count: 0,
burst_published_count: 0,
active_pet_id: null,
},
};
let selectedPetCardId = null;
const petCardDrafts = new Map();
let nfcPollTimer = null;
let legacySameOriginChecked = false;
let legacyMigrationCompleted = false;
@@ -3299,6 +3314,277 @@
return String(highest + 1).padStart(3, "0");
}
function petCards() {
return Array.isArray(petCardSnapshot.cards) ? petCardSnapshot.cards : [];
}
function petCardHasUnsavedChanges(card) {
const draft = petCardDrafts.get(card?.id);
if (!card || !draft) return false;
return String(draft.pet_name ?? "") !== String(card.pet_name ?? "")
|| String(draft.action ?? "") !== String(card.action ?? "");
}
function petCardCanBind(card) {
return Boolean(card?.saved) && !petCardHasUnsavedChanges(card);
}
function linkedPetForCard(uidValue) {
const normalizedUid = normalizeCardUid(uidValue);
if (!normalizedUid) return null;
return petCards().find(
(card) => normalizeCardUid(card.nfc_id) === normalizedUid,
) || null;
}
function petCardStatus(card) {
const publisher = petCardSnapshot.publisher || {};
const active = publisher.active_pet_id === card.id;
if (!card.saved || petCardHasUnsavedChanges(card)) {
return { state: "editing", label: "待保存" };
}
if (active && publisher.state === "bursting") {
return { state: "bursting", label: "3 帧发送中" };
}
if (active && publisher.state === "burst_complete") {
return { state: "complete", label: "已发送 3 帧" };
}
if (active && publisher.state === "burst_failed") {
return { state: "error", label: "3 帧发送失败" };
}
if (card.nfc_id) return { state: "bound", label: "已绑定 NFC" };
return { state: "unbound", label: "待绑定" };
}
function applyPetCardSnapshot(value, { render = true } = {}) {
if (!value || typeof value !== "object") return false;
const parsedRevision = Number(value.revision ?? -1);
const nextRevision = Number.isFinite(parsedRevision) ? parsedRevision : -1;
if (nextRevision < petCardSnapshot.revision) return false;
const cards = Array.isArray(value.cards)
? value.cards.filter((card) => card && typeof card === "object")
: [];
const publisher = value.publisher && typeof value.publisher === "object"
? value.publisher
: {};
const next = {
revision: nextRevision,
cards,
publisher: {
state: String(publisher.state || "stopped"),
topic: String(publisher.topic || "n7/pet"),
burst_count: Number(publisher.burst_count || 3),
burst_window_ms: Number(publisher.burst_window_ms || 1000),
burst_attempt_count: Number(publisher.burst_attempt_count || 0),
burst_published_count: Number(publisher.burst_published_count || 0),
active_pet_id: publisher.active_pet_id || null,
active_nfc_id: publisher.active_nfc_id || null,
last_published_at: publisher.last_published_at || null,
last_delivery_status: publisher.last_delivery_status || null,
publish_count: Number(publisher.publish_count || 0),
last_error: publisher.last_error || null,
},
};
const cardsChanged = JSON.stringify(petCardSnapshot.cards) !== JSON.stringify(cards);
const publisherChanged = JSON.stringify(petCardSnapshot.publisher) !== JSON.stringify(next.publisher);
if (!cardsChanged && !publisherChanged && next.revision === petCardSnapshot.revision) {
return false;
}
petCardSnapshot = next;
if (selectedPetCardId && !cards.some((card) => card.id === selectedPetCardId)) {
selectedPetCardId = null;
}
if (render) {
renderNfcDialog();
if (cardsChanged) renderUnifiedExtensionView?.();
else updatePetPublisherIndicators();
}
return true;
}
async function hydratePetCards() {
applyPetCardSnapshot(await api("pets"));
}
function nextPetCardName() {
const used = new Set(petCards().map((card) => String(card.pet_name || "")));
let index = petCards().length + 1;
while (used.has(`宠物 ${index}`)) index += 1;
return `宠物 ${index}`;
}
async function createPetCard(button = null) {
if (button) button.disabled = true;
try {
const response = await api("pets", {
method: "POST",
body: JSON.stringify({
pet_name: nextPetCardName(),
action: "待设置",
}),
});
selectedPetCardId = response.card?.id || null;
applyPetCardSnapshot(response);
showToast("已添加宠物卡片,请编辑并保存后再绑定 NFC。", "success");
window.setTimeout(() => {
const card = $(`[data-pet-card-id="${selectedPetCardId}"]`);
card?.scrollIntoView({ behavior: "smooth", block: "nearest" });
$('[data-pet-field="pet_name"]', card)?.focus();
}, 0);
} catch (error) {
showToast(error.message, "error");
} finally {
if (button?.isConnected) button.disabled = false;
}
}
async function savePetCard(article, button = null) {
const cardId = article?.dataset.petCardId;
const petName = $('[data-pet-field="pet_name"]', article)?.value || "";
const action = $('[data-pet-field="action"]', article)?.value || "";
if (!cardId) return;
if (button) button.disabled = true;
try {
const response = await api(`pets/${encodeURIComponent(cardId)}`, {
method: "PUT",
body: JSON.stringify({ pet_name: petName, action }),
});
petCardDrafts.delete(cardId);
applyPetCardSnapshot(response);
showToast("宠物卡片已保存,可通过 NFC 设备授权继续绑定。", "success");
} catch (error) {
showToast(error.message, "error");
} finally {
if (button?.isConnected) button.disabled = false;
}
}
async function deletePetCard(article, button = null) {
const cardId = article?.dataset.petCardId;
const petName = $('[data-pet-field="pet_name"]', article)?.value || "该宠物";
if (!cardId || !window.confirm(`确认删除“${petName}”宠物卡片吗?`)) return;
if (button) button.disabled = true;
try {
const response = await api(`pets/${encodeURIComponent(cardId)}`, {
method: "DELETE",
});
petCardDrafts.delete(cardId);
applyPetCardSnapshot(response);
showToast("宠物卡片已删除。", "success");
} catch (error) {
showToast(error.message, "error");
if (button?.isConnected) button.disabled = false;
}
}
async function bindCurrentNfcToPet() {
const petId = $("#extensionNfcExistingPet")?.value || selectedPetCardId;
if (!petId) return;
const petCard = petCards().find((card) => card.id === petId);
if (!petCardCanBind(petCard)) {
showToast("请先在右侧宠物卡片中完成编辑并保存。", "error");
return;
}
const button = $("[data-nfc-bind-pet]");
if (button) button.disabled = true;
try {
const response = await api(`pets/${encodeURIComponent(petId)}/bind-current-nfc`, {
method: "POST",
body: "{}",
});
selectedPetCardId = petId;
applyPetCardSnapshot(response);
renderNfcDialog();
showToast(`NFC 已绑定宠物“${response.card?.pet_name || "宠物"}”。`, "success");
} catch (error) {
showToast(error.message, "error");
} finally {
if (button?.isConnected) button.disabled = false;
}
}
async function unbindPetCard(petId) {
if (!petId) return;
const button = $(`[data-nfc-unbind-pet="${petId}"]`);
if (button) button.disabled = true;
try {
const response = await api(`pets/${encodeURIComponent(petId)}/unbind`, {
method: "POST",
body: "{}",
});
applyPetCardSnapshot(response);
renderNfcDialog();
showToast("已解除宠物卡片的 NFC 绑定。", "success");
} catch (error) {
showToast(error.message, "error");
} finally {
if (button?.isConnected) button.disabled = false;
}
}
function renderNfcPetBinding(linkedPet) {
if (linkedPet) {
const status = petCardStatus(linkedPet);
return `<section class="v2-nfc-pet-binding" data-state="${escapeHtml(status.state)}">
<div><span><i data-lucide="paw-print"></i></span><div><small>已绑定宠物卡片</small><strong>${escapeHtml(linkedPet.pet_name)}</strong><p>${escapeHtml(linkedPet.action)} · 本次识别仅发送 3 帧</p></div></div>
<div><code>${escapeHtml(linkedPet.nfc_id)}</code><b>${escapeHtml(status.label)}</b><button type="button" data-nfc-unbind-pet="${escapeHtml(linkedPet.id)}">解绑 NFC</button></div>
</section>`;
}
if (!petCards().length) {
return `<div class="v2-nfc-no-devices v2-nfc-no-pets"><i data-lucide="paw-print"></i><div><strong>暂无宠物卡片</strong><span>请先返回右侧“宠物卡片”区域添加、编辑并保存卡片。</span></div></div>`;
}
const bindableCards = petCards().filter(petCardCanBind);
if (!bindableCards.length) {
return `<div class="v2-nfc-no-devices v2-nfc-no-pets"><i data-lucide="save"></i><div><strong>暂无可绑定的已保存卡片</strong><span>请先在右侧完成宠物名与动作编辑并点击保存,再打开此弹窗绑定。</span></div></div>`;
}
const selected = bindableCards.some((card) => card.id === selectedPetCardId)
? selectedPetCardId
: bindableCards[0].id;
return `<div class="v2-nfc-existing v2-nfc-pet-existing"><span>绑定宠物卡片</span><div><select id="extensionNfcExistingPet" data-nfc-pet-select>${bindableCards.map((card) => `<option value="${escapeHtml(card.id)}" ${card.id === selected ? "selected" : ""}>${escapeHtml(card.pet_name)} — ${escapeHtml(card.action)}${card.nfc_id ? ` — 当前已绑定 ${escapeHtml(card.nfc_id)}` : ""}</option>`).join("")}</select><button class="v2-extension-primary" type="button" data-nfc-bind-pet>关联宠物卡片</button></div><small>卡片 ID 由当前读卡器中的 NFC 自动写入,不能手动编辑;同一 NFC 只绑定一张宠物卡片。</small></div>`;
}
function renderPetCardRegion() {
const publisher = petCardSnapshot.publisher || {};
const cards = petCards();
return `<section class="v2-pet-card-region" aria-labelledby="v2PetCardTitle">
<header><div><span><i data-lucide="paw-print"></i></span><h4 id="v2PetCardTitle">宠物卡片</h4></div><button type="button" data-pet-add><i data-lucide="plus"></i><span>添加宠物</span></button></header>
<div class="v2-pet-card-list">
${cards.length ? cards.map((card) => {
const draft = petCardDrafts.get(card.id) || card;
const status = petCardStatus(card);
const activeError = publisher.active_pet_id === card.id
? String(publisher.last_error || "")
: "";
return `<article class="v2-pet-card" data-pet-card-id="${escapeHtml(card.id)}" data-pet-state="${escapeHtml(status.state)}">
<header><span><i data-lucide="paw-print"></i></span><strong>${escapeHtml(draft.pet_name || card.pet_name)}</strong><b data-pet-publish-state="${escapeHtml(card.id)}" data-state="${escapeHtml(status.state)}" title="${escapeHtml(activeError || status.label)}">${escapeHtml(status.label)}</b><button type="button" data-pet-delete aria-label="删除宠物卡片" title="删除宠物卡片"><i data-lucide="trash-2"></i></button></header>
<div class="v2-pet-card-fields">
<label><span>宠物名</span><input type="text" maxlength="64" value="${escapeHtml(draft.pet_name || "")}" data-pet-field="pet_name" autocomplete="off" /></label>
<label><span>宠物动作</span><textarea maxlength="160" rows="2" data-pet-field="action">${escapeHtml(draft.action || "")}</textarea></label>
<label class="v2-pet-nfc-field"><span>卡片 ID</span><div><output>${card.nfc_id ? escapeHtml(card.nfc_id) : "未绑定"}</output></div></label>
</div>
<footer><button type="button" data-pet-save><i data-lucide="save"></i><span>保存修改</span></button></footer>
</article>`;
}).join("") : `<div class="v2-pet-card-empty"><i data-lucide="paw-print"></i><strong>还没有宠物卡片</strong><button type="button" data-pet-add>添加第一张</button></div>`}
</div>
</section>`;
}
function updatePetPublisherIndicators() {
petCards().forEach((card) => {
const status = petCardStatus(card);
const badge = $(`[data-pet-publish-state="${card.id}"]`);
if (badge) {
badge.dataset.state = status.state;
badge.textContent = status.label;
badge.title = publisher.active_pet_id === card.id && publisher.last_error
? String(publisher.last_error)
: status.label;
}
const article = $(`[data-pet-card-id="${card.id}"]`);
if (article) article.dataset.petState = status.state;
});
}
function nfcReaderNote(reader = nfcSnapshot.reader) {
if (reader?.online) {
const firmware = reader.firmware ? ` · PN532 ${reader.firmware}` : "";
@@ -3320,6 +3606,7 @@
const reader = nfcSnapshot.reader || {};
const card = nfcSnapshot.card;
const linkedDevice = linkedDeviceForCard(card?.uid);
const linkedPet = linkedPetForCard(card?.uid);
const deviceGroups = infraredDeviceGroups().filter((group) => group.deviceId);
status.textContent = nfcReaderNote(reader);
badge.textContent = reader.online ? "READER READY" : "READER OFFLINE";
@@ -3330,11 +3617,21 @@
body.innerHTML = `<div class="v2-nfc-waiting offline"><span class="v2-nfc-reader-visual"><i data-lucide="scan-line"></i><b></b><b></b></span><div><strong>读卡器尚未就绪</strong><p>${escapeHtml(nfcReaderNote(reader))}</p></div></div>`;
} else if (!card?.uid) {
stage.dataset.state = "waiting";
body.innerHTML = `<div class="v2-nfc-waiting"><span class="v2-nfc-reader-visual"><i data-lucide="scan-line"></i><b></b><b></b></span><div><strong>请将 NFC 卡靠近读卡器</strong><p>让卡片在读卡区停留约 1 秒;识别后可将 UID 绑定到一个已配置的设备 ID。</p></div></div>`;
} else if (!linkedDevice) {
body.innerHTML = `<div class="v2-nfc-waiting"><span class="v2-nfc-reader-visual"><i data-lucide="scan-line"></i><b></b><b></b></span><div><strong>请将 NFC 卡靠近读卡器</strong><p>让卡片在读卡区停留约 1 秒;识别后可将 UID 绑定到宠物卡片或已配置的设备 ID。</p></div></div>`;
} else if (!linkedDevice && !linkedPet) {
stage.dataset.state = "new-card";
body.innerHTML = `<div class="v2-nfc-detected-heading"><span><i data-lucide="badge-plus"></i></span><div><small>检测到未绑定 NFC</small><strong>${escapeHtml(card.uid)}</strong><p>请选择设备 ID;控制对象名称仅作为调试辅助说明。</p></div></div>
body.innerHTML = `<div class="v2-nfc-detected-heading"><span><i data-lucide="badge-plus"></i></span><div><small>检测到未绑定 NFC</small><strong>${escapeHtml(card.uid)}</strong><p>请选择宠物卡片;也可继续关联已有红外设备 ID。</p></div></div>
${renderNfcPetBinding(null)}
${deviceGroups.length ? `<div class="v2-nfc-existing"><span>绑定对象(设备 ID)</span><div><select id="extensionNfcExistingDevice" data-nfc-device-select>${deviceGroups.map((group) => `<option value="${escapeHtml(group.deviceId)}">${escapeHtml(group.deviceId)} — ${escapeHtml(group.objectNames.join(" / ") || "无对象描述")} — ${group.commands.length} 条命令</option>`).join("")}</select><button class="v2-extension-primary" type="button" data-nfc-bind-existing>关联设备 ID</button></div><small>关联后,该设备的全部命令默认允许执行,可再逐项取消。</small></div>` : `<div class="v2-nfc-no-devices"><i data-lucide="info"></i><div><strong>暂无可绑定的设备 ID</strong><span>请先在 GSCU1B TCP 设置中为已学习红外槽位填写设备 ID。</span></div></div>`}`;
} else if (linkedPet && !linkedDevice) {
stage.dataset.state = "recognized";
body.innerHTML = `<div class="v2-nfc-recognized-heading">
<span><i data-lucide="paw-print"></i></span>
<div><small>NFC UID · ${escapeHtml(card.uid)}</small><strong>${escapeHtml(linkedPet.pet_name)}</strong><p>宠物动作:${escapeHtml(linkedPet.action)} · 本次识别在 1 秒内发送 3 帧 n7/pet</p></div>
<i data-lucide="badge-check"></i>
</div>
${renderNfcPetBinding(linkedPet)}
${deviceGroups.length ? `<div class="v2-nfc-existing"><span>继续关联红外设备 ID(可选)</span><div><select id="extensionNfcExistingDevice" data-nfc-device-select>${deviceGroups.map((group) => `<option value="${escapeHtml(group.deviceId)}">${escapeHtml(group.deviceId)} — ${escapeHtml(group.objectNames.join(" / ") || "无对象描述")} — ${group.commands.length} 条命令</option>`).join("")}</select><button class="v2-extension-primary" type="button" data-nfc-bind-existing>关联设备 ID</button></div><small>宠物消息与红外设备授权相互独立,不会覆盖宠物卡片内容。</small></div>` : ""}`;
} else {
const group = deviceGroups.find((item) => item.deviceId === linkedDevice.id);
const capabilities = configuredCapabilities(linkedDevice.id);
@@ -3346,6 +3643,7 @@
<div><small>NFC UID · ${escapeHtml(card.uid)}</small><strong>${escapeHtml(linkedDevice.id)}</strong><p>控制对象:${escapeHtml(group?.objectNames.join("、") || linkedDevice.name || "无辅助描述")} · ${enabledCount}/${capabilities.length} 条命令已授权</p></div>
<i data-lucide="badge-check"></i>
</div>
${renderNfcPetBinding(linkedPet)}
<div class="v2-nfc-capability-heading"><div><strong>允许此 NFC 执行的命令</strong></div><span>${enabledCount}/${capabilities.length} 已选择</span></div>
${capabilities.length ? `<label class="v2-nfc-select-all"><input type="checkbox" data-nfc-select-all ${allEnabled ? "checked" : ""} /><span><strong>全选</strong><small>统一允许或禁止当前设备 ID 下的全部命令</small></span></label><div class="v2-nfc-command-list">${capabilities.map((capability) => `<label class="v2-nfc-command-option"><input type="checkbox" data-nfc-capability-enabled="${escapeHtml(capability.id)}" ${capability.enabled !== false ? "checked" : ""} /><span><strong>${escapeHtml(capability.name)}</strong><small>IR ${String(capability.slot).padStart(3, "0")} · ${escapeHtml((capability.aliases || []).join("、"))}</small></span></label>`).join("")}</div>` : `<div class="v2-nfc-no-devices"><i data-lucide="triangle-alert"></i><div><strong>该设备 ID 暂无有效命令</strong><span>槽位可能已删除或改绑到其他设备 ID,请重新检查红外设置。</span></div></div>`}
<div class="v2-nfc-recognized-actions"><span>复选框状态会持久化为自然语言执行白名单。</span><button type="button" data-nfc-unbind>解绑 NFC</button></div>`;
@@ -3363,7 +3661,7 @@
<header><span><i data-lucide="scan-line"></i></span><div><h3>NFC 设备绑定</h3><p id="extensionNfcReaderStatus">正在检查读卡器</p></div><b id="extensionNfcReaderBadge" data-state="offline">READER CHECK</b></header>
<div class="v2-nfc-body" id="extensionNfcBody"></div>
</section>
<footer class="v2-nfc-footnote"><i data-lucide="shield-check"></i><span>NFC UID、设备 ID 与命令授权同步到本机白名单;红外波形仍保存在 GSCU1B 物理槽位。</span></footer>`;
<footer class="v2-nfc-footnote"><i data-lucide="shield-check"></i><span>宠物卡片与 NFC UID 由后端持久化;每次重新识别只在 1 秒内发送 3 帧 n7/pet,随后保持静默。设备 ID 与命令授权逻辑保持不变。</span></footer>`;
document.body.append(dialog);
}
@@ -3446,6 +3744,7 @@
function applyNfcSnapshot(value, { announce = true } = {}) {
if (!value || typeof value !== "object") return;
if (value.pet_cards) applyPetCardSnapshot(value.pet_cards);
const revision = Number(value.revision ?? -1);
if (revision === nfcSnapshot.revision) return;
if (Array.isArray(value.legacy_profiles) && value.legacy_profiles.length) {
@@ -3464,13 +3763,25 @@
const currentUid = nfcSnapshot.card?.uid || null;
if (currentUid && currentUid !== previousUid) {
const device = linkedDeviceForCard(currentUid);
const pet = linkedPetForCard(currentUid);
if (device) {
state.selectedDeviceByType[device.type] = device.id;
saveState();
if (announce) showToast(`已识别设备 ID“${device.id}”。`, "success");
if (announce) {
showToast(
pet
? `已识别宠物“${pet.pet_name}”和设备 ID“${device.id}”。`
: `已识别设备 ID“${device.id}”。`,
"success",
);
}
void syncPeripheralCatalog(device);
} else if (pet) {
if (announce) {
showToast(`已识别宠物“${pet.pet_name}”,将在 1 秒内发送 3 帧 n7/pet。`, "success");
}
} else if (announce) {
showToast("检测到未绑定 NFC,请选择一个设备 ID。", "success");
showToast("检测到未绑定 NFC,请选择宠物卡片或设备 ID。", "success");
}
}
renderNfcDialog();
@@ -3510,8 +3821,32 @@
function bindUnifiedNfcEvents(mount) {
mount.addEventListener("click", (event) => {
if (event.target.closest("[data-open-nfc-binding]")) openNfcDialog();
if (event.target.closest("[data-open-gscu-tcp]")) openGscuTcpDialog();
const addPet = event.target.closest("[data-pet-add]");
const petArticle = event.target.closest("[data-pet-card-id]");
const savePet = event.target.closest("[data-pet-save]");
const deletePet = event.target.closest("[data-pet-delete]");
if (addPet) void createPetCard(addPet);
else if (savePet) void savePetCard(petArticle, savePet);
else if (deletePet) void deletePetCard(petArticle, deletePet);
else if (event.target.closest("[data-open-nfc-binding]")) openNfcDialog();
else if (event.target.closest("[data-open-gscu-tcp]")) openGscuTcpDialog();
});
mount.addEventListener("input", (event) => {
const field = event.target.closest("[data-pet-field]");
const article = field?.closest("[data-pet-card-id]");
if (!field || !article) return;
const current = petCardDrafts.get(article.dataset.petCardId)
|| petCards().find((card) => card.id === article.dataset.petCardId)
|| {};
petCardDrafts.set(article.dataset.petCardId, {
...current,
[field.dataset.petField]: field.value,
});
const title = $("header strong", article);
if (field.dataset.petField === "pet_name" && title) {
title.textContent = field.value.trim() || "未命名宠物";
}
updatePetPublisherIndicators();
});
bindGscuTcpDialogEvents();
const dialog = $("#extensionNfcDialog");
@@ -3519,8 +3854,15 @@
if (event.target.closest("[data-close-nfc-dialog]")) closeDialog("extensionNfcDialog");
if (event.target.closest("[data-nfc-bind-existing]")) bindCurrentNfcToExisting();
if (event.target.closest("[data-nfc-unbind]")) unbindCurrentNfc();
if (event.target.closest("[data-nfc-bind-pet]")) void bindCurrentNfcToPet();
const unbindPet = event.target.closest("[data-nfc-unbind-pet]");
if (unbindPet) void unbindPetCard(unbindPet.dataset.nfcUnbindPet);
const addPet = event.target.closest("[data-pet-add]");
if (addPet) void createPetCard(addPet);
});
dialog.addEventListener("change", (event) => {
const petSelect = event.target.closest("[data-nfc-pet-select]");
if (petSelect) selectedPetCardId = petSelect.value;
const capability = event.target.closest("[data-nfc-capability-enabled]");
if (capability) {
updateNfcCapabilityAuthorization(
@@ -3588,6 +3930,11 @@
void migrateLegacyProfiles(embeddedNfc.legacy_bridge_addresses);
}
}
if (health.pet_cards) applyPetCardSnapshot(health.pet_cards, { render: false });
if (document.activeElement?.closest?.("#vehicleExtensionSync [data-pet-field]")) {
updatePetPublisherIndicators();
return;
}
const physicalConnected = health.transport !== "simulation"
&& Boolean(health.connected || health.peer_connected);
const listening = Boolean(health.listening);
@@ -3605,6 +3952,7 @@
const nfcCard = nfcSnapshot.card;
const sensedCardUid = normalizeCardUid(nfcCard?.uid);
const nfcDevice = linkedDeviceForCard(sensedCardUid);
const nfcPet = linkedPetForCard(sensedCardUid);
const activeCatalog = sensedCardUid
? mergedCatalog.filter((item) => (
normalizeCardUid(item.card_uid) === sensedCardUid
@@ -3630,8 +3978,16 @@
const nfcCapabilities = nfcDevice
? configuredCapabilities(nfcDevice.id).filter((item) => item.enabled !== false)
: [];
const statusLabel = devices.length
? "已感应 NFC 外设"
const statusLabel = nfcPet
? petCardSnapshot.publisher?.state === "bursting"
? "宠物消息发送中"
: petCardSnapshot.publisher?.state === "burst_complete"
? "宠物消息已发送"
: petCardSnapshot.publisher?.state === "burst_failed"
? "宠物消息发送失败"
: "已感应宠物 NFC"
: devices.length
? "已感应 NFC 外设"
: sensedCardUid
? "当前 NFC 暂无已授权命令"
: nfcReader.online
@@ -3650,33 +4006,38 @@
const visibleLastAction = devices.length ? lastAction : null;
const nfcState = !nfcReader.online
? "offline"
: nfcDevice
: nfcDevice || nfcPet
? "recognized"
: nfcCard?.uid
? "new-card"
: "waiting";
const nfcTitle = nfcDevice
? nfcDevice.id
const nfcTitle = nfcPet
? `宠物 · ${nfcPet.pet_name}`
: nfcDevice
? nfcDevice.id
: nfcCard?.uid
? "检测到未绑定 NFC"
: nfcReader.online
? "等待 NFC 卡靠近"
: "NFC 读卡器未连接";
const nfcNote = nfcDevice
? `${nfcCapabilities.length} 条命令已授权 · ${nfcCard.uid}`
const nfcNote = nfcPet
? `${nfcPet.action} · ${petCardSnapshot.publisher?.topic || "n7/pet"} · 3 帧 / 1 秒`
: nfcDevice
? `${nfcCapabilities.length} 条命令已授权 · ${nfcCard.uid}`
: nfcCard?.uid
? `${nfcCard.uid} · 点击关联设备 ID`
: nfcReaderNote(nfcReader);
mount.dataset.hasDevices = String(devices.length > 0);
mount.dataset.hasPets = String(petCards().length > 0);
mount.innerHTML = `
<header class="v2-extension-sync-heading">
<div><h3>外设与扩展能力</h3><button class="v2-extension-nfc-launch" type="button" data-open-nfc-binding><i data-lucide="scan-line"></i><span>NFC 设备绑定</span></button></div>
<span data-state="${devices.length ? "online" : nfcReader.online ? "listening" : "offline"}"><i></i>${statusLabel}</span>
<span data-state="${nfcPet || devices.length ? "online" : nfcReader.online ? "listening" : "offline"}"><i></i>${statusLabel}</span>
</header>
<section class="v2-nfc-inline" data-state="${nfcState}">
<span class="v2-nfc-inline-icon"><i data-lucide="${nfcDevice ? "badge-check" : "scan-line"}"></i><b></b></span>
<span class="v2-nfc-inline-icon"><i data-lucide="${nfcPet ? "paw-print" : nfcDevice ? "badge-check" : "scan-line"}"></i><b></b></span>
<div><strong>${escapeHtml(nfcTitle)}</strong><p>${escapeHtml(nfcNote)}</p>${nfcCapabilities.length ? `<div>${nfcCapabilities.slice(0, 6).map((capability) => `<i>${escapeHtml(capability.name)}</i>`).join("")}</div>` : ""}</div>
<button type="button" data-open-nfc-binding>${nfcDevice ? "查看授权" : nfcCard?.uid ? "关联设备" : "开始感应"}</button>
<button type="button" data-open-nfc-binding>${nfcPet ? "查看宠物" : nfcDevice ? "查看授权" : nfcCard?.uid ? "关联卡片" : "开始感应"}</button>
</section>
<div class="v2-extension-gateway">
<span><i data-lucide="usb"></i></span>
@@ -3684,6 +4045,7 @@
<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>
${renderPetCardRegion()}
<div class="v2-extension-device-list">
${devices.length ? devices.map((device) => `
<article class="v2-extension-synced-device">
@@ -3691,7 +4053,7 @@
<div>${device.actions.slice(0, 8).map((action) => `<span>${escapeHtml(action)}</span>`).join("")}</div>
</article>`).join("") : `
<div class="v2-extension-empty">
<i data-lucide="${sensedCardUid ? "badge-alert" : "scan-line"}"></i><div><strong>${sensedCardUid ? "当前 NFC 暂无已授权命令" : "当前未感应到外设"}</strong><span>${sensedCardUid ? "请检查设备 ID 绑定及命令复选框" : "将已绑定设备 ID 的 NFC 卡靠近读卡器后显示命令"}</span></div>
<i data-lucide="${nfcPet ? "paw-print" : sensedCardUid ? "badge-alert" : "scan-line"}"></i><div><strong>${nfcPet ? "当前 NFC 已绑定宠物卡片" : sensedCardUid ? "当前 NFC 暂无已授权命令" : "当前未感应到外设"}</strong><span>${nfcPet ? "本次识别完成 3 帧后保持静默,移开再靠近才会再次发送" : sensedCardUid ? "请检查设备 ID 绑定及命令复选框" : "将已绑定设备 ID 的 NFC 卡靠近读卡器后显示命令"}</span></div>
</div>`}
</div>
<footer class="v2-extension-linkage" data-active="${String(Boolean(visibleLastAction))}">
@@ -3718,6 +4080,9 @@
render();
renderNfcDialog();
startNfcPolling();
void hydratePetCards().catch((error) => {
console.warn("恢复后端宠物卡片失败。", error);
});
void hydratePeripheralCatalog()
.catch((error) => {
console.warn("恢复后端红外能力目录失败,将保留浏览器本地目录。", error);
+5 -4
View File
@@ -1,4 +1,4 @@
const CACHE_VERSION = "cognitive-agent-pad-v290";
const CACHE_VERSION = "cognitive-agent-pad-v298";
const SHELL_CACHE = `${CACHE_VERSION}-shell`;
const RUNTIME_CACHE = `${CACHE_VERSION}-runtime`;
const APP_SHELL = [
@@ -6,13 +6,13 @@ const APP_SHELL = [
"/index.html",
"/styles.css?v=dual-seat-23",
"/network.js?v=android-pad-2",
"/app.js?v=dual-seat-135",
"/app.js?v=dual-seat-136",
"/gesture-control.js?v=android-pad-9",
"/pwa.js?v=android-pad-3",
"/console-v2.css?v=delivery-state-69",
"/console-v2.js?v=local-first-70",
"/self-extension.css?v=gscu-tcp-43",
"/self-extension.js?v=gscu-tcp-44",
"/self-extension.css?v=pet-acrylic-48",
"/self-extension.js?v=pet-acrylic-48",
"/speech-input.css?v=sensevoice-7",
"/speech-input.js?v=sensevoice-13",
"/speech-audio-worklet.js?v=sensevoice-5",
@@ -21,6 +21,7 @@ const APP_SHELL = [
"/energy-monitor.css?v=energy-monitor-4",
"/energy-monitor.js?v=energy-monitor-4",
"/ui-consistency.css?v=spatial-ui-4",
"/codepen-glass-theme.css?v=creative-cloud-1",
"/vue-ui.js?v=vue-presentation-3",
"/assets/pets/self-extension-cat.png?v=3",
"/assets/pets/self-extension-cat-entrance.png?v=3",
@@ -0,0 +1,507 @@
"""Durable pet cards driven by the currently present NFC tag."""
from __future__ import annotations
import re
import sqlite3
import threading
import time
import uuid
from collections.abc import Callable, Mapping
from contextlib import closing
from copy import deepcopy
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
PET_MQTT_TOPIC = "n7/pet"
PET_BURST_COUNT = 3
PET_BURST_WINDOW_S = 1.0
DEFAULT_PET_CARD_DB_PATH = (
Path(__file__).resolve().parents[3] / "artifacts" / "server" / "pet_cards.sqlite3"
)
MAX_PET_CARDS = 64
MAX_PET_NAME_LENGTH = 64
MAX_PET_ACTION_LENGTH = 160
_PET_ID_PATTERN = re.compile(r"^pet-[0-9a-f]{32}$")
_NFC_ID_PATTERN = re.compile(r"^[0-9A-F]{4,64}$")
def normalize_nfc_id(value: Any) -> str:
"""Return the canonical uppercase hexadecimal UID used by the NFC reader."""
raw_value = str(value or "").strip()
if not raw_value or re.search(r"[^0-9a-f\s:-]", raw_value, flags=re.IGNORECASE):
return ""
normalized = re.sub(r"[\s:-]", "", raw_value).upper()
return normalized if _NFC_ID_PATTERN.fullmatch(normalized) else ""
def _required_text(value: Any, *, field: str, limit: int) -> str:
text = str(value or "").strip()
if not text:
raise ValueError(f"{field} 不能为空。")
if len(text) > limit:
raise ValueError(f"{field} 不能超过 {limit} 个字符。")
return text
def _timestamp() -> str:
return datetime.now(UTC).isoformat(timespec="milliseconds").replace("+00:00", "Z")
class PetCardService:
"""Persist pet cards and publish one bounded burst per NFC detection."""
def __init__(
self,
*,
nfc_provider: Callable[[], Mapping[str, Any]] | None = None,
publisher: Callable[[dict[str, str]], Mapping[str, Any]] | None = None,
database_path: str | Path | None = None,
burst_count: int = PET_BURST_COUNT,
burst_window_s: float = PET_BURST_WINDOW_S,
) -> None:
if isinstance(burst_count, bool) or int(burst_count) != burst_count:
raise ValueError("burst_count must be an integer")
if burst_count <= 0:
raise ValueError("burst_count must be greater than zero")
if burst_window_s <= 0:
raise ValueError("burst_window_s must be greater than zero")
self.database_path = Path(
database_path or DEFAULT_PET_CARD_DB_PATH
).expanduser()
self.burst_count = int(burst_count)
self.burst_window_s = float(burst_window_s)
self.burst_interval_s = self.burst_window_s / self.burst_count
self._nfc_provider = nfc_provider or (lambda: {})
self._publisher = publisher
self._lock = threading.RLock()
self._stop = threading.Event()
self._wakeup = threading.Event()
self._thread: threading.Thread | None = None
self._revision = 0
self._cards: dict[str, dict[str, Any]] = {}
self._publisher_status: dict[str, Any] = {
"state": "stopped",
"topic": PET_MQTT_TOPIC,
"burst_count": self.burst_count,
"burst_window_ms": round(self.burst_window_s * 1_000),
"burst_attempt_count": 0,
"burst_published_count": 0,
"active_pet_id": None,
"active_nfc_id": None,
"last_burst_started_at": None,
"last_burst_completed_at": None,
"last_published_at": None,
"last_delivery_status": None,
"last_message_id": None,
"publish_count": 0,
"last_error": None,
}
self._initialize()
self._load()
def _connect(self) -> sqlite3.Connection:
connection = sqlite3.connect(self.database_path, timeout=5.0)
connection.row_factory = sqlite3.Row
return connection
def _initialize(self) -> None:
self.database_path.parent.mkdir(parents=True, exist_ok=True)
with closing(self._connect()) as connection, connection:
connection.execute(
"""
CREATE TABLE IF NOT EXISTS pet_cards (
id TEXT PRIMARY KEY,
pet_name TEXT NOT NULL,
action TEXT NOT NULL,
nfc_id TEXT UNIQUE,
is_saved INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)
"""
)
columns = {
str(row[1])
for row in connection.execute("PRAGMA table_info(pet_cards)").fetchall()
}
if "is_saved" not in columns:
# Cards created by an earlier release were already persisted through
# the explicit Save action, so preserve their binding eligibility.
connection.execute(
"ALTER TABLE pet_cards "
"ADD COLUMN is_saved INTEGER NOT NULL DEFAULT 1"
)
def _load(self) -> None:
with closing(self._connect()) as connection:
rows = connection.execute(
"SELECT id, pet_name, action, nfc_id, is_saved, created_at, updated_at "
"FROM pet_cards ORDER BY created_at, id"
).fetchall()
with self._lock:
self._cards = {
str(row["id"]): {
"id": str(row["id"]),
"pet_name": str(row["pet_name"]),
"action": str(row["action"]),
"nfc_id": str(row["nfc_id"]) if row["nfc_id"] else None,
"saved": bool(row["is_saved"]),
"created_at": str(row["created_at"]),
"updated_at": str(row["updated_at"]),
}
for row in rows
if _PET_ID_PATTERN.fullmatch(str(row["id"]))
}
@staticmethod
def _validate_pet_id(pet_id: Any) -> str:
value = str(pet_id or "").strip()
if not _PET_ID_PATTERN.fullmatch(value):
raise ValueError("宠物卡片 ID 格式无效。")
return value
def _require_card_locked(self, pet_id: str) -> dict[str, Any]:
card = self._cards.get(pet_id)
if card is None:
raise KeyError(pet_id)
return card
def start(self) -> None:
if self._thread and self._thread.is_alive():
return
self._stop.clear()
self._wakeup.clear()
self._set_publisher_status(
state="waiting_for_nfc",
active_pet_id=None,
active_nfc_id=None,
last_error=None,
)
self._thread = threading.Thread(
target=self._run,
name="n7-pet-mqtt-publisher",
daemon=True,
)
self._thread.start()
def close(self) -> None:
self._stop.set()
self._wakeup.set()
thread = self._thread
if thread and thread.is_alive():
thread.join(timeout=2.0)
self._thread = None
self._set_publisher_status(
state="stopped",
active_pet_id=None,
active_nfc_id=None,
)
def snapshot(self) -> dict[str, Any]:
with self._lock:
cards = sorted(
(deepcopy(card) for card in self._cards.values()),
key=lambda card: (card["created_at"], card["id"]),
)
return {
"revision": self._revision,
"cards": cards,
"count": len(cards),
"publisher": deepcopy(self._publisher_status),
}
def create_card(self, pet_name: Any, action: Any) -> dict[str, Any]:
normalized_name = _required_text(
pet_name,
field="宠物名",
limit=MAX_PET_NAME_LENGTH,
)
normalized_action = _required_text(
action,
field="宠物动作",
limit=MAX_PET_ACTION_LENGTH,
)
now = _timestamp()
card = {
"id": f"pet-{uuid.uuid4().hex}",
"pet_name": normalized_name,
"action": normalized_action,
"nfc_id": None,
"saved": False,
"created_at": now,
"updated_at": now,
}
with self._lock:
if len(self._cards) >= MAX_PET_CARDS:
raise ValueError(f"宠物卡片最多只能创建 {MAX_PET_CARDS} 张。")
with closing(self._connect()) as connection, connection:
connection.execute(
"INSERT INTO pet_cards "
"(id, pet_name, action, nfc_id, is_saved, created_at, updated_at) "
"VALUES (?, ?, ?, NULL, 0, ?, ?)",
(
card["id"],
card["pet_name"],
card["action"],
card["created_at"],
card["updated_at"],
),
)
self._cards[card["id"]] = card
self._revision += 1
self._wakeup.set()
return deepcopy(card)
def update_card(
self,
pet_id: Any,
*,
pet_name: Any,
action: Any,
) -> dict[str, Any]:
normalized_id = self._validate_pet_id(pet_id)
normalized_name = _required_text(
pet_name,
field="宠物名",
limit=MAX_PET_NAME_LENGTH,
)
normalized_action = _required_text(
action,
field="宠物动作",
limit=MAX_PET_ACTION_LENGTH,
)
now = _timestamp()
with self._lock:
card = self._require_card_locked(normalized_id)
if (
card["pet_name"] == normalized_name
and card["action"] == normalized_action
and card["saved"]
):
return deepcopy(card)
with closing(self._connect()) as connection, connection:
connection.execute(
"UPDATE pet_cards SET pet_name = ?, action = ?, is_saved = 1, "
"updated_at = ? "
"WHERE id = ?",
(normalized_name, normalized_action, now, normalized_id),
)
card.update(
pet_name=normalized_name,
action=normalized_action,
saved=True,
updated_at=now,
)
self._revision += 1
result = deepcopy(card)
self._wakeup.set()
return result
def bind_current_nfc(self, pet_id: Any) -> dict[str, Any]:
normalized_id = self._validate_pet_id(pet_id)
snapshot = self._nfc_provider()
raw_card = snapshot.get("card") if isinstance(snapshot, Mapping) else None
raw_uid = raw_card.get("uid") if isinstance(raw_card, Mapping) else None
nfc_id = normalize_nfc_id(raw_uid)
if not nfc_id:
raise ValueError("当前未识别到有效 NFC 卡片,无法完成绑定。")
now = _timestamp()
with self._lock:
card = self._require_card_locked(normalized_id)
if not card["saved"]:
raise ValueError("请先保存宠物卡片,再绑定 NFC。")
with closing(self._connect()) as connection, connection:
connection.execute(
"UPDATE pet_cards SET nfc_id = NULL, updated_at = ? "
"WHERE nfc_id = ? AND id <> ?",
(now, nfc_id, normalized_id),
)
connection.execute(
"UPDATE pet_cards SET nfc_id = ?, updated_at = ? WHERE id = ?",
(nfc_id, now, normalized_id),
)
for other in self._cards.values():
if other["id"] != normalized_id and other.get("nfc_id") == nfc_id:
other["nfc_id"] = None
other["updated_at"] = now
card["nfc_id"] = nfc_id
card["updated_at"] = now
self._revision += 1
result = deepcopy(card)
self._wakeup.set()
return result
def unbind_card(self, pet_id: Any) -> dict[str, Any]:
normalized_id = self._validate_pet_id(pet_id)
now = _timestamp()
with self._lock:
card = self._require_card_locked(normalized_id)
if card.get("nfc_id") is None:
return deepcopy(card)
with closing(self._connect()) as connection, connection:
connection.execute(
"UPDATE pet_cards SET nfc_id = NULL, updated_at = ? WHERE id = ?",
(now, normalized_id),
)
card["nfc_id"] = None
card["updated_at"] = now
self._revision += 1
result = deepcopy(card)
self._wakeup.set()
return result
def delete_card(self, pet_id: Any) -> dict[str, Any]:
normalized_id = self._validate_pet_id(pet_id)
with self._lock:
card = self._require_card_locked(normalized_id)
with closing(self._connect()) as connection, connection:
connection.execute("DELETE FROM pet_cards WHERE id = ?", (normalized_id,))
del self._cards[normalized_id]
self._revision += 1
result = deepcopy(card)
self._wakeup.set()
return result
def _set_publisher_status(self, **changes: Any) -> None:
with self._lock:
next_status = {**self._publisher_status, **changes}
if next_status == self._publisher_status:
return
self._publisher_status = next_status
self._revision += 1
def _active_card(self) -> tuple[str, dict[str, Any] | None]:
try:
snapshot = self._nfc_provider()
except Exception: # noqa: BLE001 - NFC failures become publisher status
return "", None
raw_card = snapshot.get("card") if isinstance(snapshot, Mapping) else None
raw_uid = raw_card.get("uid") if isinstance(raw_card, Mapping) else None
nfc_id = normalize_nfc_id(raw_uid)
if not nfc_id:
return "", None
with self._lock:
card = next(
(
deepcopy(candidate)
for candidate in self._cards.values()
if candidate.get("nfc_id") == nfc_id
),
None,
)
return nfc_id, card
def _run(self) -> None:
active_identity: tuple[str, str] | None = None
burst_started_at = 0.0
burst_attempt_count = 0
burst_published_count = 0
burst_error: str | None = None
next_publish_at = 0.0
while not self._stop.is_set():
nfc_id, card = self._active_card()
identity = (str(card["id"]), nfc_id) if card else None
now = time.monotonic()
if identity != active_identity:
active_identity = identity
burst_attempt_count = 0
burst_published_count = 0
burst_error = None
if card is not None:
burst_started_at = now
next_publish_at = now
self._set_publisher_status(
state="bursting",
active_pet_id=card["id"],
active_nfc_id=nfc_id,
burst_attempt_count=0,
burst_published_count=0,
last_burst_started_at=_timestamp(),
last_burst_completed_at=None,
last_delivery_status=None,
last_error=None,
)
else:
self._set_publisher_status(
state="waiting_for_nfc",
active_pet_id=None,
active_nfc_id=nfc_id or None,
burst_attempt_count=0,
burst_published_count=0,
last_error=None,
)
if (
card is not None
and burst_attempt_count < self.burst_count
and now >= next_publish_at
):
burst_attempt_count += 1
payload = {
"pet_name": str(card["pet_name"]),
"nfc_id": nfc_id,
"action": str(card["action"]),
}
result: Mapping[str, Any] | None = None
published_at: str | None = None
publish_count: int | None = None
try:
if self._publisher is None:
raise RuntimeError("宠物消息 MQTT 发布器未配置。")
result = self._publisher(payload)
except Exception as exc: # noqa: BLE001 - finish the bounded burst safely
burst_error = str(exc)[:240]
else:
burst_published_count += 1
published_at = _timestamp()
with self._lock:
publish_count = int(self._publisher_status["publish_count"]) + 1
burst_finished = burst_attempt_count >= self.burst_count
burst_succeeded = burst_published_count == self.burst_count
if burst_finished:
state = "burst_complete" if burst_succeeded else "burst_failed"
delivery_status = (
str(result.get("delivery_status") or "queued")
if burst_succeeded
else "partial" if burst_published_count else "failed"
)
else:
state = "bursting"
delivery_status = (
str(result.get("delivery_status") or "queued")
if result is not None
else "failed"
)
changes: dict[str, Any] = {
"state": state,
"active_pet_id": card["id"],
"active_nfc_id": nfc_id,
"burst_attempt_count": burst_attempt_count,
"burst_published_count": burst_published_count,
"last_delivery_status": delivery_status,
"last_error": burst_error,
}
if result is not None and published_at is not None:
changes.update(
last_published_at=published_at,
last_message_id=result.get("message_id"),
publish_count=publish_count,
)
if burst_finished:
changes["last_burst_completed_at"] = _timestamp()
self._set_publisher_status(**changes)
if not burst_finished:
next_publish_at = (
burst_started_at
+ burst_attempt_count * self.burst_interval_s
)
timeout = 0.2
if card is not None and burst_attempt_count < self.burst_count:
timeout = max(0.01, min(timeout, next_publish_at - time.monotonic()))
self._wakeup.wait(timeout)
self._wakeup.clear()
@@ -1333,6 +1333,11 @@ class SeatDemoService:
self._vehicle_state_monitor.close()
self._vehicle_mqtt_transport.close()
def publish_pet_message(self, payload: dict[str, str]) -> dict[str, Any]:
"""Publish one NFC-authorized pet card through the shared vehicle broker."""
return self._vehicle_mqtt_transport.publish_pet_message(payload)
def _synchronize_vehicle_state_once(
self,
conversation_id: str,
@@ -19,7 +19,7 @@ import threading
import time
import uuid
from collections import deque
from collections.abc import Callable
from collections.abc import Callable, Mapping
from copy import deepcopy
from datetime import UTC, datetime
from typing import Any
@@ -36,6 +36,7 @@ from cognitive_agent.infrastructure.can.gateway import (
from cognitive_agent.infrastructure.can.hardware import VehicleMqttSettings
MQTT_CAN_SCHEMA = "n7.can.frame.v1"
MQTT_PET_TOPIC = "n7/pet"
MQTT_POLL_INTERVAL_MS = 500
WIRELESS_MQTT_CH1_SOURCE = "wireless_mqtt_ch1"
MQTT_VALID_FD_PAYLOAD_LENGTHS = frozenset(
@@ -498,6 +499,31 @@ def mqtt_control_payload(
)
def mqtt_pet_payload(payload: Mapping[str, Any]) -> str:
"""Serialize the only application-level MQTT message emitted by N7Web."""
if set(payload) != {"pet_name", "nfc_id", "action"}:
raise CanGatewayError(
"pet MQTT payload requires exactly pet_name, nfc_id and action"
)
pet_name = str(payload["pet_name"]).strip()
nfc_id = str(payload["nfc_id"]).strip().upper()
action = str(payload["action"]).strip()
if not pet_name or len(pet_name) > 64:
raise CanGatewayError("pet_name must contain between 1 and 64 characters")
if not action or len(action) > 160:
raise CanGatewayError("pet action must contain between 1 and 160 characters")
if not 4 <= len(nfc_id) <= 64 or any(
character not in "0123456789ABCDEF" for character in nfc_id
):
raise CanGatewayError("pet nfc_id must be an uppercase hexadecimal NFC UID")
return json.dumps(
{"pet_name": pet_name, "nfc_id": nfc_id, "action": action},
ensure_ascii=False,
separators=(",", ":"),
)
def _validate_native_seat_motion_control(data: bytes) -> None:
"""Validate the firmware-native 0x300 semantic runtime subset.
@@ -970,6 +996,33 @@ class MqttCanTransport:
"payload": json.loads(raw_payload),
}
def publish_pet_message(self, payload: Mapping[str, Any]) -> dict[str, Any]:
"""Queue one validated pet-presence message on the fixed ``n7/pet`` topic."""
raw_payload = mqtt_pet_payload(payload)
with self._condition:
client = self._client
state = self._state
if client is None or state != "online":
raise CanGatewayError("pet MQTT publisher is not connected")
result = client.publish(
MQTT_PET_TOPIC,
raw_payload,
qos=self.settings.qos,
retain=False,
)
if int(getattr(result, "rc", 0)) != 0:
raise CanGatewayError(f"pet MQTT publish failed with code {result.rc}")
is_published = getattr(result, "is_published", None)
broker_confirmed = bool(is_published()) if callable(is_published) else False
return {
"topic": MQTT_PET_TOPIC,
"qos": self.settings.qos,
"retain": False,
"delivery_status": "published" if broker_confirmed else "queued",
"message_id": getattr(result, "mid", None),
}
def _receive_after(
self,
cursor: int,
+121 -3
View File
@@ -48,6 +48,7 @@ from cognitive_agent.application.mode_profile_intent import ( # noqa: E402
normalize_mode_profile_name,
parse_mode_profile_update,
)
from cognitive_agent.application.pet_cards import PetCardService # noqa: E402
from cognitive_agent.application.seat_service import ( # noqa: E402
LOCAL_CONTROL_BUDGET_MS,
SeatDemoService,
@@ -217,6 +218,10 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
gesture = GestureCapabilityBridge(REPOSITORY_ROOT)
extension = SelfExtensionService()
nfc = NfcReaderService()
pets = PetCardService(
nfc_provider=nfc.snapshot,
publisher=service.publish_pet_message,
)
calibration = CalibrationService(
REPOSITORY_ROOT / "artifacts" / "tmp" / "self-calibration",
repository_root=REPOSITORY_ROOT,
@@ -393,7 +398,16 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
self._json(self._extension_health())
return
if path == "/api/extension/nfc/status":
self._json({"success": True, **self.nfc.snapshot()})
self._json(
{
"success": True,
**self.nfc.snapshot(),
"pet_cards": self.pets.snapshot(),
}
)
return
if path == "/api/extension/pets":
self._json({"success": True, **self.pets.snapshot()})
return
if path == "/api/extension/gscu/slots":
try:
@@ -2488,6 +2502,41 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
return
if not self._require_same_origin() or not self._require_json_content_type():
return
pet_match = re.fullmatch(
r"/api/extension/pets/(?P<pet_id>pet-[0-9a-f]{32})",
path,
)
if pet_match is not None:
try:
payload = self._read_json_body(max_bytes=4096)
unexpected = set(payload) - {"pet_name", "action"}
if unexpected:
if "nfc_id" in unexpected:
raise ValueError(
"nfc_id 只能通过当前已识别的 NFC 卡片完成绑定。"
)
raise ValueError("宠物卡片包含不支持的字段。")
card = self.pets.update_card(
pet_match.group("pet_id"),
pet_name=payload.get("pet_name"),
action=payload.get("action"),
)
self._json(
{"success": True, "card": card, **self.pets.snapshot()},
HTTPStatus.OK,
)
except KeyError:
self._json(
{"success": False, "error": "not_found", "message": "未找到宠物卡片。"},
HTTPStatus.NOT_FOUND,
)
except (ValueError, json.JSONDecodeError) as exc:
self._json(
{"success": False, "error": "invalid_request", "message": str(exc)},
HTTPStatus.BAD_REQUEST,
)
return
match = re.fullmatch(r"/api/extension/gscu/slots/(?P<slot>\d+)", path)
if match is None:
self._json({"error": "not_found"}, HTTPStatus.NOT_FOUND)
@@ -2524,6 +2573,28 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
HTTPStatus.BAD_REQUEST,
)
return
pet_match = re.fullmatch(
r"/api/extension/pets/(?P<pet_id>pet-[0-9a-f]{32})",
path,
)
if pet_match is not None:
try:
deleted = self.pets.delete_card(pet_match.group("pet_id"))
self._json(
{"success": True, "deleted": deleted, **self.pets.snapshot()},
HTTPStatus.OK,
)
except KeyError:
self._json(
{"success": False, "error": "not_found", "message": "未找到宠物卡片。"},
HTTPStatus.NOT_FOUND,
)
except ValueError as exc:
self._json(
{"success": False, "error": "invalid_request", "message": str(exc)},
HTTPStatus.BAD_REQUEST,
)
return
prefix = "/api/mode-profiles/"
if not path.startswith(prefix):
self._json({"error": "not_found"}, HTTPStatus.NOT_FOUND)
@@ -2833,6 +2904,47 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
}
try:
payload = self._read_json_body(max_bytes=256 * 1024)
if path == "/api/extension/pets":
if "nfc_id" in payload:
raise ValueError(
"nfc_id 只能通过当前已识别的 NFC 卡片完成绑定。"
)
card = self.pets.create_card(
payload.get("pet_name"),
payload.get("action"),
)
self._json(
{"success": True, "card": card, **self.pets.snapshot()},
HTTPStatus.CREATED,
)
return
pet_binding_match = re.fullmatch(
r"/api/extension/pets/(?P<pet_id>pet-[0-9a-f]{32})/"
r"(?P<action>bind-current-nfc|unbind)",
path,
)
if pet_binding_match is not None:
pet_id = pet_binding_match.group("pet_id")
try:
if pet_binding_match.group("action") == "bind-current-nfc":
card = self.pets.bind_current_nfc(pet_id)
else:
card = self.pets.unbind_card(pet_id)
except KeyError:
self._json(
{
"success": False,
"error": "not_found",
"message": "未找到宠物卡片。",
},
HTTPStatus.NOT_FOUND,
)
return
self._json(
{"success": True, "card": card, **self.pets.snapshot()},
HTTPStatus.OK,
)
return
if path in {
"/api/extension/gscu/erase",
"/api/extension/gscu/erase-all",
@@ -3205,7 +3317,11 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
)
def _extension_health(self) -> dict[str, object]:
return {**self.extension.health(), "nfc": self.nfc.snapshot()}
return {
**self.extension.health(),
"nfc": self.nfc.snapshot(),
"pet_cards": self.pets.snapshot(),
}
def _handle_hardware_access_post(self, path: str) -> None:
local_path = path.startswith("/api/local/")
@@ -3848,6 +3964,7 @@ def main() -> None:
SeatDemoHandler.service.start()
SeatDemoHandler.extension.start()
SeatDemoHandler.nfc.start()
SeatDemoHandler.pets.start()
SeatDemoHandler.calibration.start()
SeatDemoHandler.repair.start()
if args.host in {"0.0.0.0", "::"}:
@@ -3900,9 +4017,10 @@ def main() -> None:
server.server_close()
SeatDemoHandler.speech.close()
SeatDemoHandler.assistant.close()
SeatDemoHandler.pets.close()
SeatDemoHandler.nfc.close()
SeatDemoHandler.service.close()
SeatDemoHandler.extension.close()
SeatDemoHandler.nfc.close()
SeatDemoHandler.calibration.close()
SeatDemoHandler.repair.close()
+130 -9
View File
@@ -15,6 +15,7 @@ from unittest.mock import patch
from cognitive_agent.application.codex_assistant import CodexAssistantService
from cognitive_agent.application.mode_profile import ModeProfileRegistry
from cognitive_agent.application.pet_cards import PetCardService
from cognitive_agent.application.seat_service import REPOSITORY_ROOT, SeatDemoService
from cognitive_agent.application.self_extension import (
GatewayError,
@@ -64,6 +65,18 @@ class SeatWebApiTests(unittest.TestCase):
cls.extension_temporary_directory.name, "gscu-ir-catalog.sqlite3"
),
)
cls.original_pets = SeatDemoHandler.pets
SeatDemoHandler.pets = PetCardService(
database_path=Path(
cls.extension_temporary_directory.name,
"pet-cards.sqlite3",
),
nfc_provider=lambda: SeatDemoHandler.nfc.snapshot(),
publisher=lambda _payload: {
"delivery_status": "queued",
"message_id": 1,
},
)
cls.original_auth = SeatDemoHandler.auth
SeatDemoHandler.auth = AuthRuntime(
AuthConfig(
@@ -94,6 +107,8 @@ class SeatWebApiTests(unittest.TestCase):
cls.thread.join(timeout=2)
SeatDemoHandler.service = cls.original_service
SeatDemoHandler.extension = cls.original_extension
SeatDemoHandler.pets.close()
SeatDemoHandler.pets = cls.original_pets
SeatDemoHandler.auth = cls.original_auth
cls.extension_temporary_directory.cleanup()
cls.temporary_directory.cleanup()
@@ -161,6 +176,105 @@ class SeatWebApiTests(unittest.TestCase):
unauthenticated.open(control_request, timeout=3)
self.assertEqual(control_denied.exception.code, 401)
def test_pet_card_crud_and_binding_accept_only_current_reader_uid(self) -> None:
create = urllib.request.Request(
f"{self.base_url}/api/extension/pets",
method="POST",
headers={"Content-Type": "application/json"},
data=json.dumps(
{"pet_name": "团团", "action": "摇尾巴"},
ensure_ascii=False,
).encode("utf-8"),
)
with self.opener.open(create, timeout=3) as response:
created = json.loads(response.read().decode("utf-8"))
self.assertEqual(response.status, 201)
pet_id = created["card"]["id"]
self.assertIsNone(created["card"]["nfc_id"])
self.assertFalse(created["card"]["saved"])
forged = urllib.request.Request(
f"{self.base_url}/api/extension/pets/{pet_id}",
method="PUT",
headers={"Content-Type": "application/json"},
data=json.dumps(
{
"pet_name": "团团",
"action": "摇尾巴",
"nfc_id": "DEADBEEF",
}
).encode("utf-8"),
)
with self.assertRaises(urllib.error.HTTPError) as rejected:
self.opener.open(forged, timeout=3)
self.assertEqual(rejected.exception.code, 400)
save = urllib.request.Request(
f"{self.base_url}/api/extension/pets/{pet_id}",
method="PUT",
headers={"Content-Type": "application/json"},
data=json.dumps(
{"pet_name": "团团", "action": "摇尾巴"},
ensure_ascii=False,
).encode("utf-8"),
)
with self.opener.open(save, timeout=3) as response:
saved = json.loads(response.read().decode("utf-8"))
self.assertTrue(saved["card"]["saved"])
original_nfc = SeatDemoHandler.nfc
SeatDemoHandler.nfc = SimpleNamespace(
snapshot=lambda: {
"revision": 8,
"reader": {"online": True, "state": "ready"},
"card": {"uid": "e1:13-b2 03", "action": "present"},
}
)
try:
bind = urllib.request.Request(
f"{self.base_url}/api/extension/pets/{pet_id}/bind-current-nfc",
method="POST",
headers={"Content-Type": "application/json"},
data=b"{}",
)
with self.opener.open(bind, timeout=3) as response:
bound = json.loads(response.read().decode("utf-8"))
self.assertEqual(bound["card"]["nfc_id"], "E113B203")
with self.opener.open(
f"{self.base_url}/api/extension/nfc/status",
timeout=3,
) as response:
nfc_status = json.loads(response.read().decode("utf-8"))
self.assertEqual(nfc_status["pet_cards"]["cards"][0]["nfc_id"], "E113B203")
self.assertEqual(nfc_status["pet_cards"]["publisher"]["topic"], "n7/pet")
finally:
SeatDemoHandler.nfc = original_nfc
update = urllib.request.Request(
f"{self.base_url}/api/extension/pets/{pet_id}",
method="PUT",
headers={"Content-Type": "application/json"},
data=json.dumps(
{"pet_name": "团团 2", "action": "趴下"},
ensure_ascii=False,
).encode("utf-8"),
)
with self.opener.open(update, timeout=3) as response:
updated = json.loads(response.read().decode("utf-8"))
self.assertEqual(updated["card"]["pet_name"], "团团 2")
self.assertEqual(updated["card"]["action"], "趴下")
self.assertEqual(updated["card"]["nfc_id"], "E113B203")
delete = urllib.request.Request(
f"{self.base_url}/api/extension/pets/{pet_id}",
method="DELETE",
)
with self.opener.open(delete, timeout=3) as response:
deleted = json.loads(response.read().decode("utf-8"))
self.assertEqual(deleted["deleted"]["id"], pet_id)
self.assertEqual(deleted["count"], 0)
def test_n7_mode_scene_catalog_is_public_and_read_only(self) -> None:
unauthenticated = urllib.request.build_opener()
@@ -693,7 +807,7 @@ operations:
def test_static_console_and_runtime_endpoint(self) -> None:
with self.opener.open(f"{self.base_url}/", timeout=3) as response:
html = response.read().decode("utf-8")
with self.opener.open(f"{self.base_url}/app.js?v=dual-seat-135", timeout=3) as response:
with self.opener.open(f"{self.base_url}/app.js?v=dual-seat-136", timeout=3) as response:
app_script = response.read().decode("utf-8")
with self.opener.open(
f"{self.base_url}/gesture-control.js?v=android-pad-9", timeout=3
@@ -720,11 +834,11 @@ operations:
) as response:
speech_worklet = response.read().decode("utf-8")
with self.opener.open(
f"{self.base_url}/self-extension.js?v=gscu-tcp-44", timeout=3
f"{self.base_url}/self-extension.js?v=pet-acrylic-48", timeout=3
) as response:
extension_script = response.read().decode("utf-8")
with self.opener.open(
f"{self.base_url}/self-extension.css?v=gscu-tcp-43", timeout=3
f"{self.base_url}/self-extension.css?v=pet-acrylic-48", timeout=3
) as response:
extension_styles = response.read().decode("utf-8").replace("\r\n", "\n")
with self.opener.open(
@@ -739,6 +853,10 @@ operations:
f"{self.base_url}/energy-monitor.css?v=energy-monitor-4", timeout=3
) as response:
energy_styles = response.read().decode("utf-8")
with self.opener.open(
f"{self.base_url}/codepen-glass-theme.css?v=creative-cloud-1", timeout=3
) as response:
glass_theme = response.read().decode("utf-8")
with self.opener.open(
f"{self.base_url}/service-worker.js", timeout=3
) as response:
@@ -769,7 +887,7 @@ operations:
self.assertIn("CMD 0x431 · FB 0x41A · CH1", html)
self.assertIn("CMD 0x431 · FB 0x388 · CH1", html)
self.assertNotIn("透明车体 · 实体设备", html)
self.assertIn('src="/app.js?v=dual-seat-135"', html)
self.assertIn('src="/app.js?v=dual-seat-136"', html)
self.assertIn('href="/styles.css?v=dual-seat-23"', html)
self.assertIn('src="/gesture-control.js?v=android-pad-9"', html)
self.assertNotIn('class="vehicle-system-hud"', html)
@@ -782,8 +900,8 @@ operations:
self.assertIn('<body class="console-v2">', html)
self.assertIn('id="v2BootScreen"', html)
self.assertIn('正在加载车机控制与实时状态', html)
self.assertIn('src="/self-extension.js?v=gscu-tcp-44"', html)
self.assertIn('href="/self-extension.css?v=gscu-tcp-43"', html)
self.assertIn('src="/self-extension.js?v=pet-acrylic-48"', html)
self.assertIn('href="/self-extension.css?v=pet-acrylic-48"', html)
self.assertNotIn("self-calibration", html)
self.assertNotIn("self-repair", html)
self.assertNotIn("self-evolution", html)
@@ -794,6 +912,9 @@ operations:
self.assertIn('src="/assistant-chat.js?v=codex-assistant-32"', html)
self.assertIn('href="/energy-monitor.css?v=energy-monitor-4"', html)
self.assertIn('src="/energy-monitor.js?v=energy-monitor-4"', html)
self.assertIn('href="/codepen-glass-theme.css?v=creative-cloud-1"', html)
self.assertIn("--glass-shell:", glass_theme)
self.assertIn("backdrop-filter: blur(22px) saturate(1.25)", glass_theme)
self.assertNotIn('"admin"', console_script)
self.assertNotIn("v2RoleSelect", console_script)
self.assertIn('sidebarToggle.id = "v2SidebarToggle"', console_script)
@@ -1073,14 +1194,14 @@ operations:
self.assertIn("CAN 指令已提交,等待车辆状态确认", console_script)
self.assertIn('canvas.addEventListener("webglcontextlost"', app_script)
self.assertIn('"cognitive-agent:module-change"', app_script)
self.assertIn('const CACHE_VERSION = "cognitive-agent-pad-v290"', service_worker)
self.assertIn('const CACHE_VERSION = "cognitive-agent-pad-v298"', service_worker)
self.assertIn('/console-v2.css?v=delivery-state-69', service_worker)
self.assertIn('/console-v2.js?v=local-first-70', service_worker)
self.assertIn('/assistant-chat.css?v=codex-assistant-28', service_worker)
self.assertIn('/assistant-chat.js?v=codex-assistant-32', service_worker)
self.assertIn('/energy-monitor.css?v=energy-monitor-4', service_worker)
self.assertIn('/energy-monitor.js?v=energy-monitor-4', service_worker)
self.assertIn('"/app.js?v=dual-seat-135"', service_worker)
self.assertIn('"/app.js?v=dual-seat-136"', service_worker)
self.assertIn('"/speech-input.js?v=sensevoice-13"', service_worker)
self.assertIn('"/speech-input.css?v=sensevoice-7"', service_worker)
self.assertIn('"/speech-audio-worklet.js?v=sensevoice-5"', service_worker)
@@ -1537,7 +1658,7 @@ operations:
) as response:
extension_script = response.read().decode("utf-8")
with self.opener.open(
f"{self.base_url}/app.js?v=dual-seat-135", timeout=3
f"{self.base_url}/app.js?v=dual-seat-136", timeout=3
) as response:
app_script = response.read().decode("utf-8")
@@ -19,7 +19,7 @@ def test_console_contains_ai_assistant_surface():
def test_codex_assistant_assets_are_versioned_together_in_the_service_worker():
service_worker = (ROOT / "apps/web-console/service-worker.js").read_text(encoding="utf-8")
assert 'const CACHE_VERSION = "cognitive-agent-pad-v290"' in service_worker
assert 'const CACHE_VERSION = "cognitive-agent-pad-v298"' in service_worker
assert '"/assistant-chat.css?v=codex-assistant-28"' in service_worker
assert '"/assistant-chat.js?v=codex-assistant-32"' in service_worker
@@ -38,7 +38,7 @@ def test_energy_monitor_assets_are_versioned_and_cached() -> None:
assert html.index("/assistant-chat.js?v=codex-assistant-32") < html.index(
"/energy-monitor.js?v=energy-monitor-4"
)
assert 'const CACHE_VERSION = "cognitive-agent-pad-v290"' in service_worker
assert 'const CACHE_VERSION = "cognitive-agent-pad-v298"' in service_worker
def test_energy_monitor_uses_the_fifth_shared_dock_icon_and_starts_closed() -> None:
+25 -2
View File
@@ -42,12 +42,35 @@ def test_theme_asset_versions_are_consistent_with_the_pwa_shell() -> None:
for asset in (
"/styles.css?v=dual-seat-23",
"/console-v2.css?v=delivery-state-69",
"/self-extension.css?v=gscu-tcp-43",
"/self-extension.css?v=pet-acrylic-48",
"/speech-input.css?v=sensevoice-7",
"/codepen-glass-theme.css?v=creative-cloud-1",
):
assert asset in html
assert f'"{asset}"' in service_worker
assert 'const CACHE_VERSION = "cognitive-agent-pad-v290"' in service_worker
assert 'const CACHE_VERSION = "cognitive-agent-pad-v298"' in service_worker
def test_reference_glass_theme_covers_the_complete_workspace() -> None:
html = source("index.html")
theme = source("codepen-glass-theme.css")
assert '<meta name="theme-color" content="#17182b" />' in html
for contract in (
"--glass-shell:",
"--glass-panel:",
"--glass-card:",
"backdrop-filter: blur(22px) saturate(1.25)",
".main-panel > .v2-mode-rail",
"#assistantDrawer",
".joint-twin-shell > .twin-band",
"#v2ExtensionPanel .v2-extension-sync-mount",
".v2-pet-card",
".mode-gallery",
".v2-nfc-dialog",
".energy-monitor-page",
):
assert contract in theme
def test_vehicle_twin_icons_and_nested_overlays_use_semantic_tokens() -> None:
@@ -54,15 +54,17 @@ class FrontendVehicleStateSyncContractTests(unittest.TestCase):
'canvas.dataset.themeAccent = `#${palette.accent.toString(16).padStart(6, "0")}`',
self.app_source,
)
for blue_theme_token in (
"accent: 0x2f80ed",
"vehicleBody: 0x91c6f5",
"vehicleOutline: 0x2879d8",
"seatShell: 0x2b76c9",
"glass: 0x4b9bea",
"lamp: 0x2e7fd4",
for glass_theme_token in (
"accent: 0x7b70f5",
"sceneBackground: 0x111426",
"floor: 0x1a2038",
"vehicleBody: 0x729bc9",
"vehicleOutline: 0x73b8ff",
"seatShell: 0x426fbd",
"glass: 0x4d8ed2",
"lamp: 0x4385cf",
):
self.assertIn(blue_theme_token, self.app_source)
self.assertIn(glass_theme_token, self.app_source)
self.assertNotIn("accent: 0x0e7773", self.app_source)
self.assertNotIn("cognitive-agent:demo-role-change", self.app_source)
@@ -70,16 +72,21 @@ class FrontendVehicleStateSyncContractTests(unittest.TestCase):
self.assertIn("function createTwinGroundTexture(", self.app_source)
self.assertIn("new THREE.PlaneGeometry(30, 30)", self.app_source)
self.assertIn(
'surfaceGradient.addColorStop(0, "rgba(255, 255, 255, 1)")',
"surfaceGradient.addColorStop(0, rgba(floorColor, 0.96))",
self.app_source,
)
self.assertIn(
'surfaceGradient.addColorStop(1, "rgba(255, 255, 255, 0)")',
"surfaceGradient.addColorStop(1, rgba(floorColor, 0))",
self.app_source,
)
self.assertIn("const outerRadius = size * 0.48", self.app_source)
self.assertIn(
"const gridColor = accent.clone().lerp(new THREE.Color(0xffffff), 0.52)",
"const gridStrong = new THREE.Color(palette.gridStrong)",
self.app_source,
)
self.assertIn("const gridSoft = new THREE.Color(palette.gridSoft)", self.app_source)
self.assertIn(
"gridContext.strokeStyle = rgba(major ? gridStrong : gridSoft, major ? 0.38 : 0.24)",
self.app_source,
)
self.assertIn("gridContext.lineWidth = major ? 0.9 : 0.45", self.app_source)
+37
View File
@@ -17,7 +17,9 @@ from cognitive_agent.infrastructure.can.hardware import (
)
from cognitive_agent.infrastructure.can.mqtt_transport import (
MQTT_CAN_SCHEMA,
MQTT_PET_TOPIC,
MqttCanTransport,
mqtt_pet_payload,
parse_mqtt_can_payload,
)
@@ -520,6 +522,41 @@ class MqttCanTransportTests(unittest.TestCase):
finally:
transport.close()
def test_pet_message_uses_fixed_topic_exact_json_and_never_retains(self) -> None:
payload = {
"pet_name": "团团",
"nfc_id": "E113B203",
"action": "摇尾巴",
}
publication = self.transport.publish_pet_message(payload)
topic, raw_payload, qos, retain = self.client.published[-1]
self.assertEqual(topic, MQTT_PET_TOPIC)
self.assertEqual(raw_payload, mqtt_pet_payload(payload))
self.assertEqual(
json.loads(raw_payload),
{"pet_name": "团团", "nfc_id": "E113B203", "action": "摇尾巴"},
)
self.assertEqual(qos, 1)
self.assertFalse(retain)
self.assertEqual(publication["delivery_status"], "published")
def test_pet_message_rejects_extra_fields_and_invalid_nfc_id(self) -> None:
with self.assertRaisesRegex(Exception, "exactly"):
self.transport.publish_pet_message(
{
"pet_name": "团团",
"nfc_id": "E113B203",
"action": "摇尾巴",
"command": "not-allowed",
}
)
with self.assertRaisesRegex(Exception, "uppercase hexadecimal"):
self.transport.publish_pet_message(
{"pet_name": "团团", "nfc_id": "not-a-uid", "action": "摇尾巴"}
)
def test_condition_frames_use_existing_decoder_and_snapshot_contract(self) -> None:
self.client.emit(
"n7/n7-headunit/can/state",
+57 -2
View File
@@ -60,8 +60,8 @@ def test_unified_console_exposes_device_id_based_nfc_authorization() -> None:
assert '.v2-nfc-command-list' in styles
assert '.v2-nfc-command-option' in styles
assert '.v2-nfc-select-all' in styles
assert 'self-extension.js?v=gscu-tcp-44' in html
assert 'self-extension.css?v=gscu-tcp-43' in html
assert 'self-extension.js?v=pet-acrylic-48' in html
assert 'self-extension.css?v=pet-acrylic-48' in html
def test_unified_console_exposes_gscu_tcp_configuration_and_actions() -> None:
@@ -121,14 +121,69 @@ def test_server_exposes_nfc_snapshot_through_same_origin_extension_api() -> None
server = (ROOT / "src" / "cognitive_agent" / "server.py").read_text(encoding="utf-8")
assert 'nfc = NfcReaderService()' in server
assert 'pets = PetCardService(' in server
assert 'path == "/api/extension/nfc/status"' in server
assert 'path == "/api/extension/pets"' in server
assert '"pet_cards": self.pets.snapshot()' in server
assert 'path == "/api/extension/peripherals"' in server
assert '"nfc": self.nfc.snapshot()' in server
assert 'SeatDemoHandler.nfc.start()' in server
assert 'SeatDemoHandler.pets.start()' in server
assert 'SeatDemoHandler.pets.close()' in server
assert 'SeatDemoHandler.nfc.close()' in server
assert "frame-src 'self' http://*:3001" in server
def test_homepage_exposes_durable_editable_pet_cards_and_nfc_only_binding() -> None:
script = source("self-extension.js")
styles = source("self-extension.css")
service = (ROOT / "src" / "cognitive_agent" / "application" / "pet_cards.py").read_text(
encoding="utf-8"
)
mqtt = (
ROOT / "src" / "cognitive_agent" / "infrastructure" / "can" / "mqtt_transport.py"
).read_text(encoding="utf-8")
assert 'class="v2-pet-card-region"' in script
assert 'data-pet-field="pet_name"' in script
assert 'data-pet-field="action"' in script
assert '<output>${card.nfc_id ?' in script
assert 'data-pet-open-nfc' not in script
assert 'data-nfc-pet-select' in script
assert 'data-nfc-bind-pet' in script
assert 'bind-current-nfc' in script
assert 'api(`pets/${encodeURIComponent(cardId)}`' in script
assert 'burst_count' in script
assert 'burst_window_ms' in script
assert 'label: "已发送 3 帧"' in script
assert 'MQTT PET CARD' not in script
assert 'data-pet-publish-note' not in script
assert '宠物名 <code>' not in script
assert '宠物动作 <code>' not in script
assert '卡片 ID <code>' not in script
assert 'n7/pet' in script
assert 'if (nextRevision < petCardSnapshot.revision) return false' in script
assert '.v2-pet-card-region' in styles
assert '.v2-pet-card-list' in styles
assert '.v2-nfc-pet-binding' in styles
assert 'grid-template-columns: minmax(0, 1fr);' in styles
assert 'scrollbar-gutter: stable' in styles
assert 'backdrop-filter: blur(18px) saturate(1.35)' in styles
assert 'height: 58px' in styles
assert 'class PetCardService' in service
assert 'PET_MQTT_TOPIC = "n7/pet"' in service
assert 'def bind_current_nfc' in service
assert '"pet_name": str(card["pet_name"])' in service
assert '"nfc_id": nfc_id' in service
assert '"action": str(card["action"])' in service
assert 'PET_BURST_COUNT = 3' in service
assert 'PET_BURST_WINDOW_S = 1.0' in service
assert '"burst_complete"' in service
assert 'MQTT_PET_TOPIC = "n7/pet"' in mqtt
assert 'def publish_pet_message' in mqtt
def test_main_parsed_control_panel_renders_validated_infrared_operations() -> None:
console = source("console-v2.js")
styles = source("console-v2.css")
+172
View File
@@ -0,0 +1,172 @@
from __future__ import annotations
import tempfile
import time
from pathlib import Path
import pytest
from cognitive_agent.application.pet_cards import PetCardService, normalize_nfc_id
def wait_until(predicate, timeout: float = 1.0) -> bool:
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if predicate():
return True
time.sleep(0.01)
return predicate()
def test_pet_cards_are_durable_and_nfc_id_only_comes_from_current_reader() -> None:
nfc = {"reader": {"online": True}, "card": None}
with tempfile.TemporaryDirectory() as directory:
database = Path(directory, "pet-cards.sqlite3")
service = PetCardService(
database_path=database,
nfc_provider=lambda: nfc,
)
first = service.create_card("团团", "摇尾巴")
second = service.create_card("圆圆", "趴下")
assert first["nfc_id"] is None
assert service.update_card(
first["id"],
pet_name="团团 2",
action="向前走",
)["action"] == "向前走"
with pytest.raises(ValueError, match="当前未识别"):
service.bind_current_nfc(first["id"])
nfc["card"] = {"uid": "e1:13-b2 03"}
bound = service.bind_current_nfc(first["id"])
assert bound["nfc_id"] == "E113B203"
with pytest.raises(ValueError, match="先保存"):
service.bind_current_nfc(second["id"])
service.update_card(
second["id"],
pet_name="圆圆",
action="趴下",
)
rebound = service.bind_current_nfc(second["id"])
assert rebound["nfc_id"] == "E113B203"
cards = {card["id"]: card for card in service.snapshot()["cards"]}
assert cards[first["id"]]["nfc_id"] is None
assert cards[second["id"]]["nfc_id"] == "E113B203"
restored = PetCardService(database_path=database)
restored_cards = {card["id"]: card for card in restored.snapshot()["cards"]}
assert restored_cards[first["id"]]["pet_name"] == "团团 2"
assert restored_cards[second["id"]]["nfc_id"] == "E113B203"
assert restored.delete_card(first["id"])["pet_name"] == "团团 2"
assert restored.snapshot()["count"] == 1
def test_bound_present_nfc_publishes_one_three_frame_burst_per_detection() -> None:
nfc = {"reader": {"online": True}, "card": {"uid": "E113B203"}}
publications: list[tuple[float, dict[str, str]]] = []
def publish(payload: dict[str, str]) -> dict[str, object]:
publications.append((time.monotonic(), dict(payload)))
return {"delivery_status": "queued", "message_id": len(publications)}
with tempfile.TemporaryDirectory() as directory:
service = PetCardService(
database_path=Path(directory, "pet-cards.sqlite3"),
nfc_provider=lambda: nfc,
publisher=publish,
burst_window_s=0.15,
)
card = service.create_card("Lucky", "向前跑")
service.update_card(
card["id"],
pet_name="Lucky",
action="向前跑",
)
service.bind_current_nfc(card["id"])
service.start()
try:
assert wait_until(lambda: len(publications) == 3)
assert all(
payload == {
"pet_name": "Lucky",
"nfc_id": "E113B203",
"action": "向前跑",
}
for _captured_at, payload in publications
)
assert publications[-1][0] - publications[0][0] <= 0.18
status = service.snapshot()["publisher"]
assert status["state"] == "burst_complete"
assert status["topic"] == "n7/pet"
assert status["burst_count"] == 3
assert status["burst_attempt_count"] == 3
assert status["burst_published_count"] == 3
assert status["publish_count"] == 3
time.sleep(0.18)
assert len(publications) == 3
nfc["card"] = None
assert wait_until(
lambda: service.snapshot()["publisher"]["state"]
== "waiting_for_nfc"
)
stopped_at = len(publications)
time.sleep(0.18)
assert len(publications) == stopped_at
nfc["card"] = {"uid": "E113B203"}
assert wait_until(lambda: len(publications) == 6)
assert service.snapshot()["publisher"]["state"] == "burst_complete"
finally:
service.close()
def test_invalid_pet_fields_and_nfc_uids_are_rejected() -> None:
with tempfile.TemporaryDirectory() as directory:
service = PetCardService(
database_path=Path(directory, "pet-cards.sqlite3")
)
with pytest.raises(ValueError, match="宠物名"):
service.create_card("", "走动")
with pytest.raises(ValueError, match="宠物动作"):
service.create_card("Lucky", "")
assert normalize_nfc_id("04:a1-b2 c3") == "04A1B2C3"
assert normalize_nfc_id("not-a-card") == ""
def test_failed_pet_burst_stops_after_three_attempts_until_redetected() -> None:
nfc = {"reader": {"online": True}, "card": {"uid": "E113B203"}}
attempts: list[float] = []
def fail_publish(_payload: dict[str, str]) -> dict[str, object]:
attempts.append(time.monotonic())
raise RuntimeError("broker unavailable")
with tempfile.TemporaryDirectory() as directory:
service = PetCardService(
database_path=Path(directory, "pet-cards.sqlite3"),
nfc_provider=lambda: nfc,
publisher=fail_publish,
burst_window_s=0.09,
)
card = service.create_card("Lucky", "坐下")
service.update_card(card["id"], pet_name="Lucky", action="坐下")
service.bind_current_nfc(card["id"])
service.start()
try:
assert wait_until(lambda: len(attempts) == 3)
status = service.snapshot()["publisher"]
assert status["state"] == "burst_failed"
assert status["burst_attempt_count"] == 3
assert status["burst_published_count"] == 0
assert status["publish_count"] == 0
assert status["last_error"] == "broker unavailable"
time.sleep(0.15)
assert len(attempts) == 3
finally:
service.close()
+2 -2
View File
@@ -18,14 +18,14 @@ def test_vue_presentation_assets_are_local_ordered_and_cached() -> None:
presentation = "/vue-ui.js?v=vue-presentation-3"
assert html.index(runtime) < html.index(presentation) < html.index(
"/app.js?v=dual-seat-135"
"/app.js?v=dual-seat-136"
)
assert html.index(presentation) < html.index(
"/assistant-chat.js?v=codex-assistant-32"
)
assert f'"{runtime}"' in service_worker
assert f'"{presentation}"' in service_worker
assert 'const CACHE_VERSION = "cognitive-agent-pad-v290"' in service_worker
assert 'const CACHE_VERSION = "cognitive-agent-pad-v298"' in service_worker
assert "unpkg.com" not in html
assert "cdn.jsdelivr.net" not in html