10 · 运行数据引擎

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

什么时候你会需要它

  • 你要把工程真正跑起来:连 PLC、出实时值
  • 你在排查"采集没跑 / 设备掉线 / 写点没生效"
  • 你要自己写一个无界面的运行服务(不带 WPF 壳)

如果你只是做组态(改工程文件),不需要 Hmi.Backend —— 那是第 15 章的事。

一句话概括

Hmi.Backend 是把"工程文件 + 协议驱动 + 实时库"三样东西拼起来、让数据真的流动起来的那一层。

它是无头的 —— 不带界面、不带窗口,只负责出数据和收命令。所以它既能跑在 WPF 壳里, 也能跑在一个纯控制台进程里(那就是 Hmi.RuntimeCore.Host,见第 17 章)。

它负责什么

        ┌────────────────── Hmi.Backend.RuntimeHost ──────────────────┐
        │                                                            │
  工程 ─>│  ① 建实时库(RtdbBridge: TagDef → Tag)                     │
        │  ② 建协议注册表(内置 + 插件)                               │
        │  ③ 为每条通道规划块读(BlockPlanner)                        │
        │  ④ 起采集循环(每通道一个任务)                              │
        │  ⑤ 收写命令,排队下发给驱动                                  │
        │  ⑥ 上报通道超时(CycleOverrun 事件)                         │
        │                                                            │
        └────────────────────────────────────────────────────────────┘

怎么用:五步

using Hmi.Backend;
using Hmi.Project.Persistence;

// 1. 先加载工程
LoadedProject project = ProjectLoader.Load(@"C:\我的工程");

// 2. 建协议注册表(内置 8 个 + 指定目录里的插件)
ProtocolRegistry registry = DriverFactory.BuildRegistry(@"C:\HMI\plugins");

//    ↑ 不传参数就是只用内置协议;加载失败的插件错误在 DriverFactory.PluginErrors 里

// 3. 建运行宿主(这一步会把工程装配成可运行状态,但还没开始采集)
var host = new RuntimeHost(project, registry);

// 4. 订阅你关心的东西(可选)
host.CycleOverrun += info =>
    Console.WriteLine($"通道 {info.ChannelName} 本轮超时了");

// 5. 开始采集
host.Start();

// …跑着…

// 6. 停机
await host.StopAsync();
host.Dispose();

⚠ 第 5 步之前不会有任何数据。Start() 之后采集循环才开始跑,PLC 才开始被读。

公开成员

public sealed class RuntimeHost : IDisposable
{
    public RuntimeHost(LoadedProject project, ProtocolRegistry? protocols = null);

    // 实时库 —— 想读值就找它
    public RtdbCore Rtdb { get; }

    // 点名 → 数字 Id,省得每次查
    public IReadOnlyDictionary<string, int> TagIdByName { get; }

    // 生命周期
    public void Start();
    public Task StopAsync();
    public void Dispose();

    // 动作(界面/脚本通过这些方法间接影响运行)
    public void WriteTag(string name, string value);
    public void OpenScreen(string name);
    public void RunScript(string name);

    // 诊断
    public bool IsDeviceOnline(string deviceName);
    public bool ReconnectChannel(string channelName);

    // 事件
    public event Action<CycleOverrunInfo>? CycleOverrun;
    public Action<string>? OnOpenScreen;
    public Action<string>? OnRunScript;
}

逐个说清楚

WriteTag(name, value) —— 写点

host.WriteTag("StartBtn", "1");

注意这不是立刻发到 PLC 的。 它会进这条通道的写队列,等当前这轮采集读完了再发出去。

所以:

  • 界面上点按钮到设备动作之间,有最多一个扫描周期的延迟(默认 200 毫秒)
  • 连续写同一个点,后面的会覆盖前面的(队列里只保留最新值)

⚠ 写采集点是可以的(这是运行态的正常操作,界面按钮就这么干); 但脚本里写采集点会被拦下(见第 12 章)。两者规则不同,别混。

IsDeviceOnline / ReconnectChannel —— 诊断与恢复

if (!host.IsDeviceOnline("1号PLC"))
{
    Console.WriteLine("设备掉线了");
    host.ReconnectChannel("车间Modbus");
}

⚠ ReconnectChannel 不是"立刻重连成功" —— 它只是断开当前连接, 真正的重连靠第 07 章讲的指数退避在下一轮自然发生。

CycleOverrun 事件 —— 采集跟不上了

