Chapter 1 起步

你是否纠结过:如何避免为各种常见的工业硬件编写“重复”的代码?

StdUnit.Tags 是我编写的一套面向工业硬件交互场景的极简类库。无论底层通信是OpcUa、S7、还是ModbusTcp,或者是串口、甚至可能是MQTT,StdUnit.Tags 都能让你以统一的方式,编写横跨各种工业硬件的交互代码。它有如下几个设计理念:

  • 模块化+可扩展: 各模块之间互相独立,没有耦合。以.AddS7Support()的方式添加S7通信支持,以.AddComScannerSupport()方式添加串口扫码枪通信支持……没发现你想要的硬件支持?你可以自己轻易扩展。
  • 允许组合读写:可以用整体读写的方式来减少IO次数。
  • 刚好够用:只提供刚好够用的功能,简单易学,符合直觉,没有魔法。

这个类库是前些年我过年放假在家时编写的,主要目的是为了降低门槛(足够简单)、提高灵活性。起初,我只想做一个通信类库以自用,所以当时冠名Itminus.Tags。后来又逐步引入了业务逻辑插件化、串行轮询扫描等等一些值得复用的手段,所以自0.15.1之后,原来的Itminus.Tags改名成StdUnit.Tags.*,寓意这是一个可重复使用的标准单元。StdUnit.Tags自0.16.0开始在测试源上发布,自1.0.0起发布到 nuget.org;老的Itminus.Tags系列依将永久保持可用状态,但不再更新。

在着手编写这个项目之前,我倾向于使用强类型的方式,在类型实例和一段连续的字节数组之间做递归式地自动映射,这种做法运行效率非常高,而且还有超级友好的类型提示,我曾偏爱这种代码风格接近十年。但不可否认,这也带来了两个坏处:

一是要求编程人员熟知内存布局,比如要求大家知道int在C#中会占据4个字节,而在西门子PLC中占据2个字节;再比如,编程者需要知道西门子PLC的字符串会在头部附加两个字节,用来存储总字节数和有效字节数。这一点无疑拔高了编程门槛。

二是点表和程序代码一一对应的,这意味着动态修改比较麻烦。熟悉我的朋友都知道,我一贯强烈反对把修改代码的工作延迟到现场,但是我直到最近才认可这和提供灵活性不完全冲突。

快速上手

我们提供两种方式让你快速跑起一个测点项目。

使用模板创建新项目

对于全新项目,我们推荐你使用模板StdUnit.Tags.Templates:

dotnet new install StdUnit.Tags.Templates

创建Web项目:

dotnet new tags.web

创建WPF项目:

dotnet new tags.wpf

你也可以创建 target 到 net472的 WPF 项目:

dotnet new tags.wpf-netfx

不过,在netfx下,AspNetCore相关功能(含Blazor)不可用,插件系统需要降级使用(不支持卸载、热更等)。

创建Lib项目:

dotnet new tags.lib

手动创建新项目/添加到已有项目

对于想一步一步手动创建的用户,也可以手动在你的项目中添加依赖、注册相关服务和硬件支持。

依赖包

首先,我们需要添加基础包依赖:

dotnet add package StdUnit.Tags

然后,根据项目所需要通信的底层硬件,选择一个或者多个具体的实现包。目前内置的硬件支持包括:

驱动包名备注
简单文件StdUnit.Tags.SimpleFiles支持
西门子S7StdUnit.Tags.S7支持
OpcUAStdUnit.Tags.OpcUa支持
华杰智控IO盒子StdUnit.Tags.Hjzk支持
ModbusTcpStdUnit.Tags.ModbusTcp实验
串口StdUnit.Tags.ComScanner实验
卓兰IO盒子StdUnit.Tags.ZLan实验

StdUnit.Tags支持的协议和硬件会持续增加。如果暂时没有你想要的,可以自由扩展实现你自己的驱动。

然后向注册相关服务和硬件支持:

services.AddTagsProjectServices(builder =>
{
    builder.AddS7Support();
    // 增加其它更多的硬件支持,比如:
    // builder.AddComScannerSupport();
    // ...
});

如果你喜欢响应式,可以添加下面两个扩展包(之一):

StdUnit.Tags.RxExtensions

StdUnit.Tags.R3Extensions

如果你需要接入AI,可以引入StdUnit.Tags.McpServer这个扩展包,把你的硬件暴露给Agent。

项目描述

通常,我们会在本地目录下创建一份测点项目描述文件(index.xml),内容类似于:

