From d3f9987d30af0194222e8e6ab927654d7fdaeede Mon Sep 17 00:00:00 2001
From: Gavin Ying
Date: Wed, 4 Nov 2026 11:20:08 +0800
Subject: [PATCH 1/5] feat(wot): validate read-only audit property references
---
packages/modpoll-wot/modpoll_wot/__init__.py | 3 +-
packages/modpoll-wot/modpoll_wot/loading.py | 9 ++++
packages/modpoll-wot/modpoll_wot/profile.py | 30 ++++++++++++-
.../modpoll_wot/schemas/acquisition-0.2.json | 17 +++++++
packages/modpoll-wot/profile.md | 13 ++++++
packages/modpoll-wot/tests/test_profile.py | 45 ++++++++++++++++++-
vocab/wot-modbus/index.html | 1 +
7 files changed, 114 insertions(+), 4 deletions(-)
diff --git a/packages/modpoll-wot/modpoll_wot/__init__.py b/packages/modpoll-wot/modpoll_wot/__init__.py
index c9cd453..2c46741 100644
--- a/packages/modpoll-wot/modpoll_wot/__init__.py
+++ b/packages/modpoll-wot/modpoll_wot/__init__.py
@@ -1,7 +1,7 @@
"""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
@@ -12,6 +12,7 @@
"AcquisitionError",
"Group",
"Member",
+ "AuditPlan",
"Model",
"ModelValidationError",
"parse_acquisition",
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..5175bf0 100644
--- a/packages/modpoll-wot/profile.md
+++ b/packages/modpoll-wot/profile.md
@@ -168,3 +168,16 @@ 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.
diff --git a/packages/modpoll-wot/tests/test_profile.py b/packages/modpoll-wot/tests/test_profile.py
index 401f5a7..15acebc 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,49 @@ 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 @@ Contract 0.2
explicit. Bit-table offsets count physical bits in 0.2. Above definitions remain
unchanged for 0.1 input.
+- modpoll:audit
- Optional identity and status lists of readable property names for a local audit.
- modpoll:codec
- Form float16 or historical register boolean array codec, raw xsd:hexBinary.
- modpoll:byteLength
- Exact UTF-8 byte capacity when different from the full register span.
- modpoll:bitPacking
- Explicit legacy-msb packed numeric/string bit-table interpretation.
From 86755d283056c7330b36c4c74d4b44d7612ed26f Mon Sep 17 00:00:00 2001
From: Gavin Ying
Date: Wed, 4 Nov 2026 11:25:50 +0800
Subject: [PATCH 2/5] feat(wot): derive public audit models and project point
tables
---
packages/modpoll-wot/modpoll_wot/__init__.py | 4 +
packages/modpoll-wot/modpoll_wot/delivery.py | 143 +++++++++++++++++++
packages/modpoll-wot/profile.md | 22 +++
packages/modpoll-wot/tests/test_delivery.py | 71 +++++++++
4 files changed, 240 insertions(+)
create mode 100644 packages/modpoll-wot/modpoll_wot/delivery.py
create mode 100644 packages/modpoll-wot/tests/test_delivery.py
diff --git a/packages/modpoll-wot/modpoll_wot/__init__.py b/packages/modpoll-wot/modpoll_wot/__init__.py
index 2c46741..f3313ab 100644
--- a/packages/modpoll-wot/modpoll_wot/__init__.py
+++ b/packages/modpoll-wot/modpoll_wot/__init__.py
@@ -4,9 +4,13 @@
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",
diff --git a/packages/modpoll-wot/modpoll_wot/delivery.py b/packages/modpoll-wot/modpoll_wot/delivery.py
new file mode 100644
index 0000000..a21b0a2
--- /dev/null
+++ b/packages/modpoll-wot/modpoll_wot/delivery.py
@@ -0,0 +1,143 @@
+"""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/profile.md b/packages/modpoll-wot/profile.md
index 5175bf0..9d15b64 100644
--- a/packages/modpoll-wot/profile.md
+++ b/packages/modpoll-wot/profile.md
@@ -181,3 +181,25 @@ 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/tests/test_delivery.py b/packages/modpoll-wot/tests/test_delivery.py
new file mode 100644
index 0000000..1a20a8d
--- /dev/null
+++ b/packages/modpoll-wot/tests/test_delivery.py
@@ -0,0 +1,71 @@
+"""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")
From 05fa0041d0b5ed055461195eba92d82e9cbdcfa1 Mon Sep 17 00:00:00 2001
From: Gavin Ying
Date: Wed, 4 Nov 2026 11:40:58 +0800
Subject: [PATCH 3/5] chore(wot): prepare audit producer candidate 0.2.0a4
---
packages/modpoll-wot/CONTRACT.md | 6 ++++++
packages/modpoll-wot/pyproject.toml | 2 +-
2 files changed, 7 insertions(+), 1 deletion(-)
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/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"
From fbb17c5465a7f30ecfca1bee75752662e3d711b4 Mon Sep 17 00:00:00 2001
From: Gavin Ying
Date: Wed, 4 Nov 2026 13:23:58 +0800
Subject: [PATCH 4/5] style(wot): format publication artifact code and tests
---
packages/modpoll-wot/modpoll_wot/delivery.py | 11 ++++++++---
packages/modpoll-wot/tests/test_delivery.py | 10 ++++++++--
2 files changed, 16 insertions(+), 5 deletions(-)
diff --git a/packages/modpoll-wot/modpoll_wot/delivery.py b/packages/modpoll-wot/modpoll_wot/delivery.py
index a21b0a2..53d1a6e 100644
--- a/packages/modpoll-wot/modpoll_wot/delivery.py
+++ b/packages/modpoll-wot/modpoll_wot/delivery.py
@@ -69,7 +69,8 @@ def derive_audit_tm(payload: bytes, *, title: str) -> AuditArtifact:
form
for form in prop["forms"]
if form["op"] == "readproperty"
- or isinstance(form["op"], list) and "readproperty" in form["op"]
+ 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"
@@ -109,7 +110,9 @@ def derive_audit_tm(payload: bytes, *, title: str) -> AuditArtifact:
allow_nan=False,
).encode("utf-8")
_parse_tm(derived)
- return AuditArtifact(derived, sha256(payload).hexdigest(), sha256(derived).hexdigest())
+ return AuditArtifact(
+ derived, sha256(payload).hexdigest(), sha256(derived).hexdigest()
+ )
def project_point_table(payload: bytes) -> list[dict]:
@@ -135,7 +138,9 @@ def project_point_table(payload: bytes) -> list[dict]:
"bit": member.bit,
"bit_packing": form.get("modpoll:bitPacking"),
"multiplier": (
- str(member.multiplier) if member.multiplier is not None else None
+ str(member.multiplier)
+ if member.multiplier is not None
+ else None
),
"unit": member.unit,
}
diff --git a/packages/modpoll-wot/tests/test_delivery.py b/packages/modpoll-wot/tests/test_delivery.py
index 1a20a8d..f28b28f 100644
--- a/packages/modpoll-wot/tests/test_delivery.py
+++ b/packages/modpoll-wot/tests/test_delivery.py
@@ -29,7 +29,9 @@ def test_public_tm_projects_only_exact_approved_read_spans():
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["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")
@@ -56,7 +58,11 @@ def test_public_tm_projects_only_exact_approved_read_spans():
rows = project_point_table(public)
full_rows = project_point_table(payload)
- assert [row["property"] for row in full_rows] == ["counter", "signed", "measurement"]
+ 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),
From 1b785ab758e750c0b52882ff7de05dfd119d478a Mon Sep 17 00:00:00 2001
From: Gavin Ying
Date: Wed, 4 Nov 2026 13:33:26 +0800
Subject: [PATCH 5/5] style(wot): format audit contract fixtures
---
packages/modpoll-wot/tests/test_profile.py | 10 +++++++---
1 file changed, 7 insertions(+), 3 deletions(-)
diff --git a/packages/modpoll-wot/tests/test_profile.py b/packages/modpoll-wot/tests/test_profile.py
index 15acebc..aa4df7b 100644
--- a/packages/modpoll-wot/tests/test_profile.py
+++ b/packages/modpoll-wot/tests/test_profile.py
@@ -27,13 +27,17 @@ def test_audit_references_are_preserved_in_project_td(self):
"status": ["measurement", "signed"],
}
model, td = instantiate(source, host="localhost")
- self.assertEqual(model.audit, AuditPlan(("counter",), ("measurement", "signed")))
+ 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")
+ "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"]
+ export_td(source, host="localhost")["modpoll:audit"],
+ source["modpoll:audit"],
)
def test_audit_rejects_invalid_or_unreadable_references(self):