pro-os-connect-py 0.8.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.
- pro_os_connect_py-0.8.0/LICENSE +21 -0
- pro_os_connect_py-0.8.0/PKG-INFO +27 -0
- pro_os_connect_py-0.8.0/README.md +9 -0
- pro_os_connect_py-0.8.0/pyproject.toml +34 -0
- pro_os_connect_py-0.8.0/setup.cfg +4 -0
- pro_os_connect_py-0.8.0/src/pro_os_connect/__init__.py +206 -0
- pro_os_connect_py-0.8.0/src/pro_os_connect/cli.py +222 -0
- pro_os_connect_py-0.8.0/src/pro_os_connect/codecs.py +140 -0
- pro_os_connect_py-0.8.0/src/pro_os_connect/common.py +76 -0
- pro_os_connect_py-0.8.0/src/pro_os_connect/request.py +126 -0
- pro_os_connect_py-0.8.0/src/pro_os_connect/response.py +248 -0
- pro_os_connect_py-0.8.0/src/pro_os_connect/util.py +15 -0
- pro_os_connect_py-0.8.0/src/pro_os_connect_py.egg-info/PKG-INFO +27 -0
- pro_os_connect_py-0.8.0/src/pro_os_connect_py.egg-info/SOURCES.txt +16 -0
- pro_os_connect_py-0.8.0/src/pro_os_connect_py.egg-info/dependency_links.txt +1 -0
- pro_os_connect_py-0.8.0/src/pro_os_connect_py.egg-info/entry_points.txt +2 -0
- pro_os_connect_py-0.8.0/src/pro_os_connect_py.egg-info/requires.txt +1 -0
- pro_os_connect_py-0.8.0/src/pro_os_connect_py.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Petr Sedlacek
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pro-os-connect-py
|
|
3
|
+
Version: 0.8.0
|
|
4
|
+
Summary: Library to interface with the Donaldson Bofa Pro OS Connect fume extractor controller
|
|
5
|
+
Author-email: Petr Sedlacek <piit@zz9.cz>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/piit79/pro-os-connect-py
|
|
8
|
+
Project-URL: Repository, https://github.com/piit79/pro-os-connect-py
|
|
9
|
+
Project-URL: Issues, https://github.com/piit79/pro-os-connect-py/issues
|
|
10
|
+
Keywords: fume extractor,donaldson bofa,bofa,pro os,pro os connect
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Requires-Python: >=3.10
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
License-File: LICENSE
|
|
16
|
+
Requires-Dist: crccheck<1.4.0,>=1.3.1
|
|
17
|
+
Dynamic: license-file
|
|
18
|
+
|
|
19
|
+
# pro-os-connect-py
|
|
20
|
+
Python library for controlling Donaldson Bofa Pro OS Connect fume extractor devices
|
|
21
|
+
|
|
22
|
+
It includes a simple `pro-os-connect-cli` CLI tool.
|
|
23
|
+
|
|
24
|
+
Example invocation:
|
|
25
|
+
```
|
|
26
|
+
pro-os-connect-cli --device bofa.local get_telemetry
|
|
27
|
+
```
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "pro-os-connect-py"
|
|
3
|
+
version = "0.8.0"
|
|
4
|
+
authors = [
|
|
5
|
+
{ name="Petr Sedlacek", email="piit@zz9.cz" },
|
|
6
|
+
]
|
|
7
|
+
description = "Library to interface with the Donaldson Bofa Pro OS Connect fume extractor controller"
|
|
8
|
+
keywords=["fume extractor", "donaldson bofa", "bofa", "pro os", "pro os connect"]
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
dependencies = [
|
|
13
|
+
"crccheck >=1.3.1, <1.4.0",
|
|
14
|
+
]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"Operating System :: OS Independent",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
[project.scripts]
|
|
21
|
+
pro-os-connect-cli = "pro_os_connect.cli:main"
|
|
22
|
+
|
|
23
|
+
[project.urls]
|
|
24
|
+
Homepage = "https://github.com/piit79/pro-os-connect-py"
|
|
25
|
+
Repository = "https://github.com/piit79/pro-os-connect-py"
|
|
26
|
+
Issues = "https://github.com/piit79/pro-os-connect-py/issues"
|
|
27
|
+
|
|
28
|
+
[tool.uv]
|
|
29
|
+
package = true
|
|
30
|
+
dev-dependencies = [
|
|
31
|
+
"ipython>=8.38.0, <9.0.0",
|
|
32
|
+
"pytest>=9.0.2, <10.0.0",
|
|
33
|
+
"pytest-cov>=7.0.0, <8.0.0",
|
|
34
|
+
]
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import select
|
|
3
|
+
import socket
|
|
4
|
+
import time
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
|
|
7
|
+
from pro_os_connect.common import TelemetryFields
|
|
8
|
+
|
|
9
|
+
from .request import (
|
|
10
|
+
EchoRequest,
|
|
11
|
+
GetCpuSerialNumberRequest,
|
|
12
|
+
GetNetworkConfigurationRequest,
|
|
13
|
+
GetProtocolVersionRequest,
|
|
14
|
+
GetSerialNumberRequest,
|
|
15
|
+
GetSoftwareVersionRequest,
|
|
16
|
+
GetTelemetryRequest,
|
|
17
|
+
GetTimeRequest,
|
|
18
|
+
PumpStartRequest,
|
|
19
|
+
PumpStopRequest,
|
|
20
|
+
RebootRequest,
|
|
21
|
+
Request,
|
|
22
|
+
SetSetpointRequest,
|
|
23
|
+
SetTimeRequest,
|
|
24
|
+
)
|
|
25
|
+
from .response import (
|
|
26
|
+
Response,
|
|
27
|
+
ResponseError,
|
|
28
|
+
ResponsePacket,
|
|
29
|
+
ResponsePacketIncompleteError,
|
|
30
|
+
StatusCode,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
TCP_PORT = 2345
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class ProOSConnectError(Exception):
|
|
37
|
+
pass
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class ProOSConnectRequestError(Exception):
|
|
41
|
+
pass
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class ProOSConnectConnectError(ProOSConnectRequestError):
|
|
45
|
+
pass
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class ProOSConnectTimeoutError(ProOSConnectRequestError):
|
|
49
|
+
pass
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class ProOSConnectResponseError(ProOSConnectError):
|
|
53
|
+
pass
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class ProOSConnect:
|
|
57
|
+
def __init__(self, host: str, port: int = TCP_PORT, timeout: float = 2.0):
|
|
58
|
+
self.host = host
|
|
59
|
+
self.port = port
|
|
60
|
+
self.timeout = timeout
|
|
61
|
+
self.socket = None
|
|
62
|
+
self.logger = logging.getLogger(__name__)
|
|
63
|
+
|
|
64
|
+
def connect(self):
|
|
65
|
+
self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
66
|
+
try:
|
|
67
|
+
self.socket.connect((self.host, self.port))
|
|
68
|
+
self.socket.setblocking(False)
|
|
69
|
+
except (OSError, ConnectionRefusedError, socket.gaierror) as e:
|
|
70
|
+
raise ProOSConnectConnectError(e)
|
|
71
|
+
|
|
72
|
+
def disconnect(self):
|
|
73
|
+
if self.socket:
|
|
74
|
+
self.socket.close()
|
|
75
|
+
self.socket = None
|
|
76
|
+
|
|
77
|
+
def get_response_packet(self) -> ResponsePacket:
|
|
78
|
+
readers = [self.socket]
|
|
79
|
+
select_timeout = 0.1 # seconds
|
|
80
|
+
resp_packet = ResponsePacket()
|
|
81
|
+
start_time = time.time()
|
|
82
|
+
while not resp_packet.is_valid() and (time.time() - start_time) < self.timeout:
|
|
83
|
+
read_ready, _, _ = select.select(readers, [], [], select_timeout)
|
|
84
|
+
if not read_ready:
|
|
85
|
+
continue
|
|
86
|
+
|
|
87
|
+
packet = self.socket.recv(4096) # pyright: ignore[reportOptionalMemberAccess]
|
|
88
|
+
self.logger.debug(f"Received packet: {packet.hex()}")
|
|
89
|
+
if packet:
|
|
90
|
+
resp_packet.append(packet)
|
|
91
|
+
|
|
92
|
+
return resp_packet
|
|
93
|
+
|
|
94
|
+
def send_request(self, request: Request) -> Response | None:
|
|
95
|
+
if not self.socket:
|
|
96
|
+
self.connect()
|
|
97
|
+
|
|
98
|
+
request_packet = request.get_packet()
|
|
99
|
+
self.logger.debug(f"Sending packet: {request_packet.hex()}")
|
|
100
|
+
self.socket.sendall(request_packet) # type: ignore
|
|
101
|
+
|
|
102
|
+
self.logger.debug("Waiting for response...")
|
|
103
|
+
response_packet = self.get_response_packet()
|
|
104
|
+
|
|
105
|
+
try:
|
|
106
|
+
response_packet.parse()
|
|
107
|
+
except ResponsePacketIncompleteError:
|
|
108
|
+
if not response_packet.has_data():
|
|
109
|
+
raise ProOSConnectTimeoutError("Timeout waiting for response")
|
|
110
|
+
else:
|
|
111
|
+
raise ProOSConnectResponseError("Response packet incomplete")
|
|
112
|
+
except ResponseError as e:
|
|
113
|
+
raise ProOSConnectResponseError(e.description)
|
|
114
|
+
|
|
115
|
+
if response_packet.status != StatusCode.OK.value:
|
|
116
|
+
self.logger.debug(f"Response status: {response_packet.status}")
|
|
117
|
+
raise ProOSConnectResponseError(
|
|
118
|
+
f"Error in response: {StatusCode(response_packet.status).name}"
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
return response_packet.get_response()
|
|
122
|
+
|
|
123
|
+
def get_protocol_version(self) -> int | None:
|
|
124
|
+
response = self.send_request(GetProtocolVersionRequest())
|
|
125
|
+
if not response or not isinstance(response.result, int):
|
|
126
|
+
return None
|
|
127
|
+
return response.result
|
|
128
|
+
|
|
129
|
+
# FIXME: Doesn't work for some reason. No response received
|
|
130
|
+
def echo(self, test_payload: bytes | None = None) -> bool:
|
|
131
|
+
if test_payload is None:
|
|
132
|
+
test_payload = b"Test Echo Payload"
|
|
133
|
+
response = self.send_request(EchoRequest(test_payload))
|
|
134
|
+
if response is None:
|
|
135
|
+
return False
|
|
136
|
+
return response.result == test_payload
|
|
137
|
+
|
|
138
|
+
def reboot(self) -> bool:
|
|
139
|
+
response = self.send_request(RebootRequest())
|
|
140
|
+
return response is not None
|
|
141
|
+
|
|
142
|
+
def get_telemetry(self) -> TelemetryFields | None:
|
|
143
|
+
response = self.send_request(GetTelemetryRequest())
|
|
144
|
+
return (
|
|
145
|
+
response.result
|
|
146
|
+
if response and isinstance(response.result, TelemetryFields)
|
|
147
|
+
else None
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
def get_uptime(self) -> int | None:
|
|
151
|
+
"""Return device uptime in seconds."""
|
|
152
|
+
fields = self.get_telemetry()
|
|
153
|
+
return int(fields.timestamp / 100.0) if fields else None
|
|
154
|
+
|
|
155
|
+
def is_running(self) -> bool | None:
|
|
156
|
+
"""Return True if the pump is running"""
|
|
157
|
+
fields = self.get_telemetry()
|
|
158
|
+
return bool(fields.is_running) if fields else None
|
|
159
|
+
|
|
160
|
+
def get_time(self) -> datetime | None:
|
|
161
|
+
"""Return device time as datetime object"""
|
|
162
|
+
response = self.send_request(GetTimeRequest())
|
|
163
|
+
return (
|
|
164
|
+
response.result
|
|
165
|
+
if response and isinstance(response.result, datetime)
|
|
166
|
+
else None
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
def set_time(self, date_obj: datetime) -> bool:
|
|
170
|
+
response = self.send_request(SetTimeRequest(date_obj))
|
|
171
|
+
return response is not None
|
|
172
|
+
|
|
173
|
+
def pump_start(self) -> bool:
|
|
174
|
+
response = self.send_request(PumpStartRequest())
|
|
175
|
+
return response is not None
|
|
176
|
+
|
|
177
|
+
def pump_stop(self) -> bool:
|
|
178
|
+
response = self.send_request(PumpStopRequest())
|
|
179
|
+
return response is not None
|
|
180
|
+
|
|
181
|
+
# FIXME: Response payload CRC mismatch
|
|
182
|
+
def get_software_version(self) -> str | None:
|
|
183
|
+
response = self.send_request(GetSoftwareVersionRequest())
|
|
184
|
+
return (
|
|
185
|
+
response.result if response and isinstance(response.result, str) else None
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
# FIXME: Response payload CRC mismatch
|
|
189
|
+
def get_serial_number(self) -> str | None:
|
|
190
|
+
response = self.send_request(GetSerialNumberRequest())
|
|
191
|
+
return (
|
|
192
|
+
response.result if response and isinstance(response.result, str) else None
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
# FIXME: Response payload CRC mismatch
|
|
196
|
+
def get_cpu_serial_number(self) -> str | None:
|
|
197
|
+
response = self.send_request(GetCpuSerialNumberRequest())
|
|
198
|
+
return response.result if response else None # pyright: ignore[reportReturnType]
|
|
199
|
+
|
|
200
|
+
def get_network_configuration(self) -> str | None:
|
|
201
|
+
response = self.send_request(GetNetworkConfigurationRequest())
|
|
202
|
+
return response.result if response else None # pyright: ignore[reportReturnType]
|
|
203
|
+
|
|
204
|
+
def set_setpoint(self, setpoint: int) -> bool:
|
|
205
|
+
response = self.send_request(SetSetpointRequest(setpoint))
|
|
206
|
+
return response is not None
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import json
|
|
3
|
+
import logging
|
|
4
|
+
import sys
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from time import sleep, time
|
|
8
|
+
|
|
9
|
+
from . import ProOSConnect, ProOSConnectRequestError, ProOSConnectResponseError
|
|
10
|
+
from .common import Node
|
|
11
|
+
|
|
12
|
+
COMMANDS = {
|
|
13
|
+
"get_protocol_version": [],
|
|
14
|
+
"echo": [
|
|
15
|
+
(("--payload", "-p"), {"type": str, "help": "Test payload for echo command"})
|
|
16
|
+
],
|
|
17
|
+
"reboot": [],
|
|
18
|
+
"get_telemetry": [],
|
|
19
|
+
"get_setpoint": [],
|
|
20
|
+
"get_uptime": [],
|
|
21
|
+
"is_running": [],
|
|
22
|
+
"get_time": [],
|
|
23
|
+
"set_time": [(("time",), {"type": str, "help": "Time to set"})],
|
|
24
|
+
"pump_start": [],
|
|
25
|
+
"pump_stop": [],
|
|
26
|
+
"get_software_version": [],
|
|
27
|
+
"get_serial_number": [],
|
|
28
|
+
"get_cpu_serial_number": [],
|
|
29
|
+
"get_network_configuration": [],
|
|
30
|
+
"set_setpoint": [(("setpoint",), {"type": int, "help": "Setpoint value to set"})],
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def output_text(data: str | bool | Mapping, error=False):
|
|
35
|
+
"""Output data as formatted text."""
|
|
36
|
+
stream = sys.stderr if error else sys.stdout
|
|
37
|
+
if isinstance(data, Mapping):
|
|
38
|
+
for key, value in data.items():
|
|
39
|
+
print(f"{key}={value}", file=stream)
|
|
40
|
+
elif isinstance(data, bool):
|
|
41
|
+
print(1 if data else 0, file=stream)
|
|
42
|
+
else:
|
|
43
|
+
print(data, file=stream)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def output_json(data: Mapping):
|
|
47
|
+
"""Output data as formatted JSON."""
|
|
48
|
+
print(json.dumps(data, indent=2))
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class ProOSConnectCli:
|
|
52
|
+
DONE = object()
|
|
53
|
+
|
|
54
|
+
def __init__(self):
|
|
55
|
+
self.args = self.get_args()
|
|
56
|
+
self.command: str = self.args.command
|
|
57
|
+
self.poc = ProOSConnect(self.args.device)
|
|
58
|
+
self.setup_logging()
|
|
59
|
+
self.json_result_key: str | None = None
|
|
60
|
+
|
|
61
|
+
def get_args(self):
|
|
62
|
+
parser = argparse.ArgumentParser(description="Pro-OS Connect CLI")
|
|
63
|
+
parser.add_argument(
|
|
64
|
+
"--device",
|
|
65
|
+
"-d",
|
|
66
|
+
type=str,
|
|
67
|
+
help="Hostname or IP address of the Pro OS Connect device",
|
|
68
|
+
required=True,
|
|
69
|
+
)
|
|
70
|
+
parser.add_argument(
|
|
71
|
+
"--json", "-j", action="store_true", help="Output in JSON format"
|
|
72
|
+
)
|
|
73
|
+
parser.add_argument(
|
|
74
|
+
"--node",
|
|
75
|
+
"-n",
|
|
76
|
+
choices=[Node.PRO_OS.name, Node.NETWORK_CARD.name],
|
|
77
|
+
help="Destination node",
|
|
78
|
+
)
|
|
79
|
+
parser.add_argument(
|
|
80
|
+
"--precision",
|
|
81
|
+
"-p",
|
|
82
|
+
type=int,
|
|
83
|
+
default=1,
|
|
84
|
+
help="Display precision in decimal digits",
|
|
85
|
+
)
|
|
86
|
+
parser.add_argument(
|
|
87
|
+
"--log-level",
|
|
88
|
+
"-l",
|
|
89
|
+
choices=["ERROR", "WARNING", "INFO", "DEBUG"],
|
|
90
|
+
help="Logging level",
|
|
91
|
+
)
|
|
92
|
+
subparsers = parser.add_subparsers(
|
|
93
|
+
required=True, help="Command to execute", dest="command"
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
for cmd_name, cmd_args in COMMANDS.items():
|
|
97
|
+
cmd_parser = subparsers.add_parser(cmd_name, help=f"{cmd_name} help")
|
|
98
|
+
for args, kwargs in cmd_args:
|
|
99
|
+
cmd_parser.add_argument(*args, **kwargs)
|
|
100
|
+
|
|
101
|
+
return parser.parse_args()
|
|
102
|
+
|
|
103
|
+
def setup_logging(self):
|
|
104
|
+
handler = logging.StreamHandler(stream=sys.stderr)
|
|
105
|
+
handler.setFormatter(logging.Formatter(fmt="{levelname}: {message}", style="{"))
|
|
106
|
+
self.poc.logger.addHandler(handler)
|
|
107
|
+
if self.args.log_level:
|
|
108
|
+
self.poc.logger.setLevel(self.args.log_level)
|
|
109
|
+
|
|
110
|
+
def ensure_json_result_key(self):
|
|
111
|
+
if self.json_result_key is None:
|
|
112
|
+
if self.command.startswith("get_"):
|
|
113
|
+
self.json_result_key = self.command[4:]
|
|
114
|
+
elif self.command.startswith("is_"):
|
|
115
|
+
self.json_result_key = self.command[3:]
|
|
116
|
+
else:
|
|
117
|
+
self.json_result_key = "result"
|
|
118
|
+
|
|
119
|
+
def output(self, data, json_data: Mapping | None = None, error=False):
|
|
120
|
+
if self.args.json:
|
|
121
|
+
if json_data is None:
|
|
122
|
+
if isinstance(data, Mapping):
|
|
123
|
+
json_data = data
|
|
124
|
+
else:
|
|
125
|
+
if isinstance(data, datetime):
|
|
126
|
+
data = data.isoformat()
|
|
127
|
+
json_data = {self.json_result_key: data}
|
|
128
|
+
output_json(json_data)
|
|
129
|
+
else:
|
|
130
|
+
output_text(data, error=error)
|
|
131
|
+
|
|
132
|
+
def error(self, error_msg):
|
|
133
|
+
self.output(error_msg, {"success": False, "error": error_msg}, error=True)
|
|
134
|
+
|
|
135
|
+
def echo(self):
|
|
136
|
+
test_payload = self.args.payload.encode("utf-8") if self.args.payload else None
|
|
137
|
+
start_time = time()
|
|
138
|
+
res = self.poc.echo(test_payload)
|
|
139
|
+
if res:
|
|
140
|
+
rtt = round(
|
|
141
|
+
(time() - start_time) * 100, 2
|
|
142
|
+
) # Round trim time in milliseconds
|
|
143
|
+
self.output(
|
|
144
|
+
f"Echo response received in {rtt} ms", {"result": True, "time": rtt}
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
def get_telemetry(self):
|
|
148
|
+
fields = self.poc.get_telemetry()
|
|
149
|
+
res = fields
|
|
150
|
+
if fields:
|
|
151
|
+
res = fields._asdict()
|
|
152
|
+
for f, v in res.items():
|
|
153
|
+
if isinstance(v, float):
|
|
154
|
+
res[f] = round(v, self.args.precision)
|
|
155
|
+
return res
|
|
156
|
+
|
|
157
|
+
def set_time(self):
|
|
158
|
+
"""Set time to current time"""
|
|
159
|
+
self.poc.set_time(datetime.now())
|
|
160
|
+
|
|
161
|
+
def get_network_configuration(self):
|
|
162
|
+
return self.poc.get_network_configuration()
|
|
163
|
+
|
|
164
|
+
def get_setpoint(self) -> int | None:
|
|
165
|
+
fields = self.poc.get_telemetry()
|
|
166
|
+
return fields.setpoint if fields else None
|
|
167
|
+
|
|
168
|
+
def set_setpoint(self):
|
|
169
|
+
res = self.poc.set_setpoint(self.args.setpoint)
|
|
170
|
+
if not res:
|
|
171
|
+
self.error("Error setting setpoint")
|
|
172
|
+
sys.exit(1)
|
|
173
|
+
sleep(0.01) # Wait a bit otherwise the old setpoint will be returned
|
|
174
|
+
setpoint = self.get_setpoint()
|
|
175
|
+
if not setpoint:
|
|
176
|
+
self.error("Error getting new setpoint")
|
|
177
|
+
sys.exit(1)
|
|
178
|
+
self.output(
|
|
179
|
+
f"Setpoint set to {setpoint}",
|
|
180
|
+
{"success": res, "setpoint": setpoint},
|
|
181
|
+
)
|
|
182
|
+
return self.DONE
|
|
183
|
+
|
|
184
|
+
def pro_os_command(self):
|
|
185
|
+
method = getattr(self.poc, self.command)
|
|
186
|
+
if method is None:
|
|
187
|
+
self.error(f"Invalid command: {self.command}")
|
|
188
|
+
return
|
|
189
|
+
|
|
190
|
+
return method()
|
|
191
|
+
|
|
192
|
+
def run(self):
|
|
193
|
+
method = getattr(self, self.command, self.pro_os_command)
|
|
194
|
+
|
|
195
|
+
try:
|
|
196
|
+
result = method()
|
|
197
|
+
except ProOSConnectRequestError as e:
|
|
198
|
+
self.error(f"Error sending request: {e}")
|
|
199
|
+
sys.exit(5)
|
|
200
|
+
except ProOSConnectResponseError as e:
|
|
201
|
+
self.error(f"Response error: {e}")
|
|
202
|
+
sys.exit(6)
|
|
203
|
+
|
|
204
|
+
match result:
|
|
205
|
+
case self.DONE:
|
|
206
|
+
# Output was handled by the method
|
|
207
|
+
return
|
|
208
|
+
case None:
|
|
209
|
+
self.error("Command failed, no result received")
|
|
210
|
+
sys.exit(7)
|
|
211
|
+
|
|
212
|
+
self.ensure_json_result_key()
|
|
213
|
+
self.output(result)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def main():
|
|
217
|
+
cli = ProOSConnectCli()
|
|
218
|
+
cli.run()
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
if __name__ == "__main__":
|
|
222
|
+
main()
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import struct
|
|
3
|
+
from abc import ABC
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
|
|
6
|
+
from .common import TelemetryFields
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Codec(ABC):
|
|
10
|
+
"""Base class for the payload encoder/decoder classes"""
|
|
11
|
+
|
|
12
|
+
@classmethod
|
|
13
|
+
def null(cls):
|
|
14
|
+
return CodecNull()
|
|
15
|
+
|
|
16
|
+
@classmethod
|
|
17
|
+
def cstr(cls):
|
|
18
|
+
return CodecCstr()
|
|
19
|
+
|
|
20
|
+
@classmethod
|
|
21
|
+
def struct(cls, format):
|
|
22
|
+
return CodecStruct(format)
|
|
23
|
+
|
|
24
|
+
@classmethod
|
|
25
|
+
def telemetry_fields(cls):
|
|
26
|
+
return CodecTelemetryFields()
|
|
27
|
+
|
|
28
|
+
@classmethod
|
|
29
|
+
def int(cls, format):
|
|
30
|
+
return CodecInt(format)
|
|
31
|
+
|
|
32
|
+
@classmethod
|
|
33
|
+
def iso_datetime(cls):
|
|
34
|
+
return CodecIsoDateTime()
|
|
35
|
+
|
|
36
|
+
@classmethod
|
|
37
|
+
def raw_hex(cls):
|
|
38
|
+
return CodecRawHex()
|
|
39
|
+
|
|
40
|
+
@classmethod
|
|
41
|
+
def json_cstr(cls):
|
|
42
|
+
return CodecCstrJson()
|
|
43
|
+
|
|
44
|
+
def pack(self, data) -> bytes:
|
|
45
|
+
raise NotImplementedError
|
|
46
|
+
|
|
47
|
+
def unpack(self, payload: bytes):
|
|
48
|
+
raise NotImplementedError
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class CodecNull(Codec):
|
|
52
|
+
"""Encoder/decoder class for a raw unformatted payload"""
|
|
53
|
+
|
|
54
|
+
def pack(self, data: bytes) -> bytes:
|
|
55
|
+
return data
|
|
56
|
+
|
|
57
|
+
def unpack(self, payload: bytes) -> bytes:
|
|
58
|
+
return payload
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class CodecCstr(Codec):
|
|
62
|
+
"""Encoder/decoder class for a null-terminated string payload"""
|
|
63
|
+
|
|
64
|
+
def pack(self, data: str) -> bytes:
|
|
65
|
+
return data.encode("utf-8") + b"\x00"
|
|
66
|
+
|
|
67
|
+
def unpack(self, payload: bytes) -> str:
|
|
68
|
+
return payload.decode("utf-8").rstrip("\x00")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class CodecStruct(Codec):
|
|
72
|
+
"""Encoder/decoder class for a struct payload"""
|
|
73
|
+
|
|
74
|
+
def __init__(self, format) -> None:
|
|
75
|
+
self.format = format
|
|
76
|
+
|
|
77
|
+
def pack(self, data: tuple) -> bytes:
|
|
78
|
+
return struct.pack(self.format, *data)
|
|
79
|
+
|
|
80
|
+
def unpack(self, payload: bytes) -> tuple:
|
|
81
|
+
return struct.unpack(self.format, payload)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class CodecTelemetryFields(CodecStruct):
|
|
85
|
+
"""Encoder/decoder class for a telemetry field struct payload"""
|
|
86
|
+
|
|
87
|
+
def __init__(self) -> None:
|
|
88
|
+
self.format = "<IIIffffHBBBBBb"
|
|
89
|
+
|
|
90
|
+
def pack(self, data: tuple) -> bytes:
|
|
91
|
+
"""Pack not implemented, this is only a decoder"""
|
|
92
|
+
raise NotImplementedError()
|
|
93
|
+
|
|
94
|
+
def unpack(self, payload: bytes) -> tuple:
|
|
95
|
+
return TelemetryFields(*super().unpack(payload))
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class CodecInt(CodecStruct):
|
|
99
|
+
"""Encoder/decoder class for a struct payload"""
|
|
100
|
+
|
|
101
|
+
def pack(self, data: int) -> bytes: # pyright: ignore[reportIncompatibleMethodOverride]
|
|
102
|
+
return super().pack((data,))
|
|
103
|
+
|
|
104
|
+
def unpack(self, payload: bytes) -> int: # pyright: ignore[reportIncompatibleMethodOverride]
|
|
105
|
+
(val,) = super().unpack(payload)
|
|
106
|
+
return val
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class CodecIsoDateTime(CodecCstr):
|
|
110
|
+
"""Encoder/decoder class for an ISO datetime payload in a null-terminated string"""
|
|
111
|
+
|
|
112
|
+
def pack(self, data: datetime) -> bytes: # pyright: ignore[reportIncompatibleMethodOverride]
|
|
113
|
+
datetime_str = data.isoformat()
|
|
114
|
+
return super().pack(datetime_str)
|
|
115
|
+
|
|
116
|
+
def unpack(self, payload: bytes) -> datetime: # pyright: ignore[reportIncompatibleMethodOverride]
|
|
117
|
+
datetime_str = super().unpack(payload)
|
|
118
|
+
return datetime.fromisoformat(datetime_str)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class CodecRawHex(Codec):
|
|
122
|
+
"""Encoder/decoder class for raw data represented as hex string"""
|
|
123
|
+
|
|
124
|
+
def pack(self, data: str) -> bytes:
|
|
125
|
+
return bytes.fromhex(data)
|
|
126
|
+
|
|
127
|
+
def unpack(self, payload: bytes) -> str:
|
|
128
|
+
return payload.hex()
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class CodecCstrJson(CodecCstr):
|
|
132
|
+
"""Encoder/decoder class for a JSON payload in a null-terminated string"""
|
|
133
|
+
|
|
134
|
+
def pack(self, data: dict) -> bytes: # pyright: ignore[reportIncompatibleMethodOverride]
|
|
135
|
+
json_str = json.dumps(data, indent=2)
|
|
136
|
+
return super().pack(json_str)
|
|
137
|
+
|
|
138
|
+
def unpack(self, payload: bytes) -> dict: # pyright: ignore[reportIncompatibleMethodOverride]
|
|
139
|
+
json_str = super().unpack(payload)
|
|
140
|
+
return json.loads(json_str)
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
from collections import namedtuple
|
|
2
|
+
from enum import Enum
|
|
3
|
+
|
|
4
|
+
FRAMING_PHRASE = 0xB0FAB0FA
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Node(Enum):
|
|
8
|
+
PRO_OS = 0x0001
|
|
9
|
+
NETWORK_CARD = 0x0002
|
|
10
|
+
CLIENT = 0x0003
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Command(Enum):
|
|
14
|
+
NONE = None
|
|
15
|
+
GET_PROTOCOL_VERSION = 0x00010001
|
|
16
|
+
ECHO = 0x00010002
|
|
17
|
+
REBOOT = 0x00010003
|
|
18
|
+
GET_TELEMETRY = 0x00010004
|
|
19
|
+
GET_TIME = 0x00010006
|
|
20
|
+
SET_TIME = 0x00010007
|
|
21
|
+
SOFTWARE_UPDATE_START = 0x00010008
|
|
22
|
+
SOFTWARE_UPDATE_FLASH_PAGE = 0x00010009
|
|
23
|
+
SOFTWARE_UPDATE_FINISH = 0x0001000A
|
|
24
|
+
FILE_GET_START = 0x0001000C
|
|
25
|
+
FILE_GET_PAGE = 0x0001000D
|
|
26
|
+
FILE_GET_FINISH = 0x0001000E
|
|
27
|
+
PUMP_START = 0x0001000F
|
|
28
|
+
PUMP_STOP = 0x00010010
|
|
29
|
+
GET_SOFTWARE_VERSION = 0x00010011
|
|
30
|
+
GET_SERIAL_NUMBER = 0x00010012
|
|
31
|
+
GET_CPU_SERIAL_NUMBER = 0x00010013
|
|
32
|
+
GET_NETWORK_CONFIGURATION = 0x00010014
|
|
33
|
+
SET_DISPLAYED_IP_ADDRESS = 0x00010015 # Not intended for use by end client device
|
|
34
|
+
SET_CLIENT_TIMEOUT = 0x00010016
|
|
35
|
+
SET_SETPOINT = 0x00010017
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class StatusCode(Enum):
|
|
39
|
+
NONE = None
|
|
40
|
+
OK = 0x00000000 # Success status, no errors, packet processed successfully
|
|
41
|
+
NOT_PROCESSED = 0x0FFFFFFF # The initial packet request status
|
|
42
|
+
INTERNAL_ERROR = 0xB0000000 # General error code, usually if internal process has failed in some way
|
|
43
|
+
INTERNAL_NULL_PTR = 0xB0000001 # If an internal process errored out due to null pointer
|
|
44
|
+
INTERNAL_BAD_ARG = 0xB0000002 # If an internal process errored out due to bad argument
|
|
45
|
+
INTERNAL_OVERFLOW = 0xB0000003 # If an internal process produced a potential buffer overflow
|
|
46
|
+
INTERNAL_TIMEOUT = 0xB0000004 # If an internal process timed-out
|
|
47
|
+
INVALID_ADDRESS = 0xC0000000 # Command Header’s To-From address was incorrectly set
|
|
48
|
+
INVALID_STATUS = 0xC0000001 # Command Header’s Status code was incorrectly set
|
|
49
|
+
INVALID_COMMAND = 0xC0000002 # Command was not found or not supported
|
|
50
|
+
WRITE_PROTECTED = 0xC0000003 # If Command was rejected because the iQ2 is configured in a read only state
|
|
51
|
+
COMMAND_BAD_ARG = 0xE0000000 # Requested command has invalid argument
|
|
52
|
+
COMMAND_NOT_IN_PROGRESS = 0xE0000001 # Requested command has been called out of order (PROTOCOL_COMMAND_FILE_GET ***)
|
|
53
|
+
COMMAND_WRONG_SEQUENCE = 0xE0000002 # Command sequence is invalid
|
|
54
|
+
COMMAND_CRC_INVALID = 0xE0000003 # Command CRC mismatch (PROTOCOL_COMMAND_SW_FINISH)
|
|
55
|
+
COMMAND_FILE_EMPTY = 0xE0000004 # Requested File does not exist or is empty
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
TelemetryFields = namedtuple(
|
|
59
|
+
"TelemetryFields",
|
|
60
|
+
[
|
|
61
|
+
"timestamp",
|
|
62
|
+
"alarms",
|
|
63
|
+
"setpoint",
|
|
64
|
+
"flow_rate",
|
|
65
|
+
"combined_pressure",
|
|
66
|
+
"pre_pressure",
|
|
67
|
+
"inlet_pressure",
|
|
68
|
+
"pump_level",
|
|
69
|
+
"rcb",
|
|
70
|
+
"rpb",
|
|
71
|
+
"rib",
|
|
72
|
+
"ccb",
|
|
73
|
+
"cpb",
|
|
74
|
+
"is_running",
|
|
75
|
+
],
|
|
76
|
+
)
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import struct
|
|
3
|
+
from abc import ABC
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from .codecs import Codec
|
|
7
|
+
from .common import FRAMING_PHRASE, Command, Node, StatusCode
|
|
8
|
+
from .util import calculate_crc
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Request(ABC):
|
|
12
|
+
COMMAND = Command.NONE
|
|
13
|
+
CODEC: Codec | None = None
|
|
14
|
+
|
|
15
|
+
def __init__(self, data: Any = None, to_addr: Node = Node.PRO_OS):
|
|
16
|
+
"""
|
|
17
|
+
:param data: Command data
|
|
18
|
+
:type data: Any
|
|
19
|
+
:param to_addr: Destination address - Node.PRO_OS or Node.NETWORK_CARD
|
|
20
|
+
:type to_addr: Node
|
|
21
|
+
"""
|
|
22
|
+
self.data = data
|
|
23
|
+
self.from_addr = Node.CLIENT
|
|
24
|
+
self.to_addr = to_addr
|
|
25
|
+
self.logger = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
# bit 0 31 32 63 64 79 80 95 96
|
|
28
|
+
# +----------+----------+-----+-----+----------
|
|
29
|
+
# | Command | Status | From| To | Data
|
|
30
|
+
# | Code | Code | | | Bytes
|
|
31
|
+
# +----------+----------+-----+-----+----------
|
|
32
|
+
# Protocol Header | Payload -->
|
|
33
|
+
def get_protocol_packet(self, payload: bytes) -> bytes:
|
|
34
|
+
"""Create and return the protocol packet from the command payload"""
|
|
35
|
+
protocol_packet = struct.pack(
|
|
36
|
+
"<IIHH",
|
|
37
|
+
self.COMMAND.value,
|
|
38
|
+
StatusCode.NOT_PROCESSED.value,
|
|
39
|
+
self.from_addr.value,
|
|
40
|
+
self.to_addr.value,
|
|
41
|
+
)
|
|
42
|
+
return protocol_packet + payload
|
|
43
|
+
|
|
44
|
+
# bit 0 31 32 63 64 95 96 127
|
|
45
|
+
# +----------+----------+----------+----------+----------
|
|
46
|
+
# | Framing | Payload | Payload | Header | Data
|
|
47
|
+
# | phrase | Length | CRC | CRC | Bytes
|
|
48
|
+
# +----------+----------+----------+----------+----------
|
|
49
|
+
# Transport Header | Payload -->
|
|
50
|
+
def get_transport_packet(self, protocol_packet: bytes) -> bytes:
|
|
51
|
+
"""Create and return the transport packet from the protocol packet"""
|
|
52
|
+
payload_crc = calculate_crc(protocol_packet)
|
|
53
|
+
header_base = struct.pack(
|
|
54
|
+
"<III", FRAMING_PHRASE, len(protocol_packet), payload_crc
|
|
55
|
+
)
|
|
56
|
+
header_crc = calculate_crc(header_base)
|
|
57
|
+
header = header_base + struct.pack("<I", header_crc)
|
|
58
|
+
return header + protocol_packet
|
|
59
|
+
|
|
60
|
+
def get_payload(self) -> bytes:
|
|
61
|
+
"""Return the encoded command data"""
|
|
62
|
+
if self.CODEC is not None:
|
|
63
|
+
return self.CODEC.pack(self.data)
|
|
64
|
+
return b""
|
|
65
|
+
|
|
66
|
+
def get_packet(self) -> bytes:
|
|
67
|
+
"""Create and return the network packet data"""
|
|
68
|
+
payload = self.get_payload()
|
|
69
|
+
protocol_packet = self.get_protocol_packet(payload)
|
|
70
|
+
transport_packet = self.get_transport_packet(protocol_packet)
|
|
71
|
+
return transport_packet
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class GetProtocolVersionRequest(Request):
|
|
75
|
+
COMMAND = Command.GET_PROTOCOL_VERSION
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class EchoRequest(Request):
|
|
79
|
+
COMMAND = Command.ECHO
|
|
80
|
+
CODEC = Codec.null()
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class RebootRequest(Request):
|
|
84
|
+
COMMAND = Command.REBOOT
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class GetTelemetryRequest(Request):
|
|
88
|
+
COMMAND = Command.GET_TELEMETRY
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class GetTimeRequest(Request):
|
|
92
|
+
COMMAND = Command.GET_TIME
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class SetTimeRequest(Request):
|
|
96
|
+
COMMAND = Command.SET_TIME
|
|
97
|
+
CODEC = Codec.iso_datetime()
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class PumpStartRequest(Request):
|
|
101
|
+
COMMAND = Command.PUMP_START
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class PumpStopRequest(Request):
|
|
105
|
+
COMMAND = Command.PUMP_STOP
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class GetSoftwareVersionRequest(Request):
|
|
109
|
+
COMMAND = Command.GET_SOFTWARE_VERSION
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class GetSerialNumberRequest(Request):
|
|
113
|
+
COMMAND = Command.GET_SERIAL_NUMBER
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class GetCpuSerialNumberRequest(Request):
|
|
117
|
+
COMMAND = Command.GET_CPU_SERIAL_NUMBER
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class GetNetworkConfigurationRequest(Request):
|
|
121
|
+
COMMAND = Command.GET_NETWORK_CONFIGURATION
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class SetSetpointRequest(Request):
|
|
125
|
+
COMMAND = Command.SET_SETPOINT
|
|
126
|
+
CODEC = Codec.int("<I")
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import struct
|
|
3
|
+
from abc import ABC
|
|
4
|
+
from typing import Any, ClassVar
|
|
5
|
+
|
|
6
|
+
from .codecs import Codec
|
|
7
|
+
from .common import FRAMING_PHRASE, Command, Node, StatusCode
|
|
8
|
+
from .util import calculate_crc
|
|
9
|
+
|
|
10
|
+
_registry: dict[Command, type["Response"]] = {}
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def response(cls: type["Response"]) -> type["Response"]:
|
|
14
|
+
_registry[cls.COMMAND] = cls
|
|
15
|
+
return cls
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ResponseError(Exception):
|
|
19
|
+
description = "Response error"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ResponseNotDefinedError(ResponseError):
|
|
23
|
+
description = "Response not defined"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ResponsePacketIncompleteError(ResponseError):
|
|
27
|
+
description = "Response packet incomplete"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ResponseFramingPhraseError(ResponseError):
|
|
31
|
+
description = "Invalid framing phrase"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class ResponseHeaderCrcError(ResponseError):
|
|
35
|
+
description = "Response header CRC error"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class ResponsePayloadLengthError(ResponseError):
|
|
39
|
+
description = "Response payload length incorrect"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class ResponsePayloadCrcError(ResponseError):
|
|
43
|
+
description = "Response payload CRC error"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class ResponsePacket:
|
|
47
|
+
TRANSPORT_HEADER_SIZE = 16
|
|
48
|
+
PROTOCOL_HEADER_SIZE = 12
|
|
49
|
+
|
|
50
|
+
# List of commands with known incorrect CRC in the response
|
|
51
|
+
KNOWN_INCORRECT_CRC_COMMANDS: ClassVar = [
|
|
52
|
+
Command.GET_CPU_SERIAL_NUMBER,
|
|
53
|
+
Command.GET_SERIAL_NUMBER,
|
|
54
|
+
Command.GET_SOFTWARE_VERSION,
|
|
55
|
+
Command.GET_NETWORK_CONFIGURATION,
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
def __init__(self, data: bytes = b""):
|
|
59
|
+
"""
|
|
60
|
+
:param data: Response network packet data
|
|
61
|
+
:type data: bytes
|
|
62
|
+
"""
|
|
63
|
+
self.data = data
|
|
64
|
+
self.command: Command = Command.NONE
|
|
65
|
+
self.status: StatusCode = StatusCode.NONE
|
|
66
|
+
self.from_addr: Node | None = None
|
|
67
|
+
self.to_addr: Node | None = None
|
|
68
|
+
self.transport_header = b""
|
|
69
|
+
self.protocol_packet = b""
|
|
70
|
+
self.protocol_header = b""
|
|
71
|
+
self.payload = b""
|
|
72
|
+
self.logger = logging.getLogger(__name__)
|
|
73
|
+
|
|
74
|
+
def append(self, data: bytes):
|
|
75
|
+
"""Add more packet data to the response"""
|
|
76
|
+
self.data += data
|
|
77
|
+
|
|
78
|
+
def __iadd__(self, data: bytes):
|
|
79
|
+
self.append(data)
|
|
80
|
+
return self
|
|
81
|
+
|
|
82
|
+
def __len__(self) -> int:
|
|
83
|
+
return len(self.data)
|
|
84
|
+
|
|
85
|
+
def has_data(self) -> bool:
|
|
86
|
+
return len(self) > 0
|
|
87
|
+
|
|
88
|
+
def is_valid(self) -> bool:
|
|
89
|
+
try:
|
|
90
|
+
self.parse_transport_packet(test=True)
|
|
91
|
+
except ResponseError:
|
|
92
|
+
return False
|
|
93
|
+
|
|
94
|
+
return True
|
|
95
|
+
|
|
96
|
+
def parse_transport_packet(self, test=False):
|
|
97
|
+
if len(self) < self.TRANSPORT_HEADER_SIZE:
|
|
98
|
+
raise ResponsePacketIncompleteError()
|
|
99
|
+
self.transport_header = self.data[: self.TRANSPORT_HEADER_SIZE]
|
|
100
|
+
self.protocol_packet = self.data[self.TRANSPORT_HEADER_SIZE :]
|
|
101
|
+
framing_phrase, payload_length, payload_crc, header_crc = struct.unpack(
|
|
102
|
+
"<IIII", self.transport_header
|
|
103
|
+
)
|
|
104
|
+
if framing_phrase != FRAMING_PHRASE:
|
|
105
|
+
msg = f"Response framing phrase invalid: {framing_phrase}"
|
|
106
|
+
self.logger.error(msg)
|
|
107
|
+
raise ResponseFramingPhraseError(msg)
|
|
108
|
+
calculated_header_crc = calculate_crc(self.transport_header[:12])
|
|
109
|
+
if calculated_header_crc != header_crc:
|
|
110
|
+
msg = f"Response header CRC mismatch: calculated={calculated_header_crc} actual={header_crc}"
|
|
111
|
+
self.logger.error(msg)
|
|
112
|
+
raise ResponseHeaderCrcError(msg)
|
|
113
|
+
if len(self.protocol_packet) != payload_length:
|
|
114
|
+
msg = f"Response payload length mismatch: actual={len(self.protocol_packet)} header={payload_length}"
|
|
115
|
+
level = logging.DEBUG if test else logging.ERROR
|
|
116
|
+
self.logger.log(level, msg)
|
|
117
|
+
raise ResponsePayloadLengthError(msg)
|
|
118
|
+
calculated_payload_crc = calculate_crc(self.protocol_packet)
|
|
119
|
+
if calculated_payload_crc != payload_crc:
|
|
120
|
+
self.parse_protocol_packet()
|
|
121
|
+
msg = f"Response payload CRC mismatch: calculated={calculated_payload_crc:x} received={payload_crc:x}"
|
|
122
|
+
if self.command in self.KNOWN_INCORRECT_CRC_COMMANDS:
|
|
123
|
+
self.logger.warning(msg)
|
|
124
|
+
else:
|
|
125
|
+
self.logger.error(msg)
|
|
126
|
+
raise ResponsePayloadCrcError("Response payload CRC mismatch")
|
|
127
|
+
|
|
128
|
+
def parse_protocol_packet(self):
|
|
129
|
+
self.protocol_header = self.protocol_packet[: self.PROTOCOL_HEADER_SIZE]
|
|
130
|
+
command, self.status, self.from_addr, self.to_addr = struct.unpack(
|
|
131
|
+
"<IIHH", self.protocol_header
|
|
132
|
+
)
|
|
133
|
+
self.command = Command(command)
|
|
134
|
+
self.payload = self.protocol_packet[self.PROTOCOL_HEADER_SIZE :]
|
|
135
|
+
|
|
136
|
+
def parse(self):
|
|
137
|
+
self.parse_transport_packet()
|
|
138
|
+
self.parse_protocol_packet()
|
|
139
|
+
|
|
140
|
+
def get_response(self) -> "Response | None":
|
|
141
|
+
try:
|
|
142
|
+
cls = _registry[self.command]
|
|
143
|
+
except KeyError:
|
|
144
|
+
raise ResponseError
|
|
145
|
+
|
|
146
|
+
return cls(self)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class Response(ABC):
|
|
150
|
+
COMMAND = Command.NONE
|
|
151
|
+
CODEC: Codec | None = None
|
|
152
|
+
|
|
153
|
+
def __init__(self, packet: ResponsePacket):
|
|
154
|
+
"""
|
|
155
|
+
:param packet: Response network packet data
|
|
156
|
+
:type packet: ResponsePacket
|
|
157
|
+
"""
|
|
158
|
+
self.packet = packet
|
|
159
|
+
self.status: StatusCode = self.packet.status
|
|
160
|
+
self.from_addr = self.packet.from_addr
|
|
161
|
+
self.to_addr = self.packet.to_addr
|
|
162
|
+
self.result: Any = None
|
|
163
|
+
self.unpack()
|
|
164
|
+
|
|
165
|
+
def unpack(self):
|
|
166
|
+
if self.CODEC is not None:
|
|
167
|
+
self.result = self.CODEC.unpack(self.packet.payload)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
@response
|
|
171
|
+
class GetProtocolVersionResponse(Response):
|
|
172
|
+
COMMAND = Command.GET_PROTOCOL_VERSION
|
|
173
|
+
CODEC = Codec.int("<I")
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
@response
|
|
177
|
+
class EchoResponse(Response):
|
|
178
|
+
COMMAND = Command.ECHO
|
|
179
|
+
CODEC = Codec.null()
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
# Response payload
|
|
183
|
+
# bit 0 31 32 63
|
|
184
|
+
# +------+------+------+------+------+------+------+------+
|
|
185
|
+
# | Timestamp | Alarms |
|
|
186
|
+
# +------+------+------+------+------+------+------+------+
|
|
187
|
+
# | Setpoint | Flow Rate |
|
|
188
|
+
# +------+------+------+------+------+------+------+------+
|
|
189
|
+
# | Combined Pressure | Pre Pressure |
|
|
190
|
+
# +------+------+------+------+------+------+------+------+
|
|
191
|
+
# | Inlet Pressure | Pump Level | RCB | RPB |
|
|
192
|
+
# +------+------+------+------+------+------+------+------+
|
|
193
|
+
# | RIB | CCB | CPB | Run |
|
|
194
|
+
# +------+------+------+------+
|
|
195
|
+
@response
|
|
196
|
+
class GetTelemetryResponse(Response):
|
|
197
|
+
COMMAND = Command.GET_TELEMETRY
|
|
198
|
+
CODEC = Codec.telemetry_fields()
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
@response
|
|
202
|
+
class GetTimeResponse(Response):
|
|
203
|
+
COMMAND = Command.GET_TIME
|
|
204
|
+
CODEC = Codec.iso_datetime()
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
@response
|
|
208
|
+
class SetTimeResponse(Response):
|
|
209
|
+
COMMAND = Command.SET_TIME
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
@response
|
|
213
|
+
class PumpStartResponse(Response):
|
|
214
|
+
COMMAND = Command.PUMP_START
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
@response
|
|
218
|
+
class PumpStopResponse(Response):
|
|
219
|
+
COMMAND = Command.PUMP_STOP
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
@response
|
|
223
|
+
class GetSoftwareVersionResponse(Response):
|
|
224
|
+
COMMAND = Command.GET_SOFTWARE_VERSION
|
|
225
|
+
CODEC = Codec.cstr()
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
@response
|
|
229
|
+
class GetSerialNumberResponse(Response):
|
|
230
|
+
COMMAND = Command.GET_SERIAL_NUMBER
|
|
231
|
+
CODEC = Codec.cstr()
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
@response
|
|
235
|
+
class GetCpuSerialNumberResponse(Response):
|
|
236
|
+
COMMAND = Command.GET_CPU_SERIAL_NUMBER
|
|
237
|
+
CODEC = Codec.raw_hex()
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
@response
|
|
241
|
+
class GetNetworkConfigurationResponse(Response):
|
|
242
|
+
COMMAND = Command.GET_NETWORK_CONFIGURATION
|
|
243
|
+
CODEC = Codec.json_cstr()
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
@response
|
|
247
|
+
class SetSetpointResponse(Response):
|
|
248
|
+
COMMAND = Command.SET_SETPOINT
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from crccheck.crc import Crc32Mpeg2
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def calculate_crc(data: bytes) -> int:
|
|
5
|
+
"""Calculate CRC32/MPEG-2 for provided data bytes.
|
|
6
|
+
|
|
7
|
+
Args:
|
|
8
|
+
data: bytes to calculate CRC for
|
|
9
|
+
|
|
10
|
+
Returns:
|
|
11
|
+
int: computed CRC value
|
|
12
|
+
"""
|
|
13
|
+
crc = Crc32Mpeg2()
|
|
14
|
+
crc.process(data)
|
|
15
|
+
return crc.final()
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pro-os-connect-py
|
|
3
|
+
Version: 0.8.0
|
|
4
|
+
Summary: Library to interface with the Donaldson Bofa Pro OS Connect fume extractor controller
|
|
5
|
+
Author-email: Petr Sedlacek <piit@zz9.cz>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/piit79/pro-os-connect-py
|
|
8
|
+
Project-URL: Repository, https://github.com/piit79/pro-os-connect-py
|
|
9
|
+
Project-URL: Issues, https://github.com/piit79/pro-os-connect-py/issues
|
|
10
|
+
Keywords: fume extractor,donaldson bofa,bofa,pro os,pro os connect
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Requires-Python: >=3.10
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
License-File: LICENSE
|
|
16
|
+
Requires-Dist: crccheck<1.4.0,>=1.3.1
|
|
17
|
+
Dynamic: license-file
|
|
18
|
+
|
|
19
|
+
# pro-os-connect-py
|
|
20
|
+
Python library for controlling Donaldson Bofa Pro OS Connect fume extractor devices
|
|
21
|
+
|
|
22
|
+
It includes a simple `pro-os-connect-cli` CLI tool.
|
|
23
|
+
|
|
24
|
+
Example invocation:
|
|
25
|
+
```
|
|
26
|
+
pro-os-connect-cli --device bofa.local get_telemetry
|
|
27
|
+
```
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/pro_os_connect/__init__.py
|
|
5
|
+
src/pro_os_connect/cli.py
|
|
6
|
+
src/pro_os_connect/codecs.py
|
|
7
|
+
src/pro_os_connect/common.py
|
|
8
|
+
src/pro_os_connect/request.py
|
|
9
|
+
src/pro_os_connect/response.py
|
|
10
|
+
src/pro_os_connect/util.py
|
|
11
|
+
src/pro_os_connect_py.egg-info/PKG-INFO
|
|
12
|
+
src/pro_os_connect_py.egg-info/SOURCES.txt
|
|
13
|
+
src/pro_os_connect_py.egg-info/dependency_links.txt
|
|
14
|
+
src/pro_os_connect_py.egg-info/entry_points.txt
|
|
15
|
+
src/pro_os_connect_py.egg-info/requires.txt
|
|
16
|
+
src/pro_os_connect_py.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
crccheck<1.4.0,>=1.3.1
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
pro_os_connect
|