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.
- fieldsim-0.2.0/.gitattributes +1 -0
- fieldsim-0.2.0/.github/workflows/ci.yml +46 -0
- fieldsim-0.2.0/.github/workflows/release.yml +82 -0
- fieldsim-0.2.0/.gitignore +17 -0
- fieldsim-0.2.0/.pre-commit-config.yaml +14 -0
- fieldsim-0.2.0/CHANGELOG.md +37 -0
- fieldsim-0.2.0/LICENSE +21 -0
- fieldsim-0.2.0/PKG-INFO +257 -0
- fieldsim-0.2.0/README.md +197 -0
- fieldsim-0.2.0/examples/boiler.yaml +47 -0
- fieldsim-0.2.0/examples/test_boiler_example.py +73 -0
- fieldsim-0.2.0/pyproject.toml +105 -0
- fieldsim-0.2.0/src/fieldsim/__init__.py +26 -0
- fieldsim-0.2.0/src/fieldsim/__main__.py +3 -0
- fieldsim-0.2.0/src/fieldsim/background.py +362 -0
- fieldsim-0.2.0/src/fieldsim/cli.py +137 -0
- fieldsim-0.2.0/src/fieldsim/config.py +560 -0
- fieldsim-0.2.0/src/fieldsim/core/__init__.py +0 -0
- fieldsim-0.2.0/src/fieldsim/core/clock.py +87 -0
- fieldsim-0.2.0/src/fieldsim/core/datastore.py +35 -0
- fieldsim-0.2.0/src/fieldsim/core/device.py +205 -0
- fieldsim-0.2.0/src/fieldsim/core/engine.py +88 -0
- fieldsim-0.2.0/src/fieldsim/core/faults.py +150 -0
- fieldsim-0.2.0/src/fieldsim/core/generators.py +109 -0
- fieldsim-0.2.0/src/fieldsim/core/scenario.py +140 -0
- fieldsim-0.2.0/src/fieldsim/core/types.py +133 -0
- fieldsim-0.2.0/src/fieldsim/protocols/__init__.py +0 -0
- fieldsim-0.2.0/src/fieldsim/protocols/modbus/__init__.py +18 -0
- fieldsim-0.2.0/src/fieldsim/protocols/modbus/constants.py +42 -0
- fieldsim-0.2.0/src/fieldsim/protocols/modbus/datastore.py +203 -0
- fieldsim-0.2.0/src/fieldsim/protocols/modbus/device.py +115 -0
- fieldsim-0.2.0/src/fieldsim/protocols/modbus/framer.py +196 -0
- fieldsim-0.2.0/src/fieldsim/protocols/modbus/pdu.py +157 -0
- fieldsim-0.2.0/src/fieldsim/protocols/modbus/server.py +135 -0
- fieldsim-0.2.0/src/fieldsim/py.typed +0 -0
- fieldsim-0.2.0/src/fieldsim/pytest_plugin.py +163 -0
- fieldsim-0.2.0/src/fieldsim/simulator.py +96 -0
- fieldsim-0.2.0/tests/__init__.py +0 -0
- fieldsim-0.2.0/tests/conftest.py +53 -0
- fieldsim-0.2.0/tests/test_background.py +199 -0
- fieldsim-0.2.0/tests/test_cli.py +146 -0
- fieldsim-0.2.0/tests/test_clock.py +77 -0
- fieldsim-0.2.0/tests/test_config.py +271 -0
- fieldsim-0.2.0/tests/test_datastore.py +114 -0
- fieldsim-0.2.0/tests/test_engine.py +88 -0
- fieldsim-0.2.0/tests/test_fault_injection.py +220 -0
- fieldsim-0.2.0/tests/test_faults.py +107 -0
- fieldsim-0.2.0/tests/test_framer.py +132 -0
- fieldsim-0.2.0/tests/test_generators.py +98 -0
- fieldsim-0.2.0/tests/test_pdu.py +128 -0
- fieldsim-0.2.0/tests/test_pytest_plugin.py +224 -0
- fieldsim-0.2.0/tests/test_scenario.py +153 -0
- fieldsim-0.2.0/tests/test_server.py +237 -0
- fieldsim-0.2.0/tests/test_simulator.py +54 -0
- 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,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.
|
fieldsim-0.2.0/PKG-INFO
ADDED
|
@@ -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
|
fieldsim-0.2.0/README.md
ADDED
|
@@ -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
|