pycomap 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.
- pycomap/__init__.py +56 -0
- pycomap/alarms.py +100 -0
- pycomap/configuration.py +546 -0
- pycomap/controller.py +790 -0
- pycomap/datatypes.py +202 -0
- pycomap/discovery.py +195 -0
- pycomap/exceptions.py +35 -0
- pycomap/history.py +166 -0
- pycomap/protocol/__init__.py +24 -0
- pycomap/protocol/client.py +357 -0
- pycomap/protocol/commands.py +62 -0
- pycomap/protocol/crc.py +20 -0
- pycomap/protocol/crypto.py +90 -0
- pycomap/protocol/framing.py +145 -0
- pycomap/protocol/objects.py +98 -0
- pycomap/protocol/transport.py +89 -0
- pycomap/py.typed +0 -0
- pycomap-1.0.0.dist-info/METADATA +57 -0
- pycomap-1.0.0.dist-info/RECORD +20 -0
- pycomap-1.0.0.dist-info/WHEEL +4 -0
pycomap/datatypes.py
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""ComAp wire data types, protection states, and raw value encode/decode.
|
|
2
|
+
|
|
3
|
+
``DataType`` mirrors ``DataTypeIdentifier`` from ``ComAp.GlobalShared.dll``; the byte-length
|
|
4
|
+
table and struct formats are from ``DataTypeDescription.DataTypeLength`` in
|
|
5
|
+
``ComAp.Controller.dll``. ``decode_raw_value``/``encode_raw_value`` are the round-trip
|
|
6
|
+
codec for individual values/setpoints — their callers (``decode_values_all``,
|
|
7
|
+
``decode_setpoints_all``, ``encode_raw_value``) live in [pycomap.configuration][].
|
|
8
|
+
|
|
9
|
+
``ProtectionState`` mirrors ``enum ProtectionState`` from ``ComAp.Controller.dll``
|
|
10
|
+
(``ComAp.Controller.DataTypes`` namespace).
|
|
11
|
+
|
|
12
|
+
``decode_fdate`` / ``decode_ftime`` / ``encode_fdate`` / ``encode_ftime`` implement the
|
|
13
|
+
3-byte BCD ``FDate``/``FTime`` types used by ``CommunicationObject.DATE`` (24553) and
|
|
14
|
+
``CommunicationObject.TIME`` (24554). Source: ``ComAp.Controller.DataTypes.FDate`` /
|
|
15
|
+
``FTime`` in ``ComAp.Controller.dll``.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import datetime
|
|
21
|
+
import enum
|
|
22
|
+
import struct
|
|
23
|
+
|
|
24
|
+
from pycomap.exceptions import ComApProtocolError
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class DataType(enum.IntEnum):
|
|
28
|
+
"""ComAp ``DataTypeIdentifier`` enum."""
|
|
29
|
+
|
|
30
|
+
INTEGER8 = 2
|
|
31
|
+
INTEGER16 = 3
|
|
32
|
+
INTEGER32 = 4
|
|
33
|
+
UNSIGNED8 = 5
|
|
34
|
+
UNSIGNED16 = 6
|
|
35
|
+
UNSIGNED32 = 7
|
|
36
|
+
FLOAT = 8
|
|
37
|
+
DATE = 11
|
|
38
|
+
DOMAIN = 15
|
|
39
|
+
TIMER = 16
|
|
40
|
+
BINARY8 = 64
|
|
41
|
+
BINARY16 = 65
|
|
42
|
+
BINARY32 = 66
|
|
43
|
+
FTIME = 67
|
|
44
|
+
FDATE = 68
|
|
45
|
+
CHAR = 69
|
|
46
|
+
STRING_LIST = 70
|
|
47
|
+
SHORT_STRING = 71
|
|
48
|
+
LONG_STRING = 72
|
|
49
|
+
HUGE_STRING = 73
|
|
50
|
+
IP_ADDRESS = 74
|
|
51
|
+
TELEPHONE_NUMBER = 75
|
|
52
|
+
EMAIL = 76
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
_DATA_TYPE_LENGTH: dict[DataType, int] = {
|
|
56
|
+
DataType.INTEGER8: 1,
|
|
57
|
+
DataType.UNSIGNED8: 1,
|
|
58
|
+
DataType.BINARY8: 1,
|
|
59
|
+
DataType.CHAR: 1,
|
|
60
|
+
DataType.STRING_LIST: 1,
|
|
61
|
+
DataType.INTEGER16: 2,
|
|
62
|
+
DataType.UNSIGNED16: 2,
|
|
63
|
+
DataType.BINARY16: 2,
|
|
64
|
+
DataType.FTIME: 3,
|
|
65
|
+
DataType.FDATE: 3,
|
|
66
|
+
DataType.INTEGER32: 4,
|
|
67
|
+
DataType.UNSIGNED32: 4,
|
|
68
|
+
DataType.FLOAT: 4,
|
|
69
|
+
DataType.BINARY32: 4,
|
|
70
|
+
DataType.TIMER: 8,
|
|
71
|
+
DataType.SHORT_STRING: 16,
|
|
72
|
+
DataType.IP_ADDRESS: 16,
|
|
73
|
+
DataType.LONG_STRING: 32,
|
|
74
|
+
DataType.TELEPHONE_NUMBER: 32,
|
|
75
|
+
DataType.HUGE_STRING: 64,
|
|
76
|
+
DataType.EMAIL: 64,
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
_NUMERIC_STRUCT_FORMAT: dict[DataType, str] = {
|
|
80
|
+
DataType.INTEGER8: "<b",
|
|
81
|
+
DataType.INTEGER16: "<h",
|
|
82
|
+
DataType.INTEGER32: "<i",
|
|
83
|
+
DataType.UNSIGNED8: "<B",
|
|
84
|
+
DataType.UNSIGNED16: "<H",
|
|
85
|
+
DataType.UNSIGNED32: "<I",
|
|
86
|
+
DataType.FLOAT: "<f",
|
|
87
|
+
DataType.BINARY8: "<B",
|
|
88
|
+
DataType.BINARY16: "<H",
|
|
89
|
+
DataType.BINARY32: "<I",
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
_BINARY_TYPES = (DataType.BINARY8, DataType.BINARY16, DataType.BINARY32)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class ProtectionState(enum.IntFlag):
|
|
96
|
+
"""ComAp ``ProtectionState`` enum (``ValueState.Level1``/``Level2``/``SensorFail``).
|
|
97
|
+
|
|
98
|
+
``Delay``, ``Active``, and ``NotConfirmed`` are combinable: ``Active | NotConfirmed``
|
|
99
|
+
(value 6) means the alarm is active but not yet acknowledged by the operator.
|
|
100
|
+
Source: ``ComAp.Controller.DataTypes.ProtectionState`` in ``ComAp.Controller.dll``.
|
|
101
|
+
"""
|
|
102
|
+
|
|
103
|
+
NOT_ACTIVE = 0
|
|
104
|
+
DELAY = 1
|
|
105
|
+
ACTIVE = 2
|
|
106
|
+
NOT_CONFIRMED = 4
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def decode_raw_value(
|
|
110
|
+
data_type: DataType, raw: bytes, decimal_places: int = 0
|
|
111
|
+
) -> int | float | bytes:
|
|
112
|
+
"""Decode ``raw`` bytes (``len(raw) == _DATA_TYPE_LENGTH[data_type]``) per ``data_type``.
|
|
113
|
+
|
|
114
|
+
Only numeric and binary(bitfield) types are decoded to Python numbers; string/domain/
|
|
115
|
+
timer/date types are returned as raw bytes.
|
|
116
|
+
"""
|
|
117
|
+
fmt = _NUMERIC_STRUCT_FORMAT.get(data_type)
|
|
118
|
+
if fmt is None:
|
|
119
|
+
return raw
|
|
120
|
+
value = struct.unpack(fmt, raw)[0]
|
|
121
|
+
if decimal_places and data_type not in _BINARY_TYPES:
|
|
122
|
+
return value / (10**decimal_places)
|
|
123
|
+
return value
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def encode_raw_value(data_type: DataType, value: int | float, decimal_places: int = 0) -> bytes:
|
|
127
|
+
"""Encode ``value`` into the raw bytes ``decode_raw_value`` would decode it back from.
|
|
128
|
+
|
|
129
|
+
Only numeric and binary(bitfield) types can be encoded -- there's no encoder for
|
|
130
|
+
string/domain/timer/date types (write raw bytes directly via ``client.write_object``
|
|
131
|
+
instead). The controller itself enforces the setpoint's min/max (returning
|
|
132
|
+
``ControllerError.BAD_WRITE_VALUE`` on rejection), so this doesn't validate limits.
|
|
133
|
+
"""
|
|
134
|
+
fmt = _NUMERIC_STRUCT_FORMAT.get(data_type)
|
|
135
|
+
if fmt is None:
|
|
136
|
+
raise ComApProtocolError(f"cannot encode DataType.{data_type.name} -- not numeric")
|
|
137
|
+
if data_type is not DataType.FLOAT and decimal_places and data_type not in _BINARY_TYPES:
|
|
138
|
+
value = round(value * (10**decimal_places))
|
|
139
|
+
return struct.pack(fmt, value if data_type is DataType.FLOAT else int(value))
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
# -- FDate / FTime BCD codec --------------------------------------------------
|
|
143
|
+
# Source: ComAp.Controller.DataTypes.FDate / FTime in ComAp.Controller.dll.
|
|
144
|
+
# Both types are 3-byte, each byte standard nibble-BCD (high nibble = tens, low = units).
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def get_bits(value: int, start: int, width: int) -> int:
|
|
148
|
+
"""Extract ``width`` bits from ``value`` starting at bit ``start`` (LSB=0)."""
|
|
149
|
+
return (value >> start) & ((1 << width) - 1)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _bcd_decode(b: int) -> int:
|
|
153
|
+
return 10 * ((b >> 4) & 0xF) + (b & 0xF)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _bcd_encode(v: int) -> int:
|
|
157
|
+
return ((v // 10) << 4) | (v % 10)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def decode_fdate(raw: bytes) -> datetime.date | None:
|
|
161
|
+
"""Decode a 3-byte FDate payload ``[BCD(day), BCD(month), BCD(year-2000)]``.
|
|
162
|
+
|
|
163
|
+
Returns ``None`` for invalid/unset values (all 0xFF, or day byte == 0).
|
|
164
|
+
"""
|
|
165
|
+
if len(raw) != 3:
|
|
166
|
+
raise ComApProtocolError(f"FDate requires 3 bytes, got {len(raw)}")
|
|
167
|
+
if raw[0] == 0 or (raw[0] == 0xFF and raw[1] == 0xFF and raw[2] == 0xFF):
|
|
168
|
+
return None
|
|
169
|
+
day = _bcd_decode(raw[0])
|
|
170
|
+
month = _bcd_decode(raw[1])
|
|
171
|
+
year = _bcd_decode(raw[2]) + 2000
|
|
172
|
+
try:
|
|
173
|
+
return datetime.date(year, month, day)
|
|
174
|
+
except ValueError:
|
|
175
|
+
return None
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def encode_fdate(date: datetime.date) -> bytes:
|
|
179
|
+
"""Encode a ``datetime.date`` as a 3-byte FDate payload."""
|
|
180
|
+
if date.year < 2000:
|
|
181
|
+
raise ComApProtocolError("FDate cannot represent years before 2000")
|
|
182
|
+
return bytes([_bcd_encode(date.day), _bcd_encode(date.month), _bcd_encode(date.year - 2000)])
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def decode_ftime(raw: bytes) -> datetime.time | None:
|
|
186
|
+
"""Decode a 3-byte FTime payload ``[BCD(hour), BCD(minute), BCD(second)]``.
|
|
187
|
+
|
|
188
|
+
Returns ``None`` for invalid/unset values (hour byte ≥ 0x36 per source check).
|
|
189
|
+
"""
|
|
190
|
+
if len(raw) != 3:
|
|
191
|
+
raise ComApProtocolError(f"FTime requires 3 bytes, got {len(raw)}")
|
|
192
|
+
if raw[0] >= 0x36:
|
|
193
|
+
return None
|
|
194
|
+
try:
|
|
195
|
+
return datetime.time(_bcd_decode(raw[0]), _bcd_decode(raw[1]), _bcd_decode(raw[2]))
|
|
196
|
+
except ValueError:
|
|
197
|
+
return None
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def encode_ftime(time: datetime.time) -> bytes:
|
|
201
|
+
"""Encode a ``datetime.time`` as a 3-byte FTime payload."""
|
|
202
|
+
return bytes([_bcd_encode(time.hour), _bcd_encode(time.minute), _bcd_encode(time.second)])
|
pycomap/discovery.py
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"""UDP discovery of ComAp controllers on the local network.
|
|
2
|
+
|
|
3
|
+
See ``docs/protocol.md`` section 1. InteliConfig broadcasts a probe to
|
|
4
|
+
``<broadcast>:2413``; controllers reply unicast from port 2413. Despite living on its own
|
|
5
|
+
UDP port, the probe and reply are not a bespoke discovery-only format — they're a regular
|
|
6
|
+
[pycomap.protocol.framing.Message][] (the exact same CRC16-validated `EthernetMessage`
|
|
7
|
+
framing used by the TCP protocol on port 23): a ``SendMe`` for the ``Discovery``
|
|
8
|
+
communication object, replied to with a ``SendTo`` carrying a binary ``DiscoveryDevice``
|
|
9
|
+
payload (IP, MAC, TCP port, firmware version, and the list of units behind the gateway).
|
|
10
|
+
|
|
11
|
+
The controller validates the CRC on this probe just like any other message, so a
|
|
12
|
+
malformed/random payload of the right length is silently ignored — it must be built via
|
|
13
|
+
[pycomap.protocol.framing.build_inner][], not improvised.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import asyncio
|
|
19
|
+
import enum
|
|
20
|
+
import logging
|
|
21
|
+
import socket
|
|
22
|
+
import struct
|
|
23
|
+
from dataclasses import dataclass, field
|
|
24
|
+
|
|
25
|
+
from pycomap.exceptions import ComApProtocolError
|
|
26
|
+
from pycomap.protocol.framing import Operation, build_inner, parse_inner
|
|
27
|
+
from pycomap.protocol.objects import CommunicationObject
|
|
28
|
+
|
|
29
|
+
DISCOVERY_PORT = 2413
|
|
30
|
+
|
|
31
|
+
_log = logging.getLogger(__name__)
|
|
32
|
+
|
|
33
|
+
_HEADER_SIZE_V0 = 42
|
|
34
|
+
_HEADER_SIZE_V1 = 44
|
|
35
|
+
_UNIT_RECORD_SIZE = 21
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class DeviceType(enum.IntEnum):
|
|
39
|
+
"""ComAp ``DiscoveryDevice.DeviceType`` enum."""
|
|
40
|
+
|
|
41
|
+
IB_NT = 0
|
|
42
|
+
IB_COM = 1
|
|
43
|
+
IB_LITE = 2
|
|
44
|
+
CM_ETHERNET = 4
|
|
45
|
+
IG500_BUILT_IN_ETHERNET = 5
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class AccessType(enum.IntFlag):
|
|
49
|
+
"""ComAp ``DiscoveryDevice.AccessType`` flags."""
|
|
50
|
+
|
|
51
|
+
IP_ADDRESS = 1
|
|
52
|
+
AIR_GATE = 2
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(slots=True, frozen=True)
|
|
56
|
+
class DiscoveryUnit:
|
|
57
|
+
"""A controller unit reachable through the replying gateway device."""
|
|
58
|
+
|
|
59
|
+
type: int
|
|
60
|
+
name: str
|
|
61
|
+
serial: str
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass(slots=True, frozen=True)
|
|
65
|
+
class DiscoveryDevice:
|
|
66
|
+
"""A controller's decoded reply to a discovery probe."""
|
|
67
|
+
|
|
68
|
+
format_version: int
|
|
69
|
+
device_type: DeviceType
|
|
70
|
+
serial_number: int
|
|
71
|
+
firmware_major_minor: int
|
|
72
|
+
firmware_patch_build: int
|
|
73
|
+
ip: str
|
|
74
|
+
mac: str
|
|
75
|
+
comm_port: int
|
|
76
|
+
access_type: AccessType
|
|
77
|
+
airgate_identifier: str
|
|
78
|
+
connected_units: int
|
|
79
|
+
is_units_list_complete: bool
|
|
80
|
+
units: list[DiscoveryUnit] = field(default_factory=list)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _build_probe() -> bytes:
|
|
84
|
+
"""Build the discovery probe: a ``SendMe`` ``EthernetMessage`` for ``Discovery``."""
|
|
85
|
+
return build_inner(
|
|
86
|
+
Operation.SEND_ME, addr=1, comm_obj=CommunicationObject.DISCOVERY, data=b"", ident=0
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _parse_device(data: bytes) -> DiscoveryDevice:
|
|
91
|
+
"""Parse a ``DiscoveryDevice`` payload (the ``Message.data`` of a ``Discovery`` reply)."""
|
|
92
|
+
format_version = data[0]
|
|
93
|
+
device_type = DeviceType(data[1])
|
|
94
|
+
serial_number = struct.unpack_from("<I", data, 2)[0]
|
|
95
|
+
|
|
96
|
+
header_size = _HEADER_SIZE_V0 if format_version == 0 else _HEADER_SIZE_V1
|
|
97
|
+
offset = 6
|
|
98
|
+
firmware_major_minor = struct.unpack_from("<H", data, offset)[0]
|
|
99
|
+
offset += 2
|
|
100
|
+
if format_version >= 1:
|
|
101
|
+
firmware_patch_build = struct.unpack_from("<H", data, offset)[0]
|
|
102
|
+
offset += 2
|
|
103
|
+
else:
|
|
104
|
+
firmware_patch_build = 0
|
|
105
|
+
|
|
106
|
+
ip = ".".join(str(b) for b in data[offset : offset + 4])
|
|
107
|
+
offset += 4
|
|
108
|
+
mac = ":".join(f"{b:02x}" for b in data[offset : offset + 6])
|
|
109
|
+
offset += 6
|
|
110
|
+
comm_port = struct.unpack_from("<H", data, offset)[0]
|
|
111
|
+
offset += 2
|
|
112
|
+
access_type = AccessType(data[offset])
|
|
113
|
+
offset += 1
|
|
114
|
+
airgate_identifier = data[offset : offset + 16].split(b"\x00", 1)[0].decode("ascii", "replace")
|
|
115
|
+
offset += 16
|
|
116
|
+
connected_units = struct.unpack_from("<I", data, offset)[0]
|
|
117
|
+
offset += 4
|
|
118
|
+
is_units_list_complete = bool(data[offset])
|
|
119
|
+
offset += 1
|
|
120
|
+
|
|
121
|
+
assert offset == header_size
|
|
122
|
+
units = [
|
|
123
|
+
DiscoveryUnit(
|
|
124
|
+
type=data[i],
|
|
125
|
+
name=data[i + 1 : i + 17].split(b"\x00", 1)[0].decode("ascii", "replace"),
|
|
126
|
+
serial=data[i + 17 : i + 21].hex(),
|
|
127
|
+
)
|
|
128
|
+
for i in range(header_size, len(data), _UNIT_RECORD_SIZE)
|
|
129
|
+
]
|
|
130
|
+
|
|
131
|
+
return DiscoveryDevice(
|
|
132
|
+
format_version=format_version,
|
|
133
|
+
device_type=device_type,
|
|
134
|
+
serial_number=serial_number,
|
|
135
|
+
firmware_major_minor=firmware_major_minor,
|
|
136
|
+
firmware_patch_build=firmware_patch_build,
|
|
137
|
+
ip=ip,
|
|
138
|
+
mac=mac,
|
|
139
|
+
comm_port=comm_port,
|
|
140
|
+
access_type=access_type,
|
|
141
|
+
airgate_identifier=airgate_identifier,
|
|
142
|
+
connected_units=connected_units,
|
|
143
|
+
is_units_list_complete=is_units_list_complete,
|
|
144
|
+
units=units,
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
async def discover(
|
|
149
|
+
timeout: float = 2.0,
|
|
150
|
+
broadcast_address: str = "255.255.255.255",
|
|
151
|
+
) -> list[DiscoveryDevice]:
|
|
152
|
+
"""Broadcast a discovery probe and collect replies for ``timeout`` seconds.
|
|
153
|
+
|
|
154
|
+
Returns one [DiscoveryDevice][pycomap.discovery.DiscoveryDevice] per distinct
|
|
155
|
+
replying IP address. Malformed or unrelated UDP traffic on the same port
|
|
156
|
+
(CRC mismatch, wrong communication object) is
|
|
157
|
+
silently skipped.
|
|
158
|
+
"""
|
|
159
|
+
loop = asyncio.get_running_loop()
|
|
160
|
+
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
161
|
+
sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
|
|
162
|
+
sock.setblocking(False)
|
|
163
|
+
|
|
164
|
+
found: dict[str, DiscoveryDevice] = {}
|
|
165
|
+
try:
|
|
166
|
+
sock.bind(("", 0))
|
|
167
|
+
_log.debug("sending discovery probe to %s:%d", broadcast_address, DISCOVERY_PORT)
|
|
168
|
+
sock.sendto(_build_probe(), (broadcast_address, DISCOVERY_PORT))
|
|
169
|
+
|
|
170
|
+
end_time = loop.time() + timeout
|
|
171
|
+
while True:
|
|
172
|
+
remaining = end_time - loop.time()
|
|
173
|
+
if remaining <= 0:
|
|
174
|
+
break
|
|
175
|
+
try:
|
|
176
|
+
payload, _peer_addr = await asyncio.wait_for(
|
|
177
|
+
loop.sock_recvfrom(sock, 4096), timeout=remaining
|
|
178
|
+
)
|
|
179
|
+
except TimeoutError:
|
|
180
|
+
break
|
|
181
|
+
try:
|
|
182
|
+
message = parse_inner(payload)
|
|
183
|
+
except ComApProtocolError:
|
|
184
|
+
continue
|
|
185
|
+
if message.comm_obj != CommunicationObject.DISCOVERY:
|
|
186
|
+
continue
|
|
187
|
+
if message.is_error or not message.data:
|
|
188
|
+
continue
|
|
189
|
+
device = _parse_device(message.data)
|
|
190
|
+
found[device.ip] = device
|
|
191
|
+
finally:
|
|
192
|
+
sock.close()
|
|
193
|
+
|
|
194
|
+
_log.info("discovery found %d device(s)", len(found))
|
|
195
|
+
return list(found.values())
|
pycomap/exceptions.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Exception hierarchy for pycomap."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ComApError(Exception):
|
|
7
|
+
"""Base class for all pycomap errors."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ComApConnectionError(ComApError):
|
|
11
|
+
"""Raised on TCP-level connection problems (refused, closed, timed out)."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ComApProtocolError(ComApError):
|
|
15
|
+
"""Raised when the wire protocol itself misbehaves (bad CRC, unexpected framing,
|
|
16
|
+
unsupported cipher, unexpected message type, etc.) — never on a clean controller-side
|
|
17
|
+
error response, see [ComApControllerError][pycomap.ComApControllerError] for that.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ComApAuthError(ComApError):
|
|
22
|
+
"""Raised when access-code verification fails or is rejected by the controller."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ComApControllerError(ComApError):
|
|
26
|
+
"""Raised when the controller answers with an explicit ``Error`` operation.
|
|
27
|
+
|
|
28
|
+
Attributes:
|
|
29
|
+
code: the raw ``uint32`` error code reported by the controller. See
|
|
30
|
+
``docs/protocol.md`` section 2.6 for known values.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def __init__(self, code: int, message: str | None = None) -> None:
|
|
34
|
+
self.code = code
|
|
35
|
+
super().__init__(message or f"controller error: {code} (0x{code:08x})")
|
pycomap/history.py
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""History record parsing for IL3 controllers.
|
|
2
|
+
|
|
3
|
+
Source: ``ClientDrivenHistorySerializer.LoadHeaderIL3`` + ``LoadHeaderIL3CommonPart``
|
|
4
|
+
+ ``HistoryTimeStamp.LoadDefinedByHistRecord`` in ``ComAp.Controller.dll``.
|
|
5
|
+
See ``docs/protocol.md`` for the full binary format notes.
|
|
6
|
+
|
|
7
|
+
IL3 record wire layout (ConfigFormatTerminal < 7, confirmed on live controller):
|
|
8
|
+
|
|
9
|
+
Bytes 0-1 uint16 LE bits 0-11 = reason_index, bits 13-14 = reason_category
|
|
10
|
+
Byte 2 bits 0-4 = prefix_index, bits 5-7 = level
|
|
11
|
+
Bytes 3-11 9-byte timestamp (DefinedByHistRecord format):
|
|
12
|
+
[0]=BCD(day) [1]=BCD(month) [2]=BCD(year-2000)
|
|
13
|
+
[3]=BCD(hour) [4]=BCD(minute) [5]=BCD(second)
|
|
14
|
+
[6] bit7=type(0=RTC,1=EngHours) bits0-3=tenths-of-second
|
|
15
|
+
[7-8] uint16 LE = sequential record index
|
|
16
|
+
Bytes 12+ payload: null-terminated ASCII for text records, raw value
|
|
17
|
+
snapshots for alarm/event records
|
|
18
|
+
|
|
19
|
+
prefix_index sentinel values: 30 = text record, 31 = invalid record.
|
|
20
|
+
reason_category: 0=HistoryReasonNames, 1=CommonNames, 2=DiagNames, 3=AlarmReasonNames
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import datetime
|
|
26
|
+
import struct
|
|
27
|
+
from dataclasses import dataclass
|
|
28
|
+
|
|
29
|
+
from pycomap.configuration import NamesCategory, parse_names_heap
|
|
30
|
+
from pycomap.datatypes import _bcd_decode, get_bits
|
|
31
|
+
|
|
32
|
+
_HIST_PREFIX_TEXT = 30
|
|
33
|
+
_HIST_PREFIX_INVALID = 31
|
|
34
|
+
|
|
35
|
+
_HIST_CAT_REASON_NAMES: dict[int, NamesCategory] = {
|
|
36
|
+
0: NamesCategory.HISTORY_REASON_NAMES,
|
|
37
|
+
1: NamesCategory.COMMON_NAMES,
|
|
38
|
+
3: NamesCategory.ALARM_REASON_NAMES,
|
|
39
|
+
# 2 = EcuDiagnosticCodes (DiagNames) — not implemented; returned as empty string
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(slots=True, frozen=True)
|
|
44
|
+
class HistoryRecord:
|
|
45
|
+
"""One entry from the controller's history ring buffer (``YOUNGEST_HISTORY_RECORD``
|
|
46
|
+
/ ``OLDER_HISTORY_RECORD``, C.O. 24569/24567).
|
|
47
|
+
|
|
48
|
+
For alarm events: ``reason`` is the value name that triggered the event (e.g.
|
|
49
|
+
``"Generator Voltage L1-N"``), ``prefix`` is the protection type (``"Wrn"``,
|
|
50
|
+
``"Sd"``, etc.), ``level`` encodes severity (1-3) or transition type (4=Start,
|
|
51
|
+
5=Stop).
|
|
52
|
+
|
|
53
|
+
For configuration-change events (``is_text=True``): ``text`` holds the human-readable
|
|
54
|
+
description (e.g. ``"T=ETH CA1 A CON(24554)=21:25:08"``); ``reason`` and ``prefix``
|
|
55
|
+
are usually empty.
|
|
56
|
+
|
|
57
|
+
``timestamp`` is the controller's local time at the event (see ``read_datetime`` note
|
|
58
|
+
about Summer Time Mode). ``engine_hours`` is set instead when the controller uses
|
|
59
|
+
run-hours as the time reference (rare; RTC is the norm for IL3).
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
index: int
|
|
63
|
+
timestamp: datetime.datetime | None
|
|
64
|
+
engine_hours: datetime.timedelta | None
|
|
65
|
+
tenth_seconds: int
|
|
66
|
+
reason: str
|
|
67
|
+
prefix: str
|
|
68
|
+
level: int
|
|
69
|
+
is_text: bool
|
|
70
|
+
text: str
|
|
71
|
+
data: bytes
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _parse_history_timestamp(
|
|
75
|
+
ts: bytes,
|
|
76
|
+
) -> tuple[datetime.datetime | None, datetime.timedelta | None, int]:
|
|
77
|
+
"""Decode the 9-byte DefinedByHistRecord timestamp.
|
|
78
|
+
|
|
79
|
+
Returns ``(datetime, engine_hours, tenth_seconds)``. Exactly one of the first two
|
|
80
|
+
will be non-None.
|
|
81
|
+
"""
|
|
82
|
+
is_engine_hours = bool(ts[6] & 0x80)
|
|
83
|
+
tenth_seconds = _bcd_decode(ts[6] & 0x0F)
|
|
84
|
+
|
|
85
|
+
if is_engine_hours:
|
|
86
|
+
dw = struct.unpack_from("<I", ts, 0)[0]
|
|
87
|
+
hours = (dw >> 0) & 0xFFFFFF
|
|
88
|
+
minutes = (dw >> 24) & 0xFF
|
|
89
|
+
return None, datetime.timedelta(hours=hours, minutes=minutes), tenth_seconds
|
|
90
|
+
|
|
91
|
+
try:
|
|
92
|
+
dt = datetime.datetime(
|
|
93
|
+
year=_bcd_decode(ts[2]) + 2000,
|
|
94
|
+
month=_bcd_decode(ts[1]),
|
|
95
|
+
day=_bcd_decode(ts[0]),
|
|
96
|
+
hour=_bcd_decode(ts[3]),
|
|
97
|
+
minute=_bcd_decode(ts[4]),
|
|
98
|
+
second=_bcd_decode(ts[5]),
|
|
99
|
+
)
|
|
100
|
+
except ValueError:
|
|
101
|
+
dt = None
|
|
102
|
+
return dt, None, tenth_seconds
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def parse_history_record(config_data: bytes, record_data: bytes) -> HistoryRecord | None:
|
|
106
|
+
"""Parse one history record blob returned by ``YOUNGEST_HISTORY_RECORD`` /
|
|
107
|
+
``OLDER_HISTORY_RECORD`` (C.O. 24569/24567).
|
|
108
|
+
|
|
109
|
+
Returns ``None`` for invalid records (prefix_index=31 or unparseable timestamp).
|
|
110
|
+
Pass the full raw ``ConfigurationTable`` blob as ``config_data`` — it is used to
|
|
111
|
+
resolve reason and prefix names from the unified names heap.
|
|
112
|
+
"""
|
|
113
|
+
if len(record_data) < 12:
|
|
114
|
+
return None
|
|
115
|
+
|
|
116
|
+
word, flags_byte = struct.unpack_from("<HB", record_data, 0)
|
|
117
|
+
reason_index = get_bits(word, 0, 12)
|
|
118
|
+
reason_category = get_bits(word, 13, 2)
|
|
119
|
+
prefix_index = get_bits(flags_byte, 0, 5)
|
|
120
|
+
level = get_bits(flags_byte, 5, 3)
|
|
121
|
+
|
|
122
|
+
if prefix_index == _HIST_PREFIX_INVALID:
|
|
123
|
+
return None
|
|
124
|
+
|
|
125
|
+
is_text = prefix_index == _HIST_PREFIX_TEXT
|
|
126
|
+
ts_bytes = record_data[3:12]
|
|
127
|
+
payload = record_data[12:]
|
|
128
|
+
|
|
129
|
+
dt, engine_hours, tenth_seconds = _parse_history_timestamp(ts_bytes)
|
|
130
|
+
index = struct.unpack_from("<H", ts_bytes, 7)[0]
|
|
131
|
+
|
|
132
|
+
if dt is None and engine_hours is None:
|
|
133
|
+
return None
|
|
134
|
+
|
|
135
|
+
# Resolve reason name
|
|
136
|
+
names_cat = _HIST_CAT_REASON_NAMES.get(reason_category)
|
|
137
|
+
reason = ""
|
|
138
|
+
if names_cat is not None:
|
|
139
|
+
names = parse_names_heap(config_data, names_cat)
|
|
140
|
+
reason = names[reason_index] if reason_index < len(names) else ""
|
|
141
|
+
|
|
142
|
+
# Resolve prefix name. Index 0 = '-' (info/status, no protection class).
|
|
143
|
+
prefix = ""
|
|
144
|
+
if not is_text:
|
|
145
|
+
prefix_names = parse_names_heap(config_data, NamesCategory.HISTORY_PREFIX_NAMES)
|
|
146
|
+
prefix = prefix_names[prefix_index] if prefix_index < len(prefix_names) else ""
|
|
147
|
+
|
|
148
|
+
# Decode text payload (null-terminated ASCII)
|
|
149
|
+
text = ""
|
|
150
|
+
if is_text:
|
|
151
|
+
null = payload.find(0)
|
|
152
|
+
raw_text = payload[:null] if null >= 0 else payload
|
|
153
|
+
text = raw_text.decode("ascii", errors="replace")
|
|
154
|
+
|
|
155
|
+
return HistoryRecord(
|
|
156
|
+
index=index,
|
|
157
|
+
timestamp=dt,
|
|
158
|
+
engine_hours=engine_hours,
|
|
159
|
+
tenth_seconds=tenth_seconds,
|
|
160
|
+
reason=reason,
|
|
161
|
+
prefix=prefix,
|
|
162
|
+
level=level,
|
|
163
|
+
is_text=is_text,
|
|
164
|
+
text=text,
|
|
165
|
+
data=payload if not is_text else b"",
|
|
166
|
+
)
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""ComAp's native ECDH/AES-encrypted control protocol (TCP port 23).
|
|
2
|
+
|
|
3
|
+
See ``docs/protocol.md`` section 2 for the reverse-engineering notes this package implements.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from pycomap.protocol.client import ComApClient
|
|
9
|
+
from pycomap.protocol.commands import Command, ControllerCommand
|
|
10
|
+
from pycomap.protocol.framing import Message, Operation
|
|
11
|
+
from pycomap.protocol.objects import CommunicationObject, ControllerError
|
|
12
|
+
from pycomap.protocol.transport import EthernetTransport, Transport
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"ComApClient",
|
|
16
|
+
"Command",
|
|
17
|
+
"CommunicationObject",
|
|
18
|
+
"ControllerCommand",
|
|
19
|
+
"ControllerError",
|
|
20
|
+
"EthernetTransport",
|
|
21
|
+
"Message",
|
|
22
|
+
"Operation",
|
|
23
|
+
"Transport",
|
|
24
|
+
]
|