Skip to content

OPC-UA Server

Normal can serve its point database over OPC-UA, so that SCADA systems, historians and analytics tools can read live values, browse the equipment model, pull history and command points without going through the REST or gRPC APIs.

This is a server, not a driver: it exposes the points Normal has already collected from BACnet, Modbus, Fox and the rest. It does not read from OPC-UA devices.

The server runs as its own process alongside the rest of Normal. A fault in the OPC-UA stack cannot take the BACnet and Modbus drivers down with it, and the port is only opened when you turn it on.

Enabling the server

The server is off by default, because it opens a network port. Turn it on under Protocols > OPC-UA > Settings by setting Enabled, and the service starts within a few seconds — no restart of Normal is needed.

Clients then connect to:

opc.tcp://<your-normal-host>:4840/

Protocols > OPC-UA shows what the server is configured to do: whether it is enabled, the endpoint URL it advertises to clients, which namespaces it publishes, and its authentication and write posture. It flags an advertised hostname that clients are unlikely to reach — see the warning below.

If a client discovers the server but will not connect

OPC-UA clients call GetEndpoints and then reconnect to the URL the server returns, rather than to the address you typed. If that URL is not reachable from the client, the connection fails after discovery succeeds — usually with an unhelpful error.

The default is the system hostname, which is correct for a normal install: Normal runs with host networking, because BACnet/IP requires it, so that is the machine's own name.

Set Advertised Hostname when it is not:

  • Normal is running with Docker bridge networking, where the hostname is the container ID and resolves nowhere else. The server logs a warning at startup when it detects this.
  • Clients reach Normal by a different name or address than the machine knows itself by — through NAT, or on another DNS domain.

Check what is actually being advertised, without needing credentials:

python3 examples/opc/endpoints.py --url opc.tcp://your-host:4840/

Namespaces

The same data is published twice, under two namespace URIs. Clients should resolve these by URI; the numeric index is assigned at startup and is not stable across versions.

Namespace URI Contents
urn:normal:points The raw point database. Devices and containers appear as Objects, points as Variables. The NodeId is the point UUID.
urn:normal:equipment The semantic model. One Object per equipment instance, referencing its member points.

Equipment members are referenced, not copied: there is one node per point and one place its value is written. A SCADA client can browse the flat tag list while an analytics client browses typed equipment, and both read the same node. If you have no use for the model, turn off Equipment Namespace and only the points are published.

How the point tree is built

A point's parent folder is resolved in this order:

  1. parent_uuid, when it names a container point — durable and unambiguous, so it wins where it exists.
  2. parent_name, which Normal populates far more widely. If exactly one container carries that name the point goes under it; otherwise a folder is synthesized for the name.
  3. Otherwise the point sits at the namespace root.

Points carrying neither end up in a flat list at the root, which is hard on tree widgets past a few thousand nodes. Imported and synthetic data often looks like this — modelling the site fixes the browse experience as a side effect.

Values and quality

NodeIds are point UUIDs, which survive renames and re-discovery.

Normal already knows when a point is stale, railed or not reporting, and OPC-UA has a vocabulary for exactly that, so quality is translated rather than everything reading Good:

Normal OPC-UA StatusCode
Communications error BadNoCommunication
Never reported BadWaitingForInitialData
Dead BadNoCommunication
Out of range (hard) BadOutOfRange
Sentinel value BadSensorFailure
Stale, intermittent UncertainLastUsableValue
Railed, flatlined, noisy, spiking, non-monotonic UncertainSensorNotAccurate
Insufficient data UncertainInitialValue

Where a point carries several flags, the most severe wins.

Client software that treats anything other than Good as a failure will show gaps where Normal is telling you something more specific. That is the intended behaviour: a stale value reported as Good is worse than one reported as Uncertain.

History

Point variables advertise Historizing and the HistoryRead access bit, and history is served straight from Normal's time series.

  • ReadRawModified returns raw samples, with continuation points for paging.
  • ReadProcessed aggregates server-side. This is the one worth using: a client asking for hourly averages over a month gets a few hundred values instead of every raw sample.

Supported aggregates are Average, Minimum, Maximum, Total, Count, Start and End. Anything else — TimeAverage, Interpolative, the quality aggregates — is reported as unsupported rather than quietly substituted, because returning a plain average where a time-weighted one was requested is a wrong number, not an approximation.

Two limits are worth knowing:

  • Raw retention is a rolling window (7 days by default). A client asking for a year gets what exists, with no way to distinguish "never recorded" from "aged out".
  • Sub-second timestamps are reported at millisecond resolution.

Writing points

Writes are off by default. Turn on Enable Writes to allow them.

