Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
f9b5714
feat: Enhance APIClient to support legacy and modern API detection
stuartp44 Jan 1, 2026
b731fef
feat: Update NodeGeneralInfo and NetworkDucoInfo to handle empty dict…
stuartp44 Jan 3, 2026
4e082ac
feat: Improve APIClient generation detection and add security warning…
stuartp44 Jan 3, 2026
8a625a6
feat: Suppress InsecureRequestWarning for SSL verification in DucoUrl…
stuartp44 Jan 3, 2026
7d24369
feat: Update README to clarify support for Communication and Print Bo…
stuartp44 Jan 3, 2026
4087e23
feat: Add support for Communication and Print Board in APIClient and …
stuartp44 Jan 3, 2026
0572d81
feat: Enhance APIClient to transform modern node info and support fle…
stuartp44 Jan 3, 2026
584e9bc
feat: Update APIClient to fetch /info and /api endpoints without requ…
stuartp44 Jan 3, 2026
781e927
feat: Update APIClient to wrap sensor values in {"Val": value} format…
stuartp44 Jan 3, 2026
2e1a533
feat: Implement transformation of legacy API responses to modern form…
stuartp44 Jan 3, 2026
a1fb052
feat: Add normalization method for node data structure in APIClient
stuartp44 Jan 4, 2026
865b44b
feat: Enhance error handling in APIClient for action responses and no…
stuartp44 Jan 4, 2026
0af9621
feat: Add normalization methods for handling Communication/Print and …
stuartp44 Jan 4, 2026
3171d3b
feat: Add device identification caching for Communication and Print B…
stuartp44 Jan 4, 2026
f427936
feat: Enhance APIClient and NodeGeneralInfo to support software versi…
stuartp44 Jan 4, 2026
e4e2bc3
feat: Add get_board_info method to retrieve board-level information f…
stuartp44 Jan 4, 2026
22bb650
feat: Update APIClient to use /board_info endpoint and add support fo…
stuartp44 Jan 4, 2026
87cbd3f
feat: Update APIClient to handle /board_info response and cache uptim…
stuartp44 Jan 4, 2026
d0cda31
feat: Normalize calibration data structure for consistent access acro…
stuartp44 Jan 4, 2026
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
20 changes: 10 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# DucoPy

**DucoPy** is a Python library and CLI tool that allows for full control of a **DucoBox** ventilation unit equipped with a **DucoBox Connectivity Board**. Using DucoPy, you can retrieve information, control settings, and monitor logs of your DucoBox system directly from your Python environment or command line.
**DucoPy** is a Python library and CLI tool that allows for full control of a **DucoBox** ventilation unit equipped with either a **DucoBox Connectivity Board** (modern API) or **Communication and Print Board** (legacy API). Using DucoPy, you can retrieve information, control settings, and monitor logs of your DucoBox system directly from your Python environment or command line.

## Features

Expand Down Expand Up @@ -67,7 +67,7 @@ Here is a list of the main methods available in the `DucoPy` facade:

- `get_api_info() -> dict`: Retrieve general API information.
- `get_info(module: str | None = None, submodule: str | None = None, parameter: str | None = None) -> dict`: Retrieve information about modules and parameters.
- `get_nodes() -> NodesResponse`: Retrieve a list of all nodes in the DucoBox system.
- `get_nodes() -> NodesInfoResponse`: Retrieve a list of all nodes in the DucoBox system.
- `get_node_info(node_id: int) -> NodeInfo`: Get details about a specific node by its ID.
- `get_config_node(node_id: int) -> ConfigNodeResponse`: Get configuration settings for a specific node.
- `get_action(action: str | None = None) -> dict`: Retrieve information about a specific action.
Expand Down Expand Up @@ -95,39 +95,39 @@ This will display a list of available commands.
1. **Retrieve API information**

```bash
ducopy get-api-info --base-url https://your-ducobox-ip
ducopy get-api-info https://your-ducobox-ip
```

2. **Get details about nodes**

