otcat 1.0.0__py3-none-any.whl
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/__init__.py +56 -0
- otcat/_bin/otcat-linux-amd64 +0 -0
- otcat/_binary.py +83 -0
- otcat/aio.py +134 -0
- otcat/alerting.py +174 -0
- otcat/audit.py +156 -0
- otcat/client.py +188 -0
- otcat/exceptions.py +84 -0
- otcat/models.py +75 -0
- otcat/pandas_ext.py +103 -0
- otcat/py.typed +0 -0
- otcat-1.0.0.dist-info/METADATA +138 -0
- otcat-1.0.0.dist-info/RECORD +14 -0
- otcat-1.0.0.dist-info/WHEEL +4 -0
otcat/__init__.py
ADDED
|
@@ -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
|
otcat/_binary.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Locates the otcat binary this package wraps.
|
|
2
|
+
|
|
3
|
+
Resolution order, first match wins:
|
|
4
|
+
|
|
5
|
+
1. ``OTCAT_BINARY`` environment variable, if set (an explicit escape
|
|
6
|
+
hatch -- useful for pointing at a locally built binary during otcat
|
|
7
|
+
Go-side development, or a version other than the one bundled here).
|
|
8
|
+
2. A binary bundled inside this wheel, under ``otcat/_bin/``, matching
|
|
9
|
+
the current OS and CPU architecture. Platform-specific wheels (see
|
|
10
|
+
``docs/packaging.md`` in the Python package) ship exactly one such
|
|
11
|
+
binary; this source checkout ships only ``otcat-linux-amd64``,
|
|
12
|
+
enough to develop and test on the platform this project's own CI
|
|
13
|
+
runs on.
|
|
14
|
+
3. ``otcat`` or ``otc`` on ``$PATH`` -- covers the case where the Go
|
|
15
|
+
binary was installed separately (``go install``, a system package
|
|
16
|
+
from Cloudsmith, a manual download).
|
|
17
|
+
|
|
18
|
+
If none of these resolve, :class:`OtcatBinaryNotFoundError` explains
|
|
19
|
+
exactly what was tried, so a confusing "file not found" from deep
|
|
20
|
+
inside subprocess machinery never reaches the caller.
|
|
21
|
+
"""
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import os
|
|
25
|
+
import platform
|
|
26
|
+
import shutil
|
|
27
|
+
import stat
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
|
|
30
|
+
from .exceptions import OtcatBinaryNotFoundError
|
|
31
|
+
|
|
32
|
+
_BIN_DIR = Path(__file__).parent / "_bin"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _platform_tag() -> str:
|
|
36
|
+
system = platform.system().lower()
|
|
37
|
+
machine = platform.machine().lower()
|
|
38
|
+
|
|
39
|
+
os_name = {"darwin": "darwin", "linux": "linux", "windows": "windows"}.get(system, system)
|
|
40
|
+
arch = {
|
|
41
|
+
"x86_64": "amd64", "amd64": "amd64",
|
|
42
|
+
"aarch64": "arm64", "arm64": "arm64",
|
|
43
|
+
}.get(machine, machine)
|
|
44
|
+
return f"{os_name}-{arch}"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _bundled_path() -> Path | None:
|
|
48
|
+
tag = _platform_tag()
|
|
49
|
+
suffix = ".exe" if tag.startswith("windows") else ""
|
|
50
|
+
candidate = _BIN_DIR / f"otcat-{tag}{suffix}"
|
|
51
|
+
return candidate if candidate.is_file() else None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def find_binary() -> str:
|
|
55
|
+
"""Return a path to a working otcat binary, or raise
|
|
56
|
+
:class:`OtcatBinaryNotFoundError` with a clear explanation."""
|
|
57
|
+
tried: list[str] = []
|
|
58
|
+
|
|
59
|
+
env = os.environ.get("OTCAT_BINARY")
|
|
60
|
+
if env:
|
|
61
|
+
tried.append(f"$OTCAT_BINARY={env}")
|
|
62
|
+
if os.path.isfile(env) and os.access(env, os.X_OK):
|
|
63
|
+
return env
|
|
64
|
+
|
|
65
|
+
bundled = _bundled_path()
|
|
66
|
+
if bundled:
|
|
67
|
+
tried.append(f"bundled binary at {bundled}")
|
|
68
|
+
if not os.access(bundled, os.X_OK):
|
|
69
|
+
# Wheel installers don't always preserve the executable
|
|
70
|
+
# bit; fix it rather than fail on something this
|
|
71
|
+
# trivially fixable.
|
|
72
|
+
bundled.chmod(bundled.stat().st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH)
|
|
73
|
+
return str(bundled)
|
|
74
|
+
else:
|
|
75
|
+
tried.append(f"bundled binary for platform '{_platform_tag()}' (none shipped in this install)")
|
|
76
|
+
|
|
77
|
+
for name in ("otcat", "otc"):
|
|
78
|
+
found = shutil.which(name)
|
|
79
|
+
tried.append(f"'{name}' on $PATH")
|
|
80
|
+
if found:
|
|
81
|
+
return found
|
|
82
|
+
|
|
83
|
+
raise OtcatBinaryNotFoundError(tried)
|
otcat/aio.py
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""Async twin of :class:`otcat.client.Client`, built on
|
|
2
|
+
``asyncio.create_subprocess_exec`` instead of the synchronous
|
|
3
|
+
``subprocess`` module, so it doesn't block an event loop -- the
|
|
4
|
+
relevant case being a FastAPI endpoint that reads or watches a
|
|
5
|
+
register without stalling every other request being served by the
|
|
6
|
+
same process. Same flags, same JSON parsing, same exception mapping;
|
|
7
|
+
see client.py's module docstring for the design rationale, which
|
|
8
|
+
applies here unchanged.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
import json
|
|
14
|
+
from collections.abc import AsyncIterator
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from . import _binary
|
|
18
|
+
from .exceptions import Timeout, error_for_exit_code
|
|
19
|
+
from .models import Value, WritePlan
|
|
20
|
+
|
|
21
|
+
_DEFAULT_TIMEOUT = 5.0
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class AsyncClient:
|
|
25
|
+
def __init__(
|
|
26
|
+
self,
|
|
27
|
+
endpoint: str,
|
|
28
|
+
*,
|
|
29
|
+
binary: str | None = None,
|
|
30
|
+
unit: int = 1,
|
|
31
|
+
type: str = "uint16", # noqa: A002
|
|
32
|
+
byte_order: str = "big",
|
|
33
|
+
word_order: str = "high",
|
|
34
|
+
raw_address: bool = False,
|
|
35
|
+
timeout: float = _DEFAULT_TIMEOUT,
|
|
36
|
+
) -> None:
|
|
37
|
+
self.endpoint = endpoint
|
|
38
|
+
self.binary = binary or _binary.find_binary()
|
|
39
|
+
self.unit = unit
|
|
40
|
+
self.type = type
|
|
41
|
+
self.byte_order = byte_order
|
|
42
|
+
self.word_order = word_order
|
|
43
|
+
self.raw_address = raw_address
|
|
44
|
+
self.timeout = timeout
|
|
45
|
+
|
|
46
|
+
def _base_flags(self) -> list[str]:
|
|
47
|
+
flags = [
|
|
48
|
+
"--modbus", self.endpoint,
|
|
49
|
+
"--unit", str(self.unit),
|
|
50
|
+
"--type", self.type,
|
|
51
|
+
"--byte-order", self.byte_order,
|
|
52
|
+
"--word-order", self.word_order,
|
|
53
|
+
"--timeout", f"{self.timeout}s",
|
|
54
|
+
"--json",
|
|
55
|
+
]
|
|
56
|
+
if self.raw_address:
|
|
57
|
+
flags.append("--raw-address")
|
|
58
|
+
return flags
|
|
59
|
+
|
|
60
|
+
async def _run(self, args: list[str]) -> tuple[int, str, str]:
|
|
61
|
+
proc = await asyncio.create_subprocess_exec(
|
|
62
|
+
self.binary, *args,
|
|
63
|
+
stdout=asyncio.subprocess.PIPE,
|
|
64
|
+
stderr=asyncio.subprocess.PIPE,
|
|
65
|
+
)
|
|
66
|
+
try:
|
|
67
|
+
stdout, stderr = await asyncio.wait_for(
|
|
68
|
+
proc.communicate(), timeout=self.timeout + 5
|
|
69
|
+
)
|
|
70
|
+
except asyncio.TimeoutError as e:
|
|
71
|
+
proc.kill()
|
|
72
|
+
await proc.wait()
|
|
73
|
+
raise Timeout(
|
|
74
|
+
f"otcat process did not exit within {self.timeout + 5}s"
|
|
75
|
+
) from e
|
|
76
|
+
assert proc.returncode is not None # guaranteed once communicate() has returned
|
|
77
|
+
return proc.returncode, stdout.decode(), stderr.decode()
|
|
78
|
+
|
|
79
|
+
async def read(self, spec: str) -> Value:
|
|
80
|
+
code, out, err = await self._run([*self._base_flags(), "--read", spec])
|
|
81
|
+
if code != 0:
|
|
82
|
+
raise error_for_exit_code(code, err)
|
|
83
|
+
return Value.from_json(json.loads(out.strip().splitlines()[-1]))
|
|
84
|
+
|
|
85
|
+
async def write(self, spec: str, value: Any, *, confirm: bool = True) -> None:
|
|
86
|
+
args = [*self._base_flags(), "--write", spec, "--value", str(value)]
|
|
87
|
+
if confirm:
|
|
88
|
+
args.append("--confirm")
|
|
89
|
+
code, _out, err = await self._run(args)
|
|
90
|
+
if code != 0:
|
|
91
|
+
raise error_for_exit_code(code, err)
|
|
92
|
+
|
|
93
|
+
async def dry_run(self, spec: str, value: Any) -> WritePlan:
|
|
94
|
+
args = [*self._base_flags(), "--write", spec, "--value", str(value), "--dry-run"]
|
|
95
|
+
code, out, err = await self._run(args)
|
|
96
|
+
if code != 0:
|
|
97
|
+
raise error_for_exit_code(code, err)
|
|
98
|
+
return WritePlan.from_json(json.loads(out.strip().splitlines()[-1]))
|
|
99
|
+
|
|
100
|
+
async def watch(
|
|
101
|
+
self, spec: str, *, interval: str = "1s", count: int = 0
|
|
102
|
+
) -> AsyncIterator[Value]:
|
|
103
|
+
"""Async generator streaming readings -- `async for v in
|
|
104
|
+
client.watch(...)`. Ideal for a FastAPI WebSocket endpoint
|
|
105
|
+
forwarding live register values to a browser."""
|
|
106
|
+
args = [
|
|
107
|
+
*self._base_flags(),
|
|
108
|
+
"--watch", spec,
|
|
109
|
+
"--interval", interval,
|
|
110
|
+
"--count", str(count),
|
|
111
|
+
]
|
|
112
|
+
proc = await asyncio.create_subprocess_exec(
|
|
113
|
+
self.binary, *args,
|
|
114
|
+
stdout=asyncio.subprocess.PIPE,
|
|
115
|
+
stderr=asyncio.subprocess.PIPE,
|
|
116
|
+
)
|
|
117
|
+
try:
|
|
118
|
+
assert proc.stdout is not None
|
|
119
|
+
async for raw_line in proc.stdout:
|
|
120
|
+
line = raw_line.decode().strip()
|
|
121
|
+
if not line:
|
|
122
|
+
continue
|
|
123
|
+
yield Value.from_json(json.loads(line))
|
|
124
|
+
finally:
|
|
125
|
+
if proc.returncode is None:
|
|
126
|
+
proc.terminate()
|
|
127
|
+
try:
|
|
128
|
+
await asyncio.wait_for(proc.wait(), timeout=3)
|
|
129
|
+
except asyncio.TimeoutError:
|
|
130
|
+
proc.kill()
|
|
131
|
+
await proc.wait()
|
|
132
|
+
|
|
133
|
+
def __repr__(self) -> str:
|
|
134
|
+
return f"AsyncClient(endpoint={self.endpoint!r}, unit={self.unit}, type={self.type!r})"
|
otcat/alerting.py
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""A small rule-based alerting engine for turning a watch() stream into
|
|
2
|
+
callbacks -- the building block for "page someone when the tank gets
|
|
3
|
+
above 90%" or "trigger a downstream automation when a coil flips."
|
|
4
|
+
|
|
5
|
+
Deliberately minimal: no rule DSL, no persistence, no distributed
|
|
6
|
+
state. A Rule is a predicate and two callbacks (on_trigger, on_clear);
|
|
7
|
+
the engine's only real logic is debouncing so a value oscillating
|
|
8
|
+
right at a threshold doesn't fire on every single sample.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import logging
|
|
13
|
+
import time
|
|
14
|
+
from collections.abc import Callable
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from .client import Client
|
|
19
|
+
from .models import Value
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger("otcat.alerting")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class Rule:
|
|
26
|
+
"""spec: address to watch. predicate: called with the decoded
|
|
27
|
+
value; return True when the alert condition is met. on_trigger is
|
|
28
|
+
called once when the rule transitions False->True (after
|
|
29
|
+
`debounce` consecutive breaching samples, not on the first one --
|
|
30
|
+
see AlertEngine's docstring for why). on_clear, if given, is
|
|
31
|
+
called once on the True->False transition."""
|
|
32
|
+
|
|
33
|
+
spec: str
|
|
34
|
+
predicate: Callable[[Any], bool]
|
|
35
|
+
on_trigger: Callable[[Value], None]
|
|
36
|
+
on_clear: Callable[[Value], None] | None = None
|
|
37
|
+
debounce: int = 1
|
|
38
|
+
name: str = ""
|
|
39
|
+
|
|
40
|
+
# internal state
|
|
41
|
+
_consecutive: int = field(default=0, repr=False, compare=False)
|
|
42
|
+
_active: bool = field(default=False, repr=False, compare=False)
|
|
43
|
+
|
|
44
|
+
def __post_init__(self):
|
|
45
|
+
self.name = self.name or self.spec
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass
|
|
49
|
+
class Threshold:
|
|
50
|
+
"""A Rule built from a plain numeric threshold, for the common
|
|
51
|
+
case that doesn't need a custom predicate.
|
|
52
|
+
|
|
53
|
+
>>> Threshold(spec="holding:0", above=9000).as_rule(on_trigger=page_oncall)
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
spec: str
|
|
57
|
+
above: float | None = None
|
|
58
|
+
below: float | None = None
|
|
59
|
+
debounce: int = 3
|
|
60
|
+
name: str = ""
|
|
61
|
+
|
|
62
|
+
def as_rule(
|
|
63
|
+
self,
|
|
64
|
+
on_trigger: Callable[[Value], None],
|
|
65
|
+
on_clear: Callable[[Value], None] | None = None,
|
|
66
|
+
) -> Rule:
|
|
67
|
+
above, below = self.above, self.below
|
|
68
|
+
|
|
69
|
+
def predicate(value: Any) -> bool:
|
|
70
|
+
v = float(value)
|
|
71
|
+
if above is not None and v > above:
|
|
72
|
+
return True
|
|
73
|
+
if below is not None and v < below:
|
|
74
|
+
return True
|
|
75
|
+
return False
|
|
76
|
+
|
|
77
|
+
return Rule(
|
|
78
|
+
spec=self.spec,
|
|
79
|
+
predicate=predicate,
|
|
80
|
+
on_trigger=on_trigger,
|
|
81
|
+
on_clear=on_clear,
|
|
82
|
+
debounce=self.debounce,
|
|
83
|
+
name=self.name or self.spec,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class AlertEngine:
|
|
88
|
+
"""Polls a set of Rules, one otcat --watch subprocess per unique
|
|
89
|
+
spec, and dispatches on_trigger/on_clear callbacks with debounce.
|
|
90
|
+
|
|
91
|
+
Debounce exists because a raw threshold on a live signal will
|
|
92
|
+
otherwise fire, clear, and re-fire every time noise crosses the
|
|
93
|
+
line -- exactly the alert-fatigue failure mode any real automation
|
|
94
|
+
or paging system needs to avoid. `debounce=N` requires N
|
|
95
|
+
consecutive breaching samples before on_trigger fires, and (by the
|
|
96
|
+
same logic, for the same reason) N consecutive non-breaching
|
|
97
|
+
samples before on_clear fires.
|
|
98
|
+
|
|
99
|
+
This runs rules sequentially in one thread by design: it is meant
|
|
100
|
+
for a handful of rules on a slow-moving industrial process (seconds
|
|
101
|
+
between readings, not microseconds), not a high-frequency
|
|
102
|
+
multiplexed data feed. For dozens of independent high-frequency
|
|
103
|
+
watches, run separate AlertEngine instances (or Client.watch()
|
|
104
|
+
loops) in separate threads/processes instead of adding concurrency
|
|
105
|
+
complexity to this class.
|
|
106
|
+
"""
|
|
107
|
+
|
|
108
|
+
def __init__(self, client: Client):
|
|
109
|
+
self.client = client
|
|
110
|
+
self._rules: list[Rule] = []
|
|
111
|
+
|
|
112
|
+
def add_rule(self, rule: Rule) -> "AlertEngine":
|
|
113
|
+
self._rules.append(rule)
|
|
114
|
+
return self
|
|
115
|
+
|
|
116
|
+
def threshold(
|
|
117
|
+
self,
|
|
118
|
+
spec: str,
|
|
119
|
+
*,
|
|
120
|
+
above: float | None = None,
|
|
121
|
+
below: float | None = None,
|
|
122
|
+
on_trigger: Callable[[Value], None],
|
|
123
|
+
on_clear: Callable[[Value], None] | None = None,
|
|
124
|
+
debounce: int = 3,
|
|
125
|
+
name: str = "",
|
|
126
|
+
) -> "AlertEngine":
|
|
127
|
+
"""Convenience: build and add a Threshold rule in one call."""
|
|
128
|
+
rule = Threshold(spec=spec, above=above, below=below, debounce=debounce, name=name).as_rule(
|
|
129
|
+
on_trigger=on_trigger, on_clear=on_clear
|
|
130
|
+
)
|
|
131
|
+
return self.add_rule(rule)
|
|
132
|
+
|
|
133
|
+
def poll_once(self) -> None:
|
|
134
|
+
"""Read every rule's spec once and evaluate it. Useful for
|
|
135
|
+
testing a rule set, or for driving the engine from your own
|
|
136
|
+
scheduler instead of run()'s built-in loop."""
|
|
137
|
+
for rule in self._rules:
|
|
138
|
+
try:
|
|
139
|
+
value = self.client.read(rule.spec)
|
|
140
|
+
except Exception:
|
|
141
|
+
logger.exception("otcat alerting: read failed for rule %r", rule.name)
|
|
142
|
+
continue
|
|
143
|
+
self._evaluate(rule, value)
|
|
144
|
+
|
|
145
|
+
def _evaluate(self, rule: Rule, value: Value) -> None:
|
|
146
|
+
breached = False
|
|
147
|
+
try:
|
|
148
|
+
breached = rule.predicate(value.value)
|
|
149
|
+
except Exception:
|
|
150
|
+
logger.exception("otcat alerting: predicate failed for rule %r", rule.name)
|
|
151
|
+
return
|
|
152
|
+
|
|
153
|
+
if breached:
|
|
154
|
+
rule._consecutive += 1
|
|
155
|
+
if not rule._active and rule._consecutive >= rule.debounce:
|
|
156
|
+
rule._active = True
|
|
157
|
+
rule.on_trigger(value)
|
|
158
|
+
else:
|
|
159
|
+
rule._consecutive = 0
|
|
160
|
+
if rule._active:
|
|
161
|
+
rule._active = False
|
|
162
|
+
if rule.on_clear:
|
|
163
|
+
rule.on_clear(value)
|
|
164
|
+
|
|
165
|
+
def run(self, *, interval_seconds: float = 1.0, iterations: int | None = None) -> None:
|
|
166
|
+
"""Blocking loop: poll_once() every interval_seconds. Run this
|
|
167
|
+
in a background thread for a long-lived service; iterations
|
|
168
|
+
(mainly for tests) bounds the loop instead of running forever."""
|
|
169
|
+
i = 0
|
|
170
|
+
while iterations is None or i < iterations:
|
|
171
|
+
self.poll_once()
|
|
172
|
+
i += 1
|
|
173
|
+
if iterations is None or i < iterations:
|
|
174
|
+
time.sleep(interval_seconds)
|
otcat/audit.py
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""Read-only auditing helpers for OT asset discovery and exposure
|
|
2
|
+
assessment -- the Python-side equivalent of the Go core's own
|
|
3
|
+
safety-first ethos (see the accompanying paper, "Safety Is a Default,
|
|
4
|
+
Not a Feature"): every function in this module only ever issues reads.
|
|
5
|
+
There is no scan-and-write mode, no fuzzer, no credential attack (moot
|
|
6
|
+
anyway -- Modbus has no authentication to attack), and no function that
|
|
7
|
+
sends more than one request at a time to a single device. That is a
|
|
8
|
+
deliberate, permanent design boundary for this module, not a version-1
|
|
9
|
+
limitation to be lifted later.
|
|
10
|
+
|
|
11
|
+
Intended use: legitimate asset inventory, exposure mapping, and
|
|
12
|
+
red-team reconnaissance *you are authorized to perform* against
|
|
13
|
+
equipment you are authorized to test. Running any scanner, including
|
|
14
|
+
this one, against equipment you do not have explicit authorization to
|
|
15
|
+
test may be illegal in your jurisdiction and can disrupt a live
|
|
16
|
+
physical process regardless of intent -- see the Go core's own
|
|
17
|
+
Limitations section for why Modbus's total absence of authentication
|
|
18
|
+
means "I can reach it" has never implied "I am allowed to."
|
|
19
|
+
"""
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import time
|
|
23
|
+
from dataclasses import dataclass, field
|
|
24
|
+
|
|
25
|
+
from .client import Client
|
|
26
|
+
from .exceptions import ConnectionError as OtcatConnectionError
|
|
27
|
+
from .exceptions import ProtocolError
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class ScanResult:
|
|
32
|
+
table: str
|
|
33
|
+
address: int
|
|
34
|
+
responsive: bool
|
|
35
|
+
exception_code: str | None = None
|
|
36
|
+
latency_ms: float | None = None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class ScanReport:
|
|
41
|
+
endpoint: str
|
|
42
|
+
table: str
|
|
43
|
+
start: int
|
|
44
|
+
count: int
|
|
45
|
+
results: list[ScanResult] = field(default_factory=list)
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def responsive_addresses(self) -> list[int]:
|
|
49
|
+
return [r.address for r in self.results if r.responsive]
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def response_rate(self) -> float:
|
|
53
|
+
return len(self.responsive_addresses) / len(self.results) if self.results else 0.0
|
|
54
|
+
|
|
55
|
+
def summary(self) -> str:
|
|
56
|
+
lat = [r.latency_ms for r in self.results if r.latency_ms is not None]
|
|
57
|
+
avg_lat = sum(lat) / len(lat) if lat else float("nan")
|
|
58
|
+
return (
|
|
59
|
+
f"{self.endpoint} {self.table}:{self.start}..{self.start + self.count - 1}: "
|
|
60
|
+
f"{len(self.responsive_addresses)}/{len(self.results)} addresses responsive "
|
|
61
|
+
f"({self.response_rate:.0%}), avg latency {avg_lat:.1f}ms"
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def scan_range(
|
|
66
|
+
client: Client,
|
|
67
|
+
table: str,
|
|
68
|
+
start: int,
|
|
69
|
+
count: int,
|
|
70
|
+
*,
|
|
71
|
+
delay_seconds: float = 0.0,
|
|
72
|
+
) -> ScanReport:
|
|
73
|
+
"""Sweep [start, start+count) with individual single-value reads,
|
|
74
|
+
recording which addresses respond, which raise a protocol
|
|
75
|
+
exception (and which one), and per-address latency. Read-only,
|
|
76
|
+
always -- see the module docstring.
|
|
77
|
+
|
|
78
|
+
delay_seconds paces requests between addresses; the default (0)
|
|
79
|
+
sends as fast as the client allows, which is fine for otcat's own
|
|
80
|
+
mock server and most modern devices, but consider a small delay
|
|
81
|
+
(e.g. 0.05) against older or resource-constrained field hardware,
|
|
82
|
+
the same courtesy the paper's own write-safety design extends
|
|
83
|
+
throughout: a scanner should not become a denial-of-service tool
|
|
84
|
+
by accident.
|
|
85
|
+
"""
|
|
86
|
+
report = ScanReport(endpoint=client.endpoint, table=table, start=start, count=count)
|
|
87
|
+
for offset in range(count):
|
|
88
|
+
addr = start + offset
|
|
89
|
+
t0 = time.perf_counter()
|
|
90
|
+
try:
|
|
91
|
+
client.read(f"{table}:{addr}")
|
|
92
|
+
latency_ms = (time.perf_counter() - t0) * 1000
|
|
93
|
+
report.results.append(ScanResult(table, addr, responsive=True, latency_ms=latency_ms))
|
|
94
|
+
except ProtocolError as e:
|
|
95
|
+
latency_ms = (time.perf_counter() - t0) * 1000
|
|
96
|
+
report.results.append(
|
|
97
|
+
ScanResult(table, addr, responsive=False, exception_code=e.exception_code, latency_ms=latency_ms)
|
|
98
|
+
)
|
|
99
|
+
except OtcatConnectionError:
|
|
100
|
+
report.results.append(ScanResult(table, addr, responsive=False))
|
|
101
|
+
if delay_seconds:
|
|
102
|
+
time.sleep(delay_seconds)
|
|
103
|
+
return report
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@dataclass
|
|
107
|
+
class DeviceFingerprint:
|
|
108
|
+
endpoint: str
|
|
109
|
+
reachable: bool
|
|
110
|
+
tables_responsive: dict[str, bool] = field(default_factory=dict)
|
|
111
|
+
latency_ms_p50: float | None = None
|
|
112
|
+
latency_ms_p99: float | None = None
|
|
113
|
+
samples: int = 0
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def fingerprint(client: Client, *, samples: int = 20, probe_address: int = 0) -> DeviceFingerprint:
|
|
117
|
+
"""A lightweight, read-only device fingerprint: which of the four
|
|
118
|
+
Modbus data tables respond at all at probe_address, and the
|
|
119
|
+
round-trip latency distribution over `samples` reads of one
|
|
120
|
+
holding register -- useful groundwork for both an asset inventory
|
|
121
|
+
entry and a latency budget for how aggressively you can safely
|
|
122
|
+
poll this specific device (see the paper's own methodology for why
|
|
123
|
+
latency percentiles, not just a mean, are the right thing to record
|
|
124
|
+
here).
|
|
125
|
+
"""
|
|
126
|
+
fp = DeviceFingerprint(endpoint=client.endpoint, reachable=False)
|
|
127
|
+
|
|
128
|
+
for table in ("holding", "input", "coil", "discrete"):
|
|
129
|
+
try:
|
|
130
|
+
client.read(f"{table}:{probe_address}")
|
|
131
|
+
fp.tables_responsive[table] = True
|
|
132
|
+
fp.reachable = True
|
|
133
|
+
except ProtocolError:
|
|
134
|
+
# Reached the device; this table/address combination just
|
|
135
|
+
# isn't valid there. Still evidence of reachability.
|
|
136
|
+
fp.tables_responsive[table] = False
|
|
137
|
+
fp.reachable = True
|
|
138
|
+
except OtcatConnectionError:
|
|
139
|
+
fp.tables_responsive[table] = False
|
|
140
|
+
|
|
141
|
+
if fp.reachable:
|
|
142
|
+
latencies: list[float] = []
|
|
143
|
+
for _ in range(samples):
|
|
144
|
+
t0 = time.perf_counter()
|
|
145
|
+
try:
|
|
146
|
+
client.read(f"holding:{probe_address}")
|
|
147
|
+
latencies.append((time.perf_counter() - t0) * 1000)
|
|
148
|
+
except (ProtocolError, OtcatConnectionError):
|
|
149
|
+
pass
|
|
150
|
+
if latencies:
|
|
151
|
+
latencies.sort()
|
|
152
|
+
fp.samples = len(latencies)
|
|
153
|
+
fp.latency_ms_p50 = latencies[len(latencies) // 2]
|
|
154
|
+
fp.latency_ms_p99 = latencies[min(len(latencies) - 1, int(len(latencies) * 0.99))]
|
|
155
|
+
|
|
156
|
+
return fp
|
otcat/client.py
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""The synchronous otcat client.
|
|
2
|
+
|
|
3
|
+
This is deliberately a subprocess wrapper around the real, compiled Go
|
|
4
|
+
binary -- not a reimplementation of the Modbus protocol in Python.
|
|
5
|
+
Every byte that crosses a real socket is still produced and parsed by
|
|
6
|
+
the same Go core the paper describes and the Go test suite exercises;
|
|
7
|
+
this package's only job is turning that binary's ndjson stdout into
|
|
8
|
+
typed Python objects, and Python calls into the right CLI flags.
|
|
9
|
+
|
|
10
|
+
Why subprocess and not a cgo shared library / ctypes binding: it is
|
|
11
|
+
the integration surface otcat was already designed around (stdin,
|
|
12
|
+
stdout, one ndjson object per line), it costs nothing extra to keep
|
|
13
|
+
correct as the Go core evolves, and it works on every platform the Go
|
|
14
|
+
binary itself supports with zero additional build tooling on the
|
|
15
|
+
Python side. The cost is one process-spawn per read/write and a few
|
|
16
|
+
milliseconds of overhead -- immaterial next to a Modbus round trip
|
|
17
|
+
(see the paper's own latency figures) and irrelevant for --watch,
|
|
18
|
+
which spawns once and streams.
|
|
19
|
+
|
|
20
|
+
Write safety: exactly one behavior carries over from the CLI without
|
|
21
|
+
a Python-side opinion layered on top -- Client.write() defaults to
|
|
22
|
+
confirm=True, deliberately the *opposite* default of the CLI's own
|
|
23
|
+
--confirm flag. The CLI defaults to refusing because a human might be
|
|
24
|
+
about to fat-finger a command; a library call is already the result of
|
|
25
|
+
a programmer deciding, in code, to write a specific value -- there is
|
|
26
|
+
no keystroke left to protect against. If you want the CLI's stricter
|
|
27
|
+
default in your own code, pass confirm=False and handle
|
|
28
|
+
WriteAbortedError.
|
|
29
|
+
"""
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import json
|
|
33
|
+
import subprocess
|
|
34
|
+
from collections.abc import Iterator
|
|
35
|
+
from typing import Any
|
|
36
|
+
|
|
37
|
+
from . import _binary
|
|
38
|
+
from .exceptions import Timeout, error_for_exit_code
|
|
39
|
+
from .models import Value, WritePlan
|
|
40
|
+
|
|
41
|
+
_DEFAULT_TIMEOUT = 5.0
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class Client:
|
|
45
|
+
def __init__(
|
|
46
|
+
self,
|
|
47
|
+
endpoint: str,
|
|
48
|
+
*,
|
|
49
|
+
binary: str | None = None,
|
|
50
|
+
unit: int = 1,
|
|
51
|
+
type: str = "uint16", # noqa: A002 - matches the CLI's --type name
|
|
52
|
+
byte_order: str = "big",
|
|
53
|
+
word_order: str = "high",
|
|
54
|
+
raw_address: bool = False,
|
|
55
|
+
timeout: float = _DEFAULT_TIMEOUT,
|
|
56
|
+
) -> None:
|
|
57
|
+
"""endpoint: "host:port" of a Modbus TCP device or otcat-mockplc."""
|
|
58
|
+
self.endpoint = endpoint
|
|
59
|
+
self.binary = binary or _binary.find_binary()
|
|
60
|
+
self.unit = unit
|
|
61
|
+
self.type = type
|
|
62
|
+
self.byte_order = byte_order
|
|
63
|
+
self.word_order = word_order
|
|
64
|
+
self.raw_address = raw_address
|
|
65
|
+
self.timeout = timeout
|
|
66
|
+
|
|
67
|
+
# -- shared flag building -------------------------------------------------
|
|
68
|
+
|
|
69
|
+
def _base_flags(self) -> list[str]:
|
|
70
|
+
flags = [
|
|
71
|
+
"--modbus", self.endpoint,
|
|
72
|
+
"--unit", str(self.unit),
|
|
73
|
+
"--type", self.type,
|
|
74
|
+
"--byte-order", self.byte_order,
|
|
75
|
+
"--word-order", self.word_order,
|
|
76
|
+
"--timeout", f"{self.timeout}s",
|
|
77
|
+
"--json",
|
|
78
|
+
]
|
|
79
|
+
if self.raw_address:
|
|
80
|
+
flags.append("--raw-address")
|
|
81
|
+
return flags
|
|
82
|
+
|
|
83
|
+
def _run(self, args: list[str], input_text: str | None = None) -> subprocess.CompletedProcess:
|
|
84
|
+
try:
|
|
85
|
+
return subprocess.run(
|
|
86
|
+
[self.binary, *args],
|
|
87
|
+
input=input_text,
|
|
88
|
+
capture_output=True,
|
|
89
|
+
text=True,
|
|
90
|
+
timeout=self.timeout + 5, # generous margin over otcat's own --timeout
|
|
91
|
+
)
|
|
92
|
+
except subprocess.TimeoutExpired as e:
|
|
93
|
+
raise Timeout(
|
|
94
|
+
f"otcat process did not exit within {self.timeout + 5}s "
|
|
95
|
+
f"(otcat's own --timeout is {self.timeout}s; this is the "
|
|
96
|
+
f"Python-side watchdog on top of it)"
|
|
97
|
+
) from e
|
|
98
|
+
|
|
99
|
+
# -- public API -------------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
def read(self, spec: str) -> Value:
|
|
102
|
+
"""Read one address once. spec: e.g. "holding:40001" or
|
|
103
|
+
"holding:200:2" for a 2-register array."""
|
|
104
|
+
proc = self._run([*self._base_flags(), "--read", spec])
|
|
105
|
+
if proc.returncode != 0:
|
|
106
|
+
raise error_for_exit_code(proc.returncode, proc.stderr)
|
|
107
|
+
return Value.from_json(json.loads(proc.stdout.strip().splitlines()[-1]))
|
|
108
|
+
|
|
109
|
+
def read_many(self, specs: list[str]) -> list[Value]:
|
|
110
|
+
"""Convenience: read() each spec in sequence (N processes, N
|
|
111
|
+
round trips -- not a single batched Modbus request, since
|
|
112
|
+
otcat's own protocol layer doesn't batch either; see the
|
|
113
|
+
paper's discussion of why the client stays single-request)."""
|
|
114
|
+
return [self.read(spec) for spec in specs]
|
|
115
|
+
|
|
116
|
+
def write(
|
|
117
|
+
self,
|
|
118
|
+
spec: str,
|
|
119
|
+
value: Any,
|
|
120
|
+
*,
|
|
121
|
+
confirm: bool = True,
|
|
122
|
+
) -> None:
|
|
123
|
+
"""Write value to spec. See the module docstring for why
|
|
124
|
+
confirm defaults to True here, opposite the CLI's own default."""
|
|
125
|
+
args = [*self._base_flags(), "--write", spec, "--value", str(value)]
|
|
126
|
+
if confirm:
|
|
127
|
+
args.append("--confirm")
|
|
128
|
+
proc = self._run(args)
|
|
129
|
+
if proc.returncode != 0:
|
|
130
|
+
raise error_for_exit_code(proc.returncode, proc.stderr)
|
|
131
|
+
|
|
132
|
+
def dry_run(self, spec: str, value: Any) -> WritePlan:
|
|
133
|
+
"""Compute exactly what write(spec, value) would send, with
|
|
134
|
+
zero network I/O -- see internal/cliapp's --dry-run in the Go
|
|
135
|
+
core; this calls the identical code path."""
|
|
136
|
+
args = [*self._base_flags(), "--write", spec, "--value", str(value), "--dry-run"]
|
|
137
|
+
proc = self._run(args)
|
|
138
|
+
if proc.returncode != 0:
|
|
139
|
+
raise error_for_exit_code(proc.returncode, proc.stderr)
|
|
140
|
+
return WritePlan.from_json(json.loads(proc.stdout.strip().splitlines()[-1]))
|
|
141
|
+
|
|
142
|
+
def watch(
|
|
143
|
+
self,
|
|
144
|
+
spec: str,
|
|
145
|
+
*,
|
|
146
|
+
interval: str = "1s",
|
|
147
|
+
count: int = 0,
|
|
148
|
+
) -> Iterator[Value]:
|
|
149
|
+
"""Stream readings as they arrive. A generator wrapping one
|
|
150
|
+
long-lived `otcat --watch` subprocess -- values are yielded the
|
|
151
|
+
instant otcat's own stdout flushes them (line-buffered, exactly
|
|
152
|
+
as documented in the Go codec package), not batched.
|
|
153
|
+
|
|
154
|
+
count=0 (the default) streams until the caller stops iterating
|
|
155
|
+
(breaking out of a `for` loop terminates the subprocess
|
|
156
|
+
cleanly) or the process is otherwise interrupted.
|
|
157
|
+
"""
|
|
158
|
+
args = [
|
|
159
|
+
*self._base_flags(),
|
|
160
|
+
"--watch", spec,
|
|
161
|
+
"--interval", interval,
|
|
162
|
+
"--count", str(count),
|
|
163
|
+
]
|
|
164
|
+
proc = subprocess.Popen(
|
|
165
|
+
[self.binary, *args],
|
|
166
|
+
stdout=subprocess.PIPE,
|
|
167
|
+
stderr=subprocess.PIPE,
|
|
168
|
+
text=True,
|
|
169
|
+
bufsize=1, # line-buffered
|
|
170
|
+
)
|
|
171
|
+
try:
|
|
172
|
+
assert proc.stdout is not None
|
|
173
|
+
for line in proc.stdout:
|
|
174
|
+
line = line.strip()
|
|
175
|
+
if not line:
|
|
176
|
+
continue
|
|
177
|
+
yield Value.from_json(json.loads(line))
|
|
178
|
+
finally:
|
|
179
|
+
if proc.poll() is None:
|
|
180
|
+
proc.terminate()
|
|
181
|
+
try:
|
|
182
|
+
proc.wait(timeout=3)
|
|
183
|
+
except subprocess.TimeoutExpired:
|
|
184
|
+
proc.kill()
|
|
185
|
+
proc.wait()
|
|
186
|
+
|
|
187
|
+
def __repr__(self) -> str:
|
|
188
|
+
return f"Client(endpoint={self.endpoint!r}, unit={self.unit}, type={self.type!r})"
|
otcat/exceptions.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""otcat's Python exceptions mirror the Go CLI's exit-code contract
|
|
2
|
+
(see the main project's README, "Exit codes" table) one-for-one, so
|
|
3
|
+
catching a specific class tells you exactly what class of failure
|
|
4
|
+
happened without parsing error text.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class OtcatError(Exception):
|
|
10
|
+
"""Base class for every error this package raises."""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class OtcatBinaryNotFoundError(OtcatError):
|
|
14
|
+
"""No otcat/otc binary could be located."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, tried: list[str]):
|
|
17
|
+
self.tried = tried
|
|
18
|
+
msg = (
|
|
19
|
+
"could not find an otcat binary. Tried, in order:\n - "
|
|
20
|
+
+ "\n - ".join(tried)
|
|
21
|
+
+ "\n\nFix: install the Go binary (see "
|
|
22
|
+
"https://github.com/QuitOperation/otcat#install--build) and "
|
|
23
|
+
"ensure it's on $PATH, or set OTCAT_BINARY to its exact path."
|
|
24
|
+
)
|
|
25
|
+
super().__init__(msg)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class UsageError(OtcatError):
|
|
29
|
+
"""Exit code 1: bad arguments, malformed address spec, bad literal.
|
|
30
|
+
Almost always a bug in the calling code, not a transient condition."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class ConnectionError(OtcatError): # noqa: A001 - deliberately shadows builtin, mirrors Go's naming
|
|
34
|
+
"""Exit code 2: dial failure, timeout, connection refused."""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class ProtocolError(OtcatError):
|
|
38
|
+
"""Exit code 3: the device returned a real Modbus exception
|
|
39
|
+
(illegal address, illegal value, etc). See .exception_code."""
|
|
40
|
+
|
|
41
|
+
def __init__(self, message: str, exception_code: str | None = None):
|
|
42
|
+
super().__init__(message)
|
|
43
|
+
self.exception_code = exception_code
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class WriteAbortedError(OtcatError):
|
|
47
|
+
"""Exit code 4: a write was refused because it was not confirmed.
|
|
48
|
+
This is otcat's safety gate working as designed, not a bug --
|
|
49
|
+
catching this and silently retrying with confirm=True defeats the
|
|
50
|
+
entire point of the gate. See client.py's module docstring."""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class IOFailureError(OtcatError):
|
|
54
|
+
"""Exit code 5: e.g. a broken output pipe, or an otherwise
|
|
55
|
+
unclassified failure."""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class Timeout(OtcatError):
|
|
59
|
+
"""Raised by watch()/read() when a client-side Python timeout
|
|
60
|
+
(distinct from otcat's own --timeout) elapses -- e.g. a watch
|
|
61
|
+
generator that produced no value for longer than expected."""
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
_EXIT_CODE_MAP = {
|
|
65
|
+
1: UsageError,
|
|
66
|
+
2: ConnectionError,
|
|
67
|
+
3: ProtocolError,
|
|
68
|
+
4: WriteAbortedError,
|
|
69
|
+
5: IOFailureError,
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def error_for_exit_code(code: int, stderr: str) -> OtcatError:
|
|
74
|
+
cls = _EXIT_CODE_MAP.get(code, OtcatError)
|
|
75
|
+
message = stderr.strip() or f"otcat exited with code {code}"
|
|
76
|
+
if cls is ProtocolError:
|
|
77
|
+
# otcat's protocol-error stderr looks like:
|
|
78
|
+
# otcat: read holding:0: modbus: unit 1 function 0x03: illegal data address (0x02)
|
|
79
|
+
exc_code = None
|
|
80
|
+
if "(0x" in message:
|
|
81
|
+
exc_code = message.rsplit("(0x", 1)[-1].rstrip(")")
|
|
82
|
+
exc_code = "0x" + exc_code
|
|
83
|
+
return ProtocolError(message, exception_code=exc_code)
|
|
84
|
+
return cls(message)
|
otcat/models.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Typed models mirroring the JSON otcat's Go core actually emits
|
|
2
|
+
(``internal/protocol.Value`` and ``internal/protocol.WritePlan``).
|
|
3
|
+
Nothing here is reimplemented protocol logic -- it's just a typed
|
|
4
|
+
Python view of exactly the bytes the Go binary printed.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from datetime import datetime, timezone
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True, slots=True)
|
|
14
|
+
class Value:
|
|
15
|
+
"""One reading. Field-for-field the same shape as the ndjson
|
|
16
|
+
otcat --json prints, so ``Value(**json.loads(line))`` (after
|
|
17
|
+
renaming ``ts``) is a valid way to think about this type even
|
|
18
|
+
though the real parsing goes through :func:`from_json`."""
|
|
19
|
+
|
|
20
|
+
address: str
|
|
21
|
+
type: str
|
|
22
|
+
value: Any
|
|
23
|
+
quality: str
|
|
24
|
+
ts: datetime
|
|
25
|
+
raw: list[int] | None = None
|
|
26
|
+
|
|
27
|
+
@property
|
|
28
|
+
def good(self) -> bool:
|
|
29
|
+
return self.quality == "good"
|
|
30
|
+
|
|
31
|
+
@classmethod
|
|
32
|
+
def from_json(cls, obj: dict) -> "Value":
|
|
33
|
+
ts_raw = obj["ts"]
|
|
34
|
+
# Go's RFC3339Nano sometimes omits the fractional seconds
|
|
35
|
+
# entirely (whole-second timestamps); Python's fromisoformat
|
|
36
|
+
# (3.11+) handles the 'Z' suffix natively, but we support 3.9+
|
|
37
|
+
# so normalize it ourselves.
|
|
38
|
+
ts_norm = ts_raw.replace("Z", "+00:00")
|
|
39
|
+
try:
|
|
40
|
+
ts = datetime.fromisoformat(ts_norm)
|
|
41
|
+
except ValueError:
|
|
42
|
+
ts = datetime.now(timezone.utc)
|
|
43
|
+
return cls(
|
|
44
|
+
address=obj["address"],
|
|
45
|
+
type=obj["type"],
|
|
46
|
+
value=obj["value"],
|
|
47
|
+
quality=obj["quality"],
|
|
48
|
+
ts=ts,
|
|
49
|
+
raw=obj.get("raw"),
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True, slots=True)
|
|
54
|
+
class WritePlan:
|
|
55
|
+
"""The output of Client.dry_run(): exactly what a write would
|
|
56
|
+
send, computed with zero network I/O (mirrors
|
|
57
|
+
internal/protocol.WritePlan)."""
|
|
58
|
+
|
|
59
|
+
driver: str
|
|
60
|
+
address: str
|
|
61
|
+
literal: str
|
|
62
|
+
type: str | None = None
|
|
63
|
+
registers: list[int] = field(default_factory=list)
|
|
64
|
+
coils: list[bool] = field(default_factory=list)
|
|
65
|
+
|
|
66
|
+
@classmethod
|
|
67
|
+
def from_json(cls, obj: dict) -> "WritePlan":
|
|
68
|
+
return cls(
|
|
69
|
+
driver=obj["driver"],
|
|
70
|
+
address=obj["address"],
|
|
71
|
+
literal=obj["literal"],
|
|
72
|
+
type=obj.get("type"),
|
|
73
|
+
registers=obj.get("registers", []) or [],
|
|
74
|
+
coils=obj.get("coils", []) or [],
|
|
75
|
+
)
|
otcat/pandas_ext.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""pandas integration. Kept as a separate, optional-import module
|
|
2
|
+
(pandas is not a hard dependency of otcat's core client -- see
|
|
3
|
+
pyproject.toml's [project.optional-dependencies]) so anything that
|
|
4
|
+
only needs Client/AsyncClient never has to install pandas at all.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from collections.abc import Iterable
|
|
9
|
+
from typing import TYPE_CHECKING
|
|
10
|
+
|
|
11
|
+
from .client import Client
|
|
12
|
+
from .models import Value
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
import pandas as pd
|
|
16
|
+
|
|
17
|
+
_MISSING_PANDAS = (
|
|
18
|
+
"pandas is required for otcat.pandas_ext but is not installed. "
|
|
19
|
+
"Install it with: pip install otcat[pandas]"
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _require_pandas():
|
|
24
|
+
try:
|
|
25
|
+
import pandas as pd
|
|
26
|
+
except ImportError as e: # pragma: no cover - exercised only without pandas installed
|
|
27
|
+
raise ImportError(_MISSING_PANDAS) from e
|
|
28
|
+
return pd
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def values_to_dataframe(values: Iterable[Value]) -> "pd.DataFrame":
|
|
32
|
+
"""Convert any iterable of Value (e.g. Client.read_many's return,
|
|
33
|
+
or a fully-consumed Client.watch() generator) into a DataFrame with
|
|
34
|
+
columns [address, type, value, quality, ts] and ts as the index --
|
|
35
|
+
ready for `.resample()`, `.rolling()`, or a straight `.plot()`."""
|
|
36
|
+
pd = _require_pandas()
|
|
37
|
+
rows = [
|
|
38
|
+
{
|
|
39
|
+
"ts": v.ts,
|
|
40
|
+
"address": v.address,
|
|
41
|
+
"type": v.type,
|
|
42
|
+
"value": v.value,
|
|
43
|
+
"quality": v.quality,
|
|
44
|
+
}
|
|
45
|
+
for v in values
|
|
46
|
+
]
|
|
47
|
+
df = pd.DataFrame(rows)
|
|
48
|
+
if not df.empty:
|
|
49
|
+
df = df.set_index("ts")
|
|
50
|
+
return df
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def read_many_df(client: Client, specs: list[str]) -> "pd.DataFrame":
|
|
54
|
+
"""client.read_many(specs), as a DataFrame. One row per spec."""
|
|
55
|
+
return values_to_dataframe(client.read_many(specs))
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def watch_df(
|
|
59
|
+
client: Client,
|
|
60
|
+
spec: str,
|
|
61
|
+
*,
|
|
62
|
+
count: int,
|
|
63
|
+
interval: str = "1s",
|
|
64
|
+
) -> "pd.DataFrame":
|
|
65
|
+
"""Block until `count` samples are collected via client.watch(),
|
|
66
|
+
then return them as one DataFrame. For an unbounded live stream
|
|
67
|
+
instead, iterate client.watch() directly and accumulate/append
|
|
68
|
+
however your application (a dashboard, a rolling buffer) wants --
|
|
69
|
+
a single ever-growing DataFrame is usually the wrong shape for a
|
|
70
|
+
truly unbounded stream, which is why this helper requires a finite
|
|
71
|
+
count rather than offering an unbounded DataFrame-producing mode."""
|
|
72
|
+
values = list(client.watch(spec, interval=interval, count=count))
|
|
73
|
+
return values_to_dataframe(values)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class RollingBuffer:
|
|
77
|
+
"""A fixed-size rolling window over a live watch() stream,
|
|
78
|
+
materialized as a DataFrame on demand -- the shape a live
|
|
79
|
+
dashboard or a streaming feature-engineering step usually wants:
|
|
80
|
+
bounded memory, always-current, no unbounded accumulation.
|
|
81
|
+
|
|
82
|
+
>>> client = Client("127.0.0.1:502")
|
|
83
|
+
>>> buf = RollingBuffer(maxlen=500)
|
|
84
|
+
>>> for v in client.watch("holding:0", interval="200ms"):
|
|
85
|
+
... buf.push(v)
|
|
86
|
+
... if len(buf) >= 10:
|
|
87
|
+
... df = buf.to_dataframe()
|
|
88
|
+
... # df.value.rolling(5).mean(), df.plot(), feed a model, etc.
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
def __init__(self, maxlen: int = 1000):
|
|
92
|
+
from collections import deque
|
|
93
|
+
self.maxlen = maxlen
|
|
94
|
+
self._buf: deque[Value] = deque(maxlen=maxlen)
|
|
95
|
+
|
|
96
|
+
def push(self, value: Value) -> None:
|
|
97
|
+
self._buf.append(value)
|
|
98
|
+
|
|
99
|
+
def __len__(self) -> int:
|
|
100
|
+
return len(self._buf)
|
|
101
|
+
|
|
102
|
+
def to_dataframe(self) -> "pd.DataFrame":
|
|
103
|
+
return values_to_dataframe(list(self._buf))
|
otcat/py.typed
ADDED
|
File without changes
|
|
@@ -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.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
otcat/__init__.py,sha256=E-b9-DPbwOLQBBsnW4SydBefRRj6OOgLH4V6ff7dqPQ,1547
|
|
2
|
+
otcat/_binary.py,sha256=N4001fu-m7XyxunqMyCdPCAXy2TGqQi6ub6q7fP85jc,2928
|
|
3
|
+
otcat/aio.py,sha256=1NoWGvy-lWNRvZcZw41Y2-M_eLZyuAgG5azCjr01Xqw,4855
|
|
4
|
+
otcat/alerting.py,sha256=iL0xjQr4bu6B3_LUCwZy4__y7LDVouTxmJCasp9qFxg,6138
|
|
5
|
+
otcat/audit.py,sha256=m_dc-hSfftNBhx0bubRDAtHXEqmIolojEuENihGJda4,6095
|
|
6
|
+
otcat/client.py,sha256=EvCwzx6dddyN1OwG_2v-eoI-m8XaaQx6IhJC1H0y7dk,7341
|
|
7
|
+
otcat/exceptions.py,sha256=8xoy4skJV6qq2XBzDNUhBjp9dfLrsDYA69fGd6YO860,2958
|
|
8
|
+
otcat/models.py,sha256=7lGwkEUI8RM32IjXJt_5eHr_zwPASAv_I2JWkDkfAjs,2366
|
|
9
|
+
otcat/pandas_ext.py,sha256=ns0PdgWk90QTuqK2Qayb3BZkbhTBqqEr3-Xb_4I1QC4,3435
|
|
10
|
+
otcat/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
11
|
+
otcat/_bin/otcat-linux-amd64,sha256=0q49tIHzby_EmTIBBA4pWKtBjIT-WgBZM3H81SM52Mk,2363652
|
|
12
|
+
otcat-1.0.0.dist-info/METADATA,sha256=fTo2idXb1mIHSVQngNlACP8d0AOlFpuSEVbh7e7CfCM,5462
|
|
13
|
+
otcat-1.0.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
14
|
+
otcat-1.0.0.dist-info/RECORD,,
|