fieldsim 0.2.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 (55) hide show
  1. fieldsim-0.2.0/.gitattributes +1 -0
  2. fieldsim-0.2.0/.github/workflows/ci.yml +46 -0
  3. fieldsim-0.2.0/.github/workflows/release.yml +82 -0
  4. fieldsim-0.2.0/.gitignore +17 -0
  5. fieldsim-0.2.0/.pre-commit-config.yaml +14 -0
  6. fieldsim-0.2.0/CHANGELOG.md +37 -0
  7. fieldsim-0.2.0/LICENSE +21 -0
  8. fieldsim-0.2.0/PKG-INFO +257 -0
  9. fieldsim-0.2.0/README.md +197 -0
  10. fieldsim-0.2.0/examples/boiler.yaml +47 -0
  11. fieldsim-0.2.0/examples/test_boiler_example.py +73 -0
  12. fieldsim-0.2.0/pyproject.toml +105 -0
  13. fieldsim-0.2.0/src/fieldsim/__init__.py +26 -0
  14. fieldsim-0.2.0/src/fieldsim/__main__.py +3 -0
  15. fieldsim-0.2.0/src/fieldsim/background.py +362 -0
  16. fieldsim-0.2.0/src/fieldsim/cli.py +137 -0
  17. fieldsim-0.2.0/src/fieldsim/config.py +560 -0
  18. fieldsim-0.2.0/src/fieldsim/core/__init__.py +0 -0
  19. fieldsim-0.2.0/src/fieldsim/core/clock.py +87 -0
  20. fieldsim-0.2.0/src/fieldsim/core/datastore.py +35 -0
  21. fieldsim-0.2.0/src/fieldsim/core/device.py +205 -0
  22. fieldsim-0.2.0/src/fieldsim/core/engine.py +88 -0
  23. fieldsim-0.2.0/src/fieldsim/core/faults.py +150 -0
  24. fieldsim-0.2.0/src/fieldsim/core/generators.py +109 -0
  25. fieldsim-0.2.0/src/fieldsim/core/scenario.py +140 -0
  26. fieldsim-0.2.0/src/fieldsim/core/types.py +133 -0
  27. fieldsim-0.2.0/src/fieldsim/protocols/__init__.py +0 -0
  28. fieldsim-0.2.0/src/fieldsim/protocols/modbus/__init__.py +18 -0
  29. fieldsim-0.2.0/src/fieldsim/protocols/modbus/constants.py +42 -0
  30. fieldsim-0.2.0/src/fieldsim/protocols/modbus/datastore.py +203 -0
  31. fieldsim-0.2.0/src/fieldsim/protocols/modbus/device.py +115 -0
  32. fieldsim-0.2.0/src/fieldsim/protocols/modbus/framer.py +196 -0
  33. fieldsim-0.2.0/src/fieldsim/protocols/modbus/pdu.py +157 -0
  34. fieldsim-0.2.0/src/fieldsim/protocols/modbus/server.py +135 -0
  35. fieldsim-0.2.0/src/fieldsim/py.typed +0 -0
  36. fieldsim-0.2.0/src/fieldsim/pytest_plugin.py +163 -0
  37. fieldsim-0.2.0/src/fieldsim/simulator.py +96 -0
  38. fieldsim-0.2.0/tests/__init__.py +0 -0
  39. fieldsim-0.2.0/tests/conftest.py +53 -0
  40. fieldsim-0.2.0/tests/test_background.py +199 -0
  41. fieldsim-0.2.0/tests/test_cli.py +146 -0
  42. fieldsim-0.2.0/tests/test_clock.py +77 -0
  43. fieldsim-0.2.0/tests/test_config.py +271 -0
  44. fieldsim-0.2.0/tests/test_datastore.py +114 -0
  45. fieldsim-0.2.0/tests/test_engine.py +88 -0
  46. fieldsim-0.2.0/tests/test_fault_injection.py +220 -0
  47. fieldsim-0.2.0/tests/test_faults.py +107 -0
  48. fieldsim-0.2.0/tests/test_framer.py +132 -0
  49. fieldsim-0.2.0/tests/test_generators.py +98 -0
  50. fieldsim-0.2.0/tests/test_pdu.py +128 -0
  51. fieldsim-0.2.0/tests/test_pytest_plugin.py +224 -0
  52. fieldsim-0.2.0/tests/test_scenario.py +153 -0
  53. fieldsim-0.2.0/tests/test_server.py +237 -0
  54. fieldsim-0.2.0/tests/test_simulator.py +54 -0
  55. fieldsim-0.2.0/tests/test_types.py +107 -0
