From 0f44bfd68fd06a13181bb308dc5de0190b104941 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Wed, 22 Apr 2026 13:44:57 +0800 Subject: [PATCH 1/2] feat: add Python pip-publishable SDK for CSM-TCP-Router (#31) * feat: add Python pip-publishable SDK for CSM-TCP-Router Agent-Logs-Url: https://github.com/NEVSTOP-LAB/CSM-TCP-Router-App/sessions/4a1ee665-7464-4bd0-8898-0725daef43d5 Co-authored-by: nevstop <8196752+nevstop@users.noreply.github.com> * fix: add least-privilege permissions to CI workflow jobs Agent-Logs-Url: https://github.com/NEVSTOP-LAB/CSM-TCP-Router-App/sessions/4a1ee665-7464-4bd0-8898-0725daef43d5 Co-authored-by: nevstop <8196752+nevstop@users.noreply.github.com> * feat: add asyncio client, Chinese README, and TestPyPI CI stage (v0.2.0) Agent-Logs-Url: https://github.com/NEVSTOP-LAB/CSM-TCP-Router-App/sessions/93afe5c4-c917-4b9b-a347-189efb3bf4db Co-authored-by: nevstop <8196752+nevstop@users.noreply.github.com> * fix: address all PR review comments (shared _errors, socket leak, locks, disconnect sentinels, isawaitable, changelog) Agent-Logs-Url: https://github.com/NEVSTOP-LAB/CSM-TCP-Router-App/sessions/b0917f8e-c50c-4a63-ae30-a1c245a6d3d7 Co-authored-by: nevstop <8196752+nevstop@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: nevstop <8196752+nevstop@users.noreply.github.com> --- .github/workflows/Python_SDK.yml | 157 ++++++ SDK/python-package/CHANGELOG.md | 84 +++ SDK/python-package/LICENSE | 21 + SDK/python-package/README.md | 311 +++++++++++ SDK/python-package/README.zh-cn.md | 308 +++++++++++ SDK/python-package/examples/async_usage.py | 97 ++++ SDK/python-package/examples/basic_usage.py | 100 ++++ .../examples/subscribe_status.py | 86 +++ SDK/python-package/pyproject.toml | 85 +++ .../src/csm_tcp_router/__init__.py | 58 ++ .../src/csm_tcp_router/_errors.py | 26 + .../src/csm_tcp_router/_protocol.py | 91 +++ .../src/csm_tcp_router/_transport.py | 176 ++++++ .../src/csm_tcp_router/async_client.py | 523 ++++++++++++++++++ .../src/csm_tcp_router/client.py | 456 +++++++++++++++ .../src/csm_tcp_router/exceptions.py | 45 ++ .../src/csm_tcp_router/models.py | 151 +++++ SDK/python-package/tests/__init__.py | 1 + SDK/python-package/tests/conftest.py | 211 +++++++ SDK/python-package/tests/test_async_client.py | 455 +++++++++++++++ SDK/python-package/tests/test_client.py | 302 ++++++++++ SDK/python-package/tests/test_integration.py | 220 ++++++++ SDK/python-package/tests/test_protocol.py | 154 ++++++ 23 files changed, 4118 insertions(+) create mode 100644 .github/workflows/Python_SDK.yml create mode 100644 SDK/python-package/CHANGELOG.md create mode 100644 SDK/python-package/LICENSE create mode 100644 SDK/python-package/README.md create mode 100644 SDK/python-package/README.zh-cn.md create mode 100644 SDK/python-package/examples/async_usage.py create mode 100644 SDK/python-package/examples/basic_usage.py create mode 100644 SDK/python-package/examples/subscribe_status.py create mode 100644 SDK/python-package/pyproject.toml create mode 100644 SDK/python-package/src/csm_tcp_router/__init__.py create mode 100644 SDK/python-package/src/csm_tcp_router/_errors.py create mode 100644 SDK/python-package/src/csm_tcp_router/_protocol.py create mode 100644 SDK/python-package/src/csm_tcp_router/_transport.py create mode 100644 SDK/python-package/src/csm_tcp_router/async_client.py create mode 100644 SDK/python-package/src/csm_tcp_router/client.py create mode 100644 SDK/python-package/src/csm_tcp_router/exceptions.py create mode 100644 SDK/python-package/src/csm_tcp_router/models.py create mode 100644 SDK/python-package/tests/__init__.py create mode 100644 SDK/python-package/tests/conftest.py create mode 100644 SDK/python-package/tests/test_async_client.py create mode 100644 SDK/python-package/tests/test_client.py create mode 100644 SDK/python-package/tests/test_integration.py create mode 100644 SDK/python-package/tests/test_protocol.py diff --git a/.github/workflows/Python_SDK.yml b/.github/workflows/Python_SDK.yml new file mode 100644 index 0000000..5711af5 --- /dev/null +++ b/.github/workflows/Python_SDK.yml @@ -0,0 +1,157 @@ +name: Python SDK + +on: + push: + paths: + - 'SDK/python-package/**' + tags: + - 'python-sdk-v*' + pull_request: + paths: + - 'SDK/python-package/**' + workflow_dispatch: + +defaults: + run: + working-directory: SDK/python-package + +jobs: + # ------------------------------------------------------------------------- + # Lint + # ------------------------------------------------------------------------- + lint: + name: Lint (ruff) + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install ruff + run: pip install ruff + + - name: Run ruff + run: ruff check src/ tests/ examples/ + + # ------------------------------------------------------------------------- + # Test matrix + # ------------------------------------------------------------------------- + test: + name: Test (Python ${{ matrix.python-version }}) + runs-on: ubuntu-latest + needs: lint + permissions: + contents: read + strategy: + fail-fast: false + matrix: + python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install package and test dependencies + run: | + python -m pip install --upgrade pip + pip install -e . + pip install pytest pytest-cov pytest-asyncio + + - name: Run tests + run: pytest --cov=csm_tcp_router --cov-report=term-missing + + # ------------------------------------------------------------------------- + # Build (wheel + sdist) + # ------------------------------------------------------------------------- + build: + name: Build distribution + runs-on: ubuntu-latest + needs: test + permissions: + contents: read + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install build tools + run: pip install build + + - name: Build wheel and sdist + run: python -m build + + - name: Verify wheel is importable + run: | + pip install dist/*.whl + python -c "import csm_tcp_router; print('Version:', csm_tcp_router.__version__)" + + - name: Upload build artifacts + uses: actions/upload-artifact@v4 + with: + name: python-sdk-dist + path: SDK/python-package/dist/ + + # ------------------------------------------------------------------------- + # Publish to TestPyPI (on tag push only – gates production publish) + # ------------------------------------------------------------------------- + publish-testpypi: + name: Publish to TestPyPI + runs-on: ubuntu-latest + needs: build + if: startsWith(github.ref, 'refs/tags/python-sdk-v') + environment: + name: testpypi + url: https://test.pypi.org/project/csm-tcp-router-client/ + + permissions: + id-token: write # required for OIDC trusted publishing + + steps: + - name: Download build artifacts + uses: actions/download-artifact@v4 + with: + name: python-sdk-dist + path: dist/ + + - name: Publish to TestPyPI + uses: pypa/gh-action-pypi-publish@release/v1 + with: + repository-url: https://test.pypi.org/legacy/ + + # ------------------------------------------------------------------------- + # Publish to PyPI (on tag push only – after TestPyPI succeeds) + # ------------------------------------------------------------------------- + publish: + name: Publish to PyPI + runs-on: ubuntu-latest + needs: publish-testpypi + if: startsWith(github.ref, 'refs/tags/python-sdk-v') + environment: + name: pypi + url: https://pypi.org/project/csm-tcp-router-client/ + + permissions: + id-token: write # required for trusted publishing (OIDC) + + steps: + - name: Download build artifacts + uses: actions/download-artifact@v4 + with: + name: python-sdk-dist + path: dist/ + + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/SDK/python-package/CHANGELOG.md b/SDK/python-package/CHANGELOG.md new file mode 100644 index 0000000..a69cd48 --- /dev/null +++ b/SDK/python-package/CHANGELOG.md @@ -0,0 +1,84 @@ +# Changelog + +All notable changes to `csm-tcp-router-client` are documented here. + +The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). +This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +--- + +## [Unreleased] + +--- + +## [0.2.0] – 2026-04-22 + +### Added + +- `AsyncTcpRouterClient` class: full asyncio API mirroring every method of + `TcpRouterClient`, using `asyncio.StreamReader`/`StreamWriter` and + `asyncio.Queue` for non-blocking I/O. +- Async context-manager support: `async with AsyncTcpRouterClient() as client:`. +- Both sync and `async def` callbacks supported for `subscribe_status()` and + `register_async_callback()` on the async client. +- `AsyncTcpRouterClient` exported from the top-level `csm_tcp_router` package. +- `examples/async_usage.py` – asyncio quickstart demonstrating all features. +- Test suite extended with `tests/test_async_client.py` (48 tests: unit + + integration via `MockServer`); test runner now uses `asyncio_mode = "auto"`. +- `pytest-asyncio` added to CI test dependencies. +- Chinese documentation: `README.zh-cn.md` (full translation of `README.md`). +- `README.md` updated with asyncio quickstart, async API reference table, link + to Chinese docs, and `async_usage.py` in the examples list. +- CI: added `publish-testpypi` job that publishes to TestPyPI *before* + `publish` (production PyPI); production publish now depends on TestPyPI + success; both use OIDC trusted publishing. +- `Framework :: AsyncIO` classifier added to package metadata. + +### Changed + +- Package version bumped to `0.2.0`. +- `asyncio_mode = "auto"` added to `pyproject.toml` pytest options; all async + tests run automatically without explicit `@pytest.mark.asyncio` decorators. + +--- + +## [0.1.0] – 2026-04-22 + +### Added + +- Initial release of the `csm-tcp-router-client` Python SDK. +- `TcpRouterClient` class with full thread-safe implementation of the + CSM-TCP-Router protocol v0. +- Connection lifecycle: `connect()`, `disconnect()`, `wait_for_server()`, + `connected` property, context-manager support. +- Synchronous command: `send_and_wait()`. +- Asynchronous command: `post()` with `CMD_RESP` handshake. +- No-reply async command: `post_no_reply()` with `CMD_RESP` handshake. +- Round-trip ping: `ping()`. +- Router management helpers: `list_modules()`, `list_api()`, `list_states()`, + `help()`. +- Status / interrupt subscriptions: `subscribe_status()`, + `unsubscribe_status()`, `register_async_callback()`, + `unregister_async_callback()`. +- Polling queues: `status_queue`, `async_response_queue`. +- Typed exception hierarchy: `TcpRouterError`, `ConnectionError`, + `TimeoutError`, `ProtocolError`, `ServerError` (with `.code` and `.message`). +- Public data models: `PacketType`, `Packet`, `CommandResponse`, + `AsyncResponse`, `StatusNotification`. +- Internal protocol v0 codec (`_protocol.py`) with `encode_packet()`, + `decode_header()`, `parse_packet()`; unknown packet types mapped to + `INFO` for forward compatibility. +- Internal TCP transport layer (`_transport.py`) with background daemon + receive thread, `memoryview`-based zero-copy reads, and clean shutdown. +- Comprehensive test suite: unit tests for protocol codec, unit tests for + client dispatch logic (mock transport), and integration tests against a + `MockServer` fixture. +- Examples: `basic_usage.py`, `subscribe_status.py`. +- `pyproject.toml` with `hatchling` build backend; ready for `pip install` + and upload to PyPI. +- GitHub Actions workflow `Python_SDK.yml`: lint (ruff), test (pytest) on + Python 3.8–3.12, build, and optional publish to PyPI on tag. + +[Unreleased]: https://github.com/NEVSTOP-LAB/CSM-TCP-Router-App/compare/python-sdk-v0.2.0...HEAD +[0.2.0]: https://github.com/NEVSTOP-LAB/CSM-TCP-Router-App/compare/python-sdk-v0.1.0...python-sdk-v0.2.0 +[0.1.0]: https://github.com/NEVSTOP-LAB/CSM-TCP-Router-App/releases/tag/python-sdk-v0.1.0 diff --git a/SDK/python-package/LICENSE b/SDK/python-package/LICENSE new file mode 100644 index 0000000..78e1cd2 --- /dev/null +++ b/SDK/python-package/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 NEVSTOP-LAB + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/SDK/python-package/README.md b/SDK/python-package/README.md new file mode 100644 index 0000000..1460803 --- /dev/null +++ b/SDK/python-package/README.md @@ -0,0 +1,311 @@ +# csm-tcp-router-client + +[![PyPI](https://img.shields.io/pypi/v/csm-tcp-router-client)](https://pypi.org/project/csm-tcp-router-client/) +[![Python](https://img.shields.io/pypi/pyversions/csm-tcp-router-client)](https://pypi.org/project/csm-tcp-router-client/) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) +[![CI](https://github.com/NEVSTOP-LAB/CSM-TCP-Router-App/actions/workflows/Python_SDK.yml/badge.svg)](https://github.com/NEVSTOP-LAB/CSM-TCP-Router-App/actions/workflows/Python_SDK.yml) + +Python client SDK for the [CSM-TCP-Router](https://github.com/NEVSTOP-LAB/CSM-TCP-Router-App) LabVIEW server. + +CSM-TCP-Router exposes a LabVIEW [Communicable State Machine (CSM)](https://github.com/NEVSTOP-LAB/Communicable-State-Machine) application over TCP so that any TCP client—including Python scripts, test harnesses, or CI pipelines—can send commands and receive responses without touching the LabVIEW code. + +> 📖 [中文文档 README.zh-cn.md](README.zh-cn.md) + +--- + +## Installation + +```bash +pip install csm-tcp-router-client +``` + +Requires Python 3.8 or later. No third-party dependencies—only the Python standard library. + +--- + +## Quickstart + +### Synchronous client + +```python +from csm_tcp_router import TcpRouterClient + +with TcpRouterClient() as client: + client.connect("localhost", 30007) + + # List all loaded CSM modules + print(client.list_modules()) + + # Send a synchronous command and wait for the response + resp = client.send_and_wait("API: Read -@ DAQmx") + print(resp.text) + + # Ping the server + ok, elapsed_s = client.ping() + print(f"Ping: {ok}, latency={elapsed_s*1000:.1f} ms") +``` + +### Asyncio client + +```python +import asyncio +from csm_tcp_router import AsyncTcpRouterClient + +async def main(): + async with AsyncTcpRouterClient() as client: + await client.connect("localhost", 30007) + print(await client.list_modules()) + resp = await client.send_and_wait("API: Read -@ DAQmx") + print(resp.text) + +asyncio.run(main()) +``` + +--- + +## Features + +- **Synchronous commands** (`-@`) – `send_and_wait()` blocks until the server returns the response. +- **Asynchronous commands** (`->`) – `post()` waits for the `cmd-resp` handshake; the eventual response is delivered via callback or queue. +- **No-reply commands** (`->|`) – `post_no_reply()` waits for the `cmd-resp` handshake; no further response expected. +- **Status subscriptions** – `subscribe_status()` / `unsubscribe_status()` with optional callback or polling queue. +- **Router management helpers** – `list_modules()`, `list_api()`, `list_states()`, `help()`. +- **Connection utilities** – `wait_for_server()` for polling during app startup. +- **Thread-safe sync client** – `TcpRouterClient`: all methods may be called from multiple threads concurrently. +- **Asyncio client** – `AsyncTcpRouterClient`: full `async def` API with both sync and async callbacks supported. +- **Zero dependencies** – pure Python standard library. +- **Context manager** support (`with TcpRouterClient()` / `async with AsyncTcpRouterClient()`). + +--- + +## Protocol + +The SDK implements the CSM-TCP-Router **protocol v0**. + +``` +| Data Length (4B) | Version (1B) | TYPE (1B) | FLAG1 (1B) | FLAG2 (1B) | Text Data | +╰────────────────────────── Header (8B) ─────────────────────────────╯ +``` + +| TYPE byte | Name | Direction | Description | +|-----------|---------------|----------------|------------------------------------------------| +| `0x00` | `INFO` | Server → Client| Welcome / goodbye informational message | +| `0x01` | `ERROR` | Server → Client| CSM error: `[Error: ] ` | +| `0x02` | `CMD` | Client → Server| Command string | +| `0x03` | `CMD_RESP` | Server → Client| Handshake ACK for async / subscribe commands | +| `0x04` | `RESP` | Server → Client| Synchronous response payload | +| `0x05` | `ASYNC_RESP` | Server → Client| Async response: ` <- ` | +| `0x06` | `STATUS` | Server → Client| Status broadcast: ` >> <- ` | +| `0x07` | `INTERRUPT` | Server → Client| Interrupt broadcast (same format as STATUS) | + +### Communication flows + +**Synchronous (`-@`)** + +``` +Client ─── CMD ──────────────────► Server +Client ◄── RESP (or ERROR) ─────── Server +``` + +**Asynchronous (`->`)** + +``` +Client ─── CMD ──────────────────► Server +Client ◄── CMD_RESP (or ERROR) ─── Server ← handshake +Client ◄── ASYNC_RESP ──────────── Server ← later, async result +``` + +**No-reply (`->|`)** + +``` +Client ─── CMD ──────────────────► Server +Client ◄── CMD_RESP (or ERROR) ─── Server ← handshake; no further reply +``` + +**Subscribe / unsubscribe** + +``` +Client ─── CMD () ─────► Server +Client ◄── CMD_RESP (or ERROR) ─── Server + … (whenever the CSM module emits the status) … +Client ◄── STATUS ──────────────── Server +Client ─── CMD () ───► Server +Client ◄── CMD_RESP ─────────────── Server +``` + +--- + +## API Reference + +### `TcpRouterClient` (sync) + +#### Connection + +| Method | Description | +|---|---| +| `connect(host, port, timeout=5.0)` | Connect to the server; raises `ConnectionError` on failure. | +| `disconnect()` | Close the connection; safe to call even when not connected. | +| `connected` | `True` when the transport is connected. | +| `wait_for_server(host, port, timeout=30, retry_interval=0.5)` | Poll until the server is reachable; returns `True`/`False`. | + +#### Commands + +| Method | Description | +|---|---| +| `send_and_wait(command, timeout=5.0) → CommandResponse` | Synchronous command (`-@`); blocks until `RESP` arrives. | +| `post(command, timeout=5.0)` | Async command (`->`); waits for `CMD_RESP` handshake. | +| `post_no_reply(command, timeout=5.0)` | No-reply command (`->|`); waits for `CMD_RESP` handshake. | +| `ping(timeout=2.0) → (bool, float)` | Round-trip latency check. | + +#### Router management helpers + +| Method | Description | +|---|---| +| `list_modules(timeout=5.0) → str` | `List` command result. | +| `list_api(module, timeout=5.0) → str` | `List API ` result. | +| `list_states(module, timeout=5.0) → str` | `List State ` result. | +| `help(module, timeout=5.0) → str` | `Help ` result. | + +#### Subscriptions + +| Method | Description | +|---|---| +| `subscribe_status(status_name, module_name, callback=None, timeout=5.0)` | Subscribe; optional callback invoked per notification. | +| `unsubscribe_status(status_name, module_name, timeout=5.0)` | Unsubscribe. | +| `register_async_callback(original_command, callback)` | Register a callback for `ASYNC_RESP` packets. | +| `unregister_async_callback(original_command)` | Remove an async callback. | + +#### Queues (polling alternative to callbacks) + +| Attribute | Type | Description | +|---|---|---| +| `status_queue` | `Queue[StatusNotification]` | Receive status/interrupt broadcasts by polling. | +| `async_response_queue` | `Queue[AsyncResponse]` | Receive async responses by polling. | + +--- + +### `AsyncTcpRouterClient` (asyncio) + +All methods are `async def` coroutines; use `await` to call them. + +#### Connection + +| Method | Description | +|---|---| +| `await connect(host, port, timeout=5.0)` | Open a TCP connection; raises `ConnectionError` on failure. | +| `await disconnect()` | Close the connection; safe to call when not connected. | +| `connected` | `True` when the writer is open. | +| `await wait_for_server(host, port, timeout=30, retry_interval=0.5)` | Poll until the server is reachable. | + +#### Commands + +| Method | Description | +|---|---| +| `await send_and_wait(command, timeout=5.0) → CommandResponse` | Synchronous command (`-@`). | +| `await post(command, timeout=5.0)` | Async command (`->`). | +| `await post_no_reply(command, timeout=5.0)` | No-reply command (`->|`). | +| `await ping(timeout=2.0) → (bool, float)` | Round-trip latency check. | + +#### Router management helpers + +Same as sync client but all methods are `async def`. + +#### Subscriptions + +| Method | Description | +|---|---| +| `await subscribe_status(status_name, module_name, callback=None, timeout=5.0)` | Subscribe; callback may be sync or `async def`. | +| `await unsubscribe_status(status_name, module_name, timeout=5.0)` | Unsubscribe. | +| `register_async_callback(original_command, callback)` | Register callback for `ASYNC_RESP`; may be sync or `async def`. | +| `unregister_async_callback(original_command)` | Remove callback. | + +#### Queues + +| Attribute | Type | Description | +|---|---|---| +| `status_queue` | `asyncio.Queue[StatusNotification]` | Available after `connect()`; poll with `await queue.get()`. | +| `async_response_queue` | `asyncio.Queue[AsyncResponse]` | Available after `connect()`. | + +--- + +### Data models + +#### `CommandResponse` +- `.raw: bytes` – raw server payload +- `.text: str` – UTF-8 decoded text + +#### `AsyncResponse` +- `.raw: bytes`, `.text: str` +- `.original_command: str` – the command echoed by the server + +#### `StatusNotification` +- `.raw: bytes` +- `.packet_type: PacketType` – `STATUS` or `INTERRUPT` +- `.status_name: str` – e.g. `"Status"` +- `.data: str` – the broadcasted value +- `.module_name: str` – the sending CSM module + +### Exceptions + +| Exception | Raised when | +|---|---| +| `TcpRouterError` | Base class for all SDK exceptions | +| `ConnectionError` | TCP connection fails or is lost | +| `TimeoutError` | No response within the timeout window | +| `ProtocolError` | Invalid or unexpected wire frame | +| `ServerError` | Server returns an `ERROR` packet; `.code` and `.message` attributes available | + +--- + +## Examples + +See the [`examples/`](examples/) directory: + +- [`basic_usage.py`](examples/basic_usage.py) – sync client: connect, ping, list modules, send commands. +- [`subscribe_status.py`](examples/subscribe_status.py) – sync client: real-time status subscription with callback. +- [`async_usage.py`](examples/async_usage.py) – asyncio client: all features using `async def` / `await`. + +--- + +## Migration from the script SDK + +The previous single-file SDK (`SDK/PythonClientAPI/tcp_router_client.py`) is +still available but is not pip-installable and uses a different packet-type +numbering (aligned with protocol v1-draft rather than the published v0 spec). + +| Old method | New method | Notes | +|---|---|---| +| `connect()` | `connect()` | Returns `None`; raises `ConnectionError` instead of returning `False` | +| `disconnect()` | `disconnect()` | Unchanged | +| `send_message_and_wait_for_reply(msg)` | `send_and_wait(cmd)` | Returns `CommandResponse`; raises on error | +| `post_message(msg)` | `post(cmd)` | Waits for `CMD_RESP` handshake | +| `post_no_rep_message(msg)` | `post_no_reply(cmd)` | Waits for `CMD_RESP` handshake | +| `ping()` | `ping()` | Same signature | +| `register_status_change(s, m, cb)` | `subscribe_status(s, m, callback=cb)` | Raises on error instead of returning `False` | +| `unregister_status_change(s, m)` | `unsubscribe_status(s, m)` | Raises on error | +| `wait_for_server(h, p, t)` | `wait_for_server(h, p, timeout=t)` | Keyword arg | +| `obtain()` / `release()` | Use context manager `with TcpRouterClient() as c:` | – | + +--- + +## Development + +```bash +# Install dev dependencies +pip install -e ".[dev]" +# or +pip install hatchling pytest pytest-asyncio ruff + +# Run tests (sync + async) +pytest + +# Lint +ruff check src/ tests/ +``` + +--- + +## License + +[MIT](LICENSE) — © NEVSTOP-LAB + diff --git a/SDK/python-package/README.zh-cn.md b/SDK/python-package/README.zh-cn.md new file mode 100644 index 0000000..49f3ef5 --- /dev/null +++ b/SDK/python-package/README.zh-cn.md @@ -0,0 +1,308 @@ +# csm-tcp-router-client + +[![PyPI](https://img.shields.io/pypi/v/csm-tcp-router-client)](https://pypi.org/project/csm-tcp-router-client/) +[![Python](https://img.shields.io/pypi/pyversions/csm-tcp-router-client)](https://pypi.org/project/csm-tcp-router-client/) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) +[![CI](https://github.com/NEVSTOP-LAB/CSM-TCP-Router-App/actions/workflows/Python_SDK.yml/badge.svg)](https://github.com/NEVSTOP-LAB/CSM-TCP-Router-App/actions/workflows/Python_SDK.yml) + +[CSM-TCP-Router](https://github.com/NEVSTOP-LAB/CSM-TCP-Router-App) LabVIEW 服务器的 Python 客户端 SDK。 + +CSM-TCP-Router 将 LabVIEW [可通信状态机(CSM)](https://github.com/NEVSTOP-LAB/Communicable-State-Machine) 应用通过 TCP 对外暴露,使任意 TCP 客户端(Python 脚本、测试框架、CI 流水线等)无需修改 LabVIEW 代码即可发送指令并接收响应。 + +> 📖 [English README](README.md) + +--- + +## 安装 + +```bash +pip install csm-tcp-router-client +``` + +要求 Python 3.8 或更高版本,无第三方依赖——仅依赖 Python 标准库。 + +--- + +## 快速入门 + +### 同步客户端 + +```python +from csm_tcp_router import TcpRouterClient + +with TcpRouterClient() as client: + client.connect("localhost", 30007) + + # 获取已加载的 CSM 模块列表 + print(client.list_modules()) + + # 发送同步指令并等待响应 + resp = client.send_and_wait("API: Read -@ DAQmx") + print(resp.text) + + # Ping 服务器 + ok, elapsed_s = client.ping() + print(f"Ping: {ok}, 延迟={elapsed_s*1000:.1f} ms") +``` + +### 异步客户端(asyncio) + +```python +import asyncio +from csm_tcp_router import AsyncTcpRouterClient + +async def main(): + async with AsyncTcpRouterClient() as client: + await client.connect("localhost", 30007) + print(await client.list_modules()) + resp = await client.send_and_wait("API: Read -@ DAQmx") + print(resp.text) + +asyncio.run(main()) +``` + +--- + +## 功能特性 + +- **同步指令**(`-@`)——`send_and_wait()` 阻塞直到服务器返回响应。 +- **异步指令**(`->`)——`post()` 等待 `cmd-resp` 握手包;最终响应通过回调或队列传递。 +- **无响应指令**(`->|`)——`post_no_reply()` 等待 `cmd-resp` 握手包;不再有后续响应。 +- **状态订阅**——`subscribe_status()` / `unsubscribe_status()`,支持可选回调或轮询队列。 +- **路由器管理助手**——`list_modules()`、`list_api()`、`list_states()`、`help()`。 +- **连接工具**——`wait_for_server()` 在应用启动期间轮询等待服务器就绪。 +- **线程安全的同步客户端**——`TcpRouterClient`:所有方法均可从多个线程并发调用。 +- **异步客户端**——`AsyncTcpRouterClient`:完整的 `async def` API,支持同步和异步回调。 +- **零第三方依赖**——纯 Python 标准库实现。 +- **上下文管理器**支持(`with TcpRouterClient()` / `async with AsyncTcpRouterClient()`)。 + +--- + +## 通信协议 + +本 SDK 实现了 CSM-TCP-Router **v0 协议**。 + +``` +| 数据长度 (4B) | 版本 (1B) | TYPE (1B) | FLAG1 (1B) | FLAG2 (1B) | 文本数据 | +╰────────────────────────── 头部 (8B) ────────────────────────────────╯ +``` + +| TYPE 字节 | 名称 | 方向 | 描述 | +|-----------|---------------|----------------|---------------------------------------------------| +| `0x00` | `INFO` | 服务器 → 客户端 | 欢迎 / 再见等信息报文 | +| `0x01` | `ERROR` | 服务器 → 客户端 | CSM 错误:`[Error: ] ` | +| `0x02` | `CMD` | 客户端 → 服务器 | 指令字符串 | +| `0x03` | `CMD_RESP` | 服务器 → 客户端 | 异步 / 订阅指令的握手确认包 | +| `0x04` | `RESP` | 服务器 → 客户端 | 同步响应负载 | +| `0x05` | `ASYNC_RESP` | 服务器 → 客户端 | 异步响应:`<数据> <- <原始指令>` | +| `0x06` | `STATUS` | 服务器 → 客户端 | 状态广播:`<名称> >> <数据> <- <模块>` | +| `0x07` | `INTERRUPT` | 服务器 → 客户端 | 中断广播(格式与 STATUS 相同) | + +### 通信流程 + +**同步(`-@`)** + +``` +客户端 ─── CMD ──────────────────► 服务器 +客户端 ◄── RESP(或 ERROR)─────── 服务器 +``` + +**异步(`->`)** + +``` +客户端 ─── CMD ──────────────────► 服务器 +客户端 ◄── CMD_RESP(或 ERROR)─── 服务器 ← 握手 +客户端 ◄── ASYNC_RESP ──────────── 服务器 ← 稍后,异步结果 +``` + +**无响应(`->|`)** + +``` +客户端 ─── CMD ──────────────────► 服务器 +客户端 ◄── CMD_RESP(或 ERROR)─── 服务器 ← 握手;无后续响应 +``` + +**订阅 / 取消订阅** + +``` +客户端 ─── CMD () ─────► 服务器 +客户端 ◄── CMD_RESP(或 ERROR)─── 服务器 + …(CSM 模块每次发出状态时)… +客户端 ◄── STATUS ──────────────── 服务器 +客户端 ─── CMD () ───► 服务器 +客户端 ◄── CMD_RESP ─────────────── 服务器 +``` + +--- + +## API 参考 + +### `TcpRouterClient`(同步) + +#### 连接管理 + +| 方法 | 描述 | +|---|---| +| `connect(host, port, timeout=5.0)` | 连接服务器;失败时抛出 `ConnectionError`。 | +| `disconnect()` | 关闭连接;即使未连接也可安全调用。 | +| `connected` | 已连接时为 `True`。 | +| `wait_for_server(host, port, timeout=30, retry_interval=0.5)` | 轮询直到服务器可达;返回 `True`/`False`。 | + +#### 指令方法 + +| 方法 | 描述 | +|---|---| +| `send_and_wait(command, timeout=5.0) → CommandResponse` | 同步指令(`-@`);阻塞直到 `RESP` 到达。 | +| `post(command, timeout=5.0)` | 异步指令(`->`);等待 `CMD_RESP` 握手。 | +| `post_no_reply(command, timeout=5.0)` | 无响应指令(`->|`);等待 `CMD_RESP` 握手。 | +| `ping(timeout=2.0) → (bool, float)` | 往返延迟检测。 | + +#### 路由器管理助手 + +| 方法 | 描述 | +|---|---| +| `list_modules(timeout=5.0) → str` | 执行 `List` 指令,返回模块列表。 | +| `list_api(module, timeout=5.0) → str` | 执行 `List API ` 指令。 | +| `list_states(module, timeout=5.0) → str` | 执行 `List State ` 指令。 | +| `help(module, timeout=5.0) → str` | 执行 `Help ` 指令。 | + +#### 订阅管理 + +| 方法 | 描述 | +|---|---| +| `subscribe_status(status_name, module_name, callback=None, timeout=5.0)` | 订阅;可选回调,每次收到通知时调用。 | +| `unsubscribe_status(status_name, module_name, timeout=5.0)` | 取消订阅。 | +| `register_async_callback(original_command, callback)` | 注册 `ASYNC_RESP` 回调。 | +| `unregister_async_callback(original_command)` | 移除异步响应回调。 | + +#### 轮询队列(回调的替代方案) + +| 属性 | 类型 | 描述 | +|---|---|---| +| `status_queue` | `Queue[StatusNotification]` | 通过轮询接收状态/中断广播。 | +| `async_response_queue` | `Queue[AsyncResponse]` | 通过轮询接收异步响应。 | + +--- + +### `AsyncTcpRouterClient`(asyncio) + +所有方法均为 `async def` 协程,需使用 `await` 调用。 + +#### 连接管理 + +| 方法 | 描述 | +|---|---| +| `await connect(host, port, timeout=5.0)` | 建立 TCP 连接;失败时抛出 `ConnectionError`。 | +| `await disconnect()` | 关闭连接;未连接时可安全调用。 | +| `connected` | 写入端开启时为 `True`。 | +| `await wait_for_server(host, port, timeout=30, retry_interval=0.5)` | 轮询直到服务器可达。 | + +#### 指令方法 + +| 方法 | 描述 | +|---|---| +| `await send_and_wait(command, timeout=5.0) → CommandResponse` | 同步指令(`-@`)。 | +| `await post(command, timeout=5.0)` | 异步指令(`->`)。 | +| `await post_no_reply(command, timeout=5.0)` | 无响应指令(`->|`)。 | +| `await ping(timeout=2.0) → (bool, float)` | 往返延迟检测。 | + +#### 路由器管理助手 + +与同步客户端相同,但所有方法均为 `async def`。 + +#### 订阅管理 + +| 方法 | 描述 | +|---|---| +| `await subscribe_status(status_name, module_name, callback=None, timeout=5.0)` | 订阅;回调可以是普通函数或 `async def` 协程。 | +| `await unsubscribe_status(status_name, module_name, timeout=5.0)` | 取消订阅。 | +| `register_async_callback(original_command, callback)` | 注册 `ASYNC_RESP` 回调;可以是普通函数或 `async def`。 | +| `unregister_async_callback(original_command)` | 移除回调。 | + +#### 轮询队列 + +| 属性 | 类型 | 描述 | +|---|---|---| +| `status_queue` | `asyncio.Queue[StatusNotification]` | `connect()` 后可用;使用 `await queue.get()` 轮询。 | +| `async_response_queue` | `asyncio.Queue[AsyncResponse]` | `connect()` 后可用。 | + +--- + +### 数据模型 + +#### `CommandResponse` +- `.raw: bytes` – 原始服务器负载 +- `.text: str` – UTF-8 解码后的文本 + +#### `AsyncResponse` +- `.raw: bytes`, `.text: str` +- `.original_command: str` – 服务器回显的原始指令 + +#### `StatusNotification` +- `.raw: bytes` +- `.packet_type: PacketType` – `STATUS` 或 `INTERRUPT` +- `.status_name: str` – 例如 `"Status"` +- `.data: str` – 广播的值 +- `.module_name: str` – 发送该状态的 CSM 模块名称 + +### 异常 + +| 异常 | 触发场景 | +|---|---| +| `TcpRouterError` | 所有 SDK 异常的基类 | +| `ConnectionError` | TCP 连接失败或断开 | +| `TimeoutError` | 在超时时间内未收到响应 | +| `ProtocolError` | 无效或意外的数据帧 | +| `ServerError` | 服务器返回 `ERROR` 包;可通过 `.code` 和 `.message` 属性获取错误详情 | + +--- + +## 示例 + +详见 [`examples/`](examples/) 目录: + +- [`basic_usage.py`](examples/basic_usage.py) – 同步客户端:连接、Ping、列出模块、发送指令。 +- [`subscribe_status.py`](examples/subscribe_status.py) – 同步客户端:通过回调实时接收状态订阅。 +- [`async_usage.py`](examples/async_usage.py) – 异步客户端:使用 `async def` / `await` 实现所有功能。 + +--- + +## 从旧版脚本 SDK 迁移 + +原有的单文件 SDK(`SDK/PythonClientAPI/tcp_router_client.py`)仍然可用,但无法通过 pip 安装,且其数据包类型编号采用的是 v1 草稿协议,而非已发布的 v0 规范。 + +| 旧方法 | 新方法 | 备注 | +|---|---|---| +| `connect()` | `connect()` | 返回 `None`;失败时抛出 `ConnectionError` 而非返回 `False` | +| `disconnect()` | `disconnect()` | 无变化 | +| `send_message_and_wait_for_reply(msg)` | `send_and_wait(cmd)` | 返回 `CommandResponse`;出错时抛出异常 | +| `post_message(msg)` | `post(cmd)` | 等待 `CMD_RESP` 握手 | +| `post_no_rep_message(msg)` | `post_no_reply(cmd)` | 等待 `CMD_RESP` 握手 | +| `ping()` | `ping()` | 签名不变 | +| `register_status_change(s, m, cb)` | `subscribe_status(s, m, callback=cb)` | 失败时抛出异常而非返回 `False` | +| `unregister_status_change(s, m)` | `unsubscribe_status(s, m)` | 失败时抛出异常 | +| `wait_for_server(h, p, t)` | `wait_for_server(h, p, timeout=t)` | 改为关键字参数 | +| `obtain()` / `release()` | 使用上下文管理器 `with TcpRouterClient() as c:` | — | + +--- + +## 开发 + +```bash +# 安装开发依赖 +pip install -e ".[dev]" +# 或 +pip install hatchling pytest pytest-asyncio ruff + +# 运行测试(同步 + 异步) +pytest + +# 代码检查 +ruff check src/ tests/ +``` + +--- + +## 许可证 + +[MIT](LICENSE) — © NEVSTOP-LAB diff --git a/SDK/python-package/examples/async_usage.py b/SDK/python-package/examples/async_usage.py new file mode 100644 index 0000000..226023c --- /dev/null +++ b/SDK/python-package/examples/async_usage.py @@ -0,0 +1,97 @@ +"""Async quickstart example for csm-tcp-router-client. + +Run against a live CSM-TCP-Router server:: + + pip install csm-tcp-router-client + python examples/async_usage.py +""" + +import asyncio + +from csm_tcp_router import AsyncTcpRouterClient +from csm_tcp_router.models import StatusNotification + + +async def on_status(notif: StatusNotification) -> None: + """Async callback – invoked each time the subscribed status changes.""" + print(f"[async callback] {notif.module_name}/{notif.status_name} = {notif.data!r}") + + +async def main() -> None: + # --------------------------------------------------------------------------- + # Basic connection + # --------------------------------------------------------------------------- + async with AsyncTcpRouterClient() as client: + # Optional: wait until the server is available (e.g. during app startup) + print("Waiting for server …", end=" ", flush=True) + ok = await client.wait_for_server("localhost", 30007, timeout=15.0) + if not ok: + print("timed out") + return + print("ready") + + await client.connect("localhost", 30007) + print(f"Connected: {client.connected}") + + # --------------------------------------------------------------------------- + # Router management helpers + # --------------------------------------------------------------------------- + modules = await client.list_modules() + print(f"\nLoaded modules:\n{modules}") + + # Ping / latency check + ok, elapsed_s = await client.ping() + print(f"\nPing: {ok}, latency = {elapsed_s * 1000:.1f} ms") + + # --------------------------------------------------------------------------- + # Synchronous command (client blocks until RESP arrives) + # --------------------------------------------------------------------------- + resp = await client.send_and_wait("API: Read -@ DAQmx", timeout=5.0) + print(f"\nsend_and_wait → {resp.text!r}") + + # --------------------------------------------------------------------------- + # Asynchronous command (await the cmd-resp handshake only) + # --------------------------------------------------------------------------- + await client.post("API: Start Sampling -> DAQmx", timeout=5.0) + print("post → handshake received (async result delivered via queue)") + + # Collect the eventual async response from the queue + if client.async_response_queue is not None: + try: + ar = await asyncio.wait_for(client.async_response_queue.get(), timeout=5.0) + print(f"async_response_queue → {ar.text!r}") + except asyncio.TimeoutError: + print("async_response_queue → no result yet (server may not have replied)") + + # --------------------------------------------------------------------------- + # No-reply command + # --------------------------------------------------------------------------- + await client.post_no_reply("API: Reset ->| DAQmx", timeout=5.0) + print("post_no_reply → handshake received") + + # --------------------------------------------------------------------------- + # Status subscription with an async callback + # --------------------------------------------------------------------------- + await client.subscribe_status("Status", "DAQmx", callback=on_status, timeout=5.0) + print("\nSubscribed to Status@DAQmx — waiting 3 s for notifications …") + await asyncio.sleep(3.0) + + # Also drain any notifications that arrived via the polling queue + if client.status_queue is not None: + count = 0 + while not client.status_queue.empty(): + notif = client.status_queue.get_nowait() + print( + f" [queue poll] {notif.module_name}/{notif.status_name} = {notif.data!r}" + ) + count += 1 + print(f" {count} notification(s) retrieved from queue") + + await client.unsubscribe_status("Status", "DAQmx", timeout=5.0) + print("Unsubscribed") + + print("\nDisconnected.") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/SDK/python-package/examples/basic_usage.py b/SDK/python-package/examples/basic_usage.py new file mode 100644 index 0000000..3e07d5a --- /dev/null +++ b/SDK/python-package/examples/basic_usage.py @@ -0,0 +1,100 @@ +"""Basic usage example for csm-tcp-router-client. + +Prerequisites +------------- +A running CSM-TCP-Router server (LabVIEW app). The reference app defaults +to port 30007. Start it from ``CSM-TCP-Router(Server).vi``. + +Install the SDK:: + + pip install csm-tcp-router-client + +Run this example:: + + python basic_usage.py +""" + + +from csm_tcp_router import TcpRouterClient +from csm_tcp_router.exceptions import ConnectionError + +HOST = "localhost" +PORT = 30007 + + +def main() -> None: + # ----------------------------------------------------------------------- + # 1. Wait until the server is ready (optional – useful during app startup) + # ----------------------------------------------------------------------- + print("Waiting for server …", end=" ", flush=True) + client = TcpRouterClient() + ok = client.wait_for_server(HOST, PORT, timeout=30, retry_interval=0.5) + if not ok: + print("TIMEOUT – server did not start within 30 s.") + return + print("ready.") + + # ----------------------------------------------------------------------- + # 2. Connect (use as a context manager so disconnect is always called) + # ----------------------------------------------------------------------- + with TcpRouterClient() as client: + try: + client.connect(HOST, PORT) + except ConnectionError as exc: + print(f"Connection failed: {exc}") + return + + print(f"Connected to {HOST}:{PORT}") + + # ------------------------------------------------------------------- + # 3. Ping – verify round-trip latency + # ------------------------------------------------------------------- + ok, ms = client.ping() + if ok: + print(f"Ping OK latency={ms * 1000:.1f} ms") + else: + print("Ping failed.") + + # ------------------------------------------------------------------- + # 4. List CSM modules loaded on the server + # ------------------------------------------------------------------- + modules = client.list_modules() + print(f"\nLoaded modules:\n{modules}") + + # ------------------------------------------------------------------- + # 5. List the API for the first module (if any) + # ------------------------------------------------------------------- + first_module = modules.strip().splitlines()[0] if modules.strip() else None + if first_module: + api_text = client.list_api(first_module) + print(f"\nAPI for '{first_module}':\n{api_text}") + + # ------------------------------------------------------------------- + # 6. Send a synchronous command (replace with a real API of yours) + # ------------------------------------------------------------------- + # resp = client.send_and_wait("API: Read -@ DAQmx") + # print(f"\nSync response: {resp.text}") + + # ------------------------------------------------------------------- + # 7. Send an asynchronous command (server returns cmd-resp handshake) + # ------------------------------------------------------------------- + # client.post("API: Start Sampling -> DAQmx") + # print("Async command sent – waiting for async-resp …") + # time.sleep(1) + # if not client.async_response_queue.empty(): + # ar = client.async_response_queue.get_nowait() + # print(f"Async-resp: {ar.text}") + + # ------------------------------------------------------------------- + # 8. Send a no-reply command + # ------------------------------------------------------------------- + # client.post_no_reply("API: Reset ->| DAQmx") + # print("No-reply command sent.") + + print("\nDone.") + + print("Disconnected.") + + +if __name__ == "__main__": + main() diff --git a/SDK/python-package/examples/subscribe_status.py b/SDK/python-package/examples/subscribe_status.py new file mode 100644 index 0000000..0442467 --- /dev/null +++ b/SDK/python-package/examples/subscribe_status.py @@ -0,0 +1,86 @@ +"""Status subscription example for csm-tcp-router-client. + +Prerequisites +------------- +A running CSM-TCP-Router server that has a CSM module publishing a status. +The reference app (``CSM-TCP-Router(Server).vi``) exposes an ``AI`` module +that continuously broadcasts a ``Status`` status. + +Install the SDK:: + + pip install csm-tcp-router-client + +Run this example:: + + python subscribe_status.py +""" + +import signal +import threading +import time + +from csm_tcp_router import StatusNotification, TcpRouterClient +from csm_tcp_router.exceptions import ConnectionError, ServerError + +HOST = "localhost" +PORT = 30007 + +# Module and status name to subscribe to (adjust to match your server) +MODULE_NAME = "AI" +STATUS_NAME = "Status" + +# Global stop flag +_stop = threading.Event() + + +def on_status(notification: StatusNotification) -> None: + """Callback invoked on every status broadcast from the server.""" + print( + f"[{time.strftime('%H:%M:%S')}] " + f"{notification.status_name} @ {notification.module_name} " + f"→ {notification.data}" + ) + + +def main() -> None: + # Allow Ctrl-C to exit cleanly + signal.signal(signal.SIGINT, lambda *_: _stop.set()) + + with TcpRouterClient() as client: + try: + client.connect(HOST, PORT) + except ConnectionError as exc: + print(f"Connection failed: {exc}") + return + + print(f"Connected to {HOST}:{PORT}") + + # Subscribe to status broadcasts + try: + client.subscribe_status(STATUS_NAME, MODULE_NAME, callback=on_status) + print( + f"Subscribed to '{STATUS_NAME}' from module '{MODULE_NAME}'. " + "Press Ctrl-C to exit.\n" + ) + except ServerError as exc: + print(f"Subscription failed: {exc}") + return + + # Keep running until Ctrl-C + while not _stop.is_set(): + # You can also poll client.status_queue here if you prefer + # notification = client.status_queue.get(timeout=1.0) + time.sleep(0.1) + + # Unsubscribe cleanly before disconnecting + try: + client.unsubscribe_status(STATUS_NAME, MODULE_NAME) + print("\nUnsubscribed.") + except Exception: + pass + + print("Disconnected.") + + +if __name__ == "__main__": + main() diff --git a/SDK/python-package/pyproject.toml b/SDK/python-package/pyproject.toml new file mode 100644 index 0000000..3b77791 --- /dev/null +++ b/SDK/python-package/pyproject.toml @@ -0,0 +1,85 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "csm-tcp-router-client" +version = "0.2.0" +description = "Python client SDK for the CSM-TCP-Router LabVIEW server" +readme = "README.md" +license = { text = "MIT" } +requires-python = ">=3.8" +authors = [{ name = "NEVSTOP-LAB" }] +keywords = [ + "csm", + "labview", + "tcp", + "router", + "client", + "sdk", + "daq", + "communicable-state-machine", + "asyncio", +] +classifiers = [ + "Development Status :: 3 - Alpha", + "Framework :: AsyncIO", + "Intended Audience :: Developers", + "Intended Audience :: Science/Research", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Topic :: Scientific/Engineering :: Interface Engine/Protocol Translator", + "Topic :: Software Development :: Libraries :: Python Modules", + "Topic :: System :: Networking", +] + +[project.urls] +Homepage = "https://github.com/NEVSTOP-LAB/CSM-TCP-Router-App" +Repository = "https://github.com/NEVSTOP-LAB/CSM-TCP-Router-App" +Issues = "https://github.com/NEVSTOP-LAB/CSM-TCP-Router-App/issues" +Documentation = "https://github.com/NEVSTOP-LAB/CSM-TCP-Router-App/blob/main/SDK/python-package/README.md" +Changelog = "https://github.com/NEVSTOP-LAB/CSM-TCP-Router-App/blob/main/SDK/python-package/CHANGELOG.md" + +[tool.hatch.build.targets.wheel] +packages = ["src/csm_tcp_router"] + +# --------------------------------------------------------------------------- +# Testing +# --------------------------------------------------------------------------- +[tool.pytest.ini_options] +testpaths = ["tests"] +addopts = "-v --tb=short" +asyncio_mode = "auto" + +# --------------------------------------------------------------------------- +# Linting (ruff) +# --------------------------------------------------------------------------- +[tool.ruff] +target-version = "py38" +line-length = 100 + +[tool.ruff.lint] +select = ["E", "F", "W", "I", "UP", "B", "C4", "PIE", "SIM", "RUF"] +ignore = [ + "E501", # line length handled by formatter + "B008", # do not perform function calls in argument defaults + "UP006", # use `type` instead of `Type` — Python 3.8 compat + "UP007", # use `X | Y` — Python 3.9 compat + "UP035", # deprecated typing imports — Python 3.8 compat + "UP045", # use `X | None` — Python 3.10 compat + "SIM105", # contextlib.suppress — prefer explicit try/except for clarity + "RUF001", # ambiguous unicode in strings — intentional em-dash usage + "RUF002", # ambiguous unicode in docstrings — intentional em-dash usage + "RUF003", # ambiguous unicode in comments — intentional em-dash usage + "RUF022", # __all__ not sorted — grouped by category intentionally +] + +[tool.ruff.lint.per-file-ignores] +"tests/*" = ["S101"] # allow assert in tests +"examples/*" = ["T201"] # allow print in examples diff --git a/SDK/python-package/src/csm_tcp_router/__init__.py b/SDK/python-package/src/csm_tcp_router/__init__.py new file mode 100644 index 0000000..1a8d875 --- /dev/null +++ b/SDK/python-package/src/csm_tcp_router/__init__.py @@ -0,0 +1,58 @@ +"""csm-tcp-router-client – Python client SDK for the CSM-TCP-Router server. + +Sync usage:: + + from csm_tcp_router import TcpRouterClient + + with TcpRouterClient() as client: + client.connect("localhost", 30007) + print(client.list_modules()) + +Async usage:: + + import asyncio + from csm_tcp_router import AsyncTcpRouterClient + + async def main(): + async with AsyncTcpRouterClient() as client: + await client.connect("localhost", 30007) + print(await client.list_modules()) + + asyncio.run(main()) +""" + +from .async_client import AsyncTcpRouterClient +from .client import TcpRouterClient +from .exceptions import ( + ConnectionError, + ProtocolError, + ServerError, + TcpRouterError, + TimeoutError, +) +from .models import ( + AsyncResponse, + CommandResponse, + PacketType, + StatusNotification, +) + +__version__ = "0.2.0" + +__all__ = [ + "TcpRouterClient", + "AsyncTcpRouterClient", + # Exceptions + "TcpRouterError", + "ConnectionError", + "TimeoutError", + "ProtocolError", + "ServerError", + # Models + "PacketType", + "CommandResponse", + "AsyncResponse", + "StatusNotification", + # Version + "__version__", +] diff --git a/SDK/python-package/src/csm_tcp_router/_errors.py b/SDK/python-package/src/csm_tcp_router/_errors.py new file mode 100644 index 0000000..41c6017 --- /dev/null +++ b/SDK/python-package/src/csm_tcp_router/_errors.py @@ -0,0 +1,26 @@ +"""Shared server-error parsing helper. + +Internal module – nothing is re-exported from the package. +""" + +from __future__ import annotations + +from .exceptions import ServerError +from .models import Packet + +__all__: list = [] # internal; nothing re-exported + + +def _parse_server_error(packet: Packet) -> ServerError: + """Extract code and message from a CSM Error format ``[Error: ] ``.""" + text = packet.data.decode("utf-8", errors="replace").strip() + code = "" + msg = text + if text.startswith("[Error:"): + try: + end_idx = text.index("]") + code = text[7:end_idx].strip() + msg = text[end_idx + 1:].strip() + except ValueError: + pass + return ServerError(msg, code) diff --git a/SDK/python-package/src/csm_tcp_router/_protocol.py b/SDK/python-package/src/csm_tcp_router/_protocol.py new file mode 100644 index 0000000..4834ff0 --- /dev/null +++ b/SDK/python-package/src/csm_tcp_router/_protocol.py @@ -0,0 +1,91 @@ +"""Internal protocol v0 codec. + +Wire format (8-byte header, big-endian):: + + | Data Length (4B) | Version (1B=0x01) | Type (1B) | FLAG1 (1B) | FLAG2 (1B) | + ╰────────────────────────── Header (8B) ──────────────────────────╯ + +followed by exactly ``Data Length`` bytes of payload. + +This module is internal; nothing is re-exported from the package. +""" + +from __future__ import annotations + +import struct +from typing import Tuple + +from .exceptions import ProtocolError +from .models import Packet, PacketType + +__all__: list = [] # internal; nothing re-exported + +# Header layout: big-endian uint32 data_len + 4 x uint8 (version, type, flag1, flag2) +_HEADER_FORMAT = "!IBBBB" + +#: Number of bytes in the fixed packet header. +HEADER_SIZE: int = struct.calcsize(_HEADER_FORMAT) # == 8 + +#: Protocol version byte sent in every outgoing packet. +PROTOCOL_VERSION: int = 0x01 + + +def encode_packet( + data: bytes, + packet_type: PacketType, + flag1: int = 0, + flag2: int = 0, +) -> bytes: + """Encode *data* into a complete wire-format packet (header + body). + + :param data: Raw payload bytes. + :param packet_type: :class:`~csm_tcp_router.models.PacketType` for the header. + :param flag1: FLAG1 byte (currently unused; defaults to 0). + :param flag2: FLAG2 byte (currently unused; defaults to 0). + :returns: Concatenated header + payload bytes ready for ``sendall()``. + """ + header = struct.pack( + _HEADER_FORMAT, + len(data), + PROTOCOL_VERSION, + packet_type.value, + flag1, + flag2, + ) + return header + data + + +def decode_header(header_bytes: bytes) -> Tuple[int, int, int, int, int]: + """Decode an 8-byte header into its constituent fields. + + :returns: ``(data_len, version, type_byte, flag1, flag2)`` + :raises ProtocolError: if *header_bytes* is not exactly :data:`HEADER_SIZE` bytes. + """ + if len(header_bytes) != HEADER_SIZE: + raise ProtocolError( + f"Expected {HEADER_SIZE}-byte header, got {len(header_bytes)} bytes." + ) + return struct.unpack(_HEADER_FORMAT, header_bytes) # type: ignore[return-value] + + +def parse_packet(header_bytes: bytes, body: bytes) -> Packet: + """Build a :class:`~csm_tcp_router.models.Packet` from raw header + body. + + Unknown packet type bytes are mapped to :attr:`PacketType.INFO` for + forward compatibility (the server may introduce new types in future + protocol revisions). + + :raises ProtocolError: on header size mismatch or body length mismatch. + """ + data_len, version, type_byte, flag1, flag2 = decode_header(header_bytes) + if len(body) != data_len: + raise ProtocolError( + f"Payload length mismatch: header says {data_len} bytes, " + f"got {len(body)} bytes." + ) + try: + ptype = PacketType(type_byte) + except ValueError: + # Forward-compatible: treat unknown type as INFO + ptype = PacketType.INFO + return Packet(type=ptype, data=body, version=version, flag1=flag1, flag2=flag2) diff --git a/SDK/python-package/src/csm_tcp_router/_transport.py b/SDK/python-package/src/csm_tcp_router/_transport.py new file mode 100644 index 0000000..2de9938 --- /dev/null +++ b/SDK/python-package/src/csm_tcp_router/_transport.py @@ -0,0 +1,176 @@ +"""Internal TCP transport layer with a background receive thread. + +This module is internal; nothing is re-exported from the package. +""" + +from __future__ import annotations + +import socket +import struct +import threading +from typing import Callable, Optional + +from ._protocol import HEADER_SIZE, parse_packet +from .exceptions import ConnectionError as RouterConnectionError +from .exceptions import ProtocolError +from .models import Packet + +__all__: list = [] # internal; nothing re-exported + + +class Transport: + """Thread-safe, blocking TCP transport. + + A background daemon thread continuously reads packets from the socket and + dispatches them via *on_packet*. Callers are responsible for keeping + callbacks fast and non-blocking, as they run in the receive thread. + + Lifecycle:: + + t = Transport(on_packet=..., on_disconnect=...) + t.connect("localhost", 30007) + t.send_raw(wire_bytes) + t.disconnect() + """ + + def __init__( + self, + on_packet: Callable[[Packet], None], + on_disconnect: Callable[[], None], + ) -> None: + self._sock: Optional[socket.socket] = None + self._send_lock = threading.Lock() + self._stop_event = threading.Event() + self._recv_thread: Optional[threading.Thread] = None + self._on_packet = on_packet + self._on_disconnect = on_disconnect + + # ------------------------------------------------------------------ + # Public interface + # ------------------------------------------------------------------ + + @property + def connected(self) -> bool: + """``True`` while the socket is open and the stop event has not fired.""" + return self._sock is not None and not self._stop_event.is_set() + + def connect(self, host: str, port: int, timeout: float = 5.0) -> None: + """Open a TCP connection and start the receive thread. + + :param host: Target hostname or IP address. + :param port: Target TCP port. + :param timeout: Connect timeout in seconds. + :raises ConnectionError: if already connected or if the OS refuses. + """ + if self.connected: + raise RouterConnectionError( + "Already connected; call disconnect() first." + ) + sock: Optional[socket.socket] = None + try: + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(timeout) + sock.connect((host, port)) + sock.settimeout(None) # switch to blocking for the recv loop + except OSError as exc: + if sock is not None: + try: + sock.close() + except OSError: + pass + raise RouterConnectionError( + f"Cannot connect to {host}:{port}: {exc}" + ) from exc + + self._sock = sock + self._stop_event.clear() + self._recv_thread = threading.Thread( + target=self._recv_loop, + daemon=True, + name="csm-tcp-router-recv", + ) + self._recv_thread.start() + + def disconnect(self, join_timeout: float = 2.0) -> None: + """Close the connection and stop the receive thread. + + Safe to call even if not connected. + """ + self._stop_event.set() + if self._sock is not None: + try: + self._sock.shutdown(socket.SHUT_RDWR) + except OSError: + pass + try: + self._sock.close() + except OSError: + pass + self._sock = None + if self._recv_thread is not None and self._recv_thread.is_alive(): + self._recv_thread.join(timeout=join_timeout) + + def send_raw(self, data: bytes) -> None: + """Send *data* atomically. Thread-safe. + + :raises ConnectionError: if not connected or if the send fails. + """ + if not self.connected: + raise RouterConnectionError("Not connected.") + with self._send_lock: + try: + self._sock.sendall(data) # type: ignore[union-attr] + except OSError as exc: + self._stop_event.set() + raise RouterConnectionError(f"Send failed: {exc}") from exc + + # ------------------------------------------------------------------ + # Private helpers + # ------------------------------------------------------------------ + + def _recv_all(self, size: int) -> bytes: + """Read exactly *size* bytes; returns empty bytes on clean EOF or disconnect.""" + buf = bytearray(size) + view = memoryview(buf) + received = 0 + while received < size: + sock = self._sock # capture locally to avoid TOCTOU race with disconnect() + if sock is None: + return b"" + try: + n = sock.recv_into(view[received:], size - received) + except OSError: + return b"" + if n == 0: + return b"" + received += n + return bytes(buf) + + def _recv_loop(self) -> None: + """Background thread: read packets and dispatch via callback.""" + try: + while not self._stop_event.is_set(): + header = self._recv_all(HEADER_SIZE) + if not header: + break + + # Extract data_len from the first 4 bytes without full decode + (data_len,) = struct.unpack("!I", header[:4]) + body = self._recv_all(data_len) + if len(body) != data_len: + break + + try: + packet = parse_packet(header, body) + except ProtocolError: + # Corrupted frame – skip it and keep the loop alive + continue + + self._on_packet(packet) + + except OSError: + pass + finally: + if not self._stop_event.is_set(): + self._stop_event.set() + self._on_disconnect() diff --git a/SDK/python-package/src/csm_tcp_router/async_client.py b/SDK/python-package/src/csm_tcp_router/async_client.py new file mode 100644 index 0000000..de64c00 --- /dev/null +++ b/SDK/python-package/src/csm_tcp_router/async_client.py @@ -0,0 +1,523 @@ +"""Asyncio-based CSM-TCP-Router client.""" + +from __future__ import annotations + +import asyncio +import inspect +import struct +import time +from typing import Any, Callable, Coroutine, Dict, Optional, Tuple, Union + +from ._errors import _parse_server_error +from ._protocol import HEADER_SIZE, encode_packet, parse_packet +from .exceptions import ConnectionError as RouterConnectionError +from .exceptions import ProtocolError, ServerError +from .exceptions import TimeoutError as RouterTimeoutError +from .models import ( + AsyncResponse, + CommandResponse, + Packet, + PacketType, + StatusNotification, +) + +__all__ = ["AsyncTcpRouterClient"] + +# --------------------------------------------------------------------------- +# Callback type aliases – both plain callables and async coroutines are accepted +# --------------------------------------------------------------------------- + +_SyncStatusCb = Callable[[StatusNotification], None] +_AsyncStatusCb = Callable[[StatusNotification], "Coroutine[Any, Any, None]"] +StatusCallback = Union[_SyncStatusCb, _AsyncStatusCb] + +_SyncAsyncRespCb = Callable[[AsyncResponse], None] +_AsyncAsyncRespCb = Callable[[AsyncResponse], "Coroutine[Any, Any, None]"] +AsyncRespCallback = Union[_SyncAsyncRespCb, _AsyncAsyncRespCb] + +_SubKey = Tuple[str, str] + + +class AsyncTcpRouterClient: + """Asyncio client for a CSM-TCP-Router server. + + Provides the same interface as :class:`~csm_tcp_router.TcpRouterClient` but + as ``async def`` coroutines, suitable for use inside an asyncio event loop. + + **Quickstart**:: + + import asyncio + from csm_tcp_router import AsyncTcpRouterClient + + async def main(): + async with AsyncTcpRouterClient() as client: + await client.connect("localhost", 30007) + print(await client.list_modules()) + resp = await client.send_and_wait("API: Read -@ DAQmx") + print(resp.text) + + asyncio.run(main()) + + **Protocol flows** are identical to :class:`~csm_tcp_router.TcpRouterClient`. + + **Callbacks** passed to :meth:`subscribe_status` and + :meth:`register_async_callback` may be either a plain callable *or* an + ``async def`` coroutine — both are supported. + + **Polling queues** (:attr:`async_response_queue`, :attr:`status_queue`) are + created when :meth:`connect` is called and are bound to the running event + loop. Access them only after :meth:`connect` has been awaited. + """ + + def __init__(self) -> None: + self._reader: Optional[asyncio.StreamReader] = None + self._writer: Optional[asyncio.StreamWriter] = None + self._recv_task: Optional[asyncio.Task[None]] = None + + # Asyncio objects created lazily in connect() to bind to the running loop + self._resp_queue: Optional[asyncio.Queue[object]] = None + self._cmd_resp_queue: Optional[asyncio.Queue[object]] = None + self._send_lock: Optional[asyncio.Lock] = None + # Serialisation locks – at most one in-flight RESP / CMD_RESP waiter + self._resp_lock: Optional[asyncio.Lock] = None + self._cmd_resp_lock: Optional[asyncio.Lock] = None + + #: Polling queue for :class:`~csm_tcp_router.models.AsyncResponse` objects + #: received from the server. Available after :meth:`connect` is called. + self.async_response_queue: Optional[asyncio.Queue[AsyncResponse]] = None + + #: Polling queue for :class:`~csm_tcp_router.models.StatusNotification` + #: objects received from the server. Available after :meth:`connect`. + self.status_queue: Optional[asyncio.Queue[StatusNotification]] = None + + # Callback registries – plain dicts (asyncio is single-threaded) + self._status_callbacks: Dict[_SubKey, Optional[StatusCallback]] = {} + self._async_callbacks: Dict[str, AsyncRespCallback] = {} + + # ------------------------------------------------------------------ + # Connection management + # ------------------------------------------------------------------ + + def _init_async_objects(self) -> None: + """(Re)create asyncio objects bound to the current running loop.""" + self._resp_queue = asyncio.Queue() + self._cmd_resp_queue = asyncio.Queue() + self._send_lock = asyncio.Lock() + self._resp_lock = asyncio.Lock() + self._cmd_resp_lock = asyncio.Lock() + self.async_response_queue = asyncio.Queue() + self.status_queue = asyncio.Queue() + + @property + def connected(self) -> bool: + """``True`` while the writer is open and not being closed.""" + return self._writer is not None and not self._writer.is_closing() + + async def connect(self, host: str, port: int, timeout: float = 5.0) -> None: + """Open a TCP connection and start the background receive task. + + :param host: Server hostname or IP address. + :param port: Server TCP port (the reference app defaults to 30007). + :param timeout: Connection timeout in seconds. + :raises ConnectionError: if already connected or the OS refuses. + """ + if self.connected: + raise RouterConnectionError( + "Already connected; call disconnect() first." + ) + self._init_async_objects() + try: + self._reader, self._writer = await asyncio.wait_for( + asyncio.open_connection(host, port), timeout=timeout + ) + except asyncio.TimeoutError: + raise RouterConnectionError( + f"Connection to {host}:{port} timed out after {timeout:.1f}s." + ) from None + except OSError as exc: + raise RouterConnectionError( + f"Cannot connect to {host}:{port}: {exc}" + ) from exc + self._recv_task = asyncio.ensure_future(self._recv_loop()) + + async def disconnect(self) -> None: + """Close the connection and stop the background receive task. + + Safe to call even if not currently connected. Any coroutines currently + blocked inside :meth:`send_and_wait`, :meth:`post`, or similar methods + will receive a :exc:`~csm_tcp_router.exceptions.ConnectionError` + immediately rather than waiting for their timeout to expire. + """ + # Wake blocked waiters *before* cancelling the recv task. + sentinel = RouterConnectionError("Disconnected from server.") + if self._resp_queue is not None: + self._resp_queue.put_nowait(sentinel) + if self._cmd_resp_queue is not None: + self._cmd_resp_queue.put_nowait(sentinel) + # Cancel the recv task first; its finally block notifies pending waiters + if self._recv_task is not None and not self._recv_task.done(): + self._recv_task.cancel() + try: + await self._recv_task + except (asyncio.CancelledError, Exception): + pass + self._recv_task = None + + if self._writer is not None: + try: + self._writer.close() + await self._writer.wait_closed() + except OSError: + pass + self._writer = None + self._reader = None + + async def wait_for_server( + self, + host: str, + port: int, + timeout: float = 30.0, + retry_interval: float = 0.5, + ) -> bool: + """Poll until *host*:*port* accepts a connection or *timeout* elapses. + + :param host: Server hostname or IP address. + :param port: Server TCP port. + :param timeout: Maximum time to wait in seconds. + :param retry_interval: Pause between retries in seconds. + :returns: ``True`` when the server is reachable; ``False`` on timeout. + """ + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + try: + _, writer = await asyncio.wait_for( + asyncio.open_connection(host, port), timeout=1.0 + ) + writer.close() + try: + await writer.wait_closed() + except OSError: + pass + return True + except (OSError, asyncio.TimeoutError): + pass + await asyncio.sleep(retry_interval) + return False + + # ------------------------------------------------------------------ + # Core command methods + # ------------------------------------------------------------------ + + async def send_and_wait( + self, command: str, timeout: float = 5.0 + ) -> CommandResponse: + """Send a **synchronous** command and await the response. + + Use the CSM synchronous suffix ``-@`` in *command*:: + + resp = await client.send_and_wait("API: Read -@ DAQmx") + print(resp.text) + + :param command: CSM command string. + :param timeout: Seconds to wait for the ``resp`` packet. + :raises ConnectionError: if not connected. + :raises TimeoutError: if no response arrives within *timeout*. + :raises ServerError: if the server returns an error packet. + """ + wire = encode_packet(command.encode("utf-8"), PacketType.CMD) + assert self._resp_lock is not None + async with self._resp_lock: + await self._send_raw(wire) + return await self._wait_for_resp(timeout) + + async def post(self, command: str, timeout: float = 5.0) -> None: + """Send an **asynchronous** command and await the ``cmd-resp`` handshake. + + Use the CSM async suffix ``->`` in *command*:: + + await client.post("API: Start Sampling -> DAQmx") + + :param command: CSM command string including the ``->`` suffix. + :param timeout: Seconds to wait for the ``cmd-resp`` handshake. + :raises ConnectionError: if not connected. + :raises TimeoutError: if no handshake arrives within *timeout*. + :raises ServerError: if the server rejects the command. + """ + wire = encode_packet(command.encode("utf-8"), PacketType.CMD) + assert self._cmd_resp_lock is not None + async with self._cmd_resp_lock: + await self._send_raw(wire) + await self._wait_for_cmd_resp(timeout) + + async def post_no_reply(self, command: str, timeout: float = 5.0) -> None: + """Send an **async no-reply** command and await the ``cmd-resp`` handshake. + + Use the CSM no-reply suffix ``->|`` in *command*:: + + await client.post_no_reply("API: Reset ->| DAQmx") + + :param command: CSM command string including the ``->|`` suffix. + :param timeout: Seconds to wait for the ``cmd-resp`` handshake. + :raises ConnectionError: if not connected. + :raises TimeoutError: if no handshake arrives within *timeout*. + :raises ServerError: if the server rejects the command. + """ + wire = encode_packet(command.encode("utf-8"), PacketType.CMD) + assert self._cmd_resp_lock is not None + async with self._cmd_resp_lock: + await self._send_raw(wire) + await self._wait_for_cmd_resp(timeout) + + async def ping(self, timeout: float = 2.0) -> Tuple[bool, float]: + """Send a ``Ping`` command and measure round-trip latency. + + :returns: ``(True, elapsed_seconds)`` on success, + ``(False, 0.0)`` on failure. + """ + try: + t0 = time.monotonic() + await self.send_and_wait("Ping", timeout=timeout) + return True, time.monotonic() - t0 + except (RouterConnectionError, RouterTimeoutError, ServerError): + return False, 0.0 + + # ------------------------------------------------------------------ + # Router management helpers + # ------------------------------------------------------------------ + + async def list_modules(self, timeout: float = 5.0) -> str: + """Return the server's loaded CSM module list as plain text.""" + return (await self.send_and_wait("List", timeout=timeout)).text + + async def list_api(self, module: str, timeout: float = 5.0) -> str: + """Return the API list for *module* as plain text.""" + return (await self.send_and_wait(f"List API {module}", timeout=timeout)).text + + async def list_states(self, module: str, timeout: float = 5.0) -> str: + """Return the CSM state list for *module* as plain text.""" + return (await self.send_and_wait(f"List State {module}", timeout=timeout)).text + + async def help(self, module: str, timeout: float = 5.0) -> str: + """Return the help text for *module* as plain text.""" + return (await self.send_and_wait(f"Help {module}", timeout=timeout)).text + + # ------------------------------------------------------------------ + # Status / interrupt subscriptions + # ------------------------------------------------------------------ + + async def subscribe_status( + self, + status_name: str, + module_name: str, + callback: Optional[StatusCallback] = None, + timeout: float = 5.0, + ) -> None: + """Subscribe to a CSM module's status broadcast. + + Sends ``"@ ->"`` and awaits the + ``cmd-resp`` handshake. Once subscribed, + :class:`~csm_tcp_router.models.StatusNotification` objects will be: + + * delivered to *callback* (if provided – sync or async both accepted), and + * added to :attr:`status_queue`. + + :param status_name: Name of the status (e.g. ``"Status"``). + :param module_name: Name of the CSM module (e.g. ``"AI"``). + :param callback: Optional callable or coroutine invoked per notification. + :param timeout: Seconds to wait for the ``cmd-resp`` handshake. + :raises ConnectionError: if not connected. + :raises TimeoutError: if no handshake arrives within *timeout*. + :raises ServerError: if the server rejects the subscription. + """ + # Register the callback before sending to eliminate the race where a + # STATUS packet could arrive before the callback is stored. + self._status_callbacks[(status_name, module_name)] = callback + cmd = f"{status_name}@{module_name} ->" + wire = encode_packet(cmd.encode("utf-8"), PacketType.CMD) + assert self._cmd_resp_lock is not None + try: + async with self._cmd_resp_lock: + await self._send_raw(wire) + await self._wait_for_cmd_resp(timeout) + except Exception: + self._status_callbacks.pop((status_name, module_name), None) + raise + + async def unsubscribe_status( + self, + status_name: str, + module_name: str, + timeout: float = 5.0, + ) -> None: + """Cancel a status subscription. + + :param status_name: Name of the subscribed status. + :param module_name: Name of the CSM module. + :param timeout: Seconds to wait for the ``cmd-resp`` handshake. + :raises ConnectionError: if not connected. + :raises TimeoutError: if no handshake arrives within *timeout*. + :raises ServerError: if the server rejects the request. + """ + cmd = f"{status_name}@{module_name} ->" + wire = encode_packet(cmd.encode("utf-8"), PacketType.CMD) + assert self._cmd_resp_lock is not None + async with self._cmd_resp_lock: + await self._send_raw(wire) + await self._wait_for_cmd_resp(timeout) + self._status_callbacks.pop((status_name, module_name), None) + + def register_async_callback( + self, + original_command: str, + callback: AsyncRespCallback, + ) -> None: + """Register a callback for ``async-resp`` packets. + + The callback is matched by *original_command* (the command text + echoed in the ``async-resp`` payload after the `` <- `` separator). + + Callbacks may be either a plain callable or an ``async def`` coroutine. + + :param original_command: The command text echoed in the ``async-resp``. + :param callback: Callable or coroutine receiving an + :class:`~csm_tcp_router.models.AsyncResponse`. + """ + self._async_callbacks[original_command] = callback + + def unregister_async_callback(self, original_command: str) -> None: + """Remove a previously registered async-response callback.""" + self._async_callbacks.pop(original_command, None) + + # ------------------------------------------------------------------ + # Async context-manager support + # ------------------------------------------------------------------ + + async def __aenter__(self) -> AsyncTcpRouterClient: + return self + + async def __aexit__(self, *_args: object) -> None: + await self.disconnect() + + # ------------------------------------------------------------------ + # Internal: send + # ------------------------------------------------------------------ + + async def _send_raw(self, data: bytes) -> None: + if not self.connected: + raise RouterConnectionError("Not connected.") + assert self._writer is not None + assert self._send_lock is not None + async with self._send_lock: + self._writer.write(data) + await self._writer.drain() + + # ------------------------------------------------------------------ + # Internal: receive loop (background task) + # ------------------------------------------------------------------ + + async def _recv_loop(self) -> None: + """Background task: read frames and dispatch them.""" + assert self._reader is not None + try: + while True: + header = await self._reader.readexactly(HEADER_SIZE) + (data_len,) = struct.unpack("!I", header[:4]) + body = ( + await self._reader.readexactly(data_len) if data_len else b"" + ) + try: + packet = parse_packet(header, body) + except ProtocolError: + continue # skip corrupted frame; keep connection alive + await self._dispatch_packet(packet) + except (asyncio.IncompleteReadError, asyncio.CancelledError, OSError): + pass + finally: + self._notify_disconnect() + + async def _dispatch_packet(self, packet: Packet) -> None: + """Route a received packet to the correct queue and/or callback.""" + assert self._resp_queue is not None + assert self._cmd_resp_queue is not None + assert self.async_response_queue is not None + assert self.status_queue is not None + + ptype = packet.type + + if ptype == PacketType.RESP: + self._resp_queue.put_nowait(packet) + + elif ptype == PacketType.CMD_RESP: + self._cmd_resp_queue.put_nowait(packet) + + elif ptype == PacketType.ASYNC_RESP: + resp = AsyncResponse.from_packet(packet) + self.async_response_queue.put_nowait(resp) + cb = self._async_callbacks.get(resp.original_command) + if cb is not None: + try: + result = cb(resp) # type: ignore[arg-type] + if inspect.isawaitable(result): + await result + except Exception: + pass + + elif ptype in (PacketType.STATUS, PacketType.INTERRUPT): + notif = StatusNotification.from_packet(packet) + self.status_queue.put_nowait(notif) + cb = self._status_callbacks.get( # type: ignore[assignment] + (notif.status_name, notif.module_name) + ) + if cb is not None: + try: + result = cb(notif) # type: ignore[arg-type] + if inspect.isawaitable(result): + await result + except Exception: + pass + + elif ptype == PacketType.ERROR: + err = _parse_server_error(packet) + self._resp_queue.put_nowait(err) + self._cmd_resp_queue.put_nowait(err) + + # PacketType.INFO is silently discarded (welcome / goodbye messages) + + def _notify_disconnect(self) -> None: + """Put sentinels in waiter queues when the connection is lost.""" + if self._resp_queue is None: + return + sentinel = RouterConnectionError("Connection lost unexpectedly.") + self._resp_queue.put_nowait(sentinel) + self._cmd_resp_queue.put_nowait(sentinel) + + # ------------------------------------------------------------------ + # Internal: synchronised waiters + # ------------------------------------------------------------------ + + async def _wait_for_resp(self, timeout: float) -> CommandResponse: + assert self._resp_queue is not None + try: + item = await asyncio.wait_for(self._resp_queue.get(), timeout=timeout) + except asyncio.TimeoutError: + raise RouterTimeoutError( + f"No response received within {timeout:.1f}s." + ) from None + if isinstance(item, Exception): + raise item + assert isinstance(item, Packet) + return CommandResponse(raw=item.data) + + async def _wait_for_cmd_resp(self, timeout: float) -> None: + assert self._cmd_resp_queue is not None + try: + item = await asyncio.wait_for( + self._cmd_resp_queue.get(), timeout=timeout + ) + except asyncio.TimeoutError: + raise RouterTimeoutError( + f"No cmd-resp received within {timeout:.1f}s." + ) from None + if isinstance(item, Exception): + raise item + # CMD_RESP payload is a handshake acknowledgment; discard it diff --git a/SDK/python-package/src/csm_tcp_router/client.py b/SDK/python-package/src/csm_tcp_router/client.py new file mode 100644 index 0000000..2f870d0 --- /dev/null +++ b/SDK/python-package/src/csm_tcp_router/client.py @@ -0,0 +1,456 @@ +"""High-level CSM-TCP-Router client.""" + +from __future__ import annotations + +import queue +import threading +import time +from typing import Callable, Dict, Optional, Tuple + +from ._errors import _parse_server_error +from ._protocol import encode_packet +from ._transport import Transport +from .exceptions import ConnectionError as RouterConnectionError +from .exceptions import ServerError +from .exceptions import TimeoutError as RouterTimeoutError +from .models import ( + AsyncResponse, + CommandResponse, + Packet, + PacketType, + StatusNotification, +) + +__all__ = ["TcpRouterClient"] + +# Type aliases +_SubKey = Tuple[str, str] +StatusCallback = Callable[[StatusNotification], None] +AsyncCallback = Callable[[AsyncResponse], None] + +# Items held in the internal queues are either Packet or Exception instances. +_QueueItem = object + + +class TcpRouterClient: + """Python client for a CSM-TCP-Router server. + + This class mirrors the LabVIEW ClientAPI VIs and speaks the + CSM-TCP-Router protocol v0. It is thread-safe in that its internal + state is protected by locks; however, the protocol allows at most one + in-flight *synchronous* command at a time and at most one in-flight + *async* command / subscription at a time. Concurrent callers are + serialised by ``_resp_lock`` and ``_cmd_resp_lock`` respectively. + + **Quickstart**:: + + from csm_tcp_router import TcpRouterClient + + with TcpRouterClient() as client: + client.connect("localhost", 30007) + print(client.list_modules()) + + **Protocol flows**: + + - *Synchronous* command (``-@``): :meth:`send_and_wait` – sends a ``CMD`` + packet and blocks until a ``RESP`` (or ``ERROR``) is received. + - *Asynchronous* command (``->``): :meth:`post` – sends a ``CMD`` packet + and blocks until the ``CMD_RESP`` handshake is received; the eventual + ``ASYNC_RESP`` is delivered asynchronously. + - *No-reply async* command (``->|``): :meth:`post_no_reply` – same as + :meth:`post` but no ``ASYNC_RESP`` will ever arrive. + - *Subscribe / unsubscribe*: :meth:`subscribe_status` / + :meth:`unsubscribe_status` – sends a ```` / ```` + command and waits for the ``CMD_RESP`` handshake. + + **Received-packet routing** (on the background receive thread): + + - ``RESP`` (0x04) – unblocks the caller of :meth:`send_and_wait`. + - ``CMD_RESP`` (0x03) – unblocks callers of :meth:`post`, + :meth:`post_no_reply`, :meth:`subscribe_status`, and + :meth:`unsubscribe_status`. + - ``ASYNC_RESP`` (0x05) – added to :attr:`async_response_queue` and + dispatched to any matching :meth:`register_async_callback`. + - ``STATUS`` / ``INTERRUPT`` (0x06 / 0x07) – added to + :attr:`status_queue` and dispatched to any matching + :meth:`subscribe_status` callback. + - ``ERROR`` (0x01) – unblocks any pending synchronous waiter with a + :exc:`~csm_tcp_router.exceptions.ServerError`. + - ``INFO`` (0x00) – silently discarded (welcome / goodbye messages). + """ + + def __init__(self) -> None: + self._transport = Transport( + on_packet=self._on_packet, + on_disconnect=self._on_disconnect, + ) + + # One-item-deep queues for synchronised waits. + # Items are either Packet or Exception instances. + self._resp_queue: queue.Queue[_QueueItem] = queue.Queue() + self._cmd_resp_queue: queue.Queue[_QueueItem] = queue.Queue() + + #: Polling queue for :class:`~csm_tcp_router.models.AsyncResponse` + #: objects received from the server. + self.async_response_queue: queue.Queue[AsyncResponse] = queue.Queue() + + #: Polling queue for :class:`~csm_tcp_router.models.StatusNotification` + #: objects received from the server. + self.status_queue: queue.Queue[StatusNotification] = queue.Queue() + + # Callback registries (protected by _lock) + self._status_callbacks: Dict[_SubKey, Optional[StatusCallback]] = {} + self._async_callbacks: Dict[str, AsyncCallback] = {} + self._lock = threading.Lock() + + # Serialisation locks – at most one in-flight RESP / CMD_RESP waiter + # at a time. This prevents concurrent callers from consuming each + # other's response packets. + self._resp_lock = threading.Lock() + self._cmd_resp_lock = threading.Lock() + + # ------------------------------------------------------------------ + # Connection management + # ------------------------------------------------------------------ + + def connect(self, host: str, port: int, timeout: float = 5.0) -> None: + """Connect to a CSM-TCP-Router server. + + :param host: Server hostname or IP address. + :param port: Server TCP port (the reference app defaults to 30007). + :param timeout: Connect timeout in seconds. + :raises ConnectionError: if the connection cannot be established. + """ + self._transport.connect(host, port, timeout=timeout) + + def disconnect(self) -> None: + """Disconnect from the server and release all resources. + + Safe to call even if not currently connected. Any threads currently + blocked inside :meth:`send_and_wait`, :meth:`post`, or similar methods + will receive a :exc:`~csm_tcp_router.exceptions.ConnectionError` + immediately rather than waiting for their timeout to expire. + """ + # Wake blocked waiters *before* tearing down the transport. + sentinel = RouterConnectionError("Disconnected from server.") + self._resp_queue.put(sentinel) + self._cmd_resp_queue.put(sentinel) + self._transport.disconnect() + + @property + def connected(self) -> bool: + """``True`` if the underlying transport is currently connected.""" + return self._transport.connected + + def wait_for_server( + self, + host: str, + port: int, + timeout: float = 30.0, + retry_interval: float = 0.5, + ) -> bool: + """Poll until *host*:*port* accepts a connection or *timeout* elapses. + + :param host: Server hostname or IP address. + :param port: Server TCP port. + :param timeout: Maximum time to wait in seconds. + :param retry_interval: Pause between retries in seconds. + :returns: ``True`` when the server is reachable; ``False`` on timeout. + """ + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + probe = Transport( + on_packet=lambda _p: None, + on_disconnect=lambda: None, + ) + try: + probe.connect(host, port, timeout=1.0) + probe.disconnect() + return True + except RouterConnectionError: + pass + time.sleep(retry_interval) + return False + + # ------------------------------------------------------------------ + # Core command methods + # ------------------------------------------------------------------ + + def send_and_wait(self, command: str, timeout: float = 5.0) -> CommandResponse: + """Send a **synchronous** command and block until the response arrives. + + Use the CSM synchronous message suffix ``-@`` in *command*:: + + resp = client.send_and_wait("API: Read -@ DAQmx") + print(resp.text) + + The built-in router management commands (``List``, ``Ping``, …) are + also synchronous and do not require a suffix. + + :param command: CSM command string. + :param timeout: Seconds to wait for the ``resp`` packet. + :raises ConnectionError: if not connected. + :raises TimeoutError: if no response arrives within *timeout*. + :raises ServerError: if the server returns an error packet. + """ + wire = encode_packet(command.encode("utf-8"), PacketType.CMD) + with self._resp_lock: + self._transport.send_raw(wire) + return self._wait_for_resp(timeout) + + def post(self, command: str, timeout: float = 5.0) -> None: + """Send an **asynchronous** command and wait for the ``cmd-resp`` handshake. + + Use the CSM async message suffix ``->`` in *command*:: + + client.post("API: Start Sampling -> DAQmx") + + The eventual ``async-resp`` payload will be delivered to any callback + registered with :meth:`register_async_callback` and added to + :attr:`async_response_queue`. + + :param command: CSM command string including the ``->`` suffix. + :param timeout: Seconds to wait for the ``cmd-resp`` handshake. + :raises ConnectionError: if not connected. + :raises TimeoutError: if no handshake arrives within *timeout*. + :raises ServerError: if the server rejects the command. + """ + wire = encode_packet(command.encode("utf-8"), PacketType.CMD) + with self._cmd_resp_lock: + self._transport.send_raw(wire) + self._wait_for_cmd_resp(timeout) + + def post_no_reply(self, command: str, timeout: float = 5.0) -> None: + """Send an **async no-reply** command and wait for the ``cmd-resp`` handshake. + + Use the CSM no-reply suffix ``->|`` in *command*:: + + client.post_no_reply("API: Reset ->| DAQmx") + + After the handshake the server will not send any further response. + + :param command: CSM command string including the ``->|`` suffix. + :param timeout: Seconds to wait for the ``cmd-resp`` handshake. + :raises ConnectionError: if not connected. + :raises TimeoutError: if no handshake arrives within *timeout*. + :raises ServerError: if the server rejects the command. + """ + wire = encode_packet(command.encode("utf-8"), PacketType.CMD) + with self._cmd_resp_lock: + self._transport.send_raw(wire) + self._wait_for_cmd_resp(timeout) + + def ping(self, timeout: float = 2.0) -> Tuple[bool, float]: + """Send a ``Ping`` command and measure round-trip latency. + + :param timeout: Seconds to wait for the reply. + :returns: ``(True, elapsed_seconds)`` on success, + ``(False, 0.0)`` on failure or error. + """ + try: + t0 = time.monotonic() + self.send_and_wait("Ping", timeout=timeout) + return True, time.monotonic() - t0 + except (RouterConnectionError, RouterTimeoutError, ServerError): + return False, 0.0 + + # ------------------------------------------------------------------ + # Router management helpers + # ------------------------------------------------------------------ + + def list_modules(self, timeout: float = 5.0) -> str: + """Return the server's loaded CSM module list as plain text. + + Equivalent to the ``List`` router management command. + """ + return self.send_and_wait("List", timeout=timeout).text + + def list_api(self, module: str, timeout: float = 5.0) -> str: + """Return the API list for *module* as plain text.""" + return self.send_and_wait(f"List API {module}", timeout=timeout).text + + def list_states(self, module: str, timeout: float = 5.0) -> str: + """Return the CSM state list for *module* as plain text.""" + return self.send_and_wait(f"List State {module}", timeout=timeout).text + + def help(self, module: str, timeout: float = 5.0) -> str: + """Return the help text for *module* as plain text.""" + return self.send_and_wait(f"Help {module}", timeout=timeout).text + + # ------------------------------------------------------------------ + # Status / interrupt subscriptions + # ------------------------------------------------------------------ + + def subscribe_status( + self, + status_name: str, + module_name: str, + callback: Optional[StatusCallback] = None, + timeout: float = 5.0, + ) -> None: + """Subscribe to a CSM module's status broadcast. + + Sends ``"@ ->"`` and waits for + the ``cmd-resp`` handshake. Once subscribed, + :class:`~csm_tcp_router.models.StatusNotification` objects will be: + + * delivered to *callback* (if provided), and + * added to :attr:`status_queue`. + + :param status_name: Name of the status to subscribe to (e.g. ``"Status"``). + :param module_name: Name of the CSM module (e.g. ``"AI"``). + :param callback: Optional callable invoked on each notification. + Must be fast and non-blocking (runs in the recv thread). + :param timeout: Seconds to wait for the ``cmd-resp`` handshake. + :raises ConnectionError: if not connected. + :raises TimeoutError: if no handshake arrives within *timeout*. + :raises ServerError: if the server rejects the subscription. + """ + # Register the callback *before* sending to eliminate the race where + # a STATUS packet could arrive before the callback is stored. + with self._lock: + self._status_callbacks[(status_name, module_name)] = callback + cmd = f"{status_name}@{module_name} ->" + wire = encode_packet(cmd.encode("utf-8"), PacketType.CMD) + try: + with self._cmd_resp_lock: + self._transport.send_raw(wire) + self._wait_for_cmd_resp(timeout) + except Exception: + with self._lock: + self._status_callbacks.pop((status_name, module_name), None) + raise + + def unsubscribe_status( + self, + status_name: str, + module_name: str, + timeout: float = 5.0, + ) -> None: + """Cancel a status subscription. + + :param status_name: Name of the subscribed status. + :param module_name: Name of the CSM module. + :param timeout: Seconds to wait for the ``cmd-resp`` handshake. + :raises ConnectionError: if not connected. + :raises TimeoutError: if no handshake arrives within *timeout*. + :raises ServerError: if the server rejects the request. + """ + cmd = f"{status_name}@{module_name} ->" + wire = encode_packet(cmd.encode("utf-8"), PacketType.CMD) + with self._cmd_resp_lock: + self._transport.send_raw(wire) + self._wait_for_cmd_resp(timeout) + with self._lock: + self._status_callbacks.pop((status_name, module_name), None) + + def register_async_callback( + self, + original_command: str, + callback: AsyncCallback, + ) -> None: + """Register a callback for ``async-resp`` packets. + + The callback is matched by *original_command* (the command text + echoed in the ``async-resp`` payload after the `` <- `` separator). + + :param original_command: The command text that will appear in the + ``async-resp`` echo + (e.g. ``"API: Read -> DAQmx"``). + :param callback: Callable receiving an + :class:`~csm_tcp_router.models.AsyncResponse`. + """ + with self._lock: + self._async_callbacks[original_command] = callback + + def unregister_async_callback(self, original_command: str) -> None: + """Remove a previously registered async callback.""" + with self._lock: + self._async_callbacks.pop(original_command, None) + + # ------------------------------------------------------------------ + # Context-manager support + # ------------------------------------------------------------------ + + def __enter__(self) -> TcpRouterClient: + return self + + def __exit__(self, *_args: object) -> None: + self.disconnect() + + # ------------------------------------------------------------------ + # Internal: packet dispatch (runs in the receive thread) + # ------------------------------------------------------------------ + + def _on_packet(self, packet: Packet) -> None: + ptype = packet.type + if ptype == PacketType.RESP: + self._resp_queue.put(packet) + + elif ptype == PacketType.CMD_RESP: + self._cmd_resp_queue.put(packet) + + elif ptype == PacketType.ASYNC_RESP: + resp = AsyncResponse.from_packet(packet) + self.async_response_queue.put(resp) + with self._lock: + cb = self._async_callbacks.get(resp.original_command) + if cb is not None: + try: + cb(resp) + except Exception: + pass + + elif ptype in (PacketType.STATUS, PacketType.INTERRUPT): + notif = StatusNotification.from_packet(packet) + self.status_queue.put(notif) + with self._lock: + cb = self._status_callbacks.get( # type: ignore[assignment] + (notif.status_name, notif.module_name) + ) + if cb is not None: + try: + cb(notif) # type: ignore[call-arg] + except Exception: + pass + + elif ptype == PacketType.ERROR: + err = _parse_server_error(packet) + # Unblock any pending synchronous waiter + self._resp_queue.put(err) + self._cmd_resp_queue.put(err) + + # PacketType.INFO is silently discarded (welcome / goodbye messages) + + def _on_disconnect(self) -> None: + """Called from the receive thread when the connection drops unexpectedly.""" + sentinel = RouterConnectionError("Connection lost unexpectedly.") + self._resp_queue.put(sentinel) + self._cmd_resp_queue.put(sentinel) + + # ------------------------------------------------------------------ + # Internal: synchronised waiters + # ------------------------------------------------------------------ + + def _wait_for_resp(self, timeout: float) -> CommandResponse: + try: + item = self._resp_queue.get(timeout=timeout) + except queue.Empty: + raise RouterTimeoutError( + f"No response received within {timeout:.1f}s." + ) from None + if isinstance(item, Exception): + raise item + assert isinstance(item, Packet) + return CommandResponse(raw=item.data) + + def _wait_for_cmd_resp(self, timeout: float) -> None: + try: + item = self._cmd_resp_queue.get(timeout=timeout) + except queue.Empty: + raise RouterTimeoutError( + f"No cmd-resp received within {timeout:.1f}s." + ) from None + if isinstance(item, Exception): + raise item + # CMD_RESP payload is a handshake acknowledgment; discard it diff --git a/SDK/python-package/src/csm_tcp_router/exceptions.py b/SDK/python-package/src/csm_tcp_router/exceptions.py new file mode 100644 index 0000000..e79ad48 --- /dev/null +++ b/SDK/python-package/src/csm_tcp_router/exceptions.py @@ -0,0 +1,45 @@ +"""Exception hierarchy for csm-tcp-router-client.""" + +__all__ = [ + "ConnectionError", + "ProtocolError", + "ServerError", + "TcpRouterError", + "TimeoutError", +] + + +class TcpRouterError(Exception): + """Base exception for all CSM-TCP-Router client errors.""" + + +class ConnectionError(TcpRouterError): + """Raised when a connection cannot be established or is lost.""" + + +class TimeoutError(TcpRouterError): + """Raised when a synchronous operation exceeds its timeout.""" + + +class ProtocolError(TcpRouterError): + """Raised when an invalid or unexpected protocol frame is received.""" + + +class ServerError(TcpRouterError): + """Raised when the server returns an error packet. + + Attributes: + message: Human-readable error text from the server. + code: Optional error code extracted from the CSM Error format + ``[Error: ] ``. + """ + + def __init__(self, message: str, code: str = "") -> None: + super().__init__(message) + self.message = message + self.code = code + + def __str__(self) -> str: + if self.code: + return f"[Error: {self.code}] {self.message}" + return self.message diff --git a/SDK/python-package/src/csm_tcp_router/models.py b/SDK/python-package/src/csm_tcp_router/models.py new file mode 100644 index 0000000..70ec0e2 --- /dev/null +++ b/SDK/python-package/src/csm_tcp_router/models.py @@ -0,0 +1,151 @@ +"""Public data models and enumerations for the CSM-TCP-Router protocol.""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import IntEnum + +__all__ = [ + "AsyncResponse", + "CommandResponse", + "Packet", + "PacketType", + "StatusNotification", +] + + +class PacketType(IntEnum): + """Packet type constants as defined in the CSM-TCP-Router protocol v0. + + Wire values + ----------- + ``INFO`` 0x00 – informational messages (welcome / goodbye) + ``ERROR`` 0x01 – error messages from the server + ``CMD`` 0x02 – command sent by the client + ``CMD_RESP`` 0x03 – server handshake for async / no-reply / subscribe + ``RESP`` 0x04 – synchronous response payload + ``ASYNC_RESP`` 0x05 – asynchronous response payload + ``STATUS`` 0x06 – status broadcast from a subscribed CSM module + ``INTERRUPT`` 0x07 – interrupt broadcast from a subscribed CSM module + """ + + INFO = 0x00 + ERROR = 0x01 + CMD = 0x02 + CMD_RESP = 0x03 + RESP = 0x04 + ASYNC_RESP = 0x05 + STATUS = 0x06 + INTERRUPT = 0x07 + + +@dataclass(frozen=True) +class Packet: + """A decoded packet received from the server (internal representation).""" + + type: PacketType + data: bytes + version: int = 1 + flag1: int = 0 + flag2: int = 0 + + +@dataclass(frozen=True) +class CommandResponse: + """The result of a synchronous command (:meth:`~csm_tcp_router.TcpRouterClient.send_and_wait`).""" + + raw: bytes + + @property + def text(self) -> str: + """Decoded UTF-8 text of the response payload.""" + return self.raw.decode("utf-8", errors="replace") + + def __repr__(self) -> str: + return f"CommandResponse({self.text!r})" + + +@dataclass(frozen=True) +class AsyncResponse: + """An asynchronous response payload delivered via an ``async-resp`` packet. + + Attributes: + raw: Raw response bytes (the part *before* the `` <- `` separator). + original_command: The original command text echoed back by the server + (the part *after* the `` <- `` separator). + """ + + raw: bytes + original_command: str = "" + + @property + def text(self) -> str: + """Decoded UTF-8 text of the response payload.""" + return self.raw.decode("utf-8", errors="replace") + + @classmethod + def from_packet(cls, packet: Packet) -> AsyncResponse: + """Parse an ``ASYNC_RESP`` packet. + + Server format: ``" <- "``. + """ + text = packet.data.decode("utf-8", errors="replace") + parts = text.split(" <- ", 1) + if len(parts) == 2: + return cls(raw=parts[0].encode("utf-8"), original_command=parts[1]) + return cls(raw=packet.data) + + def __repr__(self) -> str: + return f"AsyncResponse({self.text!r}, cmd={self.original_command!r})" + + +@dataclass(frozen=True) +class StatusNotification: + """A status broadcast delivered via a ``status`` or ``interrupt`` packet. + + Attributes: + raw: Full raw payload bytes. + packet_type: Either :attr:`PacketType.STATUS` or + :attr:`PacketType.INTERRUPT`. + status_name: The name of the broadcasted status (left of ``>>``). + data: The status payload (between ``>>`` and ``<-``). + module_name: The sending CSM module name (right of ``<-``). + """ + + raw: bytes + packet_type: PacketType = PacketType.STATUS + status_name: str = "" + data: str = "" + module_name: str = "" + + @classmethod + def from_packet(cls, packet: Packet) -> StatusNotification: + """Parse a ``STATUS`` or ``INTERRUPT`` packet. + + Server format: ``" >> <- "``. + """ + text = packet.data.decode("utf-8", errors="replace") + module = "" + left = text + if " <- " in text: + left, module = text.rsplit(" <- ", 1) + module = module.strip() + status_name = "" + data = left.strip() + if " >> " in left: + status_name, data = left.split(" >> ", 1) + status_name = status_name.strip() + data = data.strip() + return cls( + raw=packet.data, + packet_type=packet.type, + status_name=status_name, + data=data, + module_name=module, + ) + + def __repr__(self) -> str: + return ( + f"StatusNotification(status={self.status_name!r}, " + f"data={self.data!r}, module={self.module_name!r})" + ) diff --git a/SDK/python-package/tests/__init__.py b/SDK/python-package/tests/__init__.py new file mode 100644 index 0000000..9cece0a --- /dev/null +++ b/SDK/python-package/tests/__init__.py @@ -0,0 +1 @@ +# tests package marker diff --git a/SDK/python-package/tests/conftest.py b/SDK/python-package/tests/conftest.py new file mode 100644 index 0000000..8055db0 --- /dev/null +++ b/SDK/python-package/tests/conftest.py @@ -0,0 +1,211 @@ +"""Shared pytest fixtures – mock TCP server for integration tests.""" + +from __future__ import annotations + +import queue +import socket +import struct +import threading +from typing import Dict, Optional, Tuple + +import pytest + +from csm_tcp_router._protocol import HEADER_SIZE, encode_packet +from csm_tcp_router.models import PacketType + +# --------------------------------------------------------------------------- +# Internal helpers +# --------------------------------------------------------------------------- + +def _recv_all(sock: socket.socket, size: int) -> bytes: + """Read exactly *size* bytes from *sock*; returns ``b""`` on EOF.""" + buf = bytearray(size) + view = memoryview(buf) + received = 0 + while received < size: + try: + n = sock.recv_into(view[received:], size - received) + except OSError: + return b"" + if n == 0: + return b"" + received += n + return bytes(buf) + + +# --------------------------------------------------------------------------- +# MockServer +# --------------------------------------------------------------------------- + +class MockServer: + """Minimal TCP server that emulates a CSM-TCP-Router for testing. + + Usage:: + + server = MockServer() + server.start() + # ... connect a TcpRouterClient to server.port ... + server.stop() + + Custom responses can be registered before the client sends commands:: + + server.set_response("My Command", "My Reply") + server.set_error_response("Bad Command", "[Error: 42] bad command") + """ + + def __init__(self) -> None: + self._server_sock: Optional[socket.socket] = None + self._thread: Optional[threading.Thread] = None + self._stop = threading.Event() + self.host: str = "127.0.0.1" + self.port: int = 0 + + #: All raw command strings received from the client, in order. + self.received_commands: queue.Queue[str] = queue.Queue() + + # custom response map: command text -> (PacketType, bytes) + self._responses: Dict[str, Tuple[PacketType, bytes]] = {} + + # Connected client sockets (for push operations like STATUS) + self._client_sockets: list = [] + self._clients_lock = threading.Lock() + + def start(self) -> None: + self._server_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self._server_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + self._server_sock.bind((self.host, 0)) + self.port = self._server_sock.getsockname()[1] + self._server_sock.listen(5) + self._stop.clear() + self._thread = threading.Thread( + target=self._accept_loop, daemon=True, name="mock-server" + ) + self._thread.start() + + def stop(self) -> None: + self._stop.set() + if self._server_sock: + try: + self._server_sock.close() + except OSError: + pass + if self._thread: + self._thread.join(timeout=2) + + def set_response(self, cmd_text: str, resp_text: str) -> None: + """Register a custom ``RESP`` reply for *cmd_text*.""" + self._responses[cmd_text] = (PacketType.RESP, resp_text.encode("utf-8")) + + def set_error_response(self, cmd_text: str, error_text: str) -> None: + """Register an ``ERROR`` reply for *cmd_text*.""" + self._responses[cmd_text] = (PacketType.ERROR, error_text.encode("utf-8")) + + def push_status(self, payload: str) -> None: + """Push a ``STATUS`` packet to all currently connected clients.""" + wire = encode_packet(payload.encode("utf-8"), PacketType.STATUS) + with self._clients_lock: + for conn in list(self._client_sockets): + try: + conn.sendall(wire) + except OSError: + pass + + def get_received(self, timeout: float = 1.0) -> Optional[str]: + """Return the next received command string, or ``None`` on timeout.""" + try: + return self.received_commands.get(timeout=timeout) + except queue.Empty: + return None + + # ------------------------------------------------------------------ + # Internal + # ------------------------------------------------------------------ + + def _accept_loop(self) -> None: + assert self._server_sock is not None + self._server_sock.settimeout(0.5) + while not self._stop.is_set(): + try: + conn, _ = self._server_sock.accept() + except (OSError, socket.timeout): + continue + with self._clients_lock: + self._client_sockets.append(conn) + t = threading.Thread( + target=self._handle_client, args=(conn,), daemon=True + ) + t.start() + + def _handle_client(self, conn: socket.socket) -> None: + # Send welcome info packet on connect + conn.sendall(encode_packet(b"Welcome to mock server", PacketType.INFO)) + conn.settimeout(1.0) + try: + while not self._stop.is_set(): + header = _recv_all(conn, HEADER_SIZE) + if not header: + break + try: + (data_len,) = struct.unpack("!I", header[:4]) + body = _recv_all(conn, data_len) + except (OSError, struct.error): + break + if len(body) != data_len: + break + + type_byte = header[5] # offset 5 == TYPE byte + if type_byte == PacketType.CMD.value: + cmd_text = body.decode("utf-8", errors="replace").strip() + self.received_commands.put(cmd_text) + self._handle_command(conn, cmd_text) + except OSError: + pass + finally: + with self._clients_lock: + try: + self._client_sockets.remove(conn) + except ValueError: + pass + try: + conn.close() + except OSError: + pass + + def _handle_command(self, conn: socket.socket, cmd: str) -> None: + """Respond to a received command.""" + if cmd in self._responses: + ptype, data = self._responses[cmd] + conn.sendall(encode_packet(data, ptype)) + return + + # Built-in defaults + if cmd == "Ping": + conn.sendall(encode_packet(b"Pong", PacketType.RESP)) + elif cmd == "List": + conn.sendall(encode_packet(b"AI\nDIO\nSystem", PacketType.RESP)) + elif cmd.startswith("List API "): + module = cmd[len("List API "):].strip() + payload = f"API: Start -> {module}\nAPI: Stop -> {module}" + conn.sendall(encode_packet(payload.encode(), PacketType.RESP)) + elif cmd.startswith("List State "): + module = cmd[len("List State "):].strip() + payload = f"Idle <- {module}\nRunning <- {module}" + conn.sendall(encode_packet(payload.encode(), PacketType.RESP)) + elif "->" in cmd or "->" in cmd: + conn.sendall(encode_packet(b"", PacketType.CMD_RESP)) + else: + # Generic async handshake for any other command + conn.sendall(encode_packet(b"", PacketType.CMD_RESP)) + + +# --------------------------------------------------------------------------- +# Pytest fixture +# --------------------------------------------------------------------------- + +@pytest.fixture +def mock_server(): + """Provide a running :class:`MockServer`; stop it after the test.""" + server = MockServer() + server.start() + yield server + server.stop() diff --git a/SDK/python-package/tests/test_async_client.py b/SDK/python-package/tests/test_async_client.py new file mode 100644 index 0000000..7047ec2 --- /dev/null +++ b/SDK/python-package/tests/test_async_client.py @@ -0,0 +1,455 @@ +"""Tests for AsyncTcpRouterClient – unit tests and integration tests.""" + +from __future__ import annotations + +import asyncio +import time +from typing import List + +import pytest + +from csm_tcp_router import AsyncTcpRouterClient +from csm_tcp_router.async_client import _parse_server_error +from csm_tcp_router.exceptions import ( + ConnectionError as RouterConnectionError, +) +from csm_tcp_router.exceptions import ( + ServerError, +) +from csm_tcp_router.exceptions import ( + TimeoutError as RouterTimeoutError, +) +from csm_tcp_router.models import AsyncResponse, Packet, PacketType, StatusNotification + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_packet(ptype: PacketType, text: str = "") -> Packet: + return Packet(type=ptype, data=text.encode("utf-8")) + + +def _client_with_queues() -> AsyncTcpRouterClient: + """Return a client with asyncio objects pre-initialised (no TCP connection).""" + client = AsyncTcpRouterClient() + client._init_async_objects() + return client + + +# --------------------------------------------------------------------------- +# Unit tests: _parse_server_error (sync – no asyncio needed) +# --------------------------------------------------------------------------- + + +def test_parse_error_plain_text(): + pkt = _make_packet(PacketType.ERROR, "something went wrong") + err = _parse_server_error(pkt) + assert err.message == "something went wrong" + assert err.code == "" + + +def test_parse_error_csm_format(): + pkt = _make_packet(PacketType.ERROR, "[Error: 42] module not found") + err = _parse_server_error(pkt) + assert err.code == "42" + assert err.message == "module not found" + + +def test_parse_error_malformed_bracket(): + pkt = _make_packet(PacketType.ERROR, "[Error: missing close") + err = _parse_server_error(pkt) + assert err.message == "[Error: missing close" + + +# --------------------------------------------------------------------------- +# Unit tests: packet dispatch +# --------------------------------------------------------------------------- + + +async def test_dispatch_resp(): + client = _client_with_queues() + pkt = _make_packet(PacketType.RESP, "ok") + await client._dispatch_packet(pkt) + item = client._resp_queue.get_nowait() + assert isinstance(item, Packet) + assert item.data == b"ok" + + +async def test_dispatch_cmd_resp(): + client = _client_with_queues() + await client._dispatch_packet(_make_packet(PacketType.CMD_RESP)) + assert not client._cmd_resp_queue.empty() + + +async def test_dispatch_error_unblocks_both_queues(): + client = _client_with_queues() + await client._dispatch_packet(_make_packet(PacketType.ERROR, "[Error: 7] bad")) + r = client._resp_queue.get_nowait() + c = client._cmd_resp_queue.get_nowait() + assert isinstance(r, ServerError) and r.code == "7" + assert isinstance(c, ServerError) + + +async def test_dispatch_async_resp_to_queue(): + client = _client_with_queues() + await client._dispatch_packet( + _make_packet(PacketType.ASYNC_RESP, "result <- API: Start -> DIO") + ) + ar = client.async_response_queue.get_nowait() + assert ar.text == "result" + assert ar.original_command == "API: Start -> DIO" + + +async def test_dispatch_async_resp_sync_callback(): + client = _client_with_queues() + received: List[AsyncResponse] = [] + client.register_async_callback("API: Start -> DIO", received.append) + await client._dispatch_packet( + _make_packet(PacketType.ASYNC_RESP, "result <- API: Start -> DIO") + ) + assert len(received) == 1 and received[0].text == "result" + + +async def test_dispatch_async_resp_async_callback(): + client = _client_with_queues() + received: List[AsyncResponse] = [] + + async def async_cb(ar: AsyncResponse) -> None: + received.append(ar) + + client.register_async_callback("cmd", async_cb) + await client._dispatch_packet(_make_packet(PacketType.ASYNC_RESP, "val <- cmd")) + assert len(received) == 1 + + +async def test_dispatch_status_to_queue(): + client = _client_with_queues() + await client._dispatch_packet(_make_packet(PacketType.STATUS, "Status >> 42 <- AI")) + notif = client.status_queue.get_nowait() + assert notif.status_name == "Status" + assert notif.data == "42" + assert notif.module_name == "AI" + + +async def test_dispatch_status_sync_callback(): + client = _client_with_queues() + received: List[StatusNotification] = [] + client._status_callbacks[("Status", "AI")] = received.append + await client._dispatch_packet(_make_packet(PacketType.STATUS, "Status >> v1 <- AI")) + assert len(received) == 1 and received[0].data == "v1" + + +async def test_dispatch_status_async_callback(): + client = _client_with_queues() + received: List[StatusNotification] = [] + + async def async_cb(notif: StatusNotification) -> None: + received.append(notif) + + client._status_callbacks[("Temp", "Sensor")] = async_cb + await client._dispatch_packet(_make_packet(PacketType.STATUS, "Temp >> 25.5 <- Sensor")) + assert len(received) == 1 and received[0].data == "25.5" + + +async def test_dispatch_interrupt_to_queue(): + client = _client_with_queues() + await client._dispatch_packet( + _make_packet(PacketType.INTERRUPT, "Stop >> 1 <- AI") + ) + notif = client.status_queue.get_nowait() + assert notif.packet_type == PacketType.INTERRUPT + + +async def test_dispatch_info_silently_discarded(): + client = _client_with_queues() + await client._dispatch_packet(_make_packet(PacketType.INFO, "Welcome")) + assert client._resp_queue.empty() + assert client._cmd_resp_queue.empty() + + +async def test_notify_disconnect_puts_sentinels(): + client = _client_with_queues() + client._notify_disconnect() + r = client._resp_queue.get_nowait() + c = client._cmd_resp_queue.get_nowait() + assert isinstance(r, RouterConnectionError) + assert isinstance(c, RouterConnectionError) + + +# --------------------------------------------------------------------------- +# Unit tests: callback management +# --------------------------------------------------------------------------- + + +def test_unregister_async_callback_noop_if_missing(): + client = AsyncTcpRouterClient() + client.unregister_async_callback("nonexistent") # must not raise + + +# --------------------------------------------------------------------------- +# Unit tests: timeout waiters +# --------------------------------------------------------------------------- + + +async def test_wait_for_resp_timeout(): + client = _client_with_queues() + with pytest.raises(RouterTimeoutError, match=r"0\.1s"): + await client._wait_for_resp(timeout=0.1) + + +async def test_wait_for_cmd_resp_timeout(): + client = _client_with_queues() + with pytest.raises(RouterTimeoutError, match=r"0\.1s"): + await client._wait_for_cmd_resp(timeout=0.1) + + +async def test_wait_for_resp_raises_server_error(): + client = _client_with_queues() + client._resp_queue.put_nowait(ServerError("boom", "5")) + with pytest.raises(ServerError, match="boom"): + await client._wait_for_resp(timeout=1.0) + + +async def test_wait_for_resp_raises_connection_error(): + client = _client_with_queues() + client._resp_queue.put_nowait(RouterConnectionError("lost")) + with pytest.raises(RouterConnectionError): + await client._wait_for_resp(timeout=1.0) + + +# --------------------------------------------------------------------------- +# Integration tests (real TCP via MockServer fixture) +# --------------------------------------------------------------------------- + + +class TestConnection: + async def test_connect_and_disconnect(self, mock_server): + client = AsyncTcpRouterClient() + await client.connect(mock_server.host, mock_server.port) + assert client.connected + await client.disconnect() + assert not client.connected + + async def test_async_context_manager(self, mock_server): + async with AsyncTcpRouterClient() as client: + await client.connect(mock_server.host, mock_server.port) + assert client.connected + assert not client.connected + + async def test_connect_already_connected_raises(self, mock_server): + async with AsyncTcpRouterClient() as client: + await client.connect(mock_server.host, mock_server.port) + with pytest.raises(RouterConnectionError, match="Already connected"): + await client.connect(mock_server.host, mock_server.port) + + async def test_connect_bad_port_raises(self): + client = AsyncTcpRouterClient() + with pytest.raises(RouterConnectionError): + await client.connect("127.0.0.1", 1, timeout=0.5) + + async def test_wait_for_server_success(self, mock_server): + client = AsyncTcpRouterClient() + ok = await client.wait_for_server( + mock_server.host, mock_server.port, timeout=5.0, retry_interval=0.1 + ) + assert ok is True + + async def test_wait_for_server_timeout(self): + client = AsyncTcpRouterClient() + ok = await client.wait_for_server("127.0.0.1", 1, timeout=0.3, retry_interval=0.1) + assert ok is False + + +class TestPing: + async def test_ping_returns_true_and_elapsed(self, mock_server): + async with AsyncTcpRouterClient() as client: + await client.connect(mock_server.host, mock_server.port) + ok, elapsed = await client.ping(timeout=2.0) + assert ok is True + assert elapsed > 0 + + +class TestSendAndWait: + async def test_list_modules(self, mock_server): + async with AsyncTcpRouterClient() as client: + await client.connect(mock_server.host, mock_server.port) + text = await client.list_modules(timeout=2.0) + assert "AI" in text and "DIO" in text + + async def test_list_api(self, mock_server): + async with AsyncTcpRouterClient() as client: + await client.connect(mock_server.host, mock_server.port) + text = await client.list_api("DAQmx", timeout=2.0) + assert "DAQmx" in text + + async def test_list_states(self, mock_server): + async with AsyncTcpRouterClient() as client: + await client.connect(mock_server.host, mock_server.port) + text = await client.list_states("AI", timeout=2.0) + assert "AI" in text + + async def test_custom_command(self, mock_server): + mock_server.set_response("My Cmd", "My Reply") + async with AsyncTcpRouterClient() as client: + await client.connect(mock_server.host, mock_server.port) + resp = await client.send_and_wait("My Cmd", timeout=2.0) + assert resp.text == "My Reply" + assert resp.raw == b"My Reply" + + async def test_server_error_raises(self, mock_server): + mock_server.set_error_response("Bad Cmd", "[Error: 9] nope") + async with AsyncTcpRouterClient() as client: + await client.connect(mock_server.host, mock_server.port) + with pytest.raises(ServerError) as exc_info: + await client.send_and_wait("Bad Cmd", timeout=2.0) + assert exc_info.value.code == "9" + assert exc_info.value.message == "nope" + + async def test_timeout_when_server_sends_only_cmd_resp(self, mock_server): + """send_and_wait should time out when server sends CMD_RESP instead of RESP.""" + async with AsyncTcpRouterClient() as client: + await client.connect(mock_server.host, mock_server.port) + with pytest.raises(RouterTimeoutError): + # Default mock sends CMD_RESP for unknown commands, never RESP + await client.send_and_wait("Unknown Async", timeout=0.3) + + async def test_concurrent_commands(self, mock_server): + """Two sequential send_and_wait calls on the same client both succeed.""" + mock_server.set_response("Cmd1", "R1") + mock_server.set_response("Cmd2", "R2") + async with AsyncTcpRouterClient() as client: + await client.connect(mock_server.host, mock_server.port) + r1 = await client.send_and_wait("Cmd1", timeout=2.0) + r2 = await client.send_and_wait("Cmd2", timeout=2.0) + assert r1.text == "R1" + assert r2.text == "R2" + + +class TestPost: + async def test_post_command(self, mock_server): + async with AsyncTcpRouterClient() as client: + await client.connect(mock_server.host, mock_server.port) + await client.post("API: Start -> DIO", timeout=2.0) + + async def test_post_no_reply(self, mock_server): + async with AsyncTcpRouterClient() as client: + await client.connect(mock_server.host, mock_server.port) + await client.post_no_reply("API: Reset ->| DIO", timeout=2.0) + + async def test_post_error_raises(self, mock_server): + mock_server.set_error_response("API: Start -> DIO", "module missing") + async with AsyncTcpRouterClient() as client: + await client.connect(mock_server.host, mock_server.port) + with pytest.raises(ServerError): + await client.post("API: Start -> DIO", timeout=2.0) + + +class TestSubscriptions: + async def test_subscribe_receives_via_queue(self, mock_server): + async with AsyncTcpRouterClient() as client: + await client.connect(mock_server.host, mock_server.port) + await client.subscribe_status("Status", "AI", timeout=2.0) + mock_server.push_status("Status >> 99 <- AI") + assert client.status_queue is not None + notif = await asyncio.wait_for(client.status_queue.get(), timeout=2.0) + assert notif.status_name == "Status" + assert notif.data == "99" + assert notif.module_name == "AI" + + async def test_subscribe_sync_callback(self, mock_server): + received: List[StatusNotification] = [] + async with AsyncTcpRouterClient() as client: + await client.connect(mock_server.host, mock_server.port) + await client.subscribe_status( + "Status", "AI", callback=received.append, timeout=2.0 + ) + mock_server.push_status("Status >> hello <- AI") + await asyncio.sleep(0.3) + assert len(received) == 1 and received[0].data == "hello" + + async def test_subscribe_async_callback(self, mock_server): + received: List[StatusNotification] = [] + + async def async_cb(notif: StatusNotification) -> None: + received.append(notif) + + async with AsyncTcpRouterClient() as client: + await client.connect(mock_server.host, mock_server.port) + await client.subscribe_status("Status", "AI", callback=async_cb, timeout=2.0) + mock_server.push_status("Status >> world <- AI") + await asyncio.sleep(0.3) + assert len(received) == 1 and received[0].data == "world" + + async def test_unsubscribe_stops_callback(self, mock_server): + received: List[StatusNotification] = [] + async with AsyncTcpRouterClient() as client: + await client.connect(mock_server.host, mock_server.port) + await client.subscribe_status( + "Status", "AI", callback=received.append, timeout=2.0 + ) + await client.unsubscribe_status("Status", "AI", timeout=2.0) + mock_server.push_status("Status >> ignored <- AI") + await asyncio.sleep(0.2) + assert len(received) == 0 + + async def test_multiple_notifications_in_order(self, mock_server): + received: List[StatusNotification] = [] + async with AsyncTcpRouterClient() as client: + await client.connect(mock_server.host, mock_server.port) + await client.subscribe_status( + "Temp", "Sensor", callback=received.append, timeout=2.0 + ) + for i in range(5): + mock_server.push_status(f"Temp >> {i} <- Sensor") + await asyncio.sleep(0.5) + assert len(received) == 5 + assert [n.data for n in received] == ["0", "1", "2", "3", "4"] + + async def test_subscribe_error_rolls_back_callback(self, mock_server): + mock_server.set_error_response( + "Status@AI ->", "[Error: 1] denied" + ) + received: List[StatusNotification] = [] + async with AsyncTcpRouterClient() as client: + await client.connect(mock_server.host, mock_server.port) + with pytest.raises(ServerError): + await client.subscribe_status( + "Status", "AI", callback=received.append, timeout=2.0 + ) + # Callback must be removed on failure + assert ("Status", "AI") not in client._status_callbacks + + +class TestConnectedProperty: + async def test_not_connected_before_connect(self): + client = AsyncTcpRouterClient() + assert not client.connected + + async def test_connected_after_connect(self, mock_server): + client = AsyncTcpRouterClient() + await client.connect(mock_server.host, mock_server.port) + assert client.connected + await client.disconnect() + + async def test_not_connected_after_disconnect(self, mock_server): + client = AsyncTcpRouterClient() + await client.connect(mock_server.host, mock_server.port) + await client.disconnect() + assert not client.connected + + async def test_send_when_not_connected_raises(self): + client = AsyncTcpRouterClient() + client._init_async_objects() + with pytest.raises(RouterConnectionError, match="Not connected"): + await client.send_and_wait("Ping", timeout=0.1) + + +class TestTimingAndPerformance: + async def test_elapsed_time_is_positive(self, mock_server): + async with AsyncTcpRouterClient() as client: + await client.connect(mock_server.host, mock_server.port) + t0 = time.monotonic() + await client.send_and_wait("Ping", timeout=2.0) + elapsed = time.monotonic() - t0 + assert elapsed >= 0 diff --git a/SDK/python-package/tests/test_client.py b/SDK/python-package/tests/test_client.py new file mode 100644 index 0000000..4f824df --- /dev/null +++ b/SDK/python-package/tests/test_client.py @@ -0,0 +1,302 @@ +"""Unit tests for TcpRouterClient using a mock transport.""" + +from __future__ import annotations + +import threading +import time +from typing import List +from unittest.mock import MagicMock, patch + +import pytest + +from csm_tcp_router.client import TcpRouterClient, _parse_server_error +from csm_tcp_router.exceptions import ( + ConnectionError as RouterConnectionError, +) +from csm_tcp_router.exceptions import ( + ServerError, +) +from csm_tcp_router.exceptions import ( + TimeoutError as RouterTimeoutError, +) +from csm_tcp_router.models import AsyncResponse, Packet, PacketType, StatusNotification + +# --------------------------------------------------------------------------- +# Helpers: inject packets directly into the client's dispatch method +# --------------------------------------------------------------------------- + +def make_packet(ptype: PacketType, text: str = "") -> Packet: + return Packet(type=ptype, data=text.encode("utf-8")) + + +def inject(client: TcpRouterClient, packet: Packet) -> None: + """Simulate the receive thread delivering a packet.""" + client._on_packet(packet) + + +# --------------------------------------------------------------------------- +# _parse_server_error +# --------------------------------------------------------------------------- + +class TestParseServerError: + def test_plain_message(self): + pkt = make_packet(PacketType.ERROR, "something went wrong") + err = _parse_server_error(pkt) + assert err.message == "something went wrong" + assert err.code == "" + + def test_csm_format(self): + pkt = make_packet(PacketType.ERROR, "[Error: 42] module not found") + err = _parse_server_error(pkt) + assert err.code == "42" + assert err.message == "module not found" + assert str(err) == "[Error: 42] module not found" + + def test_csm_format_no_message(self): + pkt = make_packet(PacketType.ERROR, "[Error: 0]") + err = _parse_server_error(pkt) + assert err.code == "0" + assert err.message == "" + + def test_malformed_bracket_no_crash(self): + pkt = make_packet(PacketType.ERROR, "[Error: no closing bracket") + err = _parse_server_error(pkt) + assert err.code == "" + assert "no closing bracket" in err.message + + +# --------------------------------------------------------------------------- +# TcpRouterClient internal dispatch +# --------------------------------------------------------------------------- + +class TestPacketDispatch: + def _client_no_transport(self) -> TcpRouterClient: + """Return a client with a mocked (never-connecting) transport.""" + client = TcpRouterClient() + client._transport = MagicMock() + client._transport.connected = True + client._transport.send_raw = MagicMock() + return client + + def test_resp_unblocks_wait_for_resp(self): + client = self._client_no_transport() + pkt = make_packet(PacketType.RESP, "ok") + threading.Timer(0.05, inject, args=(client, pkt)).start() + resp = client._wait_for_resp(timeout=1.0) + assert resp.text == "ok" + + def test_cmd_resp_unblocks_wait_for_cmd_resp(self): + client = self._client_no_transport() + pkt = make_packet(PacketType.CMD_RESP) + threading.Timer(0.05, inject, args=(client, pkt)).start() + client._wait_for_cmd_resp(timeout=1.0) # should not raise + + def test_error_unblocks_resp_waiter_with_exception(self): + client = self._client_no_transport() + pkt = make_packet(PacketType.ERROR, "[Error: 1] bad") + threading.Timer(0.05, inject, args=(client, pkt)).start() + with pytest.raises(ServerError): + client._wait_for_resp(timeout=1.0) + + def test_error_unblocks_cmd_resp_waiter_with_exception(self): + client = self._client_no_transport() + pkt = make_packet(PacketType.ERROR, "no module") + threading.Timer(0.05, inject, args=(client, pkt)).start() + with pytest.raises(ServerError): + client._wait_for_cmd_resp(timeout=1.0) + + def test_async_resp_added_to_queue(self): + client = self._client_no_transport() + pkt = make_packet(PacketType.ASYNC_RESP, "result <- API: Start -> DIO") + inject(client, pkt) + ar = client.async_response_queue.get(timeout=0.5) + assert ar.text == "result" + assert ar.original_command == "API: Start -> DIO" + + def test_async_resp_calls_callback(self): + client = self._client_no_transport() + received: List[AsyncResponse] = [] + client.register_async_callback("API: Start -> DIO", received.append) + pkt = make_packet(PacketType.ASYNC_RESP, "result <- API: Start -> DIO") + inject(client, pkt) + time.sleep(0.05) + assert len(received) == 1 + assert received[0].text == "result" + + def test_status_added_to_queue(self): + client = self._client_no_transport() + pkt = make_packet(PacketType.STATUS, "Status >> value42 <- AI") + inject(client, pkt) + notif = client.status_queue.get(timeout=0.5) + assert notif.status_name == "Status" + assert notif.data == "value42" + assert notif.module_name == "AI" + + def test_status_calls_registered_callback(self): + client = self._client_no_transport() + received: List[StatusNotification] = [] + + with patch.object(client._transport, "send_raw"), patch.object(client, "_wait_for_cmd_resp"): + client._status_callbacks[("Status", "AI")] = received.append + + pkt = make_packet(PacketType.STATUS, "Status >> v1 <- AI") + inject(client, pkt) + time.sleep(0.05) + assert len(received) == 1 + assert received[0].data == "v1" + + def test_interrupt_added_to_status_queue(self): + client = self._client_no_transport() + pkt = make_packet(PacketType.INTERRUPT, "Alarm >> fire <- Safety") + inject(client, pkt) + notif = client.status_queue.get(timeout=0.5) + assert notif.packet_type == PacketType.INTERRUPT + assert notif.status_name == "Alarm" + + def test_info_packet_silently_discarded(self): + client = self._client_no_transport() + pkt = make_packet(PacketType.INFO, "Welcome to the server") + inject(client, pkt) + assert client._resp_queue.empty() + assert client._cmd_resp_queue.empty() + + def test_on_disconnect_unblocks_waiters(self): + client = self._client_no_transport() + threading.Timer(0.05, client._on_disconnect).start() + with pytest.raises(RouterConnectionError): + client._wait_for_resp(timeout=1.0) + + +# --------------------------------------------------------------------------- +# Timeout behaviour +# --------------------------------------------------------------------------- + +class TestTimeouts: + def _client(self) -> TcpRouterClient: + client = TcpRouterClient() + client._transport = MagicMock() + client._transport.connected = True + client._transport.send_raw = MagicMock() + return client + + def test_wait_for_resp_timeout(self): + client = self._client() + with pytest.raises(RouterTimeoutError, match=r"0\.1s"): + client._wait_for_resp(timeout=0.1) + + def test_wait_for_cmd_resp_timeout(self): + client = self._client() + with pytest.raises(RouterTimeoutError, match=r"0\.1s"): + client._wait_for_cmd_resp(timeout=0.1) + + +# --------------------------------------------------------------------------- +# ping convenience method +# --------------------------------------------------------------------------- + +class TestPing: + def test_ping_success_returns_true_and_elapsed(self): + client = TcpRouterClient() + client._transport = MagicMock() + client._transport.connected = True + client._transport.send_raw = MagicMock() + + threading.Timer( + 0.02, + inject, + args=(client, make_packet(PacketType.RESP, "Pong")), + ).start() + ok, elapsed = client.ping(timeout=1.0) + assert ok is True + assert elapsed > 0 + + def test_ping_failure_returns_false(self): + client = TcpRouterClient() + client._transport = MagicMock() + client._transport.connected = True + client._transport.send_raw = MagicMock() + # No packet injected → times out + ok, elapsed = client.ping(timeout=0.05) + assert ok is False + assert elapsed == 0.0 + + +# --------------------------------------------------------------------------- +# Context manager +# --------------------------------------------------------------------------- + +def test_context_manager_calls_disconnect(): + client = TcpRouterClient() + client._transport = MagicMock() + client._transport.connected = False + + with client: + pass + + client._transport.disconnect.assert_called_once() + + +# --------------------------------------------------------------------------- +# subscribe_status / unsubscribe_status +# --------------------------------------------------------------------------- + +class TestSubscriptions: + def _client_with_mock_handshake(self) -> TcpRouterClient: + client = TcpRouterClient() + client._transport = MagicMock() + client._transport.connected = True + client._transport.send_raw = MagicMock() + # Patch _wait_for_cmd_resp to succeed immediately + client._wait_for_cmd_resp = MagicMock() + return client + + def test_subscribe_stores_callback(self): + client = self._client_with_mock_handshake() + cb = MagicMock() + client.subscribe_status("Status", "AI", callback=cb) + assert client._status_callbacks[("Status", "AI")] is cb + + def test_subscribe_sends_register_command(self): + client = self._client_with_mock_handshake() + client.subscribe_status("Status", "AI") + wire = client._transport.send_raw.call_args[0][0] + assert b"Status@AI ->" in wire + + def test_unsubscribe_removes_callback(self): + client = self._client_with_mock_handshake() + client._status_callbacks[("Status", "AI")] = MagicMock() + client.unsubscribe_status("Status", "AI") + assert ("Status", "AI") not in client._status_callbacks + + def test_unsubscribe_sends_unregister_command(self): + client = self._client_with_mock_handshake() + client.unsubscribe_status("Status", "AI") + wire = client._transport.send_raw.call_args[0][0] + assert b"Status@AI ->" in wire + + def test_subscribe_cleans_up_callback_on_error(self): + client = TcpRouterClient() + client._transport = MagicMock() + client._transport.connected = True + client._transport.send_raw = MagicMock() + # Make _wait_for_cmd_resp raise + client._wait_for_cmd_resp = MagicMock(side_effect=RouterTimeoutError("t/o")) + + cb = MagicMock() + with pytest.raises(RouterTimeoutError): + client.subscribe_status("Status", "AI", callback=cb) + assert ("Status", "AI") not in client._status_callbacks + + +# --------------------------------------------------------------------------- +# register_async_callback / unregister_async_callback +# --------------------------------------------------------------------------- + +class TestAsyncCallbacks: + def test_register_and_unregister(self): + client = TcpRouterClient() + cb = MagicMock() + client.register_async_callback("cmd", cb) + assert client._async_callbacks["cmd"] is cb + client.unregister_async_callback("cmd") + assert "cmd" not in client._async_callbacks diff --git a/SDK/python-package/tests/test_integration.py b/SDK/python-package/tests/test_integration.py new file mode 100644 index 0000000..563dfd1 --- /dev/null +++ b/SDK/python-package/tests/test_integration.py @@ -0,0 +1,220 @@ +"""Integration tests: real TcpRouterClient talking to MockServer over localhost TCP.""" + +from __future__ import annotations + +import time +from typing import List + +import pytest + +from csm_tcp_router import TcpRouterClient +from csm_tcp_router.exceptions import ServerError +from csm_tcp_router.exceptions import TimeoutError as RouterTimeoutError +from csm_tcp_router.models import StatusNotification + +# All tests in this module use the `mock_server` fixture from conftest.py. + + +# --------------------------------------------------------------------------- +# Connection lifecycle +# --------------------------------------------------------------------------- + +class TestConnection: + def test_connect_and_disconnect(self, mock_server): + client = TcpRouterClient() + client.connect(mock_server.host, mock_server.port) + assert client.connected + client.disconnect() + assert not client.connected + + def test_context_manager(self, mock_server): + with TcpRouterClient() as client: + client.connect(mock_server.host, mock_server.port) + assert client.connected + assert not client.connected + + def test_connect_bad_port_raises(self): + client = TcpRouterClient() + from csm_tcp_router.exceptions import ConnectionError as RouterConnectionError + with pytest.raises(RouterConnectionError): + client.connect("127.0.0.1", 1, timeout=0.5) + + def test_wait_for_server_success(self, mock_server): + client = TcpRouterClient() + ok = client.wait_for_server( + mock_server.host, mock_server.port, timeout=5.0, retry_interval=0.1 + ) + assert ok is True + + def test_wait_for_server_timeout(self): + client = TcpRouterClient() + ok = client.wait_for_server("127.0.0.1", 1, timeout=0.3, retry_interval=0.1) + assert ok is False + + +# --------------------------------------------------------------------------- +# Ping +# --------------------------------------------------------------------------- + +class TestPing: + def test_ping_returns_true_and_elapsed(self, mock_server): + with TcpRouterClient() as client: + client.connect(mock_server.host, mock_server.port) + ok, elapsed = client.ping(timeout=2.0) + assert ok is True + assert elapsed > 0 + + +# --------------------------------------------------------------------------- +# Synchronous command (send_and_wait) +# --------------------------------------------------------------------------- + +class TestSendAndWait: + def test_list_modules(self, mock_server): + with TcpRouterClient() as client: + client.connect(mock_server.host, mock_server.port) + resp = client.send_and_wait("List", timeout=2.0) + assert "AI" in resp.text + assert "DIO" in resp.text + + def test_list_api(self, mock_server): + with TcpRouterClient() as client: + client.connect(mock_server.host, mock_server.port) + text = client.list_api("DAQmx", timeout=2.0) + assert "DAQmx" in text + + def test_list_states(self, mock_server): + with TcpRouterClient() as client: + client.connect(mock_server.host, mock_server.port) + text = client.list_states("DAQmx", timeout=2.0) + assert "Idle" in text or "Running" in text + + def test_custom_command_response(self, mock_server): + mock_server.set_response("My Command", "My Reply") + with TcpRouterClient() as client: + client.connect(mock_server.host, mock_server.port) + resp = client.send_and_wait("My Command", timeout=2.0) + assert resp.text == "My Reply" + + def test_server_error_raises(self, mock_server): + mock_server.set_error_response("Bad Command", "[Error: 7] not found") + with TcpRouterClient() as client: + client.connect(mock_server.host, mock_server.port) + with pytest.raises(ServerError) as exc_info: + client.send_and_wait("Bad Command", timeout=2.0) + assert exc_info.value.code == "7" + + def test_list_modules_helper(self, mock_server): + with TcpRouterClient() as client: + client.connect(mock_server.host, mock_server.port) + text = client.list_modules(timeout=2.0) + assert "AI" in text + + def test_command_received_by_server(self, mock_server): + mock_server.set_response("API: Probe -@ Sensor", "ok") + with TcpRouterClient() as client: + client.connect(mock_server.host, mock_server.port) + client.send_and_wait("API: Probe -@ Sensor", timeout=2.0) + received = mock_server.get_received(timeout=0.5) + assert received == "API: Probe -@ Sensor" + + +# --------------------------------------------------------------------------- +# Async command (post) +# --------------------------------------------------------------------------- + +class TestPost: + def test_post_command_sends_and_receives_handshake(self, mock_server): + # MockServer sends CMD_RESP for unknown commands by default + with TcpRouterClient() as client: + client.connect(mock_server.host, mock_server.port) + client.post("API: Start -> DIO", timeout=2.0) # should not raise + + def test_post_no_reply_sends_and_receives_handshake(self, mock_server): + with TcpRouterClient() as client: + client.connect(mock_server.host, mock_server.port) + client.post_no_reply("API: Reset ->| DIO", timeout=2.0) + + def test_post_error_raises(self, mock_server): + mock_server.set_error_response("API: Start -> DIO", "module missing") + with TcpRouterClient() as client: + client.connect(mock_server.host, mock_server.port) + with pytest.raises(ServerError): + client.post("API: Start -> DIO", timeout=2.0) + + +# --------------------------------------------------------------------------- +# Status subscriptions +# --------------------------------------------------------------------------- + +class TestStatusSubscriptions: + def test_subscribe_receives_status_via_queue(self, mock_server): + with TcpRouterClient() as client: + client.connect(mock_server.host, mock_server.port) + client.subscribe_status("Status", "AI", timeout=2.0) + + # Server pushes a STATUS packet + mock_server.push_status("Status >> 42.5 <- AI") + + notif = client.status_queue.get(timeout=2.0) + assert notif.status_name == "Status" + assert notif.data == "42.5" + assert notif.module_name == "AI" + + def test_subscribe_callback_is_invoked(self, mock_server): + received: List[StatusNotification] = [] + + with TcpRouterClient() as client: + client.connect(mock_server.host, mock_server.port) + client.subscribe_status("Status", "AI", callback=received.append, timeout=2.0) + mock_server.push_status("Status >> hello <- AI") + time.sleep(0.3) + + assert len(received) == 1 + assert received[0].data == "hello" + + def test_unsubscribe_removes_callback(self, mock_server): + received: List[StatusNotification] = [] + + with TcpRouterClient() as client: + client.connect(mock_server.host, mock_server.port) + client.subscribe_status("Status", "AI", callback=received.append, timeout=2.0) + client.unsubscribe_status("Status", "AI", timeout=2.0) + mock_server.push_status("Status >> ignored <- AI") + time.sleep(0.3) + + # Callback was removed so it should not have been called + assert len(received) == 0 + + def test_multiple_status_notifications(self, mock_server): + received: List[StatusNotification] = [] + + with TcpRouterClient() as client: + client.connect(mock_server.host, mock_server.port) + client.subscribe_status("Temp", "Sensor", callback=received.append, timeout=2.0) + for i in range(5): + mock_server.push_status(f"Temp >> {i} <- Sensor") + time.sleep(0.5) + + assert len(received) == 5 + values = [n.data for n in received] + assert values == ["0", "1", "2", "3", "4"] + + +# --------------------------------------------------------------------------- +# Timeout on disconnect +# --------------------------------------------------------------------------- + +class TestDisconnectBehaviour: + def test_wait_raises_timeout_when_no_resp(self, mock_server): + """send_and_wait raises TimeoutError when the server sends CMD_RESP instead of RESP. + + The mock server's default handler for unknown commands sends a CMD_RESP + handshake, which goes to the cmd_resp queue. send_and_wait waits on + the resp queue, so it must time out. + """ + # "Unknown Sync Command" has no registered response → server sends CMD_RESP + with TcpRouterClient() as client: + client.connect(mock_server.host, mock_server.port) + with pytest.raises(RouterTimeoutError): + client.send_and_wait("Unknown Sync Command", timeout=0.3) diff --git a/SDK/python-package/tests/test_protocol.py b/SDK/python-package/tests/test_protocol.py new file mode 100644 index 0000000..bb5c12e --- /dev/null +++ b/SDK/python-package/tests/test_protocol.py @@ -0,0 +1,154 @@ +"""Unit tests for the protocol v0 codec (_protocol.py).""" + +from __future__ import annotations + +import struct + +import pytest + +from csm_tcp_router._protocol import ( + HEADER_SIZE, + PROTOCOL_VERSION, + decode_header, + encode_packet, + parse_packet, +) +from csm_tcp_router.exceptions import ProtocolError +from csm_tcp_router.models import PacketType + +# --------------------------------------------------------------------------- +# encode_packet +# --------------------------------------------------------------------------- + +class TestEncodePacket: + def test_returns_header_plus_body(self): + data = b"hello" + wire = encode_packet(data, PacketType.CMD) + assert len(wire) == HEADER_SIZE + len(data) + + def test_header_format(self): + data = b"hello" + wire = encode_packet(data, PacketType.CMD) + data_len, version, type_byte, flag1, flag2 = struct.unpack( + "!IBBBB", wire[:HEADER_SIZE] + ) + assert data_len == len(data) + assert version == PROTOCOL_VERSION + assert type_byte == PacketType.CMD.value + assert flag1 == 0 + assert flag2 == 0 + + def test_body_appended_verbatim(self): + data = b"test payload" + wire = encode_packet(data, PacketType.RESP) + assert wire[HEADER_SIZE:] == data + + def test_empty_body(self): + wire = encode_packet(b"", PacketType.CMD_RESP) + assert len(wire) == HEADER_SIZE + (data_len,) = struct.unpack("!I", wire[:4]) + assert data_len == 0 + + def test_custom_flags(self): + wire = encode_packet(b"x", PacketType.INFO, flag1=0xAB, flag2=0xCD) + _, _, _, flag1, flag2 = struct.unpack("!IBBBB", wire[:HEADER_SIZE]) + assert flag1 == 0xAB + assert flag2 == 0xCD + + def test_all_packet_types_encode(self): + for ptype in PacketType: + wire = encode_packet(b"data", ptype) + assert wire[5] == ptype.value # TYPE byte at offset 5 + + def test_utf8_command_string(self): + cmd = "API: Start Sampling -@ DAQmx" + wire = encode_packet(cmd.encode("utf-8"), PacketType.CMD) + assert wire[HEADER_SIZE:] == cmd.encode("utf-8") + + def test_large_payload_length_field(self): + data = b"x" * 1024 + wire = encode_packet(data, PacketType.RESP) + (data_len,) = struct.unpack("!I", wire[:4]) + assert data_len == 1024 + + +# --------------------------------------------------------------------------- +# decode_header +# --------------------------------------------------------------------------- + +class TestDecodeHeader: + def test_round_trip(self): + wire = encode_packet(b"body", PacketType.ASYNC_RESP, flag1=1, flag2=2) + data_len, version, type_byte, flag1, flag2 = decode_header( + wire[:HEADER_SIZE] + ) + assert data_len == 4 + assert version == PROTOCOL_VERSION + assert type_byte == PacketType.ASYNC_RESP.value + assert flag1 == 1 + assert flag2 == 2 + + def test_wrong_length_raises(self): + with pytest.raises(ProtocolError, match="header"): + decode_header(b"\x00" * 7) + + def test_zero_length_raises(self): + with pytest.raises(ProtocolError): + decode_header(b"") + + +# --------------------------------------------------------------------------- +# parse_packet +# --------------------------------------------------------------------------- + +class TestParsePacket: + def _make_wire(self, data: bytes, ptype: PacketType) -> tuple: + wire = encode_packet(data, ptype) + return wire[:HEADER_SIZE], wire[HEADER_SIZE:] + + def test_basic_round_trip(self): + header, body = self._make_wire(b"hello", PacketType.RESP) + pkt = parse_packet(header, body) + assert pkt.type == PacketType.RESP + assert pkt.data == b"hello" + assert pkt.version == PROTOCOL_VERSION + + def test_all_known_types(self): + for ptype in PacketType: + header, body = self._make_wire(b"data", ptype) + pkt = parse_packet(header, body) + assert pkt.type == ptype + + def test_unknown_type_mapped_to_info(self): + # Manually craft a packet with an unknown type byte (0xFF) + raw_header = struct.pack("!IBBBB", 4, PROTOCOL_VERSION, 0xFF, 0, 0) + pkt = parse_packet(raw_header, b"data") + assert pkt.type == PacketType.INFO + + def test_body_length_mismatch_raises(self): + header, _ = self._make_wire(b"hello", PacketType.CMD) + with pytest.raises(ProtocolError, match="mismatch"): + parse_packet(header, b"hi") # shorter body + + def test_empty_body(self): + header, body = self._make_wire(b"", PacketType.CMD_RESP) + pkt = parse_packet(header, body) + assert pkt.data == b"" + + def test_flags_preserved(self): + wire = encode_packet(b"x", PacketType.STATUS, flag1=3, flag2=7) + pkt = parse_packet(wire[:HEADER_SIZE], wire[HEADER_SIZE:]) + assert pkt.flag1 == 3 + assert pkt.flag2 == 7 + + def test_header_too_short_raises(self): + with pytest.raises(ProtocolError): + parse_packet(b"\x00" * 4, b"") + + +# --------------------------------------------------------------------------- +# HEADER_SIZE constant +# --------------------------------------------------------------------------- + +def test_header_size_is_eight(): + assert HEADER_SIZE == 8 From a5bb0adda4b647ed9c6c04d7ad45e22ba0aafe22 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Wed, 22 Apr 2026 20:31:10 +0800 Subject: [PATCH 2/2] Add bilingual VI API reference docs for CSM-TCP-Router (Server + Client) (#35) * Initial plan * docs: add bilingual VI API documentation for CSM-TCP-Router Agent-Logs-Url: https://github.com/NEVSTOP-LAB/CSM-TCP-Router-App/sessions/cacb955d-6c38-4fc7-b30d-9381fbbd06e1 Co-authored-by: nevstop <8196752+nevstop@users.noreply.github.com> * docs: move VI API docs under src and align reference format Agent-Logs-Url: https://github.com/NEVSTOP-LAB/CSM-TCP-Router-App/sessions/117da425-df26-4ecc-a8c4-ab0e98412e50 Co-authored-by: nevstop <8196752+nevstop@users.noreply.github.com> * docs: restore compatibility notes in status API sections Agent-Logs-Url: https://github.com/NEVSTOP-LAB/CSM-TCP-Router-App/sessions/117da425-df26-4ecc-a8c4-ab0e98412e50 Co-authored-by: nevstop <8196752+nevstop@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: nevstop <8196752+nevstop@users.noreply.github.com> --- .../VI Description(en-us) - CSM-TCP-Router.md | 95 +++++++++++++++++++ .../VI Description(zh-cn) - CSM-TCP-Router.md | 95 +++++++++++++++++++ 2 files changed, 190 insertions(+) create mode 100644 src/help/NEVSTOP/Communicable State Machine(CSM)/VI Description/VI Description(en-us)/VI Description(en-us) - CSM-TCP-Router.md create mode 100644 src/help/NEVSTOP/Communicable State Machine(CSM)/VI Description/VI Description(zh-cn)/VI Description(zh-cn) - CSM-TCP-Router.md diff --git a/src/help/NEVSTOP/Communicable State Machine(CSM)/VI Description/VI Description(en-us)/VI Description(en-us) - CSM-TCP-Router.md b/src/help/NEVSTOP/Communicable State Machine(CSM)/VI Description/VI Description(en-us)/VI Description(en-us) - CSM-TCP-Router.md new file mode 100644 index 0000000..8f7ae0a --- /dev/null +++ b/src/help/NEVSTOP/Communicable State Machine(CSM)/VI Description/VI Description(en-us)/VI Description(en-us) - CSM-TCP-Router.md @@ -0,0 +1,95 @@ +# CSM-TCP-Router + +## API + +> [!NOTE] +> CSM-TCP-Router API Scope +> +> CSM-TCP-Router APIs are split into two parts: +> - Server VIs: Start and host the TCP router service for CSM modules. +> - Client VIs: Connect to the router and send/receive commands. +> +> The API list below is inferred from current project structure, VI naming, and shipped examples. + +## Server VIs + +### CSM-TCP-Router.vi +Core router VI in `src/_addons/TCP-Router/`. + +Starts the CSM TCP router communication layer, handles packet routing, and serves Router built-in management commands. + +### CSM-TCP-Router(Server).vi +Server startup VI in `src/Server/`. + +Standard runnable entry VI used by the example project to host CSM modules through CSM-TCP-Router. + +### Router Built-in Commands +Commands provided by the router service side: + +- `List`: List all available CSM modules. +- `List API`: List exposed APIs of a specified module. +- `List State`: List CSM states of a specified module. +- `Help`: Return module help text from VI Description. +- `Refresh lvcsm`: Refresh cached lvcsm data. + +## Client VIs + +Client API VIs are under `src/_addons/TCP-Router/ClientAPI/`. + +### Obtain.vi +Create and connect a client session to the TCP router server. + +### Release.vi +Release a client session and related resources. + +### Send Message and Wait for Reply.vi +Send a synchronous command and wait for the final response. + +### Post Message.vi +Post an asynchronous command (non-blocking for final response). + +### Post No-Rep Message.vi +Post an asynchronous command that does not require final response. + +### Ping.vi +Check server reachability and return communication elapsed time. + +### Wait for Server.vi +Wait until the server is reachable or timeout is reached. + +### Register Status Change.vi +Register a status-change subscription callback. + +### Unregister Status Change.vi +Unregister a status-change subscription callback. + +> [!NOTE] +> `Register Status Change.vi` / `Unregister Status Change.vi` are kept for compatibility. +> For new integrations, prefer the `... for Client.vi` variants. + +### Register Status for Client.vi +Register status subscription for a specified client context. + +### Unregister Status for Client.vi +Unregister status subscription for a specified client context. + +### Status Queue.vi +Receive status updates through queue-based API. + +### ASync-Response Queue.vi +Receive asynchronous command responses through queue-based API. + +### ASync-Response User Event.vi +Receive asynchronous command responses through User Event API. + +### Register Broadcast.vi +Register broadcast-message subscription. + +### Unregister Broadcast.vi +Unregister broadcast-message subscription. + +### Register Broadcast for Client.vi +Register broadcast-message subscription for a specified client context. + +### Unregister Broadcast for Client.vi +Unregister broadcast-message subscription for a specified client context. diff --git a/src/help/NEVSTOP/Communicable State Machine(CSM)/VI Description/VI Description(zh-cn)/VI Description(zh-cn) - CSM-TCP-Router.md b/src/help/NEVSTOP/Communicable State Machine(CSM)/VI Description/VI Description(zh-cn)/VI Description(zh-cn) - CSM-TCP-Router.md new file mode 100644 index 0000000..c3f13f1 --- /dev/null +++ b/src/help/NEVSTOP/Communicable State Machine(CSM)/VI Description/VI Description(zh-cn)/VI Description(zh-cn) - CSM-TCP-Router.md @@ -0,0 +1,95 @@ +# CSM-TCP-Router + +## API + +> [!NOTE] +> CSM-TCP-Router API 范围 +> +> CSM-TCP-Router 的 API 分为两部分: +> - Server 侧 VI:启动并承载 CSM 模块的 TCP Router 服务。 +> - Client 侧 VI:连接 Router 并发送/接收指令。 +> +> 下述 API 清单依据当前项目结构、VI 命名和示例工程推断整理。 + +## Server 侧 VI + +### CSM-TCP-Router.vi +位于 `src/_addons/TCP-Router/` 的核心 Router VI。 + +用于启动 CSM TCP Router 通讯层,处理数据包路由,并提供 Router 内建管理指令。 + +### CSM-TCP-Router(Server).vi +位于 `src/Server/` 的服务端启动 VI。 + +作为示例工程的标准入口 VI,用于通过 CSM-TCP-Router 对外承载 CSM 模块。 + +### Router 内建指令 +由 Router 服务端提供的内建指令: + +- `List`:列出所有可用 CSM 模块。 +- `List API`:列出指定模块暴露的 API。 +- `List State`:列出指定模块可用的 CSM 状态。 +- `Help`:返回模块 VI Description 中的帮助文本。 +- `Refresh lvcsm`:刷新 lvcsm 缓存数据。 + +## Client 侧 VI + +Client API 位于 `src/_addons/TCP-Router/ClientAPI/`。 + +### Obtain.vi +创建并连接到 TCP Router 服务端的客户端会话。 + +### Release.vi +释放客户端会话及相关资源。 + +### Send Message and Wait for Reply.vi +发送同步指令并等待最终响应。 + +### Post Message.vi +发送异步指令(调用不阻塞等待最终响应)。 + +### Post No-Rep Message.vi +发送无需最终响应的异步指令。 + +### Ping.vi +检查服务端可达性并返回通讯耗时。 + +### Wait for Server.vi +等待服务端可连接,直到成功或超时。 + +### Register Status Change.vi +注册状态变更订阅回调。 + +### Unregister Status Change.vi +取消状态变更订阅回调。 + +> [!NOTE] +> `Register Status Change.vi` / `Unregister Status Change.vi` 主要用于兼容旧用法。 +> 新的集成建议优先使用 `... for Client.vi` 版本接口。 + +### Register Status for Client.vi +面向指定客户端上下文注册状态订阅。 + +### Unregister Status for Client.vi +面向指定客户端上下文取消状态订阅。 + +### Status Queue.vi +通过队列方式获取状态更新。 + +### ASync-Response Queue.vi +通过队列方式获取异步指令响应。 + +### ASync-Response User Event.vi +通过 User Event 方式获取异步指令响应。 + +### Register Broadcast.vi +注册广播消息订阅。 + +### Unregister Broadcast.vi +取消广播消息订阅。 + +### Register Broadcast for Client.vi +面向指定客户端上下文注册广播订阅。 + +### Unregister Broadcast for Client.vi +面向指定客户端上下文取消广播订阅。