# 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` |
| "`isEnabled="false"` 会让这棵子树不能读写" | `isEnabled` 只门控**自动轮询**；手动读写照常执行（见 [3.2 测点树](#32-测点树)） |

---

## 目录

- [0. 30 秒速览](#0-30-秒速览)
- [1. 铁律（生成代码前必读）](#1-铁律生成代码前必读)
- [2. 包、命名空间、依赖注入](#2-包命名空间依赖注入)
- [3. 核心抽象与 API](#3-核心抽象与-api)
- [4. XML 项目描述参考](#4-xml-项目描述参考)
- [5. 驱动参考](#5-驱动参考)
- [6. 任务配方（可直接抄）](#6-任务配方可直接抄)
- [7. 陷阱、死锁与故障排查](#7-陷阱死锁与故障排查)
- [8. 需求 → API 索引](#8-需求--api-索引)

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

---

## 0. 30 秒速览

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

**静态结构**：

```text
测点项目 ITagsProject = 通道 ITagChannel + 测点树 ITagGrp + 逻辑 ILogicet
```

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

**运行机制**（每个**入口** `isEntry="true"` 的 `TagGrp` 分配一个 `ITagGrpRunner`，多个入口**并行**，入口内部**严格串行**）：

```text
每轮 (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 测点树](#32-测点树)）。

---

## 1. 铁律（生成代码前必读）

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

| # | 规则 |
|---|---|
| **M1** | **不要在循环外部直接给 `tag.Value` 赋值**。必须走 `proj.WriteIntent(entryName, intent, out var task)`，让变更在下一轮被串行执行。 |
| **M2** | **`ProcessAsync` 与 `TagGrpWriteIntent` 必须尽快返回**。它们跑在串行轮询里，阻塞会拖垮整个入口。要干慢活 → `Task.Run` 或丢给外部。 |
| **M3** | **禁止"外部等意图、内部等外部"的环路等待**（死锁）。详见 [7.1](#71-死锁环路等待)。 |
| **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](../驱动专题/chapter_11——SimpleFiles.md)）。 |
| **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](#75-aot--裁剪)。 |

---

## 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 使用它们**。

### 注册（唯一推荐写法）

```csharp
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();
});
```

```csharp
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

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

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

---

## 3. 核心抽象与 API

### 3.1 通道

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

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

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

### 3.2 测点树

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

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

```csharp
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` 的封闭联合：

```csharp
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 取值 / 赋值 / 其它扩展

```csharp
// 取值（硬转换，类型必须完全匹配）
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 逻辑组件

```csharp
public interface ILogicet
{
    int Order { get; }                                        // 升序执行
    IReadOnlyList<ITagChannel> Channels { get; }
    ITagGrp Tags { get; }
    bool Enabled { get; }
    bool MatchEntry(ITagGrp entry);                           // 是否参与该入口
    Task ProcessAsync(ITagGrp entry, ITagChannel? thisChannel);
}

public abstract class LogicetBase : ILogicet
{
    protected LogicetBase(IReadOnlyList<ITagChannel> channels, ITagGrp 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` 即可；`LogicetBase` 只是省掉几个属性。
- `MatchEntry` 返回 `true` 的逻辑组件会在**每个**入口的每一轮里都执行。
- `thisChannel` 是入口的**主通道**（`entry.SearchChannel()`），辅通道请自己从 `entry` 解析。

### 3.6 运行器与策略

```csharp
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 项目与启停

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

```csharp
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 描述符与枚举

```csharp
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](../驱动专题/chapter_11——SimpleFiles.md)） |
| `RO` | 每轮 | 永不写 | |
| `WO` | 永不读 | 脏才写 | |

**`TagSize` 语义**：0 表示未指定。字节型驱动（S7）按字节数；寄存器型驱动（Modbus）按**字节**数填（BIT/BYTE/INT16/UINT16 → 2，INT32/UINT32/FLOAT → 4，INT64/UINT64 → 8）。

### 3.9 校验器

```csharp
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

```xml
<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](#32-测点树)） |
| `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
<?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>
```

对应的取值代码：

```csharp
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 驱动专题](../驱动专题/chapter_12——S7.md)。

**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 驱动专题](../驱动专题/chapter_14——ModbusTcp.md)。

**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` | 与端序无关 | — | — | 不允许 |

- 多字节数值测点（`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 驱动专题](../驱动专题/chapter_13——OpcUa.md)。

**Channel 子元素**（`tags:opcuaclient`）：

```xml
<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`）：

```csharp
// 更严：把 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 驱动专题](../驱动专题/chapter_11——SimpleFiles.md)。

**Channel 子元素**（`tags:simplefiles`）：`<sf:BaseDir>`（相对/绝对目录；为空则地址原样使用）。

```xml
<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`（消息里给出迁移提示）。
- 自定义类型：

```csharp
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`：

```csharp
[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 写一个逻辑组件

```csharp
using StdUnit.Tags;

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

    public HeartBeatLogicet(IReadOnlyList<ITagChannel> channels, ITagGrp tags) : base(channels, tags)
    {
        // 构造期解析一次，之后每轮复用
        _req = tags.SelectTag("IoBox/PLC/心跳请求");
        _ack = 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` 形态）

```csharp
// 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.Channels, proj.Tags));

            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 从外部读值

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

### R4 从外部写值（唯一正确姿势）

```csharp
// 入队成功返回 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）

```csharp
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](#75-aot--裁剪)。

```xml
<Logicet>Samples.Plugin1/Samples.Plugin1.dll</Logicet>
```

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

### R7 把项目暴露给 AI（MCP）

```csharp
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 死锁（环路等待）

```text
(循环内) 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` |
| 抛 `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](#75-aot--裁剪)）。
- `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](#57-simplefiles测点即文件)）。未登记 → 构建测点抛 `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 支持](../部署专题/AOT支持.md)。

---

## 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` |
| 动态插件 | 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>()` |