@@ -0,0 +1 @@
1
+ * text=auto eol=lf
@@ -0,0 +1,46 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+
8
+ concurrency:
9
+ group: ${{ github.workflow }}-${{ github.ref }}
10
+ cancel-in-progress: true
11
+
12
+ jobs:
13
+ lint:
14
+ runs-on: ubuntu-latest
15
+ steps:
16
+ - uses: actions/checkout@v4
17
+ - uses: actions/setup-python@v5
18
+ with:
19
+ python-version: "3.12"
20
+ cache: pip
21
+ - run: pip install -e ".[dev]"
22
+ - run: ruff check .
23
+ - run: ruff format --check .
24
+ - run: mypy
25
+
26
+ test:
27
+ runs-on: ${{ matrix.os }}
28
+ strategy:
29
+ fail-fast: false
30
+ matrix:
31
+ os: [ubuntu-latest, windows-latest]
32
+ python-version: ["3.10", "3.11", "3.12", "3.13"]
33
+ steps:
34
+ - uses: actions/checkout@v4
35
+ - uses: actions/setup-python@v5
36
+ with:
37
+ python-version: ${{ matrix.python-version }}
38
+ cache: pip
39
+ - run: pip install -e ".[dev]"
40
+ # `coverage run` instead of `pytest --cov`: fieldsim's pytest plugin is loaded
41
+ # from an entry point before pytest-cov starts, which would hide import-time lines.
42
+ - run: coverage run -m pytest
43
+ - run: coverage report
44
+ - run: coverage xml
45
+ - name: Example project tests
46
+ run: pytest examples
@@ -0,0 +1,82 @@
1
+ name: Release
2
+
3
+ # Push a tag like v0.2.0 to publish to PyPI and create a GitHub release.
4
+ # Run manually ("Run workflow") to publish the current main to TestPyPI first.
5
+ #
6
+ # Publishing uses PyPI Trusted Publishing (no API token): add this repository
7
+ # and workflow as a trusted publisher on pypi.org and test.pypi.org, with
8
+ # environments named "pypi" and "testpypi".
9
+
10
+ on:
11
+ push:
12
+ tags: ["v*"]
13
+ workflow_dispatch:
14
+
15
+ jobs:
16
+ build:
17
+ runs-on: ubuntu-latest
18
+ steps:
19
+ - uses: actions/checkout@v4
20
+ - uses: actions/setup-python@v5
21
+ with:
22
+ python-version: "3.12"
23
+ - name: Check that the tag matches the package version
24
+ if: startsWith(github.ref, 'refs/tags/v')
25
+ run: |
26
+ version=$(sed -n 's/^__version__ = "\(.*\)"/\1/p' src/fieldsim/__init__.py)
27
+ if [ "v$version" != "${GITHUB_REF_NAME}" ]; then
28
+ echo "tag ${GITHUB_REF_NAME} does not match __version__ $version" >&2
29
+ exit 1
30
+ fi
31
+ - run: pip install build twine
32
+ - run: python -m build
33
+ - run: twine check --strict dist/*
34
+ - uses: actions/upload-artifact@v4
35
+ with:
36
+ name: dist
37
+ path: dist/
38
+
39
+ testpypi:
40
+ if: github.event_name == 'workflow_dispatch'
41
+ needs: build
42
+ runs-on: ubuntu-latest
43
+ environment: testpypi
44
+ permissions:
45
+ id-token: write
46
+ steps:
47
+ - uses: actions/download-artifact@v4
48
+ with:
49
+ name: dist
50
+ path: dist/
51
+ - uses: pypa/gh-action-pypi-publish@release/v1
52
+ with:
53
+ repository-url: https://test.pypi.org/legacy/
54
+
55
+ pypi:
56
+ if: startsWith(github.ref, 'refs/tags/v')
57
+ needs: build
58
+ runs-on: ubuntu-latest
59
+ environment: pypi
60
+ permissions:
61
+ id-token: write
62
+ steps:
63
+ - uses: actions/download-artifact@v4
64
+ with:
65
+ name: dist
66
+ path: dist/
67
+ - uses: pypa/gh-action-pypi-publish@release/v1
68
+
69
+ github-release:
70
+ needs: pypi
71
+ runs-on: ubuntu-latest
72
+ permissions:
73
+ contents: write
74
+ steps:
75
+ - uses: actions/checkout@v4
76
+ - uses: actions/download-artifact@v4
77
+ with:
78
+ name: dist
79
+ path: dist/
80
+ - run: gh release create "$GITHUB_REF_NAME" dist/* --generate-notes --verify-tag
81
+ env:
82
+ GH_TOKEN: ${{ github.token }}
@@ -0,0 +1,17 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ .venv/
5
+ venv/
6
+ build/
7
+ dist/
8
+ .pytest_cache/
9
+ .mypy_cache/
10
+ .ruff_cache/
11
+ .coverage
12
+ .coverage.*
13
+ coverage.xml
14
+ htmlcov/
15
+ site/
16
+ .idea/
17
+ .vscode/
@@ -0,0 +1,14 @@
1
+ repos:
2
+ - repo: https://github.com/pre-commit/pre-commit-hooks
3
+ rev: v5.0.0
4
+ hooks:
5
+ - id: trailing-whitespace
6
+ - id: end-of-file-fixer
7
+ - id: check-yaml
8
+ - id: check-toml
9
+ - repo: https://github.com/astral-sh/ruff-pre-commit
10
+ rev: v0.8.4
11
+ hooks:
12
+ - id: ruff
13
+ args: [--fix]
14
+ - id: ruff-format
@@ -0,0 +1,37 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here. The project follows
4
+ [Semantic Versioning](https://semver.org/); until 1.0 minor versions may change
5
+ the file format.
6
+
7
+ ## [0.2.0] - unreleased
8
+
9
+ ### Added
10
+
11
+ - **Fault injection**: `latency` (with jitter), `drop` (optionally after executing the
12
+ request), `disconnect`, `exception` and `corrupt` (`bit_flip`, `truncate`, `garbage`,
13
+ `bad_length`, `transaction_id`, `bad_crc`). Faults can be limited by `probability`,
14
+ `function_codes` and `count`, declared in YAML or switched on from code with
15
+ `device.fault(...)`.
16
+ - **Outages**: `go_offline()` / `go_online()` / `offline()` stop listening and drop all
17
+ clients, then come back on the same port.
18
+ - **Scenarios**: a timeline of `set`, `release`, `fault` (with `duration`) and `offline`
19
+ steps per device.
20
+ - **Virtual clock**: play long scenarios instantly and deterministically with
21
+ `clock="virtual"` and `advance()`.
22
+ - **Modbus RTU over TCP** (`protocol: modbus_rtu_over_tcp`) with CRC checking.
23
+ - **Write log**: `device.writes` and `wait_for_write(point)` to assert what a client sent.
24
+ - **pytest plugin**: `modbus_device`, `fieldsim` and `make_modbus_device` fixtures, the
25
+ `@pytest.mark.fieldsim(...)` marker and `fieldsim_config` / `fieldsim_clock` /
26
+ `fieldsim_free_ports` ini options.
27
+ - `BackgroundSimulator` to drive devices from synchronous code or other threads.
28
+ - Release workflow publishing to PyPI with Trusted Publishing.
29
+
30
+ ### Changed
31
+
32
+ - `ModbusTcpDevice` is now `ModbusDeviceSpec` (the old name remains as an alias).
33
+
34
+ ## [0.1.0a1]
35
+
36
+ - First preview: YAML device definitions, value generators, asyncio Modbus TCP server
37
+ and the `fieldsim` CLI.
fieldsim-0.2.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 enesuslu15
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,257 @@
1
+ Metadata-Version: 2.5
2
+ Name: fieldsim
3
+ Version: 0.2.0
4
+ Summary: Virtual Modbus, OPC UA and S7 devices for testing industrial software without hardware.
5
+ Project-URL: Homepage, https://github.com/enesuslu15/fieldsim
6
+ Project-URL: Issues, https://github.com/enesuslu15/fieldsim/issues
7
+ Author: enesuslu15
8
+ License: MIT License
9
+
10
+ Copyright (c) 2026 enesuslu15
11
+
12
+ Permission is hereby granted, free of charge, to any person obtaining a copy
13
+ of this software and associated documentation files (the "Software"), to deal
14
+ in the Software without restriction, including without limitation the rights
15
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
16
+ copies of the Software, and to permit persons to whom the Software is
17
+ furnished to do so, subject to the following conditions:
18
+
19
+ The above copyright notice and this permission notice shall be included in all
20
+ copies or substantial portions of the Software.
21
+
22
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
23
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
24
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
25
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
26
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
27
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
28
+ SOFTWARE.
29
+ License-File: LICENSE
30
+ Keywords: iiot,industrial,modbus,opc-ua,plc,pytest,s7,simulator,testing
31
+ Classifier: Development Status :: 3 - Alpha
32
+ Classifier: Framework :: AsyncIO
33
+ Classifier: Framework :: Pytest
34
+ Classifier: Intended Audience :: Developers
35
+ Classifier: Intended Audience :: Manufacturing
36
+ Classifier: License :: OSI Approved :: MIT License
37
+ Classifier: Operating System :: OS Independent
38
+ Classifier: Programming Language :: Python :: 3
39
+ Classifier: Programming Language :: Python :: 3.10
40
+ Classifier: Programming Language :: Python :: 3.11
41
+ Classifier: Programming Language :: Python :: 3.12
42
+ Classifier: Programming Language :: Python :: 3.13
43
+ Classifier: Topic :: Software Development :: Testing
44
+ Classifier: Topic :: System :: Networking
45
+ Classifier: Typing :: Typed
46
+ Requires-Python: >=3.10
47
+ Requires-Dist: pydantic<3,>=2.6
48
+ Requires-Dist: pyyaml>=6.0
49
+ Requires-Dist: typer>=0.12
50
+ Provides-Extra: dev
51
+ Requires-Dist: mypy>=1.10; extra == 'dev'
52
+ Requires-Dist: pre-commit>=3.5; extra == 'dev'
53
+ Requires-Dist: pymodbus<4,>=3.10; extra == 'dev'
54
+ Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
55
+ Requires-Dist: pytest-cov>=5.0; extra == 'dev'
56
+ Requires-Dist: pytest>=8.0; extra == 'dev'
57
+ Requires-Dist: ruff>=0.6; extra == 'dev'
58
+ Requires-Dist: types-pyyaml; extra == 'dev'
59
+ Description-Content-Type: text/markdown
60
+
61
+ # fieldsim
62
+
63
+ **Virtual industrial devices for testing software without hardware.**
64
+
65
+ fieldsim starts fake Modbus devices (OPC UA and S7 are on the roadmap) from a YAML
66
+ file, drives their values with generators, breaks them on purpose with fault
67
+ injection and plays scripted scenarios — so you can test gateways, SCADA
68
+ connectors and edge apps in CI, no PLC on your desk required.
69
+
70
+ > Status: alpha (`0.2.0`). The file format may still change before 1.0.
71
+
72
+ ## Install
73
+
74
+ ```bash
75
+ pip install fieldsim
76
+ ```
77
+
78
+ ## Test with pytest
79
+
80
+ Installing fieldsim registers a pytest plugin. With no configuration at all you
81
+ get a Modbus TCP device whose whole address space is plain memory:
82
+
83
+ ```python
84
+ def test_gateway_writes_registers(modbus_device):
85
+ Gateway(modbus_device.host, modbus_device.port).write_register(100, 42)
86
+ event = modbus_device.wait_for_write(timeout=2)
87
+ assert event.address == 100 and event.values == (42,)
88
+ ```
89
+
90
+ Point it at a device file to get named points, generators and scenarios:
91
+
92
+ ```python
93
+ import pytest
94
+
95
+
96
+ @pytest.mark.fieldsim("boiler.yaml")
97
+ def test_gateway_reads_temperature(modbus_device):
98
+ modbus_device.set("temperature", 85.0) # pauses the generator
99
+ assert Gateway(*modbus_device.address).temperature() == 85.0
100
+
101
+
102
+ @pytest.mark.fieldsim("boiler.yaml")
103
+ def test_gateway_survives_a_slow_plc(modbus_device):
104
+ with modbus_device.fault("latency", delay="800ms"):
105
+ assert Gateway(*modbus_device.address, timeout=1).temperature() is not None
106
+
107
+
108
+ @pytest.mark.fieldsim("boiler.yaml")
109
+ def test_gateway_retries_lost_responses(modbus_device):
110
+ with modbus_device.fault("drop", count=2) as fault:
111
+ Gateway(*modbus_device.address, retries=3).temperature()
112
+ assert fault.hits == 2
113
+
114
+
115
+ @pytest.mark.fieldsim("boiler.yaml")
116
+ def test_gateway_reconnects_after_outage(modbus_device):
117
+ gw = Gateway(*modbus_device.address)
118
+ with modbus_device.offline():
119
+ assert not gw.healthy()
120
+ assert gw.healthy()
121
+
122
+
123
+ @pytest.mark.fieldsim("boiler.yaml")
124
+ def test_gateway_writes_setpoint(modbus_device):
125
+ Gateway(*modbus_device.address).set_setpoint(80)
126
+ modbus_device.wait_for_write("setpoint", timeout=2)
127
+ assert modbus_device.get("setpoint") == 80
128
+
129
+
130
+ @pytest.mark.fieldsim("boiler.yaml", clock="virtual")
131
+ def test_overheat_alarm(modbus_device):
132
+ modbus_device.advance(600) # ten minutes, instantly
133
+ ...
134
+ ```
135
+
136
+ Fixtures and options:
137
+
138
+ | | |
139
+ |---|---|
140
+ | `modbus_device` | The Modbus device of the simulation (choose with `mark.fieldsim(device="name")` if there are several). |
141
+ | `fieldsim` | The whole running simulation: `fieldsim["plc-1"]`, `fieldsim.advance(10)`. |
142
+ | `make_modbus_device` | Factory for ad-hoc devices: `make_modbus_device(registers={...}, unit_id=3)`. |
143
+ | `@pytest.mark.fieldsim(config, device=, clock=, free_ports=)` | Per-test configuration. `config` is a YAML path (relative to the test file or rootdir) or a dict. |
144
+ | ini `fieldsim_config`, `fieldsim_clock`, `fieldsim_free_ports` | Project-wide defaults. |
145
+
146
+ Devices run in a background thread on free ports, so the fixtures work in plain,
147
+ asyncio and trio tests, and with `pytest-xdist`. In async tests use
148
+ `await device.wait_for_write_async(...)` and `await device.advance_async(...)`.
149
+
150
+ ## Describe a device
151
+
152
+ ```yaml
153
+ # boiler.yaml
154
+ version: 1
155
+ devices:
156
+ - name: boiler-plc
157
+ protocol: modbus_tcp # or modbus_rtu_over_tcp
158
+ port: 5020
159
+ registers:
160
+ coils:
161
+ - { name: pump_on, address: 0, value: true }
162
+ holding_registers:
163
+ - name: temperature
164
+ address: 0
165
+ type: float32
166
+ generator: { type: sine, min: 60, max: 90, period: 30s }
167
+ - { name: setpoint, address: 2, type: uint16, value: 75 }
168
+
169
+ faults: # active from the start
170
+ - { type: latency, delay: 20ms, jitter: 10ms }
171
+
172
+ scenario: # relative to device start
173
+ - { at: 30s, set: { temperature: 120 } }
174
+ - { at: 40s, fault: { type: exception, code: 6 }, duration: 5s }
175
+ - { at: 60s, offline: 10s }
176
+ - { at: 90s, release: [temperature] }
177
+ ```
178
+
179
+ Run it standalone, e.g. as a service container in CI:
180
+
181
+ ```bash
182
+ fieldsim validate boiler.yaml
183
+ fieldsim run boiler.yaml
184
+ ```
185
+
186
+ Get a JSON Schema for editor autocompletion with `fieldsim schema -o fieldsim.schema.json`.
187
+
188
+ ### Device options
189
+
190
+ | Key | Meaning |
191
+ |---|---|
192
+ | `protocol` | `modbus_tcp`, or `modbus_rtu_over_tcp` for RTU frames (with CRC) tunnelled over TCP. |
193
+ | `host`, `port` | Listen address. `port: 0` picks a free port. |
194
+ | `unit_id` | Only answer this unit id (default: answer all). |
195
+ | `unmapped` | `error` (exception 02 for undefined addresses, default) or `allow` (plain memory). |
196
+ | `update_interval` | Generator refresh rate, e.g. `100ms`. |
197
+ | `faults` | Faults active from the start (see below). |
198
+ | `scenario` | Timed steps: `set`, `release`, `fault` (+ `duration`), `offline`. |
199
+ | `seed` | Makes random faults reproducible. |
200
+
201
+ Points live in `registers.coils`, `discrete_inputs`, `holding_registers` and
202
+ `input_registers`, with `name`, `address`, `value`, `generator`, `description` and —
203
+ for registers — `type` (`int16 uint16 int32 uint32 int64 uint64 float32 float64`),
204
+ `byte_order` and `word_order`. Coils and holding registers take `writable: false`.
205
+
206
+ Generators: `constant`, `sine`, `ramp` (`sawtooth` / `triangle` / `once`),
207
+ `random_walk`, `sequence`.
208
+
209
+ ### Faults
210
+
211
+ | Type | Effect |
212
+ |---|---|
213
+ | `latency` | Delay responses by `delay` + random `0..jitter`. |
214
+ | `drop` | No response. `executed: true` still performs the request (lost reply). |
215
+ | `disconnect` | Close the connection instead of answering. |
216
+ | `exception` | Answer with Modbus exception `code` (default 6, SERVER DEVICE BUSY). |
217
+ | `corrupt` | Damage the response: `bit_flip`, `truncate`, `garbage`, `bad_length` (TCP), `transaction_id` (TCP), `bad_crc` (RTU). |
218
+
219
+ Every fault accepts `probability` (0..1), `function_codes` (list) and `count`
220
+ (remove after N hits).
221
+
222
+ ## Use from Python
223
+
224
+ ```python
225
+ from fieldsim import Simulator, VirtualClock
226
+
227
+ clock = VirtualClock()
228
+ async with Simulator.from_yaml("boiler.yaml", clock=clock) as sim:
229
+ plc = sim["boiler-plc"]
230
+ with plc.fault("corrupt", mode="bit_flip", probability=0.1):
231
+ await clock.advance(60)
232
+ ```
233
+
234
+ From synchronous code use `BackgroundSimulator("boiler.yaml")` as a context
235
+ manager; its device handles are thread-safe.
236
+
237
+ ## Supported Modbus function codes
238
+
239
+ 01 Read Coils · 02 Read Discrete Inputs · 03 Read Holding Registers · 04 Read Input Registers ·
240
+ 05 Write Single Coil · 06 Write Single Register · 15 Write Multiple Coils · 16 Write Multiple Registers
241
+
242
+ ## Development
243
+
244
+ ```bash
245
+ python -m venv .venv
246
+ .venv/Scripts/activate # Windows; use .venv/bin/activate elsewhere
247
+ pip install -e ".[dev]"
248
+ pre-commit install
249
+ coverage run -m pytest && coverage report
250
+ ```
251
+
252
+ `coverage run` is used instead of `pytest --cov` because fieldsim's own pytest
253
+ plugin is imported before pytest-cov starts measuring.
254
+
255
+ ## License
256
+
257
+ MIT
@@ -0,0 +1,197 @@
1
+ # fieldsim
2
+
3
+ **Virtual industrial devices for testing software without hardware.**
4
+
5
+ fieldsim starts fake Modbus devices (OPC UA and S7 are on the roadmap) from a YAML
6
+ file, drives their values with generators, breaks them on purpose with fault
7
+ injection and plays scripted scenarios — so you can test gateways, SCADA
8
+ connectors and edge apps in CI, no PLC on your desk required.
9
+
10
+ > Status: alpha (`0.2.0`). The file format may still change before 1.0.
11
+
12
+ ## Install
13
+
14
+ ```bash
15
+ pip install fieldsim
16
+ ```
17
+
18
+ ## Test with pytest
19
+
20
+ Installing fieldsim registers a pytest plugin. With no configuration at all you
21
+ get a Modbus TCP device whose whole address space is plain memory:
22
+
23
+ ```python
24
+ def test_gateway_writes_registers(modbus_device):
25
+ Gateway(modbus_device.host, modbus_device.port).write_register(100, 42)
26
+ event = modbus_device.wait_for_write(timeout=2)
27
+ assert event.address == 100 and event.values == (42,)
28
+ ```
29
+
30
+ Point it at a device file to get named points, generators and scenarios:
31
+
32
+ ```python
33
+ import pytest
34
+
35
+
36
+ @pytest.mark.fieldsim("boiler.yaml")
37
+ def test_gateway_reads_temperature(modbus_device):
38
+ modbus_device.set("temperature", 85.0) # pauses the generator
39
+ assert Gateway(*modbus_device.address).temperature() == 85.0
40
+
41
+
42
+ @pytest.mark.fieldsim("boiler.yaml")
43
+ def test_gateway_survives_a_slow_plc(modbus_device):
44
+ with modbus_device.fault("latency", delay="800ms"):
45
+ assert Gateway(*modbus_device.address, timeout=1).temperature() is not None
46
+
47
+
48
+ @pytest.mark.fieldsim("boiler.yaml")
49
+ def test_gateway_retries_lost_responses(modbus_device):
50
+ with modbus_device.fault("drop", count=2) as fault:
51
+ Gateway(*modbus_device.address, retries=3).temperature()
52
+ assert fault.hits == 2
53
+
54
+
55
+ @pytest.mark.fieldsim("boiler.yaml")
56
+ def test_gateway_reconnects_after_outage(modbus_device):
57
+ gw = Gateway(*modbus_device.address)
58
+ with modbus_device.offline():
59
+ assert not gw.healthy()
60
+ assert gw.healthy()
61
+
62
+
63
+ @pytest.mark.fieldsim("boiler.yaml")
64
+ def test_gateway_writes_setpoint(modbus_device):
65
+ Gateway(*modbus_device.address).set_setpoint(80)
66
+ modbus_device.wait_for_write("setpoint", timeout=2)
67
+ assert modbus_device.get("setpoint") == 80
68
+
69
+
70
+ @pytest.mark.fieldsim("boiler.yaml", clock="virtual")
71
+ def test_overheat_alarm(modbus_device):
72
+ modbus_device.advance(600) # ten minutes, instantly
73
+ ...
74
+ ```
75
+
76
+ Fixtures and options:
77
+
78
+ | | |
79
+ |---|---|
80
+ | `modbus_device` | The Modbus device of the simulation (choose with `mark.fieldsim(device="name")` if there are several). |
81
+ | `fieldsim` | The whole running simulation: `fieldsim["plc-1"]`, `fieldsim.advance(10)`. |
82
+ | `make_modbus_device` | Factory for ad-hoc devices: `make_modbus_device(registers={...}, unit_id=3)`. |
83
+ | `@pytest.mark.fieldsim(config, device=, clock=, free_ports=)` | Per-test configuration. `config` is a YAML path (relative to the test file or rootdir) or a dict. |
84
+ | ini `fieldsim_config`, `fieldsim_clock`, `fieldsim_free_ports` | Project-wide defaults. |
85
+
86
+ Devices run in a background thread on free ports, so the fixtures work in plain,
87
+ asyncio and trio tests, and with `pytest-xdist`. In async tests use
88
+ `await device.wait_for_write_async(...)` and `await device.advance_async(...)`.
89
+
90
+ ## Describe a device
91
+
92
+ ```yaml
93
+ # boiler.yaml
94
+ version: 1
95
+ devices:
96
+ - name: boiler-plc
97
+ protocol: modbus_tcp # or modbus_rtu_over_tcp
98
+ port: 5020
99
+ registers:
100
+ coils:
101
+ - { name: pump_on, address: 0, value: true }
102
+ holding_registers:
103
+ - name: temperature
104
+ address: 0
105
+ type: float32
106
+ generator: { type: sine, min: 60, max: 90, period: 30s }
107
+ - { name: setpoint, address: 2, type: uint16, value: 75 }
108
+
109
+ faults: # active from the start
110
+ - { type: latency, delay: 20ms, jitter: 10ms }
111
+
112
+ scenario: # relative to device start
113
+ - { at: 30s, set: { temperature: 120 } }
114
+ - { at: 40s, fault: { type: exception, code: 6 }, duration: 5s }
115
+ - { at: 60s, offline: 10s }
116
+ - { at: 90s, release: [temperature] }
117
+ ```
118
+
119
+ Run it standalone, e.g. as a service container in CI:
120
+
121
+ ```bash
122
+ fieldsim validate boiler.yaml
123
+ fieldsim run boiler.yaml
124
+ ```
125
+
126
+ Get a JSON Schema for editor autocompletion with `fieldsim schema -o fieldsim.schema.json`.
127
+
128
+ ### Device options
129
+
130
+ | Key | Meaning |
131
+ |---|---|
132
+ | `protocol` | `modbus_tcp`, or `modbus_rtu_over_tcp` for RTU frames (with CRC) tunnelled over TCP. |
133
+ | `host`, `port` | Listen address. `port: 0` picks a free port. |
134
+ | `unit_id` | Only answer this unit id (default: answer all). |
135
+ | `unmapped` | `error` (exception 02 for undefined addresses, default) or `allow` (plain memory). |
136
+ | `update_interval` | Generator refresh rate, e.g. `100ms`. |
137
+ | `faults` | Faults active from the start (see below). |
138
+ | `scenario` | Timed steps: `set`, `release`, `fault` (+ `duration`), `offline`. |
139
+ | `seed` | Makes random faults reproducible. |
140
+
141
+ Points live in `registers.coils`, `discrete_inputs`, `holding_registers` and
142
+ `input_registers`, with `name`, `address`, `value`, `generator`, `description` and —
143
+ for registers — `type` (`int16 uint16 int32 uint32 int64 uint64 float32 float64`),
144
+ `byte_order` and `word_order`. Coils and holding registers take `writable: false`.
145
+
146
+ Generators: `constant`, `sine`, `ramp` (`sawtooth` / `triangle` / `once`),
147
+ `random_walk`, `sequence`.
148
+
149
+ ### Faults
150
+
151
+ | Type | Effect |
152
+ |---|---|
153
+ | `latency` | Delay responses by `delay` + random `0..jitter`. |
154
+ | `drop` | No response. `executed: true` still performs the request (lost reply). |
155
+ | `disconnect` | Close the connection instead of answering. |
156
+ | `exception` | Answer with Modbus exception `code` (default 6, SERVER DEVICE BUSY). |
157
+ | `corrupt` | Damage the response: `bit_flip`, `truncate`, `garbage`, `bad_length` (TCP), `transaction_id` (TCP), `bad_crc` (RTU). |
158
+
159
+ Every fault accepts `probability` (0..1), `function_codes` (list) and `count`
160
+ (remove after N hits).
161
+
162
+ ## Use from Python
163
+
164
+ ```python
165
+ from fieldsim import Simulator, VirtualClock
166
+
167
+ clock = VirtualClock()
168
+ async with Simulator.from_yaml("boiler.yaml", clock=clock) as sim:
169
+ plc = sim["boiler-plc"]
170
+ with plc.fault("corrupt", mode="bit_flip", probability=0.1):
171
+ await clock.advance(60)
172
+ ```
173
+
174
+ From synchronous code use `BackgroundSimulator("boiler.yaml")` as a context
175
+ manager; its device handles are thread-safe.
176
+
177
+ ## Supported Modbus function codes
178
+
179
+ 01 Read Coils · 02 Read Discrete Inputs · 03 Read Holding Registers · 04 Read Input Registers ·
180
+ 05 Write Single Coil · 06 Write Single Register · 15 Write Multiple Coils · 16 Write Multiple Registers
181
+
182
+ ## Development
183
+
184
+ ```bash
185
+ python -m venv .venv
186
+ .venv/Scripts/activate # Windows; use .venv/bin/activate elsewhere
187
+ pip install -e ".[dev]"
188
+ pre-commit install
189
+ coverage run -m pytest && coverage report
190
+ ```
191
+
192
+ `coverage run` is used instead of `pytest --cov` because fieldsim's own pytest
193
+ plugin is imported before pytest-cov starts measuring.
194
+
195
+ ## License
196
+
197
+ MIT