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.
Files changed (121) hide show
  1. ser2netweb/__init__.py +20 -0
  2. ser2netweb/__main__.py +6 -0
  3. ser2netweb/accounts.py +503 -0
  4. ser2netweb/cli.py +489 -0
  5. ser2netweb/clock.py +128 -0
  6. ser2netweb/config.py +4491 -0
  7. ser2netweb/console.py +181 -0
  8. ser2netweb/datalock.py +181 -0
  9. ser2netweb/engine/__init__.py +0 -0
  10. ser2netweb/engine/access.py +173 -0
  11. ser2netweb/engine/bindhint.py +115 -0
  12. ser2netweb/engine/bridge.py +1928 -0
  13. ser2netweb/engine/cluster.py +1140 -0
  14. ser2netweb/engine/modbus.py +530 -0
  15. ser2netweb/engine/modbus_gateway.py +1465 -0
  16. ser2netweb/engine/mqtt_pub.py +632 -0
  17. ser2netweb/engine/netguard.py +805 -0
  18. ser2netweb/engine/netinfo.py +199 -0
  19. ser2netweb/engine/portdiag.py +414 -0
  20. ser2netweb/engine/portlist.py +471 -0
  21. ser2netweb/engine/protocols/__init__.py +28 -0
  22. ser2netweb/engine/protocols/base.py +117 -0
  23. ser2netweb/engine/protocols/raw.py +12 -0
  24. ser2netweb/engine/protocols/rfc2217.py +586 -0
  25. ser2netweb/engine/protocols/telnet.py +172 -0
  26. ser2netweb/engine/serial_compat.py +598 -0
  27. ser2netweb/engine/serial_io.py +210 -0
  28. ser2netweb/engine/serial_link.py +414 -0
  29. ser2netweb/engine/sockopts.py +103 -0
  30. ser2netweb/engine/sparkplug.py +538 -0
  31. ser2netweb/engine/sparkplug_node.py +550 -0
  32. ser2netweb/engine/supervisor.py +533 -0
  33. ser2netweb/engine/tls.py +232 -0
  34. ser2netweb/envvars.py +57 -0
  35. ser2netweb/explain.py +117 -0
  36. ser2netweb/healthcheck.py +78 -0
  37. ser2netweb/i18n.py +110 -0
  38. ser2netweb/importer.py +550 -0
  39. ser2netweb/layout.py +118 -0
  40. ser2netweb/licences.py +86 -0
  41. ser2netweb/locale/tr.json +1569 -0
  42. ser2netweb/logsetup.py +253 -0
  43. ser2netweb/logtext.py +23 -0
  44. ser2netweb/notify.py +761 -0
  45. ser2netweb/optional.py +76 -0
  46. ser2netweb/reload.py +295 -0
  47. ser2netweb/runtime.py +762 -0
  48. ser2netweb/sdnotify.py +85 -0
  49. ser2netweb/ser2net_import.py +2001 -0
  50. ser2netweb/signins.py +98 -0
  51. ser2netweb/standalone.py +154 -0
  52. ser2netweb/state.py +1457 -0
  53. ser2netweb/storage.py +130 -0
  54. ser2netweb/textfold.py +51 -0
  55. ser2netweb/tlsreload.py +37 -0
  56. ser2netweb/watchdog.py +201 -0
  57. ser2netweb/web/__init__.py +0 -0
  58. ser2netweb/web/api.py +793 -0
  59. ser2netweb/web/auth.py +699 -0
  60. ser2netweb/web/certinfo.py +144 -0
  61. ser2netweb/web/httpguard.py +413 -0
  62. ser2netweb/web/ldap_auth.py +556 -0
  63. ser2netweb/web/oidc_auth.py +330 -0
  64. ser2netweb/web/proxy.py +398 -0
  65. ser2netweb/web/routes/__init__.py +78 -0
  66. ser2netweb/web/routes/cluster.py +669 -0
  67. ser2netweb/web/routes/common.py +359 -0
  68. ser2netweb/web/routes/console.py +451 -0
  69. ser2netweb/web/routes/forms.py +853 -0
  70. ser2netweb/web/routes/login.py +573 -0
  71. ser2netweb/web/routes/mappings.py +470 -0
  72. ser2netweb/web/routes/ops.py +374 -0
  73. ser2netweb/web/routes/settings.py +2135 -0
  74. ser2netweb/web/routes/system.py +308 -0
  75. ser2netweb/web/routes/totp.py +282 -0
  76. ser2netweb/web/selfsigned.py +129 -0
  77. ser2netweb/web/server.py +483 -0
  78. ser2netweb/web/static/app.css +327 -0
  79. ser2netweb/web/static/app.js +1102 -0
  80. ser2netweb/web/static/htmx.min.js +1 -0
  81. ser2netweb/web/static/points.js +218 -0
  82. ser2netweb/web/static/theme.js +7 -0
  83. ser2netweb/web/static/xterm-addon-fit.js +2 -0
  84. ser2netweb/web/static/xterm.css +218 -0
  85. ser2netweb/web/static/xterm.js +2 -0
  86. ser2netweb/web/templates/_cluster_body.html +109 -0
  87. ser2netweb/web/templates/_console.html +21 -0
  88. ser2netweb/web/templates/_footer.html +13 -0
  89. ser2netweb/web/templates/_login_notice.html +8 -0
  90. ser2netweb/web/templates/_macros.html +21 -0
  91. ser2netweb/web/templates/_mapping_form.html +477 -0
  92. ser2netweb/web/templates/_mapping_log.html +28 -0
  93. ser2netweb/web/templates/_mapping_traces.html +44 -0
  94. ser2netweb/web/templates/_mappings_body.html +149 -0
  95. ser2netweb/web/templates/_modbus_read.html +18 -0
  96. ser2netweb/web/templates/_ports_table.html +37 -0
  97. ser2netweb/web/templates/_serial_port_select.html +48 -0
  98. ser2netweb/web/templates/_shell.html +41 -0
  99. ser2netweb/web/templates/audit.html +55 -0
  100. ser2netweb/web/templates/base.html +77 -0
  101. ser2netweb/web/templates/dashboard.html +72 -0
  102. ser2netweb/web/templates/licenses.html +18 -0
  103. ser2netweb/web/templates/login.html +32 -0
  104. ser2netweb/web/templates/login_totp.html +23 -0
  105. ser2netweb/web/templates/settings.html +830 -0
  106. ser2netweb/web/templates/setup.html +43 -0
  107. ser2netweb/web/templates/totp.html +41 -0
  108. ser2netweb/web/totp.py +94 -0
  109. ser2netweb/winservice.py +1793 -0
  110. ser2netweb-3.0.0.dist-info/METADATA +120 -0
  111. ser2netweb-3.0.0.dist-info/RECORD +121 -0
  112. ser2netweb-3.0.0.dist-info/WHEEL +5 -0
  113. ser2netweb-3.0.0.dist-info/entry_points.txt +2 -0
  114. ser2netweb-3.0.0.dist-info/licenses/COMMERCIAL_LICENSE.md +94 -0
  115. ser2netweb-3.0.0.dist-info/licenses/LICENSE +662 -0
  116. ser2netweb-3.0.0.dist-info/licenses/NOTICE +20 -0
  117. ser2netweb-3.0.0.dist-info/licenses/THIRD-PARTY-NOTICES.md +105 -0
  118. ser2netweb-3.0.0.dist-info/licenses/tools/licenses/npm/htmx.org.txt +13 -0
  119. ser2netweb-3.0.0.dist-info/licenses/tools/licenses/npm/xterm-addon-fit.txt +19 -0
  120. ser2netweb-3.0.0.dist-info/licenses/tools/licenses/npm/xterm.txt +21 -0
  121. 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")}