Skip to content

Commit fdb6c26

Browse files
authored
SY-3616: Refactor Protocol Sims to use SimDAQ Infrastructure (#2001)
* Refactor Protocol Sims tro inheret from base simulator * Update simulators to have a configurable rate * Extract SimulatorCase, flatten SimulatorTaskCase, and use class attributes
1 parent 84a73f3 commit fdb6c26

29 files changed

Lines changed: 573 additions & 712 deletions

.github/PULL_REQUEST_TEMPLATE/rc.md

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -441,9 +441,6 @@ I can successfully:
441441
- **Read Task**
442442
- **Single Sampling**
443443
- [ ] Read timestamps from the OPC UA server.
444-
- **Test the following array sizes:**
445-
- [ ] 10
446-
- [ ] 100
447444
- [ ] Read timestamps from the OPC UA server.
448445
- [ ] Obtain recommended Synnax channels based on the configured OPC UA node.
449446
- [ ] Connect to and read data from a physical device.
@@ -464,10 +461,6 @@ I can successfully:
464461
- [ ] Apply scaling to register values.
465462
- [ ] Enable and disable data saving.
466463
- [ ] Handle device disconnection gracefully.
467-
- **Reliable data reading at the following sample rates:**
468-
- [ ] 1 Hz
469-
- [ ] 10 Hz
470-
- [ ] 100 Hz
471464
- **Write Task**
472465
- [ ] Write to holding registers on a Modbus server.
473466
- [ ] Write to coils on a Modbus server.

client/py/examples/modbus/__init__.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,6 @@
99

1010
"""Modbus TCP example package."""
1111

12-
from .server import run_server
12+
from .server import ModbusSim, run_server
1313

14-
__all__ = ["run_server"]
14+
__all__ = ["run_server", "ModbusSim"]

client/py/examples/modbus/server.py

Lines changed: 37 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,16 @@
1919
)
2020
from pymodbus.server import StartAsyncTcpServer
2121

22+
import synnax as sy
23+
from examples.simulators.device_sim import DeviceSim
24+
from synnax import modbus
2225

23-
async def updating_writer(context):
26+
27+
async def updating_writer(context, rate: sy.Rate = 50 * sy.Rate.HZ):
2428
"""Update Modbus registers continuously with simulated sensor data."""
2529
slave_id = 0x00
2630
start_ref = time.time()
2731
i = 0
28-
RATE = 50 # Hz
2932
SENSOR_COUNT = 5
3033

3134
while True:
@@ -65,10 +68,38 @@ async def updating_writer(context):
6568
coil_values = [True, False, True, False, True]
6669
context[slave_id].setValues(1, 0, coil_values)
6770

68-
await asyncio.sleep(1 / RATE)
71+
await asyncio.sleep(1 / rate)
72+
73+
74+
class ModbusSim(DeviceSim):
75+
"""Modbus TCP device simulator on port 5020."""
76+
77+
description = "Modbus TCP simulator on port 5020"
78+
host = "127.0.0.1"
79+
port = 5020
80+
device_name = "Modbus TCP Test Server"
6981

82+
async def _run_server(self) -> None:
83+
await run_server(self.host, self.port, self.rate)
7084

