Compare commits
9 Commits
157a6571c7
...
d29e2190a9
| Author | SHA1 | Date | |
|---|---|---|---|
| d29e2190a9 | |||
| 8973ecdf7c | |||
| c0afecda50 | |||
| 75021fa2c9 | |||
| 5990b673cc | |||
| fd9fa75d0e | |||
| d9dbd7917a | |||
| cc53fc8feb | |||
| 913fea7a3c |
@@ -0,0 +1,170 @@
|
||||
# Driver-typed Tag Editors in the UNS TagModal — Design
|
||||
|
||||
**Date:** 2026-06-09
|
||||
**Task:** #135 (F-uns-1, deferred follow-up from the global-UNS feature)
|
||||
**Status:** Approved (design); pending implementation plan
|
||||
**Scope:** AdminUI only. No data-model / migration change.
|
||||
|
||||
## Goal
|
||||
|
||||
Replace the single generic raw-`TagConfig`-JSON textarea in the global UNS
|
||||
`TagModal` (`Components/Shared/Uns/TagModal.razor`) with **per-driver typed tag
|
||||
editors** that dispatch on the selected driver's `DriverType`, so an operator
|
||||
authoring an equipment-bound tag fills in driver-shaped fields
|
||||
(Modbus register/address/byte-order, S7 address, AbCip tag path, …) instead of
|
||||
hand-writing JSON.
|
||||
|
||||
This was specified at a high level in the global-UNS design
|
||||
(`docs/plans/2026-06-08-global-uns-management-design.md`, "TagModal —
|
||||
driver-typed, mirroring DriverEditRouter"). This document settles the
|
||||
implementation decisions.
|
||||
|
||||
## Decisions locked during brainstorming
|
||||
|
||||
1. **New UNS editors; reuse contracts, not markup.** Build per-driver tag-config
|
||||
editor components specifically for the TagModal. They **reuse each driver's
|
||||
enums** (`ModbusRegion`, `ModbusDataType`, …) and emit the driver's
|
||||
**camelCase JSON property names**, but do **not** reuse the driver pages'
|
||||
razor markup. The existing driver edit pages are left untouched (their field
|
||||
set differs — see §"Field mapping"). Lower risk, contained.
|
||||
2. **All 6 typed editors this pass.** Modbus, S7, AbCip, AbLegacy, TwinCAT,
|
||||
Focas each get a typed editor. OpcUaClient, Galaxy (`GalaxyMxGateway`), and
|
||||
Historian.Wonderware keep the existing generic raw-JSON editor (they have no
|
||||
tag editor in their driver pages either; Galaxy tags are browse-sourced).
|
||||
3. **No raw-JSON escape hatch on typed drivers.** Typed drivers render typed
|
||||
fields only — there is no per-modal "edit raw JSON" toggle. Consequence: each
|
||||
typed editor must cover the driver's **full curated field set** — the same
|
||||
fields the driver page's tag `EditTemplate` exposes (minus the fields the
|
||||
TagModal owns at top level). The generic textarea remains only for the 3
|
||||
unmapped drivers.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Components (`Components/Shared/Uns/TagEditors/`)
|
||||
|
||||
One Razor component per typed driver:
|
||||
`ModbusTagConfigEditor.razor`, `S7TagConfigEditor.razor`,
|
||||
`AbCipTagConfigEditor.razor`, `AbLegacyTagConfigEditor.razor`,
|
||||
`TwinCATTagConfigEditor.razor`, `FocasTagConfigEditor.razor`.
|
||||
|
||||
**Component contract (convention, not a C# interface — these are components):**
|
||||
|
||||
```
|
||||
[Parameter] public string? ConfigJson { get; set; }
|
||||
[Parameter] public EventCallback<string> ConfigJsonChanged { get; set; }
|
||||
```
|
||||
|
||||
Behaviour: on `OnParametersSet`, parse `ConfigJson` → a typed working model
|
||||
(falling back to defaults on null/blank/malformed). Render the fields bound to
|
||||
the model. On any field change, serialize the model → JSON and raise
|
||||
`ConfigJsonChanged`. The TagModal binds it as `@bind-ConfigJson="_form.TagConfig"`.
|
||||
|
||||
**Pure mapping helpers.** Each editor's JSON↔model mapping lives in a pure,
|
||||
static, side-effect-free helper (e.g. `ModbusTagConfigModel.FromJson(string?)`,
|
||||
`.ToJson()`, `.Validate()`), so the (de)serialization and validation are
|
||||
unit-testable in `AdminUI.Tests` **without bUnit** (which this project lacks).
|
||||
The razor component is a thin shell over the helper.
|
||||
|
||||
### Dispatch map
|
||||
|
||||
A static `TagConfigEditorMap` — `IReadOnlyDictionary<string, Type>` keyed by
|
||||
`DriverType` (case-insensitive), mirroring `DriverEditRouter._componentMap`:
|
||||
|
||||
```
|
||||
["ModbusTcp"] = typeof(ModbusTagConfigEditor),
|
||||
["S7"] = typeof(S7TagConfigEditor),
|
||||
["AbCip"] = typeof(AbCipTagConfigEditor),
|
||||
["AbLegacy"] = typeof(AbLegacyTagConfigEditor),
|
||||
["TwinCat"] = typeof(TwinCATTagConfigEditor),
|
||||
["Focas"] = typeof(FocasTagConfigEditor),
|
||||
```
|
||||
|
||||
Drivers not in the map → the generic textarea. (Kept deliberately separate from
|
||||
`DriverEditRouter._componentMap` to avoid coupling tag editing to driver-page
|
||||
editing; both are short and stable.)
|
||||
|
||||
### TagModal integration — learning the driver type
|
||||
|
||||
- `IUnsTreeService.LoadTagDriversForEquipmentAsync` changes its return shape from
|
||||
`(string DriverInstanceId, string Display)` to
|
||||
`(string DriverInstanceId, string Display, string DriverType)`. (`UnsTreeService`
|
||||
already loads `DriverInstance`; it just projects `DriverType` too.)
|
||||
- The TagModal keeps a lookup `DriverInstanceId → DriverType` from those options
|
||||
and resolves the **currently-selected** driver's type (the driver is chosen in
|
||||
the modal's existing dropdown and can change while open).
|
||||
- Render logic in the modal body, where the raw textarea is today:
|
||||
- no driver selected → a muted "pick a driver to configure this tag" hint;
|
||||
- selected type in `TagConfigEditorMap` → `<DynamicComponent Type="…"
|
||||
Parameters="…">` passing `ConfigJson = _form.TagConfig` and a
|
||||
`ConfigJsonChanged` `EventCallback<string>` that writes back to
|
||||
`_form.TagConfig`;
|
||||
- otherwise → the existing raw `InputTextArea` over `_form.TagConfig`.
|
||||
- When the selected driver changes to a different `DriverType`, the editor swaps;
|
||||
`_form.TagConfig` carries over (a mismatched blob is simply re-parsed to
|
||||
defaults by the new editor, and the user re-enters fields).
|
||||
|
||||
## Field mapping (Tag columns vs TagConfig)
|
||||
|
||||
These stay **top-level TagModal fields** (already present): `Tag.Name`,
|
||||
`Tag.DataType` (the OPC UA type), `Tag.AccessLevel`, `Tag.WriteIdempotent`,
|
||||
`Tag.PollGroupId`.
|
||||
|
||||
`TagConfig` holds **only the driver-specific addressing/encoding fields** — the
|
||||
driver page's tag `EditTemplate` fields minus the ones the modal owns:
|
||||
|
||||
| Driver | TagConfig fields (editor) | Excluded (lives elsewhere) |
|
||||
|---|---|---|
|
||||
| Modbus (`ModbusTcp`) | region, address, dataType (wire), byteOrder, bitIndex, stringLength | name → Tag.Name; writable → AccessLevel |
|
||||
| S7 | address, stringLength | name; dataType → Tag.DataType; writable |
|
||||
| AbCip | deviceHostAddress, tagPath | name; dataType; writable |
|
||||
| AbLegacy | deviceHostAddress, address | name; dataType; writable |
|
||||
| TwinCAT | deviceHostAddress, symbolPath | name; dataType; writable |
|
||||
| Focas | deviceHostAddress, address | name; dataType; writable |
|
||||
|
||||
> Note: a driver's *wire* data type (e.g. `ModbusDataType` = how to decode the
|
||||
> register) is distinct from `Tag.DataType` (the OPC UA type) and belongs in
|
||||
> TagConfig; S7/AbCip/… have no separate wire type, so their data type is the
|
||||
> top-level `Tag.DataType` only. The exact per-driver field list is finalized in
|
||||
> the plan against each driver's tag DTO property names.
|
||||
|
||||
**Assumption / out of scope:** this assumes the runtime materialises a per-driver
|
||||
`TagDefinition` for equipment-bound tags from the `Tag` columns + `TagConfig`
|
||||
JSON. Confirming or wiring that runtime consumption is **not** part of this UI
|
||||
feature — the editors faithfully capture the driver-shaped fields under the
|
||||
driver's JSON names so the config is correct when consumed.
|
||||
|
||||
## Validation
|
||||
|
||||
Each editor helper exposes a light `Validate()` (required fields / simple ranges,
|
||||
e.g. Modbus requires an address, AbCip requires a tag path) returning an error
|
||||
string or null. The TagModal surfaces it as its existing inline error and blocks
|
||||
Save. The current "valid JSON" guard (`JsonException`) still protects the generic
|
||||
textarea path. **Server-side deploy validation remains authoritative.**
|
||||
|
||||
## Testing
|
||||
|
||||
No bUnit in this project, so:
|
||||
|
||||
- **Unit tests** (`AdminUI.Tests`, xUnit + Shouldly): each editor's
|
||||
`FromJson` / `ToJson` / `Validate` helper — round-trip fidelity, defaults on
|
||||
null/blank/malformed, property-name correctness (the emitted JSON matches the
|
||||
driver's DTO names), and validation pass/fail cases.
|
||||
- **Live verify** (docker-dev `/run`): the rig's Main cluster currently has only
|
||||
the Galaxy driver (no typed editor), so verifying a typed editor first needs a
|
||||
**non-Galaxy driver** in the rig. Steps: add a Modbus driver instance to the
|
||||
Main cluster, bind an equipment to it, open the equipment's `+ Tag` modal →
|
||||
confirm the Modbus typed fields render, fill them, Save, and inspect the
|
||||
persisted `TagConfig` JSON. Spot-check a second driver (AbCip) and confirm an
|
||||
OpcUaClient/Galaxy-bound equipment still shows the generic textarea.
|
||||
|
||||
Gate = build clean + `dotnet test` green + the docker-dev live verify above.
|
||||
|
||||
## Constraints / non-goals
|
||||
|
||||
- AdminUI only; **no** Configuration entity or migration change.
|
||||
- Driver edit pages are **not** modified (their inline tag editors stay).
|
||||
- Not changing how the runtime consumes tags; not adding fields the driver pages
|
||||
don't already expose.
|
||||
- Git safety: stage by path (never `git add .`), never stage `sql_login.txt` /
|
||||
`pki/`, never echo the gateway API key into a new tracked file, no force-push /
|
||||
no `--no-verify`.
|
||||
@@ -0,0 +1,411 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"planPath": "docs/plans/2026-06-09-driver-typed-tag-editors.md",
|
||||
"tasks": [
|
||||
{"id": 1, "nativeId": 146, "subject": "Task 1: Surface DriverType to TagModal driver dropdown", "classification": "standard", "status": "pending"},
|
||||
{"id": 2, "nativeId": 147, "subject": "Task 2: TagConfigJson util + EditorMap + TagModal dispatch scaffold", "classification": "standard", "status": "pending", "blockedBy": [1]},
|
||||
{"id": 3, "nativeId": 148, "subject": "Task 3: Modbus tag editor (worked example)", "classification": "standard", "status": "pending", "blockedBy": [2]},
|
||||
{"id": 4, "nativeId": 149, "subject": "Task 4: S7 tag editor", "classification": "standard", "status": "pending", "blockedBy": [3], "parallelizableWith": [5, 6, 7, 8]},
|
||||
{"id": 5, "nativeId": 150, "subject": "Task 5: AbCip tag editor", "classification": "standard", "status": "pending", "blockedBy": [3], "parallelizableWith": [4, 6, 7, 8]},
|
||||
{"id": 6, "nativeId": 151, "subject": "Task 6: AbLegacy tag editor", "classification": "standard", "status": "pending", "blockedBy": [3], "parallelizableWith": [4, 5, 7, 8]},
|
||||
{"id": 7, "nativeId": 152, "subject": "Task 7: TwinCAT tag editor", "classification": "standard", "status": "pending", "blockedBy": [3], "parallelizableWith": [4, 5, 6, 8]},
|
||||
{"id": 8, "nativeId": 153, "subject": "Task 8: Focas tag editor", "classification": "standard", "status": "pending", "blockedBy": [3], "parallelizableWith": [4, 5, 6, 7]},
|
||||
{"id": 9, "nativeId": 154, "subject": "Task 9: Register the 5 editors in TagConfigEditorMap", "classification": "small", "status": "pending", "blockedBy": [4, 5, 6, 7, 8]},
|
||||
{"id": 10, "nativeId": 155, "subject": "Task 10: Live-verify typed editors in docker-dev", "classification": "verification", "status": "pending", "blockedBy": [9]}
|
||||
],
|
||||
"lastUpdated": "2026-06-09"
|
||||
}
|
||||
@@ -157,7 +157,7 @@
|
||||
private bool _tagModalIsNew;
|
||||
private string? _tagModalEquipmentId;
|
||||
private TagEditDto? _tagModalExisting;
|
||||
private IReadOnlyList<(string Id, string Display)> _tagModalDriverOptions = Array.Empty<(string, string)>();
|
||||
private IReadOnlyList<(string Id, string Display, string DriverType)> _tagModalDriverOptions = Array.Empty<(string, string, string)>();
|
||||
|
||||
// --- Virtual-tag modal state ---
|
||||
private bool _vtagModalVisible;
|
||||
@@ -613,7 +613,7 @@
|
||||
_tagModalIsNew = false;
|
||||
_tagModalEquipmentId = null;
|
||||
_tagModalExisting = null;
|
||||
_tagModalDriverOptions = Array.Empty<(string, string)>();
|
||||
_tagModalDriverOptions = Array.Empty<(string, string, string)>();
|
||||
_vtagModalVisible = false;
|
||||
_vtagModalIsNew = false;
|
||||
_vtagModalEquipmentId = null;
|
||||
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors
|
||||
@using ZB.MOM.WW.OtOpcUa.Driver.AbCip
|
||||
|
||||
<div class="row g-2">
|
||||
<div class="col-md-4"><label class="form-label">Device host</label>
|
||||
<input class="form-control form-control-sm mono" value="@_m.DeviceHostAddress" placeholder="ab://gateway[:port]/cip-path" @onchange="@(e => Update(() => _m.DeviceHostAddress = e.Value?.ToString() ?? string.Empty))" /></div>
|
||||
<div class="col-md-5"><label class="form-label">Tag path</label>
|
||||
<input class="form-control form-control-sm mono" value="@_m.TagPath" placeholder="e.g. Motor1.Speed" @onchange="@(e => Update(() => _m.TagPath = e.Value?.ToString() ?? string.Empty))" /></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 = ParseEnum(e.Value, AbCipDataType.DInt)))">
|
||||
@foreach (var v in Enum.GetValues<AbCipDataType>()) { <option value="@v">@v</option> }
|
||||
</select></div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter] public string? ConfigJson { get; set; }
|
||||
[Parameter] public EventCallback<string> ConfigJsonChanged { get; set; }
|
||||
|
||||
private AbCipTagConfigModel _m = new();
|
||||
private string? _lastConfigJson;
|
||||
|
||||
// Re-parse only when the incoming JSON actually changes, so an unrelated parent re-render
|
||||
// (Blazor Server live-status pushes do this) can't reset the user's in-progress edits.
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
if (ConfigJson == _lastConfigJson) { return; }
|
||||
_lastConfigJson = ConfigJson;
|
||||
_m = AbCipTagConfigModel.FromJson(ConfigJson);
|
||||
}
|
||||
|
||||
// TryParse so a bad/empty change value can never throw into the Blazor circuit — it falls back.
|
||||
private static TEnum ParseEnum<TEnum>(object? v, TEnum fallback) where TEnum : struct, Enum
|
||||
=> Enum.TryParse<TEnum>(v?.ToString(), out var r) ? r : fallback;
|
||||
|
||||
private async Task Update(Action apply)
|
||||
{
|
||||
apply();
|
||||
await ConfigJsonChanged.InvokeAsync(_m.ToJson());
|
||||
}
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors
|
||||
@using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy
|
||||
|
||||
<div class="row g-2">
|
||||
<div class="col-md-4"><label class="form-label">Device host</label>
|
||||
<input class="form-control form-control-sm mono" value="@_m.DeviceHostAddress" placeholder="ab://host" @onchange="@(e => Update(() => _m.DeviceHostAddress = e.Value?.ToString() ?? string.Empty))" /></div>
|
||||
<div class="col-md-5"><label class="form-label">Address</label>
|
||||
<input class="form-control form-control-sm mono" value="@_m.Address" placeholder="e.g. N7:0, B3:0/0" @onchange="@(e => Update(() => _m.Address = e.Value?.ToString() ?? string.Empty))" /></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 = ParseEnum(e.Value, AbLegacyDataType.Int)))">
|
||||
@foreach (var v in Enum.GetValues<AbLegacyDataType>()) { <option value="@v">@v</option> }
|
||||
</select></div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter] public string? ConfigJson { get; set; }
|
||||
[Parameter] public EventCallback<string> ConfigJsonChanged { get; set; }
|
||||
|
||||
private AbLegacyTagConfigModel _m = new();
|
||||
private string? _lastConfigJson;
|
||||
|
||||
// Re-parse only when the incoming JSON actually changes, so an unrelated parent re-render
|
||||
// (Blazor Server live-status pushes do this) can't reset the user's in-progress edits.
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
if (ConfigJson == _lastConfigJson) { return; }
|
||||
_lastConfigJson = ConfigJson;
|
||||
_m = AbLegacyTagConfigModel.FromJson(ConfigJson);
|
||||
}
|
||||
|
||||
// TryParse so a bad/empty change value can never throw into the Blazor circuit — it falls back.
|
||||
private static TEnum ParseEnum<TEnum>(object? v, TEnum fallback) where TEnum : struct, Enum
|
||||
=> Enum.TryParse<TEnum>(v?.ToString(), out var r) ? r : fallback;
|
||||
|
||||
private async Task Update(Action apply)
|
||||
{
|
||||
apply();
|
||||
await ConfigJsonChanged.InvokeAsync(_m.ToJson());
|
||||
}
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors
|
||||
@using ZB.MOM.WW.OtOpcUa.Driver.FOCAS
|
||||
|
||||
<div class="row g-2">
|
||||
<div class="col-md-4"><label class="form-label">Device host</label>
|
||||
<input class="form-control form-control-sm mono" value="@_m.DeviceHostAddress" placeholder="host[:port]" @onchange="@(e => Update(() => _m.DeviceHostAddress = e.Value?.ToString() ?? string.Empty))" /></div>
|
||||
<div class="col-md-5"><label class="form-label">Address</label>
|
||||
<input class="form-control form-control-sm mono" value="@_m.Address" placeholder="e.g. X0.0, R100, PARAM:1815/0, MACRO:500" @onchange="@(e => Update(() => _m.Address = e.Value?.ToString() ?? string.Empty))" /></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 = ParseEnum(e.Value, FocasDataType.Int32)))">
|
||||
@foreach (var v in Enum.GetValues<FocasDataType>()) { <option value="@v">@v</option> }
|
||||
</select></div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter] public string? ConfigJson { get; set; }
|
||||
[Parameter] public EventCallback<string> ConfigJsonChanged { get; set; }
|
||||
|
||||
private FocasTagConfigModel _m = new();
|
||||
private string? _lastConfigJson;
|
||||
|
||||
// Re-parse only when the incoming JSON actually changes, so an unrelated parent re-render
|
||||
// (Blazor Server live-status pushes do this) can't reset the user's in-progress edits.
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
if (ConfigJson == _lastConfigJson) { return; }
|
||||
_lastConfigJson = ConfigJson;
|
||||
_m = FocasTagConfigModel.FromJson(ConfigJson);
|
||||
}
|
||||
|
||||
// TryParse so a bad/empty change value can never throw into the Blazor circuit — it falls back.
|
||||
private static TEnum ParseEnum<TEnum>(object? v, TEnum fallback) where TEnum : struct, Enum
|
||||
=> Enum.TryParse<TEnum>(v?.ToString(), out var r) ? r : fallback;
|
||||
|
||||
private async Task Update(Action apply)
|
||||
{
|
||||
apply();
|
||||
await ConfigJsonChanged.InvokeAsync(_m.ToJson());
|
||||
}
|
||||
}
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
@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 = ParseEnum(e.Value, ModbusRegion.HoldingRegisters)))">
|
||||
@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 = ParseEnum(e.Value, ModbusDataType.Int16)))">
|
||||
@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 = ParseEnum(e.Value, ModbusByteOrder.BigEndian)))">
|
||||
@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();
|
||||
private string? _lastConfigJson;
|
||||
|
||||
// Re-parse only when the incoming JSON actually changes, so an unrelated parent re-render
|
||||
// (Blazor Server live-status pushes do this) can't reset the user's in-progress edits.
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
if (ConfigJson == _lastConfigJson) { return; }
|
||||
_lastConfigJson = ConfigJson;
|
||||
_m = ModbusTagConfigModel.FromJson(ConfigJson);
|
||||
}
|
||||
|
||||
private static int ParseInt(object? v, int fallback = 0) => int.TryParse(v?.ToString(), out var i) ? i : fallback;
|
||||
|
||||
// TryParse so a bad/empty change value can never throw into the Blazor circuit — it falls back.
|
||||
private static TEnum ParseEnum<TEnum>(object? v, TEnum fallback) where TEnum : struct, Enum
|
||||
=> Enum.TryParse<TEnum>(v?.ToString(), out var r) ? r : fallback;
|
||||
|
||||
private async Task Update(Action apply)
|
||||
{
|
||||
apply();
|
||||
await ConfigJsonChanged.InvokeAsync(_m.ToJson());
|
||||
}
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors
|
||||
@using ZB.MOM.WW.OtOpcUa.Driver.S7
|
||||
|
||||
<div class="row g-2">
|
||||
<div class="col-md-5"><label class="form-label">Address</label>
|
||||
<input class="form-control form-control-sm mono" value="@_m.Address" placeholder="e.g. DB1.DBW0, M0.0, I0.0, QD4" @onchange="@(e => Update(() => _m.Address = e.Value?.ToString() ?? string.Empty))" /></div>
|
||||
<div class="col-md-4"><label class="form-label">Data type</label>
|
||||
<select class="form-select form-select-sm" value="@_m.DataType" @onchange="@(e => Update(() => _m.DataType = ParseEnum(e.Value, S7DataType.Int16)))">
|
||||
@foreach (var v in Enum.GetValues<S7DataType>()) { <option value="@v">@v</option> }
|
||||
</select></div>
|
||||
<div class="col-md-3"><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 S7TagConfigModel _m = new();
|
||||
private string? _lastConfigJson;
|
||||
|
||||
// Re-parse only when the incoming JSON actually changes, so an unrelated parent re-render
|
||||
// (Blazor Server live-status pushes do this) can't reset the user's in-progress edits.
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
if (ConfigJson == _lastConfigJson) { return; }
|
||||
_lastConfigJson = ConfigJson;
|
||||
_m = S7TagConfigModel.FromJson(ConfigJson);
|
||||
}
|
||||
|
||||
private static int ParseInt(object? v, int fallback = 0) => int.TryParse(v?.ToString(), out var i) ? i : fallback;
|
||||
|
||||
// TryParse so a bad/empty change value can never throw into the Blazor circuit — it falls back.
|
||||
private static TEnum ParseEnum<TEnum>(object? v, TEnum fallback) where TEnum : struct, Enum
|
||||
=> Enum.TryParse<TEnum>(v?.ToString(), out var r) ? r : fallback;
|
||||
|
||||
private async Task Update(Action apply)
|
||||
{
|
||||
apply();
|
||||
await ConfigJsonChanged.InvokeAsync(_m.ToJson());
|
||||
}
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors
|
||||
@using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT
|
||||
|
||||
<div class="row g-2">
|
||||
<div class="col-md-4"><label class="form-label">Device host</label>
|
||||
<input class="form-control form-control-sm mono" value="@_m.DeviceHostAddress" placeholder="AmsNetId[:port]" @onchange="@(e => Update(() => _m.DeviceHostAddress = e.Value?.ToString() ?? string.Empty))" /></div>
|
||||
<div class="col-md-5"><label class="form-label">Symbol path</label>
|
||||
<input class="form-control form-control-sm mono" value="@_m.SymbolPath" placeholder="e.g. MAIN.bStart, GVL.Counter" @onchange="@(e => Update(() => _m.SymbolPath = e.Value?.ToString() ?? string.Empty))" /></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 = ParseEnum(e.Value, TwinCATDataType.DInt)))">
|
||||
@foreach (var v in Enum.GetValues<TwinCATDataType>()) { <option value="@v">@v</option> }
|
||||
</select></div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter] public string? ConfigJson { get; set; }
|
||||
[Parameter] public EventCallback<string> ConfigJsonChanged { get; set; }
|
||||
|
||||
private TwinCATTagConfigModel _m = new();
|
||||
private string? _lastConfigJson;
|
||||
|
||||
// Re-parse only when the incoming JSON actually changes, so an unrelated parent re-render
|
||||
// (Blazor Server live-status pushes do this) can't reset the user's in-progress edits.
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
if (ConfigJson == _lastConfigJson) { return; }
|
||||
_lastConfigJson = ConfigJson;
|
||||
_m = TwinCATTagConfigModel.FromJson(ConfigJson);
|
||||
}
|
||||
|
||||
// TryParse so a bad/empty change value can never throw into the Blazor circuit — it falls back.
|
||||
private static TEnum ParseEnum<TEnum>(object? v, TEnum fallback) where TEnum : struct, Enum
|
||||
=> Enum.TryParse<TEnum>(v?.ToString(), out var r) ? r : fallback;
|
||||
|
||||
private async Task Update(Action apply)
|
||||
{
|
||||
apply();
|
||||
await ConfigJsonChanged.InvokeAsync(_m.ToJson());
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@
|
||||
@using System.ComponentModel.DataAnnotations
|
||||
@using Microsoft.AspNetCore.Components.Forms
|
||||
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns
|
||||
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Enums
|
||||
@inject IUnsTreeService Svc
|
||||
|
||||
@@ -41,9 +42,9 @@
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label" for="tag-driver">Driver instance</label>
|
||||
<InputSelect id="tag-driver" @bind-Value="_form.DriverInstanceId" class="form-select form-select-sm">
|
||||
<InputSelect id="tag-driver" @bind-Value="_form.DriverInstanceId" @bind-Value:after="OnDriverChanged" class="form-select form-select-sm">
|
||||
<option value="">— pick a driver —</option>
|
||||
@foreach (var (id, display) in Drivers)
|
||||
@foreach (var (id, display, _) in Drivers)
|
||||
{
|
||||
<option value="@id">@display</option>
|
||||
}
|
||||
@@ -81,11 +82,25 @@
|
||||
<InputText id="tag-pgroup" @bind-Value="_form.PollGroupId" class="form-control form-control-sm mono" />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="tag-config">Tag config (JSON)</label>
|
||||
<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 — register / address / scaling / byte-order. Validated server-side at deploy.</div>
|
||||
<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>
|
||||
|
||||
@@ -125,7 +140,7 @@
|
||||
[Parameter] public TagEditDto? Existing { get; set; }
|
||||
|
||||
/// <summary>The candidate drivers — scoped to the equipment's cluster by the host — as <c>(Id, Display)</c> pairs.</summary>
|
||||
[Parameter] public IReadOnlyList<(string Id, string Display)> Drivers { get; set; } = Array.Empty<(string, string)>();
|
||||
[Parameter] public IReadOnlyList<(string Id, string Display, string DriverType)> Drivers { get; set; } = Array.Empty<(string, string, string)>();
|
||||
|
||||
/// <summary>Raised after a successful create/save so the host can refresh the equipment's children and close.</summary>
|
||||
[Parameter] public EventCallback OnSaved { get; set; }
|
||||
@@ -137,6 +152,21 @@
|
||||
private bool _busy;
|
||||
private string? _error;
|
||||
|
||||
// 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;
|
||||
|
||||
// When the operator switches drivers, the previous driver's TagConfig schema no longer applies —
|
||||
// reset it so the newly-dispatched typed editor starts clean (no stale/leaked keys from the old
|
||||
// driver). Fires only on a user dropdown change (@bind-Value:after), not on the initial edit-load.
|
||||
private void OnDriverChanged() => _form.TagConfig = "{}";
|
||||
|
||||
private IDictionary<string, object> BuildEditorParameters() => new Dictionary<string, object>
|
||||
{
|
||||
["ConfigJson"] = _form.TagConfig,
|
||||
["ConfigJsonChanged"] = EventCallback.Factory.Create<string>(this, v => _form.TagConfig = v),
|
||||
};
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
// Rebuild the working form whenever the host (re)opens the modal for a fresh target.
|
||||
|
||||
@@ -316,8 +316,9 @@ public interface IUnsTreeService
|
||||
/// </summary>
|
||||
/// <param name="equipmentId">The equipment whose candidate drivers to load.</param>
|
||||
/// <param name="ct">A token to cancel the load.</param>
|
||||
/// <returns>The eligible drivers projected to <c>(DriverInstanceId, Display)</c> pairs.</returns>
|
||||
Task<IReadOnlyList<(string DriverInstanceId, string Display)>> LoadTagDriversForEquipmentAsync(string equipmentId, CancellationToken ct = default);
|
||||
/// <returns>The eligible drivers projected to <c>(DriverInstanceId, Display, DriverType)</c> triples,
|
||||
/// where <c>DriverType</c> lets the TagModal dispatch to a per-driver-type typed config editor.</returns>
|
||||
Task<IReadOnlyList<(string DriverInstanceId, string Display, string DriverType)>> LoadTagDriversForEquipmentAsync(string equipmentId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new equipment-bound tag. <c>FolderPath</c> is always <c>null</c> (decision #110 —
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
using System.Text.Json.Nodes;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
|
||||
|
||||
/// <summary>Typed working model for an AB CIP / EtherNet-IP tag's TagConfig JSON (the driver-specific addressing/encoding
|
||||
/// fields; name/writable live on the Tag entity). Preserves unrecognised JSON keys across a load→save.</summary>
|
||||
public sealed class AbCipTagConfigModel
|
||||
{
|
||||
/// <summary>Which device (<c>AbCipDeviceOptions.HostAddress</c>) this tag lives on. Optional.</summary>
|
||||
public string DeviceHostAddress { get; set; } = "";
|
||||
|
||||
/// <summary>Logix symbolic path (controller or program scope). Required.</summary>
|
||||
public string TagPath { get; set; } = "";
|
||||
|
||||
/// <summary>Logix atomic type, or <c>AbCipDataType.Structure</c> for UDT-typed tags.</summary>
|
||||
public AbCipDataType DataType { get; set; } = AbCipDataType.DInt;
|
||||
|
||||
private JsonObject _bag = new();
|
||||
|
||||
/// <summary>Loads a model from a TagConfig JSON string, defaulting any absent field and retaining
|
||||
/// every original key (so fields this editor doesn't expose survive a load→save).</summary>
|
||||
public static AbCipTagConfigModel FromJson(string? json)
|
||||
{
|
||||
var o = TagConfigJson.ParseOrNew(json);
|
||||
return new AbCipTagConfigModel
|
||||
{
|
||||
DeviceHostAddress = TagConfigJson.GetString(o, "deviceHostAddress") ?? "",
|
||||
TagPath = TagConfigJson.GetString(o, "tagPath") ?? "",
|
||||
DataType = TagConfigJson.GetEnum(o, "dataType", AbCipDataType.DInt),
|
||||
_bag = o,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Serialises this model back to a TagConfig JSON string, writing the exposed fields
|
||||
/// (enum as its name string; the optional host address omitted when blank) over the preserved key bag.</summary>
|
||||
public string ToJson()
|
||||
{
|
||||
TagConfigJson.Set(_bag, "deviceHostAddress", string.IsNullOrWhiteSpace(DeviceHostAddress) ? null : DeviceHostAddress.Trim());
|
||||
TagConfigJson.Set(_bag, "tagPath", TagPath.Trim());
|
||||
TagConfigJson.Set(_bag, "dataType", DataType);
|
||||
return TagConfigJson.Serialize(_bag);
|
||||
}
|
||||
|
||||
/// <summary>Validation hook; returns an error message or null when the model is valid.</summary>
|
||||
public string? Validate()
|
||||
=> string.IsNullOrWhiteSpace(TagPath) ? "Tag path is required." : null;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
using System.Text.Json.Nodes;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
|
||||
|
||||
/// <summary>Typed working model for an AB Legacy (PCCC) tag's TagConfig JSON (the driver-specific addressing/encoding
|
||||
/// fields; name/writable live on the Tag entity). Preserves unrecognised JSON keys across a load→save.</summary>
|
||||
public sealed class AbLegacyTagConfigModel
|
||||
{
|
||||
/// <summary>Which device (<c>AbLegacyDeviceOptions.HostAddress</c>) this tag lives on. Optional.</summary>
|
||||
public string DeviceHostAddress { get; set; } = "";
|
||||
|
||||
/// <summary>Canonical PCCC file-address string, e.g. <c>N7:0</c>, <c>B3:0/0</c>. Required.</summary>
|
||||
public string Address { get; set; } = "";
|
||||
|
||||
/// <summary>PCCC data type that maps onto SLC / MicroLogix / PLC-5 files.</summary>
|
||||
public AbLegacyDataType DataType { get; set; } = AbLegacyDataType.Int;
|
||||
|
||||
private JsonObject _bag = new();
|
||||
|
||||
/// <summary>Loads a model from a TagConfig JSON string, defaulting any absent field and retaining
|
||||
/// every original key (so fields this editor doesn't expose survive a load→save).</summary>
|
||||
public static AbLegacyTagConfigModel FromJson(string? json)
|
||||
{
|
||||
var o = TagConfigJson.ParseOrNew(json);
|
||||
return new AbLegacyTagConfigModel
|
||||
{
|
||||
DeviceHostAddress = TagConfigJson.GetString(o, "deviceHostAddress") ?? "",
|
||||
Address = TagConfigJson.GetString(o, "address") ?? "",
|
||||
DataType = TagConfigJson.GetEnum(o, "dataType", AbLegacyDataType.Int),
|
||||
_bag = o,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Serialises this model back to a TagConfig JSON string, writing the exposed fields
|
||||
/// (enum as its name string; the optional host address omitted when blank) over the preserved key bag.</summary>
|
||||
public string ToJson()
|
||||
{
|
||||
TagConfigJson.Set(_bag, "deviceHostAddress", string.IsNullOrWhiteSpace(DeviceHostAddress) ? null : DeviceHostAddress.Trim());
|
||||
TagConfigJson.Set(_bag, "address", Address.Trim());
|
||||
TagConfigJson.Set(_bag, "dataType", DataType);
|
||||
return TagConfigJson.Serialize(_bag);
|
||||
}
|
||||
|
||||
/// <summary>Validation hook; returns an error message or null when the model is valid.</summary>
|
||||
public string? Validate()
|
||||
=> string.IsNullOrWhiteSpace(Address) ? "Address is required." : null;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
using System.Text.Json.Nodes;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
|
||||
|
||||
/// <summary>Typed working model for a FOCAS (FANUC CNC) tag's TagConfig JSON (the driver-specific addressing/encoding
|
||||
/// fields; name/writable live on the Tag entity). Preserves unrecognised JSON keys across a load→save.</summary>
|
||||
public sealed class FocasTagConfigModel
|
||||
{
|
||||
/// <summary>Which device (<c>FocasDeviceOptions.HostAddress</c>) this tag lives on. Optional.</summary>
|
||||
public string DeviceHostAddress { get; set; } = "";
|
||||
|
||||
/// <summary>Canonical FOCAS address string, e.g. <c>X0.0</c>, <c>R100</c>, <c>PARAM:1815/0</c>, <c>MACRO:500</c>. Required.</summary>
|
||||
public string Address { get; set; } = "";
|
||||
|
||||
/// <summary>FOCAS atomic data type.</summary>
|
||||
public FocasDataType DataType { get; set; } = FocasDataType.Int32;
|
||||
|
||||
private JsonObject _bag = new();
|
||||
|
||||
/// <summary>Loads a model from a TagConfig JSON string, defaulting any absent field and retaining
|
||||
/// every original key (so fields this editor doesn't expose survive a load→save).</summary>
|
||||
public static FocasTagConfigModel FromJson(string? json)
|
||||
{
|
||||
var o = TagConfigJson.ParseOrNew(json);
|
||||
return new FocasTagConfigModel
|
||||
{
|
||||
DeviceHostAddress = TagConfigJson.GetString(o, "deviceHostAddress") ?? "",
|
||||
Address = TagConfigJson.GetString(o, "address") ?? "",
|
||||
DataType = TagConfigJson.GetEnum(o, "dataType", FocasDataType.Int32),
|
||||
_bag = o,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Serialises this model back to a TagConfig JSON string, writing the exposed fields
|
||||
/// (enum as its name string; the optional host address omitted when blank) over the preserved key bag.</summary>
|
||||
public string ToJson()
|
||||
{
|
||||
TagConfigJson.Set(_bag, "deviceHostAddress", string.IsNullOrWhiteSpace(DeviceHostAddress) ? null : DeviceHostAddress.Trim());
|
||||
TagConfigJson.Set(_bag, "address", Address.Trim());
|
||||
TagConfigJson.Set(_bag, "dataType", DataType);
|
||||
return TagConfigJson.Serialize(_bag);
|
||||
}
|
||||
|
||||
/// <summary>Validation hook; returns an error message or null when the model is valid.</summary>
|
||||
public string? Validate()
|
||||
=> string.IsNullOrWhiteSpace(Address) ? "Address is required." : null;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
using System.Text.Json.Nodes;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Modbus;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
|
||||
|
||||
/// <summary>Typed working model for a Modbus tag's TagConfig JSON (the driver-specific addressing/encoding
|
||||
/// fields; name/writable live on the Tag entity). Preserves unrecognised JSON keys across a load→save.</summary>
|
||||
public sealed class ModbusTagConfigModel
|
||||
{
|
||||
/// <summary>Register region (HoldingRegisters/InputRegisters/Coils/DiscreteInputs).</summary>
|
||||
public ModbusRegion Region { get; set; } = ModbusRegion.HoldingRegisters;
|
||||
|
||||
/// <summary>Starting register/coil address.</summary>
|
||||
public int Address { get; set; }
|
||||
|
||||
/// <summary>Wire value type the register block decodes to.</summary>
|
||||
public ModbusDataType DataType { get; set; } = ModbusDataType.Int16;
|
||||
|
||||
/// <summary>Word/byte ordering for multi-register values.</summary>
|
||||
public ModbusByteOrder ByteOrder { get; set; } = ModbusByteOrder.BigEndian;
|
||||
|
||||
/// <summary>Bit index (0-15) for BitInRegister tags.</summary>
|
||||
public int BitIndex { get; set; }
|
||||
|
||||
/// <summary>String length in characters for String tags.</summary>
|
||||
public int StringLength { get; set; }
|
||||
|
||||
private JsonObject _bag = new();
|
||||
|
||||
/// <summary>Loads a model from a TagConfig JSON string, defaulting any absent field and retaining
|
||||
/// every original key (so fields this editor doesn't expose survive a load→save).</summary>
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Serialises this model back to a TagConfig JSON string, writing the six exposed fields
|
||||
/// (enums as their name strings) over the preserved key bag.</summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>Validation hook; returns an error message or null when the model is valid.</summary>
|
||||
public string? Validate() => null;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
using System.Text.Json.Nodes;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.S7;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
|
||||
|
||||
/// <summary>Typed working model for a Siemens S7 tag's TagConfig JSON (the driver-specific addressing/encoding
|
||||
/// fields; name/writable live on the Tag entity). Preserves unrecognised JSON keys across a load→save.</summary>
|
||||
public sealed class S7TagConfigModel
|
||||
{
|
||||
/// <summary>S7 address string, e.g. <c>DB1.DBW0</c>, <c>M0.0</c>, <c>I0.0</c>, <c>QD4</c>. Required.</summary>
|
||||
public string Address { get; set; } = "";
|
||||
|
||||
/// <summary>Logical data type — drives the underlying S7.Net read/write width.</summary>
|
||||
public S7DataType DataType { get; set; } = S7DataType.Int16;
|
||||
|
||||
/// <summary>For <c>DataType = String</c>: S7-string max length (max 254).</summary>
|
||||
public int StringLength { get; set; }
|
||||
|
||||
private JsonObject _bag = new();
|
||||
|
||||
/// <summary>Loads a model from a TagConfig JSON string, defaulting any absent field and retaining
|
||||
/// every original key (so fields this editor doesn't expose survive a load→save).</summary>
|
||||
public static S7TagConfigModel FromJson(string? json)
|
||||
{
|
||||
var o = TagConfigJson.ParseOrNew(json);
|
||||
return new S7TagConfigModel
|
||||
{
|
||||
Address = TagConfigJson.GetString(o, "address") ?? "",
|
||||
DataType = TagConfigJson.GetEnum(o, "dataType", S7DataType.Int16),
|
||||
StringLength = TagConfigJson.GetInt(o, "stringLength"),
|
||||
_bag = o,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Serialises this model back to a TagConfig JSON string, writing the exposed fields
|
||||
/// (enum as its name string) over the preserved key bag.</summary>
|
||||
public string ToJson()
|
||||
{
|
||||
TagConfigJson.Set(_bag, "address", Address.Trim());
|
||||
TagConfigJson.Set(_bag, "dataType", DataType);
|
||||
TagConfigJson.Set(_bag, "stringLength", StringLength);
|
||||
return TagConfigJson.Serialize(_bag);
|
||||
}
|
||||
|
||||
/// <summary>Validation hook; returns an error message or null when the model is valid.</summary>
|
||||
public string? Validate()
|
||||
=> string.IsNullOrWhiteSpace(Address) ? "Address is required." : null;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
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)
|
||||
{
|
||||
["ModbusTcp"] = typeof(Components.Shared.Uns.TagEditors.ModbusTagConfigEditor),
|
||||
["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),
|
||||
};
|
||||
|
||||
/// <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;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
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
|
||||
{
|
||||
/// <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 (JsonNode.ToJsonString() defaults to non-indented).</summary>
|
||||
public static string Serialize(JsonObject obj) => obj.ToJsonString();
|
||||
|
||||
/// <summary>Reads a string value, or null if absent/null/non-string.</summary>
|
||||
public static string? GetString(JsonObject o, string name)
|
||||
=> o.TryGetPropertyValue(name, out var n) && n is JsonValue v && v.TryGetValue<string>(out var s) ? s : null;
|
||||
|
||||
/// <summary>Reads an int value, or <paramref name="fallback"/> if absent/null/non-numeric (incl. object/array nodes).</summary>
|
||||
public static int GetInt(JsonObject o, string name, int fallback = 0)
|
||||
=> o.TryGetPropertyValue(name, out var n) && n is JsonValue v && v.TryGetValue<int>(out var i) ? i : 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()). A null value REMOVES the key, so it is omitted from the serialised JSON.</summary>
|
||||
public static void Set(JsonObject o, string name, object? value)
|
||||
{
|
||||
if (value is null) { o.Remove(name); return; }
|
||||
o[name] = JsonValue.Create(value is Enum e ? e.ToString() : value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
using System.Text.Json.Nodes;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
|
||||
|
||||
/// <summary>Typed working model for a TwinCAT ADS tag's TagConfig JSON (the driver-specific addressing/encoding
|
||||
/// fields; name/writable live on the Tag entity). Preserves unrecognised JSON keys across a load→save.</summary>
|
||||
public sealed class TwinCATTagConfigModel
|
||||
{
|
||||
/// <summary>Which device (<c>TwinCATDeviceOptions.HostAddress</c>) this tag lives on. Optional.</summary>
|
||||
public string DeviceHostAddress { get; set; } = "";
|
||||
|
||||
/// <summary>Full TwinCAT symbolic name, e.g. <c>MAIN.bStart</c>, <c>GVL.Counter</c>. Required.</summary>
|
||||
public string SymbolPath { get; set; } = "";
|
||||
|
||||
/// <summary>TwinCAT / IEC 61131-3 atomic data type.</summary>
|
||||
public TwinCATDataType DataType { get; set; } = TwinCATDataType.DInt;
|
||||
|
||||
private JsonObject _bag = new();
|
||||
|
||||
/// <summary>Loads a model from a TagConfig JSON string, defaulting any absent field and retaining
|
||||
/// every original key (so fields this editor doesn't expose survive a load→save).</summary>
|
||||
public static TwinCATTagConfigModel FromJson(string? json)
|
||||
{
|
||||
var o = TagConfigJson.ParseOrNew(json);
|
||||
return new TwinCATTagConfigModel
|
||||
{
|
||||
DeviceHostAddress = TagConfigJson.GetString(o, "deviceHostAddress") ?? "",
|
||||
SymbolPath = TagConfigJson.GetString(o, "symbolPath") ?? "",
|
||||
DataType = TagConfigJson.GetEnum(o, "dataType", TwinCATDataType.DInt),
|
||||
_bag = o,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Serialises this model back to a TagConfig JSON string, writing the exposed fields
|
||||
/// (enum as its name string; the optional host address omitted when blank) over the preserved key bag.</summary>
|
||||
public string ToJson()
|
||||
{
|
||||
TagConfigJson.Set(_bag, "deviceHostAddress", string.IsNullOrWhiteSpace(DeviceHostAddress) ? null : DeviceHostAddress.Trim());
|
||||
TagConfigJson.Set(_bag, "symbolPath", SymbolPath.Trim());
|
||||
TagConfigJson.Set(_bag, "dataType", DataType);
|
||||
return TagConfigJson.Serialize(_bag);
|
||||
}
|
||||
|
||||
/// <summary>Validation hook; returns an error message or null when the model is valid.</summary>
|
||||
public string? Validate()
|
||||
=> string.IsNullOrWhiteSpace(SymbolPath) ? "Symbol path is required." : null;
|
||||
}
|
||||
@@ -704,7 +704,7 @@ public sealed class UnsTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<(string DriverInstanceId, string Display)>> LoadTagDriversForEquipmentAsync(
|
||||
public async Task<IReadOnlyList<(string DriverInstanceId, string Display, string DriverType)>> LoadTagDriversForEquipmentAsync(
|
||||
string equipmentId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
@@ -713,7 +713,7 @@ public sealed class UnsTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
|
||||
var equipmentCluster = await ResolveEquipmentClusterAsync(db, equipmentId, ct);
|
||||
if (equipmentCluster is null)
|
||||
{
|
||||
return Array.Empty<(string, string)>();
|
||||
return Array.Empty<(string, string, string)>();
|
||||
}
|
||||
|
||||
// Drivers in the equipment's cluster whose namespace is Equipment-kind (decision #110).
|
||||
@@ -725,11 +725,11 @@ public sealed class UnsTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
|
||||
var drivers = await db.DriverInstances
|
||||
.Where(d => d.ClusterId == equipmentCluster && equipmentNamespaceIds.Contains(d.NamespaceId))
|
||||
.OrderBy(d => d.DriverInstanceId)
|
||||
.Select(d => new { d.DriverInstanceId, d.Name })
|
||||
.Select(d => new { d.DriverInstanceId, d.Name, d.DriverType })
|
||||
.ToListAsync(ct);
|
||||
|
||||
return drivers
|
||||
.Select(d => (d.DriverInstanceId, Display: $"{d.DriverInstanceId} — {d.Name}"))
|
||||
.Select(d => (d.DriverInstanceId, Display: $"{d.DriverInstanceId} — {d.Name}", d.DriverType))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
|
||||
|
||||
public sealed class AbCipTagConfigModelTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData("{}")]
|
||||
public void FromJson_returns_defaults_for_empty_input(string? json)
|
||||
{
|
||||
var m = AbCipTagConfigModel.FromJson(json);
|
||||
|
||||
m.DeviceHostAddress.ShouldBe("");
|
||||
m.TagPath.ShouldBe("");
|
||||
m.DataType.ShouldBe(AbCipDataType.DInt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Round_trip_preserves_all_fields()
|
||||
{
|
||||
var m = new AbCipTagConfigModel
|
||||
{
|
||||
DeviceHostAddress = "ab://gw/1,0",
|
||||
TagPath = "Program:Main.Speed",
|
||||
DataType = AbCipDataType.Real,
|
||||
};
|
||||
|
||||
var json = m.ToJson();
|
||||
var m2 = AbCipTagConfigModel.FromJson(json);
|
||||
|
||||
m2.DeviceHostAddress.ShouldBe("ab://gw/1,0");
|
||||
m2.TagPath.ShouldBe("Program:Main.Speed");
|
||||
m2.DataType.ShouldBe(AbCipDataType.Real);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToJson_emits_camelCase_keys_with_enum_names()
|
||||
{
|
||||
var m = new AbCipTagConfigModel
|
||||
{
|
||||
DeviceHostAddress = "ab://gw/1,0",
|
||||
TagPath = "Motor1.Status",
|
||||
DataType = AbCipDataType.DInt,
|
||||
};
|
||||
|
||||
var json = m.ToJson();
|
||||
|
||||
json.ShouldContain("\"deviceHostAddress\":\"ab://gw/1,0\"");
|
||||
json.ShouldContain("\"tagPath\":\"Motor1.Status\"");
|
||||
json.ShouldContain("\"dataType\":\"DInt\"");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromJson_then_ToJson_preserves_unknown_keys()
|
||||
{
|
||||
var json = AbCipTagConfigModel
|
||||
.FromJson("""{"tagPath":"Motor1.Status","extraKey":"keepme"}""")
|
||||
.ToJson();
|
||||
|
||||
json.ShouldContain("extraKey");
|
||||
json.ShouldContain("keepme");
|
||||
json.ShouldContain("\"tagPath\":\"Motor1.Status\"");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_returns_error_when_tagPath_blank()
|
||||
{
|
||||
new AbCipTagConfigModel { TagPath = "" }.Validate().ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_returns_null_when_tagPath_filled()
|
||||
{
|
||||
new AbCipTagConfigModel { TagPath = "Motor1.Status" }.Validate().ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToJson_omits_blank_deviceHostAddress()
|
||||
{
|
||||
var m = new AbCipTagConfigModel { TagPath = "Motor1.Status", DeviceHostAddress = "" };
|
||||
|
||||
var json = m.ToJson();
|
||||
|
||||
json.ShouldNotContain("deviceHostAddress");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToJson_includes_deviceHostAddress_when_set()
|
||||
{
|
||||
var m = new AbCipTagConfigModel { TagPath = "Motor1.Status", DeviceHostAddress = "ab://host" };
|
||||
|
||||
var json = m.ToJson();
|
||||
|
||||
json.ShouldContain("\"deviceHostAddress\":\"ab://host\"");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
|
||||
|
||||
public sealed class AbLegacyTagConfigModelTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData("{}")]
|
||||
public void FromJson_returns_defaults_for_empty_input(string? json)
|
||||
{
|
||||
var m = AbLegacyTagConfigModel.FromJson(json);
|
||||
|
||||
m.DeviceHostAddress.ShouldBe("");
|
||||
m.Address.ShouldBe("");
|
||||
m.DataType.ShouldBe(AbLegacyDataType.Int);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Round_trip_preserves_all_fields()
|
||||
{
|
||||
var m = new AbLegacyTagConfigModel
|
||||
{
|
||||
DeviceHostAddress = "ab://10.0.0.5",
|
||||
Address = "N7:0",
|
||||
DataType = AbLegacyDataType.Float,
|
||||
};
|
||||
|
||||
var json = m.ToJson();
|
||||
var m2 = AbLegacyTagConfigModel.FromJson(json);
|
||||
|
||||
m2.DeviceHostAddress.ShouldBe("ab://10.0.0.5");
|
||||
m2.Address.ShouldBe("N7:0");
|
||||
m2.DataType.ShouldBe(AbLegacyDataType.Float);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToJson_emits_camelCase_keys_with_enum_names()
|
||||
{
|
||||
var m = new AbLegacyTagConfigModel
|
||||
{
|
||||
DeviceHostAddress = "ab://10.0.0.5",
|
||||
Address = "N7:0",
|
||||
DataType = AbLegacyDataType.Int,
|
||||
};
|
||||
|
||||
var json = m.ToJson();
|
||||
|
||||
json.ShouldContain("\"deviceHostAddress\":\"ab://10.0.0.5\"");
|
||||
json.ShouldContain("\"address\":\"N7:0\"");
|
||||
json.ShouldContain("\"dataType\":\"Int\"");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromJson_then_ToJson_preserves_unknown_keys()
|
||||
{
|
||||
var json = AbLegacyTagConfigModel
|
||||
.FromJson("""{"address":"N7:0","extraKey":"keepme"}""")
|
||||
.ToJson();
|
||||
|
||||
json.ShouldContain("extraKey");
|
||||
json.ShouldContain("keepme");
|
||||
json.ShouldContain("\"address\":\"N7:0\"");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_returns_error_when_address_blank()
|
||||
{
|
||||
new AbLegacyTagConfigModel { Address = "" }.Validate().ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_returns_null_when_address_filled()
|
||||
{
|
||||
new AbLegacyTagConfigModel { Address = "N7:0" }.Validate().ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToJson_omits_blank_deviceHostAddress()
|
||||
{
|
||||
var m = new AbLegacyTagConfigModel { Address = "N7:0", DeviceHostAddress = "" };
|
||||
|
||||
var json = m.ToJson();
|
||||
|
||||
json.ShouldNotContain("deviceHostAddress");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToJson_includes_deviceHostAddress_when_set()
|
||||
{
|
||||
var m = new AbLegacyTagConfigModel { Address = "N7:0", DeviceHostAddress = "ab://host" };
|
||||
|
||||
var json = m.ToJson();
|
||||
|
||||
json.ShouldContain("\"deviceHostAddress\":\"ab://host\"");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
|
||||
|
||||
public sealed class FocasTagConfigModelTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData("{}")]
|
||||
public void FromJson_returns_defaults_for_empty_input(string? json)
|
||||
{
|
||||
var m = FocasTagConfigModel.FromJson(json);
|
||||
|
||||
m.DeviceHostAddress.ShouldBe("");
|
||||
m.Address.ShouldBe("");
|
||||
m.DataType.ShouldBe(FocasDataType.Int32);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Round_trip_preserves_all_fields()
|
||||
{
|
||||
var m = new FocasTagConfigModel
|
||||
{
|
||||
DeviceHostAddress = "192.168.0.1:8193",
|
||||
Address = "MACRO:500",
|
||||
DataType = FocasDataType.Float64,
|
||||
};
|
||||
|
||||
var json = m.ToJson();
|
||||
var m2 = FocasTagConfigModel.FromJson(json);
|
||||
|
||||
m2.DeviceHostAddress.ShouldBe("192.168.0.1:8193");
|
||||
m2.Address.ShouldBe("MACRO:500");
|
||||
m2.DataType.ShouldBe(FocasDataType.Float64);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToJson_emits_camelCase_keys_with_enum_names()
|
||||
{
|
||||
var m = new FocasTagConfigModel
|
||||
{
|
||||
DeviceHostAddress = "192.168.0.1:8193",
|
||||
Address = "R100",
|
||||
DataType = FocasDataType.Int32,
|
||||
};
|
||||
|
||||
var json = m.ToJson();
|
||||
|
||||
json.ShouldContain("\"deviceHostAddress\":\"192.168.0.1:8193\"");
|
||||
json.ShouldContain("\"address\":\"R100\"");
|
||||
json.ShouldContain("\"dataType\":\"Int32\"");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromJson_then_ToJson_preserves_unknown_keys()
|
||||
{
|
||||
var json = FocasTagConfigModel
|
||||
.FromJson("""{"address":"R100","extraKey":"keepme"}""")
|
||||
.ToJson();
|
||||
|
||||
json.ShouldContain("extraKey");
|
||||
json.ShouldContain("keepme");
|
||||
json.ShouldContain("\"address\":\"R100\"");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_returns_error_when_address_blank()
|
||||
{
|
||||
new FocasTagConfigModel { Address = "" }.Validate().ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_returns_null_when_address_filled()
|
||||
{
|
||||
new FocasTagConfigModel { Address = "R100" }.Validate().ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToJson_omits_blank_deviceHostAddress()
|
||||
{
|
||||
var m = new FocasTagConfigModel { Address = "R100", DeviceHostAddress = "" };
|
||||
|
||||
var json = m.ToJson();
|
||||
|
||||
json.ShouldNotContain("deviceHostAddress");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToJson_includes_deviceHostAddress_when_set()
|
||||
{
|
||||
var m = new FocasTagConfigModel { Address = "R100", DeviceHostAddress = "ab://host" };
|
||||
|
||||
var json = m.ToJson();
|
||||
|
||||
json.ShouldContain("\"deviceHostAddress\":\"ab://host\"");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Modbus;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
|
||||
|
||||
public sealed class ModbusTagConfigModelTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData("{}")]
|
||||
public void FromJson_returns_defaults_for_empty_input(string? json)
|
||||
{
|
||||
var m = ModbusTagConfigModel.FromJson(json);
|
||||
|
||||
m.Region.ShouldBe(ModbusRegion.HoldingRegisters);
|
||||
m.Address.ShouldBe(0);
|
||||
m.DataType.ShouldBe(ModbusDataType.Int16);
|
||||
m.ByteOrder.ShouldBe(ModbusByteOrder.BigEndian);
|
||||
m.BitIndex.ShouldBe(0);
|
||||
m.StringLength.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Round_trip_preserves_all_six_fields()
|
||||
{
|
||||
var m = new ModbusTagConfigModel
|
||||
{
|
||||
Region = ModbusRegion.InputRegisters,
|
||||
Address = 40001,
|
||||
DataType = ModbusDataType.Float32,
|
||||
ByteOrder = ModbusByteOrder.WordSwap,
|
||||
BitIndex = 3,
|
||||
StringLength = 16,
|
||||
};
|
||||
|
||||
var json = m.ToJson();
|
||||
var m2 = ModbusTagConfigModel.FromJson(json);
|
||||
|
||||
m2.Region.ShouldBe(ModbusRegion.InputRegisters);
|
||||
m2.Address.ShouldBe(40001);
|
||||
m2.DataType.ShouldBe(ModbusDataType.Float32);
|
||||
m2.ByteOrder.ShouldBe(ModbusByteOrder.WordSwap);
|
||||
m2.BitIndex.ShouldBe(3);
|
||||
m2.StringLength.ShouldBe(16);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToJson_emits_camelCase_keys_with_enum_names()
|
||||
{
|
||||
var m = new ModbusTagConfigModel
|
||||
{
|
||||
Region = ModbusRegion.HoldingRegisters,
|
||||
Address = 100,
|
||||
DataType = ModbusDataType.Int16,
|
||||
ByteOrder = ModbusByteOrder.BigEndian,
|
||||
BitIndex = 0,
|
||||
StringLength = 0,
|
||||
};
|
||||
|
||||
var json = m.ToJson();
|
||||
|
||||
json.ShouldContain("\"region\":\"HoldingRegisters\"");
|
||||
json.ShouldContain("\"dataType\":\"Int16\"");
|
||||
json.ShouldContain("\"byteOrder\":\"BigEndian\"");
|
||||
json.ShouldContain("\"address\":100");
|
||||
json.ShouldContain("\"bitIndex\":0");
|
||||
json.ShouldContain("\"stringLength\":0");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromJson_then_ToJson_preserves_unknown_keys()
|
||||
{
|
||||
var json = ModbusTagConfigModel
|
||||
.FromJson("""{"region":"InputRegisters","addressString":"40001:F"}""")
|
||||
.ToJson();
|
||||
|
||||
json.ShouldContain("addressString");
|
||||
json.ShouldContain("40001:F");
|
||||
// and the exposed field still round-trips
|
||||
json.ShouldContain("\"region\":\"InputRegisters\"");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_returns_null_for_default_model()
|
||||
{
|
||||
new ModbusTagConfigModel().Validate().ShouldBeNull();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.S7;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
|
||||
|
||||
public sealed class S7TagConfigModelTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData("{}")]
|
||||
public void FromJson_returns_defaults_for_empty_input(string? json)
|
||||
{
|
||||
var m = S7TagConfigModel.FromJson(json);
|
||||
|
||||
m.Address.ShouldBe("");
|
||||
m.DataType.ShouldBe(S7DataType.Int16);
|
||||
m.StringLength.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Round_trip_preserves_all_fields()
|
||||
{
|
||||
var m = new S7TagConfigModel
|
||||
{
|
||||
Address = "DB1.DBW0",
|
||||
DataType = S7DataType.Float32,
|
||||
StringLength = 32,
|
||||
};
|
||||
|
||||
var json = m.ToJson();
|
||||
var m2 = S7TagConfigModel.FromJson(json);
|
||||
|
||||
m2.Address.ShouldBe("DB1.DBW0");
|
||||
m2.DataType.ShouldBe(S7DataType.Float32);
|
||||
m2.StringLength.ShouldBe(32);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToJson_emits_camelCase_keys_with_enum_names()
|
||||
{
|
||||
var m = new S7TagConfigModel
|
||||
{
|
||||
Address = "M0.0",
|
||||
DataType = S7DataType.Bool,
|
||||
StringLength = 0,
|
||||
};
|
||||
|
||||
var json = m.ToJson();
|
||||
|
||||
json.ShouldContain("\"address\":\"M0.0\"");
|
||||
json.ShouldContain("\"dataType\":\"Bool\"");
|
||||
json.ShouldContain("\"stringLength\":0");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromJson_then_ToJson_preserves_unknown_keys()
|
||||
{
|
||||
var json = S7TagConfigModel
|
||||
.FromJson("""{"address":"DB1.DBW0","extraKey":"keepme"}""")
|
||||
.ToJson();
|
||||
|
||||
json.ShouldContain("extraKey");
|
||||
json.ShouldContain("keepme");
|
||||
json.ShouldContain("\"address\":\"DB1.DBW0\"");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_returns_error_when_address_blank()
|
||||
{
|
||||
new S7TagConfigModel { Address = "" }.Validate().ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_returns_null_when_address_filled()
|
||||
{
|
||||
new S7TagConfigModel { Address = "DB1.DBW0" }.Validate().ShouldBeNull();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
using System.Text.Json.Nodes;
|
||||
using Shouldly;
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
|
||||
|
||||
public sealed class TagConfigJsonTests
|
||||
{
|
||||
private enum Flavor { None, Vanilla, Chocolate }
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData("{not json")]
|
||||
[InlineData("[1,2]")]
|
||||
public void ParseOrNew_returns_empty_object_for_unusable_input(string? json)
|
||||
{
|
||||
var obj = TagConfigJson.ParseOrNew(json);
|
||||
|
||||
obj.ShouldNotBeNull();
|
||||
obj.Count.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseOrNew_parses_an_object()
|
||||
{
|
||||
var obj = TagConfigJson.ParseOrNew("""{"foo":"bar"}""");
|
||||
|
||||
obj.Count.ShouldBe(1);
|
||||
TagConfigJson.GetString(obj, "foo").ShouldBe("bar");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RoundTrip_preserves_unknown_keys()
|
||||
{
|
||||
var obj = TagConfigJson.ParseOrNew("""{"foo":"bar","region":"X"}""");
|
||||
|
||||
TagConfigJson.Set(obj, "region", "Y");
|
||||
var json = TagConfigJson.Serialize(obj);
|
||||
|
||||
json.ShouldContain("\"foo\":\"bar\"");
|
||||
json.ShouldContain("\"region\":\"Y\"");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetString_returns_null_when_absent()
|
||||
{
|
||||
var obj = new JsonObject();
|
||||
|
||||
TagConfigJson.GetString(obj, "missing").ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetInt_reads_an_int()
|
||||
{
|
||||
var obj = TagConfigJson.ParseOrNew("""{"register":40001}""");
|
||||
|
||||
TagConfigJson.GetInt(obj, "register").ShouldBe(40001);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetInt_falls_back_when_absent()
|
||||
{
|
||||
var obj = new JsonObject();
|
||||
|
||||
TagConfigJson.GetInt(obj, "register", fallback: 7).ShouldBe(7);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetInt_falls_back_when_value_is_a_non_scalar_node()
|
||||
{
|
||||
// A nested object/array under the key must not throw — GetInt returns the fallback.
|
||||
var obj = TagConfigJson.ParseOrNew("""{"register":{},"other":[1,2]}""");
|
||||
|
||||
TagConfigJson.GetInt(obj, "register", fallback: 7).ShouldBe(7);
|
||||
TagConfigJson.GetInt(obj, "other", fallback: 9).ShouldBe(9);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetString_returns_null_when_value_is_a_non_string_node()
|
||||
{
|
||||
var obj = TagConfigJson.ParseOrNew("""{"register":40001,"nested":{}}""");
|
||||
|
||||
TagConfigJson.GetString(obj, "register").ShouldBeNull();
|
||||
TagConfigJson.GetString(obj, "nested").ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetEnum_parses_by_name_case_insensitive()
|
||||
{
|
||||
var obj = TagConfigJson.ParseOrNew("""{"flavor":"chocolate"}""");
|
||||
|
||||
TagConfigJson.GetEnum(obj, "flavor", Flavor.None).ShouldBe(Flavor.Chocolate);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetEnum_falls_back_when_absent()
|
||||
{
|
||||
var obj = new JsonObject();
|
||||
|
||||
TagConfigJson.GetEnum(obj, "flavor", Flavor.Vanilla).ShouldBe(Flavor.Vanilla);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetEnum_falls_back_on_garbage()
|
||||
{
|
||||
var obj = TagConfigJson.ParseOrNew("""{"flavor":"strawberry"}""");
|
||||
|
||||
TagConfigJson.GetEnum(obj, "flavor", Flavor.Vanilla).ShouldBe(Flavor.Vanilla);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Set_writes_an_enum_as_its_name_string()
|
||||
{
|
||||
var obj = new JsonObject();
|
||||
|
||||
TagConfigJson.Set(obj, "flavor", Flavor.Chocolate);
|
||||
var json = TagConfigJson.Serialize(obj);
|
||||
|
||||
json.ShouldContain($"\"flavor\":\"{Flavor.Chocolate}\"");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Set_with_null_removes_the_key()
|
||||
{
|
||||
var obj = TagConfigJson.ParseOrNew("""{"region":"X"}""");
|
||||
|
||||
TagConfigJson.Set(obj, "region", null);
|
||||
|
||||
obj.ContainsKey("region").ShouldBeFalse();
|
||||
TagConfigJson.Serialize(obj).ShouldNotContain("region");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
|
||||
|
||||
public sealed class TwinCATTagConfigModelTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData("{}")]
|
||||
public void FromJson_returns_defaults_for_empty_input(string? json)
|
||||
{
|
||||
var m = TwinCATTagConfigModel.FromJson(json);
|
||||
|
||||
m.DeviceHostAddress.ShouldBe("");
|
||||
m.SymbolPath.ShouldBe("");
|
||||
m.DataType.ShouldBe(TwinCATDataType.DInt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Round_trip_preserves_all_fields()
|
||||
{
|
||||
var m = new TwinCATTagConfigModel
|
||||
{
|
||||
DeviceHostAddress = "5.1.2.3.1.1:851",
|
||||
SymbolPath = "MAIN.bStart",
|
||||
DataType = TwinCATDataType.Real,
|
||||
};
|
||||
|
||||
var json = m.ToJson();
|
||||
var m2 = TwinCATTagConfigModel.FromJson(json);
|
||||
|
||||
m2.DeviceHostAddress.ShouldBe("5.1.2.3.1.1:851");
|
||||
m2.SymbolPath.ShouldBe("MAIN.bStart");
|
||||
m2.DataType.ShouldBe(TwinCATDataType.Real);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToJson_emits_camelCase_keys_with_enum_names()
|
||||
{
|
||||
var m = new TwinCATTagConfigModel
|
||||
{
|
||||
DeviceHostAddress = "5.1.2.3.1.1:851",
|
||||
SymbolPath = "GVL.Counter",
|
||||
DataType = TwinCATDataType.DInt,
|
||||
};
|
||||
|
||||
var json = m.ToJson();
|
||||
|
||||
json.ShouldContain("\"deviceHostAddress\":\"5.1.2.3.1.1:851\"");
|
||||
json.ShouldContain("\"symbolPath\":\"GVL.Counter\"");
|
||||
json.ShouldContain("\"dataType\":\"DInt\"");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromJson_then_ToJson_preserves_unknown_keys()
|
||||
{
|
||||
var json = TwinCATTagConfigModel
|
||||
.FromJson("""{"symbolPath":"GVL.Counter","extraKey":"keepme"}""")
|
||||
.ToJson();
|
||||
|
||||
json.ShouldContain("extraKey");
|
||||
json.ShouldContain("keepme");
|
||||
json.ShouldContain("\"symbolPath\":\"GVL.Counter\"");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_returns_error_when_symbolPath_blank()
|
||||
{
|
||||
new TwinCATTagConfigModel { SymbolPath = "" }.Validate().ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_returns_null_when_symbolPath_filled()
|
||||
{
|
||||
new TwinCATTagConfigModel { SymbolPath = "MAIN.bStart" }.Validate().ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToJson_omits_blank_deviceHostAddress()
|
||||
{
|
||||
var m = new TwinCATTagConfigModel { SymbolPath = "MAIN.bStart", DeviceHostAddress = "" };
|
||||
|
||||
var json = m.ToJson();
|
||||
|
||||
json.ShouldNotContain("deviceHostAddress");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToJson_includes_deviceHostAddress_when_set()
|
||||
{
|
||||
var m = new TwinCATTagConfigModel { SymbolPath = "MAIN.bStart", DeviceHostAddress = "ab://host" };
|
||||
|
||||
var json = m.ToJson();
|
||||
|
||||
json.ShouldContain("\"deviceHostAddress\":\"ab://host\"");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that <see cref="UnsTreeService.LoadTagDriversForEquipmentAsync"/> surfaces each
|
||||
/// candidate driver's <c>DriverType</c> alongside its id and display string, so the UNS TagModal
|
||||
/// can later dispatch to a per-driver-type typed tag-config editor (F-uns-1).
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class UnsTreeServiceTagDriversTests
|
||||
{
|
||||
/// <summary>
|
||||
/// A driver loaded for an equipment carries its <c>DriverType</c> in the returned tuple.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task LoadTagDriversForEquipment_surfaces_driver_type()
|
||||
{
|
||||
var dbName = $"uns-tagdrivers-{Guid.NewGuid():N}";
|
||||
|
||||
using (var db = UnsTreeTestDb.CreateNamed(dbName))
|
||||
{
|
||||
db.ServerClusters.Add(new ServerCluster
|
||||
{
|
||||
ClusterId = "MAIN",
|
||||
Name = "Main",
|
||||
Enterprise = "zb",
|
||||
Site = "warsaw-west",
|
||||
RedundancyMode = RedundancyMode.None,
|
||||
CreatedBy = "test",
|
||||
});
|
||||
db.UnsAreas.Add(new UnsArea { UnsAreaId = "AREA-1", ClusterId = "MAIN", Name = "a" });
|
||||
db.UnsLines.Add(new UnsLine { UnsLineId = "LINE-1", UnsAreaId = "AREA-1", Name = "l" });
|
||||
db.Equipment.Add(new Equipment
|
||||
{
|
||||
EquipmentId = "EQ-1",
|
||||
EquipmentUuid = Guid.NewGuid(),
|
||||
UnsLineId = "LINE-1",
|
||||
Name = "machine-1",
|
||||
MachineCode = "machine_001",
|
||||
});
|
||||
db.Namespaces.Add(new Namespace
|
||||
{
|
||||
NamespaceId = "NS-EQ",
|
||||
ClusterId = "MAIN",
|
||||
Kind = NamespaceKind.Equipment,
|
||||
NamespaceUri = "urn:zb:eq",
|
||||
});
|
||||
db.DriverInstances.Add(new DriverInstance
|
||||
{
|
||||
DriverInstanceId = "DRV-EQ",
|
||||
ClusterId = "MAIN",
|
||||
NamespaceId = "NS-EQ",
|
||||
Name = "equipment driver",
|
||||
DriverType = "ModbusTcp",
|
||||
DriverConfig = "{}",
|
||||
});
|
||||
db.SaveChanges();
|
||||
}
|
||||
|
||||
var service = new UnsTreeService(UnsTreeTestDb.Factory(dbName));
|
||||
|
||||
var drivers = await service.LoadTagDriversForEquipmentAsync("EQ-1");
|
||||
|
||||
drivers.Count.ShouldBe(1);
|
||||
drivers[0].DriverInstanceId.ShouldBe("DRV-EQ");
|
||||
drivers[0].DriverType.ShouldBe("ModbusTcp");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user