├── .gitignore ├── icon.png ├── hacs.json ├── .github └── workflows │ ├── hacs-action.yml │ └── hassfest.yml ├── manifest.json ├── custom_components └── smarthub │ ├── manifest.json │ ├── services.yaml │ ├── exceptions.py │ ├── const.py │ ├── strings.json │ ├── translations │ └── en.json │ ├── config_flow.py │ ├── __init___minimal.py │ ├── __init__.py │ ├── test_integration.py │ ├── config_flow_complex.py │ ├── __init___working.py │ ├── sensor.py │ └── api.py ├── LICENSE ├── info.md ├── test_integration.py ├── CHANGELOG.md └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__/ 2 | *swp 3 | -------------------------------------------------------------------------------- /icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gagata/ha-smarthub-energy-sensor/HEAD/icon.png -------------------------------------------------------------------------------- /hacs.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "SmartHub Coop Energy", 3 | "render_readme": true, 4 | "homeassistant": "2024.1.0", 5 | "hacs": "1.34.0" 6 | } 7 | -------------------------------------------------------------------------------- /.github/workflows/hacs-action.yml: -------------------------------------------------------------------------------- 1 | name: HACS Action 2 | 3 | on: 4 | push: 5 | 6 | jobs: 7 | hacs: 8 | name: HACS Action 9 | runs-on: "ubuntu-latest" 10 | steps: 11 | - name: HACS Action 12 | uses: "hacs/action@main" 13 | with: 14 | category: "integration" 15 | ignore: "brands" 16 | -------------------------------------------------------------------------------- /.github/workflows/hassfest.yml: -------------------------------------------------------------------------------- 1 | name: Validate with hassfest 2 | 3 | on: 4 | push: 5 | 6 | jobs: 7 | validate: 8 | runs-on: "ubuntu-latest" 9 | steps: 10 | - uses: "actions/checkout@v4" 11 | with: 12 | path: "smarthub" 13 | - name: Validate with hassfest 14 | uses: "home-assistant/actions/hassfest@master" 15 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "domain": "smarthub", 3 | "name": "SmartHub Coop Energy", 4 | "codeowners": ["@gagata"], 5 | "config_flow": true, 6 | "dependencies": [], 7 | "documentation": "https://github.com/gagata/ha-smarthub-energy-sensor", 8 | "iot_class": "cloud_polling", 9 | "issue_tracker": "https://github.com/gagata/ha-smarthub-energy-sensor/issues", 10 | "requirements": ["aiohttp>=3.8.0"], 11 | "version": "2.1.4" 12 | } 13 | 14 | -------------------------------------------------------------------------------- /custom_components/smarthub/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "domain": "smarthub", 3 | "name": "SmartHub Coop Energy", 4 | "codeowners": ["@gagata"], 5 | "config_flow": true, 6 | "dependencies": [], 7 | "documentation": "https://github.com/gagata/ha-smarthub-energy-sensor", 8 | "iot_class": "cloud_polling", 9 | "issue_tracker": "https://github.com/gagata/ha-smarthub-energy-sensor/issues", 10 | "requirements": ["aiohttp>=3.8.0", "pyotp"], 11 | "version": "2.1.4" 12 | } 13 | 14 | -------------------------------------------------------------------------------- /custom_components/smarthub/services.yaml: -------------------------------------------------------------------------------- 1 | # SmartHub Energy Integration Services 2 | 3 | refresh_data: 4 | name: Refresh Energy Data 5 | description: Manually refresh energy usage data from SmartHub 6 | target: 7 | entity: 8 | domain: sensor 9 | integration: smarthub 10 | 11 | refresh_authentication: 12 | name: Refresh Authentication 13 | description: Force refresh of SmartHub authentication token 14 | target: 15 | entity: 16 | domain: sensor 17 | integration: smarthub -------------------------------------------------------------------------------- /custom_components/smarthub/exceptions.py: -------------------------------------------------------------------------------- 1 | """Exceptions for SmartHub integration.""" 2 | 3 | 4 | class SmartHubError(Exception): 5 | """Base class for SmartHub errors.""" 6 | 7 | 8 | class SmartHubConfigError(SmartHubError): 9 | """Configuration error.""" 10 | 11 | 12 | class SmartHubConnectionError(SmartHubError): 13 | """Connection error.""" 14 | 15 | 16 | class SmartHubAuthenticationError(SmartHubError): 17 | """Authentication error.""" 18 | 19 | 20 | class SmartHubDataError(SmartHubError): 21 | """Data parsing error.""" 22 | 23 | 24 | class SmartHubTimeoutError(SmartHubError): 25 | """Request timeout error.""" -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 SmartHub Energy Sensor Contributors 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /custom_components/smarthub/const.py: -------------------------------------------------------------------------------- 1 | """Constants for the SmartHub integration.""" 2 | 3 | DOMAIN = "smarthub" 4 | 5 | # Configuration keys 6 | CONF_EMAIL = "email" 7 | CONF_PASSWORD = "password" 8 | CONF_ACCOUNT_ID = "account_id" 9 | CONF_LOCATION_ID = "location_id" 10 | CONF_HOST = "host" 11 | CONF_POLL_INTERVAL = "poll_interval" 12 | 13 | # Default values 14 | DEFAULT_POLL_INTERVAL = 60 # 1 hour in minutes (more frequent for energy monitoring) 15 | MIN_POLL_INTERVAL = 15 # Minimum 15 minutes 16 | MAX_POLL_INTERVAL = 1440 # Maximum 24 hours 17 | 18 | # API constants 19 | DEFAULT_TIMEOUT = 30 # seconds 20 | MAX_RETRIES = 3 21 | RETRY_DELAY = 5 # seconds 22 | SESSION_TIMEOUT = 300 # 5 minutes - force session refresh 23 | HISTORICAL_IMPORT_DAYS = 90 # number of days for initial import 24 | 25 | # Sensor constants 26 | ENERGY_SENSOR_KEY = "current_energy_usage" 27 | ATTR_LAST_READING_TIME = "last_reading_time" 28 | ATTR_ACCOUNT_ID = "account_id" 29 | ATTR_LOCATION_ID = "location_id" 30 | LOCATION_KEY = "location" 31 | 32 | # List of supported services provided by the smarthub endpoint 33 | ELECTRIC_SERVICE = "ELEC" 34 | SUPPORTED_SERVICES = [ELECTRIC_SERVICE] 35 | -------------------------------------------------------------------------------- /info.md: -------------------------------------------------------------------------------- 1 | # SmartHub Coop Energy Integration 2 | 3 | A Home Assistant custom integration that connects to SmartHub Coop energy portals to provide real-time electricity usage data. 4 | 5 | ## Features 6 | 7 | - ✅ **Energy Dashboard Compatible**: Works seamlessly with Home Assistant's built-in Energy Dashboard 8 | - ✅ **Real-time Monitoring**: Configurable polling intervals for up-to-date data 9 | - ✅ **Secure Authentication**: Robust credential handling with automatic token refresh 10 | - ✅ **Production Ready**: Comprehensive error handling and retry logic 11 | 12 | ## Installation 13 | 14 | 1. Add this repository to HACS as a custom repository 15 | 2. Install the "SmartHub Coop Energy" integration 16 | 3. Restart Home Assistant 17 | 4. Add the integration through the UI (Settings → Devices & Services → Add Integration) 18 | 19 | ## Configuration 20 | 21 | You'll need your SmartHub portal credentials: 22 | - Email address 23 | - Password 24 | - Account ID 25 | - Location ID 26 | - Host URL (your SmartHub portal URL) 27 | 28 | ## Support 29 | 30 | Visit the [GitHub repository](https://github.com/gagata/ha-smarthub-energy-sensor) for documentation, issues, and updates. -------------------------------------------------------------------------------- /custom_components/smarthub/strings.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "SmartHub Energy Integration", 3 | "config": { 4 | "step": { 5 | "user": { 6 | "data": { 7 | "email": "Email Address", 8 | "password": "Password", 9 | "account_id": "Account ID", 10 | "host": "SmartHub Host", 11 | "timezone": "Timezone for power company", 12 | "mfa_totp": "MFA TOTP Seed" 13 | }, 14 | "data_description" : { 15 | "host": "e.g XXXXXX.smarthub.coop", 16 | "timezone": "Timezone for power company", 17 | "mfa_totp": "MFA TOTP Seed - only required if using MFA" 18 | } 19 | } 20 | }, 21 | "error": { 22 | "cannot_connect": "Failed to connect to SmartHub. Please check your host and network connection.", 23 | "invalid_auth": "Invalid credentials. Please check your email and password.", 24 | "invalid_input": "Invalid input provided. Please check all fields.", 25 | "unknown": "Unexpected error occurred. Please check the logs." 26 | }, 27 | "abort": { 28 | "already_configured": "SmartHub integration is already configured for this account and host." 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /custom_components/smarthub/translations/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "SmartHub Energy Integration", 3 | "config": { 4 | "step": { 5 | "user": { 6 | "data": { 7 | "email": "Email Address", 8 | "password": "Password", 9 | "account_id": "Account ID", 10 | "host": "SmartHub Host", 11 | "timezone": "Timezone for power company", 12 | "mfa_totp": "MFA TOTP Seed" 13 | }, 14 | "data_description" : { 15 | "host": "e.g XXXXXX.smarthub.coop", 16 | "timezone": "Timezone for power company", 17 | "mfa_totp": "MFA TOTP Seed - only required if using MFA" 18 | } 19 | } 20 | }, 21 | "error": { 22 | "cannot_connect": "Failed to connect to SmartHub. Please check your host and network connection.", 23 | "invalid_auth": "Invalid credentials. Please check your email and password.", 24 | "invalid_input": "Invalid input provided. Please check all fields.", 25 | "unknown": "Unexpected error occurred. Please check the logs." 26 | }, 27 | "abort": { 28 | "already_configured": "SmartHub integration is already configured for this account and host." 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /custom_components/smarthub/config_flow.py: -------------------------------------------------------------------------------- 1 | """Simple config flow for SmartHub integration.""" 2 | import voluptuous as vol 3 | from homeassistant import config_entries 4 | from .const import DOMAIN 5 | 6 | from typing import Any 7 | import zoneinfo 8 | import logging 9 | from homeassistant.helpers.selector import ( 10 | SelectSelector, 11 | SelectSelectorConfig, 12 | SelectSelectorMode, 13 | 14 | TextSelector, 15 | TextSelectorConfig, 16 | TextSelectorType 17 | ) 18 | 19 | _LOGGER = logging.getLogger(__name__) 20 | 21 | class SmartHubConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): 22 | """Handle a config flow for SmartHub.""" 23 | 24 | VERSION = 1 25 | MINOR_VERSION = 1 # Updated to handle addition of TOTP, and TimeZone 26 | 27 | async def async_step_user(self, user_input=None) -> config_entries.ConfigFlowResult: 28 | """Handle the initial step.""" 29 | if user_input is not None: 30 | if self.source == config_entries.SOURCE_RECONFIGURE: 31 | return self.async_update_reload_and_abort( 32 | self._get_reconfigure_entry(), data_updates=user_input 33 | ) 34 | # else - create a new entry 35 | return self.async_create_entry( 36 | title="SmartHub", 37 | data=user_input, 38 | ) 39 | 40 | schema_values: dict[str, Any] | MappingProxyType[str, Any] = {} 41 | if self.source == config_entries.SOURCE_RECONFIGURE: 42 | schema_values = self._get_reconfigure_entry().data 43 | 44 | timezones = await self.hass.async_add_executor_job( 45 | zoneinfo.available_timezones 46 | ) 47 | schema = vol.Schema( 48 | { 49 | vol.Required("email"): str, 50 | vol.Required("password"): str, 51 | vol.Required("account_id"): str, 52 | vol.Required("host"): str, 53 | vol.Required("timezone", default="GMT"): SelectSelector( 54 | SelectSelectorConfig( 55 | options=list(timezones), mode=SelectSelectorMode.DROPDOWN, sort=True 56 | ) 57 | ), 58 | vol.Optional("mfa_totp"): TextSelector( 59 | TextSelectorConfig( 60 | type=TextSelectorType.PASSWORD 61 | ) 62 | ), 63 | } 64 | ) 65 | 66 | errors = {} 67 | 68 | # Show basic form 69 | return self.async_show_form( 70 | step_id="user", 71 | data_schema=self.add_suggested_values_to_schema( 72 | schema, 73 | schema_values, 74 | ), 75 | errors=errors 76 | ) 77 | 78 | async def async_step_reconfigure(self, user_input: dict[str, Any] | None = None) -> config_entries.ConfigFlowResult: 79 | """Handle a reconfiguration config flow initialized by the user.""" 80 | return await self.async_step_user(user_input) 81 | -------------------------------------------------------------------------------- /custom_components/smarthub/__init___minimal.py: -------------------------------------------------------------------------------- 1 | """The SmartHub integration - Minimal Version.""" 2 | from __future__ import annotations 3 | 4 | import logging 5 | from typing import Dict, Any 6 | 7 | from homeassistant.config_entries import ConfigEntry 8 | from homeassistant.core import HomeAssistant 9 | from homeassistant.exceptions import ConfigEntryError 10 | 11 | from .api import SmartHubAPI 12 | from .const import DOMAIN, CONF_POLL_INTERVAL, DEFAULT_POLL_INTERVAL 13 | 14 | _LOGGER = logging.getLogger(__name__) 15 | 16 | PLATFORMS: list[str] = ["sensor"] 17 | 18 | 19 | async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 20 | """Set up SmartHub from a config entry.""" 21 | config = entry.data 22 | 23 | # Validate required configuration 24 | required_fields = ["email", "password", "account_id", "location_id", "host"] 25 | missing_fields = [field for field in required_fields if not config.get(field)] 26 | 27 | if missing_fields: 28 | _LOGGER.error("Missing required configuration fields: %s", missing_fields) 29 | raise ConfigEntryError(f"Missing configuration fields: {missing_fields}") 30 | 31 | # Initialize the API object 32 | api = SmartHubAPI( 33 | email=config["email"], 34 | password=config["password"], 35 | account_id=config["account_id"], 36 | location_id=config["location_id"], 37 | host=config["host"], 38 | ) 39 | 40 | # Test the connection 41 | try: 42 | await api.get_token() 43 | _LOGGER.info("Successfully connected to SmartHub API") 44 | except Exception as e: 45 | _LOGGER.error("Failed to connect to SmartHub API: %s", e) 46 | await api.close() 47 | raise ConfigEntryError(f"Cannot connect to SmartHub: {e}") from e 48 | 49 | # Store the API instance and configuration in hass.data 50 | hass.data.setdefault(DOMAIN, {}) 51 | hass.data[DOMAIN][entry.entry_id] = { 52 | "api": api, 53 | "poll_interval": config.get(CONF_POLL_INTERVAL, DEFAULT_POLL_INTERVAL), 54 | "config": config, 55 | } 56 | 57 | _LOGGER.debug("Stored SmartHub API instance for entry %s", entry.entry_id) 58 | 59 | # Set up platforms 60 | await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) 61 | 62 | return True 63 | 64 | 65 | async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 66 | """Unload a config entry.""" 67 | # Unload platforms 68 | unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) 69 | 70 | if unload_ok: 71 | # Clean up API connection 72 | data = hass.data[DOMAIN].get(entry.entry_id) 73 | if data and "api" in data: 74 | api = data["api"] 75 | await api.close() 76 | _LOGGER.debug("Closed SmartHub API connection for entry %s", entry.entry_id) 77 | 78 | # Remove data 79 | hass.data[DOMAIN].pop(entry.entry_id, None) 80 | 81 | # Remove domain data if no entries left 82 | if not hass.data[DOMAIN]: 83 | hass.data.pop(DOMAIN, None) 84 | 85 | return unload_ok -------------------------------------------------------------------------------- /custom_components/smarthub/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Custom integration to integrate SmartHub Coop energy sensors with Home Assistant. 3 | 4 | For more details about this integration, please refer to 5 | https://github.com/gagata/ha-smarthub-energy-sensor 6 | """ 7 | from __future__ import annotations 8 | 9 | import logging 10 | 11 | from homeassistant.config_entries import ConfigEntry 12 | from homeassistant.const import Platform 13 | from homeassistant.core import HomeAssistant 14 | from homeassistant.exceptions import ConfigEntryError 15 | 16 | from .api import SmartHubAPI 17 | from .sensor import SmartHubDataUpdateCoordinator 18 | from .const import DOMAIN 19 | 20 | from datetime import timedelta 21 | 22 | # Remove explicit config flow import 23 | # from . import config_flow # noqa: F401 24 | 25 | _LOGGER = logging.getLogger(__name__) 26 | 27 | PLATFORMS: list[Platform] = [Platform.SENSOR] 28 | 29 | 30 | async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 31 | """Set up SmartHub from a config entry.""" 32 | config = entry.data 33 | 34 | # Validate required configuration 35 | required_fields = ["email", "password", "account_id", "host"] 36 | missing_fields = [field for field in required_fields if not config.get(field)] 37 | 38 | if missing_fields: 39 | _LOGGER.error("Missing required configuration fields: %s", missing_fields) 40 | raise ConfigEntryError(f"Missing configuration fields: {missing_fields}") 41 | 42 | # Initialize the API object 43 | api = SmartHubAPI( 44 | email=config["email"], 45 | password=config["password"], 46 | account_id=config["account_id"], 47 | timezone=config.get("timezone", "GMT"), # timezone was not previously required - default it to be GMT 48 | mfa_totp=config.get("mfa_totp", ""), # mfa_totp is optional 49 | host=config["host"], 50 | ) 51 | 52 | # Test the connection 53 | try: 54 | await api.get_token() 55 | _LOGGER.info("Successfully connected to SmartHub API") 56 | except Exception as e: 57 | _LOGGER.error("Failed to connect to SmartHub API: %s", e) 58 | await api.close() 59 | raise ConfigEntryError(f"Cannot connect to SmartHub: {e}") from e 60 | 61 | # Create update coordinator, and store in the config entry 62 | coordinator = SmartHubDataUpdateCoordinator( 63 | hass=hass, 64 | api=api, 65 | update_interval=timedelta(minutes=config.get("poll_interval", 720)), 66 | config_entry=entry, 67 | ) 68 | await coordinator.async_config_entry_first_refresh() 69 | entry.runtime_data = coordinator 70 | 71 | # Set up platforms 72 | await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) 73 | 74 | return True 75 | 76 | 77 | async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 78 | """Unload a config entry.""" 79 | # Unload platforms 80 | unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) 81 | 82 | if unload_ok: 83 | # Clean up API connection 84 | data = hass.data.get(DOMAIN,{}).get(entry.entry_id) 85 | 86 | if hasattr(entry, "runtime_data") and hasattr(entry.runtime_data, "api"): 87 | api= entry.runtime_data.api 88 | 89 | if data: 90 | if isinstance(data, dict) and "api" in data: 91 | api = data["api"] 92 | else: 93 | api = data # Direct API reference 94 | 95 | if api: 96 | await api.close() 97 | 98 | # Remove data 99 | hass.data.get(DOMAIN,{}).pop(entry.entry_id, None) 100 | 101 | # Remove domain data if no entries left 102 | if DOMAIN in hass.data: 103 | hass.data.pop(DOMAIN, None) 104 | 105 | return unload_ok 106 | 107 | -------------------------------------------------------------------------------- /test_integration.py: -------------------------------------------------------------------------------- 1 | """Test file for basic SmartHub integration functionality.""" 2 | import pytest 3 | import asyncio 4 | from unittest.mock import Mock, patch 5 | from homeassistant.core import HomeAssistant 6 | from homeassistant.config_entries import ConfigEntry 7 | 8 | from custom_components.smarthub import async_setup_entry, async_unload_entry 9 | from custom_components.smarthub.api import SmartHubAPI, parse_last_usage, SmartHubAPIError 10 | from custom_components.smarthub.const import DOMAIN 11 | 12 | 13 | @pytest.fixture 14 | def mock_config_entry(): 15 | """Create a mock config entry.""" 16 | return ConfigEntry( 17 | version=1, 18 | domain=DOMAIN, 19 | title="SmartHub Test", 20 | data={ 21 | "email": "test@example.com", 22 | "password": "testpass", 23 | "account_id": "123456", 24 | "location_id": "789012", 25 | "host": "test.smarthub.coop", 26 | "poll_interval": 60, 27 | }, 28 | unique_id="test@example.com_test.smarthub.coop_123456", 29 | ) 30 | 31 | 32 | @pytest.fixture 33 | def mock_hass(): 34 | """Create a mock Home Assistant instance.""" 35 | hass = Mock(spec=HomeAssistant) 36 | hass.data = {} 37 | return hass 38 | 39 | 40 | def test_parse_last_usage_valid_data(): 41 | """Test parsing valid usage data.""" 42 | test_data = { 43 | "data": { 44 | "ELECTRIC": [ 45 | { 46 | "type": "USAGE", 47 | "series": [ 48 | { 49 | "data": [ 50 | {"x": 1640995200000, "y": 100.5}, 51 | {"x": 1641081600000, "y": 150.2}, 52 | ] 53 | } 54 | ] 55 | } 56 | ] 57 | } 58 | } 59 | 60 | result = parse_last_usage(test_data) 61 | 62 | assert result is not None 63 | assert result["current_energy_usage"] == 150.2 64 | assert "last_reading_time" in result 65 | 66 | 67 | def test_parse_last_usage_no_data(): 68 | """Test parsing when no usage data is available.""" 69 | test_data = {"data": {"ELECTRIC": []}} 70 | 71 | result = parse_last_usage(test_data) 72 | 73 | assert result is None 74 | 75 | 76 | def test_parse_last_usage_invalid_data(): 77 | """Test parsing invalid data raises appropriate error.""" 78 | with pytest.raises(Exception): 79 | parse_last_usage("invalid_data") 80 | 81 | 82 | @pytest.mark.asyncio 83 | async def test_async_setup_entry_success(mock_hass, mock_config_entry): 84 | """Test successful setup of config entry.""" 85 | with patch("custom_components.smarthub.SmartHubAPI") as mock_api_class: 86 | mock_api = Mock() 87 | mock_api.get_token.return_value = "test_token" 88 | mock_api_class.return_value = mock_api 89 | 90 | with patch("custom_components.smarthub.hass.config_entries.async_forward_entry_setups"): 91 | result = await async_setup_entry(mock_hass, mock_config_entry) 92 | 93 | assert result is True 94 | assert DOMAIN in mock_hass.data 95 | assert mock_config_entry.entry_id in mock_hass.data[DOMAIN] 96 | 97 | 98 | @pytest.mark.asyncio 99 | async def test_async_setup_entry_connection_failure(mock_hass, mock_config_entry): 100 | """Test setup failure due to connection error.""" 101 | with patch("custom_components.smarthub.SmartHubAPI") as mock_api_class: 102 | mock_api = Mock() 103 | mock_api.get_token.side_effect = SmartHubAPIError("Connection failed") 104 | mock_api_class.return_value = mock_api 105 | 106 | with pytest.raises(Exception): 107 | await async_setup_entry(mock_hass, mock_config_entry) 108 | 109 | 110 | @pytest.mark.asyncio 111 | async def test_smarthub_api_basic_functionality(): 112 | """Test basic SmartHub API functionality.""" 113 | api = SmartHubAPI( 114 | email="test@example.com", 115 | password="testpass", 116 | account_id="123456", 117 | location_id="789012", 118 | host="test.smarthub.coop" 119 | ) 120 | 121 | # Test that API object is created correctly 122 | assert api.email == "test@example.com" 123 | assert api.account_id == "123456" 124 | assert api.location_id == "789012" 125 | assert api.host == "test.smarthub.coop" 126 | assert api.token is None 127 | 128 | 129 | if __name__ == "__main__": 130 | # Run basic tests 131 | test_parse_last_usage_valid_data() 132 | test_parse_last_usage_no_data() 133 | print("Basic tests passed!") -------------------------------------------------------------------------------- /custom_components/smarthub/test_integration.py: -------------------------------------------------------------------------------- 1 | """Test file for basic SmartHub integration functionality.""" 2 | import pytest 3 | import asyncio 4 | from unittest.mock import Mock, patch 5 | from homeassistant.core import HomeAssistant 6 | from homeassistant.config_entries import ConfigEntry 7 | 8 | from custom_components.smarthub import async_setup_entry, async_unload_entry 9 | from custom_components.smarthub.api import SmartHubAPI, parse_last_usage, SmartHubAPIError 10 | from custom_components.smarthub.const import DOMAIN 11 | 12 | 13 | @pytest.fixture 14 | def mock_config_entry(): 15 | """Create a mock config entry.""" 16 | return ConfigEntry( 17 | version=1, 18 | domain=DOMAIN, 19 | title="SmartHub Test", 20 | data={ 21 | "email": "test@example.com", 22 | "password": "testpass", 23 | "account_id": "123456", 24 | "location_id": "789012", 25 | "host": "test.smarthub.coop", 26 | "poll_interval": 60, 27 | }, 28 | unique_id="test@example.com_test.smarthub.coop_123456", 29 | ) 30 | 31 | 32 | @pytest.fixture 33 | def mock_hass(): 34 | """Create a mock Home Assistant instance.""" 35 | hass = Mock(spec=HomeAssistant) 36 | hass.data = {} 37 | return hass 38 | 39 | 40 | def test_parse_last_usage_valid_data(): 41 | """Test parsing valid usage data.""" 42 | test_data = { 43 | "data": { 44 | "ELECTRIC": [ 45 | { 46 | "type": "USAGE", 47 | "series": [ 48 | { 49 | "data": [ 50 | {"x": 1640995200000, "y": 100.5}, 51 | {"x": 1641081600000, "y": 150.2}, 52 | ] 53 | } 54 | ] 55 | } 56 | ] 57 | } 58 | } 59 | 60 | result = parse_last_usage(test_data) 61 | 62 | assert result is not None 63 | assert result["current_energy_usage"] == 150.2 64 | assert "last_reading_time" in result 65 | 66 | 67 | def test_parse_last_usage_no_data(): 68 | """Test parsing when no usage data is available.""" 69 | test_data = {"data": {"ELECTRIC": []}} 70 | 71 | result = parse_last_usage(test_data) 72 | 73 | assert result is None 74 | 75 | 76 | def test_parse_last_usage_invalid_data(): 77 | """Test parsing invalid data raises appropriate error.""" 78 | with pytest.raises(Exception): 79 | parse_last_usage("invalid_data") 80 | 81 | 82 | @pytest.mark.asyncio 83 | async def test_async_setup_entry_success(mock_hass, mock_config_entry): 84 | """Test successful setup of config entry.""" 85 | with patch("custom_components.smarthub.SmartHubAPI") as mock_api_class: 86 | mock_api = Mock() 87 | mock_api.get_token.return_value = "test_token" 88 | mock_api_class.return_value = mock_api 89 | 90 | with patch("custom_components.smarthub.hass.config_entries.async_forward_entry_setups"): 91 | result = await async_setup_entry(mock_hass, mock_config_entry) 92 | 93 | assert result is True 94 | assert DOMAIN in mock_hass.data 95 | assert mock_config_entry.entry_id in mock_hass.data[DOMAIN] 96 | 97 | 98 | @pytest.mark.asyncio 99 | async def test_async_setup_entry_connection_failure(mock_hass, mock_config_entry): 100 | """Test setup failure due to connection error.""" 101 | with patch("custom_components.smarthub.SmartHubAPI") as mock_api_class: 102 | mock_api = Mock() 103 | mock_api.get_token.side_effect = SmartHubAPIError("Connection failed") 104 | mock_api_class.return_value = mock_api 105 | 106 | with pytest.raises(Exception): 107 | await async_setup_entry(mock_hass, mock_config_entry) 108 | 109 | 110 | @pytest.mark.asyncio 111 | async def test_smarthub_api_basic_functionality(): 112 | """Test basic SmartHub API functionality.""" 113 | api = SmartHubAPI( 114 | email="test@example.com", 115 | password="testpass", 116 | account_id="123456", 117 | location_id="789012", 118 | host="test.smarthub.coop" 119 | ) 120 | 121 | # Test that API object is created correctly 122 | assert api.email == "test@example.com" 123 | assert api.account_id == "123456" 124 | assert api.location_id == "789012" 125 | assert api.host == "test.smarthub.coop" 126 | assert api.token is None 127 | 128 | 129 | if __name__ == "__main__": 130 | # Run basic tests 131 | test_parse_last_usage_valid_data() 132 | test_parse_last_usage_no_data() 133 | print("Basic tests passed!") -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [Unreleased] 9 | 10 | ### Added 11 | * Support for MFA login via Timebased One Time Tokens 12 | * Support for historical hourly energy statistics 13 | * Auto-detection of location IDs, as well as support for multiple locations 14 | 15 | ### Changed 16 | * The state entity ID is now includes the locationID which is not migrated to the new ID value. Historical monthly state data will be orphaned. 17 | 18 | ### Removed 19 | 20 | ### Fixed 21 | 22 | ## v2.0.1 - 2025-09-10 23 | ### Fixed 24 | * **🔑 Critical Authentication Fix**: Resolved "Authentication failed after token refresh" error that caused sensors to become unavailable after a few days 25 | * **🔄 Session Management**: Improved session handling to prevent stale authentication state 26 | * **⏰ Session Timeouts**: Added automatic session refresh after 5 minutes to prevent connection staleness 27 | * **🛡️ Retry Logic**: Enhanced authentication retry logic to properly handle token expiration scenarios 28 | * **🧹 Resource Cleanup**: Better cleanup of authentication state during refresh operations 29 | 30 | ### Added 31 | * **🛠️ Manual Services**: Added `refresh_data` and `refresh_authentication` services for manual troubleshooting 32 | * **📊 Enhanced Logging**: More detailed logging for authentication and session management issues 33 | * **⚡ Session Lifecycle**: Automatic session recreation when authentication issues occur 34 | 35 | ### Changed 36 | * **🔧 Authentication Flow**: Completely redesigned authentication refresh to eliminate session conflicts 37 | * **🔒 Token Management**: Improved token lifecycle management with proper cleanup 38 | * **📡 Error Handling**: Better distinction between authentication and connection errors 39 | 40 | ## v2.0.0 - 2025-08-28 41 | ### Added 42 | * **🚀 Production-Ready Architecture**: Complete rewrite for stability and reliability 43 | * **🔌 Full Energy Dashboard Integration**: Proper device class and state class for seamless Energy Dashboard compatibility 44 | * **⚡ Enhanced Error Handling**: Comprehensive exception handling with specific error types 45 | * **🔒 Improved Security**: Better credential validation and secure session management 46 | * **📊 Type Safety**: Full type hints throughout the codebase for better maintainability 47 | * **🎛️ Input Validation**: Robust validation for all configuration inputs including email, host, and numeric ranges 48 | * **🔄 Advanced Retry Logic**: Smart retry mechanism with exponential backoff for API reliability 49 | * **📱 Better User Experience**: Informative error messages and validation feedback 50 | * **🌐 Internationalization**: Proper translation support with English strings 51 | * **📋 Session Management**: Automatic session cleanup and proper connection pooling 52 | * **🛠️ Debug Support**: Enhanced logging and debugging capabilities 53 | * **📖 Documentation**: Comprehensive README with setup instructions and troubleshooting 54 | 55 | ### Changed 56 | * **⏰ Default Poll Interval**: Reduced from 12 hours to 1 hour for more responsive monitoring 57 | * **🏷️ Sensor Naming**: More descriptive sensor names including account information 58 | * **📊 Device Information**: Enhanced device info with proper manufacturer and model details 59 | * **🔧 Configuration Flow**: Improved UI with better validation and error messages 60 | * **📦 Dependencies**: Updated aiohttp requirement to version 3.8.0+ 61 | * **🏠 Home Assistant Compatibility**: Updated for modern Home Assistant standards and best practices 62 | 63 | ### Removed 64 | * **🗑️ Deprecated Patterns**: Removed outdated code patterns and improved architecture 65 | * **🧹 Code Cleanup**: Eliminated redundant code and improved code organization 66 | 67 | ### Fixed 68 | * **🐛 Authentication Issues**: Better handling of authentication failures and token refresh 69 | * **🔗 Connection Stability**: Improved connection management and timeout handling 70 | * **📊 Data Parsing**: More robust parsing of energy data with better error handling 71 | * **🏷️ Unique ID Generation**: Fixed unique ID generation for stable entity management 72 | * **🎯 Energy Dashboard Compatibility**: Proper device class and state class for energy monitoring 73 | * **⚠️ Error Reporting**: Better error messages and logging for troubleshooting 74 | 75 | ### Security 76 | * **🔐 Credential Protection**: Enhanced security for credential storage and transmission 77 | * **🛡️ SSL Verification**: Proper SSL certificate verification for API calls 78 | * **🚫 Data Privacy**: No data transmission to unauthorized third parties 79 | 80 | ## v1.1.0 - 2025-07-29 81 | ### Added 82 | * **Enhanced Device Information (`device_info` property):** 83 | * The `SmartHubEnergySensor` now exposes a `device_info` property. This creates a conceptual "device" in Home Assistant's Device Registry, improving organization by grouping your sensor under a dedicated "Device" card in Settings > Devices & Services. 84 | * The device will display a user-friendly name (e.g., "YourHostName (Account: YourAccountID)"), manufacturer ("gagata"), and model ("Energy Monitor"). 85 | * Includes a `configuration_url` for future direct access to the SmartHub web interface. 86 | 87 | ### Changed 88 | * **Robust `unique_id` Generation:** 89 | * The method for generating the sensor's `unique_id` has been significantly improved for greater reliability and stability. It now primarily uses `config_entry.unique_id` (a stable identifier for the integration setup) and falls back to `config_entry.entry_id` for older configurations. This ensures stable and unique identifiers across Home Assistant restarts and reloads, preserving historical data, allowing customizations, and preventing duplicate entities. 90 | 91 | ### Fixed 92 | * **Improved Debugging and Logging:** 93 | * Strategic `_LOGGER.debug` statements have been added throughout the `async_setup_entry` functions (in `__init__.py` and `sensor.py`) and within the `SmartHubEnergySensor` class. These provide detailed information for troubleshooting and development when debug logging is enabled. 94 | * **Missing `state_class` Attribute for Existing Entities:** 95 | * Resolved an issue identified during further development of separate properties where the `state_class` attribute was missing for existing entities. This was fixed by moving `state_class` and `device_class` from separate properties back to attributes in `sensor.py`. 96 | -------------------------------------------------------------------------------- /custom_components/smarthub/config_flow_complex.py: -------------------------------------------------------------------------------- 1 | """Config flow for SmartHub integration.""" 2 | from __future__ import annotations 3 | 4 | import logging 5 | import re 6 | from typing import Any, Dict, Optional 7 | from urllib.parse import urlparse 8 | 9 | import voluptuous as vol 10 | from homeassistant import config_entries 11 | from homeassistant.core import HomeAssistant 12 | 13 | from .api import SmartHubAPI, SmartHubAPIError, SmartHubAuthError, SmartHubConnectionError 14 | from .const import ( 15 | DOMAIN, 16 | CONF_EMAIL, 17 | CONF_PASSWORD, 18 | CONF_ACCOUNT_ID, 19 | CONF_LOCATION_ID, 20 | CONF_HOST, 21 | CONF_POLL_INTERVAL, 22 | DEFAULT_POLL_INTERVAL, 23 | MIN_POLL_INTERVAL, 24 | MAX_POLL_INTERVAL, 25 | ) 26 | 27 | _LOGGER = logging.getLogger(__name__) 28 | 29 | 30 | def validate_email(email: str) -> bool: 31 | """Validate email format.""" 32 | pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$' 33 | return bool(re.match(pattern, email)) 34 | 35 | 36 | def validate_host(host: str) -> bool: 37 | """Validate host format.""" 38 | # Remove protocol if present 39 | if host.startswith(('http://', 'https://')): 40 | parsed = urlparse(host) 41 | host = parsed.netloc 42 | 43 | # Basic hostname validation 44 | pattern = r'^[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$' 45 | return bool(re.match(pattern, host)) and len(host) <= 253 46 | 47 | 48 | async def validate_input(hass: HomeAssistant, data: Dict[str, Any]) -> Dict[str, str]: 49 | """ 50 | Validate the user input allows us to connect. 51 | 52 | Returns: 53 | Dict containing validation result information. 54 | 55 | Raises: 56 | vol.Invalid: If validation fails. 57 | """ 58 | # Input validation 59 | email = data[CONF_EMAIL].strip().lower() 60 | if not validate_email(email): 61 | raise vol.Invalid("Invalid email format") 62 | 63 | password = data[CONF_PASSWORD] 64 | if len(password) < 1: 65 | raise vol.Invalid("Password cannot be empty") 66 | 67 | account_id = data[CONF_ACCOUNT_ID].strip() 68 | if not account_id: 69 | raise vol.Invalid("Account ID cannot be empty") 70 | 71 | location_id = data[CONF_LOCATION_ID].strip() 72 | if not location_id: 73 | raise vol.Invalid("Location ID cannot be empty") 74 | 75 | host = data[CONF_HOST].strip().lower() 76 | # Remove protocol if present for storage 77 | if host.startswith(('http://', 'https://')): 78 | parsed = urlparse(host) 79 | host = parsed.netloc 80 | 81 | if not validate_host(host): 82 | raise vol.Invalid("Invalid host format") 83 | 84 | poll_interval = data.get(CONF_POLL_INTERVAL, DEFAULT_POLL_INTERVAL) 85 | if not isinstance(poll_interval, int) or not (MIN_POLL_INTERVAL <= poll_interval <= MAX_POLL_INTERVAL): 86 | raise vol.Invalid(f"Poll interval must be between {MIN_POLL_INTERVAL} and {MAX_POLL_INTERVAL} minutes") 87 | 88 | # Test API connection 89 | api = SmartHubAPI( 90 | email=email, 91 | password=password, 92 | account_id=account_id, 93 | location_id=location_id, 94 | host=host, 95 | ) 96 | 97 | try: 98 | await api.get_token() 99 | # Test data retrieval 100 | await api.get_energy_data() 101 | return { 102 | "title": f"SmartHub ({account_id})", 103 | "email": email, 104 | "host": host, 105 | } 106 | finally: 107 | await api.close() 108 | 109 | 110 | class SmartHubConfigFlow(config_entries.ConfigFlow): 111 | """Handle a config flow for SmartHub.""" 112 | 113 | DOMAIN = "smarthub" 114 | VERSION = 1 115 | 116 | async def async_step_user( 117 | self, user_input: Optional[Dict[str, Any]] = None 118 | ) -> Dict[str, Any]: 119 | """Handle the initial step.""" 120 | errors: Dict[str, str] = {} 121 | 122 | if user_input is not None: 123 | try: 124 | info = await validate_input(self.hass, user_input) 125 | 126 | # Generate a unique ID from validated input 127 | unique_id = f"{info['email']}_{info['host']}_{user_input[CONF_ACCOUNT_ID]}" 128 | 129 | # Set the unique ID and check for duplicates 130 | await self.async_set_unique_id(unique_id) 131 | self._abort_if_unique_id_configured() 132 | 133 | _LOGGER.debug("Successfully validated SmartHub configuration") 134 | 135 | return self.async_create_entry( 136 | title=info["title"], 137 | data=user_input, 138 | ) 139 | 140 | except SmartHubAuthError: 141 | _LOGGER.error("Authentication failed during config flow") 142 | errors["base"] = "invalid_auth" 143 | except SmartHubConnectionError: 144 | _LOGGER.error("Connection failed during config flow") 145 | errors["base"] = "cannot_connect" 146 | except vol.Invalid as e: 147 | _LOGGER.error("Validation error: %s", e) 148 | errors["base"] = "invalid_input" 149 | except SmartHubAPIError as e: 150 | _LOGGER.error("API error during config flow: %s", e) 151 | errors["base"] = "unknown" 152 | except Exception as e: 153 | _LOGGER.exception("Unexpected error during config flow: %s", e) 154 | errors["base"] = "unknown" 155 | 156 | # Build the schema with current values if available 157 | schema_dict = { 158 | vol.Required(CONF_EMAIL, default=user_input.get(CONF_EMAIL, "") if user_input else ""): str, 159 | vol.Required(CONF_PASSWORD): str, 160 | vol.Required(CONF_ACCOUNT_ID, default=user_input.get(CONF_ACCOUNT_ID, "") if user_input else ""): str, 161 | vol.Required(CONF_LOCATION_ID, default=user_input.get(CONF_LOCATION_ID, "") if user_input else ""): str, 162 | vol.Required(CONF_HOST, default=user_input.get(CONF_HOST, "") if user_input else ""): str, 163 | vol.Optional( 164 | CONF_POLL_INTERVAL, 165 | default=user_input.get(CONF_POLL_INTERVAL, DEFAULT_POLL_INTERVAL) if user_input else DEFAULT_POLL_INTERVAL 166 | ): vol.All(vol.Coerce(int), vol.Range(min=MIN_POLL_INTERVAL, max=MAX_POLL_INTERVAL)), 167 | } 168 | 169 | return self.async_show_form( 170 | step_id="user", 171 | data_schema=vol.Schema(schema_dict), 172 | errors=errors, 173 | description_placeholders={ 174 | "min_poll": str(MIN_POLL_INTERVAL), 175 | "max_poll": str(MAX_POLL_INTERVAL), 176 | }, 177 | ) 178 | -------------------------------------------------------------------------------- /custom_components/smarthub/__init___working.py: -------------------------------------------------------------------------------- 1 | """The SmartHub integration.""" 2 | from __future__ import annotations 3 | 4 | import logging 5 | from typing import Dict, Any 6 | 7 | from homeassistant.config_entries import ConfigEntry 8 | from homeassistant.const import Platform 9 | from homeassistant.core import HomeAssistant, ServiceCall 10 | from homeassistant.exceptions import ConfigEntryError 11 | from homeassistant.helpers import entity_registry 12 | import voluptuous as vol 13 | 14 | from .api import SmartHubAPI 15 | from .const import DOMAIN, CONF_POLL_INTERVAL, DEFAULT_POLL_INTERVAL 16 | 17 | _LOGGER = logging.getLogger(__name__) 18 | 19 | PLATFORMS: list[Platform] = [Platform.SENSOR] 20 | 21 | # Service schemas 22 | SERVICE_REFRESH_DATA = "refresh_data" 23 | SERVICE_REFRESH_AUTH = "refresh_authentication" 24 | 25 | SERVICE_REFRESH_DATA_SCHEMA = vol.Schema({ 26 | vol.Required("entity_id"): str, 27 | }) 28 | 29 | SERVICE_REFRESH_AUTH_SCHEMA = vol.Schema({ 30 | vol.Required("entity_id"): str, 31 | }) 32 | 33 | 34 | async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 35 | """Set up SmartHub from a config entry.""" 36 | config = entry.data 37 | 38 | # Validate required configuration 39 | required_fields = ["email", "password", "account_id", "location_id", "host"] 40 | missing_fields = [field for field in required_fields if not config.get(field)] 41 | 42 | if missing_fields: 43 | _LOGGER.error("Missing required configuration fields: %s", missing_fields) 44 | raise ConfigEntryError(f"Missing configuration fields: {missing_fields}") 45 | 46 | # Initialize the API object 47 | api = SmartHubAPI( 48 | email=config["email"], 49 | password=config["password"], 50 | account_id=config["account_id"], 51 | location_id=config["location_id"], 52 | host=config["host"], 53 | ) 54 | 55 | # Test the connection 56 | try: 57 | await api.get_token() 58 | _LOGGER.info("Successfully connected to SmartHub API") 59 | except Exception as e: 60 | _LOGGER.error("Failed to connect to SmartHub API: %s", e) 61 | await api.close() 62 | raise ConfigEntryError(f"Cannot connect to SmartHub: {e}") from e 63 | 64 | # Store the API instance and configuration in hass.data 65 | hass.data.setdefault(DOMAIN, {}) 66 | hass.data[DOMAIN][entry.entry_id] = { 67 | "api": api, 68 | "poll_interval": config.get(CONF_POLL_INTERVAL, DEFAULT_POLL_INTERVAL), 69 | "config": config, 70 | } 71 | 72 | _LOGGER.debug("Stored SmartHub API instance for entry %s", entry.entry_id) 73 | 74 | # Set up platforms 75 | await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) 76 | 77 | # Register services with unique names per entry to avoid conflicts 78 | service_refresh_data = f"{SERVICE_REFRESH_DATA}_{entry.entry_id}" 79 | service_refresh_auth = f"{SERVICE_REFRESH_AUTH}_{entry.entry_id}" 80 | 81 | async def handle_refresh_data(call: ServiceCall): 82 | """Handle the refresh_data service call.""" 83 | entity_id = call.data["entity_id"] 84 | _LOGGER.info("Manual refresh requested for entity: %s", entity_id) 85 | 86 | # Find the coordinator for this entity 87 | ent_reg = entity_registry.async_get(hass) 88 | entity_entry = ent_reg.async_get(entity_id) 89 | 90 | if entity_entry and entity_entry.config_entry_id == entry.entry_id: 91 | # Trigger refresh for the coordinator 92 | coordinator = hass.data[DOMAIN][entry.entry_id].get("coordinator") 93 | if coordinator: 94 | await coordinator.async_request_refresh() 95 | _LOGGER.info("Refresh completed for entity: %s", entity_id) 96 | else: 97 | _LOGGER.error("No coordinator found for entity: %s", entity_id) 98 | else: 99 | _LOGGER.error("Entity not found or not part of SmartHub integration: %s", entity_id) 100 | 101 | async def handle_refresh_auth(call: ServiceCall): 102 | """Handle the refresh_authentication service call.""" 103 | entity_id = call.data["entity_id"] 104 | _LOGGER.info("Authentication refresh requested for entity: %s", entity_id) 105 | 106 | # Force authentication refresh 107 | api = hass.data[DOMAIN][entry.entry_id]["api"] 108 | try: 109 | await api._refresh_authentication() 110 | _LOGGER.info("Authentication refresh completed for entity: %s", entity_id) 111 | except Exception as e: 112 | _LOGGER.error("Failed to refresh authentication for entity %s: %s", entity_id, e) 113 | 114 | # Register services only if this is the first SmartHub entry 115 | existing_entries = [e for e in hass.config_entries.async_entries(DOMAIN) if e.state.recoverable] 116 | if len(existing_entries) == 1: # This is the first entry 117 | try: 118 | hass.services.async_register( 119 | DOMAIN, SERVICE_REFRESH_DATA, handle_refresh_data, schema=SERVICE_REFRESH_DATA_SCHEMA 120 | ) 121 | _LOGGER.debug("Registered refresh_data service") 122 | 123 | hass.services.async_register( 124 | DOMAIN, SERVICE_REFRESH_AUTH, handle_refresh_auth, schema=SERVICE_REFRESH_AUTH_SCHEMA 125 | ) 126 | _LOGGER.debug("Registered refresh_authentication service") 127 | except Exception as e: 128 | _LOGGER.warning("Failed to register services, continuing without them: %s", e) 129 | 130 | return True 131 | 132 | 133 | async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 134 | """Unload a config entry.""" 135 | # Unload platforms 136 | unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) 137 | 138 | if unload_ok: 139 | # Clean up API connection 140 | data = hass.data[DOMAIN].get(entry.entry_id) 141 | if data and "api" in data: 142 | api = data["api"] 143 | await api.close() 144 | _LOGGER.debug("Closed SmartHub API connection for entry %s", entry.entry_id) 145 | 146 | # Remove data 147 | hass.data[DOMAIN].pop(entry.entry_id, None) 148 | 149 | # Remove domain data if no entries left 150 | if not hass.data[DOMAIN]: 151 | hass.data.pop(DOMAIN, None) 152 | 153 | # Remove services only when the last entry is removed 154 | try: 155 | if hass.services.has_service(DOMAIN, SERVICE_REFRESH_DATA): 156 | hass.services.async_remove(DOMAIN, SERVICE_REFRESH_DATA) 157 | _LOGGER.debug("Removed refresh_data service") 158 | 159 | if hass.services.has_service(DOMAIN, SERVICE_REFRESH_AUTH): 160 | hass.services.async_remove(DOMAIN, SERVICE_REFRESH_AUTH) 161 | _LOGGER.debug("Removed refresh_authentication service") 162 | except Exception as e: 163 | _LOGGER.warning("Failed to remove services during unload: %s", e) 164 | 165 | return unload_ok -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Home Assistant SmartHub Energy Sensor Integration 2 | 3 | [![hacs_badge](https://img.shields.io/badge/HACS-Custom-orange.svg)](https://github.com/custom-components/hacs) 4 | [![Version](https://img.shields.io/github/v/release/gagata/ha-smarthub-energy-sensor)](https://github.com/gagata/ha-smarthub-energy-sensor/releases) 5 | [![License](https://img.shields.io/github/license/gagata/ha-smarthub-energy-sensor)](LICENSE) 6 | 7 | A Home Assistant custom integration that connects to SmartHub Coop energy portals to provide real-time electricity usage data. This integration is fully compatible with Home Assistant's Energy Dashboard and provides reliable, stable monitoring for your energy consumption. 8 | 9 | ## ✨ Features 10 | 11 | - 🔌 **Energy Dashboard Integration**: Seamlessly works with Home Assistant's built-in Energy Dashboard, including backfilling hourly metrics 12 | - 📊 **Real-time Monitoring**: Tracks your electricity usage with configurable polling intervals 13 | - 🔒 **Secure Authentication**: Robust credential handling with proper error management 14 | - 🔄 **Automatic Retry**: Built-in retry logic for reliable data collection 15 | - 🎛️ **Easy Configuration**: User-friendly configuration flow with input validation 16 | - 🏠 **Device Integration**: Creates proper device entities for better organization 17 | - 📱 **Production Ready**: Comprehensive error handling and logging for stability 18 | 19 | ## 🚀 Installation 20 | 21 | ### Option 1: HACS (Recommended) 22 | 23 | 1. Open HACS in your Home Assistant instance 24 | 2. Click on the three-dot menu and select "Custom repositories" 25 | 3. Add this repository URL: `https://github.com/gagata/ha-smarthub-energy-sensor` 26 | 4. Select "Integration" as the category 27 | 5. Click "ADD" and then search for "SmartHub Energy" 28 | 6. Click "Download" to install 29 | 30 | [![Open your Home Assistant instance and open a repository inside the Home Assistant Community Store.](https://my.home-assistant.io/badges/hacs_repository.svg)](https://my.home-assistant.io/redirect/hacs_repository/?owner=gagata&repository=ha-smarthub-energy-sensor) 31 | 32 | ### Option 2: Manual Installation 33 | 34 | 1. Download the latest release from the [releases page](https://github.com/gagata/ha-smarthub-energy-sensor/releases) 35 | 2. Extract the `smarthub` folder to your `custom_components` directory 36 | 3. Restart Home Assistant 37 | 38 | ``` 39 | config/ 40 | ├── custom_components/ 41 | │ └── smarthub/ 42 | │ ├── __init__.py 43 | │ ├── api.py 44 | │ ├── config_flow.py 45 | │ ├── const.py 46 | │ ├── exceptions.py 47 | │ ├── manifest.json 48 | │ ├── sensor.py 49 | │ ├── services.yaml 50 | │ ├── strings.json 51 | │ ├── icons/ 52 | │ │ ├── icon.svg 53 | │ │ └── README.md 54 | │ └── translations/ 55 | │ └── en.json 56 | ``` 57 | 58 | ## ⚙️ Configuration 59 | 60 | ### Requirements 61 | 62 | Before setting up the integration, you'll need to gather the following information from your SmartHub portal: 63 | 64 | 1. **Email Address**: Your login email for the SmartHub portal 65 | 2. **Password**: Your SmartHub portal password 66 | 3. **Host**: Your energy provider's SmartHub domain (e.g., `yourprovider.smarthub.coop`) 67 | 4. **Account ID**: Found on your billing statements 68 | 69 | ### Setup Process 70 | 71 | 1. Go to **Settings** → **Devices & Services** 72 | 2. Click **"+ Add Integration"** 73 | 3. Search for **"SmartHub"** 74 | 4. Follow the configuration wizard: 75 | - Enter your email address 76 | - Enter your password 77 | - Enter your account ID 78 | - Enter your SmartHub host 79 | 80 | The integration will validate your credentials and create the energy sensor automatically. 81 | 82 | ## 📊 Energy Dashboard Integration 83 | 84 | Once configured, your SmartHub energy sensor will automatically appear in Home Assistant with the correct device class and state class for energy monitoring. 85 | 86 | ### Adding to Energy Dashboard 87 | 88 | 1. Go to **Settings** → **Dashboards** → **Energy** 89 | 2. Click **"Add Consumption"** in the Electricity grid section 90 | 3. Select your SmartHub energy sensor from the dropdown 91 | Note: there will be multiple entries - select the one that says "Hourly usage" 92 | 4. The sensor will now provide data to your Energy Dashboard 93 | 94 | ### Sensor Details 95 | 96 | - **Device Class**: Energy 97 | - **State Class**: Total Increasing 98 | - **Unit**: kWh (Kilowatt Hours) 99 | - **Icon**: Lightning bolt (mdi:lightning-bolt) 100 | 101 | ## 🔧 Configuration Options 102 | 103 | | Option | Default | Range | Description | 104 | |--------|---------|-------|-------------| 105 | | Poll Interval | 60 minutes | 15-1440 minutes | How often to check for new energy data | 106 | 107 | **Note**: SmartHub data typically updates every 15-60 minutes, so setting a very low poll interval may not provide more frequent updates but will increase API calls. 108 | 109 | ## 🛠️ Troubleshooting 110 | 111 | ### Common Issues 112 | 113 | **"Cannot Connect" Error** 114 | - Verify your SmartHub host is correct (without http:// or https://) 115 | - Check your internet connection 116 | - Ensure the SmartHub portal is accessible 117 | 118 | **"Invalid Authentication" Error** 119 | - Double-check your email and password 120 | - Try logging into the SmartHub portal manually to verify credentials 121 | - Some portals may have rate limiting - wait a few minutes and try again 122 | 123 | **"No Data Available"** 124 | - Verify your Account ID and Location ID are correct 125 | - Check that your account has recent usage data in the SmartHub portal 126 | - Some providers may have delays in data availability 127 | 128 | **"Energy Statistics are offset from the right time"** 129 | - Update the timezone where your SmartHub utility is located. 130 | This can be done easily via when you reconfigure the integration. 131 | 132 | ### Debug Logging 133 | 134 | To enable debug logging for troubleshooting: 135 | 136 | ```yaml 137 | # configuration.yaml 138 | logger: 139 | default: info 140 | logs: 141 | custom_components.smarthub: debug 142 | ``` 143 | 144 | ### Manual Services 145 | 146 | The integration provides two services for manual troubleshooting: 147 | 148 | **Refresh Data Service** 149 | ```yaml 150 | # Example service call to manually refresh data 151 | service: smarthub.refresh_data 152 | data: 153 | entity_id: sensor.smarthub_energy_123456 154 | ``` 155 | 156 | **Refresh Authentication Service** 157 | ```yaml 158 | # Example service call to force authentication refresh 159 | service: smarthub.refresh_authentication 160 | data: 161 | entity_id: sensor.smarthub_energy_123456 162 | ``` 163 | 164 | These services can be called from Developer Tools → Services or used in automations. 165 | 166 | ### Support 167 | 168 | If you encounter issues: 169 | 170 | 1. Check the Home Assistant logs for error messages 171 | 2. Verify all configuration parameters are correct 172 | 3. Test access to your SmartHub portal manually 173 | 4. [Open an issue](https://github.com/gagata/ha-smarthub-energy-sensor/issues) with logs and details 174 | 175 | ## 🔒 Security & Privacy 176 | 177 | - Credentials are stored securely using Home Assistant's configuration encryption 178 | - API calls use HTTPS with proper SSL verification 179 | - No data is transmitted to third parties outside of your SmartHub provider 180 | - Session tokens are properly managed and refreshed as needed 181 | 182 | ## 🤝 Contributing 183 | 184 | Contributions are welcome! Please feel free to: 185 | 186 | - Report bugs or issues 187 | - Suggest new features 188 | - Submit pull requests 189 | - Improve documentation 190 | 191 | ## 📋 Limitations 192 | 193 | - Data availability depends on your energy provider's SmartHub implementation 194 | - Update frequency is limited by the provider's data refresh rate 195 | - Currently supports electricity usage only (no gas or other utilities) 196 | - Requires active SmartHub portal access 197 | 198 | ## 📜 Changelog 199 | 200 | See [CHANGELOG.md](CHANGELOG.md) for detailed release notes and version history. 201 | 202 | ## 🙏 Credits 203 | 204 | - Thanks to [@tedpearson](https://github.com/tedpearson) for the [Go implementation](https://github.com/tedpearson/electric-usage-downloader) that provided inspiration 205 | - Original integration concept by [@gagata](https://github.com/gagata) 206 | - Production improvements and maintenance by [@gagata](https://github.com/gagata) 207 | 208 | ## 📄 License 209 | 210 | This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. 211 | -------------------------------------------------------------------------------- /custom_components/smarthub/sensor.py: -------------------------------------------------------------------------------- 1 | """SmartHub energy sensor platform.""" 2 | from __future__ import annotations 3 | 4 | import logging 5 | from datetime import datetime, timedelta, timezone 6 | from typing import Any, Dict, Optional 7 | 8 | from homeassistant.components.sensor import ( 9 | SensorDeviceClass, 10 | SensorEntity, 11 | SensorStateClass, 12 | ) 13 | from homeassistant.config_entries import ConfigEntry 14 | from homeassistant.const import UnitOfEnergy 15 | from homeassistant.core import HomeAssistant 16 | from homeassistant.helpers.entity_platform import AddEntitiesCallback 17 | from homeassistant.helpers.update_coordinator import ( 18 | CoordinatorEntity, 19 | DataUpdateCoordinator, 20 | UpdateFailed, 21 | ) 22 | from homeassistant.util.unit_conversion import EnergyConverter 23 | from homeassistant.components.recorder.statistics import ( 24 | async_add_external_statistics, 25 | get_last_statistics, 26 | statistics_during_period, 27 | ) 28 | from homeassistant.components.recorder import get_instance 29 | from homeassistant.components.recorder.models import ( 30 | StatisticData, 31 | StatisticMeanType, 32 | StatisticMetaData, 33 | ) 34 | 35 | from .api import Aggregation, SmartHubAPI, SmartHubAPIError, SmartHubAuthError, SmartHubLocation 36 | from .const import ( 37 | DOMAIN, 38 | ENERGY_SENSOR_KEY, 39 | ATTR_LAST_READING_TIME, 40 | ATTR_ACCOUNT_ID, 41 | ATTR_LOCATION_ID, 42 | LOCATION_KEY, 43 | HISTORICAL_IMPORT_DAYS, 44 | ) 45 | 46 | _LOGGER = logging.getLogger(__name__) 47 | 48 | 49 | async def async_setup_entry( 50 | hass: HomeAssistant, 51 | config_entry: ConfigEntry, 52 | async_add_entities: AddEntitiesCallback, 53 | ) -> None: 54 | """Set up the SmartHub sensor platform.""" 55 | _LOGGER.debug("Setting up SmartHub sensor platform") 56 | 57 | config: Dict[str, Any] = config_entry.data 58 | 59 | coordinator = config_entry.runtime_data 60 | 61 | # Ensure that it is the smartHub coordinator 62 | assert type(coordinator) is SmartHubDataUpdateCoordinator 63 | 64 | last_locations_consumption = coordinator.data.values() 65 | 66 | # Create sensor entities for each location 67 | entities = [] 68 | for last_consumption in last_locations_consumption: 69 | entities.append( 70 | SmartHubEnergySensor( 71 | coordinator=coordinator, 72 | config_entry=config_entry, 73 | config=config, 74 | location=last_consumption.get(LOCATION_KEY), 75 | ) 76 | ) 77 | 78 | async_add_entities(entities, update_before_add=True) 79 | _LOGGER.debug(f"{len(entities)} SmartHub sensor entities added successfully") 80 | 81 | 82 | class SmartHubDataUpdateCoordinator(DataUpdateCoordinator): 83 | """Class to manage fetching SmartHub data.""" 84 | 85 | def __init__( 86 | self, 87 | hass: HomeAssistant, 88 | api: SmartHubAPI, 89 | update_interval: timedelta, 90 | config_entry: str, 91 | ) -> None: 92 | """Initialize the coordinator.""" 93 | super().__init__( 94 | hass, 95 | _LOGGER, 96 | name=f"{DOMAIN}_{config_entry.entry_id}", 97 | update_interval=update_interval, 98 | ) 99 | self.api = api 100 | self.account_id = config_entry.data.get('account_id','unknown') 101 | 102 | async def _async_update_data(self) -> Dict[str, Any]: 103 | """Fetch data from the SmartHub API.""" 104 | try: 105 | _LOGGER.debug("Fetching data from SmartHub API") 106 | 107 | # force a logout of the session 108 | self.api.token = None 109 | 110 | locations = await self.api.get_service_locations() 111 | 112 | entity_response = {} 113 | 114 | for location in locations: 115 | # Because SmartHub provides historical usage/cost with delay of a 116 | # number of hours we need to insert data into statistics. 117 | await self._insert_statistics(location) 118 | 119 | # Fetch monthly information for entity value 120 | first_day_of_current_month = datetime.now().replace(day=1, hour=0, minute=0, second=0, microsecond=0) 121 | 122 | data = await self.api.get_energy_data(location=location, start_datetime=first_day_of_current_month) 123 | 124 | if data.get("USAGE", None) is None: 125 | _LOGGER.warning("No data received from SmartHub API for location %s", location) 126 | # Return previous data if available, otherwise empty dict 127 | entity_response[location.id] = self.data or {} 128 | continue 129 | 130 | last_reading = data.get("USAGE")[-1] 131 | _LOGGER.debug("Successfully fetched data: %s for location: %s", last_reading, location) 132 | 133 | entity_response[location.id] = { 134 | ENERGY_SENSOR_KEY: last_reading['consumption'], 135 | ATTR_LAST_READING_TIME: last_reading['reading_time'], 136 | LOCATION_KEY: location, 137 | } 138 | 139 | return entity_response 140 | 141 | except SmartHubAuthError as e: 142 | _LOGGER.error("Authentication error fetching SmartHub data: %s", e) 143 | # For auth errors, we want to raise UpdateFailed to trigger retry 144 | # but also ensure the API will refresh authentication on next attempt 145 | raise UpdateFailed(f"Authentication failed: {e}") from e 146 | except SmartHubAPIError as e: 147 | _LOGGER.error("Error fetching data from SmartHub API: %s", e) 148 | raise UpdateFailed(f"Error communicating with SmartHub API: {e}") from e 149 | except Exception as e: 150 | _LOGGER.exception("Unexpected error fetching SmartHub data: %s", e) 151 | raise UpdateFailed(f"Unexpected error: {e}") from e 152 | 153 | 154 | # https://github.com/tronikos/opower/ was used as a model for how to populate 155 | # hourly metrics when access to realtime information is not possible via 156 | # utility dashboards. 157 | async def _insert_statistics(self, location): 158 | """Retrieve energy usage data asynchronously with retry logic. Always backfills the data overwriting the history based on the collection window.""" 159 | consumption_statistic_id = f"{DOMAIN}:smarthub_energy_sensor_{self.account_id}_{location.id}" 160 | 161 | consumption_unit_class = ( 162 | EnergyConverter.UNIT_CLASS 163 | ) 164 | consumption_unit = ( 165 | UnitOfEnergy.KILO_WATT_HOUR 166 | ) 167 | consumption_metadata = StatisticMetaData( 168 | mean_type=StatisticMeanType.NONE, 169 | has_sum=True, 170 | name=f"SmartHub Energy Hourly Usage - {self.account_id} - {location.description}", 171 | source=DOMAIN, 172 | statistic_id=consumption_statistic_id, 173 | # unit_class=consumption_unit_class, # required in 2025.11 174 | unit_of_measurement=consumption_unit, 175 | ) 176 | 177 | last_stat = await get_instance(self.hass).async_add_executor_job( 178 | get_last_statistics, self.hass, 1, consumption_statistic_id, True, set() 179 | ) 180 | _LOGGER.debug("last_stat: %s", last_stat) 181 | 182 | smarthub_data = {} 183 | if not last_stat: 184 | _LOGGER.debug("Updating statistic for the first time") 185 | consumption_sum = 0.0 186 | last_stats_time = None 187 | 188 | # Initialize with last HISTORICAL_IMPORT_DAYS (usually 90) days of data 189 | start_datetime = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0) - timedelta(days=HISTORICAL_IMPORT_DAYS) 190 | 191 | # Load read data for use in populating statistics 192 | smarthub_data = await self.api.get_energy_data(location=location, aggregation=Aggregation.HOURLY, start_datetime=start_datetime) 193 | else: 194 | _LOGGER.debug("Checking if data migration is needed...") 195 | migrated = False 196 | # SmartHub doesn't hvae any current migrations - this sample code was left 197 | # from the opower version 198 | #migrated = await self._async_maybe_migrate_statistics( 199 | # account.utility_account_id, 200 | # { 201 | # cost_statistic_id: compensation_statistic_id, 202 | # consumption_statistic_id: return_statistic_id, 203 | # }, 204 | # { 205 | # cost_statistic_id: cost_metadata, 206 | # compensation_statistic_id: compensation_metadata, 207 | # consumption_statistic_id: consumption_metadata, 208 | # return_statistic_id: return_metadata, 209 | # }, 210 | #) 211 | if migrated: 212 | # Skip update to avoid working on old data since the migration is done 213 | # asynchronously. Update the statistics in the next refresh in 12h. 214 | _LOGGER.debug( 215 | "Statistics migration completed. Skipping update for now" 216 | ) 217 | return 218 | 219 | # Update reads... 220 | # Load read data for use in populating statistics 221 | start_datetime = datetime.fromtimestamp(last_stat[consumption_statistic_id][0]["start"], tz=timezone.utc) 222 | 223 | # always backdate the start_datetime to ensure no gaps in recorded data 224 | start_datetime = start_datetime - timedelta(days=2) 225 | 226 | _LOGGER.debug("Fetching statistics from %s", start_datetime) 227 | smarthub_data = await self.api.get_energy_data(location=location, start_datetime=start_datetime, aggregation=Aggregation.HOURLY) 228 | 229 | start = smarthub_data.get("USAGE")[0].get("reading_time") 230 | _LOGGER.debug("Getting statistics at: %s", start) 231 | 232 | # In the common case there should be a previous statistic at start time 233 | # so we only need to fetch one statistic. If there isn't any, fetch all. 234 | # Counterintutitively - but consistent with opower - this aligns the 235 | # last Stats collection with the data collected form the server - then imports 236 | # and overrights all the data after that point. The opower logic is that the 237 | # data might be refreshed, or have collection gaps that are fixed. 238 | # Its duplicated for SmartHub as it seems reasonable. 239 | for end in (start + timedelta(seconds=1), None): 240 | stats = await get_instance(self.hass).async_add_executor_job( 241 | statistics_during_period, 242 | self.hass, 243 | start, 244 | end, 245 | { 246 | consumption_statistic_id, 247 | }, 248 | "hour", 249 | None, 250 | {"sum"}, 251 | ) 252 | if stats: 253 | break 254 | if end: 255 | _LOGGER.debug( 256 | "Not found. Trying to find the oldest statistic after %s", 257 | start, 258 | ) 259 | # We are in this code path only if get_last_statistics found a stat 260 | # so statistics_during_period should also have found at least one. 261 | assert stats 262 | 263 | def _safe_get_sum(records: list[Any]) -> float: 264 | if records and "sum" in records[0]: 265 | return float(records[0]["sum"]) 266 | return 0.0 267 | 268 | consumption_sum = _safe_get_sum(stats.get(consumption_statistic_id, [])) 269 | last_stats_time = stats[consumption_statistic_id][0]["start"] 270 | 271 | _LOGGER.info(f"Updating statistics since {last_stats_time}") 272 | 273 | consumption_statistics = [] 274 | 275 | for cost_read in smarthub_data.get("USAGE"): 276 | start = cost_read.get("reading_time") 277 | if last_stats_time is not None and start.timestamp() <= last_stats_time: 278 | continue 279 | 280 | consumption_state = max(0, cost_read.get("consumption")) 281 | consumption_sum += consumption_state 282 | 283 | consumption_statistics.append( 284 | StatisticData( 285 | start=start, state=consumption_state, sum=consumption_sum 286 | ) 287 | ) 288 | 289 | _LOGGER.info( 290 | "Adding %s statistics for %s", 291 | len(consumption_statistics), 292 | consumption_statistic_id, 293 | ) 294 | async_add_external_statistics( 295 | self.hass, consumption_metadata, consumption_statistics 296 | ) 297 | 298 | 299 | 300 | 301 | class SmartHubEnergySensor(CoordinatorEntity, SensorEntity): 302 | """Representation of a SmartHub energy sensor.""" 303 | 304 | _attr_device_class = SensorDeviceClass.ENERGY 305 | _attr_state_class = SensorStateClass.TOTAL_INCREASING 306 | _attr_native_unit_of_measurement = UnitOfEnergy.KILO_WATT_HOUR 307 | _attr_icon = "mdi:lightning-bolt" 308 | 309 | def __init__( 310 | self, 311 | coordinator: SmartHubDataUpdateCoordinator, 312 | config_entry: ConfigEntry, 313 | config: Dict[str, Any], 314 | location: SmartHubLocation, 315 | ) -> None: 316 | """Initialize the sensor.""" 317 | super().__init__(coordinator) 318 | 319 | self._config_entry = config_entry 320 | self._config = config 321 | self._attr_unique_id = f"{config_entry.unique_id}_{location.id}_energy" 322 | self.location = location 323 | 324 | # Extract account info for naming 325 | account_id = config.get("account_id", "Unknown") 326 | 327 | self._attr_name = f"SmartHub Energy Monthly Usage {account_id} {self.location.description}" 328 | 329 | _LOGGER.debug("Initialized SmartHub energy sensor with unique_id: %s", self._attr_unique_id) 330 | 331 | @property 332 | def available(self) -> bool: 333 | """Return True if entity is available.""" 334 | return self.coordinator.last_update_success and self.native_value is not None 335 | 336 | @property 337 | def native_value(self) -> Optional[float]: 338 | """Return the state of the sensor.""" 339 | if not self.coordinator.data: 340 | return None 341 | 342 | value = self.coordinator.data.get(self.location.id, {}).get(ENERGY_SENSOR_KEY, None) 343 | if value is None: 344 | _LOGGER.debug("No energy usage value found in coordinator data") 345 | return None 346 | 347 | try: 348 | return float(value) 349 | except (ValueError, TypeError) as e: 350 | _LOGGER.warning("Could not convert energy value '%s' to float: %s", value, e) 351 | return None 352 | 353 | @property 354 | def extra_state_attributes(self) -> Dict[str, Any]: 355 | """Return additional state attributes.""" 356 | attributes = { 357 | ATTR_ACCOUNT_ID: self._config.get("account_id"), 358 | ATTR_LOCATION_ID: self.location.id, 359 | } 360 | 361 | # Add last reading time if available 362 | if self.coordinator.data: 363 | last_reading = self.coordinator.data.get(self.location.id).get(ATTR_LAST_READING_TIME) 364 | if last_reading: 365 | attributes[ATTR_LAST_READING_TIME] = last_reading 366 | 367 | return attributes 368 | 369 | @property 370 | def device_info(self) -> Dict[str, Any]: 371 | """Return device information.""" 372 | account_id = self._config.get("account_id", "Unknown") 373 | host = self._config.get("host", "Unknown") 374 | 375 | return { 376 | "identifiers": {(DOMAIN, self._config_entry.unique_id or self._config_entry.entry_id)}, 377 | "name": f"SmartHub Energy Monthly Usage ({account_id} - {self.location.description})", 378 | "manufacturer": "SmartHub Coop", 379 | "model": "Energy Monitor", 380 | "configuration_url": f"https://{host}", 381 | } 382 | -------------------------------------------------------------------------------- /custom_components/smarthub/api.py: -------------------------------------------------------------------------------- 1 | """SmartHub API client for Home Assistant integration.""" 2 | from __future__ import annotations 3 | 4 | import asyncio 5 | import logging 6 | from datetime import datetime, timedelta, timezone 7 | from zoneinfo import ZoneInfo 8 | from typing import Any, Dict, Optional, List 9 | from enum import Enum 10 | 11 | import aiohttp 12 | from aiohttp import ClientTimeout, ClientError 13 | import pyotp 14 | 15 | from .const import ( 16 | DEFAULT_TIMEOUT, 17 | MAX_RETRIES, 18 | RETRY_DELAY, 19 | SESSION_TIMEOUT, 20 | ELECTRIC_SERVICE, 21 | SUPPORTED_SERVICES, 22 | ) 23 | 24 | _LOGGER = logging.getLogger(__name__) 25 | 26 | class Aggregation(Enum): 27 | HOURLY = "HOURLY" 28 | MONTHLY = "MONTHLY" 29 | 30 | 31 | class SmartHubAPIError(Exception): 32 | """Base exception for SmartHub API errors.""" 33 | 34 | 35 | class SmartHubAuthError(SmartHubAPIError): 36 | """Authentication error.""" 37 | 38 | 39 | class SmartHubConnectionError(SmartHubAPIError): 40 | """Connection error.""" 41 | 42 | 43 | class SmartHubDataError(SmartHubAPIError): 44 | """Data parsing error.""" 45 | 46 | class SmartHubLocation(): 47 | """Smarthub Location object - contains location_id, location_description, etc""" 48 | 49 | def __init__( 50 | self, 51 | id: str, 52 | service: str, 53 | description: str 54 | ) -> None: 55 | """Initialize the SmartHubLocation.""" 56 | self.id = id 57 | self.service = service 58 | self.description = description 59 | 60 | def __str__(self): 61 | return f"[SmartHubLocation: '{self.id}' '{self.service}' '{self.description}']" 62 | 63 | class SmartHubAPI: 64 | """Class to interact with the SmartHub API.""" 65 | 66 | def __init__( 67 | self, 68 | email: str, 69 | password: str, 70 | account_id: str, 71 | timezone: str, 72 | mfa_totp: str, 73 | host: str, 74 | timeout: int = DEFAULT_TIMEOUT, 75 | ) -> None: 76 | """Initialize the SmartHub API client.""" 77 | self.email = email 78 | self.password = password 79 | self.account_id = account_id 80 | self.timezone = timezone 81 | self.mfa_totp = mfa_totp 82 | self.host = host 83 | self.timeout = timeout 84 | self.token: Optional[str] = None 85 | self.primary_username: Optional[str] = None 86 | self._session: Optional[aiohttp.ClientSession] = None 87 | self._session_created_at: Optional[datetime] = None 88 | 89 | 90 | def parse_usage(self, data: Dict[str, Any]) -> Optional[Dict[str, Any]]: 91 | """ 92 | Parse the JSON data and extract the last data point for usage. 93 | 94 | Args: 95 | data: The JSON data as a Python dictionary. 96 | 97 | Returns: 98 | A dictionary containing the "USAGE" with a list of parsed data and metadata, or an empty dictionary if not found. 99 | 100 | Raises: 101 | SmartHubDataError: If there's an error parsing the data. 102 | """ 103 | try: 104 | parsed_response = {} 105 | 106 | if not isinstance(data, dict): 107 | raise SmartHubDataError("Invalid data format: expected dictionary") 108 | 109 | # Locate the "ELECTRIC" data 110 | electric_data = data.get("data", {}).get("ELECTRIC", []) 111 | if len(electric_data) == 0: 112 | _LOGGER.warning("No ELECTRIC data found in response") 113 | 114 | for entry in electric_data: 115 | # Find the entry with type "USAGE" 116 | if entry.get("type","") == "USAGE": 117 | parsed_data = [] 118 | series = entry.get("series", []) 119 | for serie in series: 120 | # Extract the last data point in the "data" array 121 | usage_data = serie.get("data", []) 122 | 123 | for usage in usage_data: 124 | event_time = datetime.fromtimestamp(usage.get("x") / 1000.0, tz=timezone.utc).replace(tzinfo=ZoneInfo(self.timezone)) # convert microseconds to timestmap -> read data as if it was in provider TZ, then conver to UTC for statistics 125 | # HA stats import wants timestamps only at standard intervals - 126 | # https://github.com/home-assistant/core/blob/4fef19c7bc7c1f7be827f6c489ad1df232e44906/homeassistant/components/recorder/statistics.py#L2634 127 | if event_time.minute != 0: 128 | _LOGGER.debug("consolidating sub hour data: %s, %s + %s", event_time, parsed_data[-1]['consumption'], usage.get("y")) 129 | parsed_data[-1]['consumption'] += usage.get("y") 130 | continue 131 | 132 | # Ignore events with no energy recording 133 | if usage.get("y") == 0: 134 | continue 135 | 136 | parsed_data.append({ 137 | "reading_time" : event_time, 138 | "consumption" : usage.get("y"), 139 | "raw_timestamp": usage.get("x"), 140 | }) 141 | _LOGGER.debug("Parsed %d items for usage history", len(parsed_data)) 142 | parsed_response["USAGE"] = parsed_data 143 | 144 | return parsed_response 145 | 146 | except Exception as e: 147 | raise SmartHubDataError(f"Error parsing usage data: {e}") from e 148 | 149 | 150 | async def _get_session(self) -> aiohttp.ClientSession: 151 | """Get or create an aiohttp session.""" 152 | now = datetime.now() 153 | 154 | # Check if session needs refresh due to age 155 | if (self._session_created_at and 156 | (now - self._session_created_at).total_seconds() > SESSION_TIMEOUT): 157 | _LOGGER.debug("Session timeout reached, refreshing session") 158 | if self._session and not self._session.closed: 159 | await self._session.close() 160 | self._session = None 161 | self._session_created_at = None 162 | 163 | if self._session is None or self._session.closed: 164 | timeout = ClientTimeout(total=self.timeout) 165 | self._session = aiohttp.ClientSession( 166 | timeout=timeout, 167 | connector=aiohttp.TCPConnector(ssl=True, limit=10), 168 | ) 169 | self._session_created_at = now 170 | _LOGGER.debug("Created new aiohttp session") 171 | return self._session 172 | 173 | async def close(self) -> None: 174 | """Close the aiohttp session.""" 175 | if self._session and not self._session.closed: 176 | await self._session.close() 177 | self._session = None 178 | 179 | async def _refresh_authentication(self) -> None: 180 | """ 181 | Refresh authentication by clearing session and getting new token. 182 | 183 | This method ensures we start with a clean session state when 184 | authentication issues occur. 185 | """ 186 | _LOGGER.debug("Refreshing authentication and session") 187 | 188 | # Close existing session to clear any stale state 189 | if self._session and not self._session.closed: 190 | await self._session.close() 191 | self._session = None 192 | self._session_created_at = None 193 | 194 | # Clear the old token 195 | self.token = None 196 | 197 | # Get a fresh token with a new session 198 | await self.get_token() 199 | 200 | async def get_token(self) -> str: 201 | """ 202 | Authenticate and retrieve the token asynchronously. 203 | 204 | Returns: 205 | The authentication token. 206 | 207 | Raises: 208 | SmartHubAuthError: If authentication fails. 209 | SmartHubConnectionError: If there's a connection error. 210 | """ 211 | auth_url = f"https://{self.host}/services/oauth/auth/v2" 212 | headers = { 213 | "Authority": self.host, 214 | "Content-Type": "application/x-www-form-urlencoded", 215 | "User-Agent": "HomeAssistant SmartHub Integration", 216 | } 217 | 218 | payload = { 219 | "password": self.password, 220 | "userId": self.email, 221 | } 222 | 223 | if self.mfa_totp != "": 224 | totp = pyotp.TOTP(self.mfa_totp) 225 | current_otp = totp.now() 226 | payload["twoFactorCode"] = current_otp 227 | 228 | _LOGGER.debug("Sending authentication request to: %s", auth_url) 229 | 230 | try: 231 | session = await self._get_session() 232 | async with session.post(auth_url, headers=headers, params=payload) as response: 233 | _ = await response.text() 234 | _LOGGER.debug("Auth response status: %s", response.status) 235 | 236 | if response.status == 401: 237 | raise SmartHubAuthError("Invalid credentials") 238 | elif response.status != 200: 239 | raise SmartHubConnectionError( 240 | f"Authentication failed with HTTP status: {response.status}" 241 | ) 242 | 243 | try: 244 | response_json = await response.json() 245 | except Exception as e: 246 | raise SmartHubDataError(f"Invalid JSON response: {e}") from e 247 | 248 | self.token = response_json.get("authorizationToken") 249 | self.primary_username = response_json.get("primaryUsername", self.email) 250 | 251 | if not self.token: 252 | raise SmartHubAuthError("No authorization token in response") 253 | 254 | _LOGGER.debug("Successfully retrieved authentication token") 255 | return self.token 256 | 257 | except ClientError as e: 258 | raise SmartHubConnectionError(f"Connection error during authentication: {e}") from e 259 | 260 | async def get_service_locations(self) -> List[SmartHubLocation]: 261 | """ 262 | Retrieve details about the service locaitons 263 | 264 | Returns: 265 | List of SmartHubLocation 266 | 267 | Raises: 268 | SmartHubAPIError: If the request fails after retries. 269 | """ 270 | user_data_url = f"https://{self.host}/services/secured/user-data" 271 | payload = { 272 | "userId" : self.primary_username, 273 | } 274 | 275 | if not self.token: 276 | await self._refresh_authentication() 277 | 278 | headers = { 279 | "Authority": self.host, 280 | "Authorization": f"Bearer {self.token}", 281 | "Content-Type": "application/json", 282 | "X-Nisc-Smarthub-Username": self.email, 283 | "User-Agent": "HomeAssistant SmartHub Integration", 284 | } 285 | 286 | try: 287 | session = await self._get_session() 288 | async with session.get(user_data_url, headers=headers, params=payload) as response: 289 | _ = await response.text() 290 | _LOGGER.debug("User Data response status: %s", response.status) 291 | 292 | if response.status == 401: 293 | raise SmartHubAuthError("Invalid credentials") 294 | elif response.status != 200: 295 | raise SmartHubConnectionError( 296 | f"User_data request failed with HTTP status: {response.status}" 297 | ) 298 | 299 | try: 300 | response_json = await response.json() 301 | except Exception as e: 302 | raise SmartHubDataError(f"Invalid JSON response: {e}") from e 303 | 304 | # Response format is structured as a list of dictionaries - 305 | # each dictionary has the following keys 306 | # "account", 307 | # "additionalCustomerName", 308 | # "address", 309 | # "agreementStatus", 310 | # "consumerClassCode", 311 | # "customer", 312 | # "customerName", 313 | # "disconnectNonPay", 314 | # "email", 315 | # "inactive", 316 | # "invoiceGroupNumber", 317 | # "isAutoPay", 318 | # "isDisconnected", 319 | # "isMultiService", 320 | # "isPendingDisconnect", 321 | # "isUnCollectible", 322 | # "primaryServiceLocationId", 323 | # "providerOrServiceDescription", 324 | # "providerToDescription", 325 | # "providerToProviderDescription", 326 | # "serviceLocationIdToServiceLocationSummary", 327 | # "serviceLocationToIndustries", 328 | # "serviceLocationToProviders", 329 | # "serviceLocationToUserDataServiceLocationSummaries", 330 | # "serviceToProviders", 331 | # "serviceToServiceDescription", 332 | # "services" 333 | # `serviceLocationToUserDataServiceLocationSummaries` Includes human readable information about the service location. 334 | # Which is a map of the location_id, to a list of 335 | # "activeRateSchedules", 336 | # "address", 337 | # "description", 338 | # "id", 339 | # "lastBillPresReadDtTm", 340 | # "lastBillPrevReadDtTm", 341 | # "location", 342 | # "serviceStatus", 343 | # "services" 344 | 345 | locations = [] 346 | _LOGGER.debug(response_json) 347 | 348 | for entry in response_json: 349 | for location_id, service_descriptions in entry.get("serviceLocationToUserDataServiceLocationSummaries", {}).items(): 350 | for service_description in service_descriptions: 351 | # for now only support electric service type 352 | if any(service in SUPPORTED_SERVICES for service in service_description.get("services",[])): 353 | locations.append( 354 | SmartHubLocation( 355 | id=location_id, 356 | service=ELECTRIC_SERVICE, 357 | description=service_description.get("description", "unknown"), 358 | ) 359 | ) 360 | 361 | return locations 362 | 363 | except ClientError as e: 364 | raise SmartHubConnectionError(f"Connection error during User_data request: {e}") from e 365 | 366 | async def get_energy_data(self, location, start_datetime=None, aggregation:Aggregation=Aggregation.HOURLY) -> Optional[Dict[str, Any]]: 367 | """ 368 | Retrieve energy usage data asynchronously with retry logic. 369 | 370 | Returns: 371 | Parsed energy usage data or None if no data available. 372 | 373 | Raises: 374 | SmartHubAPIError: If the request fails after retries. 375 | """ 376 | poll_url = f"https://{self.host}/services/secured/utility-usage/poll" 377 | 378 | # Calculate startDateTime and endDateTime 379 | now = datetime.now() 380 | # Get data since specified start (or last 30 days) as of midnight yesterday 381 | end_datetime = now.replace(minute=0, second=0, microsecond=0) 382 | if start_datetime is None: 383 | # fetch data from last period 384 | start_datetime = end_datetime - timedelta(days=30) 385 | 386 | start_timestamp = int(start_datetime.timestamp()) * 1000 387 | end_timestamp = int(end_datetime.timestamp()) * 1000 388 | 389 | data = { 390 | "timeFrame": aggregation.value, 391 | "userId": self.email, 392 | "screen": "USAGE_EXPLORER", 393 | "includeDemand": False, 394 | "serviceLocationNumber": location.id, 395 | "accountNumber": self.account_id, 396 | "industries": ["ELECTRIC"], 397 | "startDateTime": str(start_timestamp), 398 | "endDateTime": str(end_timestamp), 399 | } 400 | 401 | _LOGGER.debug("Requesting energy data from: %s", poll_url) 402 | 403 | # Track if we've already tried refreshing the token 404 | token_refreshed = False 405 | 406 | for attempt in range(1, MAX_RETRIES + 1): 407 | try: 408 | # If token is unset - refresh auth 409 | if not self.token: 410 | await self._refresh_authentication() 411 | token_refreshed = True 412 | 413 | headers = { 414 | "Authority": self.host, 415 | "Authorization": f"Bearer {self.token}", 416 | "Content-Type": "application/json", 417 | "X-Nisc-Smarthub-Username": self.email, 418 | "User-Agent": "HomeAssistant SmartHub Integration", 419 | } 420 | 421 | session = await self._get_session() 422 | async with session.post(poll_url, headers=headers, json=data) as response: 423 | _LOGGER.debug("Attempt %d: Response status: %s", attempt, response.status) 424 | 425 | if response.status == 401: 426 | if not token_refreshed: 427 | # Token expired, refresh and retry 428 | _LOGGER.info("Token expired, refreshing authentication...") 429 | await self._refresh_authentication() 430 | token_refreshed = True 431 | continue 432 | else: 433 | # Already tried refreshing, this is a persistent auth issue 434 | raise SmartHubAuthError("Authentication failed after token refresh") 435 | elif response.status != 200: 436 | error_text = await response.text() 437 | _LOGGER.warning("HTTP error %d: %s", response.status, error_text) 438 | raise SmartHubConnectionError( 439 | f"HTTP error {response.status}: {error_text}" 440 | ) 441 | 442 | try: 443 | response_json = await response.json() 444 | except Exception as e: 445 | raise SmartHubDataError(f"Invalid JSON response: {e}") from e 446 | 447 | # Check if the status is still pending 448 | status = response_json.get("status") 449 | if status == "PENDING": 450 | _LOGGER.debug("Attempt %d: Status is PENDING, retrying...", attempt) 451 | if attempt < MAX_RETRIES: 452 | await asyncio.sleep(RETRY_DELAY) 453 | continue 454 | else: 455 | _LOGGER.warning("Maximum retries reached, data still PENDING") 456 | return None 457 | elif status == "COMPLETE": 458 | _LOGGER.debug("Successfully retrieved energy data") 459 | return self.parse_usage(response_json) 460 | else: 461 | _LOGGER.warning("Unexpected status in response: %s", status) 462 | return None 463 | 464 | except SmartHubAuthError: 465 | # Re-raise auth errors immediately 466 | raise 467 | except ClientError as e: 468 | if attempt < MAX_RETRIES: 469 | _LOGGER.warning( 470 | "Attempt %d failed with connection error: %s, retrying...", 471 | attempt, e 472 | ) 473 | await asyncio.sleep(RETRY_DELAY) 474 | continue 475 | else: 476 | raise SmartHubConnectionError( 477 | f"Connection failed after {MAX_RETRIES} attempts: {e}" 478 | ) from e 479 | 480 | raise SmartHubAPIError(f"Failed to retrieve data after {MAX_RETRIES} attempts") 481 | 482 | --------------------------------------------------------------------------------