host.CycleOverrun += info =>
{
    // info.ChannelName / info.ElapsedMs / info.ScanMs / info.Streak / info.BlockCount
    log.Warn($"通道 {info.ChannelName} 计划 {info.ScanMs}ms,实际花了 {info.ElapsedMs}ms" +
             $"(连续第 {info.Streak} 轮,本周期 {info.BlockCount} 块)");
};

CycleOverrunInfo 的五个字段:

字段 意思
ChannelName 超时的是哪条通道
ElapsedMs 本轮"采集 + 清写队列"实际耗时
ScanMs 这条通道设定的采集周期(注意叫 ScanMs,不是 IntervalMs)
Streak 连续超时第几轮(1 = 刚开始;恢复后清零)—— 用它做节流,别每周期刷屏
BlockCount 本周期要读的块总数(判断是不是块划得太碎)

这个事件说明采集周期设得太紧,或者点太多、块划得太碎。 长期超时要处理,否则数据会越来越滞后。

OnOpenScreen / OnRunScript —— 把动作交回给宿主

RuntimeHost 是无头的,它不知道怎么切画面、怎么跑脚本(那是前端和脚本运行时的事)。 所以它把这两个动作以回调形式交出去,由宿主决定怎么做:

host.OnOpenScreen = name => 前端.切到画面(name);
host.OnRunScript = name => 脚本宿主.跑(name);

和脚本/历史/报警怎么接

RuntimeHost 只负责"采集 + 实时库"。要历史、报警、脚本,得再叠一层:

using Hmi.ScriptRuntime;

// 一次性把报警 + 历史 + 保留清理 + 联动镜像都装上
RuntimeDataServices services = RuntimeDataServices.Compose(
    rtdb: host.Rtdb,
    project: project,
    historianDbPath: @"C:\我的工程\history.db");

// 内部已经帮你 Subscribe 了,而且走的是 Lossless 车道

详见第 14 章。

完整示例:一个最小的无界面采集服务

using Hmi.Backend;
using Hmi.Project.Persistence;
using Hmi.ScriptRuntime;

class Program
{
    static async Task Main(string[] args)
    {
        string dir = args[0];

        var project = ProjectLoader.Load(dir);
        var registry = DriverFactory.BuildRegistry(Path.Combine(dir, "plugins"));

        // 插件加载失败要看一眼,不然会以为"协议不支持"
        foreach (var err in DriverFactory.PluginErrors)
            Console.Error.WriteLine($"[插件] {err}");

        using var host = new RuntimeHost(project, registry);
        host.CycleOverrun += i => Console.Error.WriteLine($"[超时] {i.ChannelName}");

        using var services = RuntimeDataServices.Compose(
            host.Rtdb, project, Path.Combine(dir, "history.db"));

        host.Start();
        Console.Error.WriteLine("采集已启动,Ctrl+C 退出");

        var done = new TaskCompletionSource();
        Console.CancelKeyPress += (_, e) => { e.Cancel = true; done.TrySetResult(); };
        await done.Task;

        await host.StopAsync();
    }
}

💡 注意日志都往 Console.Error 写。如果这个进程以后要改成用 stdout 做进程间通信 (像 Hmi.RuntimeCore.Host 那样),stdout 会被协议占用,日志必须走 stderr。见第 17 章。

踩过的坑

  1. 忘了 Start() —— 装配完了不等于跑起来了。没数据先检查这一步。
  2. Dispose() 没调 —— 采集任务还在跑,进程退不干净。用 using 或 await StopAsync() 后再释放。
  3. 把 ReconnectChannel 当成"立刻重连" —— 它只是断开,重连要等退避周期。
  4. CycleOverrun 一直报 —— 采集周期太紧或点太多。调大 ScanIntervalMs,或者把点按地址顺序排好让块划得更整。
  5. 插件加载失败没看 —— 表现为"协议不可用",实际原因在 DriverFactory.PluginErrors 里。
  6. 以为 WriteTag 是同步下发 —— 它是入队,有一个扫描周期的延迟。

小结

  • Hmi.Backend = 无头的数据引擎:建实时库 → 起采集循环 → 收写命令
  • 顺序:Load → BuildRegistry → new RuntimeHost → Start
  • WriteTag 是入队,不是立即下发,有最多一个扫描周期的延迟
  • CycleOverrun 告诉你采集跟不上,别忽略
  • 历史/报警/脚本要另外叠 RuntimeDataServices.Compose

下一步:第 11 章 脚本引擎:从代码到运行