```bash
ducopy get-nodes --base-url https://your-ducobox-ip
ducopy get-nodes https://your-ducobox-ip
```

3. **Get information for a specific node**

```bash
ducopy get-node-info --base-url https://your-ducobox-ip --node-id 1
ducopy get-node-info https://your-ducobox-ip --node-id 1
```

4. **Get actions available for a node**

```bash
ducopy get-actions-node --base-url https://your-ducobox-ip --node-id 1
ducopy get-actions-node https://your-ducobox-ip --node-id 1
```

5. **Retrieve system logs**

```bash
ducopy get-logs --base-url https://your-ducobox-ip
ducopy get-logs https://your-ducobox-ip
```

### Output Formatting

All commands support an optional `--output-format` argument to specify the output format (`pretty` or `json`):
All commands support an optional `--format` argument to specify the output format (`pretty` or `json`):

```bash
ducopy get-nodes --base-url https://your-ducobox-ip --output-format json
ducopy get-nodes https://your-ducobox-ip --format json
```

- `pretty` (default): Formats the output in a structured, readable style.
Expand All @@ -138,7 +138,7 @@ ducopy get-nodes --base-url https://your-ducobox-ip --output-format json
To set the logging level, use the `--logging-level` option, which accepts values like `DEBUG`, `INFO`, `WARNING`, `ERROR`, or `CRITICAL`.

```bash
ducopy --logging-level DEBUG get-nodes --base-url https://your-ducobox-ip
ducopy --logging-level DEBUG get-nodes https://your-ducobox-ip
```

## Contributing
Expand Down
3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,9 @@ ducopy = "ducopy.cli:entry_point"

[tool.pytest.ini_options]
testpaths = ["tests"]
filterwarnings = [
"ignore:Unverified HTTPS request:urllib3.exceptions.InsecureRequestWarning"
]

[tool.ruff]
line-length = 120
Expand Down
213 changes: 193 additions & 20 deletions src/ducopy/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,10 @@
import sys
from typing import Any, Annotated
from ducopy.ducopy import DucoPy
from rich import print as rich_print
from rich.console import Console
from rich.pretty import Pretty
from urllib.parse import urlparse
import pprint

from ducopy.rest.models import ConfigNodeRequest

Expand Down Expand Up @@ -72,13 +73,24 @@ def validate_url(url: str) -> str:

def print_output(data: Any, format: str) -> None: # noqa: ANN401
"""Print output in the specified format."""
if isinstance(data, BaseModel): # Check if data is a Pydantic model instance
data = data.dict() # Use `.dict()` for JSON serialization
# Recursively convert Pydantic models to dicts
def convert_to_dict(obj):
if isinstance(obj, BaseModel):
return obj.dict() if hasattr(obj, 'dict') else obj.model_dump()
elif isinstance(obj, dict):
return {k: convert_to_dict(v) for k, v in obj.items()}
elif isinstance(obj, (list, tuple)):
return [convert_to_dict(item) for item in obj]
return obj
Comment on lines +77 to +84
Copy link

Copilot AI Jan 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The function convert_to_dict uses both hasattr checks for 'dict' and 'model_dump', but could be simplified by checking the PYDANTIC_V2 flag that's already imported in the file. This would be more consistent with the rest of the codebase.

Copilot uses AI. Check for mistakes.

data = convert_to_dict(data)

if format == "json":
typer.echo(json.dumps(data, indent=4))
else:
rich_print(Pretty(data))
# Use Rich Console with wider width to keep NetworkDuco and similar dicts on one line
console = Console(width=200)
console.print(Pretty(data, expand_all=False))


