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==)联系我。或许在我有空的时候,大家可以畅聊人生。

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

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;
});

Chapter 3 配置和构建

我们提供完整的公开接口来让你以编程的方式操作每一个点位和每一个群组——你可以自由地手动调用读写测点(Tbnt/Grp/Unit)功能。但是我们推荐你使用配置的方式描述和运行测点项目。

配置

用代码的方式构建项目的好处是强类型支持和优秀的智能提示,但是纯代码的方式既不够简洁,也不够灵活。我们可以把测点项目中的纯静态结构用一个配置文件加以描述:

<?xml version="1.0" encoding="utf-8"?>
<Project>
    <!-- 可以有1~N个通道 -->
	<Channel name="S7-1" driver="S7">
		<IpAddr>192.168.10.2</IpAddr>
		<Rack>0</Rack>
		<Slot>1</Slot>
	</Channel>
	<Channel name="S7-2" driver="S7">
		<IpAddr>192.168.10.2</IpAddr>
		<Rack>0</Rack>
		<Slot>1</Slot>
	</Channel>

    <!-- 可以有1~N个测点组作为入口 -->
	<TagGrp name="信号区-1#" isEntry="true" isEnabled="true" channel="S7-1" scanInterval="10">
		<TagCbnt name="PLC" address="DB10.16" access="RO">
			<Tag name="心跳" address="DB10.16.0" type="BIT"></Tag>
			<Tag name="复位" address="DB10.16.1" type="BIT"></Tag>
			<Tag name="故障" address="DB10.18" type="INT16" endian="BigEndian"></Tag>
            <!--  ...more... -->
		</TagCbnt>
		<TagCbnt name="IPC" address="DB10.0" access="RW">
			<Tag name="心跳" address="DB10.0.0" type="BIT"></Tag>
			<Tag name="复位" address="DB10.0.1" type="BIT"></Tag>
			<Tag name="自动" address="DB10.0.2" type="BIT"></Tag>
			<Tag name="结束确认" address="DB10.0.3" type="BIT"></Tag>
            <!--  ...more... -->
		</TagCbnt>
	</TagGrp>

	<TagGrp name="信号区-2#" isEntry="true" isEnabled="true" channel="S7-1" scanInterval="10">
        <!--  ...more... -->
	</TagGrp>


    <!-- 可以有0~N个逻辑组件 -->
	<Logicet>Samples.Plugin1/Samples.Plugin1.dll</Logicet>
	<Logicet>Samples.Plugin2/Samples.Plugin2.dll</Logicet>
	<!--  ...more... -->
</Project>

这里的有些基本规则:

  • 整个<Project/>可以分成三大部分:
    • <Channel/>(1~N个):描述了项目中用到的通道。
    • <TagGrp/>(1~N个): 描述了项目中用到的测点树。
    • <Logicet/>(0~N个): 描述了项目中用到的业务逻辑(插件)。
  • <Channel/>:
    • name属性:表示通道的名称,会被测点(以及TagCbnt,TagGrp)所引用。
    • driver属性:表示该通道使用的底层驱动,e.g. S7、COM等。每种驱动的通信方式并不相同。
  • <TagGrp>:
    • 通常顶层的<TagGrp/>会标注isEntry来表明这是一个入口。一个项目可以有多个入口,这些入口会被并行执行。
    • <TagGrp/> 表示一个逻辑分组,其下可以套多层,最终会嵌<TagCbnt/>或者<Tag/>。但是有一些底层驱动不支持批量读写,这时候<TagCbnt/>就是非法的;而另一些底层驱动可能并没有针对单个测点的读写提供支持,这时候直接嵌套<Tag/>也是非法的。总之,使用 <TagCbnt/>还是<Tag/>,要查看通道测点的驱动。
    • 测点的地址是和通道驱动密切相关的,每种驱动的地址都不相同。比如在S7下DB100.10.0代表的是DB100的第10个字节的第0个比特,特别的,可以用$$10.0代表使用和父级一致的Block;而对于OpcUA,地址则可能是ns=4;s=|var|CODESYS Control Win V3 x64.Application.PLC_PRG.var3。
    • 测点的访问方式有RO| WO | RW | R1W,对应的是 只读 、只写、读写、只在开始时读1次,后续只写,默认是RW。
    • 测点组和测点组合上都可以写isEnabled,用来控制自动轮询是否采集这棵子树(默认true)。它不影响手动读写——细节见 Chapter 2 基本概念。
    • 测点的通道检索是冒泡的。即如果本层节点未指定channel特性,则会向上冒泡检索,直至在祖先节点上检索到非空的通道(或者最终都没有检索到,会给出null)
  • <Logicet/>: 描述了项目中用到的业务逻辑(插件),程序集里所有 public、非抽象、非泛型的 ILogicet 实现都会被实例化。
    • 如果你偏好使用代码的方式注册逻辑,可以不用这里的动态插件机制,直接调用接口proj.Logicets.Add(...) / TryAddLogicet<T>()。
    • AOT(PublishAot=true)宿主不能使用动态插件:NativeAOT 的运行时没有动态加载程序集的能力,配了 <Logicet/> 会在加载期直接报错——见 AOT 支持。

校验和智能提示

书写上面的XML既没有智能提示,也没有自动校验机制。这可能会让不少缺爱的工程师们没有安全感。我们可以引入XSD来解决这个问题,代价则是要使用命名空间,这会显得稍稍繁琐一点:

<Project 
	xmlns:s7="tags:s7" 
	xmlns:usbkbcs="tags:usbkeyboardcodescanner"

	xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" 
	xsi:noNamespaceSchemaLocation="..\Schemas\tagsproject.xsd"
	xsi:schemaLocation="
		tags:s7 your-xsd-loc1
		tags:modbustcp your-xsd-loc2 
	"
	>

	<!-- 通道,可以配置多个-->
	<Channel name="S7-1" driver="S7" >
		<s7:IpAddr>172.16.10.20</s7:IpAddr>
		<s7:Rack>0</s7:Rack>
		<s7:Slot>1</s7: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>

注:这里的相关XSD文件会在dotnet恢复依赖时自动链接到你的项目中。不过仍需要你在你喜欢的IDE、编辑器中引入我们编写的XSD。

尽管变得啰嗦了许多,但是在这种方式下,IDE、编辑器会自动进行智能提示、补全代码。

需要说明的是,这是XML的编辑时功能,不会影响运行时。如果你还希望在项目运行前也能有自动的XSD程序化校验,则可以builder.EnableXmlSchemaValidation()来启用这个功能:

services.AddTagsProjectServices(builder =>
{
	// 启用 XML Schema 校验,每次启动项目之前会自动触发验证。
	builder.EnableXmlSchemaValidation(); 

    builder.AddS7Support();
    // 增加其它更多的硬件支持,比如:
    // builder.AddComScanner();
    // ...
});

一旦启用EnableXmlSchemaValidation(),使用未配置命名空间的XML配置、或者非法的XML配置都会在项目运行之前抛出TagsProjectSchemaException异常(它是TagsProjectValidationException的派生类型,二者都属于加载期异常族TagsProjectLoadException;逐条错误在Errors里)。

顺带说明另外 4 个校验器(跨引用、驱动工厂、入口通道独占、嵌套入口)默认就是开启的,不需要(也无法)通过EnableXmlSchemaValidation()打开——它们只拒绝"确定的配置错误",且因为不依赖XSD,连"无命名空间的老写法"也能正常校验。是否需要运行时XSD校验,由你自己决定。

注:为了保持简洁,让读者能更专注于几个核心概念,在后续本系列文档中,文档本身都使用无XSD的方式来讲解。但是在我们实际的示例代码仓库中,使用了XSD来保持良好的正确性。这不代表我们在推荐或者不推荐哪一种具体方式。是否需要引入XSD的编辑时或者运行时校验, 由你自己决定。

构建+运行

如果你想手工把上面的index.xml构建成测点项目(ITagsProject),可以使用ITagsProjectFactory接口:

var dir = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData); 
// 或者,如果不指定root,将会自动加载`dir/`下的`index.xml`文件
var root = XDocument.Load(xmlFileName).Root;

// 构建测点项目
var proj = factory.Create(dir!, root);  

// ... 可选:可以手动注册 Logicets 、可以手动监听事件

在拿到了ITagsProject实例之后,我们可以手动将它运行起来:

// 运行测点项目
await proj.RunAsync(ct);

但是,大部分时候,没必要手动构建再手动运行——直接利用ITagsProjectCtrl接口一键构建+配置+运行:

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

Chapter 4 逻辑组件

逻辑组件是我们编写业务代码的地方。

internal class HeartBeatLogicet : LogicetBase
{
    public HeartBeatLogicet(ITagsProject project, int robot) 
        : base(project)
    {
        this.Robot = robot;
        this._heartbeatReq = this.Tags.SelectTag("PLC/信号区-1#/心跳请求");
        this._heartbeatAck = this.Tags.SelectTag("MST/信号区-1#/心跳响应");
    }

    public override int Order => 100;

    public int Robot { get; }

    private ITag _heartbeatReq;
    private ITag _heartbeatAck;

    public override bool MatchEntry(ITagGrp entry) => entry.TagName() == "信号交互";

    public override Task ProcessAsync(ITagGrp entry, ITagChannel? thisChannel)
    {
        this._heartbeatAck.Value = this._heartbeatReq.GetTagValue<bool>();
        return Task.CompletedTask;
    }
}

组件编写

逻辑组件并不一定要继承自LogicetBase,只需要实现ILogicet即可。构造函数会收到所属的ITagsProject(继承LogicetBase时写作 : base(project)),通道与测点都从它上面取:Project.Channels / Project.Tags。LogicetBase只是把接口成员实现掉,并在基类上保留了 Channels / Tags 两个转发属性(等价于 Project.Channels / Project.Tags),避免子类反复写 Project.。

  • Project:所属的测点项目。构造时通道与测点已加载完成,可以直接在构造函数里用 this.Tags.SelectTag(...) 解析并缓存常用测点。
  • MatchEntry(ITagGrp entry): 用来表示本逻辑组件能否在指定入口 Loop 中执行。
  • Order: (如果能被执行的话)本逻辑组件在的执行顺序。序号越小,越会被优先执行。
  • ProcessAsync(ITagGrp entry, ITagChannel? thisChannel): 具体的业务逻辑。

逻辑组件的实例一旦被创建,在整个ITagsProject的生命期内都会被重用。

注册

逻辑组件需要注册到测点项目中,才能被执行。

手动注册

proj.Logicets.Add(new HeartBeatLogicet(proj, 1));

自动注册

<Project>
    <!-- ... -->

    <!-- 动态指定程序集 -->
    <Logicet>Samples.Plugin1/Samples.Plugin1.dll</Logicet>
    <Logicet>Samples.Plugin2/Samples.Plugin2.dll</Logicet>
</Project>

注意上面的逻辑组件,我们可以定义0~n个逻辑组件(<Logicet/>),只需指定*.dll文件路径,即可自动完成动态加载程序集、自动注册其中的ILogicet的公开(public)且具体(Concrete)的实现。

逻辑组件的动态管理

StdUnit.Tags会在 ITagsProject 初始化时,动态加载相关程序集、并构造ILogicet实例;最后在Dispose()时,自动卸载不再需要的相关程序集。

开发者可以注册自己的 ILogicetCreator,来控制如何构造相关Logicet实例,如果未提供这样的自定义实现,StdUnit.Tags会回退到默认实现。

组件的运行

还记得我们之前说过的运行机制吗?

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

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

这里我有意模仿了PLC的工作原理,请注意这是一个重大抉择: 我们有意放弃了(也许存在的)性能提升和花活,来换取所有的逻辑组件都是串行轮询的。

注意:由于所有逻辑组件都是规定的顺序串行轮询的,所以,在任何时候,都应该让ProcessAsync(entry, thisChannel)这个方法尽早结束,否则,串行约束会让后续的逻辑组件长时间得不到执行。

Chapter 5 Rx 扩展

我们为ITag增加了Rx支持。在开始之前,根据你的需求,需要添加下面两个包的引用(之一):

dotnet add package StdUnit.Tags.RxExtensions
dotnet add package StdUnit.Tags.R3Extensions

RxExtensions是使用dotnet/reactive来扩展ITag,而R3Extensions使用的则是Cysharp/R3。二者思路一致、接口类似,我们这里以dotnet/reactive为例讲解。

ITag的扩展

目前我们针对ITag主要提供了三个扩展方法:

  • tag.ObserveOnRead(): 观测从底层读取
  • tag.ObserveOnWritten(): 观测向底层写入
  • tag.Watch(): 观测测点发生了变化

这三个方法都是建立在ITag的测点读写事件上的。

观测从底层读取事件

本质上,ITag定义了一个事件OnTagRead:

public interface ITag 
{
    //...

    /// <summary>
    /// 在每次从底层读取后,触发事件
    /// </summary>
    event TagSyncEventHandler OnTagRead
}

ObserveOnRead()无非是把这个事件转成了Rx上可观测的事件流:

public static IObservable<EventPattern<ITag, TagSyncEventArgs>> ObserveOnRead(this ITag tag)
{
    var read = Observable.FromEventPattern<TagSyncEventHandler, ITag, TagSyncEventArgs>(
        h => tag.OnTagRead += h,
        h => tag.OnTagRead -= h
        );
    return read;
}

观测向底层写入事件

类似地,ITag也定义了一个事件OnTagWritten:

public interface ITag 
{
    //...

    /// <summary>
    /// 在每次向底层写入后,触发事件
    /// </summary>
    public event TagSyncEventHandler OnTagWritten;
}

ObserveOnWritten()也无非是把这个事件转成了Rx上可观测的事件流:

public static IObservable<EventPattern<ITag, TagSyncEventArgs>> ObserveOnWritten(this ITag tag)
{
    var written = Observable.FromEventPattern<TagSyncEventHandler, ITag, TagSyncEventArgs>(
        h => tag.OnTagWritten += h,
        h => tag.OnTagWritten -= h
        );
    return written;
}

观测测点改变事件

不管是从底层读取,还是向底层写入,都可以视作“内存”和“底层”之间发生了“同步”。tag.Watch()就是组合了这两种事件的一个便利方法:

public static IObservable<EventPattern<ITag, TagSyncEventArgs>> Watch(this ITag tag, bool startWithCurrent=true)
{
    var read = tag.ObserveOnRead();
    var written = tag.ObserveOnWritten();
    var obs = read.Merge(written);
    if(startWithCurrent)
    {
        var ev = new EventPattern<ITag, TagSyncEventArgs>(
            tag,
            new TagSyncEventArgs(tag.Value, tag.Timestamp, TagSyncEventArgs.Kinds.None)
            );
        return obs.StartWith(ev);
    }
    else
    {
        return obs;
    }
}

这里的默认值true表示需要把当时的值作为初始事件发给下游监听者。有些测点长时间不读写,而监听又通常又发生比较晚,指定true作为参数,可以有效避免这种长时间观测不到值的情况。

注意:不要在订阅中直接通过tag.Value=xx的方式修改点位。你应该通过外部意图的方式完成这种修改。关于这一点,我们会在后面详细讲解。

ITagsProjectCtrl的扩展

ITagsProjectCtrl有一个启停事件:

    event TagsProjectStartedOrStopped? StartedOrStopped;

上面这个委托TagsProjectStartedOrStopped的签名是:

public delegate void TagsProjectStartedOrStopped(ITagsProjectCtrl sender, TagsProjectEventArgs args);

这里的TagsProjectEventArgs提供了两个属性:

public class TagsProjectEventArgs : EventArgs
{
    /// 关联的项目
    public ITagsProject? Project { get; }
    /// true 表示启动事件, false表示停止事件
    public bool IsStarted { get; }
}

有了这个启停事件机制,我们就可以将它转成对事件的观测ctrl.ObserveStartedOrStopped():

public static Observable<TagsProjectEventArgs> ObserveStartedOrStopped(this ITagsProjectCtrl ctrl)
{
    var obs = Observable.FromEvent<TagsProjectStartedOrStopped, TagsProjectEventArgs>(
        h => (sender, e) => h(e),
        d => ctrl.StartedOrStopped += d,
        d => ctrl.StartedOrStopped -= d
    );
    return obs;
}

Chapter 6 综合练习1

本文示例源码地址是: StdUnit.Tags.WPFDemo ,位于分支 eg-1 中.

你可以直接使用StdUnit.Tags.Templates 模板,执行命令dotnet new tags.wpf来创建一个脚手架,并基于它来微调成这里代码。

我们打算新建一个简单的WPF项目,该项目涉及PLC两个信号:

  • 心跳请求,位于 DB200.100.0, 固定以0.5Hz的频率闪烁。
  • 心跳响应,位于 DB201.100.0, 跟随心跳请求这个信号跳动

创建完一个模板项目后,请添加如下依赖:

  • StdUnit.Tags.Core
  • StdUnit.Tags
  • StdUnit.Tags.S7
  • StdUnit.Tags.RxExtensions

以及日志相关依赖:

  • Serilog
  • Serilog.AspNetCore
  • Serilog.Sinks.Console
  • Serilog.Sinks.File

项目描述和逻辑

让我们建立一个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" />
			</TagCbnt>
			<TagCbnt name="MST" address="DB201.100" access="R1W">
				<Tag name="心跳响应" address="$$100.0" type="BIT" />
			</TagCbnt>
		</TagGrp>
	</TagGrp>
</Project>

然后再添加一个心跳随动逻辑组件:

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)
    {
        var now = Stopwatch.GetTimestamp();
        heartAck.Value = heartReq.Value;
        return Task.CompletedTask;
    }
}

组合到一起

现在,我们可以把上面这些代码组合到一起了。

首先创建一个静态扩展方法,用于注册Tags相关服务:

public static IServiceCollection AddWpfDemoTags(this IServiceCollection services)
{
    services.AddLogging();
    services.AddTagsProjectServices(builder =>
    {
        builder.EnableXmlSchemaValidation();
        builder.AddS7Support();
    });
    return services;
}

然后为App添加一个静态方法,以构造一个ServiceCollection,调用上面的AddWpfDemoTags():

public partial class App : Application
{
    private static ServiceCollection ConfigureServiceCollections()
    {
        var services = new ServiceCollection();
        services.AddSerilog((sp, lc) => lc
            .ReadFrom.Services(sp)
            .WriteTo.Console()
            .WriteTo.File("logs/log.txt", outputTemplate: "{Timestamp:yyyy-MM-dd HH:mm:ss} [{Level:u3}] [{SourceContext}] {Message}{NewLine}{Exception}", rollingInterval: RollingInterval.Day)
            .Enrich.FromLogContext()
        );

        services.AddWpfDemoTags();
        return services;
    }

接着为App指定Startup=Application_Startup,以在程序启动的时候就自动启动测点项目:

public partial class App : Application
{
    public IServiceProvider? Root { get; private set; }
    internal ITagsProjectCtrl? Ctrl { get; private set; }

    private void Application_Startup(object sender, StartupEventArgs e)
    {
        ServiceCollection services = ConfigureServiceCollections();
        this.Root = services.BuildServiceProvider();
        this.Ctrl = this.Root.GetRequiredService<ITagsProjectCtrl>();

        var th = new Thread(async () =>
        {
            var loggerFactory = this.Root.GetRequiredService<ILoggerFactory>();
            var logger = loggerFactory.CreateLogger<App>();

            var dir = Path.Combine(AppContext.BaseDirectory, "Tags");
            await this.Ctrl.StartPollAsync(dir, null, (proj,sp, ct) =>
            {
                proj.Logicets.Add(new HeartBeatLogicet(
                    proj,
                    loggerFactory.CreateLogger<HeartBeatLogicet>()
                ));

                proj.RunnerStarted += (grp, ch) => {
                    logger.LogInformation("Tags处理开始,grp={grpName}", grp.TagName());
                    return Task.CompletedTask;
                };
                proj.RunnerCrashed += (grp, ch, ex) => {
                    logger.LogError(ex, "Tags处理异常,grp={grpName}", grp.TagName());
                    return Task.CompletedTask;
                };

                return Task.CompletedTask;
            });
        });
        th.IsBackground = true;
        th.Start();
    }

最后,我们还需要在程序退出的时候自动停止我们的测点项目:

