Skip to content

Conversation

@stuartp44
Copy link
Collaborator

@stuartp44 stuartp44 commented Jan 1, 2026

This PR allows the inclusion of older "Communication and Print" systems within the library. Due to the nature of difference between "newer" generation Communication boards and the older communication and print, we have used the library to abstract some of the commands away so systems utilizing the library dont need to be overly complex to deal with different type of connectivity systems. This means the library will handle all of the nuances between generational communication boards (At the moment this is Communication and print and connectivity board V1). It does not, however, do everything. For example if you request https on a older board, this will not work.

Due to the fact DUCO communication and print never exposes the API version on any of the API endpoints, the only "way" you can actually programmatically see this would be the zeroconf broadcast where it actually mentions the API version. Being that this library never has done auto discovery, I think it would be pointless to add it for one type of connectivity board. Annoyingly, on the more modern versions, you can see this within the REST endpoints. With this in mind, we have used certain endpoints to actually see if we are talking to a communication and print board or a connectivity board and save that for further use.

Noteworthy changes

  • Added automatic detection of API generation (modern vs legacy) in APIClient.
  • Implemented endpoint mapping for legacy Communication and Print Board.
  • Updated raw GET and POST methods to handle different API structures.
  • Introduced properties for API version and board type.
  • Enhanced error handling for connection issues and 404 responses.
  • Updated models to normalize node fields between API versions.
  • Added tests for both modern and legacy API interactions, including node actions and configurations.

- Added automatic detection of API generation (modern vs legacy) in APIClient.
- Implemented endpoint mapping for legacy Communication and Print Board.
- Updated raw GET and POST methods to handle different API structures.
- Introduced properties for API version and board type.
- Enhanced error handling for connection issues and 404 responses.
- Updated models to normalize node fields between API versions.
- Added tests for both modern and legacy API interactions, including node actions and configurations.
@stuartp44 stuartp44 marked this pull request as draft January 1, 2026 22:07
@stuartp44
Copy link
Collaborator Author

waiting on #8

@stuartp44
Copy link
Collaborator Author

Tested locally

Setting operational state or "action" on newer gens
image

Getting nodes
image

Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR enhances the DucoPy library to support both modern Connectivity Board and legacy Communication and Print Board APIs. The changes enable automatic detection of board types and handle API differences transparently through endpoint mapping and response transformation.

Key changes:

  • Added automatic API generation detection in APIClient that distinguishes between modern and legacy boards
  • Implemented endpoint mapping to translate Connectivity Board endpoints to Communication and Print Board equivalents
  • Enhanced models with field normalization and optional fields to support both API versions
  • Updated tests to cover both modern and legacy API interactions

Reviewed changes

Copilot reviewed 8 out of 8 changed files in this pull request and generated 18 comments.

Show a summary per file
File Description
src/ducopy/rest/client.py Added detect_generation() method, endpoint mapping, legacy API transformations, and properties for board type/version detection
src/ducopy/rest/utils.py Updated DucoUrlSession to accept endpoint_mapper callback, added timeout parameter, and enhanced API key generation to handle both board types
src/ducopy/rest/models.py Improved Pydantic version detection, added field normalization validators, and made fields optional for legacy compatibility
tests/test_client.py Added test fixtures and test cases for both modern and legacy API interactions, including detection mocking
tests/test_restapi.py Updated assertions to handle optional Code field and multiple success result formats
src/ducopy/cli.py Enhanced commands to include generation info in outputs and updated help text for multi-board support
README.md Updated documentation to reflect support for both board types and corrected CLI command examples
pyproject.toml Added pytest filter to suppress InsecureRequestWarning during tests

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 318 to 331
# Extract all sensor fields (snsr, co2, temp, rh, etc.)
sensor_fields = {}
known_sensor_keys = ["snsr", "co2", "temp", "rh", "CO2", "Temp", "RH", "Snsr"]
for key in known_sensor_keys:
if key in gen1_data:
sensor_fields[key.lower()] = gen1_data[key]

# Also check for any other potential sensor fields by looking for numeric values
# that aren't already captured in other sections
known_non_sensor_keys = {"node", "devtype", "addr", "state", "ovrl", "cerr", "cntdwn", "endtime", "mode"}
for key, value in gen1_data.items():
if key not in known_non_sensor_keys and isinstance(value, (int, float)):
sensor_fields[key.lower()] = value

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 transformation logic collects sensor fields but may incorrectly include non-sensor numeric fields. The heuristic "any numeric value not in known_non_sensor_keys" is fragile and could capture unrelated fields. Consider maintaining an explicit allowlist of sensor field names or using a more structured approach.

