modsim 1.0.0__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,7 +1,24 @@
1
+ Metadata-Version: 2.1
2
+ Name: modsim
3
+ Version: 1.0.1
4
+ Summary: A Simple Modbus TCP Device Simulator used for modpoll tool
5
+ Home-page: https://github.com/top-maker/modsim
6
+ Author: Ying Shaodong
7
+ Author-email: helloysd@gmail.com
8
+ Requires-Python: >=3.10,<4.0
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Programming Language :: Python :: 3.10
11
+ Classifier: Programming Language :: Python :: 3.11
12
+ Classifier: Programming Language :: Python :: 3.12
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
16
+ Description-Content-Type: text/markdown
17
+
1
18
  # ModSim - A Simple Modbus TCP Device Simulator
2
19
 
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.
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:
5
22
  - Coil Type
6
23
  - Discrete input Type
7
24
  - Input register Type
@@ -73,8 +90,18 @@ modpoll \
73
90
  --config https://raw.githubusercontent.com/gavinying/modpoll/main/examples/modsim.csv
74
91
  ```
75
92
 
76
- > **_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.
77
94
 
78
95
  ## Local server behavior
79
96
 
80
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.
107
+
@@ -1,25 +1,7 @@
1
- Metadata-Version: 2.1
2
- Name: modsim
3
- Version: 1.0.0
4
- Summary: A Simple Modbus TCP Device Simulator used for modpoll tool
5
- Home-page: https://github.com/gavinying/modsim
6
- Author: Ying Shaodong
7
- Author-email: helloysd@gmail.com
8
- Requires-Python: >=3.8.2,<4.0
9
- Classifier: Programming Language :: Python :: 3
10
- Classifier: Programming Language :: Python :: 3.9
11
- Classifier: Programming Language :: Python :: 3.10
12
- Classifier: Programming Language :: Python :: 3.11
13
- Classifier: Programming Language :: Python :: 3.12
14
- Requires-Dist: pymodbus (>=3.6.9,<3.7.0)
15
- Project-URL: Documentation, https://gavinying.github.io/modsim/
16
- Project-URL: Repository, https://github.com/gavinying/modsim
17
- Description-Content-Type: text/markdown
18
-
19
1
  # ModSim - A Simple Modbus TCP Device Simulator
20
2
 
21
- *modsim* is a simple Modbus TCP device simulator deployed at `modsim.topmaker.net:502`.
22
- 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:
23
5
  - Coil Type
24
6
  - Discrete input Type
25
7
  - Input register Type
@@ -91,9 +73,17 @@ modpoll \
91
73
  --config https://raw.githubusercontent.com/gavinying/modpoll/main/examples/modsim.csv
92
74
  ```
93
75
 
94
- > **_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.
95
77
 
96
78
  ## Local server behavior
97
79
 