    protected override async void OnExit(ExitEventArgs e)
    {
        if (this.Ctrl is not null)
        {
            await this.Ctrl.StopAsync();
        }
        Environment.Exit(0);
    }

至此,运行这个项目,PLC上已经可以观测到你在跟随它的心跳随动了。

UI 监控

但光有后台逻辑还不够直观,让我们再花两分钟编写一个简单界面来显示这个两个信号的实时值:

MainWindow
    <Grid>
        <Grid.ColumnDefinitions>
            <ColumnDefinition Width="Auto" />
            <ColumnDefinition />
        </Grid.ColumnDefinitions>

        <Grid.RowDefinitions>
            <RowDefinition/>
            <RowDefinition />
        </Grid.RowDefinitions>

        <Grid.Resources>
            <Style TargetType="Label" >
                <Setter Property="VerticalAlignment" Value="Center" />
            </Style>
            <Style TargetType="TextBlock" >
                <Setter Property="VerticalAlignment" Value="Center" />
            </Style>
        </Grid.Resources>

        <Label Grid.Row="0" Grid.Column="0">心跳请求</Label>
        <TextBlock  Name="txtReq" Grid.Row="0" Grid.Column="1" />

        <Label Grid.Row="1" Grid.Column="0">心跳响应</Label>
        <TextBlock Name="txtAck" Grid.Row="1" Grid.Column="1" />
    </Grid>

让我们用code-behind的方式把测点的值实时绑定到界面上:

public partial class MainWindow : Window, IDisposable
{
    private CompositeDisposable _disposables;

    public MainWindow()
    {
        InitializeComponent();
        this._disposables = new CompositeDisposable();

        var app = App.Current as App ?? throw new InvalidCastException("App.Current is not of type App");
        var projobs = app.Ctrl.ObserveStartedOrStopped()
            .Select(evt => evt.IsStarted ? evt.Project : null)
            // 测点项目由后台线程在 Application_Startup 中启动,可能早于本窗口构造;
            // StartedOrStopped 是普通事件、不会重放历史记录,因此用当前 Project 作首值兜底。
            .Prepend(app.Ctrl.Project)
            .DistinctUntilChanged()
            .Publish()
            .RefCount();

        this._disposable = projobs.Select(proj => Observable.Create<Unit>(observer => {
                return proj is null ?
                    Disposable.Empty :
                    SubscribeTags(proj!.Tags);
            }))
            .Switch()
            .Subscribe();
    }

    private IDisposable SubscribeTags(ITagGrp tags)
    {
        var req = tags.SelectTag("IoBox/通用状态/PLC/心跳请求");
        var ack = tags.SelectTag("IoBox/通用状态/MST/心跳响应");

        CompositeDisposable d = new CompositeDisposable();

        req.Watch()
            .ObserveOn(DispatcherScheduler.Current)
            .Subscribe(evt =>
            {
                this.Dispatcher.Invoke(() =>
                {
                    this.txtReq.Text = evt.EventArgs.NewValue?.ToString();
                });
            })
            .DisposeWith(d);

        ack.Watch()
            .ObserveOn(DispatcherScheduler.Current)
            .Subscribe(evt =>
            {
                this.Dispatcher.Invoke(() =>
                {
                    this.txtAck.Text = evt.EventArgs.NewValue?.ToString();
                });
            })
            .DisposeWith(d);
        return d;

    }

    public void Dispose()
    {
        _disposables.Dispose();
    }
}

小结

至此,你已经完成了一个简单的测点项目。不管看起来有多简单,我都强烈推荐你亲自动手一试,哪怕是克隆上面的源码仓库到本地,做些实验也很好。

Chapter 7 外部意图

最理想的情况当然是把所有的处理都放到串行轮询这个LOOP的中第二部分:

\[ \boxed{ \boxed{ \text{1. 读入数据} } \ \boxed{ \text{2. 处理逻辑} } \ \boxed{ \text{3. 刷写底层} } } \]

这样一来,各个ILogicet实例会以固定的顺序串行执行。

但事实上很多时候,我们需要从这个LOOP 的外部改变状态。在这种场景下,如果直接尝试写入,会带来非常大的不确定性——你甚至都不能确定对应的脏数据有没有被刷写到底层:也许在LOOP的某一轮结束后,你恰好手动更新了测点;但在同一时刻,下一轮正在从底层硬件读取缓存……这种并行不确定性会给程序员带来极大的不安。于是我引入了如下机制,以让来自外部的更改和自身的LOOP 串行执行:

\[ \boxed{ \boxed{ \text{0.执行意图} } \boxed{ \text{1. 读入数据} } \ \boxed{ \text{2. 处理逻辑} } \ \boxed{ \text{3. 刷写底层} } } \]

  1. \(\boxed{\text{执行意图}}\):在LOOP每一轮开始前,先处理队列头部的前几个意图,直到队列已空或者达到单轮处理的最大意图数量。完成之后,如果缓存区已脏,则立刻刷写底层。
  2. \(\boxed{\text{读入数据}}\): 从底层硬件读取数据到缓存区。
  3. \(\boxed{\text{处理逻辑}}\): 串行执行逻辑组件。
  4. \(\boxed{\text{刷写底层}}\): 刷写变更的点位到底层硬件。

意图入队

例如:

// 意图入队: 返回true 表示入队成功;如果入队失败,await t 将直接抛出异常
var res = proj.WriteIntent(
    "IoBox", 
    (entry, ct) => {
        ack.Value = true;
        return ValueTask.CompletedTask;
    }, 
    out var t
);

// 等待意图被执行完成
await t; 

注意,写意图本身只是将"意图"入队,out出的参数Task task并不会立刻被执行。意图一旦入队成功,则会在将来某个合适时间点被执行。你可以使用await task的手法,等待相关意图被执行完成,如果相关意图未能被正确执行,相关await会抛出异常。

意图执行和死锁陷阱

让我们重新审视这里的串行轮询机理:

\[ \boxed{ \boxed{ \text{0.执行意图} } \boxed{ \text{1. 读入数据} } \ \boxed{ \text{2. 处理逻辑} } \ \boxed{ \text{3. 刷写底层} } } \]

引入意图,是为了在原有的扫描循环之外,增加一种可以临时改变Tag的手段。但是如果意图执行等待得不恰当,有可能会引发死锁。

考虑这样一个情况:

  1. (循环内部)某个Logicet中,捕获到相关业务A的请求信号(req)。该Logicet遂对外发布通知,要求处理。
  2. (循环外部)某处理器捕获到相同通知,执行完相关业务逻辑,最后写入意图,用以通知PLC开始另一个业务B。
  3. (循环内部)该Logicet await上一步完成,再向PLC反馈与业务A请求(req)相匹配的ack信号。

你能发现这里潜在的死锁吗?

重点看第3步:

  • 该Logicet在等待外部处理器执行完;
  • 而外部处理器向运行时写入了意图,然后干了什么?它在等待该意图被执行。

这里出现了环路等待的情况:Logicet在等外部处理器处理完;而外部处理器在等意图被执行完——但是,意图执行需要等到LOOP下一轮开始的时候才有可能被执行!

正确的做法有很多:

  • 比如让外部处理不要直接写入意图——而是把修改tags的动作改到循环内部。
  • 或者,如果你一定要在外部写入意图,应该让内部不再死等外部结束:
if (req.GetTagValue<bool>() && !ack.GetTagValue<bool>() && Interlocked.CompareExchange(ref isBusy, 1, 0) == 0)
{

    // 如果外部处理器也需要写入意图并等待意图被执行完成,那么这里就不要等待,尽早结束
    _ = Task.Run(async () =>
    {
        // 这里的代码脱离了`Logicet`,所有的变更应当使用`WriteIntent`机制
        try
        {
            // 等待外部处理完成
            var evt = new ScrewResultEvt(block.CompleteBlock.DataBlock.ToResult());
            await _mediator.Publish(evt);

            // 写意图:确认
            var res = ctrl.Project!.WriteIntent(
                "IoBox", (entry, ct) =>
                {
                    ack.Value = true;
                    return ValueTask.CompletedTask;
                }, 
                out var t
            );
            await t;
        }
        catch (Exception ex)
        {
            // ... 错误处理
        }
        finally
        {
            var busy = Interlocked.CompareExchange(ref isBusy, 0, 1);
        }
    });
}
else if (!req.GetTagValue<bool>())
{
    ack.Value = false;
}

我个人更推荐尽量在Logicet内部进行信号处理,比起WriteIntent,Logicet结构清晰,而且执行时机固定、可预测。

还记得我们在逻辑组件中提到的注意点吗? 所有逻辑组件都是以预先设定的顺序串行轮询的,所以,在任何时候,都应该让ProcessAsync(entry, thisChannel)这个方法尽早结束,否则,串行约束会让后续的逻辑组件长时间得不到执行。

这里外部意图也有类似的要求——在任何时候,都应该让TagGrpWriteIntent这个委托尽早结束,否则,串行约束会让后续的逻辑组件长时间得不到执行。

.IsEnabled对外部意图的影响

ITagGrp和ITagCbnt都提供了.IsEabled属性,用来在自动轮询中跳过一些群组和分组。

在外部意图中,如果是通过ITagGrp.WrirteAsync(ct)和ITagCbnt.WriteAsync(ct)方法来写入数据到底层,通常没什么问题;但是,如果通过setter修改属性.Value,则新值会等待到下一轮循环执行才会刷新到底层。

Chapter 8 综合练习2

本文示例源码地址是: StdUnit.Tags.WPFDemo ,位于分支 eg-2 中

你可以直接使用StdUnit.Tags.Templates 模板,执行命令dotnet new tags.wpf来创建一个脚手架,并基于它来微调成这里代码。

在上一篇,我们讲述了外部意图的适用场景和工作机制。在这里,我们打算在综合练习一的基础上,演示如何使用意图在Logicet外部变更点位。

作为示例,我们将演示如何通过按钮在Logicet外部变更点位。之前我们都是基于dotnet/reactive进行演示,这次我们尝试使用Cysharp/R3来完成这个任务。

首先移除之前的StdUnit.Tags.RxExtensions依赖,改用StdUnit.Tags.R3Extensions:

<PackageReference Include="StdUnit.Tags.R3Extensions" Version="1.0.0" />
<PackageReference Include="R3Extensions.WPF" Version="1.3.1" />

这里的R3Extensions.WPF是R3官方为WPF平台开发的扩展包。

界面调整

我们把之前的示例改成左右两栏,左侧放心跳状态的监视页面,右侧放一个切换心跳请求的按钮:

<Grid>
    <Grid.ColumnDefinitions>
        <ColumnDefinition/>
        <ColumnDefinition/>
    </Grid.ColumnDefinitions>
    
    <Grid Grid.Column="0">
        <Grid.ColumnDefinitions>
            <ColumnDefinition Width="Auto" />
            <ColumnDefinition />
        </Grid.ColumnDefinitions>

        <Grid.RowDefinitions>
            <RowDefinition/>
            <RowDefinition />
        </Grid.RowDefinitions>

        <Label Grid.Row="0" Grid.Column="0">心跳请求</Label>
        <TextBlock  Name="txtReq" Grid.Row="0" Grid.Column="1" />

        <Label Grid.Row="1" Grid.Column="0">心跳响应</Label>
        <TextBlock Name="txtAck" Grid.Row="1" Grid.Column="1" />
    </Grid>

    <Grid Grid.Column="1">
        <Button x:Name="btnToggle" Click="btnToggle_Click">切换心跳</Button>
    </Grid>
</Grid>

这里单击事件btnToggle_Click是:

private async void btnToggle_Click(object sender, RoutedEventArgs e)
{
    var proj = this._ctrl?.Project;
    if(proj is null)
    {
        return;
    }
    var enqueue = proj.WriteIntent(
        entry:"IoBox", 
        intent: (grp, ct) => {
            var tag = proj.Tags.SelectTag("IoBox/通用状态/PLC/心跳请求");
            var old = tag.GetTagValue<bool>();
            tag.Value = !old;
            return ValueTask.CompletedTask;
        }, 
        out var task
    );
    await task;
}

在这里,我们通过proj.WriteIntent(entry, intent, out task)进行了外部意图的写入。

信号监听

为了让左侧的信号界面能如实反映当前的信号状态,我们还需要几行监视代码:

req.Watch()
    .ObserveOnCurrentDispatcher()
    .Subscribe(evt =>
    {
        var newvalue = evt.NewValue;
        this.txtReq.Text = newvalue?.ToString();
        this.txtReq.Foreground= newvalue is true ? Brushes.Green : Brushes.Black;
    })
    .AddTo(_disposables);

ack.Watch()
    .ObserveOnCurrentDispatcher()
    .Subscribe(evt =>
    {
        var newvalue = evt.NewValue;
        this.txtAck.Text = newvalue?.ToString();
        this.txtAck.Foreground = newvalue is true ? Brushes.Green : Brushes.Black;
    })
    .AddTo(_disposables);

至此,一个简单的信号监控程序完成了:

Chapter 9 综合练习3(MCP)

如今的LLM能很好的理解文字,这意味着,我们可以把项目描述(XML)作为MCP工具暴露给它,如此一来,就可以用对话的方式(文字或者语音)完成海量的工作。

我们为StdUnit.Tags项目添加了一个StdUnit.Tags.McpServer扩展包。简单的说,它会把你的测点描述映射成一组MCP工具和资源,以给你的AI大脑安上观测设备的眼睛和操作设备的手。

仍以之前简单的心跳示例为例,这里我们会让AI自主读写心跳。开始之前,让我们先看一段示例视频:

MCP让AI操作点位

测点描述

为了能修改点位,我们需要把心跳请求 的访问方式从RO改成RW:

<Tag name="心跳请求" address="$$100.0" type="BIT" access="RW"></Tag>

环境配置

我们打算把MCP嵌入到我们之前的WPF程序中。我们需要添加包 StdUnit.Tags.McpServer。这个包负责把项目描述、点位读写作为MCP工具暴露出来。

最后,对于WPF项目,MCP stdio 肯定是不适合了,所以我们打算在WPF中嵌入一个Web,这意味我们还需要添加包依赖ModelContextProtocol.AspNetCore。

引入 MCP

然后像普通的Web项目那样,让我们建一个STARTUP.cs文件来配置依赖注入的服务和中间件:

public static class STARTUP
{
    public static void ConfigureServies(this IServiceCollection services)
    {
        services.AddLogging();
        services.AddSerilog((sp, lc) => lc
            .ReadFrom.Services(sp)
            .WriteTo.Console()
            .WriteTo.File("logs/log.txt", outputTemplate: "{Timestamp:yyyy-MM-dd HH:mm:ss} [{Level:u3}] [{SourceContext}] {Message}{NewLine}{Exception}", rollingInterval: RollingInterval.Day)
            .Enrich.FromLogContext()
        );
        services.AddWpfDemoTags();

        // 注册MCP Server  (使用HTTP通道)
        services.AddMcpServer()
            .WithHttpTransport(opts => {
                opts.Stateless = true;
            })
            // 注册 StdUnit.Tags MCP 服务
            .AddTagsMcp();
        services.AddAuthentication();
    }

    public static void ConfigureMiddlewares(this WebApplication app)
    {
        // 使用MCP中间件
        app.MapMcp("/mcp");
    }
}

在WPF的App.xaml.cs下,增加一个StartWeb方法:

    private static void StartWeb(WebApplication app)
    {
        app.ConfigureMiddlewares();
        var th2 = new Thread(() =>
        {
            app.Run("http://localhost:3001");
        });
        th2.IsBackground = true;
        th2.Start();
    }

最后在Application_Startup中启用配置并启动服务即可:

    private void Application_Startup(object sender, StartupEventArgs e)
    {
        var builder = WebApplication.CreateBuilder();
        builder.Services.ConfigureServies();
        var app = builder.Build();
        this.Root = app.Services;
        this.Ctrl = this.Root.GetRequiredService<ITagsProjectCtrl>();
        this.Ctrl.OnStartingException = ex =>
        {
            MessageBox.Show($"Tags处理启动异常:{ex.Message}", "错误", MessageBoxButton.OK, MessageBoxImage.Error);
            return Task.FromResult(true);
        };

        StartTagsPoll(this.Root, this.Ctrl);
        StartWeb(app);
    }

至此,你就可以把MCP Server丢给AI,让AI读写相关测点了(参见的上面的视频)。

Chapter 10 访问者模式

我们之前在《基本概念》中讲到:

TagUnion = TagGrp | TagCbnt | TagUnit

由于这是一个非常稳定的数据结构,所以我们很自然地想到应该为它提供一些基础设施以支持访问者模式。

本章所讲述了内置ITagUnionVisitor的设计背景,相关代码均已内置。如果你熟悉 访问者模式 ,直接跳至文末了解一下示例用法即可。

访问者

访问者模式的核心理念是,将行为与相对稳定的数据结构相剥离,并将新的行为抽象成对数据结构进行访问的新类。 在这里,TagUnion就是相对稳定的数据结构——它要么是TagUnit,要么是TagCbnt,要么是TagGroup。

为了支持访问者模式,StdUnit.Tags为TagUnion定义了.Accept(visitor),令其可以接受一个访问者对自身进行访问。但是具体怎么访问,我们留待子类实现——因为只有子类才能知道自己是什么类型的节点:

public abstract record TagUnion
{
    /// 接受一个访问者对自己进行"访问"
    public abstract void Accept(ITagUnionVisitor visitor);
}

这里ITagUnionVisitor是一个接口抽象,规定了每种访问者应该支持访问 TagGrp、TagCbnt、和TagUnit:

public interface ITagUnionVisitor
{
    /// 访问 TagGrp 节点
    void Visit(TagUnion.TagGrp grp);

    /// 访问 TagCbnt 节点
    void Visit(TagUnion.TagCbnt cbnt);

    /// 访问 TagUnit 节点
    void Visit(TagUnion.TagUnit tag);
}

然后我们为每种数据节点提供实现——这里就是委托给访问者的访问方法,由于有了自身的类型信息,编译器可以清晰地知道它调用的是哪个重载方法:

public record TagUnit(ITag Value) : TagUnion 
{
    public override void Accept(ITagUnionVisitor visitor) 
    {
        visitor.Visit(this);  // 调用的是 Visit(TagUnion.TagUnit)方法
    }
};


public record TagCbnt(ITagCbnt Value) : TagUnion
{
    public override void Accept(ITagUnionVisitor visitor)
    {
        visitor.Visit(this); // 调用的是 Visit(TagUnion.Cbnt)方法
    }
}

public record TagGrp(ITagGrp Value): TagUnion
{
    public override void Accept(ITagUnionVisitor visitor)
    {
        visitor.Visit(this); // 调用的是 Visit(TagUnion.TagGrp)方法
    }
}

遍历器

作为访问者模式的一个示例,我们用访问者模式实现一个最简单的节点遍历器:

public class TagUnionTraverser : ITagUnionVisitor
{
    private Action<ITagGrp>? _procGrp;
    private Action<ITagCbnt>? _procCbnt;
    private Action<ITag>? _procUnit;

