pycomap 1.0.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.
pycomap-1.0.0/PKG-INFO ADDED
@@ -0,0 +1,57 @@
1
+ Metadata-Version: 2.4
2
+ Name: pycomap
3
+ Version: 1.0.0
4
+ Summary: Async Python client for ComAp controllers: LAN discovery and the native ECDH/AES-encrypted control protocol
5
+ Author: Igor Panteleyev
6
+ Author-email: Igor Panteleyev <panteleev.igor69@gmail.com>
7
+ License-Expression: MIT
8
+ Requires-Dist: cryptography>=42
9
+ Requires-Dist: pytz>=2026.2
10
+ Requires-Python: >=3.13
11
+ Description-Content-Type: text/markdown
12
+
13
+ # pycomap
14
+
15
+ Async Python client for ComAp controllers (InteliLite AMF25 and likely compatible
16
+ siblings): LAN discovery and the native ECDH/AES-encrypted control protocol on port 23.
17
+
18
+ Reverse-engineered from `ComAp.Controller.dll` and cross-validated against live hardware.
19
+
20
+ Requires Python 3.13+. Dependencies: `cryptography`, `pytz`.
21
+
22
+ ## Quick start
23
+
24
+ ```python
25
+ from pycomap import Controller, EthernetTransport
26
+ from pycomap.protocol import ComApClient
27
+
28
+ async with Controller(
29
+ ComApClient(EthernetTransport("192.168.1.9")),
30
+ access_code="0", # factory default (drives ECDH key derivation)
31
+ password=1234, # write-protection password (0-9999); omit for read-only
32
+ ) as ctrl:
33
+ values = await ctrl.read_values()
34
+ print(values)
35
+
36
+ await ctrl.set_setpoint("Nominal RPM", 1500)
37
+ await ctrl.set_setpoint("Summer Time Mode", "Winter") # STRING_LIST by label
38
+ ```
39
+
40
+ See the [API docs](docs/) for full reference. `just docs-serve` to browse locally.
41
+
42
+ ## Development
43
+
44
+ ```sh
45
+ just format # ruff check --fix + ruff format
46
+ just typecheck # ty check
47
+ just unit # tests/unit (no hardware needed)
48
+ just integration # tests/integration (requires .env with PYCOMAP_TEST_HOST)
49
+ just docs-serve # browse API docs locally
50
+ just ai-docs # regenerate llms.txt and CLAUDE.md
51
+ ```
52
+
53
+ `pre-commit` runs format + typecheck on every commit:
54
+
55
+ ```sh
56
+ uv run pre-commit install
57
+ ```
@@ -0,0 +1,45 @@
1
+ # pycomap
2
+
3
+ Async Python client for ComAp controllers (InteliLite AMF25 and likely compatible
4
+ siblings): LAN discovery and the native ECDH/AES-encrypted control protocol on port 23.
5
+
6
+ Reverse-engineered from `ComAp.Controller.dll` and cross-validated against live hardware.
7
+
8
+ Requires Python 3.13+. Dependencies: `cryptography`, `pytz`.
9
+
10
+ ## Quick start
11
+
12
+ ```python
13
+ from pycomap import Controller, EthernetTransport
14
+ from pycomap.protocol import ComApClient
15
+
16
+ async with Controller(
17
+ ComApClient(EthernetTransport("192.168.1.9")),
18
+ access_code="0", # factory default (drives ECDH key derivation)
19
+ password=1234, # write-protection password (0-9999); omit for read-only
20
+ ) as ctrl:
21
+ values = await ctrl.read_values()
22
+ print(values)
23
+
24
+ await ctrl.set_setpoint("Nominal RPM", 1500)
25
+ await ctrl.set_setpoint("Summer Time Mode", "Winter") # STRING_LIST by label
26
+ ```
27
+
28
+ See the [API docs](docs/) for full reference. `just docs-serve` to browse locally.
29
+
30
+ ## Development
31
+
32
+ ```sh
33
+ just format # ruff check --fix + ruff format
34
+ just typecheck # ty check
35
+ just unit # tests/unit (no hardware needed)
36
+ just integration # tests/integration (requires .env with PYCOMAP_TEST_HOST)
37
+ just docs-serve # browse API docs locally
38
+ just ai-docs # regenerate llms.txt and CLAUDE.md
39
+ ```
40
+
41
+ `pre-commit` runs format + typecheck on every commit:
42
+
43
+ ```sh
44
+ uv run pre-commit install
45
+ ```
@@ -0,0 +1,73 @@
1
+ [project]
2
+ name = "pycomap"
3
+ version = "1.0.0"
4
+ description = "Async Python client for ComAp controllers: LAN discovery and the native ECDH/AES-encrypted control protocol"
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ authors = [
8
+ { name = "Igor Panteleyev", email = "panteleev.igor69@gmail.com" }
9
+ ]
10
+ requires-python = ">=3.13"
11
+ dependencies = [
12
+ "cryptography>=42",
13
+ "pytz>=2026.2",
14
+ ]
15
+
16
+ [build-system]
17
+ requires = ["uv_build>=0.9.26,<0.10.0"]
18
+ build-backend = "uv_build"
19
+
20
+ [dependency-groups]
21
+ dev = [
22
+ "mkdocs>=1.6,<2",
23
+ "mkdocs-material>=9.5",
24
+ "mkdocstrings[python]>=0.25",
25
+ "pre-commit>=4.6.0",
26
+ "pytest>=8",
27
+ "pytest-asyncio>=0.24",
28
+ "pytest-cov>=7.1.0",
29
+ "pytest-env>=1.6.0",
30
+ "pytest-mock>=3.15.1",
31
+ "ruff>=0.15",
32
+ "ty>=0.0.16",
33
+ ]
34
+
35
+ [tool.pytest.ini_options]
36
+ addopts = "-ra"
37
+ asyncio_mode = "auto"
38
+ testpaths = ["tests"]
39
+ markers = [
40
+ "integration: requires real ComAp hardware reachable on the network (set PYCOMAP_TEST_HOST, see .env.example)",
41
+ ]
42
+
43
+ [tool.pytest_env]
44
+ env_files = [".env"]
45
+
46
+ [tool.coverage.run]
47
+ source = ["src/pycomap"]
48
+ omit = ["tests/*"]
49
+
50
+ [tool.ruff]
51
+ line-length = 100
52
+ target-version = "py313"
53
+
54
+ [tool.ruff.lint]
55
+ select = [
56
+ "E", # pycodestyle errors
57
+ "F", # pyflakes
58
+ "I", # isort
59
+ "UP", # pyupgrade
60
+ "B", # flake8-bugbear
61
+ "SIM", # flake8-simplify
62
+ "C4", # flake8-comprehensions
63
+ "RUF", # ruff-specific
64
+ ]
65
+
66
+ [tool.ruff.lint.isort]
67
+ known-first-party = ["pycomap"]
68
+
69
+ [tool.ty.environment]
70
+ python-version = "3.13"
71
+
72
+ [tool.uv]
73
+ publish-url = "https://upload.pypi.org/legacy/"
@@ -0,0 +1,56 @@
1
+ """pycomap — async Python client for ComAp controllers (InteliLite/AMF25 and others).
2
+
3
+ Quick start::
4
+
5
+ from pycomap import Command, Controller, EthernetTransport, discover
6
+
7
+ async with ComApClient(EthernetTransport("192.168.1.9")) as client:
8
+ await client.authenticate("0")
9
+ await client.elevate_access(password)
10
+ result = await client.execute_command(Command.FAULT_RESET)
11
+
12
+ Public API surface:
13
+
14
+ - [ComApClient][pycomap.protocol.ComApClient] — low-level protocol client
15
+ - [Command][pycomap.protocol.Command] — named controller commands
16
+ - [EthernetTransport][pycomap.protocol.EthernetTransport] /
17
+ [Transport][pycomap.protocol.transport.Transport]
18
+ - [discover][pycomap.discovery.discover] — UDP controller discovery
19
+ - [pycomap.configuration][] — `ConfigurationTable` parsing and value/setpoint decode
20
+ - [pycomap.alarms][] — alarm list parsing
21
+ - [pycomap.history][] — history record parsing
22
+ - [pycomap.datatypes][] — wire types and BCD codec
23
+
24
+ See ``docs/protocol.md`` in the project repository for the full reverse-engineering notes.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import logging
30
+
31
+ from pycomap.controller import Controller
32
+ from pycomap.discovery import discover
33
+ from pycomap.exceptions import (
34
+ ComApAuthError,
35
+ ComApConnectionError,
36
+ ComApControllerError,
37
+ ComApError,
38
+ ComApProtocolError,
39
+ )
40
+ from pycomap.protocol import ComApClient, Command, ControllerCommand, EthernetTransport
41
+
42
+ logging.getLogger(__name__).addHandler(logging.NullHandler())
43
+
44
+ __all__ = [
45
+ "ComApAuthError",
46
+ "ComApClient",
47
+ "ComApConnectionError",
48
+ "ComApControllerError",
49
+ "ComApError",
50
+ "ComApProtocolError",
51
+ "Command",
52
+ "Controller",
53
+ "ControllerCommand",
54
+ "EthernetTransport",
55
+ "discover",
56
+ ]
@@ -0,0 +1,100 @@
1
+ """Alarm list parsing for IL3 controllers.
2
+
3
+ Source: ``AlarmListRecord.LoadIL3`` + ``LoadCommonPart`` in ``ComAp.Controller.dll``.
4
+ See ``docs/protocol.md`` for the full binary format notes.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import struct
10
+ from dataclasses import dataclass
11
+
12
+ from pycomap.configuration import NamesCategory, parse_names_heap
13
+ from pycomap.datatypes import get_bits
14
+
15
+ # IL3 alarm list: 112 bytes = up to 16 x 7-byte records.
16
+ # Record: uint32 dw + uint16 flags + uint8 source.
17
+ # IsUsed: bit 31 of dw must be 1 AND not all fields 0xFF.
18
+ # DiagnosticCodeType.ComAp = 7 (bits 0-2 of flags).
19
+
20
+ _IL3_ALARM_RECORD_SIZE = 7
21
+ _DIAGNOSTIC_CODE_TYPE_COMAP = 7
22
+ _ALARM_RECORD_STRUCT = struct.Struct("<IHB") # uint32 dw, uint16 flags, uint8 source
23
+
24
+
25
+ @dataclass(slots=True, frozen=True)
26
+ class AlarmRecord:
27
+ """One entry from the controller's alarm list (``CommunicationObject.ALARM_LIST``,
28
+ C.O. 24545).
29
+
30
+ Source: ``AlarmListRecord.LoadIL3`` + ``LoadCommonPart`` in ``ComAp.Controller.dll``.
31
+ The IL3 format is 112 bytes = up to 16 x 7-byte records; records stop at the first
32
+ unused slot (all fields at max value) or end of buffer.
33
+
34
+ ``reason`` is resolved from ``AlarmReasonNames`` and matches the value's display name
35
+ (e.g. ``"Generator Voltage L1-N"``). ``prefix`` comes from ``HistoryPrefixNames`` and
36
+ encodes the protection type (e.g. ``"Wrn"``, ``"Sd"``).
37
+
38
+ For non-ComAp diagnostic codes (ECU alarms from CAN bus), ``reason`` and ``prefix``
39
+ will be empty strings and ``fault_code`` carries the raw ECU fault code.
40
+ """
41
+
42
+ is_active: bool
43
+ is_confirmed: bool
44
+ reason: str
45
+ prefix: str
46
+ occurred: int
47
+ source: int
48
+ fault_code: int
49
+
50
+
51
+ def parse_alarm_list(config_data: bytes, alarm_data: bytes) -> list[AlarmRecord]:
52
+ """Parse the controller's ``ALARM_LIST`` blob (C.O. 24545) into a list of
53
+ ``AlarmRecord`` objects, in list order (most recent first per controller behaviour).
54
+
55
+ Only active (``IsUsed``) slots are returned; the list will be empty if there are no
56
+ current alarms. Pass the full raw ``ConfigurationTable`` blob as ``config_data`` —
57
+ it is used to resolve the ``AlarmReasonNames`` and ``HistoryPrefixNames`` heaps.
58
+ """
59
+ reason_names = parse_names_heap(config_data, NamesCategory.ALARM_REASON_NAMES)
60
+ prefix_names = parse_names_heap(config_data, NamesCategory.HISTORY_PREFIX_NAMES)
61
+
62
+ records: list[AlarmRecord] = []
63
+ for offset in range(0, len(alarm_data) - _IL3_ALARM_RECORD_SIZE + 1, _IL3_ALARM_RECORD_SIZE):
64
+ dw, flags, source = _ALARM_RECORD_STRUCT.unpack_from(alarm_data, offset)
65
+
66
+ is_used = bool(get_bits(dw, 31, 1)) and not (
67
+ dw == 0xFFFFFFFF and flags == 0xFFFF and source == 0xFF
68
+ )
69
+ if not is_used:
70
+ break
71
+
72
+ kind = get_bits(flags, 0, 3)
73
+ reason_index = get_bits(flags, 3, 11)
74
+ is_active = bool(get_bits(flags, 14, 1))
75
+ is_confirmed = bool(get_bits(flags, 15, 1))
76
+
77
+ fault_code = get_bits(dw, 0, 19)
78
+ prefix_index = get_bits(dw, 19, 5)
79
+ occurred = get_bits(dw, 24, 7)
80
+
81
+ if kind == _DIAGNOSTIC_CODE_TYPE_COMAP:
82
+ reason = reason_names[reason_index] if reason_index < len(reason_names) else ""
83
+ prefix = prefix_names[prefix_index] if prefix_index < len(prefix_names) else ""
84
+ else:
85
+ reason = ""
86
+ prefix = ""
87
+
88
+ records.append(
89
+ AlarmRecord(
90
+ is_active=is_active,
91
+ is_confirmed=is_confirmed,
92
+ reason=reason,
93
+ prefix=prefix,
94
+ occurred=occurred,
95
+ source=source,
96
+ fault_code=fault_code,
97
+ )
98
+ )
99
+
100
+ return records