From 81291e2d9ec69e22e1e5ffd361c0da6033c5abed Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 28 Oct 2026 17:01:33 +0800 Subject: [PATCH 1/5] feat: start experimental WoT acquisition model --- packages/modpoll-wot-modbus/README.md | 16 +++ .../modpoll_wot_modbus/__init__.py | 5 + .../modpoll_wot_modbus/acquisition.py | 97 +++++++++++++++++++ packages/modpoll-wot-modbus/pyproject.toml | 9 ++ .../tests/test_acquisition.py | 49 ++++++++++ 5 files changed, 176 insertions(+) create mode 100644 packages/modpoll-wot-modbus/README.md create mode 100644 packages/modpoll-wot-modbus/modpoll_wot_modbus/__init__.py create mode 100644 packages/modpoll-wot-modbus/modpoll_wot_modbus/acquisition.py create mode 100644 packages/modpoll-wot-modbus/pyproject.toml create mode 100644 packages/modpoll-wot-modbus/tests/test_acquisition.py diff --git a/packages/modpoll-wot-modbus/README.md b/packages/modpoll-wot-modbus/README.md new file mode 100644 index 0000000..ebc4dd4 --- /dev/null +++ b/packages/modpoll-wot-modbus/README.md @@ -0,0 +1,16 @@ +# modpoll-wot-modbus (experimental) + +This initial package parses a deliberately small experimental acquisition-group +subset. It does not validate WoT TM/TD, fetch contexts, decode registers, read +or write devices, or integrate with modpoll. It is not ready for release. + +The supported subset describes holding and input register groups with numeric +members and four byte/word orders. Coils, discrete inputs, bit selectors, +strings, scaling and write behavior are not implemented. Unknown member and +group fields are rejected. The vocabulary and format may change. + +Run the local unit checks from the repository root: + +``` +PYTHONPATH=packages/modpoll-wot-modbus python -m unittest discover -s packages/modpoll-wot-modbus/tests +``` diff --git a/packages/modpoll-wot-modbus/modpoll_wot_modbus/__init__.py b/packages/modpoll-wot-modbus/modpoll_wot_modbus/__init__.py new file mode 100644 index 0000000..d6085f5 --- /dev/null +++ b/packages/modpoll-wot-modbus/modpoll_wot_modbus/__init__.py @@ -0,0 +1,5 @@ +"""Experimental shared model for Modpoll WoT acquisition groups.""" + +from .acquisition import AcquisitionError, Group, Member, parse_acquisition + +__all__ = ["AcquisitionError", "Group", "Member", "parse_acquisition"] diff --git a/packages/modpoll-wot-modbus/modpoll_wot_modbus/acquisition.py b/packages/modpoll-wot-modbus/modpoll_wot_modbus/acquisition.py new file mode 100644 index 0000000..ee676ee --- /dev/null +++ b/packages/modpoll-wot-modbus/modpoll_wot_modbus/acquisition.py @@ -0,0 +1,97 @@ +"""Parse the experimental register acquisition subset. + +This module does not validate a WoT TM/TD or execute Modbus operations. +Addresses and offsets are zero-based literal register addresses. +""" + +from dataclasses import dataclass +from typing import Mapping + + +class AcquisitionError(ValueError): + """An acquisition group cannot be interpreted safely.""" + + +@dataclass(frozen=True) +class Member: + property: str + offset: int + data_type: str + + +@dataclass(frozen=True) +class Group: + table: str + start: int + quantity: int + byte_order: str + members: tuple[Member, ...] + + +_WIDTH = {"int16": 1, "uint16": 1, "float16": 1, + "int32": 2, "uint32": 2, "float32": 2, + "int64": 4, "uint64": 4, "float64": 4} + + +def _integer(value, path): + if type(value) is not int: + raise AcquisitionError(f"{path}: expected integer") + return value + + +def _fields(value, fields, path): + if not isinstance(value, dict): + raise AcquisitionError(f"{path}: expected object") + missing = fields - value.keys() + extra = value.keys() - fields + if missing or extra: + raise AcquisitionError(f"{path}: missing {sorted(missing)}, unknown {sorted(extra)}") + + +def parse_acquisition(document: Mapping) -> tuple[Group, ...]: + """Parse only the experimental register subset; reject unsupported data.""" + if not isinstance(document, dict): + raise AcquisitionError("document: expected object") + properties = document.get("properties") + groups = document.get("modpoll:acquisition") + if not isinstance(properties, dict) or not properties: + raise AcquisitionError("properties: expected nonempty object") + if not isinstance(groups, list) or not groups: + raise AcquisitionError("modpoll:acquisition: expected nonempty array") + parsed = [] + seen = set() + for i, group in enumerate(groups): + path = f"modpoll:acquisition[{i}]" + _fields(group, {"modpoll:table", "modpoll:start", "modpoll:quantity", "modpoll:byteOrder", "modpoll:members"}, path) + table = group["modpoll:table"] + if table not in ("holding_register", "input_register"): + raise AcquisitionError(f"{path}: unsupported register table") + start = _integer(group["modpoll:start"], path + ".start") + quantity = _integer(group["modpoll:quantity"], path + ".quantity") + if not 0 <= start <= 65535 or not 1 <= quantity <= 125 or start + quantity > 65536: + raise AcquisitionError(f"{path}: invalid register range") + order = group["modpoll:byteOrder"] + if order not in ("BE_BE", "BE_LE", "LE_BE", "LE_LE"): + raise AcquisitionError(f"{path}: invalid byte order") + members = group["modpoll:members"] + if not isinstance(members, list) or not members: + raise AcquisitionError(f"{path}.members: expected nonempty array") + parsed_members = [] + for j, member in enumerate(members): + mp = f"{path}.members[{j}]" + _fields(member, {"modpoll:property", "modpoll:offset", "modpoll:dataType"}, mp) + name = member["modpoll:property"] + if not isinstance(name, str) or name not in properties: + raise AcquisitionError(f"{mp}: unknown property") + if name in seen: + raise AcquisitionError(f"{mp}: duplicate property {name!r}") + dtype = member["modpoll:dataType"] + if not isinstance(dtype, str) or dtype not in _WIDTH: + raise AcquisitionError(f"{mp}: unsupported data type") + offset = _integer(member["modpoll:offset"], mp + ".offset") + if offset < 0 or offset + _WIDTH[dtype] > quantity: + raise AcquisitionError(f"{mp}: member outside group") + seen.add(name) + parsed_members.append(Member(name, offset, dtype)) + parsed.append(Group(table, start, quantity, order, tuple(parsed_members))) + return tuple(parsed) diff --git a/packages/modpoll-wot-modbus/pyproject.toml b/packages/modpoll-wot-modbus/pyproject.toml new file mode 100644 index 0000000..95090dd --- /dev/null +++ b/packages/modpoll-wot-modbus/pyproject.toml @@ -0,0 +1,9 @@ +[build-system] +requires = ["setuptools>=61"] +build-backend = "setuptools.build_meta" + +[project] +name = "modpoll-wot-modbus" +version = "0.1.0a1" +description = "Modpoll WoT Modbus configuration support" +requires-python = ">=3.10" diff --git a/packages/modpoll-wot-modbus/tests/test_acquisition.py b/packages/modpoll-wot-modbus/tests/test_acquisition.py new file mode 100644 index 0000000..ac2c5b0 --- /dev/null +++ b/packages/modpoll-wot-modbus/tests/test_acquisition.py @@ -0,0 +1,49 @@ +import copy +import unittest + +from modpoll_wot_modbus import AcquisitionError, parse_acquisition + + +def model(): + return { + "properties": {"voltage": {"type": "number"}, "current": {"type": "number"}}, + "modpoll:acquisition": [{ + "modpoll:table": "holding_register", "modpoll:start": 40000, + "modpoll:quantity": 6, "modpoll:byteOrder": "BE_BE", + "modpoll:members": [ + {"modpoll:property": "voltage", "modpoll:offset": 0, "modpoll:dataType": "float32"}, + {"modpoll:property": "current", "modpoll:offset": 4, "modpoll:dataType": "float32"}, + ], + }], + } + + +class AcquisitionTests(unittest.TestCase): + def test_preserves_declared_group_and_hole(self): + group, = parse_acquisition(model()) + self.assertEqual((group.start, group.quantity, group.table), (40000, 6, "holding_register")) + self.assertEqual([(m.property, m.offset) for m in group.members], [("voltage", 0), ("current", 4)]) + + def test_rejects_invalid_or_unimplemented_inputs(self): + def duplicate(group): + group["modpoll:members"].append(copy.deepcopy(group["modpoll:members"][0])) + changes = [ + lambda g: g.update({"modpoll:quantity": 3}), + lambda g: g.update({"modpoll:start": 65535}), + lambda g: g.update({"modpoll:quantity": True}), + lambda g: g.update({"modpoll:table": "coil"}), + lambda g: g["modpoll:members"][0].update({"modpoll:property": "missing"}), + lambda g: g["modpoll:members"][0].update({"modpoll:dataType": "unit32"}), + lambda g: g["modpoll:members"][0].update({"modpoll:scale": 2}), + duplicate, + ] + for change in changes: + with self.subTest(change=change): + document = model() + change(document["modpoll:acquisition"][0]) + with self.assertRaises(AcquisitionError): + parse_acquisition(document) + + +if __name__ == "__main__": + unittest.main() From 016c96080446e084f188bfc122bd673306ca5eae Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 28 Oct 2026 17:10:57 +0800 Subject: [PATCH 2/5] feat: validate WoT descriptions using local schemas --- packages/modpoll-wot-modbus/README.md | 4 +- .../modpoll_wot_modbus/schemas/td-1.1.json | 1493 ++++++++++++ .../modpoll_wot_modbus/schemas/tm-1.1.json | 2063 +++++++++++++++++ .../modpoll_wot_modbus/validation.py | 27 + packages/modpoll-wot-modbus/pyproject.toml | 4 + .../tests/test_validation.py | 20 + 6 files changed, 3610 insertions(+), 1 deletion(-) create mode 100644 packages/modpoll-wot-modbus/modpoll_wot_modbus/schemas/td-1.1.json create mode 100644 packages/modpoll-wot-modbus/modpoll_wot_modbus/schemas/tm-1.1.json create mode 100644 packages/modpoll-wot-modbus/modpoll_wot_modbus/validation.py create mode 100644 packages/modpoll-wot-modbus/tests/test_validation.py diff --git a/packages/modpoll-wot-modbus/README.md b/packages/modpoll-wot-modbus/README.md index ebc4dd4..d533ac6 100644 --- a/packages/modpoll-wot-modbus/README.md +++ b/packages/modpoll-wot-modbus/README.md @@ -1,7 +1,7 @@ # modpoll-wot-modbus (experimental) This initial package parses a deliberately small experimental acquisition-group -subset. It does not validate WoT TM/TD, fetch contexts, decode registers, read +subset. It validates TM/TD against bundled W3C 1.1 schemas but does not fetch contexts, decode registers, read or write devices, or integrate with modpoll. It is not ready for release. The supported subset describes holding and input register groups with numeric @@ -14,3 +14,5 @@ Run the local unit checks from the repository root: ``` PYTHONPATH=packages/modpoll-wot-modbus python -m unittest discover -s packages/modpoll-wot-modbus/tests ``` + +The bundled W3C schemas identify themselves as version 1.1-12-March-2025 and were retrieved from the W3C TD and TM schema URLs. Standard schema validation and acquisition semantic checks are separate; neither establishes device correctness. diff --git a/packages/modpoll-wot-modbus/modpoll_wot_modbus/schemas/td-1.1.json b/packages/modpoll-wot-modbus/modpoll_wot_modbus/schemas/td-1.1.json new file mode 100644 index 0000000..bd3daca --- /dev/null +++ b/packages/modpoll-wot-modbus/modpoll_wot_modbus/schemas/td-1.1.json @@ -0,0 +1,1493 @@ +{ + "title": "Thing Description", + "version": "1.1-12-March-2025", + "description": "JSON Schema for validating TD instances against the TD information model. TD instances can be with or without terms that have default values", + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://www.w3.org/2022/wot/td-schema/v1.1", + "definitions": { + "anyUri": { + "type": "string" + }, + "description": { + "type": "string" + }, + "descriptions": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "title": { + "type": "string" + }, + "titles": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "security": { + "oneOf": [ + { + "type": "array", + "items": { + "type": "string" + }, + "minItems": 1 + }, + { + "type": "string" + } + ] + }, + "scopes": { + "oneOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "string" + } + ] + }, + "subprotocol": { + "type": "string", + "examples": [ + "longpoll", + "websub", + "sse" + ] + }, + "thing-context-td-uri-v1": { + "type": "string", + "const": "https://www.w3.org/2019/wot/td/v1" + }, + "thing-context-td-uri-v1.1": { + "type": "string", + "const": "https://www.w3.org/2022/wot/td/v1.1" + }, + "thing-context-td-uri-temp": { + "type": "string", + "const": "http://www.w3.org/ns/td" + }, + "thing-context": { + "anyOf": [ + { + "$comment": "New context URI with other vocabularies after it but not the old one", + "type": "array", + "items": [ + { + "$ref": "#/definitions/thing-context-td-uri-v1.1" + } + ], + "additionalItems": { + "anyOf": [ + { + "$ref": "#/definitions/anyUri" + }, + { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + ], + "not": { + "$ref": "#/definitions/thing-context-td-uri-v1" + } + } + }, + { + "$comment": "Only the new context URI", + "$ref": "#/definitions/thing-context-td-uri-v1.1" + }, + { + "$comment": "Old context URI, followed by the new one and possibly other vocabularies. minItems and contains are required since prefixItems does not say all items should be provided", + "type": "array", + "items": [ + { + "$ref": "#/definitions/thing-context-td-uri-v1" + }, + { + "$ref": "#/definitions/thing-context-td-uri-v1.1" + } + ], + "minItems": 2, + "additionalItems": { + "anyOf": [ + { + "$ref": "#/definitions/anyUri" + }, + { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + ] + } + }, + { + "$comment": "Old context URI, followed by possibly other vocabularies. minItems and contains are required since prefixItems does not say all items should be provided", + "type": "array", + "items": [ + { + "$ref": "#/definitions/thing-context-td-uri-v1" + } + ], + "minItems": 1, + "additionalItems": { + "anyOf": [ + { + "$ref": "#/definitions/anyUri" + }, + { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + ] + } + }, + { + "$comment": "Only the old context URI", + "$ref": "#/definitions/thing-context-td-uri-v1" + } + ] + }, + "bcp47_string": { + "type": "string", + "pattern": "^(((([A-Za-z]{2,3}(-([A-Za-z]{3}(-[A-Za-z]{3}){0,2}))?)|[A-Za-z]{4}|[A-Za-z]{5,8})(-([A-Za-z]{4}))?(-([A-Za-z]{2}|[0-9]{3}))?(-([A-Za-z0-9]{5,8}|[0-9][A-Za-z0-9]{3}))*(-([0-9A-WY-Za-wy-z](-[A-Za-z0-9]{2,8})+))*(-(x(-[A-Za-z0-9]{1,8})+))?)|(x(-[A-Za-z0-9]{1,8})+)|((en-GB-oed|i-ami|i-bnn|i-default|i-enochian|i-hak|i-klingon|i-lux|i-mingo|i-navajo|i-pwn|i-tao|i-tay|i-tsu|sgn-BE-FR|sgn-BE-NL|sgn-CH-DE)|(art-lojban|cel-gaulish|no-bok|no-nyn|zh-guoyu|zh-hakka|zh-min|zh-min-nan|zh-xiang)))$" + }, + "type_declaration": { + "oneOf": [ + { + "type": "string", + "not": { + "const": "tm:ThingModel" + } + }, + { + "type": "array", + "items": { + "type": "string", + "not": { + "const": "tm:ThingModel" + } + } + } + ] + }, + "dataSchema-type": { + "type": "string", + "enum": [ + "boolean", + "integer", + "number", + "string", + "object", + "array", + "null" + ] + }, + "dataSchema": { + "type": "object", + "properties": { + "@type": { + "$ref": "#/definitions/type_declaration" + }, + "description": { + "$ref": "#/definitions/description" + }, + "title": { + "$ref": "#/definitions/title" + }, + "descriptions": { + "$ref": "#/definitions/descriptions" + }, + "titles": { + "$ref": "#/definitions/titles" + }, + "writeOnly": { + "type": "boolean" + }, + "readOnly": { + "type": "boolean" + }, + "oneOf": { + "type": "array", + "items": { + "$ref": "#/definitions/dataSchema" + } + }, + "unit": { + "type": "string" + }, + "enum": { + "type": "array", + "minItems": 1, + "uniqueItems": true + }, + "format": { + "type": "string" + }, + "const": {}, + "default": {}, + "contentEncoding": { + "type": "string" + }, + "contentMediaType": { + "type": "string" + }, + "type": { + "$ref": "#/definitions/dataSchema-type" + }, + "items": { + "oneOf": [ + { + "$ref": "#/definitions/dataSchema" + }, + { + "type": "array", + "items": { + "$ref": "#/definitions/dataSchema" + } + } + ] + }, + "maxItems": { + "type": "integer", + "minimum": 0 + }, + "minItems": { + "type": "integer", + "minimum": 0 + }, + "minimum": { + "type": "number" + }, + "maximum": { + "type": "number" + }, + "exclusiveMinimum": { + "type": "number" + }, + "exclusiveMaximum": { + "type": "number" + }, + "minLength": { + "type": "integer", + "minimum": 0 + }, + "maxLength": { + "type": "integer", + "minimum": 0 + }, + "multipleOf": { + "$ref": "#/definitions/multipleOfDefinition" + }, + "properties": { + "additionalProperties": { + "$ref": "#/definitions/dataSchema" + } + }, + "required": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "additionalResponsesDefinition": { + "type": "array", + "items": { + "type": "object", + "properties": { + "contentType": { + "type": "string" + }, + "schema": { + "type": "string" + }, + "success": { + "type": "boolean" + } + } + } + }, + "multipleOfDefinition": { + "type": [ + "integer", + "number" + ], + "exclusiveMinimum": 0 + }, + "expectedResponse": { + "type": "object", + "properties": { + "contentType": { + "type": "string" + } + }, + "required": [ + "contentType" + ] + }, + "form_element_base": { + "type": "object", + "properties": { + "op": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "href": { + "$ref": "#/definitions/anyUri" + }, + "contentType": { + "type": "string" + }, + "contentCoding": { + "type": "string" + }, + "subprotocol": { + "$ref": "#/definitions/subprotocol" + }, + "security": { + "$ref": "#/definitions/security" + }, + "scopes": { + "$ref": "#/definitions/scopes" + }, + "response": { + "$ref": "#/definitions/expectedResponse" + }, + "additionalResponses": { + "$ref": "#/definitions/additionalResponsesDefinition" + } + }, + "required": [ + "href" + ], + "additionalProperties": true + }, + "form_element_property": { + "allOf": [ + { + "$ref": "#/definitions/form_element_base" + } + ], + "type": "object", + "properties": { + "op": { + "oneOf": [ + { + "type": "string", + "enum": [ + "readproperty", + "writeproperty", + "observeproperty", + "unobserveproperty" + ] + }, + { + "type": "array", + "items": { + "type": "string", + "enum": [ + "readproperty", + "writeproperty", + "observeproperty", + "unobserveproperty" + ] + }, + "minItems": 1 + } + ] + } + }, + "additionalProperties": true + }, + "form_element_action": { + "allOf": [ + { + "$ref": "#/definitions/form_element_base" + } + ], + "type": "object", + "properties": { + "op": { + "oneOf": [ + { + "type": "string", + "enum": [ + "invokeaction", + "queryaction", + "cancelaction" + ] + }, + { + "type": "array", + "items": { + "type": "string", + "enum": [ + "invokeaction", + "queryaction", + "cancelaction" + ] + }, + "minItems": 1 + } + ] + } + }, + "additionalProperties": true + }, + "form_element_event": { + "allOf": [ + { + "$ref": "#/definitions/form_element_base" + } + ], + "type": "object", + "properties": { + "op": { + "oneOf": [ + { + "type": "string", + "enum": [ + "subscribeevent", + "unsubscribeevent" + ] + }, + { + "type": "array", + "items": { + "type": "string", + "enum": [ + "subscribeevent", + "unsubscribeevent" + ] + }, + "minItems": 1 + } + ] + } + }, + "additionalProperties": true + }, + "form_element_root": { + "allOf": [ + { + "$ref": "#/definitions/form_element_base" + } + ], + "type": "object", + "properties": { + "op": { + "oneOf": [ + { + "type": "string", + "enum": [ + "readallproperties", + "writeallproperties", + "readmultipleproperties", + "writemultipleproperties", + "observeallproperties", + "unobserveallproperties", + "queryallactions", + "subscribeallevents", + "unsubscribeallevents" + ] + }, + { + "type": "array", + "items": { + "type": "string", + "enum": [ + "readallproperties", + "writeallproperties", + "readmultipleproperties", + "writemultipleproperties", + "observeallproperties", + "unobserveallproperties", + "queryallactions", + "subscribeallevents", + "unsubscribeallevents" + ] + }, + "minItems": 1 + } + ] + } + }, + "additionalProperties": true, + "required": [ + "op" + ] + }, + "form": { + "$comment": "This is NOT for validation purposes but for automatic generation of TS types. For more info, please see: https://github.com/w3c/wot-thing-description/pull/1319#issuecomment-994950057", + "oneOf": [ + { + "$ref": "#/definitions/form_element_property" + }, + { + "$ref": "#/definitions/form_element_action" + }, + { + "$ref": "#/definitions/form_element_event" + }, + { + "$ref": "#/definitions/form_element_root" + } + ] + }, + "property_element": { + "type": "object", + "properties": { + "@type": { + "$ref": "#/definitions/type_declaration" + }, + "description": { + "$ref": "#/definitions/description" + }, + "descriptions": { + "$ref": "#/definitions/descriptions" + }, + "title": { + "$ref": "#/definitions/title" + }, + "titles": { + "$ref": "#/definitions/titles" + }, + "forms": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/definitions/form_element_property" + } + }, + "uriVariables": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/dataSchema" + } + }, + "observable": { + "type": "boolean" + }, + "writeOnly": { + "type": "boolean" + }, + "readOnly": { + "type": "boolean" + }, + "oneOf": { + "type": "array", + "items": { + "$ref": "#/definitions/dataSchema" + } + }, + "unit": { + "type": "string" + }, + "enum": { + "type": "array", + "minItems": 1, + "uniqueItems": true + }, + "format": { + "type": "string" + }, + "const": {}, + "default": {}, + "type": { + "$ref": "#/definitions/dataSchema-type" + }, + "items": { + "oneOf": [ + { + "$ref": "#/definitions/dataSchema" + }, + { + "type": "array", + "items": { + "$ref": "#/definitions/dataSchema" + } + } + ] + }, + "maxItems": { + "type": "integer", + "minimum": 0 + }, + "minItems": { + "type": "integer", + "minimum": 0 + }, + "minimum": { + "type": "number" + }, + "maximum": { + "type": "number" + }, + "exclusiveMinimum": { + "type": "number" + }, + "exclusiveMaximum": { + "type": "number" + }, + "minLength": { + "type": "integer", + "minimum": 0 + }, + "maxLength": { + "type": "integer", + "minimum": 0 + }, + "multipleOf": { + "$ref": "#/definitions/multipleOfDefinition" + }, + "properties": { + "additionalProperties": { + "$ref": "#/definitions/dataSchema" + } + }, + "required": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "forms" + ], + "additionalProperties": true + }, + "action_element": { + "type": "object", + "properties": { + "@type": { + "$ref": "#/definitions/type_declaration" + }, + "description": { + "$ref": "#/definitions/description" + }, + "descriptions": { + "$ref": "#/definitions/descriptions" + }, + "title": { + "$ref": "#/definitions/title" + }, + "titles": { + "$ref": "#/definitions/titles" + }, + "forms": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/definitions/form_element_action" + } + }, + "uriVariables": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/dataSchema" + } + }, + "input": { + "$ref": "#/definitions/dataSchema" + }, + "output": { + "$ref": "#/definitions/dataSchema" + }, + "safe": { + "type": "boolean" + }, + "idempotent": { + "type": "boolean" + }, + "synchronous": { + "type": "boolean" + } + }, + "required": [ + "forms" + ], + "additionalProperties": true + }, + "event_element": { + "type": "object", + "properties": { + "@type": { + "$ref": "#/definitions/type_declaration" + }, + "description": { + "$ref": "#/definitions/description" + }, + "descriptions": { + "$ref": "#/definitions/descriptions" + }, + "title": { + "$ref": "#/definitions/title" + }, + "titles": { + "$ref": "#/definitions/titles" + }, + "forms": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/definitions/form_element_event" + } + }, + "uriVariables": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/dataSchema" + } + }, + "subscription": { + "$ref": "#/definitions/dataSchema" + }, + "data": { + "$ref": "#/definitions/dataSchema" + }, + "dataResponse": { + "$ref": "#/definitions/dataSchema" + }, + "cancellation": { + "$ref": "#/definitions/dataSchema" + } + }, + "required": [ + "forms" + ], + "additionalProperties": true + }, + "base_link_element": { + "type": "object", + "properties": { + "href": { + "$ref": "#/definitions/anyUri" + }, + "type": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "anchor": { + "$ref": "#/definitions/anyUri" + }, + "hreflang": { + "anyOf": [ + { + "$ref": "#/definitions/bcp47_string" + }, + { + "type": "array", + "items": { + "$ref": "#/definitions/bcp47_string" + } + } + ] + } + }, + "required": [ + "href" + ], + "additionalProperties": true + }, + "link_element": { + "allOf": [ + { + "$ref": "#/definitions/base_link_element" + }, + { + "not": { + "description": "A basic link element should not contain sizes", + "type": "object", + "properties": { + "sizes": {} + }, + "required": [ + "sizes" + ] + } + }, + { + "not": { + "description": "A basic link element should not contain icon or tm:extends", + "properties": { + "rel": { + "enum": [ + "icon", + "tm:extends" + ] + } + }, + "required": [ + "rel" + ] + } + } + ] + }, + "icon_link_element": { + "allOf": [ + { + "$ref": "#/definitions/base_link_element" + }, + { + "properties": { + "rel": { + "const": "icon" + }, + "sizes": { + "type": "string", + "pattern": "[0-9]*x[0-9]+" + } + }, + "required": [ + "rel" + ] + } + ] + }, + "additionalSecurityScheme": { + "description": "Applies to additional SecuritySchemes not defined in the WoT TD specification.", + "$comment": "Additional SecuritySchemes should always be defined via a context extension, using a prefixed value for the scheme. This prefix (e.g. 'ace', see the example below) must contain at least one character in order to reference a valid JSON-LD context extension.", + "examples": [ + { + "scheme": "ace:ACESecurityScheme", + "ace:as": "coaps://as.example.com/token", + "ace:audience": "coaps://rs.example.com", + "ace:scopes": [ + "limited", + "special" + ], + "ace:cnonce": true + } + ], + "type": "object", + "properties": { + "@type": { + "$ref": "#/definitions/type_declaration" + }, + "description": { + "$ref": "#/definitions/description" + }, + "descriptions": { + "$ref": "#/definitions/descriptions" + }, + "proxy": { + "$ref": "#/definitions/anyUri" + }, + "scheme": { + "type": "string", + "pattern": ".+:.*" + } + }, + "required": [ + "scheme" + ], + "additionalProperties": true + }, + "noSecurityScheme": { + "type": "object", + "properties": { + "@type": { + "$ref": "#/definitions/type_declaration" + }, + "description": { + "$ref": "#/definitions/description" + }, + "descriptions": { + "$ref": "#/definitions/descriptions" + }, + "proxy": { + "$ref": "#/definitions/anyUri" + }, + "scheme": { + "type": "string", + "enum": [ + "nosec" + ] + } + }, + "required": [ + "scheme" + ], + "additionalProperties": true + }, + "autoSecurityScheme": { + "type": "object", + "properties": { + "@type": { + "$ref": "#/definitions/type_declaration" + }, + "description": { + "$ref": "#/definitions/description" + }, + "descriptions": { + "$ref": "#/definitions/descriptions" + }, + "proxy": { + "$ref": "#/definitions/anyUri" + }, + "scheme": { + "type": "string", + "enum": [ + "auto" + ] + } + }, + "not": { + "required": [ + "name" + ] + }, + "required": [ + "scheme" + ], + "additionalProperties": true + }, + "comboSecurityScheme": { + "oneOf": [ + { + "type": "object", + "properties": { + "@type": { + "$ref": "#/definitions/type_declaration" + }, + "description": { + "$ref": "#/definitions/description" + }, + "descriptions": { + "$ref": "#/definitions/descriptions" + }, + "proxy": { + "$ref": "#/definitions/anyUri" + }, + "scheme": { + "type": "string", + "enum": [ + "combo" + ] + }, + "oneOf": { + "type": "array", + "minItems": 2, + "items": { + "type": "string" + } + } + }, + "required": [ + "scheme", + "oneOf" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "@type": { + "$ref": "#/definitions/type_declaration" + }, + "description": { + "$ref": "#/definitions/description" + }, + "descriptions": { + "$ref": "#/definitions/descriptions" + }, + "proxy": { + "$ref": "#/definitions/anyUri" + }, + "scheme": { + "type": "string", + "enum": [ + "combo" + ] + }, + "allOf": { + "type": "array", + "minItems": 2, + "items": { + "type": "string" + } + } + }, + "required": [ + "scheme", + "allOf" + ], + "additionalProperties": true + } + ] + }, + "basicSecurityScheme": { + "type": "object", + "properties": { + "@type": { + "$ref": "#/definitions/type_declaration" + }, + "description": { + "$ref": "#/definitions/description" + }, + "descriptions": { + "$ref": "#/definitions/descriptions" + }, + "proxy": { + "$ref": "#/definitions/anyUri" + }, + "scheme": { + "type": "string", + "enum": [ + "basic" + ] + }, + "in": { + "type": "string", + "enum": [ + "header", + "query", + "body", + "cookie", + "auto" + ] + }, + "name": { + "type": "string" + } + }, + "required": [ + "scheme" + ], + "additionalProperties": true + }, + "digestSecurityScheme": { + "type": "object", + "properties": { + "@type": { + "$ref": "#/definitions/type_declaration" + }, + "description": { + "$ref": "#/definitions/description" + }, + "descriptions": { + "$ref": "#/definitions/descriptions" + }, + "proxy": { + "$ref": "#/definitions/anyUri" + }, + "scheme": { + "type": "string", + "enum": [ + "digest" + ] + }, + "qop": { + "type": "string", + "enum": [ + "auth", + "auth-int" + ] + }, + "in": { + "type": "string", + "enum": [ + "header", + "query", + "body", + "cookie", + "auto" + ] + }, + "name": { + "type": "string" + } + }, + "required": [ + "scheme" + ], + "additionalProperties": true + }, + "apiKeySecurityScheme": { + "type": "object", + "properties": { + "@type": { + "$ref": "#/definitions/type_declaration" + }, + "description": { + "$ref": "#/definitions/description" + }, + "descriptions": { + "$ref": "#/definitions/descriptions" + }, + "proxy": { + "$ref": "#/definitions/anyUri" + }, + "scheme": { + "type": "string", + "enum": [ + "apikey" + ] + }, + "in": { + "type": "string", + "enum": [ + "header", + "query", + "body", + "cookie", + "uri", + "auto" + ] + }, + "name": { + "type": "string" + } + }, + "required": [ + "scheme" + ], + "additionalProperties": true + }, + "bearerSecurityScheme": { + "type": "object", + "properties": { + "@type": { + "$ref": "#/definitions/type_declaration" + }, + "description": { + "$ref": "#/definitions/description" + }, + "descriptions": { + "$ref": "#/definitions/descriptions" + }, + "proxy": { + "$ref": "#/definitions/anyUri" + }, + "scheme": { + "type": "string", + "enum": [ + "bearer" + ] + }, + "authorization": { + "$ref": "#/definitions/anyUri" + }, + "alg": { + "type": "string" + }, + "format": { + "type": "string" + }, + "in": { + "type": "string", + "enum": [ + "header", + "query", + "body", + "cookie", + "auto" + ] + }, + "name": { + "type": "string" + } + }, + "required": [ + "scheme" + ], + "additionalProperties": true + }, + "pskSecurityScheme": { + "type": "object", + "properties": { + "@type": { + "$ref": "#/definitions/type_declaration" + }, + "description": { + "$ref": "#/definitions/description" + }, + "descriptions": { + "$ref": "#/definitions/descriptions" + }, + "proxy": { + "$ref": "#/definitions/anyUri" + }, + "scheme": { + "type": "string", + "enum": [ + "psk" + ] + }, + "identity": { + "type": "string" + } + }, + "required": [ + "scheme" + ], + "additionalProperties": true + }, + "oAuth2SecurityScheme": { + "type": "object", + "properties": { + "@type": { + "$ref": "#/definitions/type_declaration" + }, + "description": { + "$ref": "#/definitions/description" + }, + "descriptions": { + "$ref": "#/definitions/descriptions" + }, + "proxy": { + "$ref": "#/definitions/anyUri" + }, + "scheme": { + "type": "string", + "enum": [ + "oauth2" + ] + }, + "authorization": { + "$ref": "#/definitions/anyUri" + }, + "token": { + "$ref": "#/definitions/anyUri" + }, + "refresh": { + "$ref": "#/definitions/anyUri" + }, + "scopes": { + "oneOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "string" + } + ] + }, + "flow": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "string", + "enum": [ + "code", + "client" + ] + } + ] + } + }, + "required": [ + "scheme" + ], + "additionalProperties": true + }, + "securityScheme": { + "oneOf": [ + { + "$ref": "#/definitions/noSecurityScheme" + }, + { + "$ref": "#/definitions/autoSecurityScheme" + }, + { + "$ref": "#/definitions/comboSecurityScheme" + }, + { + "$ref": "#/definitions/basicSecurityScheme" + }, + { + "$ref": "#/definitions/digestSecurityScheme" + }, + { + "$ref": "#/definitions/apiKeySecurityScheme" + }, + { + "$ref": "#/definitions/bearerSecurityScheme" + }, + { + "$ref": "#/definitions/pskSecurityScheme" + }, + { + "$ref": "#/definitions/oAuth2SecurityScheme" + }, + { + "$ref": "#/definitions/additionalSecurityScheme" + } + ] + } + }, + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uri" + }, + "title": { + "$ref": "#/definitions/title" + }, + "titles": { + "$ref": "#/definitions/titles" + }, + "properties": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/property_element" + } + }, + "actions": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/action_element" + } + }, + "events": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/event_element" + } + }, + "description": { + "$ref": "#/definitions/description" + }, + "descriptions": { + "$ref": "#/definitions/descriptions" + }, + "version": { + "type": "object", + "properties": { + "instance": { + "type": "string" + } + }, + "required": [ + "instance" + ] + }, + "links": { + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/definitions/link_element" + }, + { + "$ref": "#/definitions/icon_link_element" + } + ] + } + }, + "forms": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/definitions/form_element_root" + } + }, + "base": { + "$ref": "#/definitions/anyUri" + }, + "securityDefinitions": { + "type": "object", + "minProperties": 1, + "additionalProperties": { + "$ref": "#/definitions/securityScheme" + } + }, + "schemaDefinitions": { + "type": "object", + "minProperties": 1, + "additionalProperties": { + "$ref": "#/definitions/dataSchema" + } + }, + "support": { + "$ref": "#/definitions/anyUri" + }, + "created": { + "type": "string", + "format": "date-time" + }, + "modified": { + "type": "string", + "format": "date-time" + }, + "profile": { + "oneOf": [ + { + "$ref": "#/definitions/anyUri" + }, + { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/definitions/anyUri" + } + } + ] + }, + "security": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "array", + "minItems": 1, + "items": { + "type": "string" + } + } + ] + }, + "uriVariables": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/dataSchema" + } + }, + "@type": { + "$ref": "#/definitions/type_declaration" + }, + "@context": { + "$ref": "#/definitions/thing-context" + } + }, + "required": [ + "title", + "security", + "securityDefinitions", + "@context" + ], + "additionalProperties": true +} \ No newline at end of file diff --git a/packages/modpoll-wot-modbus/modpoll_wot_modbus/schemas/tm-1.1.json b/packages/modpoll-wot-modbus/modpoll_wot_modbus/schemas/tm-1.1.json new file mode 100644 index 0000000..36bb096 --- /dev/null +++ b/packages/modpoll-wot-modbus/modpoll_wot_modbus/schemas/tm-1.1.json @@ -0,0 +1,2063 @@ +{ + "title": "Thing Model", + "version": "1.1-12-March-2025", + "description": "JSON Schema for validating Thing Models. This is automatically generated from the WoT TD Schema.", + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://www.w3.org/2022/wot/tm-schema/v1.1", + "definitions": { + "anyUri": { + "type": "string" + }, + "description": { + "type": "string" + }, + "descriptions": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "propertyNames": { + "not": { + "$ref": "#/definitions/placeholder-pattern" + } + } + }, + "title": { + "type": "string" + }, + "titles": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "propertyNames": { + "not": { + "$ref": "#/definitions/placeholder-pattern" + } + } + }, + "security": { + "oneOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "string" + } + ] + }, + "scopes": { + "oneOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "string" + } + ] + }, + "subprotocol": { + "type": "string", + "examples": [ + "longpoll", + "websub", + "sse" + ] + }, + "thing-context-td-uri-v1": { + "type": "string", + "const": "https://www.w3.org/2019/wot/td/v1" + }, + "thing-context-td-uri-v1.1": { + "type": "string", + "const": "https://www.w3.org/2022/wot/td/v1.1" + }, + "thing-context-td-uri-temp": { + "type": "string", + "const": "http://www.w3.org/ns/td" + }, + "thing-context": { + "anyOf": [ + { + "$comment": "New context URI with other vocabularies after it but not the old one", + "type": "array", + "items": [ + { + "$ref": "#/definitions/thing-context-td-uri-v1.1" + } + ], + "additionalItems": { + "anyOf": [ + { + "$ref": "#/definitions/anyUri" + }, + { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + ], + "not": { + "$ref": "#/definitions/thing-context-td-uri-v1" + } + } + }, + { + "$comment": "Only the new context URI", + "$ref": "#/definitions/thing-context-td-uri-v1.1" + }, + { + "$comment": "Old context URI, followed by the new one and possibly other vocabularies. minItems and contains are required since prefixItems does not say all items should be provided", + "type": "array", + "items": [ + { + "$ref": "#/definitions/thing-context-td-uri-v1" + }, + { + "$ref": "#/definitions/thing-context-td-uri-v1.1" + } + ], + "minItems": 2, + "additionalItems": { + "anyOf": [ + { + "$ref": "#/definitions/anyUri" + }, + { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + ] + } + }, + { + "$comment": "Old context URI, followed by possibly other vocabularies. minItems and contains are required since prefixItems does not say all items should be provided", + "type": "array", + "items": [ + { + "$ref": "#/definitions/thing-context-td-uri-v1" + } + ], + "minItems": 1, + "additionalItems": { + "anyOf": [ + { + "$ref": "#/definitions/anyUri" + }, + { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + ] + } + }, + { + "$comment": "Only the old context URI", + "$ref": "#/definitions/thing-context-td-uri-v1" + } + ] + }, + "bcp47_string": { + "type": "string", + "pattern": "^(((([A-Za-z]{2,3}(-([A-Za-z]{3}(-[A-Za-z]{3}){0,2}))?)|[A-Za-z]{4}|[A-Za-z]{5,8})(-([A-Za-z]{4}))?(-([A-Za-z]{2}|[0-9]{3}))?(-([A-Za-z0-9]{5,8}|[0-9][A-Za-z0-9]{3}))*(-([0-9A-WY-Za-wy-z](-[A-Za-z0-9]{2,8})+))*(-(x(-[A-Za-z0-9]{1,8})+))?)|(x(-[A-Za-z0-9]{1,8})+)|((en-GB-oed|i-ami|i-bnn|i-default|i-enochian|i-hak|i-klingon|i-lux|i-mingo|i-navajo|i-pwn|i-tao|i-tay|i-tsu|sgn-BE-FR|sgn-BE-NL|sgn-CH-DE)|(art-lojban|cel-gaulish|no-bok|no-nyn|zh-guoyu|zh-hakka|zh-min|zh-min-nan|zh-xiang)))$" + }, + "type_declaration": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "dataSchema-type": { + "type": "string", + "anyOf": [ + { + "enum": [ + "boolean", + "integer", + "number", + "string", + "object", + "array", + "null" + ] + }, + { + "$ref": "#/definitions/placeholder-pattern" + } + ] + }, + "dataSchema": { + "type": "object", + "properties": { + "@type": { + "$ref": "#/definitions/type_declaration" + }, + "description": { + "$ref": "#/definitions/description" + }, + "title": { + "$ref": "#/definitions/title" + }, + "descriptions": { + "$ref": "#/definitions/descriptions" + }, + "titles": { + "$ref": "#/definitions/titles" + }, + "writeOnly": { + "anyOf": [ + { + "type": "boolean" + }, + { + "$ref": "#/definitions/placeholder-pattern" + } + ] + }, + "readOnly": { + "anyOf": [ + { + "type": "boolean" + }, + { + "$ref": "#/definitions/placeholder-pattern" + } + ] + }, + "oneOf": { + "type": "array", + "items": { + "$ref": "#/definitions/dataSchema" + } + }, + "unit": { + "type": "string" + }, + "enum": { + "anyOf": [ + { + "type": "array", + "minItems": 1, + "uniqueItems": true + }, + { + "$ref": "#/definitions/placeholder-pattern" + } + ] + }, + "format": { + "type": "string" + }, + "const": {}, + "default": {}, + "contentEncoding": { + "type": "string" + }, + "contentMediaType": { + "type": "string" + }, + "type": { + "$ref": "#/definitions/dataSchema-type" + }, + "items": { + "oneOf": [ + { + "$ref": "#/definitions/dataSchema" + }, + { + "type": "array", + "items": { + "$ref": "#/definitions/dataSchema" + } + } + ] + }, + "maxItems": { + "anyOf": [ + { + "type": "integer", + "minimum": 0 + }, + { + "$ref": "#/definitions/placeholder-pattern" + } + ] + }, + "minItems": { + "anyOf": [ + { + "type": "integer", + "minimum": 0 + }, + { + "$ref": "#/definitions/placeholder-pattern" + } + ] + }, + "minimum": { + "anyOf": [ + { + "type": "number" + }, + { + "$ref": "#/definitions/placeholder-pattern" + } + ] + }, + "maximum": { + "anyOf": [ + { + "type": "number" + }, + { + "$ref": "#/definitions/placeholder-pattern" + } + ] + }, + "exclusiveMinimum": { + "type": "number" + }, + "exclusiveMaximum": { + "type": "number" + }, + "minLength": { + "anyOf": [ + { + "type": "integer", + "minimum": 0 + }, + { + "$ref": "#/definitions/placeholder-pattern" + } + ] + }, + "maxLength": { + "anyOf": [ + { + "type": "integer", + "minimum": 0 + }, + { + "$ref": "#/definitions/placeholder-pattern" + } + ] + }, + "multipleOf": { + "$ref": "#/definitions/multipleOfDefinition" + }, + "properties": { + "additionalProperties": { + "$ref": "#/definitions/dataSchema" + } + }, + "required": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "$ref": "#/definitions/placeholder-pattern" + } + ] + }, + "tm:ref": { + "$ref": "#/definitions/tm_ref" + } + }, + "propertyNames": { + "not": { + "$ref": "#/definitions/placeholder-pattern" + } + } + }, + "additionalResponsesDefinition": { + "type": "array", + "items": { + "type": "object", + "properties": { + "contentType": { + "type": "string" + }, + "schema": { + "type": "string" + }, + "success": { + "type": "boolean" + } + } + } + }, + "multipleOfDefinition": { + "anyOf": [ + { + "type": [ + "integer", + "number" + ], + "exclusiveMinimum": 0 + }, + { + "$ref": "#/definitions/placeholder-pattern" + } + ] + }, + "expectedResponse": { + "type": "object", + "properties": { + "contentType": { + "type": "string" + } + }, + "propertyNames": { + "not": { + "$ref": "#/definitions/placeholder-pattern" + } + } + }, + "form_element_base": { + "type": "object", + "properties": { + "op": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "href": { + "$ref": "#/definitions/anyUri" + }, + "contentType": { + "type": "string" + }, + "contentCoding": { + "type": "string" + }, + "subprotocol": { + "$ref": "#/definitions/subprotocol" + }, + "security": { + "$ref": "#/definitions/security" + }, + "scopes": { + "$ref": "#/definitions/scopes" + }, + "response": { + "$ref": "#/definitions/expectedResponse" + }, + "additionalResponses": { + "$ref": "#/definitions/additionalResponsesDefinition" + } + }, + "additionalProperties": true, + "propertyNames": { + "not": { + "$ref": "#/definitions/placeholder-pattern" + } + } + }, + "form_element_property": { + "allOf": [ + { + "$ref": "#/definitions/form_element_base" + } + ], + "type": "object", + "properties": { + "op": { + "oneOf": [ + { + "type": "string", + "anyOf": [ + { + "enum": [ + "readproperty", + "writeproperty", + "observeproperty", + "unobserveproperty" + ] + }, + { + "$ref": "#/definitions/placeholder-pattern" + } + ] + }, + { + "type": "array", + "items": { + "type": "string", + "anyOf": [ + { + "enum": [ + "readproperty", + "writeproperty", + "observeproperty", + "unobserveproperty" + ] + }, + { + "$ref": "#/definitions/placeholder-pattern" + } + ] + }, + "minItems": 1 + } + ] + }, + "tm:ref": { + "$ref": "#/definitions/tm_ref" + } + }, + "additionalProperties": true, + "propertyNames": { + "not": { + "$ref": "#/definitions/placeholder-pattern" + } + } + }, + "form_element_action": { + "allOf": [ + { + "$ref": "#/definitions/form_element_base" + } + ], + "type": "object", + "properties": { + "op": { + "oneOf": [ + { + "type": "string", + "anyOf": [ + { + "enum": [ + "invokeaction", + "queryaction", + "cancelaction" + ] + }, + { + "$ref": "#/definitions/placeholder-pattern" + } + ] + }, + { + "type": "array", + "items": { + "type": "string", + "anyOf": [ + { + "enum": [ + "invokeaction", + "queryaction", + "cancelaction" + ] + }, + { + "$ref": "#/definitions/placeholder-pattern" + } + ] + }, + "minItems": 1 + } + ] + }, + "tm:ref": { + "$ref": "#/definitions/tm_ref" + } + }, + "additionalProperties": true, + "propertyNames": { + "not": { + "$ref": "#/definitions/placeholder-pattern" + } + } + }, + "form_element_event": { + "allOf": [ + { + "$ref": "#/definitions/form_element_base" + } + ], + "type": "object", + "properties": { + "op": { + "oneOf": [ + { + "type": "string", + "anyOf": [ + { + "enum": [ + "subscribeevent", + "unsubscribeevent" + ] + }, + { + "$ref": "#/definitions/placeholder-pattern" + } + ] + }, + { + "type": "array", + "items": { + "type": "string", + "anyOf": [ + { + "enum": [ + "subscribeevent", + "unsubscribeevent" + ] + }, + { + "$ref": "#/definitions/placeholder-pattern" + } + ] + }, + "minItems": 1 + } + ] + }, + "tm:ref": { + "$ref": "#/definitions/tm_ref" + } + }, + "additionalProperties": true, + "propertyNames": { + "not": { + "$ref": "#/definitions/placeholder-pattern" + } + } + }, + "form_element_root": { + "allOf": [ + { + "$ref": "#/definitions/form_element_base" + } + ], + "type": "object", + "properties": { + "op": { + "oneOf": [ + { + "type": "string", + "anyOf": [ + { + "enum": [ + "readallproperties", + "writeallproperties", + "readmultipleproperties", + "writemultipleproperties", + "observeallproperties", + "unobserveallproperties", + "queryallactions", + "subscribeallevents", + "unsubscribeallevents" + ] + }, + { + "$ref": "#/definitions/placeholder-pattern" + } + ] + }, + { + "type": "array", + "items": { + "type": "string", + "anyOf": [ + { + "enum": [ + "readallproperties", + "writeallproperties", + "readmultipleproperties", + "writemultipleproperties", + "observeallproperties", + "unobserveallproperties", + "queryallactions", + "subscribeallevents", + "unsubscribeallevents" + ] + }, + { + "$ref": "#/definitions/placeholder-pattern" + } + ] + }, + "minItems": 1 + } + ] + }, + "tm:ref": { + "$ref": "#/definitions/tm_ref" + } + }, + "additionalProperties": true, + "propertyNames": { + "not": { + "$ref": "#/definitions/placeholder-pattern" + } + } + }, + "form": { + "$comment": "This is NOT for validation purposes but for automatic generation of TS types. For more info, please see: https://github.com/w3c/wot-thing-description/pull/1319#issuecomment-994950057", + "oneOf": [ + { + "$ref": "#/definitions/form_element_property" + }, + { + "$ref": "#/definitions/form_element_action" + }, + { + "$ref": "#/definitions/form_element_event" + }, + { + "$ref": "#/definitions/form_element_root" + } + ] + }, + "property_element": { + "type": "object", + "properties": { + "@type": { + "$ref": "#/definitions/type_declaration" + }, + "description": { + "$ref": "#/definitions/description" + }, + "descriptions": { + "$ref": "#/definitions/descriptions" + }, + "title": { + "$ref": "#/definitions/title" + }, + "titles": { + "$ref": "#/definitions/titles" + }, + "forms": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/definitions/form_element_property" + } + }, + "uriVariables": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/dataSchema" + }, + "propertyNames": { + "not": { + "$ref": "#/definitions/placeholder-pattern" + } + } + }, + "observable": { + "anyOf": [ + { + "type": "boolean" + }, + { + "$ref": "#/definitions/placeholder-pattern" + } + ] + }, + "writeOnly": { + "anyOf": [ + { + "type": "boolean" + }, + { + "$ref": "#/definitions/placeholder-pattern" + } + ] + }, + "readOnly": { + "anyOf": [ + { + "type": "boolean" + }, + { + "$ref": "#/definitions/placeholder-pattern" + } + ] + }, + "oneOf": { + "type": "array", + "items": { + "$ref": "#/definitions/dataSchema" + } + }, + "unit": { + "type": "string" + }, + "enum": { + "anyOf": [ + { + "type": "array", + "minItems": 1, + "uniqueItems": true + }, + { + "$ref": "#/definitions/placeholder-pattern" + } + ] + }, + "format": { + "type": "string" + }, + "const": {}, + "default": {}, + "type": { + "$ref": "#/definitions/dataSchema-type" + }, + "items": { + "oneOf": [ + { + "$ref": "#/definitions/dataSchema" + }, + { + "type": "array", + "items": { + "$ref": "#/definitions/dataSchema" + } + } + ] + }, + "maxItems": { + "anyOf": [ + { + "type": "integer", + "minimum": 0 + }, + { + "$ref": "#/definitions/placeholder-pattern" + } + ] + }, + "minItems": { + "anyOf": [ + { + "type": "integer", + "minimum": 0 + }, + { + "$ref": "#/definitions/placeholder-pattern" + } + ] + }, + "minimum": { + "anyOf": [ + { + "type": "number" + }, + { + "$ref": "#/definitions/placeholder-pattern" + } + ] + }, + "maximum": { + "anyOf": [ + { + "type": "number" + }, + { + "$ref": "#/definitions/placeholder-pattern" + } + ] + }, + "exclusiveMinimum": { + "type": "number" + }, + "exclusiveMaximum": { + "type": "number" + }, + "minLength": { + "anyOf": [ + { + "type": "integer", + "minimum": 0 + }, + { + "$ref": "#/definitions/placeholder-pattern" + } + ] + }, + "maxLength": { + "anyOf": [ + { + "type": "integer", + "minimum": 0 + }, + { + "$ref": "#/definitions/placeholder-pattern" + } + ] + }, + "multipleOf": { + "$ref": "#/definitions/multipleOfDefinition" + }, + "properties": { + "additionalProperties": { + "$ref": "#/definitions/dataSchema" + } + }, + "required": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "$ref": "#/definitions/placeholder-pattern" + } + ] + }, + "tm:ref": { + "$ref": "#/definitions/tm_ref" + } + }, + "additionalProperties": true, + "propertyNames": { + "not": { + "$ref": "#/definitions/placeholder-pattern" + } + } + }, + "action_element": { + "type": "object", + "properties": { + "@type": { + "$ref": "#/definitions/type_declaration" + }, + "description": { + "$ref": "#/definitions/description" + }, + "descriptions": { + "$ref": "#/definitions/descriptions" + }, + "title": { + "$ref": "#/definitions/title" + }, + "titles": { + "$ref": "#/definitions/titles" + }, + "forms": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/definitions/form_element_action" + } + }, + "uriVariables": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/dataSchema" + }, + "propertyNames": { + "not": { + "$ref": "#/definitions/placeholder-pattern" + } + } + }, + "input": { + "$ref": "#/definitions/dataSchema" + }, + "output": { + "$ref": "#/definitions/dataSchema" + }, + "safe": { + "anyOf": [ + { + "type": "boolean" + }, + { + "$ref": "#/definitions/placeholder-pattern" + } + ] + }, + "idempotent": { + "anyOf": [ + { + "type": "boolean" + }, + { + "$ref": "#/definitions/placeholder-pattern" + } + ] + }, + "synchronous": { + "anyOf": [ + { + "type": "boolean" + }, + { + "$ref": "#/definitions/placeholder-pattern" + } + ] + }, + "tm:ref": { + "$ref": "#/definitions/tm_ref" + } + }, + "additionalProperties": true, + "propertyNames": { + "not": { + "$ref": "#/definitions/placeholder-pattern" + } + } + }, + "event_element": { + "type": "object", + "properties": { + "@type": { + "$ref": "#/definitions/type_declaration" + }, + "description": { + "$ref": "#/definitions/description" + }, + "descriptions": { + "$ref": "#/definitions/descriptions" + }, + "title": { + "$ref": "#/definitions/title" + }, + "titles": { + "$ref": "#/definitions/titles" + }, + "forms": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/definitions/form_element_event" + } + }, + "uriVariables": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/dataSchema" + }, + "propertyNames": { + "not": { + "$ref": "#/definitions/placeholder-pattern" + } + } + }, + "subscription": { + "$ref": "#/definitions/dataSchema" + }, + "data": { + "$ref": "#/definitions/dataSchema" + }, + "dataResponse": { + "$ref": "#/definitions/dataSchema" + }, + "cancellation": { + "$ref": "#/definitions/dataSchema" + }, + "tm:ref": { + "$ref": "#/definitions/tm_ref" + } + }, + "additionalProperties": true, + "propertyNames": { + "not": { + "$ref": "#/definitions/placeholder-pattern" + } + } + }, + "base_link_element": { + "type": "object", + "properties": { + "href": { + "$ref": "#/definitions/anyUri" + }, + "type": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "anchor": { + "$ref": "#/definitions/anyUri" + }, + "hreflang": { + "anyOf": [ + { + "$ref": "#/definitions/bcp47_string" + }, + { + "type": "array", + "items": { + "$ref": "#/definitions/bcp47_string" + } + } + ] + }, + "instanceName": { + "type": "string" + } + }, + "additionalProperties": true, + "propertyNames": { + "not": { + "$ref": "#/definitions/placeholder-pattern" + } + } + }, + "link_element": { + "allOf": [ + { + "$ref": "#/definitions/base_link_element" + }, + { + "not": { + "description": "A basic link element should not contain sizes", + "type": "object", + "properties": { + "sizes": {} + }, + "required": [ + "sizes" + ] + } + }, + { + "not": { + "description": "A basic link element should not contain icon", + "properties": { + "rel": { + "anyOf": [ + { + "enum": [ + "icon" + ] + }, + { + "$ref": "#/definitions/placeholder-pattern" + } + ] + } + }, + "required": [ + "rel" + ] + } + } + ] + }, + "icon_link_element": { + "allOf": [ + { + "$ref": "#/definitions/base_link_element" + }, + { + "properties": { + "rel": { + "const": "icon" + }, + "sizes": { + "type": "string", + "pattern": "[0-9]*x[0-9]+" + } + }, + "required": [ + "rel" + ] + } + ] + }, + "additionalSecurityScheme": { + "description": "Applies to additional SecuritySchemes not defined in the WoT TD specification.", + "$comment": "Additional SecuritySchemes should always be defined via a context extension, using a prefixed value for the scheme. This prefix (e.g. 'ace', see the example below) must contain at least one character in order to reference a valid JSON-LD context extension.", + "examples": [ + { + "scheme": "ace:ACESecurityScheme", + "ace:as": "coaps://as.example.com/token", + "ace:audience": "coaps://rs.example.com", + "ace:scopes": [ + "limited", + "special" + ], + "ace:cnonce": true + } + ], + "type": "object", + "properties": { + "@type": { + "$ref": "#/definitions/type_declaration" + }, + "description": { + "$ref": "#/definitions/description" + }, + "descriptions": { + "$ref": "#/definitions/descriptions" + }, + "proxy": { + "$ref": "#/definitions/anyUri" + }, + "scheme": { + "type": "string", + "pattern": ".+:.*" + } + }, + "additionalProperties": true, + "propertyNames": { + "not": { + "$ref": "#/definitions/placeholder-pattern" + } + } + }, + "noSecurityScheme": { + "type": "object", + "properties": { + "@type": { + "$ref": "#/definitions/type_declaration" + }, + "description": { + "$ref": "#/definitions/description" + }, + "descriptions": { + "$ref": "#/definitions/descriptions" + }, + "proxy": { + "$ref": "#/definitions/anyUri" + }, + "scheme": { + "type": "string", + "anyOf": [ + { + "enum": [ + "nosec" + ] + }, + { + "$ref": "#/definitions/placeholder-pattern" + } + ] + }, + "tm:ref": { + "$ref": "#/definitions/tm_ref" + } + }, + "additionalProperties": true, + "propertyNames": { + "not": { + "$ref": "#/definitions/placeholder-pattern" + } + } + }, + "autoSecurityScheme": { + "type": "object", + "properties": { + "@type": { + "$ref": "#/definitions/type_declaration" + }, + "description": { + "$ref": "#/definitions/description" + }, + "descriptions": { + "$ref": "#/definitions/descriptions" + }, + "proxy": { + "$ref": "#/definitions/anyUri" + }, + "scheme": { + "type": "string", + "anyOf": [ + { + "enum": [ + "auto" + ] + }, + { + "$ref": "#/definitions/placeholder-pattern" + } + ] + } + }, + "not": { + "required": [ + "name" + ] + }, + "additionalProperties": true, + "propertyNames": { + "not": { + "$ref": "#/definitions/placeholder-pattern" + } + } + }, + "comboSecurityScheme": { + "oneOf": [ + { + "type": "object", + "properties": { + "@type": { + "$ref": "#/definitions/type_declaration" + }, + "description": { + "$ref": "#/definitions/description" + }, + "descriptions": { + "$ref": "#/definitions/descriptions" + }, + "proxy": { + "$ref": "#/definitions/anyUri" + }, + "scheme": { + "type": "string", + "anyOf": [ + { + "enum": [ + "combo" + ] + }, + { + "$ref": "#/definitions/placeholder-pattern" + } + ] + }, + "oneOf": { + "type": "array", + "minItems": 2, + "items": { + "type": "string" + } + }, + "tm:ref": { + "$ref": "#/definitions/tm_ref" + } + }, + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "@type": { + "$ref": "#/definitions/type_declaration" + }, + "description": { + "$ref": "#/definitions/description" + }, + "descriptions": { + "$ref": "#/definitions/descriptions" + }, + "proxy": { + "$ref": "#/definitions/anyUri" + }, + "scheme": { + "type": "string", + "anyOf": [ + { + "enum": [ + "combo" + ] + }, + { + "$ref": "#/definitions/placeholder-pattern" + } + ] + }, + "allOf": { + "type": "array", + "minItems": 2, + "items": { + "type": "string" + } + }, + "tm:ref": { + "$ref": "#/definitions/tm_ref" + } + }, + "additionalProperties": true + } + ] + }, + "basicSecurityScheme": { + "type": "object", + "properties": { + "@type": { + "$ref": "#/definitions/type_declaration" + }, + "description": { + "$ref": "#/definitions/description" + }, + "descriptions": { + "$ref": "#/definitions/descriptions" + }, + "proxy": { + "$ref": "#/definitions/anyUri" + }, + "scheme": { + "type": "string", + "anyOf": [ + { + "enum": [ + "basic" + ] + }, + { + "$ref": "#/definitions/placeholder-pattern" + } + ] + }, + "in": { + "type": "string", + "anyOf": [ + { + "enum": [ + "header", + "query", + "body", + "cookie", + "auto" + ] + }, + { + "$ref": "#/definitions/placeholder-pattern" + } + ] + }, + "name": { + "type": "string" + }, + "tm:ref": { + "$ref": "#/definitions/tm_ref" + } + }, + "additionalProperties": true, + "propertyNames": { + "not": { + "$ref": "#/definitions/placeholder-pattern" + } + } + }, + "digestSecurityScheme": { + "type": "object", + "properties": { + "@type": { + "$ref": "#/definitions/type_declaration" + }, + "description": { + "$ref": "#/definitions/description" + }, + "descriptions": { + "$ref": "#/definitions/descriptions" + }, + "proxy": { + "$ref": "#/definitions/anyUri" + }, + "scheme": { + "type": "string", + "anyOf": [ + { + "enum": [ + "digest" + ] + }, + { + "$ref": "#/definitions/placeholder-pattern" + } + ] + }, + "qop": { + "type": "string", + "anyOf": [ + { + "enum": [ + "auth", + "auth-int" + ] + }, + { + "$ref": "#/definitions/placeholder-pattern" + } + ] + }, + "in": { + "type": "string", + "anyOf": [ + { + "enum": [ + "header", + "query", + "body", + "cookie", + "auto" + ] + }, + { + "$ref": "#/definitions/placeholder-pattern" + } + ] + }, + "name": { + "type": "string" + }, + "tm:ref": { + "$ref": "#/definitions/tm_ref" + } + }, + "additionalProperties": true, + "propertyNames": { + "not": { + "$ref": "#/definitions/placeholder-pattern" + } + } + }, + "apiKeySecurityScheme": { + "type": "object", + "properties": { + "@type": { + "$ref": "#/definitions/type_declaration" + }, + "description": { + "$ref": "#/definitions/description" + }, + "descriptions": { + "$ref": "#/definitions/descriptions" + }, + "proxy": { + "$ref": "#/definitions/anyUri" + }, + "scheme": { + "type": "string", + "anyOf": [ + { + "enum": [ + "apikey" + ] + }, + { + "$ref": "#/definitions/placeholder-pattern" + } + ] + }, + "in": { + "type": "string", + "anyOf": [ + { + "enum": [ + "header", + "query", + "body", + "cookie", + "uri", + "auto" + ] + }, + { + "$ref": "#/definitions/placeholder-pattern" + } + ] + }, + "name": { + "type": "string" + }, + "tm:ref": { + "$ref": "#/definitions/tm_ref" + } + }, + "additionalProperties": true, + "propertyNames": { + "not": { + "$ref": "#/definitions/placeholder-pattern" + } + } + }, + "bearerSecurityScheme": { + "type": "object", + "properties": { + "@type": { + "$ref": "#/definitions/type_declaration" + }, + "description": { + "$ref": "#/definitions/description" + }, + "descriptions": { + "$ref": "#/definitions/descriptions" + }, + "proxy": { + "$ref": "#/definitions/anyUri" + }, + "scheme": { + "type": "string", + "anyOf": [ + { + "enum": [ + "bearer" + ] + }, + { + "$ref": "#/definitions/placeholder-pattern" + } + ] + }, + "authorization": { + "$ref": "#/definitions/anyUri" + }, + "alg": { + "type": "string" + }, + "format": { + "type": "string" + }, + "in": { + "type": "string", + "anyOf": [ + { + "enum": [ + "header", + "query", + "body", + "cookie", + "auto" + ] + }, + { + "$ref": "#/definitions/placeholder-pattern" + } + ] + }, + "name": { + "type": "string" + }, + "tm:ref": { + "$ref": "#/definitions/tm_ref" + } + }, + "additionalProperties": true, + "propertyNames": { + "not": { + "$ref": "#/definitions/placeholder-pattern" + } + } + }, + "pskSecurityScheme": { + "type": "object", + "properties": { + "@type": { + "$ref": "#/definitions/type_declaration" + }, + "description": { + "$ref": "#/definitions/description" + }, + "descriptions": { + "$ref": "#/definitions/descriptions" + }, + "proxy": { + "$ref": "#/definitions/anyUri" + }, + "scheme": { + "type": "string", + "anyOf": [ + { + "enum": [ + "psk" + ] + }, + { + "$ref": "#/definitions/placeholder-pattern" + } + ] + }, + "identity": { + "type": "string" + }, + "tm:ref": { + "$ref": "#/definitions/tm_ref" + } + }, + "additionalProperties": true, + "propertyNames": { + "not": { + "$ref": "#/definitions/placeholder-pattern" + } + } + }, + "oAuth2SecurityScheme": { + "type": "object", + "properties": { + "@type": { + "$ref": "#/definitions/type_declaration" + }, + "description": { + "$ref": "#/definitions/description" + }, + "descriptions": { + "$ref": "#/definitions/descriptions" + }, + "proxy": { + "$ref": "#/definitions/anyUri" + }, + "scheme": { + "type": "string", + "anyOf": [ + { + "enum": [ + "oauth2" + ] + }, + { + "$ref": "#/definitions/placeholder-pattern" + } + ] + }, + "authorization": { + "$ref": "#/definitions/anyUri" + }, + "token": { + "$ref": "#/definitions/anyUri" + }, + "refresh": { + "$ref": "#/definitions/anyUri" + }, + "scopes": { + "oneOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "string" + } + ] + }, + "flow": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "string", + "anyOf": [ + { + "enum": [ + "code", + "client" + ] + }, + { + "$ref": "#/definitions/placeholder-pattern" + } + ] + } + ] + }, + "tm:ref": { + "$ref": "#/definitions/tm_ref" + } + }, + "additionalProperties": true, + "propertyNames": { + "not": { + "$ref": "#/definitions/placeholder-pattern" + } + } + }, + "securityScheme": { + "anyOf": [ + { + "$ref": "#/definitions/noSecurityScheme" + }, + { + "$ref": "#/definitions/autoSecurityScheme" + }, + { + "$ref": "#/definitions/comboSecurityScheme" + }, + { + "$ref": "#/definitions/basicSecurityScheme" + }, + { + "$ref": "#/definitions/digestSecurityScheme" + }, + { + "$ref": "#/definitions/apiKeySecurityScheme" + }, + { + "$ref": "#/definitions/bearerSecurityScheme" + }, + { + "$ref": "#/definitions/pskSecurityScheme" + }, + { + "$ref": "#/definitions/oAuth2SecurityScheme" + }, + { + "$ref": "#/definitions/additionalSecurityScheme" + } + ] + }, + "tm_type_declaration": { + "oneOf": [ + { + "type": "string", + "const": "tm:ThingModel" + }, + { + "type": "array", + "items": { + "type": "string" + }, + "contains": { + "const": "tm:ThingModel" + } + } + ] + }, + "placeholder-pattern": { + "type": "string", + "pattern": "^.*[{]{2}[ -~]+[}]{2}.*$" + }, + "tm_optional": { + "type": "array", + "items": { + "$comment": "this first checks for the general structure of /properties/myProp and then prohibits using / 3 times", + "allOf": [ + { + "type": "string", + "pattern": "^((/properties/)|(/actions/)|(/events/))(([^/]))", + "$comment": "regex tests available at https://regex101.com/r/UgOzrJ/1" + }, + { + "not": { + "type": "string", + "pattern": "(/)(.*/){2}", + "$comment": "regex tests available at https://regex101.com/r/r7vB0r/2" + } + } + ] + } + }, + "tm_ref": { + "type": "string", + "format": "uri-reference" + } + }, + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "title": { + "$ref": "#/definitions/title" + }, + "titles": { + "$ref": "#/definitions/titles" + }, + "properties": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/property_element" + }, + "propertyNames": { + "not": { + "$ref": "#/definitions/placeholder-pattern" + } + } + }, + "actions": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/action_element" + }, + "propertyNames": { + "not": { + "$ref": "#/definitions/placeholder-pattern" + } + } + }, + "events": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/event_element" + }, + "propertyNames": { + "not": { + "$ref": "#/definitions/placeholder-pattern" + } + } + }, + "description": { + "$ref": "#/definitions/description" + }, + "descriptions": { + "$ref": "#/definitions/descriptions" + }, + "version": { + "anyOf": [ + { + "type": "object", + "properties": { + "model": { + "type": "string" + } + }, + "propertyNames": { + "not": { + "$ref": "#/definitions/placeholder-pattern" + } + }, + "not": { + "type": "object", + "properties": { + "instance": { + "type": "string" + } + }, + "required": [ + "instance" + ] + } + }, + { + "$ref": "#/definitions/placeholder-pattern" + } + ] + }, + "links": { + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/definitions/link_element" + }, + { + "$ref": "#/definitions/icon_link_element" + } + ] + } + }, + "forms": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/definitions/form_element_root" + } + }, + "base": { + "$ref": "#/definitions/anyUri" + }, + "securityDefinitions": { + "type": "object", + "minProperties": 1, + "additionalProperties": { + "$ref": "#/definitions/securityScheme" + }, + "propertyNames": { + "not": { + "$ref": "#/definitions/placeholder-pattern" + } + } + }, + "schemaDefinitions": { + "type": "object", + "minProperties": 1, + "additionalProperties": { + "$ref": "#/definitions/dataSchema" + }, + "propertyNames": { + "not": { + "$ref": "#/definitions/placeholder-pattern" + } + } + }, + "support": { + "$ref": "#/definitions/anyUri" + }, + "created": { + "type": "string" + }, + "modified": { + "type": "string" + }, + "profile": { + "oneOf": [ + { + "$ref": "#/definitions/anyUri" + }, + { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/definitions/anyUri" + } + } + ] + }, + "security": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "array", + "minItems": 1, + "items": { + "type": "string" + } + } + ] + }, + "uriVariables": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/dataSchema" + }, + "propertyNames": { + "not": { + "$ref": "#/definitions/placeholder-pattern" + } + } + }, + "@type": { + "$ref": "#/definitions/tm_type_declaration" + }, + "@context": { + "$ref": "#/definitions/thing-context" + }, + "tm:optional": { + "$ref": "#/definitions/tm_optional" + } + }, + "additionalProperties": true, + "propertyNames": { + "not": { + "$ref": "#/definitions/placeholder-pattern" + } + }, + "required": [ + "@context", + "@type" + ] +} \ No newline at end of file diff --git a/packages/modpoll-wot-modbus/modpoll_wot_modbus/validation.py b/packages/modpoll-wot-modbus/modpoll_wot_modbus/validation.py new file mode 100644 index 0000000..aa5a6df --- /dev/null +++ b/packages/modpoll-wot-modbus/modpoll_wot_modbus/validation.py @@ -0,0 +1,27 @@ +"""Offline W3C TM/TD JSON Schema validation.""" + +import json +from functools import lru_cache +from importlib.resources import files + +from jsonschema import Draft7Validator + + +class ModelValidationError(ValueError): + """The supplied JSON document does not match the selected schema.""" + + +@lru_cache(maxsize=2) +def _validator(kind): + if kind not in ("tm", "td"): + raise ValueError("kind must be 'tm' or 'td'") + schema = json.loads(files("modpoll_wot_modbus").joinpath("schemas", f"{kind}-1.1.json").read_text(encoding="utf-8")) + return Draft7Validator(schema) + + +def validate_standard(document, *, kind): + """Validate standard structure; project semantics require separate checks.""" + error = next(_validator(kind).iter_errors(document), None) + if error is not None: + path = "/" + "/".join(str(part).replace("~", "~0").replace("/", "~1") for part in error.absolute_path) + raise ModelValidationError(f"{path}: {error.message}") diff --git a/packages/modpoll-wot-modbus/pyproject.toml b/packages/modpoll-wot-modbus/pyproject.toml index 95090dd..80498c1 100644 --- a/packages/modpoll-wot-modbus/pyproject.toml +++ b/packages/modpoll-wot-modbus/pyproject.toml @@ -7,3 +7,7 @@ name = "modpoll-wot-modbus" version = "0.1.0a1" description = "Modpoll WoT Modbus configuration support" requires-python = ">=3.10" +dependencies = ["jsonschema>=4.18,<5"] + +[tool.setuptools.package-data] +modpoll_wot_modbus = ["schemas/*.json"] diff --git a/packages/modpoll-wot-modbus/tests/test_validation.py b/packages/modpoll-wot-modbus/tests/test_validation.py new file mode 100644 index 0000000..1ca926e --- /dev/null +++ b/packages/modpoll-wot-modbus/tests/test_validation.py @@ -0,0 +1,20 @@ +import unittest + +from modpoll_wot_modbus.validation import ModelValidationError, validate_standard + + +class StandardValidationTests(unittest.TestCase): + def test_tm_valid(self): + validate_standard({"@context": "https://www.w3.org/2022/wot/td/v1.1", "@type": "tm:ThingModel", "title": "Meter", "properties": {"power": {"type": "number"}}}, kind="tm") + + def test_missing_title_rejected(self): + with self.assertRaises(ModelValidationError): + validate_standard({"@context": "https://www.w3.org/2022/wot/td/v1.1"}, kind="tm") + + def test_wrong_kind(self): + with self.assertRaises(ValueError): + validate_standard({}, kind="other") + + def test_td_requires_security(self): + with self.assertRaises(ModelValidationError): + validate_standard({"@context": "https://www.w3.org/2022/wot/td/v1.1", "title": "Meter"}, kind="td") From ca7aad51efa348b7560897ac9770e54a95760a67 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 28 Oct 2026 17:27:08 +0800 Subject: [PATCH 3/5] feat: expand acquisition contract and add modsim model fixture --- packages/modpoll-wot-modbus/CONTRACT.md | 35 ++ packages/modpoll-wot-modbus/README.md | 25 +- .../modpoll_wot_modbus/acquisition.py | 97 +++-- .../modpoll_wot_modbus/loading.py | 51 +++ .../schemas/acquisition-0.1.json | 94 +++++ .../modpoll_wot_modbus/validation.py | 6 +- .../tests/fixtures/modsim.tm.json | 375 ++++++++++++++++++ .../tests/test_acquisition.py | 2 +- .../modpoll-wot-modbus/tests/test_loading.py | 71 ++++ 9 files changed, 708 insertions(+), 48 deletions(-) create mode 100644 packages/modpoll-wot-modbus/CONTRACT.md create mode 100644 packages/modpoll-wot-modbus/modpoll_wot_modbus/loading.py create mode 100644 packages/modpoll-wot-modbus/modpoll_wot_modbus/schemas/acquisition-0.1.json create mode 100644 packages/modpoll-wot-modbus/tests/fixtures/modsim.tm.json create mode 100644 packages/modpoll-wot-modbus/tests/test_loading.py diff --git a/packages/modpoll-wot-modbus/CONTRACT.md b/packages/modpoll-wot-modbus/CONTRACT.md new file mode 100644 index 0000000..4d498f3 --- /dev/null +++ b/packages/modpoll-wot-modbus/CONTRACT.md @@ -0,0 +1,35 @@ +# Experimental acquisition contract 0.1 + +This contract is for Modpoll configuration replacement. It is not a W3C Modbus +binding and does not claim support for arbitrary TM/TD. W3C structural validity +and executable acquisition validity are checked separately, using local schemas. +The namespace is reserved for this project; its documentation is not published yet. + +| 1.x CSV field | Contract 0.1 | +| --- | --- | +| device name | TM/TD title; preserve effective output name | +| Unit ID | Explicit load_model unit_id parameter, default 1, range 1–254 (legacy range) | +| poll table/start/count | acquisition table/start/quantity; literal PDU addresses | +| poll endian | byteOrder BE_BE, BE_LE, LE_BE, LE_LE | +| ref name | property key and member property reference | +| ref address | register offset from poll start; bit tables use eight-bit group offsets | +| ref `address:bit` | member bit, 0–15, register bool only | +| ref dtype | integer, float, bool/bool8/bool16, stringN | +| ref rw | standard readOnly/writeOnly; neither means rw | +| ref unit | standard property unit | +| ref scale | member multiplier; decimal multiplier, never inferred as divisor | + +Block order, boundaries and gaps are preserved. Coils and DI count physical bits; +legacy bit-member offsets count eight-bit groups. This asymmetry is explicit to +preserve 1.x behavior. Register offsets count 16-bit registers; stringN lengths +count bytes. Read-only tables require readOnly, correcting the sample CSV's rw +labels rather than promising unsupported writes. Shared backing addresses are +permitted; duplicate property membership is rejected. No automatic coalescing, +TM inheritance, placeholder substitution, remote context resolution or protocol +execution is performed by this layer. Unsupported semantics are deliberately rejected or excluded; runtime integration +and instantiation of protocol forms remain subsequent work. + +The modsim fixture is an explicitly authored mapping of modpoll/examples/modsim.csv, +including its full 44-register holding group and 37 output properties. It does not +claim the default server is generated from the fixture. Standard schema success +alone does not verify decoding, write commands or device behavior. diff --git a/packages/modpoll-wot-modbus/README.md b/packages/modpoll-wot-modbus/README.md index d533ac6..0fc1db3 100644 --- a/packages/modpoll-wot-modbus/README.md +++ b/packages/modpoll-wot-modbus/README.md @@ -1,18 +1,23 @@ # modpoll-wot-modbus (experimental) -This initial package parses a deliberately small experimental acquisition-group -subset. It validates TM/TD against bundled W3C 1.1 schemas but does not fetch contexts, decode registers, read -or write devices, or integrate with modpoll. It is not ready for release. +An independently packaged TM/TD configuration interpreter under development. +See CONTRACT.md for the executable 0.1 subset and 1.x field mapping. -The supported subset describes holding and input register groups with numeric -members and four byte/word orders. Coils, discrete inputs, bit selectors, -strings, scaling and write behavior are not implemented. Unknown member and -group fields are rejected. The vocabulary and format may change. - -Run the local unit checks from the repository root: +The loader validates bundled W3C TM/TD 1.1 schemas, the project extension +schema, and acquisition semantics. It does not execute Modbus requests. +```python +from modpoll_wot_modbus.loading import load_model +model = load_model("tests/fixtures/modsim.tm.json", kind="tm", unit_id=1) ``` + +Run checks from the repository root: + +```sh PYTHONPATH=packages/modpoll-wot-modbus python -m unittest discover -s packages/modpoll-wot-modbus/tests ``` -The bundled W3C schemas identify themselves as version 1.1-12-March-2025 and were retrieved from the W3C TD and TM schema URLs. Standard schema validation and acquisition semantic checks are separate; neither establishes device correctness. +The bundled W3C schemas identify themselves as 1.1-12-March-2025, retrieved from +https://www.w3.org/2022/wot/td-schema/v1.1 and +https://www.w3.org/2022/wot/tm-schema/v1.1. Standard validation does not +establish device correctness. diff --git a/packages/modpoll-wot-modbus/modpoll_wot_modbus/acquisition.py b/packages/modpoll-wot-modbus/modpoll_wot_modbus/acquisition.py index ee676ee..e627f3c 100644 --- a/packages/modpoll-wot-modbus/modpoll_wot_modbus/acquisition.py +++ b/packages/modpoll-wot-modbus/modpoll_wot_modbus/acquisition.py @@ -1,15 +1,11 @@ -"""Parse the experimental register acquisition subset. - -This module does not validate a WoT TM/TD or execute Modbus operations. -Addresses and offsets are zero-based literal register addresses. -""" - +"""Version 0.1 acquisition contract; no transport or codec dependency.""" from dataclasses import dataclass -from typing import Mapping +from decimal import Decimal +import math class AcquisitionError(ValueError): - """An acquisition group cannot be interpreted safely.""" + """Invalid or unsupported acquisition semantics.""" @dataclass(frozen=True) @@ -17,6 +13,11 @@ class Member: property: str offset: int data_type: str + bit: int | None = None + multiplier: Decimal | None = None + readable: bool = True + writable: bool = False + unit: str | None = None @dataclass(frozen=True) @@ -28,9 +29,9 @@ class Group: members: tuple[Member, ...] -_WIDTH = {"int16": 1, "uint16": 1, "float16": 1, - "int32": 2, "uint32": 2, "float32": 2, - "int64": 4, "uint64": 4, "float64": 4} +WIDTHS = {"int16": 1, "uint16": 1, "float16": 1, "int32": 2, + "uint32": 2, "float32": 2, "int64": 4, "uint64": 4, + "float64": 4, "bool": 1, "bool8": 1, "bool16": 2} def _integer(value, path): @@ -39,17 +40,14 @@ def _integer(value, path): return value -def _fields(value, fields, path): +def _fields(value, required, path, optional=frozenset()): if not isinstance(value, dict): raise AcquisitionError(f"{path}: expected object") - missing = fields - value.keys() - extra = value.keys() - fields - if missing or extra: - raise AcquisitionError(f"{path}: missing {sorted(missing)}, unknown {sorted(extra)}") + if required - value.keys() or value.keys() - required - optional: + raise AcquisitionError(f"{path}: missing or unsupported fields") -def parse_acquisition(document: Mapping) -> tuple[Group, ...]: - """Parse only the experimental register subset; reject unsupported data.""" +def parse_acquisition(document) -> tuple[Group, ...]: if not isinstance(document, dict): raise AcquisitionError("document: expected object") properties = document.get("properties") @@ -58,40 +56,71 @@ def parse_acquisition(document: Mapping) -> tuple[Group, ...]: raise AcquisitionError("properties: expected nonempty object") if not isinstance(groups, list) or not groups: raise AcquisitionError("modpoll:acquisition: expected nonempty array") - parsed = [] - seen = set() + parsed, seen = [], set() for i, group in enumerate(groups): path = f"modpoll:acquisition[{i}]" _fields(group, {"modpoll:table", "modpoll:start", "modpoll:quantity", "modpoll:byteOrder", "modpoll:members"}, path) table = group["modpoll:table"] - if table not in ("holding_register", "input_register"): - raise AcquisitionError(f"{path}: unsupported register table") + if table not in ("coil", "discrete_input", "holding_register", "input_register"): + raise AcquisitionError(f"{path}: unsupported table") + bits = table in ("coil", "discrete_input") start = _integer(group["modpoll:start"], path + ".start") quantity = _integer(group["modpoll:quantity"], path + ".quantity") - if not 0 <= start <= 65535 or not 1 <= quantity <= 125 or start + quantity > 65536: - raise AcquisitionError(f"{path}: invalid register range") + if not 0 <= start <= 65535 or not 1 <= quantity <= (2000 if bits else 125) or start + quantity > 65536: + raise AcquisitionError(f"{path}: invalid range") order = group["modpoll:byteOrder"] if order not in ("BE_BE", "BE_LE", "LE_BE", "LE_LE"): raise AcquisitionError(f"{path}: invalid byte order") members = group["modpoll:members"] if not isinstance(members, list) or not members: raise AcquisitionError(f"{path}.members: expected nonempty array") - parsed_members = [] + result = [] for j, member in enumerate(members): mp = f"{path}.members[{j}]" - _fields(member, {"modpoll:property", "modpoll:offset", "modpoll:dataType"}, mp) + _fields(member, {"modpoll:property", "modpoll:offset", "modpoll:dataType"}, mp, {"modpoll:bit", "modpoll:multiplier"}) name = member["modpoll:property"] - if not isinstance(name, str) or name not in properties: - raise AcquisitionError(f"{mp}: unknown property") - if name in seen: - raise AcquisitionError(f"{mp}: duplicate property {name!r}") + if not isinstance(name, str) or name not in properties or name in seen: + raise AcquisitionError(f"{mp}: unknown or duplicate property") + prop = properties[name] + if not isinstance(prop, dict): + raise AcquisitionError(f"{mp}: invalid property schema") dtype = member["modpoll:dataType"] - if not isinstance(dtype, str) or dtype not in _WIDTH: + if not isinstance(dtype, str): + raise AcquisitionError(f"{mp}: invalid data type") + width = WIDTHS.get(dtype) + if dtype.startswith("string") and dtype[6:].isdigit() and int(dtype[6:]) > 0: + width = math.ceil(int(dtype[6:]) / 2) + if width is None: raise AcquisitionError(f"{mp}: unsupported data type") offset = _integer(member["modpoll:offset"], mp + ".offset") - if offset < 0 or offset + _WIDTH[dtype] > quantity: + # Legacy bit-table offsets count eight-bit groups, relative to poll start. + if offset < 0 or offset + width > (math.ceil(quantity / 8) if bits else quantity): raise AcquisitionError(f"{mp}: member outside group") + bit = member.get("modpoll:bit") + if "modpoll:bit" in member and (bits or dtype != "bool" or type(bit) is not int or not 0 <= bit <= 15): + raise AcquisitionError(f"{mp}: invalid register bit selector") + for flag in ("readOnly", "writeOnly"): + if flag in prop and type(prop[flag]) is not bool: + raise AcquisitionError(f"{mp}: invalid {flag}") + if prop.get("readOnly") and prop.get("writeOnly"): + raise AcquisitionError(f"{mp}: conflicting access flags") + readable, writable = not prop.get("writeOnly", False), not prop.get("readOnly", False) + if table in ("discrete_input", "input_register") and writable: + raise AcquisitionError(f"{mp}: read-only table must declare readOnly") + multiplier = None + if "modpoll:multiplier" in member: + value = member["modpoll:multiplier"] + if type(value) not in (int, float, str): + raise AcquisitionError(f"{mp}: invalid multiplier") + try: + multiplier = Decimal(str(value)) + except Exception as exc: + raise AcquisitionError(f"{mp}: invalid multiplier") from exc + if not multiplier.is_finite(): + raise AcquisitionError(f"{mp}: non-finite multiplier") + result.append(Member(name, offset, dtype, bit, multiplier, readable, writable, prop.get("unit"))) seen.add(name) - parsed_members.append(Member(name, offset, dtype)) - parsed.append(Group(table, start, quantity, order, tuple(parsed_members))) + parsed.append(Group(table, start, quantity, order, tuple(result))) + if seen != properties.keys(): + raise AcquisitionError("properties: each property must have an acquisition member") return tuple(parsed) diff --git a/packages/modpoll-wot-modbus/modpoll_wot_modbus/loading.py b/packages/modpoll-wot-modbus/modpoll_wot_modbus/loading.py new file mode 100644 index 0000000..0f9177a --- /dev/null +++ b/packages/modpoll-wot-modbus/modpoll_wot_modbus/loading.py @@ -0,0 +1,51 @@ +"""Load a bounded JSON document and validate its executable contract.""" +import json +from dataclasses import dataclass +from pathlib import Path + +from .acquisition import AcquisitionError, Group, parse_acquisition +from .validation import validate_standard + +CONTEXT = "https://top-maker.github.io/modpoll/vocab/wot-modbus#" + + +@dataclass(frozen=True) +class Model: + title: str + kind: str + groups: tuple[Group, ...] + unit_id: int + + +def _pairs(pairs): + value = {} + for key, item in pairs: + if key in value: + raise AcquisitionError(f"duplicate JSON key: {key}") + value[key] = item + return value + + +def load_model(path, *, kind, unit_id=1): + if type(unit_id) is not int or not 1 <= unit_id <= 254: + raise AcquisitionError("unit_id: expected integer between 1 and 254") + path = Path(path) + try: + with path.open('rb') as source: + payload = source.read(1048577) + if len(payload) > 1048576: + raise AcquisitionError("configuration exceeds 1 MiB") + document = json.loads(payload, object_pairs_hook=_pairs, + parse_constant=lambda value: (_ for _ in ()).throw(AcquisitionError(f"invalid number: {value}"))) + validate_standard(document, kind=kind) + validate_standard(document, kind="acquisition") + contexts = document.get('@context', []) + if not isinstance(contexts, list) or not any(isinstance(c, dict) and c.get('modpoll') == CONTEXT for c in contexts): + raise AcquisitionError("@context: missing supported modpoll vocabulary") + if document.get('modpoll:version') != '0.1': + raise AcquisitionError("modpoll:version: expected 0.1") + if document.get('actions') or document.get('events') or document.get('links'): + raise AcquisitionError("actions, events and links are not executable in version 0.1") + return Model(document['title'], kind, parse_acquisition(document), unit_id) + except (ValueError, OSError, RecursionError) as exc: + raise AcquisitionError(f"{path}: {exc}") from exc diff --git a/packages/modpoll-wot-modbus/modpoll_wot_modbus/schemas/acquisition-0.1.json b/packages/modpoll-wot-modbus/modpoll_wot_modbus/schemas/acquisition-0.1.json new file mode 100644 index 0000000..e0f0d71 --- /dev/null +++ b/packages/modpoll-wot-modbus/modpoll_wot_modbus/schemas/acquisition-0.1.json @@ -0,0 +1,94 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "required": [ + "modpoll:version", + "modpoll:acquisition" + ], + "properties": { + "modpoll:version": { + "const": "0.1" + }, + "modpoll:acquisition": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "required": [ + "modpoll:table", + "modpoll:start", + "modpoll:quantity", + "modpoll:byteOrder", + "modpoll:members" + ], + "additionalProperties": false, + "properties": { + "modpoll:table": { + "enum": [ + "coil", + "discrete_input", + "input_register", + "holding_register" + ] + }, + "modpoll:start": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "modpoll:quantity": { + "type": "integer", + "minimum": 1, + "maximum": 2000 + }, + "modpoll:byteOrder": { + "enum": [ + "BE_BE", + "BE_LE", + "LE_BE", + "LE_LE" + ] + }, + "modpoll:members": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "required": [ + "modpoll:property", + "modpoll:offset", + "modpoll:dataType" + ], + "additionalProperties": false, + "properties": { + "modpoll:property": { + "type": "string", + "minLength": 1 + }, + "modpoll:offset": { + "type": "integer", + "minimum": 0 + }, + "modpoll:dataType": { + "type": "string", + "pattern": "^(u?int(16|32|64)|float(16|32|64)|bool(8|16)?|string[1-9][0-9]*)$" + }, + "modpoll:bit": { + "type": "integer", + "minimum": 0, + "maximum": 15 + }, + "modpoll:multiplier": { + "type": [ + "number", + "string" + ] + } + } + } + } + } + } + } + } +} diff --git a/packages/modpoll-wot-modbus/modpoll_wot_modbus/validation.py b/packages/modpoll-wot-modbus/modpoll_wot_modbus/validation.py index aa5a6df..438ce31 100644 --- a/packages/modpoll-wot-modbus/modpoll_wot_modbus/validation.py +++ b/packages/modpoll-wot-modbus/modpoll_wot_modbus/validation.py @@ -11,11 +11,11 @@ class ModelValidationError(ValueError): """The supplied JSON document does not match the selected schema.""" -@lru_cache(maxsize=2) +@lru_cache(maxsize=3) def _validator(kind): - if kind not in ("tm", "td"): + if kind not in ("tm", "td", "acquisition"): raise ValueError("kind must be 'tm' or 'td'") - schema = json.loads(files("modpoll_wot_modbus").joinpath("schemas", f"{kind}-1.1.json").read_text(encoding="utf-8")) + schema = json.loads(files("modpoll_wot_modbus").joinpath("schemas", ("acquisition-0.1.json" if kind == "acquisition" else f"{kind}-1.1.json")).read_text(encoding="utf-8")) return Draft7Validator(schema) diff --git a/packages/modpoll-wot-modbus/tests/fixtures/modsim.tm.json b/packages/modpoll-wot-modbus/tests/fixtures/modsim.tm.json new file mode 100644 index 0000000..2f2bf8e --- /dev/null +++ b/packages/modpoll-wot-modbus/tests/fixtures/modsim.tm.json @@ -0,0 +1,375 @@ +{ + "@context": [ + "https://www.w3.org/2022/wot/td/v1.1", + { + "modpoll": "https://top-maker.github.io/modpoll/vocab/wot-modbus#" + } + ], + "@type": "tm:ThingModel", + "title": "modsim01", + "modpoll:version": "0.1", + "properties": { + "coil01-08": { + "type": "array", + "items": { + "type": "boolean" + } + }, + "coil09-16": { + "type": "array", + "items": { + "type": "boolean" + } + }, + "di01-08": { + "type": "array", + "items": { + "type": "boolean" + }, + "readOnly": true + }, + "di09-16": { + "type": "array", + "items": { + "type": "boolean" + }, + "readOnly": true + }, + "input_reg01": { + "type": "number", + "readOnly": true + }, + "input_reg02": { + "type": "number", + "readOnly": true + }, + "input_reg03": { + "type": "number", + "readOnly": true + }, + "input_reg04": { + "type": "number", + "readOnly": true + }, + "input_reg05": { + "type": "number", + "readOnly": true + }, + "input_reg06": { + "type": "number", + "readOnly": true + }, + "input_reg07": { + "type": "number", + "readOnly": true + }, + "input_reg08": { + "type": "number", + "readOnly": true + }, + "input_reg09": { + "type": "number", + "readOnly": true + }, + "input_reg10": { + "type": "number", + "readOnly": true + }, + "input_reg11": { + "type": "number", + "readOnly": true + }, + "input_reg12": { + "type": "number", + "readOnly": true + }, + "input_reg13": { + "type": "number", + "readOnly": true + }, + "input_reg14": { + "type": "number", + "readOnly": true + }, + "holding_reg01": { + "type": "number" + }, + "holding_reg02": { + "type": "number" + }, + "holding_reg03": { + "type": "number" + }, + "holding_reg04": { + "type": "number" + }, + "holding_reg05": { + "type": "number" + }, + "holding_reg06": { + "type": "number" + }, + "holding_reg07": { + "type": "number" + }, + "holding_reg08": { + "type": "number" + }, + "holding_reg09": { + "type": "number" + }, + "holding_reg10": { + "type": "number" + }, + "holding_reg11": { + "type": "number" + }, + "holding_reg12": { + "type": "number" + }, + "holding_reg13": { + "type": "number" + }, + "holding_reg14": { + "type": "number" + }, + "holding_reg15": { + "type": "number" + }, + "holding_reg16": { + "type": "number" + }, + "holding_reg17": { + "type": "number" + }, + "holding_reg18": { + "type": "number" + }, + "holding_reg19": { + "type": "string" + } + }, + "modpoll:acquisition": [ + { + "modpoll:table": "coil", + "modpoll:start": 0, + "modpoll:quantity": 16, + "modpoll:byteOrder": "BE_BE", + "modpoll:members": [ + { + "modpoll:property": "coil01-08", + "modpoll:offset": 0, + "modpoll:dataType": "bool8" + }, + { + "modpoll:property": "coil09-16", + "modpoll:offset": 1, + "modpoll:dataType": "bool8" + } + ] + }, + { + "modpoll:table": "discrete_input", + "modpoll:start": 10000, + "modpoll:quantity": 16, + "modpoll:byteOrder": "BE_BE", + "modpoll:members": [ + { + "modpoll:property": "di01-08", + "modpoll:offset": 0, + "modpoll:dataType": "bool8" + }, + { + "modpoll:property": "di09-16", + "modpoll:offset": 1, + "modpoll:dataType": "bool8" + } + ] + }, + { + "modpoll:table": "input_register", + "modpoll:start": 30000, + "modpoll:quantity": 20, + "modpoll:byteOrder": "BE_BE", + "modpoll:members": [ + { + "modpoll:property": "input_reg01", + "modpoll:offset": 0, + "modpoll:dataType": "uint16" + }, + { + "modpoll:property": "input_reg02", + "modpoll:offset": 1, + "modpoll:dataType": "uint16" + }, + { + "modpoll:property": "input_reg03", + "modpoll:offset": 2, + "modpoll:dataType": "uint16" + }, + { + "modpoll:property": "input_reg04", + "modpoll:offset": 3, + "modpoll:dataType": "uint16" + }, + { + "modpoll:property": "input_reg05", + "modpoll:offset": 4, + "modpoll:dataType": "int16" + }, + { + "modpoll:property": "input_reg06", + "modpoll:offset": 5, + "modpoll:dataType": "int16" + }, + { + "modpoll:property": "input_reg07", + "modpoll:offset": 6, + "modpoll:dataType": "int16" + }, + { + "modpoll:property": "input_reg08", + "modpoll:offset": 7, + "modpoll:dataType": "int16" + }, + { + "modpoll:property": "input_reg09", + "modpoll:offset": 8, + "modpoll:dataType": "uint32" + }, + { + "modpoll:property": "input_reg10", + "modpoll:offset": 10, + "modpoll:dataType": "uint32", + "modpoll:multiplier": "0.001" + }, + { + "modpoll:property": "input_reg11", + "modpoll:offset": 12, + "modpoll:dataType": "int32" + }, + { + "modpoll:property": "input_reg12", + "modpoll:offset": 14, + "modpoll:dataType": "int32", + "modpoll:multiplier": "1000" + }, + { + "modpoll:property": "input_reg13", + "modpoll:offset": 16, + "modpoll:dataType": "float32" + }, + { + "modpoll:property": "input_reg14", + "modpoll:offset": 18, + "modpoll:dataType": "float32" + } + ] + }, + { + "modpoll:table": "holding_register", + "modpoll:start": 40000, + "modpoll:quantity": 44, + "modpoll:byteOrder": "BE_BE", + "modpoll:members": [ + { + "modpoll:property": "holding_reg01", + "modpoll:offset": 0, + "modpoll:dataType": "uint16" + }, + { + "modpoll:property": "holding_reg02", + "modpoll:offset": 1, + "modpoll:dataType": "uint16" + }, + { + "modpoll:property": "holding_reg03", + "modpoll:offset": 2, + "modpoll:dataType": "uint16" + }, + { + "modpoll:property": "holding_reg04", + "modpoll:offset": 3, + "modpoll:dataType": "uint16" + }, + { + "modpoll:property": "holding_reg05", + "modpoll:offset": 4, + "modpoll:dataType": "int16" + }, + { + "modpoll:property": "holding_reg06", + "modpoll:offset": 5, + "modpoll:dataType": "int16" + }, + { + "modpoll:property": "holding_reg07", + "modpoll:offset": 6, + "modpoll:dataType": "int16" + }, + { + "modpoll:property": "holding_reg08", + "modpoll:offset": 7, + "modpoll:dataType": "int16" + }, + { + "modpoll:property": "holding_reg09", + "modpoll:offset": 8, + "modpoll:dataType": "uint32" + }, + { + "modpoll:property": "holding_reg10", + "modpoll:offset": 10, + "modpoll:dataType": "uint32", + "modpoll:multiplier": "0.001" + }, + { + "modpoll:property": "holding_reg11", + "modpoll:offset": 12, + "modpoll:dataType": "int32" + }, + { + "modpoll:property": "holding_reg12", + "modpoll:offset": 14, + "modpoll:dataType": "int32", + "modpoll:multiplier": "1000" + }, + { + "modpoll:property": "holding_reg13", + "modpoll:offset": 16, + "modpoll:dataType": "float32" + }, + { + "modpoll:property": "holding_reg14", + "modpoll:offset": 18, + "modpoll:dataType": "float32" + }, + { + "modpoll:property": "holding_reg15", + "modpoll:offset": 20, + "modpoll:dataType": "uint64" + }, + { + "modpoll:property": "holding_reg16", + "modpoll:offset": 24, + "modpoll:dataType": "int64" + }, + { + "modpoll:property": "holding_reg17", + "modpoll:offset": 28, + "modpoll:dataType": "float64" + }, + { + "modpoll:property": "holding_reg18", + "modpoll:offset": 32, + "modpoll:dataType": "float64" + }, + { + "modpoll:property": "holding_reg19", + "modpoll:offset": 36, + "modpoll:dataType": "string16" + } + ] + } + ] +} diff --git a/packages/modpoll-wot-modbus/tests/test_acquisition.py b/packages/modpoll-wot-modbus/tests/test_acquisition.py index ac2c5b0..2900cee 100644 --- a/packages/modpoll-wot-modbus/tests/test_acquisition.py +++ b/packages/modpoll-wot-modbus/tests/test_acquisition.py @@ -31,7 +31,7 @@ def duplicate(group): lambda g: g.update({"modpoll:quantity": 3}), lambda g: g.update({"modpoll:start": 65535}), lambda g: g.update({"modpoll:quantity": True}), - lambda g: g.update({"modpoll:table": "coil"}), + lambda g: g.update({"modpoll:table": "unknown"}), lambda g: g["modpoll:members"][0].update({"modpoll:property": "missing"}), lambda g: g["modpoll:members"][0].update({"modpoll:dataType": "unit32"}), lambda g: g["modpoll:members"][0].update({"modpoll:scale": 2}), diff --git a/packages/modpoll-wot-modbus/tests/test_loading.py b/packages/modpoll-wot-modbus/tests/test_loading.py new file mode 100644 index 0000000..e95295b --- /dev/null +++ b/packages/modpoll-wot-modbus/tests/test_loading.py @@ -0,0 +1,71 @@ +import json +from pathlib import Path +import tempfile +import unittest + +from modpoll_wot_modbus import AcquisitionError +from modpoll_wot_modbus.loading import load_model + +FIXTURE = Path(__file__).parent / 'fixtures/modsim.tm.json' + + +class LoadingTests(unittest.TestCase): + def test_modsim_four_tables_and_values_contract(self): + model = load_model(FIXTURE, kind='tm') + self.assertEqual([(g.table, g.start, g.quantity) for g in model.groups], [ + ('coil', 0, 16), ('discrete_input', 10000, 16), + ('input_register', 30000, 20), ('holding_register', 40000, 44)]) + self.assertEqual(sum(len(g.members) for g in model.groups), 37) + self.assertFalse(model.groups[1].members[0].writable) + self.assertTrue(model.groups[3].members[0].writable) + self.assertEqual(str(model.groups[2].members[9].multiplier), '0.001') + self.assertEqual(model.groups[3].members[-1].data_type, 'string16') + + def test_unit_id_is_instance_input(self): + self.assertEqual(load_model(FIXTURE, kind='tm', unit_id=7).unit_id, 7) + for value in (True, 0, 255, "1"): + with self.subTest(value=value), self.assertRaises(AcquisitionError): + load_model(FIXTURE, kind='tm', unit_id=value) + + def test_authored_fixture_matches_csv_groups_and_members(self): + import csv + from decimal import Decimal + source = Path(__file__).resolve().parents[3] / 'examples/modsim.csv' + model = load_model(FIXTURE, kind='tm') + groups = [] + for row in csv.reader(source.read_text().splitlines()): + if row[0] == 'poll': + groups.append((row, [])) + elif row[0] == 'ref': + groups[-1][1].append(row) + self.assertEqual(len(groups), len(model.groups)) + for (poll, refs), group in zip(groups, model.groups): + self.assertEqual((poll[1], int(poll[2]), int(poll[3]), poll[4]), + (group.table, group.start, group.quantity, group.byte_order)) + self.assertEqual(len(refs), len(group.members)) + for row, member in zip(refs, group.members): + self.assertEqual((row[1], int(row[2]) - group.start, row[3]), + (member.property, member.offset, member.data_type)) + self.assertEqual(Decimal(row[6]) if len(row) > 6 and row[6] else None, member.multiplier) + + def test_bad_json_and_semantics_include_filename(self): + with tempfile.TemporaryDirectory() as temp: + path = Path(temp) / 'broken.json' + for text in ('{"title":"a","title":"b"}', '{', 'NaN'): + path.write_text(text) + with self.subTest(text=text), self.assertRaisesRegex(AcquisitionError, 'broken.json'): + load_model(path, kind='tm') + document = json.loads(FIXTURE.read_text()) + document['modpoll:acquisition'][1]['modpoll:members'][0]['modpoll:bit'] = 1 + path.write_text(json.dumps(document)) + with self.assertRaisesRegex(AcquisitionError, 'bit selector'): + load_model(path, kind='tm') + + def test_extension_schema_rejects_unknown_member(self): + with tempfile.TemporaryDirectory() as temp: + path = Path(temp) / 'broken.json' + document = json.loads(FIXTURE.read_text()) + document['modpoll:acquisition'][0]['modpoll:members'][0]['typo'] = 1 + path.write_text(json.dumps(document)) + with self.assertRaises(AcquisitionError): + load_model(path, kind='tm') From f27e192e99baa1a3ba720a8647b782820c25a81a Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 28 Oct 2026 17:53:14 +0800 Subject: [PATCH 4/5] feat: execute WoT configurations and verify modsim equivalence --- .github/actions/setup-poetry/action.yml | 5 +- .github/workflows/main.yml | 43 +- Dockerfile | 1 + Makefile | 2 +- README.md | 112 +-- docs/configure.rst | 98 +- docs/quickstart.rst | 6 +- docs/usage.rst | 10 +- examples/modsim.td.json | 888 ++++++++++++++++++ .../fixtures => examples}/modsim.tm.json | 47 +- examples/modsim2.tm.json | 380 ++++++++ modpoll/arg_parser.py | 8 +- modpoll/modbus_task.py | 236 +---- modpoll/wot_config.py | 58 ++ packages/modpoll-wot-modbus/CONTRACT.md | 117 ++- packages/modpoll-wot-modbus/README.md | 4 +- .../modpoll_wot_modbus/__init__.py | 17 +- .../modpoll_wot_modbus/__main__.py | 35 + .../modpoll_wot_modbus/acquisition.py | 205 ++-- .../modpoll_wot_modbus/forms.py | 145 +++ .../modpoll_wot_modbus/loading.py | 134 ++- .../modpoll_wot_modbus/schemas/SOURCES.md | 9 + .../schemas/acquisition-0.1.json | 51 +- .../modpoll_wot_modbus/validation.py | 47 +- packages/modpoll-wot-modbus/pyproject.toml | 24 +- .../tests/test_acquisition.py | 50 +- .../modpoll-wot-modbus/tests/test_contract.py | 215 +++++ .../modpoll-wot-modbus/tests/test_loading.py | 72 +- .../tests/test_validation.py | 19 +- poetry.lock | 205 +++- pyproject.toml | 6 +- requirements.txt | 7 + tests/__init__.py | 1 + tests/test_modbus_task.py | 24 +- tests/test_modsim_interop.py | 33 +- tests/test_poll_cycle.py | 154 +-- tests/test_process_lifecycle.py | 7 +- tests/test_wot_config.py | 78 ++ tests/wot_helpers.py | 40 + tools/export_requirements.py | 21 + tools/verify_modsim_equivalence.py | 249 +++++ 41 files changed, 3063 insertions(+), 800 deletions(-) create mode 100644 examples/modsim.td.json rename {packages/modpoll-wot-modbus/tests/fixtures => examples}/modsim.tm.json (91%) create mode 100644 examples/modsim2.tm.json create mode 100644 modpoll/wot_config.py create mode 100644 packages/modpoll-wot-modbus/modpoll_wot_modbus/__main__.py create mode 100644 packages/modpoll-wot-modbus/modpoll_wot_modbus/forms.py create mode 100644 packages/modpoll-wot-modbus/modpoll_wot_modbus/schemas/SOURCES.md create mode 100644 packages/modpoll-wot-modbus/tests/test_contract.py create mode 100644 tests/__init__.py create mode 100644 tests/test_wot_config.py create mode 100644 tests/wot_helpers.py create mode 100644 tools/export_requirements.py create mode 100644 tools/verify_modsim_equivalence.py diff --git a/.github/actions/setup-poetry/action.yml b/.github/actions/setup-poetry/action.yml index 26f34a1..f77b6ec 100644 --- a/.github/actions/setup-poetry/action.yml +++ b/.github/actions/setup-poetry/action.yml @@ -37,13 +37,12 @@ runs: uses: actions/cache@v4 with: path: .venv - key: venv-${{ runner.os }}-${{ inputs.python-version }}-${{ hashFiles('pyproject.toml', 'poetry.lock') }} + key: venv-${{ runner.os }}-${{ inputs.python-version }}-${{ hashFiles('pyproject.toml', 'poetry.lock', 'packages/**/pyproject.toml') }} restore-keys: | venv-${{ runner.os }}-${{ inputs.python-version }}- - name: Install dependencies - if: steps.cached-poetry-dependencies.outputs.cache-hit != 'true' run: | poetry config virtualenvs.in-project true - poetry install --no-interaction --no-root + poetry install --no-interaction shell: bash diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 206dd21..70141eb 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -2,7 +2,7 @@ name: main on: push: - branches: [main, master] + branches: [main, develop/v2] pull_request: types: [opened, synchronize, reopened, ready_for_review] @@ -52,7 +52,6 @@ jobs: runs-on: ubuntu-latest permissions: contents: read - if: github.event_name != 'pull_request' steps: - name: Check out uses: actions/checkout@v4 @@ -65,3 +64,43 @@ jobs: - name: Build documentation run: make docs + + modsim-equivalence: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/setup-poetry + - name: Check out the 1.x baseline + uses: actions/checkout@v4 + with: + repository: top-maker/modpoll + ref: 0b42099e8b22eaaa4788a9f91e8bf60b4870204c + path: .interop/modpoll-1x + - name: Check out the simulator fixture + uses: actions/checkout@v4 + with: + repository: top-maker/modsim + ref: 92fe5d4c417b2691693feb2b6bd93fd1f54439fe + path: .interop/modsim + - name: Install isolated baseline and simulator environments + run: | + python -m venv .interop/modpoll-1x/.venv + .interop/modpoll-1x/.venv/bin/pip install -r .interop/modpoll-1x/requirements.txt + .interop/modpoll-1x/.venv/bin/pip install --no-deps ./.interop/modpoll-1x + python -m venv .interop/modsim/.venv + .interop/modsim/.venv/bin/pip install -r .interop/modsim/requirements.txt + .interop/modsim/.venv/bin/pip install --no-deps ./.interop/modsim + - name: Compare CSV, TM and TD requests and outputs + run: | + poetry run python tools/verify_modsim_equivalence.py --modpoll-1x .interop/modpoll-1x --modsim .interop/modsim --report .interop/report.json + - name: Verify lifecycle and existing write commands + env: + MODSIM_PYTHON: ${{ github.workspace }}/.interop/modsim/.venv/bin/python + MODSIM_SOURCE: ${{ github.workspace }}/.interop/modsim + run: poetry run pytest -q tests/test_process_lifecycle.py tests/test_modsim_interop.py + - uses: actions/upload-artifact@v4 + with: + name: modsim-equivalence + path: .interop/report.json diff --git a/Dockerfile b/Dockerfile index dd73ef2..abe38a7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,6 +13,7 @@ RUN poetry config virtualenvs.create false WORKDIR /app COPY poetry.lock pyproject.toml README.md /app/ COPY modpoll /app/modpoll +COPY packages /app/packages # Project initialization: RUN poetry install --no-interaction --no-ansi --all-extras --without dev,docs diff --git a/Makefile b/Makefile index c4c46a5..2dd2284 100644 --- a/Makefile +++ b/Makefile @@ -13,7 +13,7 @@ check: ## Run code quality tools. @echo "🚀 Checking Poetry lock file consistency with 'pyproject.toml': Running poetry check --lock" @poetry check --lock @echo "🚀 Exporting 'requirements.txt' file: Running poetry export" - @poetry export -f requirements.txt -o requirements.txt --without-hashes + @poetry run python tools/export_requirements.py @echo "🚀 Linting code: Running pre-commit" @poetry run pre-commit run -a @echo "🚀 Checking for obsolete dependencies: Running deptry" diff --git a/README.md b/README.md index abda583..4bc136a 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,5 @@ +> **2.x development branch.** This checkout accepts WoT TM/TD JSON. CSV remains supported on the 1.x `main` branch. A 2.x release has not been published yet. For development, run `mise install` and `mise exec -- poetry install`, then prefix the examples below with `mise exec -- poetry run`. + # Modpoll - A New Command-line Tool for Modbus and MQTT [![Release](https://img.shields.io/github/v/release/top-maker/modpoll)](https://img.shields.io/github/v/release/top-maker/modpoll) @@ -84,7 +86,7 @@ Use the public Modbus TCP simulator at `modsim.topmaker.net:502` for a quick tes ```bash modpoll --once \ --tcp modsim.topmaker.net --tcp-port 502 \ - --config https://raw.githubusercontent.com/top-maker/modpoll/main/examples/modsim.csv + --config examples/modsim.tm.json ``` ### Run a local simulator @@ -100,10 +102,10 @@ In another terminal, run: ```bash modpoll --once \ --tcp 127.0.0.1 --tcp-port 5020 \ - --config https://raw.githubusercontent.com/top-maker/modpoll/main/examples/modsim.csv + --config examples/modsim.tm.json ``` -You can download the configuration and pass a local file instead of the URL. +The JSON examples are included in this checkout. A public URL containing the same JSON format is also accepted.

@@ -116,79 +118,19 @@ You can download the configuration and pass a local file instead of the URL. - Serial transports: use `--serial PORT --framer ascii` (alias: `--rtu` still accepted); pyserial URLs such as `socket://host:port` and `rfc2217://host:port` work for serial-over-TCP tunnels. - Serial supports framers `ascii` and `rtu` (pymodbus defaults to RTU if `--framer default`). Binary framer is no longer supported in pymodbus 3.9+. -### Prepare Modbus configure file - -The [modsim configuration](https://raw.githubusercontent.com/top-maker/modpoll/main/examples/modsim.csv) maps the simulator's four tables as follows: - -```CSV -device,modsim01,1,, -poll,coil,0,16,BE_BE -ref,coil01-08,0,bool8,rw -ref,coil09-16,1,bool8,rw -poll,discrete_input,10000,16,BE_BE -ref,di01-08,10000,bool8,rw -ref,di09-16,10001,bool8,rw -poll,input_register,30000,20,BE_BE -ref,input_reg01,30000,uint16,rw -ref,input_reg02,30001,uint16,rw -ref,input_reg03,30002,uint16,rw -ref,input_reg04,30003,uint16,rw -ref,input_reg05,30004,int16,rw -ref,input_reg06,30005,int16,rw -ref,input_reg07,30006,int16,rw -ref,input_reg08,30007,int16,rw -ref,input_reg09,30008,uint32,rw -ref,input_reg10,30010,uint32,rw -ref,input_reg11,30012,int32,rw -ref,input_reg12,30014,int32,rw -ref,input_reg13,30016,float32,rw -ref,input_reg14,30018,float32,rw -poll,holding_register,40000,44,BE_BE -ref,holding_reg01,40000,uint16,rw -ref,holding_reg02,40001,uint16,rw -ref,holding_reg03,40002,uint16,rw -ref,holding_reg04,40003,uint16,rw -ref,holding_reg05,40004,int16,rw -ref,holding_reg06,40005,int16,rw -ref,holding_reg07,40006,int16,rw -ref,holding_reg08,40007,int16,rw -ref,holding_reg09,40008,uint32,rw -ref,holding_reg10,40010,uint32,rw -ref,holding_reg11,40012,int32,rw -ref,holding_reg12,40014,int32,rw -ref,holding_reg13,40016,float32,rw -ref,holding_reg14,40018,float32,rw -ref,holding_reg15,40020,uint64,rw -ref,holding_reg16,40024,int64,rw -ref,holding_reg17,40028,float64,rw -ref,holding_reg18,40032,float64,rw -ref,holding_reg19,40036,string16,rw -``` - -This configuration tells *modpoll* to do the following for each poll, - -- Read `16` coils from the address starting from `0` and parse the response as two 8-bits boolean values; -- Read `16` discrete inputs from the address starting from `10000` and parse the response as two 8-bits boolean values; -- Read `20` input registers from the address starting from `30000` and parse data accordingly; -- Read `44` holding registers from the address starting from `40000` and parse data accordingly; - -> Notice the holding registers contain a 16-byte long string, which counts as 8 registers in poll size, because Modbus protocol defines a holding register as 2-byte width. - -In practical, you usually need to customize a Modbus configuration file for your own device before running *modpoll* tool, which defines the optimal polling patterns and register mappings according to device vendor's documents. - -You can also take a look at [contrib](https://github.com/top-maker/modpoll/tree/main/contrib) folder, which collects a few types of device configuration shared by contributors. - -The configuration can be either a local file or a remote public URL resource. - -> *Refer to the [documentation](https://top-maker.github.io/modpoll/configure.html) site for more details.* - -CSV validation requires a unique device name, a device ID from 1 to 254, a positive poll size within the Modbus function-code limit, and an address range that stays within 0–65535. Reference data types, read/write modes, endian values, and scales must be valid; errors include the CSV line number. A scale of `0` is valid; non-finite scales are rejected. Non-finite floating-point samples are treated as invalid and omitted from MQTT output. Device names must also be unique across all configuration files; duplicate names stop startup. Rename conflicting devices and update their MQTT topics before upgrading. - -Read polls support up to 2000 bits or 125 registers. `stringN` reads exactly N bytes and occupies `(N + 1) // 2` registers. The `address:bit` syntax applies to `bool` references on register polls. +### Prepare a WoT configuration -When `--autoremove` is enabled, a poller is disabled after three consecutive failed polling attempts, including connection failures. A successful poll resets its failure count. Restart modpoll to retry a disabled poller; the default is to keep retrying without disabling pollers. +Use [examples/modsim.tm.json](examples/modsim.tm.json) for the simulator model or +[examples/modsim.td.json](examples/modsim.td.json) for a concrete TCP description. +Both preserve the four original blocks and 37 output references. `--unit-id` +selects the instance Unit ID (default 1); a concrete TD's forms must agree with it. +The existing transport, polling, output and MQTT arguments remain available. -For coil and discrete input polls, reference addresses identify consecutive eight-bit groups beginning at the poll start, as shown in the configuration example. Values follow Modbus bit order; a final partial group contains only the requested bits. Earlier versions could reverse bits or decode out-of-order references incorrectly, so consumers relying on those results should review the change. +See [the executable contract](packages/modpoll-wot-modbus/CONTRACT.md) for field +mapping, access flags, byte order, multiplier semantics and supported limits. +The configuration loader rejects CSV and reports invalid model fields before +polling. Each model describes one device; use multiple `--config` paths for +multiple devices. Give each model a unique `title`, which is the output device name. ### Poll local device (modsim) @@ -204,7 +146,7 @@ It will create a virtual Modbus TCP device running at `localhost:5020`, and you modpoll \ --tcp localhost \ --tcp-port 5020 \ - --config https://raw.githubusercontent.com/top-maker/modpoll/main/examples/modsim.csv + --config examples/modsim.tm.json ``` > Use `sudo` before the docker command if you want to use the standard port `502`. @@ -218,7 +160,7 @@ In a new terminal, ``` modpoll \ --tcp localhost \ - --config https://raw.githubusercontent.com/top-maker/modpoll/main/examples/modsim.csv + --config examples/modsim.tm.json ``` ### Publish data to MQTT broker @@ -231,7 +173,7 @@ The following example uses a public MQTT broker `broker.emqx.io` for test purpos modpoll \ --tcp 127.0.0.1 --tcp-port 5020 \ --mqtt-host broker.emqx.io \ - --config https://raw.githubusercontent.com/top-maker/modpoll/main/examples/modsim.csv + --config examples/modsim.tm.json ``` With successful data polling and publishing, you can subscribe the default data topic `modpoll/modsim01/data` on the same MQTT broker `broker.emqx.io` to view the collected data. @@ -295,19 +237,19 @@ On Linux, use host networking to connect the container to a simulator on the hos docker run --rm --network host topmaker/modpoll \ modpoll -d \ --tcp 127.0.0.1 --tcp-port 5020 \ - --config https://raw.githubusercontent.com/top-maker/modpoll/main/examples/modsim.csv + --config examples/modsim.tm.json ``` For host networking on other platforms, consult the Docker networking documentation. If you want to load a local configure file, you need to mount a local folder onto container volume, -for example, if the child folder `examples` contains the config file `modsim.csv`, we can use it via the following command, +for example, if the child folder `examples` contains the config file `modsim.tm.json`, we can use it via the following command, ```bash docker run --rm --network host -v $(pwd)/examples:/app/examples topmaker/modpoll \ modpoll -d \ --tcp 127.0.0.1 --tcp-port 5020 \ - --config /app/examples/modsim.csv + --config /app/examples/modsim.tm.json ``` @@ -318,7 +260,7 @@ for example, if the child folder `examples` contains the config file `modsim.csv ```bash modpoll \ --tcp 192.168.1.10 \ - --config examples/modsim.csv + --config examples/modsim.tm.json ``` - Connect to Modbus serial device @@ -327,7 +269,7 @@ for example, if the child folder `examples` contains the config file `modsim.csv modpoll \ --serial /dev/ttyUSB0 \ --serial-baud 9600 \ - --config contrib/eniwise/scpms6.csv + --config examples/modsim.tm.json ``` - Connect to Modbus TCP device and publish data to MQTT broker @@ -336,7 +278,7 @@ for example, if the child folder `examples` contains the config file `modsim.csv modpoll \ --tcp 127.0.0.1 --tcp-port 5020 \ --mqtt-host broker.emqx.io \ - --config examples/modsim.csv + --config examples/modsim.tm.json ``` - Connect to Modbus TCP device and export a local JSON snapshot @@ -345,7 +287,7 @@ for example, if the child folder `examples` contains the config file `modsim.csv modpoll \ --tcp 127.0.0.1 --tcp-port 5020 \ --export data.json \ - --config examples/modsim.csv + --config examples/modsim.tm.json ``` - Connect to Modbus TCP devices using multiple config files @@ -353,7 +295,7 @@ for example, if the child folder `examples` contains the config file `modsim.csv ```bash modpoll \ --tcp 127.0.0.1 --tcp-port 5020 \ - --config examples/modsim.csv examples/modsim2.csv + --config examples/modsim.tm.json examples/modsim2.tm.json ``` diff --git a/docs/configure.rst b/docs/configure.rst index ad1bbea..23661fd 100644 --- a/docs/configure.rst +++ b/docs/configure.rst @@ -1,44 +1,54 @@ -Configuration -==================== - -Modbus Configure File ----------------------- - -In order to communicate with modbus devices, a proper modbus configure file is required. -It is mainly to describe the device address and register mappings. - -Here is the basic structure of modbus configure file. - -The parser checks device IDs (1–254), unique device and reference names, positive poll sizes within the function-code limits, address ranges, endian settings, data types, read/write modes, and numeric scales. Configuration errors include the file name and CSV line number. Invalid rows are skipped while later rows continue to be checked; duplicate device names stop startup, including duplicates across configuration files. Rename duplicates and update the associated MQTT topics before upgrading. Non-finite scales are rejected. - -Each read poll can request up to 2000 coils or discrete inputs, or 125 registers. A ``stringN`` reference reads exactly N bytes, occupying ``ceil(N / 2)`` registers. The ``address:bit`` syntax applies only to ``bool`` references on register polls. - - -.. literalinclude:: ../examples/config_template.csv - :language: default - :emphasize-lines: 8-10 - :linenos: - - -Example 1: Modsim device (Modbus TCP device) --------------------------------------------------------- - -This online Modbus TCP device simulator is designed for user to quickly test the modpoll functions. - -Here is an example of modbus configure file for reading modsim device. - -.. literalinclude:: ../examples/modsim.csv - :language: default - :linenos: - - -Example 2: SCPM-S6 Power Meter (Modbus RTU device) ---------------------------------------------------- - -SCPM-S6 is designed as a sub-circuit power meter to monitor multiple electrical circuit power consumptions. - -Here is an example of modbus configure file for SCPM-S6. - -.. literalinclude:: ../contrib/eniwise/scpms6.csv - :language: default - :linenos: +WoT TM/TD configuration +======================= + +The 2.x development branch accepts JSON configurations using WoT TM/TD 1.1. +It rejects CSV input. The bundled ``examples/modsim.tm.json`` and +``examples/modsim.td.json`` describe the same four blocks and 37 output properties. + +Each file describes one device. Its ``title`` is the output device name; +``--unit-id`` selects the instance Unit ID (default 1, range 1–254). +Multiple files may be supplied to ``--config``; titles must be unique. +The existing transport CLI arguments explicitly select the connection endpoint. +A concrete TD's form Unit IDs, addresses and quantities must agree with the model. + +The interpreter validates the official, locally bundled TD/TM 1.1 schema, +the project extension schema and cross-field acquisition semantics. It does not +fetch context files or schemas. The local/URL input limit is 1 MiB and the JSON +nesting limit is 64. Duplicate JSON keys and non-finite numbers are rejected. + +A model uses ``modpoll:version`` equal to ``0.1`` and an ordered +``modpoll:acquisition`` array. Groups reuse the Modbus draft's ``modv:entity``, +``modv:address``, ``modv:quantity``, ``modv:mostSignificantByte`` and +``modv:mostSignificantWord`` vocabulary. ``modpoll:members`` describes decoded +properties, offsets, precise types, optional register bit selectors and optional +multipliers. These acquisition semantics are a project extension, not a W3C +standard. The selected subset does not execute arbitrary TD protocol bindings. + +Migration from CSV +------------------ + +* Copy the device name to ``title`` and pass its Unit ID through ``--unit-id``. +* Preserve every poll's table, literal start address, quantity and byte/word order. +* Map references to property keys and group members. Register member offsets are + relative register offsets. Bit-table offsets count eight-bit groups, matching + the old CSV behavior; counts still specify physical bits. +* Express read-only and write-only references through ``readOnly`` and + ``writeOnly``. Neither flag means read/write. DI and IR are read-only tables; + their historical ``rw`` sample flags must become ``readOnly: true``. +* Copy units unchanged and express the old scale as ``modpoll:multiplier``. + Multipliers retain the existing floating-point execution behavior; they are + not interpreted as divisors. Zero and negative multipliers are valid. +* Preserve effective reference names: the old loader replaced spaces with + underscores. Unknown types or ambiguous fields require review. + +The executable contract and test vectors live under +``packages/modpoll-wot-modbus``. Strings, bit selectors, four tables and all four +byte/word orders are represented by the contract. Numeric decoding of bit-table +payloads, model inheritance, optional members, URI templates, non-Modbus forms, +and security schemes other than ``nosec`` are rejected in this preview subset. +The interpreter records access flags; raw MQTT write commands retain the existing +1.x behavior and are not property-based writes. + +Standard schema validity alone does not establish device correctness or prove +that a general WoT consumer will implement the project's acquisition/transform +extensions. This change adds no HTTP/cache service. diff --git a/docs/quickstart.rst b/docs/quickstart.rst index 068cd56..0ed7ba3 100644 --- a/docs/quickstart.rst +++ b/docs/quickstart.rst @@ -6,7 +6,7 @@ modpoll without starting a local server: .. code-block:: shell - modpoll --once --tcp modsim.topmaker.net --tcp-port 502 --config https://raw.githubusercontent.com/top-maker/modpoll/main/examples/modsim.csv + modpoll --once --tcp modsim.topmaker.net --tcp-port 502 --config examples/modsim.tm.json Run a local simulator --------------------- @@ -22,10 +22,10 @@ In another terminal, poll its sample data once: .. code-block:: shell - modpoll --once --tcp 127.0.0.1 --tcp-port 5020 --config examples/modsim.csv + modpoll --once --tcp 127.0.0.1 --tcp-port 5020 --config examples/modsim.tm.json The command assumes you are in the modpoll repository. You can instead provide -the published configuration URL or your own local CSV file. +the published configuration URL or your own local TM/TD JSON file. The simulator's default datastore uses literal Modbus addresses: diff --git a/docs/usage.rst b/docs/usage.rst index 7bff2ac..5336570 100644 --- a/docs/usage.rst +++ b/docs/usage.rst @@ -15,30 +15,30 @@ Commandline Usage .. code-block:: shell - modpoll --tcp 192.168.1.10 --config examples/modsim.csv + modpoll --tcp 192.168.1.10 --config examples/modsim.tm.json - Connect to Modbus serial device .. code-block:: shell - modpoll --serial /dev/ttyUSB0 --serial-baud 9600 --config contrib/eniwise/scpms6.csv + modpoll --serial /dev/ttyUSB0 --serial-baud 9600 --config examples/modsim.tm.json - Connect to Modbus TCP device and publish data to remote MQTT broker .. code-block:: shell - modpoll --tcp 192.168.1.10 --config examples/modsim.csv --mqtt-host broker.emqx.io + modpoll --tcp 192.168.1.10 --config examples/modsim.tm.json --mqtt-host broker.emqx.io - Connect to Modbus TCP device and export a local JSON snapshot .. code-block:: shell - modpoll --tcp 127.0.0.1 --tcp-port 5020 --config examples/modsim.csv --export data.json + modpoll --tcp 127.0.0.1 --tcp-port 5020 --config examples/modsim.tm.json --export data.json Configuration File ------------------ -The configuration file (`--config`) is a CSV file that defines the devices, pollers, and references to be read. +The configuration file (`--config`) is a WoT TM/TD JSON file that defines the devices, pollers, and references to be read. For register references (i.e., Holding or Input registers) with a ``dtype`` of ``bool``, you can specify a single bit to be extracted from the 16-bit register. This is done by appending ``:bit`` to the address, where ``bit`` is an integer from 0 to 15. diff --git a/examples/modsim.td.json b/examples/modsim.td.json new file mode 100644 index 0000000..9a3ccff --- /dev/null +++ b/examples/modsim.td.json @@ -0,0 +1,888 @@ +{ + "@context": [ + "https://www.w3.org/2022/wot/td/v1.1", + { + "modpoll": "https://top-maker.github.io/modpoll/vocab/wot-modbus#", + "modv": "https://www.w3.org/2019/wot/modbus", + "xsd": "http://www.w3.org/2001/XMLSchema#" + } + ], + "title": "modsim01", + "modpoll:version": "0.1", + "properties": { + "coil01-08": { + "type": "array", + "items": { + "type": "boolean" + }, + "forms": [ + { + "href": "modbus+tcp://127.0.0.1:5020/1/0?quantity=8", + "op": [ + "readproperty", + "writeproperty" + ], + "contentType": "application/octet-stream", + "modv:entity": "Coil", + "modv:mostSignificantByte": true, + "modv:mostSignificantWord": true, + "modv:type": "xsd:boolean" + } + ] + }, + "coil09-16": { + "type": "array", + "items": { + "type": "boolean" + }, + "forms": [ + { + "href": "modbus+tcp://127.0.0.1:5020/1/8?quantity=8", + "op": [ + "readproperty", + "writeproperty" + ], + "contentType": "application/octet-stream", + "modv:entity": "Coil", + "modv:mostSignificantByte": true, + "modv:mostSignificantWord": true, + "modv:type": "xsd:boolean" + } + ] + }, + "di01-08": { + "type": "array", + "items": { + "type": "boolean" + }, + "readOnly": true, + "forms": [ + { + "href": "modbus+tcp://127.0.0.1:5020/1/10000?quantity=8", + "op": [ + "readproperty" + ], + "contentType": "application/octet-stream", + "modv:entity": "DiscreteInput", + "modv:mostSignificantByte": true, + "modv:mostSignificantWord": true, + "modv:type": "xsd:boolean" + } + ] + }, + "di09-16": { + "type": "array", + "items": { + "type": "boolean" + }, + "readOnly": true, + "forms": [ + { + "href": "modbus+tcp://127.0.0.1:5020/1/10008?quantity=8", + "op": [ + "readproperty" + ], + "contentType": "application/octet-stream", + "modv:entity": "DiscreteInput", + "modv:mostSignificantByte": true, + "modv:mostSignificantWord": true, + "modv:type": "xsd:boolean" + } + ] + }, + "input_reg01": { + "type": "number", + "readOnly": true, + "forms": [ + { + "href": "modbus+tcp://127.0.0.1:5020/1/30000?quantity=1", + "op": [ + "readproperty" + ], + "contentType": "application/octet-stream", + "modv:entity": "InputRegister", + "modv:mostSignificantByte": true, + "modv:mostSignificantWord": true, + "modv:type": "xsd:unsignedShort" + } + ] + }, + "input_reg02": { + "type": "number", + "readOnly": true, + "forms": [ + { + "href": "modbus+tcp://127.0.0.1:5020/1/30001?quantity=1", + "op": [ + "readproperty" + ], + "contentType": "application/octet-stream", + "modv:entity": "InputRegister", + "modv:mostSignificantByte": true, + "modv:mostSignificantWord": true, + "modv:type": "xsd:unsignedShort" + } + ] + }, + "input_reg03": { + "type": "number", + "readOnly": true, + "forms": [ + { + "href": "modbus+tcp://127.0.0.1:5020/1/30002?quantity=1", + "op": [ + "readproperty" + ], + "contentType": "application/octet-stream", + "modv:entity": "InputRegister", + "modv:mostSignificantByte": true, + "modv:mostSignificantWord": true, + "modv:type": "xsd:unsignedShort" + } + ] + }, + "input_reg04": { + "type": "number", + "readOnly": true, + "forms": [ + { + "href": "modbus+tcp://127.0.0.1:5020/1/30003?quantity=1", + "op": [ + "readproperty" + ], + "contentType": "application/octet-stream", + "modv:entity": "InputRegister", + "modv:mostSignificantByte": true, + "modv:mostSignificantWord": true, + "modv:type": "xsd:unsignedShort" + } + ] + }, + "input_reg05": { + "type": "number", + "readOnly": true, + "forms": [ + { + "href": "modbus+tcp://127.0.0.1:5020/1/30004?quantity=1", + "op": [ + "readproperty" + ], + "contentType": "application/octet-stream", + "modv:entity": "InputRegister", + "modv:mostSignificantByte": true, + "modv:mostSignificantWord": true, + "modv:type": "xsd:short" + } + ] + }, + "input_reg06": { + "type": "number", + "readOnly": true, + "forms": [ + { + "href": "modbus+tcp://127.0.0.1:5020/1/30005?quantity=1", + "op": [ + "readproperty" + ], + "contentType": "application/octet-stream", + "modv:entity": "InputRegister", + "modv:mostSignificantByte": true, + "modv:mostSignificantWord": true, + "modv:type": "xsd:short" + } + ] + }, + "input_reg07": { + "type": "number", + "readOnly": true, + "forms": [ + { + "href": "modbus+tcp://127.0.0.1:5020/1/30006?quantity=1", + "op": [ + "readproperty" + ], + "contentType": "application/octet-stream", + "modv:entity": "InputRegister", + "modv:mostSignificantByte": true, + "modv:mostSignificantWord": true, + "modv:type": "xsd:short" + } + ] + }, + "input_reg08": { + "type": "number", + "readOnly": true, + "forms": [ + { + "href": "modbus+tcp://127.0.0.1:5020/1/30007?quantity=1", + "op": [ + "readproperty" + ], + "contentType": "application/octet-stream", + "modv:entity": "InputRegister", + "modv:mostSignificantByte": true, + "modv:mostSignificantWord": true, + "modv:type": "xsd:short" + } + ] + }, + "input_reg09": { + "type": "number", + "readOnly": true, + "forms": [ + { + "href": "modbus+tcp://127.0.0.1:5020/1/30008?quantity=2", + "op": [ + "readproperty" + ], + "contentType": "application/octet-stream", + "modv:entity": "InputRegister", + "modv:mostSignificantByte": true, + "modv:mostSignificantWord": true, + "modv:type": "xsd:unsignedInt" + } + ] + }, + "input_reg10": { + "type": "number", + "readOnly": true, + "forms": [ + { + "href": "modbus+tcp://127.0.0.1:5020/1/30010?quantity=2", + "op": [ + "readproperty" + ], + "contentType": "application/octet-stream", + "modv:entity": "InputRegister", + "modv:mostSignificantByte": true, + "modv:mostSignificantWord": true, + "modv:type": "xsd:unsignedInt" + } + ] + }, + "input_reg11": { + "type": "number", + "readOnly": true, + "forms": [ + { + "href": "modbus+tcp://127.0.0.1:5020/1/30012?quantity=2", + "op": [ + "readproperty" + ], + "contentType": "application/octet-stream", + "modv:entity": "InputRegister", + "modv:mostSignificantByte": true, + "modv:mostSignificantWord": true, + "modv:type": "xsd:int" + } + ] + }, + "input_reg12": { + "type": "number", + "readOnly": true, + "forms": [ + { + "href": "modbus+tcp://127.0.0.1:5020/1/30014?quantity=2", + "op": [ + "readproperty" + ], + "contentType": "application/octet-stream", + "modv:entity": "InputRegister", + "modv:mostSignificantByte": true, + "modv:mostSignificantWord": true, + "modv:type": "xsd:int" + } + ] + }, + "input_reg13": { + "type": "number", + "readOnly": true, + "forms": [ + { + "href": "modbus+tcp://127.0.0.1:5020/1/30016?quantity=2", + "op": [ + "readproperty" + ], + "contentType": "application/octet-stream", + "modv:entity": "InputRegister", + "modv:mostSignificantByte": true, + "modv:mostSignificantWord": true, + "modv:type": "xsd:float" + } + ] + }, + "input_reg14": { + "type": "number", + "readOnly": true, + "forms": [ + { + "href": "modbus+tcp://127.0.0.1:5020/1/30018?quantity=2", + "op": [ + "readproperty" + ], + "contentType": "application/octet-stream", + "modv:entity": "InputRegister", + "modv:mostSignificantByte": true, + "modv:mostSignificantWord": true, + "modv:type": "xsd:float" + } + ] + }, + "holding_reg01": { + "type": "number", + "forms": [ + { + "href": "modbus+tcp://127.0.0.1:5020/1/40000?quantity=1", + "op": [ + "readproperty", + "writeproperty" + ], + "contentType": "application/octet-stream", + "modv:entity": "HoldingRegister", + "modv:mostSignificantByte": true, + "modv:mostSignificantWord": true, + "modv:type": "xsd:unsignedShort" + } + ] + }, + "holding_reg02": { + "type": "number", + "forms": [ + { + "href": "modbus+tcp://127.0.0.1:5020/1/40001?quantity=1", + "op": [ + "readproperty", + "writeproperty" + ], + "contentType": "application/octet-stream", + "modv:entity": "HoldingRegister", + "modv:mostSignificantByte": true, + "modv:mostSignificantWord": true, + "modv:type": "xsd:unsignedShort" + } + ] + }, + "holding_reg03": { + "type": "number", + "forms": [ + { + "href": "modbus+tcp://127.0.0.1:5020/1/40002?quantity=1", + "op": [ + "readproperty", + "writeproperty" + ], + "contentType": "application/octet-stream", + "modv:entity": "HoldingRegister", + "modv:mostSignificantByte": true, + "modv:mostSignificantWord": true, + "modv:type": "xsd:unsignedShort" + } + ] + }, + "holding_reg04": { + "type": "number", + "forms": [ + { + "href": "modbus+tcp://127.0.0.1:5020/1/40003?quantity=1", + "op": [ + "readproperty", + "writeproperty" + ], + "contentType": "application/octet-stream", + "modv:entity": "HoldingRegister", + "modv:mostSignificantByte": true, + "modv:mostSignificantWord": true, + "modv:type": "xsd:unsignedShort" + } + ] + }, + "holding_reg05": { + "type": "number", + "forms": [ + { + "href": "modbus+tcp://127.0.0.1:5020/1/40004?quantity=1", + "op": [ + "readproperty", + "writeproperty" + ], + "contentType": "application/octet-stream", + "modv:entity": "HoldingRegister", + "modv:mostSignificantByte": true, + "modv:mostSignificantWord": true, + "modv:type": "xsd:short" + } + ] + }, + "holding_reg06": { + "type": "number", + "forms": [ + { + "href": "modbus+tcp://127.0.0.1:5020/1/40005?quantity=1", + "op": [ + "readproperty", + "writeproperty" + ], + "contentType": "application/octet-stream", + "modv:entity": "HoldingRegister", + "modv:mostSignificantByte": true, + "modv:mostSignificantWord": true, + "modv:type": "xsd:short" + } + ] + }, + "holding_reg07": { + "type": "number", + "forms": [ + { + "href": "modbus+tcp://127.0.0.1:5020/1/40006?quantity=1", + "op": [ + "readproperty", + "writeproperty" + ], + "contentType": "application/octet-stream", + "modv:entity": "HoldingRegister", + "modv:mostSignificantByte": true, + "modv:mostSignificantWord": true, + "modv:type": "xsd:short" + } + ] + }, + "holding_reg08": { + "type": "number", + "forms": [ + { + "href": "modbus+tcp://127.0.0.1:5020/1/40007?quantity=1", + "op": [ + "readproperty", + "writeproperty" + ], + "contentType": "application/octet-stream", + "modv:entity": "HoldingRegister", + "modv:mostSignificantByte": true, + "modv:mostSignificantWord": true, + "modv:type": "xsd:short" + } + ] + }, + "holding_reg09": { + "type": "number", + "forms": [ + { + "href": "modbus+tcp://127.0.0.1:5020/1/40008?quantity=2", + "op": [ + "readproperty", + "writeproperty" + ], + "contentType": "application/octet-stream", + "modv:entity": "HoldingRegister", + "modv:mostSignificantByte": true, + "modv:mostSignificantWord": true, + "modv:type": "xsd:unsignedInt" + } + ] + }, + "holding_reg10": { + "type": "number", + "forms": [ + { + "href": "modbus+tcp://127.0.0.1:5020/1/40010?quantity=2", + "op": [ + "readproperty", + "writeproperty" + ], + "contentType": "application/octet-stream", + "modv:entity": "HoldingRegister", + "modv:mostSignificantByte": true, + "modv:mostSignificantWord": true, + "modv:type": "xsd:unsignedInt" + } + ] + }, + "holding_reg11": { + "type": "number", + "forms": [ + { + "href": "modbus+tcp://127.0.0.1:5020/1/40012?quantity=2", + "op": [ + "readproperty", + "writeproperty" + ], + "contentType": "application/octet-stream", + "modv:entity": "HoldingRegister", + "modv:mostSignificantByte": true, + "modv:mostSignificantWord": true, + "modv:type": "xsd:int" + } + ] + }, + "holding_reg12": { + "type": "number", + "forms": [ + { + "href": "modbus+tcp://127.0.0.1:5020/1/40014?quantity=2", + "op": [ + "readproperty", + "writeproperty" + ], + "contentType": "application/octet-stream", + "modv:entity": "HoldingRegister", + "modv:mostSignificantByte": true, + "modv:mostSignificantWord": true, + "modv:type": "xsd:int" + } + ] + }, + "holding_reg13": { + "type": "number", + "forms": [ + { + "href": "modbus+tcp://127.0.0.1:5020/1/40016?quantity=2", + "op": [ + "readproperty", + "writeproperty" + ], + "contentType": "application/octet-stream", + "modv:entity": "HoldingRegister", + "modv:mostSignificantByte": true, + "modv:mostSignificantWord": true, + "modv:type": "xsd:float" + } + ] + }, + "holding_reg14": { + "type": "number", + "forms": [ + { + "href": "modbus+tcp://127.0.0.1:5020/1/40018?quantity=2", + "op": [ + "readproperty", + "writeproperty" + ], + "contentType": "application/octet-stream", + "modv:entity": "HoldingRegister", + "modv:mostSignificantByte": true, + "modv:mostSignificantWord": true, + "modv:type": "xsd:float" + } + ] + }, + "holding_reg15": { + "type": "number", + "forms": [ + { + "href": "modbus+tcp://127.0.0.1:5020/1/40020?quantity=4", + "op": [ + "readproperty", + "writeproperty" + ], + "contentType": "application/octet-stream", + "modv:entity": "HoldingRegister", + "modv:mostSignificantByte": true, + "modv:mostSignificantWord": true, + "modv:type": "xsd:unsignedLong" + } + ] + }, + "holding_reg16": { + "type": "number", + "forms": [ + { + "href": "modbus+tcp://127.0.0.1:5020/1/40024?quantity=4", + "op": [ + "readproperty", + "writeproperty" + ], + "contentType": "application/octet-stream", + "modv:entity": "HoldingRegister", + "modv:mostSignificantByte": true, + "modv:mostSignificantWord": true, + "modv:type": "xsd:long" + } + ] + }, + "holding_reg17": { + "type": "number", + "forms": [ + { + "href": "modbus+tcp://127.0.0.1:5020/1/40028?quantity=4", + "op": [ + "readproperty", + "writeproperty" + ], + "contentType": "application/octet-stream", + "modv:entity": "HoldingRegister", + "modv:mostSignificantByte": true, + "modv:mostSignificantWord": true, + "modv:type": "xsd:double" + } + ] + }, + "holding_reg18": { + "type": "number", + "forms": [ + { + "href": "modbus+tcp://127.0.0.1:5020/1/40032?quantity=4", + "op": [ + "readproperty", + "writeproperty" + ], + "contentType": "application/octet-stream", + "modv:entity": "HoldingRegister", + "modv:mostSignificantByte": true, + "modv:mostSignificantWord": true, + "modv:type": "xsd:double" + } + ] + }, + "holding_reg19": { + "type": "string", + "forms": [ + { + "href": "modbus+tcp://127.0.0.1:5020/1/40036?quantity=8", + "op": [ + "readproperty", + "writeproperty" + ], + "contentType": "application/octet-stream", + "modv:entity": "HoldingRegister", + "modv:mostSignificantByte": true, + "modv:mostSignificantWord": true, + "modv:type": "xsd:string" + } + ] + } + }, + "modpoll:acquisition": [ + { + "modpoll:members": [ + { + "modpoll:property": "coil01-08", + "modpoll:offset": 0, + "modpoll:dataType": "bool8" + }, + { + "modpoll:property": "coil09-16", + "modpoll:offset": 1, + "modpoll:dataType": "bool8" + } + ], + "modv:entity": "Coil", + "modv:address": 0, + "modv:quantity": 16, + "modv:mostSignificantByte": true, + "modv:mostSignificantWord": true + }, + { + "modpoll:members": [ + { + "modpoll:property": "di01-08", + "modpoll:offset": 0, + "modpoll:dataType": "bool8" + }, + { + "modpoll:property": "di09-16", + "modpoll:offset": 1, + "modpoll:dataType": "bool8" + } + ], + "modv:entity": "DiscreteInput", + "modv:address": 10000, + "modv:quantity": 16, + "modv:mostSignificantByte": true, + "modv:mostSignificantWord": true + }, + { + "modpoll:members": [ + { + "modpoll:property": "input_reg01", + "modpoll:offset": 0, + "modpoll:dataType": "uint16" + }, + { + "modpoll:property": "input_reg02", + "modpoll:offset": 1, + "modpoll:dataType": "uint16" + }, + { + "modpoll:property": "input_reg03", + "modpoll:offset": 2, + "modpoll:dataType": "uint16" + }, + { + "modpoll:property": "input_reg04", + "modpoll:offset": 3, + "modpoll:dataType": "uint16" + }, + { + "modpoll:property": "input_reg05", + "modpoll:offset": 4, + "modpoll:dataType": "int16" + }, + { + "modpoll:property": "input_reg06", + "modpoll:offset": 5, + "modpoll:dataType": "int16" + }, + { + "modpoll:property": "input_reg07", + "modpoll:offset": 6, + "modpoll:dataType": "int16" + }, + { + "modpoll:property": "input_reg08", + "modpoll:offset": 7, + "modpoll:dataType": "int16" + }, + { + "modpoll:property": "input_reg09", + "modpoll:offset": 8, + "modpoll:dataType": "uint32" + }, + { + "modpoll:property": "input_reg10", + "modpoll:offset": 10, + "modpoll:dataType": "uint32", + "modpoll:multiplier": "0.001" + }, + { + "modpoll:property": "input_reg11", + "modpoll:offset": 12, + "modpoll:dataType": "int32" + }, + { + "modpoll:property": "input_reg12", + "modpoll:offset": 14, + "modpoll:dataType": "int32", + "modpoll:multiplier": "1000" + }, + { + "modpoll:property": "input_reg13", + "modpoll:offset": 16, + "modpoll:dataType": "float32" + }, + { + "modpoll:property": "input_reg14", + "modpoll:offset": 18, + "modpoll:dataType": "float32" + } + ], + "modv:entity": "InputRegister", + "modv:address": 30000, + "modv:quantity": 20, + "modv:mostSignificantByte": true, + "modv:mostSignificantWord": true + }, + { + "modpoll:members": [ + { + "modpoll:property": "holding_reg01", + "modpoll:offset": 0, + "modpoll:dataType": "uint16" + }, + { + "modpoll:property": "holding_reg02", + "modpoll:offset": 1, + "modpoll:dataType": "uint16" + }, + { + "modpoll:property": "holding_reg03", + "modpoll:offset": 2, + "modpoll:dataType": "uint16" + }, + { + "modpoll:property": "holding_reg04", + "modpoll:offset": 3, + "modpoll:dataType": "uint16" + }, + { + "modpoll:property": "holding_reg05", + "modpoll:offset": 4, + "modpoll:dataType": "int16" + }, + { + "modpoll:property": "holding_reg06", + "modpoll:offset": 5, + "modpoll:dataType": "int16" + }, + { + "modpoll:property": "holding_reg07", + "modpoll:offset": 6, + "modpoll:dataType": "int16" + }, + { + "modpoll:property": "holding_reg08", + "modpoll:offset": 7, + "modpoll:dataType": "int16" + }, + { + "modpoll:property": "holding_reg09", + "modpoll:offset": 8, + "modpoll:dataType": "uint32" + }, + { + "modpoll:property": "holding_reg10", + "modpoll:offset": 10, + "modpoll:dataType": "uint32", + "modpoll:multiplier": "0.001" + }, + { + "modpoll:property": "holding_reg11", + "modpoll:offset": 12, + "modpoll:dataType": "int32" + }, + { + "modpoll:property": "holding_reg12", + "modpoll:offset": 14, + "modpoll:dataType": "int32", + "modpoll:multiplier": "1000" + }, + { + "modpoll:property": "holding_reg13", + "modpoll:offset": 16, + "modpoll:dataType": "float32" + }, + { + "modpoll:property": "holding_reg14", + "modpoll:offset": 18, + "modpoll:dataType": "float32" + }, + { + "modpoll:property": "holding_reg15", + "modpoll:offset": 20, + "modpoll:dataType": "uint64" + }, + { + "modpoll:property": "holding_reg16", + "modpoll:offset": 24, + "modpoll:dataType": "int64" + }, + { + "modpoll:property": "holding_reg17", + "modpoll:offset": 28, + "modpoll:dataType": "float64" + }, + { + "modpoll:property": "holding_reg18", + "modpoll:offset": 32, + "modpoll:dataType": "float64" + }, + { + "modpoll:property": "holding_reg19", + "modpoll:offset": 36, + "modpoll:dataType": "string16" + } + ], + "modv:entity": "HoldingRegister", + "modv:address": 40000, + "modv:quantity": 44, + "modv:mostSignificantByte": true, + "modv:mostSignificantWord": true + } + ], + "securityDefinitions": { + "nosec_sc": { + "scheme": "nosec" + } + }, + "security": "nosec_sc" +} diff --git a/packages/modpoll-wot-modbus/tests/fixtures/modsim.tm.json b/examples/modsim.tm.json similarity index 91% rename from packages/modpoll-wot-modbus/tests/fixtures/modsim.tm.json rename to examples/modsim.tm.json index 2f2bf8e..dce958d 100644 --- a/packages/modpoll-wot-modbus/tests/fixtures/modsim.tm.json +++ b/examples/modsim.tm.json @@ -2,7 +2,8 @@ "@context": [ "https://www.w3.org/2022/wot/td/v1.1", { - "modpoll": "https://top-maker.github.io/modpoll/vocab/wot-modbus#" + "modpoll": "https://top-maker.github.io/modpoll/vocab/wot-modbus#", + "modv": "https://www.w3.org/2019/wot/modbus" } ], "@type": "tm:ThingModel", @@ -151,10 +152,6 @@ }, "modpoll:acquisition": [ { - "modpoll:table": "coil", - "modpoll:start": 0, - "modpoll:quantity": 16, - "modpoll:byteOrder": "BE_BE", "modpoll:members": [ { "modpoll:property": "coil01-08", @@ -166,13 +163,14 @@ "modpoll:offset": 1, "modpoll:dataType": "bool8" } - ] + ], + "modv:entity": "Coil", + "modv:address": 0, + "modv:quantity": 16, + "modv:mostSignificantByte": true, + "modv:mostSignificantWord": true }, { - "modpoll:table": "discrete_input", - "modpoll:start": 10000, - "modpoll:quantity": 16, - "modpoll:byteOrder": "BE_BE", "modpoll:members": [ { "modpoll:property": "di01-08", @@ -184,13 +182,14 @@ "modpoll:offset": 1, "modpoll:dataType": "bool8" } - ] + ], + "modv:entity": "DiscreteInput", + "modv:address": 10000, + "modv:quantity": 16, + "modv:mostSignificantByte": true, + "modv:mostSignificantWord": true }, { - "modpoll:table": "input_register", - "modpoll:start": 30000, - "modpoll:quantity": 20, - "modpoll:byteOrder": "BE_BE", "modpoll:members": [ { "modpoll:property": "input_reg01", @@ -264,13 +263,14 @@ "modpoll:offset": 18, "modpoll:dataType": "float32" } - ] + ], + "modv:entity": "InputRegister", + "modv:address": 30000, + "modv:quantity": 20, + "modv:mostSignificantByte": true, + "modv:mostSignificantWord": true }, { - "modpoll:table": "holding_register", - "modpoll:start": 40000, - "modpoll:quantity": 44, - "modpoll:byteOrder": "BE_BE", "modpoll:members": [ { "modpoll:property": "holding_reg01", @@ -369,7 +369,12 @@ "modpoll:offset": 36, "modpoll:dataType": "string16" } - ] + ], + "modv:entity": "HoldingRegister", + "modv:address": 40000, + "modv:quantity": 44, + "modv:mostSignificantByte": true, + "modv:mostSignificantWord": true } ] } diff --git a/examples/modsim2.tm.json b/examples/modsim2.tm.json new file mode 100644 index 0000000..ee78e4a --- /dev/null +++ b/examples/modsim2.tm.json @@ -0,0 +1,380 @@ +{ + "@context": [ + "https://www.w3.org/2022/wot/td/v1.1", + { + "modpoll": "https://top-maker.github.io/modpoll/vocab/wot-modbus#", + "modv": "https://www.w3.org/2019/wot/modbus" + } + ], + "@type": "tm:ThingModel", + "title": "modsim02", + "modpoll:version": "0.1", + "properties": { + "coil01-08": { + "type": "array", + "items": { + "type": "boolean" + } + }, + "coil09-16": { + "type": "array", + "items": { + "type": "boolean" + } + }, + "di01-08": { + "type": "array", + "items": { + "type": "boolean" + }, + "readOnly": true + }, + "di09-16": { + "type": "array", + "items": { + "type": "boolean" + }, + "readOnly": true + }, + "input_reg01": { + "type": "number", + "readOnly": true + }, + "input_reg02": { + "type": "number", + "readOnly": true + }, + "input_reg03": { + "type": "number", + "readOnly": true + }, + "input_reg04": { + "type": "number", + "readOnly": true + }, + "input_reg05": { + "type": "number", + "readOnly": true + }, + "input_reg06": { + "type": "number", + "readOnly": true + }, + "input_reg07": { + "type": "number", + "readOnly": true + }, + "input_reg08": { + "type": "number", + "readOnly": true + }, + "input_reg09": { + "type": "number", + "readOnly": true + }, + "input_reg10": { + "type": "number", + "readOnly": true + }, + "input_reg11": { + "type": "number", + "readOnly": true + }, + "input_reg12": { + "type": "number", + "readOnly": true + }, + "input_reg13": { + "type": "number", + "readOnly": true + }, + "input_reg14": { + "type": "number", + "readOnly": true + }, + "holding_reg01": { + "type": "number" + }, + "holding_reg02": { + "type": "number" + }, + "holding_reg03": { + "type": "number" + }, + "holding_reg04": { + "type": "number" + }, + "holding_reg05": { + "type": "number" + }, + "holding_reg06": { + "type": "number" + }, + "holding_reg07": { + "type": "number" + }, + "holding_reg08": { + "type": "number" + }, + "holding_reg09": { + "type": "number" + }, + "holding_reg10": { + "type": "number" + }, + "holding_reg11": { + "type": "number" + }, + "holding_reg12": { + "type": "number" + }, + "holding_reg13": { + "type": "number" + }, + "holding_reg14": { + "type": "number" + }, + "holding_reg15": { + "type": "number" + }, + "holding_reg16": { + "type": "number" + }, + "holding_reg17": { + "type": "number" + }, + "holding_reg18": { + "type": "number" + }, + "holding_reg19": { + "type": "string" + } + }, + "modpoll:acquisition": [ + { + "modpoll:members": [ + { + "modpoll:property": "coil01-08", + "modpoll:offset": 0, + "modpoll:dataType": "bool8" + }, + { + "modpoll:property": "coil09-16", + "modpoll:offset": 1, + "modpoll:dataType": "bool8" + } + ], + "modv:entity": "Coil", + "modv:address": 0, + "modv:quantity": 16, + "modv:mostSignificantByte": true, + "modv:mostSignificantWord": true + }, + { + "modpoll:members": [ + { + "modpoll:property": "di01-08", + "modpoll:offset": 0, + "modpoll:dataType": "bool8" + }, + { + "modpoll:property": "di09-16", + "modpoll:offset": 1, + "modpoll:dataType": "bool8" + } + ], + "modv:entity": "DiscreteInput", + "modv:address": 10000, + "modv:quantity": 16, + "modv:mostSignificantByte": true, + "modv:mostSignificantWord": true + }, + { + "modpoll:members": [ + { + "modpoll:property": "input_reg01", + "modpoll:offset": 0, + "modpoll:dataType": "uint16" + }, + { + "modpoll:property": "input_reg02", + "modpoll:offset": 1, + "modpoll:dataType": "uint16" + }, + { + "modpoll:property": "input_reg03", + "modpoll:offset": 2, + "modpoll:dataType": "uint16" + }, + { + "modpoll:property": "input_reg04", + "modpoll:offset": 3, + "modpoll:dataType": "uint16" + }, + { + "modpoll:property": "input_reg05", + "modpoll:offset": 4, + "modpoll:dataType": "int16" + }, + { + "modpoll:property": "input_reg06", + "modpoll:offset": 5, + "modpoll:dataType": "int16" + }, + { + "modpoll:property": "input_reg07", + "modpoll:offset": 6, + "modpoll:dataType": "int16" + }, + { + "modpoll:property": "input_reg08", + "modpoll:offset": 7, + "modpoll:dataType": "int16" + }, + { + "modpoll:property": "input_reg09", + "modpoll:offset": 8, + "modpoll:dataType": "uint32" + }, + { + "modpoll:property": "input_reg10", + "modpoll:offset": 10, + "modpoll:dataType": "uint32", + "modpoll:multiplier": "0.001" + }, + { + "modpoll:property": "input_reg11", + "modpoll:offset": 12, + "modpoll:dataType": "int32" + }, + { + "modpoll:property": "input_reg12", + "modpoll:offset": 14, + "modpoll:dataType": "int32", + "modpoll:multiplier": "1000" + }, + { + "modpoll:property": "input_reg13", + "modpoll:offset": 16, + "modpoll:dataType": "float32" + }, + { + "modpoll:property": "input_reg14", + "modpoll:offset": 18, + "modpoll:dataType": "float32" + } + ], + "modv:entity": "InputRegister", + "modv:address": 30000, + "modv:quantity": 20, + "modv:mostSignificantByte": true, + "modv:mostSignificantWord": true + }, + { + "modpoll:members": [ + { + "modpoll:property": "holding_reg01", + "modpoll:offset": 0, + "modpoll:dataType": "uint16" + }, + { + "modpoll:property": "holding_reg02", + "modpoll:offset": 1, + "modpoll:dataType": "uint16" + }, + { + "modpoll:property": "holding_reg03", + "modpoll:offset": 2, + "modpoll:dataType": "uint16" + }, + { + "modpoll:property": "holding_reg04", + "modpoll:offset": 3, + "modpoll:dataType": "uint16" + }, + { + "modpoll:property": "holding_reg05", + "modpoll:offset": 4, + "modpoll:dataType": "int16" + }, + { + "modpoll:property": "holding_reg06", + "modpoll:offset": 5, + "modpoll:dataType": "int16" + }, + { + "modpoll:property": "holding_reg07", + "modpoll:offset": 6, + "modpoll:dataType": "int16" + }, + { + "modpoll:property": "holding_reg08", + "modpoll:offset": 7, + "modpoll:dataType": "int16" + }, + { + "modpoll:property": "holding_reg09", + "modpoll:offset": 8, + "modpoll:dataType": "uint32" + }, + { + "modpoll:property": "holding_reg10", + "modpoll:offset": 10, + "modpoll:dataType": "uint32", + "modpoll:multiplier": "0.001" + }, + { + "modpoll:property": "holding_reg11", + "modpoll:offset": 12, + "modpoll:dataType": "int32" + }, + { + "modpoll:property": "holding_reg12", + "modpoll:offset": 14, + "modpoll:dataType": "int32", + "modpoll:multiplier": "1000" + }, + { + "modpoll:property": "holding_reg13", + "modpoll:offset": 16, + "modpoll:dataType": "float32" + }, + { + "modpoll:property": "holding_reg14", + "modpoll:offset": 18, + "modpoll:dataType": "float32" + }, + { + "modpoll:property": "holding_reg15", + "modpoll:offset": 20, + "modpoll:dataType": "uint64" + }, + { + "modpoll:property": "holding_reg16", + "modpoll:offset": 24, + "modpoll:dataType": "int64" + }, + { + "modpoll:property": "holding_reg17", + "modpoll:offset": 28, + "modpoll:dataType": "float64" + }, + { + "modpoll:property": "holding_reg18", + "modpoll:offset": 32, + "modpoll:dataType": "float64" + }, + { + "modpoll:property": "holding_reg19", + "modpoll:offset": 36, + "modpoll:dataType": "string16" + } + ], + "modv:entity": "HoldingRegister", + "modv:address": 40000, + "modv:quantity": 44, + "modv:mostSignificantByte": true, + "modv:mostSignificantWord": true + } + ] +} diff --git a/modpoll/arg_parser.py b/modpoll/arg_parser.py index e28c136..f952e27 100644 --- a/modpoll/arg_parser.py +++ b/modpoll/arg_parser.py @@ -17,9 +17,15 @@ def get_parser(): "-f", "--config", required=True, - help="A local path or URL of Modbus configuration file. Required!", + help="Local path or URL of a WoT TM/TD JSON configuration. Required!", nargs="+", ) + parser.add_argument( + "--unit-id", + type=int, + default=1, + help="Modbus unit ID for the model instance (1-254). Defaults to 1", + ) parser.add_argument( "-d", "--daemon", diff --git a/modpoll/modbus_task.py b/modpoll/modbus_task.py index 6e04c51..8470a71 100644 --- a/modpoll/modbus_task.py +++ b/modpoll/modbus_task.py @@ -1,4 +1,3 @@ -import csv import json import logging import math @@ -22,9 +21,6 @@ FLOAT_TYPE_PRECISION = 3 -CONFIG_DEVICE_COL_MIN = 3 -CONFIG_POLL_COL_MIN = 5 -CONFIG_REF_COL_MIN = 5 def export_modbus_handlers(handlers, file, timestamp=None) -> bool: @@ -415,9 +411,11 @@ def __init__( mqtt_diagnostics_topic_pattern: Optional[str] = None, mqtt_single_publish: bool = False, autoremove: bool = False, + unit_id: int = 1, ): self.modbus_client = modbus_client self.config_file = config_file + self.unit_id = unit_id self.mqtt_handler = mqtt_handler self.timeout = timeout self.interval = interval @@ -433,231 +431,18 @@ def __init__( ) def load_config(self) -> bool: - self.logger.info(f"Loading config from: {self.config_file}") - try: - with requests.Session() as s: - response = s.get(self.config_file, timeout=self.timeout) - response.raise_for_status() - decoded_content = response.content.decode("utf-8") - csv_reader = csv.reader(decoded_content.splitlines(), delimiter=",") - self.deviceList = self._parse_config(csv_reader) - except requests.RequestException: - try: - with open(self.config_file, "r") as f: - csv_reader = csv.reader(f) - self.deviceList = self._parse_config(csv_reader) - except IOError as e: - self.logger.error(f"Error opening file: {e}") - return False - if self.deviceList: - self.logger.info(f"Added {len(self.deviceList)} device(s)...") - return True - else: - self.logger.error("No device found in the config file. Skipping.") - return False - - def _parse_config(self, csv_reader) -> List[Device]: - device_list = [] - current_device = None - current_poller = None - duplicate_devices = [] - for line_number, row in enumerate(csv_reader, start=1): - if not row or all(cell.strip() == "" for cell in row): - continue - try: - if "device" in row[0].lower(): - if len(row) < CONFIG_DEVICE_COL_MIN: - current_device = None - current_poller = None - self.logger.error( - "Config line %s: invalid device configuration", line_number - ) - continue - device_name = row[1].strip() - if any(device.name == device_name for device in device_list): - self.logger.error( - "Config line %s: duplicate device name %s", - line_number, - device_name, - ) - duplicate_devices.append((line_number, device_name)) - current_device = None - current_poller = None - continue - try: - device_id = int(row[2], 0) - except ValueError: - self.logger.error( - "Config line %s: invalid device ID for %s", - line_number, - device_name, - ) - current_device = None - current_poller = None - continue - if not 1 <= device_id <= 254: - self.logger.error( - "Config line %s: device ID out of range for %s", - line_number, - device_name, - ) - current_device = None - current_poller = None - continue - current_device = Device(device_name, device_id) - device_list.append(current_device) - current_poller = None - elif "poll" in row[0].lower(): - if not current_device: - self.logger.error( - "Config line %s: no device to add poller", line_number - ) - current_poller = None - continue - current_poller = self._create_poller(row, current_device) - if current_poller: - current_device.pollerList.append(current_poller) - else: - self.logger.error("Config line %s: poller ignored", line_number) - elif "ref" in row[0].lower(): - if not current_device or not current_poller: - self.logger.error( - "Config line %s: reference has no valid device/poller", - line_number, - ) - continue - ref = self._create_reference(row, current_device) - if ref is None: - self.logger.error( - "Config line %s: reference ignored", line_number - ) - continue - if ref.name in current_device.references: - self.logger.error( - "Config line %s: duplicate reference name %s", - line_number, - ref.name, - ) - continue - if self._validate_reference(ref, current_poller): - if "r" in ref.rw.lower(): - current_poller.add_readable_reference(ref) - current_device.add_reference_mapping(ref) - else: - self.logger.error( - "Config line %s: reference failed validation", line_number - ) - else: - self.logger.warning( - "Config line %s: unknown row type %r", line_number, row[0] - ) - except Exception as exc: - self.logger.error( - "Config line %s: invalid row %r: %s", line_number, row, exc - ) - if duplicate_devices: - details = ", ".join( - "%s (line %s)" % (name, line) for line, name in duplicate_devices - ) - raise ValueError( - "Duplicate device names in %s: %s" % (self.config_file, details) - ) - return device_list - - def _create_poller(self, row, current_device): - if len(row) < CONFIG_POLL_COL_MIN: - self.logger.error("Invalid poller configuration") - return None - fc = row[1].lower() - try: - start_address = int(row[2], 0) - size = int(row[3], 0) - except ValueError: - self.logger.error("Invalid start address or size for poller") - return None - endian = row[4].upper() - if endian not in {"BE_BE", "BE_LE", "LE_BE", "LE_LE"}: - self.logger.error("Invalid endian setting: %s", endian) - return None - function_code = self._get_function_code(fc) - if function_code is None: - return None - if not 0 <= start_address <= 65535: - self.logger.error("Invalid poller start address: %s", start_address) - return None - if not self._validate_poller_size(function_code, size): - return None - if start_address + size > 65536: - self.logger.error("Poller address range exceeds Modbus address space") - return None - return Poller(current_device, function_code, start_address, size, endian) - - def _get_function_code(self, fc): - fc_map = { - "coil": 1, - "discrete_input": 2, - "holding_register": 3, - "input_register": 4, - } - if fc in fc_map: - return fc_map[fc] - self.logger.warning(f"Unknown function code ({fc}) ignoring poller.") - return None - - def _validate_poller_size(self, function_code, size): - if size <= 0: - self.logger.error("Poller size must be positive: %s", size) - return False - if function_code in (1, 2) and size > 2000: - self.logger.error( - f"Too many coils/discrete inputs (max. 2000): {size}. Ignoring poller." - ) - return False - if function_code in (3, 4) and size > 125: - self.logger.error( - f"Too many registers (max. 125): {size}. Ignoring poller." - ) - return False - return True + from .wot_config import create_devices, read_model - def _create_reference(self, row, current_device): - if len(row) < CONFIG_REF_COL_MIN: - self.logger.error("Invalid reference configuration") - return None - ref_name = row[1].replace(" ", "_") - address = row[2] - dtype = row[3].lower() - rw = (row[4] or "r").lower() - if rw not in {"r", "w", "rw"}: - self.logger.error("Invalid read/write mode for reference %s", ref_name) - return None - unit = row[5] if len(row) > 5 else None + self.logger.info(f"Loading WoT config from: {self.config_file}") try: - scale = float(row[6]) if len(row) > 6 and row[6] else None - if scale is not None and not math.isfinite(scale): - raise ValueError("scale must be finite") - except ValueError: - self.logger.error("Invalid scale for reference %s", ref_name) - return None - return Reference(current_device, ref_name, address, dtype, rw, unit, scale) - - def _validate_reference(self, ref, current_poller): - if current_poller.fc in (1, 2) and ref.bit is not None: - self.logger.warning("Bit selectors require a register poller: %s", ref.name) - return False - if ref in current_poller.readableReferences: - self.logger.warning(f"Reference {ref.name} is already added, ignoring it.") - return False - reference_size = ( - math.ceil(current_poller.size / 8) - if current_poller.fc in (1, 2) - else current_poller.size - ) - if not ref.check_sanity(current_poller.start_address, reference_size): - self.logger.warning( - f"Reference {ref.name} failed to pass sanity check, ignoring it." + model = read_model( + self.config_file, timeout=self.timeout, unit_id=self.unit_id ) + self.deviceList = create_devices(model) + except (ValueError, OSError, requests.RequestException) as exc: + self.logger.error("Invalid WoT configuration: %s", exc) return False + self.logger.info("Added %d device(s)", len(self.deviceList)) return True def connect(self) -> bool: @@ -894,6 +679,7 @@ def setup_modbus_handlers(args, mqtt_handler: Optional[MqttHandler] = None): mqtt_diagnostics_topic_pattern=args.mqtt_diagnostics_topic_pattern, mqtt_single_publish=args.mqtt_single, autoremove=args.autoremove, + unit_id=args.unit_id, ) if current_handler.load_config(): names = {device.name for device in current_handler.get_device_list()} diff --git a/modpoll/wot_config.py b/modpoll/wot_config.py new file mode 100644 index 0000000..50e1fc9 --- /dev/null +++ b/modpoll/wot_config.py @@ -0,0 +1,58 @@ +"""Adapt validated WoT configuration to the existing Modpoll execution engine.""" + +from modpoll_wot_modbus.loading import load_model, loads_model, MAX_BYTES +import requests + + +def read_model(location, *, timeout, unit_id): + if location.startswith(("http://", "https://")): + with requests.get(location, timeout=timeout, stream=True) as response: + response.raise_for_status() + chunks, size = [], 0 + for chunk in response.iter_content(65536): + size += len(chunk) + if size > MAX_BYTES: + raise ValueError(f"{location}: configuration exceeds 1 MiB") + chunks.append(chunk) + return loads_model(b"".join(chunks), source=location, unit_id=unit_id) + return load_model(location, unit_id=unit_id) + + +def create_devices(model): + # The shared interpreter has no dependency on the application or PyModbus. + from .modbus_task import Device, Poller, Reference + + device = Device(model.title, model.unit_id) + function_codes = { + "coil": 1, + "discrete_input": 2, + "holding_register": 3, + "input_register": 4, + } + for group in model.groups: + poller = Poller( + device, + function_codes[group.table], + group.start, + group.quantity, + group.byte_order, + ) + for member in group.members: + address = str(group.start + member.offset) + if member.bit is not None: + address += ":" + str(member.bit) + access = ("r" if member.readable else "") + ("w" if member.writable else "") + reference = Reference( + device, + member.property, + address, + member.data_type, + access, + member.unit, + None if member.multiplier is None else float(member.multiplier), + ) + if member.readable: + poller.add_readable_reference(reference) + device.add_reference_mapping(reference) + device.pollerList.append(poller) + return [device] diff --git a/packages/modpoll-wot-modbus/CONTRACT.md b/packages/modpoll-wot-modbus/CONTRACT.md index 4d498f3..8be3a4a 100644 --- a/packages/modpoll-wot-modbus/CONTRACT.md +++ b/packages/modpoll-wot-modbus/CONTRACT.md @@ -1,35 +1,88 @@ -# Experimental acquisition contract 0.1 +# Executable configuration contract 0.1 (development) -This contract is for Modpoll configuration replacement. It is not a W3C Modbus -binding and does not claim support for arbitrary TM/TD. W3C structural validity -and executable acquisition validity are checked separately, using local schemas. -The namespace is reserved for this project; its documentation is not published yet. +This is Modpoll's supported WoT TM/TD 1.1 subset for replacing CSV configuration. +It is not a general WoT runtime. The Modbus binding document remains a draft: +https://w3c.github.io/wot-binding-templates/bindings/protocols/modbus/. +Its entity, address, quantity and byte/word-order vocabulary is reused. Ordered +acquisition groups, offsets, precise decoding and multipliers are project extensions. +They do not claim standard W3C acquisition or transformation semantics. -| 1.x CSV field | Contract 0.1 | +Use `python -m modpoll_wot_modbus MODEL [--unit-id N]` to validate and inspect a +model without opening a device connection. Schemas are bundled locally; the +loader does not dereference contexts or remote schema dependencies. The official +TD/TM schemas identify themselves as `1.1-12-March-2025` (Draft 7). + +| 1.x input | Contract 0.1 | | --- | --- | -| device name | TM/TD title; preserve effective output name | -| Unit ID | Explicit load_model unit_id parameter, default 1, range 1–254 (legacy range) | -| poll table/start/count | acquisition table/start/quantity; literal PDU addresses | -| poll endian | byteOrder BE_BE, BE_LE, LE_BE, LE_LE | -| ref name | property key and member property reference | -| ref address | register offset from poll start; bit tables use eight-bit group offsets | -| ref `address:bit` | member bit, 0–15, register bool only | -| ref dtype | integer, float, bool/bool8/bool16, stringN | -| ref rw | standard readOnly/writeOnly; neither means rw | -| ref unit | standard property unit | -| ref scale | member multiplier; decimal multiplier, never inferred as divisor | - -Block order, boundaries and gaps are preserved. Coils and DI count physical bits; -legacy bit-member offsets count eight-bit groups. This asymmetry is explicit to -preserve 1.x behavior. Register offsets count 16-bit registers; stringN lengths -count bytes. Read-only tables require readOnly, correcting the sample CSV's rw -labels rather than promising unsupported writes. Shared backing addresses are -permitted; duplicate property membership is rejected. No automatic coalescing, -TM inheritance, placeholder substitution, remote context resolution or protocol -execution is performed by this layer. Unsupported semantics are deliberately rejected or excluded; runtime integration -and instantiation of protocol forms remain subsequent work. - -The modsim fixture is an explicitly authored mapping of modpoll/examples/modsim.csv, -including its full 44-register holding group and 37 output properties. It does not -claim the default server is generated from the fixture. Standard schema success -alone does not verify decoding, write commands or device behavior. +| device name | `title`, used verbatim as output device name | +| Unit ID | Explicit instance argument; default 1, range 1–254 | +| poll table | group `modv:entity`: Coil, DiscreteInput, InputRegister, HoldingRegister | +| poll start/count | group `modv:address` / `modv:quantity`, literal PDU addresses | +| poll endian | `modv:mostSignificantByte` and `modv:mostSignificantWord` booleans | +| ref effective name | property key and member `modpoll:property` | +| ref address | member `modpoll:offset`, relative to group start | +| ref `address:bit` | member `modpoll:bit`, 0–15, register bool only | +| ref dtype | member `modpoll:dataType` | +| ref rw | standard property `readOnly` / `writeOnly`; neither means rw | +| ref unit | standard property `unit` | +| ref scale | member `modpoll:multiplier`, multiplication, never inferred division | + +The JSON root declares `modpoll:version: "0.1"` and `modpoll:acquisition`. +Context aliases must match the supported project and draft Modbus namespaces; +these are not arbitrary remotely resolved JSON-LD aliases. The project namespace +is reserved for this development contract; its hosted documentation is not yet +published. Concrete TD Forms use `modbus+tcp` URLs. The Form's entity, operations, +unit/address/quantity and declared payload/byte order must match the group/member. +Explicit transport CLI options select the actual endpoint, allowing the same +configuration to be tested through a proxy or alternate transport. Concrete TD +Unit IDs must match `--unit-id`; unbound TMs can be instantiated with any supported ID. + +Register member offsets count 16-bit registers. Bit-table member offsets count +eight-bit groups relative to poll start, preserving the old behavior; poll counts +still specify physical bits. A final partial bit group is clipped by the executor. +Bit tables support bool/bool8/bool16; numeric bit-table decoding is explicitly +unsupported in this subset. Register types are int/uint16/32/64, float16/32/64, +bool/bool8/bool16 and stringN. String lengths count bytes, including odd lengths; +spans must fit the declared block. A register bool with a bit selector is a scalar +boolean; bool groups are arrays with boolean items. Strings cannot be multiplied. +Float and fractional-multiplier outputs require a number property schema. + +Groups preserve order, boundaries and holes. Reads are limited to 125 registers +or 2000 physical bits per request, within 0–65535. No coalescing takes place. +Each property must have exactly one mapping. Duplicate backing address/bit pairs +within a group are rejected to avoid silent omission by the existing executor. +Overlapping views at distinct starting offsets remain explicit, independent values. +Missing fields, unknown executable extension fields, invalid access, non-finite +values/multipliers, duplicate JSON keys and invalid type combinations fail the +whole file with source and field diagnostics. Inputs are bounded to 1 MiB and 64 +nested containers. Invalid files do not yield partially configured devices. + +DI/IR are read-only. The historical simulator CSV's rw flags for those tables are +corrected to readOnly in the fixtures; no unsupported write capability is promised. +The interpreter records access flags; raw MQTT write commands retain the 1.x raw +address behavior. This milestone does not add property-based writes or inverse +engineering-value transforms. Multipliers are retained as Decimal in the model +but adapted to the existing float execution semantics. Int64 without a multiplier +stays a Python integer. JSON/JavaScript consumers must still account for their own +numeric precision limits. + +Inheritance, links, optional TM members, URI templates, root operation Forms, +actions/events, unsupported Form terms and non-nosec security schemes are rejected. +Model/TD schema validation does not prove that a stock WoT consumer will implement +this acquisition extension or its transforms. In particular, a direct Modbus Form +identifies raw device access; engineering-value interpretation requires the project +transform metadata. No HTTP endpoint or cache service is declared. + +`examples/modsim.tm.json` is an authored mapping of `examples/modsim.csv`, retaining +four groups and 37 properties; it is not generated from CSV at runtime. +`examples/modsim.td.json` binds the same mapping to the documented local TCP +endpoint. Their mappings are checked against the CSV in tests. Runtime equivalence +checks additionally compare independently known initial values, complete JSON +snapshots, rendered tables, and the exact four requests against modsim. Shared +remote modsim state may have been written by others, so remote checks compare +successive read-only snapshots rather than assuming fixed initial values. + +The shared package currently uses a local Poetry path dependency for development. +Before publishing the application, publish the shared package and replace that +path with its exact registry version. A development application wheel containing +a local dependency URL is not a portable public release artifact. diff --git a/packages/modpoll-wot-modbus/README.md b/packages/modpoll-wot-modbus/README.md index 0fc1db3..3d51b01 100644 --- a/packages/modpoll-wot-modbus/README.md +++ b/packages/modpoll-wot-modbus/README.md @@ -8,10 +8,10 @@ schema, and acquisition semantics. It does not execute Modbus requests. ```python from modpoll_wot_modbus.loading import load_model -model = load_model("tests/fixtures/modsim.tm.json", kind="tm", unit_id=1) +model = load_model("examples/modsim.tm.json", kind="tm", unit_id=1) ``` -Run checks from the repository root: +The application adapter is connected to the existing Modpoll poll/decode/output engine. Run checks from the repository root: ```sh PYTHONPATH=packages/modpoll-wot-modbus python -m unittest discover -s packages/modpoll-wot-modbus/tests diff --git a/packages/modpoll-wot-modbus/modpoll_wot_modbus/__init__.py b/packages/modpoll-wot-modbus/modpoll_wot_modbus/__init__.py index d6085f5..497c160 100644 --- a/packages/modpoll-wot-modbus/modpoll_wot_modbus/__init__.py +++ b/packages/modpoll-wot-modbus/modpoll_wot_modbus/__init__.py @@ -1,5 +1,18 @@ -"""Experimental shared model for Modpoll WoT acquisition groups.""" +"""Modpoll's versioned executable subset of WoT TM/TD and Modbus metadata.""" from .acquisition import AcquisitionError, Group, Member, parse_acquisition +from .loading import Model, load_model, loads_model, parse_model +from .validation import ModelValidationError, validate_standard -__all__ = ["AcquisitionError", "Group", "Member", "parse_acquisition"] +__all__ = [ + "AcquisitionError", + "Group", + "Member", + "Model", + "ModelValidationError", + "parse_acquisition", + "load_model", + "loads_model", + "parse_model", + "validate_standard", +] diff --git a/packages/modpoll-wot-modbus/modpoll_wot_modbus/__main__.py b/packages/modpoll-wot-modbus/modpoll_wot_modbus/__main__.py new file mode 100644 index 0000000..c12ca4b --- /dev/null +++ b/packages/modpoll-wot-modbus/modpoll_wot_modbus/__main__.py @@ -0,0 +1,35 @@ +"""Validate a concrete executable TM/TD without contacting a device.""" + +import argparse +import json +import sys + +from . import AcquisitionError, load_model + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("model") + parser.add_argument("--unit-id", type=int, default=1) + args = parser.parse_args() + try: + model = load_model(args.model, unit_id=args.unit_id) + except AcquisitionError as exc: + print(str(exc), file=sys.stderr) + return 1 + print( + json.dumps( + { + "title": model.title, + "kind": model.kind, + "unit_id": model.unit_id, + "groups": len(model.groups), + "properties": sum(len(g.members) for g in model.groups), + } + ) + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/packages/modpoll-wot-modbus/modpoll_wot_modbus/acquisition.py b/packages/modpoll-wot-modbus/modpoll_wot_modbus/acquisition.py index e627f3c..44a07f0 100644 --- a/packages/modpoll-wot-modbus/modpoll_wot_modbus/acquisition.py +++ b/packages/modpoll-wot-modbus/modpoll_wot_modbus/acquisition.py @@ -1,11 +1,35 @@ -"""Version 0.1 acquisition contract; no transport or codec dependency.""" +"""Version 0.1 acquisition semantics, independent of Modbus transport.""" + from dataclasses import dataclass -from decimal import Decimal +from decimal import Decimal, InvalidOperation +import re import math class AcquisitionError(ValueError): - """Invalid or unsupported acquisition semantics.""" + """Invalid or unsupported executable configuration.""" + + +ENTITIES = { + "Coil": "coil", + "DiscreteInput": "discrete_input", + "InputRegister": "input_register", + "HoldingRegister": "holding_register", +} +WIDTHS = { + "int16": 1, + "uint16": 1, + "float16": 1, + "int32": 2, + "uint32": 2, + "float32": 2, + "int64": 4, + "uint64": 4, + "float64": 4, + "bool": 1, + "bool8": 1, + "bool16": 2, +} @dataclass(frozen=True) @@ -19,6 +43,10 @@ class Member: writable: bool = False unit: str | None = None + @property + def width(self): + return type_width(self.data_type) + @dataclass(frozen=True) class Group: @@ -29,98 +57,129 @@ class Group: members: tuple[Member, ...] -WIDTHS = {"int16": 1, "uint16": 1, "float16": 1, "int32": 2, - "uint32": 2, "float32": 2, "int64": 4, "uint64": 4, - "float64": 4, "bool": 1, "bool8": 1, "bool16": 2} +def type_width(dtype): + if dtype in WIDTHS: + return WIDTHS[dtype] + if re.fullmatch(r"string[1-9][0-9]{0,3}", dtype): + return (int(dtype[6:]) + 1) // 2 + raise AcquisitionError(f"unsupported data type: {dtype}") -def _integer(value, path): - if type(value) is not int: - raise AcquisitionError(f"{path}: expected integer") - return value - - -def _fields(value, required, path, optional=frozenset()): - if not isinstance(value, dict): - raise AcquisitionError(f"{path}: expected object") - if required - value.keys() or value.keys() - required - optional: - raise AcquisitionError(f"{path}: missing or unsupported fields") +def _schema_type(prop, dtype, bit, multiplier, path): + actual = prop.get("type") + if dtype.startswith("string"): + valid = actual == "string" + if multiplier is not None: + raise AcquisitionError(f"{path}: strings cannot have a multiplier") + elif dtype.startswith("bool"): + valid = ( + actual == "boolean" + if bit is not None + else ( + actual == "array" + and isinstance(prop.get("items"), dict) + and prop["items"].get("type") == "boolean" + ) + ) + if multiplier is not None: + raise AcquisitionError(f"{path}: bit values cannot have a multiplier") + else: + valid = actual in ("integer", "number") + if dtype.startswith("float") or ( + multiplier is not None and multiplier != multiplier.to_integral_value() + ): + valid = actual == "number" + if not valid: + raise AcquisitionError( + f"{path}: property type disagrees with decoded type/transform" + ) def parse_acquisition(document) -> tuple[Group, ...]: - if not isinstance(document, dict): - raise AcquisitionError("document: expected object") - properties = document.get("properties") - groups = document.get("modpoll:acquisition") + """Apply cross-field checks after structural schema validation.""" + # Also validate here so callers cannot bypass structural validation. + from .validation import validate_extension + + validate_extension(document) + properties = document.get("properties", {}) if not isinstance(properties, dict) or not properties: - raise AcquisitionError("properties: expected nonempty object") - if not isinstance(groups, list) or not groups: - raise AcquisitionError("modpoll:acquisition: expected nonempty array") - parsed, seen = [], set() - for i, group in enumerate(groups): - path = f"modpoll:acquisition[{i}]" - _fields(group, {"modpoll:table", "modpoll:start", "modpoll:quantity", "modpoll:byteOrder", "modpoll:members"}, path) - table = group["modpoll:table"] - if table not in ("coil", "discrete_input", "holding_register", "input_register"): - raise AcquisitionError(f"{path}: unsupported table") + raise AcquisitionError("/properties: expected nonempty object") + result, seen = [], set() + for i, group in enumerate(document["modpoll:acquisition"]): + path = f"/modpoll:acquisition/{i}" + table = ENTITIES[group["modv:entity"]] bits = table in ("coil", "discrete_input") - start = _integer(group["modpoll:start"], path + ".start") - quantity = _integer(group["modpoll:quantity"], path + ".quantity") - if not 0 <= start <= 65535 or not 1 <= quantity <= (2000 if bits else 125) or start + quantity > 65536: - raise AcquisitionError(f"{path}: invalid range") - order = group["modpoll:byteOrder"] - if order not in ("BE_BE", "BE_LE", "LE_BE", "LE_LE"): - raise AcquisitionError(f"{path}: invalid byte order") - members = group["modpoll:members"] - if not isinstance(members, list) or not members: - raise AcquisitionError(f"{path}.members: expected nonempty array") - result = [] - for j, member in enumerate(members): - mp = f"{path}.members[{j}]" - _fields(member, {"modpoll:property", "modpoll:offset", "modpoll:dataType"}, mp, {"modpoll:bit", "modpoll:multiplier"}) + start, count = group["modv:address"], group["modv:quantity"] + if count > (2000 if bits else 125) or start + count > 65536: + raise AcquisitionError(f"{path}: invalid Modbus range") + byte_order = ( + ("BE" if group["modv:mostSignificantByte"] else "LE") + + "_" + + ("BE" if group["modv:mostSignificantWord"] else "LE") + ) + members = [] + locations = set() + for j, member in enumerate(group["modpoll:members"]): + mp = f"{path}/modpoll:members/{j}" name = member["modpoll:property"] - if not isinstance(name, str) or name not in properties or name in seen: - raise AcquisitionError(f"{mp}: unknown or duplicate property") + if name not in properties or name in seen: + raise AcquisitionError(f"{mp}: unknown or duplicate property {name!r}") prop = properties[name] if not isinstance(prop, dict): - raise AcquisitionError(f"{mp}: invalid property schema") - dtype = member["modpoll:dataType"] - if not isinstance(dtype, str): - raise AcquisitionError(f"{mp}: invalid data type") - width = WIDTHS.get(dtype) - if dtype.startswith("string") and dtype[6:].isdigit() and int(dtype[6:]) > 0: - width = math.ceil(int(dtype[6:]) / 2) - if width is None: - raise AcquisitionError(f"{mp}: unsupported data type") - offset = _integer(member["modpoll:offset"], mp + ".offset") - # Legacy bit-table offsets count eight-bit groups, relative to poll start. - if offset < 0 or offset + width > (math.ceil(quantity / 8) if bits else quantity): + raise AcquisitionError(f"{mp}: expected property schema") + dtype, offset = member["modpoll:dataType"], member["modpoll:offset"] + width = type_width(dtype) + # 1.x bit table references count eight-bit groups, not physical bits. + limit = (count + 7) // 8 if bits else count + if offset + width > limit: raise AcquisitionError(f"{mp}: member outside group") bit = member.get("modpoll:bit") - if "modpoll:bit" in member and (bits or dtype != "bool" or type(bit) is not int or not 0 <= bit <= 15): - raise AcquisitionError(f"{mp}: invalid register bit selector") + if (offset, bit) in locations: + raise AcquisitionError(f"{mp}: duplicate backing address/bit in group") + locations.add((offset, bit)) + if bit is not None and (bits or dtype != "bool"): + raise AcquisitionError( + f"{mp}: register bit selector requires register bool" + ) + if bits and (not dtype.startswith("bool")): + raise AcquisitionError( + f"{mp}: bit tables support bool/bool8/bool16 only" + ) for flag in ("readOnly", "writeOnly"): if flag in prop and type(prop[flag]) is not bool: raise AcquisitionError(f"{mp}: invalid {flag}") if prop.get("readOnly") and prop.get("writeOnly"): raise AcquisitionError(f"{mp}: conflicting access flags") - readable, writable = not prop.get("writeOnly", False), not prop.get("readOnly", False) + readable, writable = not prop.get("writeOnly", False), not prop.get( + "readOnly", False + ) if table in ("discrete_input", "input_register") and writable: - raise AcquisitionError(f"{mp}: read-only table must declare readOnly") + raise AcquisitionError(f"{mp}: read-only table requires readOnly") multiplier = None if "modpoll:multiplier" in member: - value = member["modpoll:multiplier"] - if type(value) not in (int, float, str): - raise AcquisitionError(f"{mp}: invalid multiplier") try: - multiplier = Decimal(str(value)) - except Exception as exc: + multiplier = Decimal(str(member["modpoll:multiplier"])) + except InvalidOperation as exc: raise AcquisitionError(f"{mp}: invalid multiplier") from exc - if not multiplier.is_finite(): + if not multiplier.is_finite() or not math.isfinite(float(multiplier)): raise AcquisitionError(f"{mp}: non-finite multiplier") - result.append(Member(name, offset, dtype, bit, multiplier, readable, writable, prop.get("unit"))) + _schema_type(prop, dtype, bit, multiplier, mp) + members.append( + Member( + name, + offset, + dtype, + bit, + multiplier, + readable, + writable, + prop.get("unit"), + ) + ) seen.add(name) - parsed.append(Group(table, start, quantity, order, tuple(result))) + result.append(Group(table, start, count, byte_order, tuple(members))) if seen != properties.keys(): - raise AcquisitionError("properties: each property must have an acquisition member") - return tuple(parsed) + raise AcquisitionError( + "/properties: every property must have an acquisition member" + ) + return tuple(result) diff --git a/packages/modpoll-wot-modbus/modpoll_wot_modbus/forms.py b/packages/modpoll-wot-modbus/modpoll_wot_modbus/forms.py new file mode 100644 index 0000000..a1a288b --- /dev/null +++ b/packages/modpoll-wot-modbus/modpoll_wot_modbus/forms.py @@ -0,0 +1,145 @@ +"""Check any declared Modbus Forms against the acquisition mapping.""" + +from urllib.parse import parse_qs, urlsplit + +from .acquisition import AcquisitionError, ENTITIES + + +def validate_forms(document, groups, unit_id, kind): + endpoint = None + for group in groups: + entity = next(key for key, value in ENTITIES.items() if value == group.table) + for member in group.members: + forms = document["properties"][member.property].get("forms", []) + for form in forms: + path = f"/properties/{member.property}/forms" + if any(key.startswith("modpoll:") for key in form): + raise AcquisitionError( + f"{path}: unsupported project Form extension" + ) + if form.get("modv:entity") != entity: + raise AcquisitionError(f"{path}: expected modv:entity {entity}") + if "modv:function" in form or "modv:zeroBasedAddressing" in form: + raise AcquisitionError( + f"{path}: function overrides and deprecated addressing are unsupported" + ) + for term, expected in ( + ("modv:mostSignificantByte", group.byte_order[:2] == "BE"), + ("modv:mostSignificantWord", group.byte_order[-2:] == "BE"), + ): + if term in form and ( + type(form[term]) is not bool or form[term] != expected + ): + raise AcquisitionError( + f"{path}: byte order disagrees with group" + ) + allowed = (["readproperty"] if member.readable else []) + ( + ["writeproperty"] if member.writable else [] + ) + ops = form.get("op", allowed) + if isinstance(ops, str): + ops = [ops] + if not ops or any(op not in allowed for op in ops): + raise AcquisitionError( + f"{path}: operation disagrees with property access" + ) + expected_type = { + "uint16": "xsd:unsignedShort", + "int16": "xsd:short", + "uint32": "xsd:unsignedInt", + "int32": "xsd:int", + "uint64": "xsd:unsignedLong", + "int64": "xsd:long", + "float32": "xsd:float", + "float64": "xsd:double", + }.get(member.data_type) + if member.data_type.startswith("string"): + expected_type = "xsd:string" + elif member.data_type.startswith("bool"): + expected_type = ( + "xsd:unsignedShort" if member.bit is not None else "xsd:boolean" + ) + if "modv:type" in form and ( + expected_type is None or form["modv:type"] != expected_type + ): + raise AcquisitionError( + f"{path}: modv:type disagrees with decoding type" + ) + supported = { + "modv:entity", + "modv:address", + "modv:unitID", + "modv:quantity", + "modv:mostSignificantByte", + "modv:mostSignificantWord", + "modv:type", + } + if any( + key.startswith("modv:") and key not in supported for key in form + ): + raise AcquisitionError(f"{path}: unsupported Modbus form term") + if ( + form.get("contentType", "application/octet-stream") + != "application/octet-stream" + ): + raise AcquisitionError(f"{path}: unsupported content type") + href = form.get("href") + if href is None and kind == "tm": + continue + if not isinstance(href, str): + raise AcquisitionError(f"{path}: missing href") + if "://" not in href: + base = document.get("base", "") + href = base.rstrip("/") + "/" + href + try: + url = urlsplit(href) + parts = url.path.strip("/").split("/") + query = parse_qs( + url.query, strict_parsing=True, keep_blank_values=True + ) + actual_unit, address = map(int, parts) + quantity = int(query.get("quantity", ["1"])[0]) + if set(query) - {"quantity"} or len(query.get("quantity", [])) > 1: + raise ValueError("unsupported query") + remote = (url.hostname, url.port or 502) + if ( + url.scheme != "modbus+tcp" + or not remote[0] + or url.username + or url.password + or url.fragment + ): + raise ValueError("invalid Modbus TCP URL") + except (TypeError, ValueError) as exc: + raise AcquisitionError(f"{path}: invalid Modbus TCP href") from exc + for term, value in ( + ("modv:address", address), + ("modv:quantity", quantity), + ("modv:unitID", actual_unit), + ): + if term in form and ( + type(form[term]) is not int or form[term] != value + ): + raise AcquisitionError( + f"{path}: Modbus URL term disagrees with href" + ) + bit_table = group.table in ("coil", "discrete_input") + expected_address = group.start + member.offset * (8 if bit_table else 1) + expected_quantity = ( + min(member.width * 8, group.quantity - member.offset * 8) + if bit_table + else member.width + ) + if (actual_unit, address, quantity) != ( + unit_id, + expected_address, + expected_quantity, + ): + raise AcquisitionError( + f"{path}: href disagrees with group/member or Unit ID" + ) + if endpoint is not None and endpoint != remote: + raise AcquisitionError( + f"{path}: multiple endpoints in one device are unsupported" + ) + endpoint = remote diff --git a/packages/modpoll-wot-modbus/modpoll_wot_modbus/loading.py b/packages/modpoll-wot-modbus/modpoll_wot_modbus/loading.py index 0f9177a..5afad65 100644 --- a/packages/modpoll-wot-modbus/modpoll_wot_modbus/loading.py +++ b/packages/modpoll-wot-modbus/modpoll_wot_modbus/loading.py @@ -1,12 +1,18 @@ -"""Load a bounded JSON document and validate its executable contract.""" +"""Bounded JSON loading and validation of the executable Modpoll subset.""" + import json +import math from dataclasses import dataclass from pathlib import Path from .acquisition import AcquisitionError, Group, parse_acquisition from .validation import validate_standard +from .forms import validate_forms CONTEXT = "https://top-maker.github.io/modpoll/vocab/wot-modbus#" +MODBUS_CONTEXT = "https://www.w3.org/2019/wot/modbus" +MAX_BYTES = 1048576 +MAX_DEPTH = 64 @dataclass(frozen=True) @@ -18,34 +24,114 @@ class Model: def _pairs(pairs): - value = {} - for key, item in pairs: - if key in value: + result = {} + for key, value in pairs: + if key in result: raise AcquisitionError(f"duplicate JSON key: {key}") - value[key] = item - return value + result[key] = value + return result + + +def _invalid_number(value): + raise AcquisitionError(f"invalid JSON number: {value}") -def load_model(path, *, kind, unit_id=1): +def _check_depth(value, depth=0): + if depth > MAX_DEPTH: + raise AcquisitionError(f"JSON nesting exceeds {MAX_DEPTH}") + if isinstance(value, float) and not math.isfinite(value): + raise AcquisitionError("non-finite JSON number") + if isinstance(value, dict): + for item in value.values(): + _check_depth(item, depth + 1) + elif isinstance(value, list): + for item in value: + _check_depth(item, depth + 1) + + +def parse_model(document, *, kind=None, unit_id=1): if type(unit_id) is not int or not 1 <= unit_id <= 254: raise AcquisitionError("unit_id: expected integer between 1 and 254") - path = Path(path) + if not isinstance(document, dict): + raise AcquisitionError("document: expected object") + _check_depth(document) + types = document.get("@type", []) + if isinstance(types, str): + types = [types] + detected = "tm" if isinstance(types, list) and "tm:ThingModel" in types else "td" + if kind is None: + kind = detected + if kind != detected: + raise AcquisitionError("document kind disagrees with tm:ThingModel declaration") + validate_standard(document, kind=kind) + contexts = document.get("@context", []) + mappings = {} + for context in contexts if isinstance(contexts, list) else []: + if isinstance(context, dict): + mappings.update(context) + if mappings.get("modpoll") != CONTEXT or mappings.get("modv") != MODBUS_CONTEXT: + raise AcquisitionError("@context: unsupported modpoll/modv vocabulary mapping") + if ( + document.get("actions") + or document.get("events") + or document.get("links") + or document.get("tm:extends") + ): + raise AcquisitionError( + "actions/events/links/TM inheritance are unsupported in contract 0.1" + ) + for key in document: + if key.startswith("modpoll:") and key not in ( + "modpoll:version", + "modpoll:acquisition", + ): + raise AcquisitionError(f"unsupported extension: {key}") + if ( + document.get("forms") + or document.get("uriVariables") + or document.get("tm:optional") + ): + raise AcquisitionError( + "root operations, URI templates and optional members are unsupported" + ) + security = document.get("security", []) + if isinstance(security, str): + security = [security] + definitions = document.get("securityDefinitions", {}) + if any(definitions.get(name, {}).get("scheme") != "nosec" for name in security): + raise AcquisitionError("this Modbus configuration subset supports only nosec") + for name, prop in document.get("properties", {}).items(): + if any(key.startswith(("modpoll:", "modv:")) for key in prop): + raise AcquisitionError( + f"/properties/{name}: executable metadata belongs in members/forms" + ) + groups = parse_acquisition(document) + validate_forms(document, groups, unit_id, kind) + if not document["title"].strip(): + raise AcquisitionError("/title: device name cannot be empty") + return Model(document["title"], kind, groups, unit_id) + + +def loads_model(payload, *, source="", kind=None, unit_id=1): try: - with path.open('rb') as source: - payload = source.read(1048577) - if len(payload) > 1048576: + if ( + len(payload.encode("utf-8") if isinstance(payload, str) else payload) + > MAX_BYTES + ): raise AcquisitionError("configuration exceeds 1 MiB") - document = json.loads(payload, object_pairs_hook=_pairs, - parse_constant=lambda value: (_ for _ in ()).throw(AcquisitionError(f"invalid number: {value}"))) - validate_standard(document, kind=kind) - validate_standard(document, kind="acquisition") - contexts = document.get('@context', []) - if not isinstance(contexts, list) or not any(isinstance(c, dict) and c.get('modpoll') == CONTEXT for c in contexts): - raise AcquisitionError("@context: missing supported modpoll vocabulary") - if document.get('modpoll:version') != '0.1': - raise AcquisitionError("modpoll:version: expected 0.1") - if document.get('actions') or document.get('events') or document.get('links'): - raise AcquisitionError("actions, events and links are not executable in version 0.1") - return Model(document['title'], kind, parse_acquisition(document), unit_id) - except (ValueError, OSError, RecursionError) as exc: + document = json.loads( + payload, object_pairs_hook=_pairs, parse_constant=_invalid_number + ) + return parse_model(document, kind=kind, unit_id=unit_id) + except (ValueError, TypeError, RecursionError) as exc: + raise AcquisitionError(f"{source}: {exc}") from exc + + +def load_model(path, *, kind=None, unit_id=1): + path = Path(path) + try: + with path.open("rb") as stream: + payload = stream.read(MAX_BYTES + 1) + except OSError as exc: raise AcquisitionError(f"{path}: {exc}") from exc + return loads_model(payload, source=str(path), kind=kind, unit_id=unit_id) diff --git a/packages/modpoll-wot-modbus/modpoll_wot_modbus/schemas/SOURCES.md b/packages/modpoll-wot-modbus/modpoll_wot_modbus/schemas/SOURCES.md new file mode 100644 index 0000000..87df2ad --- /dev/null +++ b/packages/modpoll-wot-modbus/modpoll_wot_modbus/schemas/SOURCES.md @@ -0,0 +1,9 @@ +# Bundled schema sources + +Retrieved 2026-10-28; official schema content is stored without modification. +Upstream repository and licensing: https://github.com/w3c/wot-thing-description + +- td-1.1.json: https://www.w3.org/2022/wot/td-schema/v1.1 + SHA-256: fd44f319f94c6f16f66978860ef2f9f4e0e9fd7fc5835a2f3840b9cf2002e0f1 +- tm-1.1.json: https://www.w3.org/2022/wot/tm-schema/v1.1 + SHA-256: 6912708a0fa9966b51ae75f91951cf48a202d873023b8cba1b53ba4af51e92d3 diff --git a/packages/modpoll-wot-modbus/modpoll_wot_modbus/schemas/acquisition-0.1.json b/packages/modpoll-wot-modbus/modpoll_wot_modbus/schemas/acquisition-0.1.json index e0f0d71..2803e39 100644 --- a/packages/modpoll-wot-modbus/modpoll_wot_modbus/schemas/acquisition-0.1.json +++ b/packages/modpoll-wot-modbus/modpoll_wot_modbus/schemas/acquisition-0.1.json @@ -15,39 +15,38 @@ "items": { "type": "object", "required": [ - "modpoll:table", - "modpoll:start", - "modpoll:quantity", - "modpoll:byteOrder", + "modv:entity", + "modv:address", + "modv:quantity", + "modv:mostSignificantByte", + "modv:mostSignificantWord", "modpoll:members" ], "additionalProperties": false, "properties": { - "modpoll:table": { + "modv:entity": { "enum": [ - "coil", - "discrete_input", - "input_register", - "holding_register" + "Coil", + "DiscreteInput", + "InputRegister", + "HoldingRegister" ] }, - "modpoll:start": { + "modv:address": { "type": "integer", "minimum": 0, "maximum": 65535 }, - "modpoll:quantity": { + "modv:quantity": { "type": "integer", "minimum": 1, "maximum": 2000 }, - "modpoll:byteOrder": { - "enum": [ - "BE_BE", - "BE_LE", - "LE_BE", - "LE_LE" - ] + "modv:mostSignificantByte": { + "type": "boolean" + }, + "modv:mostSignificantWord": { + "type": "boolean" }, "modpoll:members": { "type": "array", @@ -67,11 +66,12 @@ }, "modpoll:offset": { "type": "integer", - "minimum": 0 + "minimum": 0, + "maximum": 65535 }, "modpoll:dataType": { "type": "string", - "pattern": "^(u?int(16|32|64)|float(16|32|64)|bool(8|16)?|string[1-9][0-9]*)$" + "pattern": "^(u?int(16|32|64)|float(16|32|64)|bool(8|16)?|string[1-9][0-9]{0,3})$" }, "modpoll:bit": { "type": "integer", @@ -79,9 +79,14 @@ "maximum": 15 }, "modpoll:multiplier": { - "type": [ - "number", - "string" + "oneOf": [ + { + "type": "number" + }, + { + "type": "string", + "pattern": "^[+-]?([0-9]+(\\.[0-9]*)?|\\.[0-9]+)([eE][+-]?[0-9]+)?$" + } ] } } diff --git a/packages/modpoll-wot-modbus/modpoll_wot_modbus/validation.py b/packages/modpoll-wot-modbus/modpoll_wot_modbus/validation.py index 438ce31..629d3e9 100644 --- a/packages/modpoll-wot-modbus/modpoll_wot_modbus/validation.py +++ b/packages/modpoll-wot-modbus/modpoll_wot_modbus/validation.py @@ -1,27 +1,56 @@ -"""Offline W3C TM/TD JSON Schema validation.""" +"""Offline JSON Schema validation against bundled, immutable schemas.""" import json from functools import lru_cache from importlib.resources import files from jsonschema import Draft7Validator +from referencing import Registry +from .acquisition import AcquisitionError -class ModelValidationError(ValueError): - """The supplied JSON document does not match the selected schema.""" + +class ModelValidationError(AcquisitionError): + """A document does not match its selected schema.""" + + +def _no_remote(uri): + raise ModelValidationError(f"remote schema resolution is disabled: {uri}") @lru_cache(maxsize=3) def _validator(kind): - if kind not in ("tm", "td", "acquisition"): + names = { + "tm": "tm-1.1.json", + "td": "td-1.1.json", + "acquisition": "acquisition-0.1.json", + } + if kind not in names: raise ValueError("kind must be 'tm' or 'td'") - schema = json.loads(files("modpoll_wot_modbus").joinpath("schemas", ("acquisition-0.1.json" if kind == "acquisition" else f"{kind}-1.1.json")).read_text(encoding="utf-8")) - return Draft7Validator(schema) + schema = json.loads( + files("modpoll_wot_modbus") + .joinpath("schemas", names[kind]) + .read_text(encoding="utf-8") + ) + Draft7Validator.check_schema(schema) + return Draft7Validator(schema, registry=Registry(retrieve=_no_remote)) -def validate_standard(document, *, kind): - """Validate standard structure; project semantics require separate checks.""" +def _validate(document, kind): error = next(_validator(kind).iter_errors(document), None) if error is not None: - path = "/" + "/".join(str(part).replace("~", "~0").replace("/", "~1") for part in error.absolute_path) + path = "/" + "/".join( + str(p).replace("~", "~0").replace("/", "~1") for p in error.absolute_path + ) raise ModelValidationError(f"{path}: {error.message}") + + +def validate_standard(document, *, kind): + """Validate WoT standard structure, not device or acquisition semantics.""" + if kind not in ("tm", "td"): + raise ValueError("kind must be 'tm' or 'td'") + _validate(document, kind) + + +def validate_extension(document): + _validate(document, "acquisition") diff --git a/packages/modpoll-wot-modbus/pyproject.toml b/packages/modpoll-wot-modbus/pyproject.toml index 80498c1..b4de6fe 100644 --- a/packages/modpoll-wot-modbus/pyproject.toml +++ b/packages/modpoll-wot-modbus/pyproject.toml @@ -1,13 +1,17 @@ -[build-system] -requires = ["setuptools>=61"] -build-backend = "setuptools.build_meta" - -[project] +[tool.poetry] name = "modpoll-wot-modbus" version = "0.1.0a1" -description = "Modpoll WoT Modbus configuration support" -requires-python = ">=3.10" -dependencies = ["jsonschema>=4.18,<5"] +description = "WoT Modbus configuration interpreter for Modpoll" +authors = ["Ying Shaodong "] +readme = "README.md" +packages = [{ include = "modpoll_wot_modbus" }] +include = ["CONTRACT.md", "modpoll_wot_modbus/schemas/*.json"] -[tool.setuptools.package-data] -modpoll_wot_modbus = ["schemas/*.json"] +[tool.poetry.dependencies] +python = ">=3.10,<4.0" +jsonschema = ">=4.18,<5" +referencing = ">=0.28.4,<1" + +[build-system] +requires = ["poetry-core>=1.9.0"] +build-backend = "poetry.core.masonry.api" diff --git a/packages/modpoll-wot-modbus/tests/test_acquisition.py b/packages/modpoll-wot-modbus/tests/test_acquisition.py index 2900cee..e3ca05e 100644 --- a/packages/modpoll-wot-modbus/tests/test_acquisition.py +++ b/packages/modpoll-wot-modbus/tests/test_acquisition.py @@ -6,32 +6,52 @@ def model(): return { + "modpoll:version": "0.1", "properties": {"voltage": {"type": "number"}, "current": {"type": "number"}}, - "modpoll:acquisition": [{ - "modpoll:table": "holding_register", "modpoll:start": 40000, - "modpoll:quantity": 6, "modpoll:byteOrder": "BE_BE", - "modpoll:members": [ - {"modpoll:property": "voltage", "modpoll:offset": 0, "modpoll:dataType": "float32"}, - {"modpoll:property": "current", "modpoll:offset": 4, "modpoll:dataType": "float32"}, - ], - }], + "modpoll:acquisition": [ + { + "modv:entity": "HoldingRegister", + "modv:address": 40000, + "modv:quantity": 6, + "modv:mostSignificantByte": True, + "modv:mostSignificantWord": True, + "modpoll:members": [ + { + "modpoll:property": "voltage", + "modpoll:offset": 0, + "modpoll:dataType": "float32", + }, + { + "modpoll:property": "current", + "modpoll:offset": 4, + "modpoll:dataType": "float32", + }, + ], + } + ], } class AcquisitionTests(unittest.TestCase): def test_preserves_declared_group_and_hole(self): - group, = parse_acquisition(model()) - self.assertEqual((group.start, group.quantity, group.table), (40000, 6, "holding_register")) - self.assertEqual([(m.property, m.offset) for m in group.members], [("voltage", 0), ("current", 4)]) + (group,) = parse_acquisition(model()) + self.assertEqual( + (group.start, group.quantity, group.table), (40000, 6, "holding_register") + ) + self.assertEqual( + [(m.property, m.offset) for m in group.members], + [("voltage", 0), ("current", 4)], + ) def test_rejects_invalid_or_unimplemented_inputs(self): def duplicate(group): group["modpoll:members"].append(copy.deepcopy(group["modpoll:members"][0])) + changes = [ - lambda g: g.update({"modpoll:quantity": 3}), - lambda g: g.update({"modpoll:start": 65535}), - lambda g: g.update({"modpoll:quantity": True}), - lambda g: g.update({"modpoll:table": "unknown"}), + lambda g: g.update({"modv:quantity": 3}), + lambda g: g.update({"modv:address": 65535}), + lambda g: g.update({"modv:quantity": True}), + lambda g: g.update({"modv:entity": "unknown"}), lambda g: g["modpoll:members"][0].update({"modpoll:property": "missing"}), lambda g: g["modpoll:members"][0].update({"modpoll:dataType": "unit32"}), lambda g: g["modpoll:members"][0].update({"modpoll:scale": 2}), diff --git a/packages/modpoll-wot-modbus/tests/test_contract.py b/packages/modpoll-wot-modbus/tests/test_contract.py new file mode 100644 index 0000000..70f2292 --- /dev/null +++ b/packages/modpoll-wot-modbus/tests/test_contract.py @@ -0,0 +1,215 @@ +"""Independent acceptance vectors for the configuration contract.""" + +import copy +import json +from pathlib import Path +import unittest +from unittest.mock import patch + +from modpoll_wot_modbus import AcquisitionError +from modpoll_wot_modbus.loading import load_model, loads_model, parse_model + +ROOT = Path(__file__).resolve().parents[3] + + +def scalar(dtype="uint16", type_="integer"): + return { + "@context": [ + "https://www.w3.org/2022/wot/td/v1.1", + { + "modpoll": "https://top-maker.github.io/modpoll/vocab/wot-modbus#", + "modv": "https://www.w3.org/2019/wot/modbus", + }, + ], + "@type": "tm:ThingModel", + "title": "test", + "modpoll:version": "0.1", + "properties": {"value": {"type": type_, "readOnly": True}}, + "modpoll:acquisition": [ + { + "modv:entity": "HoldingRegister", + "modv:address": 0, + "modv:quantity": 125, + "modv:mostSignificantByte": True, + "modv:mostSignificantWord": True, + "modpoll:members": [ + { + "modpoll:property": "value", + "modpoll:offset": 0, + "modpoll:dataType": dtype, + } + ], + } + ], + } + + +class ContractTests(unittest.TestCase): + def test_all_legacy_numeric_and_string_types(self): + for dtype, width in [ + ("uint16", 1), + ("int16", 1), + ("float16", 1), + ("uint32", 2), + ("int32", 2), + ("float32", 2), + ("uint64", 4), + ("int64", 4), + ("float64", 4), + ("string3", 2), + ("string16", 8), + ]: + with self.subTest(dtype=dtype): + type_ = "string" if dtype.startswith("string") else "number" + member = parse_model(scalar(dtype, type_)).groups[0].members[0] + self.assertEqual(member.width, width) + + def test_bool_groups_and_register_bits(self): + for entity in ("Coil", "DiscreteInput", "HoldingRegister", "InputRegister"): + for dtype in ("bool", "bool8", "bool16"): + with self.subTest(entity=entity, dtype=dtype): + d = scalar(dtype, "array") + d["properties"]["value"]["items"] = {"type": "boolean"} + d["modpoll:acquisition"][0]["modv:entity"] = entity + self.assertTrue(parse_model(d).groups[0].members[0].readable) + for bit in (0, 15): + d = scalar("bool", "boolean") + d["modpoll:acquisition"][0]["modpoll:members"][0]["modpoll:bit"] = bit + self.assertEqual(parse_model(d).groups[0].members[0].bit, bit) + + def test_all_endian_combinations(self): + for byte in (True, False): + for word in (True, False): + d = scalar() + g = d["modpoll:acquisition"][0] + g["modv:mostSignificantByte"], g["modv:mostSignificantWord"] = ( + byte, + word, + ) + self.assertEqual( + parse_model(d).groups[0].byte_order, + ("BE" if byte else "LE") + "_" + ("BE" if word else "LE"), + ) + + def test_access_and_multiplier(self): + for flags, expected in [ + ({}, (True, True)), + ({"readOnly": True}, (True, False)), + ({"writeOnly": True}, (False, True)), + ]: + d = scalar() + d["properties"]["value"] = {"type": "number", **flags} + d["modpoll:acquisition"][0]["modpoll:members"][0][ + "modpoll:multiplier" + ] = "0" + m = parse_model(d).groups[0].members[0] + self.assertEqual((m.readable, m.writable), expected) + self.assertEqual(m.multiplier, 0) + for factor in ("-2", "0.001", "1e3"): + d = scalar(type_="number") + d["modpoll:acquisition"][0]["modpoll:members"][0][ + "modpoll:multiplier" + ] = factor + self.assertIsNotNone(parse_model(d).groups[0].members[0].multiplier) + + def test_semantic_failures(self): + mutations = [ + lambda d, g, m: g.update({"modv:address": 65535}), + lambda d, g, m: g.update({"modv:quantity": 126}), + lambda d, g, m: g.update({"modv:quantity": 0}), + lambda d, g, m: g.update({"modv:quantity": True}), + lambda d, g, m: g.update({"modv:mostSignificantByte": "BE"}), + lambda d, g, m: m.update({"modpoll:offset": -1}), + lambda d, g, m: m.update({"modpoll:offset": 125}), + lambda d, g, m: m.update({"modpoll:property": "unknown"}), + lambda d, g, m: m.update({"modpoll:dataType": "unit32"}), + lambda d, g, m: m.update({"modpoll:dataType": "float32"}), + lambda d, g, m: m.update({"modpoll:bit": 1}), + lambda d, g, m: m.update({"modpoll:multiplier": "NaN"}), + lambda d, g, m: m.update({"modpoll:multiplier": "1e9999"}), + lambda d, g, m: m.update({"modpoll:multiplier": True}), + lambda d, g, m: m.update({"modpoll:scale": 1}), + lambda d, g, m: g["modpoll:members"].append(copy.deepcopy(m)), + lambda d, g, m: d["properties"].update({"missing": {"type": "number"}}), + lambda d, g, m: d["properties"]["value"].update({"writeOnly": True}), + lambda d, g, m: g.update({"modv:entity": "Coil"}), + lambda d, g, m: d.update({"modpoll:unknown": 1}), + ] + for index, mutation in enumerate(mutations): + with self.subTest(case=index): + d = scalar() + g = d["modpoll:acquisition"][0] + m = g["modpoll:members"][0] + mutation(d, g, m) + with self.assertRaises(AcquisitionError): + parse_model(d) + + def test_boundary_addresses_and_limits(self): + for entity, count in [ + ("HoldingRegister", 125), + ("InputRegister", 125), + ("Coil", 2000), + ("DiscreteInput", 2000), + ]: + d = scalar("bool8", "array") if count == 2000 else scalar() + if count == 2000: + d["properties"]["value"]["items"] = {"type": "boolean"} + g = d["modpoll:acquisition"][0] + g.update( + { + "modv:entity": entity, + "modv:quantity": count, + "modv:address": 65536 - count, + } + ) + parse_model(d) + g["modv:quantity"] += 1 + with self.assertRaises(AcquisitionError): + parse_model(d) + + def test_offline_loading_limits_and_namespaces(self): + with patch( + "socket.create_connection", side_effect=AssertionError("network used") + ): + load_model(ROOT / "examples/modsim.tm.json") + load_model(ROOT / "examples/modsim.td.json") + for text in ( + '{"a":1,"a":2}', + "NaN", + "1e9999", + "[" * 80 + "0" + "]" * 80, + " " * 1048577, + ): + with self.subTest(text=text[:20]), self.assertRaises(AcquisitionError): + loads_model(text) + d = scalar() + d["@context"][1]["modpoll"] = "https://example.com/unknown#" + with self.assertRaises(AcquisitionError): + parse_model(d) + + def test_td_forms_cannot_disagree_with_acquisition(self): + source = json.loads((ROOT / "examples/modsim.td.json").read_text()) + changes = [ + lambda f: f.update( + {"href": "modbus+tcp://localhost:5020/1/999?quantity=8"} + ), + lambda f: f.update({"modv:entity": "InputRegister"}), + lambda f: f.update({"modv:mostSignificantByte": False}), + lambda f: f.update({"modv:function": "readHoldingRegisters"}), + lambda f: f.update({"op": "invokeaction"}), + lambda f: f.update({"href": "http://localhost/1/0"}), + ] + changes.extend( + [ + lambda f: f.update({"modv:quantity": 999}), + lambda f: f.update({"modv:type": "xsd:string"}), + lambda f: f.update( + {"href": "modbus+tcp://localhost:5020/1/0?quantity="} + ), + ] + ) + for change in changes: + d = copy.deepcopy(source) + change(d["properties"]["coil01-08"]["forms"][0]) + with self.assertRaises(AcquisitionError): + parse_model(d) diff --git a/packages/modpoll-wot-modbus/tests/test_loading.py b/packages/modpoll-wot-modbus/tests/test_loading.py index e95295b..25efbea 100644 --- a/packages/modpoll-wot-modbus/tests/test_loading.py +++ b/packages/modpoll-wot-modbus/tests/test_loading.py @@ -6,66 +6,82 @@ from modpoll_wot_modbus import AcquisitionError from modpoll_wot_modbus.loading import load_model -FIXTURE = Path(__file__).parent / 'fixtures/modsim.tm.json' +FIXTURE = Path(__file__).resolve().parents[3] / "examples/modsim.tm.json" class LoadingTests(unittest.TestCase): def test_modsim_four_tables_and_values_contract(self): - model = load_model(FIXTURE, kind='tm') - self.assertEqual([(g.table, g.start, g.quantity) for g in model.groups], [ - ('coil', 0, 16), ('discrete_input', 10000, 16), - ('input_register', 30000, 20), ('holding_register', 40000, 44)]) + model = load_model(FIXTURE, kind="tm") + self.assertEqual( + [(g.table, g.start, g.quantity) for g in model.groups], + [ + ("coil", 0, 16), + ("discrete_input", 10000, 16), + ("input_register", 30000, 20), + ("holding_register", 40000, 44), + ], + ) self.assertEqual(sum(len(g.members) for g in model.groups), 37) self.assertFalse(model.groups[1].members[0].writable) self.assertTrue(model.groups[3].members[0].writable) - self.assertEqual(str(model.groups[2].members[9].multiplier), '0.001') - self.assertEqual(model.groups[3].members[-1].data_type, 'string16') + self.assertEqual(str(model.groups[2].members[9].multiplier), "0.001") + self.assertEqual(model.groups[3].members[-1].data_type, "string16") def test_unit_id_is_instance_input(self): - self.assertEqual(load_model(FIXTURE, kind='tm', unit_id=7).unit_id, 7) + self.assertEqual(load_model(FIXTURE, kind="tm", unit_id=7).unit_id, 7) for value in (True, 0, 255, "1"): with self.subTest(value=value), self.assertRaises(AcquisitionError): - load_model(FIXTURE, kind='tm', unit_id=value) + load_model(FIXTURE, kind="tm", unit_id=value) def test_authored_fixture_matches_csv_groups_and_members(self): import csv from decimal import Decimal - source = Path(__file__).resolve().parents[3] / 'examples/modsim.csv' - model = load_model(FIXTURE, kind='tm') + + source = Path(__file__).resolve().parents[3] / "examples/modsim.csv" + model = load_model(FIXTURE, kind="tm") groups = [] for row in csv.reader(source.read_text().splitlines()): - if row[0] == 'poll': + if row[0] == "poll": groups.append((row, [])) - elif row[0] == 'ref': + elif row[0] == "ref": groups[-1][1].append(row) self.assertEqual(len(groups), len(model.groups)) for (poll, refs), group in zip(groups, model.groups): - self.assertEqual((poll[1], int(poll[2]), int(poll[3]), poll[4]), - (group.table, group.start, group.quantity, group.byte_order)) + self.assertEqual( + (poll[1], int(poll[2]), int(poll[3]), poll[4]), + (group.table, group.start, group.quantity, group.byte_order), + ) self.assertEqual(len(refs), len(group.members)) for row, member in zip(refs, group.members): - self.assertEqual((row[1], int(row[2]) - group.start, row[3]), - (member.property, member.offset, member.data_type)) - self.assertEqual(Decimal(row[6]) if len(row) > 6 and row[6] else None, member.multiplier) + self.assertEqual( + (row[1], int(row[2]) - group.start, row[3]), + (member.property, member.offset, member.data_type), + ) + self.assertEqual( + Decimal(row[6]) if len(row) > 6 and row[6] else None, + member.multiplier, + ) def test_bad_json_and_semantics_include_filename(self): with tempfile.TemporaryDirectory() as temp: - path = Path(temp) / 'broken.json' - for text in ('{"title":"a","title":"b"}', '{', 'NaN'): + path = Path(temp) / "broken.json" + for text in ('{"title":"a","title":"b"}', "{", "NaN"): path.write_text(text) - with self.subTest(text=text), self.assertRaisesRegex(AcquisitionError, 'broken.json'): - load_model(path, kind='tm') + with self.subTest(text=text), self.assertRaisesRegex( + AcquisitionError, "broken.json" + ): + load_model(path, kind="tm") document = json.loads(FIXTURE.read_text()) - document['modpoll:acquisition'][1]['modpoll:members'][0]['modpoll:bit'] = 1 + document["modpoll:acquisition"][1]["modpoll:members"][0]["modpoll:bit"] = 1 path.write_text(json.dumps(document)) - with self.assertRaisesRegex(AcquisitionError, 'bit selector'): - load_model(path, kind='tm') + with self.assertRaisesRegex(AcquisitionError, "bit selector"): + load_model(path, kind="tm") def test_extension_schema_rejects_unknown_member(self): with tempfile.TemporaryDirectory() as temp: - path = Path(temp) / 'broken.json' + path = Path(temp) / "broken.json" document = json.loads(FIXTURE.read_text()) - document['modpoll:acquisition'][0]['modpoll:members'][0]['typo'] = 1 + document["modpoll:acquisition"][0]["modpoll:members"][0]["typo"] = 1 path.write_text(json.dumps(document)) with self.assertRaises(AcquisitionError): - load_model(path, kind='tm') + load_model(path, kind="tm") diff --git a/packages/modpoll-wot-modbus/tests/test_validation.py b/packages/modpoll-wot-modbus/tests/test_validation.py index 1ca926e..78c82a9 100644 --- a/packages/modpoll-wot-modbus/tests/test_validation.py +++ b/packages/modpoll-wot-modbus/tests/test_validation.py @@ -5,11 +5,21 @@ class StandardValidationTests(unittest.TestCase): def test_tm_valid(self): - validate_standard({"@context": "https://www.w3.org/2022/wot/td/v1.1", "@type": "tm:ThingModel", "title": "Meter", "properties": {"power": {"type": "number"}}}, kind="tm") + validate_standard( + { + "@context": "https://www.w3.org/2022/wot/td/v1.1", + "@type": "tm:ThingModel", + "title": "Meter", + "properties": {"power": {"type": "number"}}, + }, + kind="tm", + ) def test_missing_title_rejected(self): with self.assertRaises(ModelValidationError): - validate_standard({"@context": "https://www.w3.org/2022/wot/td/v1.1"}, kind="tm") + validate_standard( + {"@context": "https://www.w3.org/2022/wot/td/v1.1"}, kind="tm" + ) def test_wrong_kind(self): with self.assertRaises(ValueError): @@ -17,4 +27,7 @@ def test_wrong_kind(self): def test_td_requires_security(self): with self.assertRaises(ModelValidationError): - validate_standard({"@context": "https://www.w3.org/2022/wot/td/v1.1", "title": "Meter"}, kind="td") + validate_standard( + {"@context": "https://www.w3.org/2022/wot/td/v1.1", "title": "Meter"}, + kind="td", + ) diff --git a/poetry.lock b/poetry.lock index 825549c..53631d3 100644 --- a/poetry.lock +++ b/poetry.lock @@ -11,6 +11,17 @@ files = [ {file = "alabaster-0.7.16.tar.gz", hash = "sha256:75a8b99c28a5dad50dd7f8ccdd447a121ddb3892da9e53d1ca5cca3106d58d65"}, ] +[[package]] +name = "attrs" +version = "26.1.0" +description = "Classes Without Boilerplate" +optional = false +python-versions = ">=3.9" +files = [ + {file = "attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309"}, + {file = "attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32"}, +] + [[package]] name = "babel" version = "2.17.0" @@ -358,6 +369,41 @@ MarkupSafe = ">=2.0" [package.extras] i18n = ["Babel (>=2.7)"] +[[package]] +name = "jsonschema" +version = "4.26.0" +description = "An implementation of JSON Schema validation for Python" +optional = false +python-versions = ">=3.10" +files = [ + {file = "jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce"}, + {file = "jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326"}, +] + +[package.dependencies] +attrs = ">=22.2.0" +jsonschema-specifications = ">=2023.03.6" +referencing = ">=0.28.4" +rpds-py = ">=0.25.0" + +[package.extras] +format = ["fqdn", "idna", "isoduration", "jsonpointer (>1.13)", "rfc3339-validator", "rfc3987", "uri-template", "webcolors (>=1.11)"] +format-nongpl = ["fqdn", "idna", "isoduration", "jsonpointer (>1.13)", "rfc3339-validator", "rfc3986-validator (>0.1.0)", "rfc3987-syntax (>=1.1.0)", "uri-template", "webcolors (>=24.6.0)"] + +[[package]] +name = "jsonschema-specifications" +version = "2025.9.1" +description = "The JSON Schema meta-schemas and vocabularies, exposed as a Registry" +optional = false +python-versions = ">=3.9" +files = [ + {file = "jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe"}, + {file = "jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d"}, +] + +[package.dependencies] +referencing = ">=0.31.0" + [[package]] name = "livereload" version = "2.7.1" @@ -470,6 +516,23 @@ files = [ {file = "markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698"}, ] +[[package]] +name = "modpoll-wot-modbus" +version = "0.1.0a1" +description = "WoT Modbus configuration interpreter for Modpoll" +optional = false +python-versions = ">=3.10,<4.0" +files = [] +develop = true + +[package.dependencies] +jsonschema = ">=4.18,<5" +referencing = ">=0.28.4,<1" + +[package.source] +type = "directory" +url = "packages/modpoll-wot-modbus" + [[package]] name = "nodeenv" version = "1.9.1" @@ -814,6 +877,22 @@ files = [ {file = "pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f"}, ] +[[package]] +name = "referencing" +version = "0.37.0" +description = "JSON Referencing + Python" +optional = false +python-versions = ">=3.10" +files = [ + {file = "referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231"}, + {file = "referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8"}, +] + +[package.dependencies] +attrs = ">=22.2.0" +rpds-py = ">=0.7.0" +typing-extensions = {version = ">=4.4.0", markers = "python_version < \"3.13\""} + [[package]] name = "requests" version = "2.32.5" @@ -835,6 +914,130 @@ urllib3 = ">=1.21.1,<3" socks = ["PySocks (>=1.5.6,!=1.5.7)"] use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] +[[package]] +name = "rpds-py" +version = "0.30.0" +description = "Python bindings to Rust's persistent data structures (rpds)" +optional = false +python-versions = ">=3.10" +files = [ + {file = "rpds_py-0.30.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:679ae98e00c0e8d68a7fda324e16b90fd5260945b45d3b824c892cec9eea3288"}, + {file = "rpds_py-0.30.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4cc2206b76b4f576934f0ed374b10d7ca5f457858b157ca52064bdfc26b9fc00"}, + {file = "rpds_py-0.30.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:389a2d49eded1896c3d48b0136ead37c48e221b391c052fba3f4055c367f60a6"}, + {file = "rpds_py-0.30.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:32c8528634e1bf7121f3de08fa85b138f4e0dc47657866630611b03967f041d7"}, + {file = "rpds_py-0.30.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f207f69853edd6f6700b86efb84999651baf3789e78a466431df1331608e5324"}, + {file = "rpds_py-0.30.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:67b02ec25ba7a9e8fa74c63b6ca44cf5707f2fbfadae3ee8e7494297d56aa9df"}, + {file = "rpds_py-0.30.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c0e95f6819a19965ff420f65578bacb0b00f251fefe2c8b23347c37174271f3"}, + {file = "rpds_py-0.30.0-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:a452763cc5198f2f98898eb98f7569649fe5da666c2dc6b5ddb10fde5a574221"}, + {file = "rpds_py-0.30.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e0b65193a413ccc930671c55153a03ee57cecb49e6227204b04fae512eb657a7"}, + {file = "rpds_py-0.30.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:858738e9c32147f78b3ac24dc0edb6610000e56dc0f700fd5f651d0a0f0eb9ff"}, + {file = "rpds_py-0.30.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:da279aa314f00acbb803da1e76fa18666778e8a8f83484fba94526da5de2cba7"}, + {file = "rpds_py-0.30.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7c64d38fb49b6cdeda16ab49e35fe0da2e1e9b34bc38bd78386530f218b37139"}, + {file = "rpds_py-0.30.0-cp310-cp310-win32.whl", hash = "sha256:6de2a32a1665b93233cde140ff8b3467bdb9e2af2b91079f0333a0974d12d464"}, + {file = "rpds_py-0.30.0-cp310-cp310-win_amd64.whl", hash = "sha256:1726859cd0de969f88dc8673bdd954185b9104e05806be64bcd87badbe313169"}, + {file = "rpds_py-0.30.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a2bffea6a4ca9f01b3f8e548302470306689684e61602aa3d141e34da06cf425"}, + {file = "rpds_py-0.30.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dc4f992dfe1e2bc3ebc7444f6c7051b4bc13cd8e33e43511e8ffd13bf407010d"}, + {file = "rpds_py-0.30.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:422c3cb9856d80b09d30d2eb255d0754b23e090034e1deb4083f8004bd0761e4"}, + {file = "rpds_py-0.30.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:07ae8a593e1c3c6b82ca3292efbe73c30b61332fd612e05abee07c79359f292f"}, + {file = "rpds_py-0.30.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12f90dd7557b6bd57f40abe7747e81e0c0b119bef015ea7726e69fe550e394a4"}, + {file = "rpds_py-0.30.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:99b47d6ad9a6da00bec6aabe5a6279ecd3c06a329d4aa4771034a21e335c3a97"}, + {file = "rpds_py-0.30.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:33f559f3104504506a44bb666b93a33f5d33133765b0c216a5bf2f1e1503af89"}, + {file = "rpds_py-0.30.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:946fe926af6e44f3697abbc305ea168c2c31d3e3ef1058cf68f379bf0335a78d"}, + {file = "rpds_py-0.30.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:495aeca4b93d465efde585977365187149e75383ad2684f81519f504f5c13038"}, + {file = "rpds_py-0.30.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9a0ca5da0386dee0655b4ccdf46119df60e0f10da268d04fe7cc87886872ba7"}, + {file = "rpds_py-0.30.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8d6d1cc13664ec13c1b84241204ff3b12f9bb82464b8ad6e7a5d3486975c2eed"}, + {file = "rpds_py-0.30.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3896fa1be39912cf0757753826bc8bdc8ca331a28a7c4ae46b7a21280b06bb85"}, + {file = "rpds_py-0.30.0-cp311-cp311-win32.whl", hash = "sha256:55f66022632205940f1827effeff17c4fa7ae1953d2b74a8581baaefb7d16f8c"}, + {file = "rpds_py-0.30.0-cp311-cp311-win_amd64.whl", hash = "sha256:a51033ff701fca756439d641c0ad09a41d9242fa69121c7d8769604a0a629825"}, + {file = "rpds_py-0.30.0-cp311-cp311-win_arm64.whl", hash = "sha256:47b0ef6231c58f506ef0b74d44e330405caa8428e770fec25329ed2cb971a229"}, + {file = "rpds_py-0.30.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a161f20d9a43006833cd7068375a94d035714d73a172b681d8881820600abfad"}, + {file = "rpds_py-0.30.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6abc8880d9d036ecaafe709079969f56e876fcf107f7a8e9920ba6d5a3878d05"}, + {file = "rpds_py-0.30.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca28829ae5f5d569bb62a79512c842a03a12576375d5ece7d2cadf8abe96ec28"}, + {file = "rpds_py-0.30.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a1010ed9524c73b94d15919ca4d41d8780980e1765babf85f9a2f90d247153dd"}, + {file = "rpds_py-0.30.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8d1736cfb49381ba528cd5baa46f82fdc65c06e843dab24dd70b63d09121b3f"}, + {file = "rpds_py-0.30.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d948b135c4693daff7bc2dcfc4ec57237a29bd37e60c2fabf5aff2bbacf3e2f1"}, + {file = "rpds_py-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47f236970bccb2233267d89173d3ad2703cd36a0e2a6e92d0560d333871a3d23"}, + {file = "rpds_py-0.30.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2e6ecb5a5bcacf59c3f912155044479af1d0b6681280048b338b28e364aca1f6"}, + {file = "rpds_py-0.30.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a8fa71a2e078c527c3e9dc9fc5a98c9db40bcc8a92b4e8858e36d329f8684b51"}, + {file = "rpds_py-0.30.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73c67f2db7bc334e518d097c6d1e6fed021bbc9b7d678d6cc433478365d1d5f5"}, + {file = "rpds_py-0.30.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5ba103fb455be00f3b1c2076c9d4264bfcb037c976167a6047ed82f23153f02e"}, + {file = "rpds_py-0.30.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7cee9c752c0364588353e627da8a7e808a66873672bcb5f52890c33fd965b394"}, + {file = "rpds_py-0.30.0-cp312-cp312-win32.whl", hash = "sha256:1ab5b83dbcf55acc8b08fc62b796ef672c457b17dbd7820a11d6c52c06839bdf"}, + {file = "rpds_py-0.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:a090322ca841abd453d43456ac34db46e8b05fd9b3b4ac0c78bcde8b089f959b"}, + {file = "rpds_py-0.30.0-cp312-cp312-win_arm64.whl", hash = "sha256:669b1805bd639dd2989b281be2cfd951c6121b65e729d9b843e9639ef1fd555e"}, + {file = "rpds_py-0.30.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f83424d738204d9770830d35290ff3273fbb02b41f919870479fab14b9d303b2"}, + {file = "rpds_py-0.30.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7536cd91353c5273434b4e003cbda89034d67e7710eab8761fd918ec6c69cf8"}, + {file = "rpds_py-0.30.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2771c6c15973347f50fece41fc447c054b7ac2ae0502388ce3b6738cd366e3d4"}, + {file = "rpds_py-0.30.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0a59119fc6e3f460315fe9d08149f8102aa322299deaa5cab5b40092345c2136"}, + {file = "rpds_py-0.30.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:76fec018282b4ead0364022e3c54b60bf368b9d926877957a8624b58419169b7"}, + {file = "rpds_py-0.30.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:692bef75a5525db97318e8cd061542b5a79812d711ea03dbc1f6f8dbb0c5f0d2"}, + {file = "rpds_py-0.30.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9027da1ce107104c50c81383cae773ef5c24d296dd11c99e2629dbd7967a20c6"}, + {file = "rpds_py-0.30.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:9cf69cdda1f5968a30a359aba2f7f9aa648a9ce4b580d6826437f2b291cfc86e"}, + {file = "rpds_py-0.30.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a4796a717bf12b9da9d3ad002519a86063dcac8988b030e405704ef7d74d2d9d"}, + {file = "rpds_py-0.30.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d4c2aa7c50ad4728a094ebd5eb46c452e9cb7edbfdb18f9e1221f597a73e1e7"}, + {file = "rpds_py-0.30.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba81a9203d07805435eb06f536d95a266c21e5b2dfbf6517748ca40c98d19e31"}, + {file = "rpds_py-0.30.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:945dccface01af02675628334f7cf49c2af4c1c904748efc5cf7bbdf0b579f95"}, + {file = "rpds_py-0.30.0-cp313-cp313-win32.whl", hash = "sha256:b40fb160a2db369a194cb27943582b38f79fc4887291417685f3ad693c5a1d5d"}, + {file = "rpds_py-0.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:806f36b1b605e2d6a72716f321f20036b9489d29c51c91f4dd29a3e3afb73b15"}, + {file = "rpds_py-0.30.0-cp313-cp313-win_arm64.whl", hash = "sha256:d96c2086587c7c30d44f31f42eae4eac89b60dabbac18c7669be3700f13c3ce1"}, + {file = "rpds_py-0.30.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:eb0b93f2e5c2189ee831ee43f156ed34e2a89a78a66b98cadad955972548be5a"}, + {file = "rpds_py-0.30.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:922e10f31f303c7c920da8981051ff6d8c1a56207dbdf330d9047f6d30b70e5e"}, + {file = "rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdc62c8286ba9bf7f47befdcea13ea0e26bf294bda99758fd90535cbaf408000"}, + {file = "rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:47f9a91efc418b54fb8190a6b4aa7813a23fb79c51f4bb84e418f5476c38b8db"}, + {file = "rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1f3587eb9b17f3789ad50824084fa6f81921bbf9a795826570bda82cb3ed91f2"}, + {file = "rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:39c02563fc592411c2c61d26b6c5fe1e51eaa44a75aa2c8735ca88b0d9599daa"}, + {file = "rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51a1234d8febafdfd33a42d97da7a43f5dcb120c1060e352a3fbc0c6d36e2083"}, + {file = "rpds_py-0.30.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:eb2c4071ab598733724c08221091e8d80e89064cd472819285a9ab0f24bcedb9"}, + {file = "rpds_py-0.30.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6bdfdb946967d816e6adf9a3d8201bfad269c67efe6cefd7093ef959683c8de0"}, + {file = "rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c77afbd5f5250bf27bf516c7c4a016813eb2d3e116139aed0096940c5982da94"}, + {file = "rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:61046904275472a76c8c90c9ccee9013d70a6d0f73eecefd38c1ae7c39045a08"}, + {file = "rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4c5f36a861bc4b7da6516dbdf302c55313afa09b81931e8280361a4f6c9a2d27"}, + {file = "rpds_py-0.30.0-cp313-cp313t-win32.whl", hash = "sha256:3d4a69de7a3e50ffc214ae16d79d8fbb0922972da0356dcf4d0fdca2878559c6"}, + {file = "rpds_py-0.30.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f14fc5df50a716f7ece6a80b6c78bb35ea2ca47c499e422aa4463455dd96d56d"}, + {file = "rpds_py-0.30.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:68f19c879420aa08f61203801423f6cd5ac5f0ac4ac82a2368a9fcd6a9a075e0"}, + {file = "rpds_py-0.30.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ec7c4490c672c1a0389d319b3a9cfcd098dcdc4783991553c332a15acf7249be"}, + {file = "rpds_py-0.30.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f251c812357a3fed308d684a5079ddfb9d933860fc6de89f2b7ab00da481e65f"}, + {file = "rpds_py-0.30.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac98b175585ecf4c0348fd7b29c3864bda53b805c773cbf7bfdaffc8070c976f"}, + {file = "rpds_py-0.30.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3e62880792319dbeb7eb866547f2e35973289e7d5696c6e295476448f5b63c87"}, + {file = "rpds_py-0.30.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e7fc54e0900ab35d041b0601431b0a0eb495f0851a0639b6ef90f7741b39a18"}, + {file = "rpds_py-0.30.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47e77dc9822d3ad616c3d5759ea5631a75e5809d5a28707744ef79d7a1bcfcad"}, + {file = "rpds_py-0.30.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b4dc1a6ff022ff85ecafef7979a2c6eb423430e05f1165d6688234e62ba99a07"}, + {file = "rpds_py-0.30.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4559c972db3a360808309e06a74628b95eaccbf961c335c8fe0d590cf587456f"}, + {file = "rpds_py-0.30.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0ed177ed9bded28f8deb6ab40c183cd1192aa0de40c12f38be4d59cd33cb5c65"}, + {file = "rpds_py-0.30.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ad1fa8db769b76ea911cb4e10f049d80bf518c104f15b3edb2371cc65375c46f"}, + {file = "rpds_py-0.30.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:46e83c697b1f1c72b50e5ee5adb4353eef7406fb3f2043d64c33f20ad1c2fc53"}, + {file = "rpds_py-0.30.0-cp314-cp314-win32.whl", hash = "sha256:ee454b2a007d57363c2dfd5b6ca4a5d7e2c518938f8ed3b706e37e5d470801ed"}, + {file = "rpds_py-0.30.0-cp314-cp314-win_amd64.whl", hash = "sha256:95f0802447ac2d10bcc69f6dc28fe95fdf17940367b21d34e34c737870758950"}, + {file = "rpds_py-0.30.0-cp314-cp314-win_arm64.whl", hash = "sha256:613aa4771c99f03346e54c3f038e4cc574ac09a3ddfb0e8878487335e96dead6"}, + {file = "rpds_py-0.30.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:7e6ecfcb62edfd632e56983964e6884851786443739dbfe3582947e87274f7cb"}, + {file = "rpds_py-0.30.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a1d0bc22a7cdc173fedebb73ef81e07faef93692b8c1ad3733b67e31e1b6e1b8"}, + {file = "rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d08f00679177226c4cb8c5265012eea897c8ca3b93f429e546600c971bcbae7"}, + {file = "rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5965af57d5848192c13534f90f9dd16464f3c37aaf166cc1da1cae1fd5a34898"}, + {file = "rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a4e86e34e9ab6b667c27f3211ca48f73dba7cd3d90f8d5b11be56e5dbc3fb4e"}, + {file = "rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5d3e6b26f2c785d65cc25ef1e5267ccbe1b069c5c21b8cc724efee290554419"}, + {file = "rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:626a7433c34566535b6e56a1b39a7b17ba961e97ce3b80ec62e6f1312c025551"}, + {file = "rpds_py-0.30.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:acd7eb3f4471577b9b5a41baf02a978e8bdeb08b4b355273994f8b87032000a8"}, + {file = "rpds_py-0.30.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe5fa731a1fa8a0a56b0977413f8cacac1768dad38d16b3a296712709476fbd5"}, + {file = "rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:74a3243a411126362712ee1524dfc90c650a503502f135d54d1b352bd01f2404"}, + {file = "rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3e8eeb0544f2eb0d2581774be4c3410356eba189529a6b3e36bbbf9696175856"}, + {file = "rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dbd936cde57abfee19ab3213cf9c26be06d60750e60a8e4dd85d1ab12c8b1f40"}, + {file = "rpds_py-0.30.0-cp314-cp314t-win32.whl", hash = "sha256:dc824125c72246d924f7f796b4f63c1e9dc810c7d9e2355864b3c3a73d59ade0"}, + {file = "rpds_py-0.30.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27f4b0e92de5bfbc6f86e43959e6edd1425c33b5e69aab0984a72047f2bcf1e3"}, + {file = "rpds_py-0.30.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:c2262bdba0ad4fc6fb5545660673925c2d2a5d9e2e0fb603aad545427be0fc58"}, + {file = "rpds_py-0.30.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ee6af14263f25eedc3bb918a3c04245106a42dfd4f5c2285ea6f997b1fc3f89a"}, + {file = "rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3adbb8179ce342d235c31ab8ec511e66c73faa27a47e076ccc92421add53e2bb"}, + {file = "rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:250fa00e9543ac9b97ac258bd37367ff5256666122c2d0f2bc97577c60a1818c"}, + {file = "rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9854cf4f488b3d57b9aaeb105f06d78e5529d3145b1e4a41750167e8c213c6d3"}, + {file = "rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:993914b8e560023bc0a8bf742c5f303551992dcb85e247b1e5c7f4a7d145bda5"}, + {file = "rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58edca431fb9b29950807e301826586e5bbf24163677732429770a697ffe6738"}, + {file = "rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:dea5b552272a944763b34394d04577cf0f9bd013207bc32323b5a89a53cf9c2f"}, + {file = "rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ba3af48635eb83d03f6c9735dfb21785303e73d22ad03d489e88adae6eab8877"}, + {file = "rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:dff13836529b921e22f15cb099751209a60009731a68519630a24d61f0b1b30a"}, + {file = "rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:1b151685b23929ab7beec71080a8889d4d6d9fa9a983d213f07121205d48e2c4"}, + {file = "rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:ac37f9f516c51e5753f27dfdef11a88330f04de2d564be3991384b2f3535d02e"}, + {file = "rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84"}, +] + [[package]] name = "snowballstemmer" version = "3.0.1" @@ -1206,4 +1409,4 @@ serial = ["pyserial"] [metadata] lock-version = "2.0" python-versions = ">=3.10,<4.0" -content-hash = "304438e67123fa63a022ec675be9733b3e32e1fb273bd1b94bd861def3d1ec4b" +content-hash = "46094ed5a66db8889216bf1b1fb9f625982c2bcbed7ededea1054e39804795e5" diff --git a/pyproject.toml b/pyproject.toml index d6820e1..f707765 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "modpoll" -version = "1.6.1" +version = "2.0.0a1" description = "A New Command-line Tool for Modbus and MQTT" authors = ["Ying Shaodong "] repository = "https://github.com/top-maker/modpoll" @@ -14,6 +14,7 @@ pymodbus = "~3.9.0" paho-mqtt = "^2.1.0" prettytable = "^3.9.0" requests = "^2.32.5" +modpoll-wot-modbus = { path = "packages/modpoll-wot-modbus", develop = true } pyserial = { version = "^3.5", optional = true } [tool.poetry.extras] @@ -37,13 +38,14 @@ requires = ["poetry-core>=1.0.0"] build-backend = "poetry.core.masonry.api" [tool.deptry] +extend_exclude = ["packages"] package_module_name_map = { pyserial = "serial" } [tool.deptry.per_rule_ignores] DEP002 = ["pyserial"] [tool.pytest.ini_options] -testpaths = ["tests"] +testpaths = ["tests", "packages/modpoll-wot-modbus/tests"] markers = ["integration: marks tests as integration tests"] [tool.poetry.scripts] diff --git a/requirements.txt b/requirements.txt index f1b350b..07d1636 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,9 +1,16 @@ +./packages/modpoll-wot-modbus +attrs==26.1.0 ; python_version >= "3.10" and python_version < "4.0" certifi==2025.11.12 ; python_version >= "3.10" and python_version < "4.0" charset-normalizer==3.4.4 ; python_version >= "3.10" and python_version < "4.0" idna==3.11 ; python_version >= "3.10" and python_version < "4.0" +jsonschema==4.26.0 ; python_version >= "3.10" and python_version < "4.0" +jsonschema-specifications==2025.9.1 ; python_version >= "3.10" and python_version < "4.0" paho-mqtt==2.1.0 ; python_version >= "3.10" and python_version < "4.0" prettytable==3.17.0 ; python_version >= "3.10" and python_version < "4.0" pymodbus==3.9.2 ; python_version >= "3.10" and python_version < "4.0" +referencing==0.37.0 ; python_version >= "3.10" and python_version < "4.0" requests==2.32.5 ; python_version >= "3.10" and python_version < "4.0" +rpds-py==0.30.0 ; python_version >= "3.10" and python_version < "4.0" +typing-extensions==4.15.0 ; python_version >= "3.10" and python_version < "3.13" urllib3==2.6.0 ; python_version >= "3.10" and python_version < "4.0" wcwidth==0.2.14 ; python_version >= "3.10" and python_version < "4.0" diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..df560a5 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Modpoll regression tests.""" diff --git a/tests/test_modbus_task.py b/tests/test_modbus_task.py index 7ddb8ac..a9a6893 100644 --- a/tests/test_modbus_task.py +++ b/tests/test_modbus_task.py @@ -1,3 +1,4 @@ +from tests.wot_helpers import write_meter import pytest # type: ignore from modpoll.arg_parser import get_parser from modpoll.modbus_task import setup_modbus_handlers @@ -9,8 +10,8 @@ def test_modbus_task_modbus_setup(): args = parser.parse_args( [ "--config", - "examples/modsim.csv", - "examples/modsim2.csv", + "examples/modsim.tm.json", + "examples/modsim2.tm.json", "--tcp", "modsim.topmaker.net", ] @@ -25,7 +26,7 @@ def test_modbus_task_poll_modsim(): args = parser.parse_args( [ "--config", - "examples/modsim.csv", + "examples/modsim.tm.json", "--tcp", "modsim.topmaker.net", ] @@ -67,13 +68,9 @@ def close(self): self.closed += 1 monkeypatch.setattr(modbus_task, "_create_modbus_client", lambda args: Client()) - valid = tmp_path / "valid.csv" - valid.write_text( - "device,meter,1,description\npoll,holding_register,0,1,BE_BE\nref,value,0,uint16,r\n" - ) - second = tmp_path / "second.csv" - second.write_text(valid.read_text().replace("device,meter,", "device,second,")) - missing = tmp_path / "missing.csv" + valid = write_meter(tmp_path / "valid.tm.json") + second = write_meter(tmp_path / "second.tm.json", name="second") + missing = tmp_path / "missing.tm.json" args = get_parser().parse_args( ["--config", str(valid), str(missing), str(second), "--tcp", "localhost"] ) @@ -131,11 +128,8 @@ def test_duplicate_device_names_across_configs_fail_before_polling( from modpoll import modbus_task monkeypatch.setattr(modbus_task, "_create_modbus_client", lambda args: object()) - first = tmp_path / "first.csv" - second = tmp_path / "second.csv" - contents = "device,meter,1,description\npoll,holding_register,0,1,BE_BE\nref,value,0,uint16,r\n" - first.write_text(contents) - second.write_text(contents) + first = write_meter(tmp_path / "first.tm.json") + second = write_meter(tmp_path / "second.tm.json") args = get_parser().parse_args( ["--config", str(first), str(second), "--tcp", "localhost"] ) diff --git a/tests/test_modsim_interop.py b/tests/test_modsim_interop.py index 5e1d46c..219a97b 100644 --- a/tests/test_modsim_interop.py +++ b/tests/test_modsim_interop.py @@ -1,3 +1,6 @@ +import json +from pathlib import Path + """Cross-project TCP regression; set MODSIM_PYTHON and MODSIM_SOURCE to opt in.""" import os @@ -44,14 +47,14 @@ def test_four_table_reads_and_fc5_fc6_fc16_writes(tmp_path): if time.monotonic() >= deadline: pytest.fail("simulator did not start") time.sleep(0.05) - config = tmp_path / "meter.csv" - config.write_text( - "device,meter,7,description\n" - "poll,coil,0,16,BE_BE\nref,coil_1,0,bool8,r\nref,coil_2,1,bool8,r\n" - "poll,discrete_input,10000,8,BE_BE\nref,di,10000,bool8,r\n" - "poll,input_register,30000,1,BE_BE\nref,ir,30000,uint16,r\n" - "poll,holding_register,40000,4,BE_BE\nref,hr,40000,uint16,r\n" + config = tmp_path / "meter.tm.json" + model = json.loads( + ( + Path(__file__).resolve().parents[1] / "examples/modsim.tm.json" + ).read_text() ) + model["title"] = "meter" + config.write_text(json.dumps(model)) args = get_parser().parse_args( [ "--config", @@ -60,6 +63,8 @@ def test_four_table_reads_and_fc5_fc6_fc16_writes(tmp_path): "127.0.0.1", "--tcp-port", str(port), + "--unit-id", + "7", "--daemon", "--interval", "0", @@ -70,17 +75,17 @@ def test_four_table_reads_and_fc5_fc6_fc16_writes(tmp_path): handler.poll() device = handler.get_device_list()[0] bits = [True, False, True, False, False, False, False, True] - assert device.references["coil_1"].val == bits - assert device.references["coil_2"].val == bits - assert device.references["di"].val == bits - assert device.references["ir"].val == 1 - assert device.references["hr"].val == 1 + assert device.references["coil01-08"].val == bits + assert device.references["coil09-16"].val == bits + assert device.references["di01-08"].val == bits + assert device.references["input_reg01"].val == 1 + assert device.references["holding_reg01"].val == 1 assert handler.write_coil("meter", 1, True) assert handler.write_register("meter", 40000, 123) assert handler.write_register("meter", 40001, [12, 13, 14]) handler.poll() - assert device.references["coil_1"].val[1] is True - assert device.references["hr"].val == 123 + assert device.references["coil01-08"].val[1] is True + assert device.references["holding_reg01"].val == 123 assert handler.connect() result = handler.modbus_client.read_holding_registers(40000, count=4, slave=7) assert not result.isError() diff --git a/tests/test_poll_cycle.py b/tests/test_poll_cycle.py index 4ac397e..57e3255 100644 --- a/tests/test_poll_cycle.py +++ b/tests/test_poll_cycle.py @@ -150,79 +150,6 @@ def fail_replace(*args): assert list(tmp_path.glob(".snapshot.json.*.tmp")) == [] -def test_csv_parser_reports_line_and_keeps_valid_rows(caplog): - import csv - from io import StringIO - - handler = ModbusHandler(FakeClient(), "unused.csv", daemon=True) - rows = csv.reader( - StringIO( - "device,meter,1,description\n" - "poll,holding_register,0,1,BE_BE\n" - "ref,value,0,uint16,r\n" - "poll,holding_register,not-an-address,1,BE_BE\n" - "device,backup,2,description\n" - "poll,holding_register,0,1,BE_BE\n" - "ref,value,0,uint16,r\n" - ) - ) - - devices = handler._parse_config(rows) - - assert [device.name for device in devices] == ["meter", "backup"] - assert "unused.csv" in caplog.text - assert "line 4" in caplog.text.lower() - assert "start address" in caplog.text.lower() - - -def test_csv_parser_rejects_invalid_ranges_and_device_ids(caplog): - import csv - from io import StringIO - - handler = ModbusHandler(FakeClient(), "unused.csv", daemon=True) - rows = csv.reader( - StringIO( - "device,meter,1,description\n" - "poll,holding_register,65535,2,BE_BE\n" - "poll,holding_register,0,0,BE_BE\n" - "poll,holding_register,0,1,BE_BE\n" - "ref,value,0,uint16,r,,not-a-number\n" - "device,backup,256,invalid-id\n" - ) - ) - - devices = handler._parse_config(rows) - - assert [device.name for device in devices] == ["meter"] - assert len(devices[0].pollerList) == 1 - assert not devices[0].references - assert "address" in caplog.text.lower() - assert "size" in caplog.text.lower() - assert "device id" in caplog.text.lower() - - -def test_csv_parser_rejects_unsupported_dtype_and_invalid_rw_mode(caplog): - import csv - from io import StringIO - - handler = ModbusHandler(FakeClient(), "unused.csv", daemon=True) - rows = csv.reader( - StringIO( - "device,meter,1,description\n" - "poll,holding_register,0,2,BE_BE\n" - "ref,bad_type,0,decimal128,r\n" - "ref,bad_mode,1,uint16,readwrite\n" - ) - ) - - devices = handler._parse_config(rows) - - assert len(devices) == 1 - assert not devices[0].references - assert "unsupported dtype" in caplog.text.lower() - assert "read/write mode" in caplog.text.lower() - - def test_autoremove_requires_three_consecutive_failures_and_restart(monkeypatch): from modpoll.modbus_task import Poller @@ -320,56 +247,8 @@ def read_holding_registers(self, address, count, slave): assert len(mqtt.messages) == before -def test_csv_duplicate_device_names_fail_with_filename_and_line(): - import csv - from io import StringIO - import pytest - - handler = ModbusHandler(FakeClient(), "site.csv", daemon=True) - rows = csv.reader( - StringIO("device,meter,1,description\ndevice,meter,2,description\n") - ) - with pytest.raises(ValueError, match=r"site.csv: meter \(line 2\)"): - handler._parse_config(rows) - - -def test_documented_modsim_csv_preserves_device_and_reference_mappings(caplog): - import csv - from pathlib import Path - - path = Path(__file__).resolve().parents[1] / "examples/modsim.csv" - handler = ModbusHandler(FakeClient(), str(path), daemon=True) - with path.open() as source: - devices = handler._parse_config(csv.reader(source)) - assert len(devices) == 1 - device = devices[0] - assert (device.name, device.devid) == ("modsim01", 1) - assert len(device.references) == 37 - assert [poller.fc for poller in device.pollerList] == [1, 2, 4, 3] - assert ( - device.references["coil01-08"].address, - device.references["coil01-08"].dtype, - ) == (0, "bool8") - assert ( - device.references["coil09-16"].address, - device.references["coil09-16"].dtype, - ) == (1, "bool8") - assert not [record for record in caplog.records if record.levelno >= 30] - - def test_nonfinite_scale_is_rejected_and_nonfinite_sample_is_invalid(caplog): - import csv - from io import StringIO - - handler = ModbusHandler(FakeClient(), "site.csv", daemon=True) - rows = csv.reader( - StringIO( - "device,meter,1,description\npoll,holding_register,0,1,BE_BE\nref,value,0,uint16,r,,nan\n" - ) - ) - device = handler._parse_config(rows)[0] - assert not device.references - assert "invalid scale" in caplog.text.lower() + device = Device("meter", 1) ref = Reference(device, "temperature", "0", "float32", "r", None, None) ref.update_value(float("inf")) assert ref.val is None and not ref.valid_this_poll @@ -411,24 +290,6 @@ def test_snapshot_rejects_nonstandard_nan_without_damaging_existing_file(tmp_pat assert list(tmp_path.glob(".snapshot.json.*.tmp")) == [] -def test_register_read_limit_and_invalid_device_do_not_reuse_context(): - import csv - from io import StringIO - - handler = ModbusHandler(FakeClient(), "site.csv", daemon=True) - devices = handler._parse_config( - csv.reader( - StringIO( - "device,meter,1\npoll,holding_register,0,125,BE_BE\n" - "poll,holding_register,0,126,BE_BE\n" - "device,broken\nref,misplaced,0,uint16,r\n" - ) - ) - ) - assert len(devices[0].pollerList) == 1 - assert not devices[0].references - - def test_odd_length_string_does_not_include_padding_byte(): from modpoll.modbus_task import Poller @@ -447,16 +308,3 @@ def read_holding_registers(self, address, count, slave): assert poller.poll(Client()) assert ref.val == "ABC" - - -def test_bit_selector_is_rejected_for_coil_polls(): - import csv - from io import StringIO - - handler = ModbusHandler(FakeClient(), "site.csv", daemon=True) - devices = handler._parse_config( - csv.reader( - StringIO("device,meter,1\npoll,coil,0,16,BE_BE\nref,bit,0:1,bool,r\n") - ) - ) - assert not devices[0].references diff --git a/tests/test_process_lifecycle.py b/tests/test_process_lifecycle.py index a074db1..4092155 100644 --- a/tests/test_process_lifecycle.py +++ b/tests/test_process_lifecycle.py @@ -1,3 +1,5 @@ +from tests.wot_helpers import write_meter + """Process-level Modbus TCP lifecycle coverage without external services.""" import signal @@ -30,10 +32,7 @@ def handle(self): @pytest.mark.integration @pytest.mark.parametrize("sig", [signal.SIGINT, signal.SIGTERM]) def test_process_exits_promptly_after_successful_poll(tmp_path, sig): - config = tmp_path / "meter.csv" - config.write_text( - "device,meter,1,description\npoll,holding_register,0,1,BE_BE\nref,value,0,uint16,r\n" - ) + config = write_meter(tmp_path / "meter.tm.json") with FakeModbusServer(("127.0.0.1", 0), Handler) as server: server.polled = threading.Event() thread = threading.Thread(target=server.serve_forever, daemon=True) diff --git a/tests/test_wot_config.py b/tests/test_wot_config.py new file mode 100644 index 0000000..7ad8490 --- /dev/null +++ b/tests/test_wot_config.py @@ -0,0 +1,78 @@ +"""Application adapter regression tests independent of a live device.""" + +import json +from pathlib import Path + +import pytest + +from modpoll.modbus_task import ModbusHandler +from modpoll.wot_config import read_model, create_devices +from tests.wot_helpers import meter_model, write_meter + + +def test_csv_content_is_rejected_without_partial_device(tmp_path, caplog): + path = tmp_path / "old.csv" + path.write_text( + "device,meter,1\npoll,holding_register,0,1,BE_BE\nref,x,0,uint16,r\n" + ) + handler = ModbusHandler(object(), str(path)) + assert not handler.load_config() + assert handler.deviceList == [] + assert "Invalid WoT configuration" in caplog.text + + +def test_url_model_loader_keeps_timeout_and_streams(monkeypatch): + from modpoll import wot_config + + payload = json.dumps(meter_model()).encode() + calls = [] + + class Response: + def __enter__(self): + return self + + def __exit__(self, *args): + pass + + def raise_for_status(self): + pass + + def iter_content(self, size): + yield payload[:20] + yield payload[20:] + + def get(url, **kwargs): + calls.append((url, kwargs)) + return Response() + + monkeypatch.setattr(wot_config.requests, "get", get) + model = read_model("https://example.test/model.tm.json", timeout=2.5, unit_id=7) + assert model.unit_id == 7 + assert calls == [ + ("https://example.test/model.tm.json", {"timeout": 2.5, "stream": True}) + ] + + +def test_adapter_preserves_all_modsim_reference_addresses(): + path = Path(__file__).resolve().parents[1] / "examples/modsim.tm.json" + (device,) = create_devices(read_model(str(path), timeout=1, unit_id=7)) + assert device.name == "modsim01" and device.devid == 7 + assert [(p.fc, p.start_address, p.size) for p in device.pollerList] == [ + (1, 0, 16), + (2, 10000, 16), + (4, 30000, 20), + (3, 40000, 44), + ] + assert len(device.references) == 37 + assert device.references["coil09-16"].address == 1 + assert device.references["di09-16"].address == 10001 + assert device.references["holding_reg19"].dtype == "string16" + assert device.references["holding_reg10"].scale == 0.001 + assert device.references["input_reg01"].rw == "r" + assert device.references["holding_reg01"].rw == "rw" + + +def test_unit_id_validation_fails_before_connect(tmp_path): + config = write_meter(tmp_path / "meter.tm.json") + handler = ModbusHandler(object(), str(config), unit_id=0) + assert not handler.load_config() diff --git a/tests/wot_helpers.py b/tests/wot_helpers.py new file mode 100644 index 0000000..8c0cb04 --- /dev/null +++ b/tests/wot_helpers.py @@ -0,0 +1,40 @@ +"""Small explicitly constructed executable models for application tests.""" + +import json + + +def meter_model(name="meter", start=0, count=1): + return { + "@context": [ + "https://www.w3.org/2022/wot/td/v1.1", + { + "modpoll": "https://top-maker.github.io/modpoll/vocab/wot-modbus#", + "modv": "https://www.w3.org/2019/wot/modbus", + }, + ], + "@type": "tm:ThingModel", + "title": name, + "modpoll:version": "0.1", + "properties": {"value": {"type": "integer", "readOnly": True}}, + "modpoll:acquisition": [ + { + "modv:entity": "HoldingRegister", + "modv:address": start, + "modv:quantity": count, + "modv:mostSignificantByte": True, + "modv:mostSignificantWord": True, + "modpoll:members": [ + { + "modpoll:property": "value", + "modpoll:offset": 0, + "modpoll:dataType": "uint16", + } + ], + } + ], + } + + +def write_meter(path, name="meter", start=0, count=1): + path.write_text(json.dumps(meter_model(name, start, count))) + return path diff --git a/tools/export_requirements.py b/tools/export_requirements.py new file mode 100644 index 0000000..45849f5 --- /dev/null +++ b/tools/export_requirements.py @@ -0,0 +1,21 @@ +"""Export locked requirements with a portable local shared-package path.""" + +from pathlib import Path +import subprocess + +root = Path(__file__).resolve().parents[1] +output = subprocess.run( + ["poetry", "export", "-f", "requirements.txt", "--without-hashes"], + cwd=root, + capture_output=True, + text=True, + check=True, +).stdout +lines = [] +for line in output.splitlines(): + if "file://" in line and "/packages/modpoll-wot-modbus" in line: + line = "./packages/modpoll-wot-modbus" + lines.append(line) +(root / "requirements.txt").write_text( + "\n".join(sorted(lines, key=lambda line: line.split("==", 1)[0].lower())) + "\n" +) diff --git a/tools/verify_modsim_equivalence.py b/tools/verify_modsim_equivalence.py new file mode 100644 index 0000000..9688bf2 --- /dev/null +++ b/tools/verify_modsim_equivalence.py @@ -0,0 +1,249 @@ +"""Compare installed 1.x CSV and 2.x TM/TD CLIs against one modsim process. + +This is a read-only test. Supply isolated installations of both projects. +The proxy records Modbus requests, so equal output cannot hide smaller reads. +""" + +import argparse +import json +import math +from pathlib import Path +import socket +import socketserver +import subprocess +import tempfile +import threading +import time + + +ROOT = Path(__file__).resolve().parents[1] +EXPECTED_REQUESTS = [ + (1, 1, 0, 16), + (1, 2, 10000, 16), + (1, 4, 30000, 20), + (1, 3, 40000, 44), +] + + +def recv_exact(sock, length): + data = b"" + while len(data) < length: + chunk = sock.recv(length - len(data)) + if not chunk: + raise EOFError("connection closed") + data += chunk + return data + + +def frame(sock): + header = recv_exact(sock, 7) + return header + recv_exact(sock, int.from_bytes(header[4:6], "big") - 1) + + +class Proxy(socketserver.ThreadingTCPServer): + allow_reuse_address = True + daemon_threads = True + + +class Handler(socketserver.BaseRequestHandler): + def handle(self): + self.request.settimeout(5) + try: + with socket.create_connection(self.server.upstream, timeout=5) as upstream: + while True: + request = frame(self.request) + if len(request) != 12: + raise ValueError("unexpected request length") + self.server.requests.append( + ( + request[6], + request[7], + int.from_bytes(request[8:10], "big"), + int.from_bytes(request[10:12], "big"), + ) + ) + upstream.sendall(request) + self.request.sendall(frame(upstream)) + except EOFError: + pass + except Exception as exc: + self.server.errors.append(str(exc)) + + +def assert_values(snapshot): + data = snapshot["modsim01"] + assert len(data) == 37, data + bits = [True, False, True, False, False, False, False, True] + for name in ("coil01-08", "coil09-16", "di01-08", "di09-16"): + assert data[name] == bits, (name, data[name]) + values = [ + 1, + 2, + 3, + 4, + -1, + -2, + -3, + -4, + 12345678, + 12345.678, + -12345678, + -12345678000.0, + 12.34, + -12.34, + ] + for prefix in ("input_reg", "holding_reg"): + for index, value in enumerate(values, 1): + actual = data[f"{prefix}{index:02}"] + assert actual is not None and math.isclose(actual, value, rel_tol=1e-6), ( + index, + actual, + value, + ) + for index, value in enumerate( + (12345678900, -12345678900, 123.45, -123.45, "this is a string"), 15 + ): + assert data[f"holding_reg{index:02}"] == value + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--modpoll-1x", type=Path, required=True) + parser.add_argument("--modsim", type=Path) + parser.add_argument("--remote-host") + parser.add_argument("--remote-port", type=int, default=502) + parser.add_argument("--report", type=Path, required=True) + args = parser.parse_args() + old = args.modpoll_1x.resolve() + server = None + host = args.remote_host or "127.0.0.1" + if args.remote_host: + port = args.remote_port + else: + if args.modsim is None: + parser.error("--modsim is required for a local simulator") + simulator = args.modsim.resolve() + with socket.socket() as reservation: + reservation.bind(("127.0.0.1", 0)) + port = reservation.getsockname()[1] + server = subprocess.Popen( + [ + str(simulator / ".venv/bin/python"), + "-m", + "modsim", + "--port", + str(port), + "--log", + "error", + ], + cwd=simulator, + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + ) + runs = [] + try: + deadline = time.monotonic() + 10 + while True: + if server is not None and server.poll() is not None: + raise RuntimeError(server.stderr.read().decode()) + try: + with socket.create_connection((host, port), timeout=1): + break + except OSError: + if time.monotonic() > deadline: + raise TimeoutError("modsim did not start") + time.sleep(0.05) + with tempfile.TemporaryDirectory() as temp: + for label, root, config in ( + ("1.x CSV", old, old / "examples/modsim.csv"), + ("2.x TM", ROOT, ROOT / "examples/modsim.tm.json"), + ("2.x TD", ROOT, ROOT / "examples/modsim.td.json"), + ): + with Proxy(("127.0.0.1", 0), Handler) as proxy: + proxy.upstream = (host, port) + proxy.requests, proxy.errors = [], [] + worker = threading.Thread(target=proxy.serve_forever, daemon=True) + worker.start() + output = Path(temp) / "values.json" + command = [ + str(root / ".venv/bin/python"), + "-m", + "modpoll", + "--once", + "--tcp", + "127.0.0.1", + "--tcp-port", + str(proxy.server_address[1]), + "--config", + str(config), + "--export", + str(output), + "--interval", + "0", + "--delay", + "0", + "--loglevel", + "ERROR", + ] + try: + result = subprocess.run( + command, + cwd=root, + text=True, + capture_output=True, + timeout=20, + ) + assert result.returncode == 0, result.stderr + assert not result.stderr.strip(), result.stderr + snapshot = json.loads(output.read_text()) + if args.remote_host: + assert len(snapshot["modsim01"]) == 37 + assert all( + value is not None + for value in snapshot["modsim01"].values() + ) + else: + assert_values(snapshot) + assert proxy.requests == EXPECTED_REQUESTS, proxy.requests + assert not proxy.errors, proxy.errors + runs.append( + { + "label": label, + "snapshot": snapshot, + "requests": list(proxy.requests), + "table": result.stdout[ + result.stdout.index("Device:") : + ].strip(), + } + ) + finally: + proxy.shutdown() + worker.join(timeout=5) + assert all(run["snapshot"] == runs[0]["snapshot"] for run in runs) + assert all(run["table"] == runs[0]["table"] for run in runs) + report = { + "upstream": host, + "fixed_initial_values_verified": not bool(args.remote_host), + "success": True, + "properties": 37, + "requests_per_cycle": 4, + "runs": runs, + } + args.report.parent.mkdir(parents=True, exist_ok=True) + args.report.write_text(json.dumps(report, indent=2) + "\n") + print( + "PASS: 1.x CSV = 2.x TM = 2.x TD; 37 values, terminal tables and 4 block requests match" + ) + finally: + if server is not None: + server.terminate() + try: + server.wait(timeout=5) + except subprocess.TimeoutExpired: + server.kill() + server.wait(timeout=5) + server.stderr.close() + + +if __name__ == "__main__": + main() From bdc001af3b74a481082f11950c1f174e3331573f Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 28 Oct 2026 18:23:39 +0800 Subject: [PATCH 5/5] feat: complete M2 reading and MQTT acceptance --- .github/workflows/main.yml | 23 +- README.md | 2 +- docs/configure.rst | 4 +- docs/index.rst | 1 + docs/validation.rst | 70 ++++ modpoll/arg_parser.py | 4 +- modpoll/modbus_task.py | 2 +- packages/modpoll-wot-modbus/CONTRACT.md | 2 +- .../modpoll_wot_modbus/__main__.py | 2 +- .../modpoll_wot_modbus/loading.py | 13 +- .../schemas/acquisition-0.1.json | 5 + .../modpoll-wot-modbus/tests/test_loading.py | 14 + tests/conftest.py | 47 +++ tests/test_mqtt_modsim_interop.py | 344 ++++++++++++++++ tests/test_wot_reading_interop.py | 387 ++++++++++++++++++ 15 files changed, 907 insertions(+), 13 deletions(-) create mode 100644 docs/validation.rst create mode 100644 tests/conftest.py create mode 100644 tests/test_mqtt_modsim_interop.py create mode 100644 tests/test_wot_reading_interop.py diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 70141eb..718d349 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -99,8 +99,27 @@ jobs: env: MODSIM_PYTHON: ${{ github.workspace }}/.interop/modsim/.venv/bin/python MODSIM_SOURCE: ${{ github.workspace }}/.interop/modsim - run: poetry run pytest -q tests/test_process_lifecycle.py tests/test_modsim_interop.py + run: poetry run pytest -q tests/test_process_lifecycle.py tests/test_modsim_interop.py tests/test_wot_reading_interop.py + - name: Start private MQTT acceptance broker + run: | + printf 'listener 1883\nallow_anonymous true\npersistence false\n' > "$RUNNER_TEMP/modpoll-mqtt.conf" + docker run -d --name modpoll-mqtt-${{ github.run_id }}-${{ github.run_attempt }} -p 127.0.0.1:18883:1883 -v "$RUNNER_TEMP/modpoll-mqtt.conf:/mosquitto/config/mosquitto.conf:ro" eclipse-mosquitto:2 + - name: Verify MQTT equivalence, commands and broker recovery + env: + MODSIM_PYTHON: ${{ github.workspace }}/.interop/modsim/.venv/bin/python + MODSIM_SOURCE: ${{ github.workspace }}/.interop/modsim + MODPOLL_1X_SOURCE: ${{ github.workspace }}/.interop/modpoll-1x + MODPOLL_MQTT_HOST: 127.0.0.1 + MODPOLL_MQTT_PORT: '18883' + MODPOLL_MQTT_INTEGRATION: '1' + run: poetry run pytest -q tests/test_mqtt_modsim_interop.py tests/test_local_mqtt_broker.py --junitxml=.interop/mqtt-results.xml + - name: Remove private acceptance broker + if: always() + run: docker rm -f modpoll-mqtt-${{ github.run_id }}-${{ github.run_attempt }} || true - uses: actions/upload-artifact@v4 + if: always() with: name: modsim-equivalence - path: .interop/report.json + path: | + .interop/report.json + .interop/mqtt-results.xml diff --git a/README.md b/README.md index 4bc136a..daa2d64 100644 --- a/README.md +++ b/README.md @@ -123,7 +123,7 @@ The JSON examples are included in this checkout. A public URL containing the sam Use [examples/modsim.tm.json](examples/modsim.tm.json) for the simulator model or [examples/modsim.td.json](examples/modsim.td.json) for a concrete TCP description. Both preserve the four original blocks and 37 output references. `--unit-id` -selects the instance Unit ID (default 1); a concrete TD's forms must agree with it. +overrides each model's optional root `modv:unitID` (fallback 1); a concrete TD's forms must agree with it. The existing transport, polling, output and MQTT arguments remain available. See [the executable contract](packages/modpoll-wot-modbus/CONTRACT.md) for field diff --git a/docs/configure.rst b/docs/configure.rst index 23661fd..f854e50 100644 --- a/docs/configure.rst +++ b/docs/configure.rst @@ -6,7 +6,9 @@ It rejects CSV input. The bundled ``examples/modsim.tm.json`` and ``examples/modsim.td.json`` describe the same four blocks and 37 output properties. Each file describes one device. Its ``title`` is the output device name; -``--unit-id`` selects the instance Unit ID (default 1, range 1–254). +``--unit-id`` overrides the instance Unit ID (range 1–254). Without it, each file +uses its optional root ``modv:unitID`` default, falling back to 1. Different files +can therefore address different Unit IDs in one process. Multiple files may be supplied to ``--config``; titles must be unique. The existing transport CLI arguments explicitly select the connection endpoint. A concrete TD's form Unit IDs, addresses and quantities must agree with the model. diff --git a/docs/index.rst b/docs/index.rst index a3548c6..1cb229b 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -11,6 +11,7 @@ Welcome to Modpoll's documentation! quickstart usage configure + validation changelog Indices and tables diff --git a/docs/validation.rst b/docs/validation.rst new file mode 100644 index 0000000..7492544 --- /dev/null +++ b/docs/validation.rst @@ -0,0 +1,70 @@ +Configuration replacement verification +====================================== + +M2 exercises the complete TM/TD-to-device-to-output path. Tests use a separate +modsim process and compare against the pinned 1.x checkout; no physical device +or contributed device configuration is required. + +Reading acceptance +------------------ + +``tools/verify_modsim_equivalence.py`` compares all 37 simulator properties, +rendered CLI tables, JSON snapshots and the four actual requests from 1.x CSV, +2.x TM and 2.x TD. The local simulator also has independent expected values. +Public remote state may have been changed, so remote runs compare successive +actual snapshots rather than asserting initial values. + +``tests/test_wot_reading_interop.py`` independently encodes all supported numeric +register types with Python struct, including integers beyond JavaScript's exact +integer range, and tests all four byte/word orders. It also checks register bits, +odd string lengths, zero/negative/fractional multipliers, Unit ID 7, real local +HTTP model loading, multiple devices, timestamps and JSON precision. A request-recording proxy also +verifies per-model Unit IDs 2 and 7 in one CLI run, with explicit CLI overrides +still available. Invalid +models fail before polling; Modbus exceptions produce null samples without +hiding valid groups. + +MQTT acceptance +--------------- + +``tests/test_mqtt_modsim_interop.py`` runs unmodified 1.x and 2.x CLIs, checking +MQTT 3.1.1 and 5.0, grouped JSON and individual topics, publication topics, +QoS 0/1, diagnostics, retained-command rejection and FC5/6/16 command round trips. +Every run subscribes and publishes only under its own UUID namespace, clears its +retained test key, and directs commands exclusively to a temporary local modsim. + +For repeatable acceptance, CI starts a private local Mosquitto broker. The existing +local broker tests additionally cover authentication rejection, subscription +recovery after broker restart and a full receiving queue. + +To start an isolated local broker (remove it after testing):: + + printf 'listener 1883\nallow_anonymous true\npersistence false\n' > /tmp/modpoll-mosquitto.conf + docker run -d --name modpoll-mqtt-acceptance -p 127.0.0.1:18883:1883 -v /tmp/modpoll-mosquitto.conf:/mosquitto/config/mosquitto.conf:ro eclipse-mosquitto:2 + +Then run with installed mise/Poetry project environments:: + + export MODSIM_SOURCE=/path/to/modsim + export MODSIM_PYTHON=$MODSIM_SOURCE/.venv/bin/python + export MODPOLL_1X_SOURCE=/path/to/modpoll-1x + export MODPOLL_MQTT_HOST=127.0.0.1 + export MODPOLL_MQTT_PORT=18883 + mise exec -- poetry run pytest -q tests/test_wot_reading_interop.py tests/test_mqtt_modsim_interop.py + docker rm -f modpoll-mqtt-acceptance + +The public broker smoke test can use ``MODPOLL_MQTT_HOST=broker.emqx.io`` and +``MODPOLL_MQTT_PORT=1883`` with ``-k grouped-json``. Both protocol versions passed +that test. In a separate single-topic baseline probe, the 1.x publisher received +65 PUBACKs but the subscriber observed only 15 distinct topics, with no publisher +disconnect recorded. This public-service observation is not treated as a 2.x +regression or hidden by retries: the complete individual-topic matrix is verified +on the isolated broker, where all 1.x/TM/TD messages and values matched. + +These tests validate the M2 reading/output contract and selected existing command +paths. They do not claim serial hardware validation or a release-ready application +package; those remain later milestone work. + +The complete local acceptance run on 2026-10-28 passed 109 tests with all +opt-in environments enabled, including the three local authentication/reconnect/queue +tests. Public grouped JSON verification passed four cases: MQTT 3.1.1/5.0 with +QoS 0/1. The six-case local CLI matrix also covered individual-topic output. diff --git a/modpoll/arg_parser.py b/modpoll/arg_parser.py index f952e27..6dba882 100644 --- a/modpoll/arg_parser.py +++ b/modpoll/arg_parser.py @@ -23,8 +23,8 @@ def get_parser(): parser.add_argument( "--unit-id", type=int, - default=1, - help="Modbus unit ID for the model instance (1-254). Defaults to 1", + default=None, + help="Override model Unit ID (1-254); otherwise use modv:unitID or 1", ) parser.add_argument( "-d", diff --git a/modpoll/modbus_task.py b/modpoll/modbus_task.py index 8470a71..d9641d5 100644 --- a/modpoll/modbus_task.py +++ b/modpoll/modbus_task.py @@ -411,7 +411,7 @@ def __init__( mqtt_diagnostics_topic_pattern: Optional[str] = None, mqtt_single_publish: bool = False, autoremove: bool = False, - unit_id: int = 1, + unit_id: Optional[int] = None, ): self.modbus_client = modbus_client self.config_file = config_file diff --git a/packages/modpoll-wot-modbus/CONTRACT.md b/packages/modpoll-wot-modbus/CONTRACT.md index 8be3a4a..b328225 100644 --- a/packages/modpoll-wot-modbus/CONTRACT.md +++ b/packages/modpoll-wot-modbus/CONTRACT.md @@ -15,7 +15,7 @@ TD/TM schemas identify themselves as `1.1-12-March-2025` (Draft 7). | 1.x input | Contract 0.1 | | --- | --- | | device name | `title`, used verbatim as output device name | -| Unit ID | Explicit instance argument; default 1, range 1–254 | +| Unit ID | Optional root modv:unitID default per file; explicit CLI overrides it; fallback 1; range 1–254 | | poll table | group `modv:entity`: Coil, DiscreteInput, InputRegister, HoldingRegister | | poll start/count | group `modv:address` / `modv:quantity`, literal PDU addresses | | poll endian | `modv:mostSignificantByte` and `modv:mostSignificantWord` booleans | diff --git a/packages/modpoll-wot-modbus/modpoll_wot_modbus/__main__.py b/packages/modpoll-wot-modbus/modpoll_wot_modbus/__main__.py index c12ca4b..0cf59b2 100644 --- a/packages/modpoll-wot-modbus/modpoll_wot_modbus/__main__.py +++ b/packages/modpoll-wot-modbus/modpoll_wot_modbus/__main__.py @@ -10,7 +10,7 @@ def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("model") - parser.add_argument("--unit-id", type=int, default=1) + parser.add_argument("--unit-id", type=int, default=None) args = parser.parse_args() try: model = load_model(args.model, unit_id=args.unit_id) diff --git a/packages/modpoll-wot-modbus/modpoll_wot_modbus/loading.py b/packages/modpoll-wot-modbus/modpoll_wot_modbus/loading.py index 5afad65..4ca0292 100644 --- a/packages/modpoll-wot-modbus/modpoll_wot_modbus/loading.py +++ b/packages/modpoll-wot-modbus/modpoll_wot_modbus/loading.py @@ -49,8 +49,8 @@ def _check_depth(value, depth=0): _check_depth(item, depth + 1) -def parse_model(document, *, kind=None, unit_id=1): - if type(unit_id) is not int or not 1 <= unit_id <= 254: +def parse_model(document, *, kind=None, unit_id=None): + if unit_id is not None and (type(unit_id) is not int or not 1 <= unit_id <= 254): raise AcquisitionError("unit_id: expected integer between 1 and 254") if not isinstance(document, dict): raise AcquisitionError("document: expected object") @@ -105,6 +105,11 @@ def parse_model(document, *, kind=None, unit_id=1): raise AcquisitionError( f"/properties/{name}: executable metadata belongs in members/forms" ) + configured_id = document.get("modv:unitID", 1) + if type(configured_id) is not int or not 1 <= configured_id <= 254: + raise AcquisitionError("/modv:unitID: expected integer between 1 and 254") + if unit_id is None: + unit_id = configured_id groups = parse_acquisition(document) validate_forms(document, groups, unit_id, kind) if not document["title"].strip(): @@ -112,7 +117,7 @@ def parse_model(document, *, kind=None, unit_id=1): return Model(document["title"], kind, groups, unit_id) -def loads_model(payload, *, source="", kind=None, unit_id=1): +def loads_model(payload, *, source="", kind=None, unit_id=None): try: if ( len(payload.encode("utf-8") if isinstance(payload, str) else payload) @@ -127,7 +132,7 @@ def loads_model(payload, *, source="", kind=None, unit_id=1): raise AcquisitionError(f"{source}: {exc}") from exc -def load_model(path, *, kind=None, unit_id=1): +def load_model(path, *, kind=None, unit_id=None): path = Path(path) try: with path.open("rb") as stream: diff --git a/packages/modpoll-wot-modbus/modpoll_wot_modbus/schemas/acquisition-0.1.json b/packages/modpoll-wot-modbus/modpoll_wot_modbus/schemas/acquisition-0.1.json index 2803e39..bc2129c 100644 --- a/packages/modpoll-wot-modbus/modpoll_wot_modbus/schemas/acquisition-0.1.json +++ b/packages/modpoll-wot-modbus/modpoll_wot_modbus/schemas/acquisition-0.1.json @@ -94,6 +94,11 @@ } } } + }, + "modv:unitID": { + "type": "integer", + "minimum": 1, + "maximum": 254 } } } diff --git a/packages/modpoll-wot-modbus/tests/test_loading.py b/packages/modpoll-wot-modbus/tests/test_loading.py index 25efbea..87ee076 100644 --- a/packages/modpoll-wot-modbus/tests/test_loading.py +++ b/packages/modpoll-wot-modbus/tests/test_loading.py @@ -85,3 +85,17 @@ def test_extension_schema_rejects_unknown_member(self): path.write_text(json.dumps(document)) with self.assertRaises(AcquisitionError): load_model(path, kind="tm") + + +class ModelUnitDefaultsTests(unittest.TestCase): + def test_model_default_and_explicit_override(self): + from modpoll_wot_modbus import parse_model + + document = json.loads(FIXTURE.read_text()) + document["modv:unitID"] = 7 + self.assertEqual(parse_model(document).unit_id, 7) + self.assertEqual(parse_model(document, unit_id=2).unit_id, 2) + for value in (0, 255, True, "7"): + document["modv:unitID"] = value + with self.subTest(value=value), self.assertRaises(AcquisitionError): + parse_model(document) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..12ee7c1 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,47 @@ +"""Opt-in, isolated simulator fixture shared by protocol acceptance tests.""" + +import os +import socket +import subprocess +import time + +import pytest + + +@pytest.fixture +def live_modsim(): + executable = os.environ.get("MODSIM_PYTHON") + source = os.environ.get("MODSIM_SOURCE") + if not executable or not source: + pytest.skip("requires MODSIM_PYTHON and MODSIM_SOURCE") + with socket.socket() as reservation: + reservation.bind(("127.0.0.1", 0)) + port = reservation.getsockname()[1] + process = subprocess.Popen( + [executable, "-m", "modsim", "--port", str(port), "--log", "error"], + cwd=source, + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + ) + try: + deadline = time.monotonic() + 10 + while True: + if process.poll() is not None: + pytest.fail("modsim exited: " + process.stderr.read().decode()) + try: + with socket.create_connection(("127.0.0.1", port), timeout=0.2): + break + except OSError: + if time.monotonic() > deadline: + pytest.fail("modsim did not start") + time.sleep(0.05) + yield port + finally: + process.terminate() + try: + process.wait(timeout=5) + except subprocess.TimeoutExpired: + process.kill() + process.wait(timeout=5) + process.stderr.close() + assert process.returncode == 0 diff --git a/tests/test_mqtt_modsim_interop.py b/tests/test_mqtt_modsim_interop.py new file mode 100644 index 0000000..3a91e26 --- /dev/null +++ b/tests/test_mqtt_modsim_interop.py @@ -0,0 +1,344 @@ +"""Opt-in real MQTT/CLI equivalence and command tests on private UUID topics. + +Set MODPOLL_MQTT_HOST (e.g. broker.emqx.io), MODSIM_PYTHON, MODSIM_SOURCE, +and MODPOLL_1X_SOURCE. No other topics are subscribed to or changed. Writes +only target the temporary local simulator. The retained test key is cleared. +""" + +import json +import os +from pathlib import Path +import sys +import subprocess +import threading +import time +import uuid + +import pytest +from paho.mqtt.client import CallbackAPIVersion, Client, MQTTProtocolVersion +from pymodbus.client import ModbusTcpClient + +ROOT = Path(__file__).resolve().parents[1] +pytestmark = [ + pytest.mark.integration, + pytest.mark.skipif( + not os.environ.get("MODPOLL_MQTT_HOST") + or not os.environ.get("MODPOLL_1X_SOURCE"), + reason="requires explicit broker and 1.x baseline environment", + ), +] + + +def wait_for(predicate, timeout=30): + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + value = predicate() + if value: + return value + time.sleep(0.05) + raise AssertionError("Timed out waiting for MQTT/Modbus result") + + +class Observer: + def __init__(self, root, version): + self.root, self.messages, self.lock = root, {}, threading.Lock() + self.count = 0 + self.connected, self.subscribed = threading.Event(), threading.Event() + self.client = Client( + CallbackAPIVersion.VERSION2, + client_id="observer-" + uuid.uuid4().hex, + protocol=( + MQTTProtocolVersion.MQTTv5 + if version == "5.0" + else MQTTProtocolVersion.MQTTv311 + ), + ) + self.client.on_connect = self.on_connect + self.client.on_subscribe = lambda *args: self.subscribed.set() + self.client.on_message = self.on_message + + def on_connect(self, client, userdata, flags, reason_code, properties): + if reason_code == 0: + self.connected.set() + client.subscribe(self.root + "/#", qos=1) + + def on_message(self, client, userdata, message): + with self.lock: + self.messages[message.topic] = ( + bytes(message.payload), + message.retain, + message.qos, + ) + if "/data" in message.topic: + self.count += 1 + + def start(self): + self.client.connect( + os.environ["MODPOLL_MQTT_HOST"], + int(os.environ.get("MODPOLL_MQTT_PORT", "1883")), + keepalive=30, + ) + self.client.loop_start() + assert self.connected.wait(15), "MQTT CONNACK failed" + assert self.subscribed.wait(15), "MQTT SUBACK failed" + return self + + def publish(self, topic, payload, retain=False): + info = self.client.publish(topic, payload, qos=1, retain=retain) + info.wait_for_publish(timeout=15) + assert info.is_published(), "MQTT publication not acknowledged" + + def snapshot(self): + with self.lock: + return dict(self.messages), self.count + + def close(self): + self.client.disconnect() + self.client.loop_stop() + + +def scalar(payload): + if payload == b"True": + return True + if payload == b"False": + return False + try: + return json.loads(payload) + except ValueError: + return payload.decode() + + +def telemetry(observer, prefix, single): + messages, _ = observer.snapshot() + base = prefix + "/modsim01/data" + if not single: + if base not in messages: + return None + return json.loads(messages[base][0]) + model = json.loads((ROOT / "examples/modsim.tm.json").read_text()) + result = {} + for name, prop in model["properties"].items(): + topic = base + "/" + name + if prop["type"] == "array": + topics = [topic + "/" + str(i) for i in range(8)] + if any(t not in messages for t in topics): + return None + result[name] = [scalar(messages[t][0]) for t in topics] + else: + if topic not in messages: + return None + result[name] = scalar(messages[topic][0]) + return result + + +def rounded_values(snapshot): + return { + key: round(value, 3) if isinstance(value, float) else value + for key, value in snapshot.items() + if key != "timestamp" + } + + +@pytest.mark.parametrize( + "version,single,qos", + [ + ("3.1.1", False, 0), + ("5.0", False, 0), + ("3.1.1", False, 1), + ("5.0", False, 1), + ("3.1.1", True, 1), + ("5.0", True, 1), + ], + ids=[ + "grouped-json-3.1.1-qos0", + "grouped-json-5.0-qos0", + "grouped-json-3.1.1-qos1", + "grouped-json-5.0-qos1", + "individual-topics-3.1.1-qos1", + "individual-topics-5.0-qos1", + ], +) +def test_csv_tm_td_mqtt_equivalence_and_local_write_commands( + live_modsim, tmp_path, version, single, qos +): + namespace = "modpoll-tests/" + uuid.uuid4().hex + observer = Observer(namespace, version) + baseline = Path(os.environ["MODPOLL_1X_SOURCE"]).resolve() + runs = [ + ("csv", baseline, baseline / "examples/modsim.csv"), + ("tm", ROOT, ROOT / "examples/modsim.tm.json"), + ("td", ROOT, ROOT / "examples/modsim.td.json"), + ] + expected = None + retained_topics = [] + try: + observer.start() + for label, source, config in runs: + prefix = namespace + "/" + label + command_topic = prefix + "/modsim01/set" + retained_topics.append(command_topic) + # Only the dedicated local simulator is addressed by these commands. + observer.publish( + command_topic, + json.dumps( + {"object_type": "holding_register", "address": 40000, "value": 999} + ), + retain=True, + ) + output = tmp_path / (label + ".json") + command = [ + str(source / ".venv/bin/python"), + "-m", + "modpoll", + "--daemon", + "--tcp", + "127.0.0.1", + "--tcp-port", + str(live_modsim), + "--config", + str(config), + "--export", + str(output), + "--timestamp", + "--rate", + "2.0" if single else "0.4", + "--interval", + "0", + "--delay", + "0", + "--mqtt-host", + os.environ["MODPOLL_MQTT_HOST"], + "--mqtt-port", + os.environ.get("MODPOLL_MQTT_PORT", "1883"), + "--mqtt-version", + version, + "--mqtt-qos", + str(qos), + "--mqtt-clientid", + "poller-" + uuid.uuid4().hex, + "--mqtt-publish-topic-pattern", + prefix + "/{{device_name}}/data", + "--mqtt-subscribe-topic-pattern", + prefix + "/+/set", + "--mqtt-diagnostics-topic-pattern", + prefix + "/{{device_name}}/diagnostics", + "--diagnostics-rate", + "0.4", + "--loglevel", + "ERROR", + ] + if single: + command.append("--mqtt-single") + started = time.time() + process = subprocess.Popen( + command, cwd=source, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE + ) + try: + + def ready(): + if process.poll() is not None: + raise AssertionError(process.stderr.read().decode()) + data = telemetry(observer, prefix, single) + return data if data and output.exists() else None + + try: + data = wait_for(ready) + except AssertionError as exc: + seen, _ = observer.snapshot() + details = [ + topic[len(prefix) :] + for topic in seen + if topic.startswith(prefix) + ] + raise AssertionError( + f"{label}: telemetry incomplete; observed {details}" + ) from exc + snapshot = json.loads(output.read_text())["modsim01"] + assert isinstance(snapshot["timestamp"], (int, float)) + assert started - 1 <= snapshot["timestamp"] <= time.time() + 1 + if not single: + assert isinstance(data.pop("timestamp"), (int, float)) + assert data == rounded_values(snapshot) + assert len(data) == 37 + assert ( + data["holding_reg01"] == 1 + ), "retained command changed local device" + if expected is None: + expected = dict(data) + assert data == expected + diag_topic = prefix + "/modsim01/diagnostics" + diagnostic = wait_for(lambda: observer.snapshot()[0].get(diag_topic)) + diagnostic = json.loads(diagnostic[0]) + assert ( + diagnostic["last_poll_success"] and diagnostic["error_count"] == 0 + ) + assert diagnostic["poll_count"] >= 4 + messages, count = observer.snapshot() + for topic, (_, retained, message_qos) in messages.items(): + if topic.startswith(prefix + "/modsim01/data"): + assert retained is False and message_qos == qos + if label == "td": + # Wait another complete cycle, allowing retained-message rejection to execute. + wait_for( + lambda: observer.snapshot()[1] > count + (65 if single else 1) + ) + client = ModbusTcpClient("127.0.0.1", port=live_modsim) + try: + assert client.connect() + assert client.read_holding_registers( + 40000, count=1, slave=1 + ).registers == [1] + commands = [ + ( + {"object_type": "coil", "address": 1, "value": True}, + lambda d: d["coil01-08"][1] is True, + ), + ( + { + "object_type": "holding_register", + "address": 40000, + "value": 321, + }, + lambda d: d["holding_reg01"] == 321, + ), + ( + { + "object_type": "holding_register", + "address": 40001, + "value": [11, 22, 33], + }, + lambda d: [ + d["holding_reg02"], + d["holding_reg03"], + d["holding_reg04"], + ] + == [11, 22, 33], + ), + ] + for payload, condition in commands: + observer.publish(command_topic, json.dumps(payload)) + wait_for( + lambda: condition(telemetry(observer, prefix, single)) + ) + assert client.read_coils(1, count=1, slave=1).bits[0] is True + assert client.read_holding_registers( + 40000, count=4, slave=1 + ).registers == [321, 11, 22, 33] + finally: + client.close() + finally: + process.terminate() + try: + process.wait(timeout=20) + except subprocess.TimeoutExpired: + process.kill() + process.wait(timeout=5) + stderr = process.stderr.read().decode() + process.stderr.close() + if sys.exc_info()[0] is None: + assert process.returncode == 0, stderr + finally: + if observer.client.is_connected(): + for topic in retained_topics: + observer.publish(topic, b"", retain=True) + observer.close() diff --git a/tests/test_wot_reading_interop.py b/tests/test_wot_reading_interop.py new file mode 100644 index 0000000..ebb1306 --- /dev/null +++ b/tests/test_wot_reading_interop.py @@ -0,0 +1,387 @@ +"""M2 read-path acceptance against independently encoded Modbus data.""" + +import copy +from functools import partial +import http.server +import json +from pathlib import Path +import struct +import subprocess +import sys +import threading + +import pytest +from pymodbus.client import ModbusTcpClient + +from modpoll.arg_parser import get_parser +from modpoll.modbus_task import setup_modbus_handlers +from tests.wot_helpers import meter_model + +pytestmark = pytest.mark.integration +ROOT = Path(__file__).resolve().parents[1] + + +def wire_registers(format_, value, byte_big, word_big): + # Encode independently of the shared interpreter and PyModbus payload codec. + data = struct.pack(">" + format_, value) + words = [data[i : i + 2] for i in range(0, len(data), 2)] + if not word_big: + words.reverse() + if not byte_big: + words = [word[::-1] for word in words] + return [int.from_bytes(word, "big") for word in words] + + +@pytest.mark.parametrize( + "byte_big,word_big", [(True, True), (True, False), (False, True), (False, False)] +) +def test_all_numeric_types_bits_strings_and_multipliers( + live_modsim, tmp_path, byte_big, word_big +): + document = meter_model("vectors") + document["properties"] = {} + group = document["modpoll:acquisition"][0] + group.update( + { + "modv:address": 40000, + "modv:mostSignificantByte": byte_big, + "modv:mostSignificantWord": word_big, + "modpoll:members": [], + } + ) + registers, expected = [], {} + + def add(name, dtype, values, value, type_="number", multiplier=None, bit=None): + offset = len(registers) + registers.extend(values) + document["properties"][name] = {"type": type_, "readOnly": True, "unit": "test"} + member = { + "modpoll:property": name, + "modpoll:dataType": dtype, + "modpoll:offset": offset, + } + if multiplier is not None: + member["modpoll:multiplier"] = multiplier + if bit is not None: + member["modpoll:bit"] = bit + group["modpoll:members"].append(member) + expected[name] = value + + cases = [ + ("half", "float16", "e", 1.5), + ("signed16", "int16", "h", -1234), + ("unsigned16", "uint16", "H", 65530), + ("signed32", "int32", "i", -2000000001), + ("unsigned32", "uint32", "I", 4000000000), + ("signed64", "int64", "q", -(2**61) + 17), + ("unsigned64", "uint64", "Q", 0xFEDCBA9876543210), + ("float32", "float32", "f", 3.125), + ("float64", "float64", "d", -123.5), + ] + for name, dtype, format_, value in cases: + add(name, dtype, wire_registers(format_, value, byte_big, word_big), value) + # The existing string codec reads byte sequences without numeric endian swaps. + add("label", "string3", [0x4142, 0x4358], "ABC", type_="string") + for name, factor, result in [ + ("zero", "0", 0.0), + ("negative", "-0.5", 617.0), + ("fractional", "0.001", -1.234), + ]: + add( + name, + "int16", + wire_registers("h", -1234, byte_big, word_big), + result, + multiplier=factor, + ) + add( + "bit0", + "bool", + wire_registers("H", 0x8001, byte_big, word_big), + True, + type_="boolean", + bit=0, + ) + # A second bit view references the same raw register, with a distinct bit selector. + member = copy.deepcopy(group["modpoll:members"][-1]) + member.update({"modpoll:property": "bit15", "modpoll:bit": 15}) + group["modpoll:members"].append(member) + document["properties"]["bit15"] = {"type": "boolean", "readOnly": True} + expected["bit15"] = True + group["modv:quantity"] = len(registers) + config = tmp_path / "vectors.tm.json" + config.write_text(json.dumps(document)) + client = ModbusTcpClient("127.0.0.1", port=live_modsim) + try: + assert client.connect() + assert not client.write_registers(40000, registers, slave=7).isError() + finally: + client.close() + args = get_parser().parse_args( + [ + "--tcp", + "127.0.0.1", + "--tcp-port", + str(live_modsim), + "--config", + str(config), + "--unit-id", + "7", + "--daemon", + "--interval", + "0", + ] + ) + handlers = setup_modbus_handlers(args) + try: + (handler,) = handlers + handler.poll() + (device,) = handler.deviceList + assert device.pollSuccess + actual = {name: ref.val for name, ref in device.references.items()} + assert actual == expected + assert type(actual["unsigned64"]) is int + assert type(actual["signed64"]) is int + output = tmp_path / "snapshot.json" + assert handler.export(output) + assert json.loads(output.read_text()) == {"vectors": expected} + + class Capture: + messages = [] + + def publish(self, topic, payload): + self.messages.append((topic, json.loads(payload))) + + capture = Capture() + handler.mqtt_handler = capture + handler.mqtt_publish_topic_pattern = "test/{{device_name}}/data" + handler.publish_data() + topic, payload = capture.messages[-1] + assert topic == "test/vectors/data" + assert payload["half|test"] == 1.5 + assert payload["signed64|test"] == -(2**61) + 17 + assert payload["label|test"] == "ABC" + assert payload["negative|test"] == 617.0 + assert payload["bit15"] is True + + finally: + for handler in handlers: + handler.close() + + +class QuietHttpHandler(http.server.SimpleHTTPRequestHandler): + def log_message(self, *args): + pass + + +@pytest.mark.parametrize("kind", ["tm", "td"]) +def test_url_and_local_multi_device_cli_json_and_timestamp(live_modsim, tmp_path, kind): + source = json.loads((ROOT / f"examples/modsim.{kind}.json").read_text()) + source["title"] = "http-device" + (tmp_path / f"meter.{kind}.json").write_text(json.dumps(source)) + source["title"] = "local-device" + local = tmp_path / f"local.{kind}.json" + local.write_text(json.dumps(source)) + server = http.server.ThreadingHTTPServer( + ("127.0.0.1", 0), partial(QuietHttpHandler, directory=str(tmp_path)) + ) + worker = threading.Thread(target=server.serve_forever, daemon=True) + worker.start() + output = tmp_path / "output.json" + try: + url = f"http://127.0.0.1:{server.server_address[1]}/meter.{kind}.json" + result = subprocess.run( + [ + sys.executable, + "-m", + "modpoll", + "--once", + "--tcp", + "127.0.0.1", + "--tcp-port", + str(live_modsim), + "--config", + url, + str(local), + "--export", + str(output), + "--timestamp", + "--interval", + "0", + "--delay", + "0", + "--loglevel", + "ERROR", + ], + cwd=ROOT, + text=True, + capture_output=True, + timeout=15, + ) + assert result.returncode == 0, result.stderr + assert not result.stderr.strip(), result.stderr + data = json.loads(output.read_text()) + assert set(data) == {"http-device", "local-device"} + assert data["http-device"] == data["local-device"] + assert len(data["http-device"]) == 38 + assert isinstance(data["http-device"]["timestamp"], (int, float)) + assert data["http-device"]["holding_reg15"] == 12345678900 + assert data["http-device"]["holding_reg19"] == "this is a string" + assert data["http-device"]["input_reg10"] == 12345.678 + finally: + server.shutdown() + worker.join(timeout=5) + server.server_close() + + +def test_device_exception_produces_null_without_hiding_valid_group( + live_modsim, tmp_path +): + document = meter_model("partial", start=40000) + group = copy.deepcopy(document["modpoll:acquisition"][0]) + group["modv:address"] = 50000 # In Modbus range, outside the simulator datastore. + group["modpoll:members"][0]["modpoll:property"] = "unavailable" + document["properties"]["unavailable"] = {"type": "integer", "readOnly": True} + document["modpoll:acquisition"].append(group) + config = tmp_path / "partial.tm.json" + config.write_text(json.dumps(document)) + args = get_parser().parse_args( + [ + "--tcp", + "127.0.0.1", + "--tcp-port", + str(live_modsim), + "--config", + str(config), + "--daemon", + "--interval", + "0", + ] + ) + handlers = setup_modbus_handlers(args) + try: + (handler,) = handlers + handler.poll() + (device,) = handler.deviceList + assert not device.pollSuccess + assert device.references["value"].valid_this_poll + assert not device.references["unavailable"].valid_this_poll + output = tmp_path / "partial.json" + assert handler.export(output) + assert json.loads(output.read_text()) == { + "partial": {"value": 1, "unavailable": None} + } + finally: + for handler in handlers: + handler.close() + + +@pytest.mark.parametrize("remote", [False, True], ids=["local-file", "http-url"]) +def test_invalid_point_fails_entire_configuration_before_network_poll(tmp_path, remote): + document = meter_model() + document["properties"]["bad"] = {"type": "integer", "readOnly": True} + document["modpoll:acquisition"][0]["modpoll:members"].append( + {"modpoll:property": "bad", "modpoll:offset": 1, "modpoll:dataType": "uint64"} + ) + path = tmp_path / "invalid.tm.json" + path.write_text(json.dumps(document)) + server = None + if remote: + server = http.server.ThreadingHTTPServer( + ("127.0.0.1", 0), partial(QuietHttpHandler, directory=str(tmp_path)) + ) + worker = threading.Thread(target=server.serve_forever, daemon=True) + worker.start() + location = f"http://127.0.0.1:{server.server_address[1]}/{path.name}" + else: + location = str(path) + output = tmp_path / "must-not-exist.json" + try: + result = subprocess.run( + [ + sys.executable, + "-m", + "modpoll", + "--once", + "--tcp", + "127.0.0.1", + "--tcp-port", + "1", + "--config", + location, + "--export", + str(output), + "--loglevel", + "ERROR", + ], + cwd=ROOT, + text=True, + capture_output=True, + timeout=10, + ) + assert result.returncode != 0 + assert "member outside group" in result.stderr + assert "invalid.tm.json" in result.stderr + assert not output.exists() + finally: + if server is not None: + server.shutdown() + worker.join(timeout=5) + server.server_close() + + +def test_multiple_models_send_their_own_unit_ids(live_modsim, tmp_path): + from tools.verify_modsim_equivalence import Proxy, Handler + + first = meter_model("first", start=40000) + second = meter_model("second", start=40000) + first["modv:unitID"], second["modv:unitID"] = 2, 7 + paths = [tmp_path / "first.tm.json", tmp_path / "second.tm.json"] + for path, model in zip(paths, (first, second)): + path.write_text(json.dumps(model)) + output = tmp_path / "multi-unit.json" + with Proxy(("127.0.0.1", 0), Handler) as proxy: + proxy.upstream, proxy.requests, proxy.errors = ( + ("127.0.0.1", live_modsim), + [], + [], + ) + thread = threading.Thread(target=proxy.serve_forever, daemon=True) + thread.start() + try: + result = subprocess.run( + [ + sys.executable, + "-m", + "modpoll", + "--once", + "--tcp", + "127.0.0.1", + "--tcp-port", + str(proxy.server_address[1]), + "--config", + *map(str, paths), + "--export", + str(output), + "--interval", + "0", + "--delay", + "0", + "--loglevel", + "ERROR", + ], + cwd=ROOT, + text=True, + capture_output=True, + timeout=15, + ) + assert result.returncode == 0 and not result.stderr, result.stderr + assert proxy.requests == [(2, 3, 40000, 1), (7, 3, 40000, 1)] + assert not proxy.errors + assert json.loads(output.read_text()) == { + "first": {"value": 1}, + "second": {"value": 1}, + } + finally: + proxy.shutdown() + thread.join(timeout=5)