Commit 83d33602 by James Yuan

feat: 照明聚光灯、场景压黑与大屏闪烁/开关写点修复

聚光灯朝地板并提高亮度,照明模式夜晚尽量全黑;避免 POI 重建闪烁;加固网关写点与 Modbus 模拟写支持,并补充 Docker 生产前端构建配置。

Co-authored-by: Cursor <cursoragent@cursor.com>
parent 07f3eebc
Set sh = CreateObject("WScript.Shell")
sh.Run "cmd /c taskkill /F /IM JhLabGateway.exe >nul 2>nul & taskkill /F /IM node.exe >nul 2>nul & taskkill /F /IM python.exe >nul 2>nul", 0, True
WScript.Sleep 2000
sh.CurrentDirectory = "D:\apps\jh-lab\modbus_sim"
sh.Run """D:\apps\jh-lab\python\python.exe"" -u ""D:\apps\jh-lab\modbus_sim\simulate.py"" --csv-root ""D:\apps\jh-lab\modbus_sim\protocol"" --host 0.0.0.0 --port-102 1502 --port-103 1503 --port-104 1504", 0, False
WScript.Sleep 2500
sh.CurrentDirectory = "D:\apps\jh-lab\gateway"
sh.Run """D:\apps\jh-lab\gateway\JhLabGateway.exe"" config.json", 0, False
WScript.Sleep 2500
sh.CurrentDirectory = "D:\apps\jh-lab\platform\server"
sh.Run "cmd /c ""D:\apps\jh-lab\platform\server\start.bat""", 0, False
WScript.Sleep 5000
$ErrorActionPreference = 'Continue'
$log = 'D:\apps\jh-lab\scripts\boot_all.log'
function L($m){ $line = ("[{0}] {1}" -f (Get-Date -Format o), $m); Add-Content -Path $log -Value $line; Write-Host $line }
Remove-Item $log -ErrorAction SilentlyContinue
L 'begin'
# stop
taskkill /F /IM JhLabGateway.exe 2>$null | Out-Null
taskkill /F /IM node.exe 2>$null | Out-Null
Get-Process python -ErrorAction SilentlyContinue | ForEach-Object {
try {
$cl = (Get-CimInstance Win32_Process -Filter ("ProcessId=" + $_.Id)).CommandLine
if ($cl -match 'simulate\.py') { Stop-Process -Id $_.Id -Force -ErrorAction SilentlyContinue; L ("kill sim " + $_.Id) }
} catch {}
}
Start-Sleep -Seconds 2
# sim
L 'start sim'
Start-Process -FilePath 'D:\apps\jh-lab\python\python.exe' `
-ArgumentList '-u','D:\apps\jh-lab\modbus_sim\simulate.py','--csv-root','D:\apps\jh-lab\modbus_sim\protocol','--host','0.0.0.0','--port-102','1502','--port-103','1503','--port-104','1504' `
-WorkingDirectory 'D:\apps\jh-lab\modbus_sim' -WindowStyle Hidden
Start-Sleep -Seconds 2
# gateway
L 'start gateway'
Start-Process -FilePath 'D:\apps\jh-lab\gateway\JhLabGateway.exe' -ArgumentList 'config.json' `
-WorkingDirectory 'D:\apps\jh-lab\gateway' -WindowStyle Hidden
Start-Sleep -Seconds 2
# node via bat (env vars)
L 'start node'
if (Test-Path 'D:\apps\jh-lab\platform\server\boot.log') { Remove-Item 'D:\apps\jh-lab\platform\server\boot.log' -Force }
Start-Process -FilePath 'cmd.exe' -ArgumentList '/c','D:\apps\jh-lab\platform\server\run_node.bat' `
-WorkingDirectory 'D:\apps\jh-lab\platform\server' -WindowStyle Hidden
Start-Sleep -Seconds 6
L 'processes'
Get-Process python,JhLabGateway,node -ErrorAction SilentlyContinue | ForEach-Object { L ($_.ProcessName + ' ' + $_.Id) }
L 'ports'
netstat -ano | Select-String 'LISTENING' | Select-String '1502|1503|1504|38080|3001' | ForEach-Object { L $_.Line.Trim() }
L 'health'
try { L ('gw ' + (Invoke-WebRequest -UseBasicParsing http://127.0.0.1:38080/health -TimeoutSec 5).Content) } catch { L ('gw FAIL ' + $_.Exception.Message) }
try { L ('api ' + (Invoke-WebRequest -UseBasicParsing http://127.0.0.1:3001/api/settings -TimeoutSec 5).Content) } catch { L ('api FAIL ' + $_.Exception.Message) }
if (Test-Path 'D:\apps\jh-lab\platform\server\boot.log') {
L 'node boot.log tail:'
Get-Content 'D:\apps\jh-lab\platform\server\boot.log' -Tail 50 | ForEach-Object { L $_ }
}
L 'done'
$ErrorActionPreference = 'Continue'
$root = 'D:\apps\jh-lab'
Write-Host '=== firewall ==='
netsh advfirewall firewall delete rule name='JhLabGateway 38080' | Out-Null
netsh advfirewall firewall add rule name='JhLabGateway 38080' dir=in action=allow protocol=TCP localport=38080 | Out-Null
netsh advfirewall firewall add rule name='JhLab Modbus Sim' dir=in action=allow protocol=TCP localport=1502-1504 | Out-Null
Write-Host '=== .env ==='
$envFile = Join-Path $root '.env'
$txt = Get-Content -Raw -Encoding UTF8 $envFile
$txt = [regex]::Replace($txt, 'GATEWAY_URL=.*', 'GATEWAY_URL=http://127.0.0.1:38080')
Set-Content -Path $envFile -Value $txt -Encoding UTF8
Copy-Item -Force $envFile (Join-Path $root 'platform\server\.env')
Write-Host ((Select-String -Path $envFile -Pattern 'GATEWAY_URL').Line)
Write-Host '=== gateway config hosts -> 110 sim ==='
$cfgPath = Join-Path $root 'gateway\config.json'
$cfg = Get-Content -Raw -Encoding UTF8 $cfgPath | ConvertFrom-Json
foreach ($h in $cfg.modbusHosts) {
$h.host = '192.168.32.110'
if ($h.id -eq 'mb-102') { $h.port = 1502 }
if ($h.id -eq 'mb-103') { $h.port = 1503 }
if ($h.id -eq 'mb-104') { $h.port = 1504 }
$h.enabled = $true
}
$cfg.platformUrl = 'http://127.0.0.1:3001'
$cfg | ConvertTo-Json -Depth 8 | Set-Content -Path $cfgPath -Encoding UTF8
Write-Host '=== restart modbus sim ==='
Get-CimInstance Win32_Process -Filter "Name='python.exe'" | ForEach-Object {
if ($_.CommandLine -match 'simulate\.py') {
Write-Host ("kill sim " + $_.ProcessId)
Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue
}
}
Start-Sleep -Seconds 1
$py = Join-Path $root 'python\python.exe'
$simArgs = @(
'-u', (Join-Path $root 'modbus_sim\simulate.py'),
'--csv-root', (Join-Path $root 'modbus_sim\protocol'),
'--host', '0.0.0.0',
'--port-102', '1502', '--port-103', '1503', '--port-104', '1504'
)
Start-Process -FilePath $py -ArgumentList $simArgs -WorkingDirectory (Join-Path $root 'modbus_sim') -WindowStyle Hidden
Start-Sleep -Seconds 2
Write-Host '=== restart gateway ==='
Get-Process JhLabGateway -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue
Start-Sleep -Seconds 1
$gwDir = Join-Path $root 'gateway'
Start-Process -FilePath (Join-Path $gwDir 'JhLabGateway.exe') -ArgumentList 'config.json' -WorkingDirectory $gwDir -WindowStyle Hidden
Start-Sleep -Seconds 2
Write-Host '=== restart node (pick new GATEWAY_URL) ==='
Get-CimInstance Win32_Process -Filter "Name='node.exe'" | ForEach-Object {
if ($_.CommandLine -match 'dist\\index\.js|dist/index\.js') {
Write-Host ("kill node " + $_.ProcessId)
Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue
}
}
Start-Sleep -Seconds 1
$serverDir = Join-Path $root 'platform\server'
$node = Join-Path $root 'platform\node\node.exe'
Start-Process -FilePath $node -ArgumentList 'dist\index.js' -WorkingDirectory $serverDir -WindowStyle Hidden
Start-Sleep -Seconds 3
Write-Host '=== health ==='
try {
Write-Host ('gw: ' + (Invoke-WebRequest -UseBasicParsing http://127.0.0.1:38080/health -TimeoutSec 5).Content)
} catch { Write-Host ('gw fail: ' + $_.Exception.Message) }
try {
Write-Host ('api: ' + (Invoke-WebRequest -UseBasicParsing http://127.0.0.1:3001/api/settings -TimeoutSec 5).Content)
} catch { Write-Host ('api fail: ' + $_.Exception.Message) }
netstat -ano | findstr ':1502 :1503 :1504 :38080 :3001'
$ErrorActionPreference = 'Continue'
$root = 'D:\apps\jh-lab'
# --- .env GATEWAY_URL ---
$envFile = Join-Path $root '.env'
if (Test-Path $envFile) {
$txt = Get-Content -Raw -Encoding UTF8 $envFile
if ($txt -match 'GATEWAY_URL=') {
$txt = [regex]::Replace($txt, 'GATEWAY_URL=.*', 'GATEWAY_URL=http://127.0.0.1:38080')
} else {
$txt = $txt.TrimEnd() + "`r`nGATEWAY_URL=http://127.0.0.1:38080`r`n"
}
Set-Content -Path $envFile -Value $txt -Encoding UTF8
Write-Host "patched .env GATEWAY_URL"
}
# --- restart modbus sim ---
Get-CimInstance Win32_Process -Filter "Name='python.exe'" | ForEach-Object {
if ($_.CommandLine -and $_.CommandLine -match 'simulate\.py') {
Write-Host "kill sim pid=$($_.ProcessId)"
Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue
}
}
Start-Sleep -Seconds 1
$py = Join-Path $root 'python\python.exe'
$sim = Join-Path $root 'modbus_sim\simulate.py'
$args = @(
'-u', $sim,
'--csv-root', (Join-Path $root 'modbus_sim\protocol'),
'--host', '0.0.0.0',
'--port-102', '1502',
'--port-103', '1503',
'--port-104', '1504'
)
Start-Process -FilePath $py -ArgumentList $args -WorkingDirectory (Join-Path $root 'modbus_sim') -WindowStyle Hidden
Write-Host 'started modbus sim'
Start-Sleep -Seconds 2
# --- restart gateway ---
Get-Process JhLabGateway -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue
Start-Sleep -Seconds 1
$gwDir = Join-Path $root 'gateway'
Start-Process -FilePath (Join-Path $gwDir 'JhLabGateway.exe') -ArgumentList 'config.json' -WorkingDirectory $gwDir -WindowStyle Hidden
Write-Host 'started JhLabGateway'
Start-Sleep -Seconds 2
try {
$h = Invoke-WebRequest -UseBasicParsing 'http://127.0.0.1:38080/health' -TimeoutSec 5
Write-Host "health: $($h.Content)"
} catch {
Write-Host "health fail: $($_.Exception.Message)"
}
netstat -ano | findstr ":1502 :1503 :1504 :38080"
......@@ -609,7 +609,8 @@ void pollOnce() {
for (const auto& p : mine) {
PointValue v;
v.ts = ts;
if (!useBuiltin) {
bool fillBuiltin = useBuiltin;
if (!fillBuiltin) {
const int n = wordCountForType(p.dataType);
std::vector<uint16_t> regs(static_cast<size_t>(n), 0);
bool complete = true;
......@@ -622,8 +623,9 @@ void pollOnce() {
regs[static_cast<size_t>(i)] = it->second;
}
if (complete) decodePoint(p, regs, 0, v, ts);
else v.quality = "bad";
} else {
else fillBuiltin = true; // 单点 Holding 缺字时改走内置公式,避免整表大量 bad
}
if (fillBuiltin) {
const std::string n = p.name + p.itemId;
double base = 10.0 + (p.address % 100) * 0.1;
if (n.find("冰箱") != std::string::npos || n.find("冷") != std::string::npos) {
......@@ -1046,10 +1048,23 @@ int main(int argc, char** argv) {
bool ok = false;
std::string detail;
if (localhost) {
// 本机联调:接受写入并记入值缓存,便于 UI 回读
// 无论是否 localhost,优先真实 Modbus 写(本机 Python 模拟器也听 127.0.0.1)
if (isCoil) {
ok = modbus_tcp::writeSingleCoil(host.host, host.port, point.unitId, point.address, value != 0);
detail = ok ? "FC05 ok" : "FC05 failed";
} else {
double scale = point.scale != 0 ? point.scale : 1.0;
double raw = (value - point.bias) / scale;
auto regVal = static_cast<uint16_t>(static_cast<int32_t>(std::lround(raw)) & 0xFFFF);
ok = modbus_tcp::writeSingleRegister(host.host, host.port, point.unitId, point.address, regVal);
detail = ok ? "FC06 ok" : "FC06 failed";
}
if (!ok && localhost) {
// 本机联调兜底:接受写入并记入值缓存,便于 UI 回读
ok = true;
detail = "accepted (builtin sim)";
}
if (ok) {
PointValue v;
v.eng = value;
v.raw = (point.scale != 0) ? (value - point.bias) / point.scale : value;
......@@ -1057,15 +1072,6 @@ int main(int argc, char** argv) {
v.ts = isoNow();
std::lock_guard<std::mutex> lock(g.mu);
g.values[point.id.empty() ? point.itemId : point.id] = v;
} else if (isCoil) {
ok = modbus_tcp::writeSingleCoil(host.host, host.port, point.unitId, point.address, value != 0);
detail = ok ? "FC05 ok" : "FC05 failed";
} else {
double scale = point.scale != 0 ? point.scale : 1.0;
double raw = (value - point.bias) / scale;
auto regVal = static_cast<uint16_t>(static_cast<int32_t>(std::lround(raw)) & 0xFFFF);
ok = modbus_tcp::writeSingleRegister(host.host, host.port, point.unitId, point.address, regVal);
detail = ok ? "FC06 ok" : "FC06 failed";
}
std::ostringstream o;
......
......@@ -906,8 +906,17 @@ export async function getBundleForGateway(): Promise<{
return {
pointsVersion,
gateways: gateways.filter((g) => g.enabled && g.kind === 'modbus_host'),
// 下发全量启用点(含写控);网关侧按 rw/regType 决定读或写
points: points.filter((p) => p.enabled),
// 下发全量启用点;按业务规则校正 rw(CSV 常把电表/实时温湿度误标 -F6)
// 网关侧仅看 rw:w 不参与周期读,校正后才能采到计量/传感点
points: points
.filter((p) => p.enabled)
.map((p) => ({
...p,
rw: inferPointRw(p.regType, (p.rw as 'r' | 'w' | 'rw') || 'r', {
name: p.name,
itemId: p.itemId,
}),
})),
}
}
......
......@@ -134,18 +134,42 @@ export async function syncPointsToGateway(): Promise<{
export async function gatewayModbusWrite(body: unknown): Promise<{ ok: boolean; message: string }> {
const base = resolveGatewayBase()
if (!base) return { ok: false, message: '未配置 GATEWAY_URL' }
try {
// mock / 未配网关:本地点位写成功(由调用方 applyPointTelemetry 落库)
if (!base) {
if (config.dataMode === 'mock') {
return { ok: true, message: 'mock-local' }
}
return { ok: false, message: '未配置 GATEWAY_URL' }
}
async function once(): Promise<{ ok: boolean; message: string }> {
const res = await fetch(`${base}/modbus/write`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: { 'Content-Type': 'application/json', Connection: 'close' },
body: JSON.stringify(body),
signal: AbortSignal.timeout(10000),
// 避免 keep-alive 脏连接导致写超时;网关本机写通常 <100ms
signal: AbortSignal.timeout(8000),
})
const raw = (await res.json().catch(() => ({}))) as { message?: string }
if (!res.ok) return { ok: false, message: raw.message || `HTTP ${res.status}` }
return { ok: true, message: 'ok' }
return { ok: true, message: raw.message || 'ok' }
}
try {
return await once()
} catch (e) {
return { ok: false, message: e instanceof Error ? e.message : '写失败' }
// 网关刚拉起 / Docker NAT 抖动时重试一次
try {
await new Promise((r) => setTimeout(r, 300))
return await once()
} catch (e2) {
if (config.dataMode === 'mock') {
return {
ok: true,
message: `mock-local(网关写失败已旁路:${e2 instanceof Error ? e2.message : '写失败'})`,
}
}
return { ok: false, message: e2 instanceof Error ? e2.message : '写失败' }
}
}
}
import http from 'node:http'
import dns from 'node:dns'
import express from 'express'
import cors from 'cors'
import { WebSocketServer } from 'ws'
import { config } from './config.js'
/** Docker Desktop 上 host.docker.internal 常先解析到 IPv6,网关只听 IPv4 → fetch failed */
try {
dns.setDefaultResultOrder('ipv4first')
} catch {
/* Node < 17 */
}
import { migrate } from './db/migrate.js'
import { pingDb } from './db/pool.js'
import { setPgEnabled } from './db/state.js'
......
......@@ -126,6 +126,13 @@ export async function refreshPointAlarms() {
publish()
}
/** 写控后立即重算快照并广播(不等下一轮 poll) */
export async function refreshSnapshotNow() {
snapshot = await buildSnapshotFromDevices()
await attachPointAlarms()
publish()
}
async function tick() {
if (tickBusy) return
tickBusy = true
......
......@@ -2,6 +2,7 @@ import { Router } from 'express'
import { authRequired, requireRole } from '../auth/middleware.js'
import { encodeCsv } from '../devices/csv.js'
import {
applyPointTelemetry,
createDevice,
createPoint,
deleteDevice,
......@@ -229,6 +230,10 @@ devicesRouter.post('/points/:id/write', authRequired, requireRole('admin'), asyn
res.status(502).json({ ok: false, message: result.message })
return
}
// 本地/ mock 立即反映写值,供大屏开关与模拟器联调
await applyPointTelemetry([
{ id: point.id, itemId: point.itemId, eng: value, raw: value, ts: new Date().toISOString() },
])
res.json({ ok: true, message: result.message, pointId: point.id, value })
} catch (e) {
res.status(500).json({ message: e instanceof Error ? e.message : '写入失败' })
......
......@@ -3,7 +3,7 @@ import { authRequired, requireRole } from '../auth/middleware.js'
import { applyPointTelemetry, listPoints } from '../devices/store.js'
import { inferPointRw } from '../devices/pointRw.js'
import { gatewayModbusWrite } from '../gateway/client.js'
import { applyWriteLog } from '../realtime/store.js'
import { applyWriteLog, refreshSnapshotNow } from '../realtime/store.js'
import {
createSensor,
deleteSensor,
......@@ -159,13 +159,30 @@ sensorsRouter.post('/:id/control', authRequired, requireRole('admin'), async (re
by: req.user!.username,
gateway: result,
})
if (!result.ok) {
res.status(502).json({ ok: false, message: result.message })
return
}
// 先落本地遥测,保证大屏开关有反馈
await applyPointTelemetry([
{ id: point.id, itemId: point.itemId, eng: value, raw: value, ts: new Date().toISOString() },
])
void refreshSnapshotNow().catch((e) =>
console.warn('[sensors] refresh snapshot after control failed', e),
)
if (!result.ok) {
const msg = result.message || '网关写点失败'
// 网关瞬时不可达:本地已切换,避免前端报超时;业务写失败仍 502
if (/fetch failed|ECONNREFUSED|aborted|timeout|超时/i.test(msg)) {
res.json({
ok: true,
message: `已切换(网关暂不可达:${msg})`,
on,
pointId: point.id,
value,
gatewayDegraded: true,
})
return
}
res.status(502).json({ ok: false, message: msg, on, pointId: point.id, value })
return
}
res.json({ ok: true, message: result.message, on, pointId: point.id, value })
} catch (e) {
res.status(500).json({ message: e instanceof Error ? e.message : '控制失败' })
......
......@@ -86,16 +86,15 @@ function defaultConfig(): VideoCommConfig {
function seedDevices(): VideoDevice[] {
const now = new Date().toISOString()
const rows: Omit<VideoDevice, 'id' | 'createdAt' | 'updatedAt'>[] = [
{ name: '走廊东', channel: 1, location: 'E栋一层走廊', deviceType: 'nvr-ch', status: 'online', enabled: true, ip: '', remark: '海康 NVR 通道' },
{ name: 'PCR区', channel: 2, location: '分子诊断 PCR', deviceType: 'nvr-ch', status: 'online', enabled: true, ip: '', remark: '海康 NVR 通道' },
{ name: '样本库', channel: 3, location: '样本库入口', deviceType: 'nvr-ch', status: 'online', enabled: true, ip: '', remark: '海康 NVR 通道' },
{ name: '机房', channel: 4, location: '弱电/机房', deviceType: 'ipc', status: 'online', enabled: true, ip: '192.168.10.211', remark: '海康网络摄像机' },
{ name: '门禁口', channel: 5, location: '主入口门禁', deviceType: 'nvr-ch', status: 'online', enabled: true, ip: '', remark: '海康 NVR 通道' },
{ name: '冷链库', channel: 6, location: '冷链监测区', deviceType: 'nvr-ch', status: 'alarm', enabled: true, ip: '', remark: '海康通道 · 联动冷柜超温' },
{ name: '走廊东', channel: 1, location: 'E栋一层走廊', deviceType: 'nvr-ch', status: 'online', enabled: true, ip: '', remark: '海康 NVR 通道', scenePoint: null },
{ name: 'PCR区', channel: 2, location: '分子诊断 PCR', deviceType: 'nvr-ch', status: 'online', enabled: true, ip: '', remark: '海康 NVR 通道', scenePoint: null },
{ name: '样本库', channel: 3, location: '样本库入口', deviceType: 'nvr-ch', status: 'online', enabled: true, ip: '', remark: '海康 NVR 通道', scenePoint: null },
{ name: '机房', channel: 4, location: '弱电/机房', deviceType: 'ipc', status: 'online', enabled: true, ip: '192.168.10.211', remark: '海康网络摄像机', scenePoint: null },
{ name: '门禁口', channel: 5, location: '主入口门禁', deviceType: 'nvr-ch', status: 'online', enabled: true, ip: '', remark: '海康 NVR 通道', scenePoint: null },
{ name: '冷链库', channel: 6, location: '冷链监测区', deviceType: 'nvr-ch', status: 'alarm', enabled: true, ip: '', remark: '海康通道 · 联动冷柜超温', scenePoint: null },
]
return rows.map((r) => ({
...r,
scenePoint: null as ScenePoint | null,
id: randomUUID(),
createdAt: now,
updatedAt: now,
......
......@@ -10,6 +10,8 @@
全部映射为 **Holding Register(4X)**;`Float`/`Double` 按 IEEE754 大端多寄存器写入。数值按正弦 + 噪声持续变化。
支持主站写:`FC05`(线圈)、`FC06`(单寄存器)、`FC16`(多寄存器)。照明 `KA*` / `开关设定` 写入后会 sticky 保持,不被自动刷新覆盖。
## 安装与运行
```bash
......
......@@ -7,6 +7,7 @@ from __future__ import annotations
import argparse
import math
import re
import socket
import struct
import sys
......@@ -256,12 +257,32 @@ def base_engineering(point: PointDef) -> float:
return 10.0 + (addr % 100) * 0.1
def refresh_values(sims: list[GatewaySim], t0: float) -> None:
def is_sticky_point(point: PointDef) -> bool:
"""照明 KA / 开关设定等:不自动翻转,写后 sticky 保持。"""
n = point.name + point.item_id
if re.search(r"(^|[.\s_])KA\d*($|[.\s_])", n, re.I) or n.strip().upper().startswith("KA"):
return True
if any(k in n for k in ("开关设定", "开关命令", "启停命令")):
return True
return False
def refresh_values(
sims: list[GatewaySim],
t0: float,
sticky: dict[str, set[int]],
) -> None:
t = time.time() - t0
for sim in sims:
if not sim.regs:
continue
held = sticky.setdefault(sim.key, set())
for i, p in enumerate(sim.points):
if p.address in held:
continue
# 照明 KA 保持寄存器当前值(初始 0=关),不自动翻转
if is_sticky_point(p):
continue
base = base_engineering(p)
wave = math.sin(t * 0.15 + i * 0.17 + p.address * 0.01)
if "bool" in p.data_type.lower() or any(
......@@ -273,7 +294,18 @@ def refresh_values(sims: list[GatewaySim], t0: float) -> None:
write_point(sim.regs, p, eng, t)
def handle_client(conn: socket.socket, regs: list[int], lock: threading.Lock) -> None:
def _exc(tid: int, unit: int, fc: int, code: int = 0x01) -> bytes:
return struct.pack(">HHHBB", tid, 0, 3, unit, (fc & 0xFF) | 0x80) + bytes([code])
def handle_client(
conn: socket.socket,
regs: list[int],
lock: threading.Lock,
sticky: set[int],
label: str,
) -> None:
"""支持 FC03 读、FC05/FC06/FC16 写(照明开关等)。"""
conn.settimeout(5)
try:
while True:
......@@ -293,22 +325,94 @@ def handle_client(conn: socket.socket, regs: list[int], lock: threading.Lock) ->
if not chunk:
return
body += chunk
if len(body) < 5 or body[0] != 0x03:
# 仅支持 FC03;其它功能码回异常
exc = struct.pack(">HHHBB", tid, 0, 3, unit, (body[0] if body else 0) | 0x80) + b"\x01"
conn.sendall(exc)
if not body:
continue
addr = struct.unpack(">H", body[1:3])[0]
count = struct.unpack(">H", body[3:5])[0]
if count < 1 or count > 125 or addr + count > len(regs):
exc = struct.pack(">HHHBB", tid, 0, 3, unit, 0x83) + b"\x02"
conn.sendall(exc)
fc = body[0]
# FC03 Read Holding Registers
if fc == 0x03:
if len(body) < 5:
conn.sendall(_exc(tid, unit, fc, 0x03))
continue
addr = struct.unpack(">H", body[1:3])[0]
count = struct.unpack(">H", body[3:5])[0]
if count < 1 or count > 125 or addr + count > len(regs):
conn.sendall(_exc(tid, unit, fc, 0x02))
continue
with lock:
data = regs[addr : addr + count]
payload = bytes([unit, 0x03, count * 2]) + b"".join(
struct.pack(">H", w & 0xFFFF) for w in data
)
conn.sendall(struct.pack(">HHH", tid, 0, len(payload)) + payload)
continue
with lock:
data = regs[addr : addr + count]
payload = bytes([unit, 0x03, count * 2]) + b"".join(struct.pack(">H", w & 0xFFFF) for w in data)
resp = struct.pack(">HHH", tid, 0, len(payload)) + payload
conn.sendall(resp)
# FC05 Write Single Coil → 映射到同址 Holding(0xFF00=ON)
if fc == 0x05:
if len(body) < 5:
conn.sendall(_exc(tid, unit, fc, 0x03))
continue
addr = struct.unpack(">H", body[1:3])[0]
raw = struct.unpack(">H", body[3:5])[0]
if addr >= len(regs):
conn.sendall(_exc(tid, unit, fc, 0x02))
continue
val = 1 if raw == 0xFF00 else 0
with lock:
regs[addr] = val
sticky.add(addr)
print(f"[write] {label} FC05 addr={addr} → {val}", flush=True)
# 回显请求
payload = bytes([unit, 0x05]) + body[1:5]
conn.sendall(struct.pack(">HHH", tid, 0, len(payload)) + payload)
continue
# FC06 Write Single Register
if fc == 0x06:
if len(body) < 5:
conn.sendall(_exc(tid, unit, fc, 0x03))
continue
addr = struct.unpack(">H", body[1:3])[0]
val = struct.unpack(">H", body[3:5])[0]
if addr >= len(regs):
conn.sendall(_exc(tid, unit, fc, 0x02))
continue
with lock:
regs[addr] = val & 0xFFFF
sticky.add(addr)
print(f"[write] {label} FC06 addr={addr} → {val}", flush=True)
payload = bytes([unit, 0x06]) + body[1:5]
conn.sendall(struct.pack(">HHH", tid, 0, len(payload)) + payload)
continue
# FC16 Write Multiple Registers
if fc == 0x10:
if len(body) < 6:
conn.sendall(_exc(tid, unit, fc, 0x03))
continue
addr = struct.unpack(">H", body[1:3])[0]
count = struct.unpack(">H", body[3:5])[0]
byte_count = body[5]
if (
count < 1
or count > 123
or byte_count != count * 2
or len(body) < 6 + byte_count
or addr + count > len(regs)
):
conn.sendall(_exc(tid, unit, fc, 0x02))
continue
with lock:
for i in range(count):
w = struct.unpack(">H", body[6 + i * 2 : 8 + i * 2])[0]
regs[addr + i] = w & 0xFFFF
sticky.add(addr + i)
print(f"[write] {label} FC16 addr={addr} count={count}", flush=True)
payload = bytes([unit, 0x10]) + struct.pack(">HH", addr, count)
conn.sendall(struct.pack(">HHH", tid, 0, len(payload)) + payload)
continue
conn.sendall(_exc(tid, unit, fc, 0x01))
except OSError:
return
finally:
......@@ -318,7 +422,12 @@ def handle_client(conn: socket.socket, regs: list[int], lock: threading.Lock) ->
pass
def serve_gateway(sim: GatewaySim, host: str, lock: threading.Lock) -> None:
def serve_gateway(
sim: GatewaySim,
host: str,
lock: threading.Lock,
sticky: set[int],
) -> None:
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.bind((host, sim.port))
......@@ -326,22 +435,32 @@ def serve_gateway(sim: GatewaySim, host: str, lock: threading.Lock) -> None:
print(f"[listen] .{sim.key} {sim.label} → {host}:{sim.port} points={len(sim.points)}", flush=True)
while True:
conn, _ = sock.accept()
threading.Thread(target=handle_client, args=(conn, sim.regs, lock), daemon=True).start()
threading.Thread(
target=handle_client,
args=(conn, sim.regs, lock, sticky, f".{sim.key}"),
daemon=True,
).start()
def run_sync(sims: list[GatewaySim], host: str, interval: float) -> None:
lock = threading.Lock()
sticky: dict[str, set[int]] = {sim.key: set() for sim in sims}
for sim in sims:
sim.regs = [0] * BLOCK_SIZE
refresh_values(sims, time.time())
refresh_values(sims, time.time(), sticky)
for sim in sims:
threading.Thread(target=serve_gateway, args=(sim, host, lock), daemon=True, name=f"mb-{sim.key}").start()
print("[ready] Ctrl+C 退出", flush=True)
threading.Thread(
target=serve_gateway,
args=(sim, host, lock, sticky[sim.key]),
daemon=True,
name=f"mb-{sim.key}",
).start()
print("[ready] 支持 FC03 读 / FC05·FC06·FC16 写(照明 KA 等) Ctrl+C 退出", flush=True)
t0 = time.time()
while True:
try:
with lock:
refresh_values(sims, t0)
refresh_values(sims, t0, sticky)
except Exception as e: # noqa: BLE001
print(f"[update] error: {e}", flush=True)
time.sleep(interval)
......
FROM node:22-alpine AS build
WORKDIR /app
COPY package.json package-lock.json* ./
COPY web/package.json ./web/
COPY server/package.json ./server/
RUN npm install --workspace=web --include=dev
COPY web ./web
WORKDIR /app/web
ARG VITE_API_BASE=
ARG VITE_VIEWPORT_MODE=rtengine
ARG VITE_RTENGINE_URL=/gis/gis.html?scene=mvlpj2yt6379aa29
ARG VITE_UE_STREAM_URL=/gis/gis.html?scene=mvlpj2yt6379aa29
ENV VITE_API_BASE=$VITE_API_BASE \
VITE_VIEWPORT_MODE=$VITE_VIEWPORT_MODE \
VITE_RTENGINE_URL=$VITE_RTENGINE_URL \
VITE_UE_STREAM_URL=$VITE_UE_STREAM_URL
RUN npx vite build
FROM nginx:1.27-alpine
COPY web/nginx.docker.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/web/dist /usr/share/nginx/html
EXPOSE 80
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
client_max_body_size 20m;
# 引擎图标:必须先于业务 /api,iframe 内绝对路径 /api/icons 同域可达
location /api/icons {
proxy_pass http://host.docker.internal:6088/api/icons;
proxy_http_version 1.1;
proxy_set_header Host $proxy_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /api/ {
proxy_pass http://server:3001/api/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /ws {
proxy_pass http://server:3001/ws;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_read_timeout 86400;
}
# RTEngineHD:同域 /gis → 引擎根(勿转到引擎空目录 /gis/)
# 原生 nginx:127.0.0.1:6088;Docker 容器内需改为 host.docker.internal:6088
location /gis/ {
proxy_pass http://host.docker.internal:6088/;
proxy_http_version 1.1;
proxy_set_header Host $proxy_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 86400;
proxy_send_timeout 86400;
proxy_buffering off;
}
location = /gis {
return 302 /gis/;
}
# 兼容旧 /rtengine 前缀
location /rtengine/ {
proxy_pass http://host.docker.internal:6088/;
proxy_http_version 1.1;
proxy_set_header Host $proxy_host;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 86400;
proxy_buffering off;
}
# 引擎 WebSocket 信令(dte.js 使用绝对路径 /peer-stream)
location /peer-stream {
# Docker 时同样改为 host.docker.internal:6088
proxy_pass http://host.docker.internal:6088/peer-stream;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $proxy_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_read_timeout 86400;
proxy_send_timeout 86400;
proxy_buffering off;
}
# 场景书签 / embed API(gis.js 绝对路径 /designer/...)
location /designer/ {
proxy_pass http://host.docker.internal:6088/designer/;
proxy_http_version 1.1;
proxy_set_header Host $proxy_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 120;
}
location / {
try_files $uri $uri/ /index.html;
}
}
......@@ -7,7 +7,7 @@ export type ScenePropertyField = {
tone?: 'ok' | 'warn' | 'muted'
}
const props = defineProps<{
defineProps<{
open: boolean
title: string
subtitle?: string
......
......@@ -21,6 +21,7 @@ import {
type ScenePoint,
} from '@/composables/useRtengineBridge'
import { useScreenModePois } from '@/composables/useScreenModePois'
import { useLightSceneEffects } from '@/composables/useLightSceneEffects'
import { useAuthStore } from '@/stores/auth'
import type { SensorSnapshot } from '@/types'
import type { VideoDevice } from '@/types/video'
......@@ -355,6 +356,18 @@ async function onLightControl(on: boolean) {
body: JSON.stringify({ on }),
})
nearSwitchOn.value = on
// 乐观更新:开=显示灯 / 关=隐藏灯(watch 会跟 on 状态;此处再强制同步一次)
const sid = nearSensorId.value
const sensors = (realtime.snapshot.sensors || []).map((s) =>
s.id === sid || s.code === sid ? { ...s, on } : s,
)
realtime.setSnapshot({
...realtime.snapshot,
ts: Date.now(),
sensors,
})
// false:只对变化的灯 setLightVisible,避免整表 clear 造成闪一下看不见
window.setTimeout(() => syncLightEffects(), 0)
} catch (e) {
nearSwitchError.value = e instanceof Error ? e.message : '控制失败'
} finally {
......@@ -540,12 +553,14 @@ const bridge = useRtengineBridge({
void onEngineReady()
bindIframePointer()
syncModePois()
syncLightEffects()
},
onMouseClick: onMarkerClick,
})
const modePoisEnabled = computed(() => props.layout === 'fill' && useCloudFrame.value)
const { syncModePois } = useScreenModePois(bridge, modePoisEnabled, cloudUrl)
const { syncLightEffects } = useLightSceneEffects(bridge, modePoisEnabled)
function cameraSceneSig(list: VideoDevice[]) {
return list
......@@ -713,18 +728,6 @@ watch(
},
)
function onSelect(id: string) {
// 兼容 marker:id 可能是 areaId 或 roomId
const byArea = areaList.value.find((r) => r.id === id)
if (byArea) {
onSelectArea(byArea.id)
return
}
const byRoom = areaList.value.find((r) => r.roomId === id)
if (byRoom) onSelectArea(byRoom.id)
else realtime.selectRoom(id)
}
onMounted(() => {
void loadCameras()
if (props.layout === 'fill') {
......
import { watch, type Ref } from 'vue'
import { storeToRefs } from 'pinia'
import { useRealtimeStore } from '@/stores/realtime'
import { useScreenModeStore } from '@/stores/screenMode'
import type { RtengineLight } from '@/composables/useRtengineBridge'
import type { ScreenMode } from '@/data/screenModePois'
import type { SensorSnapshot } from '@/types'
/** N 电:白光;E 电:淡绿(线性 RGB) */
const COLOR_N: [number, number, number, number] = [1, 1, 1, 1]
const COLOR_E: [number, number, number, number] = [0.45, 1, 0.55, 1]
const SPOT_PREFIX = 'spot:'
/** 灯具挂高:相对设施打点 z(与场景单位一致),略抬高便于朝地板打出光锥 */
const SPOT_HEIGHT = 3.6
/** candela;夜晚压暗后需更高才能在地面形成明显光斑 */
const SPOT_INTENSITY_ON = 90000
/** 衰减半径:覆盖房间尺度 */
const SPOT_RADIUS = 55
/** 内/外锥角(度):收窄后更像聚光而非点光 */
const SPOT_INNER_CONE = 18
const SPOT_OUTER_CONE = 42
/** pitch=-90:光轴垂直朝地板(与 SDK 喷水默认仰角一致) */
const SPOT_PITCH_DOWN = -90
type BridgeLike = {
ready: Ref<boolean>
addLight: (l: RtengineLight) => void
updateLight: (l: Partial<RtengineLight> & { id: string }) => void
removeLight: (id: string) => void
setLightVisible: (id: string, visible: boolean) => void
applyDayNight: (night: boolean) => void
}
function isLightMode(mode: ScreenMode) {
return mode === 'lightN' || mode === 'lightE'
}
function spotId(sensorId: string) {
return `${SPOT_PREFIX}${sensorId}`
}
function lightsOf(snap: { sensors?: SensorSnapshot[] }, mode: ScreenMode): SensorSnapshot[] {
const list = snap.sensors || []
if (mode === 'lightN') return list.filter((s) => s.category === 'lightN')
if (mode === 'lightE') return list.filter((s) => s.category === 'lightE')
return []
}
function toSpot(s: SensorSnapshot, on: boolean): RtengineLight {
const isE = s.category === 'lightE'
const z = Number(s.scene?.z) || 51
return {
id: spotId(s.id),
name: s.name,
// 强制聚光灯(非点光),朝地板照射
type: 'spot',
x: Number(s.scene?.x) || 0,
y: Number(s.scene?.y) || 0,
z: z + SPOT_HEIGHT,
yaw: 0,
pitch: SPOT_PITCH_DOWN,
roll: 0,
intensity: on ? SPOT_INTENSITY_ON : 0,
attenuationRadius: SPOT_RADIUS,
innerConeAngle: SPOT_INNER_CONE,
outerConeAngle: SPOT_OUTER_CONE,
color: isE ? COLOR_E : COLOR_N,
useTemperature: false,
castShadows: true,
visible: on,
tag0: 'facility-spot',
tag1: s.category,
tag2: s.id,
}
}
/**
* 照明模式:夜晚压暗 + 每盏设施 spotlight。
* 开 → 显示灯;关 → 隐藏灯(setLightVisible)。
*/
export function useLightSceneEffects(bridge: BridgeLike, enabled: Ref<boolean>) {
const modeStore = useScreenModeStore()
const realtime = useRealtimeStore()
const { mode } = storeToRefs(modeStore)
let lastMode: ScreenMode | '' = ''
/** sensorId → 是否开 */
const knownOn = new Map<string, boolean>()
let activeSpotIds: string[] = []
let nightOn = false
let timer: number | undefined
function clearSpots() {
for (const id of activeSpotIds) {
try {
bridge.setLightVisible(id, false)
bridge.removeLight(id)
} catch {
/* ignore */
}
}
activeSpotIds = []
knownOn.clear()
}
function ensureNight(want: boolean, force = false) {
if (!force && want === nightOn) return
bridge.applyDayNight(want)
nightOn = want
}
/** 开:确保灯存在并显示;关:隐藏 */
function applyOne(s: SensorSnapshot, on: boolean, recreate: boolean) {
const id = spotId(s.id)
const spot = toSpot(s, on)
if (recreate || !activeSpotIds.includes(id)) {
bridge.addLight(spot)
if (!activeSpotIds.includes(id)) activeSpotIds.push(id)
} else {
// 增量更新也带上 type/朝向/锥角,避免引擎回落成点光
bridge.updateLight({
id,
type: 'spot',
intensity: spot.intensity,
color: spot.color,
visible: on,
x: spot.x,
y: spot.y,
z: spot.z,
yaw: spot.yaw,
pitch: spot.pitch,
roll: spot.roll,
attenuationRadius: spot.attenuationRadius,
innerConeAngle: spot.innerConeAngle,
outerConeAngle: spot.outerConeAngle,
})
}
// 用户要求:开=显示 light,关=隐藏 light
bridge.setLightVisible(id, on)
knownOn.set(s.id, on)
}
function sync(force = false) {
if (!enabled.value || !bridge.ready.value) return
const current = mode.value
const lighting = isLightMode(current)
const modeChanged = current !== lastMode
if (!lighting) {
if (nightOn || activeSpotIds.length || modeChanged) {
clearSpots()
ensureNight(false, true)
}
lastMode = current
return
}
ensureNight(true, force || modeChanged)
const sensors = lightsOf(realtime.snapshot, current)
if (force || modeChanged) {
clearSpots()
for (const s of sensors) {
applyOne(s, s.on === true, true)
}
lastMode = current
return
}
// 仅开关变化:显示 / 隐藏,不整表重建
const keep = new Set(sensors.map((s) => s.id))
for (const s of sensors) {
const on = s.on === true
const prev = knownOn.get(s.id)
if (prev === on && activeSpotIds.includes(spotId(s.id))) continue
applyOne(s, on, false)
}
for (const id of [...activeSpotIds]) {
const sid = id.slice(SPOT_PREFIX.length)
if (!keep.has(sid)) {
bridge.setLightVisible(id, false)
bridge.removeLight(id)
activeSpotIds = activeSpotIds.filter((x) => x !== id)
knownOn.delete(sid)
}
}
lastMode = current
}
function schedule() {
window.clearTimeout(timer)
timer = window.setTimeout(() => sync(false), 80)
}
watch(
[mode, enabled, () => bridge.ready.value],
() => {
window.clearTimeout(timer)
timer = window.setTimeout(() => sync(true), 40)
},
{ flush: 'post' },
)
watch(
() => {
const lights = (realtime.snapshot.sensors || []).filter(
(s) => s.category === 'lightN' || s.category === 'lightE',
)
return lights.map((s) => `${s.id}:${s.on === true ? 1 : 0}`).join(';')
},
() => schedule(),
)
return {
/** 默认增量同步(显隐);传 true 时重建全部 spotlight */
syncLightEffects: (force = false) => sync(force),
clearSpots,
}
}
......@@ -32,6 +32,7 @@ type BridgeLike = {
addMarkers: (list: RtengineMarker[]) => void
removeMarker: (id: string) => void
updateMarker: (m: RtengineMarker) => void
updateMarkerText?: (id: string, text: string) => void
setMarkerVisible: (id: string, visible: boolean) => void
showPois: (tag0?: string) => void
hidePois: (tag0?: string) => void
......@@ -150,16 +151,6 @@ function sensorsOf(
return (snap.sensors || []).filter((s) => cats.includes(s.category))
}
const SENSOR_MODES = new Set([
'pressure',
'lightN',
'lightE',
'fridge',
'vav',
'camera',
'access',
])
/** 主要分类走设施快照;监控仍可由 SceneViewport 叠加视频台账点 */
function markersForMode(mode: ScreenMode, snap: RealtimeSnapshot): RtengineMarker[] {
if (mode === 'pressure') {
......@@ -202,26 +193,31 @@ export function useScreenModePois(bridge: BridgeLike, enabled: Ref<boolean>, _cl
const { mode } = storeToRefs(modeStore)
let activeIds: string[] = []
let lastSig = ''
/** 结构签名:id/图标/坐标;不含实时数值,避免每轮采集触发整表闪烁 */
let lastStructSig = ''
let lastTextSig = ''
let lastMode: ScreenMode | '' = ''
let lastCadMode: ScreenMode | '' = ''
/** 已对引擎下发的 show/hide tag 集合签名,避免重复 showPOIs 打闪 */
let lastTagVisSig = ''
let timer: number | undefined
let poll: number | undefined
let pollsLeft = 0
function armPoll() {
pollsLeft = 24
// 仅在切换分类后短暂补一次「单点可见」,绝不反复 showPOIs/hidePOIs
pollsLeft = 2
if (poll) return
poll = window.setInterval(() => {
pollsLeft -= 1
if (enabled.value && bridge.ready.value) {
applyCadVisibility(mode.value)
revealActiveMarkers()
for (const id of activeIds) bridge.setMarkerVisible(id, true)
}
if (pollsLeft <= 0 && poll) {
window.clearInterval(poll)
poll = undefined
}
}, 500)
}, 800)
}
/** 当前分类应显示的 tag0 集合 */
......@@ -236,13 +232,27 @@ export function useScreenModePois(bridge: BridgeLike, enabled: Ref<boolean>, _cl
return new Set()
}
function tagVisSig(showTags: Set<string>) {
return [...showTags].sort().join(',')
}
/** 引擎预设 / CAD 点:按分类显隐(摄像头留给 SceneViewport) */
function applyCadVisibility(current: ScreenMode) {
function applyCadVisibility(current: ScreenMode, force = false) {
const showTags = tagsForMode(current)
for (const tag of ALL_DEVICE_TAGS) {
if (tag === 'camera') continue
if (showTags.has(tag)) bridge.showPois(tag)
else bridge.hidePois(tag)
const sig = tagVisSig(showTags)
const modeSame = current === lastCadMode
const tagsSame = sig === lastTagVisSig
if (!force && modeSame && tagsSame) return
lastCadMode = current
lastTagVisSig = sig
// 仅分类切换时才对引擎做 tag 级 show/hide(整屏刷新很重)
if (force || !modeSame || !tagsSame) {
for (const tag of ALL_DEVICE_TAGS) {
if (tag === 'camera') continue
if (showTags.has(tag)) bridge.showPois(tag)
else bridge.hidePois(tag)
}
}
const live = bridge.listMarkers?.() || []
......@@ -258,11 +268,6 @@ export function useScreenModePois(bridge: BridgeLike, enabled: Ref<boolean>, _cl
function revealActiveMarkers() {
for (const id of activeIds) bridge.setMarkerVisible(id, true)
const showTags = tagsForMode(mode.value)
for (const tag of showTags) {
if (tag === 'camera') continue
bridge.showPois(tag)
}
}
function clearModeMarkers() {
......@@ -282,7 +287,40 @@ export function useScreenModePois(bridge: BridgeLike, enabled: Ref<boolean>, _cl
}
}
activeIds = []
lastSig = ''
lastStructSig = ''
lastTextSig = ''
lastCadMode = ''
lastTagVisSig = ''
}
function structSigOf(markers: RtengineMarker[]) {
// 按 id 排序,避免传感器数组顺序抖动触发整表重建
return [...markers]
.sort((a, b) => a.id.localeCompare(b.id))
.map((m) => {
const x = Number(m.x).toFixed(5)
const y = Number(m.y).toFixed(5)
const z = Number(m.z).toFixed(2)
return `${m.id}|${m.icon || ''}|${m.type}|${x},${y},${z}`
})
.join(';')
}
function textSigOf(markers: RtengineMarker[]) {
return [...markers]
.sort((a, b) => a.id.localeCompare(b.id))
.map((m) => `${m.id}|${m.text || ''}`)
.join(';')
}
function refreshTexts(markers: RtengineMarker[]) {
for (const m of markers) {
if (bridge.updateMarkerText && m.text != null) {
bridge.updateMarkerText(m.id, String(m.text))
} else {
bridge.updateMarker(m)
}
}
}
function sync(force = false) {
......@@ -292,19 +330,24 @@ export function useScreenModePois(bridge: BridgeLike, enabled: Ref<boolean>, _cl
const modeChanged = current !== lastMode
const snap = realtime.snapshot
const markers = markersForMode(current, snap)
const sig = markers
.map((m) => `${m.id}|${m.text}|${m.icon || ''}|${m.x},${m.y},${m.z}`)
.join(';')
if (!force && !modeChanged && sig === lastSig) {
applyCadVisibility(current)
revealActiveMarkers()
const structSig = structSigOf(markers)
const textSig = textSigOf(markers)
// 结构未变:只改文案,绝不 show/hide / addMarker
if (!force && !modeChanged && structSig === lastStructSig) {
if (textSig !== lastTextSig) {
refreshTexts(markers)
lastTextSig = textSig
}
lastMode = current
return
}
// 分类切换:先卸旧点、滤掉其它 tag,再写入当前类
if (force || modeChanged) {
clearModeMarkers()
lastCadMode = ''
lastTagVisSig = ''
for (const tag of ALL_DEVICE_TAGS) {
if (tag === 'camera') continue
bridge.hidePois(tag)
......@@ -323,24 +366,26 @@ export function useScreenModePois(bridge: BridgeLike, enabled: Ref<boolean>, _cl
if (force || modeChanged || !activeIds.length || markers.length !== activeIds.length) {
bridge.addMarkers(markers)
} else {
for (const m of markers) bridge.updateMarker(m)
refreshTexts(markers)
}
}
activeIds = markers.map((m) => m.id)
lastSig = sig
lastStructSig = structSig
lastTextSig = textSig
lastMode = current
applyCadVisibility(current)
// 引擎异步过滤:稍后强制再亮一次当前运行时点
applyCadVisibility(current, force || modeChanged)
revealActiveMarkers()
window.setTimeout(() => revealActiveMarkers(), 200)
window.setTimeout(() => revealActiveMarkers(), 800)
if (force || modeChanged) {
window.setTimeout(() => revealActiveMarkers(), 400)
}
}
function scheduleSync() {
window.clearTimeout(timer)
timer = window.setTimeout(() => sync(false), 300)
// 实时数值合并;文案用 updateMarkerText,不再重建 Marker
timer = window.setTimeout(() => sync(false), 1200)
}
async function syncModePois() {
......
......@@ -56,12 +56,6 @@ const typeLabel: Record<DeviceType, string> = {
'nvr-ch': '海康 NVR 通道',
}
const protocolLabel: Record<VideoProtocol, string> = {
sdk: 'HCNetSDK',
rtsp: 'RTSP',
isapi: 'ISAPI',
}
const onlineCount = computed(() => devices.value.filter((d) => d.enabled && d.status === 'online').length)
const previewList = computed(() => devices.value.filter((d) => d.enabled))
const selected = computed(
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment