Chapter 2 基本概念

StdUnit.Tags用测点项目(ITagsProject)来组织各个模块:

\( \boxed{\text{测点项目}} = \boxed{\text{通道}} + \boxed{\text{测点}} + \boxed{\text{逻辑}} \)

通道(ITagChannel)

顾名思义,它规定了和底层硬件通信的信息“通道”:

public interface ITagChannel : IDisposable
{
    /// 通道描述符
    TagChannelDescriptor Descriptor { get; }

    /// 连接
    Task EnsureConnectedAsync(bool force, CancellationToken ct);

    /// 关闭
    Task DisconnectAsync(CancellationToken ct);
}

每种通道的读写方式并不一致。比如,几乎每种PLC都会使用一整片连续字节数组来存储数据;而对应扫码枪而言,它很可能基于串口或者USB通信的方式,收取最近一次扫描到的值。

一个测点项目中可以定义1~n个通道(<Channel/>)。

测点(ITag)

在工业通信中,往往有一层非常简单的抽象——用一个寄存器表示一个值。比如用浮点型寄存器表示当前机器人的某个关节轴的角度,或者用一个比特表示外部开关的通断信号。

测点——可观测的点位——是对上面这些表达方式的绝佳抽象。它们往往和硬件寄存器的地址相关,又可以在高层读取或者写入。StdUnit.Tags提供了三种层次的测点抽象:

  • Tag: 代表单个叶子节点,比如一个数字输入信号、一个数字输出信号、一个16位整型变量、一个32位浮点型变量等等。所有的Tag都是叶子节点,它们都不能再嵌套子节点。
  • TagCbnt:代表一个测点组合(这里Cbnt是Combination的缩写)。有经验的程序员都知道,工业相关程序中IO往往是瓶颈,减少IO至关重要。我们可以把一些测点进行组合,从底层硬件中整体读取数据、并向底层整体写入,这种整体读写的组合,我们叫TagCbnt。TagCnbt的子元素只能是Tag。
  • TagGrp: 很多测点往往共享一些特征,比如通道、功能等,这些测点在逻辑上可以划分到一个群组,我们用TagGrp来表达这个概念(显然,Grp是Group的缩写)。测点群组可以继续嵌套TagGrp、TagCbnt、或者Tag。通常我们会定义 1~n个测点组(TagGrp)作为入口。

测点组可以通过[]索引的方式访问直接子元素。

var child = grp[childName]

这种索引式操作非常类似于字典元素的访问方式——如果对应的元素不存在,它也会报异常。

要访问内部某个子元素下的某个子元素,可以使用多级索引的方式:

var child2 = grp[_path][_to][_child]

此外,我们还可以通过Descendant("path/to/child")方法返回子孙元素:

TagUnion descendant = grp.Descendant("path/to/child")

不过这种.Descendant()方式有两个局限:

  1. 这里的路径用“/”进行分隔,所以要求路径中的每个片段内都不得含有/。如果必须要在名称内使用/,只能使用[]逐层索引的方式获取。
  2. 这里返回的类型是TagUnion,它代表 TagUnion.TagUnit | TagUnion.TagUnit | TagUnion.TagGrp的一种。可以通过.Value属性得到内部的ITag、ITagCbnt、ITagGrp。

为了避免.Descendant()仍需要使用手动类型转化的麻烦,我们提供了三个扩展方法:

ITag tag0 = grp.SelectTag("path/to/child");       // 直接拿到 ITag
ITagCbnt cbnt0 = grp.SelectCbnt("path/to/child"); // 直接拿到 ITagCbnt
ITagGrp grp0 = grp.SelectGrp("path/to/child");    // 直接拿到 ITag

示例:

_grp = tags.SelectGrp("IoBox");

_ledRed = _grp.SelectTag("输出/红灯");
_ledReset = _grp.SelectTag("输出/复位_提示灯");
_btnReset = _grp.SelectTag("输入/复位_执行键");

要从特定的ITag取值,可以直接读取.Value属性——不过为了通用,.Value返回的是object类型。我们可以使用.GetTagValue<TValue>()方法避免手动类型转化:

var resetBnt = _btnReset.GetTagValue<bool>();
var resetLed = this._ledReset.GetTagValue<bool>();

想要写入值,可以直接对属性赋值:

_ledRed.Value = true;

注意: 本类库不会自动尝试任何隐式自动转化,哪怕是你看起来觉得没什么问题的隐式类型转换!这意味着类库使用者不得混写类型,比如不可以把short写给int,也不要把int写给float!

另外,ITag.Timestamp记的是UTC时间(内部用DateTime.UtcNow写入),要展示或者和本地时间比较时,请自行转换。

使能(isEnabled)与遍历模式

测点组(TagGrp)和测点组合(TagCbnt)上都可以写isEnabled(默认true)。它只管自动轮询:为false时,轮询到这一层就短路——这棵子树既不读也不写(跳过时保留脏标记,重新使能后该刷的照常刷)。注意Tag上没有这个属性,见下。

