# Driver-typed Tag Editors (F-uns-1) Implementation Plan

> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans (or subagent-driven-development) to implement this plan task-by-task.

**Goal:** Replace the UNS `TagModal`'s single generic raw-JSON `TagConfig` textarea with per-driver typed editors that dispatch on the selected driver's `DriverType` (typed for Modbus/S7/AbCip/AbLegacy/TwinCAT/Focas; generic textarea for OpcUaClient/Galaxy/Historian.Wonderware).

**Architecture:** Each typed driver gets a small Razor editor component under `Components/Shared/Uns/TagEditors/` that is a thin shell over a pure, unit-testable model with `FromJson`/`ToJson`/`Validate`. A static `TagConfigEditorMap` (`DriverType → Type`, mirroring `DriverEditRouter._componentMap`) drives a `<DynamicComponent>` dispatch in `TagModal`. The editors reuse the drivers' enums + camelCase JSON property names (driver pages untouched) and preserve unrecognised JSON keys across a load→save. No data-model or migration change.

**Tech Stack:** .NET 10 Blazor Server (`InteractiveServer`), `System.Text.Json` (incl. `JsonObject`), xUnit + Shouldly (no bUnit), docker-dev rig for live verify.

**Design doc:** `docs/plans/2026-06-09-driver-typed-tag-editors-design.md`

**Hard rules (every task):** AdminUI-only, no Configuration entity/migration change; driver pages NOT modified; stage by path (never `git add .`); never stage `sql_login.txt` / `src/Server/ZB.MOM.WW.OtOpcUa.Host/pki/`; never echo the gateway API key into a new tracked file; no force-push, no `--no-verify`.

---

### Task 1: Surface DriverType to the TagModal driver dropdown

**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** none (foundational — Task 2 depends on it)

**Files:**
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/IUnsTreeService.cs` (the `LoadTagDriversForEquipmentAsync` signature)
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsTreeService.cs` (impl ~line 707-734)
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Uns/GlobalUns.razor` (the `_tagModalDriverOptions` field + its two assignment sites in `HandleAddChild` and `HandleEdit`)
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagModal.razor` (the `Drivers` `[Parameter]` type + the driver `<InputSelect>` loop)
- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeServiceTagDriversTests.cs` (new; or extend an existing `*Tests.cs` in that folder)

**Context:** Today `LoadTagDriversForEquipmentAsync` returns `IReadOnlyList<(string DriverInstanceId, string Display)>`. The TagModal needs the driver's `DriverType` to choose an editor. Change the tuple to carry it. The service already loads `DriverInstance` rows — just project `DriverType` too (see `UnsTreeService.cs:725-733`).

**Step 1 — Write the failing test.** In the new test file, using the in-memory EF pattern from the existing `tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/` suites (find one that seeds a cluster + Equipment-namespace driver, e.g. `EquipmentTests`/`TagTests`, and copy its `UnsTreeTestDb` setup): seed an equipment in a cluster that has one Equipment-namespace driver of `DriverType="ModbusTcp"`, call `LoadTagDriversForEquipmentAsync(equipmentId)`, and assert the returned item exposes `DriverType == "ModbusTcp"` (e.g. `result[0].DriverType.ShouldBe("ModbusTcp")`).

**Step 2 — Run it; expect compile failure** (`DriverType` member doesn't exist on the tuple).
Run: `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests --filter FullyQualifiedName~UnsTreeServiceTagDriversTests`

**Step 3 — Implement.**
- `IUnsTreeService.cs`: change the return type to `Task<IReadOnlyList<(string DriverInstanceId, string Display, string DriverType)>>`.
- `UnsTreeService.cs`: in the final projection add `d.DriverType`, and return `(d.DriverInstanceId, Display: $"{d.DriverInstanceId} — {d.Name}", d.DriverType)`.
- `GlobalUns.razor`: change `private IReadOnlyList<(string Id, string Display)> _tagModalDriverOptions = Array.Empty<(string, string)>();` to `(string Id, string Display, string DriverType)` (and the `Array.Empty<(string, string, string)>()` initialiser). The two assignment sites already call `await Svc.LoadTagDriversForEquipmentAsync(...)` so they compile unchanged.
- `TagModal.razor`: change `[Parameter] public IReadOnlyList<(string Id, string Display)> Drivers { get; set; } = Array.Empty<(string, string)>();` to the 3-tuple; the dropdown `@foreach (var (id, display) in Drivers)` becomes `@foreach (var (id, display, _) in Drivers)`.

**Step 4 — Run tests + build.**
Run: `dotnet build src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ZB.MOM.WW.OtOpcUa.AdminUI.csproj && dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests`
Expected: build clean; new test passes; existing 216 still pass.

**Step 5 — Commit** (stage the 4 source files + the test file by path).

---

### Task 2: Shared JSON util + TagConfigEditorMap + TagModal dispatch scaffold

**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** none (Task 3 depends on it)

**Files:**
- Create: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigJson.cs` (shared parse/serialize util)
- Create: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigEditorMap.cs` (the dispatch map — starts EMPTY)
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagModal.razor` (replace the raw `InputTextArea` block with the dispatch)
- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/TagConfigJsonTests.cs` (new)

**Context:** The shared util gives every editor a round-trip-safe way to read/write known fields while **preserving unrecognised keys** (mirrors how `ModbusTagRow` keeps its `_source` record). The map is the dispatch table; it starts empty so this task is a no-behaviour-change scaffold (everything still falls to the generic textarea).

**Step 1 — `TagConfigJson.cs`** (full):

```csharp
using System.Text.Json;
using System.Text.Json.Nodes;

namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;

/// <summary>
/// Helpers for per-driver tag-config editors: parse a TagConfig JSON string into a mutable
/// <see cref="JsonObject"/> (preserving every key, so fields the editor doesn't expose survive a
/// load→save), read typed scalars, and serialise back.
/// </summary>
public static class TagConfigJson
{
    private static readonly JsonSerializerOptions Compact = new() { WriteIndented = false };

    /// <summary>Parses <paramref name="json"/> into a mutable object; returns a fresh empty object on null/blank/malformed/non-object input.</summary>
    public static JsonObject ParseOrNew(string? json)
    {
        if (string.IsNullOrWhiteSpace(json)) { return new JsonObject(); }
        try { return JsonNode.Parse(json) as JsonObject ?? new JsonObject(); }
        catch (JsonException) { return new JsonObject(); }
    }

    /// <summary>Serialises the object to compact JSON.</summary>
    public static string Serialize(JsonObject obj) => obj.ToJsonString(Compact);

    /// <summary>Reads a string value, or null if absent/null.</summary>
    public static string? GetString(JsonObject o, string name)
        => o.TryGetPropertyValue(name, out var n) && n is not null ? n.GetValue<string>() : null;

    /// <summary>Reads an int value, or <paramref name="fallback"/> if absent/null/non-numeric.</summary>
    public static int GetInt(JsonObject o, string name, int fallback = 0)
        => o.TryGetPropertyValue(name, out var n) && n is not null && n.AsValue().TryGetValue<int>(out var v) ? v : fallback;

    /// <summary>Reads an enum by its serialised name, or <paramref name="fallback"/> if absent/unparseable.</summary>
    public static TEnum GetEnum<TEnum>(JsonObject o, string name, TEnum fallback) where TEnum : struct, Enum
        => GetString(o, name) is { } s && Enum.TryParse<TEnum>(s, ignoreCase: true, out var v) ? v : fallback;

    /// <summary>Sets a string/number/enum-name value (enums via ToString()). Null removes the key.</summary>
    public static void Set(JsonObject o, string name, object? value)
        => o[name] = value is null ? null : JsonValue.Create(value is Enum e ? e.ToString() : value);
}
```

**Step 2 — `TagConfigEditorMap.cs`** (starts empty; editors register here as later tasks land):

```csharp
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;

