Commit 8a2514de by James Yuan

feat: 网关主动上报测点落库,点表与模拟器倍率对齐

Windows 网关拉取平台点表并 POST telemetry;本机联调内置与 Python 模拟器一致的温湿/冰箱工程值公式。

Co-authored-by: Cursor <cursoragent@cursor.com>
parent 744cee89
......@@ -19,6 +19,8 @@ DATA_MODE=mock
# Windows 协议网关(现场采集机;本机 Docker 可留空)
GATEWAY_URL=
# 网关上报鉴权(可选;空=开发放行)。网关请求头 X-Gateway-Key
# GATEWAY_INGEST_KEY=
# WebRTC WHEP 媒体面:本机 Compose 默认 mediamtx;有 GATEWAY 且无本项时走网关 /webrtc/whep/:id
WEBRTC_WHEP_BASE=http://mediamtx:8889
WEBRTC_ICE_HOST=127.0.0.1
......@@ -39,6 +41,10 @@ MODBUS_PAU2_HOST=192.168.10.103
MODBUS_ROOM_HOST=192.168.10.104
MODBUS_FRIDGE_HOST=192.168.10.104
MODBUS_PORT=502
# 联调模拟器可分别设 1502/1503/1504;未设则回落 MODBUS_PORT
# MODBUS_PAU1_PORT=1502
# MODBUS_PAU2_PORT=1503
# MODBUS_ROOM_PORT=1504
MODBUS_POLL_MS=2000
# 兼容旧配置:门禁桥接;优先使用 GATEWAY_URL
......@@ -51,5 +57,7 @@ ADMIN_PASSWORD=Admin@123
# Web (Vite 开发用;容器部署走 Nginx 同域反代,构建时 VITE_API_BASE 留空)
VITE_API_BASE=
VITE_WS_URL=
VITE_VIEWPORT_MODE=glb
VITE_UE_STREAM_URL=
# 三维视口:glb | ue-stream | rtengine(RTEngineHD 云渲染 iframe)
VITE_VIEWPORT_MODE=rtengine
VITE_RTENGINE_URL=http://192.168.32.121:6088/gis.html
VITE_UE_STREAM_URL=http://192.168.32.121:6088/gis.html
......@@ -74,8 +74,9 @@ npm run dev
- `DATABASE_URL` PostgreSQL 连接串
- `DATA_MODE=mock|modbus`
- `VITE_VIEWPORT_MODE=glb|ue-stream`
- `VITE_UE_STREAM_URL=` UE 推流地址
- `VITE_VIEWPORT_MODE=glb|ue-stream|rtengine`
- `VITE_RTENGINE_URL=` RTEngineHD 云渲染页(默认 `http://192.168.32.121:6088/gis.html`)
- `VITE_UE_STREAM_URL=` UE/云渲染 iframe 备用地址
- `ACCESS_BRIDGE_URL=` 门禁桥接
## 目录
......
......@@ -73,8 +73,9 @@ services:
dockerfile: web/Dockerfile
args:
VITE_API_BASE: ""
VITE_VIEWPORT_MODE: ${VITE_VIEWPORT_MODE:-glb}
VITE_UE_STREAM_URL: ${VITE_UE_STREAM_URL:-}
VITE_VIEWPORT_MODE: ${VITE_VIEWPORT_MODE:-rtengine}
VITE_RTENGINE_URL: ${VITE_RTENGINE_URL:-http://192.168.32.121:6088/gis.html}
VITE_UE_STREAM_URL: ${VITE_UE_STREAM_URL:-http://192.168.32.121:6088/gis.html}
container_name: jh-lab-web
restart: unless-stopped
depends_on:
......
......@@ -68,6 +68,22 @@ Body:全量点位数组(与平台 `device_points` 导出字段一致),
`WS /telemetry` — 推送同上结构的增量或全量帧(`type: "telemetry"`)。
### 上报本机平台落库(推荐)
网关每轮 Modbus 轮询后,向 `platformUrl` 主动 POST:
`POST {platformUrl}/api/gateway/telemetry`
Body 与 `GET /snapshot` 相同。平台写入 `device_points.last_raw / last_eng / last_ts`。
启动时网关还会:
`GET {platformUrl}/api/gateway/points-bundle`
拉取与后台一致的点表(同源协议 CSV 种子),保证 ID/倍率与 Python 模拟器一致。
可选鉴权头:`X-Gateway-Key: <GATEWAY_INGEST_KEY>`(平台 `.env`;空则开发放行)。
## WebRTC(WHEP,已定稿)
大屏/后台实况统一走 WebRTC。浏览器经 Node `POST /api/video/devices/:id/whep` 反代到网关:
......
......@@ -6,9 +6,9 @@
"adminUsername": "admin",
"adminPassword": "Admin@123",
"modbusHosts": [
{ "id": "mb-102", "name": "PAU-201-1(本机模拟)", "host": "192.168.32.122", "port": 1502, "enabled": true },
{ "id": "mb-103", "name": "计量/通风柜(本机模拟)", "host": "192.168.32.122", "port": 1503, "enabled": true },
{ "id": "mb-104", "name": "房间/冰箱(本机模拟)", "host": "192.168.32.122", "port": 1504, "enabled": true }
{ "id": "mb-102", "name": "PAU-201-1(Windows 本机模拟)", "host": "127.0.0.1", "port": 11502, "enabled": true },
{ "id": "mb-103", "name": "计量/通风柜(Windows 本机模拟)", "host": "127.0.0.1", "port": 11503, "enabled": true },
{ "id": "mb-104", "name": "房间/冰箱(Windows 本机模拟)", "host": "127.0.0.1", "port": 11504, "enabled": true }
],
"nvr": {
"id": "nvr-140",
......
$ErrorActionPreference = 'Continue'
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
netsh advfirewall firewall show rule name="JhLabGateway 38080"
Write-Host '=== platform bundle ==='
try {
$raw = (Invoke-WebRequest -Uri 'http://192.168.32.122:3001/api/gateway/points-bundle' -UseBasicParsing -TimeoutSec 20).Content
$j = $raw | ConvertFrom-Json
Write-Host ("bundle ok=$($j.ok) points=$($j.points.Count) ver=$($j.pointsVersion)")
if ($j.points.Count -gt 0) {
$p0 = $j.points[0]
Write-Host ("sample0 id=$($p0.id) name=$($p0.name) ov=$($p0.scaleOverride)")
}
} catch {
Write-Host ("bundle FAIL: " + $_.Exception.Message)
}
Write-Host '=== restart gateway ==='
Set-Location C:\jh-lab\gateway\win-protocol\build
Stop-Process -Name JhLabGateway -Force -ErrorAction SilentlyContinue
Start-Sleep -Seconds 1
Start-Process -FilePath .\JhLabGateway.exe -ArgumentList 'config.json' -WorkingDirectory (Get-Location).Path -WindowStyle Hidden
Start-Sleep -Seconds 5
Get-Process JhLabGateway -ErrorAction SilentlyContinue | Format-List Id
netstat -ano | Select-String '38080'
try {
Write-Host ((Invoke-WebRequest -Uri 'http://127.0.0.1:38080/config/points' -UseBasicParsing -TimeoutSec 3).Content)
} catch {
Write-Host $_.Exception.Message
}
try {
$s = (Invoke-WebRequest -Uri 'http://127.0.0.1:38080/snapshot' -UseBasicParsing -TimeoutSec 8).Content
Write-Host ("snap_len=" + $s.Length)
# count good fridge
if ($s -match '"values":\[(.+)\]') { Write-Host 'has values array' }
$fridgeHits = ([regex]::Matches($s, '冰箱')).Count
Write-Host ("fridge_token_count=" + $fridgeHits)
Write-Host $s.Substring(0, [Math]::Min(300, $s.Length))
} catch {
Write-Host $_.Exception.Message
}
$ErrorActionPreference = 'Continue'
Write-Host '=== extract protocol ==='
Set-Location C:\jh-lab
if (Test-Path protocol\data) { Remove-Item protocol\data -Recurse -Force -ErrorAction SilentlyContinue }
New-Item -ItemType Directory -Force -Path protocol | Out-Null
tar -xzf protocol.tgz -C protocol
Get-ChildItem protocol -Recurse -Filter *.csv | Measure-Object | ForEach-Object { Write-Host ("csv_count=" + $_.Count) }
$csvRoot = Get-ChildItem protocol -Directory | Select-Object -First 1
if ($csvRoot) {
Write-Host ("csv_root=" + $csvRoot.FullName)
# expect 机组01 under this
Get-ChildItem $csvRoot.FullName | Select-Object Name
}
Write-Host '=== pip pymodbus ==='
& D:\Programs\Python\Python313\python.exe -m pip install -q pymodbus
Write-Host '=== update gateway config to 127.0.0.1 ==='
$cfgPath = 'C:\jh-lab\gateway\win-protocol\build\config.json'
$cfg = Get-Content $cfgPath -Raw -Encoding UTF8 | ConvertFrom-Json
foreach ($h in $cfg.modbusHosts) {
$h.host = '127.0.0.1'
}
$cfg.platformUrl = 'http://192.168.32.122:3001'
($cfg | ConvertTo-Json -Depth 8) | Set-Content $cfgPath -Encoding UTF8
# also repo config
Copy-Item $cfgPath 'C:\jh-lab\gateway\win-protocol\config.json' -Force
Write-Host 'config hosts -> 127.0.0.1'
Write-Host '=== start sim ==='
Get-Process python -ErrorAction SilentlyContinue | Where-Object { $_.Path -like '*Python313*' } | Stop-Process -Force -ErrorAction SilentlyContinue
$simPy = 'C:\jh-lab\modbus_sim\simulate.py'
if (-not (Test-Path $simPy)) { Copy-Item C:\jh-lab\simulate.py $simPy -Force }
$root = (Get-ChildItem C:\jh-lab\protocol -Directory | Select-Object -First 1).FullName
$arg = "`"$simPy`" --csv-root `"$root`""
Start-Process -FilePath 'D:\Programs\Python\Python313\python.exe' -ArgumentList $arg -WorkingDirectory 'C:\jh-lab\modbus_sim' -WindowStyle Hidden -RedirectStandardOutput 'C:\jh-lab\modbus_sim\out.log' -RedirectStandardError 'C:\jh-lab\modbus_sim\err.log'
Start-Sleep -Seconds 4
Get-Content C:\jh-lab\modbus_sim\out.log -ErrorAction SilentlyContinue
Get-Content C:\jh-lab\modbus_sim\err.log -ErrorAction SilentlyContinue
netstat -ano | Select-String '1502|1503|1504'
......@@ -52,7 +52,7 @@ inline bool readHolding(const std::string& host, int port, int unitId, int addre
SOCKET sock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
if (sock == INVALID_SOCKET) return false;
DWORD timeout = 2000;
DWORD timeout = 800;
setsockopt(sock, SOL_SOCKET, SO_RCVTIMEO, reinterpret_cast<const char*>(&timeout), sizeof(timeout));
setsockopt(sock, SOL_SOCKET, SO_SNDTIMEO, reinterpret_cast<const char*>(&timeout), sizeof(timeout));
......@@ -60,7 +60,6 @@ inline bool readHolding(const std::string& host, int port, int unitId, int addre
addr.sin_family = AF_INET;
addr.sin_port = htons(static_cast<u_short>(port));
if (InetPtonA(AF_INET, host.c_str(), &addr.sin_addr) != 1) {
// 尝试解析主机名
addrinfo hints{};
hints.ai_family = AF_INET;
hints.ai_socktype = SOCK_STREAM;
......
$ErrorActionPreference = 'Stop'
$ErrorActionPreference = 'Continue'
$dir = 'C:\jh-lab\gateway\win-protocol\build'
$exe = Join-Path $dir 'JhLabGateway.exe'
$cfgSrc = 'C:\jh-lab\gateway\win-protocol\config.json'
Copy-Item -Force $cfgSrc (Join-Path $dir 'config.json')
Get-Process JhLabGateway -ErrorAction SilentlyContinue | Stop-Process -Force
Start-Sleep -Milliseconds 500
$p = Start-Process -FilePath $exe -ArgumentList 'config.json' `
-WorkingDirectory $dir `
-RedirectStandardOutput (Join-Path $dir 'out.log') `
-RedirectStandardError (Join-Path $dir 'err.log') `
-PassThru -WindowStyle Hidden
Start-Sleep -Seconds 2
Write-Output ("pid=" + $p.Id)
Write-Output '--- out.log ---'
Get-Content (Join-Path $dir 'out.log') -ErrorAction SilentlyContinue
Write-Output '--- err.log ---'
Get-Content (Join-Path $dir 'err.log') -ErrorAction SilentlyContinue
Write-Output '--- netstat ---'
netstat -ano | findstr 38080
Set-Location $dir
Stop-Process -Name JhLabGateway -Force -ErrorAction SilentlyContinue
Start-Sleep -Seconds 1
# 通过 cmd 重定向日志,避免 PowerShell RedirectStandardOutput 句柄问题
$cmd = 'JhLabGateway.exe config.json >> out.log 2>>&1'
Start-Process -FilePath 'cmd.exe' -ArgumentList '/c', $cmd -WorkingDirectory $dir -WindowStyle Hidden
Start-Sleep -Seconds 6
Write-Host '=== process ==='
Get-Process JhLabGateway -ErrorAction SilentlyContinue | Format-List Id, StartTime
Write-Host '=== out.log ==='
Get-Content .\out.log -Tail 40 -ErrorAction SilentlyContinue
Write-Host '=== err via type ==='
if (Test-Path .\err.log) { Get-Content .\err.log -Tail 20 }
Write-Host '=== local http ==='
try {
Write-Host ((Invoke-WebRequest 'http://127.0.0.1:38080/config/points' -UseBasicParsing -TimeoutSec 5).Content)
} catch { Write-Host $_.Exception.Message }
$ErrorActionPreference = 'Continue'
$root = (Get-ChildItem C:\jh-lab\protocol -Directory | Select-Object -First 1).FullName
Write-Host "ROOT=$root"
Get-ChildItem $root -Directory | ForEach-Object { Write-Host $_.Name }
Get-CimInstance Win32_Process -Filter "Name='python.exe'" |
Where-Object { $_.CommandLine -match 'simulate.py' } |
ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }
Start-Sleep -Seconds 1
$logOut = 'C:\jh-lab\modbus_sim\out.log'
$logErr = 'C:\jh-lab\modbus_sim\err.log'
Remove-Item $logOut, $logErr -ErrorAction SilentlyContinue
# 与可工作的 miniserver 相同启动方式;绑定 127.0.0.1 + 11502-11504
$p = Start-Process -FilePath 'D:\Programs\Python\Python313\python.exe' `
-ArgumentList @(
'-u', 'C:\jh-lab\modbus_sim\simulate.py',
'--csv-root', $root,
'--host', '127.0.0.1',
'--port-102', '11502',
'--port-103', '11503',
'--port-104', '11504'
) `
-WorkingDirectory 'C:\jh-lab\modbus_sim' `
-RedirectStandardOutput $logOut -RedirectStandardError $logErr `
-PassThru -WindowStyle Hidden
Start-Sleep -Seconds 5
Write-Host "sim_pid=$($p.Id) exited=$($p.HasExited)"
Write-Host '=== out ==='
Get-Content $logOut -ErrorAction SilentlyContinue
Write-Host '=== err ==='
Get-Content $logErr -ErrorAction SilentlyContinue
Write-Host '=== ports ==='
netstat -ano | Select-String '1502|1503|1504'
foreach ($p in 1502, 1503, 1504, 3001) {
try {
$tcp = New-Object Net.Sockets.TcpClient
$iar = $tcp.BeginConnect('192.168.32.122', $p, $null, $null)
if (-not $iar.AsyncWaitHandle.WaitOne(2500, $false)) {
Write-Host "$p TIMEOUT"
$tcp.Close()
continue
}
$tcp.EndConnect($iar)
Write-Host "$p OK"
$tcp.Close()
} catch {
Write-Host "$p FAIL $($_.Exception.Message)"
}
}
$ErrorActionPreference = 'Continue'
Set-Location C:\jh-lab\gateway\win-protocol\build
Write-Host '=== ensure gateway ==='
if (-not (Get-Process JhLabGateway -ErrorAction SilentlyContinue)) {
Start-Process -FilePath .\JhLabGateway.exe -ArgumentList 'config.json' -WorkingDirectory (Get-Location).Path -WindowStyle Hidden
Start-Sleep -Seconds 3
}
Get-Process JhLabGateway | Format-List Id
Write-Host ((Invoke-WebRequest -Uri 'http://127.0.0.1:38080/config/points' -UseBasicParsing).Content)
Write-Host '=== download bundle ==='
$bundle = (Invoke-WebRequest -Uri 'http://192.168.32.122:3001/api/gateway/points-bundle' -UseBasicParsing -TimeoutSec 30).Content
Write-Host ("bundle_len=" + $bundle.Length)
[System.IO.File]::WriteAllText("$pwd\bundle.json", $bundle)
Write-Host '=== POST reload to local gateway ==='
try {
$r = Invoke-WebRequest -Uri 'http://127.0.0.1:38080/config/points/reload' -Method POST -Body $bundle -ContentType 'application/json; charset=utf-8' -UseBasicParsing -TimeoutSec 60
Write-Host ("reload status=" + $r.StatusCode + " body=" + $r.Content)
} catch {
Write-Host ("reload FAIL: " + $_.Exception.Message)
}
Start-Sleep -Seconds 2
Write-Host '=== after reload ==='
Get-Process JhLabGateway -ErrorAction SilentlyContinue | Format-List Id
try {
Write-Host ((Invoke-WebRequest -Uri 'http://127.0.0.1:38080/config/points' -UseBasicParsing -TimeoutSec 3).Content)
} catch { Write-Host $_.Exception.Message }
Start-Sleep -Seconds 4
Write-Host '=== snapshot after poll ==='
try {
$s = (Invoke-WebRequest -Uri 'http://127.0.0.1:38080/snapshot' -UseBasicParsing -TimeoutSec 10).Content
Write-Host ("snap_len=" + $s.Length)
$fridge = ([regex]::Matches($s, '冰箱温度')).Count
Write-Host ("fridge_temp_tokens=" + $fridge)
# extract a few eng values near 冰箱
$m = [regex]::Match($s, '冰箱温度[^\}]{0,200}')
if ($m.Success) { Write-Host $m.Value.Substring(0, [Math]::Min(180, $m.Value.Length)) }
} catch { Write-Host $_.Exception.Message }
Write-Host '=== push telemetry manually sample ==='
try {
$snap = (Invoke-WebRequest -Uri 'http://127.0.0.1:38080/snapshot' -UseBasicParsing -TimeoutSec 10).Content
$pr = Invoke-WebRequest -Uri 'http://192.168.32.122:3001/api/gateway/telemetry' -Method POST -Body $snap -ContentType 'application/json; charset=utf-8' -UseBasicParsing -TimeoutSec 30
Write-Host ("telemetry status=" + $pr.StatusCode + " body=" + $pr.Content.Substring(0, [Math]::Min(200, $pr.Content.Length)))
} catch { Write-Host ("telemetry FAIL: " + $_.Exception.Message) }
$ErrorActionPreference = 'Continue'
Write-Host '=== process ==='
Get-Process JhLabGateway -ErrorAction SilentlyContinue | Format-List Id
Get-Content C:\jh-lab\gateway\win-protocol\build\watchdog.log -Tail 15 -ErrorAction SilentlyContinue
Write-Host '=== points ==='
try {
Write-Host ((Invoke-WebRequest 'http://127.0.0.1:38080/config/points' -UseBasicParsing -TimeoutSec 5).Content)
} catch { Write-Host $_.Exception.Message }
Write-Host '=== snapshot fridge ==='
try {
$s = (Invoke-WebRequest 'http://127.0.0.1:38080/snapshot' -UseBasicParsing -TimeoutSec 15).Content
Write-Host ("snap_len=" + $s.Length)
$j = $s | ConvertFrom-Json
$good = @($j.values | Where-Object { $_.quality -eq 'good' })
Write-Host ("values=" + $j.values.Count + " good=" + $good.Count)
$fr = @($j.values | Where-Object { $_.itemId -match '冰箱' -and $_.itemId -match '温度' })
Write-Host ("fridge_temp=" + $fr.Count)
foreach ($v in $fr | Select-Object -First 5) {
$tail = if ($v.itemId.Length -gt 42) { $v.itemId.Substring($v.itemId.Length - 42) } else { $v.itemId }
Write-Host (" $tail raw=$($v.raw) eng=$($v.eng) q=$($v.quality)")
}
} catch { Write-Host $_.Exception.Message }
# 保活 JhLabGateway:崩溃后自动拉起;启动后从平台拉点表(进程内已实现)
$ErrorActionPreference = 'Continue'
$dir = 'C:\jh-lab\gateway\win-protocol\build'
$exe = Join-Path $dir 'JhLabGateway.exe'
$log = Join-Path $dir 'watchdog.log'
function Log($m) {
$line = "$(Get-Date -Format o) $m"
Add-Content -Path $log -Value $line -Encoding UTF8
Write-Host $line
}
Log 'watchdog start'
while ($true) {
$p = Get-Process JhLabGateway -ErrorAction SilentlyContinue
if (-not $p) {
Log 'starting JhLabGateway'
Start-Process -FilePath $exe -ArgumentList 'config.json' -WorkingDirectory $dir -WindowStyle Hidden
Start-Sleep -Seconds 8
try {
$c = (Invoke-WebRequest -Uri 'http://127.0.0.1:38080/config/points' -UseBasicParsing -TimeoutSec 3).Content
Log "points $c"
} catch {
Log ("health fail: " + $_.Exception.Message)
}
}
Start-Sleep -Seconds 5
}
......@@ -26,6 +26,10 @@ export const config = {
room: process.env.MODBUS_ROOM_HOST || '192.168.10.104',
fridge: process.env.MODBUS_FRIDGE_HOST || '192.168.10.104',
port: Number(process.env.MODBUS_PORT || 502),
/** 三网关端口(联调模拟器默认 1502/1503/1504;现场多为同端口 502) */
portPau1: Number(process.env.MODBUS_PAU1_PORT || process.env.MODBUS_PORT || 502),
portPau2: Number(process.env.MODBUS_PAU2_PORT || process.env.MODBUS_PORT || 502),
portRoom: Number(process.env.MODBUS_ROOM_PORT || process.env.MODBUS_PORT || 502),
pollMs: Number(process.env.MODBUS_POLL_MS || 2000),
},
accessBridgeUrl: process.env.ACCESS_BRIDGE_URL || '',
......
......@@ -116,7 +116,7 @@ function defaultGateways(): Gateway[] {
name: 'PAU-201-1 机组',
kind: 'modbus_host',
host: config.modbus.pau1,
port: config.modbus.port,
port: config.modbus.portPau1,
enabled: true,
meta: { role: 'pau' },
lastOkAt: null,
......@@ -128,7 +128,7 @@ function defaultGateways(): Gateway[] {
name: 'PAU-201-2 计量/通风柜',
kind: 'modbus_host',
host: config.modbus.pau2,
port: config.modbus.port,
port: config.modbus.portPau2,
enabled: true,
meta: { role: 'meter_vav' },
lastOkAt: null,
......@@ -140,7 +140,7 @@ function defaultGateways(): Gateway[] {
name: '房间屏/PLC/冰箱/N·E电',
kind: 'modbus_host',
host: config.modbus.room,
port: config.modbus.port,
port: config.modbus.portRoom,
enabled: true,
meta: { role: 'room' },
lastOkAt: null,
......@@ -150,15 +150,22 @@ function defaultGateways(): Gateway[] {
]
}
/** 与 Python 模拟器一致:温/湿/压差/冰箱 Short·Word 在 Scale=0 时按 *0.1 */
function resolveScaleOverride(row: ParsedPointRow): number | null {
if (row.scale !== 0) return null
const dtype = (row.dataType || '').toLowerCase()
if (dtype.includes('float') || dtype.includes('double') || dtype.includes('bool')) return null
const n = `${row.name} ${row.itemId}`
if (/温度|湿度|压差|Temp|Humid|冰箱|冷链/i.test(n)) return 0.1
return null
}
function pointFromRow(
row: ParsedPointRow,
gatewayId: string,
deviceId: string,
t: string,
): DevicePoint {
const needScale =
row.scale === 0 &&
/温度|湿度|压差|风速|流量|电压|电流|功率|能量/.test(row.name)
return {
id: randomUUID(),
gatewayId,
......@@ -189,7 +196,7 @@ function pointFromRow(
: /能量计|冷量/.test(row.name)
? 'cooling'
: null,
scaleOverride: needScale ? 0.1 : row.scale === 0 ? 0.1 : null,
scaleOverride: resolveScaleOverride(row),
enabled: true,
lastRaw: null,
lastEng: null,
......
......@@ -3,7 +3,13 @@ import { randomUUID } from 'node:crypto'
import { config } from '../config.js'
import { query } from '../db/pool.js'
import { isPgEnabled } from '../db/state.js'
import { decodeCsvBuffer, exportPointsCsv, parsePointsCsv, type ParsedPointRow } from './csv.js'
import {
decodeCsvBuffer,
exportPointsCsv,
parsePointsCsv,
toEngineering,
type ParsedPointRow,
} from './csv.js'
import { buildBundleFromProtocolDir, resolveProtocolDir } from './seedFromCsv.js'
import type {
Device,
......@@ -49,7 +55,7 @@ function defaultGateways(): Gateway[] {
name: 'PAU-201-1 机组',
kind: 'modbus_host',
host: config.modbus.pau1,
port: config.modbus.port,
port: config.modbus.portPau1,
enabled: true,
meta: { role: 'pau' },
lastOkAt: null,
......@@ -61,7 +67,7 @@ function defaultGateways(): Gateway[] {
name: 'PAU-201-2 计量/通风柜',
kind: 'modbus_host',
host: config.modbus.pau2,
port: config.modbus.port,
port: config.modbus.portPau2,
enabled: true,
meta: { role: 'meter_vav' },
lastOkAt: null,
......@@ -73,7 +79,7 @@ function defaultGateways(): Gateway[] {
name: '房间屏/PLC/冰箱/N·E电',
kind: 'modbus_host',
host: config.modbus.room,
port: config.modbus.port,
port: config.modbus.portRoom,
enabled: true,
meta: { role: 'room' },
lastOkAt: null,
......@@ -519,7 +525,13 @@ function pointFromParsed(
snapshotPath: '',
rw: 'r',
energyKind: null,
scaleOverride: row.scale === 0 ? 0.1 : null,
scaleOverride:
row.scale !== 0
? null
: /温度|湿度|压差|Temp|Humid|冰箱/i.test(`${row.name} ${row.itemId}`) &&
!/float|double|bool/i.test(row.dataType)
? 0.1
: null,
enabled: true,
lastRaw: null,
lastEng: null,
......@@ -703,3 +715,120 @@ export async function getBundleForGateway(): Promise<{
points: points.filter((p) => p.enabled),
}
}
export type PointTelemetrySample = {
id?: string
itemId?: string
raw?: number | null
eng?: number | null
ts?: string | null
quality?: string
}
/** 将采集值写回点位 last_*(供后台设备/点位页展示),不 bump 点表版本 */
export async function applyPointTelemetry(samples: PointTelemetrySample[]): Promise<number> {
if (!samples.length) return 0
const tsDefault = now()
if (!isPgEnabled()) {
const byId = new Map(mem.points.map((p) => [p.id, p]))
const byItem = new Map(mem.points.filter((p) => p.itemId).map((p) => [p.itemId, p]))
let n = 0
for (const s of samples) {
const p = (s.id && byId.get(s.id)) || (s.itemId && byItem.get(s.itemId)) || null
if (!p) continue
if (s.raw !== undefined && s.raw !== null) p.lastRaw = Number(s.raw)
if (s.eng !== undefined && s.eng !== null) p.lastEng = Number(s.eng)
p.lastTs = s.ts || tsDefault
n += 1
}
if (n) saveFile()
return n
}
// 按 id / item_id 批量更新(分块,避免超长 SQL)
const withId = samples.filter((s) => s.id)
const withItem = samples.filter((s) => !s.id && s.itemId)
let updated = 0
const chunk = 200
for (let i = 0; i < withId.length; i += chunk) {
const part = withId.slice(i, i + chunk)
const ids = part.map((s) => s.id!)
const raws = part.map((s) => (s.raw === undefined || s.raw === null ? null : Number(s.raw)))
const engs = part.map((s) => (s.eng === undefined || s.eng === null ? null : Number(s.eng)))
const tss = part.map((s) => s.ts || tsDefault)
const { rowCount } = await query(
`UPDATE device_points AS dp SET
last_raw = COALESCE(v.raw, dp.last_raw),
last_eng = COALESCE(v.eng, dp.last_eng),
last_ts = v.ts
FROM (
SELECT * FROM unnest($1::uuid[], $2::float8[], $3::float8[], $4::timestamptz[])
AS t(id, raw, eng, ts)
) v
WHERE dp.id = v.id`,
[ids, raws, engs, tss],
)
updated += rowCount ?? 0
}
for (let i = 0; i < withItem.length; i += chunk) {
const part = withItem.slice(i, i + chunk)
const items = part.map((s) => s.itemId!)
const raws = part.map((s) => (s.raw === undefined || s.raw === null ? null : Number(s.raw)))
const engs = part.map((s) => (s.eng === undefined || s.eng === null ? null : Number(s.eng)))
const tss = part.map((s) => s.ts || tsDefault)
const { rowCount } = await query(
`UPDATE device_points AS dp SET
last_raw = COALESCE(v.raw, dp.last_raw),
last_eng = COALESCE(v.eng, dp.last_eng),
last_ts = v.ts
FROM (
SELECT * FROM unnest($1::text[], $2::float8[], $3::float8[], $4::timestamptz[])
AS t(item_id, raw, eng, ts)
) v
WHERE dp.item_id = v.item_id`,
[items, raws, engs, tss],
)
updated += rowCount ?? 0
}
return updated
}
/** Mock/网关旁路:按点位生成可读工程值,便于后台联调 */
export async function applyMockPointTelemetry(): Promise<number> {
const points = (await listPoints()).filter((p) => p.enabled)
if (!points.length) return 0
const t = Date.now()
const samples: PointTelemetrySample[] = points.map((p, i) => {
const raw = mockRaw(p, t, i)
const eng = toEngineering(
raw,
p.scale,
p.bias,
p.scaleOverride,
p.minRawRange,
p.maxRawRange,
p.minEngRange,
p.maxEngRange,
)
return { id: p.id, itemId: p.itemId, raw, eng, ts: now(), quality: 'good' }
})
return applyPointTelemetry(samples)
}
function mockRaw(p: DevicePoint, t: number, i: number): number {
const wave = Math.sin(t / 8000 + i * 0.17)
const name = `${p.name} ${p.itemId}`
if (/Boolean|Bit/i.test(p.dataType) || /开停|状态|到位|信号|开关/.test(name)) {
return wave > 0 ? 1 : 0
}
if (/温度|temp/i.test(name)) return Math.round((-22 + wave * 3) * 10) // *0.1 → ℃
if (/湿度|humid/i.test(name)) return Math.round((45 + wave * 15) * 10)
if (/压差|pressure|压力/.test(name)) return Math.round((-15 + wave * 8) * 10)
if (/电压|电流|功率|电|流量|流速|能量/.test(name)) return Math.round((100 + wave * 40 + (i % 7)) * 10) / 10
if (/Float|Double/i.test(p.dataType)) return +(12 + wave * 5 + (i % 5)).toFixed(3)
return Math.round(100 + wave * 30 + (i % 11))
}
......@@ -20,6 +20,7 @@ import { settingsRouter } from './routes/settings.js'
import { videoRouter } from './routes/video.js'
import { lightRouter } from './routes/light.js'
import { devicesRouter } from './routes/devices.js'
import { gatewayRouter } from './routes/gateway.js'
import { getSnapshot, startRealtimeLoop, subscribe } from './realtime/store.js'
async function bootstrap() {
......@@ -66,6 +67,8 @@ async function bootstrap() {
app.use('/api/video', videoRouter)
app.use('/api/light', lightRouter)
app.use('/api/devices', devicesRouter)
/** Windows 网关上行:拉点表 / 推测点落库 */
app.use('/api/gateway', gatewayRouter)
const server = http.createServer(app)
const wss = new WebSocketServer({ server, path: '/ws' })
......
......@@ -4,6 +4,11 @@ import { config } from '../config.js'
import { persistSnapshot, persistWriteLog, pruneCollectData } from '../db/collect.js'
import { getCollectPollMs } from '../settings/store.js'
import { fetchGatewayHealth, fetchGatewaySnapshot, resolveGatewayBase } from '../gateway/client.js'
import {
applyMockPointTelemetry,
applyPointTelemetry,
type PointTelemetrySample,
} from '../devices/store.js'
let snapshot: RealtimeSnapshot = generateSnapshot()
const listeners = new Set<(s: RealtimeSnapshot) => void>()
......@@ -43,6 +48,29 @@ function useGatewayMode() {
return config.dataMode === 'gateway' || config.dataMode === 'modbus'
}
function parseGatewayValues(gwSnap: unknown): PointTelemetrySample[] {
if (!gwSnap || typeof gwSnap !== 'object') return []
const values = (gwSnap as { values?: unknown }).values
if (!Array.isArray(values)) return []
const out: PointTelemetrySample[] = []
for (const row of values) {
if (!row || typeof row !== 'object') continue
const r = row as Record<string, unknown>
const id = typeof r.id === 'string' && r.id ? r.id : undefined
const itemId = typeof r.itemId === 'string' && r.itemId ? r.itemId : undefined
if (!id && !itemId) continue
out.push({
id,
itemId,
raw: r.raw === undefined || r.raw === null ? null : Number(r.raw),
eng: r.eng === undefined || r.eng === null ? null : Number(r.eng),
ts: typeof r.ts === 'string' ? r.ts : null,
quality: typeof r.quality === 'string' ? r.quality : undefined,
})
}
return out
}
async function tick() {
if (tickBusy) return
tickBusy = true
......@@ -50,14 +78,28 @@ async function tick() {
if (!useGatewayMode() || !resolveGatewayBase()) {
snapshot = generateSnapshot()
if (useGatewayMode()) snapshot.online.gatewayOk = false
// 仅 mock 模式本地造数;gateway 模式优先等 Windows 网关 POST /api/gateway/telemetry
if (config.dataMode === 'mock') {
void applyMockPointTelemetry().catch((e) =>
console.warn('[realtime] mock point telemetry failed', e),
)
}
} else {
const [health, gwSnap] = await Promise.all([fetchGatewayHealth(), fetchGatewaySnapshot()])
// 网关快照合并进业务快照:一期先保留 mock 结构,标记网关连通;点值供设备页展示
snapshot = generateSnapshot()
snapshot.online.gatewayOk = health.ok
const samples = parseGatewayValues(gwSnap)
if (gwSnap && typeof gwSnap === 'object') {
const values = (gwSnap as { values?: unknown }).values
;(snapshot as RealtimeSnapshot & { gatewayTelemetry?: unknown }).gatewayTelemetry = values
;(snapshot as RealtimeSnapshot & { gatewayTelemetry?: unknown }).gatewayTelemetry = (
gwSnap as { values?: unknown }
).values
}
// 拉取兜底(网关也会主动推送到 /api/gateway/telemetry)
if (samples.length) {
void applyPointTelemetry(samples).catch((e) =>
console.warn('[realtime] gateway point telemetry failed', e),
)
}
}
publish()
......
import { Router } from 'express'
import { config } from '../config.js'
import {
applyPointTelemetry,
getBundleForGateway,
type PointTelemetrySample,
} from '../devices/store.js'
export const gatewayRouter = Router()
function ingestAuthorized(req: { headers: Record<string, unknown> }): boolean {
const key = (process.env.GATEWAY_INGEST_KEY || '').trim()
if (!key) return true // 开发联调默认放行;生产请设置 GATEWAY_INGEST_KEY
const hdr = String(req.headers['x-gateway-key'] || '')
const auth = String(req.headers.authorization || '')
if (hdr && hdr === key) return true
if (auth === `Bearer ${key}`) return true
return false
}
/** Windows 网关拉取全量点表(与后台「下发网关」同源) */
gatewayRouter.get('/points-bundle', async (req, res) => {
if (!ingestAuthorized(req as never)) {
res.status(401).json({ ok: false, message: '网关鉴权失败' })
return
}
const bundle = await getBundleForGateway()
res.json({
ok: true,
pointsVersion: bundle.pointsVersion,
gateways: bundle.gateways,
points: bundle.points,
})
})
/**
* Windows 网关主动上报测点 → 写入 device_points.last_*(落库/内存)
* Body: { ts?, pointsVersion?, values: [{ id, itemId, raw, eng, quality, ts }] }
*/
gatewayRouter.post('/telemetry', async (req, res) => {
if (!ingestAuthorized(req as never)) {
res.status(401).json({ ok: false, message: '网关鉴权失败' })
return
}
const values = req.body?.values
if (!Array.isArray(values)) {
res.status(400).json({ ok: false, message: '缺少 values 数组' })
return
}
const samples: PointTelemetrySample[] = []
for (const row of values) {
if (!row || typeof row !== 'object') continue
const id = typeof row.id === 'string' && row.id ? row.id : undefined
const itemId = typeof row.itemId === 'string' && row.itemId ? row.itemId : undefined
if (!id && !itemId) continue
samples.push({
id,
itemId,
raw: row.raw === undefined || row.raw === null ? null : Number(row.raw),
eng: row.eng === undefined || row.eng === null ? null : Number(row.eng),
ts: typeof row.ts === 'string' ? row.ts : null,
quality: typeof row.quality === 'string' ? row.quality : undefined,
})
}
try {
const updated = await applyPointTelemetry(samples)
res.json({
ok: true,
updated,
received: samples.length,
mode: config.dataMode,
})
} catch (e) {
res.status(500).json({
ok: false,
message: e instanceof Error ? e.message : '写入失败',
})
}
})
pymodbus>=3.6.0,<4
# 本模拟器已改为纯 socket 服务端,pymodbus 仅作可选客户端探测
pymodbus>=3.6.0,<3.7
......@@ -11,10 +11,12 @@ COPY web ./web
WORKDIR /app/web
ARG VITE_API_BASE=
ARG VITE_VIEWPORT_MODE=glb
ARG VITE_UE_STREAM_URL=
ARG VITE_VIEWPORT_MODE=rtengine
ARG VITE_RTENGINE_URL=http://192.168.32.121:6088/gis.html
ARG VITE_UE_STREAM_URL=http://192.168.32.121:6088/gis.html
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
......
......@@ -12,8 +12,20 @@ withDefaults(
)
const realtime = useRealtimeStore()
const mode = (import.meta.env.VITE_VIEWPORT_MODE || 'glb') as 'glb' | 'ue-stream'
const ueUrl = import.meta.env.VITE_UE_STREAM_URL || ''
type ViewportMode = 'glb' | 'ue-stream' | 'rtengine'
const mode = (import.meta.env.VITE_VIEWPORT_MODE || 'rtengine') as ViewportMode
/** RTEngineHD / UE 云渲染页面地址(iframe) */
const cloudUrl =
(import.meta.env.VITE_RTENGINE_URL || '').trim() ||
(import.meta.env.VITE_UE_STREAM_URL || '').trim() ||
'http://192.168.32.121:6088/gis.html'
const useCloudFrame = computed(
() =>
(mode === 'rtengine' || mode === 'ue-stream') && Boolean(cloudUrl),
)
const viewerRef = ref<HTMLElement | null>(null)
type SmoothControlsLike = {
......@@ -89,6 +101,7 @@ const selected = computed(() => {
})
const markers = computed(() => {
if (useCloudFrame.value) return []
return roomList.value
.filter((r) => ['sample-1', 'lib-1', 'seq'].includes(r.id))
.map((r, i) => ({
......@@ -102,11 +115,18 @@ const markers = computed(() => {
}))
})
const modeLabel = computed(() => {
if (mode === 'rtengine') return 'RTEngineHD 云渲染'
if (mode === 'ue-stream') return 'UE 推流模式'
return '实景扫描模型 · 自由视角'
})
function onSelect(id: string) {
realtime.selectRoom(id)
}
onMounted(() => {
if (useCloudFrame.value) return
const el = viewerRef.value
el?.addEventListener('load', onViewerLoad)
applyMouseMapping()
......@@ -125,7 +145,7 @@ onBeforeUnmount(() => {
<AppIcon name="pin" />
<span class="area">{{ areaLabel || `当前区域:${selected?.name || '—'}` }}</span>
<span class="sep" />
<span class="view">{{ mode === 'ue-stream' ? 'UE 推流模式' : '实景扫描模型 · 自由视角' }}</span>
<span class="view">{{ modeLabel }}</span>
</div>
<div class="vp-room-panel">
......@@ -150,11 +170,17 @@ onBeforeUnmount(() => {
</div>
</div>
<template v-if="mode === 'ue-stream' && ueUrl">
<iframe class="ue-frame" :src="ueUrl" title="UE Stream" allow="autoplay; fullscreen" />
</template>
<template v-else>
<iframe
v-if="useCloudFrame"
class="ue-frame"
:src="cloudUrl"
title="RTEngineHD Cloud Render"
allow="autoplay; fullscreen; microphone; camera; clipboard-read; clipboard-write; display-capture"
allowfullscreen
referrerpolicy="no-referrer-when-downgrade"
/>
<model-viewer
v-else
ref="viewerRef"
class="scene"
src="/models/scene-model.glb"
......@@ -165,7 +191,6 @@ onBeforeUnmount(() => {
interaction-prompt="none"
@contextmenu.prevent
/>
</template>
<div
v-for="m in markers"
......@@ -181,7 +206,12 @@ onBeforeUnmount(() => {
<div class="vp-tip">
<AppIcon name="hand" class-name="hand" />
<span><b>左键</b> 拖动 · <b>右键</b> 旋转 · <b>滚轮</b> 缩放 · 右侧点选房间</span>
<span v-if="useCloudFrame">
RTEngineHD 云渲染 · 在画面内操作漫游 · 右侧点选房间
</span>
<span v-else>
<b>左键</b> 拖动 · <b>右键</b> 旋转 · <b>滚轮</b> 缩放 · 右侧点选房间
</span>
</div>
</div>
</template>
......@@ -195,6 +225,7 @@ onBeforeUnmount(() => {
}
.scene, .ue-frame {
position: absolute; inset: 0; width: 100%; height: 100%; border: 0;
background: #000;
}
.vp-topbar, .vp-tip {
position: absolute; z-index: 5;
......@@ -203,6 +234,7 @@ onBeforeUnmount(() => {
background: rgba(8, 26, 61, 0.85);
border: 1px solid rgba(91, 212, 255, 0.42);
backdrop-filter: blur(10px);
pointer-events: none;
}
.vp-topbar { left: 14px; top: 14px; }
.vp-room-panel {
......
......@@ -12,8 +12,11 @@ interface SystemSettings {
}
const auth = useAuthStore()
const viewportMode = import.meta.env.VITE_VIEWPORT_MODE || 'glb'
const ueUrl = import.meta.env.VITE_UE_STREAM_URL || '(未配置)'
const viewportMode = import.meta.env.VITE_VIEWPORT_MODE || 'rtengine'
const ueUrl =
import.meta.env.VITE_RTENGINE_URL ||
import.meta.env.VITE_UE_STREAM_URL ||
'(未配置)'
const settings = ref<SystemSettings | null>(null)
const pollMs = ref(2000)
......@@ -35,7 +38,7 @@ const presets = [
const rows = computed(() => [
{ key: 'user', label: '当前用户', value: `${auth.user?.displayName || '—'} (${auth.user?.username || '—'})` },
{ key: 'viewport', label: '三维视口模式', value: viewportMode },
{ key: 'ue', label: 'UE 推流地址', value: ueUrl },
{ key: 'ue', label: '云渲染 / UE 地址', value: ueUrl },
{
key: 'dataMode',
label: '数据采集模式',
......
......@@ -3,8 +3,10 @@
interface ImportMetaEnv {
readonly VITE_API_BASE: string
readonly VITE_WS_URL: string
readonly VITE_VIEWPORT_MODE: 'glb' | 'ue-stream'
readonly VITE_VIEWPORT_MODE: 'glb' | 'ue-stream' | 'rtengine'
readonly VITE_UE_STREAM_URL: string
/** RTEngineHD 云渲染页面,如 http://192.168.32.121:6088/gis.html */
readonly VITE_RTENGINE_URL: string
}
interface ImportMeta {
......
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