StdUnit.Tags —— 给 AI 的参考手册
本文件的定位:一份可以整篇丢给 AI 的"事实清单 + 配方"。 教程
chapter_1~chapter_14讲"为什么这么设计",本文件只讲"怎么写才编译得过、跑得起来、跑得对"。 二者冲突时以本文件为准(本文件按dev分支源码逐条核对)。
已核对并纠正的常见错误(旧文档/教程/记忆中可能写错):
| 错误写法 | 正确写法 |
|---|---|
根元素 <root> | XSD 定义的是 <Project>(运行期不校验根元素名,但写 XSD 时必须是 <Project>) |
builder.AddComScanner() | builder.AddComScannerSupport() |
枚举 TagAccess / Endian | TagAccessMode / EndianKinds |
ITagGrp.IsDirty 属性 | IsDirty() 是方法(ITag.IsDirty / ITagCbnt.IsDirty 才是属性) |
"ITagCbnt 只能嵌套 ITagGrp" | ITagCbnt 的成员是 ITag(ITagCbntor) |
TagDescriptor.TagAccess | TagDescriptor.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 测点树) |
目录
- 0. 30 秒速览
- 1. 铁律(生成代码前必读)
- 2. 包、命名空间、依赖注入
- 3. 核心抽象与 API
- 4. XML 项目描述参考
- 5. 驱动参考
- 6. 任务配方(可直接抄)
- 7. 陷阱、死锁与故障排查
- 8. 需求 → API 索引
章节编号约定:主线教程与「驱动专题」按加入顺序编号(驱动专题从 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),让变更在下一轮被串行执行。 |
| M2 | ProcessAsync 与 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)。 |
| M8 | ITagGrp 的脏标记是方法 IsDirty();ITag / ITagCbnt 的是属性 IsDirty。别写错。只有脏的测点才会被刷写,写完自动清脏(实战要点见 Chapter 11)。 |
| M9 | StartPollAsync 直到项目停止才返回。StopAsync() 自 1.0 起会先等待轮询循环退出(有界,默认 30s)再释放项目、断开通道、触发 StartedOrStopped(false),所以 await StopAsync() 之后即可安全重启;等待超时(驱动不响应取消)时会记 Warning 并继续清理,那种情况下仍需自己 await 上一次 StartPollAsync 的 Task。 |
| M10 | IntentCapacity 必须 > 0(默认 5),且只能在 RunAsync 之前设置。 |
| M11 | WriteIntent 的 entry 必须是真实存在的入口名,否则抛 KeyNotFoundException。 |
| M12 | EnableXmlSchemaValidation() 是opt-in;跨引用校验(4 个代码校验器)在 UseDefaults=true(默认)下自动开启。 |
| M13 | isEnabled 只作用于自动轮询(TraversalMode.RespectEnabled,自顶向下短路整棵子树)。手动读写(ReadAsync(ct) / WriteAsync(ct),含外部意图里的显式读写)不检查使能,等价 TraversalMode.IgnoreEnabled——"平时禁用、需要时手动点动"是刻意支持的用法,别把它当 bug 去"修"。 |
| M14 | AOT / 裁剪宿主(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 / R3Extensions | ITag 的响应式扩展(只依赖 Core) |
StdUnit.Tags.McpServer | 把测点项目暴露成 MCP Server 给 AI 用 |
StdUnit.Tags.BlazorLib(.Core) | Blazor 监控界面 |
StdUnit.Tags.Templates | dotnet 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.Projects | ITagChannelsLoader、ILogicetsLoader、LoadedLogicets |
StdUnit.Tags.Logicets | ILogicetCreator、TagProjectExtensions.TryAddLogicet |
StdUnit.Tags.Rx | TagRxExtensions |
StdUnit.Tags.R3 | TagR3Extensions |
StdUnit.Tags.SimpleFiles | SimpleFilesTagChannel、SimpleFilesDirectTagBuilder、SimpleFilesDirectTagBase<T>、JsonDirectTag<T> …… |
StdUnit.Tags.McpServer | TagsMcpServerTools、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) |
|---|---|---|
ITagGrpRunnerPollDelayStrategy | TimeSpan GetDelay(TimeSpan scanInterval, TimeSpan elapsed) | AdaptivePollDelayStrategy:elapsed >= scanInterval ? Zero : scanInterval - elapsed |
ITagGrpRunnerRetryStrategy | TimeSpan GetDelay(int consecutiveFailureCount) | DefaultTagGrpRunnerRetryStrategy:logistic,Min 500ms → Max 30s(GrowthRate 0.8,Midpoint 5) |
ITagGrpRunnerDisconnectStrategy | Task 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)
| 校验器 | 检查什么 | 默认 |
|---|---|---|
TagsProjectSchemaValidator | XSD(核心 + 各驱动) | 关(EnableXmlSchemaValidation() 开启) |
ChannelCrossReferenceValidator | 每个 channel= 必须指向已声明的 <Channel> | 开 |
ChannelDriverFactoryValidator | driver= 必须已注册工厂 | 开 |
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 | 继承 | 不写则向上冒泡 |
isEntry | false | 只有大小写不敏感的 "true" 才算 |
isEnabled | true | 除 "false" 外都算 true;只作用于自动轮询(自顶向下短路整棵子树),手动读写不受影响(见 3.2) |
scanInterval | 继承 | 毫秒;解析失败抛 ArgumentException("{name}的扫描周期无法解析成整数,它应该是一个毫秒数量") |
access | 继承 | RW/RO/R1W/WO |
子元素:TagGrp | TagCbnt | Tag,任意顺序、任意个数。
4.4 <TagCbnt>
| 属性 | 默认 | 说明 |
|---|---|---|
name | 必填 | |
address | "" | 组合起始地址(驱动相关) |
channel | 继承 | |
isEnabled | true | 只作用于自动轮询,语义同 <TagGrp> |
access | 继承 | |
其它(slave、area、……) | — | 进入 Extras,由驱动读取 |
子元素:只能是 Tag。
4.5 <Tag>
| 属性 | 默认 | 说明 |
|---|---|---|
name | 必填 | 缺失抛 "Tag 未配置名称" |
address | "" | 驱动相关地址 |
type | Unknown | 自由字符串(不校验,便于插件自定义类型) |
endian | LittleEndian | 大小写敏感:LittleEndian / BigEndian,非法抛 "Tag(Name=x) 配置了未知的字节序=y" |
access | 继承(最终 RW) | RW/RO/R1W/WO,非法抛 "Tag(Name=x) 配置了未知的访问模式=y" |
channel | 继承 | |
note | null | 备注 |
tagSize | 0 | 非法抛 "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 命名空间 |
|---|---|---|---|
S7 | StdUnit.Tags.S7 | AddS7Support() | tags:s7 |
ModbusTcp | StdUnit.Tags.ModbusTcp | AddModbusTcpSupport() | tags:modbustcp |
HjzkIoBox | StdUnit.Tags.Hjzk | AddHjzkSupport() | tags:hjzk |
ZLanTcp | StdUnit.Tags.ZLan | AddZLanTcpSupport() | tags:zlan |
OpcUaClient | StdUnit.Tags.OpcUaClient | AddOpcUaClientSupport() | tags:opcuaclient |
COM | StdUnit.Tags.ComScanner | AddComScannerSupport() | tags:com |
SimpleFiles | StdUnit.Tags.SimpleFiles | AddSimpleFilesSupport() | 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 可用 type | TagSize |
|---|---|---|---|
位空间(ModbusBitTagCbntBuilder,缓存 bool[]) | 0x / 1x | DI(必须是 1x)、DO(必须是 0x) | 1 |
寄存器空间(ModbusRegisterTagCbntBuilder,缓存 ushort[]) | 3x / 4x | BIT BYTE INT16 UINT16 INT32 UINT32 FLOAT INT64 UINT64 | 2 / 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 管什么 | BigEndian | LittleEndian(默认) | interpret |
|---|---|---|---|---|
INT16 UINT16(1 寄存器) | 寄存器内两字节顺序 | 直取寄存器值 | 交换寄存器内两字节 | 不允许 |
INT32 UINT32 FLOAT(2 寄存器) | 每个寄存器内两字节顺序 | 单元内高位在前 | 单元内低位在前 | 可选,4 个字符 |
INT64 UINT64(4 寄存器) | 每个寄存器内两字节顺序 | 单元内高位在前 | 单元内低位在前 | 可选,8 个字符 |
BYTE | 取寄存器哪一半 | 高字节 | 低字节 | 不允许 |
BIT DI DO | 与端序无关 | — | — | 不允许 |
- 多字节数值测点(
INT16UINT16INT32UINT32FLOATINT64UINT64)应显式写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) | true | TagsProjectXmlException:通道配置的 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)。 - 默认共享类型:
ILogicetITagsProjectITagChannelITagGrpIServiceProviderIServiceCollectionILogger;可在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 自定义驱动(扩展点)
ITagChannelFactory:按TagChannelDescriptor.Driver创建ITagChannel;用composite.AddFactory(...)挂上。TagBuilderBase(直接测点)/TagCbntBuilderBase(组合测点):用composite.AddDirectTagBuilder<TBuilder>(driver, configure, predicate)/AddTagsCbntBuilder<TBuilder>(...)挂上。TagsProjectServiceBuilder上加一个.AddXxxSupport()扩展方法收口(照抄 S7/ModbusTcp)。- 需要 XSD 提示就实现
ITagsProjectSchemaProvider并注册为 Singleton。
7. 陷阱、死锁与故障排查
7.1 死锁(环路等待)
(循环内) Logicet 收到 req,发事件给外部,然后 await 外部处理完成
(循环外) 收到事件 → 干活 → WriteIntent(...) → await 该意图执行完成
(循环内) 外部完成后才 ack —— 但循环内正卡在 await 上,LOOP 无法进入下一轮
⇒ 意图永远不被执行 ⇒ 死锁
正确做法(按推荐度排序):
- 首选:把状态变更全部放进
Logicet内部,外部只观察事件,不写意图。 - 若外部必须写意图,则内部不要同步等待外部:用
_ = 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 |
抛 KeyNotFoundException | WriteIntent 的入口名不是真实入口 |
await task 抛 IntentWrittenException | 意图入队失败(队列满,IntentCapacity 太小) |
抛 "通道集为空" / "未配置入口测点组" | RunAsync 前置条件不满足 |
FileNotFoundException | root == 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>() |