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.
@@ -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,9 @@
1
+ # pro-os-connect-py
2
+ Python library for controlling Donaldson Bofa Pro OS Connect fume extractor devices
3
+
4
+ It includes a simple `pro-os-connect-cli` CLI tool.
5
+
6
+ Example invocation:
7
+ ```
8
+ pro-os-connect-cli --device bofa.local get_telemetry
9
+ ```
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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,2 @@
1
+ [console_scripts]
2
+ pro-os-connect-cli = pro_os_connect.cli:main
@@ -0,0 +1 @@
1
+ crccheck<1.4.0,>=1.3.1