opcua-sim 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. opcua_sim-0.1.0/CHANGELOG.md +22 -0
  2. opcua_sim-0.1.0/LICENSE +21 -0
  3. opcua_sim-0.1.0/MANIFEST.in +5 -0
  4. opcua_sim-0.1.0/PKG-INFO +326 -0
  5. opcua_sim-0.1.0/README.md +292 -0
  6. opcua_sim-0.1.0/examples/plant.yaml +7 -0
  7. opcua_sim-0.1.0/examples/test_example.py +32 -0
  8. opcua_sim-0.1.0/pyproject.toml +56 -0
  9. opcua_sim-0.1.0/setup.cfg +4 -0
  10. opcua_sim-0.1.0/src/opcua_sim/__init__.py +17 -0
  11. opcua_sim-0.1.0/src/opcua_sim/__main__.py +3 -0
  12. opcua_sim-0.1.0/src/opcua_sim/bench.py +424 -0
  13. opcua_sim-0.1.0/src/opcua_sim/cli.py +390 -0
  14. opcua_sim-0.1.0/src/opcua_sim/control.py +150 -0
  15. opcua_sim-0.1.0/src/opcua_sim/device.py +313 -0
  16. opcua_sim-0.1.0/src/opcua_sim/faults.py +157 -0
  17. opcua_sim-0.1.0/src/opcua_sim/link.py +109 -0
  18. opcua_sim-0.1.0/src/opcua_sim/models.py +450 -0
  19. opcua_sim-0.1.0/src/opcua_sim/profile.py +363 -0
  20. opcua_sim-0.1.0/src/opcua_sim/profiles/beckhoff-twincat3.yaml +68 -0
  21. opcua_sim-0.1.0/src/opcua_sim/profiles/codesys-control.yaml +68 -0
  22. opcua_sim-0.1.0/src/opcua_sim/profiles/drive.yaml +26 -0
  23. opcua_sim-0.1.0/src/opcua_sim/profiles/energy-meter.yaml +24 -0
  24. opcua_sim-0.1.0/src/opcua_sim/profiles/generic-sensor.yaml +16 -0
  25. opcua_sim-0.1.0/src/opcua_sim/profiles/leuze-bcl300.yaml +48 -0
  26. opcua_sim-0.1.0/src/opcua_sim/profiles/omron-nx.yaml +78 -0
  27. opcua_sim-0.1.0/src/opcua_sim/profiles/phoenix-plcnext.yaml +65 -0
  28. opcua_sim-0.1.0/src/opcua_sim/profiles/pressure-transmitter.yaml +33 -0
  29. opcua_sim-0.1.0/src/opcua_sim/profiles/siemens-s7-1500.yaml +68 -0
  30. opcua_sim-0.1.0/src/opcua_sim/profiles/temperature-controller.yaml +26 -0
  31. opcua_sim-0.1.0/src/opcua_sim/pytest_plugin.py +93 -0
  32. opcua_sim-0.1.0/src/opcua_sim/server.py +481 -0
  33. opcua_sim-0.1.0/src/opcua_sim/signals.py +30 -0
  34. opcua_sim-0.1.0/src/opcua_sim.egg-info/PKG-INFO +326 -0
  35. opcua_sim-0.1.0/src/opcua_sim.egg-info/SOURCES.txt +48 -0
  36. opcua_sim-0.1.0/src/opcua_sim.egg-info/dependency_links.txt +1 -0
  37. opcua_sim-0.1.0/src/opcua_sim.egg-info/entry_points.txt +5 -0
  38. opcua_sim-0.1.0/src/opcua_sim.egg-info/requires.txt +6 -0
  39. opcua_sim-0.1.0/src/opcua_sim.egg-info/top_level.txt +1 -0
  40. opcua_sim-0.1.0/tests/conftest.py +109 -0
  41. opcua_sim-0.1.0/tests/test_alarms.py +125 -0
  42. opcua_sim-0.1.0/tests/test_cli.py +133 -0
  43. opcua_sim-0.1.0/tests/test_faults.py +221 -0
  44. opcua_sim-0.1.0/tests/test_models.py +151 -0
  45. opcua_sim-0.1.0/tests/test_packaging.py +65 -0
  46. opcua_sim-0.1.0/tests/test_plugin.py +54 -0
  47. opcua_sim-0.1.0/tests/test_products.py +211 -0
  48. opcua_sim-0.1.0/tests/test_profile.py +112 -0
  49. opcua_sim-0.1.0/tests/test_security.py +113 -0
  50. opcua_sim-0.1.0/tests/test_server.py +113 -0
