feat(n7web): add pet NFC workflow and glass UI
This commit is contained in:
@@ -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
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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%;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user