otcat 1.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
otcat-1.0.0/.gitignore ADDED
@@ -0,0 +1,12 @@
1
+ /bin/
2
+ /coverage.out
3
+ *.test
4
+ /dist/
5
+ __pycache__/
6
+ *.pyc
7
+ python/dist/
8
+ python/build/
9
+ python/*.egg-info/
10
+ python/.pytest_cache/
11
+ python/.mypy_cache/
12
+ python/.ruff_cache/
otcat-1.0.0/PKG-INFO ADDED
@@ -0,0 +1,138 @@
1
+ Metadata-Version: 2.4
2
+ Name: otcat
3
+ Version: 1.0.0
4
+ Summary: Python bindings for otcat, the netcat for industrial I/O -- reads and writes Modbus TCP through the real Go core.
5
+ Project-URL: Homepage, https://github.com/QuitOperation/otcat
6
+ Project-URL: Go core, https://github.com/QuitOperation/otcat
7
+ Project-URL: Documentation, https://github.com/QuitOperation/otcat/tree/main/python
8
+ Project-URL: Issues, https://github.com/QuitOperation/otcat/issues
9
+ Author-email: Moustafa Mahmoud Atta <MoustafaAt1a@outlook.com>
10
+ License: MIT
11
+ Keywords: automation,ics,industrial,modbus,ot,plc,scada
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Intended Audience :: Manufacturing
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Operating System :: OS Independent
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3.9
19
+ Classifier: Programming Language :: Python :: 3.10
20
+ Classifier: Programming Language :: Python :: 3.11
21
+ Classifier: Programming Language :: Python :: 3.12
22
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
23
+ Classifier: Topic :: System :: Hardware
24
+ Classifier: Topic :: System :: Networking
25
+ Requires-Python: >=3.9
26
+ Provides-Extra: all
27
+ Requires-Dist: fastapi>=0.100; extra == 'all'
28
+ Requires-Dist: pandas>=1.5; extra == 'all'
29
+ Requires-Dist: streamlit>=1.28; extra == 'all'
30
+ Requires-Dist: uvicorn>=0.23; extra == 'all'
31
+ Provides-Extra: dashboard
32
+ Requires-Dist: streamlit>=1.28; extra == 'dashboard'
33
+ Provides-Extra: dev
34
+ Requires-Dist: mypy>=1.5; extra == 'dev'
35
+ Requires-Dist: pandas>=1.5; extra == 'dev'
36
+ Requires-Dist: pytest-asyncio>=0.21; extra == 'dev'
37
+ Requires-Dist: pytest>=7.4; extra == 'dev'
38
+ Requires-Dist: ruff>=0.1; extra == 'dev'
39
+ Provides-Extra: fastapi
40
+ Requires-Dist: fastapi>=0.100; extra == 'fastapi'
41
+ Requires-Dist: uvicorn>=0.23; extra == 'fastapi'
42
+ Provides-Extra: pandas
43
+ Requires-Dist: pandas>=1.5; extra == 'pandas'
44
+ Description-Content-Type: text/markdown
45
+
46
+ # otcat (Python)
47
+
48
+ Python bindings for [otcat](https://github.com/QuitOperation/otcat),
49
+ the netcat for industrial I/O. This package wraps the real, compiled
50
+ Go binary — every Modbus read and write is still executed by the same
51
+ tested, fuzzed Go core the main project's paper describes, not a
52
+ Python reimplementation of the protocol.
53
+
54
+ ```python
55
+ from otcat import Client
56
+
57
+ c = Client("127.0.0.1:502")
58
+ v = c.read("holding:40001")
59
+ print(v.value, v.quality, v.ts)
60
+
61
+ for v in c.watch("holding:40001", interval="500ms", count=10):
62
+ print(v.ts, v.value)
63
+
64
+ c.write("holding:40001", 100) # confirm=True by default -- see "Write safety" below
65
+ ```
66
+
67
+ ## Install
68
+
69
+ ```sh
70
+ pip install otcat # core client only, zero extra dependencies
71
+ pip install otcat[pandas] # + DataFrame helpers
72
+ pip install otcat[fastapi] # + the async client's natural home
73
+ pip install otcat[dashboard] # + Streamlit
74
+ pip install otcat[all] # everything
75
+ ```
76
+
77
+ The Go binary itself ships bundled inside platform-specific wheels; if
78
+ none is available for your platform, install it separately (`go
79
+ install github.com/QuitOperation/otcat/cmd/otcat@latest`, or one of the
80
+ [Cloudsmith packages](../docs/releasing.md)) and either put it on
81
+ `$PATH` or point `OTCAT_BINARY` at it directly. See
82
+ [`docs/packaging.md`](docs/packaging.md) for exactly how the bundled
83
+ binary is resolved.
84
+
85
+ ## Write safety: one deliberate difference from the CLI
86
+
87
+ The Go CLI refuses every write by default unless `--confirm` is passed
88
+ or an interactive operator answers a y/N prompt — the right default
89
+ for a human typing a command who might have a typo. `Client.write()`
90
+ defaults to `confirm=True` instead, because a library call is already
91
+ the result of a programmer's code deciding, deliberately, to write a
92
+ specific value; there is no keystroke left to protect against. Pass
93
+ `confirm=False` if you want the stricter behavior and are prepared to
94
+ catch `WriteAbortedError`.
95
+
96
+ ## Modules
97
+
98
+ | Module | What it's for |
99
+ |---|---|
100
+ | `otcat.Client` | Synchronous read/write/watch/dry_run — the core |
101
+ | `otcat.aio.AsyncClient` | Same API, `asyncio`-native — FastAPI, WebSockets |
102
+ | `otcat.pandas_ext` | `Value` lists ↔ `DataFrame`, plus a bounded `RollingBuffer` for live dashboards |
103
+ | `otcat.alerting` | Threshold rules with debounce → callbacks, for automations and paging |
104
+ | `otcat.audit` | Read-only OT asset discovery / device fingerprinting (never writes — see its module docstring) |
105
+
106
+ ## Examples
107
+
108
+ See [`examples/`](examples/): a pandas time-series pull, a FastAPI
109
+ service with a live WebSocket, a Streamlit dashboard, a threshold
110
+ alerting script, and a read-only network audit script.
111
+
112
+ ## Exit codes → exceptions
113
+
114
+ | Go CLI exit code | Python exception |
115
+ |---|---|
116
+ | 1 | `otcat.UsageError` |
117
+ | 2 | `otcat.ConnectionError` |
118
+ | 3 | `otcat.ProtocolError` (`.exception_code` holds the Modbus exception, e.g. `"0x02"`) |
119
+ | 4 | `otcat.WriteAbortedError` |
120
+ | 5 | `otcat.IOFailureError` |
121
+ | (n/a) | `otcat.Timeout` — a Python-side watchdog, distinct from otcat's own `--timeout` |
122
+
123
+ ## Testing
124
+
125
+ ```sh
126
+ pip install -e ".[dev]"
127
+ pytest
128
+ ```
129
+
130
+ Every test is an integration test against a freshly spawned
131
+ `otcat-mockplc` — see `tests/conftest.py`. Building `otcat-mockplc`
132
+ requires a Go 1.22+ toolchain on `$PATH` at test time (the shipped
133
+ wheel itself needs no Go toolchain to *use*, only this repo's test
134
+ suite needs one to build its own test fixture).
135
+
136
+ ## License
137
+
138
+ MIT — same as the Go core.
otcat-1.0.0/README.md ADDED
@@ -0,0 +1,93 @@
1
+ # otcat (Python)
2
+
3
+ Python bindings for [otcat](https://github.com/QuitOperation/otcat),
4
+ the netcat for industrial I/O. This package wraps the real, compiled
5
+ Go binary — every Modbus read and write is still executed by the same
6
+ tested, fuzzed Go core the main project's paper describes, not a
7
+ Python reimplementation of the protocol.
8
+
9
+ ```python
10
+ from otcat import Client
11
+
12
+ c = Client("127.0.0.1:502")
13
+ v = c.read("holding:40001")
14
+ print(v.value, v.quality, v.ts)
15
+
16
+ for v in c.watch("holding:40001", interval="500ms", count=10):
17
+ print(v.ts, v.value)
18
+
19
+ c.write("holding:40001", 100) # confirm=True by default -- see "Write safety" below
20
+ ```
21
+
22
+ ## Install
23
+
24
+ ```sh
25
+ pip install otcat # core client only, zero extra dependencies
26
+ pip install otcat[pandas] # + DataFrame helpers
27
+ pip install otcat[fastapi] # + the async client's natural home
28
+ pip install otcat[dashboard] # + Streamlit
29
+ pip install otcat[all] # everything
30
+ ```
31
+
32
+ The Go binary itself ships bundled inside platform-specific wheels; if
33
+ none is available for your platform, install it separately (`go
34
+ install github.com/QuitOperation/otcat/cmd/otcat@latest`, or one of the
35
+ [Cloudsmith packages](../docs/releasing.md)) and either put it on
36
+ `$PATH` or point `OTCAT_BINARY` at it directly. See
37
+ [`docs/packaging.md`](docs/packaging.md) for exactly how the bundled
38
+ binary is resolved.
39
+
40
+ ## Write safety: one deliberate difference from the CLI
41
+
42
+ The Go CLI refuses every write by default unless `--confirm` is passed
43
+ or an interactive operator answers a y/N prompt — the right default
44
+ for a human typing a command who might have a typo. `Client.write()`
45
+ defaults to `confirm=True` instead, because a library call is already
46
+ the result of a programmer's code deciding, deliberately, to write a
47
+ specific value; there is no keystroke left to protect against. Pass
48
+ `confirm=False` if you want the stricter behavior and are prepared to
49
+ catch `WriteAbortedError`.
50
+
51
+ ## Modules
52
+
53
+ | Module | What it's for |
54
+ |---|---|
55
+ | `otcat.Client` | Synchronous read/write/watch/dry_run — the core |
56
+ | `otcat.aio.AsyncClient` | Same API, `asyncio`-native — FastAPI, WebSockets |
57
+ | `otcat.pandas_ext` | `Value` lists ↔ `DataFrame`, plus a bounded `RollingBuffer` for live dashboards |
58
+ | `otcat.alerting` | Threshold rules with debounce → callbacks, for automations and paging |
59
+ | `otcat.audit` | Read-only OT asset discovery / device fingerprinting (never writes — see its module docstring) |
60
+
61
+ ## Examples
62
+
63
+ See [`examples/`](examples/): a pandas time-series pull, a FastAPI
64
+ service with a live WebSocket, a Streamlit dashboard, a threshold
65
+ alerting script, and a read-only network audit script.
66
+
67
+ ## Exit codes → exceptions
68
+
69
+ | Go CLI exit code | Python exception |
70
+ |---|---|
71
+ | 1 | `otcat.UsageError` |
72
+ | 2 | `otcat.ConnectionError` |
73
+ | 3 | `otcat.ProtocolError` (`.exception_code` holds the Modbus exception, e.g. `"0x02"`) |
74
+ | 4 | `otcat.WriteAbortedError` |
75
+ | 5 | `otcat.IOFailureError` |
76
+ | (n/a) | `otcat.Timeout` — a Python-side watchdog, distinct from otcat's own `--timeout` |
77
+
78
+ ## Testing
79
+
80
+ ```sh
81
+ pip install -e ".[dev]"
82
+ pytest
83
+ ```
84
+
85
+ Every test is an integration test against a freshly spawned
86
+ `otcat-mockplc` — see `tests/conftest.py`. Building `otcat-mockplc`
87
+ requires a Go 1.22+ toolchain on `$PATH` at test time (the shipped
88
+ wheel itself needs no Go toolchain to *use*, only this repo's test
89
+ suite needs one to build its own test fixture).
90
+
91
+ ## License
92
+
93
+ MIT — same as the Go core.
@@ -0,0 +1,118 @@
1
+ # Python packaging: how the Go binary gets into the wheel
2
+
3
+ ## What's real vs. what's the plan
4
+
5
+ This source tree ships one bundled binary,
6
+ `python/otcat/_bin/otcat-linux-amd64`, built directly from this
7
+ project's own Go source in this environment. It is what makes the
8
+ Python test suite an actual integration test rather than a mock of a
9
+ mock: `Client()` with no arguments resolves to that real binary and
10
+ talks to a real `otcat-mockplc` over a real socket (see
11
+ `tests/conftest.py`).
12
+
13
+ Shipping *one* platform's binary in a source checkout is fine for
14
+ development. Shipping it on PyPI is not — a `pip install otcat` on
15
+ macOS or Windows would silently get a Linux binary that can't execute.
16
+ The correct end state is **platform-specific wheels**, each containing
17
+ only the one binary that platform needs, which is what the CI
18
+ pipeline below is designed to produce. It has not been run for real
19
+ end-to-end (that requires PyPI publish credentials this project's
20
+ build environment doesn't have) — the Linux leg has been verified
21
+ directly (build binary → build wheel → install wheel → run tests
22
+ against it, all in this repo's own CI-equivalent commands); the
23
+ macOS/Windows legs follow the identical, mechanical pattern.
24
+
25
+ ## The pipeline
26
+
27
+ ```
28
+ GoReleaser (.goreleaser.yaml)
29
+ --> dist/otcat_<os>_<arch>/otcat[.exe] (already verified, see docs/releasing.md)
30
+ |
31
+ v
32
+ copy each into python/otcat/_bin/otcat-<os>-<arch>[.exe]
33
+ |
34
+ v
35
+ build ONE wheel per (os, arch) pair, each containing only its own binary,
36
+ tagged with the right platform tag (manylinux_x86_64, macosx_11_0_arm64,
37
+ win_amd64, ...) so pip's own platform matching does the rest
38
+ |
39
+ v
40
+ twine upload dist/*.whl (+ one sdist, which ships NO binary and falls
41
+ back to $PATH / OTCAT_BINARY at import time --
42
+ see otcat/_binary.py)
43
+ ```
44
+
45
+ ## Why platform-specific wheels instead of one universal wheel
46
+
47
+ A wheel is not allowed to contain multiple incompatible native
48
+ binaries and pick one at install time — `pip` picks the *wheel* based
49
+ on platform tags before anything inside it runs. The standard,
50
+ widely-used pattern (the same one `ruff`, ripgrep's Python wrapper, and
51
+ many other Go/Rust-backed Python tools use) is: build the native
52
+ binary once per target with the language's own cross-compiler (Go's
53
+ `GOOS`/`GOARCH`, already free — see `.goreleaser.yaml`), then build a
54
+ separate wheel per target that embeds just that one binary and
55
+ declares the matching platform tag.
56
+
57
+ ## GitHub Actions sketch (`python-release.yml`, not yet added to
58
+ ## `.github/workflows/` -- add it once PyPI publishing is actually
59
+ ## wanted, so a stray push can't trigger a broken/premature publish)
60
+
61
+ ```yaml
62
+ name: python-release
63
+ on:
64
+ push:
65
+ tags: ["v*"]
66
+ jobs:
67
+ build-wheels:
68
+ strategy:
69
+ matrix:
70
+ include:
71
+ - { os: ubuntu-latest, goos: linux, goarch: amd64, tag: manylinux_2_17_x86_64 }
72
+ - { os: ubuntu-latest, goos: linux, goarch: arm64, tag: manylinux_2_17_aarch64 }
73
+ - { os: macos-latest, goos: darwin, goarch: amd64, tag: macosx_11_0_x86_64 }
74
+ - { os: macos-latest, goos: darwin, goarch: arm64, tag: macosx_11_0_arm64 }
75
+ - { os: windows-latest, goos: windows, goarch: amd64, tag: win_amd64 }
76
+ runs-on: ${{ matrix.os }}
77
+ steps:
78
+ - uses: actions/checkout@v4
79
+ - uses: actions/setup-go@v5
80
+ with: { go-version: "1.22" }
81
+ - run: |
82
+ ext=""; [ "${{ matrix.goos }}" = "windows" ] && ext=".exe"
83
+ GOOS=${{ matrix.goos }} GOARCH=${{ matrix.goarch }} \
84
+ go build -ldflags "-s -w" \
85
+ -o python/otcat/_bin/otcat-${{ matrix.goos }}-${{ matrix.goarch }}$ext \
86
+ ./cmd/otcat
87
+ - uses: actions/setup-python@v5
88
+ with: { python-version: "3.12" }
89
+ - run: pip install build
90
+ - working-directory: python
91
+ run: python -m build --wheel -C--build-option=--plat-name -C--build-option=${{ matrix.tag }}
92
+ - uses: actions/upload-artifact@v4
93
+ with: { name: wheel-${{ matrix.tag }}, path: python/dist/*.whl }
94
+
95
+ publish:
96
+ needs: build-wheels
97
+ runs-on: ubuntu-latest
98
+ steps:
99
+ - uses: actions/download-artifact@v4
100
+ with: { path: dist, merge-multiple: true }
101
+ - run: pip install twine
102
+ - run: twine upload dist/*.whl
103
+ env:
104
+ TWINE_USERNAME: __token__
105
+ TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }}
106
+ ```
107
+
108
+ ## Before actually publishing to PyPI
109
+
110
+ - Confirm the name `otcat` is available on PyPI (not checked from this
111
+ environment — no network path to pypi.org's search from here that
112
+ wouldn't also require deciding on a fallback name in the same
113
+ breath; do this manually before the first publish).
114
+ - Decide the sdist's behavior deliberately: it ships no binary at all
115
+ (by design, per the pipeline above), so `pip install otcat` from
116
+ source on a platform with no wheel falls straight to `_binary.py`'s
117
+ `$PATH`/`OTCAT_BINARY` search. Document that clearly in the PyPI
118
+ project description, not just in this file.
@@ -0,0 +1,47 @@
1
+ """Threshold alerting: page/log/trigger an automation when a tank
2
+ level crosses a limit, with debounce so noise near the threshold
3
+ doesn't spam.
4
+
5
+ otcat-mockplc --addr 127.0.0.1:15020 &
6
+ python examples/alerting_example.py 127.0.0.1:15020
7
+ """
8
+ import sys
9
+
10
+ from otcat import Client
11
+ from otcat.alerting import AlertEngine
12
+
13
+
14
+ def page_oncall(value) -> None:
15
+ print(f"[ALERT] {value.address} = {value.value} at {value.ts} -- paging on-call")
16
+
17
+
18
+ def clear_alert(value) -> None:
19
+ print(f"[CLEAR] {value.address} = {value.value} at {value.ts} -- back to normal")
20
+
21
+
22
+ def trigger_automation(value) -> None:
23
+ print(f"[AUTOMATION] {value.address} crossed threshold -- running downstream action")
24
+
25
+
26
+ def main() -> None:
27
+ endpoint = sys.argv[1] if len(sys.argv) > 1 else "127.0.0.1:15020"
28
+ client = Client(endpoint, raw_address=True)
29
+
30
+ engine = AlertEngine(client)
31
+ engine.threshold(
32
+ "holding:0", above=25_00, # tank level is x100 fixed point; 25.00%
33
+ on_trigger=page_oncall, on_clear=clear_alert, debounce=3,
34
+ name="tank-high",
35
+ )
36
+ engine.threshold(
37
+ "holding:0", above=21_00,
38
+ on_trigger=trigger_automation, debounce=2,
39
+ name="tank-automation-threshold",
40
+ )
41
+
42
+ print(f"watching {endpoint} holding:0, polling every 500ms (Ctrl+C to stop)")
43
+ engine.run(interval_seconds=0.5)
44
+
45
+
46
+ if __name__ == "__main__":
47
+ main()
@@ -0,0 +1,40 @@
1
+ """A read-only asset audit: fingerprint a device, then sweep a table
2
+ range and report which addresses respond. Never writes -- see
3
+ otcat.audit's module docstring for why that's a permanent property of
4
+ this module, not a flag you can turn off.
5
+
6
+ Only run this against equipment you are authorized to test.
7
+
8
+ otcat-mockplc --addr 127.0.0.1:15020 &
9
+ python examples/audit_example.py 127.0.0.1:15020
10
+ """
11
+ import sys
12
+
13
+ from otcat import Client
14
+ from otcat.audit import fingerprint, scan_range
15
+
16
+
17
+ def main() -> None:
18
+ endpoint = sys.argv[1] if len(sys.argv) > 1 else "127.0.0.1:15020"
19
+ client = Client(endpoint, raw_address=True, timeout=2.0)
20
+
21
+ print(f"fingerprinting {endpoint} ...")
22
+ fp = fingerprint(client, samples=20)
23
+ if not fp.reachable:
24
+ print("device unreachable")
25
+ return
26
+ print(f" reachable: {fp.reachable}")
27
+ print(f" tables responsive: {fp.tables_responsive}")
28
+ print(f" latency p50={fp.latency_ms_p50:.2f}ms p99={fp.latency_ms_p99:.2f}ms "
29
+ f"({fp.samples} samples)")
30
+
31
+ print()
32
+ print("scanning holding:0..49 ...")
33
+ report = scan_range(client, "holding", start=0, count=50, delay_seconds=0.0)
34
+ print(" " + report.summary())
35
+ print(f" responsive addresses: {report.responsive_addresses[:10]}"
36
+ f"{' ...' if len(report.responsive_addresses) > 10 else ''}")
37
+
38
+
39
+ if __name__ == "__main__":
40
+ main()
@@ -0,0 +1,74 @@
1
+ """A minimal FastAPI service exposing a Modbus device over HTTP/WebSocket.
2
+
3
+ pip install otcat[fastapi]
4
+ otcat-mockplc --addr 127.0.0.1:15020 &
5
+ OTCAT_ENDPOINT=127.0.0.1:15020 uvicorn fastapi_service:app --reload
6
+
7
+ Then:
8
+ curl localhost:8000/read/holding:100?raw_address=true
9
+ curl -X POST localhost:8000/write -d '{"spec":"holding:100","value":42,"raw_address":true}' -H 'content-type: application/json'
10
+ websocat ws://localhost:8000/watch/holding:0?raw_address=true&interval=500ms
11
+
12
+ This intentionally does not add its own write-confirmation UI/flow on
13
+ top of otcat's -- see otcat.Client's docstring on why the library
14
+ defaults to confirm=True. If you're building a service that lets a
15
+ remote caller trigger writes, put your own authorization and
16
+ confirmation step in *this* layer (the FastAPI app), since the network
17
+ boundary that actually needs protecting is "who can call this HTTP
18
+ endpoint at all," which is entirely this app's responsibility, not
19
+ otcat's.
20
+ """
21
+ import os
22
+
23
+ from fastapi import FastAPI, WebSocket, WebSocketDisconnect
24
+ from pydantic import BaseModel
25
+
26
+ from otcat import ProtocolError
27
+ from otcat import ConnectionError as OtcatConnectionError
28
+ from otcat.aio import AsyncClient
29
+
30
+ ENDPOINT = os.environ.get("OTCAT_ENDPOINT", "127.0.0.1:15020")
31
+
32
+ app = FastAPI(title="otcat FastAPI example")
33
+
34
+
35
+ class WriteRequest(BaseModel):
36
+ spec: str
37
+ value: str
38
+ raw_address: bool = False
39
+ confirm: bool = True
40
+
41
+
42
+ @app.get("/read/{spec:path}")
43
+ async def read(spec: str, raw_address: bool = False):
44
+ client = AsyncClient(ENDPOINT, raw_address=raw_address)
45
+ try:
46
+ v = await client.read(spec)
47
+ except ProtocolError as e:
48
+ return {"error": str(e), "exception_code": e.exception_code}
49
+ except OtcatConnectionError as e:
50
+ return {"error": str(e)}
51
+ return {"address": v.address, "value": v.value, "quality": v.quality, "ts": v.ts.isoformat()}
52
+
53
+
54
+ @app.post("/write")
55
+ async def write(req: WriteRequest):
56
+ client = AsyncClient(ENDPOINT, raw_address=req.raw_address)
57
+ try:
58
+ await client.write(req.spec, req.value, confirm=req.confirm)
59
+ except Exception as e:
60
+ return {"ok": False, "error": str(e)}
61
+ return {"ok": True}
62
+
63
+
64
+ @app.websocket("/watch/{spec:path}")
65
+ async def watch(websocket: WebSocket, spec: str, raw_address: bool = False, interval: str = "1s"):
66
+ await websocket.accept()
67
+ client = AsyncClient(ENDPOINT, raw_address=raw_address)
68
+ try:
69
+ async for v in client.watch(spec, interval=interval):
70
+ await websocket.send_json(
71
+ {"address": v.address, "value": v.value, "quality": v.quality, "ts": v.ts.isoformat()}
72
+ )
73
+ except WebSocketDisconnect:
74
+ pass
@@ -0,0 +1,35 @@
1
+ """Pull a live register into pandas and compute rolling statistics --
2
+ the shape of a lot of predictive-maintenance and data-science work:
3
+ get real sensor data into a DataFrame with the least possible glue code.
4
+
5
+ Run against otcat-mockplc for a no-hardware demo:
6
+ otcat-mockplc --addr 127.0.0.1:15020 &
7
+ python examples/pandas_quickstart.py 127.0.0.1:15020
8
+ """
9
+ import sys
10
+
11
+ from otcat import Client
12
+ from otcat.pandas_ext import watch_df
13
+
14
+
15
+ def main() -> None:
16
+ endpoint = sys.argv[1] if len(sys.argv) > 1 else "127.0.0.1:15020"
17
+ client = Client(endpoint, raw_address=True)
18
+
19
+ print(f"pulling 30 samples of holding:0 from {endpoint} ...")
20
+ df = watch_df(client, "holding:0", count=30, interval="200ms")
21
+
22
+ # holding:0 on otcat-mockplc is a tank level, x100 fixed point
23
+ df["percent"] = df["value"] / 100.0
24
+
25
+ print(df[["address", "percent", "quality"]].tail(10))
26
+ print()
27
+ print("rolling 5-sample mean:")
28
+ print(df["percent"].rolling(5).mean().tail(10))
29
+ print()
30
+ print(f"min={df['percent'].min():.2f}% max={df['percent'].max():.2f}% "
31
+ f"mean={df['percent'].mean():.2f}% std={df['percent'].std():.3f}")
32
+
33
+
34
+ if __name__ == "__main__":
35
+ main()
@@ -0,0 +1,52 @@
1
+ """A live dashboard for one register.
2
+
3
+ pip install otcat[dashboard]
4
+ otcat-mockplc --addr 127.0.0.1:15020 &
5
+ streamlit run streamlit_dashboard.py
6
+ """
7
+ import time
8
+
9
+ import pandas as pd
10
+ import streamlit as st
11
+
12
+ from otcat import Client
13
+ from otcat.pandas_ext import RollingBuffer
14
+
15
+ st.set_page_config(page_title="otcat live dashboard", layout="wide")
16
+ st.title("otcat live dashboard")
17
+
18
+ endpoint = st.sidebar.text_input("Modbus endpoint", "127.0.0.1:15020")
19
+ spec = st.sidebar.text_input("Address spec", "holding:0")
20
+ raw_address = st.sidebar.checkbox("--raw-address", value=True)
21
+ window = st.sidebar.slider("Rolling window (samples)", 10, 500, 100)
22
+ interval_ms = st.sidebar.slider("Poll interval (ms)", 100, 2000, 500)
23
+
24
+ if "buffer" not in st.session_state or st.session_state.get("_spec") != spec:
25
+ st.session_state.buffer = RollingBuffer(maxlen=window)
26
+ st.session_state._spec = spec
27
+
28
+ placeholder = st.empty()
29
+ run = st.sidebar.toggle("Running", value=True)
30
+
31
+ if run:
32
+ client = Client(endpoint, raw_address=raw_address, timeout=2.0)
33
+ try:
34
+ v = client.read(spec)
35
+ st.session_state.buffer.push(v)
36
+ except Exception as e:
37
+ st.sidebar.error(str(e))
38
+
39
+ df = st.session_state.buffer.to_dataframe()
40
+ with placeholder.container():
41
+ col1, col2, col3 = st.columns(3)
42
+ if not df.empty:
43
+ col1.metric("latest", df["value"].iloc[-1])
44
+ col2.metric("mean", f"{pd.to_numeric(df['value'], errors='coerce').mean():.2f}")
45
+ col3.metric("quality", df["quality"].iloc[-1])
46
+ st.line_chart(df["value"])
47
+ st.dataframe(df.tail(20), use_container_width=True)
48
+ else:
49
+ st.info("waiting for first reading...")
50
+
51
+ time.sleep(interval_ms / 1000)
52
+ st.rerun()
@@ -0,0 +1,56 @@
1
+ """otcat: Python bindings for the otcat Go core.
2
+
3
+ A thin, typed wrapper around the real otcat/otc binary -- every read
4
+ and write in this package is executed by the same tested, fuzzed Go
5
+ Modbus TCP client the accompanying paper describes, not a Python
6
+ reimplementation of the protocol. See client.py's module docstring for
7
+ why that's a subprocess boundary rather than a cgo/ctypes one.
8
+
9
+ Quick start::
10
+
11
+ from otcat import Client
12
+
13
+ c = Client("127.0.0.1:502")
14
+ v = c.read("holding:40001")
15
+ print(v.value, v.quality)
16
+
17
+ for v in c.watch("holding:40001", interval="500ms", count=10):
18
+ print(v.ts, v.value)
19
+
20
+ c.write("holding:40001", 100) # confirm=True by default in the library
21
+
22
+ Optional extras (install with e.g. ``pip install otcat[pandas]``):
23
+
24
+ - ``otcat.pandas_ext`` -- DataFrame conversion, a rolling-window buffer
25
+ - ``otcat.alerting`` -- threshold rules with debounce, for automations
26
+ - ``otcat.audit`` -- read-only OT asset discovery / fingerprinting
27
+ - ``otcat.aio`` -- asyncio client for FastAPI and friends
28
+ """
29
+ from .client import Client
30
+ from .exceptions import (
31
+ ConnectionError,
32
+ IOFailureError,
33
+ OtcatBinaryNotFoundError,
34
+ OtcatError,
35
+ ProtocolError,
36
+ Timeout,
37
+ UsageError,
38
+ WriteAbortedError,
39
+ )
40
+ from .models import Value, WritePlan
41
+
42
+ __all__ = [
43
+ "Client",
44
+ "Value",
45
+ "WritePlan",
46
+ "OtcatError",
47
+ "OtcatBinaryNotFoundError",
48
+ "UsageError",
49
+ "ConnectionError",
50
+ "ProtocolError",
51
+ "WriteAbortedError",
52
+ "IOFailureError",
53
+ "Timeout",
54
+ ]
55
+
56
+ __version__ = "1.0.0"
Binary file