No description
Find a file
hubo d801dcc406 lcview:阶段 1–9 实现 + 控制链落地(工作区检查点)
HEAD 之前只有导入提交,阶段 1–9 的实现一直只存在于工作区,且与本次控制链改动
混在同一批文件里(如 HmiRuntimeView.vue / points.go / plbridge/main.go)。
这次按当前状态一次性入库,作为检查点。

服务端(零 cgo)
- iam:双因素登录、刷新令牌轮换(重用即撤整链)、独立 step-up、六角色 + 权限点 + ABAC 范围
- audit:append-only + SHA256 前向链,库层触发器禁 UPDATE/DELETE
- points:project.db 只读挂载,查询按调用者范围裁剪;**YT 点补 min/max/step**(本次)
- rt:一条上游订阅流被所有浏览器共享的合流器 + WS hub(令牌复核、慢消费者保护)
- history:taosAdapter REST(自行解析 TDengine 2.6 的 column_meta,官方 v3 驱动解不了)
- hmi:图元(props/events/builtin)、模板与 rev、画面树与发布、批量升级/回滚、工程包
- control:控制下发编排(本次新增)
- api:路由 / 鉴权中间件先于路由 / 审计 / 配置反代(方法分流 + step-up + 范围裁剪)

桥(cgo,独立 go.mod)
- rtdb:libphotolink_rtdb_capi 只读封装,写方法在类型边界上不可达
- ctrl:libphotolink_ctrl 控制面(本次新增)—— SELECT→EXECUTE→等 CtrlEvent
- ring / grpcserver / supervise / cmd{plbridge,probe,ctrlprobe}
- api/proto:bridge.proto(只读数据面)+ ctrl.proto(控制面,独立的 BridgeCtrl 服务)

前端
- 实时点表(视口驱动订阅、每点一槽位)、历史、告警、组态编辑器与运行态(自绘 DOM/SVG)、
  16 个配置视图、认证与审计页
- 组态按钮真下发(本次新增):POST /api/v1/control/write,按设备回执提示而非"已发送"

控制链(本次新增,板载实测)
- 控制只支持遥控/遥调(YK/YT);YC/YX 一律 L5012(底层约定)
- ABAC 范围 L4031 → YT 值域 L5013 → 同点"在飞"去重 L5011 → 同点 500ms 冷却 L5015(429,
  LCVIEW_CONTROL_COOLDOWN_MS 可调、0=关闭) → 桥控制面 → 成功失败都写审计
- 结果取自设备回执:accepted 只表示引擎收下;TIMEOUT 按"结果不确定"呈现,绝不自动重试
- 板载实测:1.35.YT ← 25 → OK 43ms,读回 1.35.YC=25 / 5.35.YC=25;
  连点第二次 429 L5015「距上次 63 ms,限流 500 ms」;600 ms 后放行

验证:server `go test ./...` 全绿;bridge `go test -race ./...` 全绿;web `vue-tsc` 零错误。
2026-09-21 14:44:21 +08:00
api lcview:阶段 1–9 实现 + 控制链落地(工作区检查点) 2026-09-21 14:44:21 +08:00
bridge lcview:阶段 1–9 实现 + 控制链落地(工作区检查点) 2026-09-21 14:44:21 +08:00
config/hmi lcview:阶段 1–9 实现 + 控制链落地(工作区检查点) 2026-09-21 14:44:21 +08:00
deploy lcview:阶段 1–9 实现 + 控制链落地(工作区检查点) 2026-09-21 14:44:21 +08:00
server lcview:阶段 1–9 实现 + 控制链落地(工作区检查点) 2026-09-21 14:44:21 +08:00
tools lcview:PhotoLink 门户导入 2026-09-18 09:03:59 +08:00
web lcview:阶段 1–9 实现 + 控制链落地(工作区检查点) 2026-09-21 14:44:21 +08:00
.gitignore lcview:PhotoLink 门户导入 2026-09-18 09:03:59 +08:00
development-outline.md lcview:阶段 1–9 实现 + 控制链落地(工作区检查点) 2026-09-21 14:44:21 +08:00
go.work lcview:PhotoLink 门户导入 2026-09-18 09:03:59 +08:00
go.work.sum lcview:阶段 1–9 实现 + 控制链落地(工作区检查点) 2026-09-21 14:44:21 +08:00
misty-reef-char.md lcview:阶段 1–9 实现 + 控制链落地(工作区检查点) 2026-09-21 14:44:21 +08:00
README.md lcview:阶段 1–9 实现 + 控制链落地(工作区检查点) 2026-09-21 14:44:21 +08:00

lcview

PhotoLink 的唯一 Web 门户:实时监视 + 组态 + 历史 + 告警 + 控制 + 策略配置 + 统一认证与审计。 开发路线图见 development-outline.md,实现级规格见 misty-reef-char.md。

