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,374 @@
|
|
|
1
|
+
"""Mapping operations shared by several entry points (web UI, cluster peers, REST API):
|
|
2
|
+
validate + persist + apply, without any HTTP concerns."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import dataclasses
|
|
7
|
+
import itertools
|
|
8
|
+
from typing import NamedTuple, Optional
|
|
9
|
+
|
|
10
|
+
from ... import importer
|
|
11
|
+
from ...config import (
|
|
12
|
+
ConfigError, MappingConfig, _ip_overlaps, mapping_revision, mqtt_clashes, serial_conflict,
|
|
13
|
+
sparkplug_identity, valid_mapping_id,
|
|
14
|
+
)
|
|
15
|
+
from ...i18n import _
|
|
16
|
+
from .forms import apply_usb_match as apply_usb_match # where it was before 2.9.0
|
|
17
|
+
from .forms import mapping_from_form
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _gone() -> str:
|
|
21
|
+
return _("This mapping no longer exists (it may have been deleted in the meantime). The "
|
|
22
|
+
"form shows your values as a new mapping: save it to add the mapping again, or "
|
|
23
|
+
"Cancel.")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _changed() -> str:
|
|
27
|
+
return _("This mapping was changed after this form was opened (by someone else, in another "
|
|
28
|
+
"tab, or by a start or stop). The form shows your values: save again to replace "
|
|
29
|
+
"the changes, or Cancel to keep them.")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class StaleRevision(Exception):
|
|
33
|
+
"""The mapping is no longer at the revision the change was made for."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class Saved(NamedTuple):
|
|
37
|
+
"""A mapping a form saved: its id, why it did not start ("" when it runs or is
|
|
38
|
+
disabled) and whether the supervisor tries the start again (a listener that could not
|
|
39
|
+
bind: it starts once the address can be used)."""
|
|
40
|
+
id: str
|
|
41
|
+
start_error: str
|
|
42
|
+
retrying: bool = False
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
async def save_mapping_from_form(state, form) -> tuple[bool, str, Saved | None]:
|
|
46
|
+
"""Parse a mapping form, validate, persist and (re)apply. Returns (True, name,
|
|
47
|
+
Saved) on success or (False, error, None). Shared by the local UI save and the
|
|
48
|
+
cluster remote-edit save (which runs the same form on the target node). A saved
|
|
49
|
+
mapping that doesn't start (its port taken by another program, ...) is still saved:
|
|
50
|
+
Saved says why.
|
|
51
|
+
|
|
52
|
+
The server owns mapping ids: a form without an id creates a mapping with a new
|
|
53
|
+
one, a form with an id edits that mapping and is refused when there is none. A form
|
|
54
|
+
with the revision it was opened at ("rev", see config.mapping_revision; forms of
|
|
55
|
+
nodes before 2.9.0 have none) is refused when the mapping changed since: checked
|
|
56
|
+
first, so any other refusal means it hadn't."""
|
|
57
|
+
posted_id = form.get("id") or ""
|
|
58
|
+
posted_rev = form.get("rev") or ""
|
|
59
|
+
|
|
60
|
+
def stale(m) -> bool:
|
|
61
|
+
return bool(posted_rev) and posted_rev != mapping_revision(state.config.secret_key, m)
|
|
62
|
+
|
|
63
|
+
try:
|
|
64
|
+
# The form doesn't expose (or always post) every field; when editing, carry
|
|
65
|
+
# those over so a save never silently wipes them (get_mapping is a sync
|
|
66
|
+
# read — safe outside the config lock).
|
|
67
|
+
existing = state.config.get_mapping(posted_id) if valid_mapping_id(posted_id) else None
|
|
68
|
+
if posted_id and existing is None:
|
|
69
|
+
return False, _gone(), None
|
|
70
|
+
if existing is not None and stale(existing):
|
|
71
|
+
return False, _changed(), None
|
|
72
|
+
mapping = mapping_from_form(form, existing, await state.ports.current())
|
|
73
|
+
mapping.validate()
|
|
74
|
+
mapping.validate_input()
|
|
75
|
+
await asyncio.to_thread(mapping.check_tls_files) # (reads the files)
|
|
76
|
+
await asyncio.to_thread(mapping.check_mqtt_tls_files)
|
|
77
|
+
# ConfigError is a ValueError; values of the wrong type are read as the right one
|
|
78
|
+
# (config._read_fields), so the others only guard a check that still trips over one
|
|
79
|
+
except (ValueError, TypeError, AttributeError, OverflowError) as e:
|
|
80
|
+
return False, str(e), None
|
|
81
|
+
# Hold the lock only to mutate + persist config; release it BEFORE the
|
|
82
|
+
# (potentially slow) supervisor call so other admin requests aren't blocked
|
|
83
|
+
# while a serial port is being (re)opened.
|
|
84
|
+
try:
|
|
85
|
+
async with state.edit_config():
|
|
86
|
+
if posted_id:
|
|
87
|
+
existing = state.config.get_mapping(mapping.id)
|
|
88
|
+
if existing is None: # deleted while this request was being checked
|
|
89
|
+
raise ConfigError(_gone())
|
|
90
|
+
if stale(existing): # ... or changed
|
|
91
|
+
raise ConfigError(_changed())
|
|
92
|
+
state.config.mappings[state.config.mappings.index(existing)] = mapping
|
|
93
|
+
else:
|
|
94
|
+
state.config.mappings.append(mapping)
|
|
95
|
+
# next to the config as it is saved: ser2netweb's own ports, a serial port another
|
|
96
|
+
# mapping uses, the admin UI's TLS key
|
|
97
|
+
if conflict := edit_conflict(state.config, mapping):
|
|
98
|
+
raise ConfigError(conflict)
|
|
99
|
+
except ConfigError as e: # not saved, nothing changed (config.json unwritable too)
|
|
100
|
+
return False, str(e), None
|
|
101
|
+
started, why = await state.supervisor.apply_mapping(mapping)
|
|
102
|
+
retrying = not started and bool((state.supervisor.status(mapping.id) or {}).get("retrying"))
|
|
103
|
+
return True, mapping.name, Saved(mapping.id, "" if started else why, retrying)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def edit_conflict(config, m: MappingConfig) -> str:
|
|
107
|
+
"""Why mapping `m`, created or changed, can't be saved in `config` ("" when it can):
|
|
108
|
+
it would listen on one of ser2netweb's own ports (AppConfig.own_port_conflict), share a
|
|
109
|
+
serial port with another enabled mapping (serial_conflict), use the admin UI's TLS
|
|
110
|
+
key (admin_key_reuse) or share its MQTT client ID, Sparkplug B edge node or a command
|
|
111
|
+
topic with another enabled mapping on its broker (mqtt_conflict). Edit-time checks,
|
|
112
|
+
never validate()'s (a stored clash keeps loading and saving). The form, a cluster
|
|
113
|
+
peer's save, the REST API (POST, PUT, PATCH)."""
|
|
114
|
+
return (config.own_port_conflict(m) or config.serial_conflict(m)
|
|
115
|
+
or config.admin_key_reuse(m) or mqtt_conflict(config, m))
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def mqtt_conflict(config, m: MappingConfig) -> str:
|
|
119
|
+
"""What `m` would share on its MQTT broker with another enabled mapping of `config`
|
|
120
|
+
that the edits refuse (config.check_mqtt_clashes), "" when nothing."""
|
|
121
|
+
return next((c.text() for c in mqtt_clashes(m, config.mappings) if c.refused), "")
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def copy_of(config, m: MappingConfig) -> MappingConfig:
|
|
125
|
+
"""The dashboard's Copy: ``m``'s settings under a new id, disabled, and nothing in it
|
|
126
|
+
that clashes with ``m`` (or another mapping) when it is enabled next to it, once its
|
|
127
|
+
serial port is changed: the name "<name> (copy)" ("(copy 2)", … when taken), the next
|
|
128
|
+
free listening port for its transport and address that is not one of ser2netweb's own
|
|
129
|
+
(AppConfig.own_port_conflict; searched down from ``m``'s once 65535 is reached), no
|
|
130
|
+
MQTT client ID (a broker keeps one connection per ID), the base
|
|
131
|
+
topic "<topic>-copy" ("-copy-2", …) so a /set is not written by both, and a Sparkplug
|
|
132
|
+
B edge node ID made from the new name. Raises ConfigError when no port is free."""
|
|
133
|
+
d = m.to_dict()
|
|
134
|
+
d.pop("id", None)
|
|
135
|
+
d["enabled"] = False # don't auto-start a duplicate
|
|
136
|
+
dup = MappingConfig.from_dict(d)
|
|
137
|
+
names = {x.name for x in config.mappings}
|
|
138
|
+
dup.name, k = f"{m.name} (copy)", 1
|
|
139
|
+
while dup.name in names:
|
|
140
|
+
k += 1
|
|
141
|
+
dup.name = f"{m.name} (copy {k})"
|
|
142
|
+
mq = dup.mqtt
|
|
143
|
+
mq.client_id = ""
|
|
144
|
+
base = str(mq.base_topic).rstrip("/")
|
|
145
|
+
if base:
|
|
146
|
+
topics = {str(x.mqtt.base_topic).rstrip("/") for x in config.mappings}
|
|
147
|
+
mq.base_topic, k = f"{base}-copy", 1
|
|
148
|
+
while mq.base_topic in topics:
|
|
149
|
+
k += 1
|
|
150
|
+
mq.base_topic = f"{base}-copy-{k}"
|
|
151
|
+
mq.sparkplug_node_id = "" # made from the name, unless another mapping has that one
|
|
152
|
+
if mq.payload_format == "sparkplug_b":
|
|
153
|
+
nodes = {sparkplug_identity(x) for x in config.mappings
|
|
154
|
+
if x.mqtt.payload_format == "sparkplug_b"}
|
|
155
|
+
group, node = sparkplug_identity(dup)
|
|
156
|
+
k = 1
|
|
157
|
+
while (group, mq.sparkplug_node_id or node) in nodes:
|
|
158
|
+
k += 1
|
|
159
|
+
mq.sparkplug_node_id = f"{node}_{k}"
|
|
160
|
+
net = dup.network
|
|
161
|
+
if dup.kind == "net" and net.listens:
|
|
162
|
+
proto = "udp" if net.mode == "udp" else "tcp"
|
|
163
|
+
taken = {x.network.port for x in config.mappings
|
|
164
|
+
if x.kind == "net" and x.network.listens
|
|
165
|
+
and ("udp" if x.network.mode == "udp" else "tcp") == proto
|
|
166
|
+
and _ip_overlaps(x.network.bind_ip, net.bind_ip, x.network.both_families,
|
|
167
|
+
net.both_families)}
|
|
168
|
+
|
|
169
|
+
def free(port: int) -> bool: # (not ser2netweb's own ports either: own_port_conflict)
|
|
170
|
+
net.port = port
|
|
171
|
+
return port not in taken and not config.own_port_conflict(dup)
|
|
172
|
+
|
|
173
|
+
start = net.port
|
|
174
|
+
if not free(start):
|
|
175
|
+
port = next((p for p in itertools.chain(range(max(start + 1, 1), 65536),
|
|
176
|
+
range(min(start - 1, 65535), 0, -1))
|
|
177
|
+
if free(p)), None)
|
|
178
|
+
if port is None:
|
|
179
|
+
raise ConfigError(_("No free {0} port is left on {1} for the copy.").format(
|
|
180
|
+
proto.upper(), net.bind_ip))
|
|
181
|
+
net.port = port
|
|
182
|
+
return dup
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
class ActionResult(NamedTuple):
|
|
186
|
+
"""What apply_action did with one mapping."""
|
|
187
|
+
found: bool # a mapping has this id
|
|
188
|
+
name: str = ""
|
|
189
|
+
error: Optional[ConfigError] = None # not done: its change can't be saved
|
|
190
|
+
warning: str = "" # done, but config.json could not keep it
|
|
191
|
+
changed: bool = False # False: it ran (or was stopped) already
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
async def apply_action(state, mid, action) -> ActionResult:
|
|
195
|
+
"""Start/stop/restart a local mapping by id. Shared by the UI action endpoint, the
|
|
196
|
+
cluster remote-control endpoint and the REST API: apply_actions for one mapping."""
|
|
197
|
+
return (await apply_actions(state, [mid], action))[0]
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
async def apply_actions(state, ids, action) -> list[ActionResult]:
|
|
201
|
+
"""Start/stop/restart the mappings with these ids (once each, in this order; the
|
|
202
|
+
dashboard's bulk action) with one save.
|
|
203
|
+
|
|
204
|
+
The enabled flags are saved first (when one changes; a start or stop takes no backup
|
|
205
|
+
of its own, see ConfigStore.save), then the supervisor brings each runner in line (it
|
|
206
|
+
reads the config when the mapping's turn comes). Start and stop are idempotent: a
|
|
207
|
+
start leaves a mapping that runs with its settings as it is, clients and serial port
|
|
208
|
+
included (changed=False); restart always restarts. A start or restart that can't be
|
|
209
|
+
saved (e.g. the mapping shares a serial port with a running one, or config.json can't
|
|
210
|
+
be written) leaves that mapping as it was and gives `error`; the others go on. A stop
|
|
211
|
+
always stops: when config.json can't be written, the mappings stay stopped (in memory
|
|
212
|
+
too) and `warning` says they start again when ser2netweb restarts."""
|
|
213
|
+
stop = action == "stop"
|
|
214
|
+
ids = list(dict.fromkeys(ids))
|
|
215
|
+
found: dict[str, MappingConfig] = {}
|
|
216
|
+
refused: dict[str, ConfigError] = {}
|
|
217
|
+
warning = ""
|
|
218
|
+
try:
|
|
219
|
+
async with state.edit_config(keep_on_failure=stop, backup=False):
|
|
220
|
+
found = {mid: m for mid in ids if (m := state.config.get_mapping(mid)) is not None}
|
|
221
|
+
flipped = [m for m in found.values() if m.enabled == stop]
|
|
222
|
+
for m in flipped:
|
|
223
|
+
m.enabled = not stop
|
|
224
|
+
if flipped and not stop: # starting can make a clash: find who can't
|
|
225
|
+
refused = _refuse_clashes(state.config, flipped)
|
|
226
|
+
except ConfigError as e: # the save itself failed (only a change is saved)
|
|
227
|
+
if not stop:
|
|
228
|
+
return [ActionResult(mid in found, found[mid].name if mid in found else "",
|
|
229
|
+
error=e) for mid in ids]
|
|
230
|
+
state.log(f"WARNING: stopped {', '.join(repr(m.name) for m in found.values())}, but "
|
|
231
|
+
f"config.json could not be saved ({e}): they start again when ser2netweb "
|
|
232
|
+
"restarts, unless a later change is saved")
|
|
233
|
+
warning = _("Stopped, but config.json could not be saved ({0}): it starts again when "
|
|
234
|
+
"ser2netweb restarts.").format(e)
|
|
235
|
+
results = []
|
|
236
|
+
for mid in ids:
|
|
237
|
+
m = found.get(mid)
|
|
238
|
+
if m is None:
|
|
239
|
+
results.append(ActionResult(False))
|
|
240
|
+
continue
|
|
241
|
+
if mid in refused:
|
|
242
|
+
results.append(ActionResult(True, m.name, error=refused[mid]))
|
|
243
|
+
continue
|
|
244
|
+
if stop:
|
|
245
|
+
changed = await state.supervisor.stop_mapping(mid)
|
|
246
|
+
elif action == "restart":
|
|
247
|
+
await state.supervisor.restart_mapping(m)
|
|
248
|
+
changed = True
|
|
249
|
+
else: # start
|
|
250
|
+
_ok, _msg, changed = await state.supervisor.ensure_running(m)
|
|
251
|
+
results.append(ActionResult(True, m.name, warning=warning, changed=changed))
|
|
252
|
+
return results
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _start_clash(config, m: MappingConfig) -> str:
|
|
256
|
+
"""What a mapping just switched on would share with another enabled one: a serial port
|
|
257
|
+
(AppConfig.serial_conflict, in any spelling of the port and by USB identity), or on its
|
|
258
|
+
MQTT broker a client ID, Sparkplug B edge node or command topic (mqtt_conflict)."""
|
|
259
|
+
return config.serial_conflict(m) or mqtt_conflict(config, m)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _refuse_clashes(config, started: list) -> dict[str, ConfigError]:
|
|
263
|
+
"""Mappings just switched on whose start would share a serial port or what an MQTT
|
|
264
|
+
broker tells clients apart by with another enabled one (_start_clash), or that the
|
|
265
|
+
save would refuse: each is switched off again, by id with the reason. One check when
|
|
266
|
+
none is refused; otherwise they are switched on again one by one, in order, so the
|
|
267
|
+
first of two that share a port starts. (ser2netweb's own ports and the admin UI's TLS key
|
|
268
|
+
are checked when a mapping is created or changed, not at a start: a stored clash must
|
|
269
|
+
not stop one; nor does a clash between mappings enabled already.)"""
|
|
270
|
+
def refusal(m) -> Optional[ConfigError]:
|
|
271
|
+
if clash := _start_clash(config, m):
|
|
272
|
+
return ConfigError(clash)
|
|
273
|
+
try:
|
|
274
|
+
config.validate_for_save()
|
|
275
|
+
except ConfigError as e:
|
|
276
|
+
return e
|
|
277
|
+
return None
|
|
278
|
+
|
|
279
|
+
if not any(_start_clash(config, m) for m in started):
|
|
280
|
+
try:
|
|
281
|
+
config.validate_for_save()
|
|
282
|
+
return {}
|
|
283
|
+
except ConfigError:
|
|
284
|
+
pass
|
|
285
|
+
for m in started:
|
|
286
|
+
m.enabled = False
|
|
287
|
+
refused = {}
|
|
288
|
+
for m in started:
|
|
289
|
+
m.enabled = True
|
|
290
|
+
if (error := refusal(m)) is not None:
|
|
291
|
+
m.enabled = False
|
|
292
|
+
refused[m.id] = error
|
|
293
|
+
return refused
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
async def delete_mapping(state, mid, expected=None):
|
|
297
|
+
"""Remove a mapping from the config, stop it and drop its log and traces. Returns the removed
|
|
298
|
+
MappingConfig, or None when no mapping has that id. Raises ConfigError when the deletion
|
|
299
|
+
can't be saved (then nothing is deleted), StaleRevision when `expected` (a test of the
|
|
300
|
+
mapping, e.g. its revision) fails."""
|
|
301
|
+
removed = None
|
|
302
|
+
async with state.edit_config():
|
|
303
|
+
m = state.config.get_mapping(mid)
|
|
304
|
+
if m and expected is not None and not expected(m):
|
|
305
|
+
raise StaleRevision(mid)
|
|
306
|
+
if m:
|
|
307
|
+
state.config.mappings.remove(m)
|
|
308
|
+
removed = m
|
|
309
|
+
if removed is not None: # by the stored id, never the id from the request
|
|
310
|
+
await state.supervisor.remove_mapping(removed.id)
|
|
311
|
+
state.delete_mapping_log(removed.id)
|
|
312
|
+
state.delete_mapping_traces(removed.id)
|
|
313
|
+
return removed
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def import_preview(state, plan, mode: str) -> tuple[list[dict], str | None]:
|
|
317
|
+
"""What importing `plan` (importer.ImportPlan) in `mode` would do, without changing
|
|
318
|
+
anything: the preview rows (importer.plan_rows, with each mapping's own warnings) and
|
|
319
|
+
why the result could not be saved (None when it could). Shared by the import form's
|
|
320
|
+
preview and the REST API's dry run. A classic ser2net file updates a mapping through
|
|
321
|
+
importer._overlay (plan.classic), as the import itself does."""
|
|
322
|
+
current = state.config.mappings
|
|
323
|
+
_general, notes = plan.split_warnings()
|
|
324
|
+
rows = importer.plan_rows(current, plan.mappings, mode, classic=plan.classic, notes=notes,
|
|
325
|
+
absent=plan.absent)
|
|
326
|
+
candidate, touched = importer.apply_import(current, plan.mappings, mode,
|
|
327
|
+
classic=plan.classic, absent=plan.absent)
|
|
328
|
+
try:
|
|
329
|
+
dataclasses.replace(state.config, mappings=candidate).validate_for_save()
|
|
330
|
+
except ConfigError as e:
|
|
331
|
+
return rows, str(e)
|
|
332
|
+
return rows, import_conflict(state.config, touched, candidate) or None
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
async def import_mappings(state, plan, mode: str) -> tuple[list[dict], list[MappingConfig], bool]:
|
|
336
|
+
"""Import `plan` in `mode` as one change: one save (so one backup of the configuration it
|
|
337
|
+
replaces), then the mappings it added or changed are (re)started; replace restarts them
|
|
338
|
+
all. A mapping a merge updates keeps what its file doesn't say (importer.apply_import).
|
|
339
|
+
Returns (the preview rows, the new mapping list, whether the configuration it replaced
|
|
340
|
+
is in backups/). Raises ConfigError when the result can't be saved, and nothing has
|
|
341
|
+
changed then. Shared by the import form and the REST API."""
|
|
342
|
+
async with state.edit_config() as edit:
|
|
343
|
+
before = {m.id for m in state.config.mappings}
|
|
344
|
+
rows = importer.plan_rows(state.config.mappings, plan.mappings, mode,
|
|
345
|
+
classic=plan.classic, absent=plan.absent)
|
|
346
|
+
new_maps, touched = importer.apply_import(state.config.mappings, plan.mappings, mode,
|
|
347
|
+
classic=plan.classic, absent=plan.absent)
|
|
348
|
+
if conflict := import_conflict(state.config, touched, new_maps): # the UI's port, ...
|
|
349
|
+
raise ConfigError(conflict)
|
|
350
|
+
state.config.mappings = new_maps
|
|
351
|
+
if mode == "replace":
|
|
352
|
+
await state.supervisor.stop_all()
|
|
353
|
+
await state.forget_mappings(before - {m.id for m in new_maps})
|
|
354
|
+
await state.supervisor.start_all(state.config)
|
|
355
|
+
else:
|
|
356
|
+
for m in touched:
|
|
357
|
+
await state.supervisor.apply_mapping(m)
|
|
358
|
+
return rows, new_maps, edit.backed_up
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def import_conflict(config, mappings, result) -> str:
|
|
362
|
+
"""The first of ``mappings`` (an import's new and changed ones) that can't listen next
|
|
363
|
+
to ser2netweb's own listeners (AppConfig.own_port_conflict) or would share a serial port
|
|
364
|
+
with another enabled one of ``result`` (the mappings after the import; serial_conflict),
|
|
365
|
+
else the first that would share its MQTT client ID, Sparkplug B edge node or a command
|
|
366
|
+
topic with another enabled one on its broker (importer.mqtt_clash); "" when none."""
|
|
367
|
+
return (next((c for m in mappings
|
|
368
|
+
if (c := config.own_port_conflict(m) or serial_conflict(m, result))), "")
|
|
369
|
+
or importer.mqtt_clash(result, list(mappings)) or "")
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def import_counts(rows: list[dict]) -> dict[str, int]:
|
|
373
|
+
"""How many preview rows add, update, leave unchanged and remove a mapping."""
|
|
374
|
+
return {a: sum(1 for r in rows if r["action"] == a) for a in ("add", "update", "same", "remove")}
|