/// <summary>
/// Maps a driver's <c>DriverType</c> string to its typed tag-config editor component (mirrors
/// <c>DriverEditRouter._componentMap</c>). Drivers absent from the map fall back to the generic
/// raw-JSON textarea in the TagModal.
/// </summary>
public static class TagConfigEditorMap
{
    private static readonly IReadOnlyDictionary<string, Type> Map =
        new Dictionary<string, Type>(StringComparer.OrdinalIgnoreCase)
        {
            // Editors registered by later tasks, e.g.:
            // ["ModbusTcp"] = typeof(Components.Shared.Uns.TagEditors.ModbusTagConfigEditor),
        };

    /// <summary>Returns the editor component type for a driver type, or null if none is registered.</summary>
    public static Type? Resolve(string? driverType)
        => driverType is not null && Map.TryGetValue(driverType, out var t) ? t : null;
}
```

> Note on the registration-coupling: the `Map` dictionary literal is the single edit point for registering an editor. Tasks 4–8 deliberately do NOT touch it (so they're parallel-safe); Task 9 registers their five editors in one edit.

**Step 3 — TagModal dispatch.** In `TagModal.razor`, replace the current `Tag config (JSON)` block (the `<label> + <InputTextArea @bind-Value="_form.TagConfig" …>` + form-text + `ValidationMessage`) with:

```razor
<div class="mb-3">
    <label class="form-label">Tag config</label>
    @{
        var editorType = TagConfigEditorMap.Resolve(SelectedDriverType);
    }
    @if (string.IsNullOrEmpty(_form.DriverInstanceId))
    {
        <div class="form-text">Pick a driver above to configure this tag.</div>
    }
    else if (editorType is not null)
    {
        <DynamicComponent Type="editorType" Parameters="BuildEditorParameters()" />
    }
    else
    {
        <InputTextArea id="tag-config" @bind-Value="_form.TagConfig" rows="6"
                       class="form-control form-control-sm mono"
                       placeholder='{ "register": 40001, "scale": 0.1 }' />
        <div class="form-text">Schemaless per driver type. Validated server-side at deploy.</div>
    }
    <ValidationMessage For="@(() => _form.TagConfig)" />
</div>
```

Add to `TagModal.razor` `@code` (and `@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors`):

```csharp
// The DriverType of the currently-selected driver (drives editor dispatch). Null when no driver chosen.
private string? SelectedDriverType =>
    Drivers.FirstOrDefault(d => d.Id == _form.DriverInstanceId).DriverType;