    /// <summary>
    /// c'tor
    /// </summary>
    /// <param name="procGrp">处理 ITagGrp节点本身,不处理子节点</param>
    /// <param name="procCbnt">处理 ITagCbnt 节点本身,不处理子节点</param>
    /// <param name="procTag">处理 ITag 节点本身</param>
    public TagUnionTraverser(
        Action<ITagGrp>? procGrp, 
        Action<ITagCbnt>? procCbnt, 
        Action<ITag>? procTag
    ){
        this._procGrp = procGrp;
        this._procCbnt = procCbnt;
        this._procUnit = procTag;
    }

    /// <inheritdoc/>
    public void Visit(TagUnion.TagGrp grp)
    {
        this._procGrp?.Invoke(grp.Value);
        foreach (var kvp in grp.Value.Children)
        {
            var child = kvp.Value;
            child.Accept(this);
        }
    }

    /// <inheritdoc/>
    public void Visit(TagUnion.TagCbnt cbnt)
    {
        this._procCbnt?.Invoke(cbnt.Value);

        foreach (var kvp in cbnt.Value.Children)
        {
            var key = kvp.Key;
            var child = cbnt[key];
            child.Accept(this);
        }
    }

    /// <inheritdoc/>
    public void Visit(TagUnion.TagUnit tag)
    {
        this._procUnit?.Invoke(tag.Value);
    }

}

这个类已经内置在StdUnit.Tags库中,你不必重复实现这个遍历器

于是我们可以这样使用这个遍历器:

var data = new TagUnion.TagGrp(grp);

// 构造遍历器对数据进行访问来统计 Tag 节点
var tagCount = 0;
var traverser = new TagUnionTraverser(
    null,                  // 什么都不做
    null,                  // 什么都不做
    tag => tagCount + 1    // 递增计数器
);

// 接受访问者
data.Accept(traverser);
Console.WriteLine($"tagCount = {tagCount}");

Chapter 11 SimpleFiles 驱动专题

SimpleFiles 驱动的做法很朴素:把每个测点映射成磁盘上的一个文本文件。测点的地址就是文件路径,文件内容就是测点的值。

它最初是为了"测试"和"喂狗"——让整套逻辑在没有硬件的机器上也能跑测试。但用久了会发现它在两个场景里特别顺手:

  • 测点即文件:写业务逻辑时不必等 PLC 到位,用文件先跑通;也可以反过来,把上位机算出来的结果落成文件,让别的程序(甚至人)去读。
  • 测点即配置:有些"参数"本来就该能被人编辑、能被版本管理,放在文件里比放在数据库里方便快捷。

用法三步,和别的驱动没有区别:

  1. 配通道:XML 里写一个 driver="SimpleFiles" 的 <Channel>,可以指定基础目录(见 11.1);
  2. 配测点:<Tag address="sub/state.txt" type="INT32">,地址就是文件路径(见 11.2);
  3. 注册 builder.AddSimpleFilesSupport()(见 11.3)。

配套包:StdUnit.Tags.SimpleFiles。

本系列文档只讲"怎么用、为什么这样设计、容易踩什么"。字段默认值、API 清单请查 AI 参考手册 的 §5.7;两者冲突时以手册为准。

11.1 通道配置

<Project xmlns:sf="tags:simplefiles"
         xsi:noNamespaceSchemaLocation="Schemas/tagsproject.xsd"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
  <Channel name="file" driver="SimpleFiles">
    <sf:BaseDir>D:/temp/tags</sf:BaseDir>
  </Channel>
</Project>

BaseDir 是唯一可配的子元素,可以省略:

配置测点地址如何变成路径
写了 BaseDir用 Path.Combine(BaseDir, address) 拼出来
省略 BaseDiraddress 原样使用(此时它就是一个完整路径)

这里有一个必须知道的事实:BaseDir 之外不做任何路径规整,既不判断地址是不是绝对路径,也不转换分隔符。所以:

  • 相对路径请一律写正斜杠(sub/state.txt)。Windows 上反斜杠也能用,但那是 Windows 的宽容,写到 Linux 上会变成一个名字里带反斜杠的文件;
  • BaseDir 与地址里的子目录必须已经存在,库不会替你创建目录(见 11.6 的排错表)。

这个通道还是无状态的:连接、断开、释放三个动作都是空实现(它不持有任何句柄)。所以多个通道指向同一个目录的代价几乎为零——不过"一个通道只属于一个入口"这条约束依旧成立,理由与别的驱动相同(两套扫描周期交错读写同一批文件会互相干扰)。

11.2 测点怎么写

每个 <Tag> 对应一个文件,address 是路径,type 决定解析方式:

<TagGrp name="仿真" isEntry="true" channel="file" scanInterval="50">
  <Tag name="启动" address="in/start.txt" type="BIT"></Tag>
  <Tag name="产量" address="in/count.txt" type="INT32"></Tag>
  <Tag name="工艺配方" address="config/recipe.json" type="STR"></Tag>
  <Tag name="输出" address="out/result.txt" type="DOUBLE" access="R1W" autoCreateFile="true"></Tag>
</TagGrp>

type 与 C# 类型的对应关系,以及内容怎么读怎么写:

type值类型读取(文件内容变成值)写入(值变成文件内容)
BITboolbool.TryParseTrue / False
BYTEbytebyte.TryParse数字文本
INT16 / UINT16short / ushortTryParse数字文本
INT32 / UINT32int / uintTryParse数字文本
FLOAT / DOUBLEfloat / doubleTryParse数字文本
STRstring原样返回文件内容原样写入

自定义类型(JSON、或自己实现的类型)见 11.5。

几个容易踩的点:

  • 内容不做裁剪。STR 读到的是文件的全部文本,末尾的换行也会一起读进来;写入时也不会自动补换行。用别的程序编辑这些文件时要注意这一点。
  • 数值按当前区域性解析与格式化(ToString() 与 TryParse 都没指定 InvariantCulture)。同一台机器上读写是对称的,但如果这些文件会被别处的程序或别的语言环境读,小数点符号可能对不上——对这类文件建议用 STR 自己解析。
  • 不写 type 的文件不能用:SimpleFiles 是直连测点驱动,测点种类必须明确。
  • <TagCbnt> 不支持。SimpleFiles 只注册了直连测点构建器,用组合会报"未注册相应的 TagCbntBuilder"。文件本来就是一次读写一个,组合在这里也没有收益。

autoCreateFile

默认 false。文件不存在时:

场景autoCreateFile="false"(默认)autoCreateFile="true"
读直接返回,值、时间戳、通知都不动(静默跳过)先按默认值创建文件,本轮仍不读值,下一轮才读到
写直接返回,不写文件(静默 no-op)先创建默认内容,随后写入当前值

创建出来的默认内容由 FormatValue(default) 决定,也就是数字是 0、字符串是空内容。注意"读"那一列:开启自动创建后,首次读到值要等到下一轮轮询,因为这一轮只是把文件建出来。

1.0 起旧属性名 AutoCreateFile 已移除。它当初是驼峰命名统一时的历史包袱,现在仍写旧名会在加载期抛 TagsProjectConfigurationException,消息里直接给出改用 autoCreateFile 的提示。

11.3 注册与运行

builder.AddSimpleFilesSupport();          // = Channel + DirectTagBuilder + XSD provider
builder.EnableXmlSchemaValidation();      // 可选:让 <sf:BaseDir> 有智能提示与加载期校验

只想要一部分时可以分别调用 AddSimpleFilesChannel() 与 AddSimpleFilesDirectTagBuilder()。

运行方式与其它驱动完全一致:

await ctrl.StartPollAsync(dir, root: null, hook: (proj, sp, ct) =>
{
    // 需要时在这里补逻辑组件
    return Task.CompletedTask;
});

11.4 它和其它驱动不一样的地方

其它驱动SimpleFiles
地址协议地址或 NodeId文件路径
连接的代价建连、断连要处理超时与重试无连接概念,通道是无状态的
批量组合能合并多次 IO一个测点一个文件,没有组合
出错形态多半是通信故障多半是文件被占、权限不足、内容格式不对

R1W

R1W 的含义是"只读一次,读取成功之后变成只写模式"。软件系统里有很多参数"只在启动时需要读取一次",此后反复访问文件纯属浪费。R1W 虽然是为设备测点准备的,但用在文件访问场景里格外合适:

  • 与 autoCreateFile 搭配:文件不存在时也能自动落盘,第一次读取仍然成立。
  • 两侧的刷写时机是确定的:如果测点是在轮询线程内被改的(例如逻辑组件里赋值),本轮结束前就会被刷写到文件;如果是通过"外部意图"改的,意图会在下一轮开头执行,紧接着的那次刷写就把它落地,不需要你额外做什么。
  • 外部改了文件,可以重新读一次:把该测点的 IsScanned 手动改回 false,下一轮就会重新从文件读,读完又会被自动标记为 true。手动改或者订阅事件后自动改都行;如果你用了 StdUnit.Tags.BlazorLib.Core,/stdunit.tags/monitor 页面上有现成的按钮可以点。

IsDirty

也不必担心它会反复刷写文件系统。每个测点都有 IsDirty 属性,只有被改过的测点才会被刷写到磁盘,写完就自动把脏标记清掉。

反过来,"读"不会把测点标脏(读路径直接写缓存字段,不走属性 setter),所以不会出现"读一次就写回一次"的无谓写入,R1W 的"只读一次"也不会因为随后有写入而退化。

11.5 自定义测点类型

内置的 type 覆盖了常见标量;当文件内容是 JSON、或者你想自己决定文本与值之间如何转换时,需要自己注册一个测点构建器。两种做法的注册方式相同:用 predicate 挑出属于你的那种 type,用 configure 决定怎么建测点。type 是在 XML 里自己起的名字,只要注册时的条件与它一致即可。

其一:直接用 JSON

测点值的类型由开发者自己指定,序列化交给 System.Text.Json。

<Tag name="配方" address="config/recipe.json" type="MyJson"></Tag>

注册代码:

builder.AddSimpleFilesChannel()
    .AddSimpleFilesDirectTagBuilder(
        configure: b => b.WithJsonTagFactory<MyJson>(),
        predicate: b => b.TagDescriptor.TagKind == "MyJson");

内置的 JsonDirectTag<T> 会把文件内容反序列化成 T,写入时再序列化回去。文件内容为 JSON 字面量 null 时,值就是 null(这是合法值,不是错误)。

AOT / 裁剪宿主还要多一步:这些宿主下 System.Text.Json 默认关闭反射序列化,必须登记一份源生成元数据(b.AddJsonSerializerContext(MyJsonContext.Default))。本地 JIT 运行不需要。见 AOT 支持。

其二:自己写一个测点类型

继承 SimpleFilesDirectTagBase<T> 并重写两个方法:ParseValue 负责"文本变值",FormatValue 负责"值变文本",两者要成对实现。

public class VersionTag : SimpleFilesDirectTagBase<Version>
{
    public VersionTag(TagDescriptor descriptor, SimpleFilesTagChannel? thisChannel, TagContainer container)
        : base(descriptor, thisChannel, container) { }

    protected override Version ParseValue(string text) => Version.Parse(text);

    protected override string FormatValue(Version? value) => value?.ToString() ?? string.Empty;
}

配置时由开发者为测点值指定一个自定义的类型:

<Tag name="版本" address="config/version.txt" type="MyVersion"></Tag>

注册代码:

builder.AddSimpleFilesDirectTagBuilder(
    configure: b => b.WithFactory<Version>(
        (descriptor, thisChannel, container) => new VersionTag(descriptor, thisChannel, container)),
    predicate: b => b.TagDescriptor.TagKind == "MyVersion");

三点要注意:

  • 自动创建文件时是以 default(T) 调用 FormatValue 的,所以它必须能处理"空值";若 T 是引用类型,null 是合法输入。
  • 用 WithFactory 注册时不必自己归一化地址,框架已经按 BaseDir 处理好了 NormalizedAddress。
  • 注册时框架会检查通道类型,不是 SimpleFilesTagChannel 会直接报配置错误,不会到运行期才炸。

11.6 排错清单

现象先看这里
启动就报 DirectoryNotFoundExceptionBaseDir 或地址里的子目录不存在。库不会创建目录,请先建好
读到的字符串末尾多了个换行STR 原样返回文件内容。文件是别的程序写的(多半带换行),要么让它别写换行,要么在 ParseValue 里自己裁剪
文件明明存在,值却没变该测点是 R1W:只有第一轮读,之后不再读了
文件不存在,什么都没发生autoCreateFile 没开(默认关)。读是静默跳过,写是静默 no-op
数字解析失败内容是给人看的还是给程序读的?float/double 的文本格式与语言环境有关;跨程序共享的文件建议用 STR 自己解析
报"未注册相应的 TagCbntBuilder"SimpleFiles 只支持直连 <Tag>,不要用 <TagCbnt>
想让文件创建后立刻有值开启 autoCreateFile 后,创建发生在这一轮,读到值要等下一轮

Chapter 12 S7 驱动专题

S7 是最早支持、也用得最多的驱动。它把西门子 PLC 的存储区(DB 块、M 区)映射成测点:地址写 DB200.100 这样的字符串,剩下的交给库。

用法三步,和别的驱动没有区别:

  1. 配通道:XML 里写一个 driver="S7" 的 <Channel>,填 IP 与机架/槽位(见 12.1);
  2. 配测点:<Tag address="DB200.100.1" type="BIT">,地址是区域加偏移(见 12.2);
  3. 注册 builder.AddS7Support()(见 12.4)。

配套包:StdUnit.Tags.S7。

本系列文档只讲"怎么用、为什么这样设计、容易踩什么"。地址全表、类型全表、字段默认值请查 AI 参考手册 的 §5.1;两者冲突时以手册为准。

12.1 通道配置

<Project xmlns:s7="tags:s7"
         xsi:noNamespaceSchemaLocation="Schemas/tagsproject.xsd"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
  <Channel name="S7-1" driver="S7">
    <s7:IpAddr>192.168.10.2</s7:IpAddr>
    <s7:Rack>0</s7:Rack>
    <s7:Slot>1</s7:Slot>
    <s7:ConnectionType>3</s7:ConnectionType>
  </Channel>
</Project>
子元素默认说明
IpAddrlocalhostPLC 的地址
Rack0机架号(short)
Slot1槽位号(short)。S7-1200/1500 常见为 1,S7-300/400 视硬件组态而定
ConnectionType3连接类型:0 是 PG、1 是 OP、3 是 PG 内部(默认值沿用旧行为)

这四个字段的解析失败都会在加载期抛 TagsProjectXmlException,消息里带 Channel(通道名),不会等到连接时才发现。

12.2 地址怎么写

S7 的地址语法是"区域 + 块号(可选)+ 偏移 + 位号(可选)":

写法区域例子
DB<块号>.<偏移>数据块DB200.100
DB<块号>.<偏移>.<位号>数据块的某一位(位号 0~15)DB200.100.1
MB.<偏移>M 区(Merker)MB.20
MB.<偏移>.<位号>M 区的某一位MB.20.3
$$<偏移>相对地址:沿用所属组合的区域与块号$$104、$$104.3

几个容易误解的地方:

  • $$ 后面的数字是块内的绝对偏移,不是相对偏移。例如组合起始地址是 DB200.100,子测点写 $$100,它指向的仍是 DB200.100——$$ 只省掉"重复写区域与块号",不改变偏移的算法。
  • 组合的起始地址必须是绝对地址。$$ 的含义是"沿用上层",而组合自身没有可沿用的上层。
  • 子测点若写了绝对地址,必须与组合同区域同块号,否则加载期直接报错,并提示改用 $$。这是为了避免"看起来在同一个组合里、实际读到另一个块"的沉默错误。
  • 位号支持 0~15:大于 7 的部分会被换算到下一个字节(8 表示下一个字节的第 0 位)。不要写超过 15 的值——库只把"除以 8 的余数"当作位号,更大的值(如 16)会静默落到和 8 相同的位置,读到的不是你写的那个位。

12.3 类型与端序

支持的 type:BIT、BYTE、INT16、UINT16、INT32、UINT32、INT64、UINT64、FLOAT、STR。占用的字节数由类型决定(BIT 与 BYTE 是 1 字节,INT16/UINT16 是 2 字节,INT32/UINT32/FLOAT 是 4 字节,INT64/UINT64 是 8 字节),不写 TagSize 时按类型自动补。

多字节类型必须写 endian="BigEndian"

这是本章最需要注意的一点。库的全局默认端序是 LittleEndian,而西门子 PLC 是大端,所以多字节类型(INT16、UINT16、INT32、UINT32、INT64、UINT64、FLOAT)请一律显式指定:

<Tag name="程序号" address="DB400.2" type="INT16" endian="BigEndian"></Tag>
<Tag name="最终扭矩" address="DB401.18" type="INT32" endian="BigEndian"></Tag>

漏写不会报错,只会得到字节颠倒的数值——这类错误在现场看起来像"PLC 数据不对",在现场和电气工程师对点时很容易发现这种字节序的问题。BIT、BYTE、STR 不涉及多字节组装,写不写都一样。

端序的取值大小写敏感:BigEndian、LittleEndian,其它写法(如 bigendian)会在加载期报错。

字符串(STR)

西门子 PLC 中的字符串在其内存里是"2 个字节的头 + 内容":头两个字节分别记录总容量与当前有效长度。所以测点必须额外写 maxlen(1~255),库据此把 TagSize 算成 2 + maxlen,并在组合初始化时把容量写进缓存头部。

<Tag name="拍照-请求-PN" address="$$108" type="STR" maxlen="16" />

不写 maxlen、或写成非正整数,都会在加载期抛 TagsProjectXmlException。

12.4 注册与运行

builder.AddS7Support();                   // 等价于手动注册Channel + TagCbntBuilder + DirectTagBuilder + XSD provider
builder.EnableXmlSchemaValidation();      // 可选:让 <s7:IpAddr> 等子元素有智能提示与加载期校验

想要更精细的控制,可以手动调用 AddS7Channel()、AddS7TagCbntBuilder()、AddS7DirectTagBuilder() 自由组合。

运行方式与其它驱动完全一致:

await ctrl.StartPollAsync(dir, root: null, hook: (proj, sp, ct) =>
{
    // 需要时在这里补逻辑组件
    return Task.CompletedTask;
});

什么时候该用 <TagCbnt>(组合)

S7 的读写是按"一段连续的字节"发出去的,所以组合在这里的收益很实在:把同一块区域里相邻的测点放进一个 <TagCbnt>,一轮就只发一次读/写请求,而不是每个测点各发一次。

<TagCbnt name="拍照请求" access="RO" address="DB200.100">
  <Tag name="拍照-请求-标志"   address="$$100.1" type="BIT" />                      <!-- 字节 100 的第 1 位 -->
  <Tag name="拍照-请求-料号"   address="$$102"   type="INT16" endian="BigEndian" />  <!-- 字节 102~103 -->
  <Tag name="拍照-请求-程序号" address="$$104"   type="INT32" endian="BigEndian" />  <!-- 字节 104~107 -->
  <Tag name="拍照-请求-PN"     address="$$108"   type="STR" maxlen="16" />           <!-- 字节 108~125 -->
</TagCbnt>

组合内部的字节缓存由库按各测点的偏移和长度自动排版(AutoLayout),你只需保证地址落在组合覆盖的范围里、且互相不重叠。上面这个组合覆盖了 DB200.100 到 DB200.125(共 26 字节,其中 STR 占 2 + 16),所以读一次就能拿到全部四个测点。

注意地址要与 PLC 侧的 DB 布局对齐:库不会替你做对齐填充,它完全按你写的偏移紧密排版。而西门子 PLC 的 DB 里,多字节变量通常要求偶数地址起始(INT16/INT32/REAL/STRING 都是如此),所以在 PLC 里定义变量时最好就按偶数地址排布,XML 里照抄即可。上面例子里 INT16 从 102 起、INT32 从 104 起、STRING 从 108 起,都是偶数;如果 PLC 侧实际偏移是奇数(例如 INT16 在 103),XML 里也必须写 103,否则读到的将是隔壁变量的字节。

S7 也支持直连测点(不在组合里的 <Tag>),适合"就一个孤立的点、不值得为它建组合"的场景;代价是每次都要单独发一次请求。

为了性能考虑,我们建议你尽量使用 <TagCbnt> 来组合测点,而不是把 <Tag> 直接套进 <TagGrp>。

12.5 它和其它驱动不一样的地方

S7其它驱动(对比)
地址区域 + 块号 + 偏移 + 位号,支持位寻址与 $$ 相对地址Modbus 是功能码区域加参考号,OpcUa 是 NodeId
端序默认 LittleEndian 与 PLC 相反,多字节类型必须显式声明 BigEndian语义不同:endian 同为"每个 16 位单元内部两字节的顺序",但 32/64 位还多一个 interpret(单元之间的顺序),见 §5.2(详见 Chapter 14)
字符串有"两字节头"的 PLC 格式,必须给 maxlenOpcUa 无此概念;SimpleFiles 是纯文本
底层同步实现,靠线程包装成异步OpcUa 原生异步

最后一条带来的后果:S7 通道的读写实际占用线程,且同一通道上的读、写、断开是串行的(内部有互斥)。这一点在"一个入口里配了很多 S7 通道"时值得留意——入口之间是并行的,但每个通道内部不会并发。

12.6 排错清单

现象先看这里
数值像是"字节颠倒"多字节类型漏了 endian="BigEndian"(见 12.3)。这是最常见的 S7 配置错误
加载期报"组合自身必须写绝对地址"<TagCbnt> 的 address 写了 $$...。组合的起始地址必须写 DB<块>.<偏移> 或 MB.<偏移>
加载期报"不在同一区域/DB 块"组合内的子测点写了绝对地址,但区域或块号与组合不一致。要么改成同块,要么改用 $$ 相对地址
加载期报 maxlen 相关错误STR 遗漏了 maxlen,或者值不是 1~255 的正整数
连接失败看消息:通道(S7-1) 连接PLC失败:{IP} Rack=.. Slot=..,后面跟着底层错误码。先确认 Rack/Slot 与硬件组态一致、PLC 允许 PUT/GET
读写失败消息形如 通道(S7-1) 读取DB失败:DB200.100,长度=3;...,包含区域、块号、偏移、长度与底层错误码明细,可据此定位是地址越界还是链路问题
报"不支持"的地址区域检查地址字符串本身;$$ 只能出现在组合内部的子测点上
位测点的值总是同一个位位号写超过 15 了(见 12.2)。库只取"除以 8 的余数",16 与 8 会落到同一个位置
读到的数值像是隔壁变量地址与 PLC 的 DB 布局不一致:库不做对齐填充,请按 PLC 侧的实际偏移写(见 12.4 的说明)

Chapter 13 OpcUa 驱动专题

OpcUaClient 驱动的目标:把"读一个节点、写一个节点"这件事变成和其它驱动完全一样的测点。你不需要在业务代码里碰 Session、Subscription、DataValue——通道负责连接与会话,组合(TagCbnt)负责一次读多个节点。

用法三步,和别的驱动没有区别:

