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.
- modpoll_wot-0.1.0a1/CONTRACT.md +89 -0
- modpoll_wot-0.1.0a1/LICENSE +21 -0
- modpoll_wot-0.1.0a1/PKG-INFO +50 -0
- modpoll_wot-0.1.0a1/README.md +29 -0
- modpoll_wot-0.1.0a1/modpoll_wot/__init__.py +18 -0
- modpoll_wot-0.1.0a1/modpoll_wot/__main__.py +35 -0
- modpoll_wot-0.1.0a1/modpoll_wot/acquisition.py +185 -0
- modpoll_wot-0.1.0a1/modpoll_wot/forms.py +145 -0
- modpoll_wot-0.1.0a1/modpoll_wot/loading.py +142 -0
- modpoll_wot-0.1.0a1/modpoll_wot/schemas/SOURCES.md +9 -0
- modpoll_wot-0.1.0a1/modpoll_wot/schemas/acquisition-0.1.json +104 -0
- modpoll_wot-0.1.0a1/modpoll_wot/schemas/td-1.1.json +1493 -0
- modpoll_wot-0.1.0a1/modpoll_wot/schemas/tm-1.1.json +2063 -0
- modpoll_wot-0.1.0a1/modpoll_wot/validation.py +56 -0
- modpoll_wot-0.1.0a1/pyproject.toml +20 -0
|
@@ -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
|