-
Notifications
You must be signed in to change notification settings - Fork 1
feat: Enhance APIClient to support legacy and modern API detection #7
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
feat: Enhance APIClient to support legacy and modern API detection #7
Conversation
- 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.
|
waiting on #8 |
…s and allow None values
… for HTTP connections
…ard and simplify command usage
There was a problem hiding this 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
APIClientthat 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.
src/ducopy/rest/client.py
Outdated
| # 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 | ||
|
|
Copilot
AI
Jan 3, 2026
There was a problem hiding this comment.
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.
| # 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] |
| try: | ||
| from pydantic import VERSION | ||
| PYDANTIC_V2 = int(VERSION.split('.')[0]) >= 2 | ||
| except (ImportError, AttributeError): |
Copilot
AI
Jan 3, 2026
There was a problem hiding this comment.
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.
| except (ImportError, AttributeError): | |
| except (ImportError, AttributeError, ValueError, TypeError): |
| @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 |
Copilot
AI
Jan 3, 2026
There was a problem hiding this comment.
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.
| "Updating node configuration is not available on the Communication and Print Board. " | ||
| "This feature is only available on the Connectivity Board." |
Copilot
AI
Jan 3, 2026
There was a problem hiding this comment.
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.
| "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." |
| 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 | ||
| } |
Copilot
AI
Jan 3, 2026
There was a problem hiding this comment.
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.
| if "node" in values and "Node" not in values: | ||
| values["Node"] = values.pop("node") |
Copilot
AI
Jan 3, 2026
There was a problem hiding this comment.
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.
| 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) |
|
|
||
| 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: |
Copilot
AI
Jan 3, 2026
There was a problem hiding this comment.
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.
| logger.debug("Attempting to fetch /info endpoint...") | ||
| response = self.session.get("/info") | ||
| response.raise_for_status() | ||
| info_response = response.json() |
Copilot
AI
Jan 3, 2026
There was a problem hiding this comment.
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.
| info_response = response.json() | |
| response.json() |
| Name: ParameterConfig | None = None | ||
|
|
||
| @unified_validator() | ||
| def normalize_node_field(cls, values: dict[str, Any]) -> dict[str, Any]: |
Copilot
AI
Jan 3, 2026
There was a problem hiding this comment.
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.
| Name: ParameterConfig | None = None | ||
|
|
||
| @unified_validator() | ||
| def normalize_node_field(cls, values: dict[str, Any]) -> dict[str, Any]: |
Copilot
AI
Jan 3, 2026
There was a problem hiding this comment.
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.
…xible base URL handling in tests
… for Connectivity Board compatibility
…at for Communication and Print Board
…Connectivity board formats
…on and serial number extraction
…or Connectivity and Communication/Print Boards
…e for Communication/Print Boards


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