<?xml version="1.0" encoding="utf-8"?>
<Project>

	<!-- 通道,可以配置多个-->
	<Channel name="S7-1" driver="S7" >
		<IpAddr>172.16.10.20</IpAddr>
		<Rack>0</Rack>
		<Slot>1</Slot>
	</Channel>

	<!-- 测点配置,可以配置多个 -->
	<TagGrp name="IoBox" isEntry="true" isEnabled="true" channel="S7-1" scanInterval="0">
		<TagGrp name="通用状态">
			<TagCbnt name="PLC" address="DB200.100">
				<Tag name="心跳请求" address="$$100.0" type="BIT" access="RO"></Tag>
			</TagCbnt>
			<TagCbnt name="MST" address="DB201.100" access="R1W">
				<Tag name="心跳响应" address="$$100.0" type="BIT"></Tag>
			</TagCbnt>
		</TagGrp>

	</TagGrp>

	<!-- 逻辑配置,可以配置0~N个 -->
	<!--<Logicet>Samples.Plugin1.dll</Logicet>-->
</Project>

备注:除了使用约定的index.xml,我们也可以用程序的方式自己构建节点,这样项目工厂就不会去搜寻index.xml文件了。

备注:XML没有智能提示和自动校验,让你没有安全感?可以考虑引入我编写的XSD,参见配置和构建

业务逻辑

让我们创建一个心跳逻辑来处理心跳随动信号:

public class HeartBeatLogicet : LogicetBase
{
    private readonly ITag heartReq;
    private readonly ITag heartAck;
    private readonly ILogger<HeartBeatLogicet> _logger;

    public HeartBeatLogicet(ITagsProject project, ILogger<HeartBeatLogicet> logger)
		: base(project)
    {
        var grp = this.Tags.SelectGrp("IoBox/通用状态");

        heartReq = grp.SelectTag("PLC/心跳请求");
        heartAck = grp.SelectTag("MST/心跳响应");
        this._logger = logger;
    }

    public override int Order => 2;

    public override bool MatchEntry(ITagGrp entry) => entry.TagName() == "IoBox";

    public override Task ProcessAsync(ITagGrp entry, ITagChannel? thisChannel)
    {
        heartAck.Value = heartReq.Value;
        return Task.CompletedTask;
    }
}

项目运行

上面的index.xml仅仅只是一份测点项目静态描述。为了将该项目跑起来,最简单的思路是利用ITagsProjectCtrl接口提供的.StartPollAsync()方法:

using var scope = this._ssf.CreateScope();
var sp = scope.ServiceProvider;

// 项目启停控制器
var ctrl = sp.GetRequiredService<ITagsProjectCtrl>();

var dir ="D:/manufacture/pl01/";	// 提供项目运行目录,其中有通信点表和可能用到的插件
XElement? root = null;			// 空表示使用默认的`index.xml`来配置项目

// 启动测点项目串行轮询,此方法通常会长时间运行:
//     即使发生异常,也不会主动结束,而是会自动重试;
//     除非在启动阶段发生异常或者测点项目被停止,否则不会主动退出。
await ctrl.StartPollAsync(dir, root, hook: async(proj, sp, ct) =>{
	// 添加业务逻辑
	proj.Logicets.Add(new HeartBeatLogicet(
		proj,
		loggerFactory.CreateLogger<HeartBeatLogicet>()
	));

    // ... 可选:但推荐注册 proj.RunnerStarted 事件处理
	// ... 可选,但推荐注册 proj.RunnerCrashed 事件处理
});

项目停止

如果想在某个时刻停止项目,调用.StopAsync()方法即可:

await ctrl.StopAsync();

项目停止后,与项目相关资源会被自动释放。如果需要重新启动项目,则需要重新调用.StartAsync(dir, root, hook)方法。

备注

如果我的代码能帮到你,我们希望你也能在力所能及的情况下,回馈开源。程序员的生涯也许是非常短暂的,但是一旦你的作品开始发出微弱的光芒,就能轻松照亮无数的后来者以快步前行。

如果需要和我探讨编程技术,可以通过(aXRtaW51c0Bob3RtYWlsLmNvbQ==)联系我。或许在我有空的时候,大家可以畅聊人生。

由于个人精力有限,我只能优先实现我自己需要的特性。对于一些尚未支持的通信协议,我们鼓励你自己动手写实现。如果你自己不想花太多精力,也可以付费购买其他开发者的时间。