Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/deploy-docs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ jobs:
run: uv sync --frozen

- name: Build docs
run: uv run mkdocs build
run: uv run zensical build

- name: Upload Pages artifact
uses: actions/upload-pages-artifact@v5
Expand Down
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ just unit # pytest tests/unit, no hardware (alias: u)
just test # full pytest suite (alias: t)
just integration # pytest tests/integration, requires .env (alias: i)
just coverage # unit tests with coverage report (alias: cov)
just docs # build MkDocs static site → site/
just docs # build Zensical static site → site/
just docs-serve # live-reload preview at localhost:8000
just ai-docs # regenerate llms.txt and CLAUDE.md from README.md
```
Expand Down Expand Up @@ -64,7 +64,7 @@ async with Controller(
await ctrl.set_setpoint("Summer Time Mode", "Winter") # STRING_LIST by label
```

See the [API docs](https://igor-panteleev.github.io/pycomap/) for full reference. `just docs-serve` to browse locally.
See the [API docs](https://igor-panteleev.github.io/pycomap/) for full reference, built with [Zensical](https://zensical.org/). `just docs-serve` to browse locally.

## Development

Expand Down
8 changes: 4 additions & 4 deletions Justfile
Original file line number Diff line number Diff line change
Expand Up @@ -64,13 +64,13 @@ coverage: prepare

# --- Documentation ---

# Build MkDocs static site (output: site/)
# Build Zensical static site (output: site/)
docs: prepare
uv run mkdocs build
uv run zensical build

# Serve MkDocs site locally with live reload
# Serve Zensical site locally with live reload
docs-serve: prepare
uv run mkdocs serve
uv run zensical serve

# Regenerate llms.txt and CLAUDE.md from README.md
ai-docs: prepare
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ async with Controller(
await ctrl.set_setpoint("Summer Time Mode", "Winter") # STRING_LIST by label
```

See the [API docs](https://igor-panteleev.github.io/pycomap/) for full reference. `just docs-serve` to browse locally.
See the [API docs](https://igor-panteleev.github.io/pycomap/) for full reference, built with [Zensical](https://zensical.org/). `just docs-serve` to browse locally.

## Development

Expand Down
1,220 changes: 990 additions & 230 deletions docs/controller_reference.md

Large diffs are not rendered by default.

5 changes: 5 additions & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
---
hide:
- navigation
---

# pycomap

Async Python client for ComAp controllers (InteliLite AMF25 and likely compatible
Expand Down
5 changes: 5 additions & 0 deletions docs/protocol.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
---
hide:
- navigation
---

# ComAp InteliLite/AMF25 — Protocol Reverse-Engineering Notes

This document records everything established during the reverse-engineering session: the
Expand Down
2 changes: 1 addition & 1 deletion llms.txt
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ async with Controller(
await ctrl.set_setpoint("Summer Time Mode", "Winter") # STRING_LIST by label
```

See the [API docs](https://igor-panteleev.github.io/pycomap/) for full reference. `just docs-serve` to browse locally.
See the [API docs](https://igor-panteleev.github.io/pycomap/) for full reference, built with [Zensical](https://zensical.org/). `just docs-serve` to browse locally.

## Development

Expand Down
2 changes: 2 additions & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
site_name: pycomap
site_url: https://igor-panteleev.github.io/pycomap/
site_description: Async Python client for ComAp controllers
repo_url: https://github.com/igor-panteleev/pycomap
repo_name: igor-panteleev/pycomap

theme:
name: material
Expand Down
9 changes: 5 additions & 4 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "pycomap"
version = "2.1.0"
version = "2.1.1"
description = "Async Python client for ComAp controllers: LAN discovery and the native ECDH/AES-encrypted control protocol"
readme = "README.md"
license = "MIT"
Expand Down Expand Up @@ -38,10 +38,11 @@ build-backend = "uv_build"

[dependency-groups]
dev = [
"configargparse>=1.7.5",
"ifaddr>=0.2.0",
"mkdocs>=1.6,<2",
"mkdocs-material>=9.5",
"mkdocstrings[python]>=0.25",
"python-dotenv>=1.2.2",
"zensical>=0.0.50",
"mkdocstrings-python>=1.0",
"pre-commit>=4.6.0",
"pytest>=8",
"pytest-asyncio>=0.24",
Expand Down
2 changes: 1 addition & 1 deletion scripts/gen_ai_docs.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@
just test # full pytest suite (alias: t)
just integration # pytest tests/integration, requires .env (alias: i)
just coverage # unit tests with coverage report (alias: cov)
just docs # build MkDocs static site → site/
just docs # build Zensical static site → site/
just docs-serve # live-reload preview at localhost:8000
just ai-docs # regenerate llms.txt and CLAUDE.md from README.md
```
Expand Down
188 changes: 188 additions & 0 deletions scripts/gen_controller_reference.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
"""Regenerate docs/controller_reference.md from a live ComAp controller.

Connects read-only (no password) to a real controller and writes the full Values,
Setpoints, and String Lists tables from its ``ConfigurationTable`` plus a single
``read_values()``/``read_setpoints()`` snapshot. Re-run whenever the controller's
configuration changes.

Usage::

uv run python scripts/gen_controller_reference.py --host 192.168.1.9 --access-code 0

``--host``/``--access-code`` fall back to ``PYCOMAP_TEST_HOST``/``PYCOMAP_TEST_ACCESS_CODE``
from ``.env`` (see ``.env.example``) when omitted.
"""

from __future__ import annotations

import asyncio
import re
from ipaddress import IPv4Address
from pathlib import Path

import configargparse
from dotenv import load_dotenv

from pycomap import Controller, EthernetTransport
from pycomap.configuration import SetpointDescription, ValueDescription
from pycomap.datatypes import DataType, Value
from pycomap.protocol import ComApClient

ROOT = Path(__file__).parent.parent
OUTPUT = ROOT / "docs" / "controller_reference.md"

# Values that identify this specific controller/network rather than describing its generic
# configuration -- redacted so the generated reference is safe to commit/publish.
_REDACTED_VALUE_NAMES = frozenset({"Password Decode", "MAC Address"})
_REDACTED = "***"

Description = ValueDescription | SetpointDescription
StringListEntry = tuple[Description, list[tuple[int, str]]]


def _slugify(text: str) -> str:
"""Reproduce python-markdown's default TOC heading-id algorithm.

mkdocs/Zensical use this to generate heading anchors -- links computed here must match
the anchors actually rendered, or the cross-links from the Values/Setpoints tables into
the String Lists section will silently 404.
"""
text = re.sub(r"[^\w\s-]", "", text.lower())
return re.sub(r"\s+", "-", text.strip())


def _anchor(desc: Description) -> str:
return _slugify(f"{desc.name} ({desc.number})")


def _display(desc: Description, val: Value | None) -> str:
if val is None:
return "—"
if desc.name in _REDACTED_VALUE_NAMES:
return _REDACTED
if isinstance(val, float) and desc.decimal_places:
return f"{val:.{desc.decimal_places}f}"
if isinstance(val, bytes):
return val.hex()
return str(val)


def _type_cell(desc: Description, string_list_numbers: set[int]) -> str:
if desc.number in string_list_numbers:
return f"[STRING_LIST](#{_anchor(desc)})"
return desc.data_type.name


async def _gather(
host: IPv4Address, access_code: str
) -> tuple[
dict[int, Value],
dict[int, Value],
list[ValueDescription],
list[SetpointDescription],
list[StringListEntry],
]:
async with Controller(ComApClient(EthernetTransport(host)), access_code=access_code) as ctrl:
values = await ctrl.read_values()
setpoints = await ctrl.read_setpoints()
value_descs = sorted(ctrl.values, key=lambda d: d.number)
setpoint_descs = sorted(ctrl.setpoints, key=lambda d: d.number)

string_lists: list[StringListEntry] = []
for value_desc in value_descs:
if value_desc.data_type is DataType.STRING_LIST:
string_lists.append((value_desc, ctrl.value_options(value_desc.number)))
for setpoint_desc in setpoint_descs:
if setpoint_desc.data_type is DataType.STRING_LIST:
string_lists.append((setpoint_desc, ctrl.setpoint_options(setpoint_desc.number)))

return values, setpoints, value_descs, setpoint_descs, string_lists


def _render(
values: dict[int, Value],
setpoints: dict[int, Value],
value_descs: list[ValueDescription],
setpoint_descs: list[SetpointDescription],
string_lists: list[StringListEntry],
) -> str:
string_list_numbers = {desc.number for desc, _ in string_lists}

lines: list[str] = [
"---",
"hide:",
" - navigation",
"---",
"",
"# ComAp Controller Reference",
"",
"## Values",
"",
"| # | Category | Group | Name | Type | Unit | Value |",
"| --- | --- | --- | --- | --- | --- | --- |",
]
for value_desc in value_descs:
val = _display(value_desc, values.get(value_desc.number))
lines.append(
f"| {value_desc.number} | {value_desc.category.name} | {value_desc.group or ''} | "
f"{value_desc.name} | {_type_cell(value_desc, string_list_numbers)} | "
f"{value_desc.dimension} | {val} |"
)

lines += [
"",
"## Setpoints",
"",
"| # | Group | Name | Type | Unit | Min | Max | Password | Value |",
"| --- | --- | --- | --- | --- | --- | --- | --- | --- |",
]
for setpoint_desc in setpoint_descs:
val = _display(setpoint_desc, setpoints.get(setpoint_desc.number))
lo = "var" if setpoint_desc.var_low_limit else setpoint_desc.low_limit
hi = "var" if setpoint_desc.var_high_limit else setpoint_desc.high_limit
password = "yes" if setpoint_desc.needs_password else "no"
lines.append(
f"| {setpoint_desc.number} | {setpoint_desc.group or ''} | {setpoint_desc.name} | "
f"{_type_cell(setpoint_desc, string_list_numbers)} | {setpoint_desc.dimension} | "
f"{lo} | {hi} | {password} | {val} |"
)

lines += [
"",
"## String Lists",
"",
"Option tables for every `STRING_LIST` value and setpoint above, linked from their "
"**Type** column. The wire value is the raw 0-based integer used on the wire (e.g. "
"by [set_setpoint][pycomap.Controller.set_setpoint]); the label is what's shown on "
"the front panel and in InteliConfig.",
]
for desc, options in string_lists:
lines += [
"",
f"### {desc.name} ({desc.number})",
"",
"| Wire Value | Label |",
"| --- | --- |",
]
lines += [f"| {wire_value} | {label} |" for wire_value, label in options]
lines.append("")

return "\n".join(lines)


async def main(host: IPv4Address, access_code: str) -> None:
values, setpoints, value_descs, setpoint_descs, string_lists = await _gather(host, access_code)
OUTPUT.write_text(_render(values, setpoints, value_descs, setpoint_descs, string_lists))
print(
f"wrote {OUTPUT.relative_to(ROOT)}: {len(value_descs)} values, "
f"{len(setpoint_descs)} setpoints, {len(string_lists)} string lists"
)


if __name__ == "__main__":
load_dotenv()
parser = configargparse.ArgParser(description=__doc__)
parser.add_argument("--host", type=IPv4Address, required=True, env_var="PYCOMAP_TEST_HOST")
parser.add_argument("--access-code", default="0", env_var="PYCOMAP_TEST_ACCESS_CODE")
args = parser.parse_args()
asyncio.run(main(args.host, args.access_code))
56 changes: 46 additions & 10 deletions src/pycomap/controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -319,6 +319,51 @@ def setpoint_info(self, name_or_number: str | int) -> SetpointDescription:
except KeyError:
raise KeyError(f"no setpoint named {name_or_number!r}") from None

def _string_list_options(
self, desc: ValueDescription | SetpointDescription
) -> list[tuple[int, str]]:
"""Shared ``STRING_LIST`` option lookup for
[value_options][pycomap.Controller.value_options] and
[setpoint_options][pycomap.Controller.setpoint_options].

Options are stored in ``CommonNames`` at indices ``[low_limit .. high_limit]``.
"""
if desc.data_type is not DataType.STRING_LIST:
raise ComApProtocolError(
f"{desc.name!r} is DataType.{desc.data_type.name}, not STRING_LIST"
)
return [
(wire_value, self._common_names[desc.low_limit + wire_value])
for wire_value in range(desc.high_limit - desc.low_limit + 1)
if desc.low_limit + wire_value < len(self._common_names)
]

def value_options(self, name_or_number: str | int) -> list[tuple[int, str]]:
"""Return the available options for a ``STRING_LIST`` value.

Options are stored in ``CommonNames`` at indices ``[low_limit .. high_limit]``.
The wire value (0-based) is what the controller sends on the wire and what
[value_label][pycomap.Controller.value_label] expects; the label is the string
shown on the front panel and in InteliConfig. Note that
[read_values][pycomap.Controller.read_values] resolves ``STRING_LIST`` values to
their label automatically — this method is for enumerating all possible options
up front (e.g. to build a legend or validate against known states).

Args:
name_or_number: Value name or comm object number.

Returns:
``[(wire_value, label), ...]`` ordered by wire value.

Raises:
ComApProtocolError: If the value is not ``STRING_LIST`` type.

Examples:
>>> ctrl.value_options("Engine State")
[(0, 'Ready'), (1, 'Prestart'), (2, 'Cranking'), ...]
"""
return self._string_list_options(self.value_info(name_or_number))

def setpoint_options(self, name_or_number: str | int) -> list[tuple[int, str]]:
"""Return the available options for a ``STRING_LIST`` setpoint.

Expand All @@ -340,16 +385,7 @@ def setpoint_options(self, name_or_number: str | int) -> list[tuple[int, str]]:
>>> ctrl.setpoint_options("Summer Time Mode")
[(0, 'Disabled'), (1, 'Winter'), (2, 'Summer'), (3, 'Winter-S'), (4, 'Summer-S')]
"""
desc = self.setpoint_info(name_or_number)
if desc.data_type is not DataType.STRING_LIST:
raise ComApProtocolError(
f"setpoint {desc.name!r} is DataType.{desc.data_type.name}, not STRING_LIST"
)
return [
(wire_value, self._common_names[desc.low_limit + wire_value])
for wire_value in range(desc.high_limit - desc.low_limit + 1)
if desc.low_limit + wire_value < len(self._common_names)
]
return self._string_list_options(self.setpoint_info(name_or_number))

def value_label(self, name_or_number: str | int, wire_value: int) -> str:
"""Return the display label for a ``STRING_LIST`` value's wire integer.
Expand Down
Loading