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 [![Release](https://img.shields.io/github/v/release/top-maker/modpoll)](https://img.shields.io/github/v/release/top-maker/modpoll) @@ -84,7 +86,7 @@ Use the public Modbus TCP simulator at `modsim.topmaker.net:502` for a quick tes ```bash modpoll --once \ --tcp modsim.topmaker.net --tcp-port 502 \ - --config https://raw.githubusercontent.com/top-maker/modpoll/main/examples/modsim.csv + --config examples/modsim.tm.json ``` ### Run a local simulator @@ -100,10 +102,10 @@ In another terminal, run: ```bash modpoll --once \ --tcp 127.0.0.1 --tcp-port 5020 \ - --config https://raw.githubusercontent.com/top-maker/modpoll/main/examples/modsim.csv + --config examples/modsim.tm.json ``` -You can download the configuration and pass a local file instead of the URL. +The JSON examples are included in this checkout. A public URL containing the same JSON format is also accepted.

@@ -116,79 +118,19 @@ You can download the configuration and pass a local file instead of the URL. - Serial transports: use `--serial PORT --framer ascii` (alias: `--rtu` still accepted); pyserial URLs such as `socket://host:port` and `rfc2217://host:port` work for serial-over-TCP tunnels. - Serial supports framers `ascii` and `rtu` (pymodbus defaults to RTU if `--framer default`). Binary framer is no longer supported in pymodbus 3.9+. -### Prepare Modbus configure file - -The [modsim configuration](https://raw.githubusercontent.com/top-maker/modpoll/main/examples/modsim.csv) maps the simulator's four tables as follows: - -```CSV -device,modsim01,1,, -poll,coil,0,16,BE_BE -ref,coil01-08,0,bool8,rw -ref,coil09-16,1,bool8,rw -poll,discrete_input,10000,16,BE_BE -ref,di01-08,10000,bool8,rw -ref,di09-16,10001,bool8,rw -poll,input_register,30000,20,BE_BE -ref,input_reg01,30000,uint16,rw -ref,input_reg02,30001,uint16,rw -ref,input_reg03,30002,uint16,rw -ref,input_reg04,30003,uint16,rw -ref,input_reg05,30004,int16,rw -ref,input_reg06,30005,int16,rw -ref,input_reg07,30006,int16,rw -ref,input_reg08,30007,int16,rw -ref,input_reg09,30008,uint32,rw -ref,input_reg10,30010,uint32,rw -ref,input_reg11,30012,int32,rw -ref,input_reg12,30014,int32,rw -ref,input_reg13,30016,float32,rw -ref,input_reg14,30018,float32,rw -poll,holding_register,40000,44,BE_BE -ref,holding_reg01,40000,uint16,rw -ref,holding_reg02,40001,uint16,rw -ref,holding_reg03,40002,uint16,rw -ref,holding_reg04,40003,uint16,rw -ref,holding_reg05,40004,int16,rw -ref,holding_reg06,40005,int16,rw -ref,holding_reg07,40006,int16,rw -ref,holding_reg08,40007,int16,rw -ref,holding_reg09,40008,uint32,rw -ref,holding_reg10,40010,uint32,rw -ref,holding_reg11,40012,int32,rw -ref,holding_reg12,40014,int32,rw -ref,holding_reg13,40016,float32,rw -ref,holding_reg14,40018,float32,rw -ref,holding_reg15,40020,uint64,rw -ref,holding_reg16,40024,int64,rw -ref,holding_reg17,40028,float64,rw -ref,holding_reg18,40032,float64,rw -ref,holding_reg19,40036,string16,rw -``` - -This configuration tells *modpoll* to do the following for each poll, - -- Read `16` coils from the address starting from `0` and parse the response as two 8-bits boolean values; -- Read `16` discrete inputs from the address starting from `10000` and parse the response as two 8-bits boolean values; -- Read `20` input registers from the address starting from `30000` and parse data accordingly; -- Read `44` holding registers from the address starting from `40000` and parse data accordingly; - -> Notice the holding registers contain a 16-byte long string, which counts as 8 registers in poll size, because Modbus protocol defines a holding register as 2-byte width. - -In practical, you usually need to customize a Modbus configuration file for your own device before running *modpoll* tool, which defines the optimal polling patterns and register mappings according to device vendor's documents. - -You can also take a look at [contrib](https://github.com/top-maker/modpoll/tree/main/contrib) folder, which collects a few types of device configuration shared by contributors. - -The configuration can be either a local file or a remote public URL resource. - -> *Refer to the [documentation](https://top-maker.github.io/modpoll/configure.html) site for more details.* - -CSV validation requires a unique device name, a device ID from 1 to 254, a positive poll size within the Modbus function-code limit, and an address range that stays within 0–65535. Reference data types, read/write modes, endian values, and scales must be valid; errors include the CSV line number. A scale of `0` is valid; non-finite scales are rejected. Non-finite floating-point samples are treated as invalid and omitted from MQTT output. Device names must also be unique across all configuration files; duplicate names stop startup. Rename conflicting devices and update their MQTT topics before upgrading. - -Read polls support up to 2000 bits or 125 registers. `stringN` reads exactly N bytes and occupies `(N + 1) // 2` registers. The `address:bit` syntax applies to `bool` references on register polls. +### Prepare a WoT configuration -When `--autoremove` is enabled, a poller is disabled after three consecutive failed polling attempts, including connection failures. A successful poll resets its failure count. Restart modpoll to retry a disabled poller; the default is to keep retrying without disabling pollers. +Use [examples/modsim.tm.json](examples/modsim.tm.json) for the simulator model or +[examples/modsim.td.json](examples/modsim.td.json) for a concrete TCP description. +Both preserve the four original blocks and 37 output references. `--unit-id` +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()