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 fd4055a..718d349 100644
--- a/.github/workflows/main.yml
+++ b/.github/workflows/main.yml
@@ -64,3 +64,62 @@ 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 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
+ .interop/mqtt-results.xml
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..daa2d64 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
[](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`
+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.
-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..f854e50 100644
--- a/docs/configure.rst
+++ b/docs/configure.rst
@@ -1,44 +1,56 @@
-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`` 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.
+
+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/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/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/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/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/examples/modsim.tm.json b/examples/modsim.tm.json
new file mode 100644
index 0000000..dce958d
--- /dev/null
+++ b/examples/modsim.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": "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: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/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..6dba882 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=None,
+ help="Override model Unit ID (1-254); otherwise use modv:unitID or 1",
+ )
parser.add_argument(
"-d",
"--daemon",
diff --git a/modpoll/modbus_task.py b/modpoll/modbus_task.py
index 6e04c51..d9641d5 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: Optional[int] = None,
):
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
new file mode 100644
index 0000000..b328225
--- /dev/null
+++ b/packages/modpoll-wot-modbus/CONTRACT.md
@@ -0,0 +1,88 @@
+# Executable configuration contract 0.1 (development)
+
+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.
+
+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 | `title`, used verbatim as output device name |
+| 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 |
+| 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
new file mode 100644
index 0000000..3d51b01
--- /dev/null
+++ b/packages/modpoll-wot-modbus/README.md
@@ -0,0 +1,23 @@
+# modpoll-wot-modbus (experimental)
+
+An independently packaged TM/TD configuration interpreter under development.
+See CONTRACT.md for the executable 0.1 subset and 1.x field mapping.
+
+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("examples/modsim.tm.json", kind="tm", unit_id=1)
+```
+
+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
+```
+
+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/__init__.py b/packages/modpoll-wot-modbus/modpoll_wot_modbus/__init__.py
new file mode 100644
index 0000000..497c160
--- /dev/null
+++ b/packages/modpoll-wot-modbus/modpoll_wot_modbus/__init__.py
@@ -0,0 +1,18 @@
+"""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",
+ "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..0cf59b2
--- /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=None)
+ 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
new file mode 100644
index 0000000..44a07f0
--- /dev/null
+++ b/packages/modpoll-wot-modbus/modpoll_wot_modbus/acquisition.py
@@ -0,0 +1,185 @@
+"""Version 0.1 acquisition semantics, independent of Modbus transport."""
+
+from dataclasses import dataclass
+from decimal import Decimal, InvalidOperation
+import re
+import math
+
+
+class AcquisitionError(ValueError):
+ """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)
+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
+
+ @property
+ def width(self):
+ return type_width(self.data_type)
+
+
+@dataclass(frozen=True)
+class Group:
+ table: str
+ start: int
+ quantity: int
+ byte_order: str
+ members: tuple[Member, ...]
+
+
+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 _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, ...]:
+ """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")
+ 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, 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 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}: 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 (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
+ )
+ if table in ("discrete_input", "input_register") and writable:
+ raise AcquisitionError(f"{mp}: read-only table requires readOnly")
+ multiplier = None
+ if "modpoll:multiplier" in member:
+ try:
+ multiplier = Decimal(str(member["modpoll:multiplier"]))
+ except InvalidOperation as exc:
+ raise AcquisitionError(f"{mp}: invalid multiplier") from exc
+ if not multiplier.is_finite() or not math.isfinite(float(multiplier)):
+ raise AcquisitionError(f"{mp}: non-finite multiplier")
+ _schema_type(prop, dtype, bit, multiplier, mp)
+ members.append(
+ Member(
+ name,
+ offset,
+ dtype,
+ bit,
+ multiplier,
+ readable,
+ writable,
+ prop.get("unit"),
+ )
+ )
+ seen.add(name)
+ result.append(Group(table, start, count, byte_order, tuple(members)))
+ if seen != properties.keys():
+ 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
new file mode 100644
index 0000000..4ca0292
--- /dev/null
+++ b/packages/modpoll-wot-modbus/modpoll_wot_modbus/loading.py
@@ -0,0 +1,142 @@
+"""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)
+class Model:
+ title: str
+ kind: str
+ groups: tuple[Group, ...]
+ unit_id: int
+
+
+def _pairs(pairs):
+ result = {}
+ for key, value in pairs:
+ if key in result:
+ raise AcquisitionError(f"duplicate JSON key: {key}")
+ result[key] = value
+ return result
+
+
+def _invalid_number(value):
+ raise AcquisitionError(f"invalid JSON number: {value}")
+
+
+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=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")
+ _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"
+ )
+ 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():
+ 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=None):
+ try:
+ 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=_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=None):
+ 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
new file mode 100644
index 0000000..bc2129c
--- /dev/null
+++ b/packages/modpoll-wot-modbus/modpoll_wot_modbus/schemas/acquisition-0.1.json
@@ -0,0 +1,104 @@
+{
+ "$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": [
+ "modv:entity",
+ "modv:address",
+ "modv:quantity",
+ "modv:mostSignificantByte",
+ "modv:mostSignificantWord",
+ "modpoll:members"
+ ],
+ "additionalProperties": false,
+ "properties": {
+ "modv:entity": {
+ "enum": [
+ "Coil",
+ "DiscreteInput",
+ "InputRegister",
+ "HoldingRegister"
+ ]
+ },
+ "modv:address": {
+ "type": "integer",
+ "minimum": 0,
+ "maximum": 65535
+ },
+ "modv:quantity": {
+ "type": "integer",
+ "minimum": 1,
+ "maximum": 2000
+ },
+ "modv:mostSignificantByte": {
+ "type": "boolean"
+ },
+ "modv:mostSignificantWord": {
+ "type": "boolean"
+ },
+ "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,
+ "maximum": 65535
+ },
+ "modpoll:dataType": {
+ "type": "string",
+ "pattern": "^(u?int(16|32|64)|float(16|32|64)|bool(8|16)?|string[1-9][0-9]{0,3})$"
+ },
+ "modpoll:bit": {
+ "type": "integer",
+ "minimum": 0,
+ "maximum": 15
+ },
+ "modpoll:multiplier": {
+ "oneOf": [
+ {
+ "type": "number"
+ },
+ {
+ "type": "string",
+ "pattern": "^[+-]?([0-9]+(\\.[0-9]*)?|\\.[0-9]+)([eE][+-]?[0-9]+)?$"
+ }
+ ]
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "modv:unitID": {
+ "type": "integer",
+ "minimum": 1,
+ "maximum": 254
+ }
+ }
+}
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..629d3e9
--- /dev/null
+++ b/packages/modpoll-wot-modbus/modpoll_wot_modbus/validation.py
@@ -0,0 +1,56 @@
+"""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(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):
+ 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", names[kind])
+ .read_text(encoding="utf-8")
+ )
+ Draft7Validator.check_schema(schema)
+ return Draft7Validator(schema, registry=Registry(retrieve=_no_remote))
+
+
+def _validate(document, kind):
+ error = next(_validator(kind).iter_errors(document), None)
+ if error is not None:
+ 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
new file mode 100644
index 0000000..b4de6fe
--- /dev/null
+++ b/packages/modpoll-wot-modbus/pyproject.toml
@@ -0,0 +1,17 @@
+[tool.poetry]
+name = "modpoll-wot-modbus"
+version = "0.1.0a1"
+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.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
new file mode 100644
index 0000000..e3ca05e
--- /dev/null
+++ b/packages/modpoll-wot-modbus/tests/test_acquisition.py
@@ -0,0 +1,69 @@
+import copy
+import unittest
+
+from modpoll_wot_modbus import AcquisitionError, parse_acquisition
+
+
+def model():
+ return {
+ "modpoll:version": "0.1",
+ "properties": {"voltage": {"type": "number"}, "current": {"type": "number"}},
+ "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)],
+ )
+
+ 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({"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}),
+ 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()
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
new file mode 100644
index 0000000..87ee076
--- /dev/null
+++ b/packages/modpoll-wot-modbus/tests/test_loading.py
@@ -0,0 +1,101 @@
+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__).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),
+ ],
+ )
+ 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")
+
+
+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/packages/modpoll-wot-modbus/tests/test_validation.py b/packages/modpoll-wot-modbus/tests/test_validation.py
new file mode 100644
index 0000000..78c82a9
--- /dev/null
+++ b/packages/modpoll-wot-modbus/tests/test_validation.py
@@ -0,0 +1,33 @@
+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",
+ )
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/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_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_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_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/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)
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()