71-
async def run_server() -> None:
85+
@staticmethod
86+
def create_device(rack_key: int) -> modbus.Device:
87+
return modbus.Device(
88+
host=ModbusSim.host,
89+
port=ModbusSim.port,
90+
name=ModbusSim.device_name,
91+
location=f"{ModbusSim.host}:{ModbusSim.port}",
92+
rack=rack_key,
93+
swap_bytes=False,
94+
swap_words=False,
95+
)
96+
97+
98+
async def run_server(
99+
host: str = ModbusSim.host,
100+
port: int = ModbusSim.port,
101+
rate: sy.Rate = 50 * sy.Rate.HZ,
102+
) -> None:
72103
"""Run the Modbus TCP server."""
73104
# Initialize data store
74105
store = ModbusDeviceContext(
@@ -89,13 +120,9 @@ async def run_server() -> None:
89120
identity.ModelName = "Extended Simulator"
90121
identity.MajorMinorRevision = "1.0.0"
91122

92-
# Start the updating task
93-
task = asyncio.create_task(updating_writer(context))
123+
asyncio.create_task(updating_writer(context, rate))
94124

95-
# Start Modbus TCP server on localhost:5020
96-
await StartAsyncTcpServer(
97-
context=context, identity=identity, address=("127.0.0.1", 5020)
98-
)
125+
await StartAsyncTcpServer(context=context, identity=identity, address=(host, port))
99126

100127

101128
if __name__ == "__main__":

client/py/examples/opcua/__init__.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,6 @@
99

1010
"""OPC UA example package."""
1111

12-
from .server import run_server
12+
from .server import OPCUASim, run_server
1313

14-
__all__ = ["run_server"]
14+
__all__ = ["run_server", "OPCUASim"]

client/py/examples/opcua/server.py

Lines changed: 57 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,16 @@
1414

1515
from asyncua import Server, ua
1616

17+
import synnax as sy
18+
from examples.simulators.device_sim import DeviceSim
19+
from synnax import opcua
20+
1721
# Configuration constants
1822
ARRAY_COUNT = 5
19-
ARRAY_SIZE = 5
23+
DEFAULT_ARRAY_SIZE = 5
2024
FLOAT_COUNT = 5
2125
BOOL_COUNT = 5
22-
RATE = 50 # Hz
26+
DEFAULT_RATE = 50 # Hz
2327
BOOL_OFFSET = 0.2 # seconds between each boolean transition
2428

2529
# Error injection configuration
@@ -29,30 +33,30 @@
2933

3034

3135
# Initialization Functions
32-
async def create_array_variables(myobj, idx):
36+
async def create_array_variables(myobj, idx, array_size: int = DEFAULT_ARRAY_SIZE):
3337
"""Create array variables with initial values."""
3438
arrays = []
3539
for i in range(ARRAY_COUNT):
36-
initial_values = [float(j + i) for j in range(ARRAY_SIZE)]
40+
initial_values = [float(j + i) for j in range(array_size)]
3741
arr = await myobj.add_variable(
3842
idx, f"my_array_{i}", initial_values, ua.VariantType.Float
3943
)
40-
await arr.write_array_dimensions([ARRAY_SIZE])
44+
await arr.write_array_dimensions([array_size])
4145
arrays.append(arr)
4246
return arrays
4347

4448

45-
async def create_time_array(myobj, idx):
49+
async def create_time_array(myobj, idx, array_size: int = DEFAULT_ARRAY_SIZE):
4650
"""Create timestamp array variable."""
4751
now = datetime.datetime.now(datetime.timezone.utc)
4852
initial_times = [
49-
now + datetime.timedelta(milliseconds=j) for j in range(ARRAY_SIZE)
53+
now + datetime.timedelta(milliseconds=j) for j in range(array_size)
5054
]
5155
mytimearray = await myobj.add_variable(
5256
idx, "my_time_array", initial_times, ua.VariantType.DateTime
5357
)
5458
await mytimearray.set_writable()
55-
await mytimearray.write_array_dimensions([ARRAY_SIZE])
59+
await mytimearray.write_array_dimensions([array_size])
5660
return mytimearray
5761

5862

@@ -116,7 +120,7 @@ def generate_sinewave_values(timestamps, start_ref):
116120

117121

118122
# Update Functions
119-
def inject_error(values):
123+
def inject_error(values, array_size: int = DEFAULT_ARRAY_SIZE):
120124
"""
121125
Generate corrupted array data for error injection.
122126
"""
@@ -129,7 +133,7 @@ def inject_error(values):
129133

130134
# Array smaller than expected
131135
elif error_chance < 0.667:
132-
size = random.randint(1, ARRAY_SIZE - 2)
136+
size = random.randint(1, max(2, array_size - 2))
133137
return values[:size]
134138

135139
# Array larger than expected
@@ -178,18 +182,24 @@ async def update_bools(bools, elapsed):
178182
await bool_var.set_value(square_wave, varianttype=ua.VariantType.Boolean)
179183

180184

181-
async def run_server() -> None:
185+
async def run_server(
186+
endpoint: str = "",
187+
rate: sy.Rate = DEFAULT_RATE * sy.Rate.HZ,
188+
array_size: int = DEFAULT_ARRAY_SIZE,
189+
) -> None:
182190
# Initialize server
183191
server = Server()
184192
await server.init()
185-
server.set_endpoint("opc.tcp://127.0.0.1:4841/freeopcua/server/")
193+
if not endpoint:
194+
endpoint = OPCUASim.endpoint
195+
server.set_endpoint(endpoint)
186196
uri = "http://examples.freeopcua.github.io"
187197
idx = await server.register_namespace(uri)
188198

189199
# Create OPC UA object and variables
190200
myobj = await server.nodes.objects.add_object(idx, "MyObject")
191-
arrays = await create_array_variables(myobj, idx)
192-
mytimearray = await create_time_array(myobj, idx)
201+
arrays = await create_array_variables(myobj, idx, array_size)
202+
mytimearray = await create_time_array(myobj, idx, array_size)
193203
floats = await create_float_variables(myobj, idx)
194204
bools = await create_bool_variables(myobj, idx)
195205
commands = await create_command_variables(myobj, idx)
@@ -224,7 +234,7 @@ async def run_server() -> None:
224234
elapsed = (start - start_ref).total_seconds()
225235

226236
# Generate data
227-
timestamps = generate_timestamps(start, RATE, ARRAY_SIZE)
237+
timestamps = generate_timestamps(start, rate, array_size)
228238
sinewave_values = generate_sinewave_values(timestamps, start_ref)
229239

230240
# Update all variables
@@ -237,7 +247,38 @@ async def run_server() -> None:
237247
duration = (
238248
datetime.datetime.now(datetime.timezone.utc) - start
239249
).total_seconds()
240-
await asyncio.sleep((1 / RATE) - duration)
250+
await asyncio.sleep(max(0, (1 / rate) - duration))
251+
252+
253+
class OPCUASim(DeviceSim):
254+
"""OPC UA device simulator on port 4841."""
255+
256+
description = "OPC UA simulator on port 4841"
257+
host = "127.0.0.1"
258+
port = 4841
259+
device_name = "OPC UA Test Server"
260+
endpoint = f"opc.tcp://{host}:{port}/freeopcua/server/"
261+
262+
def __init__(
263+
self,
264+
array_size: int = DEFAULT_ARRAY_SIZE,
265+
rate: sy.Rate = 50 * sy.Rate.HZ,
266+
verbose: bool = False,
267+
):
268+
super().__init__(rate=rate, verbose=verbose)
269+
self.array_size = array_size
270+
271+
async def _run_server(self) -> None:
272+
await run_server(self.endpoint, self.rate, self.array_size)
273+
274+
@staticmethod
275+
def create_device(rack_key: int) -> opcua.Device:
276+
return opcua.Device(
277+
endpoint=OPCUASim.endpoint,
278+
name=OPCUASim.device_name,
279+
location=OPCUASim.endpoint,
280+
rack=rack_key,
281+
)
241282

242283

243284
if __name__ == "__main__":

client/py/examples/simulators/README.md

Lines changed: 80 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,21 @@
11
# Hardware Simulators
22

33
This directory contains reusable hardware simulators for testing control sequences
4-
without real hardware. All simulators extend the `SimDAQ` base class which provides:
4+
without real hardware. There are two types of simulators:
5+
6+
1. **SimDAQ** - Thread-based simulators that write directly to Synnax channels
7+
2. **DeviceSim** - Process-based simulators that expose network protocol endpoints
8+
9+
Both extend the shared `Simulator` base class which provides `start()` / `stop()`
10+
lifecycle management and verbose logging.
11+
12+
## SimDAQ Simulators
13+
14+
SimDAQ simulators create Synnax channels and write data directly. They provide:
515

6-
- Thread-based lifecycle management (`start()` / `stop()`)
716
- Automatic end command handling via a separate watcher thread
8-
- Verbose logging mode for standalone execution
917
- Command-line interface for running standalone
1018

11-
## Available Simulators
12-
1319
### PressSimDAQ (`press.py`)
1420

1521
Simulates a pressurization system with:
@@ -46,9 +52,35 @@ Simulates a rocket engine tank pressurization control system with:
4652
uv run python -m examples.simulators.tpc --help
4753
```
4854

55+
## DeviceSim Simulators
56+
57+
DeviceSim simulators expose network protocol endpoints (Modbus TCP, OPC UA) for testing
58+
driver integration. They run async servers in a subprocess and do NOT interact with
59+
Synnax directly - the C++ driver connects to the endpoint and writes data to Synnax.
60+
61+
### ModbusSim (`examples/modbus/server.py`)
62+
63+
Runs a Modbus TCP server on port 5020 with:
64+
65+
- Holding registers (addresses 0-4): Sine wave data
66+
- Input registers (addresses 0-4): Sine wave data
67+
- Discrete inputs (addresses 0-3): Rotating binary patterns
68+
- Coils (addresses 0-4): Static digital outputs
69+
70+
### OPCUASim (`examples/opcua/server.py`)
71+
72+
Runs an OPC UA server on port 4841 with:
73+
74+
- Float variables (`my_float_0` - `my_float_4`): Sine wave data
75+
- Boolean variables (`my_bool_0` - `my_bool_4`): Square wave patterns
76+
- Array variables (`my_array_0` - `my_array_4`): 5-element float arrays
77+
- Command variables (`command_0` - `command_2`): Writable floats
78+
4979
## Creating Custom Simulators
5080

51-
To create a custom simulator, extend the `SimDAQ` base class:
81+
### Custom SimDAQ
82+
83+
Extend the `SimDAQ` base class for simulators that write directly to Synnax:
5284

5385
```python
5486
from examples.simulators import SimDAQ
@@ -59,7 +91,6 @@ class MySimDAQ(SimDAQ):
5991
end_cmd_channel = "end_my_test_cmd" # Optional: auto-stop on this channel
6092

6193
def _create_channels(self) -> None:
62-
# Create your Synnax channels here
6394
self.my_channel = self.client.channels.create(
6495
name="my_channel",
6596
data_type=sy.DataType.FLOAT32,
@@ -78,9 +109,37 @@ if __name__ == "__main__":
78109
MySimDAQ.main()
79110
```
80111

112+
### Custom DeviceSim
113+
114+
Extend `DeviceSim` for simulators that expose a network protocol endpoint:
115+
116+
```python
117+
from examples.simulators.device_sim import DeviceSim
118+
from synnax import modbus
119+
120+
class MyDeviceSim(DeviceSim):
121+
description = "My custom device simulator"
122+
host = "127.0.0.1"
123+
port = 5020
124+
device_name = "My Test Device"
125+
126+
async def _run_server(self) -> None:
127+
# Start your async server here
128+
...
129+
130+
@staticmethod
131+
def create_device(rack_key: int) -> modbus.Device:
132+
return modbus.Device(
133+
host=MyDeviceSim.host,
134+
port=MyDeviceSim.port,
135+
name=MyDeviceSim.device_name,
136+
rack=rack_key,
137+
)
138+
```
139+
81140
## Usage in Tests
82141

83-
Simulators can be used programmatically in tests:
142+
SimDAQ simulators can be used programmatically:
84143

85144
```python
86145
import synnax as sy
@@ -94,3 +153,16 @@ sim.start()
94153

95154
sim.stop()
96155
```
156+
157+
DeviceSim simulators are used via the integration test framework:
158+
159+
```python
160+
from examples.modbus import ModbusSim
161+
162+
sim = ModbusSim()
163+
sim.start() # Starts server subprocess on port 5020
164+
165+
# Driver connects to the endpoint...
166+
167+
sim.stop() # Terminates server subprocess
168+
```

0 commit comments

Comments
 (0)