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 +12 -0
- otcat-1.0.0/PKG-INFO +138 -0
- otcat-1.0.0/README.md +93 -0
- otcat-1.0.0/docs/packaging.md +118 -0
- otcat-1.0.0/examples/alerting_example.py +47 -0
- otcat-1.0.0/examples/audit_example.py +40 -0
- otcat-1.0.0/examples/fastapi_service.py +74 -0
- otcat-1.0.0/examples/pandas_quickstart.py +35 -0
- otcat-1.0.0/examples/streamlit_dashboard.py +52 -0
- otcat-1.0.0/otcat/__init__.py +56 -0
- otcat-1.0.0/otcat/_bin/otcat-linux-amd64 +0 -0
- otcat-1.0.0/otcat/_binary.py +83 -0
- otcat-1.0.0/otcat/aio.py +134 -0
- otcat-1.0.0/otcat/alerting.py +174 -0
- otcat-1.0.0/otcat/audit.py +156 -0
- otcat-1.0.0/otcat/client.py +188 -0
- otcat-1.0.0/otcat/exceptions.py +84 -0
- otcat-1.0.0/otcat/models.py +75 -0
- otcat-1.0.0/otcat/pandas_ext.py +103 -0
- otcat-1.0.0/otcat/py.typed +0 -0
- otcat-1.0.0/pyproject.toml +65 -0
- otcat-1.0.0/tests/conftest.py +108 -0
- otcat-1.0.0/tests/test_aio.py +47 -0
- otcat-1.0.0/tests/test_alerting.py +71 -0
- otcat-1.0.0/tests/test_audit.py +51 -0
- otcat-1.0.0/tests/test_client.py +131 -0
- otcat-1.0.0/tests/test_pandas_ext.py +39 -0
otcat-1.0.0/.gitignore
ADDED
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
|