Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions packages/modpoll-wot/CONTRACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
7 changes: 6 additions & 1 deletion packages/modpoll-wot/modpoll_wot/__init__.py
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
148 changes: 148 additions & 0 deletions packages/modpoll-wot/modpoll_wot/delivery.py
Original file line number Diff line number Diff line change
@@ -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
9 changes: 9 additions & 0 deletions packages/modpoll-wot/modpoll_wot/loading.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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):
Expand Down
30 changes: 28 additions & 2 deletions packages/modpoll-wot/modpoll_wot/profile.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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"}
Expand All @@ -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

Expand Down Expand Up @@ -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
17 changes: 17 additions & 0 deletions packages/modpoll-wot/modpoll_wot/schemas/acquisition-0.2.json
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -185,5 +194,13 @@
}
}
}
},
"definitions": {
"auditNames": {
"type": "array",
"minItems": 1,
"uniqueItems": true,
"items": { "type": "string", "minLength": 1 }
}
}
}
35 changes: 35 additions & 0 deletions packages/modpoll-wot/profile.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
2 changes: 1 addition & 1 deletion packages/modpoll-wot/pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
Loading
Loading