modsim 0.5.2__tar.gz → 1.0.1__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.
@@ -1,26 +1,24 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: modsim
3
- Version: 0.5.2
3
+ Version: 1.0.1
4
4
  Summary: A Simple Modbus TCP Device Simulator used for modpoll tool
5
- Home-page: https://github.com/gavinying/modsim
5
+ Home-page: https://github.com/top-maker/modsim
6
6
  Author: Ying Shaodong
7
7
  Author-email: helloysd@gmail.com
8
- Requires-Python: >=3.8.2,<4.0
8
+ Requires-Python: >=3.10,<4.0
9
9
  Classifier: Programming Language :: Python :: 3
10
- Classifier: Programming Language :: Python :: 3.9
11
10
  Classifier: Programming Language :: Python :: 3.10
12
11
  Classifier: Programming Language :: Python :: 3.11
13
12
  Classifier: Programming Language :: Python :: 3.12
14
- Classifier: Programming Language :: Python :: 3.13
15
- Requires-Dist: pymodbus (>=3.6.9,<3.7.0)
16
- Project-URL: Documentation, https://gavinying.github.io/modsim/
17
- Project-URL: Repository, https://github.com/gavinying/modsim
13
+ Requires-Dist: pymodbus (>=3.16.1,<3.17.0)
14
+ Project-URL: Documentation, https://top-maker.github.io/modsim/
15
+ Project-URL: Repository, https://github.com/top-maker/modsim
18
16
  Description-Content-Type: text/markdown
19
17
 
20
18
  # ModSim - A Simple Modbus TCP Device Simulator
21
19
 
22
- *modsim* is a simple Modbus TCP device simulator deployed at `modsim.topmaker.net:502`.
23
- It works as a remote Modbus-TCP (virtual) device, which has already been populated with all 4 types of registers, i.e.
20
+ *modsim* is a Modbus device simulator that can run locally or in a container. A public simulator is available at `modsim.topmaker.net:502` for quick testing.
21
+ Its default datastore contains all four Modbus tables:
24
22
  - Coil Type
25
23
  - Discrete input Type
26
24
  - Input register Type
