ser2netweb 3.0.0__py3-none-any.whl
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.
- ser2netweb/__init__.py +20 -0
- ser2netweb/__main__.py +6 -0
- ser2netweb/accounts.py +503 -0
- ser2netweb/cli.py +489 -0
- ser2netweb/clock.py +128 -0
- ser2netweb/config.py +4491 -0
- ser2netweb/console.py +181 -0
- ser2netweb/datalock.py +181 -0
- ser2netweb/engine/__init__.py +0 -0
- ser2netweb/engine/access.py +173 -0
- ser2netweb/engine/bindhint.py +115 -0
- ser2netweb/engine/bridge.py +1928 -0
- ser2netweb/engine/cluster.py +1140 -0
- ser2netweb/engine/modbus.py +530 -0
- ser2netweb/engine/modbus_gateway.py +1465 -0
- ser2netweb/engine/mqtt_pub.py +632 -0
- ser2netweb/engine/netguard.py +805 -0
- ser2netweb/engine/netinfo.py +199 -0
- ser2netweb/engine/portdiag.py +414 -0
- ser2netweb/engine/portlist.py +471 -0
- ser2netweb/engine/protocols/__init__.py +28 -0
- ser2netweb/engine/protocols/base.py +117 -0
- ser2netweb/engine/protocols/raw.py +12 -0
- ser2netweb/engine/protocols/rfc2217.py +586 -0
- ser2netweb/engine/protocols/telnet.py +172 -0
- ser2netweb/engine/serial_compat.py +598 -0
- ser2netweb/engine/serial_io.py +210 -0
- ser2netweb/engine/serial_link.py +414 -0
- ser2netweb/engine/sockopts.py +103 -0
- ser2netweb/engine/sparkplug.py +538 -0
- ser2netweb/engine/sparkplug_node.py +550 -0
- ser2netweb/engine/supervisor.py +533 -0
- ser2netweb/engine/tls.py +232 -0
- ser2netweb/envvars.py +57 -0
- ser2netweb/explain.py +117 -0
- ser2netweb/healthcheck.py +78 -0
- ser2netweb/i18n.py +110 -0
- ser2netweb/importer.py +550 -0
- ser2netweb/layout.py +118 -0
- ser2netweb/licences.py +86 -0
- ser2netweb/locale/tr.json +1569 -0
- ser2netweb/logsetup.py +253 -0
- ser2netweb/logtext.py +23 -0
- ser2netweb/notify.py +761 -0
- ser2netweb/optional.py +76 -0
- ser2netweb/reload.py +295 -0
- ser2netweb/runtime.py +762 -0
- ser2netweb/sdnotify.py +85 -0
- ser2netweb/ser2net_import.py +2001 -0
- ser2netweb/signins.py +98 -0
- ser2netweb/standalone.py +154 -0
- ser2netweb/state.py +1457 -0
- ser2netweb/storage.py +130 -0
- ser2netweb/textfold.py +51 -0
- ser2netweb/tlsreload.py +37 -0
- ser2netweb/watchdog.py +201 -0
- ser2netweb/web/__init__.py +0 -0
- ser2netweb/web/api.py +793 -0
- ser2netweb/web/auth.py +699 -0
- ser2netweb/web/certinfo.py +144 -0
- ser2netweb/web/httpguard.py +413 -0
- ser2netweb/web/ldap_auth.py +556 -0
- ser2netweb/web/oidc_auth.py +330 -0
- ser2netweb/web/proxy.py +398 -0
- ser2netweb/web/routes/__init__.py +78 -0
- ser2netweb/web/routes/cluster.py +669 -0
- ser2netweb/web/routes/common.py +359 -0
- ser2netweb/web/routes/console.py +451 -0
- ser2netweb/web/routes/forms.py +853 -0
- ser2netweb/web/routes/login.py +573 -0
- ser2netweb/web/routes/mappings.py +470 -0
- ser2netweb/web/routes/ops.py +374 -0
- ser2netweb/web/routes/settings.py +2135 -0
- ser2netweb/web/routes/system.py +308 -0
- ser2netweb/web/routes/totp.py +282 -0
- ser2netweb/web/selfsigned.py +129 -0
- ser2netweb/web/server.py +483 -0
- ser2netweb/web/static/app.css +327 -0
- ser2netweb/web/static/app.js +1102 -0
- ser2netweb/web/static/htmx.min.js +1 -0
- ser2netweb/web/static/points.js +218 -0
- ser2netweb/web/static/theme.js +7 -0
- ser2netweb/web/static/xterm-addon-fit.js +2 -0
- ser2netweb/web/static/xterm.css +218 -0
- ser2netweb/web/static/xterm.js +2 -0
- ser2netweb/web/templates/_cluster_body.html +109 -0
- ser2netweb/web/templates/_console.html +21 -0
- ser2netweb/web/templates/_footer.html +13 -0
- ser2netweb/web/templates/_login_notice.html +8 -0
- ser2netweb/web/templates/_macros.html +21 -0
- ser2netweb/web/templates/_mapping_form.html +477 -0
- ser2netweb/web/templates/_mapping_log.html +28 -0
- ser2netweb/web/templates/_mapping_traces.html +44 -0
- ser2netweb/web/templates/_mappings_body.html +149 -0
- ser2netweb/web/templates/_modbus_read.html +18 -0
- ser2netweb/web/templates/_ports_table.html +37 -0
- ser2netweb/web/templates/_serial_port_select.html +48 -0
- ser2netweb/web/templates/_shell.html +41 -0
- ser2netweb/web/templates/audit.html +55 -0
- ser2netweb/web/templates/base.html +77 -0
- ser2netweb/web/templates/dashboard.html +72 -0
- ser2netweb/web/templates/licenses.html +18 -0
- ser2netweb/web/templates/login.html +32 -0
- ser2netweb/web/templates/login_totp.html +23 -0
- ser2netweb/web/templates/settings.html +830 -0
- ser2netweb/web/templates/setup.html +43 -0
- ser2netweb/web/templates/totp.html +41 -0
- ser2netweb/web/totp.py +94 -0
- ser2netweb/winservice.py +1793 -0
- ser2netweb-3.0.0.dist-info/METADATA +120 -0
- ser2netweb-3.0.0.dist-info/RECORD +121 -0
- ser2netweb-3.0.0.dist-info/WHEEL +5 -0
- ser2netweb-3.0.0.dist-info/entry_points.txt +2 -0
- ser2netweb-3.0.0.dist-info/licenses/COMMERCIAL_LICENSE.md +94 -0
- ser2netweb-3.0.0.dist-info/licenses/LICENSE +662 -0
- ser2netweb-3.0.0.dist-info/licenses/NOTICE +20 -0
- ser2netweb-3.0.0.dist-info/licenses/THIRD-PARTY-NOTICES.md +105 -0
- ser2netweb-3.0.0.dist-info/licenses/tools/licenses/npm/htmx.org.txt +13 -0
- ser2netweb-3.0.0.dist-info/licenses/tools/licenses/npm/xterm-addon-fit.txt +19 -0
- ser2netweb-3.0.0.dist-info/licenses/tools/licenses/npm/xterm.txt +21 -0
- ser2netweb-3.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1465 @@
|
|
|
1
|
+
"""Modbus RTU<->TCP gateway runner.
|
|
2
|
+
|
|
3
|
+
A drop-in alternative to MappingRunner, selected when a mapping's network
|
|
4
|
+
protocol is ``modbus``. It listens as a Modbus/TCP server: each connected master
|
|
5
|
+
sends Modbus/TCP requests, which the gateway converts to Modbus/RTU, forwards on
|
|
6
|
+
the shared serial bus (one transaction at a time, since RTU is a single-master
|
|
7
|
+
bus), reads the slave's RTU reply, and returns it to that master as Modbus/TCP —
|
|
8
|
+
echoing the transaction id so concurrent masters don't get crossed wires. A request
|
|
9
|
+
whose slave doesn't answer in time is answered with exception 0x0B (gateway target
|
|
10
|
+
failed to respond); while the serial port is down, every request is answered with 0x0A
|
|
11
|
+
(gateway path unavailable) at once, and so is one that was on the bus when the port went
|
|
12
|
+
away. A broadcast (unit 0) write goes on the bus and, after
|
|
13
|
+
the turnaround delay, gets the echo a slave would have sent; a read of unit 0 can't get a
|
|
14
|
+
reply and is answered 0x0B at once, without touching the bus.
|
|
15
|
+
|
|
16
|
+
Shares the lifecycle shape (and the H3/H4 fixes) of MappingRunner: the same
|
|
17
|
+
supervised serial port (serial_link.SerialLink: reopened, and its USB identity
|
|
18
|
+
re-resolved, whenever it is lost) plus an asyncio TCP server, both torn down
|
|
19
|
+
cleanly on stop(). Masters are admitted by the same policy as bridge clients
|
|
20
|
+
(access.AccessPolicy: allow-list, max connections, priority IPs, kick_old_user);
|
|
21
|
+
a read-only gateway answers every request that is not known to be a read with
|
|
22
|
+
exception 01 and never puts it on the bus, and refuses MQTT/Sparkplug writes as well.
|
|
23
|
+
A banner, open/close strings and closeon don't apply: bytes outside Modbus frames
|
|
24
|
+
would break the framing.
|
|
25
|
+
|
|
26
|
+
Edge mode (MQTT enabled, poll points) reads the points off the bus on an interval, unit by
|
|
27
|
+
unit, and keeps track of each unit's health (_poll_unit): a unit that gives no reply to any
|
|
28
|
+
of its points in UNIT_OFFLINE_AFTER cycles in a row, or whose serial port stays gone that
|
|
29
|
+
long, is offline (logged once; a Sparkplug B node sends its DDEATH) and is polled with one
|
|
30
|
+
point per cycle until it answers; then all its points are read again (Sparkplug B: a DBIRTH
|
|
31
|
+
with those values). Requests from Modbus/TCP masters are not affected. Plain MQTT publishes
|
|
32
|
+
each value as text (1 / 0 for a bit), or with mqtt.value_format "json" as {"value", "ts"
|
|
33
|
+
(the read time, ms since the epoch), "quality": "good"}; then a point whose read fails, or
|
|
34
|
+
whose unit goes offline, is published once as {"value": null, "quality": "bad"} until it
|
|
35
|
+
reads again (encode_value).
|
|
36
|
+
"""
|
|
37
|
+
from __future__ import annotations
|
|
38
|
+
|
|
39
|
+
import asyncio
|
|
40
|
+
import contextlib
|
|
41
|
+
import functools
|
|
42
|
+
import json
|
|
43
|
+
import math
|
|
44
|
+
import re
|
|
45
|
+
import time
|
|
46
|
+
from collections import deque
|
|
47
|
+
from dataclasses import dataclass, field
|
|
48
|
+
from typing import TYPE_CHECKING, Any, Callable, Optional
|
|
49
|
+
|
|
50
|
+
from ..config import (MappingConfig, ModbusPoint, modbus_response_timeout, modbus_unit_map,
|
|
51
|
+
point_order, point_scale, poll_block_gap)
|
|
52
|
+
from . import modbus, netguard, sockopts, tls
|
|
53
|
+
from .access import AccessPolicy, connection_order, server_ssl
|
|
54
|
+
from .bridge import (
|
|
55
|
+
FROM_DEVICE, TO_DEVICE, RunnerStatus, Tracer, describe_disconnect, fmt_duration,
|
|
56
|
+
log_ignored_settings, open_tracer,
|
|
57
|
+
)
|
|
58
|
+
from .mqtt_pub import shown
|
|
59
|
+
from .serial_link import SerialLink
|
|
60
|
+
|
|
61
|
+
if TYPE_CHECKING:
|
|
62
|
+
from .mqtt_pub import MqttPublisher
|
|
63
|
+
from .sparkplug_node import SparkplugNode
|
|
64
|
+
|
|
65
|
+
READ_CHUNK = 512
|
|
66
|
+
# An RTU reply whose length its header does not give (FC8, FC43, user-defined codes), or
|
|
67
|
+
# that fails its CRC at that length, ends once the line has been quiet this long (or for
|
|
68
|
+
# 3.5 characters, if that is longer): well above the latency of USB serial adapters (16 ms
|
|
69
|
+
# by default on FTDI chips) and the 15.6 ms clock of the event loop on Windows.
|
|
70
|
+
SILENCE_S = 0.05
|
|
71
|
+
# After a request that got no reply in time (or bytes after its reply), the line has to
|
|
72
|
+
# be quiet this long (at most the response timeout) before the next request goes out: a
|
|
73
|
+
# reply that comes late is dropped then, not taken for the next request's.
|
|
74
|
+
GUARD_S = 0.25
|
|
75
|
+
# After a broadcast (unit 0), which no slave answers: the time the slaves get to carry it
|
|
76
|
+
# out before the next request goes out (the Modbus serial line specification's turnaround
|
|
77
|
+
# delay, 100-200 ms; at most the response timeout).
|
|
78
|
+
TURNAROUND_S = 0.2
|
|
79
|
+
# Log lines about one slave's replies (cut short, not matching): at most one per this many
|
|
80
|
+
# seconds per kind and unit; the ones in between are counted into the next.
|
|
81
|
+
NOTE_EVERY_S = 60.0
|
|
82
|
+
# Edge mode: a unit none of whose points has answered in this many poll cycles in a row (or
|
|
83
|
+
# while the serial port is gone) is offline, and polled with one point per cycle from then on.
|
|
84
|
+
UNIT_OFFLINE_AFTER = 3
|
|
85
|
+
# Writes from MQTT / Sparkplug B: one task per mapping writes them in the order they came,
|
|
86
|
+
# one at a time (a write and its read-back take their turns on the bus with the poll and
|
|
87
|
+
# the masters, whatever the number of commands). At most this many wait for the bus; a
|
|
88
|
+
# command beyond them is refused (busy).
|
|
89
|
+
WRITE_QUEUE_MAX = 32
|
|
90
|
+
# A write that waited this long for its turn is not written any more (expired): the bus is
|
|
91
|
+
# too slow for the commands coming in, and whoever sent it has likely given up on it.
|
|
92
|
+
WRITE_EXPIRE_S = 10.0
|
|
93
|
+
# A plain /set larger than this is refused unread (a backstop: a value is a few bytes).
|
|
94
|
+
SET_MAX_BYTES = 64 * 1024
|
|
95
|
+
# stop(): the write on the bus gets this long at most (and no longer than its reply can
|
|
96
|
+
# take) to finish before it is cancelled; the ones still waiting are answered at once.
|
|
97
|
+
WRITE_STOP_WAIT_S = 2.0
|
|
98
|
+
# The mapping form's test read (read_now): at most this many points, for at most this long.
|
|
99
|
+
READ_NOW_MAX = 32
|
|
100
|
+
READ_NOW_S = 10.0
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _next_timer(idle_s: float, deadline: Optional[float]) -> Optional[float]:
|
|
104
|
+
"""Seconds until a master's idle / max-connection-time timer fires (None = none)."""
|
|
105
|
+
waits = [idle_s] if idle_s > 0 else []
|
|
106
|
+
if deadline is not None:
|
|
107
|
+
waits.append(max(0.0, deadline - time.monotonic()))
|
|
108
|
+
return min(waits) if waits else None
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@dataclass(eq=False)
|
|
112
|
+
class _Master:
|
|
113
|
+
"""A connected Modbus/TCP master (identity-hashed, so it can live in a set)."""
|
|
114
|
+
writer: asyncio.StreamWriter
|
|
115
|
+
peer: str
|
|
116
|
+
priority: bool = False # admitted from priority_client_ips
|
|
117
|
+
seq: int = field(default_factory=connection_order) # access.Client
|
|
118
|
+
connected_at: float = field(default_factory=time.monotonic)
|
|
119
|
+
close_reason: str = "" # why the gateway dropped it (first cause wins)
|
|
120
|
+
refused: int = 0 # requests refused because the mapping is read-only
|
|
121
|
+
closing: bool = False # kicked: listed until its handler is done
|
|
122
|
+
|
|
123
|
+
def kick(self, reason: str = "kicked") -> None:
|
|
124
|
+
self.closing = True
|
|
125
|
+
self.close_reason = self.close_reason or reason
|
|
126
|
+
with contextlib.suppress(Exception):
|
|
127
|
+
self.writer.transport.abort()
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@dataclass(eq=False)
|
|
131
|
+
class _Write:
|
|
132
|
+
"""A write from MQTT / Sparkplug B, waiting for the bus (or on it)."""
|
|
133
|
+
point: ModbusPoint
|
|
134
|
+
value: Any
|
|
135
|
+
done: Optional[Callable[[Optional[str], Any], None]] = None # (error, value read back)
|
|
136
|
+
refused: Optional[str] = None # refused when it came: answered in its turn, not written
|
|
137
|
+
source: str = "" # where it came from, for the log: "MQTT <topic>", ...
|
|
138
|
+
queued: float = field(default_factory=time.monotonic)
|
|
139
|
+
stage: str = "queued" # sent: its request is on the line; written: acknowledged
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class NoReply(ValueError):
|
|
143
|
+
"""A poll point got no reply: its unit did not answer in time (or not usably), or the
|
|
144
|
+
serial port is down (exception 0x0B / 0x0A, which the gateway makes up then)."""
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
@dataclass(eq=False)
|
|
148
|
+
class _Unit:
|
|
149
|
+
"""A polled Modbus unit (slave address) and how it is doing (see _poll_unit)."""
|
|
150
|
+
unit: int
|
|
151
|
+
points: list[ModbusPoint] = field(default_factory=list)
|
|
152
|
+
state: str = "unknown" # unknown (not polled yet) | online | offline
|
|
153
|
+
cause: str = "" # offline: "no reply" (the unit) or "port down" (the port)
|
|
154
|
+
misses: int = 0 # poll cycles in a row in which none of its points answered,
|
|
155
|
+
miss_cause: str = "" # ... all for this cause (another one starts the count over)
|
|
156
|
+
since: float = 0.0 # time.time() when it went online / offline
|
|
157
|
+
errors: int = 0 # point reads that failed, in this run
|
|
158
|
+
probe: int = 0 # offline: which point the next probe reads (each in turn)
|
|
159
|
+
blocks: Optional[list] = None # block reads: its points as _Blocks (None: one by one)
|
|
160
|
+
|
|
161
|
+
def as_dict(self) -> dict:
|
|
162
|
+
"""For RunnerStatus.units (the dashboard, the REST API, /metrics, notifications)."""
|
|
163
|
+
return {"unit": self.unit, "state": self.state, "cause": self.cause,
|
|
164
|
+
"since": int(self.since) if self.since else None, "errors": self.errors}
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
@dataclass(eq=False)
|
|
168
|
+
class _Block:
|
|
169
|
+
"""Points of one unit and function read with one request (block reads)."""
|
|
170
|
+
fn: int
|
|
171
|
+
start: int # the first register (coil, input) read
|
|
172
|
+
count: int # how many
|
|
173
|
+
points: list = field(default_factory=list)
|
|
174
|
+
single: bool = False # its points are read one at a time (the slave refused it)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
@dataclass
|
|
178
|
+
class _Reads:
|
|
179
|
+
"""What one round of point reads gave."""
|
|
180
|
+
values: dict = field(default_factory=dict)
|
|
181
|
+
stamps: dict = field(default_factory=dict) # when each value was read (ms since the epoch)
|
|
182
|
+
failed: list = field(default_factory=list) # (point, error)
|
|
183
|
+
answered: bool = False # the unit answered at least once (a value or an exception)
|
|
184
|
+
|
|
185
|
+
def drop(self, name: str) -> None:
|
|
186
|
+
"""A point written after it was read here: what was read before the write is out
|
|
187
|
+
of date (the write's read-back has reported the point already)."""
|
|
188
|
+
self.values.pop(name, None)
|
|
189
|
+
self.stamps.pop(name, None)
|
|
190
|
+
self.failed = [(p, e) for p, e in self.failed if p.name != name]
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
class ModbusGatewayRunner:
|
|
194
|
+
def __init__(self, mapping: MappingConfig, logger=None, trace_dir: Optional[str] = None,
|
|
195
|
+
state_dir: Optional[str] = None):
|
|
196
|
+
self.mapping = mapping
|
|
197
|
+
self.trace_dir = trace_dir
|
|
198
|
+
self.state_dir = state_dir # the data folder: what a Sparkplug node keeps across runs
|
|
199
|
+
self._tracer: Optional[Tracer] = None
|
|
200
|
+
self.status = RunnerStatus(state="stopped")
|
|
201
|
+
self.log = logger
|
|
202
|
+
self._serial_task: Optional[asyncio.Task] = None
|
|
203
|
+
self._server: Optional[netguard.Listener] = None
|
|
204
|
+
self._stop = asyncio.Event()
|
|
205
|
+
# the RTU bus; is_open / ready say whether requests can reach it right now
|
|
206
|
+
self._link = SerialLink(mapping.serial, self.status, self._stop, self._log,
|
|
207
|
+
owner=mapping.name)
|
|
208
|
+
self._bus_lock = asyncio.Lock() # RTU is single-master: one txn at a time
|
|
209
|
+
self._clients: set[_Master] = set()
|
|
210
|
+
self._monitors: set = set() # browser console observers (read the bus)
|
|
211
|
+
self._traffic: set = set() # live hex view observers
|
|
212
|
+
self._mqtt: Optional[MqttPublisher] = None # optional MQTT publisher for register polling
|
|
213
|
+
self._sparkplug: Optional[SparkplugNode] = None # payload format sparkplug_b
|
|
214
|
+
self._writes: deque[_Write] = deque() # MQTT writes waiting for the bus, in order
|
|
215
|
+
self._writer: Optional[asyncio.Task] = None # writes them, one at a time
|
|
216
|
+
self._closing = False # stop() has begun: no more MQTT writes are taken
|
|
217
|
+
self._poll_task: Optional[asyncio.Task] = None
|
|
218
|
+
self._publish_error = "" # the last publishing failure logged by the poll loop
|
|
219
|
+
# plain MQTT: the command topic of each writable point, matched exactly (a point
|
|
220
|
+
# name is never read back out of a topic)
|
|
221
|
+
base = mapping.mqtt.base_topic.rstrip("/")
|
|
222
|
+
self._set_points: dict[str, ModbusPoint] = {
|
|
223
|
+
f"{base}/{p.name}/set": p for p in mapping.modbus_poll.points if p.writable}
|
|
224
|
+
self._access = AccessPolicy(mapping.network)
|
|
225
|
+
self._refusal_log = netguard.RefusalLog(self._log) # refused connections
|
|
226
|
+
# one RTU character on the line (start bit, 8 data bits, parity or a 2nd stop bit,
|
|
227
|
+
# stop bit), and the silence that ends a reply its header does not size
|
|
228
|
+
baud = max(1, mapping.serial.baudrate)
|
|
229
|
+
self._char_s = 11 / baud
|
|
230
|
+
self._gap = max(SILENCE_S, 3.5 * self._char_s)
|
|
231
|
+
self._resp_timeout = modbus_response_timeout(mapping.options)
|
|
232
|
+
# the unit ids masters send that go to another RTU unit (their replies keep the id)
|
|
233
|
+
self._unit_map = modbus_unit_map(mapping.options)
|
|
234
|
+
self._notes: dict[tuple[str, int], tuple[float, int]] = {} # see _note
|
|
235
|
+
# the line may still carry a reply to an earlier request (see _settle): since when
|
|
236
|
+
# (loop time; None: it does not), and the last request that got none in time, as
|
|
237
|
+
# (unit, PDU)
|
|
238
|
+
self._dirty: Optional[float] = None
|
|
239
|
+
self._unanswered: Optional[tuple[int, bytes]] = None
|
|
240
|
+
# edge mode: the polled units, in the order their first point is listed (a point on
|
|
241
|
+
# unit 0, the broadcast address, is never read: no slave answers a broadcast), and the
|
|
242
|
+
# points whose last read failed (True: that was logged)
|
|
243
|
+
self._units: dict[int, _Unit] = {}
|
|
244
|
+
for p in mapping.modbus_poll.points:
|
|
245
|
+
if p.unit != modbus.BROADCAST and _polled(p):
|
|
246
|
+
self._units.setdefault(p.unit, _Unit(p.unit)).points.append(p)
|
|
247
|
+
self._block_gap = poll_block_gap(mapping.modbus_poll)
|
|
248
|
+
if self._block_gap is not None:
|
|
249
|
+
for u in self._units.values():
|
|
250
|
+
u.blocks = plan_blocks(u.points, self._block_gap)
|
|
251
|
+
self._failing: dict[str, bool] = {}
|
|
252
|
+
# each polled point's last read, for the status (RunnerStatus.points)
|
|
253
|
+
self._last: dict[str, dict] = {
|
|
254
|
+
p.name: {"name": p.name, "unit": u.unit, "value": None, "ts": None, "error": ""}
|
|
255
|
+
for u in self._units.values() for p in u.points}
|
|
256
|
+
self._bad: set[str] = set() # plain JSON values: points last published as "bad"
|
|
257
|
+
self._cycle: Optional[_Reads] = None # the reads of the unit being polled (see _Reads.drop)
|
|
258
|
+
|
|
259
|
+
def _log(self, msg: str) -> None:
|
|
260
|
+
if self.log:
|
|
261
|
+
self.log(msg)
|
|
262
|
+
|
|
263
|
+
@property
|
|
264
|
+
def tracer(self) -> Optional[Tracer]:
|
|
265
|
+
"""The trace writer while the mapping runs with tracing on."""
|
|
266
|
+
return self._tracer
|
|
267
|
+
|
|
268
|
+
@property
|
|
269
|
+
def listener(self) -> Optional[netguard.Listener]:
|
|
270
|
+
"""The Modbus TCP listener while the mapping runs, else None."""
|
|
271
|
+
return self._server
|
|
272
|
+
|
|
273
|
+
# ---- console monitors (watch the raw bus) ----
|
|
274
|
+
def add_monitor(self, mon) -> None:
|
|
275
|
+
self._monitors.add(mon)
|
|
276
|
+
|
|
277
|
+
def discard_monitor(self, mon) -> None:
|
|
278
|
+
self._monitors.discard(mon)
|
|
279
|
+
|
|
280
|
+
def _feed_monitors(self, data: bytes) -> None:
|
|
281
|
+
for mon in list(self._monitors):
|
|
282
|
+
mon.feed(data)
|
|
283
|
+
|
|
284
|
+
def add_traffic_monitor(self, mon) -> None:
|
|
285
|
+
self._traffic.add(mon)
|
|
286
|
+
|
|
287
|
+
def discard_traffic_monitor(self, mon) -> None:
|
|
288
|
+
self._traffic.discard(mon)
|
|
289
|
+
|
|
290
|
+
def _feed_traffic(self, direction: int, data: bytes) -> None:
|
|
291
|
+
for mon in list(self._traffic):
|
|
292
|
+
mon.feed_traffic(direction, data)
|
|
293
|
+
|
|
294
|
+
def wake_serial(self) -> None:
|
|
295
|
+
"""A serial device was plugged in (PortWatcher): try the port now if it is waiting
|
|
296
|
+
to be opened again."""
|
|
297
|
+
self._link.wake()
|
|
298
|
+
|
|
299
|
+
async def serial_write(self, data: bytes) -> bool:
|
|
300
|
+
# the modbus console is observe-only (injecting raw bytes would corrupt
|
|
301
|
+
# the request/response framing on the shared bus)
|
|
302
|
+
return False
|
|
303
|
+
|
|
304
|
+
# ---------------- lifecycle ----------------
|
|
305
|
+
async def start(self) -> None:
|
|
306
|
+
self._stop.clear()
|
|
307
|
+
self._closing = False
|
|
308
|
+
net = self.mapping.network
|
|
309
|
+
# the listener first (as MappingRunner.start): a start that fails there has opened
|
|
310
|
+
# neither the serial port nor a broker connection, so it can be tried again
|
|
311
|
+
self._server = await netguard.start_server(
|
|
312
|
+
self._on_client, netguard.listen_hosts(net), net.port, ssl=server_ssl(net),
|
|
313
|
+
allowed=self._access.allowed, log=self._log)
|
|
314
|
+
self._log(f"modbus gateway listening on {netguard.listen_where(net, self._server.sockets)}"
|
|
315
|
+
f" ({'TLS, ' if net.tls else ''}{sockopts.describe(net)})")
|
|
316
|
+
if self._stop.is_set(): # stopped while the listener came up: it found none to close,
|
|
317
|
+
await self.stop() # and no serial port, MQTT client or polling is started
|
|
318
|
+
return
|
|
319
|
+
try:
|
|
320
|
+
self._tracer = open_tracer(self.mapping, self.trace_dir, self._log, self.status)
|
|
321
|
+
self.status.state = "running" # the serial side reports its own from here on
|
|
322
|
+
self.status.last_error = ""
|
|
323
|
+
# the port is read per transaction; the link only watches for it going away
|
|
324
|
+
self._serial_task = asyncio.create_task(
|
|
325
|
+
self._link.run(self._link.hold_open), name=f"modbus-serial:{self.mapping.name}")
|
|
326
|
+
self._log_policy()
|
|
327
|
+
log_ignored_settings(self.mapping, self._log)
|
|
328
|
+
# optional edge mode: poll Modbus registers off the bus and publish to MQTT
|
|
329
|
+
# (plain topics, or as a Sparkplug B edge node); writable points take values back
|
|
330
|
+
if self.mapping.mqtt.enabled and self.mapping.modbus_poll.points:
|
|
331
|
+
mq = self.mapping.mqtt
|
|
332
|
+
if mq.payload_format == "sparkplug_b":
|
|
333
|
+
from .sparkplug_node import SparkplugNode
|
|
334
|
+
self._sparkplug = SparkplugNode(self.mapping, self._log, functools.partial(
|
|
335
|
+
self._write_soon, source="Sparkplug DCMD"), state_dir=self.state_dir)
|
|
336
|
+
self._mqtt = self._sparkplug.publisher()
|
|
337
|
+
else:
|
|
338
|
+
from .mqtt_pub import MqttPublisher
|
|
339
|
+
self._mqtt = MqttPublisher(
|
|
340
|
+
mq, logger=self._log,
|
|
341
|
+
subscriptions=dict.fromkeys(self._set_points, self._on_set_message),
|
|
342
|
+
accept_retained=bool(mq.apply_retained_set),
|
|
343
|
+
on_connect=self._publish_unit_states if mq.unit_status else None)
|
|
344
|
+
self.status.mqtt = self._mqtt.state
|
|
345
|
+
self._mqtt.connect()
|
|
346
|
+
self._report_units()
|
|
347
|
+
self._poll_task = asyncio.create_task(
|
|
348
|
+
self._poll_loop(), name=f"modbus-poll:{self.mapping.name}")
|
|
349
|
+
except BaseException:
|
|
350
|
+
await self.stop()
|
|
351
|
+
raise
|
|
352
|
+
|
|
353
|
+
async def stop(self) -> None:
|
|
354
|
+
self._closing = True # MQTT / Sparkplug writes are refused from now on
|
|
355
|
+
self._stop.set()
|
|
356
|
+
if self._server is not None:
|
|
357
|
+
self._server.close()
|
|
358
|
+
for c in list(self._clients):
|
|
359
|
+
c.kick("gateway stopped")
|
|
360
|
+
for mon in list(self._monitors) + list(self._traffic):
|
|
361
|
+
mon.close()
|
|
362
|
+
self._monitors.clear()
|
|
363
|
+
self._traffic.clear()
|
|
364
|
+
if self._poll_task is not None:
|
|
365
|
+
self._poll_task.cancel()
|
|
366
|
+
try:
|
|
367
|
+
await self._poll_task
|
|
368
|
+
except asyncio.CancelledError:
|
|
369
|
+
pass
|
|
370
|
+
except Exception as e: # it had died: say why, and still send NDEATH etc. below
|
|
371
|
+
self._log(f"polling had stopped: {e!r}")
|
|
372
|
+
self._poll_task = None
|
|
373
|
+
await self._end_writes() # (their answers go out before the MQTT connection closes)
|
|
374
|
+
if self._sparkplug is not None:
|
|
375
|
+
sparkplug, self._sparkplug = self._sparkplug, None
|
|
376
|
+
self._mqtt = None
|
|
377
|
+
await sparkplug.aclose() # NDEATH, then disconnect
|
|
378
|
+
elif self._mqtt is not None:
|
|
379
|
+
mqtt, self._mqtt = self._mqtt, None
|
|
380
|
+
await mqtt.aclose()
|
|
381
|
+
self.status.mqtt = {}
|
|
382
|
+
if self._server is not None:
|
|
383
|
+
with contextlib.suppress(Exception):
|
|
384
|
+
await asyncio.wait_for(self._server.wait_closed(), timeout=5.0)
|
|
385
|
+
self._server = None
|
|
386
|
+
if self._serial_task:
|
|
387
|
+
self._serial_task.cancel()
|
|
388
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
389
|
+
await self._serial_task
|
|
390
|
+
self._serial_task = None
|
|
391
|
+
await self._link.close()
|
|
392
|
+
if self._tracer:
|
|
393
|
+
await self._tracer.aclose()
|
|
394
|
+
self._tracer = None
|
|
395
|
+
self.status.state = "stopped"
|
|
396
|
+
self.status.client_count = 0
|
|
397
|
+
self.status.clients = []
|
|
398
|
+
self.status.units = []
|
|
399
|
+
self.status.points = []
|
|
400
|
+
self._log("modbus gateway stopped")
|
|
401
|
+
|
|
402
|
+
# ---------------- one RTU transaction on the shared bus ----------------
|
|
403
|
+
async def _transaction(self, unit: int, pdu: bytes, job: Optional[_Write] = None) -> bytes:
|
|
404
|
+
"""One request on the RTU bus and its reply (or the exception the gateway answers in
|
|
405
|
+
its place); ``job``: the MQTT write it is for (marked sent once it is on the line)."""
|
|
406
|
+
function = pdu[0] if pdu else 0
|
|
407
|
+
if self._link.down: # the port is gone (reconnecting): answer now, don't queue
|
|
408
|
+
return modbus.exception_pdu(function, modbus.GATEWAY_PATH_UNAVAILABLE)
|
|
409
|
+
if unit == modbus.BROADCAST and modbus.is_read_request(pdu):
|
|
410
|
+
# no slave answers a broadcast, so a read of unit 0 can't get a reply: nothing
|
|
411
|
+
# goes on the bus, and the master gets at once what it got after the timeout
|
|
412
|
+
return modbus.exception_pdu(function, modbus.GATEWAY_TARGET_FAILED)
|
|
413
|
+
if not self._link.is_open:
|
|
414
|
+
# the first open at start-up is still under way: wait a little for it, outside
|
|
415
|
+
# the bus lock, so queued requests wait together and not one timeout each
|
|
416
|
+
with contextlib.suppress(asyncio.TimeoutError):
|
|
417
|
+
await asyncio.wait_for(self._link.ready.wait(), timeout=self._resp_timeout)
|
|
418
|
+
async with self._bus_lock:
|
|
419
|
+
reader, writer = self._link.reader, self._link.writer
|
|
420
|
+
if reader is None or writer is None: # not open (yet), or lost meanwhile
|
|
421
|
+
return modbus.exception_pdu(function, modbus.GATEWAY_PATH_UNAVAILABLE)
|
|
422
|
+
rtu_req = modbus.rtu_wrap(unit, pdu)
|
|
423
|
+
loop = asyncio.get_running_loop()
|
|
424
|
+
try:
|
|
425
|
+
await self._settle(reader) # drop what the line holds, then send
|
|
426
|
+
self._feed_monitors(b"\xbb tx " + rtu_req)
|
|
427
|
+
if self._tracer:
|
|
428
|
+
self._tracer.write("net>ser", rtu_req)
|
|
429
|
+
self._feed_traffic(TO_DEVICE, rtu_req)
|
|
430
|
+
writer.write(rtu_req)
|
|
431
|
+
if job is not None:
|
|
432
|
+
job.stage = "sent"
|
|
433
|
+
await writer.drain()
|
|
434
|
+
self.status.bytes_out += len(rtu_req)
|
|
435
|
+
# drain() returns once the OS has the request, which then still takes
|
|
436
|
+
# its time on the line: the response timeout starts after that
|
|
437
|
+
sent = loop.time() + len(rtu_req) * self._char_s
|
|
438
|
+
if unit == modbus.BROADCAST:
|
|
439
|
+
await self._turnaround(reader, sent)
|
|
440
|
+
reply = modbus.broadcast_reply(pdu)
|
|
441
|
+
if reply is not None: # a write: what each slave would have answered
|
|
442
|
+
return reply
|
|
443
|
+
return modbus.exception_pdu(function, modbus.GATEWAY_TARGET_FAILED)
|
|
444
|
+
rpdu = await self._read_reply(reader, unit, pdu, sent)
|
|
445
|
+
except asyncio.TimeoutError: # (an OSError too, since Python 3.11)
|
|
446
|
+
return modbus.exception_pdu(function, modbus.GATEWAY_TARGET_FAILED)
|
|
447
|
+
except (ConnectionError, OSError): # the port went away meanwhile
|
|
448
|
+
return modbus.exception_pdu(function, modbus.GATEWAY_PATH_UNAVAILABLE)
|
|
449
|
+
if rpdu is None: # no reply from that slave in time
|
|
450
|
+
return modbus.exception_pdu(function, modbus.GATEWAY_TARGET_FAILED)
|
|
451
|
+
return rpdu
|
|
452
|
+
|
|
453
|
+
async def _read_point(self, p):
|
|
454
|
+
"""One poll point's current value off the bus (bool for coils / discrete inputs and
|
|
455
|
+
a register's bit, text for a string, a number for the other registers). Raises
|
|
456
|
+
NoReply if the unit did not answer (or the port is down), ValueError for an answer
|
|
457
|
+
that is not a value (an exception, ...)."""
|
|
458
|
+
if p.fn in (modbus.READ_COILS, modbus.READ_DISCRETE_INPUTS):
|
|
459
|
+
rpdu = await self._transaction(p.unit, modbus.read_bits_pdu(p.fn, p.address, 1))
|
|
460
|
+
self._check_answered(rpdu, p)
|
|
461
|
+
return modbus.decode_bits(modbus.response_data(rpdu, p.fn), 1)[0]
|
|
462
|
+
rpdu = await self._transaction(p.unit, modbus.read_pdu(p.fn, p.address, _registers(p)))
|
|
463
|
+
self._check_answered(rpdu, p)
|
|
464
|
+
return decode_point(p, modbus.response_data(rpdu, p.fn))
|
|
465
|
+
|
|
466
|
+
def _check_answered(self, rpdu: bytes, p) -> None:
|
|
467
|
+
"""Raise NoReply for the exceptions the gateway answers itself when no reply came:
|
|
468
|
+
0x0B (none in time, or none that could be used) and 0x0A (the port is down)."""
|
|
469
|
+
if len(rpdu) == 2 and rpdu[0] & 0x80:
|
|
470
|
+
if rpdu[1] == modbus.GATEWAY_TARGET_FAILED:
|
|
471
|
+
raise NoReply(f"no reply from unit {p.unit} within {self._resp_timeout:g} s")
|
|
472
|
+
if rpdu[1] == modbus.GATEWAY_PATH_UNAVAILABLE:
|
|
473
|
+
raise NoReply("the serial port is down")
|
|
474
|
+
|
|
475
|
+
def _publish_values(self, values: dict, stamps: Optional[dict] = None,
|
|
476
|
+
force: bool = False) -> None:
|
|
477
|
+
"""Publish values read off the bus; ``stamps``: when each was read (ms since the
|
|
478
|
+
epoch), which Sparkplug B reports as the metric's timestamp; ``force``: Sparkplug B
|
|
479
|
+
sends them in a DDATA even if they did not change (after a DCMD)."""
|
|
480
|
+
if self._sparkplug is not None:
|
|
481
|
+
self._sparkplug.update(values, stamps, force=values.keys() if force else ())
|
|
482
|
+
elif self._mqtt is not None:
|
|
483
|
+
fmt = self.mapping.mqtt.value_format
|
|
484
|
+
for name, v in values.items():
|
|
485
|
+
self._bad.discard(name)
|
|
486
|
+
self._mqtt.publish_value(name, encode_value(
|
|
487
|
+
v, fmt, (stamps or {}).get(name) or _now_ms()))
|
|
488
|
+
|
|
489
|
+
def _publish_bad(self, names) -> None:
|
|
490
|
+
"""Plain MQTT, JSON values: points whose read failed, or whose unit stopped
|
|
491
|
+
answering, are published once as quality "bad" (value null), until they read
|
|
492
|
+
again (text values: nothing is published for them)."""
|
|
493
|
+
if (self._sparkplug is not None or self._mqtt is None
|
|
494
|
+
or self.mapping.mqtt.value_format != "json"):
|
|
495
|
+
return
|
|
496
|
+
now = _now_ms()
|
|
497
|
+
for name in names:
|
|
498
|
+
if name not in self._bad:
|
|
499
|
+
self._bad.add(name)
|
|
500
|
+
self._mqtt.publish_value(name, encode_value(None, "json", now, good=False))
|
|
501
|
+
|
|
502
|
+
async def _poll_loop(self) -> None:
|
|
503
|
+
"""Edge mode: read the configured points off the RTU bus on an interval, unit by
|
|
504
|
+
unit, and publish the decoded values (to <base_topic>/<point name>, or as Sparkplug
|
|
505
|
+
B); see _poll_unit."""
|
|
506
|
+
poll = self.mapping.modbus_poll
|
|
507
|
+
while not self._stop.is_set():
|
|
508
|
+
lost = [] # units that went offline in this cycle because the port is gone
|
|
509
|
+
failed = "" # the cycle's publishing failure (logged once, not every interval)
|
|
510
|
+
for u in list(self._units.values()):
|
|
511
|
+
if self._stop.is_set():
|
|
512
|
+
break
|
|
513
|
+
try:
|
|
514
|
+
if await self._poll_unit(u) == "port down":
|
|
515
|
+
lost.append(u.unit)
|
|
516
|
+
except Exception as e: # nor a value the publisher can't take
|
|
517
|
+
failed = failed or f"poll: publishing failed: {e!r}"
|
|
518
|
+
self._report_units()
|
|
519
|
+
if failed and failed != self._publish_error:
|
|
520
|
+
self._log(failed)
|
|
521
|
+
self._publish_error = failed
|
|
522
|
+
if lost:
|
|
523
|
+
self._log(f"{_units_text(lost)}: offline, the serial port has been down for "
|
|
524
|
+
f"{UNIT_OFFLINE_AFTER} polls")
|
|
525
|
+
try:
|
|
526
|
+
await asyncio.wait_for(self._stop.wait(), timeout=poll.interval_s)
|
|
527
|
+
break
|
|
528
|
+
except asyncio.TimeoutError:
|
|
529
|
+
pass
|
|
530
|
+
|
|
531
|
+
async def _poll_unit(self, u: _Unit) -> Optional[str]:
|
|
532
|
+
"""Read one unit's points and publish the values. A unit that gave no reply to any of
|
|
533
|
+
them in UNIT_OFFLINE_AFTER cycles in a row, or whose port stayed down that long, goes
|
|
534
|
+
offline: a Sparkplug B node sends its DDEATH and drops its values, and from then on
|
|
535
|
+
each cycle reads one of its points (each in turn), so a dead unit holds the bus for
|
|
536
|
+
one response timeout per cycle, not one per point. When it answers, its other points
|
|
537
|
+
are read too and it is online again (Sparkplug B: a DBIRTH with those values). A slave
|
|
538
|
+
exception is an answer: the unit is there. Only changes of state are logged; a point
|
|
539
|
+
that keeps failing on a unit that answers is logged when it starts failing (at most
|
|
540
|
+
once a minute) and when it reads again. Returns the cause if the unit went offline
|
|
541
|
+
in this cycle."""
|
|
542
|
+
if self._link.down: # nothing to read (the link logs the outage)
|
|
543
|
+
return self._unit_missed(u, "port down")
|
|
544
|
+
# what is read here is published once all of the unit's points are read; a write in
|
|
545
|
+
# between drops the value of its point that was read before it (write_point)
|
|
546
|
+
reads = self._cycle = _Reads()
|
|
547
|
+
try:
|
|
548
|
+
if u.state == "offline":
|
|
549
|
+
probe = u.points[u.probe % len(u.points)]
|
|
550
|
+
u.probe += 1
|
|
551
|
+
await self._read_points([probe], reads)
|
|
552
|
+
if reads.answered:
|
|
553
|
+
await self._read_unit(u, reads, done=probe)
|
|
554
|
+
else:
|
|
555
|
+
await self._read_unit(u, reads)
|
|
556
|
+
finally:
|
|
557
|
+
self._cycle = None
|
|
558
|
+
u.errors += len(reads.failed)
|
|
559
|
+
self._remember(reads.values, reads.stamps, reads.failed)
|
|
560
|
+
self._forget([p.name for p, _e in reads.failed])
|
|
561
|
+
if not reads.answered:
|
|
562
|
+
return self._unit_missed(u, "port down" if self._link.down else "no reply")
|
|
563
|
+
back = u.state == "offline"
|
|
564
|
+
u.misses, u.miss_cause = 0, ""
|
|
565
|
+
if u.state != "online":
|
|
566
|
+
if back:
|
|
567
|
+
self._log(f"unit {u.unit} answers again (offline for "
|
|
568
|
+
f"{fmt_duration(time.time() - u.since)})")
|
|
569
|
+
u.state, u.cause, u.since = "online", "", time.time()
|
|
570
|
+
self._unit_state_changed(u)
|
|
571
|
+
for p, e in reads.failed:
|
|
572
|
+
if p.name not in self._failing:
|
|
573
|
+
self._failing[p.name] = self._note(f"poll {p.name}", p.unit,
|
|
574
|
+
f"poll '{p.name}' (unit {p.unit}): {e}")
|
|
575
|
+
for name in reads.values:
|
|
576
|
+
if self._failing.pop(name, False):
|
|
577
|
+
self._log(f"poll '{name}' reads again")
|
|
578
|
+
self._publish_bad([p.name for p, _e in reads.failed])
|
|
579
|
+
if back and self._sparkplug is not None:
|
|
580
|
+
self._sparkplug.device_online(u.unit, reads.values, reads.stamps)
|
|
581
|
+
elif reads.values:
|
|
582
|
+
self._publish_values(reads.values, reads.stamps)
|
|
583
|
+
return None
|
|
584
|
+
|
|
585
|
+
async def _read_unit(self, u: _Unit, reads: _Reads, done=None) -> None:
|
|
586
|
+
"""Read ``u``'s points (but ``done``, read already): one request per point, or with
|
|
587
|
+
block reads one per block of neighbouring points (a block holding ``done`` is read
|
|
588
|
+
all the same)."""
|
|
589
|
+
if u.blocks is None:
|
|
590
|
+
await self._read_points([p for p in u.points if p is not done], reads)
|
|
591
|
+
return
|
|
592
|
+
for b in u.blocks:
|
|
593
|
+
if self._stop.is_set():
|
|
594
|
+
break
|
|
595
|
+
if b.single or len(b.points) == 1:
|
|
596
|
+
await self._read_points([p for p in b.points if p is not done], reads)
|
|
597
|
+
else:
|
|
598
|
+
await self._read_block(u, b, reads)
|
|
599
|
+
|
|
600
|
+
async def _read_block(self, u: _Unit, b: _Block, reads: _Reads) -> None:
|
|
601
|
+
"""One request for the points of block ``b``. No reply is no reply for each of them
|
|
602
|
+
(the unit's health counts it as for single reads); an exception 02 or 03 (an address
|
|
603
|
+
or a quantity the slave does not take) makes the block single from now on, and its
|
|
604
|
+
points are read one at a time right away; another exception fails them all."""
|
|
605
|
+
bits = b.fn in (modbus.READ_COILS, modbus.READ_DISCRETE_INPUTS)
|
|
606
|
+
pdu = (modbus.read_bits_pdu(b.fn, b.start, b.count) if bits
|
|
607
|
+
else modbus.read_pdu(b.fn, b.start, b.count))
|
|
608
|
+
rpdu = await self._transaction(u.unit, pdu)
|
|
609
|
+
try:
|
|
610
|
+
self._check_answered(rpdu, b.points[0])
|
|
611
|
+
data = modbus.response_data(rpdu, b.fn)
|
|
612
|
+
states = modbus.decode_bits(data, b.count) if bits else []
|
|
613
|
+
except NoReply as e:
|
|
614
|
+
reads.failed += [(p, e) for p in b.points]
|
|
615
|
+
return
|
|
616
|
+
except ValueError as e: # an answer, but not the data asked for
|
|
617
|
+
reads.answered = True
|
|
618
|
+
code = rpdu[1] if len(rpdu) == 2 and rpdu[0] & 0x80 else None
|
|
619
|
+
if code not in (modbus.ILLEGAL_DATA_ADDRESS, modbus.ILLEGAL_DATA_VALUE):
|
|
620
|
+
reads.failed += [(p, e) for p in b.points]
|
|
621
|
+
return
|
|
622
|
+
b.single = True
|
|
623
|
+
what = "coils or inputs" if bits else "registers"
|
|
624
|
+
self._log(f"unit {u.unit}: block read of {b.count} {what} from {b.start} (FC{b.fn}) "
|
|
625
|
+
f"refused ({e}); its {len(b.points)} points are read one at a time "
|
|
626
|
+
"from now on")
|
|
627
|
+
await self._read_points(b.points, reads)
|
|
628
|
+
return
|
|
629
|
+
stamp = _now_ms()
|
|
630
|
+
reads.answered = True
|
|
631
|
+
for p in b.points:
|
|
632
|
+
try:
|
|
633
|
+
reads.values[p.name] = (states[p.address - b.start] if bits
|
|
634
|
+
else decode_point(p, data, p.address - b.start))
|
|
635
|
+
reads.stamps[p.name] = stamp
|
|
636
|
+
except Exception as e: # (one bad point must not stop the others)
|
|
637
|
+
reads.failed.append((p, e))
|
|
638
|
+
|
|
639
|
+
async def _read_points(self, points, reads: _Reads) -> None:
|
|
640
|
+
for p in points:
|
|
641
|
+
if self._stop.is_set():
|
|
642
|
+
break
|
|
643
|
+
try:
|
|
644
|
+
reads.values[p.name] = await self._read_point(p)
|
|
645
|
+
reads.stamps[p.name] = _now_ms()
|
|
646
|
+
reads.answered = True
|
|
647
|
+
except NoReply as e:
|
|
648
|
+
reads.failed.append((p, e))
|
|
649
|
+
except Exception as e: # an answer, but not a value; one bad point must not stop the loop
|
|
650
|
+
reads.failed.append((p, e))
|
|
651
|
+
reads.answered = True
|
|
652
|
+
|
|
653
|
+
def _remember(self, values: dict, stamps: dict, failed=()) -> None:
|
|
654
|
+
"""The points' last reads, for the status: the values read (and when), and why the
|
|
655
|
+
failed ones failed (their last value stays)."""
|
|
656
|
+
for name, v in values.items():
|
|
657
|
+
if (last := self._last.get(name)) is not None:
|
|
658
|
+
last.update(value=_json_value(v), ts=stamps.get(name), error="")
|
|
659
|
+
for p, e in failed:
|
|
660
|
+
if (last := self._last.get(p.name)) is not None:
|
|
661
|
+
last["error"] = str(e) or type(e).__name__
|
|
662
|
+
|
|
663
|
+
def _report_units(self) -> None:
|
|
664
|
+
self.status.units = [u.as_dict() for u in self._units.values()]
|
|
665
|
+
self.status.points = list(self._last.values())
|
|
666
|
+
|
|
667
|
+
async def read_now(self, points) -> list[tuple[Any, Any, str]]:
|
|
668
|
+
"""The mapping form's test read: ``points`` (as typed in the form, maybe not saved)
|
|
669
|
+
read once now, each with a request of its own that takes its turn on the bus with
|
|
670
|
+
the poll, the writes and the masters; nothing is published. At most READ_NOW_MAX
|
|
671
|
+
points, for at most READ_NOW_S. Returns (point, value, error) for each."""
|
|
672
|
+
out: list[tuple[Any, Any, str]] = []
|
|
673
|
+
end = time.monotonic() + READ_NOW_S
|
|
674
|
+
for p in points[:READ_NOW_MAX]:
|
|
675
|
+
if p.unit == modbus.BROADCAST or not _polled(p):
|
|
676
|
+
out.append((p, None, "not read: unit 0 (broadcast) or write-only"))
|
|
677
|
+
elif time.monotonic() > end:
|
|
678
|
+
out.append((p, None, f"not read: the test read stops after {READ_NOW_S:g} s"))
|
|
679
|
+
else:
|
|
680
|
+
try:
|
|
681
|
+
out.append((p, await self._read_point(p), ""))
|
|
682
|
+
except Exception as e: # (shown next to the point)
|
|
683
|
+
out.append((p, None, str(e) or type(e).__name__))
|
|
684
|
+
return out
|
|
685
|
+
|
|
686
|
+
def _unit_state_changed(self, u: _Unit) -> None:
|
|
687
|
+
if self._sparkplug is None and self.mapping.mqtt.unit_status:
|
|
688
|
+
self._publish_unit_state(u)
|
|
689
|
+
|
|
690
|
+
def _publish_unit_state(self, u: _Unit) -> None:
|
|
691
|
+
"""Plain MQTT with unit status on: <base_topic>/unit<N>/status, retained."""
|
|
692
|
+
if self._mqtt is not None and u.state != "unknown":
|
|
693
|
+
base = self.mapping.mqtt.base_topic.rstrip("/")
|
|
694
|
+
self._mqtt.publish_raw(f"{base}/unit{u.unit}/status", u.state.encode(), retain=True)
|
|
695
|
+
|
|
696
|
+
def _publish_unit_states(self) -> None:
|
|
697
|
+
"""After each (re)connect: the units' states again (a change while the broker was
|
|
698
|
+
away was not delivered)."""
|
|
699
|
+
for u in self._units.values():
|
|
700
|
+
self._publish_unit_state(u)
|
|
701
|
+
|
|
702
|
+
def _unit_missed(self, u: _Unit, cause: str) -> Optional[str]:
|
|
703
|
+
"""A poll cycle in which ``u`` gave no reply, because of ``cause``; it goes offline
|
|
704
|
+
after UNIT_OFFLINE_AFTER such cycles in a row. A unit that already stopped answering
|
|
705
|
+
stays offline for that reason while the port is down."""
|
|
706
|
+
if cause != u.miss_cause:
|
|
707
|
+
u.misses, u.miss_cause = 0, cause
|
|
708
|
+
u.misses += 1
|
|
709
|
+
if u.misses < UNIT_OFFLINE_AFTER or (u.state == "offline"
|
|
710
|
+
and (u.cause == cause or cause == "port down")):
|
|
711
|
+
return None
|
|
712
|
+
went = u.state != "offline"
|
|
713
|
+
u.state, u.cause = "offline", cause
|
|
714
|
+
if went:
|
|
715
|
+
u.since = time.time()
|
|
716
|
+
if self._sparkplug is not None:
|
|
717
|
+
self._sparkplug.device_offline(u.unit)
|
|
718
|
+
self._publish_bad([p.name for p in u.points])
|
|
719
|
+
self._unit_state_changed(u)
|
|
720
|
+
if cause == "no reply":
|
|
721
|
+
n = len(u.points)
|
|
722
|
+
self._log(f"unit {u.unit}: offline, no reply to "
|
|
723
|
+
f"{'its point' if n == 1 else f'any of its {n} points'} in "
|
|
724
|
+
f"{UNIT_OFFLINE_AFTER} polls in a row; polling one point per cycle until "
|
|
725
|
+
"it answers")
|
|
726
|
+
return cause if went else None
|
|
727
|
+
|
|
728
|
+
def _forget(self, names: list) -> None:
|
|
729
|
+
"""Points whose read failed: their last value is not reported as current again."""
|
|
730
|
+
if names and self._sparkplug is not None:
|
|
731
|
+
self._sparkplug.forget(names)
|
|
732
|
+
|
|
733
|
+
# ---------------- writes from MQTT (plain <topic>/<point>/set, Sparkplug DCMD) ----
|
|
734
|
+
async def write_point(self, point, value, job: Optional[_Write] = None,
|
|
735
|
+
source: str = "") -> Any:
|
|
736
|
+
"""Write one value to one writable point, and nothing beyond it: a coil takes a
|
|
737
|
+
bool (FC5), a holding register a number, encoded as the point's own type (FC6 for
|
|
738
|
+
a 16-bit type, FC16 for the two registers of a 32-bit one). Check the slave's
|
|
739
|
+
reply, then read the point back and publish it; returns that value (None if it was
|
|
740
|
+
not read back). A point on unit 0 is a broadcast, carried out by every slave and
|
|
741
|
+
answered by none, so it is not read back, nor is a write-only point ("poll":
|
|
742
|
+
false). A read-only mapping writes nothing, nor does a value outside the point's
|
|
743
|
+
min / max / allowed (checked here, at write time, for every command path). The
|
|
744
|
+
mapping's log names the write and where it came from (`source`, else the job's)."""
|
|
745
|
+
if self.mapping.network.read_only:
|
|
746
|
+
raise ValueError("mapping is read-only")
|
|
747
|
+
if not point.writable:
|
|
748
|
+
raise ValueError(f"point {point.name!r} is not writable")
|
|
749
|
+
if point.fn == modbus.READ_COILS:
|
|
750
|
+
# only a bool: never a list (several coils), a number (a Sparkplug Int 2) or
|
|
751
|
+
# text (a Sparkplug String "false")
|
|
752
|
+
if not isinstance(value, bool):
|
|
753
|
+
raise ValueError(f"{point.name!r} is a coil: it takes true or false, "
|
|
754
|
+
f"not {shown(value)}")
|
|
755
|
+
check_limits(point, value)
|
|
756
|
+
pdu = modbus.write_single_coil_pdu(point.address, value)
|
|
757
|
+
raw = ""
|
|
758
|
+
else:
|
|
759
|
+
if point.dtype not in modbus.DTYPES: # (a stored point the edits refuse)
|
|
760
|
+
what = "a bit of a register" if point.dtype == "bool" else shown(point.dtype)
|
|
761
|
+
raise ValueError(f"{point.name!r} is {what}: MQTT can't write it")
|
|
762
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
763
|
+
raise ValueError(f"{point.name!r} takes a number, not {shown(value)}")
|
|
764
|
+
check_limits(point, value)
|
|
765
|
+
regs = modbus.encode_value(value, point.dtype, point_scale(point) or 1.0,
|
|
766
|
+
point_order(point))
|
|
767
|
+
pdu = (modbus.write_single_register_pdu(point.address, regs[0]) if len(regs) == 1
|
|
768
|
+
else modbus.write_multiple_registers_pdu(point.address, regs))
|
|
769
|
+
raw = _raw_text(value, regs, point)
|
|
770
|
+
rpdu = await self._transaction(point.unit, pdu, job)
|
|
771
|
+
self._check_answered(rpdu, point) # (NoReply: the unit did not answer)
|
|
772
|
+
modbus.check_write_response(rpdu, pdu)
|
|
773
|
+
if job is not None:
|
|
774
|
+
job.stage = "written"
|
|
775
|
+
if self._cycle is not None: # a poll that read the point before the write is under way
|
|
776
|
+
self._cycle.drop(point.name)
|
|
777
|
+
source = source or (job.source if job is not None else "")
|
|
778
|
+
via = f", from {source}" if source else ""
|
|
779
|
+
if point.unit == modbus.BROADCAST:
|
|
780
|
+
self._log(f"wrote '{point.name}' = {value!r} (broadcast to unit 0, FC{pdu[0]}{raw}"
|
|
781
|
+
f"{via}; not read back)")
|
|
782
|
+
return None
|
|
783
|
+
if not _polled(point):
|
|
784
|
+
self._log(f"wrote '{point.name}' = {value!r} (unit {point.unit}, FC{pdu[0]}{raw}"
|
|
785
|
+
f"{via}; write-only, not read back)")
|
|
786
|
+
return None
|
|
787
|
+
self._log(f"wrote '{point.name}' = {value!r} (unit {point.unit}, FC{pdu[0]}{raw}{via})")
|
|
788
|
+
if self._closing: # the mapping is stopping: no read-back
|
|
789
|
+
return None
|
|
790
|
+
return await self._read_back(point)
|
|
791
|
+
|
|
792
|
+
async def _read_back(self, point) -> Any:
|
|
793
|
+
"""After a write: the point's value, read and published (Sparkplug B: in a DDATA
|
|
794
|
+
even if it did not change, so the host learns how the DCMD went); None if it can't
|
|
795
|
+
be read."""
|
|
796
|
+
try:
|
|
797
|
+
value = await self._read_point(point)
|
|
798
|
+
except Exception:
|
|
799
|
+
return None
|
|
800
|
+
stamps = {point.name: _now_ms()}
|
|
801
|
+
self._remember({point.name: value}, stamps)
|
|
802
|
+
self._publish_values({point.name: value}, stamps, force=True)
|
|
803
|
+
return value
|
|
804
|
+
|
|
805
|
+
def _write_soon(self, point, value, done=None, refused: Optional[str] = None,
|
|
806
|
+
source: str = "") -> None:
|
|
807
|
+
"""Queue a write from MQTT (called on the loop): the writes are made one at a time,
|
|
808
|
+
in the order they came (see WRITE_QUEUE_MAX). ``done`` gets (None or the error,
|
|
809
|
+
the value read back or None) once it is over; a write ``refused`` when it came (the
|
|
810
|
+
error) is answered in its turn as well, so the answers keep the commands' order."""
|
|
811
|
+
w = _Write(point, value, done, refused, source=source)
|
|
812
|
+
if self._closing:
|
|
813
|
+
self._note("write stopping", point.unit, f"write to '{point.name}' refused: the "
|
|
814
|
+
"mapping is stopping")
|
|
815
|
+
self._finish(w, "mapping stopped")
|
|
816
|
+
return
|
|
817
|
+
if len(self._writes) >= WRITE_QUEUE_MAX:
|
|
818
|
+
self._note("write busy", point.unit, f"write to '{point.name}' refused: "
|
|
819
|
+
f"{WRITE_QUEUE_MAX} writes are waiting for the bus already (busy)")
|
|
820
|
+
self._finish(w, "busy")
|
|
821
|
+
return
|
|
822
|
+
self._writes.append(w)
|
|
823
|
+
if self._writer is None or self._writer.done():
|
|
824
|
+
self._writer = asyncio.create_task(self._run_writes(),
|
|
825
|
+
name=f"modbus-write:{self.mapping.name}")
|
|
826
|
+
|
|
827
|
+
async def _run_writes(self) -> None:
|
|
828
|
+
"""The mapping's writer: the queued writes, one at a time, in the order they came
|
|
829
|
+
(until the mapping stops: _end_writes answers the rest)."""
|
|
830
|
+
while self._writes and not self._closing:
|
|
831
|
+
w = self._writes.popleft()
|
|
832
|
+
try:
|
|
833
|
+
err, value = await self._run_write(w)
|
|
834
|
+
except asyncio.CancelledError: # stop() did not wait any longer for it
|
|
835
|
+
if w.stage == "written": # (only its read-back was cut short)
|
|
836
|
+
self._finish(w, None)
|
|
837
|
+
raise
|
|
838
|
+
outcome = "sent, outcome unknown" if w.stage == "sent" else "not sent"
|
|
839
|
+
self._log(f"write '{w.point.name}' = {shown(w.value)} cancelled (mapping "
|
|
840
|
+
f"stopped): {outcome}")
|
|
841
|
+
self._finish(w, f"cancelled: mapping stopped ({outcome})")
|
|
842
|
+
raise
|
|
843
|
+
self._finish(w, err, value)
|
|
844
|
+
|
|
845
|
+
async def _end_writes(self) -> None:
|
|
846
|
+
"""stop(): the write on the bus may finish (for as long as its reply can take, at
|
|
847
|
+
most WRITE_STOP_WAIT_S), else it is cancelled, and its answer says whether its
|
|
848
|
+
request had gone out; then the writes still waiting are answered "cancelled:
|
|
849
|
+
mapping stopped" (none of them was sent). In that order, so the answers keep the
|
|
850
|
+
order of the commands."""
|
|
851
|
+
task, self._writer = self._writer, None
|
|
852
|
+
if task is not None and not task.done():
|
|
853
|
+
await asyncio.wait({task}, timeout=min(self._resp_timeout + GUARD_S,
|
|
854
|
+
WRITE_STOP_WAIT_S))
|
|
855
|
+
task.cancel()
|
|
856
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
857
|
+
await task
|
|
858
|
+
waiting, self._writes = list(self._writes), deque()
|
|
859
|
+
for w in waiting:
|
|
860
|
+
self._finish(w, w.refused or "cancelled: mapping stopped")
|
|
861
|
+
dropped = [w.point.name for w in waiting if w.refused is None]
|
|
862
|
+
if dropped:
|
|
863
|
+
more = f" and {len(dropped) - 5} more" if len(dropped) > 5 else ""
|
|
864
|
+
self._log(f"{len(dropped)} MQTT write(s) cancelled before they were sent, the "
|
|
865
|
+
f"mapping stopped: {_names(dropped[:5])}{more}")
|
|
866
|
+
|
|
867
|
+
async def _run_write(self, w: _Write) -> tuple[Optional[str], Any]:
|
|
868
|
+
if w.refused is not None:
|
|
869
|
+
await self._report(w.point)
|
|
870
|
+
return w.refused, None
|
|
871
|
+
waited = time.monotonic() - w.queued
|
|
872
|
+
if waited > WRITE_EXPIRE_S:
|
|
873
|
+
self._note("write expired", w.point.unit, f"write '{w.point.name}' = "
|
|
874
|
+
f"{shown(w.value)} not made: it waited {waited:.1f} s for the bus "
|
|
875
|
+
"(expired)")
|
|
876
|
+
return f"expired: waited {waited:.1f} s for the bus", None
|
|
877
|
+
try:
|
|
878
|
+
return None, await self.write_point(w.point, w.value, w, source=w.source)
|
|
879
|
+
except Exception as e:
|
|
880
|
+
err = str(e) or type(e).__name__
|
|
881
|
+
self._log(f"write '{w.point.name}' = {shown(w.value)} failed: {err}")
|
|
882
|
+
if not isinstance(e, NoReply):
|
|
883
|
+
await self._report(w.point)
|
|
884
|
+
return err, None
|
|
885
|
+
|
|
886
|
+
async def _report(self, point) -> None:
|
|
887
|
+
"""Sparkplug B, a DCMD that wrote nothing (refused, or the slave's exception): the
|
|
888
|
+
point's current value in a DDATA all the same, as after a write, so the host sees
|
|
889
|
+
it was not taken. Not for a unit that did not answer (it goes offline: DDEATH)."""
|
|
890
|
+
if (self._sparkplug is not None and not self._closing
|
|
891
|
+
and point.unit != modbus.BROADCAST and _polled(point)):
|
|
892
|
+
await self._read_back(point)
|
|
893
|
+
|
|
894
|
+
def _finish(self, w: _Write, err: Optional[str], value: Any = None) -> None:
|
|
895
|
+
if w.done is not None:
|
|
896
|
+
try:
|
|
897
|
+
w.done(err, value)
|
|
898
|
+
except Exception as e: # (an answer that can't be sent must not stop the writer)
|
|
899
|
+
self._log(f"write '{w.point.name}': the answer was not sent: {e}")
|
|
900
|
+
|
|
901
|
+
def _on_set_message(self, topic: str, payload: bytes) -> None:
|
|
902
|
+
"""Plain MQTT: a value for one writable point on its <base_topic>/<point>/set; the
|
|
903
|
+
outcome goes to .../set/result (ok, or error: …), at QoS 1 or the mapping's QoS. A
|
|
904
|
+
JSON request {"value": …, "id": …} gets a JSON answer {"id": …, "ok": true,
|
|
905
|
+
"value": <read back>} / {"id": …, "ok": false, "error": "…"} (see
|
|
906
|
+
parse_write_request)."""
|
|
907
|
+
point = self._set_points.get(topic)
|
|
908
|
+
if point is None or self._mqtt is None:
|
|
909
|
+
return
|
|
910
|
+
mqtt, result = self._mqtt, topic + "/result"
|
|
911
|
+
# at QoS 1 at least, as the /set subscription: paho keeps it over a reconnect
|
|
912
|
+
qos = max(self.mapping.mqtt.qos if isinstance(self.mapping.mqtt.qos, int) else 0, 1)
|
|
913
|
+
request: Optional[dict] = {} if payload[:64].lstrip()[:1] == b"{" else None
|
|
914
|
+
|
|
915
|
+
def answer(err: Optional[str], value: Any = None) -> None:
|
|
916
|
+
if request is None: # plain text: ok, or error: …
|
|
917
|
+
out = b"ok" if err is None else f"error: {err}".encode()
|
|
918
|
+
else: # a JSON request: its id (if it had one), and the value read back
|
|
919
|
+
reply = dict(request, ok=err is None)
|
|
920
|
+
if err is None:
|
|
921
|
+
reply["value"] = _json_value(value)
|
|
922
|
+
else:
|
|
923
|
+
reply["error"] = err
|
|
924
|
+
out = json.dumps(reply, separators=(",", ":")).encode()
|
|
925
|
+
mqtt.publish_raw(result, out, qos=qos)
|
|
926
|
+
if len(payload) > SET_MAX_BYTES:
|
|
927
|
+
too_large = f"too large: {len(payload)} bytes (at most {SET_MAX_BYTES})"
|
|
928
|
+
self._note("write too large", point.unit, f"write '{point.name}' from MQTT "
|
|
929
|
+
f"refused: {too_large}")
|
|
930
|
+
self._write_soon(point, None, answer, refused=too_large)
|
|
931
|
+
return
|
|
932
|
+
try:
|
|
933
|
+
if request is None:
|
|
934
|
+
value = parse_write_payload(payload, point)
|
|
935
|
+
else:
|
|
936
|
+
value = parse_write_request(payload, point, request)
|
|
937
|
+
except ValueError as e:
|
|
938
|
+
self._write_soon(point, None, answer, refused=str(e))
|
|
939
|
+
return
|
|
940
|
+
if self.mapping.network.read_only:
|
|
941
|
+
self._note("write read-only", point.unit,
|
|
942
|
+
f"write '{point.name}' from MQTT refused: the mapping is read-only")
|
|
943
|
+
self._write_soon(point, None, answer, refused="mapping is read-only")
|
|
944
|
+
return
|
|
945
|
+
self._write_soon(point, value, answer, source=f"MQTT {topic}")
|
|
946
|
+
|
|
947
|
+
# ---------------- reading the RTU line ----------------
|
|
948
|
+
async def _settle(self, reader) -> None:
|
|
949
|
+
"""Before a request goes out: drop what the line holds, bytes no request is waiting
|
|
950
|
+
for. After a request that got no reply in time (or bytes after its reply), first
|
|
951
|
+
make sure the line has been quiet for the guard time since then, so that a reply
|
|
952
|
+
that comes late is dropped here and not taken for this request's (a read's reply
|
|
953
|
+
carries no address), and say which slave answered late. A line that never goes
|
|
954
|
+
quiet holds the request back by at most a whole frame's time more."""
|
|
955
|
+
loop = asyncio.get_running_loop()
|
|
956
|
+
since, self._dirty = self._dirty, None
|
|
957
|
+
late, self._unanswered = self._unanswered, None
|
|
958
|
+
guard = 0.0 if since is None else max(min(GUARD_S, self._resp_timeout),
|
|
959
|
+
3.5 * self._char_s)
|
|
960
|
+
last = loop.time() if since is None else since # the last sign of life on the line
|
|
961
|
+
end = loop.time() + guard + modbus.MAX_ADU * self._char_s
|
|
962
|
+
got = bytearray()
|
|
963
|
+
while (left := end - loop.time()) > 0:
|
|
964
|
+
wait = max(0.001, last + guard - loop.time()) # 1 ms: take what is buffered
|
|
965
|
+
try:
|
|
966
|
+
chunk = await asyncio.wait_for(reader.read(READ_CHUNK), timeout=min(wait, left))
|
|
967
|
+
except asyncio.TimeoutError:
|
|
968
|
+
break
|
|
969
|
+
if not chunk:
|
|
970
|
+
raise ConnectionError("serial EOF")
|
|
971
|
+
got += chunk
|
|
972
|
+
last = loop.time()
|
|
973
|
+
if not got:
|
|
974
|
+
return
|
|
975
|
+
self._dropped(got)
|
|
976
|
+
if late is not None and modbus.find_reply(got, *late, quiet=True).span is not None:
|
|
977
|
+
unit, request = late
|
|
978
|
+
self._note("late", unit, f"unit {unit} answered FC{request[0]} after the response "
|
|
979
|
+
f"timeout ({self._resp_timeout:g} s); the late reply was dropped. Raise "
|
|
980
|
+
"the mapping's response timeout if the slave needs longer")
|
|
981
|
+
else:
|
|
982
|
+
self._note("stray", -1, f"dropped {len(got)} byte(s) on the serial line that no "
|
|
983
|
+
"request was waiting for")
|
|
984
|
+
|
|
985
|
+
async def _read_reply(self, reader, unit: int, request: bytes, sent: float) -> Optional[bytes]:
|
|
986
|
+
"""The PDU of the reply from ``unit`` to ``request``, which has gone out on the line
|
|
987
|
+
at ``sent`` (loop time), or None if none came in time.
|
|
988
|
+
|
|
989
|
+
The reply has to begin within the response timeout and may then take as long as a
|
|
990
|
+
whole frame needs. It is complete as soon as it is as long as its header says (or
|
|
991
|
+
when the line goes quiet, for a reply whose header does not say), not after a fixed
|
|
992
|
+
pause, and has to answer the request (function, byte count, echo): see
|
|
993
|
+
modbus.find_reply. Bytes around it, and frames that answer something else, are
|
|
994
|
+
dropped; the next request then waits for a quiet line first (_settle)."""
|
|
995
|
+
loop = asyncio.get_running_loop()
|
|
996
|
+
deadline = sent + self._resp_timeout
|
|
997
|
+
finish = deadline + modbus.MAX_ADU * self._char_s + self._gap
|
|
998
|
+
buf = bytearray()
|
|
999
|
+
dropped = 0 # bytes cut off the front of buf (no reply is that long)
|
|
1000
|
+
quiet = False
|
|
1001
|
+
while True:
|
|
1002
|
+
scan = modbus.find_reply(buf, unit, request, quiet=quiet)
|
|
1003
|
+
if scan.span is not None:
|
|
1004
|
+
start, end = scan.span
|
|
1005
|
+
self._dropped(buf[:start])
|
|
1006
|
+
self._delivered(bytes(buf[start:end]))
|
|
1007
|
+
self._dropped(buf[end:])
|
|
1008
|
+
if dropped or end < len(buf):
|
|
1009
|
+
# bytes after the reply: more may be on its way (bytes only in front of
|
|
1010
|
+
# it, such as an adapter's echo of the request, are no reason to wait)
|
|
1011
|
+
self._dirty = loop.time()
|
|
1012
|
+
return bytes(buf[start + 1:end - 2])
|
|
1013
|
+
left = (finish if scan.pending else deadline) - loop.time()
|
|
1014
|
+
if left <= 0:
|
|
1015
|
+
break
|
|
1016
|
+
if buf and not quiet:
|
|
1017
|
+
left = min(left, self._gap) # then try what came as a whole frame
|
|
1018
|
+
try:
|
|
1019
|
+
chunk = await asyncio.wait_for(reader.read(READ_CHUNK), timeout=left)
|
|
1020
|
+
except asyncio.TimeoutError:
|
|
1021
|
+
quiet = bool(buf)
|
|
1022
|
+
continue
|
|
1023
|
+
if not chunk:
|
|
1024
|
+
raise ConnectionError("serial EOF")
|
|
1025
|
+
buf += chunk
|
|
1026
|
+
quiet = False
|
|
1027
|
+
if len(buf) > 2 * modbus.MAX_ADU: # a reply can't begin before the last MAX_ADU
|
|
1028
|
+
self._dropped(buf[:-modbus.MAX_ADU])
|
|
1029
|
+
dropped += len(buf) - modbus.MAX_ADU
|
|
1030
|
+
del buf[:-modbus.MAX_ADU]
|
|
1031
|
+
self._dirty = loop.time()
|
|
1032
|
+
self._unanswered = (unit, request)
|
|
1033
|
+
if buf or dropped:
|
|
1034
|
+
self._dropped(buf)
|
|
1035
|
+
what = scan.rejected or ("a frame from it that is cut short or fails its CRC"
|
|
1036
|
+
if scan.started else "none of them answers it")
|
|
1037
|
+
self._note("bad", unit, f"unit {unit}: no usable reply to FC{request[0]} within "
|
|
1038
|
+
f"{self._resp_timeout:g} s; dropped {dropped + len(buf)} byte(s) "
|
|
1039
|
+
f"({what})")
|
|
1040
|
+
return None
|
|
1041
|
+
|
|
1042
|
+
async def _turnaround(self, reader, sent: float) -> None:
|
|
1043
|
+
"""After a broadcast, which no slave answers: keep the bus until the request is out
|
|
1044
|
+
(``sent``) and the slaves have had the turnaround time to carry it out. What comes
|
|
1045
|
+
meanwhile is dropped."""
|
|
1046
|
+
loop = asyncio.get_running_loop()
|
|
1047
|
+
end = sent + min(TURNAROUND_S, self._resp_timeout)
|
|
1048
|
+
got = bytearray()
|
|
1049
|
+
while (left := end - loop.time()) > 0:
|
|
1050
|
+
try:
|
|
1051
|
+
chunk = await asyncio.wait_for(reader.read(READ_CHUNK), timeout=left)
|
|
1052
|
+
except asyncio.TimeoutError:
|
|
1053
|
+
break
|
|
1054
|
+
if not chunk:
|
|
1055
|
+
raise ConnectionError("serial EOF")
|
|
1056
|
+
got += chunk
|
|
1057
|
+
if got:
|
|
1058
|
+
self._dropped(got)
|
|
1059
|
+
self._dirty = loop.time()
|
|
1060
|
+
self._note("stray", modbus.BROADCAST, f"dropped {len(got)} byte(s) that came after "
|
|
1061
|
+
"a broadcast to unit 0, which no slave may answer")
|
|
1062
|
+
|
|
1063
|
+
def _delivered(self, frame: bytes) -> None:
|
|
1064
|
+
self.status.bytes_in += len(frame)
|
|
1065
|
+
self._feed_monitors(b"\xaa rx " + frame)
|
|
1066
|
+
if self._tracer:
|
|
1067
|
+
self._tracer.write("ser>net", frame)
|
|
1068
|
+
self._feed_traffic(FROM_DEVICE, frame)
|
|
1069
|
+
|
|
1070
|
+
def _dropped(self, data) -> None:
|
|
1071
|
+
"""Bytes read off the line that are not a reply: counted, and shown in the trace
|
|
1072
|
+
(as ser>drop), the console and the hex view like any others."""
|
|
1073
|
+
if not data:
|
|
1074
|
+
return
|
|
1075
|
+
data = bytes(data)
|
|
1076
|
+
self.status.bytes_in += len(data)
|
|
1077
|
+
self._feed_monitors(b"\xaa rx (dropped) " + data)
|
|
1078
|
+
if self._tracer:
|
|
1079
|
+
self._tracer.write("ser>drop", data)
|
|
1080
|
+
self._feed_traffic(FROM_DEVICE, data)
|
|
1081
|
+
|
|
1082
|
+
def _note(self, kind: str, unit: int, message: str) -> bool:
|
|
1083
|
+
"""Log a note about the replies on the line, at most once per NOTE_EVERY_S for one
|
|
1084
|
+
kind and unit: a slave that keeps failing every request must not flood the log.
|
|
1085
|
+
Returns whether it was logged (or only counted)."""
|
|
1086
|
+
now = time.monotonic()
|
|
1087
|
+
last, held = self._notes.get((kind, unit), (None, 0))
|
|
1088
|
+
if last is not None and now - last < NOTE_EVERY_S:
|
|
1089
|
+
self._notes[(kind, unit)] = (last, held + 1)
|
|
1090
|
+
return False
|
|
1091
|
+
self._notes[(kind, unit)] = (now, 0)
|
|
1092
|
+
more = f" (and {held} more since the last such line)" if held else ""
|
|
1093
|
+
self._log(message + more)
|
|
1094
|
+
return True
|
|
1095
|
+
|
|
1096
|
+
# ---------------- network side ----------------
|
|
1097
|
+
def _log_policy(self) -> None:
|
|
1098
|
+
"""Log at start which of the mapping's options a Modbus gateway treats differently:
|
|
1099
|
+
the ones it does not use, and what read-only means here."""
|
|
1100
|
+
opts = self.mapping.options
|
|
1101
|
+
unused = [name for name, value in (("banner", opts.banner), ("openstr", opts.openstr),
|
|
1102
|
+
("closestr", opts.closestr), ("closeon", opts.closeon))
|
|
1103
|
+
if value]
|
|
1104
|
+
if unused:
|
|
1105
|
+
self._log(f"not used by a Modbus gateway: {', '.join(unused)} "
|
|
1106
|
+
"(bytes outside Modbus frames would break the framing)")
|
|
1107
|
+
if self.mapping.network.read_only:
|
|
1108
|
+
self._log("read-only: requests from Modbus/TCP masters that are not reads get "
|
|
1109
|
+
"exception 01 (illegal function) and never reach the bus; MQTT/Sparkplug "
|
|
1110
|
+
"writes to writable points are refused too")
|
|
1111
|
+
umap = self.mapping.options.modbus_unit_map
|
|
1112
|
+
if self._unit_map:
|
|
1113
|
+
self._log("unit map: requests from masters for " + ", ".join(
|
|
1114
|
+
f"unit {sent} go to unit {unit}" for sent, unit in self._unit_map.items()))
|
|
1115
|
+
if not isinstance(umap, dict) or len(umap) != len(self._unit_map):
|
|
1116
|
+
self._log(f"warning: unit map {shown(umap)}: entries that are not a unit id 0-255 "
|
|
1117
|
+
"to a unit 1-255 are ignored")
|
|
1118
|
+
poll = self.mapping.modbus_poll
|
|
1119
|
+
if poll.broadcast_points:
|
|
1120
|
+
self._log(f"warning: poll point(s) {_names(poll.broadcast_points)} on unit 0, the "
|
|
1121
|
+
"broadcast address: never read (no slave answers a broadcast); a write "
|
|
1122
|
+
"to one goes to every slave on the line and is not read back")
|
|
1123
|
+
if poll.reserved_unit_points:
|
|
1124
|
+
self._log(f"warning: poll point(s) {_names(poll.reserved_unit_points)} on units "
|
|
1125
|
+
"248-255, which a Modbus serial line reserves (slave addresses are 1-247)")
|
|
1126
|
+
if self._block_gap is not None and self.mapping.mqtt.enabled:
|
|
1127
|
+
blocks = [b for u in self._units.values() for b in (u.blocks or [])]
|
|
1128
|
+
self._log(f"block reads: {sum(len(b.points) for b in blocks)} point(s) with "
|
|
1129
|
+
f"{len(blocks)} request(s) per poll (at most {self._block_gap} unused "
|
|
1130
|
+
"register(s) or coil(s) within a block)")
|
|
1131
|
+
for p in poll.points:
|
|
1132
|
+
unusable = [key for key in ("min", "max", "allowed")
|
|
1133
|
+
if getattr(p, key) is not None and _limit(p, key) is None]
|
|
1134
|
+
if p.writable and unusable:
|
|
1135
|
+
self._log(f"warning: point {p.name!r}: {' and '.join(unusable)} ignored (not a "
|
|
1136
|
+
"number, or for allowed not a list of values of the point's type)")
|
|
1137
|
+
if point_scale(p) is not p.scale:
|
|
1138
|
+
self._log(f"warning: point {p.name!r}: scale {shown(p.scale)} ignored (not a "
|
|
1139
|
+
"number): its values are not scaled")
|
|
1140
|
+
if point_order(p) != p.word_order:
|
|
1141
|
+
self._log(f"warning: point {p.name!r}: word_order {shown(p.word_order)} ignored "
|
|
1142
|
+
"(not ABCD, CDAB, BADC or DCBA): read as ABCD")
|
|
1143
|
+
mq = self.mapping.mqtt
|
|
1144
|
+
missing = [what for what, have in (("TLS", mq.tls), ("a username", mq.username))
|
|
1145
|
+
if not have]
|
|
1146
|
+
if self.mapping.accepts_mqtt_writes and missing:
|
|
1147
|
+
self._log("warning: writable points take MQTT commands over a broker connection "
|
|
1148
|
+
f"without {' or '.join(missing)}: any client the broker lets publish to "
|
|
1149
|
+
"the command topics can write them; restrict those topics with the "
|
|
1150
|
+
"broker's access rules (ACLs)")
|
|
1151
|
+
|
|
1152
|
+
def _refuse(self, master: _Master, pdu: bytes) -> bytes:
|
|
1153
|
+
"""Answer a request a read-only gateway does not forward: exception 01 (illegal
|
|
1154
|
+
function). The first one per master connection is logged, the rest are counted."""
|
|
1155
|
+
function = pdu[0] if pdu else 0
|
|
1156
|
+
master.refused += 1
|
|
1157
|
+
if master.refused == 1:
|
|
1158
|
+
self._log(f"read-only: refused FC{function} from {master.peer} with exception 01 "
|
|
1159
|
+
"(further refusals for this master are only counted)")
|
|
1160
|
+
return modbus.exception_pdu(function, modbus.ILLEGAL_FUNCTION)
|
|
1161
|
+
|
|
1162
|
+
def _refresh_clients(self) -> None:
|
|
1163
|
+
self.status.set_clients(self._clients)
|
|
1164
|
+
|
|
1165
|
+
async def _on_client(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
|
|
1166
|
+
if self._stop.is_set(): # accepted while the gateway stops
|
|
1167
|
+
writer.close()
|
|
1168
|
+
return
|
|
1169
|
+
peer_ip, peer_port = netguard.peer_address(writer)
|
|
1170
|
+
peer = netguard.client_label(peer_ip, peer_port)
|
|
1171
|
+
net = self.mapping.network
|
|
1172
|
+
|
|
1173
|
+
# allow-list, then max connections (a priority master / kick_old_user makes room)
|
|
1174
|
+
admission = self._access.admit(peer_ip, peer, self._clients, self._refusal_log)
|
|
1175
|
+
if not admission.admitted:
|
|
1176
|
+
with contextlib.suppress(Exception):
|
|
1177
|
+
writer.close()
|
|
1178
|
+
return
|
|
1179
|
+
|
|
1180
|
+
if err := sockopts.apply_keepalive(writer, net):
|
|
1181
|
+
self._log(f"TCP keepalive not enabled for {peer}: {err}")
|
|
1182
|
+
master = _Master(writer, peer, priority=admission.priority)
|
|
1183
|
+
self._clients.add(master)
|
|
1184
|
+
self._refresh_clients()
|
|
1185
|
+
tag = (" [priority]" if master.priority else "") + tls.client_certificate(writer)
|
|
1186
|
+
self._log(f"modbus master connected: {peer}{tag} ({len(self._clients)} total)")
|
|
1187
|
+
opts = self.mapping.options
|
|
1188
|
+
deadline = (time.monotonic() + opts.max_connection_time_s
|
|
1189
|
+
if opts.max_connection_time_s > 0 else None)
|
|
1190
|
+
reason = "closed by master"
|
|
1191
|
+
buf = bytearray()
|
|
1192
|
+
try:
|
|
1193
|
+
while not self._stop.is_set():
|
|
1194
|
+
try:
|
|
1195
|
+
data = await asyncio.wait_for(
|
|
1196
|
+
reader.read(READ_CHUNK), timeout=_next_timer(opts.idle_timeout_s, deadline))
|
|
1197
|
+
except asyncio.TimeoutError as e:
|
|
1198
|
+
if getattr(e, "errno", None) is not None:
|
|
1199
|
+
raise # a socket error (e.g. keepalive ETIMEDOUT), not our timer
|
|
1200
|
+
if deadline is not None and time.monotonic() >= deadline:
|
|
1201
|
+
reason = ("max connection time reached "
|
|
1202
|
+
f"({fmt_duration(opts.max_connection_time_s)})")
|
|
1203
|
+
else:
|
|
1204
|
+
reason = f"idle timeout ({fmt_duration(opts.idle_timeout_s)} without traffic)"
|
|
1205
|
+
break
|
|
1206
|
+
if not data:
|
|
1207
|
+
break
|
|
1208
|
+
buf += data
|
|
1209
|
+
while True:
|
|
1210
|
+
try:
|
|
1211
|
+
frame = modbus.take_tcp_adu(buf)
|
|
1212
|
+
except ValueError as e:
|
|
1213
|
+
self._log(f"bad Modbus/TCP framing from {peer}: {e}")
|
|
1214
|
+
reason = "bad Modbus/TCP framing"
|
|
1215
|
+
return
|
|
1216
|
+
if frame is None:
|
|
1217
|
+
break
|
|
1218
|
+
txn, unit, pdu = frame
|
|
1219
|
+
if net.read_only and not modbus.is_read_request(pdu):
|
|
1220
|
+
rpdu = self._refuse(master, pdu)
|
|
1221
|
+
else:
|
|
1222
|
+
rpdu = await self._transaction(self._unit_map.get(unit, unit), pdu)
|
|
1223
|
+
writer.write(modbus.build_tcp_adu(txn, unit, rpdu))
|
|
1224
|
+
await writer.drain()
|
|
1225
|
+
except (ConnectionError, asyncio.IncompleteReadError, OSError) as e:
|
|
1226
|
+
reason = describe_disconnect(e)
|
|
1227
|
+
finally:
|
|
1228
|
+
self._clients.discard(master)
|
|
1229
|
+
self._refresh_clients()
|
|
1230
|
+
with contextlib.suppress(Exception):
|
|
1231
|
+
writer.close()
|
|
1232
|
+
reason = master.close_reason or reason
|
|
1233
|
+
if self._stop.is_set():
|
|
1234
|
+
reason = "gateway stopped"
|
|
1235
|
+
held = fmt_duration(time.monotonic() - master.connected_at)
|
|
1236
|
+
refused = f", {master.refused} request(s) refused (read-only)" if master.refused else ""
|
|
1237
|
+
self._log(f"modbus master disconnected: {peer} after {held} — {reason}{refused} "
|
|
1238
|
+
f"({len(self._clients)} total)")
|
|
1239
|
+
|
|
1240
|
+
|
|
1241
|
+
def _now_ms() -> int:
|
|
1242
|
+
return int(time.time() * 1000)
|
|
1243
|
+
|
|
1244
|
+
|
|
1245
|
+
def encode_value(value, fmt: str, ts: int, good: bool = True) -> bytes:
|
|
1246
|
+
"""A polled value as a plain MQTT payload: text (1 / 0 for a bit; also for a value_format
|
|
1247
|
+
this version doesn't know), or for "json" {"value", "ts" (ms since the epoch),
|
|
1248
|
+
"quality"}: a bit is true / false, a NaN or infinite reading null; ``good`` False: the
|
|
1249
|
+
read failed (value null, quality "bad")."""
|
|
1250
|
+
if fmt != "json":
|
|
1251
|
+
return (("1" if value else "0") if isinstance(value, bool) else str(value)).encode()
|
|
1252
|
+
if not good or (isinstance(value, float) and not math.isfinite(value)):
|
|
1253
|
+
value = None
|
|
1254
|
+
return json.dumps({"value": value, "ts": ts, "quality": "good" if good else "bad"},
|
|
1255
|
+
separators=(",", ":")).encode()
|
|
1256
|
+
|
|
1257
|
+
|
|
1258
|
+
def _polled(point) -> bool:
|
|
1259
|
+
"""Whether a point is read (False: "poll": false, a write-only point)."""
|
|
1260
|
+
return point.poll is not False
|
|
1261
|
+
|
|
1262
|
+
|
|
1263
|
+
def _limit(point, key: str):
|
|
1264
|
+
"""A point's min / max / allowed as the gateway can use it, or None (none set, or a
|
|
1265
|
+
stored one that can't be used: the edits refuse such values, see
|
|
1266
|
+
ModbusPoll.validate_input)."""
|
|
1267
|
+
value = getattr(point, key)
|
|
1268
|
+
if key == "allowed":
|
|
1269
|
+
coil = point.fn in (modbus.READ_COILS, modbus.READ_DISCRETE_INPUTS)
|
|
1270
|
+
ok = isinstance(value, list) and value and all(
|
|
1271
|
+
isinstance(v, bool) if coil else _finite(v) for v in value)
|
|
1272
|
+
return value if ok else None
|
|
1273
|
+
return value if _finite(value) and point.fn not in (
|
|
1274
|
+
modbus.READ_COILS, modbus.READ_DISCRETE_INPUTS) else None
|
|
1275
|
+
|
|
1276
|
+
|
|
1277
|
+
def _finite(v) -> bool:
|
|
1278
|
+
return isinstance(v, (int, float)) and not isinstance(v, bool) and math.isfinite(v)
|
|
1279
|
+
|
|
1280
|
+
|
|
1281
|
+
def _num(v) -> str:
|
|
1282
|
+
return f"{v:g}" if isinstance(v, float) else str(v)
|
|
1283
|
+
|
|
1284
|
+
|
|
1285
|
+
def check_limits(point, value) -> None:
|
|
1286
|
+
"""A value MQTT writes to ``point``, against the point's allowed / min / max (in its own
|
|
1287
|
+
units, before the scale is undone). Raises ValueError."""
|
|
1288
|
+
allowed = _limit(point, "allowed")
|
|
1289
|
+
if allowed is not None and not any(
|
|
1290
|
+
(value is v) if isinstance(v, bool) else (not isinstance(value, bool) and value == v)
|
|
1291
|
+
for v in allowed):
|
|
1292
|
+
listed = ", ".join(_num(v) if not isinstance(v, bool) else str(v).lower()
|
|
1293
|
+
for v in allowed[:8]) + (", …" if len(allowed) > 8 else "")
|
|
1294
|
+
shown_value = str(value).lower() if isinstance(value, bool) else _num(value)
|
|
1295
|
+
raise ValueError(f"{shown_value} is not one of the values allowed for "
|
|
1296
|
+
f"{point.name!r} ({listed})")
|
|
1297
|
+
if isinstance(value, bool):
|
|
1298
|
+
return
|
|
1299
|
+
low, high = _limit(point, "min"), _limit(point, "max")
|
|
1300
|
+
if low is not None and high is not None and not low <= value <= high:
|
|
1301
|
+
raise ValueError(f"{_num(value)} is outside the range {_num(low)}..{_num(high)} of "
|
|
1302
|
+
f"{point.name!r}")
|
|
1303
|
+
if low is not None and value < low:
|
|
1304
|
+
raise ValueError(f"{_num(value)} is below the minimum {_num(low)} of {point.name!r}")
|
|
1305
|
+
if high is not None and value > high:
|
|
1306
|
+
raise ValueError(f"{_num(value)} is above the maximum {_num(high)} of {point.name!r}")
|
|
1307
|
+
|
|
1308
|
+
|
|
1309
|
+
def _raw_text(value, regs: list[int], point) -> str:
|
|
1310
|
+
"""For the log: the raw register value written, when it is not the value itself (an
|
|
1311
|
+
integer type rounds a fraction, a scale divides it)."""
|
|
1312
|
+
raw = modbus.decode_value(b"".join(r.to_bytes(2, "big") for r in regs), point.dtype,
|
|
1313
|
+
order=point_order(point))
|
|
1314
|
+
scale = point_scale(point)
|
|
1315
|
+
scaled = bool(scale) and scale != 1.0
|
|
1316
|
+
if not scaled and (point.dtype in ("float32", "float64") or raw == value):
|
|
1317
|
+
return ""
|
|
1318
|
+
return f", raw value {_num(raw)}"
|
|
1319
|
+
|
|
1320
|
+
|
|
1321
|
+
def _registers(p) -> int:
|
|
1322
|
+
"""How many registers a register point (fn 3, 4) reads: its string's length, one for a
|
|
1323
|
+
bit of a register, else its type's. ValueError for a stored type the edits refuse."""
|
|
1324
|
+
if p.dtype == "string":
|
|
1325
|
+
return p.length
|
|
1326
|
+
if p.dtype == "bool":
|
|
1327
|
+
return 1
|
|
1328
|
+
return modbus.dtype_registers(p.dtype)
|
|
1329
|
+
|
|
1330
|
+
|
|
1331
|
+
def plan_blocks(points, gap: int) -> list[_Block]:
|
|
1332
|
+
"""Block reads: a unit's (polled) points as the requests that read them — per function
|
|
1333
|
+
code, in address order, a point joins the block before it when at most ``gap`` unused
|
|
1334
|
+
registers (coils, inputs) lie between them and the block stays within one read (125
|
|
1335
|
+
registers, 2000 bits); points may overlap. A point whose address or size can't be used
|
|
1336
|
+
(a stored value the edits refuse) is a block of its own, read alone as before."""
|
|
1337
|
+
blocks: list[_Block] = []
|
|
1338
|
+
by_fn: dict[Any, list] = {}
|
|
1339
|
+
for p in points:
|
|
1340
|
+
by_fn.setdefault(p.fn, []).append(p)
|
|
1341
|
+
for fn, pts in by_fn.items():
|
|
1342
|
+
bits = fn in (modbus.READ_COILS, modbus.READ_DISCRETE_INPUTS)
|
|
1343
|
+
cap = modbus.MAX_READ_BITS if bits else modbus.MAX_READ_REGISTERS
|
|
1344
|
+
usable = []
|
|
1345
|
+
for p in pts:
|
|
1346
|
+
try:
|
|
1347
|
+
size = 1 if bits else _registers(p)
|
|
1348
|
+
ok = (isinstance(p.address, int) and not isinstance(p.address, bool)
|
|
1349
|
+
and 0 <= p.address and p.address + size <= 0x10000
|
|
1350
|
+
and isinstance(size, int) and 1 <= size <= cap)
|
|
1351
|
+
except (TypeError, ValueError):
|
|
1352
|
+
ok = False
|
|
1353
|
+
if ok:
|
|
1354
|
+
usable.append((p.address, size, p))
|
|
1355
|
+
else:
|
|
1356
|
+
blocks.append(_Block(fn, 0, 0, [p], single=True))
|
|
1357
|
+
current: Optional[_Block] = None
|
|
1358
|
+
for address, size, p in sorted(usable, key=lambda t: t[0]):
|
|
1359
|
+
end = address + size
|
|
1360
|
+
if (current is not None and address <= current.start + current.count + gap
|
|
1361
|
+
and max(current.count, end - current.start) <= cap):
|
|
1362
|
+
current.count = max(current.count, end - current.start)
|
|
1363
|
+
current.points.append(p)
|
|
1364
|
+
else:
|
|
1365
|
+
current = _Block(fn, address, size, [p])
|
|
1366
|
+
blocks.append(current)
|
|
1367
|
+
return blocks
|
|
1368
|
+
|
|
1369
|
+
|
|
1370
|
+
def decode_point(p, data: bytes, offset: int = 0):
|
|
1371
|
+
"""A register point's value from read data (response_data), its registers starting at
|
|
1372
|
+
``offset`` registers in: a bit of a register, a string, or a number as the point's type,
|
|
1373
|
+
word order and scale."""
|
|
1374
|
+
data = data[offset * 2:]
|
|
1375
|
+
if p.dtype == "bool":
|
|
1376
|
+
return modbus.register_bit(data, p.bit)
|
|
1377
|
+
if p.dtype == "string":
|
|
1378
|
+
if len(data) < p.length * 2:
|
|
1379
|
+
raise ValueError("not enough data for the string")
|
|
1380
|
+
return modbus.decode_string(data[:p.length * 2], point_order(p))
|
|
1381
|
+
return modbus.decode_value(data, p.dtype, point_scale(p), point_order(p))
|
|
1382
|
+
|
|
1383
|
+
|
|
1384
|
+
def _units_text(units: list[int]) -> str:
|
|
1385
|
+
return (f"unit {units[0]}" if len(units) == 1
|
|
1386
|
+
else "units " + ", ".join(str(u) for u in units))
|
|
1387
|
+
|
|
1388
|
+
|
|
1389
|
+
def _names(names: list[str]) -> str:
|
|
1390
|
+
return ", ".join(repr(n) for n in names)
|
|
1391
|
+
|
|
1392
|
+
|
|
1393
|
+
# a holding register's value: a decimal integer, 0x hex, or a decimal fraction / exponent
|
|
1394
|
+
_NUMBER = re.compile(r"[+-]?(?:0[xX][0-9a-fA-F]+|(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?)")
|
|
1395
|
+
_NUMBER_MAX_LEN = 64
|
|
1396
|
+
|
|
1397
|
+
|
|
1398
|
+
def parse_write_payload(payload: bytes, point):
|
|
1399
|
+
"""The value of a plain-MQTT write to ``point``: one coil state (1/0, true/false or
|
|
1400
|
+
on/off), or one number for a holding register (decimal, 0x hex, or a decimal fraction
|
|
1401
|
+
or exponent; write_point encodes it as the point's type). Anything else, a JSON list
|
|
1402
|
+
included, raises ValueError: a command writes the point it was sent to, nothing more."""
|
|
1403
|
+
text = payload.decode("utf-8", "replace").strip()
|
|
1404
|
+
if point.fn == modbus.READ_COILS:
|
|
1405
|
+
return _coil(text)
|
|
1406
|
+
if len(text) > _NUMBER_MAX_LEN or not _NUMBER.fullmatch(text):
|
|
1407
|
+
raise ValueError(f"not a number: {shown(text)}")
|
|
1408
|
+
if "x" in text or "X" in text:
|
|
1409
|
+
return int(text, 16)
|
|
1410
|
+
return float(text) if any(c in text for c in ".eE") else int(text)
|
|
1411
|
+
|
|
1412
|
+
|
|
1413
|
+
def _coil(text: str) -> bool:
|
|
1414
|
+
t = text.lower()
|
|
1415
|
+
if t in ("1", "true", "on"):
|
|
1416
|
+
return True
|
|
1417
|
+
if t in ("0", "false", "off"):
|
|
1418
|
+
return False
|
|
1419
|
+
raise ValueError(f"a coil takes one state: 1/0, true/false or on/off, not {shown(text)}")
|
|
1420
|
+
|
|
1421
|
+
|
|
1422
|
+
_ID_MAX_LEN = 64
|
|
1423
|
+
|
|
1424
|
+
|
|
1425
|
+
def parse_write_request(payload: bytes, point, request: dict):
|
|
1426
|
+
"""The value of a plain-MQTT write sent as JSON, {"value": …, "id": …}: one coil state
|
|
1427
|
+
(true / false, or 1 / 0) or one JSON number; "id" (optional: a string of at most 64
|
|
1428
|
+
characters or an integer) is put into ``request`` as soon as it is read, so that even
|
|
1429
|
+
the answer to a request refused here carries it. Raises ValueError."""
|
|
1430
|
+
def no_constant(name: str):
|
|
1431
|
+
raise ValueError(f"{name} is not a number")
|
|
1432
|
+
try:
|
|
1433
|
+
doc = json.loads(payload.decode("utf-8"), parse_constant=no_constant)
|
|
1434
|
+
except (ValueError, RecursionError): # (UnicodeDecodeError too; and deep nesting)
|
|
1435
|
+
raise ValueError(f"not a JSON request: {shown(payload)}") from None
|
|
1436
|
+
if not isinstance(doc, dict):
|
|
1437
|
+
raise ValueError('a JSON request is an object: {"value": …, "id": …}')
|
|
1438
|
+
if "id" in doc:
|
|
1439
|
+
rid = doc["id"]
|
|
1440
|
+
if not ((isinstance(rid, str) and len(rid) <= _ID_MAX_LEN)
|
|
1441
|
+
or (isinstance(rid, int) and not isinstance(rid, bool)
|
|
1442
|
+
and abs(rid) < 1 << 63)):
|
|
1443
|
+
raise ValueError(f"the id must be a string of at most {_ID_MAX_LEN} characters "
|
|
1444
|
+
f"or an integer, not {shown(rid)}")
|
|
1445
|
+
request["id"] = rid
|
|
1446
|
+
unknown = sorted(str(k) for k in doc if k not in ("value", "id"))
|
|
1447
|
+
if unknown:
|
|
1448
|
+
raise ValueError(f"unknown key(s) in the request: {shown(', '.join(unknown))}")
|
|
1449
|
+
if "value" not in doc:
|
|
1450
|
+
raise ValueError('the request has no "value"')
|
|
1451
|
+
value = doc["value"]
|
|
1452
|
+
if point.fn == modbus.READ_COILS:
|
|
1453
|
+
if isinstance(value, bool):
|
|
1454
|
+
return value
|
|
1455
|
+
if isinstance(value, int) and value in (0, 1):
|
|
1456
|
+
return value == 1
|
|
1457
|
+
raise ValueError(f"a coil takes true / false or 1 / 0, not {shown(value)}")
|
|
1458
|
+
return value # write_point takes a number (and refuses anything else)
|
|
1459
|
+
|
|
1460
|
+
|
|
1461
|
+
def _json_value(value):
|
|
1462
|
+
"""A value read back, for a JSON answer: a NaN or infinite reading is null."""
|
|
1463
|
+
if isinstance(value, float) and not math.isfinite(value):
|
|
1464
|
+
return None
|
|
1465
|
+
return value
|