private IDictionary<string, object> BuildEditorParameters() => new Dictionary<string, object>
{
    ["ConfigJson"] = _form.TagConfig,
    ["ConfigJsonChanged"] = EventCallback.Factory.Create<string>(this, v => _form.TagConfig = v),
};
```

> `Drivers.FirstOrDefault(...)` returns a default tuple (all-null) when nothing matches, so `.DriverType` is null — `Resolve(null)` returns null → generic textarea. Good.

**Step 4 — `TagConfigJsonTests.cs`:** `ParseOrNew` on null/""/"{bad"/"[1,2]" → empty object; round-trip preserves unknown keys (`{"foo":"bar","region":"X"}` → set `region` → still has `foo`); `GetEnum` parses by name + falls back; `Set` writes enum as its name string.

**Step 5 — Build + test** (216 + new pass; UI unchanged because the map is empty). **Commit** by path.

---

### Task 3: Modbus tag editor (the worked example)

**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** none (establishes the pattern Tasks 4–8 copy)

**Files:**
- Create: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/ModbusTagConfigModel.cs`
- Create: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/ModbusTagConfigEditor.razor`
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigEditorMap.cs` (register `ModbusTcp`)
- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/ModbusTagConfigModelTests.cs`

**Reference (read these, do not edit):** `ModbusDriverPage.razor:286-313` (the EditTemplate fields) and `:481-532` (`ModbusTagRow`); `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriverFactoryExtensions.cs:282-319` (`ModbusTagDto` — the canonical JSON names). Enums (`ModbusRegion`, `ModbusDataType`, `ModbusByteOrder`) are in namespace `ZB.MOM.WW.OtOpcUa.Driver.Modbus` (the driver page `@using`es it; the AdminUI csproj already references the Modbus contracts — confirm the `@using`/reference compiles).

**Fields the editor owns (→ TagConfig JSON, camelCase names):**

| UI field | Model type | JSON name | Default |
|---|---|---|---|
| Region | `ModbusRegion` | `region` | `HoldingRegisters` |
| Address | `int` | `address` | `0` |
| Data type (wire) | `ModbusDataType` | `dataType` | `Int16` |
| Byte order | `ModbusByteOrder` | `byteOrder` | `BigEndian` |
| Bit index | `int` | `bitIndex` | `0` |
| String length | `int` | `stringLength` | `0` |

Excluded (owned by TagModal/Tag columns): `name` (Tag.Name), `writable`/`writeIdempotent` (AccessLevel/WriteIdempotent). Enum values serialise as their NAME (e.g. `"HoldingRegisters"`) to match `ModbusTagDto` (its `Region`/`DataType`/`ByteOrder` are `string?`).

**Step 1 — Write `ModbusTagConfigModelTests.cs` (failing):**
- `FromJson("{}")` → defaults (Region `HoldingRegisters`, DataType `Int16`, ByteOrder `BigEndian`, Address 0).
- Round-trip: `FromJson(m.ToJson())` equals `m` for a populated model.
- `ToJson()` emits exactly the 6 camelCase keys with enum NAMES (assert the JSON string contains `"region":"HoldingRegisters"` etc.).
- **Preserve unknown:** `FromJson("""{"region":"InputRegisters","addressString":"40001:F"}""").ToJson()` still contains `addressString` (the editor must not drop keys it doesn't expose).
- `Validate()` returns null for a default model (Modbus needs no required free-text — address defaults 0). (If you add a rule, test it.)

**Step 2 — Run; expect compile failure.**

**Step 3 — Implement `ModbusTagConfigModel.cs`:** hold the 6 typed properties + a private `JsonObject _bag` (the parsed source, for key preservation). `FromJson(string?)` → `TagConfigJson.ParseOrNew`, read the 6 fields via `TagConfigJson.GetEnum/GetInt`, stash the bag. `ToJson()` → `TagConfigJson.Set` the 6 fields onto the bag, then `TagConfigJson.Serialize(bag)`. `Validate()` → null (or required-field checks). Example:

```csharp
using System.Text.Json.Nodes;
using ZB.MOM.WW.OtOpcUa.Driver.Modbus;

namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;

public sealed class ModbusTagConfigModel
{
    public ModbusRegion Region { get; set; } = ModbusRegion.HoldingRegisters;
    public int Address { get; set; }
    public ModbusDataType DataType { get; set; } = ModbusDataType.Int16;
    public ModbusByteOrder ByteOrder { get; set; } = ModbusByteOrder.BigEndian;
    public int BitIndex { get; set; }
    public int StringLength { get; set; }

    private JsonObject _bag = new();

    public static ModbusTagConfigModel FromJson(string? json)
    {
        var o = TagConfigJson.ParseOrNew(json);
        return new ModbusTagConfigModel
        {
            Region = TagConfigJson.GetEnum(o, "region", ModbusRegion.HoldingRegisters),
            Address = TagConfigJson.GetInt(o, "address"),
            DataType = TagConfigJson.GetEnum(o, "dataType", ModbusDataType.Int16),
            ByteOrder = TagConfigJson.GetEnum(o, "byteOrder", ModbusByteOrder.BigEndian),
            BitIndex = TagConfigJson.GetInt(o, "bitIndex"),
            StringLength = TagConfigJson.GetInt(o, "stringLength"),
            _bag = o,
        };
    }