Suggested change
# Extract all sensor fields (snsr, co2, temp, rh, etc.)
sensor_fields = {}
known_sensor_keys = ["snsr", "co2", "temp", "rh", "CO2", "Temp", "RH", "Snsr"]
for key in known_sensor_keys:
if key in gen1_data:
sensor_fields[key.lower()] = gen1_data[key]
# Also check for any other potential sensor fields by looking for numeric values
# that aren't already captured in other sections
known_non_sensor_keys = {"node", "devtype", "addr", "state", "ovrl", "cerr", "cntdwn", "endtime", "mode"}
for key, value in gen1_data.items():
if key not in known_non_sensor_keys and isinstance(value, (int, float)):
sensor_fields[key.lower()] = value
# Extract all sensor fields (snsr, co2, temp, rh, etc.) using an explicit allowlist
sensor_fields = {}
known_sensor_keys = ["snsr", "co2", "temp", "rh", "CO2", "Temp", "RH", "Snsr"]
for key in known_sensor_keys:
if key in gen1_data:
sensor_fields[key.lower()] = gen1_data[key]

Copilot uses AI. Check for mistakes.
try:
from pydantic import VERSION
PYDANTIC_V2 = int(VERSION.split('.')[0]) >= 2
except (ImportError, AttributeError):
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 Pydantic version detection logic could fail if VERSION attribute doesn't exist or has an unexpected format. Consider wrapping the version parsing in a try-except block to handle edge cases gracefully.

Suggested change
except (ImportError, AttributeError):
except (ImportError, AttributeError, ValueError, TypeError):

Copilot uses AI. Check for mistakes.
Comment on lines +250 to +255
@unified_validator()
def normalize_node_field(cls, values: dict[str, Any]) -> dict[str, Any]:
"""Normalize 'node' (Communication and Print Board) to 'Node' (Connectivity Board)"""
if "node" in values and "Node" not in values:
values["Node"] = values.pop("node")
return values
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.

This is a duplicate of the normalize_node_field validator in NodeConfig. Consider extracting this into a shared validator function to avoid code duplication and ensure consistent behavior.

Copilot uses AI. Check for mistakes.
Comment on lines +501 to +502
"Updating node configuration is not available on the Communication and Print Board. "
"This feature is only available on the Connectivity Board."
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 error messages mention "Communication and Print Board" but the actual check uses the more general _generation == "legacy". If the generation is "unknown" or None, this could lead to incorrect error messages. Consider checking self._board_type explicitly or handling the unknown/None case.

Suggested change
"Updating node configuration is not available on the Communication and Print Board. "
"This feature is only available on the Connectivity Board."
"Updating node configuration is not available when using the legacy API."

Copilot uses AI. Check for mistakes.
Comment on lines 188 to 200
if "404" in error_message:
# /info returns 404 = Communication and Print Board (legacy API)
self._generation = "legacy"
self._board_type = "Communication and Print Board"
logger.info("Detected Communication and Print Board (legacy API) - /info endpoint not found (404)")

return {
"generation": self._generation,
"api_version": None,
"public_api_version": None,
"protocol": "HTTPS" if is_https else "HTTP",
"board_type": self._board_type
}
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 detection logic returns early with a 404 status, but doesn't validate that this is truly a legacy board. It's possible that a 404 could be returned for other reasons (network issues, incorrect URL, etc.). Consider adding additional validation or attempting to fetch a known legacy endpoint like /boxinfoget to confirm this is actually a Communication and Print Board.

Copilot uses AI. Check for mistakes.
Comment on lines +131 to +132
if "node" in values and "Node" not in values:
values["Node"] = values.pop("node")
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 validation logic has a potential issue: if values is an empty dict or doesn't contain the "node" key initially, this won't handle the "Node" key if it's being set through validation. The validator should check both "node" and "Node" to ensure proper normalization regardless of which is present.

Suggested change
if "node" in values and "Node" not in values:
values["Node"] = values.pop("node")
node_value = values.get("Node", values.get("node"))
if node_value is not None:
values["Node"] = node_value
# Remove any lowercase 'node' key to avoid duplicates and ensure normalization
values.pop("node", None)

Copilot uses AI. Check for mistakes.

class DucoUrlSession(requests.Session):
def __init__(self, base_url: str, verify: bool | str = True) -> None:
def __init__(self, base_url: str, verify: bool | str = True, endpoint_mapper: callable = None, timeout: int = 10) -> None:
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 type hint for endpoint_mapper should be Callable (with capital C) from the typing module, or use collections.abc.Callable. Using lowercase callable as a type hint is not valid in Python type annotations.

Copilot uses AI. Check for mistakes.
logger.debug("Attempting to fetch /info endpoint...")
response = self.session.get("/info")
response.raise_for_status()
info_response = response.json()
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.

Variable info_response is not used.

Suggested change
info_response = response.json()
response.json()

Copilot uses AI. Check for mistakes.
Name: ParameterConfig | None = None

@unified_validator()
def normalize_node_field(cls, values: dict[str, Any]) -> dict[str, Any]:
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.

Normal methods should have 'self', rather than 'cls', as their first parameter.

Copilot uses AI. Check for mistakes.
Name: ParameterConfig | None = None

@unified_validator()
def normalize_node_field(cls, values: dict[str, Any]) -> dict[str, Any]:
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.

Normal methods should have 'self', rather than 'cls', as their first parameter.

Copilot uses AI. Check for mistakes.
…or Connectivity and Communication/Print Boards
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants