diff --git a/packages/modpoll-wot/CONTRACT.md b/packages/modpoll-wot/CONTRACT.md index 9e72a3f..13635f6 100644 --- a/packages/modpoll-wot/CONTRACT.md +++ b/packages/modpoll-wot/CONTRACT.md @@ -56,3 +56,9 @@ schema hash in a PR that validates its examples and records. A metadata-only release does not require Modpoll/Modsim to change their application pins. A later application dependency update still runs its normal regression checks. The verified immutable release for this schema is `modpoll-wot==0.2.0a3`. + +The 0.2.0a4 candidate adds optional audit references and the publication APIs +described in [profile.md](profile.md#publication-artifacts). Earlier packages +can run existing profile 0.2 documents that omit the extension. They reject +documents with the new audit extension. The shared package validates and +derives configuration without contacting any device. diff --git a/packages/modpoll-wot/modpoll_wot/__init__.py b/packages/modpoll-wot/modpoll_wot/__init__.py index c9cd453..f3313ab 100644 --- a/packages/modpoll-wot/modpoll_wot/__init__.py +++ b/packages/modpoll-wot/modpoll_wot/__init__.py @@ -1,17 +1,22 @@ """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 .loading import AuditPlan, Model, load_model, loads_model, parse_model from .validation import ModelValidationError, validate_standard from .profile import instantiate, export_td, parse_parameters +from .delivery import AuditArtifact, derive_audit_tm, project_point_table __all__ = [ "parse_parameters", + "AuditArtifact", + "derive_audit_tm", + "project_point_table", "instantiate", "export_td", "AcquisitionError", "Group", "Member", + "AuditPlan", "Model", "ModelValidationError", "parse_acquisition", diff --git a/packages/modpoll-wot/modpoll_wot/delivery.py b/packages/modpoll-wot/modpoll_wot/delivery.py new file mode 100644 index 0000000..53d1a6e --- /dev/null +++ b/packages/modpoll-wot/modpoll_wot/delivery.py @@ -0,0 +1,148 @@ +"""Build publishable artifacts from executable project Thing Models.""" + +from dataclasses import dataclass +from hashlib import sha256 +import json + +from .acquisition import AcquisitionError +from .loading import _loads_document +from .profile import instantiate + +FORM_FIELDS = ( + "modv:entity", + "modv:address", + "modv:quantity", + "modv:type", + "modv:mostSignificantByte", + "modv:mostSignificantWord", + "modpoll:codec", + "modpoll:bit", + "modpoll:multiplier", + "modpoll:byteLength", + "modpoll:bitPacking", +) +READ_FUNCTIONS = { + "Coil": 1, + "DiscreteInput": 2, + "HoldingRegister": 3, + "InputRegister": 4, +} + + +@dataclass(frozen=True) +class AuditArtifact: + payload: bytes + source_sha256: str + sha256: str + format_version: str = "audit-tm-v1" + + +def _parse_tm(payload): + try: + model, effective = instantiate(_loads_document(payload)) + except (ValueError, TypeError, RecursionError) as exc: + raise AcquisitionError(str(exc)) from exc + if model.kind != "tm": + raise AcquisitionError("publication requires a project Thing Model") + return model, effective + + +def derive_audit_tm(payload: bytes, *, title: str) -> AuditArtifact: + """Rebuild a read-only TM using the audit selection and an approved title.""" + model, effective = _parse_tm(payload) + if model.audit is None: + raise AcquisitionError("publication requires a modpoll:audit plan") + audit = { + section: list(names) + for section, names in ( + ("identity", model.audit.identity), + ("status", model.audit.status), + ) + if names + } + properties = {} + groups = [] + for names in audit.values(): + for name in names: + prop = effective["properties"][name] + read_form = next( + form + for form in prop["forms"] + if form["op"] == "readproperty" + or isinstance(form["op"], list) + and "readproperty" in form["op"] + ) + form = {key: read_form[key] for key in FORM_FIELDS if key in read_form} + form["op"] = "readproperty" + public_prop = {"type": prop["type"], "readOnly": True, "forms": [form]} + if prop["type"] == "array": + public_prop["items"] = {"type": prop["items"]["type"]} + properties[name] = public_prop + groups.append( + { + "modv:entity": form["modv:entity"], + "modv:address": form["modv:address"], + "modv:quantity": form["modv:quantity"], + "modpoll:members": [name], + } + ) + document = { + "@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#", + }, + ], + "@type": "tm:ThingModel", + "title": title, + "modpoll:version": model.profile_version, + "properties": properties, + "modpoll:acquisition": groups, + "modpoll:audit": audit, + } + derived = json.dumps( + document, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + allow_nan=False, + ).encode("utf-8") + _parse_tm(derived) + return AuditArtifact( + derived, sha256(payload).hexdigest(), sha256(derived).hexdigest() + ) + + +def project_point_table(payload: bytes) -> list[dict]: + """Project executable point spans and decoder metadata from exact TM bytes.""" + model, effective = _parse_tm(payload) + rows = [] + for group in model.groups: + for member in group.members: + form = effective["properties"][member.property]["forms"][0] + entity = form["modv:entity"] + rows.append( + { + "property": member.property, + "entity": entity, + "address": form["modv:address"], + "quantity": form["modv:quantity"], + "read_function_code": ( + READ_FUNCTIONS[entity] if member.readable else None + ), + "writable": member.writable, + "data_type": member.data_type, + "byte_order": member.byte_order, + "bit": member.bit, + "bit_packing": form.get("modpoll:bitPacking"), + "multiplier": ( + str(member.multiplier) + if member.multiplier is not None + else None + ), + "unit": member.unit, + } + ) + return rows diff --git a/packages/modpoll-wot/modpoll_wot/loading.py b/packages/modpoll-wot/modpoll_wot/loading.py index 16ec4be..5bd95aa 100644 --- a/packages/modpoll-wot/modpoll_wot/loading.py +++ b/packages/modpoll-wot/modpoll_wot/loading.py @@ -11,6 +11,14 @@ MAX_DEPTH = 64 +@dataclass(frozen=True) +class AuditPlan: + """Ordered property references for a read-only local audit.""" + + identity: tuple[str, ...] = () + status: tuple[str, ...] = () + + @dataclass(frozen=True) class Model: title: str @@ -20,6 +28,7 @@ class Model: endpoint: tuple[str, int] | None = None properties_json: str | None = None profile_version: str = "0.2" + audit: AuditPlan | None = None def _pairs(pairs): diff --git a/packages/modpoll-wot/modpoll_wot/profile.py b/packages/modpoll-wot/modpoll_wot/profile.py index fbf8ef6..bb23687 100644 --- a/packages/modpoll-wot/modpoll_wot/profile.py +++ b/packages/modpoll-wot/modpoll_wot/profile.py @@ -243,7 +243,7 @@ def instantiate(document, *, host=None, port=None, unit_id=None, parameters=None ) if any( key.startswith("modpoll:") - and key not in ("modpoll:version", "modpoll:acquisition") + and key not in ("modpoll:version", "modpoll:acquisition", "modpoll:audit") for key in td ): raise AcquisitionError("unsupported root project extension") @@ -456,7 +456,31 @@ def instantiate(document, *, host=None, port=None, unit_id=None, parameters=None if authority is not None: validate_standard(td, kind="td") validate_modbus(td) - from .loading import Model + from .loading import AuditPlan, Model + + audit = None + if "modpoll:audit" in td: + raw_audit = td["modpoll:audit"] + seen_audit = set() + for section, names in raw_audit.items(): + for name in names: + path = f"/modpoll:audit/{section}" + if name not in metadata: + raise AcquisitionError(f"{path}: unknown property {name}") + if name in seen_audit: + raise AcquisitionError(f"{path}: duplicate property {name}") + forms = metadata[name][1] + if not any( + form["op"] == "readproperty" + or (isinstance(form["op"], list) and "readproperty" in form["op"]) + for form in forms + ): + raise AcquisitionError(f"{path}: property {name} is not readable") + seen_audit.add(name) + audit = AuditPlan( + identity=tuple(raw_audit.get("identity", ())), + status=tuple(raw_audit.get("status", ())), + ) schemas = { name: {key: value for key, value in prop.items() if key != "forms"} @@ -470,6 +494,7 @@ def instantiate(document, *, host=None, port=None, unit_id=None, parameters=None (actual_host, actual_port) if authority else None, json.dumps(schemas, sort_keys=True, ensure_ascii=False), VERSION, + audit=audit, ) return model, td @@ -573,6 +598,7 @@ def export_td(document, *, target="project", **instance): ) td.pop("modpoll:version", None) td.pop("modpoll:acquisition", None) + td.pop("modpoll:audit", None) validate_standard(td, kind="td") validate_modbus(td) return td diff --git a/packages/modpoll-wot/modpoll_wot/schemas/acquisition-0.2.json b/packages/modpoll-wot/modpoll_wot/schemas/acquisition-0.2.json index cc26de6..7c10fba 100644 --- a/packages/modpoll-wot/modpoll_wot/schemas/acquisition-0.2.json +++ b/packages/modpoll-wot/modpoll_wot/schemas/acquisition-0.2.json @@ -11,6 +11,15 @@ "modpoll:version": { "const": "0.2" }, + "modpoll:audit": { + "type": "object", + "additionalProperties": false, + "minProperties": 1, + "properties": { + "identity": { "$ref": "#/definitions/auditNames" }, + "status": { "$ref": "#/definitions/auditNames" } + } + }, "modpoll:acquisition": { "type": "array", "minItems": 1, @@ -185,5 +194,13 @@ } } } + }, + "definitions": { + "auditNames": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { "type": "string", "minLength": 1 } + } } } diff --git a/packages/modpoll-wot/profile.md b/packages/modpoll-wot/profile.md index 250d804..9d15b64 100644 --- a/packages/modpoll-wot/profile.md +++ b/packages/modpoll-wot/profile.md @@ -168,3 +168,38 @@ consumer exact pins and CI. Project versions need not match. Until producer-firs publication completes, no consumer requires an unpublished version or local path. The modsim TD runtime and platform lifecycle remain separate implementation tasks; this contract does not claim those applications are implemented. + + +### Optional audit plan + +A project TM or TD can declare `modpoll:audit` with an `identity` array, a +`status` array, or both. Each supplied array contains one or more property names +in reporting order. A name may occur only once in the plan and must resolve to a +property with a read Form. Validation checks these references before any device +I/O. The property Forms remain the authority for addresses and codecs. + +Project TD export retains the plan. The explicitly selected node-wot export +omits project extensions. Audit references describe a local client operation +and do not add WoT actions to a device. + +### Publication artifacts + +`derive_audit_tm(full_tm_bytes, title=approved_title)` builds an unbound, +read-only TM from the reviewed audit selection. Treat selected property names, +their mappings, and the supplied display title as public data. The generator +retains only executable mapping and decoding fields. It drops source text, +units, endpoint and Unit ID, security metadata, write Forms, unselected +properties, and unused portions of acquisition blocks. Each generated block +has the exact span of one selected read. The result contains the UTF-8 JSON +bytes, their SHA-256, the input bytes' SHA-256, and format identifier +`audit-tm-v1`. Inputs must be valid project TMs that resolve without external +parameter bindings. An absent audit plan fails derivation. + +`project_point_table(tm_bytes)` produces rows in acquisition member order. +Addresses are zero-based and quantities count the registers or bits read by +the individual Form. Read function codes are null for write-only points. +A writable flag records access without inferring a write function code. +Multipliers are decimal strings when present. For the public preview, run +projection on the derived bytes. For the full point table, run it on the +approved source bytes. Keep the full projection under the same access +controls as the full TM. diff --git a/packages/modpoll-wot/pyproject.toml b/packages/modpoll-wot/pyproject.toml index 17c6249..6267346 100644 --- a/packages/modpoll-wot/pyproject.toml +++ b/packages/modpoll-wot/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "modpoll-wot" -version = "0.2.0a3" +version = "0.2.0a4" description = "Shared WoT Modbus configuration interpreter" authors = [{ name = "Ying Shaodong", email = "helloysd@gmail.com" }] readme = "README.md" diff --git a/packages/modpoll-wot/tests/test_delivery.py b/packages/modpoll-wot/tests/test_delivery.py new file mode 100644 index 0000000..f28b28f --- /dev/null +++ b/packages/modpoll-wot/tests/test_delivery.py @@ -0,0 +1,77 @@ +"""Regression tests for the public TM boundary and point-table projection.""" + +from hashlib import sha256 +import json +from pathlib import Path + +import pytest + +from modpoll_wot import ( + AcquisitionError, + derive_audit_tm, + loads_model, + project_point_table, +) + + +def source_document(): + fixture = Path(__file__).parent / "fixtures/numeric.tm.json" + return json.loads(fixture.read_bytes()) + + +def test_public_tm_projects_only_exact_approved_read_spans(): + source = source_document() + source["title"] = "Private title" + source["modpoll:audit"] = {"identity": ["counter"], "status": ["measurement"]} + counter = source["properties"]["counter"] + counter.update(readOnly=False, description="Internal note", unit="Private unit") + counter["forms"][0]["op"] = ["readproperty", "writeproperty"] + for prop in source["properties"].values(): + form = prop["forms"][0] + address, quantity = form["modv:address"], form["modv:quantity"] + form["href"] = ( + f"modbus+tcp://private.example:1502/9/{address}?quantity={quantity}" + ) + form["modv:unitID"] = 9 + payload = json.dumps(source).encode() + artifact = derive_audit_tm(payload, title="Approved device") + public = artifact.payload + + assert artifact == derive_audit_tm(payload, title="Approved device") + assert artifact.source_sha256 == sha256(payload).hexdigest() + assert artifact.sha256 == sha256(public).hexdigest() + for secret in ( + b"Private title", + b"Internal note", + b"Private unit", + b"private.example", + b"unitID", + b"writeproperty", + ): + assert secret not in public + + model = loads_model(public) + assert model.endpoint is None + assert model.audit.identity == ("counter",) + assert model.audit.status == ("measurement",) + assert [(g.start, g.quantity) for g in model.groups] == [(40000, 1), (40004, 2)] + + rows = project_point_table(public) + full_rows = project_point_table(payload) + assert [row["property"] for row in full_rows] == [ + "counter", + "signed", + "measurement", + ] + assert full_rows[0]["writable"] and full_rows[0]["unit"] == "Private unit" + assert [(r["property"], r["address"], r["quantity"]) for r in rows] == [ + ("counter", 40000, 1), + ("measurement", 40004, 2), + ] + assert all(r["read_function_code"] == 3 and not r["writable"] for r in rows) + + +def test_derivation_requires_a_reviewable_audit_selection(): + payload = json.dumps(source_document()).encode() + with pytest.raises(AcquisitionError, match="requires a modpoll:audit plan"): + derive_audit_tm(payload, title="Approved device") diff --git a/packages/modpoll-wot/tests/test_profile.py b/packages/modpoll-wot/tests/test_profile.py index 401f5a7..aa4df7b 100644 --- a/packages/modpoll-wot/tests/test_profile.py +++ b/packages/modpoll-wot/tests/test_profile.py @@ -5,7 +5,7 @@ from pathlib import Path import unittest -from modpoll_wot import AcquisitionError, load_model, parse_model +from modpoll_wot import AcquisitionError, AuditPlan, load_model, parse_model from modpoll_wot.profile import instantiate, export_td @@ -19,6 +19,53 @@ def numeric(): class ProfileTests(unittest.TestCase): + def test_audit_references_are_preserved_in_project_td(self): + source = numeric() + self.assertIsNone(instantiate(source)[0].audit) + source["modpoll:audit"] = { + "identity": ["counter"], + "status": ["measurement", "signed"], + } + model, td = instantiate(source, host="localhost") + self.assertEqual( + model.audit, AuditPlan(("counter",), ("measurement", "signed")) + ) + self.assertEqual(instantiate(td)[0].audit, model.audit) + self.assertNotIn( + "modpoll:audit", + export_td(source, host="localhost", target="node-wot-0.9.2"), + ) + self.assertEqual( + export_td(source, host="localhost")["modpoll:audit"], + source["modpoll:audit"], + ) + + def test_audit_rejects_invalid_or_unreadable_references(self): + for audit in ( + None, + {}, + {"status": []}, + {"status": ["missing"]}, + {"status": ["counter", "counter"]}, + {"identity": ["counter"], "status": ["counter"]}, + {"unknown": ["counter"]}, + ): + with self.subTest(audit=audit): + source = numeric() + source["modpoll:audit"] = audit + with self.assertRaises((AcquisitionError, ValueError)): + instantiate(source) + + source = numeric() + prop = source["properties"]["counter"] + del prop["readOnly"] + prop["writeOnly"] = True + prop["forms"][0]["op"] = "writeproperty" + instantiate(source) + source["modpoll:audit"] = {"status": ["counter"]} + with self.assertRaisesRegex(AcquisitionError, "not readable"): + instantiate(source) + def test_shared_td_preserves_consistent_binary_media_parameters(self): source = numeric() form = source["properties"]["counter"]["forms"][0] diff --git a/vocab/wot-modbus/index.html b/vocab/wot-modbus/index.html index 0821ec0..d8ee72b 100644 --- a/vocab/wot-modbus/index.html +++ b/vocab/wot-modbus/index.html @@ -31,6 +31,7 @@