    public string ToJson()
    {
        TagConfigJson.Set(_bag, "region", Region);
        TagConfigJson.Set(_bag, "address", Address);
        TagConfigJson.Set(_bag, "dataType", DataType);
        TagConfigJson.Set(_bag, "byteOrder", ByteOrder);
        TagConfigJson.Set(_bag, "bitIndex", BitIndex);
        TagConfigJson.Set(_bag, "stringLength", StringLength);
        return TagConfigJson.Serialize(_bag);
    }

    public string? Validate() => null;
}
```

**Step 4 — Implement `ModbusTagConfigEditor.razor`** — the thin shell. Parse on `OnParametersSet`, bind fields, raise `ConfigJsonChanged` on change:

```razor
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors
@using ZB.MOM.WW.OtOpcUa.Driver.Modbus

<div class="row g-2">
    <div class="col-md-4"><label class="form-label">Region</label>
        <select class="form-select form-select-sm" value="@_m.Region" @onchange="@(e => Update(() => _m.Region = Enum.Parse<ModbusRegion>(e.Value!.ToString()!)))">
            @foreach (var v in Enum.GetValues<ModbusRegion>()) { <option value="@v">@v</option> }
        </select></div>
    <div class="col-md-2"><label class="form-label">Address</label>
        <input type="number" class="form-control form-control-sm" value="@_m.Address" @onchange="@(e => Update(() => _m.Address = ParseInt(e.Value)))" /></div>
    <div class="col-md-3"><label class="form-label">Data type</label>
        <select class="form-select form-select-sm" value="@_m.DataType" @onchange="@(e => Update(() => _m.DataType = Enum.Parse<ModbusDataType>(e.Value!.ToString()!)))">
            @foreach (var v in Enum.GetValues<ModbusDataType>()) { <option value="@v">@v</option> }
        </select></div>
    <div class="col-md-3"><label class="form-label">Byte order</label>
        <select class="form-select form-select-sm" value="@_m.ByteOrder" @onchange="@(e => Update(() => _m.ByteOrder = Enum.Parse<ModbusByteOrder>(e.Value!.ToString()!)))">
            @foreach (var v in Enum.GetValues<ModbusByteOrder>()) { <option value="@v">@v</option> }
        </select></div>
    <div class="col-md-2"><label class="form-label">Bit index</label>
        <input type="number" class="form-control form-control-sm" value="@_m.BitIndex" @onchange="@(e => Update(() => _m.BitIndex = ParseInt(e.Value)))" /></div>
    <div class="col-md-2"><label class="form-label">String len</label>
        <input type="number" class="form-control form-control-sm" value="@_m.StringLength" @onchange="@(e => Update(() => _m.StringLength = ParseInt(e.Value)))" /></div>
</div>

