# OPC UA for Weighing Technology (OPC 40200)
The `Opc.Ua.Scales*` packages implement
[OPC 40200](https://reference.opcfoundation.org/specs/OPC-40200/full)
Weighing Technology 2.00, the companion specification for scales and scale
systems. It builds on OPC 40001-1 Machinery, OPC 10000-100 Device
Integration and OPC 30050 PackML.
- **Server side:** fluent builders publish scales with a working runtime.
That runtime covers weighing arithmetic, zero and tare, product handling,
statistics, the type-specific methods, recipes, PackML state machines,
events and alarms.
- **Client side:** a client discovers the scales, reads them into typed
snapshots, drives their methods and streams weights and notifications.
## Contents
- [Library layout](#library-layout)
- [The model](#the-model)
- [Where a scale lives](#where-a-scale-lives)
- [Quick start — server](#quick-start--server)
- [Quick start — client](#quick-start--client)
- [Server hosting](#server-hosting)
- [`AddScales` and `ConfigureScales`](#addscales-and-configurescales)
- [Composing with Machinery and other companions](#composing-with-machinery-and-other-companions)
- [Direct composition and lifetime](#direct-composition-and-lifetime)
- [Options](#options)
- [Creating scales](#creating-scales)
- [Builder reference](#builder-reference)
- [`IScaleBuilder`](#iscalebuilder)
- [`IProductionPresetBuilder`](#iproductionpresetbuilder)
- [`IScaleSystemBuilder` and `IScaleModuleBuilder`](#iscalesystembuilder-and-iscalemodulebuilder)
- [Runtime reference](#runtime-reference)
- [`ScaleHandle`](#scalehandle)
- [`ScaleNotifications`](#scalenotifications)
- [`ScaleProductionPreset`](#scaleproductionpreset)
- [`ScaleStatistics`](#scalestatistics)
- [Type controllers](#type-controllers)
- [Recipes](#recipes)
- [PackML](#packml)
- [Client reference](#client-reference)
- [Creating the client](#creating-the-client)
- [Discovery](#discovery)
- [Weighing](#weighing)
- [Products, PackML, type methods and recipes](#products-packml-type-methods-and-recipes)
- [Notifications and snapshots](#notifications-and-snapshots)
- [Contracts reference](#contracts-reference)
- [Conformance matrix](#conformance-matrix)
- [Facets](#facets)
- [Cross-cutting conformance units and behaviour](#cross-cutting-conformance-units-and-behaviour)
- [Samples](#samples)
- [Testing](#testing)
- [Troubleshooting](#troubleshooting)
- [Known limitations](#known-limitations)
- [Model provenance](#model-provenance)
- [Where the specification and the NodeSet disagree](#where-the-specification-and-the-nodeset-disagree)
- [See also](#see-also)
## Library layout
| Package | Contents |
| --- | --- |
| `Opc.Ua.PackML` | The source-generated OPC 30050 PackML model. It also has `PackMLStateMachineController`, which drives the nested base / machine / execute state machines with their transition events |
| `Opc.Ua.Scales` | The source-generated OPC 40200 model. `ScalesModel` maps the 14 scale kinds, their product types and the Annex C notifications. `ScalesProfiles` holds the 21 facet URIs. The package also has the contracts both sides share |
| `Opc.Ua.Scales.Server` | `ScalesNodeManager`, the shared `ScalesRuntime`, scale/system/module/preset builders, runtime handles and controllers, and standalone or additive Machinery hosting |
| `Opc.Ua.Scales.Client` | `ScalesClient` for discovery, weighing, products, PackML, the type methods, recipes, snapshots and streaming. Also `AddScalesClient()` |
The contracts (`ScaleIdentification`, `WeighingRangeDefinition`,
`ScaleReading`, `ScaleSnapshot`, …) live in the model package. The server
fills them from its process and the client reads them back, and neither side
references the other.
All four packages target the stack's library frameworks. They are marked
NativeAOT-compatible on `net10.0`, and so are both samples.
## The model
| Model URI | C# namespace | Model loader |
| --- | --- | --- |
| `http://opcfoundation.org/UA/Scales/V2/` | `Opc.Ua.Scales` | `AddOpcUaScales` |
| `http://opcfoundation.org/UA/PackML/` | `Opc.Ua.PackML` | `AddOpcUaPackML` |
A Scales server must also expose the DI, IA, Machinery and PackML namespaces
(§13.1, Table 186). `ScalesNodeManager` loads all five into one node manager.
`ScaleDeviceType` is abstract. Every scale is one of 14 concrete subtypes,
and `ScaleKind` names them:
| `ScaleKind` | ObjectType | Product type | Facet | Runtime controller |
| --- | --- | --- | --- | --- |
| `Simple` | `SimpleScaleType` | `SimpleProductType` | Simple Scale | — |
| `Laboratory` | `LaboratoryScaleType` | `SimpleProductType` | Laboratory Scale | `LaboratoryController` |
| `Hopper` | `HopperScaleType` | `SimpleProductType` | Hopper Scale | `HopperController` |
| `WeighingModule` | `WeighingModuleType` | `SimpleProductType` | Weighing Bridge | — |
| `AutomaticFilling` | `AutomaticFillingScaleType` | `AutomaticFillingProductType` | AutomaticFillingScale | `AutomaticFillingController` |
| `Catchweigher` | `CatchweigherType` | `CatchweigherProductType` | Catchweigher | zones in `ScaleProductionPreset` |
| `Checkweigher` | `CheckweigherType` | `CheckweigherProductType` | Checkweigher | checkweigher statistics |
| `AutomaticWeightPriceLabeler` | `AutomaticWeightPriceLabelerType` | `AutomaticWeightPriceLabelerProductType` | AutomaticWeightPriceLabeler | zones |
| `Continuous` | `ContinuousScaleType` | `ContinuousProductType` | Continuous Scale | `ContinuousController` |
| `LossInWeight` | `LossInWeightScaleType` | `ContinuousProductType` | LossInWeight Scale | `LossInWeightController` |
| `PieceCounting` | `PieceCountingScaleType` | `PieceCountingProductType` | PieceCountingScale | `PieceCountingController` |
| `Recipe` | `RecipeScaleType` | `RecipeProductType` | RecipeScale | `RecipeController` |
| `TotalizingHopper` | `TotalizingHopperScaleType` | `TotalizingHopperProductType` | Totalizing Hopper Scale | — |
| `Vehicle` | `VehicleScaleType` | `VehicleProductType` | Vehicle Scale | `VehicleController` |
- `ScalesModel.ObjectTypeOf`, `KindOf`, `ProductTypeOf` and `AllKinds` map
between the kinds and the type NodeIds.
- `ScalesProfiles.FacetOf(kind)` returns the facet URI of a kind.
- `ScaleSystemType` groups several scales. `FeederModuleType` and
`PrinterModuleType` are sub-devices of a scale.
The weight itself is a `WeightItemType` variable (§9.3) with a `WeightType`
value of `Gross`, `Net` and `Tare`. Properties such as `Overload`,
`Underload`, `TareMode`, `WeightStable`, `InsideZero` and `CurrentRangeId`
qualify it. Every scale has `CurrentWeight`; `RegisteredWeight` holds the last
value `RegisterWeight` recorded.
## Where a scale lives
A scale is both a DI device and a Machinery machine, so a conforming server
publishes the top-level node in two places:
- as a child of the DI `DeviceSet`, and
- through an `Organizes` reference from the Machinery `Machines` folder.
The Scales specification never mentions this folder. It is still required
transitively, because the mandatory *Machinery Machine Identification*
facet includes the CU *Machinery Find Machines*.
`ScalesRuntime`, shared by standalone and composed hosts, does both by default
(`ScalesServerOptions.OrganizeIntoMachinesFolder`). With the option turned
off, the server no longer advertises the facets that include *Machinery
Machine Identification*: Base Scale and every kind facet, Scale System,
Feeder Module and Printer Module. The production preset and SI unit facets
do not include it and stay advertised.
Everything below a top-level node follows the DI modular-device pattern
(§6.3):
```mermaid
flowchart TD
devices["DeviceSet"]
line["PackingLine1
ScaleSystemType (notifier)"]
sub1["SubDevices"]
check["Checkweigher1
CheckweigherType (notifier)"]
sub2["SubDevices"]
infeed["Infeed
FeederModuleType (notifier)"]
labeler["Labeler
PrinterModuleType (notifier)"]
balance["AnalyticalBalance
LaboratoryScaleType (notifier)"]
devices --> line --> sub1 --> check --> sub2
sub2 --> infeed
sub2 --> labeler
devices --> balance
line -.->|HasNotifier| check
check -.->|HasNotifier| infeed
check -.->|HasNotifier| labeler
```
- **SupportedTypes.** The NodeSet's `SupportedTypes` folders carry no
`Organizes` references. The builders add them per instance.
- **Event notifiers.** Every event source is a notifier: scale systems,
scales, weighing modules, and feeder and printer modules. A nested source is
linked from its nearest notifier ancestor by `HasNotifier`. A client can
therefore subscribe to a nested scale directly, or to the system and
receive the events of everything below it.
## Quick start — server
```csharp
builder.Services
.AddOpcUa()
.AddServer(options => { /* application name, endpoints, … */ })
.AddScales()
.ConfigureScales(async context =>
{
ScalesNodeManager manager = context.Manager;
ScaleHandle scale = await manager.CreateScaleAsync(
new QualifiedName("AnalyticalBalance", manager.InstanceNamespaceIndex),
ScaleKind.Laboratory,
b => b
.WithIdentification(new ScaleIdentification
{
Manufacturer = new LocalizedText("Contoso Lab"),
SerialNumber = "AB-220",
ProductInstanceUri = "urn:contoso:weighing:AB-220"
})
.WithWeighingRange(new WeighingRangeDefinition(0, 0.22, 0.0000001, 0.000001))
.WithUnit(ScaleUnits.Kilogram)
.WithAllowedEngineeringUnits(ScaleUnits.Kilogram, ScaleUnits.Gram)
.WithWeightDetails()
.WithZeroAndTare()
.WithRegisterWeight()
.WithTypeFeatures(),
context.CancellationToken);
// Keep the handle; the process publishes loads through it.
scale.PublishLoad(0.1);
});
```
`WithIdentification` and at least one `WithWeighingRange` are required,
because `Identification` and `` are mandatory (§7.4.2).
Every other member is materialised only when a builder call asks for it, and
the runtime binds exactly what was materialised. For example, a scale built
without `WithZeroAndTare` has no `SetZero` method, not one that fails.
## Quick start — client
```csharp
ScalesClient scales = session.Scales(telemetry); // registers the OPC 40200 structures
if (!scales.IsSupported)
{
return;
}
await foreach (ScaleEntry entry in scales.EnumerateScalesAsync(ct))
{
ScaleSnapshot snapshot = await scales.ReadSnapshotAsync(entry, ct);
Console.WriteLine($"{entry.DisplayName.Text}: {snapshot.CurrentWeight.Gross}");
}
await scales.SetTareAsync(scaleId, ct);
ScaleReading? registered = await scales.RegisterWeightAsync(scaleId, ct);
await foreach (ScaleReading reading in scales.ObserveWeightAsync(scaleId, cancellationToken: ct))
{
Console.WriteLine(reading.Net);
}
```
Create the client through `session.Scales(...)`. It registers the OPC 40200
encodeable types, so `WeightType` and the recipe structures decode into their
generated classes. Without it they arrive as `ExtensionObject`.
## Server hosting
### `AddScales` and `ConfigureScales`
- **`AddScales(configure)`** registers `ScalesNodeManager` and validates the
options. It also claims the Device Integration address space, because the
node manager loads DI itself.
- **`ConfigureScales(...)`** registers a delegate that runs once the address
space exists. It comes in sync and async overloads.
- Delegates run in registration order.
- An exception aborts server startup.
- The delegate receives an `IScalesSetupContext` with the `Manager`, the
`CancellationToken` and `GetRequiredService()`.
A second DI owner, such as `AddOpcUaDi`, is rejected with an
`InvalidOperationException` at registration time.
### Composing with Machinery and other companions
Use `AddScalesToMachinery()` after `AddMachinery()` instead of registering a
second owner with `AddScales()`. The Machinery options must include
`MachineryParts.BuildingBlocks`.
```csharp
using Microsoft.Extensions.DependencyInjection;
using Opc.Ua;
using Opc.Ua.Machinery.Server;
using Opc.Ua.Scales;
using Opc.Ua.Scales.Server;
using Opc.Ua.Scales.Server.Runtime;
using Opc.Ua.Server;
services.AddOpcUa()
.AddServer(ConfigureServer)
.AddMachinery(options =>
{
options.Parts = MachineryParts.BuildingBlocks;
options.InstanceNamespaceUri = "urn:acme:plant:instances";
})
.AddScalesToMachinery(options => options.RequireSiUnits = true)
.ConfigureScalesFor(async (context, ct) =>
{
ScalesRuntime scales = context.Runtime;
ScaleHandle scale = await scales.CreateScaleAsync(
new QualifiedName("BaggingScale", scales.InstanceNamespaceIndex),
ScaleKind.AutomaticFilling,
builder => builder
.WithIdentification(new ScaleIdentification
{
Manufacturer = new LocalizedText("Acme"),
SerialNumber = "S-1",
ProductInstanceUri = "urn:acme:scale:S-1"
})
.WithWeighingRange(new WeighingRangeDefinition(0, 30, 0.005, 0.01))
.WithZeroAndTare()
.WithRegisterWeight()
.WithPackMLState()
.WithTypeFeatures(),
ct);
scale.PublishLoad(12.5);
});
```
`ScalesMachineryModelProvider` uses the existing `IMachineryModelProvider`
pipeline. DI, IA and Machinery load first; PackML loads before Scales and is
not loaded again when another provider already supplied it. The server package
references `Opc.Ua.Machinery.Server` for this composition infrastructure;
standalone servers still load the same dependency models as before.
`ConfigureScalesFor` accepts
`Func`. Successive callbacks
share one `ScalesRuntime`, and run before the Machinery builder is sealed.
`context.Manager` is the existing owner, not a `ScalesNodeManager` created behind
the scenes. `GetRequiredService()` provides injectable application services.
The standalone manager's methods delegate to that same runtime implementation.
Composition covers all fourteen `ScaleKind` values, `CreateScaleSystemAsync`,
weighing subdevices, feeder/printer modules, product methods and DI locking,
PackML state, recipe/report files, and scale events and alarms. It does not add
new kind-specific behavior beyond the existing runtime; the
[known limitations](#known-limitations) apply equally to both hosting paths.
Scales conformance contributions are aggregated by the shared owner.
Add [`AddPumpsToMachinery()`](Pumps.md#coexisting-with-a-server-that-already-owns-di)
to host pumps alongside these scales. Both runtimes mint instances in
`MachineryServerOptions.InstanceNamespaceUri` and preserve their native type
definitions and browse-name namespaces. Additional model namespaces belong in
`AdditionalNamespaceUris`; do not repeat the owner's instance URI there.
For [AMB](AMB.md), also register `AddAssetManagement()` and use the existing
owner inside the Scales configurator:
```csharp
await context.GetRequiredService()
.RegisterAssetAsync(
context.Manager.Device(scale.Scale).Node,
asset => asset.WithConfigurableAssetId("S-1"),
ct);
```
AMB is a sidecar: the scale and all asset additions remain in the Machinery
owner. Nested scale/module events remain linked through `HasNotifier`, so clients
may subscribe to the subdevice, its scale system, or the server.
### Direct composition and lifetime
Without dependency injection, supply `new ScalesMachineryModelProvider()` and
`new MachineryModelProvider(MachineryParts.BuildingBlocks)` to a
`MachineryNodeManager`. After its address space has initialized, obtain
`ScalesRuntime scales = owner.CreateScalesRuntime(options)` and call
`CreateScaleAsync` or `CreateScaleSystemAsync` exactly as in the hosted callback.
The first call selects the runtime options; subsequent calls return the same
runtime. On a standalone `ScalesNodeManager`, this returns its existing `Runtime`.
The stock Machinery manager owns the composed runtime's lifetime. Shutdown
cancels queued publications, drains active constructions, closes recipe/report file
handles and content buffers, and detaches the product lock service from session
notifications. Repeated asynchronous disposal is safe. An application-owned
Machinery-compatible DI manager uses `DisposeMachineryResourcesAsync()` during
its own asynchronous disposal.
### Options
| `ScalesServerOptions` | Default | Meaning |
| --- | --- | --- |
| `OrganizeIntoMachinesFolder` | `true` | Add top-level scales and systems to the Machinery `Machines` folder; required for every facet that includes *Machinery Machine Identification* |
| `RequireSiUnits` | `false` | Accept only SI mass units as method inputs and advertise the *International System of Units* facet |
| `PackMLInitialState` | `Idle` | The state a PackML state machine starts in (`Aborted`, `Stopped` or `Idle`) |
| `ZeroSettingRange` | `0.04` | The zero band as a fraction of the capacity, for `SetZero`, `InsideZero` and underload |
| `AdditionalNamespaceUris` | `[]` | Extra namespaces for vendor extensions |
### Creating scales
| `ScalesNodeManager` / `ScalesRuntime` member | Purpose |
| --- | --- |
| `CreateScaleAsync(name, kind, configure[, parent])` | A scale below the `DeviceSet`, or below an explicit parent |
| `CreateScaleSystemAsync(name, configure)` | A scale system with its scales |
| `Scales`, `ScaleSystems`, `FindScale(nodeId)` | The handles of everything created, weighing modules and system scales included |
| `LockService` | The DI `ILockService` behind product locking |
| `NamespaceIndices` | The runtime indices of the Scales, DI, Machinery, PackML and IA namespaces |
| `ServerProfiles`, `ConformanceUnits` | What the server advertises, derived from what was built |
## Builder reference
### `IScaleBuilder`
| Call | Materialises |
| --- | --- |
| `WithIdentification(identification)` | The mandatory Machinery `Identification` add-in, mirrored onto the DI nameplate. Manufacturer, SerialNumber and ProductInstanceUri are required |
| `WithWeighingRange(range)` | One `WeighingRangeElementType`. Call it once per range of a multi-range scale; ranges are ordered by capacity |
| `WithUnit(unit)` | The engineering unit of every weight (default kilogram) |
| `WithAllowedEngineeringUnits(units)` | `AllowedEngineeringUnits`, which `SetPresetTare` checks against (§7.4.3). Published automatically with the scale unit when a method takes a unit |
| `WithMinimalWeight(weight)` | `MinimalWeight` |
| `WithLegalForTrade()` | `LegalForTrade`. Weights round to the verification interval `e` instead of `d` |
| `WithWeightDetails()` | The optional `WeightItemType` properties: `WeightStable`, `InsideZero`, `CenterOfZero`, `GrossNegative`, `CurrentRangeId`, `WeightId`, `HighResolutionValue`, `PrintableValue` |
| `WithZeroAndTare(presetTare)` | `SetZero`, `SetTare`, `ClearTare` and, unless `presetTare` is false, `SetPresetTare` |
| `WithRegisterWeight()` | `RegisterWeight` and `RegisteredWeight` |
| `WithProductionPreset(configure)` | `ProductionPreset` with products; see below |
| `WithProductionOutput()` | `ProductionOutput` statistics (`CheckweigherStatisticType` counters on a checkweigher) |
| `WithPackMLState()` | The PackML `State` machine, driven by `PackMLStateMachineController` |
| `WithMachineryBuildingBlocks()` | `MachineryItemState`, `MachineryOperationMode` and the `MachineryBuildingBlocks` folder, wired as add-ins |
| `WithProcessState(id, message)` | `ProcessStateId` and `ProcessStateMessage` |
| `WithTypeFeatures()` | Every optional member of the kind, for example the laboratory draft shields, the vehicle weighing methods, recipe management, or the continuous-scale figures and master totalizer |
| `WithRecipeFiles()` | A `RecipeFile` on every recipe of a recipe scale (*FileRecipeManagement*) |
| `AddTotalizer(name)` | A `` on a continuous or loss-in-weight scale |
| `AddFeederModule(name, configure)` / `AddPrinterModule(name, configure)` | Sub-devices below `SubDevices` |
| `AddWeighingModule(name, configure)` | A `WeighingModuleType` sub-device. It is a scale of its own with its own builder and handle |
| `With(configure)` | Direct access to the typed node for anything not covered above |
### `IProductionPresetBuilder`
| Call | Effect |
| --- | --- |
| `AllowSelection()` | `SelectProduct`, `DeselectProduct`, `SwitchProduct` and `CurrentProducts` (*SelectProduct*) |
| `AllowManagement()` | `AddProduct` and `RemoveProduct` (*ManageProduct*, *DynamicProductAddressSpace*) |
| `WithLocking()` | A DI `Lock` on every product. A held lock excludes other sessions from remote product mutation (§7.8.3) |
| `AddProduct(id, name, configure)` | A product of the kind's product type. `configure` gets the typed node, for example to set a checkweigher's `NominalWeight` |
| `Select(id)` | Puts the product into processing |
A preset without `AllowManagement` is a *StaticProductAddressSpace*. It needs
at least one product, because `` is a mandatory placeholder.
### `IScaleSystemBuilder` and `IScaleModuleBuilder`
A system takes `WithIdentification`, `WithProcessState`,
`WithProductionPreset`, `WithProductionOutput`, `WithPackMLState` (its
`SystemState`) and `WithMachineryBuildingBlocks`. `AddScale(name, kind,
configure)` adds each scale. §7.3.3 says the scales of one system must be of
different types. The builder does not enforce this.
A module takes `WithIdentification`, whose identification node is a
`MachineryComponentIdentificationType` because the declared
`MachineryItemIdentificationType` is abstract, and
`WithMachineryBuildingBlocks`. A feeder also takes `WithFeederSpeed(min, max,
unit, initial)`. A printer takes `WithLabel(labelTypeId, length, width,
unit)`.
## Runtime reference
### `ScaleHandle`
The application publishes the raw load. The handle does the rest.
| Member | Purpose |
| --- | --- |
| `PublishLoad(rawLoad, stable)` | Evaluates the load and publishes `CurrentWeight` with every property |
| `SetZero()`, `SetTare()`, `ClearTare()`, `SetPresetTare(value, unit)`, `RegisterWeight()` | The same operations the methods run, for local operator panels |
| `CurrentReading`, `RegisteredReading` | The last published values as `ScaleReading` |
| `WeightRegistered` | Raised on every registration, whether by method or locally |
| `CommandInterceptor` | A `Func` that can veto any method before it runs, for example while the machine is interlocked |
| `Notifications` | Events and alarms, see below |
| `PackML`, `ProductionPreset`, `ProductionOutput` | The PackML controller, the product runtime and the statistics |
| `Continuous`, `LossInWeight`, `PieceCounting`, `Laboratory`, `Hopper`, `Vehicle`, `Recipes`, `AutomaticFilling` | The kind-specific controller, or null |
| `Modules`, `WeighingModules` | Feeder/printer handles and weighing-module scale handles |
| `SetItemState`, `SetOperationMode`, `SetProcessState` | The Machinery states and the process state |
| `Update(action)` | Runs application code that writes several values of the scale (for example typed nodes reached through `With`) as one step |
**Threading.** The runtime members of a scale, of its type controller and of
its feeder and printer modules take the scale's lock, so they are safe to call
from any thread. A client method call and an equipment-side report such as
`SetActivity` or `CompleteCalibration` therefore never interleave: a method
that checks a flag sees either the whole report or none of it. The lock is
re-entrant, so code inside `Update` may call these members.
The recipe controller, the production preset, the statistics, the
notifications, the PackML controller and a continuous scale's totalizers
synchronise on locks of their own. They are thread-safe as well, but `Update`
does not keep their calls out. The `Update` delegate runs under the scale's
lock and holds up every method call on the scale meanwhile, so keep it short
and synchronous: no I/O, no waiting and no calls into other locks.
- **Operator commands** (`SetZero`, `SetReferencePieceWeight`,
`InboundWeighing`, …) pass the `CommandInterceptor`, like the methods.
- **Equipment reports** (`PublishLoad`, `PublishHopper`, `SetActivity`,
`CompleteCalibration`, `PublishFeeder`, …) take the lock but skip the
interceptor: the equipment reports what happened, it does not ask.
- **Node writes** need no node-manager lock. A `NodeState` guards its own
attributes, and the scale's lock keeps related values consistent.
**Weighing arithmetic (§9.3):**
- **Gross and net.** Gross is the load minus the zero offset `SetZero`
established. Net is gross minus the tare.
- **Weighing range.** The range is the first one whose upper limit is not
exceeded.
- **Rounding.** Values round to the range's `d`, or to `e` when the scale is
legal for trade, and snap to that interval's decimal places. The
high-resolution value keeps full precision.
- **Overload** is gross above the largest capacity.
- **Underload** is gross below zero by more than the zero band. A scale
resting slightly below zero needs a zero, not an underload alarm.
- **Unstable weights.** `SetZero`, `SetTare` and `RegisterWeight` refuse an
unstable weight. `SetZero` also refuses a weight outside the zero band.
- **Registration** refuses an overload or underload.
### `ScaleNotifications`
- `RaiseEvent(id, message, severity, auxParameters)` raises a
`ScaleEventType` with the Annex C `NotificationId` and its category.
- `RaiseVendorEvent` does the same for vendor ids, which must be above 5000.
- `SetAlarm(id, active, message)` drives one `ScaleAlarmType` condition per
notification id. The condition is created on first use and carries a
`HasCondition` reference from its source.
- The runtime raises some notifications by itself:
- `OVERLOAD_FAULT` and `UNDERLOAD_FAULT` alarms follow the weight.
- A failed zero or tare raises `ZERO_SETTING_FAULT` or
`TARE_SETTING_FAULT`.
### `ScaleProductionPreset`
The methods and the local API share the same rules.
- **Products.** `AddProduct`, `RemoveAsync`, `Select`, `Deselect` and
`Switch`. `Switch` fails with `BadInvalidState` when more than one product
is in processing (§7.7.8).
- **Locking.** On a preset built `WithLocking`, a held DI product lock rejects
other sessions' mutations with `BadUserAccessDenied`, including direct Value
writes, nested or later-created descendants and scale-level methods that
change the product. The creating/owning session is distinct even when another
session has the same username. Unlocked products retain ordinary access
checks, and trusted equipment updates remain available through the local API.
- **Catchweigher zones.** Zones come from `AddZone`.
- **Vehicle information.** `VehicleInformationProvider` answers
`GetVehicleInformation` from the application's vehicle database.
### `ScaleStatistics`
- `Record`, `RecordAccepted` and `RecordRejected(weight, reason)` maintain
counts, sum, min, max, mean and standard deviation, and `LastItem`.
- `Reset` clears them.
- On a checkweigher, each `CheckweigherRejectReason` counts into its own
`PackagesRejectedBy…` counter.
### Type controllers
| Controller | Behaviour |
| --- | --- |
| `ContinuousController` | `PublishFlow`, `Totalize`, `ResetTotalizer` (method and local) |
| `LossInWeightController` | `DischargeStart/Stop`, `RefillStart/Stop` reflected in `Discharging`/`Refilling`; `PublishHopper` |
| `PieceCountingController` | `SetReferencePieceWeight`, `SetNumberOfReferencePieces`, `StartReference` (method and local; the local `SetReferencePieceWeight(weight, unit)` takes the real weight, the method a UInt32); derives `CurrentPieceCount` from the net weight; the product's target counts |
| `LaboratoryController` | Draft shields, leveling, calibration and ionisator with their `*Running` flags. `CompleteImmediately` or `CompleteLeveling` / `CompleteCalibration` end the running operations |
| `HopperController` | `PublishLevels` for the limit switches and levels |
| `AutomaticFillingController` | `EvaluateFilling` sets `Deviation` and `ToleranceState` against the product target |
| `VehicleController` | `InboundWeighing`, `OutboundWeighing`, `OnePassWeighing` (method and local); computes `DeltaWeight` per Table 123 |
| `RecipeController` | Recipe management and processing, see below |
### Recipes
A recipe is a directed acyclic graph of elements linked by
`NextRecipeElement`. The graph can fork and join (Annex B). The start
element's predecessor is the recipe node itself.
**Building and running recipes.**
- `AddRecipe` and `AddRecipeElement` build the graph. An element can only
be linked from existing elements, so the graph cannot become cyclic.
- `StartRecipe`, `StopRecipe` (pause), `ContinueRecipe`,
`SkipCurrentRecipeElement` and `AbortRecipe` walk the graph.
- The application executes the elements:
1. It listens to `ElementStarted`.
2. It reports each finished element with `CompleteElement`.
- An element whose predecessors are all complete starts next.
- When the graph finishes, `RecipeCompleted` is raised. The report goes to the
product's `Report` and, if present, its `ReportFile`.
- `ElementStarted` and `RecipeCompleted` are raised after the controller's
lock is released, and for a method call after the call's processing. A
handler may therefore call back into the controller, for example complete
an element that takes no time straight from its `ElementStarted` handler.
**Recipe files.** `WithRecipeFiles()` gives every recipe a `RecipeFile`. The
file format is vendor-specific; the specification does not define one.
- An upload is handed to `RecipeController.RecipeFileHandler`, which parses
the file and returns a `ServiceResult`. A bad result rejects the upload.
- `ReadRecipeFile` returns the last accepted content.
- Once a recipe came from a file, `AddRecipeElement` and
`RemoveRecipeElement` on it return `BadInvalidState`. This keeps the file
and the address space from diverging (§7.31).
### PackML
`PackMLStateMachineController` from `Opc.Ua.PackML` drives the three nested
machines: base (Aborted … Cleared), machine (Stopped … Running) and execute
(Idle … Complete).
- **Commands.** Methods on an inactive sub-machine return
`BadStateNotActive`. A command that is not valid in the current state
returns `BadNotExecutable`.
- **Events.** Every change raises the standard transition event, as §5.3
requires.
- **Acting states** (Starting, Stopping, …) complete automatically unless
`AutoCompleteActingStates` is false. The equipment then calls
`CompleteActingState()`.
- **Guards.** `CommandGuard` can veto a command.
## Client reference
### Creating the client
`session.Scales(telemetry)` or `ScalesClientFactory`. `AddScalesClient()`
registers the factory with a hosted client. `IsSupported` tells whether the
server publishes the Scales namespace.
### Discovery
| Method | Returns |
| --- | --- |
| `DiscoverScalesAsync` | Every top-level scale and system from `DeviceSet` and `Machines`, de-duplicated |
| `EnumerateScalesAsync`, `EnumerateScalesUnderAsync(root)` | The same as a stream, or below any node |
| `EnumerateSystemScalesAsync(system)` | The scales of a scale system |
| `GetKindAsync(typeDefinition)` | The `ScaleKind` of a type, vendor subtypes included |
Each `ScaleEntry` carries the `NodeId`, names, type definition, `Kind` and
`IsScaleSystem`.
### Weighing
`ReadCurrentWeightAsync`, `ReadRegisteredWeightAsync`,
`ReadIdentificationAsync`, `ReadWeighingRangesAsync`,
`ReadAllowedEngineeringUnitsAsync`, `SetZeroAsync`, `SetTareAsync`,
`ClearTareAsync`, `SetPresetTareAsync`, `RegisterWeightAsync` (returns the
registered weight) and `ObserveWeightAsync`.
A streamed reading carries gross, net and tare only. Read the units and
properties once with `ReadCurrentWeightAsync`.
`ReadCurrentWeightAsync` throws `Bad_NotFound` for a node without
`CurrentWeight`, such as a scale system or a module.
`TryReadCurrentWeightAsync` returns null instead, the contract
`ReadRegisteredWeightAsync` has for a node without `RegisteredWeight`. A
published but never-written value comes back as NaN gross, net and tare.
### Products, PackML, type methods and recipes
| Area | Methods |
| --- | --- |
| Products | `ReadProductsAsync`, `ReadCurrentProductsAsync`, `AddProductAsync`, `RemoveProductAsync`, `SelectProductAsync`, `DeselectProductAsync`, `SwitchProductAsync` |
| PackML | `ReadPackMLStateAsync` (the innermost active state number), `ExecutePackMLCommandAsync(owner, PackMLCommand)` |
| Type methods | `SetReferencePieceWeightAsync`, `SetNumberOfReferencePiecesAsync`, `StartReferenceAsync`, `SetDraftShieldsAsync`, `WeighVehicleAsync`, `SetFeederSpeedAsync`, and `InvokeAsync(target, method)` for any method without arguments |
| Recipes | `AddRecipeAsync`, `AddRecipeElementAsync`, `ProcessRecipeAsync(scale, method, recipe)` |
### Notifications and snapshots
- **Notifications.**
- `ObserveNotificationsAsync(source)` streams the scale events and alarms
of a scale, module or system as `ScaleNotificationInfo`.
- `CreateNotificationFilter()` and `Decode(fields)` expose the same
filter for your own subscriptions.
- `DefinedId` maps the numeric id back to `ScaleNotificationId`.
- **Snapshots.** `ReadSnapshotAsync(entry)` reads a whole scale in one pass:
identification, both weights, ranges, units, products and PackML state.
## Contracts reference
| Contract | Holds |
| --- | --- |
| `ScaleIdentification` | Manufacturer, SerialNumber, ProductInstanceUri and the optional Machinery identification fields |
| `WeighingRangeDefinition(Low, High, d, e)` | One weighing range; `Validate()` checks the limits and intervals |
| `ScaleReading` | Gross, net, tare, tare mode, overload, underload, stability, zero, range, weight id, unit, timestamp, status |
| `ScaleEntry`, `ScaleProductInfo`, `ScaleNotificationInfo`, `ScaleSnapshot` | The client's discovery, product, notification and snapshot results |
| `ScaleKind`, `ScaleNotificationId`, `ScaleNotificationCategory` | The kinds and the Annex C tables |
## Conformance matrix
Status key: ✅ implemented and tested over a real session ·
🧪 implemented, tested in-process only · 🔧 implemented, not covered by a
test · ❌ not shipped.
OPC 40200 defines server facets only; there are no client facets or client
conformance units (§12). The server advertises the facets and CUs of what
it actually built through `ServerProfiles` and `ConformanceUnits`
(`ServerAdvertisesTheFacetsOfWhatItBuiltAsync`). A facet is advertised only
with the facets it includes: the *Machinery Machine Identification* facet
and its units come with Base Scale, and the kind facets without a controller
are left out (`ScaleFacetsBringTheMachineIdentificationTheyIncludeAsync`,
`ScaleFacetsAreNotClaimedOutsideTheMachinesFolderAsync`,
`KindFacetsWithoutAControllerAreNotClaimedAsync`).
### Facets
| Facet (§12.2.2) | Status | Tests |
| --- | --- | --- |
| Base Scale | ✅ | `EveryKindIsPublishedWithItsMandatoryMembersAsync`; `ClientReadsIdentificationRangesAndUnitsAsync` |
| Scale System | ✅ | `ScaleSystemHostsScalesAndResetsGlobalStatisticsAsync`; `ClientDiscoversEveryScaleOnceWithItsKindAsync` |
| Feeder Module | ✅ | `FeederAndPrinterModulesAsync`; `ClientCallsTheTypeSpecificMethodsAsync` |
| Printer Module | 🧪 | `FeederAndPrinterModulesAsync` |
| Minimal Production Preset | 🧪 | `StaticPresetWithoutProductsIsRejectedAsync`; `ProductionPresetSelectsDeselectsAndSwitchesProductsAsync` |
| Full Production Preset | ✅ | `ProductionPresetAddsAndRemovesProductsAsync`; `LockedProductsRejectOtherClientsAsync`; `ClientManagesAndSelectsProductsAsync` |
| International System of Units | 🧪 | `PresetTareChecksAndConvertsTheUnitAsync`; `SiMassUnitsAreRecognised` |
| Simple, Laboratory, Hopper, Weighing Bridge | ✅ / 🧪 | `LaboratoryShieldsLevelingCalibrationAndIonisatorAsync`; `HopperPublishesLevelsAsync`; `WeighingModulesAreScalesOfTheirOwnAsync`; E2E for Simple, Laboratory and Hopper |
| AutomaticFillingScale | 🧪 | `AutomaticFillingEvaluatesAgainstTheTargetAsync` |
| Catchweigher, AutomaticWeightPriceLabeler | 🧪 | `CatchweigherProductZonesCanBeAddedAndRemovedAsync` (not advertised, see [limitations](#known-limitations)) |
| Checkweigher | ✅ | `CheckweigherStatisticsCountAcceptedAndRejectedAsync`; `ClientSubscribesToANestedScaleThroughTheNotifierHierarchyAsync` |
| Continuous Scale, LossInWeight Scale | ✅ | `ContinuousScaleTotalizesAndResetsAsync`; `LossInWeightDischargesAndRefillsAsync`; `ClientStreamsWeightsEventsAndAlarmsAsync` |
| PieceCountingScale | ✅ | `PieceCountingReferencesAndCountsAsync`; `PieceCountingProductTargetsCanBeSetAsync`; `ClientCallsTheTypeSpecificMethodsAsync` |
| RecipeScale | ✅ | `RecipesAreManagedAndProcessedAlongTheirGraphAsync`; `RecipeProcessingGuardsAsync`; `RecipeFilesAreUploadedParsedAndReportedAsync`; `ClientBuildsAndProcessesARecipeAsync` |
| Totalizing Hopper Scale | 🧪 | `EveryKindIsPublishedWithItsMandatoryMembersAsync` (structure only and not advertised, see [limitations](#known-limitations)) |
| Vehicle Scale | ✅ | `VehicleWeighingComputesTheDeltaAsync`; `ClientCallsTheTypeSpecificMethodsAsync` |
### Cross-cutting conformance units and behaviour
| Area | Status | Tests |
| --- | --- | --- |
| *Scales ScaleDeviceType*: zero, tare, preset tare, register | ✅ | `ZeroTareAndRegisterMethodsDriveTheWeightAsync`; `ClientZeroesTaresAndRegistersWeightsAsync` |
| `WeightItemType` semantics: rounding, ranges, overload, underload, zero band | 🧪 | `WeightIsRoundedToTheIntervalOfItsRange`; `LegalForTradeRoundsToTheVerificationInterval`; `OverloadAndUnderloadFollowCapacityAndZeroBand`; `PublishedLoadReachesEveryWeightPropertyAsync` |
| *Scales DataChange* | ✅ | `ClientStreamsWeightsEventsAndAlarmsAsync` |
| `ScaleEventType` / `ScaleAlarmType` with Annex C ids, vendor ids > 5000 | ✅ | `EventsAndVendorEventsCanBeRaisedAsync`; `FailedZeroOrTareRaisesAFaultAndOverloadAnAlarmAsync`; `ClientStreamsWeightsEventsAndAlarmsAsync` |
| Notifier hierarchy with `HasNotifier`, nested sources subscribable | ✅ | `ClientSubscribesToANestedScaleThroughTheNotifierHierarchyAsync` |
| PackML state information with transition events | ✅ | `PackMLMethodsWalkTheStateMachineAsync`; `PackMLActingStatesCanBeCompletedByTheEquipmentAsync`; `ClientDrivesThePackMLStateMachineAsync`; advertised as *PackML State Information*: `PackMLStateInformationIsClaimedWithAPackMLStateMachineAsync` |
| Machinery Machine Identification, Find Machines | ✅ | `IdentificationWithoutMandatoryFieldsIsRejectedAsync`; `ClientDiscoversEveryScaleOnceWithItsKindAsync` |
| Machinery building blocks, MachineryItemState, OperationMode | 🧪 | `StateSettersPublishTheMachineryStatesAndProcessStateAsync` |
| *RecipeManagment*, *DynamicRecipeManagement*, *FileRecipeManagement* | ✅ | as RecipeScale above |
| Method veto through `CommandInterceptor` | 🧪 | `CommandInterceptorCanVetoACommandAsync`; `PieceCountingLocalCallsMatchTheMethodsAsync` |
| Runtime members thread-safe against method calls | 🧪 | `EquipmentReportsAreAtomicAgainstTheScaleLockAsync`; `CalibrationReportsAreAtomicAgainstTheScaleLockAsync` |
| Local operator calls for piece counting and vehicle weighing | 🧪 | `PieceCountingLocalCallsMatchTheMethodsAsync`; `VehicleLocalCallsMatchTheMethodsAsync` |
| Recipe events raised outside the lock | 🧪 | `HandlersMayCompleteElementsSynchronouslyAsync` |
| Rejecting a second DI owner | 🧪 | `ServerHostingRefusesASecondDiOwner` |
| Client hosting `AddScalesClient` | 🧪 | `ClientHostingRegistersTheFactory` |
## Samples
| Sample | Shows |
| --- | --- |
| [ScalesServer](../samples/Scales/ScalesServer) | A packing line (scale system with a checkweigher, infeed feeder and labeler) plus a laboratory balance, a counting scale, a weighbridge and a recipe batching scale, all with a simulation that rejects packages and runs the labeler out of labels |
| [ScalesClient](../samples/Scales/ScalesClient) | Discovery, snapshots, tare/register/switch product on the nested checkweigher, and streaming of its weights, events and alarms |
See [the samples README](../samples/Scales/README.md) for how to run them.
`ScalesServer` accepts `--host`, `--port`, an absolute `--pki-root`,
`--autoaccept true|false`, and an optional `--run-seconds` from 1 through 3600.
Without a runtime limit, the server runs until stopped interactively.
UaLens launches its owned simulator with private PKI, certificate auto-accept disabled
and an explicit finite runtime. Weighing tasks require a separate per-run opt-in
and fresh confirmation; see [UaLens Weighing](UaLens.md#weighing).
## Testing
```bash
dotnet test tests/Opc.Ua.Scales.Tests
```
| Fixture | Covers |
| --- | --- |
| `ScalesUnitTests` | The weighing engine, units and the model tables, with no server |
| `ScalesNodeManagerTests` | Every kind, the methods, PackML, presets, zones, facets, in-process |
| `ScaleTypeControllerTests` | The type controllers, modules, recipes, recipe files and systems |
| `ScalesCompositionTests` | Every scale kind on the stock Machinery owner, provider ordering, instance ownership, cancellation, and standalone/composed file-resource disposal |
| `ScalesClientServerE2eTests` | Standalone and shared Machinery/Pumps/Scales hosts over real sessions, including AMB aliases, file methods, PackML, weight subscriptions and nested events |
The end-to-end fixture is the one that matters for anything a client sees.
Two defects this work found passed every in-process assertion:
- Alarm conditions without an event type never reached a subscribed client.
- A nested scale was not a notifier, so a subscription on it failed.
## Troubleshooting
| Symptom | Cause | Fix |
| --- | --- | --- |
| `CS0104: 'BrowseNames' is ambiguous` | `Opc.Ua` and `Opc.Ua.Scales` both define `BrowseNames` | Qualify as `Opc.Ua.BrowseNames` or alias it |
| `ArgumentException` from `CreateScaleAsync` | `WithIdentification` or `WithWeighingRange` missing, or an identification without Manufacturer / SerialNumber / ProductInstanceUri | Add them; both are mandatory |
| `InvalidOperationException`: DI address space already owned | `AddScales` combined with `AddOpcUaDi` or another DI owner | Use only `AddScales`; it loads DI |
| `SetZero` returns `BadOutOfRange` | The gross weight is outside the zero band | Unload the scale, or widen `ZeroSettingRange` |
| `SetZero`/`SetTare`/`RegisterWeight` return `BadInvalidState` | The weight is unstable, overloaded or underloaded | Publish a stable load inside the range |
| `SetPresetTare` returns `BadInvalidArgument` | The unit is not in `AllowedEngineeringUnits` (or not SI with `RequireSiUnits`) | Pass an allowed unit |
| A product change returns `BadUserAccessDenied` | The preset is built `WithLocking` and the client holds no lock | Call the product's `Lock/InitLock` first |
| `SwitchProduct` returns `BadInvalidState` | More than one product is in processing | Deselect down to one product |
| PackML method returns `BadNotExecutable` / `BadStateNotActive` | Command not valid in the current state / sub-machine not active | Read `ReadPackMLStateAsync` first |
| Weights arrive as `ExtensionObject` | Client not created through `session.Scales(...)` | Use the extension |
| A streamed reading has no unit | Streams carry the value only | Read the unit once with `ReadCurrentWeightAsync` |
## Known limitations
- **Totalizing hopper.** Published with its full structure, but no
controller drives `TipCounter`. The application sets it through
`With` or the product node. The Totalizing
Hopper Scale facet and its CU are therefore not advertised; the scale
still counts towards Base Scale.
- **Catchweigher and price labeler.** These have no dedicated controller.
- Zones and `LastItem` statistics are implemented.
- The application writes item prices and measured dimensions itself.
- Their kind facets and CUs are therefore not advertised; the scales
still count towards Base Scale.
- **Recipe elements.** Elements are executed by the application through
`ElementStarted` / `CompleteElement`. `ConditionSleep` thresholds are not
evaluated by the server.
- **Recipe file format.** It is vendor-specific. Parsing is up to
`RecipeFileHandler`.
- **Machinery building block.** The Machinery `Notifications` building block
is not used. Events are raised on the scale nodes directly.
- **Method status codes.** OPC 40200 defines no method-specific status codes.
The runtime uses the generic Part 4 codes listed in
[Troubleshooting](#troubleshooting).
## Model provenance
- **Scales NodeSet.** `src/Opc.Ua.Scales/Model/Opc.Ua.Scales.NodeSet2.xml` is
the unmodified OPC Foundation publication from
[`UA-Nodeset/latest/Scales`](https://github.com/OPCFoundation/UA-Nodeset/tree/latest/Scales):
- model version 2.00, published 2025-03-01;
- requiring UA 1.05.03, DI 1.04.0, IA 1.01.2, Machinery 1.03.0 and
PackML 1.01.
- **PackML NodeSet.** `src/Opc.Ua.PackML/Model/Opc.Ua.PackML.NodeSet2.xml` is
version 1.01 from `UA-Nodeset/latest/PackML`.
- **Identifier tables.** The sibling `*.NodeIds.csv` identifier tables pin the
symbol-to-NodeId mapping. The generator fails the build (MODELGEN025) when
they disagree.
- **`RequiredModel` versions** are a lower bound. The repository's newer DI,
IA and Machinery models satisfy them.
- **Generator fix.** PackML exposed a source-generator bug:
- It declares seven method `InputArguments` ahead of their methods.
- The generator derived symbolic ids in the same pass that normalised a
missing `ParentNodeId`, so those children were named from the bare parent
name.
- `NodeSetToModelDesign` now normalises every parent first.
- No existing generated symbol changed.
## Where the specification and the NodeSet disagree
The implementation follows the NodeSet wherever the text and the NodeSet
differ:
- **CU spellings.** The CU is spelled *Scales RecipeManagment* in Table 162
and on `RecipeManagementType`, but *RecipeManagement* elsewhere. The server
advertises the Table 162 spelling.
- **Facet URIs.** Some facet URIs contain spaces (`…/Scales_LossInWeight
Scale`) or lack `/Server/`. `ScalesProfiles` reproduces them verbatim.
- **State machine browse names.** §7.4.3 calls the state machine
"ScaleStateMachine", but its browse name is `State`, and `SystemState` on a
scale system.
- **Method argument types.** `SetReferencePieceWeight` takes a `UInt32`
weight, and `SetFeederSpeed` takes a `Float`.
- **Browse names that differ from the text.**
- The totalizing hopper target is `VolumeTargetValue`.
- The checkweigher percentage is `PercentageLowerToleranceLimit`.
- **Abstract identification type.** Feeder and printer `Identification` are
declared with the abstract `MachineryItemIdentificationType`. Instances use
`MachineryComponentIdentificationType`.
- **No `GeneratesEvent` or `HasNotifier`.** The NodeSet has neither, so the
notifier wiring described in
[Where a scale lives](#where-a-scale-lives) is the server's own.
## See also
- [Machinery developer guide](Machinery.md): the `Machines` folder and the
OPC 40001-1 building blocks
- [State machines](StateMachines.md): the finite-state-machine support
PackML builds on
- [OPC 40200 on the OPC Foundation reference site](https://reference.opcfoundation.org/specs/OPC-40200/full)