An OPC-UA write does not go straight to the device. It opens a command in Normal's command service, exactly as a write from the console or the API would, so priority, guardrails and revert-on-expiry all still apply, and the write appears in the command log with the API key that made it.

Because OPC-UA carries no priority or duration of its own, both come from settings: Write Priority (default 10) and Write Duration (default one hour, after which the command expires and the point reverts).

Writes always require an authenticated session with a read/write scope. Anonymous sessions can never write, whatever these settings say.

Authentication

Clients authenticate with a Normal API key, created under Settings > API Keys: the client_id is the username and the client_secret the password.

The token's scopes decide what the session may do, so there is no second permission model to keep in sync with the console:

Scope OPC-UA access
normalgw.hpl.v*.readonly browse, read, subscribe, history
normalgw.hpl.v*.readwrite the above, plus writes
anonymous reads only, and only while Allow Anonymous is on

Credentials are held in memory and re-checked every five minutes (Credential Revalidation Interval). This is what makes revocation work: deleting the API key or rotating its secret makes the next check fail and the session loses its identity. Until that check, a revoked key keeps the access it already had — set a shorter interval if that matters to you.

Credentials require an encrypted endpoint

The server refuses username/password authentication on the unencrypted endpoint. With no message security the identity token is not encrypted, so accepting it there would put your client secret on the wire in clear. If your client can only do unencrypted connections, it can only be anonymous — and therefore read-only.

Security

By default the server offers both an encrypted and an unencrypted endpoint, and allows anonymous reads. That combination gets clients connected, but it is not what you want on an untrusted network.

To lock the server down:

Setting Set to Effect
Allow Insecure Endpoint off Withdraws the unencrypted endpoint entirely
Allow Anonymous off Every session must present an API key
Trust All Client Certificates off (default) Unknown client certificates must be approved by an administrator

Three encrypted policies are offered: Basic256Sha256, Aes128_Sha256_RsaOaep and Aes256_Sha256_RsaPss, all SignAndEncrypt. Basic128Rsa15 and Basic256 are not offered at any setting — both are deprecated by the OPC Foundation as insecure.

Certificates

On first start the server generates a self-signed certificate under /var/nf/opcua/pki, which lives on the Normal volume and survives upgrades.

When a client connects with a certificate the server has not seen, the connection is rejected and the certificate is written to /var/nf/opcua/pki/rejected/. To trust it, move it into /var/nf/opcua/pki/trusted/:

docker exec -it nf sh -c \
  'mv /var/nf/opcua/pki/rejected/*.der /var/nf/opcua/pki/trusted/'

The client will connect on its next attempt. This is a deliberate speed bump: it means an administrator sees every peer once. Trust All Client Certificates skips it, which is convenient during bringup and unwise afterwards, since any peer presenting any certificate is then accepted.

Settings reference

All settings live under Protocols > OPC-UA > Settings. Changing any of them restarts the OPC-UA server only; the rest of Normal is unaffected.

Setting Default Purpose
Enabled off Master switch
Port 4840 TCP port for opc.tcp
Advertised Hostname system hostname What clients are told to reconnect to
Bind Address 0.0.0.0 Local listen address
Layer (union) Point layer to expose
Equipment Namespace on Publish the equipment model
Allow Anonymous on Permit sessions with no credentials
Allow Insecure Endpoint on Offer an endpoint with no security
Security Policies all three Encrypted policies to offer
Allow Sign Without Encryption off Also offer Sign-only endpoints
Trust All Client Certificates off Skip certificate review
Check Certificate Validity Dates on Enforce certificate validity windows
Credential Revalidation Interval 300s How often held credentials are re-checked
Enable Writes off Allow commanding points
Write Priority 10 Command priority for OPC-UA writes
Write Duration 3600s How long a write holds before reverting
Load Page Size 1000 Points fetched per request at startup

Connecting a client

Any OPC-UA client works. For a first look, UaExpert (free, with registration) and the open-source opcua-client both browse the address space and plot history.

For scripted access, the nf-sdk repository has worked examples under examples/opc covering browsing, reading, subscriptions, history and writes.

pip3 install asyncua
python3 examples/opc/browse.py --url opc.tcp://localhost:4840/

Limitations

  • The address space is built at startup. Points added or removed in Normal appear after the OPC-UA server restarts. Value changes are live.
  • Equipment objects are untyped. They are BaseObjectType with the Normal type name as a property, rather than generated OPC-UA ObjectTypes.
  • History is read-only. HistoryUpdate is not supported.
  • Engineering units are display strings, not UNECE Recommendation 20 unit codes, so they are human-readable but not machine-comparable.
  • Authorization is service-level. A read/write key can command any point; there is no way to scope a key to part of the building.