12 · 脚本运行时与脚本形态

程序集:Hmi.ScriptRuntime · 源码:src/Hmi.ScriptRuntime/

什么时候你会需要它

  • 你要在工程里加逻辑:开机初始化、按钮点一下干点什么、定时巡检
  • 你的脚本写不进某个点,想知道为什么
  • 你要知道有哪几种脚本、分别什么时候被触发
  • 你要在宿主程序里跑工程的脚本

有六种脚本,触发时机完全不同

形态 配在哪 什么时候跑
全局启动脚本 工程 → 启动 → 启动时运行 程序启动时一次
全局退出脚本 工程 → 启动 → 退出时运行 程序退出时一次
后台定时脚本 全局脚本 → 程序周期 每隔 N 毫秒 或 走到指定时刻跑一次,程序运行期间一直跑
全局条件脚本 全局脚本 → 条件 条件表达式成立时跑(可设边沿/电平)
画面脚本 画面 → 进入/退出/周期 切到这个画面时开始跑,切走就停
元素事件脚本 元素 → 事件 用户对那个元素做了动作(点击、鼠标移入…)

全局启动 / 退出

启动:OnStartup 代码 → 然后按顺序调 StartupCalls 里列的函数
退出:OnShutdown 代码 → 然后按顺序调 ShutdownCalls 里列的函数

单个函数失败不会中断后面的 —— 会记日志然后继续。这样不至于因为一个初始化函数出错,后面的都不跑了。

后台定时脚本

触发方式二选一:

// ① 按间隔:每 500 毫秒跑一次
PeriodicScript { IntervalMs = 500, Code = "…" }

// ② 按时刻:每小时整点跑一次(At 非空 ⇒ 按墙钟时刻,IntervalMs 无意义)
PeriodicScript { At = "*:00", Code = "…" }

时刻表 At = 逗号分隔的 HH:mm,HH 可以用 * 表示每个小时: *:00 每小时整点、*:30 每半点、08:00,20:00 每天早八晚八。 保存时校验/归一只有一份实现(Hmi.Project.Scripts.ScheduleSpec),运行态命中判断也调它 —— 组态期说合法、运行态就一定认。

⚠ 上一拍没跑完,这一拍会被直接丢弃(这叫 TickGate)。 也就是说定时脚本不会堆积 —— 宁可少跑几次,也不让任务越积越多。 所以脚本要写得快,别在里面做耗时操作。

⚠ 按时刻的条目由宿主的 1 秒心跳扫时刻表,同一分钟只跑一次;停机期间错过的时刻不补跑 (按时刻 = 绝对时刻,不是"每 N 毫秒"那种把错过的补回来的语义)。

⚠ 画面周期脚本不支持 At:画面周期是"该画面打开期间才跑",墙钟时刻与画面生命周期语义冲突 (画面没开就永远不触发)。需要整点/定时触发请建工程级的后台定时脚本。

全局条件脚本

new ConditionScript
{
    Name          = "温度过高联锁",
    Condition     = "#PLC1.锅炉温度.实时值 > 100",              // 布尔表达式(字符串)
    Action        = new ScriptFragment { Code = "Alarm.Trigger(\"温度过高\");" },  // 动作也是 ScriptFragment
    EdgeTriggered = true,                                          // 只在"从假变真"那一刻跑一次
    IntervalMs    = 200,                                           // 求值周期(毫秒)
    Enabled       = true,
}

EdgeTriggered 的区别很重要:

  • true(边沿触发) —— 条件从假变真时跑一次,之后一直为真也不再跑。适合"报警一次"。
  • false(电平触发) —— 只要条件为真,每一拍都跑。适合"持续处理"。

画面脚本

OnEnter  —— 进入这个画面时跑一次
OnExit   —— 离开这个画面时跑一次
Periodic —— 停在这个画面期间周期跑;一切走就停

画面脚本是随画面生死的 —— 切走了就不跑了。所以适合做"这个画面专有的刷新逻辑"。

元素事件脚本

鼠标 12 个事件,字面量固定,不要改:

类别 事件名
鼠标按键(左键) Click、DoubleClick、MouseDown、MouseUp
鼠标按键(右键) RightClick、RightDoubleClick、RightMouseDown、RightMouseUp
鼠标位置 MouseEnter、MouseHover、MouseMove、MouseLeave

另有 3 个组件事件(2026-10-13 起),只在报警列表(AlarmList)的「选事件」窗里出现,由删报交互触发:

事件名 什么时候触发 程序集子程序名
AlarmDelete 行内删除报警(脚本读 Alarm.LastClickKey() 拿被删的那条) _元素_删除报警
AlarmDeleteMany 批量删除报警(读 Alarm.LastCheckedKeys()) _元素_批量删除报警
AlarmDeleteAll 全部删除报警(读 Alarm.LastCheckedKeys()) _元素_全部删除报警

组件事件不是鼠标语义:没建这些事件时,删报走内置硬删(与旧版行为一致)。

⚠ 左键那四个历史上就叫 Click / MouseDown,不叫 LeftClick / LeftMouseDown —— 这是历史命名,改掉会破坏已有工程。别顺手"统一"成 Left*。

模块与函数

模块是一组可复用的函数,一个模块一个文件(modules/<模块名>.module.json)。

ScriptModule + ScriptFunction
    ↓ ScriptModuleCompiler.Render
static class 模块名
{
    public static 返回类型 函数名(参数) { … }
}

渲染出来的这个类会被拼到脚本前面,所以脚本里可以直接调模块里的函数。

⭐ 模块名 == 画面名 → 该模块属于那个画面,随窗口生死。 这是这套软件的约定,别搞混 —— 同名模块和画面是一对。