<TagGrp name="采集" channel="S7-1" isEntry="true" isEnabled="false">
  <Tag name="温度" address="DB1.0" type="INT16"></Tag>
</TagGrp>

但是,"读"和"写"这两个动作本身不应该被isEnabled绑架:被禁用的是自动采集,而不是这个点位不能碰。所以手动读写一律不看它:

// 手动读、手动写:即使该组或该组合 isEnabled="false" 也照常执行
await tag.ReadAsync(ct);
await grp.WriteAsync(ct);

于是"平时禁用、需要时手动点动"是刻意支持的用法,就像PLC里下了使能、操作员依然可以手动操作一样。区别只在谁来驱动:自动轮询带上门控,手动调用不带门控。这套"带不带门控"在代码上叫遍历模式TraversalMode:

  • TraversalMode.RespectEnabled:检查使能,自顶向下判定——父节点没使能,整棵子树就不再被考虑。这是轮询器自己用的那一档;
  • TraversalMode.IgnoreEnabled:不检查使能,整棵子树照常读写。手动调用走的就是这一档。

想自己模拟"轮询那一档"时(比如写个外部的巡检器),显式传参即可:

await grp.ReadAsync(TraversalMode.RespectEnabled, ct);   // 按 isEnabled 短路

备注:

  • grp.ReadAsync(ct)这种单参写法是扩展方法、等价于传IgnoreEnabled(TagUnion上同理)。
  • ITagCbnt有IsEnabled,但它的读写方法不带这个参数;
  • Tag则连IsEnabled都没有——因为它可能是ITagCbntor被挂到ITagCbnt下,作为一个整体进行读写,如果给它也引入IsEnabled,语义不能自洽。

逻辑(ILogicet)

一个项目如果只能对点位进行读写,那大概是只一个无聊的采集应用。

StdUnit.Tags 允许你加入自己的业务。在这里,业务逻辑被抽象成简单的ILogicet接口:

public interface ILogicet
{
    /// <summary>
    /// 所属的测点项目(通道、测点都在这里)
    /// </summary>
    ITagsProject Project { get; }

    /// <summary>
    /// 运行顺序
    /// </summary>
    int Order { get; }

    /// <summary>
    /// 启用?
    /// </summary>
    bool Enabled { get; }

    /// <summary>
    /// 是否能匹配入口?返回true表示应该处理当前entry,否则应该跳过处理
    /// </summary>
    /// <param name="entry"></param>
    /// <returns></returns>
    bool MatchEntry(ITagGrp entry);

    /// <summary>
    /// 处理
    /// </summary>
    /// <returns></returns>
    Task ProcessAsync(ITagGrp entry, ITagChannel? thisChannel);
}

逻辑组件通过 Project.Channels / Project.Tags 访问通道与测点;LogicetBase 在基类上保留了同名的便捷属性,所以继承它时 this.Tags 照旧可用。

你可以在你的程序中以代码的方式注册你的业务逻辑实现,也可以在xml中以dll的方式手动注册。

针对每个入口(TagGrp),都有一个ITagGrpRunner执行 [扫描->处理 ->输出] loop:

  • 扫描: 从底层读取
  • 处理: 依次执行各Logicet的ProcessAsync(entry, thisChannel)
  • 输出: 向底层刷写

这种机制十分类似于PLC的工作原理。你可能觉得这种机制过于死板、浪费性能、不够酷炫,但是工程项目不是个人花活,想想波音飞机的飞控程序吧,它也以在类似的机制工作着。

我们也提供.Watch()的事件机制,不过这种事件式的方式并不会改变主循环。作为简介,我们不打算在这里过多阐述这种模式。

项目的构造和运行

我们可以使用ITagsProjectFactory构建项目,然后运行:

// var dir =  // 指定项目运行的目录

// 构造测点项目
var factory = sp.GetRequiredService<ITagsProjectFactory>();
ITagsProject proj = factory.Create(dir, rootXml);   

// ... 可选:注册自己的逻辑组件,定制事件钩子等

// 执行 [扫描 -> 处理 -> 输出] 循环
await proj.RunAsync(ct);

如之前我们在起步中所说,这里目录"dir"下通常会有一份测点索引文件(index.xml)。这个索引同样可以拆成三个部分:通道+测点+逻辑,我们将在下一章详细展开如何配置和构建一个项目。

构造测点项目、添加逻辑组件、配置事件钩子、最后运行测点项目,这些重复步骤非常死板,所以我们提供了一个ITagsProjectCtrl接口,让你可以一键完成上面所有步骤:

await projctrl.StartPollAsync(dir, root: null, hook: (proj, sp, ct) =>
{
    proj.Logicets.Add(new HeartBeatLogicet(proj));
    // ... 其它
    return Task.CompletedTask;
});