@code {
    [Parameter] public string? ConfigJson { get; set; }
    [Parameter] public EventCallback<string> ConfigJsonChanged { get; set; }

    private ModbusTagConfigModel _m = new();

    protected override void OnParametersSet() => _m = ModbusTagConfigModel.FromJson(ConfigJson);

    private static int ParseInt(object? v) => int.TryParse(v?.ToString(), out var i) ? i : 0;

    private async Task Update(Action apply)
    {
        apply();
        await ConfigJsonChanged.InvokeAsync(_m.ToJson());
    }
}
```

> Use plain `<select>`/`<input>` + `@onchange` (not `@bind`) so each edit immediately pushes the serialised JSON up via `ConfigJsonChanged` — that's what the TagModal's `_form.TagConfig` binds to. (DynamicComponent can't forward `@bind-Value`, so we drive it through the `ConfigJsonChanged` callback explicitly.)

**Step 5 — Register Modbus** in `TagConfigEditorMap.Map`: `["ModbusTcp"] = typeof(Components.Shared.Uns.TagEditors.ModbusTagConfigEditor),`. **Build + test. Commit** by path.

---

### Tasks 4–8: S7 / AbCip / AbLegacy / TwinCAT / Focas tag editors

Each of these is the SAME shape as Task 3 (model + razor shell + tests), parameterised by the driver's fields below. **Do NOT edit `TagConfigEditorMap.cs`** (Task 9 registers all five at once, so these tasks stay parallel-safe). Reuse `TagConfigJson` and copy the Modbus model/editor/test structure exactly. All five drivers' enums live in the driver namespace listed; all serialise enum values as their NAME; all preserve unknown keys; all exclude `name`/`writable`/`writeIdempotent`.

Each task's **Files:** Create `Uns/TagEditors/<Driver>TagConfigModel.cs` + `Components/Shared/Uns/TagEditors/<Driver>TagConfigEditor.razor`; Test `tests/.../Uns/<Driver>TagConfigModelTests.cs`. Each task's tests mirror Task 3's (defaults, round-trip, JSON-name correctness, preserve-unknown, validate). Build + test + commit per task.

#### Task 4: S7
**Classification:** standard · **~4 min** · **Parallelizable with:** Tasks 5, 6, 7, 8
- `@using ZB.MOM.WW.OtOpcUa.Driver.S7`. Read `S7DriverPage.razor:339-381` (`S7TagRow`) + the S7 tag DTO in `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/`.
- Fields: `address` (string, required — `Validate` returns "Address is required." when blank), `dataType` (`S7DataType` enum, default the enum's first/`Int16`-equivalent — confirm against the enum), `stringLength` (int).
- Excluded: name, writable, writeIdempotent.

#### Task 5: AbCip
**Classification:** standard · **~4 min** · **Parallelizable with:** Tasks 4, 6, 7, 8
- `@using ZB.MOM.WW.OtOpcUa.Driver.AbCip`. Read `AbCipDriverPage.razor:425-467` + the AbCip tag DTO.
- Fields: `deviceHostAddress` (string), `tagPath` (string, required), `dataType` (`AbCipDataType` enum). (Do NOT add the `members`/`safetyTag` advanced fields — the driver page's tag EditTemplate doesn't expose them; preserve-unknown keeps any that exist.)
- Excluded: name, writable, writeIdempotent.

#### Task 6: AbLegacy
**Classification:** standard · **~4 min** · **Parallelizable with:** Tasks 4, 5, 7, 8
- `@using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy`. Read `AbLegacyDriverPage.razor:391-433` + the AbLegacy tag DTO.
- Fields: `deviceHostAddress` (string), `address` (string, required — PCCC file address e.g. `N7:0`), `dataType` (`AbLegacyDataType` enum).
- Excluded: name, writable, writeIdempotent.

#### Task 7: TwinCAT
**Classification:** standard · **~4 min** · **Parallelizable with:** Tasks 4, 5, 6, 8
- `@using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT`. Read `TwinCATDriverPage.razor:395-437` + the TwinCAT tag DTO.
- Fields: `deviceHostAddress` (string), `symbolPath` (string, required — e.g. `MAIN.bStart`), `dataType` (`TwinCATDataType` enum).
- Excluded: name, writable, writeIdempotent.

#### Task 8: Focas
**Classification:** standard · **~4 min** · **Parallelizable with:** Tasks 4, 5, 6, 7
- `@using ZB.MOM.WW.OtOpcUa.Driver.FOCAS`. Read `FocasDriverPage.razor:457-499` + the Focas tag DTO.
- Fields: `deviceHostAddress` (string), `address` (string, required — e.g. `R100`, `MACRO:500`), `dataType` (`FocasDataType` enum).
- Excluded: name, writable, writeIdempotent.

---

### Task 9: Register the five editors in TagConfigEditorMap

**Classification:** small
**Estimated implement time:** ~2 min
**Parallelizable with:** none (depends on Tasks 4–8)

**Files:**
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigEditorMap.cs`

Add the five entries (Modbus already there from Task 3):