⭐ 写值边界:为什么你的脚本写不进去

这是最容易踩的坑,单独讲。

脚本想写一个点,要经过 RuntimeWriteGate 这道门:

脚本 #设备名.点名.实时值 = 值
        │
        ▼
   RuntimeWriteGate
        │
        ├─ 这个点是"内部变量"(IsInternal = true)?
        │      → 放行 ✅
        │
        └─ 这个点是"采集点"(来自 PLC)?
               → 抛 InvalidOperationException ❌

为什么这么设计:

往采集点写值,意味着要下发到 PLC。而"运行态写值"是这套软件刻意不对外开放的能力 (见第 01 章的"不可逾越的边界")。

  • 界面上的按钮写采集点 → 允许(那是操作工的主动操作,有人负责)
  • 脚本写采集点 → 禁止(脚本是自动跑的,没人盯着,一旦逻辑错了会直接改设备)

那脚本想影响设备怎么办? 两条路:

  1. 写一个内部变量,再由界面绑定或其它机制去用它
  2. 通过 Comm 库做通信类操作(受控的出口)

连接变量是个特例:写它时会先映射回它对应的采集点,然后同样被门禁拦下。

💡 报错信息是"通道点写入被拒绝"之类 —— 看到这个提示,先确认你要写的点是不是采集点。

装配:两种方式

方式一:ScriptRuntimeBuilder(推荐)

链式装配,每一步加一个能力:

using Hmi.ScriptRuntime;

ScriptGlobals globals = new ScriptRuntimeBuilder(rtdb, project.Tags, writer)
    .WithHistorian(historian)
    .WithAlarms(alarmEngine)
    .WithLog(logSink)
    .WithCurrentUser(() => auth.CurrentUserName)
    .WithDatabase(dbService)
    .WithUi(uiService)
    .WithReport(reportService)
    .WithRecipe(recipeService)
    .WithComm(commService)
    .WithFileRoot(@"C:\我的工程\files")   // Fs 库的沙箱根目录
    .Build();

没调的 With* 对应的库会变成"未就绪"占位件,脚本调它时抛 ScriptApiUnavailableException。

方式二:ScriptRuntimeHost(整机装配)

如果要把"脚本按工程的配置自动跑起来",用这个:

var dispatcher = new ScriptDispatcher(project, engine, globals);
using var host = new ScriptRuntimeHost(dispatcher, project);

await host.StartAsync();                  // 跑启动脚本,起周期/条件
await host.OpenScreenAsync("主画面");     // 触发画面脚本
// …
await host.StopAsync();                   // 跑退出脚本

ScriptRuntimeHost 会做这些事:

  • 程序启动时跑全局启动脚本
  • 按配置起全局周期脚本和条件脚本
  • 切画面时:停旧画面的脚本、跑 OnExit、跑 OnEnter、起新画面的周期脚本
  • 程序退出时跑全局退出脚本

执行模型:全部串行

⚠ 所有脚本在同一个信号量(SemaphoreSlim(1,1))上串行执行。

这意味着一件事:脚本之间不会并发。 一个脚本跑着的时候,别的脚本在排队。

为什么这么设计:

  • 脚本会读写共享的点值,并发会让逻辑变得难以推理
  • 用户写的脚本质量参差,串行是最不容易出错的模型

代价:一个慢脚本会拖住所有脚本。所以脚本要写得快 —— 周期脚本尤其。

但有两条例外:

  • 生命周期事件(启动/退出/进入画面/离开画面)一定会等到,不会被丢弃
  • 周期脚本和条件脚本上一拍没跑完,这一拍直接丢(TickGate),不会堆积

公开类型

类型 干什么
ScriptRuntimeBuilder 链式装配 ScriptGlobals
ScriptRuntimeHost 按工程配置驱动所有脚本
ScriptDispatcher 决定什么时候跑哪个脚本
RuntimeWriteGate 写值门禁(采集点拦截)
RtdbTagGateway ITagGateway 的真实现(脚本读写的底层)
RuntimeDataServices 一次性装配报警 + 历史 + 保留清理
ScriptValidator / RoslynScriptChecker 编译校验
ScriptModuleCompiler 模块渲染成 static class
LinkedVariableMirror 联动变量镜像
AlarmConfigMapper / HistoryConfigMapper 配置映射
ReportGenerator / AlarmNotifier 报表生成 / 报警通知

踩过的坑

  1. 脚本写采集点被拒 —— 这是设计,不是 bug。改成写内部变量。
  2. 周期脚本做耗时操作 —— 会丢拍,而且拖慢所有其它脚本(串行执行)。
  3. 把左键事件名写成 LeftClick —— 正确是 Click。
  4. 以为脚本并发执行 —— 全部串行,别指望并行。
  5. 模块名和画面名不一致 —— 那个模块就不属于这个画面了。
  6. Fs 库越界读写 —— 被限制在 fileRoot 沙箱内,越界会安全失败(读返回 fallback、写返回 false),不抛异常。

小结

  • 六种脚本:全局启动/退出/周期/条件、画面(进入/退出/周期)、元素事件
  • 周期脚本丢拍不堆积;所有脚本串行执行
  • 写值门禁:脚本能写内部变量,不能写采集点
  • 元素事件字面量固定:鼠标 12 个(左键那四个是历史命名,Click 不是 LeftClick)+ 报警列表组件 3 个
  • 模块名 == 画面名,该模块随画面生死
  • 装配用 ScriptRuntimeBuilder,整机驱动用 ScriptRuntimeHost

下一步:第 13 章 脚本函数库速查 —— 15 个库里都有什么函数。