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/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
+ ]