@@ -89,8 +87,21 @@ To verify it, you can test by [modpoll](https://github.com/gavinying/modpoll) to
89
87
  modpoll \
90
88
  --tcp localhost \
91
89
  --tcp-port 5020 \
92
- --config https://raw.githubusercontent.com/gavinying/modpoll/master/examples/modsim.csv
90
+ --config https://raw.githubusercontent.com/gavinying/modpoll/main/examples/modsim.csv
93
91
  ```
94
92
 
95
- > **_Note_**: add `sudo` before `docker run` command if you want to use the Modbus TCP standard port `502`.
93
+ To expose the standard Modbus TCP port on the host, use `docker run -p 502:5020 topmaker/modsim:latest`. Your system may require permission to bind port 502.
94
+
95
+ ## Local server behavior
96
+
97
+ Run the simulator without Docker with `python -m modsim --comm tcp --port 5020`. The default server exposes the four Modbus tables with the sample data shown above. Coil and holding-register writes are supported; discrete inputs and input registers are read-only. Requests outside an initialized address range return a Modbus exception. The server handles SIGINT and SIGTERM by stopping its listener so the port can be reused after shutdown.
98
+
99
+ The default TCP simulator uses one sequential datastore shared by all Unit IDs.
100
+ The CLI currently supports only `--store sequential`; `--slaves` and `--context`
101
+ are not implemented and are rejected. TCP and UDP use the socket framer, TLS uses
102
+ the TLS framer, and serial accepts RTU or ASCII framing. Serial requires a suitable local device and is not covered by the local protocol regression suite. TLS uses `examples/certificates/pymodbus.crt` and `pymodbus.key` relative to the working directory. A local test covers a self-signed server certificate and a verified client connection; the server does not authenticate client certificates.
103
+
104
+ UDP mode (`--comm udp`) listens on the loopback address and has local read and
105
+ shutdown coverage. TCP and UDP report a startup error if the requested port is
106
+ already in use.
96
107
 
@@ -1,7 +1,7 @@
1
1
  # ModSim - A Simple Modbus TCP Device Simulator
2
2
 
3
- *modsim* is a simple Modbus TCP device simulator deployed at `modsim.topmaker.net:502`.
4
- It works as a remote Modbus-TCP (virtual) device, which has already been populated with all 4 types of registers, i.e.
3
+ *modsim* is a Modbus device simulator that can run locally or in a container. A public simulator is available at `modsim.topmaker.net:502` for quick testing.
4
+ Its default datastore contains all four Modbus tables:
5
5
  - Coil Type
6
6
  - Discrete input Type
7
7
  - Input register Type
@@ -70,7 +70,20 @@ To verify it, you can test by [modpoll](https://github.com/gavinying/modpoll) to
70
70
  modpoll \
71
71
  --tcp localhost \
72
72
  --tcp-port 5020 \
73
- --config https://raw.githubusercontent.com/gavinying/modpoll/master/examples/modsim.csv
73
+ --config https://raw.githubusercontent.com/gavinying/modpoll/main/examples/modsim.csv
74
74
  ```
75
75
 
76
- > **_Note_**: add `sudo` before `docker run` command if you want to use the Modbus TCP standard port `502`.
76
+ To expose the standard Modbus TCP port on the host, use `docker run -p 502:5020 topmaker/modsim:latest`. Your system may require permission to bind port 502.
77
+
78
+ ## Local server behavior
79
+
80
+ Run the simulator without Docker with `python -m modsim --comm tcp --port 5020`. The default server exposes the four Modbus tables with the sample data shown above. Coil and holding-register writes are supported; discrete inputs and input registers are read-only. Requests outside an initialized address range return a Modbus exception. The server handles SIGINT and SIGTERM by stopping its listener so the port can be reused after shutdown.
81
+
82
+ The default TCP simulator uses one sequential datastore shared by all Unit IDs.
83
+ The CLI currently supports only `--store sequential`; `--slaves` and `--context`
84
+ are not implemented and are rejected. TCP and UDP use the socket framer, TLS uses
85
+ the TLS framer, and serial accepts RTU or ASCII framing. Serial requires a suitable local device and is not covered by the local protocol regression suite. TLS uses `examples/certificates/pymodbus.crt` and `pymodbus.key` relative to the working directory. A local test covers a self-signed server certificate and a verified client connection; the server does not authenticate client certificates.
86
+
87
+ UDP mode (`--comm udp`) listens on the loopback address and has local read and
88
+ shutdown coverage. TCP and UDP report a startup error if the requested port is
89
+ already in use.
@@ -0,0 +1 @@
1
+ __version__ = "1.0.1"
@@ -0,0 +1,131 @@
1
+ """Compatibility layer for PyModbus API changes.
2
+
3
+ This module provides backward-compatible wrappers for APIs that changed
4
+ between PyModbus 3.6.9 and 3.16.1.
5
+ """
6
+
7
+ import struct
8
+ from typing import List
9
+
10
+
11
+ class Endian:
12
+ """Compatibility class for endianness constants removed in PyModbus 3.16.1."""
13
+
14
+ BIG = "big"
15
+ LITTLE = "little"
16
+
17
+
18
+ class BinaryPayloadBuilder:
19
+ """Compatibility wrapper for PyModbus BinaryPayloadBuilder.
20
+
21
+ This class was removed in PyModbus 3.9.0+ but is needed for backward
22
+ compatibility with existing datastore initialization code.
23
+ """
24
+
25
+ def __init__(self, byteorder: str = "big", wordorder: str = "big") -> None:
26
+ """Initialize the builder.
27
+
28
+ Args:
29
+ byteorder: Either "big" or "little" for byte order
30
+ wordorder: Either "big" or "little" for word order (register order)
31
+ """
32
+ self.byteorder = byteorder
33
+ self.wordorder = wordorder
34
+ self._bits: List[bool] = []
35
+ self._registers: List[int] = []
36
+
37
+ def add_bits(self, bits: List[bool]) -> "BinaryPayloadBuilder":
38
+ """Add a list of bits to the payload.
39
+
40
+ Args:
41
+ bits: List of boolean values
42
+
43
+ Returns:
44
+ Self for chaining
45
+ """
46
+ self._bits.extend(bits)
47
+ return self
48
+
49
+ def add_16bit_uint(self, value: int) -> "BinaryPayloadBuilder":
50
+ """Add a 16-bit unsigned integer."""
51
+ self._registers.append(value & 0xFFFF)
52
+ return self
53
+
54
+ def add_16bit_int(self, value: int) -> "BinaryPayloadBuilder":
55
+ """Add a 16-bit signed integer."""
56
+ # Convert negative to unsigned representation (two's complement)
57
+ if value < 0:
58
+ value = (1 << 16) + value
59
+ self._registers.append(value & 0xFFFF)
60
+ return self
61
+
62
+ def add_32bit_uint(self, value: int) -> "BinaryPayloadBuilder":
63
+ """Add a 32-bit unsigned integer (uses 2 registers)."""
64
+ if self.wordorder == "big":
65
+ self._registers.append((value >> 16) & 0xFFFF)
66
+ self._registers.append(value & 0xFFFF)
67
+ else:
68
+ self._registers.append(value & 0xFFFF)
69
+ self._registers.append((value >> 16) & 0xFFFF)
70
+ return self
71
+
72
+ def add_32bit_int(self, value: int) -> "BinaryPayloadBuilder":
73
+ """Add a 32-bit signed integer (uses 2 registers)."""
74
+ # Convert to unsigned if negative
75
+ if value < 0:
76
+ value = (1 << 32) + value
77
+ return self.add_32bit_uint(value)
78
+
79
+ def add_32bit_float(self, value: float) -> "BinaryPayloadBuilder":
80
+ """Add a 32-bit float (uses 2 registers)."""
81
+ fmt = ">f" # big-endian float
82
+ packed = struct.pack(fmt, value)
83
+ value_int = struct.unpack(">I", packed)[0]
84
+ return self.add_32bit_uint(value_int)
85
+
86
+ def add_64bit_uint(self, value: int) -> "BinaryPayloadBuilder":
87
+ """Add a 64-bit unsigned integer (uses 4 registers)."""
88
+ if self.wordorder == "big":
89
+ self._registers.append((value >> 48) & 0xFFFF)
90
+ self._registers.append((value >> 32) & 0xFFFF)
91
+ self._registers.append((value >> 16) & 0xFFFF)
92
+ self._registers.append(value & 0xFFFF)
93
+ else:
94
+ self._registers.append(value & 0xFFFF)
95
+ self._registers.append((value >> 16) & 0xFFFF)
96
+ self._registers.append((value >> 32) & 0xFFFF)
97
+ self._registers.append((value >> 48) & 0xFFFF)
98
+ return self
99
+
100
+ def add_64bit_int(self, value: int) -> "BinaryPayloadBuilder":
101
+ """Add a 64-bit signed integer (uses 4 registers)."""
102
+ # Convert to unsigned if negative
103
+ if value < 0:
104
+ value = (1 << 64) + value
105
+ return self.add_64bit_uint(value)
106
+
107
+ def add_64bit_float(self, value: float) -> "BinaryPayloadBuilder":
108
+ """Add a 64-bit float (uses 4 registers)."""
109
+ packed = struct.pack(">d", value)
110
+ value_int = struct.unpack(">Q", packed)[0]
111
+ return self.add_64bit_uint(value_int)
112
+
113
+ def add_string(self, value: str) -> "BinaryPayloadBuilder":
114
+ """Add a string (encoded as UTF-8)."""
115
+ encoded = value.encode("utf-8")
116
+ # Pad to even number of bytes
117
+ if len(encoded) % 2:
118
+ encoded += b"\x00"
119
+ # Convert bytes to registers (2 bytes per register)
120
+ for i in range(0, len(encoded), 2):
121
+ reg_value = (encoded[i] << 8) | encoded[i + 1]
122
+ self._registers.append(reg_value)
123
+ return self
124
+
125
+ def to_coils(self) -> List[bool]:
126
+ """Return the bits as a list of booleans."""
127
+ return self._bits
128
+
129
+ def to_registers(self) -> List[int]:
130
+ """Return the registers as a list of integers."""
131
+ return self._registers
@@ -0,0 +1,252 @@
1
+ #!/usr/bin/env python3
2
+ """Modbus simulator server and process lifecycle."""
3
+
4
+ import os
5
+ import ssl
6
+ import asyncio
7
+ import signal
8
+ import logging
9
+ from contextlib import suppress
10
+ from typing import Any
11
+
12
+ from pymodbus.datastore import (
13
+ ModbusDeviceContext,
14
+ ModbusSequentialDataBlock,
15
+ ModbusServerContext,
16
+ )
17
+ from pymodbus.pdu.device import ModbusDeviceIdentification
18
+ from pymodbus.server import (
19
+ StartAsyncSerialServer,
20
+ ModbusTcpServer,
21
+ ModbusTlsServer,
22
+ ModbusUdpServer,
23
+ ServerAsyncStop,
24
+ )
25
+
26
+ from . import __version__
27
+ from .compat import BinaryPayloadBuilder, Endian
28
+ from .utils import get_commandline, get_utc_time, get_logger
29
+
30
+ _logger = logging.getLogger(__name__)
31
+
32
+
33
+ def setup_server(args: Any) -> Any:
34
+ """Run server setup."""
35
+ # The datastores only respond to the addresses that are initialized
36
+ # If you initialize a DataBlock to addresses of 0x00 to 0xFF, a request to
37
+ # 0x100 will respond with an invalid address exception.
38
+ # This is because many devices exhibit this kind of behavior (but not all)
39
+ if not args.context:
40
+ _logger.info("Create datastore")
41
+ # build your payload
42
+ co_builder = BinaryPayloadBuilder(byteorder=Endian.BIG, wordorder=Endian.BIG)
43
+ # address=0, bytes=3
44
+ for _ in range(3):
45
+ # The bits within each byte are big-endian
46
+ # The coils status (from the first position) = [1,0,1,0,0,0,0,1]
47
+ co_builder.add_bits([True, False, True, False, False, False, False, True])
48
+
49
+ hr_builder = BinaryPayloadBuilder(byteorder=Endian.BIG, wordorder=Endian.BIG)
50
+ # address=0, bytes=8
51
+ hr_builder.add_16bit_uint(1)
52
+ hr_builder.add_16bit_uint(2)
53
+ hr_builder.add_16bit_uint(3)
54
+ hr_builder.add_16bit_uint(4)
55
+ # address=8, bytes=8
56
+ hr_builder.add_16bit_int(-1)
57
+ hr_builder.add_16bit_int(-2)
58
+ hr_builder.add_16bit_int(-3)
59
+ hr_builder.add_16bit_int(-4)
60
+ # address=16, bytes=8
61
+ hr_builder.add_32bit_uint(12345678)
62
+ hr_builder.add_32bit_uint(12345678)
63
+ # address=24, bytes=8
64
+ hr_builder.add_32bit_int(-12345678)
65
+ hr_builder.add_32bit_int(-12345678)
66
+ # address=32, bytes=8
67
+ hr_builder.add_32bit_float(12.34)
68
+ hr_builder.add_32bit_float(-12.34)
69
+ # address=40, bytes=8
70
+ hr_builder.add_64bit_uint(12345678900)
71
+ # address=48, bytes=8
72
+ hr_builder.add_64bit_int(-12345678900)
73
+ # address=56, bytes=16
74
+ hr_builder.add_64bit_float(123.45)
75
+ hr_builder.add_64bit_float(-123.45)
76
+ # address=72, bytes=?
77
+ hr_builder.add_string("this is a string")
78
+
79
+ # use that payload in the data store
80
+ co_values = co_builder.to_coils()
81
+ hr_values = hr_builder.to_registers()
82
+ # Note: In PyModbus 3.16.1+, ModbusSequentialDataBlock uses address - 1 internally
83
+ # to convert to SimData. To maintain address ranges, we use address + 1 here.
84
+ # This maps Modbus addresses the same way as zero_mode=True did in older versions.
85
+ device_context = ModbusDeviceContext(
86
+ co=ModbusSequentialDataBlock(1, co_values),
87
+ di=ModbusSequentialDataBlock(10001, co_values),
88
+ ir=ModbusSequentialDataBlock(30001, hr_values),
89
+ hr=ModbusSequentialDataBlock(40001, hr_values),
90
+ )
91
+ args.context = ModbusServerContext(devices=device_context, single=True)
92
+
93
+ # initialize the server information
94
+ # If you don't set this or any fields, they are defaulted to empty strings.
95
+ args.identity = ModbusDeviceIdentification(
96
+ info_name={
97
+ "VendorName": "Pymodbus",
98
+ "ProductCode": "PM",
99
+ "VendorUrl": "https://github.com/riptideio/pymodbus/",
100
+ "ProductName": "Pymodbus Server",
101
+ "ModelName": "Pymodbus Server",
102
+ }
103
+ )
104
+ return args
105
+
106
+
107
+ async def run_async_server(args: Any) -> Any:
108
+ """Run server."""
109
+ txt = f"Start ASYNC server, listening on {args.port} - {args.comm}"
110
+ _logger.info(txt)
111
+ server: Any
112
+ if args.comm == "tcp":
113
+ address: tuple[str, int] = ("", args.port)
114
+ server = ModbusTcpServer(
115
+ context=args.context,
116
+ identity=args.identity,
117
+ address=address,
118
+ framer=args.framer,
119
+ )
120
+ try:
121
+ if not await server.listen():
122
+ raise OSError(
123
+ "Could not start Modbus TCP listener on port %s" % args.port
124
+ )
125
+ await server.serving
126
+ finally:
127
+ await server.shutdown()
128
+ return server
129
+ elif args.comm == "udp":
130
+ address = ("127.0.0.1", args.port)
131
+ server = ModbusUdpServer(
132
+ context=args.context,
133
+ identity=args.identity,
134
+ address=address,
135
+ framer=args.framer,
136
+ )
137
+ try:
138
+ if not await server.listen():
139
+ raise OSError(
140
+ "Could not start Modbus UDP listener on port %s" % args.port
141
+ )
142
+ await server.serving
143
+ finally:
144
+ await server.shutdown()
145
+ return server
146
+ elif args.comm == "serial":
147
+ await StartAsyncSerialServer(
148
+ context=args.context,
149
+ identity=args.identity,
150
+ port=args.port,
151
+ framer=args.framer,
152
+ )
153
+ return None
154
+ elif args.comm == "tls":
155
+ address = ("", args.port)
156
+ cwd = os.getcwd().split("/")[-1]
157
+ if cwd == "examples":
158
+ path = "."
159
+ elif cwd == "test":
160
+ path = "../examples"
161
+ else:
162
+ path = "examples"
163
+
164
+ # Create SSL context from cert and key files
165
+ sslctx = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
166
+ sslctx.load_cert_chain(
167
+ certfile=f"{path}/certificates/pymodbus.crt",
168
+ keyfile=f"{path}/certificates/pymodbus.key",
169
+ )
170
+
171
+ server = ModbusTlsServer(
172
+ context=args.context,
173
+ identity=args.identity,
174
+ address=address,
175
+ framer=args.framer,
176
+ sslctx=sslctx,
177
+ )
178
+ try:
179
+ if not await server.listen():
180
+ raise OSError(
181
+ "Could not start Modbus TLS listener on port %s" % args.port
182
+ )
183
+ await server.serving
184
+ finally:
185
+ await server.shutdown()
186
+ return server
187
+ raise ValueError(f"Unknown communication type: {args.comm}")
188
+
189
+
190
+ def app() -> None:
191
+ args = get_commandline()
192
+ global _logger
193
+ _logger = get_logger(args.log)
194
+ _logger.info(
195
+ f"Starting modsim v{__version__} at {get_utc_time().strftime('%Y-%m-%d %H:%M:%S')} UTC"
196
+ )
197
+
198
+ run_args = setup_server(args)
199
+ asyncio.run(_run_server_with_signals(run_args), debug=True)
200
+
201
+ _logger.info("exiting.")
202
+
203
+
204
+ async def _run_server_with_signals(args: Any) -> None:
205
+ """Run the server and close its listener when the process is signaled."""
206
+ loop = asyncio.get_running_loop()
207
+ server_task = asyncio.create_task(run_async_server(args))
208
+ stop_requested = False
209
+
210
+ async def stop_server() -> None:
211
+ for _ in range(100):
212
+ if server_task.done():
213
+ return
214
+ try:
215
+ await ServerAsyncStop()
216
+ return
217
+ except (AttributeError, RuntimeError):
218
+ await asyncio.sleep(0.01)
219
+
220
+ def request_stop() -> None:
221
+ nonlocal stop_requested
222
+ if not stop_requested:
223
+ stop_requested = True
224
+ if args.comm in ("tcp", "udp", "tls"):
225
+ server_task.cancel()
226
+ else:
227
+ loop.create_task(stop_server())
228
+
229
+ installed_signals = []
230
+ for signum in (signal.SIGINT, signal.SIGTERM):
231
+ try:
232
+ loop.add_signal_handler(signum, request_stop)
233
+ installed_signals.append(signum)
234
+ except (NotImplementedError, RuntimeError):
235
+ pass
236
+
237
+ try:
238
+ await server_task
239
+ except asyncio.CancelledError:
240
+ if not stop_requested:
241
+ raise
242
+ finally:
243
+ for signum in installed_signals:
244
+ loop.remove_signal_handler(signum)
245
+ if not server_task.done():
246
+ server_task.cancel()
247
+ with suppress(asyncio.CancelledError):
248
+ await server_task
249
+
250
+
251
+ if __name__ == "__main__":
252
+ app()
@@ -3,19 +3,13 @@
3
3
  import argparse
4
4
  import logging.config
5
5
  from datetime import datetime, timezone
6
- from typing import Any, Optional, Dict
7
- from pymodbus.transaction import (
8
- ModbusAsciiFramer,
9
- ModbusBinaryFramer,
10
- ModbusRtuFramer,
11
- ModbusSocketFramer,
12
- ModbusTlsFramer,
6
+ from typing import Any, Dict
7
+ from pymodbus.framer import (
8
+ FramerType,
13
9
  )
14
10
 
15
11
  from . import __version__
16
12
 
17
- # global
18
- args: Optional[argparse.Namespace] = None
19
13
  config: Dict[str, Any] = {}
20
14
 
21
15
 
@@ -35,7 +29,7 @@ def get_parser() -> argparse.ArgumentParser:
35
29
  )
36
30
  parser.add_argument(
37
31
  "--framer",
38
- choices=["ascii", "binary", "rtu", "socket", "tls"],
32
+ choices=["ascii", "rtu", "socket", "tls"],
39
33
  help="set framer, default depends on --comm",
40
34
  type=str,
41
35
  )
@@ -74,7 +68,25 @@ def get_parser() -> argparse.ArgumentParser:
74
68
 
75
69
 
76
70
  def get_commandline() -> argparse.Namespace:
77
- args = get_parser().parse_args()
71
+ parser = get_parser()
72
+ args = parser.parse_args()
73
+
74
+ if args.store != "sequential":
75
+ parser.error("only --store sequential is supported")
76
+ if args.slaves != 0:
77
+ parser.error(
78
+ "--slaves is not supported; the default datastore is shared by all unit IDs"
79
+ )
80
+ if args.context is not None:
81
+ parser.error("--context is not supported from the command line")
82
+ allowed_framers = {
83
+ "tcp": {"socket"},
84
+ "udp": {"socket"},
85
+ "serial": {"rtu", "ascii"},
86
+ "tls": {"tls"},
87
+ }
88
+ if args.framer and args.framer not in allowed_framers[args.comm]:
89
+ parser.error("framer %s is not supported for %s" % (args.framer, args.comm))
78
90
 
79
91
  # set defaults
80
92
  comm_defaults: Dict[str, list] = {
@@ -84,26 +96,29 @@ def get_commandline() -> argparse.Namespace:
84
96
  "tls": ["tls", 5020],
85
97
  }
86
98
  framers: Dict[str, Any] = {
87
- "ascii": ModbusAsciiFramer,
88
- "binary": ModbusBinaryFramer,
89
- "rtu": ModbusRtuFramer,
90
- "socket": ModbusSocketFramer,
91
- "tls": ModbusTlsFramer,
99
+ "ascii": FramerType.ASCII,
100
+ "rtu": FramerType.RTU,
101
+ "socket": FramerType.SOCKET,
102
+ "tls": FramerType.TLS,
92
103
  }
93
104
  args.framer = framers[args.framer or comm_defaults[args.comm][0]]
94
105
  args.port = args.port or comm_defaults[args.comm][1]
95
106
  if args.comm != "serial" and args.port:
96
- args.port = int(args.port)
107
+ try:
108
+ args.port = int(args.port)
109
+ except ValueError:
110
+ parser.error("port must be an integer between 1 and 65535")
111
+ if not 1 <= args.port <= 65535:
112
+ parser.error("port must be an integer between 1 and 65535")
97
113
  return args
98
114
 
99
115
 
100
- def get_logger() -> logging.Logger:
116
+ def get_logger(level: str = "info") -> logging.Logger:
101
117
  if config and config.get("logging"):
102
118
  logging.config.dictConfig(config["logging"])
103
119
  else:
104
- print("no config file found, load default settings.")
105
120
  logging.basicConfig(
106
- level=get_commandline().log.upper(),
121
+ level=level.upper(),
107
122
  format="%(asctime)s | %(levelname).1s | %(processName)s | %(name)s | %(message)s",
108
123
  )
109
124
  logger = logging.getLogger(__name__)
@@ -1,16 +1,16 @@
1
1
  [tool.poetry]
2
2
  name = "modsim"
3
- version = "v0.5.2"
3
+ version = "1.0.1"
4
4
  description = "A Simple Modbus TCP Device Simulator used for modpoll tool"
5
5
  authors = ["Ying Shaodong <helloysd@gmail.com>"]
6
- repository = "https://github.com/gavinying/modsim"
7
- documentation = "https://gavinying.github.io/modsim/"
6
+ repository = "https://github.com/top-maker/modsim"
7
+ documentation = "https://top-maker.github.io/modsim/"
8
8
  readme = "README.md"
9
9
  packages = [{ include = "modsim" }]
10
10
 
11
11
  [tool.poetry.dependencies]
12
- python = ">=3.8.2,<4.0"
13
- pymodbus = "~3.6.9"
12
+ python = ">=3.10,<4.0"
13
+ pymodbus = "~3.16.1"
14
14
 
15
15
  [tool.poetry.group.dev.dependencies]
16
16
  pre-commit = "^3.5.0"
@@ -23,6 +23,9 @@ tox = "^4.16.0"
23
23
  mkdocs = "^1.6.0"
24
24
  mkdocs-material = "^9.5.28"
25
25
  mkdocstrings = {extras = ["python"], version = "^0.25.1"}
26
+ mkdocs-autorefs = ">=1.2,<1.4"
27
+ markdown = ">=3.7,<3.8"
28
+ pymdown-extensions = ">=10.11,<11"
26
29
 
27
30
  [build-system]
28
31
  requires = ["poetry-core>=1.0.0"]
@@ -40,6 +43,7 @@ show_error_codes = true
40
43
 
41
44
  [tool.pytest.ini_options]
42
45
  testpaths = ["tests"]
46
+ markers = ["integration: local protocol integration test"]
43
47
 
44
48
  [tool.poetry.scripts]
45
49
  modsim = "modsim.main:app"
@@ -1 +0,0 @@
1
- __version__ = "0.5.2"
@@ -1,234 +0,0 @@
1
- #!/usr/bin/env python3
2
- """Pymodbus asynchronous Server Example.
3
- An example of a multi threaded asynchronous server.
4
- usage: server_async.py [-h] [--comm {tcp,udp,serial,tls}]
5
- [--framer {ascii,binary,rtu,socket,tls}]
6
- [--log {critical,error,warning,info,debug}]
7
- [--port PORT] [--store {sequential,sparse,factory,none}]
8
- [--slaves SLAVES]
9
- Command line options for examples
10
- options:
11
- -h, --help show this help message and exit
12
- --comm {tcp,udp,serial,tls}
13
- "serial", "tcp", "udp" or "tls"
14
- --framer {ascii,binary,rtu,socket,tls}
15
- "ascii", "binary", "rtu", "socket" or "tls"
16
- --log {critical,error,warning,info,debug}
17
- "critical", "error", "warning", "info" or "debug"
18
- --port PORT the port to use
19
- --store {sequential,sparse,factory,none}
20
- "sequential", "sparse", "factory" or "none"
21
- --slaves SLAVES number of slaves to respond to
22
- The corresponding client can be started as:
23
- python3 client_sync.py
24
- """
25
-
26
- import os
27
- import asyncio
28
- from typing import Any
29
-
30
- from pymodbus.constants import Endian
31
- from pymodbus.datastore import (
32
- ModbusSequentialDataBlock,
33
- ModbusServerContext,
34
- ModbusSlaveContext,
35
- )
36
- from pymodbus.device import ModbusDeviceIdentification
37
- from pymodbus.payload import BinaryPayloadBuilder
38
- from pymodbus.server import (
39
- StartAsyncSerialServer,
40
- StartAsyncTcpServer,
41
- StartAsyncTlsServer,
42
- StartAsyncUdpServer,
43
- )
44
-
45
- from . import __version__
46
- from .utils import get_commandline, get_utc_time, get_logger
47
-
48
- _logger = get_logger()
49
-
50
-
51
- def setup_server(args: Any) -> Any:
52
- """Run server setup."""
53
- # The datastores only respond to the addresses that are initialized
54
- # If you initialize a DataBlock to addresses of 0x00 to 0xFF, a request to
55
- # 0x100 will respond with an invalid address exception.
56
- # This is because many devices exhibit this kind of behavior (but not all)
57
- if not args.context:
58
- _logger.info("Create datastore")
59
- # ----------------------------------------------------------------------- #
60
- # build your payload
61
- # ----------------------------------------------------------------------- #
62
- co_builder = BinaryPayloadBuilder(byteorder=Endian.BIG, wordorder=Endian.BIG)
63
- # address=0, bytes=3
64
- for i in range(3):
65
- # The bits within each byte are big-endian
66
- # The coils status (from the first position) = [1,0,1,0,0,0,0,1]
67
- co_builder.add_bits([True, False, False, False, False, True, False, True])
68
-
69
- hr_builder = BinaryPayloadBuilder(byteorder=Endian.BIG, wordorder=Endian.BIG)
70
- # address=0, bytes=8
71
- hr_builder.add_16bit_uint(1)
72
- hr_builder.add_16bit_uint(2)
73
- hr_builder.add_16bit_uint(3)
74
- hr_builder.add_16bit_uint(4)
75
- # address=8, bytes=8
76
- hr_builder.add_16bit_int(-1)
77
- hr_builder.add_16bit_int(-2)
78
- hr_builder.add_16bit_int(-3)
79
- hr_builder.add_16bit_int(-4)
80
- # address=16, bytes=8
81
- hr_builder.add_32bit_uint(12345678)
82
- hr_builder.add_32bit_uint(12345678)
83
- # address=24, bytes=8
84
- hr_builder.add_32bit_int(-12345678)
85
- hr_builder.add_32bit_int(-12345678)
86
- # address=32, bytes=8
87
- hr_builder.add_32bit_float(12.34)
88
- hr_builder.add_32bit_float(-12.34)
89
- # address=40, bytes=8
90
- hr_builder.add_64bit_uint(12345678900)
91
- # address=48, bytes=8
92
- hr_builder.add_64bit_int(-12345678900)
93
- # address=56, bytes=16
94
- hr_builder.add_64bit_float(123.45)
95
- hr_builder.add_64bit_float(-123.45)
96
- # address=72, bytes=?
97
- hr_builder.add_string("this is a string")
98
-
99
- # ----------------------------------------------------------------------- #
100
- # use that payload in the data store
101
- # ----------------------------------------------------------------------- #
102
- # Here we use the same reference block for each underlying store.
103
- # ----------------------------------------------------------------------- #
104
- co_values = co_builder.to_coils()
105
- hr_values = hr_builder.to_registers()
106
- store = ModbusSlaveContext(
107
- co=ModbusSequentialDataBlock(0, co_values),
108
- di=ModbusSequentialDataBlock(10000, co_values),
109
- ir=ModbusSequentialDataBlock(30000, hr_values),
110
- hr=ModbusSequentialDataBlock(40000, hr_values),
111
- zero_mode=True,
112
- )
113
- args.context = ModbusServerContext(slaves=store, single=True)
114
-
115
- # ----------------------------------------------------------------------- #
116
- # initialize the server information
117
- # ----------------------------------------------------------------------- #
118
- # If you don't set this or any fields, they are defaulted to empty strings.
119
- # ----------------------------------------------------------------------- #
120
- args.identity = ModbusDeviceIdentification(
121
- info_name={
122
- "VendorName": "Pymodbus",
123
- "ProductCode": "PM",
124
- "VendorUrl": "https://github.com/riptideio/pymodbus/",
125
- "ProductName": "Pymodbus Server",
126
- "ModelName": "Pymodbus Server",
127
- }
128
- )
129
- return args
130
-
131
-
132
- async def run_async_server(args: Any) -> Any:
133
- """Run server."""
134
- txt = f"Start ASYNC server, listening on {args.port} - {args.comm}"
135
- _logger.info(txt)
136
- if args.comm == "tcp":
137
- address = ("", args.port) if args.port else None
138
- server = await StartAsyncTcpServer(
139
- context=args.context, # Data storage
140
- identity=args.identity, # server identify
141
- # TBD host=
142
- # TBD port=
143
- address=address, # listen address
144
- # custom_functions=[], # allow custom handling
145
- framer=args.framer, # The framer strategy to use
146
- # ignore_missing_slaves=True, # ignore request to a missing slave
147
- # broadcast_enable=False, # treat slave_id 0 as broadcast address,
148
- # timeout=1, # waiting time for request to complete
149
- # TBD strict=True, # use strict timing, t1.5 for Modbus RTU
150
- )
151
- elif args.comm == "udp":
152
- address = ("127.0.0.1", args.port) if args.port else None
153
- server = await StartAsyncUdpServer(
154
- context=args.context, # Data storage
155
- identity=args.identity, # server identify
156
- address=address, # listen address
157
- # custom_functions=[], # allow custom handling
158
- framer=args.framer, # The framer strategy to use
159
- # handler=None, # handler for each session
160
- # ignore_missing_slaves=True, # ignore request to a missing slave
161
- # broadcast_enable=False, # treat unit_id 0 as broadcast address,
162
- # timeout=1, # waiting time for request to complete
163
- # TBD strict=True, # use strict timing, t1.5 for Modbus RTU
164
- # defer_start=False, # Only define server do not activate
165
- )
166
- elif args.comm == "serial":
167
- # socat -d -d PTY,link=/tmp/ptyp0,raw,echo=0,ispeed=9600
168
- # PTY,link=/tmp/ttyp0,raw,echo=0,ospeed=9600
169
- server = await StartAsyncSerialServer(
170
- context=args.context, # Data storage
171
- identity=args.identity, # server identify
172
- # timeout=1, # waiting time for request to complete
173
- port=args.port, # serial port
174
- # custom_functions=[], # allow custom handling
175
- framer=args.framer, # The framer strategy to use
176
- # handler=None, # handler for each session
177
- # stopbits=1, # The number of stop bits to use
178
- # bytesize=8, # The bytesize of the serial messages
179
- # parity="N", # Which kind of parity to use
180
- # baudrate=9600, # The baud rate to use for the serial device
181
- # handle_local_echo=False, # Handle local echo of the USB-to-RS485 adaptor
182
- # ignore_missing_slaves=True, # ignore request to a missing slave
183
- # broadcast_enable=False, # treat unit_id 0 as broadcast address,
184
- # strict=True, # use strict timing, t1.5 for Modbus RTU
185
- # defer_start=False, # Only define server do not activate
186
- )
187
- elif args.comm == "tls":
188
- address = ("", args.port) if args.port else None
189
- cwd = os.getcwd().split("/")[-1]
190
- if cwd == "examples":
191
- path = "."
192
- elif cwd == "test":
193
- path = "../examples"
194
- else:
195
- path = "examples"
196
- server = await StartAsyncTlsServer(
197
- context=args.context, # Data storage
198
- host="localhost", # define tcp address where to connect to.
199
- # port=port, # on which port
200
- identity=args.identity, # server identify
201
- # custom_functions=[], # allow custom handling
202
- address=address, # listen address
203
- framer=args.framer, # The framer strategy to use
204
- # handler=None, # handler for each session
205
- allow_reuse_address=True, # allow the reuse of an address
206
- # The cert file path for TLS (used if sslctx is None)
207
- certfile=f"{path}/certificates/pymodbus.crt",
208
- # sslctx=sslctx, # The SSLContext to use for TLS (default None and auto create)
209
- # The key file path for TLS (used if sslctx is None)
210
- keyfile=f"{path}/certificates/pymodbus.key",
211
- # password="none", # The password for for decrypting the private key file
212
- # reqclicert=False, # Force the sever request client"s certificate
213
- # ignore_missing_slaves=True, # ignore request to a missing slave
214
- # broadcast_enable=False, # treat unit_id 0 as broadcast address,
215
- # timeout=1, # waiting time for request to complete
216
- # TBD strict=True, # use strict timing, t1.5 for Modbus RTU
217
- defer_start=False, # Only define server do not activate
218
- )
219
- return server
220
-
221
-
222
- def app() -> None:
223
- _logger.info(
224
- f"Starting modsim v{__version__} at {get_utc_time().strftime('%Y-%m-%d %H:%M:%S')} UTC"
225
- )
226
-
227
- run_args = setup_server(get_commandline())
228
- asyncio.run(run_async_server(run_args), debug=True)
229
-
230
- _logger.info("exiting.")
231
-
232
-
233
- if __name__ == "__main__":
234
- app()
File without changes
File without changes