A read-only fleet-state viewer for a BESS (battery energy storage system) digital twin. It polls the plant's OPC UA server directly and renders every inverter and battery on a single page.
The lab it belongs to already has two views of the plant:
| Layer | Tool | Answers |
|---|---|---|
| SCADA / control | Ignition Perspective | "what is it doing, and let me change it" |
| Historian | Grafana + TimescaleDB | "what did it do over the last week" |
| Fleet portal | bess-viz | "what are all 30 devices doing right now, unmediated" |
bess-viz reads the plant's OPC UA server directly, and renders it with no SCADA layer in between. Its telemetry ultimately originates in the same database as the others — the OPC bridge populates its tags by selecting the latest rows — so this is not an independent data source, and it would be wrong to claim otherwise.
What it is independent of is the SCADA layer: no named queries, no tag bindings, no panel
logic. And the OPC surface carries something the database does not. Command tags such as
Site/P_set_kW are staged inputs: written by an operator, initialised once at bridge
startup, and never refreshed from telemetry afterwards. They hold intent, not state.
That distinction is the reason this exists. When the panel and the historian disagreed about a setpoint, the raw OPC view showed a stale operator command that neither of them was displaying — because neither of them shows that layer.
Three properties define it:
- Read-only. There is no write path. It never issues a command or writes a tag.
- Stateless. It holds the current snapshot and nothing else. No history, no database.
- Unmediated. It shows the OPC UA layer as it is, with no SCADA in the path. Its telemetry still comes from the same database everything else reads.
OPC UA server poller web browser
(simulator) --> asyncua client --> fleet_state.json --> static page
:4840 every 2 seconds (atomic write) + /api/fleet
The poller discovers the namespace on connect, reads the fleet, and writes a single JSON
snapshot atomically (write to .tmp, then os.replace) so a reader never sees a
half-written file. The web half is Python standard library only — no framework, no
dependencies — and serves the snapshot at /api/fleet with a staleness flag set when the
data is older than three poll cycles, so a dead poller shows as stale rather than as
plausible-looking frozen numbers.
All configuration comes from the environment, so the same image or checkout runs against any plant without an edit. Defaults are the safe/local choice; a deployment opts in to anything wider.
| Variable | Default | Purpose |
|---|---|---|
BESS_OPC_ENDPOINT |
opc.tcp://192.168.1.71:4840 |
OPC UA server to poll |
BESS_VIZ_DATA |
<repo>/data/fleet_state.json (web), /data/fleet_state.json (poller) |
shared snapshot file |
BESS_VIZ_BIND |
127.0.0.1 |
web bind address; set 0.0.0.0 to expose on the network |
BESS_VIZ_PORT |
8002 |
web port |
Poller, as a container:
podman build -t bess-viz-poller:latest poller/
podman run -d --name bess-viz-poller --network host \
-e BESS_OPC_ENDPOINT=opc.tcp://<host>:4840 \
-v "$PWD/data:/data:Z" \
bess-viz-poller:latestPoller, directly (needs asyncua):
python3 -m venv .venv && .venv/bin/pip install asyncua
BESS_VIZ_DATA="$PWD/data/fleet_state.json" .venv/bin/python poller/poll.pyWeb (standard library only, no install step):
python3 web/server.py # http://127.0.0.1:8002poller/discover.py is a standalone tool that dumps the server's whole namespace tree —
useful when pointing this at a plant whose tag layout you don't know yet.
Deliberate, and worth stating plainly:
- No authentication. Binding to
0.0.0.0exposes it to everyone on the network. - No history. If you want trends, that is the historian's job.
- No control. By design; commanding the plant belongs to the SCADA layer.
- Anonymous OPC UA. It connects without credentials or encryption, which is a property of the lab it was built against, not a recommendation.