Commit 367d9363 by James Yuan

fix: 避免 bad/0 覆盖实时值,并同步大屏灯态文案与弹窗

读失败不再用 eng=0 冲掉 lastEng;大屏灯 label/popup 跟随 on 即时切换。

Co-authored-by: Cursor <cursoragent@cursor.com>
parent d2aa504e
......@@ -113,7 +113,7 @@ struct AppState {
std::map<std::string, PointValue> values; // key = point id
std::map<std::string, bool> hostOk;
std::map<std::string, int> hostLatencyMs;
std::string version = "0.3.8-real-only";
std::string version = "0.3.9-keep-good";
std::map<std::string, Session> sessions;
bool pollRunning = false;
};
......@@ -575,7 +575,12 @@ void pollOnce() {
addrOffset = g.modbusAddressOffset;
}
const std::string ts = isoNow();
// 保留上轮 good 值:本轮读失败不再用 eng=0/bad 冲掉后台实时数
std::map<std::string, PointValue> nextValues;
{
std::lock_guard<std::mutex> lock(g.mu);
nextValues = g.values;
}
std::map<std::string, bool> hostOk;
std::map<std::string, int> hostLatency;
......@@ -674,11 +679,7 @@ void pollOnce() {
}
for (const auto& p : mine) {
PointValue v;
v.ts = ts;
v.quality = "bad";
v.raw = 0;
v.eng = 0;
const std::string key = p.id.empty() ? p.itemId : p.id;
const int n = wordCountForType(p.dataType);
std::vector<uint16_t> regs(static_cast<size_t>(n), 0);
bool complete = true;
......@@ -690,8 +691,10 @@ void pollOnce() {
}
regs[static_cast<size_t>(i)] = it->second;
}
if (complete) decodePoint(p, regs, 0, v, ts);
nextValues[p.id.empty() ? p.itemId : p.id] = v;
if (!complete) continue; // 保留 nextValues 中上轮 good,勿写入 bad/0
PointValue v;
decodePoint(p, regs, 0, v, ts);
nextValues[key] = v;
}
}
......@@ -863,8 +866,11 @@ std::string buildHealthJson() {
if (it != g.hostOk.end()) ok = it->second;
auto lit = g.hostLatencyMs.find(h.id);
if (lit != g.hostLatencyMs.end()) lat = lit->second;
o << "{\"id\":\"" << jsonEscape(h.id) << "\",\"host\":\"" << jsonEscape(h.host)
<< "\",\"port\":" << h.port << ",\"ok\":" << (ok ? "true" : "false")
// enabled=false 时 ok 语义为「未参与采集」,避免后台一律显示「断」
o << "{\"id\":\"" << jsonEscape(h.id) << "\",\"name\":\"" << jsonEscape(h.name)
<< "\",\"host\":\"" << jsonEscape(h.host) << "\",\"port\":" << h.port
<< ",\"enabled\":" << (h.enabled ? "true" : "false")
<< ",\"ok\":" << (h.enabled ? (ok ? "true" : "false") : "null")
<< ",\"latencyMs\":" << lat << "}";
}
o << "],\"hikvision\":{\"loggedIn\":0,\"alarms\":false,\"note\":\"HCNetSDK pending\"}}";
......@@ -1003,8 +1009,11 @@ int main(int argc, char** argv) {
svr.Get("/health", [](const httplib::Request&, httplib::Response& res) {
res.set_content(buildHealthJson(), "application/json; charset=utf-8");
});
svr.Get("/snapshot", [](const httplib::Request&, httplib::Response& res) {
res.set_content(buildSnapshotJson(), "application/json; charset=utf-8");
svr.Get("/snapshot", [](const httplib::Request& req, httplib::Response& res) {
// 默认只返回 good,避免平台把 bad/0 刷进 lastEng;?all=1 可取全量
const bool all = req.has_param("all") && (req.get_param_value("all") == "1" ||
req.get_param_value("all") == "true");
res.set_content(buildSnapshotJson(!all), "application/json; charset=utf-8");
});
svr.Get("/config/points", [](const httplib::Request&, httplib::Response& res) {
std::lock_guard<std::mutex> lock(g.mu);
......
......@@ -1061,12 +1061,18 @@ export type PointTelemetrySample = {
export async function applyPointTelemetry(samples: PointTelemetrySample[]): Promise<number> {
if (!samples.length) return 0
const tsDefault = now()
// 显式 bad / 其它非 good 质量一律跳过,避免 0 值冲掉上次有效读数
const usable = samples.filter((s) => {
const q = (s.quality || 'good').toLowerCase()
return q === 'good' || q === 'simulated'
})
if (!usable.length) return 0
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) {
for (const s of usable) {
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)
......@@ -1079,8 +1085,8 @@ export async function applyPointTelemetry(samples: PointTelemetrySample[]): Prom
}
// 按 id / item_id 批量更新(分块,避免超长 SQL)
const withId = samples.filter((s) => s.id)
const withItem = samples.filter((s) => !s.id && s.itemId)
const withId = usable.filter((s) => s.id)
const withItem = usable.filter((s) => !s.id && s.itemId)
let updated = 0
const chunk = 200
......
......@@ -107,13 +107,18 @@ export function parseGatewayModbusHosts(raw: unknown): GatewayModbusHost[] {
const port = Number(m.port)
if (!host || !Number.isFinite(port) || port <= 0) continue
const id = String(m.id || '').trim() || `mb-${host}-${port}`
const enabled = m.enabled === undefined ? true : Boolean(m.enabled)
let ok: boolean | undefined
if (m.ok === null) ok = undefined
else if (m.ok === undefined) ok = undefined
else ok = Boolean(m.ok)
out.push({
id,
name: m.name != null ? String(m.name) : undefined,
host,
port,
enabled: m.enabled === undefined ? true : Boolean(m.enabled),
ok: m.ok === undefined ? undefined : Boolean(m.ok),
enabled,
ok,
latencyMs: m.latencyMs == null ? undefined : Number(m.latencyMs),
})
}
......
......@@ -93,13 +93,16 @@ function parseGatewayValues(gwSnap: unknown): PointTelemetrySample[] {
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
const quality = typeof r.quality === 'string' ? r.quality : undefined
// 读失败点不要用 eng=0 覆盖后台 lastEng(否则 77 等寄存器一直显示错值)
if (quality && quality !== 'good' && quality !== 'simulated') 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,
quality,
})
}
return out
......
......@@ -54,13 +54,15 @@ gatewayRouter.post('/telemetry', async (req, res) => {
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
const quality = typeof row.quality === 'string' ? row.quality : undefined
if (quality && quality !== 'good' && quality !== 'simulated') 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,
quality,
})
}
try {
......
......@@ -212,6 +212,7 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKey))
<button
type="button"
class="sw-btn on"
:class="{ active: switchOn === true }"
:disabled="switching"
@click="emit('control', true)"
>
......@@ -220,6 +221,7 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKey))
<button
type="button"
class="sw-btn off"
:class="{ active: switchOn === false }"
:disabled="switching"
@click="emit('control', false)"
>
......@@ -368,6 +370,13 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKey))
.sw-btn:hover:not(:disabled) {
background: rgba(0, 100, 160, 0.4);
}
.sw-btn.active {
box-shadow: 0 0 0 1px rgba(0, 247, 255, 0.55), 0 0 12px rgba(0, 180, 255, 0.35);
background: rgba(0, 90, 140, 0.55);
}
.sw-btn.on.active {
box-shadow: 0 0 0 1px rgba(77, 255, 181, 0.65), 0 0 12px rgba(77, 255, 181, 0.3);
}
.sw-btn:disabled {
opacity: 0.55;
cursor: wait;
......
......@@ -73,6 +73,16 @@ const nearLines = ref<{ label: string; value: string }[]>([])
const nearHit = ref<SceneMarkerHit | null>(null)
const nearSensorId = ref<string | null>(null)
const nearSwitchOn = ref<boolean | null>(null)
/** popup 展示用:始终跟当前传感器实时 on,写控/WS 回写都会自动切换开/关 */
const liveSwitchOn = computed(() => {
if (!nearOpen.value || nearKind.value !== 'switch' || !nearSensorId.value) {
return nearSwitchOn.value
}
const s = (realtime.snapshot.sensors || []).find(
(x) => x.id === nearSensorId.value || x.code === nearSensorId.value,
)
return s ? (s.on ?? null) : nearSwitchOn.value
})
const nearSwitching = ref(false)
const nearSwitchError = ref('')
const nearLeft = ref(120)
......@@ -348,17 +358,12 @@ async function onLightControl(on: boolean) {
nearSwitchError.value = t('screen.loginToControl')
return
}
const sid = nearSensorId.value
const prevOn = liveSwitchOn.value
nearSwitching.value = true
nearSwitchError.value = ''
try {
await api(`/api/sensors/${nearSensorId.value}/control`, {
method: 'POST',
token: auth.token,
body: JSON.stringify({ on }),
})
// 先乐观更新:popup「当前」与场景 label/灯立刻切到目标态
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,
)
......@@ -367,10 +372,26 @@ async function onLightControl(on: boolean) {
ts: Date.now(),
sensors,
})
// false:只对变化的灯 setLightVisible,避免整表 clear 造成闪一下看不见
window.setTimeout(() => syncLightEffects(), 0)
try {
await api(`/api/sensors/${sid}/control`, {
method: 'POST',
token: auth.token,
body: JSON.stringify({ on }),
})
} catch (e) {
nearSwitchError.value = e instanceof Error ? e.message : t('screen.controlFailed')
// 失败回滚
nearSwitchOn.value = prevOn
const rolled = (realtime.snapshot.sensors || []).map((s) =>
s.id === sid || s.code === sid ? { ...s, on: prevOn } : s,
)
realtime.setSnapshot({
...realtime.snapshot,
ts: Date.now(),
sensors: rolled,
})
window.setTimeout(() => syncLightEffects(), 0)
} finally {
nearSwitching.value = false
}
......@@ -703,7 +724,8 @@ watch(
const s = (realtime.snapshot.sensors || []).find(
(x) => x.id === nearSensorId.value || x.code === nearSensorId.value,
)
if (s && !nearSwitching.value) nearSwitchOn.value = s.on ?? null
// 始终跟遥测;liveSwitchOn 也会读 sensors,此处同步 ref 便于关闭后再开
if (s) nearSwitchOn.value = s.on ?? null
return
}
if (nearKind.value !== 'values') return
......@@ -859,7 +881,7 @@ onBeforeUnmount(() => {
:title="nearTitle"
:kind="nearKind"
:lines="nearLines"
:switch-on="nearSwitchOn"
:switch-on="liveSwitchOn"
:switching="nearSwitching"
:switch-error="nearSwitchError"
:left="nearLeft"
......
......@@ -590,14 +590,13 @@ export function useRtengineBridge(opts: {
if (gis && typeof gis.updateMarkerText === 'function') {
try {
gis.updateMarkerText(id, text)
return
} catch {
/* postMessage */
}
}
send('updateMarkerText', { id, text })
// 兼容只认 updateMarker 的宿主
send('updateMarker', { id, text, name: text })
// 带 leader 的 marker 上 updateMarkerText 常无效,再兜底 updateMarker
send('updateMarker', { id, text, name: text, showText: true })
}
/** 更新已有 Marker;禁止再 addMarker(会整点重建 → 大屏闪烁) */
......
......@@ -214,6 +214,8 @@ export function useScreenModePois(bridge: BridgeLike, enabled: Ref<boolean>, _cl
/** 已对引擎下发的 show/hide tag 集合签名,避免重复 showPOIs 打闪 */
let lastTagVisSig = ''
let timer: number | undefined
/** 灯/VAV 开闭文案:与 spotlight 同量级短防抖,避免 1.2s 滞后 */
let textTimer: number | undefined
let poll: number | undefined
let pollsLeft = 0
......@@ -325,8 +327,17 @@ export function useScreenModePois(bridge: BridgeLike, enabled: Ref<boolean>, _cl
.join(';')
}
function isSwitchMarker(m: RtengineMarker) {
return m.tag0 === DEVICE_TAG.lightN || m.tag0 === DEVICE_TAG.lightE || m.tag0 === DEVICE_TAG.vav
}
function refreshTexts(markers: RtengineMarker[]) {
for (const m of markers) {
// 照明/VAV:走完整 updateMarker,确保 leader 文案立刻变成开/关
if (isSwitchMarker(m)) {
bridge.updateMarker(m)
continue
}
if (bridge.updateMarkerText && m.text != null) {
bridge.updateMarkerText(m.id, String(m.text))
} else {
......@@ -335,6 +346,27 @@ export function useScreenModePois(bridge: BridgeLike, enabled: Ref<boolean>, _cl
}
}
/** 仅刷新文案(结构不变时),用于灯状态快速跟随 */
function refreshTextsNow() {
if (!enabled.value || !bridge.ready.value) return
const current = mode.value
const markers = markersForMode(current, realtime.snapshot)
const structSig = structSigOf(markers)
if (structSig !== lastStructSig) {
scheduleSync()
return
}
const textSig = textSigOf(markers)
if (textSig === lastTextSig) return
refreshTexts(markers)
lastTextSig = textSig
}
function scheduleTextRefresh() {
window.clearTimeout(textTimer)
textTimer = window.setTimeout(() => refreshTextsNow(), 80)
}
function sync(force = false) {
if (!enabled.value || !bridge.ready.value) return
......@@ -420,6 +452,19 @@ export function useScreenModePois(bridge: BridgeLike, enabled: Ref<boolean>, _cl
() => scheduleSync(),
)
// 灯/VAV 的 on 变化:立刻刷 label(不跟 1.2s 数值合并)
watch(
() => {
const list = (realtime.snapshot.sensors || []).filter(
(s) => s.category === 'lightN' || s.category === 'lightE' || s.category === 'vav',
)
return list
.map((s) => `${s.id}:${s.on === true ? 1 : s.on === false ? 0 : 'n'}`)
.join(';')
},
() => scheduleTextRefresh(),
)
// 切换语言后刷新场景标注文案
watch(locale, () => {
lastTextSig = ''
......
......@@ -626,8 +626,10 @@ function gatewayName(id: string) {
}
function gatewayChipStatus(g: Gateway) {
// 停用主机不参与采集,勿显示「断」造成误解
if (g.enabled === false) return { cls: 'off', text: '停用' }
if (g.ok === true) return { cls: 'on', text: '通' }
if (g.ok === false) return { cls: 'off', text: '断' }
if (g.ok === false) return { cls: 'off', text: '读失败' }
if (g.enabled) return { cls: 'on', text: '启用' }
return { cls: 'off', text: '停用' }
}
......
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