98
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.
99
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 = "v1.0.0"
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"]
@@ -1 +0,0 @@
1
- __version__ = "1.0.0"
@@ -1,278 +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
- import signal
29
- from contextlib import suppress
30
- from typing import Any
31
-
32
- from pymodbus.constants import Endian
33
- from pymodbus.datastore import (
34
- ModbusSequentialDataBlock,
35
- ModbusServerContext,
36
- ModbusSlaveContext,
37
- )
38
- from pymodbus.device import ModbusDeviceIdentification
39
- from pymodbus.payload import BinaryPayloadBuilder
40
- from pymodbus.server import (
41
- StartAsyncSerialServer,
42
- StartAsyncTcpServer,
43
- StartAsyncTlsServer,
44
- StartAsyncUdpServer,
45
- ServerAsyncStop,
46
- )
47
-
48
- from . import __version__
49
- from .utils import get_commandline, get_utc_time, get_logger
50
-
51
- _logger = get_logger()
52
-
53
-
54
- def setup_server(args: Any) -> Any:
55
- """Run server setup."""
56
- # The datastores only respond to the addresses that are initialized
57
- # If you initialize a DataBlock to addresses of 0x00 to 0xFF, a request to
58
- # 0x100 will respond with an invalid address exception.
59
- # This is because many devices exhibit this kind of behavior (but not all)
60
- if not args.context:
61
- _logger.info("Create datastore")
62
- # ----------------------------------------------------------------------- #
63
- # build your payload
64
- # ----------------------------------------------------------------------- #
65
- co_builder = BinaryPayloadBuilder(byteorder=Endian.BIG, wordorder=Endian.BIG)
66
- # address=0, bytes=3
67
- for i in range(3):
68
- # The bits within each byte are big-endian
69
- # The coils status (from the first position) = [1,0,1,0,0,0,0,1]
70
- co_builder.add_bits([True, False, False, False, False, True, False, True])
71
-
72
- hr_builder = BinaryPayloadBuilder(byteorder=Endian.BIG, wordorder=Endian.BIG)
73
- # address=0, bytes=8
74
- hr_builder.add_16bit_uint(1)
75
- hr_builder.add_16bit_uint(2)
76
- hr_builder.add_16bit_uint(3)
77
- hr_builder.add_16bit_uint(4)
78
- # address=8, bytes=8
79
- hr_builder.add_16bit_int(-1)
80
- hr_builder.add_16bit_int(-2)
81
- hr_builder.add_16bit_int(-3)
82
- hr_builder.add_16bit_int(-4)
83
- # address=16, bytes=8
84
- hr_builder.add_32bit_uint(12345678)
85
- hr_builder.add_32bit_uint(12345678)
86
- # address=24, bytes=8
87
- hr_builder.add_32bit_int(-12345678)
88
- hr_builder.add_32bit_int(-12345678)
89
- # address=32, bytes=8
90
- hr_builder.add_32bit_float(12.34)
91
- hr_builder.add_32bit_float(-12.34)
92
- # address=40, bytes=8
93
- hr_builder.add_64bit_uint(12345678900)
94
- # address=48, bytes=8
95
- hr_builder.add_64bit_int(-12345678900)
96
- # address=56, bytes=16
97
- hr_builder.add_64bit_float(123.45)
98
- hr_builder.add_64bit_float(-123.45)
99
- # address=72, bytes=?
100
- hr_builder.add_string("this is a string")
101
-
102
- # ----------------------------------------------------------------------- #
103
- # use that payload in the data store
104
- # ----------------------------------------------------------------------- #
105
- # Here we use the same reference block for each underlying store.
106
- # ----------------------------------------------------------------------- #
107
- co_values = co_builder.to_coils()
108
- hr_values = hr_builder.to_registers()
109
- store = ModbusSlaveContext(
110
- co=ModbusSequentialDataBlock(0, co_values),
111
- di=ModbusSequentialDataBlock(10000, co_values),
112
- ir=ModbusSequentialDataBlock(30000, hr_values),
113
- hr=ModbusSequentialDataBlock(40000, hr_values),
114
- zero_mode=True,
115
- )
116
- args.context = ModbusServerContext(slaves=store, single=True)
117
-
118
- # ----------------------------------------------------------------------- #
119
- # initialize the server information
120
- # ----------------------------------------------------------------------- #
121
- # If you don't set this or any fields, they are defaulted to empty strings.
122
- # ----------------------------------------------------------------------- #
123
- args.identity = ModbusDeviceIdentification(
124
- info_name={
125
- "VendorName": "Pymodbus",
126
- "ProductCode": "PM",
127
- "VendorUrl": "https://github.com/riptideio/pymodbus/",
128
- "ProductName": "Pymodbus Server",
129
- "ModelName": "Pymodbus Server",
130
- }
131
- )
132
- return args
133
-
134
-
135
- async def run_async_server(args: Any) -> Any:
136
- """Run server."""
137
- txt = f"Start ASYNC server, listening on {args.port} - {args.comm}"
138
- _logger.info(txt)
139
- if args.comm == "tcp":
140
- address = ("", args.port) if args.port else None
141
- server = await StartAsyncTcpServer(
142
- context=args.context, # Data storage
143
- identity=args.identity, # server identify
144
- # TBD host=
145
- # TBD port=
146
- address=address, # listen address
147
- # custom_functions=[], # allow custom handling
148
- framer=args.framer, # The framer strategy to use
149
- # ignore_missing_slaves=True, # ignore request to a missing slave
150
- # broadcast_enable=False, # treat slave_id 0 as broadcast address,
151
- # timeout=1, # waiting time for request to complete
152
- # TBD strict=True, # use strict timing, t1.5 for Modbus RTU
153
- )
154
- elif args.comm == "udp":
155
- address = ("127.0.0.1", args.port) if args.port else None
156
- server = await StartAsyncUdpServer(
157
- context=args.context, # Data storage
158
- identity=args.identity, # server identify
159
- address=address, # listen address
160
- # custom_functions=[], # allow custom handling
161
- framer=args.framer, # The framer strategy to use
162
- # handler=None, # handler for each session
163
- # ignore_missing_slaves=True, # ignore request to a missing slave
164
- # broadcast_enable=False, # treat unit_id 0 as broadcast address,
165
- # timeout=1, # waiting time for request to complete
166
- # TBD strict=True, # use strict timing, t1.5 for Modbus RTU
167
- # defer_start=False, # Only define server do not activate
168
- )
169
- elif args.comm == "serial":
170
- # socat -d -d PTY,link=/tmp/ptyp0,raw,echo=0,ispeed=9600
171
- # PTY,link=/tmp/ttyp0,raw,echo=0,ospeed=9600
172
- server = await StartAsyncSerialServer(
173
- context=args.context, # Data storage
174
- identity=args.identity, # server identify
175
- # timeout=1, # waiting time for request to complete
176
- port=args.port, # serial port
177
- # custom_functions=[], # allow custom handling
178
- framer=args.framer, # The framer strategy to use
179
- # handler=None, # handler for each session
180
- # stopbits=1, # The number of stop bits to use
181
- # bytesize=8, # The bytesize of the serial messages
182
- # parity="N", # Which kind of parity to use
183
- # baudrate=9600, # The baud rate to use for the serial device
184
- # handle_local_echo=False, # Handle local echo of the USB-to-RS485 adaptor
185
- # ignore_missing_slaves=True, # ignore request to a missing slave
186
- # broadcast_enable=False, # treat unit_id 0 as broadcast address,
187
- # strict=True, # use strict timing, t1.5 for Modbus RTU
188
- # defer_start=False, # Only define server do not activate
189
- )
190
- elif args.comm == "tls":
191
- address = ("", args.port) if args.port else None
192
- cwd = os.getcwd().split("/")[-1]
193
- if cwd == "examples":
194
- path = "."
195
- elif cwd == "test":
196
- path = "../examples"
197
- else:
198
- path = "examples"
199
- server = await StartAsyncTlsServer(
200
- context=args.context, # Data storage
201
- host="localhost", # define tcp address where to connect to.
202
- # port=port, # on which port
203
- identity=args.identity, # server identify
204
- # custom_functions=[], # allow custom handling
205
- address=address, # listen address
206
- framer=args.framer, # The framer strategy to use
207
- # handler=None, # handler for each session
208
- allow_reuse_address=True, # allow the reuse of an address
209
- # The cert file path for TLS (used if sslctx is None)
210
- certfile=f"{path}/certificates/pymodbus.crt",
211
- # sslctx=sslctx, # The SSLContext to use for TLS (default None and auto create)
212
- # The key file path for TLS (used if sslctx is None)
213
- keyfile=f"{path}/certificates/pymodbus.key",
214
- # password="none", # The password for for decrypting the private key file
215
- # reqclicert=False, # Force the sever request client"s certificate
216
- # ignore_missing_slaves=True, # ignore request to a missing slave
217
- # broadcast_enable=False, # treat unit_id 0 as broadcast address,
218
- # timeout=1, # waiting time for request to complete
219
- # TBD strict=True, # use strict timing, t1.5 for Modbus RTU
220
- defer_start=False, # Only define server do not activate
221
- )
222
- return server
223
-
224
-
225
- def app() -> None:
226
- _logger.info(
227
- f"Starting modsim v{__version__} at {get_utc_time().strftime('%Y-%m-%d %H:%M:%S')} UTC"
228
- )
229
-
230
- run_args = setup_server(get_commandline())
231
- asyncio.run(_run_server_with_signals(run_args), debug=True)
232
-
233
- _logger.info("exiting.")
234
-
235
-
236
- async def _run_server_with_signals(args: Any) -> None:
237
- """Run the server and close its listener when the process is signaled."""
238
- loop = asyncio.get_running_loop()
239
- server_task = asyncio.create_task(run_async_server(args))
240
- stop_requested = False
241
-
242
- async def stop_server() -> None:
243
- for _ in range(100):
244
- if server_task.done():
245
- return
246
- try:
247
- await ServerAsyncStop()
248
- return
249
- except (AttributeError, RuntimeError):
250
- await asyncio.sleep(0.01)
251
-
252
- def request_stop() -> None:
253
- nonlocal stop_requested
254
- if not stop_requested:
255
- stop_requested = True
256
- loop.create_task(stop_server())
257
-
258
- installed_signals = []
259
- for signum in (signal.SIGINT, signal.SIGTERM):
260
- try:
261
- loop.add_signal_handler(signum, request_stop)
262
- installed_signals.append(signum)
263
- except (NotImplementedError, RuntimeError):
264
- pass
265
-
266
- try:
267
- await server_task
268
- finally:
269
- for signum in installed_signals:
270
- loop.remove_signal_handler(signum)
271
- if not server_task.done():
272
- server_task.cancel()
273
- with suppress(asyncio.CancelledError):
274
- await server_task
275
-
276
-
277
- if __name__ == "__main__":
278
- app()
File without changes
File without changes