modpoll-wot 0.1.0a1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,89 @@
1
+ # Executable configuration contract 0.1 (preview)
2
+
3
+ This is Modpoll's supported WoT TM/TD 1.1 subset for replacing CSV configuration.
4
+ It is not a general WoT runtime. The Modbus binding document remains a draft:
5
+ https://w3c.github.io/wot-binding-templates/bindings/protocols/modbus/.
6
+ Its entity, address, quantity and byte/word-order vocabulary is reused. Ordered
7
+ acquisition groups, offsets, precise decoding and multipliers are project extensions.
8
+ They do not claim standard W3C acquisition or transformation semantics.
9
+
10
+ Use `python -m modpoll_wot MODEL [--unit-id N]` to validate and inspect a
11
+ model without opening a device connection. Schemas are bundled locally; the
12
+ loader does not dereference contexts or remote schema dependencies. The official
13
+ TD/TM schemas identify themselves as `1.1-12-March-2025` (Draft 7).
14
+
15
+ | 1.x input | Contract 0.1 |
16
+ | --- | --- |
17
+ | device name | `title`, used verbatim as output device name |
18
+ | Unit ID | Optional root modv:unitID default per file; explicit CLI overrides it; fallback 1; range 1–254 |
19
+ | poll table | group `modv:entity`: Coil, DiscreteInput, InputRegister, HoldingRegister |
20
+ | poll start/count | group `modv:address` / `modv:quantity`, literal PDU addresses |
21
+ | poll endian | `modv:mostSignificantByte` and `modv:mostSignificantWord` booleans |
22
+ | ref effective name | property key and member `modpoll:property` |
23
+ | ref address | member `modpoll:offset`, relative to group start |
24
+ | ref `address:bit` | member `modpoll:bit`, 0–15, register bool only |
25
+ | ref dtype | member `modpoll:dataType` |
26
+ | ref rw | standard property `readOnly` / `writeOnly`; neither means rw |
27
+ | ref unit | standard property `unit` |
28
+ | ref scale | member `modpoll:multiplier`, multiplication, never inferred division |
29
+
30
+ The JSON root declares `modpoll:version: "0.1"` and `modpoll:acquisition`.
31
+ Context aliases must match the supported project and draft Modbus namespaces;
32
+ these are not arbitrary remotely resolved JSON-LD aliases. The project namespace
33
+ documents contract 0.1 at https://top-maker.github.io/modpoll/vocab/wot-modbus.
34
+ This is a project extension, not a W3C Modbus standard. Concrete TD Forms use `modbus+tcp` URLs. The Form's entity, operations,
35
+ unit/address/quantity and declared payload/byte order must match the group/member.
36
+ Explicit transport CLI options select the actual endpoint, allowing the same
37
+ configuration to be tested through a proxy or alternate transport. Concrete TD
38
+ Unit IDs must match `--unit-id`; unbound TMs can be instantiated with any supported ID.
39
+
40
+ Register member offsets count 16-bit registers. Bit-table member offsets count
41
+ eight-bit groups relative to poll start, preserving the old behavior; poll counts
42
+ still specify physical bits. A final partial bit group is clipped by the executor.
43
+ Bit tables support bool/bool8/bool16 and numeric/string payloads that fit complete
44
+ requested bytes. Numeric assembly preserves v1 bit reversal within each byte; word
45
+ order is ignored for bit-table numeric decoding as in v1. Register types are int/uint16/32/64, float16/32/64,
46
+ bool/bool8/bool16 and stringN. String lengths count bytes, including odd lengths;
47
+ spans must fit the declared block. A register bool with a bit selector is a scalar
48
+ boolean; bool groups are arrays with boolean items. Strings cannot be multiplied.
49
+ Float and fractional-multiplier outputs require a number property schema.
50
+
51
+ Groups preserve order, boundaries and holes. Reads are limited to 125 registers
52
+ or 2000 physical bits per request, within 0–65535. No coalescing takes place.
53
+ Each property must have exactly one mapping. Distinct property aliases at the same backing address/bit are retained and
54
+ decoded independently from one request.
55
+ Overlapping views at distinct starting offsets remain explicit, independent values.
56
+ Missing fields, unknown executable extension fields, invalid access, non-finite
57
+ values/multipliers, duplicate JSON keys and invalid type combinations fail the
58
+ whole file with source and field diagnostics. Inputs are bounded to 1 MiB and 64
59
+ nested containers. Invalid files do not yield partially configured devices.
60
+
61
+ DI/IR are read-only. The historical simulator CSV's rw flags for those tables are
62
+ corrected to readOnly in the fixtures; no unsupported write capability is promised.
63
+ The interpreter records access flags; raw MQTT write commands retain the 1.x raw
64
+ address behavior. This milestone does not add property-based writes or inverse
65
+ engineering-value transforms. Multipliers are retained as Decimal in the model
66
+ but adapted to the existing float execution semantics. Int64 without a multiplier
67
+ stays a Python integer. JSON/JavaScript consumers must still account for their own
68
+ numeric precision limits.
69
+
70
+ Inheritance, links, optional TM members, URI templates, root operation Forms,
71
+ actions/events, unsupported Form terms and non-nosec security schemes are rejected.
72
+ Model/TD schema validation does not prove that a stock WoT consumer will implement
73
+ this acquisition extension or its transforms. In particular, a direct Modbus Form
74
+ identifies raw device access; engineering-value interpretation requires the project
75
+ transform metadata. No HTTP endpoint or cache service is declared.
76
+
77
+ `examples/modsim.tm.json` is an authored mapping of `examples/modsim.csv`, retaining
78
+ four groups and 37 properties; it is not generated from CSV at runtime.
79
+ `examples/modsim.td.json` binds the same mapping to the documented local TCP
80
+ endpoint. Their mappings are checked against the CSV in tests. Runtime equivalence
81
+ checks additionally compare independently known initial values, complete JSON
82
+ snapshots, rendered tables, and the exact four requests against modsim. Shared
83
+ remote modsim state may have been written by others, so remote checks compare
84
+ successive read-only snapshots rather than assuming fixed initial values.
85
+
86
+ The shared interpreter is independently versioned and published as
87
+ `modpoll-wot`. Application previews pin an exact registry version.
88
+ Its schemas are bundled for offline use; new profile semantics require an
89
+ explicit contract revision rather than silently changing contract 0.1.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2021-2024, Ying Shaodong
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,50 @@
1
+ Metadata-Version: 2.1
2
+ Name: modpoll-wot
3
+ Version: 0.1.0a1
4
+ Summary: WoT Modbus configuration interpreter for Modpoll
5
+ Home-page: https://github.com/top-maker/modpoll
6
+ License: MIT
7
+ Author: Ying Shaodong
8
+ Author-email: helloysd@gmail.com
9
+ Requires-Python: >=3.10,<4.0
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.10
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Requires-Dist: jsonschema (>=4.18,<5)
16
+ Requires-Dist: referencing (>=0.28.4,<1)
17
+ Project-URL: Documentation, https://top-maker.github.io/modpoll/v2/
18
+ Project-URL: Repository, https://github.com/top-maker/modpoll
19
+ Description-Content-Type: text/markdown
20
+
21
+ # modpoll-wot (experimental)
22
+
23
+ An independently versioned TM/TD configuration interpreter for Modpoll and future
24
+ compatible consumers. No Modbus execution, scheduler or MQTT service is included.
25
+
26
+ ```sh
27
+ pip install modpoll-wot==0.1.0a1
28
+ python -m modpoll_wot meter.tm.json
29
+ ```
30
+ See CONTRACT.md for the executable 0.1 subset and 1.x field mapping.
31
+
32
+ The loader validates bundled W3C TM/TD 1.1 schemas, the project extension
33
+ schema, and acquisition semantics. It does not execute Modbus requests.
34
+
35
+ ```python
36
+ from modpoll_wot.loading import load_model
37
+ model = load_model("examples/modsim.tm.json", kind="tm", unit_id=1)
38
+ ```
39
+
40
+ The application adapter is connected to the existing Modpoll poll/decode/output engine. Run checks from the repository root:
41
+
42
+ ```sh
43
+ PYTHONPATH=packages/modpoll-wot python -m unittest discover -s packages/modpoll-wot/tests
44
+ ```
45
+
46
+ The bundled W3C schemas identify themselves as 1.1-12-March-2025, retrieved from
47
+ https://www.w3.org/2022/wot/td-schema/v1.1 and
48
+ https://www.w3.org/2022/wot/tm-schema/v1.1. Standard validation does not
49
+ establish device correctness.
50
+
@@ -0,0 +1,29 @@
1
+ # modpoll-wot (experimental)
2
+
3
+ An independently versioned TM/TD configuration interpreter for Modpoll and future
4
+ compatible consumers. No Modbus execution, scheduler or MQTT service is included.
5
+
6
+ ```sh
7
+ pip install modpoll-wot==0.1.0a1
8
+ python -m modpoll_wot meter.tm.json
9
+ ```
10
+ See CONTRACT.md for the executable 0.1 subset and 1.x field mapping.
11
+
12
+ The loader validates bundled W3C TM/TD 1.1 schemas, the project extension
13
+ schema, and acquisition semantics. It does not execute Modbus requests.
14
+
15
+ ```python
16
+ from modpoll_wot.loading import load_model
17
+ model = load_model("examples/modsim.tm.json", kind="tm", unit_id=1)
18
+ ```
19
+
20
+ The application adapter is connected to the existing Modpoll poll/decode/output engine. Run checks from the repository root:
21
+
22
+ ```sh
23
+ PYTHONPATH=packages/modpoll-wot python -m unittest discover -s packages/modpoll-wot/tests
24
+ ```
25
+
26
+ The bundled W3C schemas identify themselves as 1.1-12-March-2025, retrieved from
27
+ https://www.w3.org/2022/wot/td-schema/v1.1 and
28
+ https://www.w3.org/2022/wot/tm-schema/v1.1. Standard validation does not
29
+ establish device correctness.
@@ -0,0 +1,18 @@
1
+ """Modpoll's versioned executable subset of WoT TM/TD and Modbus metadata."""
2
+
3
+ from .acquisition import AcquisitionError, Group, Member, parse_acquisition
4
+ from .loading import Model, load_model, loads_model, parse_model
5
+ from .validation import ModelValidationError, validate_standard
6
+
7
+ __all__ = [
8
+ "AcquisitionError",
9
+ "Group",
10
+ "Member",
11
+ "Model",
12
+ "ModelValidationError",
13
+ "parse_acquisition",
14
+ "load_model",
15
+ "loads_model",
16
+ "parse_model",
17
+ "validate_standard",
18
+ ]
@@ -0,0 +1,35 @@
1
+ """Validate a concrete executable TM/TD without contacting a device."""
2
+
3
+ import argparse
4
+ import json
5
+ import sys
6
+
7
+ from . import AcquisitionError, load_model
8
+
9
+
10
+ def main():
11
+ parser = argparse.ArgumentParser(description=__doc__)
12
+ parser.add_argument("model")
13
+ parser.add_argument("--unit-id", type=int, default=None)
14
+ args = parser.parse_args()
15
+ try:
16
+ model = load_model(args.model, unit_id=args.unit_id)
17
+ except AcquisitionError as exc:
18
+ print(str(exc), file=sys.stderr)
19
+ return 1
20
+ print(
21
+ json.dumps(
22
+ {
23
+ "title": model.title,
24
+ "kind": model.kind,
25
+ "unit_id": model.unit_id,
26
+ "groups": len(model.groups),
27
+ "properties": sum(len(g.members) for g in model.groups),
28
+ }
29
+ )
30
+ )
31
+ return 0
32
+
33
+
34
+ if __name__ == "__main__":
35
+ sys.exit(main())
@@ -0,0 +1,185 @@
1
+ """Version 0.1 acquisition semantics, independent of Modbus transport."""
2
+
3
+ from dataclasses import dataclass
4
+ from decimal import Decimal, InvalidOperation
5
+ import re
6
+ import math
7
+
8
+
9
+ class AcquisitionError(ValueError):
10
+ """Invalid or unsupported executable configuration."""
11
+
12
+
13
+ ENTITIES = {
14
+ "Coil": "coil",
15
+ "DiscreteInput": "discrete_input",
16
+ "InputRegister": "input_register",
17
+ "HoldingRegister": "holding_register",
18
+ }
19
+ WIDTHS = {
20
+ "int16": 1,
21
+ "uint16": 1,
22
+ "float16": 1,
23
+ "int32": 2,
24
+ "uint32": 2,
25
+ "float32": 2,
26
+ "int64": 4,
27
+ "uint64": 4,
28
+ "float64": 4,
29
+ "bool": 1,
30
+ "bool8": 1,
31
+ "bool16": 2,
32
+ }
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class Member:
37
+ property: str
38
+ offset: int
39
+ data_type: str
40
+ bit: int | None = None
41
+ multiplier: Decimal | None = None
42
+ readable: bool = True
43
+ writable: bool = False
44
+ unit: str | None = None
45
+
46
+ @property
47
+ def width(self):
48
+ return type_width(self.data_type)
49
+
50
+ @property
51
+ def bit_group_width(self):
52
+ return type_width(self.data_type, bit_table=True)
53
+
54
+
55
+ @dataclass(frozen=True)
56
+ class Group:
57
+ table: str
58
+ start: int
59
+ quantity: int
60
+ byte_order: str
61
+ members: tuple[Member, ...]
62
+
63
+
64
+ def type_width(dtype, *, bit_table=False):
65
+ if dtype in WIDTHS:
66
+ width = WIDTHS[dtype]
67
+ return width * 2 if bit_table and not dtype.startswith("bool") else width
68
+ if re.fullmatch(r"string[1-9][0-9]{0,3}", dtype):
69
+ length = int(dtype[6:])
70
+ return length if bit_table else (length + 1) // 2
71
+ raise AcquisitionError(f"unsupported data type: {dtype}")
72
+
73
+
74
+ def _schema_type(prop, dtype, bit, multiplier, path):
75
+ actual = prop.get("type")
76
+ if dtype.startswith("string"):
77
+ valid = actual == "string"
78
+ if multiplier is not None:
79
+ raise AcquisitionError(f"{path}: strings cannot have a multiplier")
80
+ elif dtype.startswith("bool"):
81
+ valid = (
82
+ actual == "boolean"
83
+ if bit is not None
84
+ else (
85
+ actual == "array"
86
+ and isinstance(prop.get("items"), dict)
87
+ and prop["items"].get("type") == "boolean"
88
+ )
89
+ )
90
+ if multiplier is not None:
91
+ raise AcquisitionError(f"{path}: bit values cannot have a multiplier")
92
+ else:
93
+ valid = actual in ("integer", "number")
94
+ if dtype.startswith("float") or (
95
+ multiplier is not None and multiplier != multiplier.to_integral_value()
96
+ ):
97
+ valid = actual == "number"
98
+ if not valid:
99
+ raise AcquisitionError(
100
+ f"{path}: property type disagrees with decoded type/transform"
101
+ )
102
+
103
+
104
+ def parse_acquisition(document) -> tuple[Group, ...]:
105
+ """Apply cross-field checks after structural schema validation."""
106
+ # Also validate here so callers cannot bypass structural validation.
107
+ from .validation import validate_extension
108
+
109
+ validate_extension(document)
110
+ properties = document.get("properties", {})
111
+ if not isinstance(properties, dict) or not properties:
112
+ raise AcquisitionError("/properties: expected nonempty object")
113
+ result, seen = [], set()
114
+ for i, group in enumerate(document["modpoll:acquisition"]):
115
+ path = f"/modpoll:acquisition/{i}"
116
+ table = ENTITIES[group["modv:entity"]]
117
+ bits = table in ("coil", "discrete_input")
118
+ start, count = group["modv:address"], group["modv:quantity"]
119
+ if count > (2000 if bits else 125) or start + count > 65536:
120
+ raise AcquisitionError(f"{path}: invalid Modbus range")
121
+ byte_order = (
122
+ ("BE" if group["modv:mostSignificantByte"] else "LE")
123
+ + "_"
124
+ + ("BE" if group["modv:mostSignificantWord"] else "LE")
125
+ )
126
+ members = []
127
+ for j, member in enumerate(group["modpoll:members"]):
128
+ mp = f"{path}/modpoll:members/{j}"
129
+ name = member["modpoll:property"]
130
+ if name not in properties or name in seen:
131
+ raise AcquisitionError(f"{mp}: unknown or duplicate property {name!r}")
132
+ prop = properties[name]
133
+ if not isinstance(prop, dict):
134
+ raise AcquisitionError(f"{mp}: expected property schema")
135
+ dtype, offset = member["modpoll:dataType"], member["modpoll:offset"]
136
+ width = type_width(dtype, bit_table=bits)
137
+ # 1.x bit table references count eight-bit groups, not physical bits.
138
+ limit = (count + 7) // 8 if bits else count
139
+ if offset + width > limit or (
140
+ bits and not dtype.startswith("bool") and (offset + width) * 8 > count
141
+ ):
142
+ raise AcquisitionError(f"{mp}: member outside group")
143
+ bit = member.get("modpoll:bit")
144
+ if bit is not None and (bits or dtype != "bool"):
145
+ raise AcquisitionError(
146
+ f"{mp}: register bit selector requires register bool"
147
+ )
148
+ for flag in ("readOnly", "writeOnly"):
149
+ if flag in prop and type(prop[flag]) is not bool:
150
+ raise AcquisitionError(f"{mp}: invalid {flag}")
151
+ if prop.get("readOnly") and prop.get("writeOnly"):
152
+ raise AcquisitionError(f"{mp}: conflicting access flags")
153
+ readable, writable = not prop.get("writeOnly", False), not prop.get(
154
+ "readOnly", False
155
+ )
156
+ if table in ("discrete_input", "input_register") and writable:
157
+ raise AcquisitionError(f"{mp}: read-only table requires readOnly")
158
+ multiplier = None
159
+ if "modpoll:multiplier" in member:
160
+ try:
161
+ multiplier = Decimal(str(member["modpoll:multiplier"]))
162
+ except InvalidOperation as exc:
163
+ raise AcquisitionError(f"{mp}: invalid multiplier") from exc
164
+ if not multiplier.is_finite() or not math.isfinite(float(multiplier)):
165
+ raise AcquisitionError(f"{mp}: non-finite multiplier")
166
+ _schema_type(prop, dtype, bit, multiplier, mp)
167
+ members.append(
168
+ Member(
169
+ name,
170
+ offset,
171
+ dtype,
172
+ bit,
173
+ multiplier,
174
+ readable,
175
+ writable,
176
+ prop.get("unit"),
177
+ )
178
+ )
179
+ seen.add(name)
180
+ result.append(Group(table, start, count, byte_order, tuple(members)))
181
+ if seen != properties.keys():
182
+ raise AcquisitionError(
183
+ "/properties: every property must have an acquisition member"
184
+ )
185
+ return tuple(result)
@@ -0,0 +1,145 @@
1
+ """Check any declared Modbus Forms against the acquisition mapping."""
2
+
3
+ from urllib.parse import parse_qs, urlsplit
4
+
5
+ from .acquisition import AcquisitionError, ENTITIES
6
+
7
+
8
+ def validate_forms(document, groups, unit_id, kind):
9
+ endpoint = None
10
+ for group in groups:
11
+ entity = next(key for key, value in ENTITIES.items() if value == group.table)
12
+ for member in group.members:
13
+ forms = document["properties"][member.property].get("forms", [])
14
+ for form in forms:
15
+ path = f"/properties/{member.property}/forms"
16
+ if any(key.startswith("modpoll:") for key in form):
17
+ raise AcquisitionError(
18
+ f"{path}: unsupported project Form extension"
19
+ )
20
+ if form.get("modv:entity") != entity:
21
+ raise AcquisitionError(f"{path}: expected modv:entity {entity}")
22
+ if "modv:function" in form or "modv:zeroBasedAddressing" in form:
23
+ raise AcquisitionError(
24
+ f"{path}: function overrides and deprecated addressing are unsupported"
25
+ )
26
+ for term, expected in (
27
+ ("modv:mostSignificantByte", group.byte_order[:2] == "BE"),
28
+ ("modv:mostSignificantWord", group.byte_order[-2:] == "BE"),
29
+ ):
30
+ if term in form and (
31
+ type(form[term]) is not bool or form[term] != expected
32
+ ):
33
+ raise AcquisitionError(
34
+ f"{path}: byte order disagrees with group"
35
+ )
36
+ allowed = (["readproperty"] if member.readable else []) + (
37
+ ["writeproperty"] if member.writable else []
38
+ )
39
+ ops = form.get("op", allowed)
40
+ if isinstance(ops, str):
41
+ ops = [ops]
42
+ if not ops or any(op not in allowed for op in ops):
43
+ raise AcquisitionError(
44
+ f"{path}: operation disagrees with property access"
45
+ )
46
+ expected_type = {
47
+ "uint16": "xsd:unsignedShort",
48
+ "int16": "xsd:short",
49
+ "uint32": "xsd:unsignedInt",
50
+ "int32": "xsd:int",
51
+ "uint64": "xsd:unsignedLong",
52
+ "int64": "xsd:long",
53
+ "float32": "xsd:float",
54
+ "float64": "xsd:double",
55
+ }.get(member.data_type)
56
+ if member.data_type.startswith("string"):
57
+ expected_type = "xsd:string"
58
+ elif member.data_type.startswith("bool"):
59
+ expected_type = (
60
+ "xsd:unsignedShort" if member.bit is not None else "xsd:boolean"
61
+ )
62
+ if "modv:type" in form and (
63
+ expected_type is None or form["modv:type"] != expected_type
64
+ ):
65
+ raise AcquisitionError(
66
+ f"{path}: modv:type disagrees with decoding type"
67
+ )
68
+ supported = {
69
+ "modv:entity",
70
+ "modv:address",
71
+ "modv:unitID",
72
+ "modv:quantity",
73
+ "modv:mostSignificantByte",
74
+ "modv:mostSignificantWord",
75
+ "modv:type",
76
+ }
77
+ if any(
78
+ key.startswith("modv:") and key not in supported for key in form
79
+ ):
80
+ raise AcquisitionError(f"{path}: unsupported Modbus form term")
81
+ if (
82
+ form.get("contentType", "application/octet-stream")
83
+ != "application/octet-stream"
84
+ ):
85
+ raise AcquisitionError(f"{path}: unsupported content type")
86
+ href = form.get("href")
87
+ if href is None and kind == "tm":
88
+ continue
89
+ if not isinstance(href, str):
90
+ raise AcquisitionError(f"{path}: missing href")
91
+ if "://" not in href:
92
+ base = document.get("base", "")
93
+ href = base.rstrip("/") + "/" + href
94
+ try:
95
+ url = urlsplit(href)
96
+ parts = url.path.strip("/").split("/")
97
+ query = parse_qs(
98
+ url.query, strict_parsing=True, keep_blank_values=True
99
+ )
100
+ actual_unit, address = map(int, parts)
101
+ quantity = int(query.get("quantity", ["1"])[0])
102
+ if set(query) - {"quantity"} or len(query.get("quantity", [])) > 1:
103
+ raise ValueError("unsupported query")
104
+ remote = (url.hostname, url.port or 502)
105
+ if (
106
+ url.scheme != "modbus+tcp"
107
+ or not remote[0]
108
+ or url.username
109
+ or url.password
110
+ or url.fragment
111
+ ):
112
+ raise ValueError("invalid Modbus TCP URL")
113
+ except (TypeError, ValueError) as exc:
114
+ raise AcquisitionError(f"{path}: invalid Modbus TCP href") from exc
115
+ for term, value in (
116
+ ("modv:address", address),
117
+ ("modv:quantity", quantity),
118
+ ("modv:unitID", actual_unit),
119
+ ):
120
+ if term in form and (
121
+ type(form[term]) is not int or form[term] != value
122
+ ):
123
+ raise AcquisitionError(
124
+ f"{path}: Modbus URL term disagrees with href"
125
+ )
126
+ bit_table = group.table in ("coil", "discrete_input")
127
+ expected_address = group.start + member.offset * (8 if bit_table else 1)
128
+ expected_quantity = (
129
+ min(member.bit_group_width * 8, group.quantity - member.offset * 8)
130
+ if bit_table
131
+ else member.width
132
+ )
133
+ if (actual_unit, address, quantity) != (
134
+ unit_id,
135
+ expected_address,
136
+ expected_quantity,
137
+ ):
138
+ raise AcquisitionError(
139
+ f"{path}: href disagrees with group/member or Unit ID"
140
+ )
141
+ if endpoint is not None and endpoint != remote:
142
+ raise AcquisitionError(
143
+ f"{path}: multiple endpoints in one device are unsupported"
144
+ )
145
+ endpoint = remote