仓库结构

目录 说明
server/ lcview 主进程,零 cgo,可静态交叉编译到 ARM64(module lcview/server)
bridge/ plbridge,cgo 桥(module lcview/bridge,独立 go.mod)
api/ bridge.proto 与生成的 gRPC Go 代码,server 与 bridge 共用
web/ Vue 3 + TS + Vite + TDesign 前端
tools/bridge-mock-so/ RTDB 桩库,无采集数据源时验证全链路
deploy/ systemd 单元与联调/冒烟脚本

两个进程

浏览器 ─HTTP(S)→ lcview(:8443) ─gRPC over UDS→ plbridge ─cgo→ libphotolink_rtdb_capi.so

bridge 独立成进程的两个硬理由:cgo 破坏零依赖交叉编译;共享内存与 ipc:// 是主机本地语义,必须与 PhotoLink 同机。

开发

bash deploy/dev-up.sh start      # 构建并起 plbridge + lcview(默认桩库,不依赖数据源)
bash deploy/smoke.sh             # 阶段 1 验收:21 项断言(快照/健康/错误语义/断连恢复)
bash deploy/dev-up.sh status|stop|restart

前端开发期单独跑:cd web && pnpm dev(:5173,mock 数据,?demo=<角色> 可免登录看角色差异)。

读真实 SHM(去掉 LD_LIBRARY_PATH 遮蔽即切回真库):

RTDB_IMPL=rtdb LCVIEW_RTDB_IMPL=rtdb_capi bash deploy/dev-up.sh start

测试

cd bridge && go test -race ./...   # ring 无锁语义、订阅差集、慢消费者保护、seq 单调

工具链要求:Go ≥ 1.25、protoc + protoc-gen-go/protoc-gen-go-grpc(改 api/proto 后需重新生成)。

protoc --proto_path=api/proto \
  --go_out=api --go_opt=module=lcview/api \
  --go-grpc_out=api --go-grpc_opt=module=lcview/api \
  api/proto/bridge/v1/bridge.proto

当前进度

阶段 1(骨架跑通)已完成:两进程经 UDS 连通,快照可用,bridge 重启后 1s 内恢复。./deploy/smoke.sh 20 项。

阶段 2(登录 + 权限 + 审计)已完成,含前端接线:

  • internal/crypto argon2id 口令、RFC 6238 TOTP(±1 周期容偏,已知答案向量测试)、ES256 令牌 + JWKS、AES-GCM 封装 TOTP 密钥
  • internal/iam 双因素登录、刷新令牌轮换与重用即撤整条家族链、step-up 时效、口令强度与历史复用、失败锁定、六角色 + 23 权限点 + ABAC 范围 + 三权分立互斥
    • step-up 只认独立一次 TOTP(sessions.stepup_time,令牌声明 sua 并随刷新前传);双因素登录不算复核
  • internal/audit append-only + SHA256 前向链;库层触发器禁止 UPDATE/DELETE;查询/分页/CSV 导出/integrity 校验
  • internal/api 鉴权中间件先于路由(匿名者无法靠状态码探测端点)、权限点门禁、状态变更全量审计、无权路由重定向
  • 反代 config_server_go:剥离门户令牌不外传、危险端点需 config.danger + X-Confirm、lua-run 默认按部署基线封死;上游 -bind 默认值已改为 127.0.0.1
  • TLS:tlsHardened()(≥TLS1.2)+ deploy/tls/gen-cert.sh 自签脚本
  • 前端:api/client.ts(信封解包、Bearer 注入、401 静默刷新且并发去重、L4032 唤起全局 step-up 弹窗)、auth store 改服务端下发 perms/scope、登录页接真接口、审计页与用户与权限页取真数据;Vite dev 加 /api 代理

./deploy/smoke.sh 20 项 + ./deploy/smoke2.sh 46 项,go test ./... 全绿,前端 vue-tsc 零错误。

阶段 3(实时点表)后端已完成,前端待接线:

  • internal/points 只读挂载上游 project.db(mode=ro,缺表即拒启),分组/点表/单点/搜索四类查询全部在 SQL 层按调用者 ABAC 区间裁剪
  • 点名搜索用 LIKE … LIMIT 而非 FTS5:unicode61 把整段中文当一个词元,实测搜"电压"命中 0 条而 LIKE 命中 128 条;%/_ 按字面转义
  • internal/rt Live 合流器:一条上游订阅流被所有浏览器共享(键并集去重、引用式退订),250ms tick 只推变化、同点多次变化保留最新值;无变化的 tick 不产生帧;订阅即补一次 batch_read 快照,避免首屏空窗
  • GET /api/v1/ws/realtime(op 走 auth/sub/unsub/resync/ping):令牌在首条消息里(浏览器套接字带不了 header)、同源 Origin 校验、每 60s 复核令牌(撤销会话立刻断流)、单连接 5000 点上限、积压 >4 帧判定慢消费者并断开
  • 断流重订阅:新流建立即重发全量期望集(声明式协议使重放幂等),实测 bridge 进程消失后亚秒级恢复,验收线 3s
  • 新端点:GET /api/v1/groups、/groups/zones、/groups/{gid}/points、/points/search、/points?key=、/rt/groups;/rt/health 增 hub(连接数/并集/重连次数/上游缺口)
  • tools/pointgen 造 200 组 / 100.4 万点的同构库;压测数字:搜索 p95 2ms、点表分页 p95 2ms、分组首屏 p95 62ms(目标分别为 300ms / 300ms / 1s)