@app.callback()
Expand Down Expand Up @@ -207,18 +219,21 @@ def raw_patch(
def change_action_node(
base_url: str,
node_id: int,
action: Annotated[str, typer.Option(help="The action key to include in the JSON body")],
value: Annotated[str, typer.Option(help="The value key to include in the JSON body")],
action: Annotated[str, typer.Option(help="The action key (Connectivity Board only, use any value for Communication and Print Board)")],
value: Annotated[str, typer.Option(help="The state/value to set (e.g., AUTO, MAN1, AUT1)")],
format: Annotated[str, typer.Option(help="Output format: pretty or json")] = "pretty",
) -> None:
"""
Perform a POST action by sending a JSON body to the endpoint.
Change the action/state for a specific node.

- Connectivity Board: Sends a POST request with JSON body to /action/nodes/{node_id}
- Communication and Print Board: Sends a GET request to /nodesetoperstate?node={node_id}&value={value}

Args:
base_url (str): The base URL of the API.
node_id (int): The ID of the node to perform the action on.
action (str): The action key to include in the JSON body.
value (str): The value key to include in the JSON body.
action (str): The action key (used only for Connectivity Board validation).
value (str): The state/value to set (e.g., AUTO, MAN1, AUT1, MAN2, AUT2, etc.).
format (str): Output format: pretty or json.
"""
base_url = validate_url(base_url)
Expand All @@ -227,8 +242,8 @@ def change_action_node(
response = facade.change_action_node(action=action, value=value, node_id=node_id)
print_output(response, format)
except Exception as e:
logger.error("Error performing POST action for node {}: {}", node_id, e)
typer.echo(f"Failed to perform POST action for node {node_id}: {e}")
logger.error("Error changing node action for node {}: {}", node_id, e)
typer.echo(f"Failed to change node action for node {node_id}: {e}")
raise typer.Exit(code=1)


Expand Down Expand Up @@ -280,8 +295,26 @@ def get_config_nodes(
base_url = validate_url(base_url)
facade = DucoPy(base_url)
try:
response = facade.get_config_nodes()
print_output(response, format)
# Get generation info
generation_info = {
"generation": facade.client.generation,
"board_type": facade.client.board_type,
"api_version": facade.client.api_version,
"public_api_version": facade.client.public_api_version,
"is_modern_api": facade.client.is_modern_api,
"is_legacy_api": facade.client.is_legacy_api,
}

# Get config data
config_data = facade.get_config_nodes()

# Combine both
output = {
"generation_info": generation_info,
"config_nodes": config_data,
}

print_output(output, format)
except Exception as e:
logger.error("Error fetching configuration for all nodes: {}", str(e))
typer.echo(f"Failed to fetch configuration for all nodes: {e}")
Expand All @@ -306,10 +339,30 @@ def get_info(
parameter: str = None,
format: Annotated[str, typer.Option(help="Output format: pretty or json")] = "pretty",
) -> None:
"""Retrieve general API information with optional filters."""
"""Retrieve general API information with optional filters. Also displays API generation info."""
base_url = validate_url(base_url)
facade = DucoPy(base_url)
print_output(facade.get_info(module=module, submodule=submodule, parameter=parameter), format)

# Get the generation info
generation_info = {
"generation": facade.client.generation,
"board_type": facade.client.board_type,
"api_version": facade.client.api_version,
"public_api_version": facade.client.public_api_version,
"is_modern_api": facade.client.is_modern_api,
"is_legacy_api": facade.client.is_legacy_api,
}

# Get the regular info
info_data = facade.get_info(module=module, submodule=submodule, parameter=parameter)

# Combine both
output = {
"generation_info": generation_info,
"api_info": info_data,
}

print_output(output, format)


@app.command()
Expand All @@ -319,7 +372,27 @@ def get_nodes(
"""Retrieve list of all nodes."""
base_url = validate_url(base_url)
facade = DucoPy(base_url)
print_output(facade.get_nodes(), format)

# Get generation info
generation_info = {
"generation": facade.client.generation,
"board_type": facade.client.board_type,
"api_version": facade.client.api_version,
"public_api_version": facade.client.public_api_version,
"is_modern_api": facade.client.is_modern_api,
"is_legacy_api": facade.client.is_legacy_api,
}

# Get nodes data
nodes_data = facade.get_nodes()

# Combine both
output = {
"generation_info": generation_info,
"nodes": nodes_data,
}

print_output(output, format)


@app.command()
Expand All @@ -329,7 +402,27 @@ def get_node_info(
"""Retrieve information for a specific node by ID."""
base_url = validate_url(base_url)
facade = DucoPy(base_url)
print_output(facade.get_node_info(node_id=node_id), format)

# Get generation info
generation_info = {
"generation": facade.client.generation,
"board_type": facade.client.board_type,
"api_version": facade.client.api_version,
"public_api_version": facade.client.public_api_version,
"is_modern_api": facade.client.is_modern_api,
"is_legacy_api": facade.client.is_legacy_api,
}

# Get node info
node_data = facade.get_node_info(node_id=node_id)

# Combine both
output = {
"generation_info": generation_info,
"node_info": node_data,
}

print_output(output, format)


@app.command()
Expand All @@ -339,7 +432,27 @@ def get_config_node(
"""Retrieve configuration settings for a specific node."""
base_url = validate_url(base_url)
facade = DucoPy(base_url)
print_output(facade.get_config_node(node_id=node_id), format)

# Get generation info
generation_info = {
"generation": facade.client.generation,
"board_type": facade.client.board_type,
"api_version": facade.client.api_version,
"public_api_version": facade.client.public_api_version,
"is_modern_api": facade.client.is_modern_api,
"is_legacy_api": facade.client.is_legacy_api,
}

# Get config data
config_data = facade.get_config_node(node_id=node_id)

# Combine both
output = {
"generation_info": generation_info,
"config": config_data,
}

print_output(output, format)


@app.command()
Expand All @@ -351,7 +464,27 @@ def get_action(
"""Retrieve action data with an optional filter."""
base_url = validate_url(base_url)
facade = DucoPy(base_url)
print_output(facade.get_action(action=action), format)

# Get generation info
generation_info = {
"generation": facade.client.generation,
"board_type": facade.client.board_type,
"api_version": facade.client.api_version,
"public_api_version": facade.client.public_api_version,
"is_modern_api": facade.client.is_modern_api,
"is_legacy_api": facade.client.is_legacy_api,
}

# Get action data
action_data = facade.get_action(action=action)

# Combine both
output = {
"generation_info": generation_info,
"action": action_data,
}

print_output(output, format)


@app.command()
Expand All @@ -364,7 +497,27 @@ def get_actions_node(
"""Retrieve actions available for a specific node."""
base_url = validate_url(base_url)
facade = DucoPy(base_url)
print_output(facade.get_actions_node(node_id=node_id, action=action), format)

# Get generation info
generation_info = {
"generation": facade.client.generation,
"board_type": facade.client.board_type,
"api_version": facade.client.api_version,
"public_api_version": facade.client.public_api_version,
"is_modern_api": facade.client.is_modern_api,
"is_legacy_api": facade.client.is_legacy_api,
}

# Get actions data
actions_data = facade.get_actions_node(node_id=node_id, action=action)

# Combine both
output = {
"generation_info": generation_info,
"actions": actions_data,
}

print_output(output, format)


@app.command()
Expand All @@ -377,6 +530,26 @@ def get_logs(
print_output(facade.get_logs(), format)


@app.command()
def check_generation(
base_url: str, format: Annotated[str, typer.Option(help="Output format: pretty or json")] = "pretty"
) -> None:
"""Check the board type (Connectivity Board or Communication and Print Board)."""
base_url = validate_url(base_url)
facade = DucoPy(base_url)

generation_info = {
"generation": facade.client.generation,
"board_type": facade.client.board_type,
"api_version": facade.client.api_version,
"public_api_version": facade.client.public_api_version,
"is_modern_api": facade.client.is_modern_api,
"is_legacy_api": facade.client.is_legacy_api,
}

print_output(generation_info, format)


def entry_point() -> None:
"""Entry point for the CLI."""
app() # Run the Typer app
Expand Down
Loading