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>()