./deploy/smoke3.sh 29 项(含 deploy/wsprobe.mjs 的 WS 端到端探针)+ go test ./... 全绿;实时链路的合流/共享/重连/慢消费者另有 7 项 Go 测试用进程内替身上游(internal/bridgetest)驱动。

阶段 3.3 前端接线已完成:

  • web/src/api/rtws.ts 实时通道客户端:首帧 auth、sub/unsub 150ms 合并、seq 缺口自动 resync、指数退避重连、令牌轮换就地重认证;无令牌不握手(未登录页签按退避重连,每次失败都会往审核链里塞一条)
  • stores/rt.ts 改为每点一个 ref 的槽位:只有真正变化的点才唤醒依赖它的组件;订阅数显式记账,退订即回收槽位(一班值守翻动的点远超 5000)
  • 只订阅看得见的点:composables/useVisible.ts 用 IntersectionObserver(含 300px 预取)把订阅集从"整页 200 行"收到"视口内 16 行";页签切到后台就断开——Chrome 会在网络层替我们回 ping,留着连接等于让看不见的页签一直占着后端订阅
  • 点表页接真接口:区域/分组树、服务端分页与按类型切换、组内点名过滤、?jump=gid.idx.TYPE 定位高亮;"没收到值"与"值为 0"分开呈现(占位色 + 质量码"无值"),且只有可见行没值才用告警色
  • 数据源可切:默认走真链路,localStorage['lcview.rt-source']='mock' 或 VITE_RT_SOURCE=mock 回到本地 250ms 仿真(设计评审用);状态栏标注来源与"桩库 mock-rtdb"
  • 静态资源按需 gzip:首屏 3.0MB → 0.82MB;哈希命名的 /assets/* 给 immutable,index.html 给 no-cache(否则改版后运营终端拿旧壳);Vite dev 代理补 ws: true
  • 修掉三处路由离开不清理的 setInterval(驾驶舱两个、运维页一个)
  • 界面验收走真无头 Chrome + CDP(内置浏览器视口是 0×0,IntersectionObserver 永不命中):点表/驾驶舱/运维/控制台四页零 JS 异常、无 NaN

仍走 mock 的页面:驾驶舱、历史、告警、控制台(数据面属阶段 4/6/7);点表、全部配置页、组态运行态/编辑器/批量生成已接真数据,状态栏里 mock 条目用斜体弱对比标注。

待验一项:桩库路线下"快照值与真实 SHM 一致"仍未验证,需接上采集数据源后补一次。

阶段 8 前端已完成(2026-09-17):

  • 运行态渲染 HmiRuntimeView:X6 只管静态图元且必须开 virtual(spike ② 结论),动态数值走 X6 容器的兄弟节点 HTML 层,刷新只改 textContent、平移缩放只给容器加一个 matrix;值来自 stores/rt 的视口订阅,图元 states 按声明顺序首条命中着色(TS 端 hmi/states.ts 与 Go 的 MatchStates/MatchWhen 逐条对齐,否则同一画面两处变色不同)

    ⚠️ 这一条已被阶段 9 取代:X6 整体移除,编辑态与运行态共用一个自绘 DOM/SVG 渲染器。保留在这里是为了说明当时为什么分两层(下面的"根因"就是它导致的)。

  • 发布驱动导航:GET /hmi/nav 决定"实时监控"里有哪些画面,未发布/越界的画面不出现在菜单也打不开;/monitor/hmi/:pageId 为参数化路由
  • 画面编辑器 HmiEditorView:图元面板拖拽入画、点选后按图元槽位绑点位(PointSelect 走门户点名检索,越界点搜不到)、保存只写作者意图(symbol/位置/绑定,运行态算出来的样式一律不落文档)、发布需确认;只用了 X6 核心,没引入 @antv/x6-plugin-*,因此没有框选/对齐线/小地图/撤销重做,连线改为"点两个图元"

    ⚠️ 这一条已被阶段 9 取代:撤销重做、复制粘贴、图层树、对齐辅助线、网格吸附、八向手柄、图元缩略图都已具备。

  • 批量生成器 HmiGenerateView + POST /hmi/generate:模板实例化 N 组,*_base/*_pid 这类点位偏移按每组点表推断,推不出来进"待人工确认"且该组不落库,确认后用 overrides 回填再生成;dry_run 只出计划
  • 验收:./deploy/smoke7.sh 22 项(预演不写库 / blocked 不留半成品 / 回填后生成并渲染 / 发布进导航 / 再生成只跳过 / 丢 symbol 的文档被拒 / 模板实例拒绝直写文档 / 渲染吃 ABAC);界面真点:运行态、编辑器、生成器三个页共 12 项断言(含 overlay 数值两次采样不同即"真在刷新")
  • 一起修掉的两个真缺陷:toGraphJSON 丢了 symbol 字段导致编辑器导出把画面写空(服务端已加校验拒绝声明为 symbol 却无 symbol 的文档);运行态与编辑器的属性面板此前会把"选中边"和"选中的节点解析不出图元"混成同一句话

阶段 8 后端已完成(2026-09-09):

  • 图元库:19 个图元(config/hmi/symbols/<id>/symbol.json),服务端把 SVG 净化成元素树(白名单:g/rect/circle/ellipse/line/polyline/polygon/path/text/tspan),装载失败逐条上报
  • 模板引擎:表达式语言 + repeat + rev 台账;500 图元展开实测 2.9ms
  • 画面注册表:画面树 / 发布 / 批量升级与回滚 / 渲染缓存 / ABAC 双路裁剪;模板发新 rev 不自动传播,须显式批量升级
  • 验收:./deploy/smoke4.sh 30 项、./deploy/smoke5.sh 34 项

阶段 5(策略与配置)已完成,含 16 个视图移植:

  • 闸门改成按方法分流:api.config.read(sysadmin+engineer,能看)与 api.config.write(仅 sysadmin,且必须过一次独立 step-up);原 api.config.proxy 已删除,启动时自动回收废弃权限点与其授予
  • 配置面开始吃 ABAC:路径 /groups/{gid}、列表 filter 的 group_id、写正文里的 group_id/*_group_id/group_ids 都要落在调用者范围内(此前代理完全不看范围)
  • 写请求把正文样本写进审计(4 KiB 取样、UTF-8 边界截断、multipart 只记类型与字节数),此前只有 method+path
  • 前端 16 视图全部落到 web/src/views/config/ + web/src/components/config/:字段清单收在 fields.ts 一处(老工具把它抄在 .vue / ColumnRules / 字段字典三处且已漂移),通用 ResourcePage/DynamicForm/InlineGrid/CellEditor,规约表由 /meta/protocol-schemas 驱动不写死表名列名
  • 表达式编辑器按运行时真实文法校验:max/min/比较/&&/@dur/$1..128.1.YC 一律当场报错(C++ 侧未知函数会静默按 sum 算),点位候选改走门户点名检索
  • 修掉老前端六处确定错误:链路搜索与协议过滤互相屏蔽、触发器对话框列出全部脚本(?script_id= 上游根本不认)、保存链路+关联分组失败不区分、批量粘贴索引跨页撞车、切页签静默丢改动、导入结果表被当成逐 sheet 报告
  • 验收:./deploy/smoke6.sh 42 项,跑在一次性夹具上:
    ./deploy/cfg-fixture.sh start                                             # 复制 project.db 到 .run/cfg 并起 127.0.0.1:18080
    LCVIEW_CONFIG_UPSTREAM=http://127.0.0.1:18080 ./deploy/dev-up.sh restart
    ./deploy/smoke6.sh
    
    脚本开头自检、结尾断言"仓库源库与现网库指纹未变、组态资产一个没少",删除只对 smoke_ 前缀行发起
  • 顺带发现两处"源码已改、现网二进制没跟上":/opt/photolink/bin/config_server(08-13 构建)的 -bind 默认仍是 0.0.0.0;lua-scripts/lua-triggers 在该二进制上是 404。处理办法见 ./deploy/config-server-loopback.sh check(只读诊断)与 build(重编到 .run/,含 arm64);替换现网要显式 install --yes
  • 界面真点验证(真无头 Chrome + CDP,门户自身托管的构建产物):16 个配置页全部通过,零 JS 异常、无 NaN/undefined,种子数据渲染符合预期(分组点位 20 行、协议插件 6 行、字段字典 144 行、进程管理 14 行);并跑通一次真实写入:新建分组 → 保存被 L4032 拦 → 全局 step-up 弹窗填 TOTP → 原请求自动重放 → "已创建" → 列表出现该行
  • 由此补了一处可用性缺口:client.ts 之前只把 L4032 抛给弹窗、调用方拿到的是失败,等于每次写都要点两下;现在弹窗结算一个门闩,认证通过即原地重放(并发请求共用一次认证),取消则照常失败
  • 单元与集成测试:go test ./... 全绿(含新增的配置权限/范围/step-up/Lua 路径 5 个用例),前端 vue-tsc 零错误

阶段 9(组态改版)已完成(2026-09-21):把编辑器与渲染内核换掉,目标是"组件效果可调、属性面板自动生成、画布能用"。

背景:旧实现有两个结构性问题。一是图元是一张定型的 SVG,实例只能改文字 + 绑点,没有可调外观;二是编辑态与运行态是两套渲染路径(编辑器 X6 不虚拟、运行态 X6 virtual + 独立 HTML 数值层),于是组态文档分裂出两个方言,而没人负责统一。

  • 弃用 X6,自绘 DOM/SVG。决定性的一个事实:X6 的官方插件生态停在 2.x(transform/snapline/selection/history/minimap 的 latest 都是 2.x、peer 要 @antv/x6 ^2.x;名义上的 3.0.0 是 2022-11-25 与 x6 3.0.0 同批发布的空壳,四年没动),而项目在 3.1.8 上 —— "留 X6 + 装官方插件"这条便宜路不存在,继续留着只能像原来那样自己绕它的坑(代码里记了 7 处)。web/package.json 已移除 @antv/x6
  • 一个渲染器通吃两级:components/hmi/CanvasStage.vue 同时服务编辑器与运行态,SymbolInstance 用 mode 区分交互。数值槽是图元实例的子元素,跟着 left/top 走,平移缩放由整张画布的一个 CSS transform 带走 —— 旧实现那套"图元本地坐标换算成画布坐标再喂给独立数值层"的矩阵运算整体消失
  • 文档只剩一种坐标方言:server/internal/hmi/geometry.go 把 position/size/zIndex 归一成扁平的 x/y/width/height/z,模板展开与画面装载都走它。此前模板引擎输出的是 X6 方言、渲染器只读扁平形式,于是 cluster_face 里 {{ per_row * 62 + 24 }} 这类参数化尺寸一次都没生效过(图元全落原点、尺寸回落到 bbox)——geometry_test.go 把这条钉住了
  • 图元参数化:symbol.json 新增 props(面板按 type 自动生成表单,落点是 sel+attr 或 text:true)、events(可点触发)、builtin(少数子结构随值变形的组件交给前端渲染器)。服务端校验 props 的每一项:sel 必须真实存在、attr 在白名单内、同一 (sel,attr) 不被两条属性抢、default 类型匹配;画面文档里的取值也过同一套检查。图元从 14 个增到 19 个(补 仪表盘/液位罐/数值显示/按钮/开关,其中 5 个走 builtin,渲染代码注册在 web/src/hmi/builtin/)
  • 画布能力:撤销重做(整文档快照 60 层,hmi/history.ts)、复制粘贴/原地复制、方向键微调、z 序上下移、对齐(左中右上下+居中)/等大等小/等距、成组解组、图层树、八向缩放手柄、网格吸附、对齐辅助线、图元库真样缩略图、快捷键(Ctrl+Z/Y/S/C/V/D/A/G、Delete、Esc、方向键)
  • 事件与工程包:cell.data.events 支持 write(运行态走控制链,需独立复核)与 goto(只跳已发布画面);GET /hmi/bundle 把一个工程(图元库 + 模板 + 画面树 + 全部画面文档)装进单个 JSON,POST /hmi/bundle/import 只增不改:画面 id 撞车落新 id、模板内容相同不造 rev、图元撞车只报冲突,默认 dry_run 出对账报告,并要求一次独立 step-up
  • 验收:./deploy/smoke8.sh 30 项(props 下发与校验 / 事件参数校验 / 工程包导出与只增不改导入 / 结尾断言仓库资产一个没少且没被改写);smoke4.sh 30 项、smoke5.sh 32/2(2 项是 09-18 遗留画面导致,与本次无关)、smoke7.sh 22 项;go test ./... 全绿(新增 geometry_test.go、props_test.go、bundle_test.go);前端 vue-tsc 零错误
  • 本机起栈/跑冒烟用 bash .run/up.sh restart 与 bash .run/up.sh smoke smoke8.sh:非交互 shell 不读 .bashrc,PHOTOLINK_PROJECT / LCVIEW_POINTS_DB 必须显式给(缺前者会让 plbridge 退到不存在的 /opt/photolink/config/project.bin,bridge 永远附加不上)

阶段 7 前置:bridge 控制面骨架已落地(2026-09-21)

  • bridge/ctrl:cgo 封装 libphotolink_ctrl 的 ctrl_client_*(ZMQ ipc:///tmp/photolink_ctrl → CtrlEngine)。既不用 rtdb 写、也不用 hmi_sender:PhotoLink 的 doc/rtdb_capi_usage.md:409-412 写明"YK/YT 不经 RTDB,本库仅提供状态回读";而 hmi_sender 是把 EXECUTE 直投总线的 DIRECT 旁路(《详设》misty-reef-char.md:387 要求封死)。bridge/rtdb 的只读类型边界未动。
  • 控制面独立成第二个 gRPC 服务 bridge.v1.BridgeCtrl(CtrlWrite / CtrlStatus),不往只读的 Bridge 上加方法 —— "数据面只读"这条不变量在接口层仍然成立;控制面接不上时明确回 Unavailable,不静默、不降级。
  • 协议语义(实测确认,反直觉):最终结果是 CtrlEvent 推的,不是 wait 的(EXECUTE 应答只有 "execute sent");SELECT 与 EXECUTE 共用同一个 request_id 与等待条目,所以顺序必须是 select → wait(消费应答) → execute → 等事件。点 ID 是 (gid<<16)|pidx,点类型 ctrl 侧是 YK=0/YT=1(与 rtdb 的 YC=0/YX=1/YK=2/YT=3 不是一套)。
  • 本机实测(x86_64):watchDog + libphotolink_ctrl.so + run_loop(modbus_loop 自环) 起栈,bridge/cmd/ctrlprobe 下发 YK 与 YT:accepted=true,ctrl_listen 同时看到 REQ topic=0x601000a / RESP topic=0x701000a —— 命令真的到了设备(modbus master 实发 FC)。点 ID、类型映射、SBO 时序、超时归因都由此验证。
  • ⚠️ 但结果读不回来(上游 bug):src/data/ctrl/src/client/ctrl_client.cpp:60-103 的接收线程先按 CtrlResponse 判 request_id()!=0,而 CtrlEvent 的 field0 是 point_id,被当 u64 读出来非 0 → 事件帧进了 response 分支、continue,ctrl_client_subscribe 的回调永不触发。裸 ZMQ 抓帧可见事件本身是好的(as CtrlEvent: result=0 verify=1 msg='OK')。因此当前门户语义只能是 accepted=true + TIMEOUT(结果不确定),绝不误报成功。
  • 上游待修四处:① ctrl_client.cpp 收包处加 Verify() 区分 CtrlResponse/CtrlEvent;② src/data/ctrl/config/ctrl.yml:5 的 ipc_path 缺 ipc:// 前缀(bind 失败但 plugin_start 仍返回 0,表现为"启动成功却没监听");③ watchdog_ctrl.yml 引用的 sub_shell_mod/libctrl_mod.so 在仓库与安装包里都不存在;④ packaging/watchdog.yml.in 里没有 ctrl 子进程项 —— 部署基线根本没起 CtrlEngine。
  • 验收:cd bridge && go test -race ./... 全绿。新增 ctrl_test.go(SBO 时序 / 结果归因 / 超时不确定 / 同点串行,靠注入假 transport,不需要真引擎)与 grpcserver/ctrl_test.go(pb↔ctrl 映射与错误语义);api 与 server 均照常 build。

板子实测:真实控制链路打通(EM-1000 / aarch64 / Ubuntu 20.04,2026-09-21)

  • 代码侧的根因修复(ctrl_client.cpp 用 flatbuffers::Verifier 区分 CtrlResponse / CtrlEvent,响应优先、命中即停)当时已在源码里,但 build/aarch64-release 的 .so 还是修复前的产物(md5 与板上一致,都是旧的)。重编 aarch64 并推板后生效(新 .so md5 e862c684…,旧件备份为 libphotolink_ctrl.so.bak-ctrlfix)。
  • 板上还有两处配置阻塞,与代码 bug 同等致命:
    • config/ctrl.yml 的 ipc_path: "/tmp/photolink_ctrl" 缺 ipc:// 前缀 —— CtrlEngine 把它直接喂给 zmq bind(),失败会抛 Invalid argument,而 plugin_start() 仍返回 0,表现为"启动成功但没监听"。
    • 同文件的 bus.shm_name: "photolink_bus" 是总线名(ctrl_plugin.cpp 直接传给 Bus::init),必须与 scada 同一条;板上实际是 photolink(见 /tmp/pl_photolink_*、/dev/shm/photolink_*)。写成 photolink_bus 会另开一条总线,CtrlRouter 永远收不到 CONTROL_REQUEST。
  • config/watchdog.yml 新增 ctrl 子进程条目,CtrlEngine 随 photolink-watchdog 自启(重启后 /tmp/photolink_ctrl 在;生产 sub_shell 里能看到 libphotolink_ctrl.so)。两个配置文件分别备份为 *.bak-ctrlfix。
  • 新的 plbridge(含 bridge/ctrl 控制面)已部署到 /opt/lcview/bin/plbridge(旧件 plbridge.bak-ctrlena),并附诊断工具 /opt/lcview/bin/ctrlprobe。
  • 端到端实测(板子自带 modbus_loop 自环,隔离起栈):YK 0x1000A=1 → result=OK accepted=true stage=event 耗时 5ms;YT 0x10008=500 → result=OK 耗时 9ms。链路完整走通:bridge/ctrl → ctrl_client → CtrlEngine → bus CONTROL_REQUEST → CtrlRouter → modbus → 设备 → CONTROL_RESPONSE → CtrlEvent。
  • 生产工程那 5 条链路是 RS485 主站(/dev/ttyRS485-1..4 主站、-5 从站模拟),台上没有物理 PCS 应答,所以生产点 0x10000 实测仍是 accepted=true + TIMEOUT(结果不确定) —— 这是设备侧不回,不是软件问题;口径上正好验证了"命令已下发"与"设备未确认"被分开表达。
  • 上游仓库待补(让下次安装自带这些修正):packaging/watchdog.yml.in 加 ctrl 子进程;src/data/ctrl/config/ctrl.yml 与 ctrl_config.h 的默认值补 ipc:// 并将 bus_shm_name 默认改成 photolink。
  • 小瑕疵:新客户端会把订阅确认(CtrlResponse 且 request_id==0)记成"丢弃无法识别的帧",每个控制客户端启动打一次;无害,但值得在上游收一下。

功率下发(YT 遥调)链路在板上实测跑通(2026-09-21)

  • 坐标:板载工程的 modbus 映射(config/modbus_1.bin,schema schemas/modbus_config.fbs)把 group 1 的 64 个保持寄存器全列成 HOLDING_REGISTER:1.0–1.31 是遥测/状态,1.32–1.63 是控制寄存器;其中 1.35 = PCS有功功率参考值(point_id 0x10023,寄存器 35,scale=1.0,INT16)。ctrl_router.bin 为 1.32–1.63 各生成一条 control_<id> → modbus_master、reply_back: true 的 DIRECT 路由(注意:它与 ctrl_router.yml 那份手写配置完全不同,.bin 才是运行态)。
  • 完整链路(实测):ctrl_client → CtrlEngine SBO(select→execute)→ 总线 CONTROL_REQUEST(topic 0x6010023)→ CtrlRouter control_65571 → ProtocolSession::execute_control(iprotocol/session/protocol_session.h:101)→ ModbusMasterProtocol::encode_control_write(iprotocol/src/protocols/modbus/modbus_master.cpp:328:按 point_id 反查寄存器,reverse_convert_registers(value, data_type, byte_order, scale, offset) 反算 raw)→ FC06 写单寄存器(addr 35 / slave 1,串口 /dev/ttyRS485-1;COIL 走 0x05、多寄存器走 0x10)→ 从站(板载「PCS1破解(从站模拟)」ttyRS485-5)→ CONTROL_RESPONSE(topic 0x7010023)→ CtrlEngine on_control_response → CtrlEvent → 客户端。
  • 实测往返:写 YT 0x10023 = 25 → result=OK accepted=true stage=event 耗时 45ms;主站轮询读回 1.35.YC = 25、从站侧 5.35.YC = 25;再写 0 复位 → 两处都回到 0。ctrl_listen 同时抓到 REQ topic=0x6010023 / RESP topic=0x7010023。控制真的落到设备且可读回。
  • ⚠️ 门户现在还发不出去的原因(点表与运行态错位):
    1. project.db 把这 32 个控制寄存器全放在 yc_points(遥测),yt_points 只有一个 index 0 的「功率设定」;而 ctrl_router.bin 只认 1.32–1.63。于是门户点表里 1.35 类型是 YC,而控制台与 HMI 的 ctrl 事件只接受 YK/YT(server/internal/hmi/props.go 的 ctrlTypes)—— 真正的可控点反而选不出来。
    2. 1.0.YT「功率设定」 是孤儿点:project.bin 里 group 1 的 yt=yk=0(没有控制槽位)、路由表里没有 1.0、modbus_1.bin 里 1.0 是「A相电网电压」。之前测 0x10000 超时是"没有路由",不是"没有设备"。
    3. project.bin/ctrl_router.bin/modbus_1.bin 生成于 09-20 13:48,而 project.db 13:54 —— 改点表必须重新 generate,否则门户与运行态永久不一致。
    4. 值域没暴露:points.One() 取 YT 时不选 min_value/max_value/step,/api/v1/points 也不返回 → 门户无法校验设定值范围。
    5. 回执不带测量值:send_control_response 里 current_value_bool/f64 固定 false/0.0,CtrlEngine select 时读到的 RTDB 值被丢弃((void)state)→ "下发后实际值"只能另读 1.35.YC。

门户控制下发已接真链路(2026-09-21)

  • 服务端:新增 internal/control(编排)+ POST /api/v1/control/write。分工——bridge/ctrl 负责 SBO 时序与设备回执,本包只做:只接受 YK/YT(底层约定,YC/YX 一律 L5012)→ 分组范围(ABAC,L4031)→ 遥调值域(L5013)→ 同点"在飞"去重(L5011)→ 同点 500ms 冷却/限流(L5015,429) → 转 gRPC → 成功失败都写审计。桥不可达回 L5014,不重试(重试会在现场造成第二次动作)。
    • 冷却按 gid.pidx.TYPE 分别计(1.35.YK 与 1.35.YT 互不影响),默认 500ms,可用 LCVIEW_CONTROL_COOLDOWN_MS 调整,0 = 关闭。它挡的是"连点两下"这类往返之后的重复动作 —— 在飞去重只挡得住并发的那一次。实测:第 1 次 OK 42ms → 立即第 2 次 429 L5015「距上次 63 ms,限流 500 ms」 → 600ms 后 OK 43ms;换点位不下发不受影响。
  • 权限点:control.execute 早在角色表里,这是它第一次被真正强制(s.need("control.execute", …))。
  • 点位 API 补值域:points.One 现在为 YT 点取 min_value/max_value/step 并随 /api/v1/points 返回 —— 没有它,门户无法校验设定值范围。
  • 前端去 mock:api/control.ts 新增;stores/control.ts 的 execute() 改为真调用并按设备回执建记录(不再 Math.random());控制的种子记录与操作票清空,页面里不再混假数据;HmiRuntimeView.dispatchCtrl 按结果提示——OK 才是成功,TIMEOUT 明确说"结果不确定、请现场确认、不会自动重试"。step-up 闸门按本部署的简化策略改为不阻断并写明原因。
  • 板载实测(经门户 HTTP,非内部工具):POST /api/v1/control/write {"key":"1.35.YT","value":25} → 200 {result:OK, accepted:true, stage:event, elapsed_ms:43};读回 1.35.YC=25、从站侧 5.35.YC=25;复位 0 同样 OK。护栏实测:越界 120 → L5013「允许范围 0 ~ 100」、遥测点 1.9.YC → L5012、不存在 9.9.YT → L4041。审计里能查到 5 条 control.write(含 DENY 的 L5013/L5012/L4041 与 OK 的记录、耗时、参数)。
  • 点表已按约定正名(板载 project.db):yt_points(1,35) 功率设定(原来错放在 index 0)、yk_points(1,32) 控制命令字;同索引的 YC 保留作读回。
  • ⚠️ 遗留两件(都还没做,且互相独立):
    1. 控制路由没有生产生成路径。正式 generate 只有 project/modbus/iec61850/lua_engine 四份规则(src/tools/config/schemas/*.sql),不产 ctrl_router.bin;板子上那份是 demo 工具 gen_goose_ctrl_router --dst-proto modbus_master 的产物(描述里还留着 "DIRECT GOOSE control")。现在能下发,靠的是这份演示产物恰好覆盖了 1.32–1.63。
    2. RTDB 的 YT/YK 槽位还没补(project.bin 里 group1 仍是 yt=yk=0)。下发本身不需要槽位(已实测),但"下发后实际值"的回读与 read_yt_state 需要。重新生成 project.bin 会让 scada 启动即崩:差异是新版多了一个 group_id=0、零点位的空组「测试组1」(它 13:54 才进 project.db,比旧 bin 晚 6 分钟)。 修法已验证(离线,不动运行态):把空组从 project.db 删掉后,用官方 CLI 在 /tmp 副本上重生成 ——
      cp -a /opt/photolink/config/project.db /tmp/t.db && sqlite3 /tmp/t.db 'delete from groups where group_id=0'
      cd /opt/photolink/share/photolink/config_server
      PATH=/opt/photolink/bin:$PATH LD_LIBRARY_PATH=/opt/photolink/lib \
        /opt/photolink/bin/photolink_config generate rules/project_rules.json \
        -d /tmp/t.db -o /tmp/t.bin --schemas-dir schemas
      
      产物结构:groups=5,group1 yc=64 yx=1 yk=1 yt=1 —— 与已知可用的旧 bin 同形,只少了那个空组。剩下的是把它部署上去重启验证(会短暂中断采集,且重启后必须重起 sim_pcs.py)。较彻底的替代方案是给运行态加载器补上对空组的容忍。
  • ⚠️ 台架依赖:板载从站数据由手工启动的 /opt/photolink/bin/sim_pcs.py 提供(往 RTDB group5 写 220.5V/50kW…),它默认只跑 1000 轮(≈17 分钟)就退出;一旦停了,group1 主站读回全 0、看起来像"链路全断"。建议给它做个 systemd 单元(或至少 --loop 拉长)。