```csharp
["S7"]       = typeof(Components.Shared.Uns.TagEditors.S7TagConfigEditor),
["AbCip"]    = typeof(Components.Shared.Uns.TagEditors.AbCipTagConfigEditor),
["AbLegacy"] = typeof(Components.Shared.Uns.TagEditors.AbLegacyTagConfigEditor),
["TwinCat"]  = typeof(Components.Shared.Uns.TagEditors.TwinCATTagConfigEditor),
["Focas"]    = typeof(Components.Shared.Uns.TagEditors.FocasTagConfigEditor),
```

Build + run full `AdminUI.Tests` suite (all editor-model tests + 216 baseline). Commit by path.

---

### Task 10: Live-verify in docker-dev

**Classification:** standard (verification — no production code)
**Estimated implement time:** ~6 min (interactive; allowed to exceed the 5-min guidance because it's a manual run, not a code edit)
**Parallelizable with:** none (depends on Task 9)

**Context:** The docker-dev Main cluster has only the Galaxy driver (no typed editor), so a typed editor can't be exercised until a non-Galaxy driver exists. Galaxy lives in a `SystemPlatform` namespace; a Modbus driver needs an **Equipment-kind** namespace (decision #110) so equipment can bind to it.

**Steps (no repo change unless a step says so):**
1. Ensure the rig is up and central runs the latest master (rebuild central per `docs/v2/dev-environment.md` / the project's docker-dev recipe if needed). Sign-in is the user's action (agent must not enter the password).
2. In AdminUI: confirm the Main cluster has an **Equipment-kind** namespace; if not, create one (`/clusters/MAIN/namespaces` → New, Kind=Equipment). Then create a **Modbus** driver instance in that namespace (`/clusters/MAIN/drivers` → New driver → Modbus), endpoint can be the docker host `10.100.0.35:5020` (the rig's Modbus sim) or any placeholder — it isn't connected for this UI check.
3. In `/uns`: pick an equipment, **Edit** it, bind it to the new Modbus driver, Save. Then **+ Tag** on that equipment → confirm the driver dropdown lists the Modbus driver, select it, and confirm the **Modbus typed fields** (Region / Address / Data type / Byte order / Bit index / String len) render instead of the JSON textarea. Fill Name + a driver + the fields, Create.
4. Verify persistence: query `dbo.Tag` (via the sql container, as in the cluster-seed verification) for the new tag's `TagConfig` and confirm it's the structured JSON (`{"region":"…","address":…,"dataType":"…",…}`).
5. Spot-check **AbCip**: temporarily add an AbCip driver + bind, open + Tag, confirm AbCip typed fields render. (Tear down the temp AbCip driver after.)
6. Confirm **fallback**: a Galaxy-bound equipment's + Tag still shows the generic JSON textarea (no typed editor for `GalaxyMxGateway`).
7. Record the outcome; if any editor mis-renders, that's a bug to fix before marking #135 done (per the no-bUnit live-verify rule — `dotnet test` green is necessary but not sufficient for AdminUI razor).

**Done gate for the whole feature:** build clean + full `AdminUI.Tests` green + the live verify above passes.

---

## Notes for the executor

- **Parallel dispatch:** Tasks 4–8 are mutually parallelizable (disjoint files; the shared map is untouched until Task 9). Dispatch their implementers concurrently.
- **Serial spine:** 1 → 2 → 3 → {4,5,6,7,8} → 9 → 10.
- **No bUnit:** every razor editor's logic lives in its `*Model` helper so it's unit-tested; the razor shell itself is only proven by Task 10's live run.
- **Preserve-unknown** is a real requirement, not a nicety: a tag may carry config keys the editor doesn't surface (e.g. Modbus `addressString`, AbCip `members`); the model must round-trip them.
- **Known limitation (out of scope):** for Modbus, an existing `addressString` takes runtime precedence over the structured fields; this editor edits the structured fields and preserves `addressString` but does not surface it. A future follow-up could add an addressString field.