  1. 配通道:XML 里写一个 driver="OpcUaClient" 的 <Channel>,填端点与(可选的)安全项(见 13.1);
  2. 配测点:<Tag address="ns=4;s=...">,地址就是 NodeId(见 13.2)。
  3. 注册 builder.AddOpcUaClientSupport()(见 13.3);

配套包:StdUnit.Tags.OpcUaClient。

本系列文档只讲"怎么用、为什么这样设计、容易踩什么"。地址表、字段默认值、API 清单请查 AI 参考手册 的 §5.5;两者冲突时以手册为准。

13.1 通道配置

<Project xmlns:ua="tags:opcuaclient"
         xsi:noNamespaceSchemaLocation="Schemas/tagsproject.xsd"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
  <Channel name="OpcUaClient-2" driver="OpcUaClient">
    <ua:ClientName>client-name-1</ua:ClientName>            <!-- 默认 StdUnitTagsOpcUaClient -->
    <ua:ServerOpt>
      <ua:DiscoveryUrl>opc.tcp://192.168.10.68:4840</ua:DiscoveryUrl>   <!-- 默认 localhost -->
      <ua:UsePassword>false</ua:UsePassword>                <!-- 默认 false -->
      <ua:UserName></ua:UserName>
      <ua:Password></ua:Password>
    </ua:ServerOpt>
    <ua:SecurityOpt>
      <ua:AutoAcceptUntrustedCertificates>true</ua:AutoAcceptUntrustedCertificates>   <!-- 默认 true -->
      <ua:RejectSHA1SignedCertificates>false</ua:RejectSHA1SignedCertificates>        <!-- 默认 false -->
      <ua:MinimumCertificateKeySize>1024</ua:MinimumCertificateKeySize>              <!-- 默认 1024 -->
      <ua:CertificateStoreRoot></ua:CertificateStoreRoot>                            <!-- 默认留空表示使用内置默认根 -->
    </ua:SecurityOpt>
  </Channel>
</Project>

ServerOpt / SecurityOpt 的每个字段都可以写成元素或属性;值写错(如 UsePassword="maybe")会在加载期抛 TagsProjectXmlException 并带 Channel(名) 这种上下文信息以开发者方便诊断。

SecurityOpt 的四项

字段默认说明
AutoAcceptUntrustedCertificatestrue自动接受不受信任的服务端证书。默认宽松(无法防中间人)
RejectSHA1SignedCertificatesfalse是否拒绝 SHA1 签名证书。默认宽松(SHA1 已不安全)
MinimumCertificateKeySize1024可接受的最小密钥长度(ushort)。现代要求通常不低于 2048
CertificateStoreRoot留空证书库根目录;留空则使用 CommonApplicationData/OPC Foundation/CertificateStores

只要有一项仍是宽松值,构造通道时会输出一条 Warning,把宽松项列出来并提示收紧:

通道(OpcUaClient-2)使用偏宽松的证书校验设置:AutoAcceptUntrustedCertificates=true;RejectSHA1SignedCertificates=false;MinimumCertificateKeySize=1024。这会降低对中间人攻击的防护,生产环境请在 <SecurityOpt> 中收紧(装入受信任证书、拒绝 SHA1、密钥长度不低于 2048)

证书库路径在 Linux 上要特别注意:默认根来自 CommonApplicationData,在 Linux 上等于 /usr/share——通常,无相关文件权限的用户写不进去,证书库创建会失败。此时用 CertificateStoreRoot 指向一个可写目录(与 OPC 官方示例一致的位置):

<ua:CertificateStoreRoot>/home/you/.local/share/OPC Foundation/CertificateStores</ua:CertificateStoreRoot>

四个证书库(MachineDefault、UA Certificate Authorities、UA Applications、RejectedCertificates)都是该根目录的子目录,由库用 Path.Combine 拼出来——你只需给根。取值按原样使用,不做占位符替换(%CommonApplicationData% 之类在这里不会被展开)。

13.2 测点怎么写

地址就是 NodeId 字符串,可以用在 Tag 上(直连测点),也可以放在 TagCbnt 里(组合,批量读写):

<TagGrp name="g4" isEntry="true" channel="OpcUaClient-2" scanInterval="10">
  <TagCbnt name="输入" access="RO">
    <Tag name="放行按钮闭合状态" address="ns=4;s=|var|CODESYS Control Win V3 x64.Application.PLC_PRG.var3"></Tag>
    <Tag name="手动" address="ns=4;s=|var|CODESYS Control Win V3 x64.Application.PLC_PRG.var3"></Tag>
  </TagCbnt>
  <TagCbnt name="输出" access="RW">
    <Tag name="绿灯" address="ns=4;s=|var|CODESYS Control Win V3 x64.Application.PLC_PRG.var4"></Tag>
    <Tag name="红灯" address="ns=4;s=|var|CODESYS Control Win V3 x64.Application.PLC_PRG.var5"></Tag>
  </TagCbnt>
</TagGrp>

几点值得知道:

  • 不用写 type(与 S7/Modbus 不同):节点的类型由服务端定,而库以 object 透传。要用的时候自己用 GetTagValue<T>() 做严格转换,或用 Note 记录语义。
  • TagCbnt 是有意义的:组合内的子测点在一次轮询里合并成一次批量读(多个 NodeId 一次 Read)或者一次批量写(多个 NodeId 一次 Write),省的是网络往返——这是所有驱动里都通用的性能杠杆。
  • 同一个 NodeId 可以被多个子测点引用(别名):读的时候都能拿到值;写的时候只写一次(最后一个脏值生效),不会因为"重复键"报错。
  • 读失败(Bad)时:缓存、时间戳、通知都不改动,异常原样向上抛。
  • 成功时"先写完整个缓存,再逐点通知":处理器里读同组其它测点不会看到半更新的缓存。

13.3 注册与运行

builder.AddOpcUaClientSupport();          // 等价于同时注册 Channel + TagCbntBuilder + DirectTagBuilder + XSD provider
builder.EnableXmlSchemaValidation();      // 可选:让 <ua:ServerOpt>/<ua:SecurityOpt> 有智能提示与加载期校验

如果想要更精细地手动组合,可以分别调用 AddOpcUaClientChannel() / AddOpcUaClientTagCbntBuilder() / AddOpcUaClientDirectTagBuilder();如果只想改"什么算失败"的判定,见 13.5 的自定义一节。

运行方式和其它驱动完全一致——它只是"一个通道":

await ctrl.StartPollAsync(dir, root: null, hook: (proj, sp, ct) =>
{
    // 需要时在这里补逻辑组件
    return Task.CompletedTask;
});

13.4 它和 S7/Modbus 不一样的地方

S7 / ModbusOpcUa
地址协议地址(DB10.16、40001)节点标识(NodeId)字符串,如 `ns=4;s=
值类型由 type 决定(例如 INT16 对应 short)节点自身的类型是服务端说了算,所以库把它当 object 透传
一次读多个合并成一次协议请求(省网络往返)组合内一次 Read 多个节点(同样省往返)
读失败PLC 报错误码就抛异常节点状态码为 Bad 时抛异常

最后一条是 1.0 里定下的最重要语义,下一节单独讲。

13.5 坏点 = 读取失败(1.0 语义)

OPC UA 的每个节点各自带状态码。库不解释质量,只区分两件事:

状态处理理由
Bad抛 InvalidOperationException这多半是通信/配置问题(BadNotConnected、BadNodeIdUnknown…),只有抛出去才会走"崩溃、断开全部通道、重连"的恢复路径
Good / Uncertain照原样采集Uncertain 是"服务器确实给了值、它自己不确定",是否可信是业务问题

异常长这样(消息里带通道、节点、状态码,用于定位是哪个通道的哪个节点):

通道(OpcUaClient-2)读取节点值失败:节点=ns=4;s=|var|...PLC_PRG.var3 状态码=0x80340000(BadNodeIdUnknown)

为什么不是"跳过坏点 + 记日志"? 那样做会掐断重连路径,并且让值被无限期冻结、而入口看起来还活着——日志不是下游能用的接口,下游只有"异常(重试/崩溃策略、RunnerCrashed)"和"值本身"两个信号。代价是:永久性坏点(例如 NodeId 写错)会让该入口持续"失败、断开、重连",退避从 500ms 涨到 30s 封顶——但这是显式失败,不是静默冻结。

也正因为如此,库不引入"可信度"概念:需要"(测点值, 可信度)"时,请另加一个测点(例如 PLC 的状态字),而不是指望驱动层给质量位。

自定义"什么算失败"

默认口径是"只有 Bad 算失败"。如果你的现场希望更严或更松,注册时传一个委托即可——无需改库、也不写进 XML(它是代码,XML 表达不了):

// 更严:把 Uncertain 也算失败(例如只信 Good)
builder.AddOpcUaClientSupport(checkIsFailed: (err, value) =>
    value is null || (err is not null && ServiceResult.IsBad(err)) || StatusCode.IsUncertain(value.StatusCode));

// 更松:任何状态都照原样采集(读取永不因质量失败;代价是 Bad 时 value 可能是 null,由你承担)
builder.AddOpcUaClientSupport(checkIsFailed: (_, _) => false);

签名是 Func<ServiceResult?, DataValue?, bool>,返回 true 表示"这次读取算失败"。细粒度的 AddOpcUaClientChannel(checkIsFailed: ...) 也可以单独用。

判定时记得直接问 IsBad,不要取反 IsGood:IsGood(Uncertain) 是 false,取反会把"不确定"误判成失败。

13.6 排错清单

现象先看这里
入口反复"崩溃—重连",退避涨到 30s是不是永久性坏点?看异常消息里的 节点= 与 状态码=(例如 BadNodeIdUnknown 就是 NodeId 写错了)
日志出现"使用偏宽松的证书校验设置"预期行为(默认值就宽松);生产环境按提示在 <SecurityOpt> 里收紧
Linux 上证书库报 "File does not exist" / 权限不足用 <ua:CertificateStoreRoot> 指向可写目录(见 13.1 的提示)
连接不上、DiscoveryUrl 明明是 opc.tcp://...确认写的是端点 URL(DiscoveryUrl 支持 opc.tcp:// 与裸主机名);服务端是否允许该客户端证书
值是 null,但读取没报错说明该节点状态是 Good 却确实没有数据(或你把 checkIsFailed 设成了永不失败);库不做质量判断
想要"值 + 可信度"别在驱动层找:另加一个测点(例如 PLC 的状态字)

Chapter 14 ModbusTcp 驱动专题

ModbusTcp 驱动的目标:把"一组线圈、一段寄存器"变成和其它驱动完全一样的测点。你不需要碰功能码、PDU 长度限制、事务号——通道负责连接与分批请求,组合(TagCbnt)负责把一段连续地址一次读进来,再按偏移切给各个子测点。

用法三步,和别的驱动没有区别:

  1. 配通道:XML 里写一个 driver="ModbusTcp" 的 <Channel>,填 IP 与端口(见 14.1);
  2. 配测点:<Tag address="40001" type="INT16">,或者放进 <TagCbnt> 里批量读写(见 14.2);
  3. 注册 builder.AddModbusTcpSupport()(见 14.4)。

配套包:StdUnit.Tags.ModbusTcp。

本系列文档只讲"怎么用、为什么这样设计、容易踩什么"。地址表、字段默认值、API 清单请查 AI 参考手册 的 §5.2;两者冲突时以手册为准。

14.1 通道配置

<Project xmlns:mb="tags:modbustcp"
         xsi:noNamespaceSchemaLocation="Schemas/tagsproject.xsd"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
  <Channel name="ModbusTcp-2" driver="ModbusTcp">
    <mb:IpAddr>192.168.1.10</mb:IpAddr>              <!-- 默认 localhost -->
    <mb:Port>502</mb:Port>                           <!-- 默认 502 -->
    <mb:MaxReadRegisters>100</mb:MaxReadRegisters>   <!-- 不写 = 协议上限 125 -->
    <mb:MaxReadBits>1000</mb:MaxReadBits>            <!-- 不写 = 协议上限 2000 -->
    <mb:MaxWriteRegisters>100</mb:MaxWriteRegisters> <!-- 不写 = 协议上限 123 -->
  </Channel>
</Project>
字段默认含义
IpAddrlocalhost设备或网关地址
Port502端口
MaxReadRegisters协议上限 125单帧读寄存器的上限(FC03/FC04)
MaxReadBits协议上限 2000单帧读位的上限(FC01/FC02)
MaxWriteRegisters协议上限 123单帧写寄存器的上限(FC16)

三点值得知道:

  • 命名空间前缀可以省:通道子元素按本地名匹配(IpAddr 就是 IpAddr),所以不声明 mb: 前缀也能工作。但如果你开了 XSD 校验(EnableXmlSchemaValidation()),就要按 Schemas/tagsproject.xsd 的导入声明前缀,否则校验会失败。
  • Port 在 XSD 里声明为字符串,为的是兼容串口类驱动那种 COM1 形态的取值;运行期按整数解析,写 abc 会在加载期抛 TagsProjectXmlException(消息形如 配置的端口号不是整数:abc,并带 Channel(名))。
  • 三个上限只在"设备的单帧上限比协议值更小"时才需要写。超过协议上限不能凭空放大能力,所以配置比协议值更大时会直接在加载期抛异常,例如 MaxReadRegisters 配置(126)超过协议上限(125);写 0 同样报错。反过来说,协议能支撑的量永远读得到——超上限只是被拆成多个请求,不是读不到。

连接/读写超时目前是固定默认值(连接 1 秒、读写各 10 秒),XML 里没有对应字段,通道也没有暴露可配置入口。确实需要更长的超时时,只能自己实现通道(IModbusBitsChannel / IModbusRegisterChannel)并注册自己的 ITagChannelFactory。

14.2 地址怎么写

地址语法:

[<slave>~]<area><reference>[.<bit>]

area 决定区域与读写权限:

area区域参考号起点读/写
0线圈 Coils00001读、写
1离散输入 Discrete Inputs10001只读
3输入寄存器 Input Registers30001只读
4保持寄存器 Holding Registers40001读、写

细节:

  • slave 省略时默认 1。40001 与 1~40001 等价。

  • reference 是 1 起算的"参考号",注意,不同厂家的设备,语义并不一样!许多设备厂家的开发工程师是半吊子,或者是个优秀的工程师却被迫只能照抄上古遗留的代码,结果导致弄出来的地址五花八门,有的是0-based,有的是1-based,还有的甚至混进了寄存器area编号。所以你在设备手册里看到的编号地址,不一定就是真正合规的Modbus地址。我们采纳的语义是:40001 指保持寄存器的首个寄存器,解析后内部偏移是 0。所以不要自己减 1变成0-based。

  • bit 是寄存器内的位序号,取值 0~15(如 4~40010.3 表示保持寄存器第 10 个的第 3 位)。超出范围会抛 NthBit范围非法。

  • 地址匹配不上时,错误消息会把期望的语法一并给出:

    非法的Modbus地址 'x4001':未能匹配模式 <area><start>的模式(期望 [<slave>~]<area><start>[.<nth>],area 取 0/1/3/4,如 '1~40001.0')
    
  • 从站号写在地址里(2~10001),也可以写在 TagCbnt 的 slave 属性上(slave="2"),两者等价、写了属性就以属性为准。一次组合读写的从站号由组合决定,子测点地址里的 2~ 前缀只用来算偏移,不会切换从站。

14.3 类型与端序

Modbus 有两套互斥的组合空间,由 TagCbnt 起始地址落在哪个区域自动分流——位空间用 ModbusBitTagCbntBuilder,寄存器空间用 ModbusRegisterTagCbntBuilder,两个构建器在注册时就已经按区域分好了谓词,你不需要手动选:

组合空间起始区域缓存组合内 type 可用值TagSize
位空间0x / 1xbool[]DI(必须落 1x)、DO(必须落 0x)1
寄存器空间3x / 4xushort[]BIT BYTE INT16 UINT16 INT32 UINT32 FLOAT INT64 UINT642 / 4 / 8(字节)

两点容易搞混的地方:

  • DI / DO 是位空间专有,BIT / BYTE / 各数值类型是寄存器空间专有,写错区域会在加载期抛异常,例如把 DI 放在 0x 会得到 地址区域OutputCoils不可作为DI测点(DI 必须使用离散输入 1x 区域)。
  • 寄存器空间的测点,偏移一律按字节算:TagSize 要写 2(16 位)、4(32 位)、8(64 位)。不写时由类型自动补齐(BIT/BYTE/INT16/UINT16 是 2,INT32/UINT32/FLOAT 是 4,INT64/UINT64 是 8)。

端序:endian 管"每个 16 位单元内部两个字节",interpret 管"这些单元之间的顺序"

先按标准设备(Modbus 规范的惯例)写,通常就是一句话:

<Tag name="int16" address="40026" type="INT16" endian="BigEndian"></Tag>
<Tag name="float" address="40024" type="FLOAT" endian="BigEndian"></Tag>

endian 在 Modbus 与 S7 同名同义:描述每个 16 位单元(寄存器)内部那两个字节的先后。协议规定标准设备是大端(高字节在前),所以标准设备写 BigEndian;被字节序镜像过的网关会把这两个字节反着放,写 LittleEndian 让本库换回来。不写时描述符默认 LittleEndian(低位在前)——对标准设备来说数值看着就是错的,但不会报错:16 位会交换寄存器内两字节。

一个 32/64 位数值由多个 16 位单元拼成,于是还有第二个自由度:这些单元之间的先后。这由可选属性 interpret 表达——一串大写字母,A 是数值的最高字节,从左到右就是这些字节在设备端(也就是设备寄存器里那串字节)出现的先后:

<Tag name="float" address="40024" type="FLOAT" endian="BigEndian" interpret="CDAB"></Tag>

以 32 位物理值 0x12345678 为例(A=0x12、B=0x34、C=0x56、D=0x78),四种排布各有一个唯一写法:

endianinterpret设备端字节说明
BigEndian不写(等价 ABCD)12 34 56 78完全大端,标准设备
BigEndianCDAB56 78 12 34字交换(两个 16 位单元顺序相反)
LittleEndianBADC34 12 78 56字节交换(每个单元内部两字节相反)
LittleEndian不写(等价 DCBA)78 56 34 12完全小端

interpret 的字符数必须等于该数值的字节数:32 位 4 个字符(AD)、64 位 8 个字符(AH,例如 GHEFCDAB / BADCFEHG / HGFEDCBA 分别对应 32 位的 CDAB / BADC / DCBA)。因为 endian 已经确定了每个单元内部两字节的先后,interpret 里每两个连续字符必须是同一个单元的两个字节,且先后与 endian 一致——BigEndian 只接受 AB/CD…,LittleEndian 只接受 BA/DC…——所以同一件事只有唯一写法。

长度不符、字符不是大写字母、字符重复、与 endian 冲突,都在加载期报错;16 位、BYTE、BIT、位空间(DI/DO)以及 TagCbnt 本身写 interpret 也会在加载期报错(它们没有"多个单元"可排)。

类型占几个寄存器endian 管什么interpret
INT16 UINT161寄存器内两字节的顺序不允许
INT32 UINT32 FLOAT2每个寄存器内两字节的顺序可选(4 个字符)
INT64 UINT644每个寄存器内两字节的顺序可选(8 个字符)
BYTE半个取寄存器的高字节还是低字节不允许
BIT DI DO一个位无不允许

取值大小写敏感:endian 是 BigEndian / LittleEndian,interpret 是 ABCD / CDAB 这类大写记法,写别的会在加载期报错。

顺带说清一个常见的误解:这里与"Intel 是小端"没有关系。NModbus 返回的 ushort[] 里存的是数值,不是字节容器——它在解析响应时用 NetworkToHostOrder 把线上两个字节还原成数值(线上 12 34 → 数值 0x1234,也就是 4660),在 x86 上这一步会做一次字节交换、在大端主机上不做,两种主机拿到的数值完全一样;写方向对称地做一次 HostToNetworkOrder。也就是说主机端序在这一层已经被消化掉了,endian 与 interpret 只负责表达"设备里这个数值怎么摆"(描述它的坐标叫设备端字节:从寄存器数值重建出的设备侧字节序列,与真机上那几个寄存器的线上字节逐字节相同)。只有当有人把 ushort[] 重新当成字节流去看(MemoryMarshal.AsBytes、序列化到文件)时主机端序才会渗进来——本库没有任何这种操作。

写在哪儿都一样。 这些语义与"测点挂在 <TagGrp> 下(直接测点)还是挂在 <TagCbnt> 下(组合成员)"无关——同一份 XML 放在两处得到同一个物理值,这一点有专门的回归测试钉着(ModbusEndianConsistencyTests、ModbusInterpretTests)。

  • BIT、DI、DO 与端序无关(位号 0 就是最低位),BYTE 只是借 endian 选择取哪一半。
  • interpret 是 Modbus 驱动的私有属性,其它驱动不认识它(写上去会被当作普通自定义属性而忽略)。
<Tag name="int32" address="40022" type="INT32" endian="BigEndian"></Tag>                                <!-- 标准设备 -->
<Tag name="u32-swapped" address="40028" type="UINT32" endian="BigEndian" interpret="CDAB"></Tag>        <!-- 字交换设备 -->
<Tag name="u32-le" address="40030" type="UINT32" endian="LittleEndian"></Tag>                           <!-- 完全小端设备 -->

14.4 注册与运行

using StdUnit.Tags.ModbusTcp;

var services = new ServiceCollection();
services.AddLogging();
services.AddTagsProject(builder =>
{
    builder.AddModbusTcpSupport();   // 通道工厂 + 位空间组合 + 寄存器空间组合 + 直连测点 + schema
});

AddModbusTcpSupport() 是四个细粒度注册的组合:AddModbusTcpChannel()、AddModbusBitTagCbntBuilder()、AddModbusRegisterTagCbntBuilder()、AddModbusTcpDirectTagBuilder()(另外注册了驱动自带的 XSD)。

想要更精细的控制,可以手动注册上面这些扩展方法自由组合。

一份完整的配置长这样——位空间管开关量,寄存器空间管采集:

<TagGrp name="g3" isEntry="true" isEnabled="true" channel="ModbusTcp-2" scanInterval="10">
  <TagCbnt name="输入" address="10001" access="RO">
    <Tag name="放行按钮闭合状态" address="10001" type="DI"></Tag>
    <Tag name="手动" address="10002" type="DI"></Tag>
  </TagCbnt>

  <TagCbnt name="输出" address="00001" access="R1W">
    <Tag name="绿灯" address="00020" type="DO"></Tag>
    <Tag name="红灯" address="00021" type="DO"></Tag>
  </TagCbnt>

  <TagCbnt name="采集" address="40001" access="RO">
    <Tag name="byte" address="40020" type="BYTE"></Tag>
    <Tag name="int16" address="40021" type="INT16"></Tag>
    <Tag name="int32" address="40022" type="INT32" endian="BigEndian"></Tag>
    <Tag name="float" address="40024" type="FLOAT" endian="BigEndian"></Tag>
  </TagCbnt>
</TagGrp>
  • TagCbnt 的 address 是必写的(不像 OpcUa 那样可以省):它既决定这块组合落在哪个空间、用哪个构建器,也决定批量读写的起始位置与从站号(从站号也可以另用 slave 属性指定)。
  • access 是通用的访问模式,与驱动无关:R1W 表示"先读一次,成功之后只写"(见 Chapter 11 的说明),RO 表示只读。寄存器空间里落在 3x 的测点还会自动变成只读,写入会抛 NotSupportedException。
  • 组合的价值在于"一次请求覆盖一段连续区间":TagCbnt 的缓存从它的起始地址开始,一直算到最后一个子测点结束,一次轮询里合并成一次(或按通道上限拆成几次)请求。所以起始地址要尽量贴近第一个真正用到的测点——起始地址离得越远、子测点之间越散,缓存里空转的寄存器就越多。
  • 一轮里是"先读整块,再逐点通知",逻辑组件看到的是同一轮的一致快照,不会读到半更新的缓存。
  • 同一个通道不能被多个入口共用(isEntry="true" 的组各占一个 ITagChannel 实例),这是全库通用的约束。

14.5 与其它驱动不一样的地方

关注点ModbusTcp
地址[<slave>~]<area><reference>[.<bit>],参考号是 1-based,比如40001就是保持寄存器的首个寄存器
区域与访问方式由地址里的 area 决定(0/1/3/4),1x 与 3x 天然只读
类型位空间只认 DI/DO;寄存器空间认 BIT/BYTE/INT16/UINT16/INT32/UINT32/FLOAT/INT64/UINT64
端序endian 描述每个 16 位单元内部两字节的顺序(与 S7 同名同义);32/64 位里单元之间的顺序由 interpret 表达(不写时 BigEndian=完全大端、LittleEndian=完全小端);BYTE 是取哪一半,BIT/DI/DO 与端序无关。写在 <TagGrp> 下与写在 <TagCbnt> 下语义一致
组合空间位空间与寄存器空间互斥,按 TagCbnt 起始地址区域自动分流
批量请求超过通道 MaxReadRegisters/MaxReadBits/MaxWriteRegisters 的区间自动拆成多个请求
多从站从站号写在地址里(2~10001),或用 TagCbnt 的 slave 属性指定;组合内的子测点不单独决定从站

14.6 排错清单

现象大概率原因
加载期报 非法的Modbus地址 ...参考号写成了 40000 这类 0 起算值,或区域数字不在 0/1/3/4 里
加载期报 地址区域X不可作为DI测点DI 放到了 0x 组合里,或 DO 放到了 1x 组合里
加载期报 MaxReadRegisters 配置(126)超过协议上限(125)配置值超过协议上限,只能往小配
加载期报 配置的端口号不是整数:COM1Port 字段填了非数字;ModbusTcp 只走 TCP 端口
INT32 / FLOAT 读出来的数值明显离谱漏写 endian="BigEndian"(默认 LittleEndian 会按完全小端解读),或设备是字交换/字节交换却只写了 endian,需要补 interpret="CDAB" / interpret="BADC"
直接测点 INT16 / UINT16 的数值像"字节颠倒"16 位测点默认 LittleEndian 会交换寄存器内两字节,标准设备要写 endian="BigEndian"
加载期报 interpret='...' 与 endian='...' 冲突每两个连续字符必须是同一个 16 位单元的两字节且先后与 endian 一致:LittleEndian 只接受 BA/DC…
加载期报 interpret='...' 的字符数(...)与测点字节数(...)不一致记法字符数必须等于该数值的字节数:32 位 4 个字符、64 位 8 个字符
写点位没有反应,也不报错该测点或所属组合的 access 是 RO:写入路径直接跳过
写入时抛 NotSupportedException地址落在只读区域(1x 离散输入 / 3x 输入寄存器),或给 DI 测点赋值
从站号改成 2 后仍在读 1 号站的点从站号写在组合上(TagCbnt 的 slave 属性或 2~ 地址前缀);写在子测点地址里不生效
单个测点读得对,组合里读得不对子测点地址与 TagCbnt 起始地址不在同一区域,或偏移算错(寄存器空间按字节算 TagSize)
连接建立失败通道名/IP/端口写在日志里,检查网关是否放通 502、设备是否允许多客户端

AOT 支持

自.NET8,.NET团队在逐步做NativeAOT支持。

本框架支持这种发布方式:核心包、驱动包与部分扩展包都做了NativeAOT适配,并标注了 IsAotCompatible。

但NativeAOT并不是免费的午餐。你要做提前编译,就意味着要限制动态特性。有一些能力要么不能再用、要么需要额外登记。幸运的是,StdUnit.Tags类库家族对NativeAOT的支持良好,仅有两个场景需要特殊注意。

各包就绪情况

分组包状态备注
核心StdUnit.Tags.Core✅
核心StdUnit.Tags✅<Logicet/> 动态插件除外,见下文「<Logicet/> 动态插件在 AOT 下不可用」
扩展StdUnit.Tags.R3Extensions✅
扩展StdUnit.Tags.McpServer✅
扩展StdUnit.Tags.RxExtensions❌底层 System.Reactive 目前对 AOT 支持不好
扩展StdUnit.Tags.BlazorLib.Core❌Blazor Server 模式不支持 AOT
驱动StdUnit.Tags.S7✅
驱动StdUnit.Tags.ModbusTcp✅
驱动StdUnit.Tags.Hjzk✅
驱动StdUnit.Tags.ZLan✅
驱动StdUnit.Tags.OpcUaClient✅
驱动StdUnit.Tags.ComScanner✅
驱动StdUnit.Tags.SimpleFiles✅JSON 测点需登记元数据,见下文「SimpleFiles 的 JSON 测点要自行登记元数据」
  • "✅":该包自己的代码在裁剪 / AOT 分析器下零告警。第三方依赖的 AOT 告警要到 dotnet publish 时才出现(见文末「备注」)。
  • "❌": 由于底层限制,无法支持;或者由于时间不足,尚不支持。

<Logicet/> 动态插件在 AOT 下不可用

这是 .NET NativeAOT 运行时能力问题。NativeAOT 的官方限制里第一条就是 No dynamic loading:运行时的 AssemblyLoadContext 在 AOT 下直接抛 PlatformNotSupportedException。所以"插件 DLL 没被裁剪"并不能救它——宿主根本读不进这个文件。

所以,在 XML 里配了 <Logicet/> 的 AOT 宿主会在加载期抛 TagsProjectNotSupportedException,异常信息类似于:

TagsProjectNotSupportedException: 当前宿主运行时不支持…无法按路径挂载 <Logicet> 指定的 Logicet 插件:plugins/Demo.dll

这里我们有意在加载期抛出异常(快速失败),并推荐两种改法:

  1. 宿主内注册(AOT 下推荐):project.TryAddLogicet<MyLogicet>(sp),逻辑组件跟着宿主一起编译。
  2. 换宿主形态:改用非 AOT(CoreCLR/JIT)发布,<Logicet/> 恢复完整能力(隔离 + 卸载)。

注意,普通 JIT 宿主不受上述影响;net472 上则是另一种降级语义(无隔离、无卸载,见 README 的「netfx 的插件化限制」)。

SimpleFiles 的 JSON 测点要自行登记元数据

JSON 测点的值类型是你自己写的 POCO,框架无法预知它的类型元数据。而 AOT / 裁剪宿主下 System.Text.Json 默认关闭反射序列化(JsonSerializer.IsReflectionEnabledByDefault 为 false),所以,和上面的手动注册ILogicet类似,这里的元数据只能由你在代码里事先注册:

[JsonSerializable(typeof(MyJson))]
internal partial class MyJsonContext : JsonSerializerContext { }
builder.AddSimpleFilesChannel()
       .AddJsonSerializerContext(MyJsonContext.Default)        // ← 只加这一行
       .AddSimpleFilesDirectTagBuilder(
           configure: b => b.WithJsonTagFactory<MyJson>(),
           predicate: b => b.TagDescriptor.TagKind == "Json");

测点的注册方式不变(WithJsonTagFactory<T>() 不带任何类型元数据参数)。在AOT场景下,两种失败都会在构建测点时就报出来,而不是等第一次读写文件:

情况异常怎么改
没登记上下文TagsProjectNotSupportedException加上 AddJsonSerializerContext(...)
登记了上下文,但里面没有该类型的 [JsonSerializable]TagsProjectConfigurationException把该类型加进某个上下文

普通 JIT 宿主不需要登记:没有登记就退回反射序列化,行为与以前完全一致。

备注

IsAotCompatible 只说明本包代码干净。第三方依赖的 AOT 分析要到 dotnet publish 才完全现形,例如 McMaster.NETCore.Plugins会报 IL2104 / IL3053——但是注意,这个依赖包不会对我们的AOT场景有任何影响,因为在我们的代码里一旦在加载期检测到 AOT+动态插件,会当场报错。所以在dotnet publish时,看到关于McMaster.NETCore.Plugins的IL2104、IL3053这两条警告,可以放心忽略。

StdUnit.Tags —— 给 AI 的参考手册

本文件的定位:一份可以整篇丢给 AI 的"事实清单 + 配方"。 教程 chapter_1 ~ chapter_14 讲"为什么这么设计",本文件只讲"怎么写才编译得过、跑得起来、跑得对"。 二者冲突时以本文件为准(本文件按 dev 分支源码逐条核对)。

已核对并纠正的常见错误(旧文档/教程/记忆中可能写错):

错误写法正确写法
根元素 <root>XSD 定义的是 <Project>(运行期不校验根元素名,但写 XSD 时必须是 <Project>)
builder.AddComScanner()builder.AddComScannerSupport()
枚举 TagAccess / EndianTagAccessMode / EndianKinds
ITagGrp.IsDirty 属性IsDirty() 是方法(ITag.IsDirty / ITagCbnt.IsDirty 才是属性)
"ITagCbnt 只能嵌套 ITagGrp"ITagCbnt 的成员是 ITag(ITagCbntor)
TagDescriptor.TagAccessTagDescriptor.AccessMode(类型 TagAccessMode?,null = 向上继承)
IsScaned(0.x 的拼写)IsScanned(1.0 起改名,旧名已移除)
AutoCreateFile="true"(SimpleFiles 旧属性名)autoCreateFile="true"(旧名在 1.0 起于加载期报错)
ITagGrp.ReadAsync(ct) 是接口方法接口上是 ReadAsync(TraversalMode, ct);单参版本是 ITagGrpExtensions 上的扩展方法,等价传 IgnoreEnabled
ILogicet.Channels / ILogicet.Tags接口上已移除;改用 logicet.Project.Channels / .Tags(LogicetBase 仍保留同名便捷属性)
"isEnabled="false" 会让这棵子树不能读写"isEnabled 只门控自动轮询;手动读写照常执行(见 3.2 测点树)

目录

章节编号约定:主线教程与「驱动专题」按加入顺序编号(驱动专题从 11 起、只追加,下一个可用的号留给"自定义驱动的实现");其它专题不编号,靠 SUMMARY.md 的分组区分(如「AOT 支持」)。


0. 30 秒速览

一句话:StdUnit.Tags 是一套面向工业硬件交互的 .NET 库。你用 XML(或代码)描述"通道 + 测点 + 逻辑",库负责把它跑成一个类似 PLC 的串行轮询循环。

静态结构:

测点项目 ITagsProject = 通道 ITagChannel + 测点树 ITagGrp + 逻辑 ILogicet
概念接口说明
通道ITagChannel一次和某种底层硬件的连接(S7 / ModbusTcp / OpcUa / 串口 / 文件……)
测点组ITagGrp逻辑分组(Group),可嵌套 TagGrp / TagCbnt / Tag
测点组合ITagCbnt一起读、一起写的组合(Combination),成员只能是 ITagCbntor : ITag
测点ITag叶子节点,对应一个寄存器/位/文件
逻辑ILogicet业务逻辑插件,核心方法是 ProcessAsync(entry, thisChannel)

运行机制(每个入口 isEntry="true" 的 TagGrp 分配一个 ITagGrpRunner,多个入口并行,入口内部严格串行):

每轮 (turn):
  0. 连接:对入口子树里用到的所有通道执行 EnsureConnectedAsync
  1. 执行意图:从队列取至多 IntentCapacity 个意图执行
  2. 若缓存已脏 → WriteAsync(RespectEnabled)(先刷一次)
  3. 读入数据:entry.ReadAsync(RespectEnabled)  ← 从底层读到缓存
  4. 处理逻辑:按 Order 升序依次 await 每个 Logicet.ProcessAsync(entry, mainChannel)
  5. 刷写底层:entry.WriteAsync(RespectEnabled)  ← 把脏数据写回底层
  6. 按 scanInterval 延时

因此:入口内的一切都是串行的。ProcessAsync 和 TagGrpWriteIntent 都是"越早返回越好",否则会拖住同一入口后面的所有逻辑。

上面这三处读写由运行器以 TraversalMode.RespectEnabled 调用,也就是受 isEnabled 门控;而手动读写走单参重载,等价 TraversalMode.IgnoreEnabled,不受门控(见 3.2 测点树)。


1. 铁律(生成代码前必读)

违反下面任意一条,代码"能编译但一定跑不对"。

#规则
M1不要在循环外部直接给 tag.Value 赋值。必须走 proj.WriteIntent(entryName, intent, out var task),让变更在下一轮被串行执行。
M2ProcessAsync 与 TagGrpWriteIntent 必须尽快返回。它们跑在串行轮询里,阻塞会拖垮整个入口。要干慢活 → Task.Run 或丢给外部。
M3禁止"外部等意图、内部等外部"的环路等待(死锁)。详见 7.1。
M4同一个 ITagChannel 实例不得被多个入口共用(含父子入口嵌套)。入口崩溃/取消时会断开它子树中的全部通道,会误伤另一个入口。校验器 EntryChannelExclusivityValidator 默认开启并会拒绝这种配置。
M5不允许在入口内再嵌套 isEntry="true"。ScanEntries() 只认最外层入口,嵌套的会静默失效(NestedEntryValidator 会报错)。
M6不要混用类型:库不做任何隐式转换。short 就是 short,不能写给 int;int 也不能写给 float。GetTagValue<T>() 是硬转换,类型不符会抛异常。
M7逻辑组件按 Order 升序串行执行,且实例在整个项目生命周期内复用(构造函数里解析一次测点即可,不要每轮 SelectTag)。
M8ITagGrp 的脏标记是方法 IsDirty();ITag / ITagCbnt 的是属性 IsDirty。别写错。只有脏的测点才会被刷写,写完自动清脏(实战要点见 Chapter 11)。
M9StartPollAsync 直到项目停止才返回。StopAsync() 自 1.0 起会先等待轮询循环退出(有界,默认 30s)再释放项目、断开通道、触发 StartedOrStopped(false),所以 await StopAsync() 之后即可安全重启;等待超时(驱动不响应取消)时会记 Warning 并继续清理,那种情况下仍需自己 await 上一次 StartPollAsync 的 Task。
M10IntentCapacity 必须 > 0(默认 5),且只能在 RunAsync 之前设置。
M11WriteIntent 的 entry 必须是真实存在的入口名,否则抛 KeyNotFoundException。
M12EnableXmlSchemaValidation() 是opt-in;跨引用校验(4 个代码校验器)在 UseDefaults=true(默认)下自动开启。
M13isEnabled 只作用于自动轮询(TraversalMode.RespectEnabled,自顶向下短路整棵子树)。手动读写(ReadAsync(ct) / WriteAsync(ct),含外部意图里的显式读写)不检查使能,等价 TraversalMode.IgnoreEnabled——"平时禁用、需要时手动点动"是刻意支持的用法,别把它当 bug 去"修"。
M14AOT / 裁剪宿主(PublishAot=true)两条禁令:① 不要用 <Logicet> 动态插件(运行时没有动态加载程序集的能力,会在加载期抛 TagsProjectNotSupportedException),改用 TryAddLogicet<TLogicet>();② SimpleFiles 的 JSON 测点必须先 AddJsonSerializerContext(MyJsonContext.Default) 登记源生成元数据。详见 7.5。

2. 包、命名空间、依赖注入

包

包作用
StdUnit.Tags基础包(含 StdUnit.Tags.Core)。无硬件依赖
StdUnit.Tags.S7 / ModbusTcp / OpcUaClient / Hjzk / ZLan / ComScanner / SimpleFiles各驱动实现
StdUnit.Tags.RxExtensions / R3ExtensionsITag 的响应式扩展(只依赖 Core)
StdUnit.Tags.McpServer把测点项目暴露成 MCP Server 给 AI 用
StdUnit.Tags.BlazorLib(.Core)Blazor 监控界面
StdUnit.Tags.Templatesdotnet new tags.web / tags.wpf 模板

命名空间对照

命名空间内容
StdUnit.Tags绝大多数类型:ITag/ITagGrp/ITagCbnt/ITagChannel、ITagsProject、ITagsProjectCtrl、ITagsProjectFactory、TagsProjectServiceBuilder/TagsProjectServiceCollection、ILogicet/LogicetBase、ITagGrpRunner 及策略、TagDescriptor、BuiltinTagKinds、TagContainer、TagUnion、TagsProjectSchema、校验器
StdUnit.Tags.Core.ProjectsITagChannelsLoader、ILogicetsLoader、LoadedLogicets
StdUnit.Tags.LogicetsILogicetCreator、TagProjectExtensions.TryAddLogicet
StdUnit.Tags.RxTagRxExtensions
StdUnit.Tags.R3TagR3Extensions
StdUnit.Tags.SimpleFilesSimpleFilesTagChannel、SimpleFilesDirectTagBuilder、SimpleFilesDirectTagBase<T>、JsonDirectTag<T> ……
StdUnit.Tags.McpServerTagsMcpServerTools、TagsMcpResources、AddTagsMcp()
StdUnit.Tags.S7 / .ModbusTcp / .OpcUaClient / .Hjzk / .ZLan / .ComScanner各驱动的 TagsProject_Extensions(都叫同一个类名,只命名空间不同)

注意:TagsProject、TagsProjectCtrl、TagGrpRunner、TagGrpRunnerFactory、LogicetLoader、SimpleFilesDirectTagFactory 都是 internal。你只能通过接口 + DI 使用它们。

注册(唯一推荐写法)

using StdUnit.Tags;          // TagsProjectServiceBuilder
using StdUnit.Tags.S7;       // AddS7Support

services.AddTagsProjectServices(builder =>
{
    builder.AddS7Support();
    // builder.AddModbusTcpSupport();
    // builder.AddOpcUaClientSupport();
    // builder.AddHjzkSupport();
    // builder.AddZLanTcpSupport();
    // builder.AddComScannerSupport();
    // builder.AddSimpleFilesSupport();
});
public static IServiceCollection AddTagsProjectServices(
    this IServiceCollection services,
    Action<TagsProjectServiceBuilder> configTagsLoader);

TagsProjectServiceBuilder 的公开成员:

成员说明
IServiceCollection Services裸 DI 容器,可注册自己的东西
bool UseDefaults { get; set; } = true关掉后必须自己注册 ITagGrpRunnerFactory / ILogicetsLoader / ITagsProjectFactory
CompositeTagChannelFactory ChannelFactories通道工厂集合
CompositeTagsLoader TagsLoaders测点加载器集合
ConfigChannelsFactory(Action<IServiceProvider, CompositeTagChannelFactory>)追加通道工厂
ConfigTagsLoader(Action<IServiceProvider, CompositeTagsLoader>)追加 Tag/TagCbnt 构建器
AddValidation<TValidator>()追加 ITagsProjectValidator
EnableXmlSchemaValidation()启用 XSD 校验(默认关)→ 等价于 AddValidation<TagsProjectSchemaValidator>()
EnableCrossReferenceValidation()注册 4 个代码校验器(默认开)
void Build()提交注册

默认注册的生命周期:ITagGrpRunnerFactory/ITagsLoader/ITagChannelFactory/ITagChannelsLoader/ILogicetsLoader 为 Singleton,ITagsProjectFactory 为 Scoped,ITagsProjectCtrl 为 Singleton。

其它辅助 API

// 注册一个 keyed 控制器(一个进程跑多个项目)
services.AddKeyedTagsProjectCtrl("plant1");
var ctrl = sp.GetRequiredKeyedService<ITagsProjectCtrl>("plant1");

// 只构建、不运行(便于手工组装)
ITagsProject proj = sp.MakeProject(dir, root);

3. 核心抽象与 API

3.1 通道

public interface ITagChannel : IDisposable
{
    TagChannelDescriptor Descriptor { get; }
    Task EnsureConnectedAsync(bool force, CancellationToken ct);
    Task DisconnectAsync(CancellationToken ct);
}
// 扩展:channel.ChannelName() / channel.Driver()

批量字节型通道额外实现:

public interface IContinuousBytesBasedTagChannel : ITagChannel
{
    Task<byte[]> ReadAsync(string address, int cbSize, CancellationToken ct);
    Task WriteAsync(string address, byte[] bytes, CancellationToken ct);
}

3.2 测点树

public interface ITag
{
    TagDescriptor TagDescriptor { get; set; }
    object? Value { get; set; }          // 注意:object?;库不做隐式转换
    DateTime Timestamp { get; set; }       // UTC(1.0 起统一 DateTime.UtcNow;展示/比较时自行转本地)
    event TagSyncEventHandler OnTagRead;     // (ITag sender, TagSyncEventArgs e)
    event TagSyncEventHandler OnTagWritten;
    bool IsScanned { get; set; }          // 1.0 起由 IsScaned 改名而来
    bool IsDirty { get; set; }
    ITagChannel? Channel { get; }         // 只读;可能为 null
    TagContainer? Parent { get; set; }
    Task ReadAsync(CancellationToken ct);
    Task WriteAsync(CancellationToken ct);
}
public interface ITagCbnt
{
    TagCbntDescriptor Descriptor { get; set; }
    ITagGrp? Parent { get; set; }
    IDictionary<string, ITagCbntor> Children { get; }
    ITagCbntor this[string tagName] { get; }   // 不存在则抛
    bool IsEnabled { get; set; }
    bool IsScanned { get; set; }
    ITagChannel? Channel { get; set; }
    string StartAddress { get; set; }
    bool IsDirty { get; set; }
    Task ReadAsync(CancellationToken ct);
    Task WriteAsync(CancellationToken ct);
}
public interface ITagGrp
{
    ITagGrp? Parent { get; set; }
    TagGrpDescriptor Descriptor { get; set; }
    IDictionary<string, TagUnion> Children { get; }
    TagUnion this[string tagName] { get; }      // 直接子元素
    TagUnion Descendant(string path);           // "/" 分隔,不存在则抛
    ITagGrp AddTag(ITag tag);
    ITagGrp AddTag(ITagCbnt tagCbnt);
    ITagGrp AddTag(ITagGrp tagGrp);
    bool IsEnabled { get; set; }
    ITagChannel? Channel { get; set; }
    bool IsDirty();                              // ← 方法!
    Task ReadAsync(TraversalMode mode, CancellationToken ct);   // 2.0 起带遍历模式
    Task WriteAsync(TraversalMode mode, CancellationToken ct);
}
// 扩展(ITagGrpExtensions):ReadAsync(ct) / WriteAsync(ct) ≡ 传 TraversalMode.IgnoreEnabled

TagUnion 是 TagGrp | TagCbnt | TagUnit 的封闭联合:

public abstract record TagUnion
{
    public abstract void Accept(ITagUnionVisitor visitor);
    public record TagUnit(ITag Value)     : TagUnion;
    public record TagCbnt(ITagCbnt Value) : TagUnion;
    public record TagGrp(ITagGrp Value)   : TagUnion;
    public T Map<T>(Func<ITag, T>, Func<ITagCbnt, T>, Func<ITagGrp, T>);
}
// 现成的遍历器:TagUnionTraverser(procGrp, procCbnt, procTag) / TagTraverser(tag => ...)

TagContainer 是 ITagCbnt | ITagGrp 的封闭联合,只能 TagContainer.From(...) 构造,只能 Map<T>(...) 拆开。

使能(isEnabled)与遍历模式(2.0 起):

  • TraversalMode.RespectEnabled:自顶向下判定,父节点未使能 ⇒ 它整棵子树既不读也不写(保留脏标记,重新使能后该刷的照常刷)。自动轮询用这一档。
  • TraversalMode.IgnoreEnabled:不检查使能,整棵子树照常读写。手动调用(单参 ReadAsync(ct) / WriteAsync(ct)、外部意图里的显式读写)用这一档。
  • 所以 isEnabled="false" 管的是"别自动采它",不是"这个点位不能碰";"平时禁用、需要时手动点动"是刻意支持的用法。
  • 注意"意图里的写"分两种:意图体内显式调 grp.WriteAsync(ct)(不带 mode)不受门控;而意图体里只做 tag.Value = x、靠轮询器在轮末刷写的那种,会被门控拦住——被禁用的子树下,值只停在缓存里、脏标记保留,等它重新使能才刷到底层。整个入口被禁用时更彻底:那一轮直接 Task.Delay(500) 跳过,意图根本不会被取出执行(await task 要一直等到重新使能)。
  • 只有需要自己模拟"自动轮询那一档"时才显式传参:grp.ReadAsync(TraversalMode.RespectEnabled, ct)。
  • ITagCbnt 有 IsEnabled,但它的 ReadAsync/WriteAsync 不带 mode;ITag 则两者都没有(Tag 直挂测点组时是自己做 IO、塞进 TagCbnt 时是整块缓存 IO,同一个开关在两种挂法下行为并不一致,所以索性不给它)。

3.3 定位测点

方法返回说明
grp[name] / cbnt[name]TagUnion / ITagCbntor直接子元素;不存在抛异常
grp.Descendant("a/b/c")TagUnion/ 分隔的后代路径
grp.SelectGrp(path)ITagGrp推荐;一步拿到强类型
grp.SelectCbnt(path)ITagCbnt拿组合
grp.SelectTag(path)ITag注意:只能用于 ITag,对 TagCbnt 里的成员用 cbnt.SelectTag(name)
cbnt.SelectTag(name)ITagCbntor逐一TagCbnt的子元素只能是ITagCbntor,所以其路径就是直接子成员名——不可能含“/”路径分隔符
tag.SearchEntry()ITagGrp?向上冒泡找到最近的入口组

路径规则:/ 是分隔符 ⇒ 名称里不能含 /;要访问含 / 的名称只能用 [] 逐层索引。

3.4 取值 / 赋值 / 其它扩展

// 取值(硬转换,类型必须完全匹配)
bool v = tag.GetTagValue<bool>();
string s = tag.GetTagValue<string>();

// 描述符信息
tag.TagName(); tag.RawAddress(); tag.NormalizedAddress(); tag.TagSize();
tag.TagKind();  tag.TagEndian(); tag.AccessMode();        // AccessMode 可能为 null
tag.SearchAccessMode();                                    // 冒泡 + 兜底 RW,永不为 null
tag.IsReadOnly(); tag.IsWriteOnly();
tag.SearchChannel();          // 冒泡,可能 null
tag.SearchRequiredChannel();  // 冒泡,找不到抛异常

ITagGrpExtensions 补充:

方法说明
TagName() / IsEntry()名称 / 是否入口
ScanEntries()向下探测入口列表;找到入口就不再向下递归
SearchChannel() / SearchRequiredChannel()向上冒泡解析通道
CollectChannels()向下收集入口子树要用到的所有通道(按文档顺序去重),运行器用它建连
SearchAccessMode() / SearchScanInterval()向上冒泡,AccessMode 兜底 RW;ScanInterval 可能为 null
ReadAsync(ct) / WriteAsync(ct)等价传 TraversalMode.IgnoreEnabled:不检查 isEnabled(真身是接口上带 mode 的重载)

3.5 逻辑组件

public interface ILogicet
{
    ITagsProject Project { get; }                             // 所属项目(通道与测点从这里取)
    int Order { get; }                                        // 升序执行
    bool Enabled { get; }
    bool MatchEntry(ITagGrp entry);                           // 是否参与该入口
    Task ProcessAsync(ITagGrp entry, ITagChannel? thisChannel);
}

public abstract class LogicetBase : ILogicet
{
    protected LogicetBase(ITagsProject project);
    public ITagsProject Project { get; }
    public virtual IReadOnlyList<ITagChannel> Channels => Project.Channels;   // 便捷属性,不在接口上
    public virtual ITagGrp Tags => Project.Tags;                              // 便捷属性,不在接口上
    public abstract int Order { get; }
    public abstract bool MatchEntry(ITagGrp entry);
    public virtual bool Enabled { get; set; } = true;
    public abstract Task ProcessAsync(ITagGrp entry, ITagChannel? thisChannel);
}
  • 只要实现 ILogicet 即可,其中 Project 是必须实现的成员;LogicetBase 只是把这几个属性实现掉,并额外提供 Channels / Tags 两个转发属性。
  • MatchEntry 返回 true 的逻辑组件会在每个入口的每一轮里都执行。
  • thisChannel 是入口的主通道(entry.SearchChannel()),辅通道请自己从 entry 解析。

3.6 运行器与策略

public delegate Task TurnProcess(ITagGrp grp, ITagChannel? channel);
public delegate Task RunnerStarted(ITagGrp grp, ITagChannel? channel);
public delegate Task RunnerCrashed(ITagGrp grp, ITagChannel? channel, Exception ex);

public interface ITagGrpRunner
{
    event RunnerStarted? RunnerStarted;
    event RunnerCrashed? RunnerCrashed;
    event TurnProcess? TurnProcess;
    Task StartAsync(ITagGrp entry, CancellationToken ct);
}
策略接口方法内置默认实现(StdUnit.Tags)
ITagGrpRunnerPollDelayStrategyTimeSpan GetDelay(TimeSpan scanInterval, TimeSpan elapsed)AdaptivePollDelayStrategy:elapsed >= scanInterval ? Zero : scanInterval - elapsed
ITagGrpRunnerRetryStrategyTimeSpan GetDelay(int consecutiveFailureCount)DefaultTagGrpRunnerRetryStrategy:logistic,Min 500ms → Max 30s(GrowthRate 0.8,Midpoint 5)
ITagGrpRunnerDisconnectStrategyTask WaitDisconnectAsync(ITagChannel? channel, Task? disconnect)DefaultTagGrpRunnerDisconnectStrategy:Timeout = 5s
——另有 SlidingWaitPollDelayStrategy:恒返回 scanInterval

自己实现策略后,直接注册到 DI 即可被 TagGrpRunnerFactory 取用。

崩溃语义:一轮抛异常 → 调用 RunnerCrashed → 断开该入口全部通道 → 按重试策略退避后重开循环。 ⚠️ 如果 RunnerCrashed 处理器自己又抛异常,该入口会被停机(记为 Critical)。

3.7 项目与启停

public interface ITagsProject : IDisposable
{
    IReadOnlyList<ITagChannel> Channels { get; }
    IList<ILogicet> Logicets { get; }          // 可 Add
    ITagGrp Tags { get; }
    string? ProjectRoot { get; }
    XElement? GetRootElement();                 // 返回 Initialize 用的根元素
    event RunnerStarted? RunnerStarted;
    event RunnerCrashed? RunnerCrashed;
    void Initialize(string projRoot, XElement? root = null);
    Task RunAsync(CancellationToken ct);        // 所有入口跑完前不返回
    int IntentCapacity { get; set; }            // 默认 5,必须 > 0,运行前设置
    bool WriteIntent(string entry, TagGrpWriteIntent intent, out Task task);
    ChannelReader<IntentCompletion>? GetIntentReader(string entry);
}
public interface ITagsProjectCtrl
{
    ITagsProject? Project { get; }
    Func<Exception, Task<bool>>? OnStartingException { get; set; }
    event TagsProjectStartedOrStopped? StartedOrStopped;
    Task StartPollAsync(string? dir, XElement? root,
                        Func<ITagsProject, IServiceProvider, CancellationToken, Task> hook);
    Task StopAsync();
}
  • root == null ⇒ 加载 {dir}/index.xml;文件不存在抛 FileNotFoundException。
  • Initialize 顺序:清空 → 跑全部 ITagsProjectValidator → LoadChannels → LoadTags → LoadLogicets。
  • RunAsync 在"通道集为空 / 没有入口"时抛异常。
  • hook(proj, sp, ct) 里的 sp 是本次启动专属的 scope,别捕获到长生命周期对象里。
  • 重复 StartPollAsync 会走 OnStartingException(消息 "当前测点项目已经启动!");返回 true 则静默返回。

3.8 描述符与枚举

public enum TagAccessMode { RW = 0, R1W = 1, RO = 2, WO = 3 }
public enum EndianKinds   { LittleEndian, BigEndian }

public class TagDescriptor : ITagsDescriptor
{
    public string TagName { get; set; }
    public TagKinds TagKind { get; set; } = BuiltinTagKinds.Unknown;  // 注意:TagKinds 是 string 的别名
    public int TagSize { get; set; }                                   // 0 = 让驱动自己规范化
    public TagAddress RawAddress { get; set; }                         // 也是 string 的别名
    public TagAddress NormalizedAddress { get; set; }                  // 空则回落 RawAddress
    public EndianKinds EndianKind { get; set; } = EndianKinds.LittleEndian;
    public TagAccessMode? AccessMode { get; set; }                     // null = 向上继承
    public string? ChannelName { get; set; }
    public string? Note { get; set; }
    public IDictionary<string, XAttribute> Extras { get; set; }
}

BuiltinTagKinds(public const string):Unknown BIT BYTE INT16 UINT16 INT32 UINT32 INT64 UINT64 FLOAT DOUBLE STR DI DO。

TagKinds / TagAddress 只是 global using TagKinds = System.String; —— 别去找这两个类型。

访问模式语义(TagUnion.ReadAsync/WriteAsync 里实现):

模式读写备注
RW(默认)每轮脏才写
R1W只在开机第一次读(读后 IsScanned = true,之后不再读)脏才写配合"测点即文件"可大幅减少 IO(实战要点见 Chapter 11)
RO每轮永不写
WO永不读脏才写

TagSize 语义:0 表示未指定。字节型驱动(S7)按字节数;寄存器型驱动(Modbus)按字节数填(BIT/BYTE/INT16/UINT16 → 2,INT32/UINT32/FLOAT → 4,INT64/UINT64 → 8)。

3.9 校验器

public interface ITagsProjectValidator { void Validate(XElement root); }  // 失败抛 TagsProjectValidationException(XSD 用其派生类型 TagsProjectSchemaException)
校验器检查什么默认
TagsProjectSchemaValidatorXSD(核心 + 各驱动)关(EnableXmlSchemaValidation() 开启)
ChannelCrossReferenceValidator每个 channel= 必须指向已声明的 <Channel>开
ChannelDriverFactoryValidatordriver= 必须已注册工厂开
EntryChannelExclusivityValidator一个通道名不得被多个最外层入口使用开
NestedEntryValidator禁止入口嵌套入口开

4. XML 项目描述参考

4.1 根元素与 XSD

<Project xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:noNamespaceSchemaLocation="Schemas/tagsproject.xsd"
         xmlns:s7="tags:s7">
  <!-- Channel | TagGrp | Logicet,任意顺序、任意个数(xs:choice maxOccurs="unbounded") -->
</Project>
  • XSD 没有 target namespace;驱动专属元素放在 tags:{driver} 命名空间里。
  • StdUnit.Tags.Core 用 buildTransitive 把 Schemas/tagsproject.xsd 链接进你的项目树(只给编辑器智能提示,不参与编译、不复制到 bin)。驱动包同样注入 Schemas/Drivers/*.xsd。
  • 用 <TagsProjectSchemaDisabled>true</TagsProjectSchemaDisabled> 可以关掉注入。
  • 运行期不校验根元素名:老格式 <root> + 无前缀子元素,只要不开 EnableXmlSchemaValidation() 就照常加载。

4.2 <Channel>

属性必填说明
name✅通道名,非空
driver✅驱动名,必须在 ITagChannelFactory.GetAvailableDrivers() 中

子元素必须是驱动命名空间的元素(如 <s7:IpAddr>)。所有子元素都会被塞进 TagChannelDescriptor.Extras(按 LocalName 索引)。

4.3 <TagGrp>

属性默认说明
name必填名称非空
channel继承不写则向上冒泡
isEntryfalse只有大小写不敏感的 "true" 才算
isEnabledtrue除 "false" 外都算 true;只作用于自动轮询(自顶向下短路整棵子树),手动读写不受影响(见 3.2)
scanInterval继承毫秒;解析失败抛 ArgumentException("{name}的扫描周期无法解析成整数,它应该是一个毫秒数量")
access继承RW/RO/R1W/WO

子元素:TagGrp | TagCbnt | Tag,任意顺序、任意个数。

4.4 <TagCbnt>

属性默认说明
name必填
address""组合起始地址(驱动相关)
channel继承
isEnabledtrue只作用于自动轮询,语义同 <TagGrp>
access继承
其它(slave、area、……)—进入 Extras,由驱动读取

子元素:只能是 Tag。

4.5 <Tag>

属性默认说明
name必填缺失抛 "Tag 未配置名称"
address""驱动相关地址
typeUnknown自由字符串(不校验,便于插件自定义类型)
endianLittleEndian大小写敏感:LittleEndian / BigEndian,非法抛 "Tag(Name=x) 配置了未知的字节序=y"
access继承(最终 RW)RW/RO/R1W/WO,非法抛 "Tag(Name=x) 配置了未知的访问模式=y"
channel继承
notenull备注
tagSize0非法抛 "Tag(Name=x) 配置了非法大小=y"
其它(如 maxlen、autoCreateFile)—进 Extras

不在内置清单里的属性一律进 Extras,键是 Name.LocalName。驱动就是靠这个读取自己专属配置的。

4.6 <Logicet>

元素文本 = 插件 DLL 路径(相对路径会拼到 ProjectRoot)。程序集里所有 public、非抽象、非泛型的 ILogicet 实现都会被实例化。

AOT / 裁剪宿主不适用:NativeAOT 的运行时没有动态加载程序集的能力(官方限制 No dynamic loading),配了它会在加载期抛 TagsProjectNotSupportedException(Location 为 Logicet(<dll>));改用 TryAddLogicet<TLogicet>()。

4.7 完整最小示例(S7)

<?xml version="1.0" encoding="utf-8"?>
<Project xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:noNamespaceSchemaLocation="Schemas/tagsproject.xsd"
         xmlns:s7="tags:s7">

  <Channel name="S7-1" driver="S7">
    <s7:IpAddr>172.16.10.20</s7:IpAddr>
    <s7:Rack>0</s7:Rack>
    <s7:Slot>1</s7:Slot>
  </Channel>

  <TagGrp name="IoBox" isEntry="true" isEnabled="true" channel="S7-1" scanInterval="10">
    <TagCbnt name="PLC" address="DB200.100" access="RO">
      <Tag name="心跳请求" address="$$100.0" type="BIT"  access="RO"/>
      <Tag name="状态码"   address="$$102.0" type="INT16" endian="BigEndian"/>
    </TagCbnt>
    <TagCbnt name="MST" address="DB201.100" access="R1W">
      <Tag name="心跳响应" address="$$100.0" type="BIT"   access="R1W"/>
      <Tag name="扫描周期" address="$$102.0" type="FLOAT" access="R1W"/>
    </TagCbnt>
  </TagGrp>

  <!-- 0~N 个逻辑插件 -->
  <!-- <Logicet>Samples.Plugin1/Samples.Plugin1.dll</Logicet> -->
</Project>

对应的取值代码:

var cbnt = proj.Tags.SelectCbnt("IoBox/PLC");     // 或 tag.SearchEntry()
var req  = cbnt.SelectTag("心跳请求");
var code = cbnt.SelectTag("状态码");
short status = code.GetTagValue<short>();          // 必须 short,不能是 int

5. 驱动参考

总表

驱动名(driver=)包注册方法XML 命名空间
S7StdUnit.Tags.S7AddS7Support()tags:s7
ModbusTcpStdUnit.Tags.ModbusTcpAddModbusTcpSupport()tags:modbustcp
HjzkIoBoxStdUnit.Tags.HjzkAddHjzkSupport()tags:hjzk
ZLanTcpStdUnit.Tags.ZLanAddZLanTcpSupport()tags:zlan
OpcUaClientStdUnit.Tags.OpcUaClientAddOpcUaClientSupport()tags:opcuaclient
COMStdUnit.Tags.ComScannerAddComScannerSupport()tags:com
SimpleFilesStdUnit.Tags.SimpleFilesAddSimpleFilesSupport()tags:simplefiles

每个 AddXxxSupport() = AddXxxChannel() + AddXxxTagCbntBuilder() + AddXxxDirectTagBuilder() + 注册 XSD provider 的组合。想只要一部分就调用对应的细粒度方法。

5.1 S7

专题讲解(怎么用、为什么、排错)见 Chapter 12 S7 驱动专题。

Channel 子元素(tags:s7):

元素默认
<s7:IpAddr>localhost
<s7:Rack>0
<s7:Slot>1
<s7:ConnectionType>3

地址语法:

形式含义
DB<db>.<byte>[.<bit>]指定 DB 块 + 块内绝对字节偏移(+位号 0~7)
MB.<byte>[.<bit>]M 区(Merker)
$$<byte>[.<bit>]沿用父 TagCbnt 的区号和块号。$$ 后面的数字仍是该块内的绝对偏移,不是相对偏移

$$ 的偏移是绝对偏移:示例里 TagCbnt address="DB200.100" 配 Tag address="$$100.0",缓存偏移 = 100 - 100 = 0。若 Tag 显式写了 DB 而同组 Cbnt 的 DB 不一致,会抛 "Tag & Cbnt start address doesn't match(...)"。

支持类型:BIT BYTE INT16 UINT16 INT32 UINT32 INT64 UINT64 FLOAT STR

  • STR 必须额外写 maxlen(1~255),否则抛 InvalidDataException("字符串型测点必须指定字符串最大长度 maxlen。测点=...");TagSize = 2 + maxlen。

5.2 ModbusTcp

专题讲解(怎么用、为什么、排错)见 Chapter 14 ModbusTcp 驱动专题。

Channel 子元素(tags:modbustcp):<mb:IpAddr>(localhost)、<mb:Port>(502)、<mb:MaxWriteRegisters>(123)、<mb:MaxReadRegisters>(125)、<mb:MaxReadBits>(2000)。

地址语法:[<slave>~]<area><reference>[.<bit>]

area区域读/写
0线圈(Coils,00001~)R/W
1离散输入(Discrete Inputs,10001~)RO
3输入寄存器(Input Registers,30001~)RO
4保持寄存器(Holding Registers,40001~)R/W
  • <slave> 缺省 = 1;<reference> 是1 起算的参考号(如 40001 ⇒ 内部 StartPoint = 0)。
  • 例:1~40001、40001、0~00001、4~40010.3(保持寄存器偏移 9 的第 3 位)。
  • TagCbnt 上可写 slave="2" 指定整个组合的从站号,等价于把 2~ 写进 address(位空间与寄存器空间一致);写了它就以后者为准。子测点地址里的 ~ 前缀只用于算偏移,不切换从站。

两种组合空间(互斥,由 Cbnt 起始地址所属区域自动分流):

组合起始区域Cbnt 内 Tag 可用 typeTagSize
位空间(ModbusBitTagCbntBuilder,缓存 bool[])0x / 1xDI(必须是 1x)、DO(必须是 0x)1
寄存器空间(ModbusRegisterTagCbntBuilder,缓存 ushort[])3x / 4xBIT BYTE INT16 UINT16 INT32 UINT32 FLOAT INT64 UINT642 / 4 / 8(字节)
  • 3x 输入寄存器的成员自动只读。
  • 跨空间会抛异常(如 "地址区域{X}不可作为DI测点"、"位空间组合不支持测点种类={kind}")。

直接测点(不由 Cbnt 承载):DI DO BIT BYTE INT16 UINT16 INT32 UINT32 INT64 UINT64 FLOAT(不支持 STR/DOUBLE),address 直接写 [<slave>~]<area><reference>[.<bit>]。

端序:Modbus 协议规定寄存器内部为大端(高字节在前),这一层由 NModbus 解析、配置改不动。于是排布由两个属性分工表达:endian = 每个 16 位单元(寄存器)内部两个字节的顺序(与 S7 同名同义),32/64 位里单元之间的顺序由可选的 interpret 表达。

类型endian 管什么BigEndianLittleEndian(默认)interpret
INT16 UINT16(1 寄存器)寄存器内两字节顺序直取寄存器值交换寄存器内两字节不允许
INT32 UINT32 FLOAT(2 寄存器)每个寄存器内两字节顺序单元内高位在前单元内低位在前可选,4 个字符
INT64 UINT64(4 寄存器)每个寄存器内两字节顺序单元内高位在前单元内低位在前可选,8 个字符
BYTE取寄存器哪一半高字节低字节不允许
BIT DI DO与端序无关——不允许
  • 多字节数值测点(INT16 UINT16 INT32 UINT32 FLOAT INT64 UINT64)应显式写 endian="BigEndian"(标准设备惯例):不写时描述符默认 LittleEndian(低位在前),16 位会交换寄存器内两字节、32/64 位会按完全小端解读,且不报错。
  • interpret:一串大写字母表达各字节在设备端字节(从寄存器数值重建出的设备侧字节序列,与真机上那几个寄存器的线上字节逐字节相同)里出现的先后(A = 最高字节),字符数 = 字节数(32 位 ABCD/CDAB/BADC/DCBA,64 位 8 个字符如 ABCDEFGH/GHEFCDAB/BADCFEHG/HGFEDCBA)。每两个连续字符必须是同一个 16 位单元的两个字节且先后与 endian 一致(BigEndian 只接受 AB/CD…,LittleEndian 只接受 BA/DC…)。不写时:BigEndian = 完全大端(ABCD)、LittleEndian = 完全小端(DCBA)。32 位 0x12345678 的四种排布即 endian="BigEndian"(设备端字节 12 34 56 78)、endian="BigEndian" interpret="CDAB"(56 78 12 34)、endian="LittleEndian" interpret="BADC"(34 12 78 56)、endian="LittleEndian"(78 56 34 12)。
  • interpret 的校验(长度不符 / 非法字符 / 重复 / 与 endian 冲突)都在加载期报错:TagsProjectXmlException;16 位、BYTE、寄存器位测点、位空间(DI/DO)以及 TagCbnt 本身写 interpret 报 TagsProjectConfigurationException。
  • interpret 是驱动私有属性(走 TagDescriptor.Extras,Core 不感知,其它驱动会忽略它)。
  • ushort[] 是数值数组:NModbus 已用 NetworkToHostOrder 把线上大端字节还原成数值,主机(Intel)端序在这一层被消化掉,endian/interpret 只表达"设备里这个数值怎么摆"(坐标是设备端字节,见上一条)。写方向对称(HostToNetworkOrder)。
  • 直接测点与组合成员的语义完全一致——同一份 XML 放在 <TagGrp> 下或 <TagCbnt> 下得到同一个物理值(回归测试:ModbusEndianConsistencyTests、ModbusEndianWireTests、ModbusInterpretTests)。

5.3 HjzkIoBox(华杰智控 IO 盒子,基于 Modbus)

Channel 子元素(tags:hjzk):IpAddr、Port、MaxWriteRegisters、MaxReadRegisters、MaxReadBits。

  • TagCbnt 通过 area="DI" 或 area="DO" 选择构建器(HjzkDICbntBuilder / HjzkDOCbntBuilder),可写 slave="1"。
  • 成员 Tag:type="DI"(16 个 DI 针脚)或 type="DO"(16 个 DO 针脚),address 是针脚枚举名(DI1…DI16 / DO1…DO16),非法值抛 ArgumentException("Hjzk DI 地址非法({addr})")。

5.4 ZLanTcp(卓兰 IO 盒子,基于 Modbus)

Channel 子元素(tags:zlan):IpAddr、Port、MaxWriteRegisters、MaxReadRegisters、MaxReadBits。

配置方式与 Hjzk 完全一致(area="DI"/"DO" + type="DI"/"DO" + 针脚名地址)。

5.5 OpcUaClient

专题讲解(怎么用、为什么、排错)见 Chapter 13 OpcUa 驱动专题。

Channel 子元素(tags:opcuaclient):

<Channel name="ua-1" driver="OpcUaClient">
  <ua:ClientName>client-name-1</ua:ClientName>   <!-- 默认 StdUnitTagsOpcUaClient -->
  <ua:ServerOpt>
    <ua:DiscoveryUrl>opc.tcp://localhost:4840</ua:DiscoveryUrl>  <!-- 默认 localhost -->
    <ua:UsePassword>false</ua:UsePassword>                       <!-- 默认 false -->
    <ua:UserName></ua:UserName>
    <ua:Password></ua:Password>
  </ua:ServerOpt>
  <ua:SecurityOpt>                                              <!-- 1.0 新增,均可省略 -->
    <ua:AutoAcceptUntrustedCertificates>true</ua:AutoAcceptUntrustedCertificates>  <!-- 默认 true(宽松) -->
    <ua:RejectSHA1SignedCertificates>false</ua:RejectSHA1SignedCertificates>       <!-- 默认 false(宽松) -->
    <ua:MinimumCertificateKeySize>1024</ua:MinimumCertificateKeySize>              <!-- 默认 1024(宽松) -->
    <ua:CertificateStoreRoot></ua:CertificateStoreRoot>                            <!-- 默认空 ⇒ CommonApplicationData/OPC Foundation/CertificateStores -->
  </ua:SecurityOpt>
</Channel>
SecurityOpt 字段默认解析失败时
AutoAcceptUntrustedCertificates (bool)trueTagsProjectXmlException:通道配置的 AutoAcceptUntrustedCertificates 无法解析为布尔值:{值},Location=Channel(名)
RejectSHA1SignedCertificates (bool)false同上(换成对应字段名)
MinimumCertificateKeySize (ushort)1024非 1~65535 整数 ⇒ TagsProjectXmlException
CertificateStoreRoot (string)空 ⇒ 内置默认根不报错;空/空白串视为未配置,回落默认根
  • ServerOpt / SecurityOpt 的字段元素与属性两种写法都接受(Attribute ?: Element ?: 默认值)。
  • 任一宽松项生效 ⇒ 构造通道时输出一次 LogWarning:通道({名})使用偏宽松的证书校验设置:{逐项}。这会降低对中间人攻击的防护,生产环境请在 <SecurityOpt> 中收紧(装入受信任证书、拒绝 SHA1、密钥长度不低于 2048);收紧后(false / true / >=2048)不再告警。
  • Linux:默认根来自 CommonApplicationData(Linux = /usr/share),普通用户通常无写权限;请显式给可写目录(如 /home/you/.local/share/OPC Foundation/CertificateStores)。四个子库(MachineDefault / UA Certificate Authorities / UA Applications / RejectedCertificates)由库 Path.Combine 拼出。取值按原样使用:%...% 占位符与反斜杠不会被替换或规范化。
  • 地址 = OPC UA 节点标识(NodeId)字符串,例如 ns=4;s=|var|CODESYS Control Win V3 x64.Application.PLC_PRG.var3。
  • 值类型是 object(不做类型转换),推荐用 Tag.GetTagValue<T>() 自行严格转换,或用 Note/type 记录语义。
  • 不需要写 type(节点类型由服务端决定,与 S7/Modbus 不同);同一个 NodeId 可被多个子测点引用(别名):读都拿得到、写只写一次(最后一个脏值生效),不会因重复键报错。

坏点语义(1.0 定调,最重要的一条):

节点状态行为
Bad(或 DataValue 为 null)抛 InvalidOperationException:通道({名})读取节点值失败:节点={NodeId} 状态码=0x{Code:X8}({BrowseName});缓存/时间戳/通知均不改动
Uncertain照原样采集(服务器确实给了值;可信度是业务问题)
  • 任意一个节点 Bad ⇒ 本次整体失败(含 TagCbnt 的批量读)。
  • 不要改成"跳过坏点 + 记日志":那会掐断"崩溃 → 断开全部通道 → 重连"的恢复路径,且对下游完全不可见。
  • 库不引入质量/可信度概念:要表达可信度请另加一个测点。

自定义"什么算失败"(默认 OpcUaValueQuality.IsFailed:只有 Bad 算失败;不做成 XML 配置项——它是委托,只能传给 AddOpcUaClientSupport / AddOpcUaClientChannel):

// 更严:把 Uncertain 也算失败
builder.AddOpcUaClientSupport(checkIsFailed: (err, value) =>
    value is null || (err is not null && ServiceResult.IsBad(err)) || StatusCode.IsUncertain(value.StatusCode));
// 更松:任何状态都照原样采集(读取永不因质量失败;Bad 时 value 可能为 null,由使用方承担)
builder.AddOpcUaClientSupport(checkIsFailed: (_, _) => false);

签名 Func<ServiceResult?, DataValue?, bool>,返回 true = 该节点本次读取算失败。判定请直接问 IsBad:IsGood(Uncertain) == false,取反会把"不确定"误判成失败。

5.6 COM(串口 / 扫码枪)

Channel 子元素(tags:com):

元素默认
<com:Port>COM1
<com:BaudRate>9600
<com:Parity>None(None/Odd/Even/Mark/Space)
<com:DataBits>8
<com:StopBits>None(None/One/Two/OnePointFive)
<com:NewLine>缺省(支持 \n、\r\n 转义)
<com:ReadEntireLine>true
<com:ReadScript> / <com:ReadScriptDebugInformationEnabled>缺省
<com:ChannelCapacity>1

0.10 及以前的拼写错误 <BaundRate> 运行期仍兼容,但 XSD 会报错,建议迁移。

约束:串口测点只支持 STR 类型,且访问模式只能是 RO 或 WO;否则抛 "串口型测点({name})只支持(RO|WO)访问" 或 NotImplementedException("串口测点({name})的类型({kind})上不支持!")。

5.7 SimpleFiles("测点即文件")

专题讲解(怎么用、为什么、排错)见 Chapter 11 SimpleFiles 驱动专题。

Channel 子元素(tags:simplefiles):<sf:BaseDir>(相对/绝对目录;为空则地址原样使用)。

<Channel name="file" driver="SimpleFiles">
  <sf:BaseDir>D:/temp</sf:BaseDir>
</Channel>
  • 每个 <Tag> 对应一个文件;文件路径 = Path.Combine(BaseDir, address)(BaseDir 为空则 address 即是路径)。
  • 目录结构完全由 address 决定(TagGrp/TagCbnt 不会映射为目录)。
  • 支持 type:BIT→bool、BYTE→byte、INT16→short、UINT16→ushort、INT32→int、UINT32→uint、FLOAT→float、DOUBLE→double、STR→string。
  • 额外属性 autoCreateFile="true":文件不存在时自动按默认值创建。
    • 未开 autoCreateFile 时:ReadAsync 静默跳过(不改值),WriteAsync 静默 no-op(不写文件、不报错)。
    • 1.0 起只认小驼峰 autoCreateFile;旧写法 AutoCreateFile 已移除,仍写旧名会在加载期抛 TagsProjectConfigurationException(消息里给出迁移提示)。
  • 自定义类型:
builder.AddSimpleFilesChannel()
       .AddSimpleFilesDirectTagBuilder(
           configure: b => b.WithJsonTagFactory<MyJson>(),          // 用 System.Text.Json 序列化
           predicate: b => b.TagDescriptor.TagKind == "MyJson")
       .AddSimpleFilesDirectTagBuilder(
           configure: b => b.WithFactory<MemInfo>((d, ch, c) => new MemInfoTag(d, ch, c)),
           predicate: b => b.TagDescriptor.TagKind == "MemInfo");

自定义类型继承 SimpleFilesDirectTagBase<T>,实现 ParseValue(string) / FormatValue(T?) 即可;基类已帮你把 NormalizedAddress 规范化成真实路径。

  • JSON 测点的 AOT / 裁剪要求:这些宿主下 System.Text.Json 默认关闭反射序列化,必须登记源生成元数据,否则构建测点时抛 TagsProjectNotSupportedException:
[JsonSerializable(typeof(MyJson))]
internal partial class MyJsonContext : JsonSerializerContext { }

builder.AddSimpleFilesChannel()
       .AddJsonSerializerContext(MyJsonContext.Default)     // AOT/裁剪宿主必需;本地 JIT 可省
       .AddSimpleFilesDirectTagBuilder(
           configure: b => b.WithJsonTagFactory<MyJson>(),
           predicate: b => b.TagDescriptor.TagKind == "Json");

普通 JIT 宿主不登记即可(退回反射序列化)。登记了上下文但没包住该类型 → TagsProjectConfigurationException(配置缺项,不是宿主能力问题)。


6. 任务配方(可直接抄)

R1 写一个逻辑组件

using StdUnit.Tags;

public sealed class HeartBeatLogicet : LogicetBase
{
    private readonly ITag _req;
    private readonly ITag _ack;

    public HeartBeatLogicet(ITagsProject project) : base(project)
    {
        // 构造期解析一次,之后每轮复用(此时项目已加载完通道与测点)
        _req = this.Tags.SelectTag("IoBox/PLC/心跳请求");
        _ack = this.Tags.SelectTag("IoBox/MST/心跳响应");
    }

    public override int Order => 100;                     // 升序执行,越小越先
    public override bool MatchEntry(ITagGrp entry) => entry.TagName() == "IoBox";

    public override Task ProcessAsync(ITagGrp entry, ITagChannel? thisChannel)
    {
        // 本轮已 ReadAsync 完成;这里赋值会标记 dirty,本轮结束前自动刷写
        _ack.Value = _req.GetTagValue<bool>();
        return Task.CompletedTask;                        // 必须尽快返回
    }
}

R2 注册 + 启动(BackgroundService 形态)

// Program.cs
builder.Services.AddTagsProjectServices(b => b.AddS7Support());
builder.Services.AddHostedService<MyTagService>();

// MyTagService.cs
public sealed class MyTagService : BackgroundService
{
    private readonly ITagsProjectCtrl _ctrl;
    public MyTagService(ITagsProjectCtrl ctrl) => _ctrl = ctrl;

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        var dir = Path.Combine(AppContext.BaseDirectory, "Tags");   // 内含 index.xml
        stoppingToken.Register(() => _ctrl.StopAsync());

        await _ctrl.StartPollAsync(dir, root: null, hook: (proj, sp, ct) =>
        {
            proj.Logicets.Add(new HeartBeatLogicet(proj));

            proj.RunnerStarted += (grp, ch) => { /* 日志 */ return Task.CompletedTask; };
            proj.RunnerCrashed += (grp, ch, ex) =>
            {
                // 这里再抛异常 ⇒ 该入口停机!
                _logger.LogError(ex, "入口 {g} 异常", grp.TagName());
                return Task.CompletedTask;
            };
            return Task.CompletedTask;
        });
    }
}

⚠️ 1.0 起 StopAsync() 会等待轮询循环退出(有界超时),await StopAsync() 之后重启是安全的;只有它超时告警(驱动不响应取消)时,才需要额外 await 上一次 StartPollAsync 返回的 Task。

R3 从外部读值

var tag = proj.Tags.SelectTag("IoBox/PLC/状态码");   // 建议缓存,不要每轮 Select
short v = tag.GetTagValue<short>();

R4 从外部写值(唯一正确姿势)

// 入队成功返回 true;失败返回 false 且 out task 会被置为异常(IntentWrittenException)
var ok = proj.WriteIntent("IoBox", (entry, ct) =>
{
    ack.Value = true;                     // 到达这里时,一定是该入口的轮询线程在跑
    return ValueTask.CompletedTask;       // 尽早返回
}, out var task);

if (!ok) { /* 队列满 */ }
await task;                               // 可选:等待该意图真正执行完

R5 订阅测点变化(Rx / R3)

using StdUnit.Tags.Rx;   // 或 using StdUnit.Tags.R3;

tag.ObserveOnRead()      // IObservable<EventPattern<ITag, TagSyncEventArgs>>(R3 版是 Observable<TagSyncEventArgs>)
   .Subscribe(e => Console.WriteLine(e.EventArgs.NewValue));

tag.ObserveOnWritten().Subscribe(...);
tag.Watch(startWithCurrent: true).Subscribe(...);   // = read.Merge(written),并补一个当前值的初始事件

订阅回调里不要直接给 tag.Value 赋值,要走 WriteIntent。

R6 以 DLL 插件方式加载逻辑

AOT / 裁剪宿主不适用(运行时没有动态加载程序集的能力):改用 R1 的宿主内注册 TryAddLogicet<TLogicet>(),否则加载期抛 TagsProjectNotSupportedException。见 7.5。

<Logicet>Samples.Plugin1/Samples.Plugin1.dll</Logicet>
  • 相对路径拼到 ProjectRoot。
  • 加载 public、非抽象、非泛型的 ILogicet 实现。
  • 想控制实例化:注册自己的 ILogicetCreator(StdUnit.Tags.Logicets);不注册则回退 ActivatorUtilities.CreateInstance(sp, type, project)。
  • 默认共享类型:ILogicet ITagsProject ITagChannel ITagGrp IServiceProvider IServiceCollection ILogger;可在 LogicetLoadOptions.SharedTypesFilter 里扩展。
  • 插件的依赖解析会检索对应 dll 所在目录,建议每个插件有自己的文件夹来隔离依赖,防止 dll 依赖相互污染。

R7 把项目暴露给 AI(MCP)

services.AddMcpServer()
        .WithHttpTransport(o => o.Stateless = true)
        .AddTagsMcp();

app.MapMcp("/mcp");

暴露的工具(TagsMcpServerTools):

工具签名
DescribeProject()返回项目 XML 原文(AI 应先调它)
ReadTagValue(path)TagValue(Descriptor, Value, Timestamp, Error?)
ReadTagValues(paths[])逐个返回,错误写在 Error 里而不抛
WriteTagValue(path, value, waitForCompletion = true)内部转成 WriteIntent;RO 会被拒绝
WriteTagValues(Dictionary<string,string>, waitForCompletion = true)按入口分组,一个入口一个意图
  • path 形如 顶层组/子组/.../组合名/测点名(/ 分隔)。
  • 另有 MCP Resource docs://StdUnit.tags/tool_guide(markdown),讲解项目 XML 结构与路径约定。

R8 自定义驱动(扩展点)

  1. ITagChannelFactory:按 TagChannelDescriptor.Driver 创建 ITagChannel;用 composite.AddFactory(...) 挂上。
  2. TagBuilderBase(直接测点)/ TagCbntBuilderBase(组合测点):用 composite.AddDirectTagBuilder<TBuilder>(driver, configure, predicate) / AddTagsCbntBuilder<TBuilder>(...) 挂上。
  3. TagsProjectServiceBuilder 上加一个 .AddXxxSupport() 扩展方法收口(照抄 S7/ModbusTcp)。
  4. 需要 XSD 提示就实现 ITagsProjectSchemaProvider 并注册为 Singleton。

7. 陷阱、死锁与故障排查

7.1 死锁(环路等待)

(循环内) Logicet 收到 req,发事件给外部,然后 await 外部处理完成
(循环外) 收到事件 → 干活 → WriteIntent(...) → await 该意图执行完成
(循环内) 外部完成后才 ack —— 但循环内正卡在 await 上,LOOP 无法进入下一轮
         ⇒ 意图永远不被执行 ⇒ 死锁

正确做法(按推荐度排序):

  1. 首选:把状态变更全部放进 Logicet 内部,外部只观察事件,不写意图。
  2. 若外部必须写意图,则内部不要同步等待外部:用 _ = Task.Run(async () => { ... await WriteIntent... await task; }) 把等待挪出轮询线程(并自行处理异常与去重标志)。

7.2 性能 / 阻塞类

症状原因处理
某个逻辑组件长时间不执行前面某个 ProcessAsync / 意图阻塞让它们尽早返回;慢活丢到 Task.Run
CPU 空转scanInterval="0" 且轮询策略为 AdaptivePollDelayStrategy ⇒ 每轮 Task.Yield()给合理 scanInterval
禁用(isEnabled="false")的入口仍占一个循环禁用分支是 Task.Delay(500) + finally 里的 scanInterval 延时不需要就别配这个入口
往被禁用的组/组合下写意图,设备侧毫无动静轮末刷写同样受 isEnabled 门控:值只停在缓存(脏标记保留),重新使能后才刷下去要立刻写就显式调 WriteAsync(ct)(扩展方法,不受门控),或别禁用那棵子树
崩溃后长时间不恢复默认重试策略是指数/Logistic 退避(500ms → 30s)注册自定义 ITagGrpRunnerRetryStrategy

7.3 配置类

症状原因
抛 "未找到名称为 {x} 的通道"channel= 写了不存在的通道名
抛 "未注册驱动名={d}的ITagChannelFactory实现!"忘了 AddXxxSupport()
抛 "未注册相应的TagCbntBuilder: ..." / NotImplementedException("未注册相应的 DirectTagBuilder: ...")该驱动不支持这种 TagCbnt / 直接 Tag
抛 "相关测点未配置通道 : Tag({name})"整条祖先链上都没有 channel
抛 KeyNotFoundExceptionWriteIntent 的入口名不是真实入口
await task 抛 IntentWrittenException意图入队失败(队列满,IntentCapacity 太小)
抛 "通道集为空" / "未配置入口测点组"RunAsync 前置条件不满足
FileNotFoundExceptionroot == null 但 {dir}/index.xml 不存在
抛 TagsProjectValidationException(XSD 用派生类型 TagsProjectSchemaException)校验器失败;Errors 里有逐条消息(XSD 或跨引用)
抛 "当前测点项目已经启动!"重复 StartPollAsync;用 OnStartingException 决定是否吞掉

7.4 其它易错点

  • ITag.Channel 是只读属性(可能为 null);ITagGrp.Channel / ITagCbnt.Channel 可读写。
  • IsScanned:1.0 由 IsScaned 改名(旧名已移除,编到旧名会直接编译不过)。注意它是"是否已被扫描"而非"是否启用"。同理,加载期异常族在 1.0 收敛为 TagsProjectLoadException → TagsProjectXmlException / TagsProjectAddressException / TagsProjectConfigurationException / TagsProjectValidationException(TagsProjectSchemaException 是最后一者的派生类型);2.0 起另有 TagsProjectNotSupportedException(配置合法、但当前宿主缺能力,见 7.5)。
  • endian / access 的枚举大小写敏感(LittleEndian、R1W);而 isEntry / isEnabled 是大小写不敏感。
  • TagDescriptor.NormalizedAddress 不持久化,默认回落 RawAddress;驱动在构建期会填好它,读地址请用 tag.NormalizedAddress()。
  • EnableXmlSchemaValidation() + 无命名空间的老 XML ⇒ 直接失败。要省事就别开它。

7.5 AOT / 裁剪

  • 各包就绪情况:StdUnit.Tags.Core / StdUnit.Tags / R3Extensions / McpServer 与各驱动包(含 SimpleFiles)都标了 IsAotCompatible;RxExtensions(System.Reactive)与 BlazorLib.Core(Blazor Server)不支持。
  • <Logicet> 动态插件不可用:NativeAOT 运行时没有动态加载程序集的能力(No dynamic loading),加载期抛 TagsProjectNotSupportedException;改用 TryAddLogicet<TLogicet>()(R1),或把插件放进独立进程。与"插件 dll 是否被裁剪"无关。
  • SimpleFiles 的 JSON 测点:必须 AddJsonSerializerContext(MyJsonContext.Default) 登记源生成元数据(见 5.7)。未登记 → 构建测点抛 TagsProjectNotSupportedException;登记了但缺该类型 → TagsProjectConfigurationException。普通 JIT 宿主两者都不需要。
  • PublishAot=true 会改变 dotnet run:SDK 把它写进 runtimeconfig.json(RuntimeFeature.IsDynamicCodeSupported=false),所以 IL 构建 + JIT 运行也会按 AOT 宿主的行为走(<Logicet> 照样报错)。不打算 AOT 的宿主别让它变 true——尤其当心依赖包在 buildTransitive 里带上它。
  • IsAotCompatible 只说明本包代码干净:第三方依赖的 AOT 告警要到 dotnet publish 才现形(已知:McMaster.NETCore.Plugins 报 IL2104/IL3053,可忽略)。
  • 验证方式:仓库的 samples/AotSmokeTest.LogicetPlugins 与 samples/AotSmokeTest.SimpleFiles 会在 CI 里真的 publish 原生二进制并运行;细节见 AOT 支持。

8. 需求 → API 索引

我想……用这个
注册整个框架services.AddTagsProjectServices(b => ...)
加某种硬件支持b.AddS7Support() / AddModbusTcpSupport() / AddOpcUaClientSupport() / AddHjzkSupport() / AddZLanTcpSupport() / AddComScannerSupport() / AddSimpleFilesSupport()
跑一个项目(一键)ITagsProjectCtrl.StartPollAsync(dir, root, hook)
手工构建项目ITagsProjectFactory.Create(dir, root) 或 sp.MakeProject(dir, root)
手工运行项目ITagsProject.RunAsync(ct)
停止项目ITagsProjectCtrl.StopAsync()
找入口/组合/测点grp.ScanEntries() / SelectGrp / SelectCbnt / SelectTag / Descendant
想知道某测点属于哪个入口tag.SearchEntry()
读值tag.Value 或 tag.GetTagValue<T>()
循环内写值tag.Value = x;(同轮末自动刷写)
循环外写值proj.WriteIntent(entry, intent, out var task)
手动点动被禁用的测点ReadAsync(ct) / WriteAsync(ct)(扩展方法,等价 TraversalMode.IgnoreEnabled,不受 isEnabled 影响)
让一次读写按 isEnabled 短路ReadAsync(TraversalMode.RespectEnabled, ct) / WriteAsync(TraversalMode.RespectEnabled, ct)
等待意图完成await task
事件钩子proj.RunnerStarted / proj.RunnerCrashed / tag.OnTagRead / tag.OnTagWritten
响应式订阅StdUnit.Tags.Rx 或 StdUnit.Tags.R3 的 ObserveOnRead/ObserveOnWritten/Watch
逻辑组件里拿通道与测点logicet.Project.Channels / logicet.Project.Tags;继承 LogicetBase 时可直接用 this.Channels / this.Tags
动态插件XML <Logicet>xxx.dll</Logicet> + 实现 ILogicet
多项目并行AddKeyedTagsProjectCtrl("k") + GetRequiredKeyedService<ITagsProjectCtrl>("k")
给 AI 操作AddMcpServer().WithHttpTransport(...).AddTagsMcp() + app.MapMcp("/mcp")
遍历整棵树TagUnionTraverser / TagTraverser / TagUnion.Accept(visitor)
自定义驱动实现 ITagChannelFactory + TagBuilderBase/TagCbntBuilderBase,再包一个 AddXxxSupport()
自定义轮询/重试策略实现 ITagGrpRunnerPollDelayStrategy / ITagGrpRunnerRetryStrategy / ITagGrpRunnerDisconnectStrategy 并注册到 DI
校验收紧b.EnableXmlSchemaValidation();b.AddValidation<T>()