├── stone_connect ├── py.typed ├── exceptions.py ├── __init__.py ├── models.py └── client.py ├── examples ├── __init__.py └── basic_usage.py ├── LICENSE ├── Makefile ├── README.md ├── .gitignore ├── pyproject.toml └── tests ├── test_temperature_validation.py ├── test_modes.py └── test_api.py /stone_connect/py.typed: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/__init__.py: -------------------------------------------------------------------------------- 1 | """Stone Connect Heater examples.""" 2 | 3 | 4 | def main(): 5 | """Entry point for basic usage example.""" 6 | import asyncio 7 | 8 | from examples.basic_usage import main as basic_main 9 | 10 | asyncio.run(basic_main()) 11 | 12 | 13 | if __name__ == "__main__": 14 | main() 15 | -------------------------------------------------------------------------------- /stone_connect/exceptions.py: -------------------------------------------------------------------------------- 1 | """Stone Connect Heater exceptions.""" 2 | 3 | 4 | class StoneConnectError(Exception): 5 | """Base exception for Stone Connect Heater errors.""" 6 | 7 | 8 | class StoneConnectConnectionError(StoneConnectError): 9 | """Connection-related errors.""" 10 | 11 | 12 | class StoneConnectAuthenticationError(StoneConnectError): 13 | """Authentication-related errors.""" 14 | 15 | 16 | class StoneConnectAPIError(StoneConnectError): 17 | """API-related errors.""" 18 | 19 | 20 | class StoneConnectValidationError(StoneConnectError): 21 | """Validation-related errors.""" 22 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright 2025 Tomas Bedrich 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /stone_connect/__init__.py: -------------------------------------------------------------------------------- 1 | """Stone Connect Heater Python Library. 2 | 3 | Async Python library for controlling Stone Connect WiFi electric heaters. 4 | """ 5 | 6 | from stone_connect.client import StoneConnectHeater 7 | from stone_connect.exceptions import ( 8 | StoneConnectAPIError, 9 | StoneConnectAuthenticationError, 10 | StoneConnectConnectionError, 11 | StoneConnectError, 12 | StoneConnectValidationError, 13 | ) 14 | from stone_connect.models import ( 15 | Info, 16 | OperationMode, 17 | Schedule, 18 | ScheduleDay, 19 | ScheduleSlot, 20 | Status, 21 | UseMode, 22 | parse_timestamp, 23 | ) 24 | 25 | __version__ = "0.1.1" 26 | __all__ = [ 27 | "StoneConnectHeater", 28 | "Info", 29 | "Status", 30 | "Schedule", 31 | "OperationMode", 32 | "ScheduleDay", 33 | "ScheduleSlot", 34 | "UseMode", 35 | "parse_timestamp", 36 | "StoneConnectError", 37 | "StoneConnectConnectionError", 38 | "StoneConnectAuthenticationError", 39 | "StoneConnectAPIError", 40 | "StoneConnectValidationError", 41 | ] 42 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: help install test lint format type-check clean build docs 2 | .DEFAULT_GOAL := help 3 | 4 | help: ## Show this help message 5 | @echo "Available commands:" 6 | @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}' 7 | 8 | install: ## Install dependencies for development 9 | uv sync --dev 10 | 11 | test: ## Run all tests 12 | uv run pytest 13 | 14 | test-cov: ## Run tests with coverage 15 | uv run pytest --cov=stone_connect --cov-report=html --cov-report=term 16 | 17 | lint: ## Run linting 18 | uv run ruff check stone_connect tests examples 19 | 20 | format: ## Format code 21 | uv run ruff format stone_connect tests examples 22 | 23 | type-check: ## Run type checking 24 | uv run mypy stone_connect 25 | 26 | check: lint type-check ## Run all checks (lint + type) 27 | 28 | clean: ## Clean build artifacts 29 | rm -rf dist/ build/ *.egg-info/ 30 | rm -rf .pytest_cache .coverage htmlcov/ 31 | find . -type d -name __pycache__ -exec rm -rf {} + 32 | find . -type f -name "*.pyc" -delete 33 | 34 | build: clean ## Build the package 35 | uv build 36 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Stone Connect Heater Python Library 2 | 3 | An async Python library for controlling Stone Connect WiFi electric heaters via their local HTTPS API. 4 | 5 | ## Features 6 | 7 | - **Async/await support** - Built with modern Python async patterns 8 | - **Local API communication** - Direct connection to your heater without cloud dependency 9 | - **Comprehensive control** - Temperature, operation modes, schedules, and more 10 | - **Type safety** - Full type hints and data validation 11 | - **Easy integration** - Simple context manager interface 12 | - **Robust error handling** - Detailed exception hierarchy 13 | - **Well tested** - Comprehensive test suite with high coverage 14 | 15 | ## Supported Operations 16 | 17 | - 🌡️ **Temperature Control** - Set target temperature and read current values 18 | - 🔥 **Operation Modes** - Manual, Comfort, Eco, Antifreeze, Schedule, Boost, and power modes 19 | - 📅 **Schedule Information** - Read weekly heating schedules 20 | - 📊 **Status Monitoring** - Heater status and sensor readings 21 | - ℹ️ **Device Information** - Hardware details, firmware version, network info 22 | 23 | ## Installation 24 | 25 | ```bash 26 | pip install stone-connect 27 | ``` 28 | 29 | ### Development Installation 30 | 31 | ```bash 32 | git clone https://github.com/tomasbedrich/stone-connect.git 33 | cd stone-connect 34 | pip install -e ".[dev]" 35 | ``` 36 | 37 | ## Quick Start 38 | 39 | ```python 40 | import asyncio 41 | from stone_connect import StoneConnectHeater, OperationMode 42 | 43 | async def main(): 44 | # Connect to your heater (replace with your heater's IP) 45 | async with StoneConnectHeater("192.168.1.100") as heater: 46 | # Get device information 47 | info = await heater.get_info() 48 | print(f"Connected to: {info.appliance_name}") 49 | 50 | # Get current status 51 | status = await heater.get_status() 52 | print(f"Current temperature: {status.current_temperature}°C") 53 | print(f"Target temperature: {status.set_point}°C") 54 | 55 | # Set temperature to 22°C in manual mode 56 | await heater.set_temperature(22.0, OperationMode.MANUAL) 57 | 58 | # Switch to comfort mode 59 | await heater.set_operation_mode(OperationMode.COMFORT) 60 | 61 | if __name__ == "__main__": 62 | asyncio.run(main()) 63 | ``` 64 | 65 | ## Disclaimer 66 | 67 | This library is not officially associated with Stone Connect or any heater manufacturer. It is developed through reverse engineering of the public API for personal and educational use. Use at your own risk. 68 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | #poetry.lock 103 | 104 | # pdm 105 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 106 | #pdm.lock 107 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 108 | # in version control. 109 | # https://pdm.fming.dev/#use-with-ide 110 | .pdm.toml 111 | 112 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 113 | __pypackages__/ 114 | 115 | # Celery stuff 116 | celerybeat-schedule 117 | celerybeat.pid 118 | 119 | # SageMath parsed files 120 | *.sage.py 121 | 122 | # Environments 123 | .env 124 | .venv 125 | env/ 126 | venv/ 127 | ENV/ 128 | env.bak/ 129 | venv.bak/ 130 | 131 | # Spyder project settings 132 | .spyderproject 133 | .spyproject 134 | 135 | # Rope project settings 136 | .ropeproject 137 | 138 | # mkdocs documentation 139 | /site 140 | 141 | # mypy 142 | .mypy_cache/ 143 | .dmypy.json 144 | dmypy.json 145 | 146 | # Pyre type checker 147 | .pyre/ 148 | 149 | # pytype static type analyzer 150 | .pytype/ 151 | 152 | # Cython debug symbols 153 | cython_debug/ 154 | 155 | # PyCharm 156 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 157 | # be added to the global gitignore or merged into this project gitignore. For a PyCharm 158 | # project, it is recommended to ignore entire .idea folder. 159 | #.idea/ 160 | 161 | # UV 162 | .uv 163 | 164 | # IDE 165 | .vscode/ 166 | .idea/ 167 | 168 | # OS 169 | .DS_Store 170 | Thumbs.db 171 | 172 | # Test artifacts 173 | .coverage 174 | htmlcov/ 175 | .pytest_cache/ 176 | 177 | # Build artifacts 178 | dist/ 179 | build/ 180 | *.egg-info/ 181 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["hatchling"] 3 | build-backend = "hatchling.build" 4 | 5 | [project] 6 | name = "stone-connect" 7 | version = "0.1.1" 8 | description = "Async Python library for controlling Stone Connect WiFi electric heaters" 9 | readme = "README.md" 10 | requires-python = ">=3.8" 11 | license = {text = "MIT"} 12 | authors = [ 13 | {name = "Tomas Bedrich", email = "ja@tbedrich.cz"}, 14 | ] 15 | keywords = ["stone", "connect", "heater", "wifi", "smart", "home", "automation"] 16 | classifiers = [ 17 | "Development Status :: 4 - Beta", 18 | "Intended Audience :: Developers", 19 | "License :: OSI Approved :: MIT License", 20 | "Operating System :: OS Independent", 21 | "Programming Language :: Python :: 3", 22 | "Programming Language :: Python :: 3.8", 23 | "Programming Language :: Python :: 3.9", 24 | "Programming Language :: Python :: 3.10", 25 | "Programming Language :: Python :: 3.11", 26 | "Programming Language :: Python :: 3.12", 27 | "Programming Language :: Python :: 3.13", 28 | "Topic :: Home Automation", 29 | "Topic :: Software Development :: Libraries :: Python Modules", 30 | ] 31 | dependencies = [ 32 | "aiohttp>=3.8.0", 33 | ] 34 | 35 | [project.optional-dependencies] 36 | dev = [ 37 | "pytest>=7.0.0", 38 | "pytest-asyncio>=0.21.0", 39 | "pytest-cov>=4.0.0", 40 | "ruff>=0.1.0", 41 | "mypy>=1.0.0", 42 | ] 43 | examples = [ 44 | "asyncio", 45 | ] 46 | 47 | [project.urls] 48 | Homepage = "https://github.com/tomasbedrich/stone-connect" 49 | Documentation = "https://github.com/tomasbedrich/stone-connect#readme" 50 | Repository = "https://github.com/tomasbedrich/stone-connect.git" 51 | Issues = "https://github.com/tomasbedrich/stone-connect/issues" 52 | 53 | [project.scripts] 54 | stone-connect-example = "examples.basic_usage:main" 55 | 56 | [tool.hatch.build.targets.wheel] 57 | packages = ["stone_connect"] 58 | 59 | [tool.ruff] 60 | line-length = 88 61 | target-version = "py38" 62 | src = ["stone_connect", "tests", "examples"] 63 | 64 | [tool.ruff.lint] 65 | select = [ 66 | "E", # pycodestyle errors 67 | "W", # pycodestyle warnings 68 | "F", # pyflakes 69 | "I", # isort 70 | "B", # flake8-bugbear 71 | "C4", # flake8-comprehensions 72 | "UP", # pyupgrade 73 | "ARG001", # unused-function-argument 74 | "SIM118", # in-dict-keys 75 | "ICN001", # unconventional-import-alias 76 | ] 77 | ignore = [ 78 | "E501", # line too long, handled by formatter 79 | "B008", # do not perform function calls in argument defaults 80 | "W191", # indentation contains tabs 81 | "B904", # raise-without-from-inside-except 82 | ] 83 | 84 | [tool.ruff.lint.per-file-ignores] 85 | "tests/*" = ["ARG001"] 86 | "examples/*" = ["ARG001"] 87 | 88 | [tool.ruff.lint.isort] 89 | known-first-party = ["stone_connect"] 90 | 91 | [tool.ruff.format] 92 | quote-style = "double" 93 | indent-style = "space" 94 | skip-magic-trailing-comma = false 95 | line-ending = "auto" 96 | 97 | [tool.mypy] 98 | python_version = "3.8" 99 | check_untyped_defs = true 100 | disallow_any_generics = true 101 | disallow_incomplete_defs = true 102 | disallow_untyped_defs = true 103 | no_implicit_optional = true 104 | warn_redundant_casts = true 105 | warn_unused_ignores = true 106 | 107 | [[tool.mypy.overrides]] 108 | module = "tests.*" 109 | disallow_untyped_defs = false 110 | 111 | [tool.pytest.ini_options] 112 | testpaths = ["tests"] 113 | python_files = "test_*.py" 114 | python_classes = "Test*" 115 | python_functions = "test_*" 116 | addopts = [ 117 | "--strict-markers", 118 | "--strict-config", 119 | "--cov=stone_connect", 120 | "--cov-report=term-missing", 121 | "--cov-report=html", 122 | "--cov-report=xml", 123 | ] 124 | asyncio_mode = "auto" 125 | markers = [ 126 | "unit: Unit tests", 127 | "integration: Integration tests", 128 | "slow: Slow tests", 129 | ] 130 | 131 | [tool.coverage.run] 132 | source = ["stone_connect"] 133 | branch = true 134 | 135 | [tool.coverage.report] 136 | exclude_lines = [ 137 | "pragma: no cover", 138 | "def __repr__", 139 | "if self.debug:", 140 | "if settings.DEBUG", 141 | "raise AssertionError", 142 | "raise NotImplementedError", 143 | "if 0:", 144 | "if __name__ == .__main__.:", 145 | "class .*\\bProtocol\\):", 146 | "@(abc\\.)?abstractmethod", 147 | ] 148 | 149 | [dependency-groups] 150 | dev = [ 151 | "build>=1.2.2.post1", 152 | "mypy>=1.14.1", 153 | "pytest>=8.3.5", 154 | "pytest-asyncio>=0.24.0", 155 | "pytest-cov>=5.0.0", 156 | "ruff>=0.12.0", 157 | "twine>=6.1.0", 158 | ] 159 | -------------------------------------------------------------------------------- /examples/basic_usage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | Example script demonstrating Stone Connect Heater API usage. 4 | 5 | This script shows how to use the stone_connect_heater library to: 6 | - Connect to a heater 7 | - Read device information and status 8 | - Control temperature and operation mode 9 | - Handle errors properly 10 | 11 | Usage: 12 | python example.py [--port PORT] 13 | 14 | Example: 15 | python example.py 192.168.1.99 16 | python example.py 127.0.0.1 --port 8443 17 | """ 18 | 19 | import argparse 20 | import asyncio 21 | import logging 22 | 23 | from stone_connect import ( 24 | StoneConnectError, 25 | StoneConnectHeater, 26 | ) 27 | 28 | 29 | async def basic_example(heater_ip: str, port: int = 443): 30 | """Basic usage example.""" 31 | print(f"Connecting to heater at {heater_ip}:{port}...") 32 | 33 | # Connect to heater 34 | async with StoneConnectHeater(heater_ip, port=port) as heater: 35 | try: 36 | # Get device information 37 | print("\n📋 Device Information:") 38 | info = await heater.get_info() 39 | print(f" Client ID: {info.client_id}") 40 | print(f" Appliance Name: {info.appliance_name}") 41 | print(f" Zone Name: {info.zone_name}") 42 | print(f" Home Name: {info.home_name}") 43 | print(f" MAC Address: {info.mac_address}") 44 | print(f" FW Version: {info.fw_version}") 45 | print(f" PCB Version: {info.pcb_version}") 46 | 47 | # Get current status 48 | print("\n🌡️ Current Status:") 49 | status = await heater.get_status() 50 | print(f" Set Point: {status.set_point}°C") 51 | print(f" Operation Mode: {status.operative_mode}") 52 | print(f" Power Consumption: {status.power_consumption_watt}W") 53 | print(f" Daily Energy: {status.daily_energy}") 54 | print(f" Signal Strength: {status.rssi} dBm") 55 | print(f" Error Code: {status.error_code}") 56 | print(f" Lock Status: {status.lock_status}") 57 | 58 | # Example: Set manual temperature to 21°C 59 | print("\n🔧 Setting manual temperature to 21°C...") 60 | await heater.set_manual_temperature(21.0) 61 | 62 | # Example: Set to comfort mode (uses preset temperature) 63 | print("🔧 Setting to comfort mode...") 64 | await heater.set_comfort_mode() 65 | 66 | # Example: Set to eco mode (uses preset temperature) 67 | print("🔧 Setting to eco mode...") 68 | await heater.set_eco_mode() 69 | 70 | # Example: Set to high power mode (no temperature setpoint) 71 | print("🔧 Setting to high power mode...") 72 | await heater.set_standby() 73 | 74 | # Get updated status 75 | print("\n🌡️ Updated Status:") 76 | status = await heater.get_status() 77 | print(f" Set Point: {status.set_point}°C") 78 | print(f" Operation Mode: {status.operative_mode}") 79 | print(f" Power Consumption: {status.power_consumption_watt}W") 80 | print(f" Is Heating: {await heater.is_heating()}") 81 | 82 | # Get weekly schedule 83 | print("\n📅 Weekly Schedule:") 84 | schedule = await heater.get_schedule() 85 | if schedule.weekly_schedule: 86 | for day in schedule.weekly_schedule[:2]: # Show first 2 days 87 | day_names = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"] 88 | print(f" {day_names[day.week_day]}:") 89 | for point in day.schedule_points: 90 | print( 91 | f" {point.hour:02d}:{point.minute:02d} -> {point.set_point}°C" 92 | ) 93 | 94 | # Check if device supports power measurement 95 | print("\n" + "=" * 50) 96 | print("POWER MEASUREMENT CAPABILITY") 97 | print("=" * 50) 98 | 99 | has_power_support = await heater.has_power_measurement_support() 100 | print(f"Power measurement supported: {has_power_support}") 101 | 102 | if not has_power_support: 103 | print("⚠️ This device model/PCB may not support power measurement") 104 | print(" Power_Consumption_Watt will likely always be 0") 105 | else: 106 | print("✓ Device appears to support power measurement") 107 | 108 | print("\n✅ Example completed successfully!") 109 | 110 | except StoneConnectError as e: 111 | print(f"❌ Heater error: {e}") 112 | 113 | 114 | async def monitoring_example(heater_ip: str, port: int = 443): 115 | """Example of continuous monitoring.""" 116 | print(f"\n🔄 Starting monitoring of heater at {heater_ip}:{port}...") 117 | print("Press Ctrl+C to stop") 118 | 119 | async with StoneConnectHeater(heater_ip, port=port) as heater: 120 | try: 121 | while True: 122 | status = await heater.get_status() 123 | 124 | print( 125 | f"\r🌡️ {status.set_point}°C " 126 | f"({status.operative_mode.value if status.operative_mode else 'unknown'}) " 127 | f"{'🔥' if (status.power_consumption_watt or 0) > 0 else '❄️ '} " 128 | f"{status.power_consumption_watt or 0}W", 129 | end="", 130 | ) 131 | 132 | await asyncio.sleep(5) # Update every 5 seconds 133 | 134 | except KeyboardInterrupt: 135 | print("\n⏹️ Monitoring stopped") 136 | except StoneConnectError as e: 137 | print(f"\n❌ Heater error: {e}") 138 | 139 | 140 | async def quick_check_example(heater_ip: str, port: int = 443): 141 | """Quick status check example.""" 142 | print(f"\n⚡ Quick status check for {heater_ip}:{port}:") 143 | 144 | try: 145 | async with StoneConnectHeater(heater_ip, port=port) as heater: 146 | status = await heater.get_status() 147 | print(f" Set Point: {status.set_point}°C") 148 | print( 149 | f" Mode: {status.operative_mode.value if status.operative_mode else 'unknown'}" 150 | ) 151 | print(f" Power: {status.power_consumption_watt or 0}W") 152 | print( 153 | f" Heating: {'Yes' if (status.power_consumption_watt or 0) > 0 else 'No'}" 154 | ) 155 | except StoneConnectError as e: 156 | print(f" ❌ Error: {e}") 157 | 158 | 159 | async def main(): 160 | """Main function.""" 161 | parser = argparse.ArgumentParser( 162 | description="Stone Connect Heater Example", 163 | formatter_class=argparse.RawDescriptionHelpFormatter, 164 | epilog=""" 165 | Examples: 166 | python example.py 192.168.1.94 167 | python example.py 192.168.1.94 --port 8443 168 | python example.py 192.168.1.94 -p 80 169 | """, 170 | ) 171 | 172 | parser.add_argument("heater_ip", help="IP address or hostname of the heater") 173 | 174 | parser.add_argument( 175 | "-p", "--port", type=int, default=443, help="HTTPS port (default: 443)" 176 | ) 177 | 178 | args = parser.parse_args() 179 | 180 | print("🏠 Stone Connect Heater Example") 181 | print("=" * 40) 182 | print(f"Target: {args.heater_ip}:{args.port}") 183 | print() 184 | 185 | # Run basic example 186 | await basic_example(args.heater_ip, args.port) 187 | 188 | # Run quick check example 189 | await quick_check_example(args.heater_ip, args.port) 190 | 191 | # Ask if user wants to start monitoring 192 | try: 193 | response = input("\nStart continuous monitoring? (y/N): ").lower() 194 | if response in ["y", "yes"]: 195 | await monitoring_example(args.heater_ip, args.port) 196 | except (KeyboardInterrupt, EOFError): 197 | print("\nGoodbye! 👋") 198 | 199 | 200 | if __name__ == "__main__": 201 | # Configure logging 202 | logging.basicConfig( 203 | level=logging.INFO, 204 | format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", 205 | ) 206 | 207 | # Run the example 208 | try: 209 | asyncio.run(main()) 210 | except KeyboardInterrupt: 211 | print("\nInterrupted by user") 212 | -------------------------------------------------------------------------------- /tests/test_temperature_validation.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | Test suite for Stone Connect heater temperature validation. 4 | """ 5 | 6 | from unittest.mock import AsyncMock, patch 7 | 8 | import pytest 9 | 10 | from stone_connect import Info, OperationMode, Status, StoneConnectHeater 11 | from stone_connect.exceptions import StoneConnectValidationError 12 | 13 | 14 | class TestTemperatureValidation: 15 | """Test temperature validation and limits.""" 16 | 17 | def setup_method(self): 18 | """Set up test fixtures.""" 19 | self.heater = StoneConnectHeater("127.0.0.1") 20 | 21 | def test_validate_temperature_valid_range(self): 22 | """Test temperature validation for valid temperatures.""" 23 | # Test minimum valid temperature 24 | StoneConnectHeater._validate_temperature(0.0) 25 | 26 | # Test maximum valid temperature 27 | StoneConnectHeater._validate_temperature(30.0) 28 | 29 | # Test temperatures in the middle 30 | StoneConnectHeater._validate_temperature(15.0) 31 | StoneConnectHeater._validate_temperature(20.5) 32 | StoneConnectHeater._validate_temperature(25.7) 33 | 34 | def test_validate_temperature_below_minimum(self): 35 | """Test temperature validation for temperatures below 0°C.""" 36 | with pytest.raises( 37 | StoneConnectValidationError, 38 | match="Temperature -1.0°C is below minimum limit of 0°C", 39 | ): 40 | StoneConnectHeater._validate_temperature(-1.0) 41 | 42 | with pytest.raises( 43 | StoneConnectValidationError, 44 | match="Temperature -10.5°C is below minimum limit of 0°C", 45 | ): 46 | StoneConnectHeater._validate_temperature(-10.5) 47 | 48 | def test_validate_temperature_above_maximum(self): 49 | """Test temperature validation for temperatures above 30°C.""" 50 | with pytest.raises( 51 | StoneConnectValidationError, 52 | match="Temperature 31.0°C is above maximum limit of 30°C", 53 | ): 54 | StoneConnectHeater._validate_temperature(31.0) 55 | 56 | with pytest.raises( 57 | StoneConnectValidationError, 58 | match="Temperature 50.5°C is above maximum limit of 30°C", 59 | ): 60 | StoneConnectHeater._validate_temperature(50.5) 61 | 62 | async def test_set_temperature_valid_range(self): 63 | """Test set_temperature with valid temperature range.""" 64 | mock_status = Status(operative_mode=OperationMode.MANUAL) 65 | 66 | with patch.object( 67 | self.heater, 68 | "get_status", 69 | new_callable=AsyncMock, 70 | return_value=mock_status, 71 | ): 72 | with patch.object( 73 | self.heater, 74 | "set_temperature_and_mode", 75 | new_callable=AsyncMock, 76 | return_value=True, 77 | ) as mock_set: 78 | # Test minimum valid temperature 79 | await self.heater.set_temperature(0.0, OperationMode.MANUAL) 80 | mock_set.assert_called_with(0.0, OperationMode.MANUAL) 81 | 82 | # Test maximum valid temperature 83 | await self.heater.set_temperature(30.0, OperationMode.MANUAL) 84 | mock_set.assert_called_with(30.0, OperationMode.MANUAL) 85 | 86 | # Test temperature in the middle 87 | await self.heater.set_temperature(21.5, OperationMode.MANUAL) 88 | mock_set.assert_called_with(21.5, OperationMode.MANUAL) 89 | 90 | async def test_set_temperature_invalid_range(self): 91 | """Test set_temperature with invalid temperature range.""" 92 | # Test below minimum 93 | with pytest.raises( 94 | StoneConnectValidationError, 95 | match="Temperature -5.0°C is below minimum limit of 0°C", 96 | ): 97 | await self.heater.set_temperature(-5.0, OperationMode.MANUAL) 98 | 99 | # Test above maximum 100 | with pytest.raises( 101 | StoneConnectValidationError, 102 | match="Temperature 35.0°C is above maximum limit of 30°C", 103 | ): 104 | await self.heater.set_temperature(35.0, OperationMode.MANUAL) 105 | 106 | async def test_set_manual_temperature_valid_range(self): 107 | """Test set_manual_temperature with valid temperature range.""" 108 | with patch.object( 109 | self.heater, "set_temperature", new_callable=AsyncMock, return_value=True 110 | ) as mock_set: 111 | await self.heater.set_manual_temperature(22.0) 112 | mock_set.assert_called_with(22.0, OperationMode.MANUAL) 113 | 114 | async def test_set_manual_temperature_invalid_range(self): 115 | """Test set_manual_temperature with invalid temperature range.""" 116 | # This should fail at the set_temperature level, so we need to patch the validation 117 | with patch.object(self.heater, "set_temperature") as mock_set: 118 | mock_set.side_effect = ValueError( 119 | "Temperature 40.0°C is above maximum limit of 30°C" 120 | ) 121 | 122 | with pytest.raises( 123 | ValueError, match="Temperature 40.0°C is above maximum limit of 30°C" 124 | ): 125 | await self.heater.set_manual_temperature(40.0) 126 | 127 | async def test_set_temperature_and_mode_validation(self): 128 | """Test that set_temperature_and_mode validates temperature for non-power modes.""" 129 | mock_device_info = Info(client_id="test_client") 130 | 131 | with patch.object( 132 | self.heater, 133 | "get_info", 134 | new_callable=AsyncMock, 135 | return_value=mock_device_info, 136 | ): 137 | with patch.object(self.heater, "_request", new_callable=AsyncMock): 138 | # Valid temperature should work 139 | await self.heater.set_temperature_and_mode(20.0, OperationMode.MANUAL) 140 | 141 | # Invalid temperature should fail 142 | with pytest.raises( 143 | StoneConnectValidationError, 144 | match="Temperature 35.0°C is above maximum limit", 145 | ): 146 | await self.heater.set_temperature_and_mode( 147 | 35.0, OperationMode.MANUAL 148 | ) 149 | 150 | async def test_set_temperature_and_mode_power_mode_no_validation(self): 151 | """Test that set_temperature_and_mode doesn't validate temperature for power modes.""" 152 | mock_device_info = Info(client_id="test_client") 153 | 154 | with patch.object( 155 | self.heater, 156 | "get_info", 157 | new_callable=AsyncMock, 158 | return_value=mock_device_info, 159 | ): 160 | with patch.object(self.heater, "_request", new_callable=AsyncMock): 161 | # Power mode should work even with invalid temperature (temperature is ignored) 162 | await self.heater.set_temperature_and_mode(999.0, OperationMode.HIGH) 163 | 164 | async def test_set_operation_mode_with_preset_temperatures(self): 165 | """Test that set_operation_mode works with preset temperatures within range.""" 166 | mock_device_info = Info( 167 | client_id="test_client", 168 | comfort_setpoint=19.0, # Valid 169 | eco_setpoint=15.0, # Valid 170 | antifreeze_setpoint=7.0, # Valid 171 | ) 172 | 173 | with patch.object( 174 | self.heater, 175 | "get_info", 176 | new_callable=AsyncMock, 177 | return_value=mock_device_info, 178 | ): 179 | with patch.object( 180 | self.heater, 181 | "set_temperature_and_mode", 182 | new_callable=AsyncMock, 183 | return_value=True, 184 | ) as mock_set: 185 | # Test comfort mode 186 | await self.heater.set_operation_mode(OperationMode.COMFORT) 187 | mock_set.assert_called_with(19.0, OperationMode.COMFORT) 188 | 189 | # Test eco mode 190 | await self.heater.set_operation_mode(OperationMode.ECO) 191 | mock_set.assert_called_with(15.0, OperationMode.ECO) 192 | 193 | # Test antifreeze mode 194 | await self.heater.set_operation_mode(OperationMode.ANTIFREEZE) 195 | mock_set.assert_called_with(7.0, OperationMode.ANTIFREEZE) 196 | 197 | async def test_set_operation_mode_with_invalid_preset_temperatures(self): 198 | """Test that set_operation_mode fails with invalid preset temperatures.""" 199 | # Test with preset temperature above maximum 200 | mock_device_info = Info( 201 | client_id="test_client", 202 | comfort_setpoint=35.0, # Invalid - above 30°C 203 | ) 204 | 205 | with patch.object( 206 | self.heater, 207 | "get_info", 208 | new_callable=AsyncMock, 209 | return_value=mock_device_info, 210 | ): 211 | with pytest.raises( 212 | StoneConnectValidationError, 213 | match="Temperature 35.0°C is above maximum limit", 214 | ): 215 | await self.heater.set_operation_mode(OperationMode.COMFORT) 216 | 217 | 218 | if __name__ == "__main__": 219 | pytest.main([__file__, "-v"]) 220 | -------------------------------------------------------------------------------- /stone_connect/models.py: -------------------------------------------------------------------------------- 1 | """Stone Connect Heater data models.""" 2 | 3 | from dataclasses import dataclass 4 | from datetime import datetime 5 | from enum import Enum 6 | from typing import Any, Dict, List, Optional 7 | 8 | 9 | def parse_timestamp(timestamp: Optional[int]) -> Optional[datetime]: 10 | """Parse timestamp from milliseconds to datetime.""" 11 | if timestamp is None: 12 | return None 13 | return datetime.fromtimestamp(timestamp / 1000) 14 | 15 | 16 | class OperationMode(Enum): 17 | """Heater operation modes based on device constants.""" 18 | 19 | # Primary modes seen from device constants (may vary by device model) 20 | ANTIFREEZE = "ANF" 21 | BOOST = "BST" 22 | COMFORT = "CMF" 23 | ECO = "ECO" 24 | HIGH = "HIG" 25 | HOLIDAY = "HOL" 26 | LOW = "LOW" 27 | MANUAL = "MAN" 28 | MEDIUM = "MED" 29 | SCHEDULE = "SCH" 30 | STANDBY = "SBY" 31 | 32 | def is_power_mode(self) -> bool: 33 | """Check if this is a power-based mode (HIGH/MEDIUM/LOW).""" 34 | return self in {self.HIGH, self.MEDIUM, self.LOW} 35 | 36 | def is_preset_mode(self) -> bool: 37 | """Check if this is a preset temperature mode (COMFORT/ECO/ANTIFREEZE).""" 38 | return self in {self.COMFORT, self.ECO, self.ANTIFREEZE} 39 | 40 | def is_custom_mode(self) -> bool: 41 | """Check if this is a custom temperature mode (MANUAL/BOOST).""" 42 | return self in {self.MANUAL, self.BOOST} 43 | 44 | def get_preset_setpoint(self, device_info: "Info") -> Optional[float]: 45 | """Get the preset setpoint for this mode from device info.""" 46 | if self == self.COMFORT: 47 | return device_info.comfort_setpoint 48 | elif self == self.ECO: 49 | return device_info.eco_setpoint 50 | elif self == self.ANTIFREEZE: 51 | return device_info.antifreeze_setpoint 52 | return None 53 | 54 | 55 | class UseMode(Enum): 56 | """Use modes based on actual API responses.""" 57 | 58 | SETPOINT = "SET" 59 | POWER = "POW" 60 | 61 | 62 | @dataclass 63 | class HolidaySettings: 64 | """Holiday mode settings.""" 65 | 66 | holiday_start: Optional[datetime] = None 67 | holiday_end: Optional[datetime] = None 68 | operative_mode: Optional[str] = None 69 | 70 | @classmethod 71 | def from_dict(cls, data: Dict[str, Any]) -> "HolidaySettings": 72 | """Create HolidaySettings from dictionary.""" 73 | return cls( 74 | holiday_start=parse_timestamp(data.get("Holiday_Start")), 75 | holiday_end=parse_timestamp(data.get("Holiday_End")), 76 | operative_mode=data.get("Operative_Mode"), 77 | ) 78 | 79 | 80 | @dataclass 81 | class ScheduleSlot: 82 | """Schedule slot for weekly schedule.""" 83 | 84 | hour: int 85 | minute: int 86 | set_point: float 87 | 88 | @classmethod 89 | def from_dict(cls, data: Dict[str, Any]) -> "ScheduleSlot": 90 | """Create ScheduleSlot from dictionary.""" 91 | return cls( 92 | hour=data["hour"], 93 | minute=data["minute"], 94 | set_point=data["set_point"], 95 | ) 96 | 97 | def to_dict(self) -> Dict[str, Any]: 98 | """Convert to dictionary.""" 99 | return { 100 | "hour": self.hour, 101 | "minute": self.minute, 102 | "set_point": self.set_point, 103 | } 104 | 105 | 106 | @dataclass 107 | class ScheduleDay: 108 | """One day of weekly schedule.""" 109 | 110 | week_day: int # 0=Monday, 6=Sunday 111 | schedule_slots: List[ScheduleSlot] 112 | 113 | @classmethod 114 | def from_dict(cls, data: Dict[str, Any]) -> "ScheduleDay": 115 | """Create ScheduleDay from dictionary.""" 116 | return cls( 117 | week_day=data["week_day"], 118 | schedule_slots=[ 119 | ScheduleSlot.from_dict(slot) for slot in data.get("schedule_slots", []) 120 | ], 121 | ) 122 | 123 | def to_dict(self) -> Dict[str, Any]: 124 | """Convert to dictionary.""" 125 | return { 126 | "week_day": self.week_day, 127 | "schedule_slots": [slot.to_dict() for slot in self.schedule_slots], 128 | } 129 | 130 | 131 | @dataclass 132 | class Info: 133 | """Device information structure based on /info endpoint.""" 134 | 135 | client_id: Optional[str] = None 136 | operative_mode: Optional[OperationMode] = None 137 | set_point: Optional[float] = None 138 | use_mode: Optional[UseMode] = None 139 | home_id: Optional[int] = None 140 | zone_id: Optional[int] = None 141 | appliance_id: Optional[int] = None 142 | temperature_unit: Optional[str] = None 143 | is_installed: Optional[bool] = None 144 | comfort_setpoint: Optional[float] = None 145 | eco_setpoint: Optional[float] = None 146 | antifreeze_setpoint: Optional[float] = None 147 | boost_timer: Optional[int] = None 148 | high_power: Optional[int] = None 149 | medium_power: Optional[int] = None 150 | low_power: Optional[int] = None 151 | mac_address: Optional[str] = None 152 | pcb_pn: Optional[str] = None 153 | pcb_version: Optional[str] = None 154 | fw_pn: Optional[str] = None 155 | fw_version: Optional[str] = None 156 | holiday: Optional[HolidaySettings] = None 157 | latitude: Optional[float] = None 158 | longitude: Optional[float] = None 159 | altitude: Optional[float] = None 160 | gps_precision: Optional[int] = None 161 | set_timezone: Optional[int] = None 162 | load_size_watt: Optional[int] = None 163 | home_name: Optional[str] = None 164 | zone_name: Optional[str] = None 165 | appliance_name: Optional[str] = None 166 | appliance_pn: Optional[str] = None 167 | appliance_sn: Optional[str] = None 168 | housing_pn: Optional[str] = None 169 | housing_sn: Optional[str] = None 170 | last_update: Optional[datetime] = None 171 | 172 | @classmethod 173 | def from_dict(cls, data: Dict[str, Any]) -> "Info": 174 | """Create Info from API response dictionary.""" 175 | try: 176 | operative_mode = OperationMode(data.get("Operative_Mode")) 177 | except ValueError: 178 | operative_mode = None 179 | 180 | try: 181 | use_mode = UseMode(data.get("Use_Mode")) 182 | except ValueError: 183 | use_mode = None 184 | 185 | holiday_data = data.get("Holiday") 186 | holiday = HolidaySettings.from_dict(holiday_data) if holiday_data else None 187 | 188 | return cls( 189 | client_id=data.get("Client_ID"), 190 | operative_mode=operative_mode, 191 | set_point=data.get("Set_Point"), 192 | use_mode=use_mode, 193 | home_id=data.get("Home_ID"), 194 | zone_id=data.get("Zone_ID"), 195 | appliance_id=data.get("Appliance_ID"), 196 | temperature_unit=data.get("Temperature_Unit"), 197 | is_installed=data.get("Is_Installed"), 198 | comfort_setpoint=data.get("Comfort_Setpoint"), 199 | eco_setpoint=data.get("Eco_Setpoint"), 200 | antifreeze_setpoint=data.get("Antifreeze_Setpoint"), 201 | boost_timer=data.get("Boost_Timer"), 202 | high_power=data.get("High_Power"), 203 | medium_power=data.get("Medium_Power"), 204 | low_power=data.get("Low_Power"), 205 | mac_address=data.get("MAC_Address"), 206 | pcb_pn=data.get("PCB_PN"), 207 | pcb_version=data.get("PCB_Version"), 208 | fw_pn=data.get("FW_PN"), 209 | fw_version=data.get("FW_Version"), 210 | holiday=holiday, 211 | latitude=data.get("Latitude"), 212 | longitude=data.get("Longitude"), 213 | altitude=data.get("Altitude"), 214 | gps_precision=data.get("GPS_Precision"), 215 | set_timezone=data.get("Set_Timezone"), 216 | load_size_watt=data.get("Load_Size_Watt"), 217 | home_name=data.get("Home_Name"), 218 | zone_name=data.get("Zone_Name"), 219 | appliance_name=data.get("Appliance_Name"), 220 | appliance_pn=data.get("Appliance_PN"), 221 | appliance_sn=data.get("Appliance_SN"), 222 | housing_pn=data.get("Housing_PN"), 223 | housing_sn=data.get("Housing_SN"), 224 | last_update=parse_timestamp(data.get("Last_Update")), 225 | ) 226 | 227 | 228 | @dataclass 229 | class Status: 230 | """Device status structure based on /Status endpoint.""" 231 | 232 | client_id: Optional[str] = None 233 | set_point: Optional[float] = None 234 | operative_mode: Optional[OperationMode] = None 235 | power_consumption_watt: Optional[int] = None 236 | daily_energy: Optional[int] = None 237 | error_code: Optional[int] = None 238 | lock_status: Optional[bool] = None 239 | rssi: Optional[int] = None 240 | connected_to_broker: Optional[bool] = None 241 | broker_enabled: Optional[bool] = None 242 | last_update: Optional[datetime] = None 243 | 244 | @classmethod 245 | def from_dict(cls, data: Dict[str, Any]) -> "Status": 246 | """Create Status from API response dictionary.""" 247 | try: 248 | operative_mode = OperationMode(data.get("Operative_Mode")) 249 | except ValueError: 250 | operative_mode = None 251 | 252 | return cls( 253 | client_id=data.get("Client_ID"), 254 | set_point=data.get("Set_Point"), 255 | operative_mode=operative_mode, 256 | power_consumption_watt=data.get("Power_Consumption_Watt"), 257 | daily_energy=data.get("Daily_Energy"), 258 | error_code=data.get("Error_Code"), 259 | lock_status=data.get("Lock_Status"), 260 | rssi=data.get("RSSI"), 261 | connected_to_broker=data.get("Connected_To_Broker"), 262 | broker_enabled=data.get("Broker_Enabled"), 263 | last_update=parse_timestamp(data.get("Last_Update")), 264 | ) 265 | 266 | 267 | @dataclass 268 | class Schedule: 269 | """Weekly schedule structure based on /Schedule endpoint.""" 270 | 271 | client_id: Optional[str] = None 272 | weekly_schedule: Optional[List[ScheduleDay]] = None 273 | last_update: Optional[datetime] = None 274 | 275 | @classmethod 276 | def from_dict(cls, data: Dict[str, Any]) -> "Schedule": 277 | """Create Schedule from API response dictionary.""" 278 | weekly_schedule = None 279 | if "Weekly_Schedule" in data: 280 | weekly_schedule = [ 281 | ScheduleDay.from_dict(day) for day in data["Weekly_Schedule"] 282 | ] 283 | 284 | return cls( 285 | client_id=data.get("Client_ID"), 286 | weekly_schedule=weekly_schedule, 287 | last_update=parse_timestamp(data.get("Last_Update")), 288 | ) 289 | 290 | def to_dict(self) -> Dict[str, Any]: 291 | """Convert to dictionary for API requests.""" 292 | result: Dict[str, Any] = {"Client_ID": self.client_id} 293 | if self.weekly_schedule: 294 | result["Weekly_Schedule"] = [day.to_dict() for day in self.weekly_schedule] 295 | return result 296 | -------------------------------------------------------------------------------- /tests/test_modes.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | Test suite for Stone Connect heater mode categorization and API functionality. 4 | """ 5 | 6 | from unittest.mock import AsyncMock, patch 7 | 8 | import pytest 9 | 10 | from stone_connect import ( 11 | Info, 12 | OperationMode, 13 | Status, 14 | StoneConnectHeater, 15 | UseMode, 16 | parse_timestamp, 17 | ) 18 | from stone_connect.exceptions import StoneConnectValidationError 19 | 20 | 21 | class TestModeCategorization: 22 | """Test mode categorization helper methods.""" 23 | 24 | def test_power_modes(self): 25 | """Test power mode identification.""" 26 | power_modes = [OperationMode.HIGH, OperationMode.MEDIUM, OperationMode.LOW] 27 | for mode in power_modes: 28 | assert StoneConnectHeater._is_power_mode(mode), ( 29 | f"{mode} should be a power mode" 30 | ) 31 | assert not StoneConnectHeater._is_preset_mode(mode), ( 32 | f"{mode} should not be a preset mode" 33 | ) 34 | assert not StoneConnectHeater._is_custom_temperature_mode(mode), ( 35 | f"{mode} should not be a custom temperature mode" 36 | ) 37 | 38 | def test_preset_modes(self): 39 | """Test preset mode identification.""" 40 | preset_modes = [ 41 | OperationMode.COMFORT, 42 | OperationMode.ECO, 43 | OperationMode.ANTIFREEZE, 44 | ] 45 | for mode in preset_modes: 46 | assert not StoneConnectHeater._is_power_mode(mode), ( 47 | f"{mode} should not be a power mode" 48 | ) 49 | assert StoneConnectHeater._is_preset_mode(mode), ( 50 | f"{mode} should be a preset mode" 51 | ) 52 | assert not StoneConnectHeater._is_custom_temperature_mode(mode), ( 53 | f"{mode} should not be a custom temperature mode" 54 | ) 55 | 56 | def test_custom_temperature_modes(self): 57 | """Test custom temperature mode identification.""" 58 | custom_modes = [OperationMode.MANUAL, OperationMode.BOOST] 59 | for mode in custom_modes: 60 | assert not StoneConnectHeater._is_power_mode(mode), ( 61 | f"{mode} should not be a power mode" 62 | ) 63 | assert not StoneConnectHeater._is_preset_mode(mode), ( 64 | f"{mode} should not be a preset mode" 65 | ) 66 | assert StoneConnectHeater._is_custom_temperature_mode(mode), ( 67 | f"{mode} should be a custom temperature mode" 68 | ) 69 | 70 | def test_other_modes(self): 71 | """Test other modes (standby, schedule, holiday).""" 72 | other_modes = [ 73 | OperationMode.STANDBY, 74 | OperationMode.SCHEDULE, 75 | OperationMode.HOLIDAY, 76 | ] 77 | for mode in other_modes: 78 | assert not StoneConnectHeater._is_power_mode(mode), ( 79 | f"{mode} should not be a power mode" 80 | ) 81 | assert not StoneConnectHeater._is_preset_mode(mode), ( 82 | f"{mode} should not be a preset mode" 83 | ) 84 | assert not StoneConnectHeater._is_custom_temperature_mode(mode), ( 85 | f"{mode} should not be a custom temperature mode" 86 | ) 87 | 88 | 89 | class TestPresetSetpointRetrieval: 90 | """Test preset setpoint retrieval.""" 91 | 92 | def setup_method(self): 93 | """Set up test fixtures.""" 94 | self.device_info = Info( 95 | comfort_setpoint=19.0, eco_setpoint=15.0, antifreeze_setpoint=7.0 96 | ) 97 | self.heater = StoneConnectHeater("127.0.0.1") # Dummy IP for testing 98 | 99 | def test_comfort_setpoint(self): 100 | """Test comfort setpoint retrieval.""" 101 | result = self.heater._get_preset_setpoint( 102 | OperationMode.COMFORT, self.device_info 103 | ) 104 | assert result == 19.0 105 | 106 | def test_eco_setpoint(self): 107 | """Test eco setpoint retrieval.""" 108 | result = self.heater._get_preset_setpoint(OperationMode.ECO, self.device_info) 109 | assert result == 15.0 110 | 111 | def test_antifreeze_setpoint(self): 112 | """Test antifreeze setpoint retrieval.""" 113 | result = self.heater._get_preset_setpoint( 114 | OperationMode.ANTIFREEZE, self.device_info 115 | ) 116 | assert result == 7.0 117 | 118 | def test_non_preset_modes(self): 119 | """Test that non-preset modes return None.""" 120 | non_preset_modes = [ 121 | OperationMode.MANUAL, 122 | OperationMode.HIGH, 123 | OperationMode.STANDBY, 124 | ] 125 | for mode in non_preset_modes: 126 | result = self.heater._get_preset_setpoint(mode, self.device_info) 127 | assert result is None 128 | 129 | def test_missing_setpoints(self): 130 | """Test behavior when setpoints are missing.""" 131 | empty_device_info = Info() 132 | result = self.heater._get_preset_setpoint( 133 | OperationMode.COMFORT, empty_device_info 134 | ) 135 | assert result is None 136 | 137 | 138 | class TestDataStructures: 139 | """Test data structure parsing and validation.""" 140 | 141 | def test_operation_mode_enum(self): 142 | """Test OperationMode enum values.""" 143 | assert OperationMode.COMFORT.value == "CMF" 144 | assert OperationMode.ECO.value == "ECO" 145 | assert OperationMode.HIGH.value == "HIG" 146 | assert OperationMode.MANUAL.value == "MAN" 147 | assert OperationMode.STANDBY.value == "SBY" 148 | 149 | def test_use_mode_enum(self): 150 | """Test UseMode enum values.""" 151 | assert UseMode.SETPOINT.value == "SET" 152 | assert UseMode.POWER.value == "POW" 153 | 154 | def test_device_info_creation(self): 155 | """Test Info dataclass creation.""" 156 | device_info = Info( 157 | client_id="test_client", 158 | operative_mode=OperationMode.COMFORT, 159 | set_point=20.0, 160 | use_mode=UseMode.SETPOINT, 161 | comfort_setpoint=19.0, 162 | ) 163 | assert device_info.client_id == "test_client" 164 | assert device_info.operative_mode == OperationMode.COMFORT 165 | assert device_info.set_point == 20.0 166 | assert device_info.use_mode == UseMode.SETPOINT 167 | assert device_info.comfort_setpoint == 19.0 168 | 169 | def test_device_status_creation(self): 170 | """Test Status dataclass creation.""" 171 | status = Status( 172 | client_id="test_client", 173 | set_point=21.0, 174 | operative_mode=OperationMode.MANUAL, 175 | power_consumption_watt=1500, 176 | ) 177 | assert status.client_id == "test_client" 178 | assert status.set_point == 21.0 179 | assert status.operative_mode == OperationMode.MANUAL 180 | assert status.power_consumption_watt == 1500 181 | 182 | def test_parse_timestamp(self): 183 | """Test timestamp parsing utility.""" 184 | # Test valid timestamp (milliseconds) 185 | timestamp_ms = 1640995200000 # 2022-01-01 00:00:00 UTC 186 | result = parse_timestamp(timestamp_ms) 187 | assert result is not None 188 | assert result.year == 2022 189 | assert result.month == 1 190 | assert result.day == 1 191 | 192 | # Test None timestamp 193 | result = parse_timestamp(None) 194 | assert result is None 195 | 196 | 197 | class TestHeaterAPI: 198 | """Test heater API methods.""" 199 | 200 | def setup_method(self): 201 | """Set up test fixtures.""" 202 | self.heater = StoneConnectHeater("127.0.0.1") 203 | 204 | @pytest.fixture 205 | def mock_device_info(self): 206 | """Mock device info for testing.""" 207 | return { 208 | "Client_ID": "test_client_123", 209 | "Operative_Mode": "CMF", 210 | "Set_Point": 20.0, 211 | "Use_Mode": "SET", 212 | "Comfort_Setpoint": 19.0, 213 | "Eco_Setpoint": 15.0, 214 | "Antifreeze_Setpoint": 7.0, 215 | } 216 | 217 | @pytest.fixture 218 | def mock_device_status(self): 219 | """Mock device status for testing.""" 220 | return { 221 | "Client_ID": "test_client_123", 222 | "Set_Point": 20.0, 223 | "Operative_Mode": "CMF", 224 | "Power_Consumption_Watt": 1200, 225 | } 226 | 227 | async def test_set_manual_temperature_validation(self): 228 | """Test that set_temperature validates modes correctly.""" 229 | # Test that power modes are rejected 230 | with pytest.raises( 231 | StoneConnectValidationError, 232 | match="Power mode.*doesn't use temperature setpoints", 233 | ): 234 | await self.heater.set_temperature(20.0, OperationMode.HIGH) 235 | 236 | # Test that preset modes are rejected 237 | with pytest.raises( 238 | StoneConnectValidationError, 239 | match="Preset mode.*uses predefined temperature", 240 | ): 241 | await self.heater.set_temperature(20.0, OperationMode.COMFORT) 242 | 243 | async def test_set_power_mode_validation(self): 244 | """Test that set_power_mode validates power modes correctly.""" 245 | # Valid power mode should work (would need mocking for actual API call) 246 | with patch.object( 247 | self.heater, "set_operation_mode", new_callable=AsyncMock 248 | ) as mock_set_mode: 249 | await self.heater.set_power_mode(OperationMode.HIGH) 250 | mock_set_mode.assert_called_once_with(OperationMode.HIGH) 251 | 252 | # Invalid power mode should raise error 253 | with pytest.raises( 254 | StoneConnectValidationError, match="is not a valid power mode" 255 | ): 256 | await self.heater.set_power_mode(OperationMode.COMFORT) 257 | 258 | async def test_convenience_methods(self): 259 | """Test convenience methods delegate correctly.""" 260 | with patch.object( 261 | self.heater, "set_operation_mode", new_callable=AsyncMock 262 | ) as mock_set_mode: 263 | await self.heater.set_comfort_mode() 264 | mock_set_mode.assert_called_with(OperationMode.COMFORT) 265 | 266 | await self.heater.set_eco_mode() 267 | mock_set_mode.assert_called_with(OperationMode.ECO) 268 | 269 | await self.heater.set_antifreeze_mode() 270 | mock_set_mode.assert_called_with(OperationMode.ANTIFREEZE) 271 | 272 | await self.heater.set_standby() 273 | mock_set_mode.assert_called_with(OperationMode.STANDBY) 274 | 275 | with patch.object( 276 | self.heater, "set_temperature", new_callable=AsyncMock 277 | ) as mock_set_temp: 278 | await self.heater.set_manual_temperature(22.0) 279 | mock_set_temp.assert_called_with(22.0, OperationMode.MANUAL) 280 | 281 | 282 | if __name__ == "__main__": 283 | pytest.main([__file__, "-v"]) 284 | -------------------------------------------------------------------------------- /tests/test_api.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | Test suite for Stone Connect heater API responses and error handling. 4 | """ 5 | 6 | from unittest.mock import AsyncMock, patch 7 | 8 | import aiohttp 9 | import pytest 10 | 11 | from stone_connect import ( 12 | Info, 13 | OperationMode, 14 | Status, 15 | StoneConnectHeater, 16 | ) 17 | from stone_connect.exceptions import StoneConnectValidationError 18 | from stone_connect.models import UseMode 19 | 20 | 21 | class TestAPIResponseParsing: 22 | """Test API response parsing.""" 23 | 24 | def setup_method(self): 25 | """Set up test fixtures.""" 26 | self.heater = StoneConnectHeater("127.0.0.1") 27 | 28 | async def test_parse_device_info_response(self): 29 | """Test parsing device info response.""" 30 | mock_response = { 31 | "Client_ID": "571519332SN20244900365", 32 | "Operative_Mode": "CMF", 33 | "Set_Point": 20.0, 34 | "Use_Mode": "SET", 35 | "Comfort_Setpoint": 19.0, 36 | "Eco_Setpoint": 15.0, 37 | "Antifreeze_Setpoint": 7.0, 38 | "MAC_Address": "30:c6:f7:e9:e2:48", 39 | "FW_Version": "1.2.3", 40 | } 41 | 42 | with patch.object( 43 | self.heater, "_request", new_callable=AsyncMock, return_value=mock_response 44 | ): 45 | device_info = await self.heater.get_info() 46 | 47 | assert device_info.client_id == "571519332SN20244900365" 48 | assert device_info.operative_mode == OperationMode.COMFORT 49 | assert device_info.set_point == 20.0 50 | assert device_info.use_mode == UseMode.SETPOINT 51 | assert device_info.comfort_setpoint == 19.0 52 | assert device_info.eco_setpoint == 15.0 53 | assert device_info.antifreeze_setpoint == 7.0 54 | assert device_info.mac_address == "30:c6:f7:e9:e2:48" 55 | assert device_info.fw_version == "1.2.3" 56 | 57 | async def test_parse_device_status_response(self): 58 | """Test parsing device status response.""" 59 | mock_response = { 60 | "Client_ID": "571519332SN20244900365", 61 | "Set_Point": 21.5, 62 | "Operative_Mode": "MAN", 63 | "Power_Consumption_Watt": 1500, 64 | "Daily_Energy": 2500, 65 | "Error_Code": 0, 66 | "Lock_Status": False, 67 | "RSSI": -45, 68 | "Connected_To_Broker": True, 69 | "Broker_Enabled": True, 70 | } 71 | 72 | with patch.object( 73 | self.heater, "_request", new_callable=AsyncMock, return_value=mock_response 74 | ): 75 | status = await self.heater.get_status() 76 | 77 | assert status.client_id == "571519332SN20244900365" 78 | assert status.set_point == 21.5 79 | assert status.operative_mode == OperationMode.MANUAL 80 | assert status.power_consumption_watt == 1500 81 | assert status.daily_energy == 2500 82 | assert status.error_code == 0 83 | assert status.lock_status is False 84 | assert status.rssi == -45 85 | assert status.connected_to_broker is True 86 | assert status.broker_enabled is True 87 | 88 | async def test_parse_unknown_operation_mode(self): 89 | """Test handling of unknown operation modes.""" 90 | mock_response = { 91 | "Client_ID": "test_client", 92 | "Operative_Mode": "UNKNOWN_MODE", 93 | "Set_Point": 20.0, 94 | } 95 | 96 | with patch.object( 97 | self.heater, "_request", new_callable=AsyncMock, return_value=mock_response 98 | ): 99 | # Should not crash but log a warning 100 | device_info = await self.heater.get_info() 101 | assert device_info.operative_mode is None # Unknown mode becomes None 102 | 103 | async def test_parse_unknown_use_mode(self): 104 | """Test handling of unknown use modes.""" 105 | mock_response = { 106 | "Client_ID": "test_client", 107 | "Use_Mode": "UNKNOWN_USE_MODE", 108 | "Set_Point": 20.0, 109 | } 110 | 111 | with patch.object( 112 | self.heater, "_request", new_callable=AsyncMock, return_value=mock_response 113 | ): 114 | # Should not crash but log a warning 115 | device_info = await self.heater.get_info() 116 | assert device_info.use_mode is None # Unknown mode becomes None 117 | 118 | 119 | class TestErrorHandling: 120 | """Test error handling scenarios.""" 121 | 122 | def setup_method(self): 123 | """Set up test fixtures.""" 124 | self.heater = StoneConnectHeater("127.0.0.1") 125 | 126 | async def test_connection_error_handling(self): 127 | """Test connection error handling.""" 128 | with patch.object( 129 | self.heater, 130 | "_request", 131 | new_callable=AsyncMock, 132 | side_effect=aiohttp.ClientError("Connection failed"), 133 | ): 134 | with pytest.raises( 135 | aiohttp.ClientError 136 | ): # Should raise the connection error 137 | await self.heater.get_info() 138 | 139 | async def test_invalid_json_response(self): 140 | """Test handling of invalid JSON responses.""" 141 | # Mock a response that's not valid JSON structure for our parsing 142 | mock_response = {"unexpected": "structure"} 143 | 144 | with patch.object( 145 | self.heater, "_request", new_callable=AsyncMock, return_value=mock_response 146 | ): 147 | device_info = await self.heater.get_info() 148 | # Should handle gracefully with None values 149 | assert device_info.client_id is None 150 | 151 | async def test_set_operation_mode_with_missing_preset(self): 152 | """Test setting preset mode when device info is missing preset temperatures.""" 153 | with patch.object( 154 | self.heater, 155 | "get_info", 156 | new_callable=AsyncMock, 157 | return_value=Info(client_id="test_client"), 158 | ): 159 | # Should raise StoneConnectValidationError when trying to set comfort mode without preset 160 | with pytest.raises( 161 | StoneConnectValidationError, match="No preset temperature found" 162 | ): 163 | await self.heater.set_operation_mode(OperationMode.COMFORT) 164 | 165 | 166 | class TestAPIRequestBuilding: 167 | """Test API request building for different operations.""" 168 | 169 | def setup_method(self): 170 | """Set up test fixtures.""" 171 | self.heater = StoneConnectHeater("127.0.0.1") 172 | 173 | async def test_set_power_mode_request(self): 174 | """Test that power mode requests don't include Set_Point.""" 175 | mock_device_info = Info(client_id="test_client") 176 | 177 | with patch.object( 178 | self.heater, 179 | "get_info", 180 | new_callable=AsyncMock, 181 | return_value=mock_device_info, 182 | ): 183 | with patch.object( 184 | self.heater, "_request", new_callable=AsyncMock 185 | ) as mock_request: 186 | await self.heater.set_temperature_and_mode(25.0, OperationMode.HIGH) 187 | 188 | # Check that the request was called with the right parameters 189 | mock_request.assert_called_once() 190 | call_args = mock_request.call_args 191 | assert call_args[0][0] == "PUT" # method 192 | assert call_args[0][1] == "setpoint" # endpoint 193 | 194 | request_body = call_args[0][2] # body 195 | assert request_body["Client_ID"] == "test_client" 196 | assert request_body["Operative_Mode"] == "HIG" 197 | assert request_body["Set_Point"] == 0 198 | 199 | async def test_set_preset_mode_request(self): 200 | """Test that preset mode requests include the correct preset temperature.""" 201 | mock_device_info = Info(client_id="test_client", comfort_setpoint=19.0) 202 | 203 | with patch.object( 204 | self.heater, 205 | "get_info", 206 | new_callable=AsyncMock, 207 | return_value=mock_device_info, 208 | ): 209 | with patch.object( 210 | self.heater, "_request", new_callable=AsyncMock 211 | ) as mock_request: 212 | await self.heater.set_operation_mode(OperationMode.COMFORT) 213 | 214 | # Check that the request was called with preset temperature 215 | mock_request.assert_called_once() 216 | call_args = mock_request.call_args 217 | request_body = call_args[0][2] # body 218 | assert request_body["Client_ID"] == "test_client" 219 | assert request_body["Operative_Mode"] == "CMF" 220 | assert ( 221 | request_body["Set_Point"] == 19.0 222 | ) # Should use preset temperature 223 | 224 | async def test_set_manual_mode_request(self): 225 | """Test that manual mode requests include the specified temperature.""" 226 | mock_device_info = Info(client_id="test_client") 227 | 228 | with patch.object( 229 | self.heater, 230 | "get_info", 231 | new_callable=AsyncMock, 232 | return_value=mock_device_info, 233 | ): 234 | with patch.object( 235 | self.heater, "_request", new_callable=AsyncMock 236 | ) as mock_request: 237 | await self.heater.set_temperature_and_mode(22.5, OperationMode.MANUAL) 238 | 239 | # Check that the request includes the specified temperature 240 | mock_request.assert_called_once() 241 | call_args = mock_request.call_args 242 | request_body = call_args[0][2] # body 243 | assert request_body["Client_ID"] == "test_client" 244 | assert request_body["Operative_Mode"] == "MAN" 245 | assert ( 246 | request_body["Set_Point"] == 22.5 247 | ) # Should use specified temperature 248 | 249 | 250 | class TestConvenienceMethods: 251 | """Test convenience method functionality.""" 252 | 253 | def setup_method(self): 254 | """Set up test fixtures.""" 255 | self.heater = StoneConnectHeater("127.0.0.1") 256 | 257 | async def test_is_heating_detection(self): 258 | """Test heating detection based on power consumption.""" 259 | # Test heating (power > 0) 260 | mock_status = Status(operative_mode=OperationMode.MANUAL) 261 | with patch.object( 262 | self.heater, 263 | "get_status", 264 | new_callable=AsyncMock, 265 | return_value=mock_status, 266 | ): 267 | is_heating = await self.heater.is_heating() 268 | assert is_heating is True 269 | 270 | # Test not heating (power = 0) 271 | mock_status = Status(operative_mode=OperationMode.STANDBY) 272 | with patch.object( 273 | self.heater, 274 | "get_status", 275 | new_callable=AsyncMock, 276 | return_value=mock_status, 277 | ): 278 | is_heating = await self.heater.is_heating() 279 | assert is_heating is False 280 | 281 | # Test unknown (power = None) 282 | mock_status = Status(operative_mode=None) 283 | with patch.object( 284 | self.heater, 285 | "get_status", 286 | new_callable=AsyncMock, 287 | return_value=mock_status, 288 | ): 289 | is_heating = await self.heater.is_heating() 290 | assert is_heating is None 291 | 292 | async def test_get_signal_strength(self): 293 | """Test signal strength retrieval.""" 294 | mock_status = Status(rssi=-42) 295 | with patch.object( 296 | self.heater, 297 | "get_status", 298 | new_callable=AsyncMock, 299 | return_value=mock_status, 300 | ): 301 | signal_strength = await self.heater.get_signal_strength() 302 | assert signal_strength == -42 303 | 304 | async def test_get_current_temperature(self): 305 | """Test current temperature retrieval.""" 306 | mock_device_info = Info(set_point=21.5) 307 | with patch.object( 308 | self.heater, 309 | "get_info", 310 | new_callable=AsyncMock, 311 | return_value=mock_device_info, 312 | ): 313 | temp = await self.heater.get_current_temperature() 314 | assert temp == 21.5 315 | 316 | 317 | if __name__ == "__main__": 318 | pytest.main([__file__, "-v"]) 319 | -------------------------------------------------------------------------------- /stone_connect/client.py: -------------------------------------------------------------------------------- 1 | """Stone Connect Heater client.""" 2 | 3 | import base64 4 | import json 5 | import logging 6 | from typing import Any, Dict, Optional, Tuple 7 | 8 | import aiohttp 9 | 10 | from stone_connect.exceptions import ( 11 | StoneConnectAPIError, 12 | StoneConnectAuthenticationError, 13 | StoneConnectConnectionError, 14 | StoneConnectValidationError, 15 | ) 16 | from stone_connect.models import ( 17 | Info, 18 | OperationMode, 19 | Schedule, 20 | Status, 21 | ) 22 | 23 | _LOGGER = logging.getLogger(__name__) 24 | 25 | 26 | class StoneConnectHeater: 27 | """ 28 | Async client for Stone Connect WiFi Electric Heater. 29 | 30 | This client provides methods to control and monitor Stone Connect heaters 31 | via their local HTTPS API. 32 | """ 33 | 34 | # Default credentials found in the decompiled APK 35 | DEFAULT_USERNAME = "App_RadWiFi_v1" 36 | DEFAULT_PASSWORD = "e1qf45s4w8e7q5wda4s5d1as2" 37 | 38 | def __init__( 39 | self, 40 | host: str, 41 | port: int = 443, 42 | username: str = DEFAULT_USERNAME, 43 | password: str = DEFAULT_PASSWORD, 44 | timeout: int = 30, 45 | session: Optional[aiohttp.ClientSession] = None, 46 | ): 47 | """ 48 | Initialize the heater client. 49 | 50 | Args: 51 | host: IP address or hostname of the heater 52 | port: HTTPS port (default: 443) 53 | username: Authentication username (default: App_RadWiFi_v1) 54 | password: Authentication password (default: e1qf45s4w8e7q5wda4s5d1as2) 55 | timeout: Request timeout in seconds 56 | session: Optional existing aiohttp session to use 57 | """ 58 | self.host = host 59 | self.port = port 60 | self.username = username 61 | self.password = password 62 | self.timeout = timeout 63 | self._session = session 64 | self._owned_session = session is None 65 | 66 | self.base_url = f"https://{host}:{port}/Domestic_Heating/Radiators/v1" 67 | 68 | # Create auth header 69 | credentials = f"{username}:{password}" 70 | encoded_credentials = base64.b64encode(credentials.encode()).decode() 71 | self.auth_header = f"Basic {encoded_credentials}" 72 | 73 | async def __aenter__(self) -> "StoneConnectHeater": 74 | """Async context manager entry.""" 75 | await self._ensure_session() 76 | return self 77 | 78 | async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None: 79 | """Async context manager exit.""" 80 | await self.close() 81 | 82 | async def _ensure_session(self) -> aiohttp.ClientSession: 83 | """Ensure we have an active session.""" 84 | if self._session is None or self._session.closed: 85 | connector = aiohttp.TCPConnector(ssl=False) 86 | timeout = aiohttp.ClientTimeout(total=self.timeout) 87 | 88 | self._session = aiohttp.ClientSession( 89 | raise_for_status=True, 90 | connector=connector, 91 | timeout=timeout, 92 | headers={ 93 | "Authorization": self.auth_header, 94 | "Content-Type": "application/json", 95 | "User-Agent": "StoneConnect-Python-Client/1.0", 96 | }, 97 | ) 98 | return self._session 99 | 100 | async def close(self) -> None: 101 | """Close the client session.""" 102 | if self._owned_session and self._session and not self._session.closed: 103 | await self._session.close() 104 | self._session = None 105 | 106 | async def _request( 107 | self, method: str, endpoint: str, data: Optional[Dict[str, Any]] = None 108 | ) -> Dict[str, Any]: 109 | """ 110 | Make an authenticated request to the heater API. 111 | 112 | Args: 113 | method: HTTP method (GET, PUT, POST, etc.) 114 | endpoint: API endpoint (without leading slash) 115 | data: Optional JSON data to send 116 | 117 | Returns: 118 | Parsed JSON response 119 | 120 | Raises: 121 | StoneConnectConnectionError: If the request fails 122 | StoneConnectAuthenticationError: If authentication fails 123 | StoneConnectAPIError: For other API errors 124 | """ 125 | session = await self._ensure_session() 126 | url = f"{self.base_url}/{endpoint.lstrip('/')}" 127 | 128 | try: 129 | _LOGGER.debug(f"Making {method} request to {url}") 130 | 131 | kwargs: Dict[str, Any] = {"method": method, "url": url} 132 | if data is not None: 133 | kwargs["json"] = data 134 | 135 | async with session.request(**kwargs) as response: 136 | response_text = await response.text() 137 | 138 | if response.status == 401: 139 | raise StoneConnectAuthenticationError("Authentication failed") 140 | elif response.status == 404: 141 | raise StoneConnectAPIError(f"Endpoint not found: {endpoint}") 142 | elif not response.ok: 143 | raise StoneConnectAPIError( 144 | f"API request failed: {response.status} - {response_text}" 145 | ) 146 | 147 | # Try to parse JSON response 148 | try: 149 | return await response.json(content_type="text/json") 150 | except json.JSONDecodeError: 151 | # Some endpoints might return plain text or empty responses 152 | return {"response": response_text} if response_text else {} 153 | 154 | except aiohttp.ClientError as e: 155 | raise StoneConnectConnectionError(f"Connection failed: {e}") 156 | 157 | async def get_info(self) -> Info: 158 | """ 159 | Get device information from /info endpoint. 160 | 161 | Returns: 162 | Info object with device details 163 | """ 164 | try: 165 | data = await self._request("GET", "info") 166 | return Info.from_dict(data) 167 | except Exception as e: 168 | _LOGGER.error(f"Failed to get device info: {e}") 169 | raise 170 | 171 | async def get_status(self) -> Status: 172 | """ 173 | Get current device status from /Status endpoint. 174 | 175 | Returns: 176 | Status object with current state 177 | """ 178 | try: 179 | data = await self._request("GET", "status") 180 | return Status.from_dict(data) 181 | except Exception as e: 182 | _LOGGER.error("Failed to get device status: %s", e) 183 | raise 184 | 185 | async def get_schedule(self) -> Schedule: 186 | """ 187 | Get weekly schedule from /Schedule endpoint. 188 | 189 | Returns: 190 | Schedule object with schedule data 191 | """ 192 | try: 193 | data = await self._request("GET", "Schedule") 194 | return Schedule.from_dict(data) 195 | except Exception as e: 196 | _LOGGER.error("Failed to get schedule: %s", e) 197 | raise 198 | 199 | async def set_temperature_and_mode( 200 | self, temperature: float, mode: OperationMode 201 | ) -> None: 202 | """ 203 | Set both temperature and operation mode in a single call. 204 | 205 | Args: 206 | temperature: Target temperature in Celsius (0-30°C, ignored for power modes) 207 | mode: Operation mode 208 | 209 | Raises: 210 | StoneConnectValidationError: If temperature is outside 0-30°C range 211 | """ 212 | try: 213 | # Validate temperature range for modes that use temperature setpoints 214 | if not mode.is_power_mode(): 215 | self._validate_temperature(temperature) 216 | 217 | # Get device info to obtain Client_ID 218 | device_info = await self.get_info() 219 | client_id = device_info.client_id 220 | 221 | # Build the request body 222 | body: Dict[str, Any] = { 223 | "Client_ID": client_id, 224 | "Operative_Mode": mode.value, 225 | } 226 | 227 | # Only include Set_Point for modes that use temperature setpoints 228 | if not mode.is_power_mode(): 229 | body["Set_Point"] = temperature 230 | log_message = "Set temperature to %s°C and mode to %s" 231 | log_args: Tuple[Any, ...] = (temperature, mode.value) 232 | else: 233 | body["Set_Point"] = 0 234 | log_message = "Set mode to %s (power mode, no temperature)" 235 | log_args = (mode.value,) 236 | 237 | await self._request("PUT", "setpoint", body) 238 | _LOGGER.info(log_message, *log_args) 239 | except Exception as e: 240 | _LOGGER.error("Failed to set temperature and mode: %s", e) 241 | raise 242 | 243 | async def set_temperature( 244 | self, temperature: float, mode: Optional[OperationMode] = None 245 | ) -> None: 246 | """ 247 | Set target temperature and optionally operation mode. 248 | 249 | Note: This method only works with modes that accept custom temperatures: 250 | - MANUAL: Manual temperature control 251 | - BOOST: Boost mode with custom temperature 252 | 253 | For preset modes (COMFORT, ECO, ANTIFREEZE), use set_operation_mode() instead. 254 | Power modes (HIGH, MEDIUM, LOW) don't use temperature setpoints. 255 | 256 | Args: 257 | temperature: Target temperature in Celsius (0-30°C) 258 | mode: Optional operation mode (if not provided, will get current mode from device) 259 | 260 | Raises: 261 | StoneConnectValidationError: If temperature is outside range or mode doesn't support custom temperatures 262 | """ 263 | # Validate temperature range 264 | self._validate_temperature(temperature) 265 | 266 | if mode is None: 267 | # Get current mode from device 268 | status = await self.get_status() 269 | mode = status.operative_mode or OperationMode.MANUAL 270 | 271 | # Validate that the mode supports custom temperature setpoints 272 | if not mode.is_custom_mode(): 273 | if mode.is_power_mode(): 274 | raise StoneConnectValidationError( 275 | f"Power mode {mode.value} doesn't use temperature setpoints. Use set_operation_mode() instead." 276 | ) 277 | elif mode.is_preset_mode(): 278 | raise StoneConnectValidationError( 279 | f"Preset mode {mode.value} uses predefined temperature. Use set_operation_mode() instead." 280 | ) 281 | else: 282 | raise StoneConnectValidationError( 283 | f"Mode {mode.value} doesn't support custom temperature setpoints." 284 | ) 285 | 286 | await self.set_temperature_and_mode(temperature, mode) 287 | 288 | async def set_operation_mode(self, mode: OperationMode) -> None: 289 | """ 290 | Set operation mode. Automatically handles temperature setpoints based on mode type: 291 | 292 | - Power modes (HIGH, MEDIUM, LOW): Don't use temperature setpoints 293 | - Preset modes (COMFORT, ECO, ANTIFREEZE): Use predefined temperatures from device settings 294 | - Custom modes (MANUAL, BOOST): Keep current temperature or use default 295 | - Other modes (STANDBY, SCHEDULE, HOLIDAY): Keep current temperature 296 | 297 | Args: 298 | mode: Operation mode to set 299 | 300 | Returns: 301 | True if successful 302 | """ 303 | # Get device info and current status 304 | device_info = await self.get_info() 305 | 306 | # Determine appropriate temperature setpoint based on mode 307 | if mode.is_power_mode(): 308 | # Power modes don't use temperature setpoints - set to 0 or current 309 | temperature = 0.0 # Power modes typically don't need a temperature 310 | elif mode.is_preset_mode(): 311 | # Use preset temperature from device settings 312 | preset_temp = mode.get_preset_setpoint(device_info) 313 | if preset_temp is None: 314 | raise StoneConnectValidationError( 315 | f"No preset temperature found for mode {mode.value}" 316 | ) 317 | temperature = preset_temp 318 | else: 319 | # For other modes, keep current temperature or use reasonable default 320 | status = await self.get_status() 321 | temperature = status.set_point or 20.0 # Default to 20°C if no current temp 322 | 323 | await self.set_temperature_and_mode(temperature, mode) 324 | 325 | async def is_online(self) -> bool: 326 | """ 327 | Check if device is online and responding. 328 | 329 | Returns: 330 | True if device is online and responding 331 | """ 332 | try: 333 | await self.get_status() 334 | return True 335 | except Exception: 336 | return False 337 | 338 | async def has_power_measurement_support(self) -> bool: 339 | """ 340 | Determine if the device supports power measurement capability. 341 | 342 | This method asynchronously checks whether the connected device has power measurement 343 | functionality by examining the Load_Size_Watt field from the device information. 344 | """ 345 | info = await self.get_info() 346 | return info.load_size_watt != 0 347 | 348 | # Convenience methods 349 | async def set_comfort_mode(self) -> None: 350 | """Set heater to comfort mode using the predefined comfort temperature.""" 351 | await self.set_operation_mode(OperationMode.COMFORT) 352 | 353 | async def set_eco_mode(self) -> None: 354 | """Set heater to eco mode using the predefined eco temperature.""" 355 | await self.set_operation_mode(OperationMode.ECO) 356 | 357 | async def set_antifreeze_mode(self) -> None: 358 | """Set heater to antifreeze mode using the predefined antifreeze temperature.""" 359 | await self.set_operation_mode(OperationMode.ANTIFREEZE) 360 | 361 | async def set_manual_temperature(self, temperature: float) -> None: 362 | """Set heater to manual mode with a specific temperature.""" 363 | await self.set_temperature(temperature, OperationMode.MANUAL) 364 | 365 | async def set_power_mode(self, power_level: OperationMode) -> None: 366 | """Set heater to a power-based mode (HIGH, MEDIUM, LOW).""" 367 | if not power_level.is_power_mode(): 368 | raise StoneConnectValidationError( 369 | f"{power_level.value} is not a valid power mode. Use HIGH, MEDIUM, or LOW." 370 | ) 371 | await self.set_operation_mode(power_level) 372 | 373 | async def set_standby(self) -> None: 374 | """Set heater to standby mode (essentially off but still connected).""" 375 | await self.set_operation_mode(OperationMode.STANDBY) 376 | 377 | # Property-like methods for convenience 378 | async def get_current_temperature(self) -> Optional[float]: 379 | """Get current temperature from device info (not available in status).""" 380 | info = await self.get_info() 381 | return info.set_point # Current setpoint, no actual temperature sensor 382 | 383 | async def get_target_temperature(self) -> Optional[float]: 384 | """Get target temperature.""" 385 | status = await self.get_status() 386 | return status.set_point 387 | 388 | async def is_heating(self) -> Optional[bool]: 389 | """Check if device is currently heating (any mode other than STANDBY).""" 390 | status = await self.get_status() 391 | if status.operative_mode is None: 392 | return None 393 | return status.operative_mode != OperationMode.STANDBY 394 | 395 | async def get_signal_strength(self) -> Optional[int]: 396 | """Get WiFi signal strength (RSSI).""" 397 | status = await self.get_status() 398 | return status.rssi 399 | 400 | async def is_locked(self) -> Optional[bool]: 401 | """Check if device is locked.""" 402 | status = await self.get_status() 403 | return status.lock_status 404 | 405 | async def get_error_code(self) -> Optional[int]: 406 | """Get current error code (0 = no error).""" 407 | status = await self.get_status() 408 | return status.error_code 409 | 410 | async def get_daily_energy(self) -> Optional[int]: 411 | """Get daily energy consumption.""" 412 | status = await self.get_status() 413 | return status.daily_energy 414 | 415 | async def get_power_consumption(self) -> Optional[int]: 416 | """Get current power consumption in watts.""" 417 | status = await self.get_status() 418 | return status.power_consumption_watt 419 | 420 | @staticmethod 421 | def _validate_temperature(temperature: float) -> None: 422 | """ 423 | Validate temperature is within acceptable range (0-30°C). 424 | 425 | Args: 426 | temperature: Temperature in Celsius to validate 427 | 428 | Raises: 429 | StoneConnectValidationError: If temperature is outside the 0-30°C range 430 | """ 431 | if temperature < 0: 432 | raise StoneConnectValidationError( 433 | f"Temperature {temperature}°C is below minimum limit of 0°C" 434 | ) 435 | if temperature > 30: 436 | raise StoneConnectValidationError( 437 | f"Temperature {temperature}°C is above maximum limit of 30°C" 438 | ) 439 | 440 | @staticmethod 441 | def _is_power_mode(mode: OperationMode) -> bool: 442 | """Check if a mode is a power mode (HIGH, MEDIUM, LOW).""" 443 | return mode in [OperationMode.HIGH, OperationMode.MEDIUM, OperationMode.LOW] 444 | 445 | @staticmethod 446 | def _is_preset_mode(mode: OperationMode) -> bool: 447 | """Check if a mode is a preset mode (COMFORT, ECO, ANTIFREEZE).""" 448 | return mode in [ 449 | OperationMode.COMFORT, 450 | OperationMode.ECO, 451 | OperationMode.ANTIFREEZE, 452 | ] 453 | 454 | @staticmethod 455 | def _is_custom_temperature_mode(mode: OperationMode) -> bool: 456 | """Check if a mode is a custom temperature mode (MANUAL, BOOST).""" 457 | return mode in [OperationMode.MANUAL, OperationMode.BOOST] 458 | 459 | def _get_preset_setpoint( 460 | self, mode: OperationMode, device_info: Info 461 | ) -> Optional[float]: 462 | """Get the preset setpoint temperature for a given mode.""" 463 | if mode == OperationMode.COMFORT: 464 | return device_info.comfort_setpoint 465 | elif mode == OperationMode.ECO: 466 | return device_info.eco_setpoint 467 | elif mode == OperationMode.ANTIFREEZE: 468 | return device_info.antifreeze_setpoint 469 | else: 470 | return None 471 | --------------------------------------------------------------------------------