31346f1ecd
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
583 lines
34 KiB
Markdown
583 lines
34 KiB
Markdown
# UNS — Global Unified-Namespace Management
|
||
|
||
The **UNS** page (`/uns` in the AdminUI) is the single surface for managing
|
||
the Unified Namespace across the whole fleet. It replaces the old
|
||
per-cluster **UNS**, **Equipment**, and **Tags** tabs and the standalone
|
||
virtual-tags list — those have been removed; everything now lives in one
|
||
global master tree.
|
||
|
||
## The tree
|
||
|
||
The page shows every layer of the UNS as one expandable tree. Equipment is a
|
||
**leaf** in the tree — tags, virtual tags, and alarms live on a dedicated
|
||
equipment page, not inline:
|
||
|
||
```
|
||
Enterprise (read-only grouping — ServerCluster.Enterprise)
|
||
└─ Site / Cluster (read-only grouping — a ServerCluster row)
|
||
└─ Area (editable — UnsArea)
|
||
└─ Line (editable — UnsLine)
|
||
└─ Equipment (leaf — Equipment; opens its own page)
|
||
```
|
||
|
||
**Enterprise and Site/Cluster can be deleted from the tree** (see
|
||
[Cluster and Enterprise delete](#cluster-and-enterprise-delete) below).
|
||
Other than deletion, they are configured on the **Clusters** pages
|
||
(`/clusters`) — on a cluster row the **⚙ settings** link jumps to that
|
||
cluster. Editable UNS entities start at **Area**.
|
||
|
||
Count badges next to a node show how many direct children it has (for
|
||
equipment, the combined tag + virtual-tag count).
|
||
|
||
### Navigating
|
||
|
||
- **Expand all / Collapse all** toggle the structural levels.
|
||
- **Filter by name** does a case-insensitive substring match on the names of
|
||
a node's direct children.
|
||
|
||
## Creating and editing
|
||
|
||
Every editable row has inline actions:
|
||
|
||
| Node | Actions |
|
||
|---|---|
|
||
| Cluster | **+ Area**, Delete |
|
||
| Area | **+ Line**, Edit, Delete |
|
||
| Line | **+ Equipment**, Edit, Delete |
|
||
| Equipment | **Open**, Delete |
|
||
|
||
A **+ Child** action pre-fills the parent for you (e.g. **+ Line** on an
|
||
area opens the Line modal with that area already selected). Build a branch
|
||
top-down: Area → Line → Equipment. Tags, virtual tags, and scripted alarms
|
||
are managed on the equipment page (see below).
|
||
|
||
### Naming: area, line and equipment names are UNS path segments
|
||
|
||
An area, line or equipment **name** becomes a level of the OPC UA NodeId
|
||
(`Area/Line/Equipment/EffectiveName`), so it must match
|
||
**`^[a-z0-9-]{1,32}$`** — lowercase letters, digits and dashes, 1–32
|
||
characters. The reserved `_default` is also accepted. `Packaging` and
|
||
`Line 1` are not valid; `packaging` and `line-1` are.
|
||
|
||
**Ids are a different thing and a different rule.** `UnsAreaId` /
|
||
`UnsLineId` are opaque handles that never reach a NodeId, so they take
|
||
`^[A-Za-z0-9_-]+$` — `SITEA-area1` is a fine id for an area named
|
||
`packaging`.
|
||
|
||
The rule is enforced in `UnsTreeService` on **every** authoring path —
|
||
create, rename, and the CSV import — and again at the deploy gate
|
||
(`UnsSegmentInvalid`). Both call the same `UnsSegment` helper, so they
|
||
cannot disagree.
|
||
|
||
⚠️ **Why it is enforced at authoring and not only at deploy (Gitea #535):**
|
||
until 2026-08-01 only the equipment form checked it, so an area or line
|
||
could be named `Packaging`, save cleanly, and fail the *next deployment* —
|
||
and because a deployment snapshots the **whole database**, one badly-named
|
||
area blocked **every** cluster's deploy, usually discovered by someone other
|
||
than whoever typed it. In the CSV import a bad name is reported as an
|
||
**error**, not a skip: a skip means "you already have this row", which would
|
||
say the equipment exists when it does not.
|
||
|
||
## Equipment page (`/uns/equipment/{id}`)
|
||
|
||
Clicking **Open** on an Equipment row (or **+ Equipment** on a Line) navigates
|
||
to the equipment page. The page is organised into four tabs:
|
||
|
||
| Tab | Content |
|
||
|---|---|
|
||
| **Details** | Equipment identity — name, description, OPC-40010 namespace fields, driver binding, and served-by cluster. (This is the former EquipmentModal, now a full page.) |
|
||
| **Tags** | Equipment-bound driver tags. Each tag uses the driver-typed config editor (same editors as before — Modbus, S7, AB CIP, etc.) with the same client-side validation. |
|
||
| **Virtual Tags** | Virtual tags driven by C# scripts. The inline Monaco script editor with Roslyn IntelliSense, completions, and live diagnostics is available here per tag. |
|
||
| **Alarms** | Scripted-alarm definitions bound to this equipment. Create, edit, or delete predicates here; the Monaco editor is available for each predicate script. |
|
||
|
||
**"Add equipment"** under a Line uses the URL `/uns/equipment/new?lineId=...`,
|
||
pre-filling the parent line. Saving redirects to the new equipment's page.
|
||
|
||
### Served-by cluster
|
||
|
||
An area's **cluster assignment is its "served-by" cluster** — the cluster
|
||
node that runs it. It's set when you create the area (under a cluster) and
|
||
changed by editing the area's cluster in the Area modal, which moves the
|
||
whole branch. There is no separate "served-by" concept and no migration —
|
||
it is simply `UnsArea.ClusterId`.
|
||
|
||
### Tags — reference-only (v3)
|
||
|
||
> **v3 (Batch 3):** UNS equipment no longer authors or binds tags. Device I/O is
|
||
> authored once in the **Raw project tree** (`/raw` — see [`Raw.md`](Raw.md)); an
|
||
> equipment's **Tags** tab holds **references** to those raw tags. The old
|
||
> driver-bound Tag modal on this tab is retired.
|
||
|
||
The **Tags** tab is a list of `UnsTagReference` rows. Each row shows the
|
||
**effective name**, the backing tag's **RawPath**, its inherited **DataType** and
|
||
**AccessLevel** (read-only — they come from the raw tag), and an editable
|
||
**display-name override**. The effective name is the override else the raw tag's
|
||
`Name`.
|
||
|
||
**"+ Add reference"** opens a raw-tree picker (the `/raw` tree in picker mode)
|
||
**scoped to the equipment's cluster** — cross-cluster tags are structurally
|
||
unreachable, and the service also enforces `tag.cluster == equipment.cluster` on
|
||
commit. Tick individual tag leaves, or use a device / tag-group's *"Select all
|
||
tags below"* menu to pull many at once.
|
||
|
||
**Effective-name uniqueness:** within an equipment the effective name must be
|
||
unique across references, VirtualTags, and ScriptedAlarms (they share the
|
||
equipment's UNS NodeId space, `{EquipmentId}/{EffectiveName}`). This is enforced
|
||
both at authoring (a readable rejection naming the colliding source) and at the
|
||
deploy gate (`DraftValidator`, `UnsEffectiveNameCollision`) — the deploy gate is
|
||
what catches **rename-induced** collisions a raw rename produced after the
|
||
reference was authored, naming both colliding sources and the equipment.
|
||
|
||
Removing a raw tag in `/raw` is blocked while a `UnsTagReference` points at it
|
||
(the error names the referencing equipment); renaming a raw tag or any ancestor
|
||
warns when a beneath-it tag is historized (no `historianTagname` override),
|
||
UNS-referenced, or named by a script literal — see [`Raw.md`](Raw.md).
|
||
|
||
**Galaxy / AVEVA System Platform points** are now ordinary raw tags on a
|
||
`GalaxyMxGateway` driver in `/raw`, referenced into equipment like any other raw
|
||
tag. There is no separate alias concept or `SystemPlatform`-kind namespace.
|
||
|
||
### OPC UA address-space projection (v3 Batch 4 — dual namespace)
|
||
|
||
> **v3 (Batch 4):** the server now exposes the address space under **two OPC UA
|
||
> namespaces** instead of the old single `https://zb.com/otopcua/ns`:
|
||
>
|
||
> | Namespace URI | Subtree | NodeId `s=` scheme |
|
||
> |---|---|---|
|
||
> | `https://zb.com/otopcua/raw` | the `/raw` device tree (Folder → Driver → Device → TagGroup → Tag) | the node's **RawPath** (e.g. `Plant/Modbus/dev1/Speed`) |
|
||
> | `https://zb.com/otopcua/uns` | the UNS tree (Area → Line → Equipment → signal) | the slash-joined **`Area/Line/Equipment/EffectiveName`** |
|
||
|
||
Every device value has **exactly one source** — the raw tag's node in the Raw
|
||
namespace. A `UnsTagReference` projects that raw tag into an equipment as a
|
||
**UNS-namespace variable** whose NodeId leaf is the reference's **effective name**
|
||
(the display-name override else the raw tag's `Name`). The UNS variable does not
|
||
bind a driver of its own: it carries an **`Organizes` reference to its backing raw
|
||
node** and mirrors it. A single driver publish for a RawPath **fans out** to the
|
||
raw NodeId AND every referencing UNS NodeId with identical value / quality /
|
||
source-timestamp — the two NodeIds never drift.
|
||
|
||
- **Reads / subscriptions** work through either NodeId and return the same data.
|
||
- **Writes** route through either NodeId to the same backing driver ref under the
|
||
**same `WriteOperate` gating** (a UNS write is neither more nor less privileged
|
||
than the raw write it fans from); a failed device write reverts both NodeIds via
|
||
the shared fan-out.
|
||
- **HistoryRead** works through either NodeId and returns the same series — both
|
||
NodeIds register the **same historian tagname** (see [`Historian.md`](Historian.md)).
|
||
- **Native alarms** materialize once at the raw tag (`ConditionId = RawPath`) and
|
||
fan via SDK notifiers to the raw device folder AND every referencing equipment
|
||
folder — see [`ScriptedAlarms.md`](ScriptedAlarms.md) / [`AlarmTracking.md`](AlarmTracking.md).
|
||
|
||
Note the two distinct identity strings: the wire **NodeId** is the path
|
||
`Area/Line/Equipment/EffectiveName`, while the **effective-name uniqueness key**
|
||
above is `{EquipmentId}/{EffectiveName}` (the logical per-equipment collision
|
||
space the guards enforce). They are related but not the same string.
|
||
|
||
### Virtual tags
|
||
|
||
A virtual tag is bound to an equipment and driven by a **script** (no driver).
|
||
Add and edit virtual tags on the equipment page's **Virtual Tags** tab; the
|
||
data type is chosen from the standard OPC UA type list and the Monaco script
|
||
editor is available inline. Scripts may read the equipment's references
|
||
relative-to-equipment with **`ctx.GetTag("{{equip}}/<RefName>")`** — the token
|
||
resolves through the equipment's `UnsTagReference` rows (by effective name) to
|
||
the backing raw tag's RawPath at deploy; an unresolved `<RefName>` is a deploy
|
||
error and a live Monaco diagnostic (editor accepts ⇔ publish accepts). See
|
||
[`ScriptEditor.md`](ScriptEditor.md).
|
||
|
||
### Galaxy tags
|
||
|
||
`GalaxyMxGateway` is an **Equipment-kind driver** — Galaxy points are ordinary
|
||
equipment tags authored through the standard Tag modal, exactly like Modbus or
|
||
S7 tags. The Galaxy reference is stored as `TagConfig.FullName`
|
||
(`tag_name.AttributeName`). The Galaxy address picker on the Tags tab lets you
|
||
browse the live Galaxy hierarchy to select an attribute; after selecting, set
|
||
the tag **Name**, **DataType**, and **AccessLevel** (default: read-only). There
|
||
is no alias concept, no `SystemPlatform`-kind namespace, and no relay→alias
|
||
converter.
|
||
|
||
## Typed TagConfig editors
|
||
|
||
The **Tag modal** dispatches a driver-typed config editor for the following
|
||
drivers:
|
||
|
||
| Driver | Fields in the typed editor |
|
||
|---|---|
|
||
| Modbus | Register type, address, data type, word order, etc. |
|
||
| S7 | Data block, offset, data type, etc. |
|
||
| AB CIP | Tag path, data type, etc. |
|
||
| AB Legacy (DF1/DH+) | Address, data type, etc. |
|
||
| TwinCAT | Symbol path, data type, etc. |
|
||
| FOCAS | PMC address, data type, etc. |
|
||
| **OpcUaClient** | `FullName` (the remote OPC UA node id string) |
|
||
|
||
**OpcUaClient** was previously raw-JSON fallback only; it now has a first-class
|
||
typed editor that exposes a single `FullName` field (PascalCase JSON key,
|
||
consistent with the Galaxy editor convention). It is registered in
|
||
`TagConfigEditorMap` and `TagConfigValidator`; unknown keys in the stored JSON
|
||
blob are preserved on round-trip.
|
||
|
||
> The historian backend is the external HistorianGateway (no OtOpcUa-side tag
|
||
> driver / tag-config editor). See [Historian.md](Historian.md).
|
||
|
||
Drivers not yet listed above (e.g. Galaxy — which uses the Galaxy address
|
||
picker described below) still use the generic raw-`TagConfig`-JSON textarea.
|
||
|
||
### "Build address" pickers in protocol-driver editors
|
||
|
||
The **Modbus, S7, AB CIP, AB Legacy, TwinCAT, and FOCAS** typed editors
|
||
include a **Build address** button. Clicking it opens the driver's existing
|
||
address-builder UI inside the shared `DriverTagPicker` overlay; confirming a
|
||
selection writes the fully constructed address string back into the editor's
|
||
address field. This means you can visually compose a register reference
|
||
(e.g. select "Holding Register → 100 → Int16") and have it serialised into
|
||
the correct JSON without hand-editing.
|
||
|
||
## Historizing tags (first-class controls)
|
||
|
||
The Tag modal exposes **Historize this tag** and **Historian tagname
|
||
(override)** as explicit controls that work for **all drivers** — including
|
||
protocol drivers (Modbus, S7, etc.) that use the typed editor and raw-JSON
|
||
drivers (Galaxy) alike.
|
||
|
||
| Control | JSON key | Type | Behaviour |
|
||
|---|---|---|---|
|
||
| **Historize this tag** checkbox | `isHistorized` | bool | When checked, the OPC UA node materialises with `Historizing=true` and the `HistoryRead` AccessLevel bit set. |
|
||
| **Historian tagname (override)** textbox | `historianTagname` | string (optional) | Explicit tagname the historian backend will query. When left blank, the server defaults to the tag's driver `FullName`. |
|
||
|
||
These fields are merged into the `TagConfig` JSON blob via the pure
|
||
`TagHistorizeConfig` helper, which preserves all other keys byte-stable.
|
||
The server's OPC UA HistoryRead dispatch already consumes these keys from the
|
||
`TagConfig` blob — see [Historian.md](Historian.md) for the full server
|
||
behaviour, continuation-point paging, and aggregates.
|
||
|
||
> **Note — native-alarm `HistorizeToAveva`:** a tag that carries a native
|
||
> `"alarm"` object has a **separate** opt-out field `alarm.historizeToAveva`
|
||
> (a checkbox labelled "Historize to AVEVA" on the Tag modal's alarm section).
|
||
> That field controls whether the alarm's **transition events** are written to
|
||
> the AVEVA historian — it does not affect tag-value history (which is
|
||
> controlled by `isHistorized`). See [ScriptedAlarms.md §Native driver
|
||
> alarms](ScriptedAlarms.md#native-driver-alarms-equipment-tag-path) for
|
||
> details.
|
||
|
||
## Array tags (1-D)
|
||
|
||
A tag can be made a **1-D OPC UA array node** by setting two keys in its `TagConfig`
|
||
JSON blob. The controls are exposed as first-class UI fields in the Tag modal (an
|
||
**Is array** checkbox + an **Array length** numeric input), available for all drivers
|
||
— typed editors (Modbus, S7, etc.) and the raw-JSON textarea (Galaxy) alike.
|
||
|
||
### Canonical rule
|
||
|
||
| Condition | OPC UA node shape |
|
||
|---|---|
|
||
| `isArray: true` AND `arrayLength >= 1` | 1-D array node (`ValueRank = OneDimension`, `ArrayDimensions = [arrayLength]`) |
|
||
| `isArray: false` (any `arrayLength`) | Scalar node (default) |
|
||
| `isArray` absent | Scalar node (default) |
|
||
|
||
A single-element array (`isArray: true, arrayLength: 1`) is valid and materialises as a
|
||
`[1]` node.
|
||
|
||
### Fields
|
||
|
||
| Field | Type | Required | Description |
|
||
|---|---|---|---|
|
||
| `isArray` | bool | no | When `true` (and `arrayLength >= 1`), makes the node a 1-D array. Absent or `false` → scalar. |
|
||
| `arrayLength` | uint (≥ 1) | when `isArray: true` | The element count for the OPC UA `ArrayDimensions` declaration. Must be ≥ 1 when `isArray` is set. |
|
||
|
||
UI validation rejects `arrayLength = 0` when `isArray` is checked.
|
||
|
||
### Examples
|
||
|
||
```json
|
||
{"FullName":"PLC1.TemperatureArray","isArray":true,"arrayLength":10}
|
||
```
|
||
|
||
```json
|
||
{"Register":"HR100","DataType":"Float32","isArray":true,"arrayLength":5}
|
||
```
|
||
|
||
Combined with historization (values are arrays — history of the whole array snapshot):
|
||
|
||
```json
|
||
{"Register":"HR200","DataType":"Int16","isArray":true,"arrayLength":20,"isHistorized":true}
|
||
```
|
||
|
||
### Per-driver read mechanism and live-verify status
|
||
|
||
| Driver | Read mechanism | Live-verify |
|
||
|---|---|---|
|
||
| **Modbus** | Contiguous FC03/FC04 block (`arrayLength × registers-per-element`); String and BitInRegister array modes also supported | Mac-verifiable (sim `10.100.0.35:5020`) |
|
||
| **S7** | Contiguous `ReadBytesAsync` block over the declared address span + per-element decode loop | Unit-proven (sim fixture down) |
|
||
| **AB CIP** | libplctag native array read (atomic and UDT member arrays) | Unit-proven (sim fixture down) |
|
||
| **AB Legacy** | PCCC multi-element file read via libplctag (cap 256 elements) | Unit-proven (sim fixture down) |
|
||
| **TwinCAT** | Raw-bytes read of the declared `SymbolPath` at the resolved array type's layout + per-element decode; native ADS notifications carry the whole array (same decode). Multi-dim PLC arrays publish flattened; a shorter `arrayLength` reads the leading sub-range | Live-verified 2026-10-07 on two TwinCAT 2.11 PLCs (see [TwinCAT.md §Array tags](drivers/TwinCAT.md#array-tags)) |
|
||
|
||
### Array writes (#610)
|
||
|
||
An authored-`ReadWrite` array tag is **writable only when its driver can write that array** —
|
||
the server never advertises a write the driver will refuse. The capability is one table,
|
||
`DriverArrayWriteSupport` (`Core.Abstractions`), consulted by the composer at compose time and
|
||
carried on the plan records (`RawTagPlan.ArrayWritable`, mirrored onto every UNS reference as
|
||
`UnsReferenceVariable.BackingArrayWritable`); the node is writable iff
|
||
`Writable && (!IsArray || ArrayWritable)`, on the raw node and every UNS mirror alike.
|
||
|
||
| Driver | Array writes | Element types |
|
||
|---|---|---|
|
||
| **Modbus** | Yes | `region: Coils` (FC15, any element, ≤ 1968 coils); `region: HoldingRegisters` with `Int16`/`UInt16`/`Bcd16`/`Int32`/`UInt32`/`Bcd32`/`Float32`/`Int64`/`UInt64`/`Float64` (FC16) whose `arrayLength × registers-per-element` fits `maxRegistersPerWrite` (default 123; the smallest value set on the driver or the tag's device wins) — the driver refuses rather than splits an over-cap FC16, so e.g. a 62-element `Int32` array (124 registers) stays read-only. Not BitInRegister / String / Bool-in-register; input regions are read-only |
|
||
| **S7** | Yes | `Byte`/`Int16`/`UInt16`/`Int32`/`UInt32`/`Float32` (one contiguous block). Not `Bool` (would clobber neighbouring bits) nor the wide / String / DateTime types |
|
||
| **OpcUaClient** | Yes | All — forwarded upstream unchanged; the upstream server decides (shape included) |
|
||
| **Galaxy** | Yes | OPC UA `Boolean`/`Int32`/`Int64`/`Float`/`Double`/`String`/`DateTime` (the `MxValueEncoder` array variants) — **only with a declared `arrayLength`**: an undeclared length materialises an unbounded node, and a short whole-array write would *resize* the Galaxy attribute (mxaccessgw contract), so such a node stays read-only |
|
||
| AB CIP, AB Legacy, TwinCAT, FOCAS, MQTT, MTConnect, Sql, Calculation | **No** — scalar-only write encoders; array nodes stay read-only and a client write is `BadNotWritable` | — |
|
||
|
||
Modbus, S7 and Galaxy additionally need a **declared length** — `isArray: true` *and* an integer
|
||
`arrayLength >= 1`. The Modbus and S7 tag factories read anything else as a **scalar** tag (whose
|
||
scalar encoder cannot take an array), even though the composer still materialises an array node;
|
||
that node stays read-only.
|
||
|
||
Each "Yes" row is pinned against the driver's real encoder by a parity test in that driver's
|
||
test project (`*ArrayWriteSupportParityTests`), so a driver gaining or losing an array encoder
|
||
fails a test until the table follows.
|
||
|
||
What a client sees on a writable array node:
|
||
|
||
- **Whole-array writes only.** The value must be a 1-D array of exactly `arrayLength` elements
|
||
(an unbounded `arrayLength`/`ArrayDimensions = [0]` takes any length — reachable only on
|
||
OpcUaClient); anything else is `BadTypeMismatch`, refused inline before dispatch.
|
||
- **The element type must match the node's DataType, or be a numeric type that converts to it
|
||
exactly.** A matching element type (or a `Byte` array, which arrives as a ByteString) is dispatched
|
||
as written. A **numeric** array of another numeric element type is **converted, checked, element by
|
||
element** into the node's element type, and the converted array is what the node stores and the
|
||
driver receives — an `Int32[]` written to an `Int16` array reaches the driver as `short[]`. The whole
|
||
write is refused `BadTypeMismatch` inline, nothing dispatched, if **any** element is out of the
|
||
target's range, fractional for an integer node (`1.5` → `Int16` refused; `2.0` → `2`), NaN/±∞ for an
|
||
integer node, or finite but beyond `Float` range for a `Float` node. Every **non-numeric** mismatch
|
||
(`Boolean[]` → `Int32`, `Int32[]` → `String`, a `Variant[]`) is refused. This is the check the OPC UA
|
||
SDK would normally make itself: it skips its own type check on any node with an `OnWriteValue`
|
||
handler, which every writable equipment node has, so the node manager makes it instead. Without it a
|
||
`UInt32[]` reached Galaxy's `int[]` encoder by CLR array covariance and was written bit-reinterpreted
|
||
(4,000,000,000 → −294,967,296) with a Good result — that write is still refused (4,000,000,000
|
||
overflows `Int32`), while an in-range `UInt32[]` converts. The conversion is required, not a
|
||
nicety: ScadaBridge writes the CLR list it decoded from a `List` attribute, and its element types are
|
||
only Boolean/Int32/Float/Double/String/DateTime, so an exact-match rule left every `Int16`/`UInt16`/
|
||
`Byte`/`UInt32`/`Int64`/`UInt64` array node (Modbus's default `Int16` included) and a `Double` list to
|
||
a `Float` array unwritable from it. **Scalar** writes keep their looser rule — any numeric to any
|
||
numeric node, unconverted — because every driver's scalar encoder converts with a range check, so an
|
||
out-of-range scalar fails the device write and is reverted.
|
||
- An **array value to a scalar node** is refused `BadTypeMismatch` inline **only on Modbus and S7**,
|
||
whose scalar encoders cannot take one (`DriverArrayWriteSupport.RefusesArrayValueOnScalarTag`). Every
|
||
other driver keeps the pre-#610 behaviour — the write is dispatched and the driver decides. That
|
||
matters for browse-committed tags: the `/raw` commit sets the DataType but never `isArray`, so an
|
||
upstream array variable (OpcUaClient) or a Galaxy array attribute committed from the browser is a
|
||
**scalar** node into which the driver publishes an array, and writing that array back still works
|
||
(OpcUaClient forwards it to the upstream server, which arbitrates the shape; Galaxy encodes it). Set
|
||
`isArray` + `arrayLength` on such a tag to get the array-node checks above.
|
||
- A partial **IndexRange** write is
|
||
`BadWriteNotSupported` — every array-capable driver writes the whole block, and the server
|
||
does not hold the device array authoritatively enough to read-modify-write it.
|
||
- The same realm-qualified `WriteOperate` / ACL gate as a scalar applies to both NodeIds, and a
|
||
device-refused array write is reverted on the NodeId it was written through (the scalar
|
||
write-outcome self-correction, unchanged).
|
||
- Flipping a tag's array-write capability (a driver-type or element-type edit on an existing
|
||
array tag) is a **surgical** in-place writability update, not a rebuild: `ArrayWritable` is on
|
||
the classifier's raw-tag whitelist, and the mirrors repair through `BackingArrayWritable`. A
|
||
change to the array-on-scalar refusal (`ScalarRefusesArrayValue` — only a driver-type edit can flip
|
||
it) is deliberately NOT whitelisted and rebuilds.
|
||
|
||
> ⚠️ **Upgrade note — array tags become client-writable without a deploy.** Before #610 every
|
||
> array node was read-only regardless of its authored `AccessLevel`. On the first boot after
|
||
> upgrading, every array tag **authored `ReadWrite`** on Modbus, S7, OpcUaClient or Galaxy whose
|
||
> shape the table above accepts is materialised **writable** — no deployment needed, since the
|
||
> capability is derived at compose time (a `FetchAndCache` node re-composes from its cached
|
||
> artifact). Any client holding `WriteOperate` (or, with `Security:Acl:Enabled`, a `WriteOperate`
|
||
> grant covering the tag) can then write whole PLC arrays. Before upgrading, audit array tags
|
||
> authored `ReadWrite` that were never meant to be written and set them `Read`; array tags on the
|
||
> other drivers are unaffected.
|
||
|
||
### Out of scope (named deferrals)
|
||
|
||
- **Multi-dimensional arrays** (`ValueRank > 1`) — **out of scope by decision** (#610 item 3),
|
||
not merely deferred. Every driver's array read decodes a flat 1-D block (TwinCAT already
|
||
publishes multi-dim PLC arrays flattened), and the node manager materialises every array
|
||
`ValueRank = OneDimension` with a single `ArrayDimensions` entry. Supporting rank > 1 would
|
||
need per-rank dimensions in TagConfig, the plan records, the classifier whitelist, the node
|
||
manager and every driver's decode/encode, and no consumer has asked for it. Author a
|
||
multi-dimensional PLC array as a flat 1-D tag (row-major), or as several 1-D tags.
|
||
- **Array historization** — a historized array tag materialises with the correct `Historizing` flag, but the historian backend treats the value as an opaque blob; per-element history is out of scope. (Continuous historization is numeric-analog only — array / non-numeric values are not recorded.)
|
||
|
||
See the individual driver docs under `docs/drivers/` for per-driver implementation details.
|
||
|
||
## Writable equipment tags (`writable` key)
|
||
|
||
An equipment tag is **writable by default** — the materialised OPC UA node is created
|
||
`CurrentReadWrite` and inbound client writes are routed to the device (subject to the
|
||
`WriteOperate` gate — a role the session's API-key principal earns from its `NodeAcl` grants, see
|
||
[security.md](security.md)). Setting the optional `"writable": false` key in a tag's
|
||
`TagConfig` makes the node **read-only** at the driver seam. The six typed tag editors
|
||
(Modbus, S7, AB CIP, AB Legacy, TwinCAT, FOCAS) expose this as a **Writable** checkbox
|
||
(checked by default); it can also be hand-authored in the raw-JSON textarea.
|
||
|
||
| Condition | Node |
|
||
|---|---|
|
||
| `writable` absent | Writable (driver default) — existing configs are unchanged |
|
||
| `writable: false` | Read-only |
|
||
| `writable: true` | Writable |
|
||
|
||
The key is honoured by Modbus, S7, AB CIP, AB Legacy, and TwinCAT. **FOCAS forces
|
||
read-only regardless** — FOCAS PMC writes are unsupported in the wire client, so its
|
||
editor shows the checkbox disabled/unchecked and any authored `writable:true` is ignored
|
||
(and flagged at the deploy gate below). Node-level authz (`WriteOperate` via the API-key principal's grants, not LDAP) remains the
|
||
effective write gate above this flag.
|
||
|
||
## Deploy-time TagConfig validation (`Deployment:TagConfigValidationMode`)
|
||
|
||
The deploy gate inspects every equipment tag's `TagConfig` per its bound driver type and
|
||
surfaces **strictness warnings** — a present-but-invalid enum value (e.g. a typo'd
|
||
`dataType`, which the lenient runtime silently defaults to the wrong width with `Good`
|
||
status), a structurally-unparseable TagConfig, or (FOCAS) an authored `writable:true`.
|
||
|
||
The behaviour is controlled by `Deployment:TagConfigValidationMode` in `appsettings.json`:
|
||
|
||
| Mode | Behaviour |
|
||
|---|---|
|
||
| `Warn` (default) | Non-blocking: warnings are logged and appended to the deployment result message; the deploy still succeeds. Existing configs never break. |
|
||
| `Error` | Blocking: warnings fold into the draft-gate reject list — a config with a typo'd enum (or unparseable TagConfig) cannot be re-deployed until fixed. Opt-in per environment once the fleet deploys warning-clean. |
|
||
|
||
Running servers are untouched either way — the gate only sees re-deploys. Unmapped drivers
|
||
(Galaxy, OpcUaClient) are skipped. **Unknown keys are never warned on**: the `TagConfig`
|
||
blob is a shared namespace (platform intent keys coexist with per-driver address keys and
|
||
forward-compat keys the typed editors preserve), so there is no single schema to validate
|
||
against — parsers read known fields and ignore the rest by design.
|
||
|
||
> **Note (runtime strictness is deferred).** In this release the runtime parsers stay
|
||
> *lenient* (a typo'd enum still silently defaults) — only the deploy gate reports it.
|
||
> Flipping the runtime parsers to strict (a typo'd enum ⇒ `BadNodeIdUnknown` instead of a
|
||
> wrong-width `Good`) is a deliberate future phase, to be taken only after fleets have run
|
||
> `Error` mode clean. Tracked in `archreview/plans/STATUS.md`.
|
||
|
||
## Galaxy address picker — native-alarm pre-fill
|
||
|
||
When the Galaxy address picker selects an attribute that is itself an alarm
|
||
(`IsAlarm == true` in the Galaxy hierarchy), the Tag modal automatically
|
||
seeds a default `alarm` object in the tag config:
|
||
|
||
```json
|
||
{"alarmType":"OffNormalAlarm","severity":700}
|
||
```
|
||
|
||
This lets the operator author the native alarm in a single picker pass
|
||
without hand-editing JSON. The pre-fill **never overwrites** an alarm object
|
||
that is already present — if the tag already has a custom `alarm` section, the
|
||
picker leaves it untouched.
|
||
|
||
## Cluster and Enterprise delete
|
||
|
||
### Cluster delete
|
||
|
||
A **Delete** action is available on Cluster rows in the UNS tree. The server
|
||
refuses the delete if the cluster still has any Areas (children) — the same
|
||
refuse-if-children guard used by Area and Line delete. Remove all Areas (and
|
||
their descendant Lines/Equipment) first, then delete the cluster.
|
||
|
||
> **No RowVersion concurrency check.** `ServerCluster` does not carry a
|
||
> concurrency token, so the delete does not have the last-writer-wins protection
|
||
> that Area/Line/Equipment deletes have. A follow-up migration will add the
|
||
> token; for now, coordinate cluster deletes manually.
|
||
|
||
### Enterprise delete
|
||
|
||
An **Enterprise** row is a read-only grouping label (the `Enterprise` column
|
||
of `ServerCluster`) — it is not a separate entity. Deleting an Enterprise row
|
||
**deletes all clusters whose `Enterprise` matches that label**, all-or-nothing:
|
||
|
||
- If any cluster under the enterprise still has children, the entire delete is
|
||
refused and no clusters are removed.
|
||
- If all clusters are empty, every cluster under that enterprise is deleted in a
|
||
single transaction.
|
||
|
||
Remove all Areas under every cluster in the enterprise first, then delete the
|
||
enterprise label.
|
||
|
||
## Create-new-script inline (virtual-tag panel)
|
||
|
||
On the equipment page's **Virtual Tags** tab, when a virtual tag is not yet
|
||
bound to any script, the inline script panel shows a **Create new script**
|
||
button. Clicking it:
|
||
|
||
1. Generates a new blank Script record with an auto-generated `SC-…` id.
|
||
2. Binds the virtual tag to that script.
|
||
3. Expands the Monaco editor inline so you can begin authoring immediately.
|
||
|
||
This removes the previous two-step flow (create a script on a separate page,
|
||
then attach it to the virtual tag); the entire lifecycle now lives on the
|
||
equipment page.
|
||
|
||
## Hosts page per-driver-instance rows (deferred)
|
||
|
||
Phase 6 did **not** implement per-driver-instance status rows on the Hosts
|
||
page. This item (H7-runtime) is **F7-runtime-blocked**: the runtime plumbing
|
||
needed to surface per-instance health rows is not yet in place. It remains on
|
||
the backlog.
|
||
|
||
## Bulk import
|
||
|
||
**Import equipment CSV** (toolbar) bulk-creates equipment across many lines
|
||
and clusters in one pass. After an import the whole tree reloads.
|
||
|
||
## Applying changes
|
||
|
||
Edits here change the configuration only. As the page header notes,
|
||
**changes apply on the next deployment** — run a **Deploy** (Deployments
|
||
page) to push them into the running address space.
|
||
|
||
### What a deploy actually tears down (surgical deploys, 2026-08-15)
|
||
|
||
Adding, removing or re-pointing a UNS **tag reference** no longer rebuilds the
|
||
address space. It used to: `RawTagPlan.ReferencingEquipmentPaths` participates in
|
||
the backing raw tag's record equality, so touching one reference flipped that tag to
|
||
Changed and the classifier's blanket `ChangedRawTags ⇒ Rebuild` rule tore down and
|
||
recreated **every** node on the server — killing every client's subscriptions for a
|
||
one-variable edit. Now **every** `UnsReferenceDelta` is applied surgically, and so is
|
||
a **non-alarm** raw-tag delta over the in-place-updatable attributes
|
||
(`Writable` / `IsHistorized` / `HistorianTagname` / `DataType` / `IsArray` /
|
||
`ArrayLength`). A **full rebuild** is still forced by an **alarm-bearing** raw-tag
|
||
delta (only a rebuild re-wires native-alarm notifiers), an **identity** change on a
|
||
raw tag (`TagId` / `DriverInstanceId` / `Name` / `ParentNodeId` / `Realm`), a raw
|
||
**container** rename or removal, and any changed equipment / scripted alarm. The
|
||
classifier stays default-closed — anything its whitelist does not name still rebuilds,
|
||
and any surgical failure ratchets back to a rebuild.
|
||
|
||
**Mirror repair.** A UNS reference variable mirrors its backing raw tag, so a raw-side
|
||
historian or shape edit must reach it too — `UnsReferenceVariable` carries the backing
|
||
tag's `IsHistorized` / `HistorianTagname` / `IsArray` / `ArrayLength`, which makes the
|
||
reference record unequal on such an edit and emits a **coincident** `UnsReferenceDelta`
|
||
alongside the raw one. **Identity-preserving** repairs (the mirror keeps the same
|
||
NodeId, equipment and backing RawPath) are applied **in place**, so the mirror's
|
||
subscriptions survive an operator historizing or reshaping the tag behind it. Only an
|
||
**identity change** — a re-point to a different raw tag, or an effective-name/display-name
|
||
edit that moves the NodeId — is a remove + recreate, and it affects **that one node**,
|
||
not the tree.
|
||
|
||
⚠️ Two consequences worth knowing. A surgically **added** UNS reference variable *is*
|
||
announced (Part 3 `GeneralModelChangeEvent`), but an added **raw** container/tag is
|
||
not — clients pick those up on their next browse. And a **degraded** non-rebuild apply
|
||
(some node failed to materialise) deliberately does not record its composition as
|
||
applied, so the next deploy re-diffs and repairs it.
|
||
|
||
## Quality on a UNS node
|
||
|
||
A UNS reference variable mirrors its backing raw tag exactly — value, status and timestamps — so when
|
||
that tag's driver stops communicating the mirror goes **`BadNoCommunication` with its last value
|
||
retained**, in the same fan-out message as the raw node, and clears only when the driver re-publishes.
|
||
A VirtualTag on the same equipment follows the same rule from its own inputs: it holds while any input
|
||
is Bad and reports that input's sub-code. The contract, and every seam that implements it, is stated
|
||
once in [`docs/DriverLifecycle.md` §"The quality contract"](DriverLifecycle.md#the-quality-contract);
|
||
what a script sees is in
|
||
[`docs/ScriptEditor.md`](ScriptEditor.md#input-quality-what-a-script-sees-and-when-it-runs-at-all).
|
||
|
||
## See also
|
||
|
||
- [Configuration.md](Configuration.md) — the underlying config entities.
|
||
- [VirtualTags.md](VirtualTags.md) — the scripting/virtual-tag engine.
|
||
- [ScriptedAlarms.md](ScriptedAlarms.md) — scripted-alarm engine internals (predicates, state machine, ack/shelve).
|
||
- Design + decision log: [plans/2026-06-08-global-uns-management-design.md](plans/2026-06-08-global-uns-management-design.md).
|