@@ -0,0 +1,22 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 (2026-11-02)
4
+
5
+ First version.
6
+
7
+ - Five bundled profiles: pressure transmitter, temperature controller, drive, energy meter
8
+ and a generic sensor; profiles and benches in YAML
9
+ - Device behaviour: ramps and a latching trip, a PI loop, 4-20 mA with NE 43 currents, a live
10
+ energy counter
11
+ - `DeviceHealth` with the NE 107 states, analog items with `EURange` and `EngineeringUnits`,
12
+ methods with arguments
13
+ - Alarms as OPC UA events: level and discrete alarms, `Acknowledge`, `ConditionRefresh`
14
+ - Runtime faults on devices (status codes, frozen values, old timestamps, drift, refused
15
+ writes and calls, forced health, model faults) and on the server (delay, black hole, dropped
16
+ and refused connections, server state, service level, restart)
17
+ - Security modes None, Sign and SignAndEncrypt (Basic256Sha256), user names with read-only users
18
+ - Command line, JSON control channel, pytest plugin
19
+ - Six profiles that present a simulated device the way a real product does, each naming its
20
+ sources and limits: Siemens S7-1500, Beckhoff TwinCAT 3, Phoenix Contact PLCnext, CODESYS,
21
+ Omron NJ/NX, Leuze BCL 300
22
+ - Browser demo in `web/`: the device code in Pyodide, without the wire protocol
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ralf Steinert
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,5 @@
1
+ # What goes into the source distribution besides the package itself
2
+ include CHANGELOG.md LICENSE README.md
3
+ graft tests
4
+ graft examples
5
+ global-exclude __pycache__ *.py[cod]
@@ -0,0 +1,326 @@
1
+ Metadata-Version: 2.4
2
+ Name: opcua-sim
3
+ Version: 0.1.0
4
+ Summary: Simulated OPC UA devices with behaviour, NE 107 health, real alarms and fault injection, for testing SCADA, HMI and gateway code
5
+ Author: Ralf Steinert
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/steitec/opcua-sim
8
+ Project-URL: Repository, https://github.com/steitec/opcua-sim
9
+ Project-URL: Changelog, https://github.com/steitec/opcua-sim/blob/main/CHANGELOG.md
10
+ Project-URL: Issues, https://github.com/steitec/opcua-sim/issues
11
+ Keywords: opc-ua,opcua,simulator,scada,hmi,industrial automation,ne107,test automation,pytest
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Intended Audience :: Manufacturing
15
+ Classifier: Framework :: Pytest
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Operating System :: OS Independent
22
+ Classifier: Topic :: Scientific/Engineering
23
+ Classifier: Topic :: Software Development :: Testing
24
+ Classifier: Topic :: System :: Hardware
25
+ Requires-Python: >=3.10
26
+ Description-Content-Type: text/markdown
27
+ License-File: LICENSE
28
+ Requires-Dist: asyncua<3,>=2.1
29
+ Requires-Dist: pyyaml>=6
30
+ Provides-Extra: dev
31
+ Requires-Dist: pytest>=8; extra == "dev"
32
+ Requires-Dist: build>=1.2; extra == "dev"
33
+ Dynamic: license-file
34
+
35
+ <a href="https://steitec.net"><picture>
36
+ <source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/steitec/.github/main/brand/steitec-logo-on-dark.svg">
37
+ <img align="right" height="32" alt="steitec" src="https://raw.githubusercontent.com/steitec/.github/main/brand/steitec-logo-on-light.svg">
38
+ </picture></a>
39
+
40
+ # opcua-sim
41
+
42
+ [![CI](https://github.com/steitec/opcua-sim/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/steitec/opcua-sim/actions/workflows/ci.yml)
43
+ [![PyPI](https://img.shields.io/pypi/v/opcua-sim?v=0.1.0)](https://pypi.org/project/opcua-sim/)
44
+ [![Python](https://img.shields.io/pypi/pyversions/opcua-sim?v=0.1.0)](https://pypi.org/project/opcua-sim/)
45
+ [![License](https://img.shields.io/pypi/l/opcua-sim?v=0.1.0)](https://github.com/steitec/opcua-sim/blob/main/LICENSE)
46
+
47
+ Simulated OPC UA devices that **behave and fail**: a drive that ramps up after `Start` and
48
+ latches a trip until `Reset`, a transmitter whose broken sensor reads `BadSensorFailure` with
49
+ health `FAILURE` and 3.6 mA on the loop, alarms that arrive as real OPC UA events and wait for
50
+ your `Acknowledge`. Develop and test SCADA links, HMIs, gateways and data loggers without the
51
+ plant, also in CI.
52
+
53
+ ```console
54
+ $ opcua-sim serve --bench examples/plant.yaml &
55
+ opcua-sim: 4 device(s) on opc.tcp://127.0.0.1:4840, control on :4841
56
+ oven (temperature-controller)
57
+ pump (drive)
58
+ tank_pressure (pressure-transmitter)
59
+ meter (energy-meter)
60
+ $ opcua-sim write pump.SpeedSetpoint 30
61
+ pump.SpeedSetpoint: written 30.0
62
+ $ opcua-sim call pump.Start
63
+ pump.Start: ok
64
+ $ opcua-sim inject pump trip code=4
65
+ $ opcua-sim read pump.State pump.FaultCode pump.DeviceHealth
66
+ pump.State: 3 [Good, 2026-11-02T18:13:22+00:00]
67
+ pump.FaultCode: 4 [Good, 2026-11-02T18:13:22+00:00]
68
+ pump.DeviceHealth: 1 [Good, 2026-11-02T18:13:22+00:00]
69
+ $ opcua-sim call pump.Start
70
+ opcua-sim: The operation cannot be completed because the object is closed, uninitialized or in some other invalid state.(BadInvalidState)
71
+ $ opcua-sim inject tank_pressure sensor_break
72
+ $ opcua-sim read tank_pressure.Pressure tank_pressure.OutputCurrent
73
+ tank_pressure.Pressure: null [BadSensorFailure, 2026-11-02T18:13:24+00:00]
74
+ tank_pressure.OutputCurrent: 3.6 [Good, 2026-11-02T18:13:24+00:00]
75
+ $ opcua-sim inject server delay seconds=5
76
+ $ opcua-sim read pump.Speed --timeout 1
77
+ opcua-sim: no answer in time
78
+ ```
79
+
80
+ ## Why
81
+
82
+ OPC UA demo servers are good at showing changing numbers. Client code breaks on other things:
83
+
84
+ - **quality**: a value that is `Bad` has no number at all, an `Uncertain` one has a number you
85
+ may not trust, a frozen one has a source timestamp that stopped moving
86
+ - **device state**: methods refused in the wrong state, a latched trip, a health state that
87
+ changes while the values still look fine
88
+ - **alarms**: conditions that must be acknowledged with the right `EventId` and have to be
89
+ fetched again with `ConditionRefresh` after a reconnect
90
+ - **the connection**: late answers, a link that stays open and carries nothing, a server restart
91
+ that takes every session and subscription with it
92
+
93
+ opcua-sim gives you all of that from a YAML profile, switchable at runtime. The OPC UA stack
94
+ underneath is [asyncua](https://github.com/FreeOpcUa/opcua-asyncio).
95
+
96
+ ## What it is not
97
+
98
+ The generic profiles use the names of OPC UA DI (`DeviceSet`, `ParameterSet`, `MethodSet`,
99
+ `DeviceHealth`), but live in their own namespace and do not implement the DI companion
100
+ specification: a client that looks for the DI types will not find them.
101
+
102
+ The [product profiles](#product-profiles) copy how a real server presents its nodes, as far as
103
+ public sources describe it. None of them is a complete copy of a product, and none has been
104
+ compared with real hardware.
105
+
106
+ It is checked with asyncua and node-opcua clients. It has not been tested against UaExpert,
107
+ commercial SCADA systems or a conformance test tool. Please
108
+ [open an issue](https://github.com/steitec/opcua-sim/issues) if your client disagrees with it.
109
+
110
+ ## Install
111
+
112
+ Requires Python 3.10 or newer.
113
+
114
+ ```bash
115
+ pip install opcua-sim
116
+ ```
117
+
118
+ From source, with the test dependencies:
119
+
120
+ ```bash
121
+ git clone https://github.com/steitec/opcua-sim
122
+ cd opcua-sim
123
+ pip install -e ".[dev]"
124
+ pytest
125
+ ```
126
+
127
+ ## Profiles
128
+
129
+ | Profile | Device | Faults of its own |
130
+ |---|---|---|
131
+ | `pressure-transmitter` | 4-20 mA with NE 43 currents, `ZeroTrim`, `StartSelfTest`, level alarm | `sensor_break`, `over_range`, `maintenance_due` |
132
+ | `temperature-controller` | PI loop on a slow plant, setpoint, manual mode, `Start`, `Stop` | `sensor_break`, `heater_failure` |
133
+ | `drive` | frequency converter with ramps, `Start`, `Stop`, `Reset`, latching trip | `trip`, `overload`, `fan_worn` |
134
+ | `energy-meter` | three phases, power, an energy counter that counts, `ResetEnergy` | `phase_loss` |
135
+ | `generic-sensor` | signal sources, no behaviour: a starting point for your own | |
136
+
137
+ `opcua-sim map drive` shows the address space of a profile. Nodes are addressed by their string
138
+ ID in the namespace `urn:steitec:opcua-sim`, for example `ns=2;s=pump.Speed`:
139
+
140
+ ```text
141
+ Objects/DeviceSet/pump
142
+ Manufacturer, Model, SerialNumber, HardwareRevision, SoftwareRevision
143
+ DeviceHealth NORMAL, FAILURE, CHECK_FUNCTION, OFF_SPEC, MAINTENANCE_REQUIRED
144
+ ParameterSet/ the variables; with a unit or range they are AnalogItemType
145
+ MethodSet/ the methods
146
+ Alarms/ one node per alarm: the ConditionId of its events
147
+ ```
148
+
149
+ A bench puts several devices behind one server:
150
+
151
+ ```yaml
152
+ name: demo plant
153
+ security: [none, encrypt] # none, sign, encrypt; self-signed certificate
154
+ users: # optional; without users everybody may write
155
+ anna: {password: s3cret} # role operator
156
+ vic: {password: look, role: viewer}
157
+ anonymous: true # anonymous clients may read
158
+ tick: 0.1 # simulation step in seconds
159
+ seed: 0 # noise is repeatable
160
+ devices:
161
+ - {name: oven, profile: temperature-controller}
162
+ - {name: pump, profile: drive, params: {ramp: 5.0}}
163
+ - {name: mine, profile: my-device.yaml, serial_number: X-42} # relative to this file
164
+ ```
165
+
166
+ A profile of your own needs no Python. Without `model`, values come from signal sources
167
+ (`constant`, `sine`, `ramp`, `square`, `noise`), from `value`, or from what a client wrote:
168
+
169
+ ```yaml
170
+ name: flow-sensor
171
+ identification: {manufacturer: ACME, model: F-100}
172
+ variables:
173
+ - {name: Flow, unit: l/min, range: [0, 200], source: {kind: sine, offset: 100, amplitude: 20, period: 30}}
174
+ - {name: Enabled, type: Boolean, value: true, writable: true}
175
+ - {name: Limit, unit: l/min, value: 150, writable: true, min: 0, max: 200}
176
+ alarms:
177
+ - {name: FlowLevel, kind: level, variable: Flow, high: 115, low: 85, deadband: 1,
178
+ message: "Flow out of limits"}
179
+ ```
180
+
181
+ Level alarms know `high_high`, `high`, `low` and `low_low`, by default with severity 900 for the
182
+ outer and 700 for the inner limits; `high: {value: 115, severity: 800}` changes that. Discrete
183
+ alarms (`kind: discrete`, `when: <flag>`) follow a flag of a device model. A value without a
184
+ valid status does not change the state of its level alarm.
185
+
186
+ ## Product profiles
187
+
188
+ These profiles make the simulated server look like a real product to your client: same namespace
189
+ URIs and indices, same form of node IDs, same data types. A PLC server publishes whatever program
190
+ runs on it, so the variables are one small example program (run and reset bits, a ramp, a part
191
+ counter, a latching fault), presented the way each server would. That is what tests the parts
192
+ that break when you move between brands: quotes in Siemens IDs, the `|var|` of CODESYS, a namespace
193
+ index that differs per server.
194
+
195
+ | Profile | Example node ID | Based on | Assumed or missing |
196
+ |---|---|---|---|
197
+ | `siemens-s7-1500` | `ns=3;s="DB_Line"."Speed"` | Siemens application document 109737901 for the ID form, third-party guides for the URI and folders | folders and URI not confirmed by Siemens |
198
+ | `beckhoff-twincat3` | `ns=4;s=MAIN.fSpeed` | Beckhoff Information System for the URI, a Cybus guide for the IDs | real defaults are stricter: encrypted only, no anonymous access |
199
+ | `phoenix-plcnext` | `ns=2;s=Arp.Plc.Eclr/MainInstance.rSpeed` | PLCnext documentation for the URI and ID form | namespace index, folders |
200
+ | `omron-nx` | `ns=2;s=new_Controller_0.GlobalVars.Speed` | Omron manual W588 for the tree, types and status nodes | manual gives no URI or ID strings: **placeholders**, so browse |
201
+ | `codesys-control` | `ns=4;s=\|var\|CODESYS Control for Linux SL.Application.PLC_PRG.rSpeed` | forum reports only | URI is a **placeholder**, folders; the weakest of the six |
202
+ | `leuze-bcl300` | `ns=4;i=6205` | Leuze technical description 50141930 | 19 of the 136 listed nodes; no `ScanStart`, `Scan` |
203
+
204
+ `leuze-bcl300` is the one real device in the list: a bar code reader that scans by itself, with
205
+ node IDs, browse names and types from the manufacturer's table. Rockwell, Mitsubishi and gateways
206
+ such as KEPServerEX are not included, because the sources found were not good enough to copy them.
207
+
208
+ `opcua-sim map <profile>` prints every node ID with the sources and limits of that profile. These
209
+ profiles have no alarms and no `DeviceHealth` node, and a product profile must be the only device
210
+ of its bench, because it is a whole server. Their own faults are `trip` and `plc_stop` on the PLCs
211
+ and `read_fail` and `device_error` on the reader; every other fault works as usual.
212
+
213
+ ```console
214
+ $ opcua-sim serve --profile siemens-s7-1500 --name plc &
215
+ $ opcua-sim write 'ns=3;s="DB_Line"."Run"' true
216
+ $ opcua-sim inject plc trip code=12
217
+ $ opcua-sim read 'ns=3;s="DB_Line"."Fault"' 'ns=3;s="DB_Line"."FaultCode"'
218
+ ```
219
+
220
+ Methods need their object: `opcua-sim call 'ns=4;i=7002' --object 'ns=4;i=5001'`.
221
+
222
+ ## Faults
223
+
224
+ ```bash
225
+ opcua-sim inject <device> <kind> [name=value ...]
226
+ opcua-sim inject server <kind> [name=value ...]
227
+ opcua-sim clear <device or server> [kind]
228
+ ```
229
+
230
+ **On every device:**
231
+
232
+ | Kind | Parameters | Effect |
233
+ |---|---|---|
234
+ | `bad_status` | `variable`, `status=BadCommunicationError` | the variable (or all) carries this status code; `Bad*` reads as null, `Uncertain*` keeps its value |
235
+ | `freeze` | `variable` | values stop changing, source timestamps stay old |
236
+ | `stale` | `seconds=60` | values go on, source timestamps lag behind |
237
+ | `drift` | `variable`, `per_second` | a numeric value runs away |
238
+ | `reject_write` | `variable`, `status=BadUserAccessDenied` | writes answer this status code |
239
+ | `method_fail` | `method`, `status=BadInvalidState` | calls answer this status code |
240
+ | `health` | `state` | forces `DeviceHealth` |
241
+
242
+ **On the server:**
243
+
244
+ | Kind | Parameters | Effect |
245
+ |---|---|---|
246
+ | `delay` | `seconds` | every response arrives late |
247
+ | `blackhole` | | connections stay open, nothing gets through |
248
+ | `drop_connections` | | all connections are cut now |
249
+ | `refuse` | | new connections are closed at once |
250
+ | `server_state` | `state` | `ServerStatus/State`: `Suspended`, `Test`, `Failed`, ... |
251
+ | `service_level` | `value` | `Server/ServiceLevel`, 0-255 |
252
+ | `restart` | `downtime=2` | the server goes away and returns on the same port without sessions or subscriptions; device state stays; building the server adds about a second |
253
+
254
+ Network faults sit in a small TCP forwarder in front of the server. `blackhole` holds the data
255
+ back rather than losing it, so a client that has not given up yet receives it after `clear`.
256
+
257
+ ## Alarms
258
+
259
+ Alarms arrive as `ExclusiveLevelAlarmType` and `OffNormalAlarmType` events, from the device node
260
+ and from the `Server` object, with the usual fields: `ConditionId`, `ConditionName`, `Severity`,
261
+ `ActiveState/Id`, `AckedState/Id`, `Retain`, `Message`, and for level alarms the limits and
262
+ `LimitState/CurrentState`.
263
+
264
+ `Acknowledge` (`i=9111`, called on the `ConditionId`) checks the `EventId` and answers
265
+ `BadEventIdUnknown` or `BadConditionBranchAlreadyAcked`. `ConditionRefresh` (`i=3875`) sends
266
+ `RefreshStartEvent`, every retained condition, then `RefreshEndEvent`.
267
+
268
+ Alarm conditions are events, not full condition instances: `Enable`, `Disable`, `AddComment`,
269
+ `Confirm`, shelving and branches are not implemented, and the `Alarms/` nodes have no child
270
+ variables to browse.
271
+
272
+ ## In pytest
273
+
274
+ Installing the package adds two fixtures. Every test gets a new bench on a free port:
275
+
276
+ ```python
277
+ def test_notices_a_device_failure(opcua_bench):
278
+ opcua_bench.inject("drive", "bad_status", variable="Speed", status="BadDeviceFailure")
279
+ with pytest.raises(ua.uaerrors.BadDeviceFailure):
280
+ read_speed(opcua_bench.url) # your code
281
+
282
+
283
+ def test_two_servers(opcua_bench_factory):
284
+ plant = opcua_bench_factory("plant.yaml") # bench file, dict or profile name
285
+ meter = opcua_bench_factory("energy-meter")
286
+ ```
287
+
288
+ `--opcua-bench FILE` or the ini key `opcua_bench` choose the bench of `opcua_bench`; without them
289
+ it is one `drive`. See [`examples/test_example.py`](examples/test_example.py). The bench API is
290
+ `url`, `port`, `inject`, `clear`, `set`, `get`, `status`, `wait_restarted`.
291
+
292
+ Starting a server takes about a second, because asyncua loads the whole standard address space.
293
+
294
+ ## Command line
295
+
296
+ | Command | |
297
+ |---|---|
298
+ | `serve --bench FILE` or `--profile NAME` | run; `--host`, `--port`, `--endpoint-host`, `--security` |
299
+ | `profiles`, `map PROFILE` | what is there |
300
+ | `browse`, `read`, `write`, `call`, `watch` | a small OPC UA client; `--url`, `--user`, `--password`, `--timeout` |
301
+ | `inject`, `clear`, `set`, `get`, `list` | the control channel of a running bench; `--control-port` |
302
+
303
+ `set` forces a value from outside (`null` hands it back to the simulation), `watch --alarms` prints
304
+ alarm events. The control channel is one JSON object per line on port 4841, described in
305
+ [`control.py`](src/opcua_sim/control.py).
306
+
307
+ ## Security
308
+
309
+ Do not put this on a network you do not trust. It is a test tool:
310
+
311
+ - By default it binds to loopback. With `--host 0.0.0.0` the control channel, which has no
312
+ authentication, is reachable too
313
+ - Passwords are plain text in the bench file, and on an endpoint without encryption they can
314
+ cross the wire in plain text
315
+ - Certificates are self-signed and created at every start; client certificates are not checked
316
+
317
+ ## Browser demo
318
+
319
+ [`web/`](web/) holds a static demo page. It runs the real device code in the browser with
320
+ [Pyodide](https://pyodide.org), but not the OPC UA wire protocol, because a browser has no TCP
321
+ sockets. To test a client, install opcua-sim.
322
+
323
+ ## License
324
+
325
+ [MIT](LICENSE). The OPC UA stack [asyncua](https://github.com/FreeOpcUa/opcua-asyncio) is LGPL-3.0
326
+ and is installed as a separate package.
@@ -0,0 +1,292 @@
1
+ <a href="https://steitec.net"><picture>
2
+ <source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/steitec/.github/main/brand/steitec-logo-on-dark.svg">
3
+ <img align="right" height="32" alt="steitec" src="https://raw.githubusercontent.com/steitec/.github/main/brand/steitec-logo-on-light.svg">
4
+ </picture></a>
5
+
6
+ # opcua-sim
7
+
8
+ [![CI](https://github.com/steitec/opcua-sim/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/steitec/opcua-sim/actions/workflows/ci.yml)
9
+ [![PyPI](https://img.shields.io/pypi/v/opcua-sim?v=0.1.0)](https://pypi.org/project/opcua-sim/)
10
+ [![Python](https://img.shields.io/pypi/pyversions/opcua-sim?v=0.1.0)](https://pypi.org/project/opcua-sim/)
11
+ [![License](https://img.shields.io/pypi/l/opcua-sim?v=0.1.0)](https://github.com/steitec/opcua-sim/blob/main/LICENSE)
12
+
13
+ Simulated OPC UA devices that **behave and fail**: a drive that ramps up after `Start` and
14
+ latches a trip until `Reset`, a transmitter whose broken sensor reads `BadSensorFailure` with
15
+ health `FAILURE` and 3.6 mA on the loop, alarms that arrive as real OPC UA events and wait for
16
+ your `Acknowledge`. Develop and test SCADA links, HMIs, gateways and data loggers without the
17
+ plant, also in CI.
18
+
19
+ ```console
20
+ $ opcua-sim serve --bench examples/plant.yaml &
21
+ opcua-sim: 4 device(s) on opc.tcp://127.0.0.1:4840, control on :4841
22
+ oven (temperature-controller)
23
+ pump (drive)
24
+ tank_pressure (pressure-transmitter)
25
+ meter (energy-meter)
26
+ $ opcua-sim write pump.SpeedSetpoint 30
27
+ pump.SpeedSetpoint: written 30.0
28
+ $ opcua-sim call pump.Start
29
+ pump.Start: ok
30
+ $ opcua-sim inject pump trip code=4
31
+ $ opcua-sim read pump.State pump.FaultCode pump.DeviceHealth
32
+ pump.State: 3 [Good, 2026-11-02T18:13:22+00:00]
33
+ pump.FaultCode: 4 [Good, 2026-11-02T18:13:22+00:00]
34
+ pump.DeviceHealth: 1 [Good, 2026-11-02T18:13:22+00:00]
35
+ $ opcua-sim call pump.Start
36
+ opcua-sim: The operation cannot be completed because the object is closed, uninitialized or in some other invalid state.(BadInvalidState)
37
+ $ opcua-sim inject tank_pressure sensor_break
38
+ $ opcua-sim read tank_pressure.Pressure tank_pressure.OutputCurrent
39
+ tank_pressure.Pressure: null [BadSensorFailure, 2026-11-02T18:13:24+00:00]
40
+ tank_pressure.OutputCurrent: 3.6 [Good, 2026-11-02T18:13:24+00:00]
41
+ $ opcua-sim inject server delay seconds=5
42
+ $ opcua-sim read pump.Speed --timeout 1
43
+ opcua-sim: no answer in time
44
+ ```
45
+
46
+ ## Why
47
+
48
+ OPC UA demo servers are good at showing changing numbers. Client code breaks on other things:
49
+
50
+ - **quality**: a value that is `Bad` has no number at all, an `Uncertain` one has a number you
51
+ may not trust, a frozen one has a source timestamp that stopped moving
52
+ - **device state**: methods refused in the wrong state, a latched trip, a health state that
53
+ changes while the values still look fine
54
+ - **alarms**: conditions that must be acknowledged with the right `EventId` and have to be
55
+ fetched again with `ConditionRefresh` after a reconnect
56
+ - **the connection**: late answers, a link that stays open and carries nothing, a server restart
57
+ that takes every session and subscription with it
58
+
59
+ opcua-sim gives you all of that from a YAML profile, switchable at runtime. The OPC UA stack
60
+ underneath is [asyncua](https://github.com/FreeOpcUa/opcua-asyncio).
61
+
62
+ ## What it is not
63
+
64
+ The generic profiles use the names of OPC UA DI (`DeviceSet`, `ParameterSet`, `MethodSet`,
65
+ `DeviceHealth`), but live in their own namespace and do not implement the DI companion
66
+ specification: a client that looks for the DI types will not find them.
67
+
68
+ The [product profiles](#product-profiles) copy how a real server presents its nodes, as far as
69
+ public sources describe it. None of them is a complete copy of a product, and none has been
70
+ compared with real hardware.
71
+
72
+ It is checked with asyncua and node-opcua clients. It has not been tested against UaExpert,
73
+ commercial SCADA systems or a conformance test tool. Please
74
+ [open an issue](https://github.com/steitec/opcua-sim/issues) if your client disagrees with it.
75
+
76
+ ## Install
77
+
78
+ Requires Python 3.10 or newer.
79
+
80
+ ```bash
81
+ pip install opcua-sim
82
+ ```
83
+
84
+ From source, with the test dependencies:
85
+
86
+ ```bash
87
+ git clone https://github.com/steitec/opcua-sim
88
+ cd opcua-sim
89
+ pip install -e ".[dev]"
90
+ pytest
91
+ ```
92
+
93
+ ## Profiles
94
+
95
+ | Profile | Device | Faults of its own |
96
+ |---|---|---|
97
+ | `pressure-transmitter` | 4-20 mA with NE 43 currents, `ZeroTrim`, `StartSelfTest`, level alarm | `sensor_break`, `over_range`, `maintenance_due` |
98
+ | `temperature-controller` | PI loop on a slow plant, setpoint, manual mode, `Start`, `Stop` | `sensor_break`, `heater_failure` |
99
+ | `drive` | frequency converter with ramps, `Start`, `Stop`, `Reset`, latching trip | `trip`, `overload`, `fan_worn` |
100
+ | `energy-meter` | three phases, power, an energy counter that counts, `ResetEnergy` | `phase_loss` |
101
+ | `generic-sensor` | signal sources, no behaviour: a starting point for your own | |
102
+
103
+ `opcua-sim map drive` shows the address space of a profile. Nodes are addressed by their string
104
+ ID in the namespace `urn:steitec:opcua-sim`, for example `ns=2;s=pump.Speed`:
105
+
106
+ ```text
107
+ Objects/DeviceSet/pump
108
+ Manufacturer, Model, SerialNumber, HardwareRevision, SoftwareRevision
109
+ DeviceHealth NORMAL, FAILURE, CHECK_FUNCTION, OFF_SPEC, MAINTENANCE_REQUIRED
110
+ ParameterSet/ the variables; with a unit or range they are AnalogItemType
111
+ MethodSet/ the methods
112
+ Alarms/ one node per alarm: the ConditionId of its events
113
+ ```
114
+
115
+ A bench puts several devices behind one server:
116
+
117
+ ```yaml
118
+ name: demo plant
119
+ security: [none, encrypt] # none, sign, encrypt; self-signed certificate
120
+ users: # optional; without users everybody may write
121
+ anna: {password: s3cret} # role operator
122
+ vic: {password: look, role: viewer}
123
+ anonymous: true # anonymous clients may read
124
+ tick: 0.1 # simulation step in seconds
125
+ seed: 0 # noise is repeatable
126
+ devices:
127
+ - {name: oven, profile: temperature-controller}
128
+ - {name: pump, profile: drive, params: {ramp: 5.0}}
129
+ - {name: mine, profile: my-device.yaml, serial_number: X-42} # relative to this file
130
+ ```
131
+
132
+ A profile of your own needs no Python. Without `model`, values come from signal sources
133
+ (`constant`, `sine`, `ramp`, `square`, `noise`), from `value`, or from what a client wrote:
134
+
135
+ ```yaml
136
+ name: flow-sensor
137
+ identification: {manufacturer: ACME, model: F-100}
138
+ variables:
139
+ - {name: Flow, unit: l/min, range: [0, 200], source: {kind: sine, offset: 100, amplitude: 20, period: 30}}
140
+ - {name: Enabled, type: Boolean, value: true, writable: true}
141
+ - {name: Limit, unit: l/min, value: 150, writable: true, min: 0, max: 200}
142
+ alarms:
143
+ - {name: FlowLevel, kind: level, variable: Flow, high: 115, low: 85, deadband: 1,
144
+ message: "Flow out of limits"}
145
+ ```
146
+
147
+ Level alarms know `high_high`, `high`, `low` and `low_low`, by default with severity 900 for the
148
+ outer and 700 for the inner limits; `high: {value: 115, severity: 800}` changes that. Discrete
149
+ alarms (`kind: discrete`, `when: <flag>`) follow a flag of a device model. A value without a
150
+ valid status does not change the state of its level alarm.
151
+
152
+ ## Product profiles
153
+
154
+ These profiles make the simulated server look like a real product to your client: same namespace
155
+ URIs and indices, same form of node IDs, same data types. A PLC server publishes whatever program
156
+ runs on it, so the variables are one small example program (run and reset bits, a ramp, a part
157
+ counter, a latching fault), presented the way each server would. That is what tests the parts
158
+ that break when you move between brands: quotes in Siemens IDs, the `|var|` of CODESYS, a namespace
159
+ index that differs per server.
160
+
161
+ | Profile | Example node ID | Based on | Assumed or missing |
162
+ |---|---|---|---|
163
+ | `siemens-s7-1500` | `ns=3;s="DB_Line"."Speed"` | Siemens application document 109737901 for the ID form, third-party guides for the URI and folders | folders and URI not confirmed by Siemens |
164
+ | `beckhoff-twincat3` | `ns=4;s=MAIN.fSpeed` | Beckhoff Information System for the URI, a Cybus guide for the IDs | real defaults are stricter: encrypted only, no anonymous access |
165
+ | `phoenix-plcnext` | `ns=2;s=Arp.Plc.Eclr/MainInstance.rSpeed` | PLCnext documentation for the URI and ID form | namespace index, folders |
166
+ | `omron-nx` | `ns=2;s=new_Controller_0.GlobalVars.Speed` | Omron manual W588 for the tree, types and status nodes | manual gives no URI or ID strings: **placeholders**, so browse |
167
+ | `codesys-control` | `ns=4;s=\|var\|CODESYS Control for Linux SL.Application.PLC_PRG.rSpeed` | forum reports only | URI is a **placeholder**, folders; the weakest of the six |
168
+ | `leuze-bcl300` | `ns=4;i=6205` | Leuze technical description 50141930 | 19 of the 136 listed nodes; no `ScanStart`, `Scan` |
169
+
170
+ `leuze-bcl300` is the one real device in the list: a bar code reader that scans by itself, with
171
+ node IDs, browse names and types from the manufacturer's table. Rockwell, Mitsubishi and gateways
172
+ such as KEPServerEX are not included, because the sources found were not good enough to copy them.
173
+
174
+ `opcua-sim map <profile>` prints every node ID with the sources and limits of that profile. These
175
+ profiles have no alarms and no `DeviceHealth` node, and a product profile must be the only device
176
+ of its bench, because it is a whole server. Their own faults are `trip` and `plc_stop` on the PLCs
177
+ and `read_fail` and `device_error` on the reader; every other fault works as usual.
178
+
179
+ ```console
180
+ $ opcua-sim serve --profile siemens-s7-1500 --name plc &
181
+ $ opcua-sim write 'ns=3;s="DB_Line"."Run"' true
182
+ $ opcua-sim inject plc trip code=12
183
+ $ opcua-sim read 'ns=3;s="DB_Line"."Fault"' 'ns=3;s="DB_Line"."FaultCode"'
184
+ ```
185
+
186
+ Methods need their object: `opcua-sim call 'ns=4;i=7002' --object 'ns=4;i=5001'`.
187
+
188
+ ## Faults
189
+
190
+ ```bash
191
+ opcua-sim inject <device> <kind> [name=value ...]
192
+ opcua-sim inject server <kind> [name=value ...]
193
+ opcua-sim clear <device or server> [kind]
194
+ ```
195
+
196
+ **On every device:**
197
+
198
+ | Kind | Parameters | Effect |
199
+ |---|---|---|
200
+ | `bad_status` | `variable`, `status=BadCommunicationError` | the variable (or all) carries this status code; `Bad*` reads as null, `Uncertain*` keeps its value |
201
+ | `freeze` | `variable` | values stop changing, source timestamps stay old |
202
+ | `stale` | `seconds=60` | values go on, source timestamps lag behind |
203
+ | `drift` | `variable`, `per_second` | a numeric value runs away |
204
+ | `reject_write` | `variable`, `status=BadUserAccessDenied` | writes answer this status code |
205
+ | `method_fail` | `method`, `status=BadInvalidState` | calls answer this status code |
206
+ | `health` | `state` | forces `DeviceHealth` |
207
+
208
+ **On the server:**
209
+
210
+ | Kind | Parameters | Effect |
211
+ |---|---|---|
212
+ | `delay` | `seconds` | every response arrives late |
213
+ | `blackhole` | | connections stay open, nothing gets through |
214
+ | `drop_connections` | | all connections are cut now |
215
+ | `refuse` | | new connections are closed at once |
216
+ | `server_state` | `state` | `ServerStatus/State`: `Suspended`, `Test`, `Failed`, ... |
217
+ | `service_level` | `value` | `Server/ServiceLevel`, 0-255 |
218
+ | `restart` | `downtime=2` | the server goes away and returns on the same port without sessions or subscriptions; device state stays; building the server adds about a second |
219
+
220
+ Network faults sit in a small TCP forwarder in front of the server. `blackhole` holds the data
221
+ back rather than losing it, so a client that has not given up yet receives it after `clear`.
222
+
223
+ ## Alarms
224
+
225
+ Alarms arrive as `ExclusiveLevelAlarmType` and `OffNormalAlarmType` events, from the device node
226
+ and from the `Server` object, with the usual fields: `ConditionId`, `ConditionName`, `Severity`,
227
+ `ActiveState/Id`, `AckedState/Id`, `Retain`, `Message`, and for level alarms the limits and
228
+ `LimitState/CurrentState`.
229
+
230
+ `Acknowledge` (`i=9111`, called on the `ConditionId`) checks the `EventId` and answers
231
+ `BadEventIdUnknown` or `BadConditionBranchAlreadyAcked`. `ConditionRefresh` (`i=3875`) sends
232
+ `RefreshStartEvent`, every retained condition, then `RefreshEndEvent`.
233
+
234
+ Alarm conditions are events, not full condition instances: `Enable`, `Disable`, `AddComment`,
235
+ `Confirm`, shelving and branches are not implemented, and the `Alarms/` nodes have no child
236
+ variables to browse.
237
+
238
+ ## In pytest
239
+
240
+ Installing the package adds two fixtures. Every test gets a new bench on a free port:
241
+
242
+ ```python
243
+ def test_notices_a_device_failure(opcua_bench):
244
+ opcua_bench.inject("drive", "bad_status", variable="Speed", status="BadDeviceFailure")
245
+ with pytest.raises(ua.uaerrors.BadDeviceFailure):
246
+ read_speed(opcua_bench.url) # your code
247
+
248
+
249
+ def test_two_servers(opcua_bench_factory):
250
+ plant = opcua_bench_factory("plant.yaml") # bench file, dict or profile name
251
+ meter = opcua_bench_factory("energy-meter")
252
+ ```
253
+
254
+ `--opcua-bench FILE` or the ini key `opcua_bench` choose the bench of `opcua_bench`; without them
255
+ it is one `drive`. See [`examples/test_example.py`](examples/test_example.py). The bench API is
256
+ `url`, `port`, `inject`, `clear`, `set`, `get`, `status`, `wait_restarted`.
257
+
258
+ Starting a server takes about a second, because asyncua loads the whole standard address space.
259
+
260
+ ## Command line
261
+
262
+ | Command | |
263
+ |---|---|
264
+ | `serve --bench FILE` or `--profile NAME` | run; `--host`, `--port`, `--endpoint-host`, `--security` |
265
+ | `profiles`, `map PROFILE` | what is there |
266
+ | `browse`, `read`, `write`, `call`, `watch` | a small OPC UA client; `--url`, `--user`, `--password`, `--timeout` |
267
+ | `inject`, `clear`, `set`, `get`, `list` | the control channel of a running bench; `--control-port` |
268
+
269
+ `set` forces a value from outside (`null` hands it back to the simulation), `watch --alarms` prints
270
+ alarm events. The control channel is one JSON object per line on port 4841, described in
271
+ [`control.py`](src/opcua_sim/control.py).
272
+
273
+ ## Security
274
+
275
+ Do not put this on a network you do not trust. It is a test tool:
276
+
277
+ - By default it binds to loopback. With `--host 0.0.0.0` the control channel, which has no
278
+ authentication, is reachable too
279
+ - Passwords are plain text in the bench file, and on an endpoint without encryption they can
280
+ cross the wire in plain text
281
+ - Certificates are self-signed and created at every start; client certificates are not checked
282
+
283
+ ## Browser demo
284
+
285
+ [`web/`](web/) holds a static demo page. It runs the real device code in the browser with
286
+ [Pyodide](https://pyodide.org), but not the OPC UA wire protocol, because a browser has no TCP
287
+ sockets. To test a client, install opcua-sim.
288
+
289
+ ## License
290
+
291
+ [MIT](LICENSE). The OPC UA stack [asyncua](https://github.com/FreeOpcUa/opcua-asyncio) is LGPL-3.0
292
+ and is installed as a separate package.