diff --git a/.github/workflows/mypy.yml b/.github/workflows/mypy.yml new file mode 100644 index 0000000..1c6087d --- /dev/null +++ b/.github/workflows/mypy.yml @@ -0,0 +1,29 @@ +name: Mypy Type Check + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + +jobs: + mypy: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.11", "3.12", "3.13"] + + steps: + - uses: actions/checkout@v4 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v3 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install mypy + - name: Run mypy + run: | + mypy src diff --git a/.gitignore b/.gitignore index 8d1bf90..670d9f6 100644 --- a/.gitignore +++ b/.gitignore @@ -73,4 +73,5 @@ ehthumbs.db Thumbs.db # Project specific -references/* \ No newline at end of file +references/* +examples/demo_config.py diff --git a/CHANGELOG.md b/CHANGELOG.md index bfed74c..a27ca98 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,40 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.7.6] - 2025-10-23 + +### Added + +- **CLI Debug Flag**: New `--print-payload` option for debugging HTTP payloads + - Usage: `pooldose --host 192.168.1.100 --print-payload` +- **Payload Debugging**: Added `debug_payload` parameter and `get_last_payload()` method +- **Enhanced Demo**: Updated `examples/demo.py` with payload inspection (only shows payloads when operations succeed) + +## [7.5.0] - 2025-10-20 + +### Added + +- Convenience setters on the client: `set_switch`, `set_number`, `set_select` to avoid fetching InstantValues manually. + - Mock client: now returns and stores the concrete POST payload for inspection (via `inspect_payload` / `get_last_payload`) and provides the same convenience setters as the real client. + - Updated `examples/demo.py` to demonstrate usage for both real and mock clients (mock prints payloads when `inspect_payload` is enabled). + - **New Device**: Added support for VA DOS EXACT (Model PDPR1H1HAR1V1, FW FW539224) + +### Changed + +- `InstantValues.set_number` now pairs `minT`/`maxT` fields and sends them as a single `[min,max]` payload when applicable. +- `RequestHandler.set_value` now always uses arrays for all value types, even for single values (e.g., `[{"value": 7, "type": "NUMBER"}]`, `[{"value": "O", "type": "STRING"}]`). + - Mock client behavior adjusted: tests/demos can opt-in to inspect payloads; tests were updated to expect payload inspection by default. + +### Enhanced + +- **Fix**: Removed Chlorine Sensor from PDPR1H1HAR1V0, as this is not supported. + +### Fixed + +- Tests updated to expect inspectable mock payloads by default; test suite now passes. + - Updated demo and tests; full test suite currently passes (119 tests). + +--- ## [0.7.0] - 2025-09-29 ### Enhanced @@ -264,4 +298,4 @@ numbers = structured_data.get("number", {}) ### Added - First working prototype for PoolDose Double/Dual WiFi supported -- All sensors and actuators for PoolDose Double/Dual WiFi supported \ No newline at end of file +- All sensors and actuators for PoolDose Double/Dual WiFi supported diff --git a/README.md b/README.md index 535b15d..464aefe 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,11 @@ # python-pooldose -Unofficial async Python client for [SEKO](https://www.seko.com/) Pooldosing systems. SEKO is a manufacturer of various monitoring and control devices for pools and spas. Some devices from VÁGNER POOL are supported as well. +Unofficial async Python client for [SEKO](https://www.seko.com/) Pooldosing systems. SEKO is a manufacturer of various monitoring and control devices for pools and spas. Some devices from [VÁGNER POOL](https://www.vagnerpool.com/web/en/) are compatible as well. This client uses an undocumented local HTTP API. It provides live readings for pool sensors such as temperature, pH, ORP/Redox, as well as status information and control over the dosing logic. +> **Disclaimer:** Use at your own risk. No liability for damages or malfunctions. + ## Features - **Async/await support** for non-blocking operations @@ -28,7 +30,7 @@ This client uses an undocumented local HTTP API. It provides live readings for p │ ├── WiFi Station Info (optional) │ ├── Access Point Info (optional) │ └── Network Info - └── Load Mapping JSON (based on MODEL_ID + FW_CODE) + └── Load mapping JSON (based on model_id + fw_code) 2. Get Static Values └── Device information and configuration @@ -232,8 +234,8 @@ The analyzer provides comprehensive information about your device: ``` === DEVICE ANALYSIS === Device: 01234567890A_DEVICE -Model: PDPR1H1HAW*** -Firmware: FW53**** +Model ID: PDZZ1H1HATEST1V1 +Firmware Code: 654321 === WIDGETS (Visible UI Elements) === @@ -277,8 +279,8 @@ pooldose --host 192.168.1.100 --analyze # Output shows: # Device: 01987654321B_DEVICE -# Model: PDPR2H2XYZ*** ← New model not yet supported -# Firmware: FW54**** ← New firmware version +# Model ID: PDZZ1H1HATEST1V1 ← New model not yet supported +# Firmware Code: 654321 ← New firmware version # # Widgets discovered: 15 sensors, 8 controls, 12 settings ``` @@ -288,7 +290,40 @@ With this information, you can: - Share the widget structure for mapping development - Help expand device support for the community -The device analyzer makes python-pooldose extensible and helps build support for the growing ecosystem of SEKO PoolDose devices. +The device analyzer makes python-pooldose extensible and helps build support for the growing ecosystem of SEKO PoolDose or VÁGNER POOL devices. + +### How to request support for a new device + +If your device is not yet supported, please help us by creating a GitHub issue and providing the following information: + +1. **Run low-level analysis and share the output files:** + - Use the following curl commands. + - Replace the IP address and DeviceId (get the id from the header of the instantvalues.json file, e.g., '012345679_DEVICE') as needed: + + - Download debug config info: + ```bash + curl http:///api/v1/debug/config/info -o debuginfo.json + ``` + **Important:** Before uploading, open `debuginfo.json` and remove any WiFi credentials. + - Download instant values + ```bash + curl --location --request POST http:///api/v1/DWI/getInstantValues -o instantvalues.json + ``` + - Download device language strings + ```bash + curl --location http:///api/v1/DWI/getDeviceLanguage --data-raw '{"DeviceId":"YOUR_DEVICE_ID","LANG":"en"}' -o strings.json + ``` +2. **Optional: Run the analyzer and share the output:** + - Run this command if you set up python-pooldose already: + ```bash + pooldose --host --analyze + ``` + - Copy and paste the full output into your issue (remove any sensitive data). + +3. **Create a GitHub issue:** + - Attach the the 3 JSON files from above. + - Optionally attach the analyzer output if available. + - This will help us add support for your device faster! ## Examples @@ -374,8 +409,13 @@ You can use the mock client with custom JSON files via the command line: # Use mock client with JSON file pooldose --mock path/to/your/data.json + +# Use mock client with model and firmware code (Beispiel mit Fantasiewerten) +pooldose --mock path/to/your/data.json --model-id PDZZ1H1HATEST1V1 --fw-code 654321 + # Or as Python module python -m pooldose --mock path/to/your/data.json +python -m pooldose --mock path/to/your/data.json --model-id PDZZ1H1HATEST1V1 --fw-code 654321 ``` ### JSON Data Format @@ -718,13 +758,13 @@ Mapping Discovery Process: │ ▼ ┌─────────────────┐ -│ Get MODEL_ID │ ──────► PDPR1H1HAW*** -│ Get FW_CODE │ ──────► 53**** +│ Get Model ID │ ──────► PDZZ1H1HATEST1V1 +│ Get Firmware Code │ ──────► 654321 └─────────────────┘ │ ▼ ┌─────────────────┐ -│ Load JSON File │ ──────► model_PDPR1H1HAW***_FW53****.json +│ Load JSON file │ ──────► model_PDZZ1H1HATEST1V1_FW654321.json └─────────────────┘ │ ▼ @@ -843,10 +883,11 @@ The `instant_values_structured()` method returns data organized by type: This client has been tested with: -- **SEKO PoolDose Double/Dual WiFi** (Model: PDPR1H1HAW***, FW: 53****) -- **VÁGNER POOL VA DOS BASIC** (Model: PDPR1H1HAR***, FW: 53****) +- **SEKO PoolDose Double** (Model: PDPR1H1HAW100, FW: 539187) +- **VÁGNER POOL VA DOS BASIC** (Model: PDHC1H1HAR1V0, FW: 539224) +- **VÁGNER POOL VA DOS EXACT** (Model: PDHC1H1HAR1V1, FW: 539224) -Other SEKO PoolDose models may work but are untested. The client uses JSON mapping files to adapt to different device models and firmware versions (see e.g. `src/pooldose/mappings/model_PDPR1H1HAW***_FW53****.json`). +Other SEKO or VÁGNER POOL models may work but are untested. The client uses JSON mapping files to adapt to different device models and firmware versions (see e.g. `src/pooldose/mappings/model_PDPR1H1HAW100_FW539187.json`). > **Note:** The JSON files in the mappings directory define the device-specific data keys and their human-readable names for different PoolDose models and firmware versions. @@ -925,9 +966,6 @@ Data Classification: For detailed release notes and version history, please see [CHANGELOG.md](CHANGELOG.md). -### Latest Release (0.7.0) +### Latest Release (0.7.6) -- **Connection Handling**: Improved session management for more reliable connections -- **RequestHandler**: Centralized session management with internal _get_session method -- **Performance**: Reduced connection overhead for multiple consecutive API calls -- **Error Handling**: Better cleanup of HTTP sessions in error cases +- Added `--print-payload` option for debugging HTTP payloads diff --git a/examples/demo.py b/examples/demo.py index b43a8a4..4201956 100644 --- a/examples/demo.py +++ b/examples/demo.py @@ -1,32 +1,56 @@ -"""Demonstration for using the PooldoseClient.""" +"""Demo showing common usage of PooldoseClient and the mock client. + +This script fetches static and instant values and demonstrates how to use +the typed setters on `InstantValues` (switch, number, select). When the +mock client is used, setter calls print the concrete POST payload that +would be sent to the device. +""" import asyncio import sys + from demo_utils import display_structured_data, display_static_values from pooldose.client import PooldoseClient, RequestStatus +from pooldose.mock_client import MockPooldoseClient -# Set UTF-8 encoding for output +# Ensure stdout is using UTF-8 encoding for consistent output if sys.stdout.encoding != 'utf-8': sys.stdout.reconfigure(encoding='utf-8') # pylint: disable=line-too-long,too-many-branches,too-many-statements -HOST = "192.168.178.137" # Replace with your device's IP address +# Load optional demo configuration from demo_config.py (not checked in) +try: + from demo_config import HOST, USE_MOCK_CLIENT, FILE, MODEL_ID, FW_CODE, DEBUG_PAYLOAD +except ImportError: + # Fallback defaults when no config file is present + USE_MOCK_CLIENT = False + HOST = "kommspot" + FILE = None + MODEL_ID = None + FW_CODE = None + DEBUG_PAYLOAD = True + async def main() -> None: """Demonstrate all PooldoseClient calls.""" - client = PooldoseClient(host=HOST, include_mac_lookup=True) - - # Connect + # Choose real or mock client based on configuration + if USE_MOCK_CLIENT: + print("Using MockPooldoseClient with JSON file", FILE) + # Enable payload inspection so the demo can print the mock POST body + client = MockPooldoseClient(json_file_path=FILE, model_id=MODEL_ID, fw_code=FW_CODE, include_sensitive_data=True, inspect_payload=DEBUG_PAYLOAD) + else: + print("Using real PooldoseClient with network connection. Host:", HOST) + client = PooldoseClient(host=HOST, include_mac_lookup=True, debug_payload=DEBUG_PAYLOAD) # pylint: disable=no-value-for-parameter + # Connect to the device (real or mock) client_status = await client.connect() if client_status != RequestStatus.SUCCESS: print(f"Error connecting to PooldoseClient: {client_status}") return + print("Connected to Pooldose device.") - print(f"Connected to Pooldose device at {HOST}") - - # Static values + # Fetch and display static values print("\nFetching static values...") static_values_status, static_values = client.static_values() if static_values_status != RequestStatus.SUCCESS: @@ -37,7 +61,7 @@ async def main() -> None: print(f" IP: {static_values.sensor_ip}") print(f" MAC: {static_values.sensor_mac}") - # Structured instant values + # Fetch and display structured instant values print("\nFetching instant values...") structured_status, structured_data = await client.instant_values_structured() if structured_status != RequestStatus.SUCCESS: @@ -46,6 +70,50 @@ async def main() -> None: display_structured_data(structured_data) + # Demonstrate setting values using the client's setters. + print("\n" + "="*50) + print("DEMONSTRATING VALUE SETTERS") + print("="*50) + + print("\nSetting switch 'pause_dosing' -> True") + ok = await client.set_switch('pause_dosing', True) + print("Result:", ok) + if ok and DEBUG_PAYLOAD and hasattr(client, 'get_last_payload'): + last_payload = client.get_last_payload() + if last_payload: + print("Payload sent:", last_payload) + + print("\nSetting number 'ph_target' -> 7.2") + ok = await client.set_number('ph_target', 7.2) + print("Result:", ok) + if ok and DEBUG_PAYLOAD and hasattr(client, 'get_last_payload'): + last_payload = client.get_last_payload() + if last_payload: + print("Payload sent:", last_payload) + + print("\nSetting select 'water_meter_unit' -> 'L'") + ok = await client.set_select('water_meter_unit', 'L') + print("Result:", ok) + if ok and DEBUG_PAYLOAD and hasattr(client, 'get_last_payload'): + last_payload = client.get_last_payload() + if last_payload: + print("Payload sent:", last_payload) + + print("\nSetting lower/upper limits of 'ofa_ph' (pairing handled internally)") + ok = await client.set_number('ofa_ph_lower', 6.2) + print("ofa_ph_lower set result:", ok) + if ok and DEBUG_PAYLOAD and hasattr(client, 'get_last_payload'): + last_payload = client.get_last_payload() + if last_payload: + print("Payload sent:", last_payload) + + ok = await client.set_number('ofa_ph_upper', 8.1) + print("ofa_ph_upper set result:", ok) + if ok and DEBUG_PAYLOAD and hasattr(client, 'get_last_payload'): + last_payload = client.get_last_payload() + if last_payload: + print("Payload sent:", last_payload) + print("\nDemo completed successfully!") if __name__ == "__main__": diff --git a/examples/demo_utils.py b/examples/demo_utils.py index bd4a546..60ae792 100644 --- a/examples/demo_utils.py +++ b/examples/demo_utils.py @@ -29,7 +29,7 @@ def _display_data_type(data_dict, type_name, title): elif type_name in ["switch", "binary_sensor"]: value = data.get("value") if type_name == "binary_sensor": - status = "ACTIVE" if value else "OK" + status = "ON" if value else "OFF" else: status = "ON" if value else "OFF" print(f" {formatted_key}: {status}") diff --git a/requirements.txt b/requirements.txt index 1dfb95e..633be76 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,6 @@ aiohttp aiofiles +types-aiofiles getmac pytest pytest-asyncio \ No newline at end of file diff --git a/src/pooldose/__init__.py b/src/pooldose/__init__.py index 119dee3..098542c 100644 --- a/src/pooldose/__init__.py +++ b/src/pooldose/__init__.py @@ -1,5 +1,5 @@ """Async API client for SEKO Pooldose.""" from .client import PooldoseClient -__version__ = "0.7.0" +__version__ = "0.7.6" __all__ = ["PooldoseClient"] diff --git a/src/pooldose/__main__.py b/src/pooldose/__main__.py index db60f4b..8b91a4a 100644 --- a/src/pooldose/__main__.py +++ b/src/pooldose/__main__.py @@ -5,6 +5,7 @@ import asyncio import sys from pathlib import Path +from typing import Optional from pooldose import __version__ from pooldose.client import PooldoseClient, RequestStatus @@ -12,7 +13,7 @@ from pooldose.mock_client import MockPooldoseClient from pooldose.request_handler import RequestHandler -# pylint: disable=line-too-long +# pylint: disable=line-too-long, too-many-locals) # Import demo utilities if available try: @@ -109,7 +110,7 @@ async def run_device_analyzer(host: str, use_ssl: bool, port: int, show_all: boo print(f"Error during analysis: {e}") -async def run_real_client(host: str, use_ssl: bool, port: int) -> None: +async def run_real_client(host: str, use_ssl: bool, port: int, print_payload: bool = False) -> None: """Run the real PooldoseClient.""" print(f"Connecting to PoolDose device at {host}") if use_ssl: @@ -117,12 +118,14 @@ async def run_real_client(host: str, use_ssl: bool, port: int) -> None: else: print(f"Using HTTP on port {port}") + # pylint: disable=no-value-for-parameter client = PooldoseClient( host=host, include_mac_lookup=True, use_ssl=use_ssl, port=port if port != 0 else None, - timeout=30 + timeout=30, + debug_payload=print_payload ) try: @@ -152,7 +155,7 @@ async def run_real_client(host: str, use_ssl: bool, port: int) -> None: print(f"Error during connection: {e}") -async def run_mock_client(json_file: str) -> None: +async def run_mock_client(json_file: str, model_id: Optional[str] = None, fw_code: Optional[str] = None, print_payload: bool = False) -> None: """Run the MockPooldoseClient.""" json_path = Path(json_file) if not json_path.exists(): @@ -161,9 +164,18 @@ async def run_mock_client(json_file: str) -> None: print(f"Loading mock data from: {json_file}") + # Create explicit arguments for MockPooldoseClient + json_file_path = json_path + include_sensitive_data = True + model_id_val = model_id if model_id is not None else "MOCK_MODEL" + fw_code_val = fw_code if fw_code is not None else "MOCK_FW" + client = MockPooldoseClient( - json_file_path=json_path, - include_sensitive_data=True + json_file_path=json_file_path, + model_id=model_id_val, + fw_code=fw_code_val, + include_sensitive_data=include_sensitive_data, + inspect_payload=print_payload, ) try: @@ -193,8 +205,8 @@ async def run_mock_client(json_file: str) -> None: print(f"Error during mock demo: {e}") -def main() -> None: - """Main entry point for command-line interface.""" +def main(): # pylint: disable=too-many-locals + """Main CLI function.""" parser = argparse.ArgumentParser( description="Python PoolDose Client - Connect to SEKO PoolDose devices", epilog=""" @@ -213,6 +225,12 @@ def main() -> None: # Use mock client with JSON file python -m pooldose --mock path/to/your/data.json + + # Use mock client with payload inspection + python -m pooldose --mock path/to/your/data.json --print-payload + + # Connect to real device with payload inspection + python -m pooldose --host 192.168.1.100 --print-payload """, formatter_class=argparse.RawDescriptionHelpFormatter ) @@ -231,6 +249,20 @@ def main() -> None: help="Path to JSON file for mock mode" ) + # Additional mock parameters + parser.add_argument( + "--model-id", + type=str, + default=None, + help="Optional: Model ID for mock client (overrides JSON)" + ) + parser.add_argument( + "--fw-code", + type=str, + default=None, + help="Optional: Firmware code for mock client (overrides JSON)" + ) + # Connection options parser.add_argument( "--ssl", @@ -253,6 +285,11 @@ def main() -> None: action="store_true", help="Analyze unknown device including hidden widgets (implies --analyze)" ) + parser.add_argument( + "--print-payload", + action="store_true", + help="Enable payload debugging logging (for development/debugging only)" + ) parser.add_argument( "--version", action="version", @@ -286,10 +323,15 @@ def main() -> None: args.host, args.ssl, port, show_all=args.analyze_all)) else: # Normal client mode - asyncio.run(run_real_client(args.host, args.ssl, port)) + asyncio.run(run_real_client(args.host, args.ssl, port, args.print_payload)) elif args.mock: # Mock mode - asyncio.run(run_mock_client(args.mock)) + asyncio.run(run_mock_client( + args.mock, + model_id=args.model_id, + fw_code=args.fw_code, + print_payload=args.print_payload + )) except KeyboardInterrupt: print("\nOperation cancelled by user.") diff --git a/src/pooldose/client.py b/src/pooldose/client.py index ceb4ea7..7120510 100644 --- a/src/pooldose/client.py +++ b/src/pooldose/client.py @@ -4,16 +4,11 @@ import asyncio import logging -from typing import Optional, Tuple +from typing import Optional, Tuple, Any, Dict import aiohttp from getmac import get_mac_address -from pooldose.type_definitions import ( - APIVersionResponse, - DeviceInfoDict, - StructuredValuesDict, -) from pooldose.constants import get_default_device_info from pooldose.mappings.mapping_info import MappingInfo @@ -34,7 +29,7 @@ class PooldoseClient: All getter methods return (status, data) and log errors. """ - def __init__(self, host: str, timeout: int = 30, *, websession: Optional[aiohttp.ClientSession] = None, include_sensitive_data: bool = False, include_mac_lookup: bool = False, use_ssl: bool = False, port: Optional[int] = None, ssl_verify: bool = True) -> None: # pylint: disable=too-many-arguments + def __init__(self, host: str, timeout: int = 30, *, websession: Optional[aiohttp.ClientSession] = None, include_sensitive_data: bool = False, include_mac_lookup: bool = False, use_ssl: bool = False, port: Optional[int] = None, ssl_verify: bool = True, debug_payload: bool = False) -> None: # pylint: disable=too-many-arguments """ Initialize the Pooldose client. @@ -48,6 +43,7 @@ def __init__(self, host: str, timeout: int = 30, *, websession: Optional[aiohttp use_ssl (bool): If True, use HTTPS instead of HTTP. port (Optional[int]): Custom port for connections. Defaults to 80 for HTTP, 443 for HTTPS. ssl_verify (bool): If True, verify SSL certificates. Only used when use_ssl=True. + debug_payload (bool): If True, log and store payloads sent to device for debugging. """ self._host = host self._timeout = timeout @@ -56,12 +52,13 @@ def __init__(self, host: str, timeout: int = 30, *, websession: Optional[aiohttp self._use_ssl = use_ssl self._port = port self._ssl_verify = ssl_verify + self._debug_payload = debug_payload self._last_data = None self._websession = websession self._request_handler: RequestHandler | None = None # Initialize device info with default or placeholder values - self.device_info: DeviceInfoDict = get_default_device_info() + self.device_info: Dict[str, Any] = get_default_device_info() # Mapping-Status und Mapping-Cache self._mapping_status = None @@ -81,7 +78,8 @@ async def connect(self) -> RequestStatus: websession=self._websession if hasattr(self, '_websession') else None, use_ssl=self._use_ssl, port=self._port, - ssl_verify=self._ssl_verify + ssl_verify=self._ssl_verify, + debug_payload=self._debug_payload ) status = await self._request_handler.connect() if status != RequestStatus.SUCCESS: @@ -105,7 +103,7 @@ def request_handler(self) -> RequestHandler: raise RuntimeError("Client not connected. Call connect() first.") return self._request_handler - def check_apiversion_supported(self) -> Tuple[RequestStatus, APIVersionResponse]: + def check_apiversion_supported(self) -> Tuple[RequestStatus, Dict[str, Optional[str]]]: """ Check if the loaded API version matches the supported version. @@ -155,12 +153,16 @@ async def _load_device_info(self) -> RequestStatus: # pylint: disable=too-many- self.device_info["SERIAL_NUMBER"] = gateway.get("DID") self.device_info["NAME"] = gateway.get("NAME") self.device_info["SW_VERSION"] = gateway.get("FW_REL") - if (device := debug_config.get("DEVICES")[0]) is not None: - self.device_info["DEVICE_ID"] = device.get("DID") - self.device_info["MODEL"] = device.get("NAME") - self.device_info["MODEL_ID"] = device.get("PRODUCT_CODE") - self.device_info["FW_VERSION"] = device.get("FW_REL") - self.device_info["FW_CODE"] = device.get("FW_CODE") + + devices = debug_config.get("DEVICES") + if devices and len(devices) > 0: + device = devices[0] + if device is not None: + self.device_info["DEVICE_ID"] = device.get("DID") + self.device_info["MODEL"] = device.get("NAME") + self.device_info["MODEL_ID"] = device.get("PRODUCT_CODE") + self.device_info["FW_VERSION"] = device.get("FW_REL") + self.device_info["FW_CODE"] = device.get("FW_CODE") await asyncio.sleep(0.5) # Load mapping information @@ -231,6 +233,7 @@ def static_values(self) -> tuple[RequestStatus, StaticValues | None]: tuple: (RequestStatus, StaticValues|None) - Status and static values object. """ try: + # Device info is already Dict[str, Any] return RequestStatus.SUCCESS, StaticValues(self.device_info) except (ValueError, TypeError, KeyError) as err: _LOGGER.warning("Error creating StaticValues: %s", err) @@ -258,13 +261,16 @@ async def instant_values(self) -> tuple[RequestStatus, InstantValues | None]: device_raw_data = raw_data.get("devicedata", {}).get(device_id, {}) model_id = str(self.device_info.get("MODEL_ID", "")) fw_code = str(self.device_info.get("FW_CODE", "")) + if model_id == 'PDHC1H1HAR1V1' and fw_code == '539224': + #due to identifier issue in device firmware, use mapping prefix of PDPR1H1HAR1V0 + model_id = 'PDPR1H1HAR1V0' prefix = f"{model_id}_FW{fw_code}_" return RequestStatus.SUCCESS, InstantValues(device_raw_data, mapping, prefix, device_id, self._request_handler) except (KeyError, TypeError, ValueError) as err: _LOGGER.warning("Error creating InstantValues: %s", err) return RequestStatus.UNKNOWN_ERROR, None - async def instant_values_structured(self) -> Tuple[RequestStatus, StructuredValuesDict]: + async def instant_values_structured(self) -> Tuple[RequestStatus, Dict[str, Any]]: """ Get instant values in structured JSON format with types as top-level keys. @@ -284,3 +290,35 @@ async def instant_values_structured(self) -> Tuple[RequestStatus, StructuredValu except (KeyError, TypeError, ValueError) as err: _LOGGER.error("Error creating structured instant values: %s", err) return RequestStatus.UNKNOWN_ERROR, {} + + # Convenience setters ------------------------------------------------- + async def set_switch(self, key: str, value: bool) -> bool: + """Set a mapped switch value without manually fetching InstantValues. + + This convenience wrapper fetches the current InstantValues and + delegates the operation to its typed setter. Returns True on success. + """ + status, iv = await self.instant_values() + if status != RequestStatus.SUCCESS or iv is None: + return False + return await iv.set_switch(key, value) + + async def set_number(self, key: str, value: Any) -> bool: + """Set a mapped numeric value without manually fetching InstantValues.""" + status, iv = await self.instant_values() + if status != RequestStatus.SUCCESS or iv is None: + return False + return await iv.set_number(key, value) + + async def set_select(self, key: str, value: Any) -> bool: + """Set a mapped select option without manually fetching InstantValues.""" + status, iv = await self.instant_values() + if status != RequestStatus.SUCCESS or iv is None: + return False + return await iv.set_select(key, value) + + def get_last_payload(self) -> Optional[str]: + """Get the last payload sent to the device (if debug_payload is enabled).""" + if self._request_handler: + return self._request_handler.get_last_payload() + return None diff --git a/src/pooldose/mappings/model_PDHC1H1HAR1V1_FW539224.json b/src/pooldose/mappings/model_PDHC1H1HAR1V1_FW539224.json new file mode 100644 index 0000000..fd5700e --- /dev/null +++ b/src/pooldose/mappings/model_PDHC1H1HAR1V1_FW539224.json @@ -0,0 +1,620 @@ +{ + "temperature": { + "key": "w_1f0io7hq6", + "type": "sensor" + }, + "ph": { + "key": "w_1f0inr5mg", + "type": "sensor" + }, + "orp": { + "key": "w_1f0invtg9", + "type": "sensor" + }, + "cl": { + "key": "w_1gribhndo", + "type": "sensor" + }, + "flow_rate": { + "key": "w_1g1l040fa", + "type": "sensor" + }, + "ph_type_dosing": { + "key": "w_1f0it2vcf", + "type": "sensor", + "conversion": { + "|PDPR1H1HAR1V0_FW539224_LABEL_w_1f0it2vcf_ALCALYNE|": "alcalyne", + "|PDPR1H1HAR1V0_FW539224_LABEL_w_1f0it2vcf_ACID|": "acid" + } + }, + "peristaltic_ph_dosing": { + "key": "w_1f0iteoja", + "type": "sensor", + "conversion": { + "|PDPR1H1HAR1V0_FW539224_LABEL_w_1f0iteoja_OFF_|": "off", + "|PDPR1H1HAR1V0_FW539224_LABEL_w_1f0iteoja_PROPORTIONAL|": "proportional", + "|PDPR1H1HAR1V0_FW539224_LABEL_w_1f0iteoja_ON_OFF|": "on_off", + "|PDPR1H1HAR1V0_FW539224_LABEL_w_1f0iteoja_TIMED|": "timed" + } + }, + "orp_type_dosing": { + "key": "w_1f0it326i", + "type": "sensor", + "conversion": { + "|PDPR1H1HAR1V0_FW539224_LABEL_w_1f0it326i_LOW_|": "low", + "|PDPR1H1HAR1V0_FW539224_LABEL_w_1f0it326i_HIGH|": "high" + } + }, + "peristaltic_orp_dosing": { + "key": "w_1f0iteqrl", + "type": "sensor", + "conversion": { + "|PDPR1H1HAR1V0_FW539224_LABEL_w_1f0iteqrl_OFF|": "off", + "|PDPR1H1HAR1V0_FW539224_LABEL_w_1f0iteqrl_PROPORTIONAL|": "proportional", + "|PDPR1H1HAR1V0_FW539224_LABEL_w_1f0iteqrl_ON_OFF|": "on_off", + "|PDPR1H1HAR1V0_FW539224_LABEL_w_1f0iteqrl_TIMED|": "timed" + } + }, + "cl_type_dosing": { + "key": "w_1f0it3458", + "type": "sensor", + "conversion": { + "|PDPR1H1HAR1V0_FW539224_LABEL_w_1f0it3458_LOW_|": "low", + "|PDPR1H1HAR1V0_FW539224_LABEL_w_1f0it3458_HIGH|": "high" + } + }, + "peristaltic_cl_dosing": { + "key": "w_1f0itlfoj", + "type": "sensor", + "conversion": { + "|PDPR1H1HAR1V0_FW539224_LABEL_w_1f0itlfoj_OFF|": "off", + "|PDPR1H1HAR1V0_FW539224_LABEL_w_1f0itlfoj_PROPORTIONAL|": "proportional", + "|PDPR1H1HAR1V0_FW539224_LABEL_w_1f0itlfoj_ON_OFF|": "on_off", + "|PDPR1H1HAR1V0_FW539224_LABEL_w_1f0itlfoj_TIMED|": "timed" + } + }, + "pump_alarm": { + "key": "w_1f1fng00q", + "type": "binary_sensor", + "conversion": { + "|PDPR1H1HAR1V0_FW539224_LABEL_w_1f1fng00q_OFF|": "F", + "|PDPR1H1HAR1V0_FW539224_LABEL_w_1f1fng00q_ON|": "O" + } + }, + "ph_level_alarm": { + "key": "w_1f0ioo093", + "type": "binary_sensor" + }, + "orp_level_alarm": { + "key": "w_1f0ioo2gc", + "type": "binary_sensor" + }, + "flow_rate_alarm": { + "key": "w_1f0iqmg8c", + "type": "binary_sensor" + }, + "relay_alarm": { + "key": "w_1f0ir59fo", + "type": "binary_sensor" + }, + "relay_aux1": { + "key": "w_1gmgkbmap", + "type": "binary_sensor" + }, + "relay_aux2": { + "key": "w_1gmgkfe9s", + "type": "binary_sensor" + }, + "relay_aux3": { + "key": "w_1gmgkflcj", + "type": "binary_sensor" + }, + "relay_aux1_type": { + "key": "w_1gqhecqs1", + "type": "select", + "options": { + "0": "|PDPR1H1HAR1V0_FW539224_COMBO_w_1gqhecqs1_DISABLE|", + "1": "|PDPR1H1HAR1V0_FW539224_COMBO_w_1gqhecqs1_PH|", + "2": "|PDPR1H1HAR1V0_FW539224_COMBO_w_1gqhecqs1_TIMED|", + "3": "|PDPR1H1HAR1V0_FW539224_COMBO_w_1gqhecqs1_ALARM|", + "4": "|PDPR1H1HAR1V0_FW539224_COMBO_w_1gqhecqs1_ORP|", + "5": "|PDPR1H1HAR1V0_FW539224_COMBO_w_1gqhecqs1_CHLORINE|" + }, + "conversion": { + "|PDPR1H1HAR1V0_FW539224_COMBO_w_1gqhecqs1_DISABLE|": "disable", + "|PDPR1H1HAR1V0_FW539224_COMBO_w_1gqhecqs1_PH|": "ph", + "|PDPR1H1HAR1V0_FW539224_COMBO_w_1gqhecqs1_TIMED|": "timed", + "|PDPR1H1HAR1V0_FW539224_COMBO_w_1gqhecqs1_ALARM|": "alarm", + "|PDPR1H1HAR1V0_FW539224_COMBO_w_1gqhecqs1_ORP|": "orp", + "|PDPR1H1HAR1V0_FW539224_COMBO_w_1gqhecqs1_CHLORINE|": "chlorine" + } + }, + "relay_aux2_type": { + "key": "w_1gqhedcrq", + "type": "select", + "options": { + "0": "|PDPR1H1HAR1V0_FW539224_COMBO_w_1gqhedcrq_DISABLE|", + "1": "|PDPR1H1HAR1V0_FW539224_COMBO_w_1gqhedcrq_PH|", + "2": "|PDPR1H1HAR1V0_FW539224_COMBO_w_1gqhedcrq_TIMED|", + "3": "|PDPR1H1HAR1V0_FW539224_COMBO_w_1gqhedcrq_ALARM|", + "4": "|PDPR1H1HAR1V0_FW539224_COMBO_w_1gqhedcrq_ORP|", + "5": "|PDPR1H1HAR1V0_FW539224_COMBO_w_1gqhedcrq_CHLORINE|" + }, + "conversion": { + "|PDPR1H1HAR1V0_FW539224_COMBO_w_1gqhedcrq_DISABLE|": "disable", + "|PDPR1H1HAR1V0_FW539224_COMBO_w_1gqhedcrq_PH|": "ph", + "|PDPR1H1HAR1V0_FW539224_COMBO_w_1gqhedcrq_TIMED|": "timed", + "|PDPR1H1HAR1V0_FW539224_COMBO_w_1gqhedcrq_ALARM|": "alarm", + "|PDPR1H1HAR1V0_FW539224_COMBO_w_1gqhedcrq_ORP|": "orp", + "|PDPR1H1HAR1V0_FW539224_COMBO_w_1gqhedcrq_CHLORINE|": "chlorine" + } + }, + "relay_aux3_type": { + "key": "w_1gqhedtcn", + "type": "select", + "options": { + "0": "|PDPR1H1HAR1V0_FW539224_COMBO_w_1gqhedtcn_DISABLE|", + "1": "|PDPR1H1HAR1V0_FW539224_COMBO_w_1gqhedtcn_PH|", + "2": "|PDPR1H1HAR1V0_FW539224_COMBO_w_1gqhedtcn_TIMED|", + "3": "|PDPR1H1HAR1V0_FW539224_COMBO_w_1gqhedtcn_ALARM|", + "4": "|PDPR1H1HAR1V0_FW539224_COMBO_w_1gqhedtcn_ORP|", + "5": "|PDPR1H1HAR1V0_FW539224_COMBO_w_1gqhedtcn_CHLORINE|" + }, + "conversion": { + "|PDPR1H1HAR1V0_FW539224_COMBO_w_1gqhedtcn_DISABLE|": "disable", + "|PDPR1H1HAR1V0_FW539224_COMBO_w_1gqhedtcn_PH|": "ph", + "|PDPR1H1HAR1V0_FW539224_COMBO_w_1gqhedtcn_TIMED|": "timed", + "|PDPR1H1HAR1V0_FW539224_COMBO_w_1gqhedtcn_ALARM|": "alarm", + "|PDPR1H1HAR1V0_FW539224_COMBO_w_1gqhedtcn_ORP|": "orp", + "|PDPR1H1HAR1V0_FW539224_COMBO_w_1gqhedtcn_CHLORINE|": "chlorine" + } + }, + "ofa_ph_lower": { + "key": "w_1g1kvba4g", + "type": "number", + "field": "minT" + }, + "ofa_ph_upper": { + "key": "w_1g1kvba4g", + "type": "number", + "field": "maxT" + }, + "alarm_ofa_ph": { + "key": "w_1f0iqoek2", + "type": "binary_sensor" + }, + "alarm_ofa_orp": { + "key": "w_1f0iqrv04", + "type": "binary_sensor" + }, + "alarm_ofa_cl": { + "key": "w_1f0iqs0hr", + "type": "binary_sensor" + }, + "ph_target": { + "key": "w_1f0irf02j", + "type": "number" + }, + "orp_target": { + "key": "w_1f0ishl5i", + "type": "number" + }, + "cl_target": { + "key": "w_1f0isio5g", + "type": "number" + }, + "pause_dosing": { + "key": "w_1f2jpqa6e", + "type": "switch" + }, + "pump_monitoring": { + "key": "w_1hn0vte5j", + "type": "binary_sensor", + "conversion": { + "|PDPR1H1HAR1V0_FW539224_LABEL_w_1hn0vte5j_DISABLED|": "F", + "|PDPR1H1HAR1V0_FW539224_LABEL_w_1hn0vte5j_ENABLED|": "O" + } + }, + "flow_rate_unit": { + "key": "w_1f0j30vam", + "type": "select", + "options": { + "50": "|PDPR1H1HAR1V0_FW539224_COMBO_w_1f0j30vam_M3_H|", + "47": "|PDPR1H1HAR1V0_FW539224_COMBO_w_1f0j30vam_L_S|" + }, + "conversion": { + "|PDPR1H1HAR1V0_FW539224_COMBO_w_1f0j30vam_M3_H|": "m3/h", + "|PDPR1H1HAR1V0_FW539224_COMBO_w_1f0j30vam_L_S|": "L/s" + } + }, + "water_meter_unit": { + "key": "w_1f3dfp59r", + "type": "select", + "options": { + "8": "|PDPR1H1HAR1V0_FW539224_COMBO_w_1f3dfp59r_LITERS__L_|", + "9": "|PDPR1H1HAR1V0_FW539224_COMBO_w_1f3dfp59r_CUBIC_METER__M__|" + }, + "conversion": { + "|PDPR1H1HAR1V0_FW539224_COMBO_w_1f3dfp59r_LITERS__L_|": "L", + "|PDPR1H1HAR1V0_FW539224_COMBO_w_1f3dfp59r_CUBIC_METER__M__|": "m³" + } + }, + "water_meter_total_permanent": { + "key": "w_1f0r19sc3", + "type": "number" + }, + "water_meter_total_resetable": { + "key": "w_1fr0042ir", + "type": "number" + }, + "setpoint_ph_type": { + "key": "w_1f0j0bfqb", + "type": "select", + "options": { + "0": "|PDPR1H1HAR1V0_FW539224_COMBO_w_1f0j0bfqb_ALCALYNE|", + "1": "|PDPR1H1HAR1V0_FW539224_COMBO_w_1f0j0bfqb_ACID|" + }, + "conversion": { + "|PDPR1H1HAR1V0_FW539224_COMBO_w_1f0j0bfqb_ALCALYNE|": "alcalyne", + "|PDPR1H1HAR1V0_FW539224_COMBO_w_1f0j0bfqb_ACID|": "acid" + } + }, + "ph_type_dosing_method": { + "key": "w_1f0j07d2o", + "type": "select", + "options": { + "0": "|PDPR1H1HAR1V0_FW539224_COMBO_w_1f0j07d2o_OFF_|", + "1": "|PDPR1H1HAR1V0_FW539224_COMBO_w_1f0j07d2o_PROPORTIONAL|", + "2": "|PDPR1H1HAR1V0_FW539224_COMBO_w_1f0j07d2o_ON_OFF|", + "3": "|PDPR1H1HAR1V0_FW539224_COMBO_w_1f0j07d2o_TIMED|" + }, + "conversion": { + "|PDPR1H1HAR1V0_FW539224_COMBO_w_1f0j07d2o_OFF_|": "off", + "|PDPR1H1HAR1V0_FW539224_COMBO_w_1f0j07d2o_PROPORTIONAL|": "proportional", + "|PDPR1H1HAR1V0_FW539224_COMBO_w_1f0j07d2o_ON_OFF|": "on / off", + "|PDPR1H1HAR1V0_FW539224_COMBO_w_1f0j07d2o_TIMED|": "timed" + } + }, + "setpoint_orp_type": { + "key": "w_1f0j0jgfd", + "type": "select", + "options": { + "0": "|PDPR1H1HAR1V0_FW539224_COMBO_w_1f0j0jgfd_LOW_|", + "1": "|PDPR1H1HAR1V0_FW539224_COMBO_w_1f0j0jgfd_HIGH|" + }, + "conversion": { + "|PDPR1H1HAR1V0_FW539224_COMBO_w_1f0j0jgfd_LOW_|": "low", + "|PDPR1H1HAR1V0_FW539224_COMBO_w_1f0j0jgfd_HIGH|": "high" + } + }, + "orp_type_dosing_method": { + "key": "w_1f0j0jnv9", + "type": "select", + "options": { + "0": "|PDPR1H1HAR1V0_FW539224_COMBO_w_1f0j0jnv9_OFF|", + "1": "|PDPR1H1HAR1V0_FW539224_COMBO_w_1f0j0jnv9_PROPORTIONAL|", + "2": "|PDPR1H1HAR1V0_FW539224_COMBO_w_1f0j0jnv9_ON_OFF|", + "3": "|PDPR1H1HAR1V0_FW539224_COMBO_w_1f0j0jnv9_TIMED|" + }, + "conversion": { + "|PDPR1H1HAR1V0_FW539224_COMBO_w_1f0j0jnv9_OFF|": "off", + "|PDPR1H1HAR1V0_FW539224_COMBO_w_1f0j0jnv9_PROPORTIONAL|": "proportional", + "|PDPR1H1HAR1V0_FW539224_COMBO_w_1f0j0jnv9_ON_OFF|": "on / off", + "|PDPR1H1HAR1V0_FW539224_COMBO_w_1f0j0jnv9_TIMED|": "timed" + } + }, + "setpoint_cl_type": { + "key": "w_1f0j0q8s4", + "type": "select", + "options": { + "0": "|PDPR1H1HAR1V0_FW539224_COMBO_w_1f0j0q8s4_LOW_|", + "1": "|PDPR1H1HAR1V0_FW539224_COMBO_w_1f0j0q8s4_HIGH_|" + }, + "conversion": { + "|PDPR1H1HAR1V0_FW539224_COMBO_w_1f0j0q8s4_LOW_|": "low", + "|PDPR1H1HAR1V0_FW539224_COMBO_w_1f0j0q8s4_HIGH_|": "high" + } + }, + "cl_type_dosing_method": { + "key": "w_1f0j0qe9q", + "type": "select", + "options": { + "0": "|PDPR1H1HAR1V0_FW539224_COMBO_w_1f0j0qe9q_OFF|", + "1": "|PDPR1H1HAR1V0_FW539224_COMBO_w_1f0j0qe9q_PROPORTIONAL|", + "2": "|PDPR1H1HAR1V0_FW539224_COMBO_w_1f0j0qe9q_ON_OFF|", + "3": "|PDPR1H1HAR1V0_FW539224_COMBO_w_1f0j0qe9q_TIMED|" + }, + "conversion": { + "|PDPR1H1HAR1V0_FW539224_COMBO_w_1f0j0qe9q_OFF|": "off", + "|PDPR1H1HAR1V0_FW539224_COMBO_w_1f0j0qe9q_PROPORTIONAL|": "proportional", + "|PDPR1H1HAR1V0_FW539224_COMBO_w_1f0j0qe9q_ON_OFF|": "on / off", + "|PDPR1H1HAR1V0_FW539224_COMBO_w_1f0j0qe9q_TIMED|": "timed" + } + }, + "pumps_configuration": { + "key": "w_1hn83bpmk", + "type": "select", + "options": { + "0": "|PDPR1H1HAR1V0_FW539224_COMBO_w_1hn83bpmk_PH_REDOX_|", + "1": "|PDPR1H1HAR1V0_FW539224_COMBO_w_1hn83bpmk_PH_CL|" + }, + "conversion": { + "|PDPR1H1HAR1V0_FW539224_COMBO_w_1hn83bpmk_PH_REDOX_|": "pH Redox", + "|PDPR1H1HAR1V0_FW539224_COMBO_w_1hn83bpmk_PH_CL|": "pH Cl" + } + }, + "chlorine_with_orp_protection": { + "key": "w_1hn83brup", + "type": "select", + "options": { + "0": "|PDPR1H1HAR1V0_FW539224_COMBO_w_1hn83brup_DISABLED|", + "1": "|PDPR1H1HAR1V0_FW539224_COMBO_w_1hn83brup_ENABLED|" + }, + "conversion": { + "|PDPR1H1HAR1V0_FW539224_COMBO_w_1hn83brup_DISABLED|": "disabled", + "|PDPR1H1HAR1V0_FW539224_COMBO_w_1hn83brup_ENABLED|": "enabled" + } + }, + "peristaltic_pump_3": { + "key": "w_1f0j115tn", + "type": "select", + "options": { + "0": "|PDPR1H1HAR1V0_FW539224_COMBO_w_1f0j115tn_DISABLED|", + "1": "|PDPR1H1HAR1V0_FW539224_COMBO_w_1f0j115tn_ENABLED|" + }, + "conversion": { + "|PDPR1H1HAR1V0_FW539224_COMBO_w_1f0j115tn_DISABLED|": "disabled", + "|PDPR1H1HAR1V0_FW539224_COMBO_w_1f0j115tn_ENABLED|": "enabled" + } + }, + "input_reed_type": { + "key": "w_1gqhf3g6d", + "type": "select", + "options": { + "0": "|PDPR1H1HAR1V0_FW539224_COMBO_w_1gqhf3g6d_NORMALLY_CLOSED|", + "1": "|PDPR1H1HAR1V0_FW539224_COMBO_w_1gqhf3g6d_NORMALLY_OPEN|", + "2": "|PDPR1H1HAR1V0_FW539224_COMBO_w_1gqhf3g6d_LEVEL_3|" + }, + "conversion": { + "|PDPR1H1HAR1V0_FW539224_COMBO_w_1gqhf3g6d_NORMALLY_CLOSED|": "normally closed", + "|PDPR1H1HAR1V0_FW539224_COMBO_w_1gqhf3g6d_NORMALLY_OPEN|": "normally open", + "|PDPR1H1HAR1V0_FW539224_COMBO_w_1gqhf3g6d_LEVEL_3|": "level 3" + } + }, + "circulation_pump_type": { + "key": "w_1hn0h4fmb", + "type": "select", + "options": { + "0": "|PDPR1H1HAR1V0_FW539224_COMBO_w_1hn0h4fmb_DISABLED|", + "1": "|PDPR1H1HAR1V0_FW539224_COMBO_w_1hn0h4fmb_ENABLED|" + }, + "conversion": { + "|PDPR1H1HAR1V0_FW539224_COMBO_w_1hn0h4fmb_DISABLED|": "disabled", + "|PDPR1H1HAR1V0_FW539224_COMBO_w_1hn0h4fmb_ENABLED|": "enabled" + } + }, + "flow_rate_sensor_type": { + "key": "w_1f0j1bd2e", + "type": "select", + "options": { + "0": "|PDPR1H1HAR1V0_FW539224_COMBO_w_1f0j1bd2e_K_FACTOR_PADDLE_WHEEL|", + "1": "|PDPR1H1HAR1V0_FW539224_COMBO_w_1f0j1bd2e_WATER_METER_PULSE_SENDER|" + }, + "conversion": { + "|PDPR1H1HAR1V0_FW539224_COMBO_w_1f0j1bd2e_K_FACTOR_PADDLE_WHEEL|": "k-factor paddle wheel", + "|PDPR1H1HAR1V0_FW539224_COMBO_w_1f0j1bd2e_WATER_METER_PULSE_SENDER|": "water meter pulse sender" + } + }, + "flow_rate_unit_measure": { + "key": "w_1f0j30vam", + "type": "select", + "options": { + "50": "|PDPR1H1HAR1V0_FW539224_COMBO_w_1f0j30vam_M3_H|", + "47": "|PDPR1H1HAR1V0_FW539224_COMBO_w_1f0j30vam_L_S|" + }, + "conversion": { + "|PDPR1H1HAR1V0_FW539224_COMBO_w_1f0j30vam_M3_H|": "m3/h", + "|PDPR1H1HAR1V0_FW539224_COMBO_w_1f0j30vam_L_S|": "L/s" + } + }, + "temp_measure_sensor": { + "key": "w_1f3dfuoqk", + "type": "select", + "options": { + "0": "|PDPR1H1HAR1V0_FW539224_COMBO_w_1f3dfuoqk_PT100|", + "1": "|PDPR1H1HAR1V0_FW539224_COMBO_w_1f3dfuoqk_MANUAL_VALUE|" + }, + "conversion": { + "|PDPR1H1HAR1V0_FW539224_COMBO_w_1f3dfuoqk_PT100|": "pt100", + "|PDPR1H1HAR1V0_FW539224_COMBO_w_1f3dfuoqk_MANUAL_VALUE|": "manual value" + } + }, + "time_on_ph_pump": { + "key": "w_1f0itopg9", + "type": "number" + }, + "time_off_ph_pump": { + "key": "w_1f0itrut7", + "type": "number" + }, + "time_on_chlorine_pump": { + "key": "w_1f0itu8ss", + "type": "number" + }, + "time_off_chlorine_pump": { + "key": "w_1f0ituajd", + "type": "number" + }, + "time1_on_timed_pump_aux1": { + "key": "w_1f0ive435", + "type": "number" + }, + "time1_off_timed_pump_aux1": { + "key": "w_1f0ivortt", + "type": "number" + }, + "time2_on_aux2_rl": { + "key": "w_1gqhd4ato", + "type": "number" + }, + "time2_off_aux2_rl": { + "key": "w_1gqhd4crb", + "type": "number" + }, + "time3_on_aux3_rl": { + "key": "w_1gqhd4fo3", + "type": "number" + }, + "time3_off_aux3_rl": { + "key": "w_1gqhd4h7v", + "type": "number" + }, + "temperature_unit": { + "key": "w_1f0iv1vlt", + "type": "select", + "options": { + "0": "|PDPR1H1HAR1V0_FW539224_LABEL_w_1f0iv1vlt__C|", + "1": "|PDPR1H1HAR1V0_FW539224_LABEL_w_1f0iv1vlt__F|" + }, + "conversion": { + "|PDPR1H1HAR1V0_FW539224_LABEL_w_1f0iv1vlt__C|": "°C", + "|PDPR1H1HAR1V0_FW539224_LABEL_w_1f0iv1vlt__F|": "°F" + } + }, + "flow_rate_value": { + "key": "w_1f0iv4ls4", + "type": "number" + }, + "device_setting": { + "key": "w_1f24dsmu0", + "type": "select", + "options": { + "0": "|PDPR1H1HAR1V0_FW539224_LABEL_w_1f24dsmu0_PH_REDOX_|", + "1": "|PDPR1H1HAR1V0_FW539224_LABEL_w_1f24dsmu0_PH_REDOX_OXY|", + "2": "|PDPR1H1HAR1V0_FW539224_LABEL_w_1f24dsmu0_PH_REDOX_CL|" + }, + "conversion": { + "|PDPR1H1HAR1V0_FW539224_LABEL_w_1f24dsmu0_PH_REDOX_|": "pH Redox", + "|PDPR1H1HAR1V0_FW539224_LABEL_w_1f24dsmu0_PH_REDOX_OXY|": "pH Redox Oxy", + "|PDPR1H1HAR1V0_FW539224_LABEL_w_1f24dsmu0_PH_REDOX_CL|": "pH Redox Cl" + } + }, + "setpoint_ph": { + "key": "w_1f0j0a7cf", + "type": "number" + }, + "time_on_ph_pump_b": { + "key": "w_1f0j03mbq", + "type": "number" + }, + "time_off_ph_pump_b": { + "key": "w_1f0j05013", + "type": "number" + }, + "setpoint_orp": { + "key": "w_1f0j0jd2a", + "type": "number" + }, + "setpoint_chlorine": { + "key": "w_1f0j0q7ae", + "type": "number" + }, + "time_on_cl_pump": { + "key": "w_1f0j0qanc", + "type": "number" + }, + "time_off_cl_pump": { + "key": "w_1f0j0qceu", + "type": "number" + }, + "time1_on_timed_pump1": { + "key": "w_1f0j11av3", + "type": "number" + }, + "time1_off_timed_pump1": { + "key": "w_1f0j11cd6", + "type": "number" + }, + "time2_on_timed_pump2": { + "key": "w_1gqhejosq", + "type": "number" + }, + "time2_off_timed_pump2": { + "key": "w_1gqhejsbt", + "type": "number" + }, + "time3_on_timed_pump3": { + "key": "w_1gqhejutn", + "type": "number" + }, + "time3_off_timed_pump3": { + "key": "w_1gqhek18o", + "type": "number" + }, + "pulse_k_factor": { + "key": "w_1f0j230o6", + "type": "number" + }, + "power_on_delay": { + "key": "w_1fka6s5ed", + "type": "number" + }, + "flow_delay": { + "key": "w_1fka6s9e3", + "type": "number" + }, + "power_on_delay_status": { + "key": "w_1fkkmorfn", + "type": "select", + "options": { + "0": "|PDPR1H1HAR1V0_FW539224_LABEL_w_1fkkmorfn_DISABLE|", + "1": "|PDPR1H1HAR1V0_FW539224_LABEL_w_1fkkmorfn_ENABLE|" + }, + "conversion": { + "|PDPR1H1HAR1V0_FW539224_LABEL_w_1fkkmorfn_DISABLE|": "disable", + "|PDPR1H1HAR1V0_FW539224_LABEL_w_1fkkmorfn_ENABLE|": "enable" + } + }, + "flow_delay_status": { + "key": "w_1fkkmou3k", + "type": "select", + "options": { + "0": "|PDPR1H1HAR1V0_FW539224_LABEL_w_1fkkmou3k_DISABLE|", + "1": "|PDPR1H1HAR1V0_FW539224_LABEL_w_1fkkmou3k_ENABLE|" + }, + "conversion": { + "|PDPR1H1HAR1V0_FW539224_LABEL_w_1fkkmou3k_DISABLE|": "disable", + "|PDPR1H1HAR1V0_FW539224_LABEL_w_1fkkmou3k_ENABLE|": "enable" + } + }, + "h2o2_type_dosing_method": { + "key": "w_1f3b1qd09", + "type": "select", + "options": { + "0": "|PDPR1H1HAR1V0_FW539224_COMBO_w_1f3b1qd09_OFF|", + "1": "|PDPR1H1HAR1V0_FW539224_COMBO_w_1f3b1qd09_ACTIVE|", + "2": "|PDPR1H1HAR1V0_FW539224_COMBO_w_1f3b1qd09_TIMED|" + }, + "conversion": { + "|PDPR1H1HAR1V0_FW539224_COMBO_w_1f3b1qd09_OFF|": "off", + "|PDPR1H1HAR1V0_FW539224_COMBO_w_1f3b1qd09_ACTIVE|": "active", + "|PDPR1H1HAR1V0_FW539224_COMBO_w_1f3b1qd09_TIMED|": "timed" + } + }, + "flow_rate_reed_sensor": { + "key": "w_1f0iolq38", + "type": "binary_sensor" + }, + "circulation_pump": { + "key": "w_1f0iqe0vh", + "type": "switch" + }, + "level_sensor_1": { + "key": "w_1f0iqi4ei", + "type": "binary_sensor" + }, + "level_sensor_2": { + "key": "w_1f0iqk56q", + "type": "binary_sensor" + } +} \ No newline at end of file diff --git a/src/pooldose/mappings/model_PDPR1H1HAR1V0_FW539224.json b/src/pooldose/mappings/model_PDPR1H1HAR1V0_FW539224.json index 873d8e1..296ebe6 100644 --- a/src/pooldose/mappings/model_PDPR1H1HAR1V0_FW539224.json +++ b/src/pooldose/mappings/model_PDPR1H1HAR1V0_FW539224.json @@ -11,10 +11,6 @@ "key": "w_1f0invtg9", "type": "sensor" }, - "cl": { - "key": "w_1f0io42cq", - "type": "sensor" - }, "ph_type_dosing": { "key": "w_1f0it2vcf", "type": "sensor", @@ -33,10 +29,6 @@ "|PDPR1H1HAR1V0_FW539224_LABEL_w_1f0iteoja_TIMED|": "timed" } }, - "ofa_ph_value": { - "key": "w_1g1l138q8", - "type": "sensor" - }, "orp_type_dosing": { "key": "w_1f0it326i", "type": "sensor", @@ -55,11 +47,7 @@ "|PDPR1H1HAR1V0_FW539224_LABEL_w_1f0iteqrl_TIMED|": "timed" } }, - "ofa_orp_value": { - "key": "w_1g1kvclje", - "type": "sensor" - }, - "pump_running": { + "pump_alarm": { "key": "w_1f1fng00q", "type": "binary_sensor", "conversion": { @@ -67,30 +55,42 @@ "|PDPR1H1HAR1V0_FW539224_LABEL_w_1f1fng00q_ON|": "O" } }, - "ph_level_ok": { + "ph_level_alarm": { "key": "w_1f0ioo093", "type": "binary_sensor" }, - "orp_level_ok": { + "orp_level_alarm": { "key": "w_1f0ioo2gc", "type": "binary_sensor" }, - "flow_rate_ok": { + "flow_rate_alarm": { "key": "w_1f0iqmg8c", "type": "binary_sensor" }, - "alarm_relay": { + "relay_alarm": { "key": "w_1f0ir59fo", "type": "binary_sensor" }, - "relay_aux1_ph": { + "relay_aux1": { "key": "w_1gmgkbmap", "type": "binary_sensor" }, - "relay_aux2_orpcl": { + "relay_aux2": { "key": "w_1gmgkfe9s", "type": "binary_sensor" }, + "relay_aux3": { + "key": "w_1gmgkflcj", + "type": "binary_sensor" + }, + "alarm_ofa_ph": { + "key": "w_1f0iqoek2", + "type": "binary_sensor" + }, + "alarm_ofa_orp": { + "key": "w_1f0iqrv04", + "type": "binary_sensor" + }, "ph_target": { "key": "w_1f0irf02j", "type": "number" @@ -99,11 +99,11 @@ "key": "w_1f0ishl5i", "type": "number" }, - "stop_pool_dosing": { + "pause_dosing": { "key": "w_1f2jpqa6e", "type": "switch" }, - "pump_detection": { + "pump_monitoring": { "key": "w_1hn0vte5j", "type": "binary_sensor", "conversion": { @@ -115,12 +115,74 @@ "key": "w_1f3dfp59r", "type": "select", "options": { - "8": "PDPR1H1HAR1V0_FW539224_COMBO_w_1f3dfp59r_LITERS__L_", - "9": "PDPR1H1HAR1V0_FW539224_COMBO_w_1f3dfp59r_CUBIC_METER__M__" + "8": "|PDPR1H1HAR1V0_FW539224_COMBO_w_1f3dfp59r_LITERS__L_|", + "9": "|PDPR1H1HAR1V0_FW539224_COMBO_w_1f3dfp59r_CUBIC_METER__M__|" + }, + "conversion": { + "|PDPR1H1HAR1V0_FW539224_COMBO_w_1f3dfp59r_LITERS__L_|": "L", + "|PDPR1H1HAR1V0_FW539224_COMBO_w_1f3dfp59r_CUBIC_METER__M__|": "m³" + } + }, + "input_reed_type": { + "key": "w_1gqhf8s0i", + "type": "select", + "options": { + "0": "|PDPR1H1HAR1V0_FW539224_LABEL_w_1gqhf8s0i_REED_N_C_|", + "1": "|PDPR1H1HAR1V0_FW539224_LABEL_w_1gqhf8s0i_REED_N_O_|", + "2": "|PDPR1H1HAR1V0_FW539224_LABEL_w_1gqhf8s0i_LEVEL_3|" }, "conversion": { - "PDPR1H1HAR1V0_FW539224_COMBO_w_1f3dfp59r_LITERS__L_": "L", - "PDPR1H1HAR1V0_FW539224_COMBO_w_1f3dfp59r_CUBIC_METER__M__": "m³" + "|PDPR1H1HAR1V0_FW539224_LABEL_w_1gqhf8s0i_REED_N_C_|": "normally closed", + "|PDPR1H1HAR1V0_FW539224_LABEL_w_1gqhf8s0i_REED_N_O_|": "normally open", + "|PDPR1H1HAR1V0_FW539224_LABEL_w_1gqhf8s0i_LEVEL_3|": "level 3" } + }, + "flow_rate_value": { + "key": "w_1f0iv4ls4", + "type": "number" + }, + "setpoint_ph": { + "key": "w_1f0j0a7cf", + "type": "number" + }, + "setpoint_orp": { + "key": "w_1f0j0jd2a", + "type": "number" + }, + "time1_on_timed_pump1": { + "key": "w_1f0j11av3", + "type": "number" + }, + "time1_off_timed_pump1": { + "key": "w_1f0j11cd6", + "type": "number" + }, + "time2_on_timed_pump2": { + "key": "w_1gqhejosq", + "type": "number" + }, + "time2_off_timed_pump2": { + "key": "w_1gqhejsbt", + "type": "number" + }, + "time3_on_timed_pump3": { + "key": "w_1gqhejutn", + "type": "number" + }, + "time3_off_timed_pump3": { + "key": "w_1gqhek18o", + "type": "number" + }, + "pulse_k_factor": { + "key": "w_1f0j230o6", + "type": "number" + }, + "power_on_delay": { + "key": "w_1fka6s5ed", + "type": "number" + }, + "flow_delay": { + "key": "w_1fka6s9e3", + "type": "number" } } \ No newline at end of file diff --git a/src/pooldose/mappings/model_PDPR1H1HAW100_FW539187.json b/src/pooldose/mappings/model_PDPR1H1HAW100_FW539187.json index 42a93f6..18f660e 100644 --- a/src/pooldose/mappings/model_PDPR1H1HAW100_FW539187.json +++ b/src/pooldose/mappings/model_PDPR1H1HAW100_FW539187.json @@ -19,54 +19,46 @@ "key": "w_1eklg44ro", "type": "sensor", "conversion": { - "|PDPR1H1HAW100_FW539187_LABEL_w_1eklg44ro_ALCALYNE|": "alcalyne", - "|PDPR1H1HAW100_FW539187_LABEL_w_1eklg44ro_ACID|" : "acid" + "|PDPR1H1HAW100_FW539187_LABEL_w_1eklg44ro_ALCALYNE|": "alcalyne", + "|PDPR1H1HAW100_FW539187_LABEL_w_1eklg44ro_ACID|": "acid" } }, "peristaltic_ph_dosing": { "key": "w_1eklj6euj", "type": "sensor", "conversion": { - "|PDPR1H1HAW100_FW539187_LABEL_w_1eklj6euj_OFF|": "off", - "|PDPR1H1HAW100_FW539187_LABEL_w_1eklj6euj_PROPORTIONAL|": "proportional", - "|PDPR1H1HAW100_FW539187_LABEL_w_1eklj6euj_ON_OFF|": "on_off", - "|PDPR1H1HAW100_FW539187_LABEL_w_1eklj6euj_TIMED|": "timed" + "|PDPR1H1HAW100_FW539187_LABEL_w_1eklj6euj_OFF|": "off", + "|PDPR1H1HAW100_FW539187_LABEL_w_1eklj6euj_PROPORTIONAL|": "proportional", + "|PDPR1H1HAW100_FW539187_LABEL_w_1eklj6euj_ON_OFF|": "on / off", + "|PDPR1H1HAW100_FW539187_LABEL_w_1eklj6euj_TIMED|": "timed" } }, - "ofa_ph_value": { - "key": "w_1eo1ttmft", - "type": "sensor" - }, "orp_type_dosing": { "key": "w_1eklgnolb", "type": "sensor", "conversion": { - "|PDPR1H1HAW100_FW539187_LABEL_w_1eklgnolb_LOW|": "low", - "|PDPR1H1HAW100_FW539187_LABEL_w_1eklgnolb_HIGH|": "high" + "|PDPR1H1HAW100_FW539187_LABEL_w_1eklgnolb_LOW|": "low", + "|PDPR1H1HAW100_FW539187_LABEL_w_1eklgnolb_HIGH|": "high" } }, "peristaltic_orp_dosing": { "key": "w_1eo1s18s8", "type": "sensor", "conversion": { - "|PDPR1H1HAW100_FW539187_LABEL_w_1eo1s18s8_OFF|": "off", - "|PDPR1H1HAW100_FW539187_LABEL_w_1eo1s18s8_PROPORTIONAL|": "proportional", - "|PDPR1H1HAW100_FW539187_LABEL_w_1eo1s18s8_ON_OFF|": "on_off", - "|PDPR1H1HAW100_FW539187_LABEL_w_1eo1s18s8_TIMED|": "timed" + "|PDPR1H1HAW100_FW539187_LABEL_w_1eo1s18s8_OFF|": "off", + "|PDPR1H1HAW100_FW539187_LABEL_w_1eo1s18s8_PROPORTIONAL|": "proportional", + "|PDPR1H1HAW100_FW539187_LABEL_w_1eo1s18s8_ON_OFF|": "on / off", + "|PDPR1H1HAW100_FW539187_LABEL_w_1eo1s18s8_TIMED|": "timed" } }, - "ofa_orp_value": { - "key": "w_1eo1tui1d", - "type": "sensor" - }, "ph_calibration_type": { "key": "w_1eklh8gb7", "type": "sensor", "conversion": { - "|PDPR1H1HAW100_FW539187_LABEL_w_1eklh8gb7_OFF|": "off", - "|PDPR1H1HAW100_FW539187_LABEL_w_1eklh8gb7_REFERENCE|": "reference", - "|PDPR1H1HAW100_FW539187_LABEL_w_1eklh8gb7_1_POINT|": "1_point", - "|PDPR1H1HAW100_FW539187_LABEL_w_1eklh8gb7_2_POINTS|": "2_points" + "|PDPR1H1HAW100_FW539187_LABEL_w_1eklh8gb7_OFF|": "off", + "|PDPR1H1HAW100_FW539187_LABEL_w_1eklh8gb7_REFERENCE|": "reference", + "|PDPR1H1HAW100_FW539187_LABEL_w_1eklh8gb7_1_POINT|": "1 point", + "|PDPR1H1HAW100_FW539187_LABEL_w_1eklh8gb7_2_POINTS|": "2 points" } }, "ph_calibration_offset": { @@ -81,9 +73,9 @@ "key": "w_1eklh8i5t", "type": "sensor", "conversion": { - "|PDPR1H1HAW100_FW539187_LABEL_w_1eklh8i5t_OFF|": "off", - "|PDPR1H1HAW100_FW539187_LABEL_w_1eklh8i5t_REFERENCE|": "reference", - "|PDPR1H1HAW100_FW539187_LABEL_w_1eklh8i5t_1_POINT|": "1_point" + "|PDPR1H1HAW100_FW539187_LABEL_w_1eklh8i5t_OFF|": "off", + "|PDPR1H1HAW100_FW539187_LABEL_w_1eklh8i5t_REFERENCE|": "reference", + "|PDPR1H1HAW100_FW539187_LABEL_w_1eklh8i5t_1_POINT|": "1 point" } }, "orp_calibration_offset": { @@ -94,68 +86,84 @@ "key": "w_1eklhsase", "type": "sensor" }, - "pump_running": { + "pump_alarm": { "key": "w_1ekga097n", "type": "binary_sensor" }, - "ph_level_ok": { + "ph_level_alarm": { "key": "w_1eklf77pm", "type": "binary_sensor" }, - "orp_level_ok": { + "orp_level_alarm": { "key": "w_1eo04bcr2", "type": "binary_sensor" }, - "flow_rate_ok": { + "flow_rate_alarm": { "key": "w_1eo04nc5n", "type": "binary_sensor" }, - "alarm_relay": { + "relay_alarm": { "key": "w_1eklffdl0", "type": "binary_sensor" }, - "relay_aux1_ph": { + "relay_aux1": { "key": "w_1eoi2rv4h", "type": "binary_sensor" }, - "relay_aux2_orpcl": { + "relay_aux2": { "key": "w_1eoi2s16b", "type": "binary_sensor" }, - "ph_target": { + "alarm_ofa_ph": { + "key": "w_1eklfb73r", + "type": "binary_sensor" + }, + "ofa_ph_time": { + "key": "w_1eo1ttmft", + "type": "sensor" + }, + "alarm_ofa_orp": { + "key": "w_1eo1pabt6", + "type": "binary_sensor" + }, + "ofa_orp_time": { + "key": "w_1eo1tui1d", + "type": "sensor" + }, + "ph_target": { "key": "w_1ekeiqfat", "type": "number" }, - "orp_target": { + "orp_target": { "key": "w_1eklgnjk2", "type": "number" }, - "cl_target": { + "cl_target": { "key": "w_1eo1unpqb", "type": "number" }, - "stop_pool_dosing": { + "pause_dosing": { "key": "w_1emtltkel", "type": "switch" }, - "pump_detection": { + "pump_monitoring": { "key": "w_1eklft47q", "type": "switch" }, - "frequency_input": { + "frequency_input": { "key": "w_1eklft5qt", "type": "switch" }, - "water_meter_unit": { - "key": "w_1eklinki6", - "type": "select", - "options": { - "0": "PDPR1H1HAW100_FW539187_COMBO_w_1eklinki6_M_", - "1": "PDPR1H1HAW100_FW539187_COMBO_w_1eklinki6_LITER" - }, - "conversion": { - "PDPR1H1HAW100_FW539187_COMBO_w_1eklinki6_M_": "m³", - "PDPR1H1HAW100_FW539187_COMBO_w_1eklinki6_LITER": "L" - } - } + "water_meter_unit": { + "key": "w_1eklinki6", + "type": "select", + "options": { + "0": "PDPR1H1HAW100_FW539187_COMBO_w_1eklinki6_M_", + "1": "PDPR1H1HAW100_FW539187_COMBO_w_1eklinki6_LITER" + }, + "conversion": { + "PDPR1H1HAW100_FW539187_COMBO_w_1eklinki6_M_": "m3", + "PDPR1H1HAW100_FW539187_COMBO_w_1eklinki6_LITER": "L" + } + } } \ No newline at end of file diff --git a/src/pooldose/mock_client.py b/src/pooldose/mock_client.py index 0a16d29..5d92f67 100644 --- a/src/pooldose/mock_client.py +++ b/src/pooldose/mock_client.py @@ -1,11 +1,13 @@ """Mock client for SEKO Pooldose that uses JSON files instead of real devices.""" +# pylint: disable=too-many-instance-attributes,line-too-long,too-many-arguments,too-many-positional-arguments,duplicate-code + from __future__ import annotations import json import logging from pathlib import Path -from typing import Any, Dict, Optional, Tuple, Union +from typing import Any, Dict, List, Optional, Tuple, Union from pooldose.constants import get_default_device_info from pooldose.mappings.mapping_info import MappingInfo @@ -18,6 +20,20 @@ API_VERSION_SUPPORTED = "v1/" +class _MockRequestHandlerShim: # pylint: disable=too-few-public-methods + """Shim that forwards InstantValues.set_value calls to the mock client. + + Defined at module level so pylint can validate the class (docstring present) + and so the same instance can be reused by multiple callers in tests/demos. + """ + def __init__(self, mc: "MockPooldoseClient"): + self._mc = mc + + async def set_value(self, device_id, path, value, value_type): + """Forward the set_value call to the mock client implementation.""" + return await self._mc.set_value(device_id, path, value, value_type) + + class MockPooldoseClient: """ Mock client for SEKO Pooldose API that uses JSON files as data source. @@ -27,9 +43,11 @@ class MockPooldoseClient: def __init__( self, json_file_path: Union[str, Path], - *, + model_id: str, + fw_code: str, timeout: int = 30, - include_sensitive_data: bool = False + include_sensitive_data: bool = False, + inspect_payload: bool = True, ) -> None: """ Initialize the Mock Pooldose client. @@ -40,17 +58,30 @@ def __init__( include_sensitive_data: If True, include sensitive data in responses """ self.json_file_path = Path(json_file_path) + self.model_id = model_id + self.fw_code = fw_code self._timeout = timeout self._include_sensitive_data = include_sensitive_data self._mock_data = None self._device_key = None self._mapping_info = None + # If True, set_value will return (True, payload_json_str) for inspection + # Useful for demos/tests; default is True to make the mock easy to inspect + self._inspect_payload = inspect_payload + # Initialize device info with default values self.device_info = get_default_device_info() # Load data immediately - self._load_json_data() + try: + self._load_json_data() + except (FileNotFoundError, json.JSONDecodeError, ValueError): + # If loading fails here, mock client will report not connected later + _LOGGER.debug("Failed to load mock JSON data during init; will try on connect") + + # Keep last built payload for optional inspection + self._last_payload: Optional[str] = None def _load_json_data(self) -> None: """Load and parse the JSON data file.""" @@ -88,31 +119,17 @@ def _extract_device_info(self) -> None: # Extract serial number from device key (remove _DEVICE suffix) serial_number = self._device_key.replace('_DEVICE', '') - # Try to determine model and firmware from data keys - device_data = self._mock_data['devicedata'][self._device_key] - model = None - fw_code = None - - # Look for model/firmware pattern in keys - for key in device_data.keys(): - if key.startswith('PDPR1H1'): - parts = key.split('_') - if len(parts) >= 3: - model = parts[0] - fw_code = parts[1] - break - # Update device info self.device_info.update({ - "NAME": f"Mock {model or 'POOLDOSE'} Device", + "NAME": f"Mock {self.model_id or 'POOLDOSE'} Device", "SERIAL_NUMBER": serial_number, "DEVICE_ID": self._device_key, - "MODEL": model or "MOCK_MODEL", - "MODEL_ID": model or "MOCK_MODEL", + "MODEL": self.model_id or "MOCK_MODEL", + "MODEL_ID": self.model_id or "MOCK_MODEL", "FW_CODE": ( - fw_code.replace('FW', '') - if fw_code and fw_code.startswith('FW') - else fw_code or "MOCK_FW" + self.fw_code.replace('FW', '') + if self.fw_code and self.fw_code.startswith('FW') + else self.fw_code or "MOCK_FW" ), "API_VERSION": API_VERSION_SUPPORTED, "IP": "127.0.0.1", # Mock IP @@ -180,16 +197,23 @@ async def instant_values(self) -> Tuple[RequestStatus, Optional[InstantValues]]: device_data = self._mock_data['devicedata'][self._device_key] + if self.device_info["MODEL_ID"] == 'PDHC1H1HAR1V1' and self.device_info["FW_CODE"] == '539224': + # due to identifier issue in device firmware, use mapping prefix of PDPR1H1HAR1V0 + self.device_info["MODEL_ID"] = 'PDPR1H1HAR1V0' + # Filter out non-sensor data filtered_data = { k: v for k, v in device_data.items() - if k.startswith(self.device_info["MODEL_ID"]) and isinstance(v, dict) + if k.startswith(self.device_info["MODEL_ID"]) and isinstance(v, (dict, bool)) } instant_values = InstantValues( device_data=filtered_data, mapping=self._mapping_info.mapping, - prefix=f"{self.device_info['MODEL_ID']}_FW{self.device_info['FW_CODE']}_", + prefix=( + f"{self.device_info['MODEL_ID']}_FW" + f"{self.device_info['FW_CODE']}_" + ), device_id=self._device_key, request_handler=None # No real request handler in mock ) @@ -200,6 +224,11 @@ async def instant_values(self) -> Tuple[RequestStatus, Optional[InstantValues]]: _LOGGER.error("Error creating instant values: %s", e) return RequestStatus.UNKNOWN_ERROR, None + def get_last_payload(self) -> Optional[str]: + """Return the last built payload string (if any).""" + return self._last_payload + + async def instant_values_structured(self) -> Tuple[RequestStatus, Dict[str, Any]]: """ Get structured instant values from mock data. @@ -224,6 +253,44 @@ def is_connected(self) -> bool: """Check if mock client is 'connected' (has valid data).""" return self._mock_data is not None and self._device_key is not None + # Convenience setters to mirror PooldoseClient API -------------------- + async def set_switch(self, key: str, value: bool) -> bool: + """Set a switch by mapped name using the mock client. + + Returns True on success. The mock stores the last payload for inspection + and will optionally return the payload when `inspect_payload=True`. + """ + status, iv = await self.instant_values() + if status != RequestStatus.SUCCESS or iv is None: + return False + + # Attach module-level shim so InstantValues calls route to this mock. + # pylint: disable=protected-access + iv._request_handler = _MockRequestHandlerShim(self) + # pylint: enable=protected-access + result = await iv.set_switch(key, value) + return result + + async def set_number(self, key: str, value: Any) -> bool: + """Set a numeric mapped value using the mock client.""" + status, iv = await self.instant_values() + if status != RequestStatus.SUCCESS or iv is None: + return False + # pylint: disable=protected-access + iv._request_handler = _MockRequestHandlerShim(self) + # pylint: enable=protected-access + return await iv.set_number(key, value) + + async def set_select(self, key: str, value: Any) -> bool: + """Set a select option by mapped name using the mock client.""" + status, iv = await self.instant_values() + if status != RequestStatus.SUCCESS or iv is None: + return False + # pylint: disable=protected-access + iv._request_handler = _MockRequestHandlerShim(self) + # pylint: enable=protected-access + return await iv.set_select(key, value) + def reload_data(self) -> bool: """ Reload data from JSON file. @@ -257,3 +324,28 @@ def get_device_data(self) -> Optional[Dict[str, Any]]: if self._mock_data and self._device_key: return self._mock_data['devicedata'][self._device_key] return None + + async def set_value(self, device_id: str, path: str, value: Any, value_type: str) -> Union[bool, Tuple[bool, str]]: + """ + Mock setting a value: build the payload string (same shape as RequestHandler) and return it. + + Returns a tuple (success, payload_json_str). + """ + vt = value_type.upper() + payload_value: List[Dict[str, Any]] + if isinstance(value, (list, tuple)): + payload_value = [{"value": v, "type": vt} for v in value] + else: + payload_value = [{"value": value, "type": vt}] + + payload = {device_id: {path: payload_value}} + + payload_str = json.dumps(payload, ensure_ascii=False) + _LOGGER.info("Mock POST payload: %s", payload_str) + # Always store last payload for later inspection + self._last_payload = payload_str + if self._inspect_payload: + # Return the payload string for tests/demos that want inspection + return True, payload_str + # Default: behave like the real RequestHandler and return a boolean + return True diff --git a/src/pooldose/request_handler.py b/src/pooldose/request_handler.py index 0ab4779..a8dce2e 100644 --- a/src/pooldose/request_handler.py +++ b/src/pooldose/request_handler.py @@ -6,7 +6,7 @@ import re import socket import ssl -from typing import Any, Optional, Tuple, Union +from typing import Any, Optional, Tuple, Union, List, Dict import aiohttp @@ -32,7 +32,7 @@ class RequestHandler: # pylint: disable=too-many-instance-attributes Only softwareVersion, and apiversion are loaded from params.js. """ - def __init__(self, host: str, timeout: int = 10, *, websession: Optional[aiohttp.ClientSession] = None, use_ssl: bool = False, port: Optional[int] = None, ssl_verify: bool = True): # pylint: disable=too-many-arguments + def __init__(self, host: str, timeout: int = 10, *, websession: Optional[aiohttp.ClientSession] = None, use_ssl: bool = False, port: Optional[int] = None, ssl_verify: bool = True, debug_payload: bool = False): # pylint: disable=too-many-arguments self.host = host self.timeout = timeout self.use_ssl = use_ssl @@ -43,6 +43,8 @@ def __init__(self, host: str, timeout: int = 10, *, websession: Optional[aiohttp self.software_version = None self.api_version = None self._connected = False + self.debug_payload = debug_payload + self._last_payload: Optional[str] = None # External session from Home Assistant (or None) self._websession = websession # Configure SSL context @@ -102,6 +104,10 @@ def is_connected(self) -> bool: """Check if the handler is connected to the device.""" return self._connected + def get_last_payload(self) -> Optional[str]: + """Get the last payload sent to the device (if debug_payload is enabled).""" + return self._last_payload + def check_host_reachable(self) -> bool: """ Check if the host is reachable on the configured port. @@ -380,34 +386,21 @@ async def get_values_raw(self) -> Tuple[RequestStatus, Optional[Any]]: async def set_value(self, device_id: str, path: str, value: Any, value_type: str) -> bool: """ Asynchronously sets a value for a specific device and path using the API. - - Args: - device_id (str): The identifier of the device to set the value for. - path (str): The path within the device to set the value. - value (Any): The value to set. - value_type (str): The type of the value (e.g., "int", "float", "str"). - - Returns: - bool: True if the value was set successfully, False otherwise. - - Raises: - aiohttp.ClientError: If there is a client error during the request. - asyncio.TimeoutError: If the request times out. - - Logs: - Warnings for client errors and errors for timeout issues. """ url = self._build_url("/api/v1/DWI/setInstantValues") - payload = { - device_id: { - path: [ - { - "value": value, - "type": value_type.upper() - } - ] - } - } + vt = value_type.upper() + payload_value: List[Dict[str, Any]] + if isinstance(value, (list, tuple)): + payload_value = [{"value": v, "type": vt} for v in value] + else: + payload_value = [{"value": value, "type": vt}] + + payload = {device_id: {path: payload_value}} + + # Store payload for debugging if enabled + if self.debug_payload: + self._last_payload = json.dumps(payload) + _LOGGER.info("Sending payload: %s", self._last_payload) try: timeout_obj = aiohttp.ClientTimeout(total=self.timeout) connector = self._get_ssl_connector() diff --git a/src/pooldose/type_definitions.py b/src/pooldose/type_definitions.py index 84c430b..bd51124 100644 --- a/src/pooldose/type_definitions.py +++ b/src/pooldose/type_definitions.py @@ -91,4 +91,4 @@ class NetworkInfoDict(TypedDict, total=False): GROUPNAME: str # Constants -SUPPORTED_VALUE_TYPES: Final = Literal["sensor", "switch", "number", "binary_sensor", "select"] +SUPPORTED_VALUE_TYPES: Final = Literal["sensor", "switch", "number", "binary_sensor", "select"] # pylint: disable=invalid-name diff --git a/src/pooldose/values/__pycache__/instant_values.cpython-313.pyc b/src/pooldose/values/__pycache__/instant_values.cpython-313.pyc index 8071fe3..92a9be8 100644 Binary files a/src/pooldose/values/__pycache__/instant_values.cpython-313.pyc and b/src/pooldose/values/__pycache__/instant_values.cpython-313.pyc differ diff --git a/src/pooldose/values/instant_values.py b/src/pooldose/values/instant_values.py index e420b7b..6929798 100644 --- a/src/pooldose/values/instant_values.py +++ b/src/pooldose/values/instant_values.py @@ -3,34 +3,41 @@ import logging from typing import Any, Dict, Tuple, Union -from pooldose.request_handler import RequestHandler # pylint: disable=line-too-long,too-many-arguments,too-many-positional-arguments,too-many-locals,too-many-return-statements,too-many-branches,no-else-return,too-many-public-methods _LOGGER = logging.getLogger(__name__) class InstantValues: - """ - Provides dict-like access to instant values from the Pooldose device. - Values are dynamically loaded based on the mapping configuration. + """Manage instant device values and provide typed setters. + + This class wraps raw device data and a mapping configuration to expose + typed accessors (sensor, number, switch, select) and to send updates + back to the device using a RequestHandler. """ - def __init__(self, device_data: Dict[str, Any], mapping: Dict[str, Any], prefix: str, device_id: str, request_handler: RequestHandler): - """ - Initialize InstantValues. + def __init__( + self, + device_data: Dict[str, Any], + mapping: Dict[str, Any], + prefix: str, + device_id: str, + request_handler: Any, + ) -> None: + """Initialize InstantValues. Args: - device_data (Dict[str, Any]): Raw device data from API. - mapping (Dict[str, Any]): Mapping configuration. - prefix (str): Key prefix for device data lookup. - device_id (str): Device ID. - request_handler (RequestHandler): API request handler. + device_data: Raw device data from API. + mapping: Mapping configuration. + prefix: Key prefix for device data lookup. + device_id: Device ID. + request_handler: API request handler (or mock shim). """ - self._device_data = device_data # Raw format: {"PDPR1H1HAW100_FW539187_w_1eommf39k": {...}, ...} + self._device_data = device_data self._mapping = mapping self._prefix = prefix self._device_id = device_id - self._request_handler = request_handler + self._request_handler: Any = request_handler self._cache: Dict[str, Any] = {} def __getitem__(self, key: str) -> Any: @@ -43,8 +50,11 @@ def __getitem__(self, key: str) -> Any: return value async def __setitem__(self, key: str, value: Any) -> None: - """Allow dict-like async write access to instant values.""" - await self._set_value(key, value) + """ + Disallow direct setting via __setitem__ to enforce type-specific methods. + Use set_number, set_switch, or set_select instead. + """ + raise NotImplementedError("Use set_number, set_switch, or set_select for setting values.") def __contains__(self, key: str) -> bool: """Allow 'in' checks for available instant values.""" @@ -64,8 +74,6 @@ def to_structured_dict(self) -> Dict[str, Any]: Returns: Dict[str, Any]: Structured data with format: - { - "sensor": { "temperature": {"value": 25.5, "unit": "°C"}, "ph": {"value": 7.2, "unit": None} }, @@ -265,19 +273,29 @@ def _process_switch_value(self, raw_entry: Dict[str, Any], name: str) -> Union[b return bool(value) def _process_number_value(self, raw_entry: Dict[str, Any], name: str) -> Tuple[Any, Union[str, None], Any, Any, Any]: - """Process number value and return (value, unit, min, max, step) tuple.""" + """Process number value and return (value, unit, min, max, step) tuple. + If the mapping for this number type contains an 'attribute', use that as the value key instead of 'current'. + """ if not isinstance(raw_entry, dict): _LOGGER.warning("Invalid raw entry type for number '%s': expected dict, got %s", name, type(raw_entry)) return (None, None, None, None, None) - - value = raw_entry.get("current") + # Check for special field in mapping + attributes = self._mapping.get(name, {}) + value_key = attributes.get("field", "current") + value = raw_entry.get(value_key) abs_min = raw_entry.get("absMin") abs_max = raw_entry.get("absMax") resolution = raw_entry.get("resolution") + # Special handling for minT/maxT fields: split abs_min/abs_max range + if value_key == "minT" and isinstance(abs_max, (int, float)): + abs_max = abs_max / 2 + elif value_key == "maxT" and isinstance(abs_max, (int, float)) and isinstance(resolution, (int, float)): + abs_min = abs_max / 2 + resolution + # Get unit units = raw_entry.get("magnitude", [""]) - unit = units[0] if units and units[0].lower() not in ("undefined", "ph") else None + unit = units[0] if isinstance(units, (list, tuple)) and units and str(units[0]).lower() not in ("undefined", "ph") else None return (value, unit, abs_min, abs_max, resolution) @@ -310,12 +328,11 @@ def _process_select_value(self, raw_entry: Dict[str, Any], attributes: Dict[str, return value async def set_number(self, key: str, value: Any) -> bool: - """Set number value with validation.""" + """Set number value with validation and device update.""" if key not in self._mapping or self._mapping[key].get("type") != "number": _LOGGER.warning("Key '%s' is not a valid number", key) return False - # Get current number info for validation current_info = self[key] if current_info is None: _LOGGER.warning("Cannot get current info for number '%s'", key) @@ -323,14 +340,10 @@ async def set_number(self, key: str, value: Any) -> bool: try: _, _, min_val, max_val, step = current_info - - # Validate range (only if min/max are defined) if min_val is not None and max_val is not None: if not min_val <= value <= max_val: _LOGGER.warning("Value %s is out of range for %s. Valid range: %s - %s", value, key, min_val, max_val) return False - - # Validate step (for float values) if isinstance(value, float) and step and min_val is not None: epsilon = 1e-9 n = (value - min_val) / step @@ -338,119 +351,108 @@ async def set_number(self, key: str, value: Any) -> bool: _LOGGER.warning("Value %s is not a valid step for %s. Step: %s", value, key, step) return False - success = await self._set_value(key, value) - if success: - # Clear cache to force refresh of value + attributes = self._mapping.get(key, {}) + key_device = attributes.get("key", key) + full_key = f"{self._prefix}{key_device}" + field = attributes.get("field") + if field in ("minT", "maxT"): + # Safe call with Dict[str, Any] guaranteed + corresponding = self._get_corresponding_value(key, field, attributes) + min_val_set, max_val_set = (value, corresponding) if field == "minT" else (corresponding, value) + if min_val_set is None or max_val_set is None: + _LOGGER.warning("Cannot set both minT and maxT: missing value for one corresponding field.") + return False + result = await self._request_handler.set_value(self._device_id, full_key, [min_val_set, max_val_set], "NUMBER") + else: + if not isinstance(value, (int, float)): + _LOGGER.warning("Invalid type for number '%s': expected int/float, got %s", key, type(value)) + return False + result = await self._request_handler.set_value(self._device_id, full_key, value, "NUMBER") + if result: self._cache.pop(key, None) - return success - - except (TypeError, ValueError, IndexError) as err: - _LOGGER.warning("Error validating number '%s': %s", key, err) + return result + except (TypeError, ValueError, IndexError, KeyError, AttributeError) as err: + _LOGGER.warning("Error setting number '%s': %s", key, err) return False async def set_switch(self, key: str, value: bool) -> bool: - """Set switch value.""" + """Set switch value with validation and device update.""" if key not in self._mapping or self._mapping[key].get("type") != "switch": _LOGGER.warning("Key '%s' is not a valid switch", key) return False - - success = await self._set_value(key, value) - if success: - # Clear cache to force refresh of value - self._cache.pop(key, None) - return success + try: + attributes = self._mapping.get(key, {}) # Use empty dict as default to avoid None + key_device = attributes.get("key", key) + full_key = f"{self._prefix}{key_device}" + if not isinstance(value, bool): + _LOGGER.warning("Invalid type for switch '%s': expected bool, got %s", key, type(value)) + return False + value_str = "O" if value else "F" + result = await self._request_handler.set_value(self._device_id, full_key, value_str, "STRING") + if result: + self._cache.pop(key, None) + return result + except (KeyError, TypeError, AttributeError, ValueError) as err: + _LOGGER.warning("Error setting switch '%s': %s", key, err) + return False async def set_select(self, key: str, value: Any) -> bool: - """Set select value with validation.""" + """Set select value with validation and device update.""" if key not in self._mapping or self._mapping[key].get("type") != "select": _LOGGER.warning("Key '%s' is not a valid select", key) return False - - # Validate against available converted values (not raw options) - mapping_entry = self._mapping[key] - options = mapping_entry.get("options", {}) - conversion = mapping_entry.get("conversion", {}) - - # Build list of valid display values - valid_values = [] - for _, option_text in options.items(): - if option_text in conversion: - valid_values.append(conversion[option_text]) - else: - valid_values.append(option_text) - - if value not in valid_values: - _LOGGER.warning("Value '%s' is not a valid option for %s. Valid options: %s", value, key, valid_values) - return False - - success = await self._set_value(key, value) - if success: - # Clear cache to force refresh of value - self._cache.pop(key, None) - return success - - async def _set_value(self, name: str, value: Any) -> bool: - """ - Internal helper to set a value on the device using the request handler. - Returns False and logs a warning on error. - """ try: - attributes = self._mapping.get(name) - if not attributes: - _LOGGER.warning("Key '%s' not found in mapping", name) + mapping_entry = self._mapping[key] + options = mapping_entry.get("options", {}) + conversion = mapping_entry.get("conversion", {}) + valid_values = [conversion[option_text] if option_text in conversion else option_text for _, option_text in options.items()] + if value not in valid_values: + _LOGGER.warning("Value '%s' is not a valid option for %s. Valid options: %s", value, key, valid_values) return False - - entry_type = attributes.get("type") - key = attributes.get("key", name) - full_key = f"{self._prefix}{key}" - - # Convert value back to device format if needed + key_device = mapping_entry.get("key", key) + full_key = f"{self._prefix}{key_device}" device_value = value - - # Handle different types - if entry_type == "number": - if not isinstance(device_value, (int, float)): - _LOGGER.warning("Invalid type for number '%s': expected int/float, got %s", name, type(device_value)) - return False - result = await self._request_handler.set_value(self._device_id, full_key, device_value, "NUMBER") - - elif entry_type == "switch": - if not isinstance(value, bool): - _LOGGER.warning("Invalid type for switch '%s': expected bool, got %s", name, type(value)) - return False - value_str = "O" if value else "F" # O = True, F = False - result = await self._request_handler.set_value(self._device_id, full_key, value_str, "STRING") - - elif entry_type == "select": - # For selects, we need to reverse the conversion process - if "conversion" in attributes and "options" in attributes: - # Find the option key for the given display value - conversion = attributes["conversion"] - options = attributes["options"] - - # Reverse lookup: display value -> option text -> option key + if "conversion" in mapping_entry and "options" in mapping_entry: + for option_key, option_text in options.items(): + if option_text in conversion and conversion[option_text] == value: + device_value = int(option_key) + break + else: for option_key, option_text in options.items(): - if option_text in conversion and conversion[option_text] == value: + if option_text == value: device_value = int(option_key) break else: - # Direct lookup if no conversion chain - for option_key, option_text in options.items(): - if option_text == value: - device_value = int(option_key) - break - else: - _LOGGER.warning("Value '%s' not found in options for '%s'", value, name) - return False - - result = await self._request_handler.set_value(self._device_id, full_key, device_value, "NUMBER") - - else: - _LOGGER.warning("Unsupported type '%s' for setting value '%s'", entry_type, name) - return False - + _LOGGER.warning("Value '%s' not found in options for '%s'", value, key) + return False + result = await self._request_handler.set_value(self._device_id, full_key, device_value, "NUMBER") + if result: + self._cache.pop(key, None) return result - except (KeyError, TypeError, AttributeError, ValueError) as err: - _LOGGER.warning("Error setting value '%s': %s", name, err) + _LOGGER.warning("Error setting select '%s': %s", key, err) return False + + def _get_corresponding_value(self, name: str, field: str, attributes: Dict[str, Any]) -> Any: + """ + Returns the value of the corresponding field (minT/maxT) for the given mapping. + """ + corresponding_field = "maxT" if field == "minT" else "minT" + # Search for the mapping entry with the corresponding field + for k, v in self._mapping.items(): + if v.get("type") == "number" and v.get("field") == corresponding_field and v.get("key") == attributes.get("key"): + val = self[k] + return val[0] if isinstance(val, tuple) else val + # Fallback: get from raw device entry if not found in mapping + raw_entry = self._find_device_entry(name) + if raw_entry is None: + return None + + if corresponding_field in raw_entry: + return raw_entry[corresponding_field] + # Final fallback: use absMin for minT, absMax for maxT + if corresponding_field == "minT": + return raw_entry.get("absMin") + elif corresponding_field == "maxT": + return raw_entry.get("absMax") + return None diff --git a/tests/test_mock_client_set_value.py b/tests/test_mock_client_set_value.py new file mode 100644 index 0000000..1b3d970 --- /dev/null +++ b/tests/test_mock_client_set_value.py @@ -0,0 +1,216 @@ +"""Tests for MockPooldoseClient set_value and convenience setters. + +These tests exercise payload shaping for arrays, +switch string handling, and the convenience setters that wrap +`InstantValues` functionality. + +Disable a couple of pylint complexity checks for this test module as the +payload-inspection test intentionally exercises multiple branches and +nested loops to validate different payload shapes. + +""" + +# pylint: disable=too-many-branches,too-many-nested-blocks + +import asyncio +import json +import tempfile +from pathlib import Path +from typing import Dict, Any + +from pooldose.mock_client import MockPooldoseClient +from pooldose.request_status import RequestStatus + + +# Create test data inline +TEST_DATA = { + "devicedata": { + "012500004415_DEVICE": { + "deviceInfo": { + "dwi_status": "ok", + "modbus_status": "on" + }, + "collapsed_bar": [], + "PDPR1H1HAR1V0_FW539224_w_test_ph": { + "visible": True, + "alarm": False, + "current": 7.3, + "resolution": 0.1, + "magnitude": ["pH", "PH"], + "absMin": 0, + "absMax": 14, + "minT": 6, + "maxT": 8 + }, + "PDPR1H1HAR1V0_FW539224_w_1f2jpqa6e": { + "current": "F" + }, + "PDHC1H1HAR1V1_FW539224_w_1g1kvba4g": { + "visible": True, + "alarm": False, + "current": 7.0, + "resolution": 0.1, + "magnitude": ["pH", "PH"], + "absMin": 0, + "absMax": 14, + "minT": 6.0, + "maxT": 8.0 + } + } + } +} + + +def create_temp_json_file(data: Dict[str, Any]) -> Path: + """Create a temporary JSON file with test data.""" + with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as temp_file: + json.dump(data, temp_file, indent=2) + return Path(temp_file.name) + + +def test_set_value_number_single() -> None: + """Verify single NUMBER value is encoded as an array with one object.""" + json_path = create_temp_json_file(TEST_DATA) + try: + client = MockPooldoseClient(json_path, model_id="PDPR1H1HAR1V0", fw_code="539224") + device_id = str(client.device_info["DEVICE_ID"]) # type: ignore[arg-type] + result = asyncio.run( + client.set_value(device_id, "some/widget", 7.5, "NUMBER") # type: ignore[arg-type] + ) + assert result is not False + + # Get payload from mock client + if isinstance(result, tuple): + success, payload_str = result + assert success is True + payload = json.loads(payload_str) + else: + # Get last payload if result is just bool + payload_str_optional = client.get_last_payload() + assert payload_str_optional is not None + payload = json.loads(payload_str_optional) + + assert device_id in payload + assert "some/widget" in payload[device_id] + entries = payload[device_id]["some/widget"] + assert isinstance(entries, list) + assert len(entries) == 1 + assert entries[0]["value"] == 7.5 + assert entries[0]["type"] == "NUMBER" + finally: + json_path.unlink() + + +def test_set_value_number_array() -> None: + """Verify NUMBER arrays are encoded as lists of value objects.""" + json_path = create_temp_json_file(TEST_DATA) + try: + client = MockPooldoseClient(json_path, model_id="PDPR1H1HAR1V0", fw_code="539224") + device_id = str(client.device_info["DEVICE_ID"]) # type: ignore[arg-type] + result = asyncio.run( + client.set_value(device_id, "some/widget", [5.5, 8.0], "NUMBER") # type: ignore[arg-type] # pylint: disable=line-too-long + ) + assert result is not False + + # Get payload from mock client + if isinstance(result, tuple): + success, payload_str = result + assert success is True + payload = json.loads(payload_str) + else: + # Get last payload if result is just bool + payload_str = client.get_last_payload() # type: ignore[assignment] + assert payload_str is not None + payload = json.loads(payload_str) + + entries = payload[device_id]["some/widget"] + assert isinstance(entries, list) and len(entries) == 2 + assert entries[0]["value"] == 5.5 + assert entries[1]["value"] == 8.0 + assert all(e["type"] == "NUMBER" for e in entries) + finally: + json_path.unlink() + + +def test_set_value_string_switch() -> None: + """Verify switch string payloads are sent as arrays.""" + json_path = create_temp_json_file(TEST_DATA) + try: + client = MockPooldoseClient(json_path, model_id="PDPR1H1HAR1V0", fw_code="539224") + device_id = str(client.device_info["DEVICE_ID"]) # type: ignore[arg-type] + result = asyncio.run( + client.set_value(device_id, "some/widget", "test", "STRING") # type: ignore[arg-type] + ) + assert result is not False + + # Get payload from mock client + if isinstance(result, tuple): + success, payload_str = result + assert success is True + payload = json.loads(payload_str) + else: + # Get last payload if result is just bool + payload_str = client.get_last_payload() # type: ignore[assignment] + assert payload_str is not None + payload = json.loads(payload_str) + + entries = payload[device_id]["some/widget"] + assert isinstance(entries, list) + assert len(entries) == 1 + assert entries[0]["value"] == "test" + assert entries[0]["type"] == "STRING" + finally: + json_path.unlink() + + +def test_switch_setter_boolean_only() -> None: + """Ensure switch setter enforces boolean-only input via the client convenience method.""" + json_path = create_temp_json_file(TEST_DATA) + try: + client = MockPooldoseClient(json_path, model_id="PDPR1H1HAR1V0", fw_code="539224") + # Connect mock to initialize mapping info + connect_status = asyncio.run(client.connect()) + assert connect_status == RequestStatus.SUCCESS + # Non-boolean should be rejected + try: + result = asyncio.run(client.set_switch("pause_dosing", "O")) # type: ignore + assert result is False + except TypeError: + # Type error is expected for non-boolean input + pass + # Boolean should be accepted (mock returns truthy value or tuple) + result = asyncio.run(client.set_switch("pause_dosing", True)) + assert result is not False + finally: + json_path.unlink() + + +def test_set_number_lower_upper_pairing() -> None: + """Test that number setters work with mock client.""" + json_path = create_temp_json_file(TEST_DATA) + try: + client = MockPooldoseClient(json_path, model_id="PDHC1H1HAR1V1", fw_code="539224") + # Connect mock to initialize mapping info + connect_status = asyncio.run(client.connect()) + assert connect_status == RequestStatus.SUCCESS + + # Just verify that the mock returns truthy values for set operations + # Since this is a mock, the exact payload format is less important than + # ensuring the interface works correctly + result = asyncio.run(client.set_value("012500004415_DEVICE", "test/path", 6.5, "NUMBER")) + assert result is not False + + # Check that we get a valid payload string + last_payload = client.get_last_payload() + assert last_payload is not None + # Verify the payload contains the expected array format + payload = json.loads(last_payload) + assert "012500004415_DEVICE" in payload + assert "test/path" in payload["012500004415_DEVICE"] + entries = payload["012500004415_DEVICE"]["test/path"] + assert isinstance(entries, list) + assert len(entries) == 1 + assert entries[0]["value"] == 6.5 + assert entries[0]["type"] == "NUMBER" + finally: + json_path.unlink() diff --git a/tests/test_request_handler.py b/tests/test_request_handler.py index bc751b1..de4c275 100644 --- a/tests/test_request_handler.py +++ b/tests/test_request_handler.py @@ -34,30 +34,37 @@ class TestSessionManagement: async def test_external_session_usage(self): """Test that when an external session is provided, it's used for requests.""" # Create mock external session - external_session = AsyncMock() + external_session = MagicMock() external_session.close = AsyncMock() - # Set up a mock response - mock_response = AsyncMock() - mock_response.__aenter__ = AsyncMock(return_value=mock_response) - mock_response.__aexit__ = AsyncMock() - mock_response.raise_for_status = AsyncMock() + # Create a mock response that simulates a successful API response + mock_response = MagicMock() + mock_response.status = 200 + mock_response.raise_for_status = MagicMock() # Not async mock_response.json = AsyncMock(return_value={"test": "data"}) - external_session.get = MagicMock(return_value=mock_response) - # Create handler with external session + # Create the handler with external session handler = RequestHandler("192.168.1.1", websession=external_session) - # Make a request - status, data = await handler.get_debug_config() + # Mock the behavior of the context manager to return our mock response + # We patch the method at the exact point it's used in the code + with patch.object(handler, '_get_session', return_value=(external_session, False)): + with patch.object(external_session, 'get') as mock_get: + # Configure mock_get to act as an async context manager + async_cm = AsyncMock() + async_cm.__aenter__.return_value = mock_response + mock_get.return_value = async_cm + + # Make the request + status, data = await handler.get_debug_config() - # Verify the request was made with the external session - external_session.get.assert_called_once() - assert status == RequestStatus.SUCCESS - assert data == {"test": "data"} + # Verify the request was made with the external session + mock_get.assert_called_once() + assert status == RequestStatus.SUCCESS + assert data == {"test": "data"} # type: ignore - # Verify session was not closed - external_session.close.assert_not_awaited() + # Verify session was not closed + external_session.close.assert_not_awaited() @pytest.mark.asyncio async def test_internal_session_creation(self): @@ -67,26 +74,29 @@ async def test_internal_session_creation(self): # Mock ClientSession to track its creation and mock response with patch('aiohttp.ClientSession') as mock_session_class: - # Setup mock session with response - mock_session_instance = AsyncMock() - mock_response = AsyncMock() - mock_response.__aenter__ = AsyncMock(return_value=mock_response) - mock_response.__aexit__ = AsyncMock() - mock_response.raise_for_status = AsyncMock() - mock_response.json = AsyncMock(return_value={"test": "data"}) - - # Configure the session mock - mock_session_instance.get = MagicMock(return_value=mock_response) + # Set up a mock session instance and response + mock_session_instance = MagicMock() mock_session_instance.close = AsyncMock() mock_session_class.return_value = mock_session_instance - # Make a request that should create an internal session + # Create a mock response that simulates a successful API response + mock_response = MagicMock() + mock_response.status = 200 + mock_response.raise_for_status = MagicMock() # Not async + mock_response.json = AsyncMock(return_value={"test": "data"}) + + # Set up the get method to return an async context manager + async_cm = AsyncMock() + async_cm.__aenter__.return_value = mock_response + mock_session_instance.get.return_value = async_cm + + # Make the request that should create an internal session status, data = await handler.get_debug_config() # Verify a new session was created mock_session_class.assert_called_once() assert status == RequestStatus.SUCCESS - assert data == {"test": "data"} + assert data == {"test": "data"} # type: ignore # Verify session was closed mock_session_instance.close.assert_awaited_once() @@ -117,7 +127,7 @@ async def mock_get_debug_config(self): # Verify we got the expected result assert status == RequestStatus.SUCCESS - assert data == {"test": "data"} + assert data == {"test": "data"} # type: ignore # Verify the session was closed mock_session.close.assert_awaited_once() @@ -146,7 +156,7 @@ async def mock_get_debug_config(self): # Verify we got the expected result assert status == RequestStatus.SUCCESS - assert data == {"test": "data"} + assert data == {"test": "data"} # type: ignore # Verify the session was not closed (since it's an external session) external_session.close.assert_not_awaited() diff --git a/tests/test_ssl_support.py b/tests/test_ssl_support.py index 705785e..169f676 100644 --- a/tests/test_ssl_support.py +++ b/tests/test_ssl_support.py @@ -176,7 +176,8 @@ async def test_client_passes_ssl_params_to_handler(self, mock_handler_class): websession=None, use_ssl=True, port=8443, - ssl_verify=False + ssl_verify=False, + debug_payload=False ) assert status == RequestStatus.HOST_UNREACHABLE @@ -198,7 +199,8 @@ async def test_client_default_ssl_params_to_handler(self, mock_handler_class): websession=None, use_ssl=False, port=None, - ssl_verify=True + ssl_verify=True, + debug_payload=False ) assert status == RequestStatus.HOST_UNREACHABLE