diff --git a/.coverage b/.coverage new file mode 100644 index 0000000..46dc9f0 Binary files /dev/null and b/.coverage differ diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..39dce17 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,42 @@ +name: CI + +on: + push: + branches: + - main + pull_request: + +jobs: + lint: + runs-on: ubuntu-latest + name: Ruff lint + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.13" + + - name: Install ruff + run: pip install ruff + + - name: Lint custom component + run: ruff check custom_components/alphaess tests + + test: + runs-on: ubuntu-latest + name: Pytest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.13" + + - name: Install test dependencies + run: | + pip install -r requirements_test.txt + pip install $(python -c "import json; print(' '.join(json.load(open('custom_components/alphaess/manifest.json'))['requirements']))") + + - name: Run tests with 100% coverage gate + run: pytest --cov --cov-report=term-missing diff --git a/.gitignore b/.gitignore index 25037dd..d8e3ee2 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,7 @@ /.idea custom_components/alphaess/__pycache__/ .claude/settings.local.json +__pycache__/ +.venv*/ +.pytest_cache/ +.ruff_cache/ diff --git a/custom_components/alphaess/__init__.py b/custom_components/alphaess/__init__.py index 8bc7420..db4bfae 100644 --- a/custom_components/alphaess/__init__.py +++ b/custom_components/alphaess/__init__.py @@ -1,34 +1,44 @@ """The AlphaEss integration.""" from __future__ import annotations -import asyncio import ipaddress import logging from datetime import timedelta +import aiohttp +import homeassistant.helpers.config_validation as cv import voluptuous as vol - -from alphaess import alphaess - -from homeassistant.config_entries import ConfigEntry, ConfigSubentry -from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.config_entries import ConfigEntry, ConfigEntryState, ConfigSubentry +from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.exceptions import ( + ConfigEntryAuthFailed, + ConfigEntryNotReady, + HomeAssistantError, +) +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.util import slugify -import homeassistant.helpers.config_validation as cv +from alphaess import alphaess from .const import ( - CONF_SCAN_INTERVAL_SECONDS, + CONF_ALT_POLLING_MODE, CONF_DISABLE_NOTIFICATIONS, CONF_EV_CHARGER_MODEL, + CONF_FAST_SCAN_INTERVAL_SECONDS, CONF_INVERTER_MODEL, CONF_IP_ADDRESS, - DEFAULT_SCAN_INTERVAL_SECONDS, - MAX_SCAN_INTERVAL_SECONDS, - MIN_SCAN_INTERVAL_SECONDS, CONF_PARENT_INVERTER, + CONF_SCAN_INTERVAL_SECONDS, CONF_SERIAL_NUMBER, + DEFAULT_FAST_SCAN_INTERVAL_SECONDS, + DEFAULT_SCAN_INTERVAL_SECONDS, DOMAIN, + MAX_FAST_SCAN_INTERVAL_SECONDS, + MAX_SCAN_INTERVAL_SECONDS, + MIN_FAST_SCAN_INTERVAL_SECONDS, + MIN_SCAN_INTERVAL_SECONDS, PLATFORMS, SUBENTRY_TYPE_EV_CHARGER, SUBENTRY_TYPE_INVERTER, @@ -38,6 +48,8 @@ _LOGGER = logging.getLogger(__name__) +type AlphaESSConfigEntry = ConfigEntry[AlphaESSDataUpdateCoordinator] + SERVICE_BATTERY_CHARGE_SCHEMA = vol.Schema( { vol.Required('serial'): cv.string, @@ -193,7 +205,50 @@ def _cleanup_stale_ev_entities( ent_reg.async_remove(entity_entry.entity_id) -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +def _resolve_client_for_serial(hass: HomeAssistant, serial: str) -> alphaess.alphaess: + """Find the API client managing the given inverter serial. + + Resolved at call time so services keep working across entry reloads. + """ + fallback = None + for entry in hass.config_entries.async_entries(DOMAIN): + coordinator = getattr(entry, "runtime_data", None) + if not isinstance(coordinator, AlphaESSDataUpdateCoordinator): + continue + if serial in (coordinator.data or {}): + return coordinator.api + if fallback is None: + fallback = coordinator.api + if fallback is not None: + return fallback + raise HomeAssistantError( + f"No loaded AlphaESS config entry found for serial {serial}" + ) + + +async def _async_service_battery_charge(call: ServiceCall) -> None: + """Handle the setbatterycharge service.""" + client = _resolve_client_for_serial(call.hass, call.data["serial"]) + await client.updateChargeConfigInfo( + call.data["serial"], call.data["chargestopsoc"], + int(call.data["enabled"] is True), call.data["cp1end"], + call.data["cp2end"], call.data["cp1start"], + call.data["cp2start"], + ) + + +async def _async_service_battery_discharge(call: ServiceCall) -> None: + """Handle the setbatterydischarge service.""" + client = _resolve_client_for_serial(call.hass, call.data["serial"]) + await client.updateDisChargeConfigInfo( + call.data["serial"], call.data["dischargecutoffsoc"], + int(call.data["enabled"] is True), call.data["dp1end"], + call.data["dp2end"], call.data["dp1start"], + call.data["dp2start"], + ) + + +async def async_setup_entry(hass: HomeAssistant, entry: AlphaESSConfigEntry) -> bool: """Set up Alpha ESS from a config entry.""" verify_ssl = entry.options.get( @@ -204,16 +259,25 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # Build per-inverter IP address mapping from subentries ip_address_map = _build_ip_address_map(entry) - # Don't set a single IP on the client - the coordinator handles per-inverter IPs + # Don't set a single IP on the client - the coordinator handles per-inverter IPs. + # Use HA's shared aiohttp session; the library applies verify_ssl per request. client = alphaess.alphaess( entry.data["AppID"], entry.data["AppSecret"], + session=async_get_clientsession(hass), verify_ssl=verify_ssl ) # Call getESSList to initialise the API client and discover systems # This is required before getdata() will work - ess_list = await client.getESSList() + try: + ess_list = await client.getESSList() + except aiohttp.ClientResponseError as err: + if err.status == 401: + raise ConfigEntryAuthFailed("AlphaESS credentials rejected") from err + raise ConfigEntryNotReady(f"AlphaESS cloud API not reachable: {err}") from err + except aiohttp.ClientError as err: + raise ConfigEntryNotReady(f"AlphaESS cloud API not reachable: {err}") from err # If no subentries exist (e.g. after migration from v1), auto-create them if not _has_inverter_subentries(entry) and ess_list: @@ -273,7 +337,21 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: min(MAX_SCAN_INTERVAL_SECONDS, scan_interval_seconds), ) - await asyncio.sleep(1) + # Alt polling mode settings + alt_polling_mode = entry.options.get(CONF_ALT_POLLING_MODE, False) + fast_scan_interval_seconds = entry.options.get( + CONF_FAST_SCAN_INTERVAL_SECONDS, + DEFAULT_FAST_SCAN_INTERVAL_SECONDS, + ) + try: + fast_scan_interval_seconds = int(fast_scan_interval_seconds) + except (TypeError, ValueError): + fast_scan_interval_seconds = DEFAULT_FAST_SCAN_INTERVAL_SECONDS + + fast_scan_interval_seconds = max( + MIN_FAST_SCAN_INTERVAL_SECONDS, + min(MAX_FAST_SCAN_INTERVAL_SECONDS, fast_scan_interval_seconds), + ) _coordinator = AlphaESSDataUpdateCoordinator( hass, @@ -282,10 +360,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: inverter_models=inverter_models, entry=entry, scan_interval=timedelta(seconds=scan_interval_seconds), + alt_polling_mode=alt_polling_mode, + fast_scan_interval=timedelta(seconds=fast_scan_interval_seconds), ) await _coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = _coordinator + entry.runtime_data = _coordinator # Auto-create EV charger subentries for any discovered chargers existing_ev_serials = { @@ -294,9 +374,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if sub.subentry_type == SUBENTRY_TYPE_EV_CHARGER } for serial, data in _coordinator.data.items(): - ev_sn = data.get("EV Charger S/N") + ev_sn = data.get(AlphaESSNames.evchargersn) if ev_sn and ev_sn not in existing_ev_serials: - ev_model = data.get("EV Charger Model", "Unknown") + ev_model = data.get(AlphaESSNames.evchargermodel, "Unknown") ev_subentry = ConfigSubentry( data={ CONF_SERIAL_NUMBER: ev_sn, @@ -348,48 +428,42 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: } hass.config_entries.async_update_entry(entry, options=new_options) - entry.async_on_unload(entry.add_update_listener(update_listener)) + # No update listener: the options flow inherits OptionsFlowWithReload, + # so HA reloads the entry automatically when options change. - # Register services (only once per domain) + # Register services (only once per domain); handlers resolve the + # owning client at call time so reloads don't leave stale references. if not hass.services.has_service(DOMAIN, 'setbatterycharge'): - async def async_battery_charge_handler(call): - await client.updateChargeConfigInfo( - call.data.get('serial'), call.data.get('chargestopsoc'), - int(call.data.get('enabled') is True), call.data.get('cp1end'), - call.data.get('cp2end'), call.data.get('cp1start'), - call.data.get('cp2start') - ) - - async def async_battery_discharge_handler(call): - await client.updateDisChargeConfigInfo( - call.data.get('serial'), call.data.get('dischargecutoffsoc'), - int(call.data.get('enabled') is True), call.data.get('dp1end'), - call.data.get('dp2end'), call.data.get('dp1start'), - call.data.get('dp2start') - ) - hass.services.async_register( - DOMAIN, 'setbatterycharge', async_battery_charge_handler, SERVICE_BATTERY_CHARGE_SCHEMA) + DOMAIN, 'setbatterycharge', _async_service_battery_charge, + SERVICE_BATTERY_CHARGE_SCHEMA) hass.services.async_register( - DOMAIN, 'setbatterydischarge', async_battery_discharge_handler, SERVICE_BATTERY_DISCHARGE_SCHEMA) + DOMAIN, 'setbatterydischarge', _async_service_battery_discharge, + SERVICE_BATTERY_DISCHARGE_SCHEMA) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: AlphaESSConfigEntry) -> bool: """Unload a config entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - hass.data[DOMAIN].pop(entry.entry_id) + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + if unload_ok: + # Remove domain services when the last loaded entry is unloaded + remaining = [ + other + for other in hass.config_entries.async_entries(DOMAIN) + if other.entry_id != entry.entry_id + and other.state is ConfigEntryState.LOADED + ] + if not remaining: + hass.services.async_remove(DOMAIN, 'setbatterycharge') + hass.services.async_remove(DOMAIN, 'setbatterydischarge') return unload_ok -async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: - """Handle options update.""" - await hass.config_entries.async_reload(entry.entry_id) - - async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Migrate old entry from version 1 to version 2.""" _LOGGER.debug( diff --git a/custom_components/alphaess/binary_sensor.py b/custom_components/alphaess/binary_sensor.py index 44d21bb..2fb0815 100644 --- a/custom_components/alphaess/binary_sensor.py +++ b/custom_components/alphaess/binary_sensor.py @@ -1,27 +1,29 @@ """Binary sensor platform for AlphaESS integration.""" -from typing import List import logging from homeassistant.components.binary_sensor import BinarySensorEntity from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ( - DOMAIN, + CONF_PARENT_INVERTER, CONF_SERIAL_NUMBER, - SUBENTRY_TYPE_INVERTER, SUBENTRY_TYPE_EV_CHARGER, - CONF_PARENT_INVERTER, + SUBENTRY_TYPE_INVERTER, ) from .coordinator import AlphaESSDataUpdateCoordinator -from .sensorlist import EV_CHARGER_BINARY_SENSORS from .device import build_ev_charger_device_info +from .enums import AlphaESSNames +from .sensorlist import EV_CHARGER_BINARY_SENSORS + +_LOGGER = logging.getLogger(__name__) -_LOGGER: logging.Logger = logging.getLogger(__package__) +# Read-only platform; the coordinator centralizes polling. +PARALLEL_UPDATES = 0 async def async_setup_entry(hass, entry, async_add_entities) -> None: """Set up EV charger readiness binary sensors.""" - coordinator: AlphaESSDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator: AlphaESSDataUpdateCoordinator = entry.runtime_data ev_binary_supported_states = { description.key: description for description in EV_CHARGER_BINARY_SENSORS @@ -34,7 +36,7 @@ async def async_setup_entry(hass, entry, async_add_entities) -> None: continue data = coordinator.data[serial] - ev_charger = data.get("EV Charger S/N") + ev_charger = data.get(AlphaESSNames.evchargersn) if not ev_charger: continue @@ -47,7 +49,7 @@ async def async_setup_entry(hass, entry, async_add_entities) -> None: continue ev_device_info = build_ev_charger_device_info(data) - ev_entities: List[BinarySensorEntity] = [] + ev_entities: list[BinarySensorEntity] = [] for description in ev_binary_supported_states.values(): ev_entities.append( AlphaEVReadinessBinarySensor( @@ -69,12 +71,12 @@ async def async_setup_entry(hass, entry, async_add_entities) -> None: continue data = coordinator.data[parent_serial] - ev_charger = data.get("EV Charger S/N") + ev_charger = data.get(AlphaESSNames.evchargersn) if not ev_charger: continue ev_device_info = build_ev_charger_device_info(data) - ev_entities: List[BinarySensorEntity] = [] + ev_entities: list[BinarySensorEntity] = [] for description in ev_binary_supported_states.values(): ev_entities.append( AlphaEVReadinessBinarySensor( diff --git a/custom_components/alphaess/button.py b/custom_components/alphaess/button.py index a971d06..3944bec 100644 --- a/custom_components/alphaess/button.py +++ b/custom_components/alphaess/button.py @@ -1,17 +1,27 @@ -import time as time_mod -from typing import List import logging -from homeassistant.components.button import ButtonEntity, ButtonDeviceClass +import time as time_mod + +from homeassistant.components.button import ButtonEntity from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DOMAIN, ALPHA_POST_REQUEST_RESTRICTION, INVERTER_SETTING_BLACKLIST, CONF_SERIAL_NUMBER, \ - SUBENTRY_TYPE_INVERTER, SUBENTRY_TYPE_EV_CHARGER, CONF_PARENT_INVERTER, CONF_DISABLE_NOTIFICATIONS +from .const import ( + ALPHA_POST_REQUEST_RESTRICTION, + CONF_DISABLE_NOTIFICATIONS, + CONF_PARENT_INVERTER, + CONF_SERIAL_NUMBER, + INVERTER_SETTING_BLACKLIST, + SUBENTRY_TYPE_EV_CHARGER, + SUBENTRY_TYPE_INVERTER, +) from .coordinator import AlphaESSDataUpdateCoordinator -from .sensorlist import SUPPORT_DISCHARGE_AND_CHARGE_BUTTON_DESCRIPTIONS, EV_DISCHARGE_AND_CHARGE_BUTTONS +from .device import build_ev_charger_device_info, build_inverter_device_info from .enums import AlphaESSNames -from .device import build_inverter_device_info, build_ev_charger_device_info +from .sensorlist import EV_DISCHARGE_AND_CHARGE_BUTTONS, SUPPORT_DISCHARGE_AND_CHARGE_BUTTON_DESCRIPTIONS -_LOGGER: logging.Logger = logging.getLogger(__package__) +_LOGGER = logging.getLogger(__name__) + +# Serialize button presses; the AlphaESS API rate-limits config writes. +PARALLEL_UPDATES = 1 async def create_persistent_notification(hass, message, title="Error"): @@ -28,7 +38,7 @@ async def create_persistent_notification(hass, message, title="Error"): async def async_setup_entry(hass, entry, async_add_entities) -> None: - coordinator: AlphaESSDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator: AlphaESSDataUpdateCoordinator = entry.runtime_data full_button_supported_states = { description.key: description for description in SUPPORT_DISCHARGE_AND_CHARGE_BUTTON_DESCRIPTIONS @@ -48,7 +58,7 @@ async def async_setup_entry(hass, entry, async_add_entities) -> None: model = data.get("Model") inverter_device_info = build_inverter_device_info(serial, data) - inverter_buttons: List[ButtonEntity] = [] + inverter_buttons: list[ButtonEntity] = [] if model not in INVERTER_SETTING_BLACKLIST: for description in full_button_supported_states: @@ -62,7 +72,7 @@ async def async_setup_entry(hass, entry, async_add_entities) -> None: ) # Auto-discovered EV charger buttons (no dedicated EV subentry) - ev_charger = data.get("EV Charger S/N") + ev_charger = data.get(AlphaESSNames.evchargersn) ev_subentry_serials = { sub.data.get(CONF_SERIAL_NUMBER) for sub in entry.subentries.values() @@ -94,12 +104,12 @@ async def async_setup_entry(hass, entry, async_add_entities) -> None: continue data = coordinator.data[parent_serial] - ev_charger = data.get("EV Charger S/N") + ev_charger = data.get(AlphaESSNames.evchargersn) if not ev_charger: continue ev_device_info = build_ev_charger_device_info(data) - ev_buttons: List[ButtonEntity] = [] + ev_buttons: list[ButtonEntity] = [] for description in ev_charging_supported_states: ev_buttons.append( AlphaESSBatteryButton( @@ -275,10 +285,6 @@ def available(self) -> bool: def unique_id(self): return f"{self._config.entry_id}_{self._serial} - {self._name}" - @property - def device_class(self): - return ButtonDeviceClass.IDENTIFY - @property def entity_category(self): return self._entity_category diff --git a/custom_components/alphaess/config_flow.py b/custom_components/alphaess/config_flow.py index de39849..ec04dcd 100644 --- a/custom_components/alphaess/config_flow.py +++ b/custom_components/alphaess/config_flow.py @@ -4,31 +4,39 @@ import asyncio import ipaddress import logging +from collections.abc import Mapping from typing import Any import aiohttp -from alphaess import alphaess import voluptuous as vol - from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, + ConfigFlowResult, ConfigSubentryFlow, - OptionsFlow, + OptionsFlowWithReload, SubentryFlowResult, ) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from alphaess import alphaess from .const import ( - CONF_SCAN_INTERVAL_SECONDS, + CONF_ALT_POLLING_MODE, CONF_DISABLE_NOTIFICATIONS, + CONF_FAST_SCAN_INTERVAL_SECONDS, CONF_INVERTER_MODEL, CONF_IP_ADDRESS, + CONF_SCAN_INTERVAL_SECONDS, CONF_SERIAL_NUMBER, + DEFAULT_FAST_SCAN_INTERVAL_SECONDS, DEFAULT_SCAN_INTERVAL_SECONDS, DOMAIN, + MAX_FAST_SCAN_INTERVAL_SECONDS, MAX_SCAN_INTERVAL_SECONDS, + MIN_FAST_SCAN_INTERVAL_SECONDS, MIN_SCAN_INTERVAL_SECONDS, SUBENTRY_TYPE_INVERTER, ) @@ -57,6 +65,7 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, client = alphaess.alphaess( data["AppID"], data["AppSecret"], + session=async_get_clientsession(hass), verify_ssl=data.get("Verify SSL Certificate", True) ) @@ -70,7 +79,7 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, if e.status == 401: raise InvalidAuth raise e - except aiohttp.client_exceptions.ClientConnectorError: + except (aiohttp.ClientError, TimeoutError): raise CannotConnect return {"title": data["AppID"], "ess_list": ess_list or []} @@ -86,7 +95,7 @@ class AlphaESSConfigFlow(ConfigFlow, domain=DOMAIN): def async_get_options_flow( config_entry: ConfigEntry, ) -> AlphaESSOptionsFlowHandler: - return AlphaESSOptionsFlowHandler(config_entry) + return AlphaESSOptionsFlowHandler() @classmethod @callback @@ -98,7 +107,7 @@ def async_get_supported_subentry_types( async def async_step_user( self, user_input: dict[str, Any] | None = None - ): + ) -> ConfigFlowResult: """Handle the initial step.""" errors = {} @@ -156,6 +165,46 @@ async def async_step_user( }, ) + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> ConfigFlowResult: + """Handle reauthentication when credentials are rejected.""" + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Ask for a new AppSecret and validate it.""" + errors = {} + reauth_entry = self._get_reauth_entry() + + if user_input is not None: + candidate = { + "AppID": reauth_entry.data["AppID"], + "AppSecret": user_input["AppSecret"], + "Verify SSL Certificate": reauth_entry.data.get( + "Verify SSL Certificate", True + ), + } + try: + await validate_input(self.hass, candidate) + except CannotConnect: + errors["base"] = "cannot_connect" + except InvalidAuth: + errors["base"] = "invalid_auth" + else: + return self.async_update_reload_and_abort( + reauth_entry, + data_updates={"AppSecret": user_input["AppSecret"]}, + ) + + return self.async_show_form( + step_id="reauth_confirm", + data_schema=vol.Schema({vol.Required("AppSecret"): str}), + errors=errors, + description_placeholders={ + "app_id": reauth_entry.data["AppID"], + }, + ) + class InvalidAuth(HomeAssistantError): """Error to indicate there is invalid auth.""" @@ -165,20 +214,21 @@ class CannotConnect(HomeAssistantError): """Error to indicate there is a problem connecting.""" -class AlphaESSOptionsFlowHandler(OptionsFlow): - """AlphaESS options flow.""" +class AlphaESSOptionsFlowHandler(OptionsFlowWithReload): + """AlphaESS options flow. - def __init__(self, config_entry: ConfigEntry): - self._config_entry = config_entry + Inherits OptionsFlowWithReload so HA reloads the entry automatically when + options change; the integration must not also register an update listener. + """ async def async_step_init( self, user_input: dict[str, Any] | None = None - ): + ) -> ConfigFlowResult: if user_input is not None: # Preserve internal flags (keys starting with '_') across option saves merged = { k: v - for k, v in self._config_entry.options.items() + for k, v in self.config_entry.options.items() if k.startswith("_") } merged.update(user_input) @@ -187,14 +237,14 @@ async def async_step_init( schema = { vol.Optional( "Verify SSL Certificate", - default=self._config_entry.options.get( + default=self.config_entry.options.get( "Verify SSL Certificate", - self._config_entry.data.get("Verify SSL Certificate", True), + self.config_entry.data.get("Verify SSL Certificate", True), ), ): bool, vol.Optional( CONF_SCAN_INTERVAL_SECONDS, - default=self._config_entry.options.get( + default=self.config_entry.options.get( CONF_SCAN_INTERVAL_SECONDS, DEFAULT_SCAN_INTERVAL_SECONDS, ), @@ -202,6 +252,23 @@ async def async_step_init( vol.Coerce(int), vol.Range(min=MIN_SCAN_INTERVAL_SECONDS, max=MAX_SCAN_INTERVAL_SECONDS), ), + vol.Optional( + CONF_ALT_POLLING_MODE, + default=self.config_entry.options.get( + CONF_ALT_POLLING_MODE, + False, + ), + ): bool, + vol.Optional( + CONF_FAST_SCAN_INTERVAL_SECONDS, + default=self.config_entry.options.get( + CONF_FAST_SCAN_INTERVAL_SECONDS, + DEFAULT_FAST_SCAN_INTERVAL_SECONDS, + ), + ): vol.All( + vol.Coerce(int), + vol.Range(min=MIN_FAST_SCAN_INTERVAL_SECONDS, max=MAX_FAST_SCAN_INTERVAL_SECONDS), + ), } return self.async_show_form(step_id="init", data_schema=vol.Schema(schema)) @@ -218,7 +285,7 @@ def __init__(self) -> None: def _get_api(self): """Get the API client from the coordinator.""" entry = self._get_entry() - coordinator = self.hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data return coordinator.api async def async_step_user( @@ -291,9 +358,7 @@ async def async_step_verify( # Schedule reload so the new system is discovered entry = self._get_entry() - self.hass.async_create_task( - self.hass.config_entries.async_reload(entry.entry_id) - ) + self.hass.config_entries.async_schedule_reload(entry.entry_id) return self.async_create_entry( title=f"Inverter ({self._sysSn})", @@ -348,14 +413,12 @@ async def async_step_reconfigure( await _notify(self.hass, f"Inverter {serial} has been successfully unbound from your account. The integration will reload.", "AlphaESS Unbind Successful") entry = self._get_entry() - self.hass.async_create_task( - self.hass.config_entries.async_reload(entry.entry_id) + self.hass.config_entries.async_remove_subentry( + entry, subentry.subentry_id ) + self.hass.config_entries.async_schedule_reload(entry.entry_id) - return self.async_remove_and_abort( - self._get_entry(), - subentry, - ) + return self.async_abort(reason="unbind_successful") else: # Save IP address and notification settings ip = (user_input.get(CONF_IP_ADDRESS) or "").strip() diff --git a/custom_components/alphaess/const.py b/custom_components/alphaess/const.py index b70f350..ac6d54f 100644 --- a/custom_components/alphaess/const.py +++ b/custom_components/alphaess/const.py @@ -19,6 +19,13 @@ MAX_SCAN_INTERVAL_SECONDS = 3600 ALPHA_POST_REQUEST_RESTRICTION = timedelta(seconds=30) +# Alt polling mode constants +CONF_ALT_POLLING_MODE = "alt_polling_mode" +CONF_FAST_SCAN_INTERVAL_SECONDS = "fast_scan_interval_seconds" +DEFAULT_FAST_SCAN_INTERVAL_SECONDS = 15 +MIN_FAST_SCAN_INTERVAL_SECONDS = 5 +MAX_FAST_SCAN_INTERVAL_SECONDS = 300 + # Subentry types SUBENTRY_TYPE_INVERTER = "inverter" SUBENTRY_TYPE_EV_CHARGER = "ev_charger" diff --git a/custom_components/alphaess/coordinator.py b/custom_components/alphaess/coordinator.py index 151ae8f..08b4305 100644 --- a/custom_components/alphaess/coordinator.py +++ b/custom_components/alphaess/coordinator.py @@ -1,18 +1,22 @@ """Coordinator for AlphaEss integration.""" +import asyncio import logging -from datetime import datetime, timedelta -from typing import Any, Dict, Optional, Union +import time as time_mod +from datetime import timedelta +from typing import Any import aiohttp -from alphaess import alphaess - from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.util import dt as dt_util + +from alphaess import alphaess from .const import ( - CONF_IP_ADDRESS, CONF_SERIAL_NUMBER, + DEFAULT_FAST_SCAN_INTERVAL_SECONDS, DOMAIN, LOWER_INVERTER_API_CALL_LIST, SCAN_INTERVAL, @@ -21,28 +25,28 @@ ) from .enums import AlphaESSNames -_LOGGER: logging.Logger = logging.getLogger(__package__) +_LOGGER = logging.getLogger(__name__) class DataProcessor: """Helper class for data processing utilities.""" @staticmethod - async def process_value(value: Any, default: Any = None) -> Any: + def process_value(value: Any, default: Any = None) -> Any: """Process and validate a value, returning default if empty.""" if value is None or (isinstance(value, str) and value.strip() == ''): return default return value @staticmethod - async def safe_get(dictionary: Optional[Dict], key: str, default: Any = None) -> Any: + def safe_get(dictionary: dict | None, key: str, default: Any = None) -> Any: """Safely get a value from a dictionary.""" if dictionary is None: return default - return await DataProcessor.process_value(dictionary.get(key), default) + return DataProcessor.process_value(dictionary.get(key), default) @staticmethod - async def safe_calculate(val1: Optional[float], val2: Optional[float]) -> Optional[float]: + def safe_calculate(val1: float | None, val2: float | None) -> float | None: """Safely calculate difference between two values.""" if val1 is None or val2 is None: return None @@ -53,9 +57,9 @@ class TimeHelper: """Helper class for time-related operations.""" @staticmethod - async def get_rounded_time() -> str: - """Get time rounded to next 15-minute interval.""" - now = datetime.now() + def get_rounded_time() -> str: + """Get time rounded to next 15-minute interval (HA local time).""" + now = dt_util.now() if now.minute > 45: rounded_time = now + timedelta(hours=1) @@ -67,13 +71,12 @@ async def get_rounded_time() -> str: return rounded_time.strftime("%H:%M") @staticmethod - async def calculate_time_window(time_period_minutes: int) -> tuple[str, str]: + def calculate_time_window(time_period_minutes: int) -> tuple[str, str]: """Calculate start and end time for a given period.""" - now = datetime.now() - start_time_str = await TimeHelper.get_rounded_time() - start_time = datetime.strptime(start_time_str, "%H:%M").replace( - year=now.year, month=now.month, day=now.day - ) + now = dt_util.now() + start_time_str = TimeHelper.get_rounded_time() + hour, minute = (int(part) for part in start_time_str.split(":")) + start_time = now.replace(hour=hour, minute=minute, second=0, microsecond=0) end_time = start_time + timedelta(minutes=time_period_minutes) return start_time.strftime("%H:%M"), end_time.strftime("%H:%M") @@ -84,20 +87,20 @@ class InverterDataParser: def __init__(self, data_processor: DataProcessor): self.dp = data_processor - async def parse_basic_info(self, invertor: Dict) -> Dict[str, Any]: + def parse_basic_info(self, invertor: dict) -> dict[str, Any]: """Parse basic inverter information.""" return { - "Model": await self.dp.process_value(invertor.get("minv")), - AlphaESSNames.mbat: await self.dp.process_value(invertor.get("mbat")), - AlphaESSNames.poinv: await self.dp.process_value(invertor.get("poinv")), - AlphaESSNames.popv: await self.dp.process_value(invertor.get("popv")), - AlphaESSNames.EmsStatus: await self.dp.process_value(invertor.get("emsStatus")), - AlphaESSNames.usCapacity: await self.dp.process_value(invertor.get("usCapacity")), - AlphaESSNames.surplusCobat: await self.dp.process_value(invertor.get("surplusCobat")), - AlphaESSNames.cobat: await self.dp.process_value(invertor.get("cobat")), + "Model": self.dp.process_value(invertor.get("minv")), + AlphaESSNames.mbat: self.dp.process_value(invertor.get("mbat")), + AlphaESSNames.poinv: self.dp.process_value(invertor.get("poinv")), + AlphaESSNames.popv: self.dp.process_value(invertor.get("popv")), + AlphaESSNames.EmsStatus: self.dp.process_value(invertor.get("emsStatus")), + AlphaESSNames.usCapacity: self.dp.process_value(invertor.get("usCapacity")), + AlphaESSNames.surplusCobat: self.dp.process_value(invertor.get("surplusCobat")), + AlphaESSNames.cobat: self.dp.process_value(invertor.get("cobat")), } - async def parse_local_ip_data(self, local_ip_data: Dict) -> Dict[str, Any]: + def parse_local_ip_data(self, local_ip_data: dict) -> dict[str, Any]: """Parse local IP system data.""" if not local_ip_data: return {} @@ -107,26 +110,26 @@ async def parse_local_ip_data(self, local_ip_data: Dict) -> Dict[str, Any]: return { AlphaESSNames.localIP: local_ip_data.get("ip"), - AlphaESSNames.deviceStatus: await self.dp.safe_get(status, "devstatus"), - AlphaESSNames.cloudConnectionStatus: await self.dp.safe_get(status, "serverstatus"), - AlphaESSNames.wifiStatus: await self.dp.safe_get(status, "wifistatus"), - AlphaESSNames.connectedSSID: await self.dp.safe_get(status, "connssid"), - AlphaESSNames.wifiDHCP: await self.dp.safe_get(status, "wifidhcp"), - AlphaESSNames.wifiIP: await self.dp.safe_get(status, "wifiip"), - AlphaESSNames.wifiMask: await self.dp.safe_get(status, "wifimask"), - AlphaESSNames.wifiGateway: await self.dp.safe_get(status, "wifigateway"), - AlphaESSNames.deviceSerialNumber: await self.dp.safe_get(device_info, "sn"), - AlphaESSNames.registerKey: await self.dp.safe_get(device_info, "key"), - AlphaESSNames.hardwareVersion: await self.dp.safe_get(device_info, "hw"), - AlphaESSNames.softwareVersion: await self.dp.safe_get(device_info, "sw"), - AlphaESSNames.apn: await self.dp.safe_get(device_info, "apn"), - AlphaESSNames.username: await self.dp.safe_get(device_info, "username"), - AlphaESSNames.password: await self.dp.safe_get(device_info, "password"), - AlphaESSNames.ethernetModule: await self.dp.safe_get(device_info, "ethmoudle"), - AlphaESSNames.fourGModule: await self.dp.safe_get(device_info, "g4moudle"), + AlphaESSNames.deviceStatus: self.dp.safe_get(status, "devstatus"), + AlphaESSNames.cloudConnectionStatus: self.dp.safe_get(status, "serverstatus"), + AlphaESSNames.wifiStatus: self.dp.safe_get(status, "wifistatus"), + AlphaESSNames.connectedSSID: self.dp.safe_get(status, "connssid"), + AlphaESSNames.wifiDHCP: self.dp.safe_get(status, "wifidhcp"), + AlphaESSNames.wifiIP: self.dp.safe_get(status, "wifiip"), + AlphaESSNames.wifiMask: self.dp.safe_get(status, "wifimask"), + AlphaESSNames.wifiGateway: self.dp.safe_get(status, "wifigateway"), + AlphaESSNames.deviceSerialNumber: self.dp.safe_get(device_info, "sn"), + AlphaESSNames.registerKey: self.dp.safe_get(device_info, "key"), + AlphaESSNames.hardwareVersion: self.dp.safe_get(device_info, "hw"), + AlphaESSNames.softwareVersion: self.dp.safe_get(device_info, "sw"), + AlphaESSNames.apn: self.dp.safe_get(device_info, "apn"), + AlphaESSNames.username: self.dp.safe_get(device_info, "username"), + AlphaESSNames.password: self.dp.safe_get(device_info, "password"), + AlphaESSNames.ethernetModule: self.dp.safe_get(device_info, "ethmoudle"), + AlphaESSNames.fourGModule: self.dp.safe_get(device_info, "g4moudle"), } - async def parse_ev_data(self, ev_data: Optional[Dict], invertor: Dict) -> Dict[str, Any]: + def parse_ev_data(self, ev_data: dict | None, invertor: dict) -> dict[str, Any]: """Parse EV charger data.""" if not ev_data: return {} @@ -136,56 +139,56 @@ async def parse_ev_data(self, ev_data: Optional[Dict], invertor: Dict) -> Dict[s ev_current = invertor.get("EVCurrent", {}) return { - AlphaESSNames.evchargersn: await self.dp.safe_get(ev_data, "evchargerSn"), - AlphaESSNames.evchargermodel: await self.dp.safe_get(ev_data, "evchargerModel"), - AlphaESSNames.evchargerstatus: await self.dp.safe_get(ev_status, "evchargerStatus"), - AlphaESSNames.evchargerstatusraw: await self.dp.safe_get(ev_status, "evchargerStatus"), - AlphaESSNames.evcurrentsetting: await self.dp.safe_get(ev_current, "currentsetting"), + AlphaESSNames.evchargersn: self.dp.safe_get(ev_data, "evchargerSn"), + AlphaESSNames.evchargermodel: self.dp.safe_get(ev_data, "evchargerModel"), + AlphaESSNames.evchargerstatus: self.dp.safe_get(ev_status, "evchargerStatus"), + AlphaESSNames.evchargerstatusraw: self.dp.safe_get(ev_status, "evchargerStatus"), + AlphaESSNames.evcurrentsetting: self.dp.safe_get(ev_current, "currentsetting"), } - async def parse_summary_data(self, sum_data: Dict) -> Dict[str, Any]: + def parse_summary_data(self, sum_data: dict, fallback_currency: str | None = None) -> dict[str, Any]: """Parse summary statistics.""" - currency = await self.dp.safe_get(sum_data, "moneyType") + currency = self.dp.safe_get(sum_data, "moneyType") data = { - AlphaESSNames.TotalLoad: await self.dp.safe_get(sum_data, "eload"), - AlphaESSNames.Income: await self.dp.safe_get(sum_data, "totalIncome"), - AlphaESSNames.Total_Generation: await self.dp.safe_get(sum_data, "epvtotal"), - AlphaESSNames.treePlanted: await self.dp.safe_get(sum_data, "treeNum"), - AlphaESSNames.carbonReduction: await self.dp.safe_get(sum_data, "carbonNum"), - AlphaESSNames.TodayGeneration: await self.dp.safe_get(sum_data, "epvtoday"), - AlphaESSNames.TodayIncome: await self.dp.safe_get(sum_data, "todayIncome"), + AlphaESSNames.TotalLoad: self.dp.safe_get(sum_data, "eload"), + AlphaESSNames.Income: self.dp.safe_get(sum_data, "totalIncome"), + AlphaESSNames.Total_Generation: self.dp.safe_get(sum_data, "epvtotal"), + AlphaESSNames.treePlanted: self.dp.safe_get(sum_data, "treeNum"), + AlphaESSNames.carbonReduction: self.dp.safe_get(sum_data, "carbonNum"), + AlphaESSNames.TodayGeneration: self.dp.safe_get(sum_data, "epvtoday"), + AlphaESSNames.TodayIncome: self.dp.safe_get(sum_data, "todayIncome"), } - if currency is not None: - data[AlphaESSNames.CurrencyCode] = currency - data["Currency"] = currency + resolved = currency or fallback_currency or "Unknown" + data[AlphaESSNames.CurrencyCode] = resolved + data["Currency"] = resolved # Handle self consumption and sufficiency correctly - self_consumption = await self.dp.safe_get(sum_data, "eselfConsumption") - self_sufficiency = await self.dp.safe_get(sum_data, "eselfSufficiency") + self_consumption = self.dp.safe_get(sum_data, "eselfConsumption") + self_sufficiency = self.dp.safe_get(sum_data, "eselfSufficiency") data[AlphaESSNames.SelfConsumption] = self_consumption * 100 if self_consumption is not None else None data[AlphaESSNames.SelfSufficiency] = self_sufficiency * 100 if self_sufficiency is not None else None return data - async def parse_energy_data(self, energy_data: Dict) -> Dict[str, Any]: + def parse_energy_data(self, energy_data: dict) -> dict[str, Any]: """Parse daily energy flow data.""" - pv = await self.dp.safe_get(energy_data, "epv") - feedin = await self.dp.safe_get(energy_data, "eOutput") - gridcharge = await self.dp.safe_get(energy_data, "eGridCharge") - charge = await self.dp.safe_get(energy_data, "eCharge") - grid_consumption = await self.dp.safe_get(energy_data, "eInput") - discharge = await self.dp.safe_get(energy_data, "eDischarge") - ev_energy = await self.dp.safe_get(energy_data, "eChargingPile") - energy_date = await self.dp.safe_get(energy_data, "theDate") + pv = self.dp.safe_get(energy_data, "epv") + feedin = self.dp.safe_get(energy_data, "eOutput") + gridcharge = self.dp.safe_get(energy_data, "eGridCharge") + charge = self.dp.safe_get(energy_data, "eCharge") + grid_consumption = self.dp.safe_get(energy_data, "eInput") + discharge = self.dp.safe_get(energy_data, "eDischarge") + ev_energy = self.dp.safe_get(energy_data, "eChargingPile") + energy_date = self.dp.safe_get(energy_data, "theDate") return { AlphaESSNames.SolarProduction: pv, - AlphaESSNames.SolarToLoad: await self.dp.safe_calculate(pv, feedin), + AlphaESSNames.SolarToLoad: self.dp.safe_calculate(pv, feedin), AlphaESSNames.SolarToGrid: feedin, - AlphaESSNames.SolarToBattery: await self.dp.safe_calculate(charge, gridcharge), + AlphaESSNames.SolarToBattery: self.dp.safe_calculate(charge, gridcharge), AlphaESSNames.GridToLoad: grid_consumption, AlphaESSNames.GridToBattery: gridcharge, AlphaESSNames.Charge: charge, @@ -201,39 +204,39 @@ async def parse_energy_data(self, energy_data: Dict) -> Dict[str, Any]: AlphaESSNames.DailyEnergyDate: energy_date, } - async def parse_power_data(self, power_data: Dict, one_day_power: Optional[list]) -> Dict[str, Any]: + def parse_power_data(self, power_data: dict, one_day_power: list | None) -> dict[str, Any]: """Parse instantaneous power data.""" - soc = await self.dp.safe_get(power_data, "soc") + soc = self.dp.safe_get(power_data, "soc") grid_details = power_data.get("pgridDetail", {}) pv_details = power_data.get("ppvDetail", {}) ev_details = power_data.get("pevDetail", {}) data = { AlphaESSNames.BatterySOC: soc, - AlphaESSNames.BatteryIO: await self.dp.safe_get(power_data, "pbat"), - AlphaESSNames.Load: await self.dp.safe_get(power_data, "pload"), - AlphaESSNames.Generation: await self.dp.safe_get(power_data, "ppv"), - AlphaESSNames.GridIOTotal: await self.dp.safe_get(power_data, "pgrid"), - AlphaESSNames.pev: await self.dp.safe_get(power_data, "pev"), - AlphaESSNames.PrealL1: await self.dp.safe_get(power_data, "prealL1"), - AlphaESSNames.PrealL2: await self.dp.safe_get(power_data, "prealL2"), - AlphaESSNames.PrealL3: await self.dp.safe_get(power_data, "prealL3"), + AlphaESSNames.BatteryIO: self.dp.safe_get(power_data, "pbat"), + AlphaESSNames.Load: self.dp.safe_get(power_data, "pload"), + AlphaESSNames.Generation: self.dp.safe_get(power_data, "ppv"), + AlphaESSNames.GridIOTotal: self.dp.safe_get(power_data, "pgrid"), + AlphaESSNames.pev: self.dp.safe_get(power_data, "pev"), + AlphaESSNames.PrealL1: self.dp.safe_get(power_data, "prealL1"), + AlphaESSNames.PrealL2: self.dp.safe_get(power_data, "prealL2"), + AlphaESSNames.PrealL3: self.dp.safe_get(power_data, "prealL3"), } # PV string data for i in range(1, 5): - data[getattr(AlphaESSNames, f"PPV{i}")] = await self.dp.safe_get(pv_details, f"ppv{i}") + data[getattr(AlphaESSNames, f"PPV{i}")] = self.dp.safe_get(pv_details, f"ppv{i}") - data[AlphaESSNames.pmeterDc] = await self.dp.safe_get(pv_details, "pmeterDc") + data[AlphaESSNames.pmeterDc] = self.dp.safe_get(pv_details, "pmeterDc") # Grid phase data for i in range(1, 4): - data[getattr(AlphaESSNames, f"GridIOL{i}")] = await self.dp.safe_get(grid_details, f"pmeterL{i}") + data[getattr(AlphaESSNames, f"GridIOL{i}")] = self.dp.safe_get(grid_details, f"pmeterL{i}") # EV power data for i in range(1, 5): key_map = {1: "One", 2: "Two", 3: "Three", 4: "Four"} - ev_power = await self.dp.safe_get(ev_details, f"ev{i}Power") + ev_power = self.dp.safe_get(ev_details, f"ev{i}Power") if ev_power is not None: data[getattr(AlphaESSNames, f"ElectricVehiclePower{key_map[i]}")] = ev_power @@ -246,20 +249,20 @@ async def parse_power_data(self, power_data: Dict, one_day_power: Optional[list] return data - async def parse_charge_config(self, config: Dict) -> Dict[str, Any]: + def parse_charge_config(self, config: dict) -> dict[str, Any]: """Parse charge configuration.""" data = {} for key in ["gridCharge", AlphaESSNames.batHighCap]: if key == AlphaESSNames.batHighCap: - data[key] = await self.dp.safe_get(config, "batHighCap") + data[key] = self.dp.safe_get(config, "batHighCap") else: - data[key] = await self.dp.safe_get(config, key) + data[key] = self.dp.safe_get(config, key) # Parse time slots with the correct key names - time_start_1 = await self.dp.safe_get(config, "timeChaf1") - time_end_1 = await self.dp.safe_get(config, "timeChae1") - time_start_2 = await self.dp.safe_get(config, "timeChaf2") - time_end_2 = await self.dp.safe_get(config, "timeChae2") + time_start_1 = self.dp.safe_get(config, "timeChaf1") + time_end_1 = self.dp.safe_get(config, "timeChae1") + time_start_2 = self.dp.safe_get(config, "timeChaf2") + time_end_2 = self.dp.safe_get(config, "timeChae2") # Format as "HH:MM - HH:MM" to match expected format if time_start_1 and time_end_1: @@ -280,20 +283,20 @@ async def parse_charge_config(self, config: Dict) -> Dict[str, Any]: return data - async def parse_discharge_config(self, config: Dict) -> Dict[str, Any]: + def parse_discharge_config(self, config: dict) -> dict[str, Any]: """Parse discharge configuration.""" data = {} for key in ["ctrDis", AlphaESSNames.batUseCap]: if key == AlphaESSNames.batUseCap: - data[key] = await self.dp.safe_get(config, "batUseCap") + data[key] = self.dp.safe_get(config, "batUseCap") else: - data[key] = await self.dp.safe_get(config, key) + data[key] = self.dp.safe_get(config, key) # Parse time slots with the correct key names - time_start_1 = await self.dp.safe_get(config, "timeDisf1") - time_end_1 = await self.dp.safe_get(config, "timeDise1") - time_start_2 = await self.dp.safe_get(config, "timeDisf2") - time_end_2 = await self.dp.safe_get(config, "timeDise2") + time_start_1 = self.dp.safe_get(config, "timeDisf1") + time_end_1 = self.dp.safe_get(config, "timeDise1") + time_start_2 = self.dp.safe_get(config, "timeDisf2") + time_end_2 = self.dp.safe_get(config, "timeDise2") # Format as "HH:MM - HH:MM" to match expected format if time_start_1 and time_end_1: @@ -315,7 +318,7 @@ async def parse_discharge_config(self, config: Dict) -> Dict[str, Any]: return data -class AlphaESSDataUpdateCoordinator(DataUpdateCoordinator): +class AlphaESSDataUpdateCoordinator(DataUpdateCoordinator[dict[str, dict[str, Any]]]): """Class to manage fetching data from the API.""" def __init__( @@ -326,19 +329,47 @@ def __init__( inverter_models: list[str] | None = None, entry: ConfigEntry | None = None, scan_interval: timedelta | None = None, + alt_polling_mode: bool = False, + fast_scan_interval: timedelta | None = None, ) -> None: """Initialize coordinator.""" + self.alt_polling_mode = alt_polling_mode + self._full_poll_interval = scan_interval or SCAN_INTERVAL + self._fast_scan_interval = fast_scan_interval or timedelta(seconds=DEFAULT_FAST_SCAN_INTERVAL_SECONDS) + + # In alt mode the coordinator ticks at the fast interval; + # full polls happen when _full_poll_interval has elapsed. + effective_interval = self._fast_scan_interval if alt_polling_mode else self._full_poll_interval + super().__init__( hass, _LOGGER, + # Pass the entry explicitly: relying on the ContextVar is + # deprecated and breaks in HA 2026.8. + config_entry=entry, name=DOMAIN, - update_interval=scan_interval or SCAN_INTERVAL, + update_interval=effective_interval, ) self.api = client self.hass = hass - self.data: dict[str, dict[str, float]] = {} + self.data: dict[str, dict[str, Any]] = {} self.entry = entry + self._last_full_poll: float | None = None # monotonic timestamp of last full poll + + # Stagger fast polls: round-robin index across inverters + self._fast_poll_index: int = 0 + + # Per-inverter consecutive error count for backoff + self._inverter_error_count: dict[str, int] = {} + self._ERROR_BACKOFF_THRESHOLD = 3 # skip inverter after this many consecutive failures + self._ERROR_BACKOFF_CYCLES = 5 # retry every N cycles when backed off + + # Poll diagnostics (exposed as sensor data per-serial) + self._last_poll_type: str = "none" + self._last_full_poll_utc: str | None = None + self._poll_tick_count: int = 0 + # Per-inverter IP address mapping self.ip_address_map = ip_address_map or {} @@ -367,6 +398,13 @@ def __init__( self.last_discharge_update: dict[str, float] = {} self.last_charge_update: dict[str, float] = {} + # Per-serial user settings from number entities (batUseCap/batHighCap), + # keyed by serial then setting key. Replaces the old hass.data[DOMAIN][serial] store. + self.number_settings: dict[str, dict[str, float]] = {} + + # Guards temporary mutation of the shared API client's ipaddress + self._local_ip_lock = asyncio.Lock() + # Build subentry lookup for device info self._inverter_subentry_map: dict[str, str] = {} self._ev_charger_subentry_map: dict[str, str] = {} @@ -382,6 +420,14 @@ def get_inverter_subentry_id(self, serial: str) -> str | None: """Get the subentry ID for an inverter by its serial number.""" return self._inverter_subentry_map.get(serial) + def set_number_setting(self, serial: str, key: str, value: float) -> None: + """Store a per-inverter number setting (e.g. batUseCap/batHighCap).""" + self.number_settings.setdefault(serial, {})[key] = value + + def get_number_setting(self, serial: str, key: str, default: float | None = None) -> float | None: + """Read a per-inverter number setting.""" + return self.number_settings.get(serial, {}).get(key, default) + def get_ev_charger_subentry_id(self, ev_serial: str) -> str | None: """Get the subentry ID for an EV charger by its serial number.""" return self._ev_charger_subentry_map.get(ev_serial) @@ -437,19 +483,19 @@ async def control_ev(self, serial: str, ev_serial: str, direction: str) -> None: result = await self.api.remoteControlEvCharger(serial, ev_serial, direction) _LOGGER.info( - f"Control EV Charger: {ev_serial} for serial: {serial} " - f"Direction: {direction} - Result: {result}" + "Control EV Charger: %s for serial: %s Direction: %s - Result: %s", + ev_serial, serial, direction, result, ) async def reset_config(self, serial: str) -> None: """Reset charge and discharge configuration.""" - bat_use_cap = self.hass.data[DOMAIN][serial].get("batUseCap", 10) - bat_high_cap = self.hass.data[DOMAIN][serial].get("batHighCap", 90) + bat_use_cap = self.get_number_setting(serial, "batUseCap", 10) + bat_high_cap = self.get_number_setting(serial, "batHighCap", 90) results = await self._reset_charge_discharge_config(serial, bat_high_cap, bat_use_cap) _LOGGER.info( - f"Reset Charge and Discharge configuration - " - f"Charge: {results['charge']}, Discharge: {results['discharge']}" + "Reset Charge and Discharge configuration - Charge: %s, Discharge: %s", + results["charge"], results["discharge"], ) # Optimistically update so switches reflect the change immediately if serial in self.data: @@ -459,7 +505,7 @@ async def reset_config(self, serial: str) -> None: async def _reset_charge_discharge_config( self, serial: str, bat_high_cap: int, bat_use_cap: int - ) -> Dict[str, Any]: + ) -> dict[str, Any]: """Internal method to reset configurations.""" charge_result = await self.api.updateChargeConfigInfo( serial, bat_high_cap, 1, "00:00", "00:00", "00:00", "00:00" @@ -471,16 +517,16 @@ async def _reset_charge_discharge_config( async def update_discharge(self, name: str, serial: str, time_period: int) -> None: """Update discharge configuration for specified time period.""" - bat_use_cap = self.hass.data[DOMAIN][serial].get(name) - start_time, end_time = await self.time_helper.calculate_time_window(time_period) + bat_use_cap = self.get_number_setting(serial, name, 10) + start_time, end_time = self.time_helper.calculate_time_window(time_period) result = await self.api.updateDisChargeConfigInfo( serial, bat_use_cap, 1, end_time, "00:00", start_time, "00:00" ) _LOGGER.info( - f"Updated discharge config - Capacity: {bat_use_cap}, " - f"Period: {start_time} to {end_time}, Result: {result}" + "Updated discharge config - Capacity: %s, Period: %s to %s, Result: %s", + bat_use_cap, start_time, end_time, result, ) # Optimistically update so the discharge switch reflects enabled immediately if serial in self.data: @@ -489,97 +535,364 @@ async def update_discharge(self, name: str, serial: str, time_period: int) -> No async def update_charge(self, name: str, serial: str, time_period: int) -> None: """Update charge configuration for specified time period.""" - bat_high_cap = self.hass.data[DOMAIN][serial].get(name) - start_time, end_time = await self.time_helper.calculate_time_window(time_period) + bat_high_cap = self.get_number_setting(serial, name, 90) + start_time, end_time = self.time_helper.calculate_time_window(time_period) result = await self.api.updateChargeConfigInfo( serial, bat_high_cap, 1, end_time, "00:00", start_time, "00:00" ) _LOGGER.info( - f"Updated charge config - Capacity: {bat_high_cap}, " - f"Period: {start_time} to {end_time}, Result: {result}" + "Updated charge config - Capacity: %s, Period: %s to %s, Result: %s", + bat_high_cap, start_time, end_time, result, ) # Optimistically update so the charge switch reflects enabled immediately if serial in self.data: self.data[serial]["gridCharge"] = 1 self.async_set_updated_data(self.data) - async def _async_update_data(self) -> Optional[Dict[str, Dict[str, Any]]]: + async def _async_update_data(self) -> dict[str, dict[str, Any]] | None: """Update data via library.""" if self.data is None: self.data = {} + if self.alt_polling_mode: + return await self._async_update_data_alt() + + self._poll_tick_count += 1 + self._last_poll_type = "normal" + try: - throttle_factor = self.throttle_multiplier * self.LOCAL_INVERTER_COUNT - jsondata = await self.api.getdata(True, True, throttle_factor) + throttle_delay = self.throttle_multiplier - if jsondata is None: + # Get list of registered inverters + units = await self.api.getESSList() + if not units: return self.data - for invertor in jsondata: - serial = invertor.get("sysSn") + # Fetch data per-inverter separately + any_success = False + for idx, unit in enumerate(units): + serial = unit.get("sysSn") if not serial: continue - # Parse all data sections - inverter_data = await self._parse_inverter_data(invertor) - self.data[serial] = inverter_data + # Per-inverter error backoff + err_count = self._inverter_error_count.get(serial, 0) + if err_count >= self._ERROR_BACKOFF_THRESHOLD: + if self._poll_tick_count % self._ERROR_BACKOFF_CYCLES != 0: + _LOGGER.debug( + "Skipping %s (backed off, %s consecutive errors)", + serial, err_count, + ) + continue + + try: + invertor = await self._fetch_inverter_data( + serial, unit, throttle_delay, get_power=True, get_ev=True, + include_local_ip=(idx == 0), + ) + inverter_data = self._parse_inverter_data(invertor) + self.data[serial] = inverter_data + self._inverter_error_count[serial] = 0 + any_success = True + except asyncio.CancelledError: + raise + except aiohttp.ClientResponseError as err: + if err.status == 401: + raise ConfigEntryAuthFailed("AlphaESS credentials rejected") from err + _LOGGER.warning("Error fetching data for %s: %s", serial, err) + self._inverter_error_count[serial] = self._inverter_error_count.get(serial, 0) + 1 + except Exception as err: + _LOGGER.warning("Error fetching data for %s: %s", serial, err) + self._inverter_error_count[serial] = self._inverter_error_count.get(serial, 0) + 1 # Fetch local IP data per-inverter for those with configured IPs await self._fetch_per_inverter_local_data() - self.cloud_available = True - return self.data + self.cloud_available = any_success + if any_success: + self._last_full_poll_utc = dt_util.utcnow().isoformat(timespec="seconds") + else: + _LOGGER.warning("All per-inverter fetches failed") + return self._finalize_data() + except ConfigEntryAuthFailed: + raise except (aiohttp.ClientConnectorError, aiohttp.ClientResponseError, TypeError) as error: - _LOGGER.warning(f"Cloud API error: {error}") + _LOGGER.warning("Cloud API error: %s", error) self.cloud_available = False - return await self._fallback_to_local_data() + self._update_diagnostics() + return await self._fallback_to_local_data(error) except Exception as error: - _LOGGER.error(f"Unexpected error fetching data: {error}") + _LOGGER.error("Unexpected error fetching data: %s", error) self.cloud_available = False - return await self._fallback_to_local_data() + self._update_diagnostics() + return await self._fallback_to_local_data(error) + + async def _async_update_data_alt(self) -> dict[str, dict[str, Any]] | None: + """Alt polling mode: fast poll for live power data, full poll at scan_interval cadence.""" + now = time_mod.monotonic() + self._poll_tick_count += 1 + need_full = ( + self._last_full_poll is None + or (now - self._last_full_poll) >= self._full_poll_interval.total_seconds() + ) + + try: + throttle_delay = self.throttle_multiplier + + if need_full: + # Full poll — per-inverter API calls + self._last_poll_type = "full" + _LOGGER.debug("Alt mode: performing full poll") + units = await self.api.getESSList() + if not units: + return self.data + + any_success = False + for idx, unit in enumerate(units): + serial = unit.get("sysSn") + if not serial: + continue + try: + invertor = await self._fetch_inverter_data( + serial, unit, throttle_delay, get_power=True, get_ev=True, + include_local_ip=(idx == 0), + ) + inverter_data = self._parse_inverter_data(invertor) + self.data[serial] = inverter_data + # Clear error count on success + self._inverter_error_count[serial] = 0 + any_success = True + except asyncio.CancelledError: + raise + except aiohttp.ClientResponseError as err: + if err.status == 401: + raise ConfigEntryAuthFailed("AlphaESS credentials rejected") from err + _LOGGER.debug("Alt mode full poll failed for %s: %s", serial, err) + self._inverter_error_count[serial] = self._inverter_error_count.get(serial, 0) + 1 + except Exception as err: + _LOGGER.debug("Alt mode full poll failed for %s: %s", serial, err) + self._inverter_error_count[serial] = self._inverter_error_count.get(serial, 0) + 1 + + self.cloud_available = any_success + if any_success: + self._last_full_poll = now + self._last_full_poll_utc = dt_util.utcnow().isoformat(timespec="seconds") + else: + _LOGGER.warning("Alt mode: all per-inverter fetches failed during full poll") + else: + # Fast poll — stagger: pick one inverter per tick (round-robin) + self._last_poll_type = "fast" + serials = list(self.data.keys()) + if serials: + serial = serials[self._fast_poll_index % len(serials)] + self._fast_poll_index += 1 + + # Per-inverter error backoff + err_count = self._inverter_error_count.get(serial, 0) + if err_count >= self._ERROR_BACKOFF_THRESHOLD: + # Retry every N cycles to see if it recovers + if self._poll_tick_count % self._ERROR_BACKOFF_CYCLES != 0: + _LOGGER.debug( + "Alt mode: skipping %s (backed off, %s consecutive errors)", + serial, err_count, + ) + return self._finalize_data() + + _LOGGER.debug("Alt mode: fast poll for %s", serial) + try: + # getLastPowerData — real-time watts/SOC (skip for unsupported models) + model = self.data[serial].get("Model") + if model not in LOWER_INVERTER_API_CALL_LIST: + power_data = await self.api.getLastPowerData(serial) + if power_data: + parsed = self.parser.parse_power_data(power_data, None) + self.data[serial].update(parsed) + await asyncio.sleep(throttle_delay) + + # getOneDateEnergyBySn — daily energy counters + energy_data = await self.api.getOneDateEnergyBySn( + serial, dt_util.now().strftime("%Y-%m-%d") + ) + if energy_data: + parsed = self.parser.parse_energy_data(energy_data) + self.data[serial].update(parsed) + await asyncio.sleep(throttle_delay) + + # EV charger status if one is known + ev_sn = self.data[serial].get(AlphaESSNames.evchargersn) + if ev_sn: + ev_status = await self.api.getEvChargerStatusBySn(serial, ev_sn) + if ev_status: + self.data[serial][AlphaESSNames.evchargerstatus] = ev_status.get("evchargerStatus") + self.data[serial][AlphaESSNames.evchargerstatusraw] = ev_status.get("evchargerStatus") + await asyncio.sleep(throttle_delay) + + # Clear error count on success + self._inverter_error_count[serial] = 0 + except asyncio.CancelledError: + raise + except aiohttp.ClientResponseError as err: + if err.status == 401: + raise ConfigEntryAuthFailed("AlphaESS credentials rejected") from err + _LOGGER.debug("Alt mode fast poll failed for %s: %s", serial, err) + self._inverter_error_count[serial] = self._inverter_error_count.get(serial, 0) + 1 + except Exception as err: + _LOGGER.debug("Alt mode fast poll failed for %s: %s", serial, err) + self._inverter_error_count[serial] = self._inverter_error_count.get(serial, 0) + 1 + + # Fetch local IP data per-inverter for those with configured IPs + await self._fetch_per_inverter_local_data() + + return self._finalize_data() + + except ConfigEntryAuthFailed: + raise + except (aiohttp.ClientConnectorError, aiohttp.ClientResponseError, TypeError) as error: + _LOGGER.warning("Cloud API error (alt mode): %s", error) + self.cloud_available = False + self._update_diagnostics() + return await self._fallback_to_local_data(error) + except Exception as error: + _LOGGER.error("Unexpected error fetching data (alt mode): %s", error) + self.cloud_available = False + self._update_diagnostics() + return await self._fallback_to_local_data(error) + + def _update_diagnostics(self) -> None: + """Write poll diagnostic data into each inverter's data dict.""" + for serial in self.data: + self.data[serial][AlphaESSNames.PollMode] = "alt" if self.alt_polling_mode else "normal" + self.data[serial][AlphaESSNames.LastPollType] = self._last_poll_type + self.data[serial][AlphaESSNames.LastFullPoll] = self._last_full_poll_utc or "never" + self.data[serial][AlphaESSNames.PollTickCount] = self._poll_tick_count + + def _finalize_data(self) -> dict[str, dict[str, Any]]: + """Write diagnostics and return a shallow per-serial copy of the data. + + Returning fresh dict objects each cycle ensures listeners comparing + old/new data never see the same mutated reference. + """ + self._update_diagnostics() + return {serial: dict(values) for serial, values in self.data.items()} + + async def _fetch_inverter_data( + self, + serial: str, + unit: dict, + throttle_delay: float, + get_power: bool = False, + get_ev: bool = False, + include_local_ip: bool = False, + ) -> dict[str, Any]: + """Fetch all API data for a single inverter by its serial number.""" + today = dt_util.now().strftime("%Y-%m-%d") + + unit["SumData"] = await self.api.getSumDataForCustomer(serial) + await asyncio.sleep(throttle_delay) + + unit["OneDateEnergy"] = await self.api.getOneDateEnergyBySn(serial, today) + await asyncio.sleep(throttle_delay) + + # Skip getLastPowerData for inverters that don't support it + if unit.get("minv") not in LOWER_INVERTER_API_CALL_LIST: + unit["LastPower"] = await self.api.getLastPowerData(serial) + await asyncio.sleep(throttle_delay) + + unit["ChargeConfig"] = await self.api.getChargeConfigInfo(serial) + await asyncio.sleep(throttle_delay) + + unit["DisChargeConfig"] = await self.api.getDisChargeConfigInfo(serial) + await asyncio.sleep(throttle_delay) + + if get_power: + unit["OneDayPower"] = await self.api.getOneDayPowerBySn(serial, today) + await asyncio.sleep(throttle_delay) + + if get_ev: + try: + unit["EVData"] = await self.api.getEvChargerConfigList(serial) + await asyncio.sleep(throttle_delay) + if unit["EVData"]: + ev_list = unit["EVData"] + ev_item = ev_list[0] if isinstance(ev_list, list) else ev_list + ev_serial = ev_item.get("evchargerSn") + if ev_serial: + unit["EVStatus"] = await self.api.getEvChargerStatusBySn(serial, ev_serial) + await asyncio.sleep(throttle_delay) + unit["EVCurrent"] = await self.api.getEvChargerCurrentsBySn(serial) + await asyncio.sleep(throttle_delay) + except asyncio.CancelledError: + raise + except Exception: + _LOGGER.debug("Failed to fetch EV data for %s", serial, exc_info=True) + + # Include local IP data if available and this is the first inverter + if include_local_ip and self.api.ipaddress: + try: + ip_data = await self.api.getIPData() + if ip_data: + unit["LocalIPData"] = { + "type": "local_ip_data", + "ip": self.api.ipaddress, + **ip_data, + } + except asyncio.CancelledError: + raise + except Exception: + _LOGGER.debug("Failed to fetch local IP data", exc_info=True) + + return unit async def _fetch_per_inverter_local_data(self) -> None: """Fetch local IP data for each inverter that has a configured IP. - Temporarily sets the API client's ipaddress for each call, - then resets it to None. + Temporarily sets the API client's ipaddress for each call under a lock + (the client instance is shared), then resets it to None. """ for serial, ip in self.ip_address_map.items(): if not ip or serial not in self.data: continue # Skip if cloud API already provided LocalIPData for this inverter - if self.data[serial].get("Local IP"): + if self.data[serial].get(AlphaESSNames.localIP): continue - try: - self.api.ipaddress = ip - local_ip_raw = await self.api.getIPData() - if local_ip_raw: - local_ip_data = {"ip": ip, **local_ip_raw} - parsed = await self.parser.parse_local_ip_data(local_ip_data) - self.data[serial].update(parsed) - _LOGGER.debug(f"Fetched local IP data for {serial} from {ip}") - except Exception as error: - _LOGGER.debug(f"Could not fetch local IP data for {serial} from {ip}: {error}") - finally: - self.api.ipaddress = None - - async def _fallback_to_local_data(self) -> Optional[Dict[str, Dict[str, Any]]]: + async with self._local_ip_lock: + try: + self.api.ipaddress = ip + local_ip_raw = await self.api.getIPData() + if local_ip_raw: + local_ip_data = {"ip": ip, **local_ip_raw} + parsed = self.parser.parse_local_ip_data(local_ip_data) + self.data[serial].update(parsed) + _LOGGER.debug("Fetched local IP data for %s from %s", serial, ip) + except Exception as error: + _LOGGER.debug("Could not fetch local IP data for %s from %s: %s", serial, ip, error) + finally: + self.api.ipaddress = None + + async def _fallback_to_local_data( + self, original_error: Exception | None = None + ) -> dict[str, dict[str, Any]] | None: """Attempt to fetch local IP data when cloud API is unavailable. Uses per-inverter IP addresses from subentry configuration. Cloud sensor keys are removed so those entities become unavailable. Local IP sensor keys are kept with fresh data. + + Raises UpdateFailed when no data source is available at all, so + HA marks the update as failed instead of silently keeping stale data. """ has_any_local_ip = any(ip for ip in self.ip_address_map.values() if ip) if not has_any_local_ip: _LOGGER.debug("No local IP configured for any inverter") - return None + raise UpdateFailed( + f"Cloud API unavailable and no local IP configured: {original_error}" + ) from original_error any_success = False @@ -591,71 +904,74 @@ async def _fallback_to_local_data(self) -> Optional[Dict[str, Dict[str, Any]]]: self.data[serial] = {"Model": model} continue - try: - self.api.ipaddress = ip - local_ip_raw = await self.api.getIPData() - if local_ip_raw: - local_ip_data = {"ip": ip, **local_ip_raw} - parsed = await self.parser.parse_local_ip_data(local_ip_data) - model = self.data.get(serial, {}).get("Model") - self.data[serial] = {"Model": model, **parsed} - any_success = True - _LOGGER.info(f"Cloud unavailable - using local data for {serial} from {ip}") - else: + async with self._local_ip_lock: + try: + self.api.ipaddress = ip + local_ip_raw = await self.api.getIPData() + if local_ip_raw: + local_ip_data = {"ip": ip, **local_ip_raw} + parsed = self.parser.parse_local_ip_data(local_ip_data) + model = self.data.get(serial, {}).get("Model") + self.data[serial] = {"Model": model, **parsed} + any_success = True + _LOGGER.info("Cloud unavailable - using local data for %s from %s", serial, ip) + else: + model = self.data.get(serial, {}).get("Model") + self.data[serial] = {"Model": model} + except Exception as error: + _LOGGER.warning("Local IP fetch failed for %s (%s): %s", serial, ip, error) model = self.data.get(serial, {}).get("Model") self.data[serial] = {"Model": model} - except Exception as error: - _LOGGER.warning(f"Local IP fetch failed for {serial} ({ip}): {error}") - model = self.data.get(serial, {}).get("Model") - self.data[serial] = {"Model": model} - finally: - self.api.ipaddress = None + finally: + self.api.ipaddress = None if not any_success: _LOGGER.warning("Cloud API unavailable and all local IP fetches failed") - return None + raise UpdateFailed( + f"Cloud API unavailable and all local IP fetches failed: {original_error}" + ) from original_error - return self.data + return self._finalize_data() - async def _parse_inverter_data(self, invertor: Dict) -> Dict[str, Any]: + def _parse_inverter_data(self, invertor: dict) -> dict[str, Any]: """Parse all data for a single inverter.""" # Start with basic info - data = await self.parser.parse_basic_info(invertor) + data = self.parser.parse_basic_info(invertor) # Add LocalIPData if available local_ip_data = invertor.get("LocalIPData", {}) if local_ip_data: - data.update(await self.parser.parse_local_ip_data(local_ip_data)) + data.update(self.parser.parse_local_ip_data(local_ip_data)) # Add EV data if available ev_data = invertor.get("EVData", {}) if ev_data: - data.update(await self.parser.parse_ev_data(ev_data, invertor)) + data.update(self.parser.parse_ev_data(ev_data, invertor)) # Add summary data sum_data = invertor.get("SumData", {}) if sum_data: - data.update(await self.parser.parse_summary_data(sum_data)) + data.update(self.parser.parse_summary_data(sum_data, fallback_currency=self.hass.config.currency)) # Add energy data energy_data = invertor.get("OneDateEnergy", {}) if energy_data: - data.update(await self.parser.parse_energy_data(energy_data)) + data.update(self.parser.parse_energy_data(energy_data)) # Add power data power_data = invertor.get("LastPower", {}) if power_data: one_day_power = invertor.get("OneDayPower", {}) - data.update(await self.parser.parse_power_data(power_data, one_day_power)) + data.update(self.parser.parse_power_data(power_data, one_day_power)) # Add configuration data charge_config = invertor.get("ChargeConfig", {}) if charge_config: - data.update(await self.parser.parse_charge_config(charge_config)) + data.update(self.parser.parse_charge_config(charge_config)) discharge_config = invertor.get("DisChargeConfig", {}) if discharge_config: - data.update(await self.parser.parse_discharge_config(discharge_config)) + data.update(self.parser.parse_discharge_config(discharge_config)) # Add Charging Range (combining charge and discharge data) if charge_config or discharge_config: @@ -663,4 +979,4 @@ async def _parse_inverter_data(self, invertor: Dict) -> Dict[str, Any]: bat_use_cap = discharge_config.get("batUseCap", 10) if discharge_config else 10 data[AlphaESSNames.ChargeRange] = f"{bat_use_cap}% - {bat_high_cap}%" - return data \ No newline at end of file + return data diff --git a/custom_components/alphaess/device.py b/custom_components/alphaess/device.py index 50151b9..3edcf5a 100644 --- a/custom_components/alphaess/device.py +++ b/custom_components/alphaess/device.py @@ -1,9 +1,9 @@ """Shared device info builders for AlphaESS integration.""" -from homeassistant.helpers.device_registry import DeviceEntryType -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from .const import DOMAIN +from .enums import AlphaESSNames def build_inverter_device_info( @@ -14,19 +14,18 @@ def build_inverter_device_info( serial_upper = serial.upper() kwargs = { - "entry_type": DeviceEntryType.SERVICE, "identifiers": {(DOMAIN, serial_upper)}, "manufacturer": "AlphaESS", "model": data.get("Model"), - "model_id": serial, + "serial_number": serial, "name": f"Alpha ESS Energy Statistics : {serial_upper}", } - if "Local IP" in data and data.get("Local IP") != "0" and data.get("Device Status") is not None: - kwargs["serial_number"] = data.get("Device Serial Number") - kwargs["sw_version"] = data.get("Software Version") - kwargs["hw_version"] = data.get("Hardware Version") - kwargs["configuration_url"] = f"http://{data['Local IP']}" + local_ip = data.get(AlphaESSNames.localIP) + if local_ip and local_ip != "0" and data.get(AlphaESSNames.deviceStatus) is not None: + kwargs["sw_version"] = data.get(AlphaESSNames.softwareVersion) + kwargs["hw_version"] = data.get(AlphaESSNames.hardwareVersion) + kwargs["configuration_url"] = f"http://{local_ip}" return DeviceInfo(**kwargs) @@ -35,16 +34,14 @@ def build_ev_charger_device_info( data: dict, ) -> DeviceInfo: """Build DeviceInfo for an EV charger.""" - ev_sn = data.get("EV Charger S/N") + ev_sn = data.get(AlphaESSNames.evchargersn) kwargs = { - "entry_type": DeviceEntryType.SERVICE, "identifiers": {(DOMAIN, ev_sn)}, "manufacturer": "AlphaESS", - "model": data.get("EV Charger Model"), - "model_id": ev_sn, + "model": data.get(AlphaESSNames.evchargermodel), + "serial_number": ev_sn, "name": f"Alpha ESS Charger : {ev_sn}", } return DeviceInfo(**kwargs) - diff --git a/custom_components/alphaess/diagnostics.py b/custom_components/alphaess/diagnostics.py new file mode 100644 index 0000000..55f317c --- /dev/null +++ b/custom_components/alphaess/diagnostics.py @@ -0,0 +1,60 @@ +"""Diagnostics support for AlphaESS.""" +from __future__ import annotations + +from typing import Any + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + +from .coordinator import AlphaESSDataUpdateCoordinator +from .enums import AlphaESSNames + +TO_REDACT_CONFIG = {"AppID", "AppSecret"} + +# AlphaESSNames is a str-mixin enum, so members hash/compare as their values +# and can be used directly alongside plain string keys. +TO_REDACT_DATA = { + AlphaESSNames.deviceSerialNumber, + AlphaESSNames.registerKey, + AlphaESSNames.username, + AlphaESSNames.password, + AlphaESSNames.connectedSSID, + AlphaESSNames.localIP, + AlphaESSNames.wifiIP, + AlphaESSNames.wifiGateway, + AlphaESSNames.evchargersn, +} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: ConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + coordinator: AlphaESSDataUpdateCoordinator = entry.runtime_data + + # Anonymise inverter serial numbers in the top-level keys + inverters = { + f"inverter_{idx + 1}": async_redact_data(values, TO_REDACT_DATA) + for idx, values in enumerate((coordinator.data or {}).values()) + } + + return { + "entry": { + "data": async_redact_data(dict(entry.data), TO_REDACT_CONFIG), + "options": dict(entry.options), + "subentry_types": [ + sub.subentry_type for sub in entry.subentries.values() + ], + }, + "coordinator": { + "alt_polling_mode": coordinator.alt_polling_mode, + "cloud_available": coordinator.cloud_available, + "last_update_success": coordinator.last_update_success, + "update_interval": str(coordinator.update_interval), + "inverter_count": coordinator.inverter_count, + "model_list": coordinator.model_list, + "has_throttle": coordinator.has_throttle, + }, + "data": inverters, + } diff --git a/custom_components/alphaess/entity.py b/custom_components/alphaess/entity.py index 9dd75fa..d56f4d6 100644 --- a/custom_components/alphaess/entity.py +++ b/custom_components/alphaess/entity.py @@ -4,8 +4,8 @@ from collections.abc import Callable from dataclasses import dataclass -from homeassistant.components.button import ButtonEntityDescription from homeassistant.components.binary_sensor import BinarySensorEntityDescription +from homeassistant.components.button import ButtonEntityDescription from homeassistant.components.number import NumberEntityDescription from homeassistant.components.sensor import SensorEntityDescription from homeassistant.components.switch import SwitchEntityDescription diff --git a/custom_components/alphaess/enums.py b/custom_components/alphaess/enums.py index 123cee8..9d7920b 100644 --- a/custom_components/alphaess/enums.py +++ b/custom_components/alphaess/enums.py @@ -116,3 +116,9 @@ class AlphaESSNames(str, Enum): password = "Password" ethernetModule = "Ethernet Module" fourGModule = "4G Module" + + # Poll diagnostics + PollMode = "Poll Mode" + LastPollType = "Last Poll Type" + LastFullPoll = "Last Full Poll" + PollTickCount = "Poll Tick Count" diff --git a/custom_components/alphaess/manifest.json b/custom_components/alphaess/manifest.json index 7508272..144c0ff 100644 --- a/custom_components/alphaess/manifest.json +++ b/custom_components/alphaess/manifest.json @@ -1,21 +1,18 @@ { "domain": "alphaess", "name": "Alpha ESS", - "after_dependencies": [ - "rest" - ], "codeowners": [ "@CharlesGillanders", "@Poshy163" ], "config_flow": true, "documentation": "https://github.com/CharlesGillanders/homeassistant-alphaESS", + "integration_type": "hub", "iot_class": "cloud_polling", "issue_tracker": "https://github.com/CharlesGillanders/homeassistant-alphaESS/issues", + "loggers": ["alphaess"], "requirements": [ - "alphaessopenapi==0.0.17" + "alphaessopenapi==0.0.19" ], - "version": "0.8.4" + "version": "0.8.5" } - - diff --git a/custom_components/alphaess/number.py b/custom_components/alphaess/number.py index 410b1e7..14a1cc9 100644 --- a/custom_components/alphaess/number.py +++ b/custom_components/alphaess/number.py @@ -1,23 +1,28 @@ -from typing import List -from homeassistant.components.number import NumberEntity -from homeassistant.helpers.update_coordinator import CoordinatorEntity -from homeassistant.components.number import RestoreNumber import logging +from homeassistant.components.number import NumberEntity, NumberMode, RestoreNumber +from homeassistant.helpers.update_coordinator import CoordinatorEntity + from .const import ( - DOMAIN, INVERTER_SETTING_BLACKLIST, CONF_SERIAL_NUMBER, SUBENTRY_TYPE_INVERTER, - SUBENTRY_TYPE_EV_CHARGER, CONF_PARENT_INVERTER, + CONF_PARENT_INVERTER, + CONF_SERIAL_NUMBER, + INVERTER_SETTING_BLACKLIST, + SUBENTRY_TYPE_EV_CHARGER, + SUBENTRY_TYPE_INVERTER, ) from .coordinator import AlphaESSDataUpdateCoordinator +from .device import build_ev_charger_device_info, build_inverter_device_info from .enums import AlphaESSNames from .sensorlist import DISCHARGE_AND_CHARGE_NUMBERS, EV_CHARGER_NUMBERS -from .device import build_inverter_device_info, build_ev_charger_device_info -_LOGGER: logging.Logger = logging.getLogger(__package__) +_LOGGER = logging.getLogger(__name__) + +# Serialize value writes; the AlphaESS API rate-limits config writes. +PARALLEL_UPDATES = 1 async def async_setup_entry(hass, entry, async_add_entities) -> None: - coordinator: AlphaESSDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator: AlphaESSDataUpdateCoordinator = entry.runtime_data full_number_supported_states = { description.key: description for description in DISCHARGE_AND_CHARGE_NUMBERS @@ -37,7 +42,7 @@ async def async_setup_entry(hass, entry, async_add_entities) -> None: model = data.get("Model") inverter_device_info = build_inverter_device_info(serial, data) - number_entities: List[NumberEntity] = [] + number_entities: list[NumberEntity] = [] if model not in INVERTER_SETTING_BLACKLIST: for description in full_number_supported_states: @@ -50,7 +55,7 @@ async def async_setup_entry(hass, entry, async_add_entities) -> None: ) # Auto-discovered EV charger numbers (no dedicated EV subentry) - ev_charger = data.get("EV Charger S/N") + ev_charger = data.get(AlphaESSNames.evchargersn) ev_subentry_serials = { sub.data.get(CONF_SERIAL_NUMBER) for sub in entry.subentries.values() @@ -80,12 +85,12 @@ async def async_setup_entry(hass, entry, async_add_entities) -> None: continue data = coordinator.data[parent_serial] - ev_charger = data.get("EV Charger S/N") + ev_charger = data.get(AlphaESSNames.evchargersn) if not ev_charger: continue ev_device_info = build_ev_charger_device_info(data) - ev_entities: List[NumberEntity] = [] + ev_entities: list[NumberEntity] = [] for description in ev_number_supported_states: ev_entities.append( AlphaEVNumber( @@ -133,26 +138,26 @@ async def async_added_to_hass(self): last_value = last_state.native_value if last_value is not None: self._attr_native_value = last_value - await self.save_value(self._attr_native_value) + self.save_value(self._attr_native_value) except Exception: self._attr_native_value = self._def_initial_value _LOGGER.info( - f"No saved state found for {self._name}. Using initial value: {self._def_initial_value}") - await self.save_value(self._attr_native_value) + "No saved state found for %s. Using initial value: %s", + self._name, self._def_initial_value, + ) + self.save_value(self._attr_native_value) self.async_write_ha_state() - async def save_value(self, value): - if DOMAIN not in self.hass.data: - self.hass.data[DOMAIN] = {} - if self._serial not in self.hass.data[DOMAIN]: - self.hass.data[DOMAIN][self._serial] = {} - - self.hass.data[DOMAIN][self._serial][self._name] = value - _LOGGER.info(f"SAVED DATA TO HASS, VALUE {self.hass.data[DOMAIN][self._serial].get(self._name, None)}") + def save_value(self, value): + """Persist the value on the coordinator for charge/discharge commands.""" + self._coordinator.set_number_setting(self._serial, self._name, value) + _LOGGER.debug( + "Saved %s=%s for %s on coordinator", self._name, value, self._serial, + ) async def async_set_native_value(self, value: float) -> None: self._attr_native_value = value - await self.save_value(value) + self.save_value(value) self.async_write_ha_state() # Push to API @@ -206,8 +211,8 @@ def suggested_object_id(self): return f"{self._serial} {self._name}" @property - def mode(self): - return "box" + def mode(self) -> NumberMode: + return NumberMode.BOX @property def native_unit_of_measurement(self): diff --git a/custom_components/alphaess/sensor.py b/custom_components/alphaess/sensor.py index dc159f6..348a8e8 100644 --- a/custom_components/alphaess/sensor.py +++ b/custom_components/alphaess/sensor.py @@ -1,24 +1,37 @@ """Alpha ESS Sensor definitions.""" import logging -from typing import List -from homeassistant.components.sensor import ( - SensorEntity, SensorDeviceClass -) +from homeassistant.components.sensor import SensorDeviceClass, SensorEntity from homeassistant.const import CURRENCY_DOLLAR from homeassistant.helpers.typing import StateType +from homeassistant.helpers.update_coordinator import CoordinatorEntity +from .const import ( + CONF_PARENT_INVERTER, + CONF_SERIAL_NUMBER, + ETHERNET_STATUS_KEYS, + EV_CHARGER_STATE_KEYS, + FOUR_G_STATUS_KEYS, + LIMITED_INVERTER_SENSOR_LIST, + SUBENTRY_TYPE_EV_CHARGER, + SUBENTRY_TYPE_INVERTER, + TCP_STATUS_KEYS, + WIFI_STATUS_KEYS, +) +from .coordinator import AlphaESSDataUpdateCoordinator +from .device import build_ev_charger_device_info, build_inverter_device_info from .enums import AlphaESSNames -from .sensorlist import FULL_SENSOR_DESCRIPTIONS, LIMITED_SENSOR_DESCRIPTIONS, EV_CHARGING_DETAILS, LOCAL_IP_SYSTEM_SENSORS +from .sensorlist import ( + EV_CHARGING_DETAILS, + FULL_SENSOR_DESCRIPTIONS, + LIMITED_SENSOR_DESCRIPTIONS, + LOCAL_IP_SYSTEM_SENSORS, +) -from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DOMAIN, LIMITED_INVERTER_SENSOR_LIST, EV_CHARGER_STATE_KEYS, TCP_STATUS_KEYS, ETHERNET_STATUS_KEYS, \ - FOUR_G_STATUS_KEYS, WIFI_STATUS_KEYS, CONF_SERIAL_NUMBER, SUBENTRY_TYPE_INVERTER, SUBENTRY_TYPE_EV_CHARGER, \ - CONF_PARENT_INVERTER -from .coordinator import AlphaESSDataUpdateCoordinator -from .device import build_inverter_device_info, build_ev_charger_device_info +_LOGGER = logging.getLogger(__name__) -_LOGGER: logging.Logger = logging.getLogger(__package__) +# Read-only platform; the coordinator centralizes polling. +PARALLEL_UPDATES = 0 # Map common currency symbols to ISO 4217 codes. @@ -96,15 +109,46 @@ def _normalize_currency_unit(value: str | None, fallback: str | None) -> str | N } +# Sensors whose values are sourced from the cloud summary endpoint +# (getSumDataForCustomer -> parse_summary_data). +# +# Following the Australian server migration this endpoint intermittently returns +# ``null`` for individual fields (e.g. epvtotal, todayIncome, eselfConsumption, +# treeNum...). When that happens the coordinator stores the key with a ``None`` +# value, so the entity would otherwise stay "available" while publishing a +# misleading Unknown/0 reading. +# +# For these keys we instead treat a ``None`` value as "data temporarily +# missing": the entity is reported unavailable, so it can be removed from HA and +# re-appears automatically once the data flows again. Regions where the data +# still flows are unaffected - a present value keeps the entity available +# exactly as before. +# +# Note: the daily energy endpoint (getOneDateEnergyBySn -> parse_energy_data) +# is NOT affected - it always returns populated values - so Charge, Discharge, +# Solar Production, Solar to Battery, etc. are intentionally left out here. +AU_MIGRATION_NULLABLE_KEYS = { + AlphaESSNames.TotalLoad, + AlphaESSNames.Income, + AlphaESSNames.Total_Generation, + AlphaESSNames.treePlanted, + AlphaESSNames.carbonReduction, + AlphaESSNames.TodayGeneration, + AlphaESSNames.TodayIncome, + AlphaESSNames.SelfConsumption, + AlphaESSNames.SelfSufficiency, +} + + def _add_ev_entities(coordinator, entry, serial, data, currency, ev_charging_supported_states, subentry_id, async_add_entities): """Create and register EV charger sensor entities.""" - ev_charger = data.get("EV Charger S/N") - ev_model = data.get("EV Charger Model") + ev_charger = data.get(AlphaESSNames.evchargersn) + ev_model = data.get(AlphaESSNames.evchargermodel) ev_device_info = build_ev_charger_device_info(data) - _LOGGER.info(f"New EV Charger: Serial: {ev_charger}, Model: {ev_model}") + _LOGGER.info("New EV Charger: Serial: %s, Model: %s", ev_charger, ev_model) - ev_entities: List[AlphaESSSensor] = [] + ev_entities: list[AlphaESSSensor] = [] for description in EV_CHARGING_DETAILS: ev_entities.append( AlphaESSSensor( @@ -120,7 +164,7 @@ def _add_ev_entities(coordinator, entry, serial, data, currency, ev_charging_sup async def async_setup_entry(hass, entry, async_add_entities) -> None: """Set up sensor entities for each subentry.""" - coordinator: AlphaESSDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator: AlphaESSDataUpdateCoordinator = entry.runtime_data full_key_supported_states = { description.key: description for description in FULL_SENSOR_DESCRIPTIONS @@ -137,7 +181,7 @@ async def async_setup_entry(hass, entry, async_add_entities) -> None: description.key: description for description in LOCAL_IP_SYSTEM_SENSORS } - _LOGGER.info(f"Initializing Inverters") + _LOGGER.info("Initializing Inverters") # Create entities per inverter subentry for subentry in entry.subentries.values(): @@ -153,12 +197,12 @@ async def async_setup_entry(hass, entry, async_add_entities) -> None: hass.config.currency, ) - _LOGGER.info(f"New Inverter: Serial: {serial}, Model: {model}") + _LOGGER.info("New Inverter: Serial: %s, Model: %s", serial, model) - has_local_ip_data = 'Local IP' in data + has_local_ip_data = AlphaESSNames.localIP in data inverter_device_info = build_inverter_device_info(serial, data) - inverter_entities: List[AlphaESSSensor] = [] + inverter_entities: list[AlphaESSSensor] = [] if model in LIMITED_INVERTER_SENSOR_LIST: for description in limited_key_supported_states: @@ -199,8 +243,12 @@ async def async_setup_entry(hass, entry, async_add_entities) -> None: ) ) - if has_local_ip_data and data.get('Local IP') != '0' and data.get('Device Status') is not None: - _LOGGER.info(f"New local IP system sensor for {serial}") + if ( + has_local_ip_data + and data.get(AlphaESSNames.localIP) != '0' + and data.get(AlphaESSNames.deviceStatus) is not None + ): + _LOGGER.info("New local IP system sensor for %s", serial) for description in LOCAL_IP_SYSTEM_SENSORS: inverter_entities.append( AlphaESSSensor( @@ -221,7 +269,7 @@ async def async_setup_entry(hass, entry, async_add_entities) -> None: continue data = coordinator.data[parent_serial] - ev_charger = data.get("EV Charger S/N") + ev_charger = data.get(AlphaESSNames.evchargersn) if not ev_charger: continue @@ -230,8 +278,8 @@ async def async_setup_entry(hass, entry, async_add_entities) -> None: hass.config.currency, ) - ev_model = data.get("EV Charger Model") - _LOGGER.info(f"New EV Charger: Serial: {ev_charger}, Model: {ev_model}") + ev_model = data.get(AlphaESSNames.evchargermodel) + _LOGGER.info("New EV Charger: Serial: %s, Model: %s", ev_charger, ev_model) _add_ev_entities( coordinator, entry, parent_serial, data, currency, @@ -254,7 +302,7 @@ async def async_setup_entry(hass, entry, async_add_entities) -> None: continue data = coordinator.data[serial] - ev_charger = data.get("EV Charger S/N") + ev_charger = data.get(AlphaESSNames.evchargersn) if not ev_charger or ev_charger in ev_subentry_serials: continue @@ -328,6 +376,14 @@ def available(self) -> bool: if self._key in EV_CONNECTOR_POWER_KEYS: return serial_data.get(self._key) is not None + # Summary sensors from the getSumDataForCustomer endpoint affected by + # the AU server migration: a null value means the data is temporarily + # missing, so report unavailable rather than a misleading Unknown/0. + # Where the data still flows (other regions) a present value keeps it + # available. + if self._key in AU_MIGRATION_NULLABLE_KEYS: + return serial_data.get(self._key) is not None + return self._key in serial_data @property @@ -435,31 +491,3 @@ def entity_category(self): def icon(self): """Return the entity_category of the sensor.""" return self._icon - - def get_charge(self): - """Get battery charge range.""" - bat_high_cap = self._coordinator.data[self._serial].get("batHighCap") - bat_use_cap = self._coordinator.data[self._serial].get("batUseCap") - - if bat_high_cap is not None and bat_use_cap is not None: - return f"{bat_use_cap}% - {bat_high_cap}%" - return None - - def get_time(self, name, value): - """Get formatted time range for Discharge or Charge.""" - direction = name.split()[0] - - def get_time_range(prefix): - """Helper to retrieve and format time ranges.""" - start_time = self._coordinator.data[self._serial].get(f"{prefix}_time{prefix[:3].capitalize()}f{value}") - end_time = self._coordinator.data[self._serial].get(f"{prefix}_time{prefix[:3].capitalize()}e{value}") - if start_time and end_time: - return f"{start_time} - {end_time}" - return None - - if direction == "Discharge": - return get_time_range("discharge") - elif direction == "Charge": - return get_time_range("charge") - - return None diff --git a/custom_components/alphaess/sensorlist.py b/custom_components/alphaess/sensorlist.py index 5366d2c..970fa25 100644 --- a/custom_components/alphaess/sensorlist.py +++ b/custom_components/alphaess/sensorlist.py @@ -1,25 +1,23 @@ -from typing import List +from homeassistant.components.number import NumberMode from homeassistant.components.sensor import ( SensorDeviceClass, SensorStateClass, ) -from homeassistant.const import UnitOfEnergy, PERCENTAGE, UnitOfPower, CURRENCY_DOLLAR, EntityCategory, UnitOfMass - -from homeassistant.components.number import NumberMode +from homeassistant.const import CURRENCY_DOLLAR, PERCENTAGE, EntityCategory, UnitOfEnergy, UnitOfMass, UnitOfPower from .entity import ( - AlphaESSSensorDescription, - AlphaESSButtonDescription, AlphaESSBinarySensorDescription, + AlphaESSButtonDescription, AlphaESSNumberDescription, + AlphaESSSensorDescription, AlphaESSSwitchDescription, AlphaESSTimeDescription, ) from .enums import AlphaESSNames # Shared daily-energy and diagnostic sensors used in both FULL and LIMITED lists. -_COMMON_DAILY_SENSORS: List[AlphaESSSensorDescription] = [ +_COMMON_DAILY_SENSORS: list[AlphaESSSensorDescription] = [ AlphaESSSensorDescription( key=AlphaESSNames.DailyPvGeneration, name="Daily PV Generation", @@ -92,9 +90,42 @@ state_class=None, entity_category=EntityCategory.DIAGNOSTIC, ), + # Poll diagnostics + AlphaESSSensorDescription( + key=AlphaESSNames.PollMode, + name="Poll Mode", + icon="mdi:swap-vertical", + native_unit_of_measurement=None, + state_class=None, + entity_category=EntityCategory.DIAGNOSTIC, + ), + AlphaESSSensorDescription( + key=AlphaESSNames.LastPollType, + name="Last Poll Type", + icon="mdi:clock-fast", + native_unit_of_measurement=None, + state_class=None, + entity_category=EntityCategory.DIAGNOSTIC, + ), + AlphaESSSensorDescription( + key=AlphaESSNames.LastFullPoll, + name="Last Full Poll", + icon="mdi:clock-check-outline", + native_unit_of_measurement=None, + state_class=None, + entity_category=EntityCategory.DIAGNOSTIC, + ), + AlphaESSSensorDescription( + key=AlphaESSNames.PollTickCount, + name="Poll Tick Count", + icon="mdi:counter", + native_unit_of_measurement=None, + state_class=None, + entity_category=EntityCategory.DIAGNOSTIC, + ), ] -FULL_SENSOR_DESCRIPTIONS: List[AlphaESSSensorDescription] = [ +FULL_SENSOR_DESCRIPTIONS: list[AlphaESSSensorDescription] = [ AlphaESSSensorDescription( key=AlphaESSNames.SolarProduction, name="Solar Production", @@ -280,7 +311,6 @@ name="Self Consumption", icon="mdi:home-percent", native_unit_of_measurement=PERCENTAGE, - device_class=SensorDeviceClass.POWER_FACTOR, state_class=SensorStateClass.MEASUREMENT, ), AlphaESSSensorDescription( @@ -288,14 +318,12 @@ name="Self Sufficiency", icon="mdi:home-percent", native_unit_of_measurement=PERCENTAGE, - device_class=SensorDeviceClass.POWER_FACTOR, state_class=SensorStateClass.MEASUREMENT, ), AlphaESSSensorDescription( key=AlphaESSNames.EmsStatus, name="EMS Status", icon="mdi:home-battery", - device_class=SensorDeviceClass.ENUM, state_class=None, entity_category=EntityCategory.DIAGNOSTIC ), @@ -304,23 +332,23 @@ name="Maximum Battery Capacity", icon="mdi:home-percent", native_unit_of_measurement=PERCENTAGE, - state_class=SensorStateClass.TOTAL, + state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC ), AlphaESSSensorDescription( key=AlphaESSNames.cobat, name="Installed Capacity", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, - state_class=SensorStateClass.TOTAL, - device_class=SensorDeviceClass.ENERGY, + state_class=None, + device_class=SensorDeviceClass.ENERGY_STORAGE, entity_category=EntityCategory.DIAGNOSTIC ), AlphaESSSensorDescription( key=AlphaESSNames.surplusCobat, name="Current Capacity", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, - state_class=SensorStateClass.TOTAL, - device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.ENERGY_STORAGE, entity_category=EntityCategory.DIAGNOSTIC ), AlphaESSSensorDescription( @@ -374,18 +402,18 @@ AlphaESSSensorDescription( key=AlphaESSNames.poinv, name="Inverter nominal Power", - native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, - state_class=SensorStateClass.TOTAL, - device_class=SensorDeviceClass.ENERGY, + native_unit_of_measurement=UnitOfPower.KILO_WATT, + state_class=None, + device_class=SensorDeviceClass.POWER, entity_category=EntityCategory.DIAGNOSTIC, icon="mdi:lightning-bolt", ), AlphaESSSensorDescription( key=AlphaESSNames.popv, name="Pv nominal Power", - native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, - state_class=SensorStateClass.TOTAL, - device_class=SensorDeviceClass.ENERGY, + native_unit_of_measurement=UnitOfPower.KILO_WATT, + state_class=None, + device_class=SensorDeviceClass.POWER, entity_category=EntityCategory.DIAGNOSTIC, icon="mdi:lightning-bolt", ), @@ -494,7 +522,7 @@ ), ] + _COMMON_DAILY_SENSORS -LIMITED_SENSOR_DESCRIPTIONS: List[AlphaESSSensorDescription] = [ +LIMITED_SENSOR_DESCRIPTIONS: list[AlphaESSSensorDescription] = [ AlphaESSSensorDescription( key=AlphaESSNames.StateOfCharge, name="State of Charge", @@ -599,7 +627,6 @@ name="Self Consumption", icon="mdi:home-percent", native_unit_of_measurement=PERCENTAGE, - device_class=SensorDeviceClass.POWER_FACTOR, state_class=SensorStateClass.MEASUREMENT, ), AlphaESSSensorDescription( @@ -607,14 +634,12 @@ name="Self Sufficiency", icon="mdi:home-percent", native_unit_of_measurement=PERCENTAGE, - device_class=SensorDeviceClass.POWER_FACTOR, state_class=SensorStateClass.MEASUREMENT, ), AlphaESSSensorDescription( key=AlphaESSNames.EmsStatus, name="EMS Status", icon="mdi:home-battery", - device_class=SensorDeviceClass.ENUM, state_class=None, entity_category=EntityCategory.DIAGNOSTIC ), @@ -623,23 +648,23 @@ name="Maximum Battery Capacity", icon="mdi:battery-high", native_unit_of_measurement=PERCENTAGE, - state_class=SensorStateClass.TOTAL, + state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC ), AlphaESSSensorDescription( key=AlphaESSNames.cobat, name="Installed Capacity", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, - state_class=SensorStateClass.TOTAL, - device_class=SensorDeviceClass.ENERGY, + state_class=None, + device_class=SensorDeviceClass.ENERGY_STORAGE, entity_category=EntityCategory.DIAGNOSTIC ), AlphaESSSensorDescription( key=AlphaESSNames.surplusCobat, name="Current Capacity", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, - state_class=SensorStateClass.TOTAL, - device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.ENERGY_STORAGE, entity_category=EntityCategory.DIAGNOSTIC ), AlphaESSSensorDescription( @@ -693,18 +718,18 @@ AlphaESSSensorDescription( key=AlphaESSNames.poinv, name="Inverter nominal Power", - native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + native_unit_of_measurement=UnitOfPower.KILO_WATT, state_class=None, - device_class=SensorDeviceClass.ENERGY, + device_class=SensorDeviceClass.POWER, entity_category=EntityCategory.DIAGNOSTIC, icon="mdi:lightning-bolt", ), AlphaESSSensorDescription( key=AlphaESSNames.popv, name="Pv nominal Power", - native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + native_unit_of_measurement=UnitOfPower.KILO_WATT, state_class=None, - device_class=SensorDeviceClass.ENERGY, + device_class=SensorDeviceClass.POWER, entity_category=EntityCategory.DIAGNOSTIC, icon="mdi:lightning-bolt", ), @@ -813,7 +838,7 @@ ), ] + _COMMON_DAILY_SENSORS -SUPPORT_DISCHARGE_AND_CHARGE_BUTTON_DESCRIPTIONS: List[AlphaESSButtonDescription] = [ +SUPPORT_DISCHARGE_AND_CHARGE_BUTTON_DESCRIPTIONS: list[AlphaESSButtonDescription] = [ AlphaESSButtonDescription( key=AlphaESSNames.ButtonDischargeFifteen, name="15 Minute Discharge", @@ -858,7 +883,7 @@ ) ] -DISCHARGE_AND_CHARGE_NUMBERS: List[AlphaESSNumberDescription] = [ +DISCHARGE_AND_CHARGE_NUMBERS: list[AlphaESSNumberDescription] = [ AlphaESSNumberDescription( key=AlphaESSNames.batHighCap, name="batHighCap", @@ -876,7 +901,7 @@ ) ] -CHARGE_DISCHARGE_TIMES: List[AlphaESSTimeDescription] = [ +CHARGE_DISCHARGE_TIMES: list[AlphaESSTimeDescription] = [ AlphaESSTimeDescription( key=AlphaESSNames.ChargeStartTime1, name="Charge Start Time 1", @@ -935,7 +960,7 @@ ), ] -EV_DISCHARGE_AND_CHARGE_BUTTONS: List[AlphaESSButtonDescription] = [ +EV_DISCHARGE_AND_CHARGE_BUTTONS: list[AlphaESSButtonDescription] = [ AlphaESSButtonDescription( key=AlphaESSNames.stopcharging, @@ -952,7 +977,7 @@ ] -EV_CHARGER_BINARY_SENSORS: List[AlphaESSBinarySensorDescription] = [ +EV_CHARGER_BINARY_SENSORS: list[AlphaESSBinarySensorDescription] = [ AlphaESSBinarySensorDescription( key=AlphaESSNames.canstartcharging, name="Can Start Charging", @@ -969,7 +994,7 @@ ), ] -LOCAL_IP_SYSTEM_SENSORS: List[AlphaESSSensorDescription] = [ +LOCAL_IP_SYSTEM_SENSORS: list[AlphaESSSensorDescription] = [ AlphaESSSensorDescription( key=AlphaESSNames.localIP, name="Local IP", @@ -1056,7 +1081,7 @@ ), ] -EV_CHARGING_DETAILS: List[AlphaESSSensorDescription] = [ +EV_CHARGING_DETAILS: list[AlphaESSSensorDescription] = [ AlphaESSSensorDescription( key=AlphaESSNames.evchargersn, name="EV Charger S/N", @@ -1088,7 +1113,7 @@ ), ] -EV_CHARGER_NUMBERS: List[AlphaESSNumberDescription] = [ +EV_CHARGER_NUMBERS: list[AlphaESSNumberDescription] = [ AlphaESSNumberDescription( key=AlphaESSNames.EVChargerCurrentSetting, name="EV Charger Current Setting", @@ -1102,7 +1127,7 @@ ), ] -CHARGE_DISCHARGE_SWITCHES: List[AlphaESSSwitchDescription] = [ +CHARGE_DISCHARGE_SWITCHES: list[AlphaESSSwitchDescription] = [ AlphaESSSwitchDescription( key=AlphaESSNames.GridChargeEnabled, name="Grid Charge Enabled", diff --git a/custom_components/alphaess/strings.json b/custom_components/alphaess/strings.json index c49b9e8..8ad8080 100644 --- a/custom_components/alphaess/strings.json +++ b/custom_components/alphaess/strings.json @@ -1,6 +1,9 @@ { "config": { "flow_title": "Alpha ESS: {AppID}", + "abort": { + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + }, "error": { "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", @@ -15,6 +18,13 @@ "AppSecret": "[%key:common::config_flow::data::appsecret%]", "Verify SSL Certificate": "Verify SSL Certificate" } + }, + "reauth_confirm": { + "title": "Reauthenticate Alpha ESS", + "description": "The AlphaESS Open API rejected the credentials for {app_id}. Enter a new AppSecret to continue.", + "data": { + "AppSecret": "[%key:common::config_flow::data::appsecret%]" + } } } }, @@ -22,7 +32,8 @@ "inverter": { "entry_type": "Inverter", "abort": { - "reconfigure_successful": "Inverter settings saved successfully." + "reconfigure_successful": "Inverter settings saved successfully.", + "unbind_successful": "Inverter was unbound from your account and removed." }, "initiate_flow": { "user": "Bind Inverter" @@ -78,7 +89,14 @@ "init": { "data": { "Verify SSL Certificate": "Verify SSL Certificate", - "scan_interval_seconds": "Scan interval (seconds)" + "scan_interval_seconds": "Full poll interval (seconds)", + "alt_polling_mode": "Alt polling mode (split fast/slow)", + "fast_scan_interval_seconds": "Fast poll interval (seconds)" + }, + "data_description": { + "scan_interval_seconds": "Interval for all data: summary, energy, config, and power. When alt polling mode is enabled this becomes the slow/full poll interval.", + "alt_polling_mode": "When enabled, live power data (SOC, PV, grid, battery, load) polls at the fast interval, while summary, energy, and config data polls at the full poll interval.", + "fast_scan_interval_seconds": "Interval for live power data when alt polling mode is enabled. Default 15 seconds." } } } diff --git a/custom_components/alphaess/switch.py b/custom_components/alphaess/switch.py index eddbb2f..62be0f6 100644 --- a/custom_components/alphaess/switch.py +++ b/custom_components/alphaess/switch.py @@ -1,24 +1,29 @@ """Switch platform for AlphaESS integration.""" -from typing import Any, List import logging +from typing import Any from homeassistant.components.switch import SwitchEntity from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ( - DOMAIN, INVERTER_SETTING_BLACKLIST, CONF_SERIAL_NUMBER, SUBENTRY_TYPE_INVERTER, + CONF_SERIAL_NUMBER, + INVERTER_SETTING_BLACKLIST, + SUBENTRY_TYPE_INVERTER, ) from .coordinator import AlphaESSDataUpdateCoordinator +from .device import build_inverter_device_info from .enums import AlphaESSNames from .sensorlist import CHARGE_DISCHARGE_SWITCHES -from .device import build_inverter_device_info -_LOGGER: logging.Logger = logging.getLogger(__package__) +_LOGGER = logging.getLogger(__name__) + +# Serialize switch writes; the AlphaESS API rate-limits config writes. +PARALLEL_UPDATES = 1 async def async_setup_entry(hass, entry, async_add_entities) -> None: """Set up AlphaESS switch entities.""" - coordinator: AlphaESSDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator: AlphaESSDataUpdateCoordinator = entry.runtime_data switch_descriptions = { description.key: description for description in CHARGE_DISCHARGE_SWITCHES @@ -36,7 +41,7 @@ async def async_setup_entry(hass, entry, async_add_entities) -> None: model = data.get("Model") inverter_device_info = build_inverter_device_info(serial, data) - switch_entities: List[SwitchEntity] = [] + switch_entities: list[SwitchEntity] = [] if model not in INVERTER_SETTING_BLACKLIST: for description in switch_descriptions: diff --git a/custom_components/alphaess/time.py b/custom_components/alphaess/time.py index d8ecfa9..1ed467e 100644 --- a/custom_components/alphaess/time.py +++ b/custom_components/alphaess/time.py @@ -1,18 +1,20 @@ """Time platform for AlphaESS integration.""" -from datetime import time -from typing import List import logging +from datetime import time from homeassistant.components.time import TimeEntity from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DOMAIN, INVERTER_SETTING_BLACKLIST, CONF_SERIAL_NUMBER, SUBENTRY_TYPE_INVERTER +from .const import CONF_SERIAL_NUMBER, INVERTER_SETTING_BLACKLIST, SUBENTRY_TYPE_INVERTER from .coordinator import AlphaESSDataUpdateCoordinator +from .device import build_inverter_device_info from .enums import AlphaESSNames from .sensorlist import CHARGE_DISCHARGE_TIMES -from .device import build_inverter_device_info -_LOGGER: logging.Logger = logging.getLogger(__package__) +_LOGGER = logging.getLogger(__name__) + +# Serialize time writes; the AlphaESS API rate-limits config writes. +PARALLEL_UPDATES = 1 # Mapping from coordinator key to the API parameter position # Charge API: updateChargeConfigInfo(serial, batHighCap, gridCharge, timeChae1, timeChae2, timeChaf1, timeChaf2) @@ -34,7 +36,7 @@ async def async_setup_entry(hass, entry, async_add_entities) -> None: """Set up AlphaESS time entities.""" - coordinator: AlphaESSDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator: AlphaESSDataUpdateCoordinator = entry.runtime_data for subentry in entry.subentries.values(): if subentry.subentry_type != SUBENTRY_TYPE_INVERTER: @@ -48,7 +50,7 @@ async def async_setup_entry(hass, entry, async_add_entities) -> None: model = data.get("Model") inverter_device_info = build_inverter_device_info(serial, data) - time_entities: List[TimeEntity] = [] + time_entities: list[TimeEntity] = [] if model not in INVERTER_SETTING_BLACKLIST: for description in CHARGE_DISCHARGE_TIMES: diff --git a/custom_components/alphaess/translations/en.json b/custom_components/alphaess/translations/en.json index 393a1f3..6ca72d9 100644 --- a/custom_components/alphaess/translations/en.json +++ b/custom_components/alphaess/translations/en.json @@ -3,16 +3,24 @@ "config": { "step": { "user": { - "description": "Enter your AppID and AppSecret from the AlphaESS OpenAPI developer portal.\n\nAfter setup, you can configure a local IP address for each inverter via its sub-entry options to enable local access when the cloud API is unavailable.\n\nIf you have any issues with the OpenAPI, read a list of potential fixes [here]({issues_url}).", + "description": "Enter your AppID and AppSecret from the AlphaESS OpenAPI developer portal.\n\nAfter setup, you can configure a local IP address for each inverter via its sub-entry options to enable access to local entities.\n\nIf you have any issues with the OpenAPI, read a list of potential fixes [here]({issues_url}).", "data": { "AppID": "AppID", "AppSecret": "AppSecret", "Verify SSL Certificate": "Verify SSL Certificate" } + }, + "reauth_confirm": { + "title": "Reauthenticate Alpha ESS", + "description": "The AlphaESS Open API rejected the credentials for {app_id}. Enter a new AppSecret to continue.", + "data": { + "AppSecret": "AppSecret" + } } }, "abort": { - "already_configured": "Account is already configured" + "already_configured": "Account is already configured", + "reauth_successful": "Re-authentication was successful" }, "error": { "cannot_connect": "Failed to connect", @@ -24,7 +32,8 @@ "inverter": { "entry_type": "Inverter", "abort": { - "reconfigure_successful": "Inverter settings saved successfully." + "reconfigure_successful": "Inverter settings saved successfully.", + "unbind_successful": "Inverter was unbound from your account and removed." }, "initiate_flow": { "user": "Bind Inverter" @@ -80,7 +89,14 @@ "init": { "data": { "Verify SSL Certificate": "Verify SSL Certificate", - "scan_interval_seconds": "Scan interval (seconds)" + "scan_interval_seconds": "Full poll interval (seconds)", + "alt_polling_mode": "Alt polling mode (split fast/slow)", + "fast_scan_interval_seconds": "Fast poll interval (seconds)" + }, + "data_description": { + "scan_interval_seconds": "Interval for all data: summary, energy, config, and power. When alt polling mode is enabled this becomes the slow/full poll interval.", + "alt_polling_mode": "When enabled, live power data (SOC, PV, grid, battery, load) polls at the fast interval, while summary, energy, and config data polls at the full poll interval.", + "fast_scan_interval_seconds": "Interval for live power data when alt polling mode is enabled. Default 15 seconds." } } } diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..e20eb02 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,41 @@ +[tool.pytest.ini_options] +testpaths = ["tests"] +asyncio_mode = "auto" +# -p no:homeassistant disables the pytest-homeassistant-custom-component +# plugin (Linux-only fixtures; the unit tests here use explicit mocks so the +# suite runs identically on Windows, macOS and Linux/CI). +addopts = "-q -p no:homeassistant" + +[tool.coverage.run] +source = ["custom_components/alphaess"] +branch = false + +[tool.coverage.report] +fail_under = 100 +show_missing = true +exclude_lines = [ + "pragma: no cover", + "if TYPE_CHECKING:", +] + +[tool.ruff] +target-version = "py313" +line-length = 120 + +[tool.ruff.lint] +select = [ + "E", # pycodestyle errors + "F", # pyflakes + "W", # pycodestyle warnings + "I", # isort + "UP", # pyupgrade + "G", # flake8-logging-format +] +ignore = [ + "E501", # long lines (legacy descriptions/sensorlist) + "UP042", # keep `str, Enum` base: StrEnum changes str()/format() semantics +] + +[tool.ruff.lint.per-file-ignores] +# Enum member names intentionally mirror API field names +"custom_components/alphaess/enums.py" = ["N815"] diff --git a/requirements_test.txt b/requirements_test.txt new file mode 100644 index 0000000..0bc505d --- /dev/null +++ b/requirements_test.txt @@ -0,0 +1,2 @@ +pytest-homeassistant-custom-component +ruff diff --git a/scripts/rate_limit_stress.py b/scripts/rate_limit_stress.py new file mode 100644 index 0000000..6707d23 --- /dev/null +++ b/scripts/rate_limit_stress.py @@ -0,0 +1,302 @@ +""" +AlphaESS OpenAPI Rate Limit Stress Tester + +Last Test Run: 2024-06-15, No rate limit detected after 500+ calls over 5 minutes, including bursts of 50 concurrent calls and sustained rapid fire for 60 seconds. + +Aggressively hammers the API with escalating call volumes to find the rate limit. +Phases: + 1. Discover serials + 2. Rapid sequential — 100 calls, zero delay + 3. Parallel bursts — 10/20/50 concurrent + 4. Sustained machine-gun — as many as possible for 60s +Stops early if a rate limit is detected (HTTP 429 or API error code). +""" + +import asyncio +import hashlib +import time +import json +import aiohttp + +APP_ID = "" +APP_SECRET = "" +BASE_URL = "https://openapi.alphaess.com/api" + + +def make_headers(): + timestamp = str(int(time.time())) + sign_str = APP_ID + APP_SECRET + timestamp + sign = hashlib.sha512(sign_str.encode("ascii")).hexdigest() + return { + "Content-Type": "application/json", + "appId": APP_ID, + "timeStamp": timestamp, + "sign": sign, + } + + +def is_ok(r): + return r["status"] == 200 and r.get("code") in (None, 200) + + +def is_rate_limited(r): + if r["status"] == 429: + return True + if r.get("code") not in (None, 200) and r.get("code") is not None: + msg = (r.get("msg") or "").lower() + if any(kw in msg for kw in ("rate", "limit", "throttl", "too many", "frequent", "busy")): + return True + return False + + +async def api_get(session, url, label=""): + headers = make_headers() + t0 = time.monotonic() + try: + async with session.get(url, headers=headers, ssl=True, timeout=aiohttp.ClientTimeout(total=30)) as resp: + elapsed = (time.monotonic() - t0) * 1000 + body = await resp.text() + try: + data = json.loads(body) + except json.JSONDecodeError: + data = body + return { + "label": label, + "status": resp.status, + "elapsed_ms": round(elapsed, 1), + "code": data.get("code") if isinstance(data, dict) else None, + "msg": data.get("msg", data.get("info", "")) if isinstance(data, dict) else str(data)[:200], + } + except Exception as e: + elapsed = (time.monotonic() - t0) * 1000 + return { + "label": label, + "status": -1, + "elapsed_ms": round(elapsed, 1), + "code": None, + "msg": str(e)[:200], + } + + +def pr(r, idx=None): + prefix = f" [{idx:>4}]" if idx is not None else " " + status_str = f"HTTP {r['status']}" if r['status'] != -1 else "ERROR" + code_str = f" code={r['code']}" if r['code'] is not None else "" + rl = " *** RATE LIMITED ***" if is_rate_limited(r) else "" + print(f"{prefix} {r['label']:40s} | {status_str:8s}{code_str} | {r['elapsed_ms']:7.1f}ms | {r['msg'][:60]}{rl}") + + +def summary(results, label): + ok = sum(1 for r in results if is_ok(r)) + rl = sum(1 for r in results if is_rate_limited(r)) + err = len(results) - ok - rl + avg = sum(r["elapsed_ms"] for r in results) / len(results) if results else 0 + print(f"\n {label}: {ok} OK, {rl} rate-limited, {err} other errors / {len(results)} total | avg {avg:.0f}ms") + return rl + + +async def main(): + print("=" * 110) + print(" AlphaESS OpenAPI — AGGRESSIVE RATE LIMIT STRESS TEST") + print("=" * 110) + global_start = time.monotonic() + all_results = [] + + connector = aiohttp.TCPConnector(limit=50) # allow up to 50 concurrent connections + async with aiohttp.ClientSession(connector=connector) as session: + + # ── Phase 1: Discover serial numbers ── + print("\n── Phase 1: Discover systems ──") + headers = make_headers() + async with session.get(f"{BASE_URL}/getEssList", headers=headers, ssl=True) as resp: + body = await resp.json() + serials = [] + if body.get("data"): + for unit in body["data"]: + sn = unit.get("sysSn", "") + if sn: + serials.append(sn) + print(f" Found: {sn} ({unit.get('minv', '?')})") + if not serials: + print(" No systems found! Exiting.") + return + + today = time.strftime("%Y-%m-%d") + sn = serials[0] # primary serial for testing + test_url = f"{BASE_URL}/getLastPowerData?sysSn={sn}" + + # ── Phase 2: 100 sequential calls, ZERO delay ── + print(f"\n── Phase 2: 100 sequential calls to getLastPowerData [{sn[-6:]}], ZERO delay ──") + phase2 = [] + rate_hit = False + t0 = time.monotonic() + for i in range(100): + r = await api_get(session, test_url, f"seq #{i+1}") + phase2.append(r) + # Print every 10th, plus any errors/rate-limits + if (i + 1) % 10 == 0 or not is_ok(r): + pr(r, i + 1) + if is_rate_limited(r): + rate_hit = True + print(f"\n *** RATE LIMIT HIT at call #{i+1} after {time.monotonic()-t0:.1f}s ***") + break + elapsed_phase2 = time.monotonic() - t0 + rl2 = summary(phase2, f"Phase 2 ({elapsed_phase2:.1f}s)") + all_results.extend(phase2) + print(f" Effective rate: {len(phase2)/elapsed_phase2:.1f} calls/sec") + + if rate_hit: + print("\n Rate limit found in Phase 2! Skipping remaining phases.") + else: + # ── Phase 3: Parallel bursts of 10, 20, 50 ── + for burst_size in [10, 20, 50]: + print(f"\n── Phase 3-{burst_size}: {burst_size} simultaneous parallel calls ──") + tasks = [api_get(session, test_url, f"burst-{burst_size} #{j+1}") for j in range(burst_size)] + t0 = time.monotonic() + burst = await asyncio.gather(*tasks) + elapsed_burst = time.monotonic() - t0 + for i, r in enumerate(burst): + if not is_ok(r) or i == 0 or i == len(burst) - 1: + pr(r, i + 1) + rl_b = summary(list(burst), f"Burst-{burst_size} ({elapsed_burst:.1f}s)") + all_results.extend(burst) + print(f" Effective rate: {burst_size/elapsed_burst:.1f} calls/sec") + if rl_b: + rate_hit = True + print(f"\n *** RATE LIMIT HIT in burst-{burst_size}! ***") + break + + if not rate_hit: + # ── Phase 4: Mixed endpoints, 200 sequential, zero delay ── + print(f"\n── Phase 4: 200 sequential calls, mixed endpoints, ZERO delay ──") + mixed_urls = [ + (f"{BASE_URL}/getLastPowerData?sysSn={s}", f"lastPower [{s[-6:]}]") + for s in serials + ] + [ + (f"{BASE_URL}/getSumDataForCustomer?sysSn={s}", f"sumData [{s[-6:]}]") + for s in serials + ] + [ + (f"{BASE_URL}/getOneDateEnergyBySn?sysSn={s}&queryDate={today}", f"oneDateEnergy [{s[-6:]}]") + for s in serials + ] + [ + (f"{BASE_URL}/getChargeConfigInfo?sysSn={s}", f"chargeConfig [{s[-6:]}]") + for s in serials + ] + phase4 = [] + t0 = time.monotonic() + idx = 0 + while idx < 200: + url, label = mixed_urls[idx % len(mixed_urls)] + r = await api_get(session, url, f"mix #{idx+1} {label}") + phase4.append(r) + if (idx + 1) % 20 == 0 or not is_ok(r): + pr(r, idx + 1) + if is_rate_limited(r): + rate_hit = True + print(f"\n *** RATE LIMIT HIT at call #{idx+1} after {time.monotonic()-t0:.1f}s ***") + break + idx += 1 + elapsed_phase4 = time.monotonic() - t0 + rl4 = summary(phase4, f"Phase 4 ({elapsed_phase4:.1f}s)") + all_results.extend(phase4) + print(f" Effective rate: {len(phase4)/elapsed_phase4:.1f} calls/sec") + + if not rate_hit: + # ── Phase 5: 60-second sustained fire, as fast as possible ── + print(f"\n── Phase 5: Sustained rapid fire for 60 seconds ──") + phase5 = [] + t0 = time.monotonic() + idx = 0 + while (time.monotonic() - t0) < 60: + s = serials[idx % len(serials)] + r = await api_get(session, f"{BASE_URL}/getLastPowerData?sysSn={s}", f"fire #{idx+1} [{s[-6:]}]") + phase5.append(r) + if (idx + 1) % 25 == 0: + elapsed_so_far = time.monotonic() - t0 + ok_so_far = sum(1 for x in phase5 if is_ok(x)) + rl_so_far = sum(1 for x in phase5 if is_rate_limited(x)) + print(f" [{elapsed_so_far:5.1f}s] {idx+1} calls | {ok_so_far} OK | {rl_so_far} rate-limited | {(idx+1)/elapsed_so_far:.1f} calls/sec") + if is_rate_limited(r): + rate_hit = True + elapsed_so_far = time.monotonic() - t0 + print(f"\n *** RATE LIMIT HIT at call #{idx+1} after {elapsed_so_far:.1f}s ***") + pr(r, idx + 1) + # Keep going for a few more to see the pattern + for extra in range(10): + r2 = await api_get(session, f"{BASE_URL}/getLastPowerData?sysSn={serials[0]}", f"post-limit #{extra+1}") + phase5.append(r2) + pr(r2, idx + 2 + extra) + await asyncio.sleep(1) + break + idx += 1 + elapsed_phase5 = time.monotonic() - t0 + rl5 = summary(phase5, f"Phase 5 ({elapsed_phase5:.1f}s)") + all_results.extend(phase5) + if phase5: + print(f" Effective rate: {len(phase5)/elapsed_phase5:.1f} calls/sec") + + if not rate_hit: + # ── Phase 6: Parallel waves — 10 waves of 20 concurrent ── + print(f"\n── Phase 6: 10 waves of 20 concurrent calls (200 total) ──") + phase6 = [] + t0 = time.monotonic() + for wave in range(10): + tasks = [ + api_get(session, f"{BASE_URL}/getLastPowerData?sysSn={serials[j % len(serials)]}", f"wave{wave+1} #{j+1}") + for j in range(20) + ] + results_wave = await asyncio.gather(*tasks) + phase6.extend(results_wave) + ok_w = sum(1 for x in results_wave if is_ok(x)) + rl_w = sum(1 for x in results_wave if is_rate_limited(x)) + elapsed_w = time.monotonic() - t0 + print(f" Wave {wave+1:>2}: {ok_w}/20 OK, {rl_w} rate-limited | cumulative {len(phase6)} calls in {elapsed_w:.1f}s") + if rl_w: + rate_hit = True + print(f"\n *** RATE LIMIT HIT in wave {wave+1}! ***") + break + elapsed_phase6 = time.monotonic() - t0 + rl6 = summary(phase6, f"Phase 6 ({elapsed_phase6:.1f}s)") + all_results.extend(phase6) + print(f" Effective rate: {len(phase6)/elapsed_phase6:.1f} calls/sec") + + # ── Final Summary ── + total_elapsed = time.monotonic() - global_start + print("\n" + "=" * 110) + print(" FINAL SUMMARY") + print("=" * 110) + total = len(all_results) + total_ok = sum(1 for r in all_results if is_ok(r)) + total_rl = sum(1 for r in all_results if is_rate_limited(r)) + total_err = total - total_ok - total_rl + avg = sum(r["elapsed_ms"] for r in all_results) / total if total else 0 + + print(f" Total calls: {total}") + print(f" Successful: {total_ok}") + print(f" Rate limited: {total_rl}") + print(f" Other errors: {total_err}") + print(f" Total time: {total_elapsed:.1f}s") + print(f" Overall rate: {total/total_elapsed:.1f} calls/sec") + print(f" Avg response time: {avg:.0f}ms") + + if total_rl: + print(f"\n *** RATE LIMIT WAS FOUND ***") + # Find first rate-limited result + for i, r in enumerate(all_results): + if is_rate_limited(r): + print(f" First rate-limited response at call #{i+1}:") + pr(r, i + 1) + break + else: + print(f"\n *** NO RATE LIMIT DETECTED after {total} calls in {total_elapsed:.1f}s ***") + + # Show any non-200 API codes + api_errs = [r for r in all_results if r["status"] == 200 and r.get("code") not in (None, 200)] + if api_errs: + print(f"\n API-level errors (HTTP 200 but non-success code):") + for r in api_errs[:20]: + pr(r) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..82d2340 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,121 @@ +"""Shared fixtures for AlphaESS tests. + +These unit tests exercise the integration with explicit mocks instead of the +pytest-homeassistant-custom-component plugin (whose runtime fixtures are +Linux-only), so the suite runs identically on Windows, macOS and Linux/CI. +The plugin is disabled via `-p no:homeassistant` in pyproject.toml. +""" +from datetime import timedelta +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from custom_components.alphaess.coordinator import AlphaESSDataUpdateCoordinator + + +class FakeEntry: + """Minimal stand-in for a ConfigEntry.""" + + def __init__(self, entry_id="test_entry", data=None, options=None, subentries=None): + self.entry_id = entry_id + self.data = data or {"AppID": "app-id", "AppSecret": "app-secret"} + self.options = options or {} + self.subentries = subentries or {} + self.runtime_data = None + self.version = 2 + self.state = None + self.add_update_listener = MagicMock(return_value=MagicMock()) + self.async_on_unload = MagicMock() + + +class FakeAddEntities: + """Capture entities passed to async_add_entities.""" + + def __init__(self): + self.entities = [] + self.calls = [] + + def __call__(self, new_entities, **kwargs): + new_entities = list(new_entities) + self.entities.extend(new_entities) + self.calls.append((new_entities, kwargs)) + + +@pytest.fixture +def mock_hass(): + """Return a MagicMock standing in for HomeAssistant.""" + hass = MagicMock() + hass.config.currency = "USD" + hass.services.async_call = AsyncMock() + hass.config_entries.async_forward_entry_setups = AsyncMock() + hass.config_entries.async_unload_platforms = AsyncMock(return_value=True) + hass.config_entries.async_reload = AsyncMock() + return hass + + +@pytest.fixture +def mock_api(): + """Return a mocked alphaess API client.""" + api = MagicMock() + api.ipaddress = None + for method in ( + "getESSList", + "getSumDataForCustomer", + "getOneDateEnergyBySn", + "getLastPowerData", + "getChargeConfigInfo", + "getDisChargeConfigInfo", + "getOneDayPowerBySn", + "getEvChargerConfigList", + "getEvChargerStatusBySn", + "getEvChargerCurrentsBySn", + "getIPData", + "updateChargeConfigInfo", + "updateDisChargeConfigInfo", + "setEvChargerCurrentsBySn", + "remoteControlEvCharger", + "getVerificationCode", + "bindSn", + "unBindSn", + "authenticate", + ): + setattr(api, method, AsyncMock(return_value=None)) + return api + + +@pytest.fixture +def make_coordinator(mock_hass, mock_api): + """Return a factory producing real coordinators with mocked hass/api.""" + + def _make( + models=None, + ip_map=None, + entry=None, + alt=False, + scan_seconds=60, + fast_seconds=15, + ): + coordinator = AlphaESSDataUpdateCoordinator( + mock_hass, + client=mock_api, + ip_address_map=ip_map, + inverter_models=models if models is not None else [], + entry=entry, + scan_interval=timedelta(seconds=scan_seconds), + alt_polling_mode=alt, + fast_scan_interval=timedelta(seconds=fast_seconds), + ) + # Keep unit tests fast: no inter-call throttling unless a test + # explicitly sets it. + coordinator.throttle_multiplier = 0.0 + coordinator.async_request_refresh = AsyncMock() + coordinator.async_set_updated_data = MagicMock() + return coordinator + + return _make + + +@pytest.fixture +def add_entities(): + """Return a callable capturing added entities.""" + return FakeAddEntities() diff --git a/tests/test_button_number.py b/tests/test_button_number.py new file mode 100644 index 0000000..f3bb4ef --- /dev/null +++ b/tests/test_button_number.py @@ -0,0 +1,589 @@ +"""Tests for the button and number platforms.""" +import time as time_mod +from unittest.mock import AsyncMock, MagicMock + +from homeassistant.config_entries import ConfigSubentry + +from custom_components.alphaess.button import ( + AlphaESSBatteryButton, + create_persistent_notification, +) +from custom_components.alphaess.button import ( + async_setup_entry as button_setup, +) +from custom_components.alphaess.const import ( + CONF_DISABLE_NOTIFICATIONS, + CONF_INVERTER_MODEL, + CONF_IP_ADDRESS, + CONF_PARENT_INVERTER, + CONF_SERIAL_NUMBER, + SUBENTRY_TYPE_EV_CHARGER, + SUBENTRY_TYPE_INVERTER, +) +from custom_components.alphaess.enums import AlphaESSNames +from custom_components.alphaess.number import ( + AlphaEVNumber, + AlphaNumber, +) +from custom_components.alphaess.number import ( + async_setup_entry as number_setup, +) +from custom_components.alphaess.sensorlist import ( + DISCHARGE_AND_CHARGE_NUMBERS, + EV_CHARGER_NUMBERS, + EV_DISCHARGE_AND_CHARGE_BUTTONS, + SUPPORT_DISCHARGE_AND_CHARGE_BUTTON_DESCRIPTIONS, +) + +from .conftest import FakeEntry + +SERIAL = "AL1000021000123" + + +def _inverter_subentry(serial=SERIAL, model="SMILE5-INV", disable_notifications=True): + return ConfigSubentry( + data={ + CONF_SERIAL_NUMBER: serial, + CONF_INVERTER_MODEL: model, + CONF_IP_ADDRESS: "", + CONF_DISABLE_NOTIFICATIONS: disable_notifications, + }, + subentry_type=SUBENTRY_TYPE_INVERTER, + title=f"{model} ({serial})", + unique_id=f"{SUBENTRY_TYPE_INVERTER}_{serial}", + ) + + +def _ev_subentry(ev_serial="EV123", parent=SERIAL): + return ConfigSubentry( + data={CONF_SERIAL_NUMBER: ev_serial, CONF_PARENT_INVERTER: parent}, + subentry_type=SUBENTRY_TYPE_EV_CHARGER, + title=f"EV ({ev_serial})", + unique_id=f"{SUBENTRY_TYPE_EV_CHARGER}_{ev_serial}", + ) + + +def _entry_for(subentries, coordinator): + entry = FakeEntry(subentries={sub.subentry_id: sub for sub in subentries}) + entry.runtime_data = coordinator + return entry + + +def _button_description(key): + for d in SUPPORT_DISCHARGE_AND_CHARGE_BUTTON_DESCRIPTIONS + EV_DISCHARGE_AND_CHARGE_BUTTONS: + if d.key == key: + return d + raise KeyError(key) + + +async def test_create_persistent_notification(mock_hass): + await create_persistent_notification(mock_hass, "msg", "title") + mock_hass.services.async_call.assert_awaited_once() + + +class TestButtonSetup: + async def test_buttons_created(self, mock_hass, make_coordinator, add_entities): + coordinator = make_coordinator() + coordinator.data = {SERIAL: {"Model": "SMILE5-INV"}} + entry = _entry_for([_inverter_subentry()], coordinator) + + await button_setup(mock_hass, entry, add_entities) + assert len(add_entities.entities) == len( + SUPPORT_DISCHARGE_AND_CHARGE_BUTTON_DESCRIPTIONS + ) + + async def test_blacklisted_model_no_battery_buttons( + self, mock_hass, make_coordinator, add_entities + ): + coordinator = make_coordinator() + coordinator.data = {SERIAL: {"Model": "VT1000"}} + entry = _entry_for([_inverter_subentry(model="VT1000")], coordinator) + + await button_setup(mock_hass, entry, add_entities) + assert add_entities.entities == [] + + async def test_auto_discovered_ev_buttons(self, mock_hass, make_coordinator, add_entities): + coordinator = make_coordinator() + coordinator.data = { + SERIAL: { + "Model": "SMILE5-INV", + AlphaESSNames.evchargersn: "EV123", + AlphaESSNames.evchargermodel: "SMILE-EVCT11", + } + } + entry = _entry_for([_inverter_subentry()], coordinator) + + await button_setup(mock_hass, entry, add_entities) + expected = len(SUPPORT_DISCHARGE_AND_CHARGE_BUTTON_DESCRIPTIONS) + len( + EV_DISCHARGE_AND_CHARGE_BUTTONS + ) + assert len(add_entities.entities) == expected + + async def test_ev_subentry_buttons(self, mock_hass, make_coordinator, add_entities): + coordinator = make_coordinator() + coordinator.data = { + SERIAL: { + "Model": "SMILE5-INV", + AlphaESSNames.evchargersn: "EV123", + AlphaESSNames.evchargermodel: "SMILE-EVCT11", + } + } + entry = _entry_for([_inverter_subentry(), _ev_subentry()], coordinator) + + await button_setup(mock_hass, entry, add_entities) + # battery buttons via inverter + EV buttons via EV subentry + assert len(add_entities.calls) == 2 + + async def test_ev_subentry_missing_parent(self, mock_hass, make_coordinator, add_entities): + coordinator = make_coordinator() + coordinator.data = {} + entry = _entry_for([_ev_subentry(parent="GONE")], coordinator) + + await button_setup(mock_hass, entry, add_entities) + assert add_entities.entities == [] + + async def test_ev_subentry_no_charger_in_data( + self, mock_hass, make_coordinator, add_entities + ): + coordinator = make_coordinator() + coordinator.data = {SERIAL: {"Model": "SMILE5-INV"}} + entry = _entry_for([_ev_subentry()], coordinator) + + await button_setup(mock_hass, entry, add_entities) + assert add_entities.entities == [] + + async def test_serial_missing_skipped(self, mock_hass, make_coordinator, add_entities): + coordinator = make_coordinator() + coordinator.data = {} + entry = _entry_for([_inverter_subentry()], coordinator) + + await button_setup(mock_hass, entry, add_entities) + assert add_entities.entities == [] + + +class TestButtonNotificationsDisabled: + def _make_button(self, coordinator, mock_hass, subentry=None, key=AlphaESSNames.ButtonDischargeSixty): + entry = _entry_for([subentry] if subentry else [], coordinator) + button = AlphaESSBatteryButton( + coordinator, entry, SERIAL, _button_description(key), subentry=subentry, + ) + button.hass = mock_hass + return button, entry + + def test_no_subentry_disables(self, make_coordinator, mock_hass): + coordinator = make_coordinator() + button, _ = self._make_button(coordinator, mock_hass, subentry=None) + assert button._notifications_disabled is True + + def test_live_subentry_value(self, make_coordinator, mock_hass): + coordinator = make_coordinator() + subentry = _inverter_subentry(disable_notifications=False) + button, entry = self._make_button(coordinator, mock_hass, subentry=subentry) + mock_hass.config_entries.async_get_entry.return_value = entry + assert button._notifications_disabled is False + + def test_live_subentry_missing(self, make_coordinator, mock_hass): + coordinator = make_coordinator() + subentry = _inverter_subentry() + button, entry = self._make_button(coordinator, mock_hass, subentry=subentry) + live_entry = FakeEntry(subentries={}) + mock_hass.config_entries.async_get_entry.return_value = live_entry + assert button._notifications_disabled is True + + def test_entry_missing(self, make_coordinator, mock_hass): + coordinator = make_coordinator() + subentry = _inverter_subentry() + button, _ = self._make_button(coordinator, mock_hass, subentry=subentry) + mock_hass.config_entries.async_get_entry.return_value = None + assert button._notifications_disabled is True + + +class TestButtonPress: + def _make_ev_button(self, coordinator, mock_hass, key, notifications_off=True): + subentry = _inverter_subentry(disable_notifications=notifications_off) + entry = _entry_for([subentry], coordinator) + button = AlphaESSBatteryButton( + coordinator, entry, SERIAL, _button_description(key), + ev_charger=True, ev_serial="EV123", subentry=subentry, + ) + button.hass = mock_hass + mock_hass.config_entries.async_get_entry.return_value = entry + return button + + def _make_battery_button(self, coordinator, mock_hass, key, notifications_off=True): + subentry = _inverter_subentry(disable_notifications=notifications_off) + entry = _entry_for([subentry], coordinator) + button = AlphaESSBatteryButton( + coordinator, entry, SERIAL, _button_description(key), subentry=subentry, + ) + button.hass = mock_hass + mock_hass.config_entries.async_get_entry.return_value = entry + return button + + async def test_stop_charging_blocked(self, make_coordinator, mock_hass, mock_api): + coordinator = make_coordinator() + coordinator.data = {SERIAL: {AlphaESSNames.evchargerstatusraw: 2}} + button = self._make_ev_button( + coordinator, mock_hass, AlphaESSNames.stopcharging, notifications_off=False + ) + + await button.async_press() + mock_api.remoteControlEvCharger.assert_not_awaited() + mock_hass.services.async_call.assert_awaited() # invalid-command notification + + async def test_stop_charging_success(self, make_coordinator, mock_hass, mock_api): + coordinator = make_coordinator() + coordinator.data = {SERIAL: {AlphaESSNames.evchargerstatusraw: 3}} + button = self._make_ev_button( + coordinator, mock_hass, AlphaESSNames.stopcharging, notifications_off=False + ) + + await button.async_press() + mock_api.remoteControlEvCharger.assert_awaited_once_with(SERIAL, "EV123", 0) + mock_hass.services.async_call.assert_awaited() + + async def test_start_charging_blocked_silent(self, make_coordinator, mock_hass, mock_api): + coordinator = make_coordinator() + coordinator.data = {SERIAL: {AlphaESSNames.evchargerstatusraw: 3}} + button = self._make_ev_button(coordinator, mock_hass, AlphaESSNames.startcharging) + + await button.async_press() + mock_api.remoteControlEvCharger.assert_not_awaited() + mock_hass.services.async_call.assert_not_awaited() + + async def test_start_charging_success(self, make_coordinator, mock_hass, mock_api): + coordinator = make_coordinator() + coordinator.data = {SERIAL: {AlphaESSNames.evchargerstatusraw: 2}} + button = self._make_ev_button(coordinator, mock_hass, AlphaESSNames.startcharging) + + await button.async_press() + mock_api.remoteControlEvCharger.assert_awaited_once_with(SERIAL, "EV123", 1) + + async def test_start_charging_success_with_notification( + self, make_coordinator, mock_hass, mock_api + ): + coordinator = make_coordinator() + coordinator.data = {SERIAL: {AlphaESSNames.evchargerstatusraw: 2}} + button = self._make_ev_button( + coordinator, mock_hass, AlphaESSNames.startcharging, notifications_off=False + ) + + await button.async_press() + mock_api.remoteControlEvCharger.assert_awaited_once_with(SERIAL, "EV123", 1) + mock_hass.services.async_call.assert_awaited() + + async def test_discharge_button(self, make_coordinator, mock_hass, mock_api): + coordinator = make_coordinator() + coordinator.data = {SERIAL: {}} + coordinator.set_number_setting(SERIAL, "batUseCap", 10) + button = self._make_battery_button( + coordinator, mock_hass, AlphaESSNames.ButtonDischargeSixty, + notifications_off=False, + ) + + await button.async_press() + mock_api.updateDisChargeConfigInfo.assert_awaited_once() + assert SERIAL in coordinator.last_discharge_update + + async def test_discharge_rate_limited(self, make_coordinator, mock_hass, mock_api): + coordinator = make_coordinator() + coordinator.data = {SERIAL: {}} + coordinator.last_discharge_update[SERIAL] = time_mod.monotonic() + button = self._make_battery_button( + coordinator, mock_hass, AlphaESSNames.ButtonDischargeSixty, + notifications_off=False, + ) + + await button.async_press() + mock_api.updateDisChargeConfigInfo.assert_not_awaited() + mock_hass.services.async_call.assert_awaited() # wait message + + async def test_charge_button(self, make_coordinator, mock_hass, mock_api): + coordinator = make_coordinator() + coordinator.data = {SERIAL: {}} + coordinator.set_number_setting(SERIAL, "batHighCap", 90) + button = self._make_battery_button( + coordinator, mock_hass, AlphaESSNames.ButtonChargeFifteen + ) + + await button.async_press() + mock_api.updateChargeConfigInfo.assert_awaited_once() + + async def test_reset_button(self, make_coordinator, mock_hass, mock_api): + coordinator = make_coordinator() + coordinator.data = {SERIAL: {}} + button = self._make_battery_button( + coordinator, mock_hass, AlphaESSNames.ButtonRechargeConfig, + notifications_off=False, + ) + + await button.async_press() + mock_api.updateChargeConfigInfo.assert_awaited_once() + mock_api.updateDisChargeConfigInfo.assert_awaited_once() + assert SERIAL in coordinator.last_charge_update + + async def test_reset_button_throttled(self, make_coordinator, mock_hass, mock_api): + coordinator = make_coordinator() + coordinator.data = {SERIAL: {}} + coordinator.last_charge_update[SERIAL] = time_mod.monotonic() + button = self._make_battery_button( + coordinator, mock_hass, AlphaESSNames.ButtonRechargeConfig, + notifications_off=False, + ) + + await button.async_press() + mock_api.updateChargeConfigInfo.assert_not_awaited() + mock_hass.services.async_call.assert_awaited() + + async def test_reset_button_throttled_silent(self, make_coordinator, mock_hass, mock_api): + coordinator = make_coordinator() + coordinator.data = {SERIAL: {}} + coordinator.last_discharge_update[SERIAL] = time_mod.monotonic() + button = self._make_battery_button( + coordinator, mock_hass, AlphaESSNames.ButtonRechargeConfig + ) + + await button.async_press() + mock_hass.services.async_call.assert_not_awaited() + + def test_button_properties(self, make_coordinator, mock_hass): + coordinator = make_coordinator() + coordinator.data = {SERIAL: {}} + button = self._make_battery_button( + coordinator, mock_hass, AlphaESSNames.ButtonDischargeSixty + ) + + coordinator.last_update_success = False + assert button.available is False + coordinator.last_update_success = True + coordinator.cloud_available = False + assert button.available is False + coordinator.cloud_available = True + assert button.available is True + + assert SERIAL in button.unique_id + assert button.device_class is None + assert button.entity_category is not None + assert button.name == "60 Minute Discharge" + assert button.suggested_object_id == f"{SERIAL} 60 Minute Discharge" + assert button.icon + + def test_button_with_device_info(self, make_coordinator, mock_hass): + coordinator = make_coordinator() + info = {"identifiers": {("alphaess", SERIAL)}} + button = AlphaESSBatteryButton( + coordinator, FakeEntry(), SERIAL, + _button_description(AlphaESSNames.ButtonDischargeSixty), + device_info=info, + ) + assert button._attr_device_info == info + + +class TestNumberSetup: + async def test_numbers_created(self, mock_hass, make_coordinator, add_entities): + coordinator = make_coordinator() + coordinator.data = {SERIAL: {"Model": "SMILE5-INV"}} + entry = _entry_for([_inverter_subentry()], coordinator) + + await number_setup(mock_hass, entry, add_entities) + assert len(add_entities.entities) == len(DISCHARGE_AND_CHARGE_NUMBERS) + + async def test_blacklisted_model(self, mock_hass, make_coordinator, add_entities): + coordinator = make_coordinator() + coordinator.data = {SERIAL: {"Model": "VT1000"}} + entry = _entry_for([_inverter_subentry(model="VT1000")], coordinator) + + await number_setup(mock_hass, entry, add_entities) + assert add_entities.entities == [] + + async def test_auto_discovered_ev_numbers(self, mock_hass, make_coordinator, add_entities): + coordinator = make_coordinator() + coordinator.data = { + SERIAL: { + "Model": "SMILE5-INV", + AlphaESSNames.evchargersn: "EV123", + AlphaESSNames.evchargermodel: "SMILE-EVCT11", + } + } + entry = _entry_for([_inverter_subentry()], coordinator) + + await number_setup(mock_hass, entry, add_entities) + expected = len(DISCHARGE_AND_CHARGE_NUMBERS) + len(EV_CHARGER_NUMBERS) + assert len(add_entities.entities) == expected + + async def test_ev_subentry_numbers(self, mock_hass, make_coordinator, add_entities): + coordinator = make_coordinator() + coordinator.data = { + SERIAL: { + "Model": "SMILE5-INV", + AlphaESSNames.evchargersn: "EV123", + AlphaESSNames.evchargermodel: "SMILE-EVCT11", + } + } + entry = _entry_for([_inverter_subentry(), _ev_subentry()], coordinator) + + await number_setup(mock_hass, entry, add_entities) + assert len(add_entities.calls) == 2 + + async def test_ev_subentry_missing_parent(self, mock_hass, make_coordinator, add_entities): + coordinator = make_coordinator() + coordinator.data = {} + entry = _entry_for([_ev_subentry(parent="GONE")], coordinator) + + await number_setup(mock_hass, entry, add_entities) + assert add_entities.entities == [] + + async def test_ev_subentry_no_charger(self, mock_hass, make_coordinator, add_entities): + coordinator = make_coordinator() + coordinator.data = {SERIAL: {"Model": "SMILE5-INV"}} + entry = _entry_for([_ev_subentry()], coordinator) + + await number_setup(mock_hass, entry, add_entities) + assert add_entities.entities == [] + + async def test_serial_missing(self, mock_hass, make_coordinator, add_entities): + coordinator = make_coordinator() + coordinator.data = {} + entry = _entry_for([_inverter_subentry()], coordinator) + + await number_setup(mock_hass, entry, add_entities) + assert add_entities.entities == [] + + +def _number_description(key): + return next(d for d in DISCHARGE_AND_CHARGE_NUMBERS if d.key == key) + + +class TestAlphaNumber: + def _make(self, coordinator, key=AlphaESSNames.batUseCap, device_info=None): + return AlphaNumber( + coordinator, SERIAL, FakeEntry(), _number_description(key), + device_info=device_info, + ) + + def test_initial_defaults(self, make_coordinator): + coordinator = make_coordinator() + high = self._make(coordinator, AlphaESSNames.batHighCap, + device_info={"identifiers": set()}) + low = self._make(coordinator, AlphaESSNames.batUseCap) + assert high._def_initial_value == 90.0 + assert low._def_initial_value == 10.0 + + async def test_added_to_hass_restores_state(self, make_coordinator, monkeypatch): + from homeassistant.helpers.update_coordinator import CoordinatorEntity + + coordinator = make_coordinator() + entity = self._make(coordinator) + monkeypatch.setattr( + CoordinatorEntity, "async_added_to_hass", AsyncMock() + ) + restored = MagicMock() + restored.native_value = 33.0 + entity.async_get_last_number_data = AsyncMock(return_value=restored) + + await entity.async_added_to_hass() + assert entity._attr_native_value == 33.0 + assert coordinator.get_number_setting(SERIAL, "batUseCap") == 33.0 + + async def test_added_to_hass_no_saved_state(self, make_coordinator, monkeypatch): + from homeassistant.helpers.update_coordinator import CoordinatorEntity + + coordinator = make_coordinator() + entity = self._make(coordinator) + monkeypatch.setattr( + CoordinatorEntity, "async_added_to_hass", AsyncMock() + ) + entity.async_get_last_number_data = AsyncMock(return_value=None) + entity.async_write_ha_state = MagicMock() + + await entity.async_added_to_hass() + assert entity._attr_native_value == 10.0 + assert coordinator.get_number_setting(SERIAL, "batUseCap") == 10.0 + + async def test_set_value_discharge_pushes_api(self, make_coordinator, mock_api): + coordinator = make_coordinator() + coordinator.data = {SERIAL: {"ctrDis": 0, "discharge_timeDise1": "23:00"}} + entity = self._make(coordinator, AlphaESSNames.batUseCap) + entity.async_write_ha_state = MagicMock() + + await entity.async_set_native_value(22) + args = mock_api.updateDisChargeConfigInfo.await_args.args + assert args == (SERIAL, 22, 0, "23:00", "00:00", "00:00", "00:00") + assert coordinator.get_number_setting(SERIAL, "batUseCap") == 22 + coordinator.async_request_refresh.assert_awaited_once() + + async def test_set_value_charge_pushes_api(self, make_coordinator, mock_api): + coordinator = make_coordinator() + coordinator.data = {SERIAL: {"gridCharge": 1, "charge_timeChaf1": "01:00"}} + entity = self._make(coordinator, AlphaESSNames.batHighCap) + entity.async_write_ha_state = MagicMock() + + await entity.async_set_native_value(88) + args = mock_api.updateChargeConfigInfo.await_args.args + assert args == (SERIAL, 88, 1, "00:00", "00:00", "01:00", "00:00") + + def test_properties(self, make_coordinator): + coordinator = make_coordinator() + coordinator.data = {SERIAL: {}} + entity = self._make(coordinator) + entity._attr_native_value = 10.0 + + coordinator.last_update_success = False + assert entity.available is False + coordinator.last_update_success = True + coordinator.cloud_available = True + assert entity.available is True + + assert entity.native_value == 10.0 + assert entity.name == "batUseCap" + assert entity.suggested_object_id == f"{SERIAL} batUseCap" + assert entity.mode == "box" + assert entity.native_unit_of_measurement == "%" + assert SERIAL in entity.unique_id + assert entity.entity_category is not None + assert entity.icon + + +class TestAlphaEVNumber: + def _make(self, coordinator, device_info=None): + return AlphaEVNumber( + coordinator, SERIAL, FakeEntry(), EV_CHARGER_NUMBERS[0], + ev_serial="EV123", device_info=device_info, + ) + + def test_native_value(self, make_coordinator): + coordinator = make_coordinator() + coordinator.data = {SERIAL: {AlphaESSNames.evcurrentsetting: "16"}} + entity = self._make(coordinator, device_info={"identifiers": set()}) + assert entity.native_value == 16.0 + + coordinator.data = {SERIAL: {}} + assert entity.native_value is None + + async def test_set_value(self, make_coordinator, mock_api): + coordinator = make_coordinator() + coordinator.data = {SERIAL: {}} + entity = self._make(coordinator) + + await entity.async_set_native_value(10.0) + mock_api.setEvChargerCurrentsBySn.assert_awaited_once_with(SERIAL, 10) + + def test_properties(self, make_coordinator): + coordinator = make_coordinator() + coordinator.data = {SERIAL: {}} + entity = self._make(coordinator) + + coordinator.last_update_success = False + assert entity.available is False + coordinator.last_update_success = True + coordinator.cloud_available = False + assert entity.available is False + coordinator.cloud_available = True + assert entity.available is True + + assert entity.name + assert entity.suggested_object_id.startswith(SERIAL) + assert entity.native_unit_of_measurement == "A" + assert SERIAL in entity.unique_id + assert entity.entity_category is not None + assert entity.icon diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py new file mode 100644 index 0000000..489252f --- /dev/null +++ b/tests/test_config_flow.py @@ -0,0 +1,366 @@ +"""Tests for the AlphaESS config, options, reauth and subentry flows.""" +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock + +import aiohttp +import pytest + +from custom_components.alphaess import config_flow +from custom_components.alphaess.config_flow import ( + AlphaESSConfigFlow, + AlphaESSInverterSubentryFlowHandler, + AlphaESSOptionsFlowHandler, + CannotConnect, + InvalidAuth, + validate_input, +) +from custom_components.alphaess.const import ( + CONF_DISABLE_NOTIFICATIONS, + CONF_IP_ADDRESS, + CONF_SERIAL_NUMBER, + SUBENTRY_TYPE_INVERTER, +) + +from .conftest import FakeEntry + +USER_INPUT = { + "AppID": "app-id", + "AppSecret": "app-secret", + "Verify SSL Certificate": True, +} + + +def _response_error(status): + return aiohttp.ClientResponseError( + request_info=MagicMock(), history=(), status=status, message="x" + ) + + +@pytest.fixture(autouse=True) +def _fast_sleep(monkeypatch): + monkeypatch.setattr(config_flow.asyncio, "sleep", AsyncMock()) + + +@pytest.fixture(autouse=True) +def _fake_clientsession(monkeypatch): + """Avoid creating real aiohttp sessions against the mocked hass.""" + monkeypatch.setattr( + config_flow, "async_get_clientsession", MagicMock(return_value=MagicMock()) + ) + + +@pytest.fixture +def mock_client(monkeypatch): + client = MagicMock() + client.authenticate = AsyncMock(return_value=True) + client.getESSList = AsyncMock( + return_value=[{"sysSn": "AL123", "minv": "SMILE5-INV"}] + ) + monkeypatch.setattr( + config_flow.alphaess, "alphaess", MagicMock(return_value=client) + ) + return client + + +class TestValidateInput: + async def test_success(self, mock_hass, mock_client): + result = await validate_input(mock_hass, USER_INPUT) + assert result["title"] == "app-id" + assert result["ess_list"][0]["sysSn"] == "AL123" + + async def test_empty_ess_list(self, mock_hass, mock_client): + mock_client.getESSList.return_value = None + result = await validate_input(mock_hass, USER_INPUT) + assert result["ess_list"] == [] + + async def test_invalid_auth(self, mock_hass, mock_client): + mock_client.authenticate.side_effect = _response_error(401) + with pytest.raises(InvalidAuth): + await validate_input(mock_hass, USER_INPUT) + + async def test_other_response_error(self, mock_hass, mock_client): + mock_client.authenticate.side_effect = _response_error(500) + with pytest.raises(aiohttp.ClientResponseError): + await validate_input(mock_hass, USER_INPUT) + + async def test_cannot_connect(self, mock_hass, mock_client): + mock_client.authenticate.side_effect = aiohttp.ClientConnectorError( + MagicMock(), OSError("no route") + ) + with pytest.raises(CannotConnect): + await validate_input(mock_hass, USER_INPUT) + + +def _make_flow(mock_hass): + flow = AlphaESSConfigFlow() + flow.hass = mock_hass + flow.context = {} + flow.async_set_unique_id = AsyncMock() + flow._abort_if_unique_id_configured = MagicMock() + return flow + + +class TestUserFlow: + async def test_form_shown_without_input(self, mock_hass): + flow = _make_flow(mock_hass) + result = await flow.async_step_user(None) + assert result["type"] == "form" + assert result["step_id"] == "user" + + async def test_create_entry(self, mock_hass, mock_client): + flow = _make_flow(mock_hass) + result = await flow.async_step_user(dict(USER_INPUT)) + assert result["type"] == "create_entry" + assert result["title"] == "app-id" + assert result["data"]["AppID"] == "app-id" + subentries = result["subentries"] + assert len(subentries) == 1 + assert subentries[0]["data"][CONF_SERIAL_NUMBER] == "AL123" + + async def test_unit_without_serial_skipped(self, mock_hass, mock_client): + mock_client.getESSList.return_value = [{"minv": "X"}] + flow = _make_flow(mock_hass) + result = await flow.async_step_user(dict(USER_INPUT)) + assert list(result["subentries"]) == [] + + async def test_cannot_connect_error(self, mock_hass, mock_client): + mock_client.authenticate.side_effect = aiohttp.ClientConnectorError( + MagicMock(), OSError("no route") + ) + flow = _make_flow(mock_hass) + result = await flow.async_step_user(dict(USER_INPUT)) + assert result["type"] == "form" + assert result["errors"] == {"base": "cannot_connect"} + + async def test_invalid_auth_error(self, mock_hass, mock_client): + mock_client.authenticate.side_effect = _response_error(401) + flow = _make_flow(mock_hass) + result = await flow.async_step_user(dict(USER_INPUT)) + assert result["errors"] == {"base": "invalid_auth"} + + def test_options_flow_factory(self): + entry = FakeEntry() + handler = AlphaESSConfigFlow.async_get_options_flow(entry) + assert isinstance(handler, AlphaESSOptionsFlowHandler) + + def test_subentry_types(self): + types = AlphaESSConfigFlow.async_get_supported_subentry_types(FakeEntry()) + assert types == {SUBENTRY_TYPE_INVERTER: AlphaESSInverterSubentryFlowHandler} + + +class TestReauthFlow: + def _make_reauth_flow(self, mock_hass, entry): + flow = _make_flow(mock_hass) + flow._get_reauth_entry = MagicMock(return_value=entry) + flow.async_update_reload_and_abort = MagicMock( + return_value={"type": "abort", "reason": "reauth_successful"} + ) + return flow + + async def test_reauth_shows_form(self, mock_hass): + entry = FakeEntry() + flow = self._make_reauth_flow(mock_hass, entry) + result = await flow.async_step_reauth({}) + assert result["type"] == "form" + assert result["step_id"] == "reauth_confirm" + + async def test_reauth_success(self, mock_hass, mock_client): + entry = FakeEntry() + flow = self._make_reauth_flow(mock_hass, entry) + result = await flow.async_step_reauth_confirm({"AppSecret": "new-secret"}) + assert result["reason"] == "reauth_successful" + flow.async_update_reload_and_abort.assert_called_once_with( + entry, data_updates={"AppSecret": "new-secret"} + ) + + async def test_reauth_invalid_auth(self, mock_hass, mock_client): + mock_client.authenticate.side_effect = _response_error(401) + entry = FakeEntry() + flow = self._make_reauth_flow(mock_hass, entry) + result = await flow.async_step_reauth_confirm({"AppSecret": "bad"}) + assert result["errors"] == {"base": "invalid_auth"} + + async def test_reauth_cannot_connect(self, mock_hass, mock_client): + mock_client.authenticate.side_effect = aiohttp.ClientConnectorError( + MagicMock(), OSError("x") + ) + entry = FakeEntry() + flow = self._make_reauth_flow(mock_hass, entry) + result = await flow.async_step_reauth_confirm({"AppSecret": "bad"}) + assert result["errors"] == {"base": "cannot_connect"} + + +class TestOptionsFlow: + def _make(self, options=None): + entry = FakeEntry(options=options or {}) + handler = AlphaESSOptionsFlowHandler() + # Wire the bits the OptionsFlow.config_entry property needs: + # the flow handler id and a hass that resolves it to our entry. + handler.handler = entry.entry_id + hass = MagicMock() + hass.config_entries.async_get_known_entry.return_value = entry + handler.hass = hass + return handler + + async def test_form_shown(self): + handler = self._make() + result = await handler.async_step_init(None) + assert result["type"] == "form" + assert result["step_id"] == "init" + + async def test_save_preserves_internal_flags(self): + handler = self._make(options={"_ev_entity_cleanup_done": True}) + result = await handler.async_step_init({"scan_interval_seconds": 120}) + assert result["type"] == "create_entry" + assert result["data"]["_ev_entity_cleanup_done"] is True + assert result["data"]["scan_interval_seconds"] == 120 + + +def _make_subentry_flow(mock_hass, api): + flow = AlphaESSInverterSubentryFlowHandler() + flow.hass = mock_hass + flow.context = {"source": "user"} + entry = FakeEntry() + entry.runtime_data = SimpleNamespace(api=api) + flow._get_entry = MagicMock(return_value=entry) + return flow, entry + + +class TestSubentryUserStep: + async def test_form_shown(self, mock_hass, mock_api): + flow, _ = _make_subentry_flow(mock_hass, mock_api) + result = await flow.async_step_user(None) + assert result["type"] == "form" + assert result["step_id"] == "user" + + async def test_verification_requested(self, mock_hass, mock_api): + mock_api.getVerificationCode.return_value = {"ok": True} + flow, _ = _make_subentry_flow(mock_hass, mock_api) + result = await flow.async_step_user( + {"serial_number": " AL999 ", "check_code": " CODE "} + ) + mock_api.getVerificationCode.assert_awaited_once_with("AL999", "CODE") + assert result["step_id"] == "verify" + assert flow._sysSn == "AL999" + + async def test_verification_request_returns_none(self, mock_hass, mock_api): + mock_api.getVerificationCode.return_value = None + flow, _ = _make_subentry_flow(mock_hass, mock_api) + result = await flow.async_step_user( + {"serial_number": "AL999", "check_code": "CODE"} + ) + assert result["errors"] == {"base": "verification_request_failed"} + + async def test_verification_request_raises(self, mock_hass, mock_api): + mock_api.getVerificationCode.side_effect = OSError("api down") + flow, _ = _make_subentry_flow(mock_hass, mock_api) + result = await flow.async_step_user( + {"serial_number": "AL999", "check_code": "CODE"} + ) + assert result["errors"] == {"base": "verification_request_failed"} + + +class TestSubentryVerifyStep: + async def test_form_shown(self, mock_hass, mock_api): + flow, _ = _make_subentry_flow(mock_hass, mock_api) + flow._sysSn = "AL999" + result = await flow.async_step_verify(None) + assert result["type"] == "form" + assert result["step_id"] == "verify" + + async def test_bind_success(self, mock_hass, mock_api): + mock_api.bindSn.return_value = {"ok": True} + flow, entry = _make_subentry_flow(mock_hass, mock_api) + flow._sysSn = "AL999" + result = await flow.async_step_verify({"verification_code": " 1234 "}) + mock_api.bindSn.assert_awaited_once_with("AL999", "1234") + assert result["type"] == "create_entry" + assert result["data"][CONF_SERIAL_NUMBER] == "AL999" + mock_hass.config_entries.async_schedule_reload.assert_called_once_with( + entry.entry_id + ) + + async def test_bind_returns_none(self, mock_hass, mock_api): + mock_api.bindSn.return_value = None + flow, _ = _make_subentry_flow(mock_hass, mock_api) + flow._sysSn = "AL999" + result = await flow.async_step_verify({"verification_code": "1234"}) + assert result["errors"] == {"base": "bind_failed"} + + async def test_bind_raises(self, mock_hass, mock_api): + mock_api.bindSn.side_effect = OSError("api down") + flow, _ = _make_subentry_flow(mock_hass, mock_api) + flow._sysSn = "AL999" + result = await flow.async_step_verify({"verification_code": "1234"}) + assert result["errors"] == {"base": "bind_failed"} + + +def _make_reconfigure_flow(mock_hass, api, subentry_data=None): + flow, entry = _make_subentry_flow(mock_hass, api) + subentry = SimpleNamespace( + data=subentry_data + or { + CONF_SERIAL_NUMBER: "AL999", + CONF_IP_ADDRESS: "", + CONF_DISABLE_NOTIFICATIONS: True, + }, + subentry_id="sub1", + ) + flow._get_reconfigure_subentry = MagicMock(return_value=subentry) + flow.async_update_and_abort = MagicMock( + return_value={"type": "abort", "reason": "reconfigure_successful"} + ) + return flow, entry, subentry + + +class TestSubentryReconfigureStep: + async def test_form_shown(self, mock_hass, mock_api): + flow, _, _ = _make_reconfigure_flow(mock_hass, mock_api) + result = await flow.async_step_reconfigure(None) + assert result["type"] == "form" + assert result["step_id"] == "reconfigure" + + async def test_save_ip(self, mock_hass, mock_api): + flow, entry, subentry = _make_reconfigure_flow(mock_hass, mock_api) + result = await flow.async_step_reconfigure( + {CONF_IP_ADDRESS: " 192.168.1.7 ", CONF_DISABLE_NOTIFICATIONS: False} + ) + assert result["reason"] == "reconfigure_successful" + saved = flow.async_update_and_abort.call_args.kwargs["data"] + assert saved[CONF_IP_ADDRESS] == "192.168.1.7" + assert saved[CONF_DISABLE_NOTIFICATIONS] is False + + async def test_save_empty_ip(self, mock_hass, mock_api): + flow, _, _ = _make_reconfigure_flow(mock_hass, mock_api) + result = await flow.async_step_reconfigure({CONF_IP_ADDRESS: ""}) + assert result["reason"] == "reconfigure_successful" + + async def test_invalid_ip(self, mock_hass, mock_api): + flow, _, _ = _make_reconfigure_flow(mock_hass, mock_api) + result = await flow.async_step_reconfigure({CONF_IP_ADDRESS: "not-an-ip"}) + assert result["errors"] == {"base": "invalid_ip"} + + async def test_unbind_success(self, mock_hass, mock_api): + mock_api.unBindSn.return_value = {"ok": True} + flow, entry, subentry = _make_reconfigure_flow(mock_hass, mock_api) + result = await flow.async_step_reconfigure({"confirm_unbind": True}) + assert result["type"] == "abort" + assert result["reason"] == "unbind_successful" + mock_hass.config_entries.async_remove_subentry.assert_called_once_with( + entry, subentry.subentry_id + ) + mock_hass.config_entries.async_schedule_reload.assert_called_once_with( + entry.entry_id + ) + + async def test_unbind_returns_none(self, mock_hass, mock_api): + mock_api.unBindSn.return_value = None + flow, _, _ = _make_reconfigure_flow(mock_hass, mock_api) + result = await flow.async_step_reconfigure({"confirm_unbind": True}) + assert result["errors"] == {"base": "unbind_failed"} + + async def test_unbind_raises(self, mock_hass, mock_api): + mock_api.unBindSn.side_effect = OSError("api down") + flow, _, _ = _make_reconfigure_flow(mock_hass, mock_api) + result = await flow.async_step_reconfigure({"confirm_unbind": True}) + assert result["errors"] == {"base": "unbind_failed"} diff --git a/tests/test_coordinator_parsers.py b/tests/test_coordinator_parsers.py new file mode 100644 index 0000000..b976519 --- /dev/null +++ b/tests/test_coordinator_parsers.py @@ -0,0 +1,235 @@ +"""Tests for the data parsing helpers in the AlphaESS coordinator.""" + +import pytest + +from custom_components.alphaess.coordinator import ( + DataProcessor, + InverterDataParser, + TimeHelper, +) +from custom_components.alphaess.enums import AlphaESSNames + + +@pytest.fixture +def parser() -> InverterDataParser: + """Return a parser instance.""" + return InverterDataParser(DataProcessor()) + + +class TestDataProcessor: + def test_process_value_passthrough(self): + assert DataProcessor.process_value(5) == 5 + assert DataProcessor.process_value(0) == 0 + assert DataProcessor.process_value("x") == "x" + assert DataProcessor.process_value(False) is False + + def test_process_value_empty(self): + assert DataProcessor.process_value(None) is None + assert DataProcessor.process_value("") is None + assert DataProcessor.process_value(" ") is None + assert DataProcessor.process_value(None, default=7) == 7 + assert DataProcessor.process_value("", default="d") == "d" + + def test_safe_get(self): + assert DataProcessor.safe_get({"a": 1}, "a") == 1 + assert DataProcessor.safe_get({"a": ""}, "a", "d") == "d" + assert DataProcessor.safe_get(None, "a", "d") == "d" + assert DataProcessor.safe_get({}, "missing") is None + + def test_safe_calculate(self): + assert DataProcessor.safe_calculate(10.0, 4.0) == 6.0 + assert DataProcessor.safe_calculate(None, 4.0) is None + assert DataProcessor.safe_calculate(10.0, None) is None + + +class TestTimeHelper: + def test_get_rounded_time_format(self): + value = TimeHelper.get_rounded_time() + hours, minutes = value.split(":") + assert 0 <= int(hours) <= 23 + assert int(minutes) in (0, 15, 30, 45) + + def test_get_rounded_time_past_45_rolls_to_next_hour(self, freezer): + freezer.move_to("2026-06-11 10:50:00") + assert TimeHelper.get_rounded_time() == "11:00" + + def test_get_rounded_time_mid_hour(self, freezer): + freezer.move_to("2026-06-11 10:20:00") + assert TimeHelper.get_rounded_time() == "10:30" + + def test_calculate_time_window(self): + start, end = TimeHelper.calculate_time_window(60) + start_h, start_m = (int(x) for x in start.split(":")) + end_h, end_m = (int(x) for x in end.split(":")) + start_total = start_h * 60 + start_m + end_total = end_h * 60 + end_m + # End is start + 60 minutes, modulo a day + assert (end_total - start_total) % (24 * 60) == 60 + + +class TestParseEnergyData: + def test_full_payload(self, parser): + data = parser.parse_energy_data( + { + "epv": 10.0, + "eOutput": 2.0, + "eGridCharge": 1.0, + "eCharge": 5.0, + "eInput": 3.0, + "eDischarge": 4.0, + "eChargingPile": 1.5, + "theDate": "2026-06-11", + } + ) + assert data[AlphaESSNames.SolarProduction] == 10.0 + assert data[AlphaESSNames.SolarToLoad] == 8.0 # epv - eOutput + assert data[AlphaESSNames.SolarToBattery] == 4.0 # eCharge - eGridCharge + assert data[AlphaESSNames.GridToBattery] == 1.0 + assert data[AlphaESSNames.EVCharger] == 1.5 + assert data[AlphaESSNames.DailyEnergyDate] == "2026-06-11" + + def test_missing_values_produce_none(self, parser): + data = parser.parse_energy_data({}) + assert data[AlphaESSNames.SolarProduction] is None + assert data[AlphaESSNames.SolarToLoad] is None + assert data[AlphaESSNames.SolarToBattery] is None + + +class TestParseSummaryData: + def test_currency_from_api(self, parser): + data = parser.parse_summary_data({"moneyType": "GBP", "eload": 5}) + assert data[AlphaESSNames.CurrencyCode] == "GBP" + assert data["Currency"] == "GBP" + assert data[AlphaESSNames.TotalLoad] == 5 + + def test_currency_fallback(self, parser): + data = parser.parse_summary_data({}, fallback_currency="EUR") + assert data[AlphaESSNames.CurrencyCode] == "EUR" + + def test_currency_unknown(self, parser): + data = parser.parse_summary_data({}) + assert data[AlphaESSNames.CurrencyCode] == "Unknown" + + def test_self_consumption_scaling(self, parser): + data = parser.parse_summary_data( + {"eselfConsumption": 0.5, "eselfSufficiency": 0.25} + ) + assert data[AlphaESSNames.SelfConsumption] == 50 + assert data[AlphaESSNames.SelfSufficiency] == 25 + + def test_self_consumption_null(self, parser): + data = parser.parse_summary_data({}) + assert data[AlphaESSNames.SelfConsumption] is None + assert data[AlphaESSNames.SelfSufficiency] is None + + +class TestParseChargeConfig: + def test_time_slot_formatting(self, parser): + data = parser.parse_charge_config( + { + "gridCharge": 1, + "batHighCap": 95, + "timeChaf1": "01:00", + "timeChae1": "05:00", + } + ) + assert data["gridCharge"] == 1 + assert data[AlphaESSNames.batHighCap] == 95 + assert data[AlphaESSNames.ChargeTime1] == "01:00 - 05:00" + assert data[AlphaESSNames.ChargeTime2] == "00:00 - 00:00" + assert data["charge_timeChaf1"] == "01:00" + + def test_second_charge_slot(self, parser): + data = parser.parse_charge_config( + {"timeChaf2": "13:00", "timeChae2": "15:00"} + ) + assert data[AlphaESSNames.ChargeTime1] == "00:00 - 00:00" + assert data[AlphaESSNames.ChargeTime2] == "13:00 - 15:00" + + def test_discharge_config(self, parser): + data = parser.parse_discharge_config( + { + "ctrDis": 0, + "batUseCap": 15, + "timeDisf2": "20:00", + "timeDise2": "23:00", + } + ) + assert data["ctrDis"] == 0 + assert data[AlphaESSNames.batUseCap] == 15 + assert data[AlphaESSNames.DischargeTime1] == "00:00 - 00:00" + assert data[AlphaESSNames.DischargeTime2] == "20:00 - 23:00" + + def test_first_discharge_slot(self, parser): + data = parser.parse_discharge_config( + {"timeDisf1": "18:00", "timeDise1": "21:00"} + ) + assert data[AlphaESSNames.DischargeTime1] == "18:00 - 21:00" + assert data[AlphaESSNames.DischargeTime2] == "00:00 - 00:00" + + +class TestParsePowerData: + def test_basic_power(self, parser): + data = parser.parse_power_data( + { + "soc": 88, + "pbat": -500, + "pload": 1200, + "ppv": 3000, + "pgrid": -1800, + "ppvDetail": {"ppv1": 1500, "ppv2": 1500}, + "pgridDetail": {"pmeterL1": -600, "pmeterL2": -600, "pmeterL3": -600}, + }, + None, + ) + assert data[AlphaESSNames.BatterySOC] == 88 + assert data[AlphaESSNames.Load] == 1200 + assert data[AlphaESSNames.PPV1] == 1500 + assert data[AlphaESSNames.GridIOL3] == -600 + + def test_ev_connectors_only_when_present(self, parser): + data = parser.parse_power_data( + {"pevDetail": {"ev1Power": 7000}}, None + ) + assert data[AlphaESSNames.ElectricVehiclePowerOne] == 7000 + assert AlphaESSNames.ElectricVehiclePowerTwo not in data + + def test_soc_fallback_from_one_day_power(self, parser): + data = parser.parse_power_data( + {"soc": 0}, [{"cbat": 42.5}] + ) + assert data[AlphaESSNames.StateOfCharge] == 42.5 + + +class TestParseEvData: + def test_list_payload(self, parser): + data = parser.parse_ev_data( + [{"evchargerSn": "EV123", "evchargerModel": "SMILE-EVCT11"}], + {"EVStatus": {"evchargerStatus": 3}, "EVCurrent": {"currentsetting": 16}}, + ) + assert data[AlphaESSNames.evchargersn] == "EV123" + assert data[AlphaESSNames.evchargerstatus] == 3 + assert data[AlphaESSNames.evcurrentsetting] == 16 + + def test_empty(self, parser): + assert parser.parse_ev_data(None, {}) == {} + assert parser.parse_ev_data([], {}) == {} + + +class TestParseLocalIpData: + def test_empty(self, parser): + assert parser.parse_local_ip_data({}) == {} + + def test_payload(self, parser): + data = parser.parse_local_ip_data( + { + "ip": "192.168.1.50", + "status": {"devstatus": 1, "wifistatus": 5}, + "device_info": {"sn": "AL1234", "sw": "1.2.3"}, + } + ) + assert data[AlphaESSNames.localIP] == "192.168.1.50" + assert data[AlphaESSNames.deviceStatus] == 1 + assert data[AlphaESSNames.wifiStatus] == 5 + assert data[AlphaESSNames.deviceSerialNumber] == "AL1234" + assert data[AlphaESSNames.softwareVersion] == "1.2.3" diff --git a/tests/test_coordinator_polling.py b/tests/test_coordinator_polling.py new file mode 100644 index 0000000..59dfa51 --- /dev/null +++ b/tests/test_coordinator_polling.py @@ -0,0 +1,745 @@ +"""Tests for AlphaESSDataUpdateCoordinator polling, control and fallback logic.""" +import asyncio +from unittest.mock import MagicMock + +import aiohttp +import pytest +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.update_coordinator import UpdateFailed + +from custom_components.alphaess.const import ( + CONF_SERIAL_NUMBER, + SUBENTRY_TYPE_EV_CHARGER, + SUBENTRY_TYPE_INVERTER, +) +from custom_components.alphaess.enums import AlphaESSNames + +SERIAL = "AL1000021000123" +SERIAL2 = "AL2000021000456" + + +def _response_error(status): + return aiohttp.ClientResponseError( + request_info=MagicMock(), history=(), status=status, message="boom" + ) + + +def _make_entry_with_subentries(): + inverter_sub = MagicMock() + inverter_sub.subentry_type = SUBENTRY_TYPE_INVERTER + inverter_sub.data = {CONF_SERIAL_NUMBER: SERIAL} + ev_sub = MagicMock() + ev_sub.subentry_type = SUBENTRY_TYPE_EV_CHARGER + ev_sub.data = {CONF_SERIAL_NUMBER: "EV123"} + other_sub = MagicMock() + other_sub.subentry_type = "other" + other_sub.data = {} + entry = MagicMock() + entry.subentries = {"sub1": inverter_sub, "sub2": ev_sub, "sub3": other_sub} + return entry + + +class TestCoordinatorInit: + def test_throttle_disabled_for_modern_inverters(self, make_coordinator): + coordinator = make_coordinator(models=["SMILE5-INV", "SMILE-T10-HV-INV"]) + assert coordinator.has_throttle is False + assert coordinator.inverter_count == 2 + assert coordinator.LOCAL_INVERTER_COUNT == 2 + + def test_throttle_enabled_for_lower_inverters(self, make_coordinator): + coordinator = make_coordinator(models=["Storion-S5"]) + assert coordinator.has_throttle is True + assert coordinator.LOCAL_INVERTER_COUNT == 0 + + def test_no_models(self, make_coordinator): + coordinator = make_coordinator(models=[]) + assert coordinator.has_throttle is True + assert coordinator.inverter_count == 0 + + def test_subentry_maps(self, make_coordinator): + entry = _make_entry_with_subentries() + coordinator = make_coordinator(entry=entry) + assert coordinator.get_inverter_subentry_id(SERIAL) == "sub1" + assert coordinator.get_ev_charger_subentry_id("EV123") == "sub2" + assert coordinator.get_inverter_subentry_id("missing") is None + assert coordinator.get_ev_charger_subentry_id("missing") is None + + def test_default_intervals(self, mock_hass, mock_api): + from custom_components.alphaess.coordinator import AlphaESSDataUpdateCoordinator + + coordinator = AlphaESSDataUpdateCoordinator(mock_hass, client=mock_api) + assert coordinator.update_interval is not None + + def test_number_settings_roundtrip(self, make_coordinator): + coordinator = make_coordinator() + assert coordinator.get_number_setting(SERIAL, "batUseCap") is None + assert coordinator.get_number_setting(SERIAL, "batUseCap", 10) == 10 + coordinator.set_number_setting(SERIAL, "batUseCap", 25) + assert coordinator.get_number_setting(SERIAL, "batUseCap") == 25 + + +class TestEvControl: + def test_status_raw_variants(self, make_coordinator): + coordinator = make_coordinator() + assert coordinator.get_ev_charger_status_raw(SERIAL) is None + + coordinator.data = {SERIAL: {AlphaESSNames.evchargerstatusraw: "3"}} + assert coordinator.get_ev_charger_status_raw(SERIAL) == 3 + + coordinator.data = {SERIAL: {AlphaESSNames.evchargerstatus: 4}} + assert coordinator.get_ev_charger_status_raw(SERIAL) == 4 + + coordinator.data = {SERIAL: {AlphaESSNames.evchargerstatusraw: "junk"}} + assert coordinator.get_ev_charger_status_raw(SERIAL) is None + + @pytest.mark.parametrize( + ("status", "direction", "expected"), + [ + (2, 1, True), + (4, 1, True), + (5, 1, True), + (3, 1, False), + (3, 0, True), + (4, 0, True), + (5, 0, True), + (2, 0, False), + (3, 9, False), + ], + ) + def test_can_control_ev(self, make_coordinator, status, direction, expected): + coordinator = make_coordinator() + coordinator.data = {SERIAL: {AlphaESSNames.evchargerstatusraw: status}} + assert coordinator.can_control_ev(SERIAL, direction) is expected + + def test_can_control_ev_no_status(self, make_coordinator): + coordinator = make_coordinator() + assert coordinator.can_control_ev(SERIAL, 1) is False + + async def test_control_ev_blocked(self, make_coordinator, mock_api): + coordinator = make_coordinator() + coordinator.data = {SERIAL: {AlphaESSNames.evchargerstatusraw: 3}} + await coordinator.control_ev(SERIAL, "EV123", "1") + mock_api.remoteControlEvCharger.assert_not_awaited() + + async def test_control_ev_allowed(self, make_coordinator, mock_api): + coordinator = make_coordinator() + coordinator.data = {SERIAL: {AlphaESSNames.evchargerstatusraw: 2}} + await coordinator.control_ev(SERIAL, "EV123", "1") + mock_api.remoteControlEvCharger.assert_awaited_once_with(SERIAL, "EV123", "1") + + async def test_set_ev_charger_current(self, make_coordinator, mock_api): + coordinator = make_coordinator() + await coordinator.set_ev_charger_current(SERIAL, 16) + mock_api.setEvChargerCurrentsBySn.assert_awaited_once_with(SERIAL, 16) + coordinator.async_request_refresh.assert_awaited_once() + + +class TestChargeDischargeCommands: + async def test_reset_config(self, make_coordinator, mock_api): + coordinator = make_coordinator() + coordinator.set_number_setting(SERIAL, "batUseCap", 20) + coordinator.set_number_setting(SERIAL, "batHighCap", 80) + coordinator.data = {SERIAL: {}} + + await coordinator.reset_config(SERIAL) + + mock_api.updateChargeConfigInfo.assert_awaited_once_with( + SERIAL, 80, 1, "00:00", "00:00", "00:00", "00:00" + ) + mock_api.updateDisChargeConfigInfo.assert_awaited_once_with( + SERIAL, 20, 1, "00:00", "00:00", "00:00", "00:00" + ) + assert coordinator.data[SERIAL]["gridCharge"] == 1 + assert coordinator.data[SERIAL]["ctrDis"] == 1 + coordinator.async_set_updated_data.assert_called_once() + + async def test_reset_config_serial_not_in_data(self, make_coordinator): + coordinator = make_coordinator() + coordinator.data = {} + await coordinator.reset_config(SERIAL) + coordinator.async_set_updated_data.assert_not_called() + + async def test_update_discharge(self, make_coordinator, mock_api): + coordinator = make_coordinator() + coordinator.set_number_setting(SERIAL, "batUseCap", 15) + coordinator.data = {SERIAL: {}} + + await coordinator.update_discharge("batUseCap", SERIAL, 30) + + args = mock_api.updateDisChargeConfigInfo.await_args.args + assert args[0] == SERIAL + assert args[1] == 15 + assert args[2] == 1 + assert coordinator.data[SERIAL]["ctrDis"] == 1 + coordinator.async_set_updated_data.assert_called_once() + + async def test_update_discharge_serial_missing(self, make_coordinator): + coordinator = make_coordinator() + coordinator.data = {} + await coordinator.update_discharge("batUseCap", SERIAL, 30) + coordinator.async_set_updated_data.assert_not_called() + + async def test_update_charge(self, make_coordinator, mock_api): + coordinator = make_coordinator() + coordinator.set_number_setting(SERIAL, "batHighCap", 85) + coordinator.data = {SERIAL: {}} + + await coordinator.update_charge("batHighCap", SERIAL, 60) + + args = mock_api.updateChargeConfigInfo.await_args.args + assert args[1] == 85 + assert coordinator.data[SERIAL]["gridCharge"] == 1 + coordinator.async_set_updated_data.assert_called_once() + + async def test_update_charge_serial_missing(self, make_coordinator): + coordinator = make_coordinator() + coordinator.data = {} + await coordinator.update_charge("batHighCap", SERIAL, 60) + coordinator.async_set_updated_data.assert_not_called() + + +def _configure_success_api(mock_api, serial=SERIAL, model="SMILE5-INV", with_ev=False): + """Configure mock API for one successful inverter fetch.""" + mock_api.getESSList.return_value = [{"sysSn": serial, "minv": model}] + mock_api.getSumDataForCustomer.return_value = {"eload": 5, "moneyType": "USD"} + mock_api.getOneDateEnergyBySn.return_value = {"epv": 10.0, "eOutput": 1.0} + mock_api.getLastPowerData.return_value = {"soc": 50, "pload": 100} + mock_api.getChargeConfigInfo.return_value = {"gridCharge": 1, "batHighCap": 90} + mock_api.getDisChargeConfigInfo.return_value = {"ctrDis": 1, "batUseCap": 10} + mock_api.getOneDayPowerBySn.return_value = [{"cbat": 55}] + if with_ev: + mock_api.getEvChargerConfigList.return_value = [ + {"evchargerSn": "EV123", "evchargerModel": "SMILE-EVCT11"} + ] + mock_api.getEvChargerStatusBySn.return_value = {"evchargerStatus": 2} + mock_api.getEvChargerCurrentsBySn.return_value = {"currentsetting": 16} + else: + mock_api.getEvChargerConfigList.return_value = None + + +class TestNormalPolling: + async def test_successful_poll(self, make_coordinator, mock_api): + coordinator = make_coordinator() + _configure_success_api(mock_api, with_ev=True) + + data = await coordinator._async_update_data() + + assert SERIAL in data + assert data[SERIAL]["Model"] == "SMILE5-INV" + assert data[SERIAL][AlphaESSNames.TotalLoad] == 5 + assert data[SERIAL][AlphaESSNames.evchargersn] == "EV123" + assert data[SERIAL][AlphaESSNames.PollMode] == "normal" + assert data[SERIAL][AlphaESSNames.LastPollType] == "normal" + assert coordinator.cloud_available is True + # Fresh copy each cycle + assert data[SERIAL] is not coordinator.data[SERIAL] + + async def test_poll_with_none_data_initializes(self, make_coordinator, mock_api): + coordinator = make_coordinator() + coordinator.data = None + _configure_success_api(mock_api) + data = await coordinator._async_update_data() + assert SERIAL in data + + async def test_empty_units(self, make_coordinator, mock_api): + coordinator = make_coordinator() + mock_api.getESSList.return_value = [] + assert await coordinator._async_update_data() == {} + + async def test_unit_without_serial_skipped(self, make_coordinator, mock_api): + coordinator = make_coordinator() + mock_api.getESSList.return_value = [{"minv": "SMILE5-INV"}] + data = await coordinator._async_update_data() + assert data == {} + assert coordinator.cloud_available is False + + async def test_backoff_skips_inverter(self, make_coordinator, mock_api): + coordinator = make_coordinator() + _configure_success_api(mock_api) + coordinator._inverter_error_count[SERIAL] = 3 + coordinator._poll_tick_count = 0 # next tick = 1, 1 % 5 != 0 -> skip + + data = await coordinator._async_update_data() + assert SERIAL not in data + mock_api.getSumDataForCustomer.assert_not_awaited() + + async def test_backoff_retry_cycle(self, make_coordinator, mock_api): + coordinator = make_coordinator() + _configure_success_api(mock_api) + coordinator._inverter_error_count[SERIAL] = 3 + coordinator._poll_tick_count = 4 # next tick = 5, 5 % 5 == 0 -> retry + + data = await coordinator._async_update_data() + assert SERIAL in data + assert coordinator._inverter_error_count[SERIAL] == 0 + + async def test_per_inverter_error_counted(self, make_coordinator, mock_api): + coordinator = make_coordinator() + _configure_success_api(mock_api) + mock_api.getSumDataForCustomer.side_effect = ValueError("bad data") + + data = await coordinator._async_update_data() + assert coordinator._inverter_error_count[SERIAL] == 1 + assert coordinator.cloud_available is False + assert data == {} + + async def test_per_inverter_non_401_response_error(self, make_coordinator, mock_api): + coordinator = make_coordinator() + _configure_success_api(mock_api) + mock_api.getSumDataForCustomer.side_effect = _response_error(500) + + await coordinator._async_update_data() + assert coordinator._inverter_error_count[SERIAL] == 1 + + async def test_401_raises_auth_failed(self, make_coordinator, mock_api): + coordinator = make_coordinator() + _configure_success_api(mock_api) + mock_api.getSumDataForCustomer.side_effect = _response_error(401) + + with pytest.raises(ConfigEntryAuthFailed): + await coordinator._async_update_data() + + async def test_cancelled_error_propagates(self, make_coordinator, mock_api): + coordinator = make_coordinator() + _configure_success_api(mock_api) + mock_api.getSumDataForCustomer.side_effect = asyncio.CancelledError + + with pytest.raises(asyncio.CancelledError): + await coordinator._async_update_data() + + async def test_cloud_error_no_local_ip_raises_update_failed( + self, make_coordinator, mock_api + ): + coordinator = make_coordinator() + mock_api.getESSList.side_effect = aiohttp.ClientConnectorError( + MagicMock(), OSError("no route") + ) + with pytest.raises(UpdateFailed): + await coordinator._async_update_data() + assert coordinator.cloud_available is False + + async def test_unexpected_error_no_local_ip_raises_update_failed( + self, make_coordinator, mock_api + ): + coordinator = make_coordinator() + mock_api.getESSList.side_effect = RuntimeError("boom") + with pytest.raises(UpdateFailed): + await coordinator._async_update_data() + + async def test_cloud_error_falls_back_to_local(self, make_coordinator, mock_api): + coordinator = make_coordinator(ip_map={SERIAL: "192.168.1.5"}) + coordinator.data = {SERIAL: {"Model": "SMILE5-INV"}} + mock_api.getESSList.side_effect = TypeError("NoneType") + mock_api.getIPData.return_value = { + "status": {"devstatus": 1}, + "device_info": {"sn": "AL1234"}, + } + + data = await coordinator._async_update_data() + assert data[SERIAL][AlphaESSNames.localIP] == "192.168.1.5" + assert data[SERIAL]["Model"] == "SMILE5-INV" + + async def test_local_ip_fetch_during_normal_poll(self, make_coordinator, mock_api): + coordinator = make_coordinator(ip_map={SERIAL: "192.168.1.5"}) + _configure_success_api(mock_api) + mock_api.getIPData.return_value = {"status": {"devstatus": 1}} + + data = await coordinator._async_update_data() + assert data[SERIAL][AlphaESSNames.localIP] == "192.168.1.5" + assert mock_api.ipaddress is None + + +class TestFetchInverterData: + async def test_storion_skips_last_power(self, make_coordinator, mock_api): + coordinator = make_coordinator() + _configure_success_api(mock_api, model="Storion-S5") + + await coordinator._async_update_data() + mock_api.getLastPowerData.assert_not_awaited() + + async def test_ev_fetch_dict_payload(self, make_coordinator, mock_api): + coordinator = make_coordinator() + _configure_success_api(mock_api) + mock_api.getEvChargerConfigList.return_value = {"evchargerSn": "EV9"} + mock_api.getEvChargerStatusBySn.return_value = {"evchargerStatus": 1} + mock_api.getEvChargerCurrentsBySn.return_value = {"currentsetting": 8} + + data = await coordinator._async_update_data() + assert data[SERIAL][AlphaESSNames.evchargersn] == "EV9" + + async def test_ev_fetch_without_serial(self, make_coordinator, mock_api): + coordinator = make_coordinator() + _configure_success_api(mock_api) + mock_api.getEvChargerConfigList.return_value = [{"evchargerModel": "X"}] + + await coordinator._async_update_data() + mock_api.getEvChargerStatusBySn.assert_not_awaited() + + async def test_ev_fetch_error_is_swallowed(self, make_coordinator, mock_api): + coordinator = make_coordinator() + _configure_success_api(mock_api) + mock_api.getEvChargerConfigList.side_effect = ValueError("ev api down") + + data = await coordinator._async_update_data() + assert SERIAL in data # main fetch still succeeded + + async def test_ev_fetch_cancelled_propagates(self, make_coordinator, mock_api): + coordinator = make_coordinator() + _configure_success_api(mock_api) + mock_api.getEvChargerConfigList.side_effect = asyncio.CancelledError + + with pytest.raises(asyncio.CancelledError): + await coordinator._async_update_data() + + async def test_include_local_ip_from_client(self, make_coordinator, mock_api): + coordinator = make_coordinator() + _configure_success_api(mock_api) + mock_api.ipaddress = "10.0.0.2" + mock_api.getIPData.return_value = {"status": {"devstatus": 1}} + + data = await coordinator._async_update_data() + assert data[SERIAL][AlphaESSNames.localIP] == "10.0.0.2" + + async def test_include_local_ip_empty_payload(self, make_coordinator, mock_api): + coordinator = make_coordinator() + _configure_success_api(mock_api) + mock_api.ipaddress = "10.0.0.2" + mock_api.getIPData.return_value = None + + data = await coordinator._async_update_data() + assert AlphaESSNames.localIP not in data[SERIAL] + + async def test_include_local_ip_error_swallowed(self, make_coordinator, mock_api): + coordinator = make_coordinator() + _configure_success_api(mock_api) + mock_api.ipaddress = "10.0.0.2" + mock_api.getIPData.side_effect = OSError("unreachable") + + data = await coordinator._async_update_data() + assert SERIAL in data + + async def test_include_local_ip_cancelled_propagates(self, make_coordinator, mock_api): + coordinator = make_coordinator() + _configure_success_api(mock_api) + mock_api.ipaddress = "10.0.0.2" + mock_api.getIPData.side_effect = asyncio.CancelledError + + with pytest.raises(asyncio.CancelledError): + await coordinator._async_update_data() + + +class TestAltPolling: + async def test_full_poll(self, make_coordinator, mock_api): + coordinator = make_coordinator(alt=True) + _configure_success_api(mock_api) + + data = await coordinator._async_update_data() + assert data[SERIAL][AlphaESSNames.PollMode] == "alt" + assert data[SERIAL][AlphaESSNames.LastPollType] == "full" + assert coordinator._last_full_poll is not None + + async def test_full_poll_empty_units(self, make_coordinator, mock_api): + coordinator = make_coordinator(alt=True) + mock_api.getESSList.return_value = [] + assert await coordinator._async_update_data() == {} + + async def test_full_poll_unit_without_serial(self, make_coordinator, mock_api): + coordinator = make_coordinator(alt=True) + mock_api.getESSList.return_value = [{"minv": "SMILE5-INV"}] + data = await coordinator._async_update_data() + assert data == {} + assert coordinator.cloud_available is False + + async def test_full_poll_error_counted(self, make_coordinator, mock_api): + coordinator = make_coordinator(alt=True) + _configure_success_api(mock_api) + mock_api.getSumDataForCustomer.side_effect = ValueError("bad") + + await coordinator._async_update_data() + assert coordinator._inverter_error_count[SERIAL] == 1 + assert coordinator.cloud_available is False + + async def test_full_poll_non_401_response_error(self, make_coordinator, mock_api): + coordinator = make_coordinator(alt=True) + _configure_success_api(mock_api) + mock_api.getSumDataForCustomer.side_effect = _response_error(503) + + await coordinator._async_update_data() + assert coordinator._inverter_error_count[SERIAL] == 1 + + async def test_full_poll_401_raises(self, make_coordinator, mock_api): + coordinator = make_coordinator(alt=True) + _configure_success_api(mock_api) + mock_api.getSumDataForCustomer.side_effect = _response_error(401) + + with pytest.raises(ConfigEntryAuthFailed): + await coordinator._async_update_data() + + async def test_full_poll_cancelled_propagates(self, make_coordinator, mock_api): + coordinator = make_coordinator(alt=True) + _configure_success_api(mock_api) + mock_api.getSumDataForCustomer.side_effect = asyncio.CancelledError + + with pytest.raises(asyncio.CancelledError): + await coordinator._async_update_data() + + def _prime_fast_mode(self, coordinator, model="SMILE5-INV", ev_sn=None): + """Set state so the next tick is a fast poll.""" + import time as time_mod + + coordinator._last_full_poll = time_mod.monotonic() + serial_data = {"Model": model} + if ev_sn: + serial_data[AlphaESSNames.evchargersn] = ev_sn + coordinator.data = {SERIAL: serial_data} + + async def test_fast_poll_power_energy_and_ev(self, make_coordinator, mock_api): + coordinator = make_coordinator(alt=True) + self._prime_fast_mode(coordinator, ev_sn="EV123") + mock_api.getLastPowerData.return_value = {"soc": 60} + mock_api.getOneDateEnergyBySn.return_value = {"epv": 12.0} + mock_api.getEvChargerStatusBySn.return_value = {"evchargerStatus": 3} + + data = await coordinator._async_update_data() + assert data[SERIAL][AlphaESSNames.LastPollType] == "fast" + assert data[SERIAL][AlphaESSNames.BatterySOC] == 60 + assert data[SERIAL][AlphaESSNames.SolarProduction] == 12.0 + assert data[SERIAL][AlphaESSNames.evchargerstatus] == 3 + + async def test_fast_poll_skips_power_for_storion(self, make_coordinator, mock_api): + coordinator = make_coordinator(alt=True) + self._prime_fast_mode(coordinator, model="Storion-S5") + mock_api.getOneDateEnergyBySn.return_value = {"epv": 1.0} + + await coordinator._async_update_data() + mock_api.getLastPowerData.assert_not_awaited() + + async def test_fast_poll_empty_payloads(self, make_coordinator, mock_api): + coordinator = make_coordinator(alt=True) + self._prime_fast_mode(coordinator) + mock_api.getLastPowerData.return_value = None + mock_api.getOneDateEnergyBySn.return_value = None + + data = await coordinator._async_update_data() + assert AlphaESSNames.BatterySOC not in data[SERIAL] + + async def test_fast_poll_ev_status_empty(self, make_coordinator, mock_api): + coordinator = make_coordinator(alt=True) + self._prime_fast_mode(coordinator, ev_sn="EV123") + mock_api.getLastPowerData.return_value = None + mock_api.getOneDateEnergyBySn.return_value = None + mock_api.getEvChargerStatusBySn.return_value = None + + data = await coordinator._async_update_data() + assert AlphaESSNames.evchargerstatus not in data[SERIAL] + + async def test_fast_poll_no_serials(self, make_coordinator, mock_api): + coordinator = make_coordinator(alt=True) + import time as time_mod + + coordinator._last_full_poll = time_mod.monotonic() + coordinator.data = {} + + assert await coordinator._async_update_data() == {} + + async def test_fast_poll_backoff(self, make_coordinator, mock_api): + coordinator = make_coordinator(alt=True) + self._prime_fast_mode(coordinator) + coordinator._inverter_error_count[SERIAL] = 3 + coordinator._poll_tick_count = 0 # tick 1 -> skip + + data = await coordinator._async_update_data() + assert SERIAL in data + mock_api.getOneDateEnergyBySn.assert_not_awaited() + + async def test_fast_poll_backoff_retry(self, make_coordinator, mock_api): + coordinator = make_coordinator(alt=True) + self._prime_fast_mode(coordinator) + coordinator._inverter_error_count[SERIAL] = 3 + coordinator._poll_tick_count = 4 # tick 5 -> retry + mock_api.getLastPowerData.return_value = {"soc": 1} + mock_api.getOneDateEnergyBySn.return_value = {"epv": 2.0} + + await coordinator._async_update_data() + assert coordinator._inverter_error_count[SERIAL] == 0 + + async def test_fast_poll_error_counted(self, make_coordinator, mock_api): + coordinator = make_coordinator(alt=True) + self._prime_fast_mode(coordinator) + mock_api.getLastPowerData.side_effect = ValueError("bad") + + await coordinator._async_update_data() + assert coordinator._inverter_error_count[SERIAL] == 1 + + async def test_fast_poll_non_401_response_error(self, make_coordinator, mock_api): + coordinator = make_coordinator(alt=True) + self._prime_fast_mode(coordinator) + mock_api.getLastPowerData.side_effect = _response_error(429) + + await coordinator._async_update_data() + assert coordinator._inverter_error_count[SERIAL] == 1 + + async def test_fast_poll_401_raises(self, make_coordinator, mock_api): + coordinator = make_coordinator(alt=True) + self._prime_fast_mode(coordinator) + mock_api.getLastPowerData.side_effect = _response_error(401) + + with pytest.raises(ConfigEntryAuthFailed): + await coordinator._async_update_data() + + async def test_fast_poll_cancelled_propagates(self, make_coordinator, mock_api): + coordinator = make_coordinator(alt=True) + self._prime_fast_mode(coordinator) + mock_api.getLastPowerData.side_effect = asyncio.CancelledError + + with pytest.raises(asyncio.CancelledError): + await coordinator._async_update_data() + + async def test_round_robin_staggers_inverters(self, make_coordinator, mock_api): + coordinator = make_coordinator(alt=True) + import time as time_mod + + coordinator._last_full_poll = time_mod.monotonic() + coordinator.data = { + SERIAL: {"Model": "SMILE5-INV"}, + SERIAL2: {"Model": "SMILE5-INV"}, + } + mock_api.getLastPowerData.return_value = {"soc": 1} + mock_api.getOneDateEnergyBySn.return_value = {"epv": 1.0} + + await coordinator._async_update_data() + first = mock_api.getLastPowerData.await_args.args[0] + await coordinator._async_update_data() + second = mock_api.getLastPowerData.await_args.args[0] + assert {first, second} == {SERIAL, SERIAL2} + + async def test_alt_cloud_error_falls_back(self, make_coordinator, mock_api): + coordinator = make_coordinator(alt=True, ip_map={SERIAL: "192.168.1.5"}) + coordinator.data = {SERIAL: {"Model": "SMILE5-INV"}} + mock_api.getESSList.side_effect = aiohttp.ClientConnectorError( + MagicMock(), OSError("no route") + ) + mock_api.getIPData.return_value = {"status": {"devstatus": 1}} + + data = await coordinator._async_update_data() + assert data[SERIAL][AlphaESSNames.localIP] == "192.168.1.5" + + async def test_alt_unexpected_error_raises_when_no_local( + self, make_coordinator, mock_api + ): + coordinator = make_coordinator(alt=True) + mock_api.getESSList.side_effect = RuntimeError("boom") + with pytest.raises(UpdateFailed): + await coordinator._async_update_data() + + +class TestLocalDataFetch: + async def test_skip_serial_not_in_data(self, make_coordinator, mock_api): + coordinator = make_coordinator(ip_map={SERIAL: "192.168.1.5"}) + coordinator.data = {} + await coordinator._fetch_per_inverter_local_data() + mock_api.getIPData.assert_not_awaited() + + async def test_skip_no_ip(self, make_coordinator, mock_api): + coordinator = make_coordinator(ip_map={SERIAL: None}) + coordinator.data = {SERIAL: {}} + await coordinator._fetch_per_inverter_local_data() + mock_api.getIPData.assert_not_awaited() + + async def test_skip_if_cloud_provided_local_ip(self, make_coordinator, mock_api): + coordinator = make_coordinator(ip_map={SERIAL: "192.168.1.5"}) + coordinator.data = {SERIAL: {AlphaESSNames.localIP: "10.0.0.9"}} + await coordinator._fetch_per_inverter_local_data() + mock_api.getIPData.assert_not_awaited() + + async def test_fetch_success(self, make_coordinator, mock_api): + coordinator = make_coordinator(ip_map={SERIAL: "192.168.1.5"}) + coordinator.data = {SERIAL: {}} + mock_api.getIPData.return_value = {"status": {"devstatus": 1}} + + await coordinator._fetch_per_inverter_local_data() + assert coordinator.data[SERIAL][AlphaESSNames.localIP] == "192.168.1.5" + assert mock_api.ipaddress is None + + async def test_fetch_empty_payload(self, make_coordinator, mock_api): + coordinator = make_coordinator(ip_map={SERIAL: "192.168.1.5"}) + coordinator.data = {SERIAL: {}} + mock_api.getIPData.return_value = None + + await coordinator._fetch_per_inverter_local_data() + assert AlphaESSNames.localIP not in coordinator.data[SERIAL] + + async def test_fetch_error_swallowed(self, make_coordinator, mock_api): + coordinator = make_coordinator(ip_map={SERIAL: "192.168.1.5"}) + coordinator.data = {SERIAL: {}} + mock_api.getIPData.side_effect = OSError("down") + + await coordinator._fetch_per_inverter_local_data() + assert mock_api.ipaddress is None + + +class TestFallbackToLocal: + async def test_no_ips_raises(self, make_coordinator): + coordinator = make_coordinator(ip_map={SERIAL: None}) + with pytest.raises(UpdateFailed): + await coordinator._fallback_to_local_data(RuntimeError("cloud down")) + + async def test_inverter_without_ip_keeps_model_only(self, make_coordinator, mock_api): + coordinator = make_coordinator( + ip_map={SERIAL: None, SERIAL2: "192.168.1.6"} + ) + coordinator.data = { + SERIAL: {"Model": "SMILE5-INV", AlphaESSNames.TotalLoad: 5}, + SERIAL2: {"Model": "SMILE5-INV"}, + } + mock_api.getIPData.return_value = {"status": {"devstatus": 1}} + + data = await coordinator._fallback_to_local_data() + assert data[SERIAL]["Model"] == "SMILE5-INV" + assert AlphaESSNames.TotalLoad not in data[SERIAL] + assert data[SERIAL2][AlphaESSNames.localIP] == "192.168.1.6" + + async def test_inverter_without_ip_not_in_data(self, make_coordinator, mock_api): + coordinator = make_coordinator( + ip_map={SERIAL: None, SERIAL2: "192.168.1.6"} + ) + coordinator.data = {SERIAL2: {"Model": "X"}} + mock_api.getIPData.return_value = {"status": {"devstatus": 1}} + + data = await coordinator._fallback_to_local_data() + assert SERIAL not in data + + async def test_empty_local_payload_keeps_model(self, make_coordinator, mock_api): + coordinator = make_coordinator(ip_map={SERIAL: "192.168.1.5"}) + coordinator.data = {SERIAL: {"Model": "SMILE5-INV"}} + mock_api.getIPData.return_value = None + + with pytest.raises(UpdateFailed): + await coordinator._fallback_to_local_data() + assert coordinator.data[SERIAL] == {"Model": "SMILE5-INV"} + + async def test_local_error_keeps_model_and_raises(self, make_coordinator, mock_api): + coordinator = make_coordinator(ip_map={SERIAL: "192.168.1.5"}) + coordinator.data = {SERIAL: {"Model": "SMILE5-INV"}} + mock_api.getIPData.side_effect = OSError("down") + + with pytest.raises(UpdateFailed): + await coordinator._fallback_to_local_data(RuntimeError("original")) + assert coordinator.data[SERIAL] == {"Model": "SMILE5-INV"} + assert mock_api.ipaddress is None + + +class TestThrottle: + async def test_throttle_delay_used(self, make_coordinator, mock_api, monkeypatch): + coordinator = make_coordinator(models=["SMILE5-INV"]) + coordinator.throttle_multiplier = 1.25 # restore real value + _configure_success_api(mock_api) + + sleeps = [] + + async def fake_sleep(delay): + sleeps.append(delay) + + monkeypatch.setattr(asyncio, "sleep", fake_sleep) + await coordinator._async_update_data() + assert 1.25 in sleeps diff --git a/tests/test_device_diagnostics.py b/tests/test_device_diagnostics.py new file mode 100644 index 0000000..23d2166 --- /dev/null +++ b/tests/test_device_diagnostics.py @@ -0,0 +1,99 @@ +"""Tests for device info builders and diagnostics.""" +from custom_components.alphaess.device import ( + build_ev_charger_device_info, + build_inverter_device_info, +) +from custom_components.alphaess.diagnostics import async_get_config_entry_diagnostics +from custom_components.alphaess.enums import AlphaESSNames + +from .conftest import FakeEntry + +SERIAL = "al1000021000123" + + +class TestInverterDeviceInfo: + def test_basic(self): + info = build_inverter_device_info(SERIAL, {"Model": "SMILE5-INV"}) + assert info["identifiers"] == {("alphaess", SERIAL.upper())} + assert info["model"] == "SMILE5-INV" + assert "configuration_url" not in info + + def test_with_local_ip(self): + data = { + "Model": "SMILE5-INV", + AlphaESSNames.localIP: "192.168.1.5", + AlphaESSNames.deviceStatus: 1, + AlphaESSNames.deviceSerialNumber: "AL1234", + AlphaESSNames.softwareVersion: "1.2.3", + AlphaESSNames.hardwareVersion: "A1", + } + info = build_inverter_device_info(SERIAL, data) + assert info["configuration_url"] == "http://192.168.1.5" + # DeviceInfo carries the system serial; the comms-dongle SN stays + # available as its own diagnostic sensor. + assert info["serial_number"] == SERIAL + assert info["sw_version"] == "1.2.3" + assert info["hw_version"] == "A1" + + def test_local_ip_zero_ignored(self): + data = { + "Model": "SMILE5-INV", + AlphaESSNames.localIP: "0", + AlphaESSNames.deviceStatus: 1, + } + info = build_inverter_device_info(SERIAL, data) + assert "configuration_url" not in info + + def test_local_ip_without_status_ignored(self): + data = {"Model": "SMILE5-INV", AlphaESSNames.localIP: "192.168.1.5"} + info = build_inverter_device_info(SERIAL, data) + assert "configuration_url" not in info + + +class TestEvChargerDeviceInfo: + def test_basic(self): + data = { + AlphaESSNames.evchargersn: "EV123", + AlphaESSNames.evchargermodel: "SMILE-EVCT11", + } + info = build_ev_charger_device_info(data) + assert info["identifiers"] == {("alphaess", "EV123")} + assert info["model"] == "SMILE-EVCT11" + + +class TestDiagnostics: + async def test_diagnostics_redacts_sensitive_data(self, mock_hass, make_coordinator): + coordinator = make_coordinator(models=["SMILE5-INV"]) + coordinator.data = { + "AL_SERIAL": { + "Model": "SMILE5-INV", + AlphaESSNames.TotalLoad: 5.0, + AlphaESSNames.registerKey: "secret-key", + AlphaESSNames.password: "wifi-pass", + } + } + entry = FakeEntry( + data={"AppID": "app-id", "AppSecret": "app-secret"}, + options={"scan_interval_seconds": 60}, + ) + entry.runtime_data = coordinator + + result = await async_get_config_entry_diagnostics(mock_hass, entry) + + assert result["entry"]["data"]["AppID"] == "**REDACTED**" + assert result["entry"]["data"]["AppSecret"] == "**REDACTED**" + inverter = result["data"]["inverter_1"] + assert inverter[AlphaESSNames.registerKey] == "**REDACTED**" + assert inverter[AlphaESSNames.password] == "**REDACTED**" + assert inverter[AlphaESSNames.TotalLoad] == 5.0 + assert "AL_SERIAL" not in result["data"] + assert result["coordinator"]["inverter_count"] == 1 + + async def test_diagnostics_with_empty_data(self, mock_hass, make_coordinator): + coordinator = make_coordinator() + coordinator.data = {} + entry = FakeEntry() + entry.runtime_data = coordinator + + result = await async_get_config_entry_diagnostics(mock_hass, entry) + assert result["data"] == {} diff --git a/tests/test_helpers.py b/tests/test_helpers.py new file mode 100644 index 0000000..42b0b61 --- /dev/null +++ b/tests/test_helpers.py @@ -0,0 +1,82 @@ +"""Tests for small helpers in sensor.py and __init__.py.""" +from types import SimpleNamespace + +from custom_components.alphaess import _build_inverter_model_list, _build_ip_address_map +from custom_components.alphaess.const import ( + CONF_INVERTER_MODEL, + CONF_IP_ADDRESS, + CONF_SERIAL_NUMBER, + SUBENTRY_TYPE_EV_CHARGER, + SUBENTRY_TYPE_INVERTER, +) +from custom_components.alphaess.sensor import _normalize_currency_unit + + +def _entry_with_subentries(subentries): + return SimpleNamespace(subentries={str(i): s for i, s in enumerate(subentries)}) + + +def _inverter_subentry(serial, ip="", model=""): + return SimpleNamespace( + subentry_type=SUBENTRY_TYPE_INVERTER, + data={ + CONF_SERIAL_NUMBER: serial, + CONF_IP_ADDRESS: ip, + CONF_INVERTER_MODEL: model, + }, + ) + + +class TestNormalizeCurrencyUnit: + def test_none_falls_back(self): + assert _normalize_currency_unit(None, "EUR") == "EUR" + assert _normalize_currency_unit("", "EUR") == "EUR" + assert _normalize_currency_unit(" ", "EUR") == "EUR" + + def test_iso_code_passthrough(self): + assert _normalize_currency_unit("gbp", "EUR") == "GBP" + assert _normalize_currency_unit("USD", "EUR") == "USD" + + def test_symbol_mapping(self): + assert _normalize_currency_unit("€", "USD") == "EUR" + assert _normalize_currency_unit("£", "USD") == "GBP" + assert _normalize_currency_unit("$", "EUR") == "USD" + + def test_unknown_symbol_falls_back(self): + assert _normalize_currency_unit("☃", "AUD") == "AUD" + + +class TestBuildIpAddressMap: + def test_valid_ip(self): + entry = _entry_with_subentries([_inverter_subentry("AL1", ip="192.168.1.10")]) + assert _build_ip_address_map(entry) == {"AL1": "192.168.1.10"} + + def test_invalid_ip_maps_to_none(self): + entry = _entry_with_subentries([_inverter_subentry("AL1", ip="not-an-ip")]) + assert _build_ip_address_map(entry) == {"AL1": None} + + def test_empty_and_zero_ip(self): + entry = _entry_with_subentries( + [_inverter_subentry("AL1", ip=""), _inverter_subentry("AL2", ip="0")] + ) + assert _build_ip_address_map(entry) == {"AL1": None, "AL2": None} + + def test_non_inverter_subentries_skipped(self): + ev = SimpleNamespace( + subentry_type=SUBENTRY_TYPE_EV_CHARGER, + data={CONF_SERIAL_NUMBER: "EV1"}, + ) + entry = _entry_with_subentries([ev]) + assert _build_ip_address_map(entry) == {} + + +class TestBuildInverterModelList: + def test_models_collected(self): + entry = _entry_with_subentries( + [ + _inverter_subentry("AL1", model="SMILE5-INV"), + _inverter_subentry("AL2", model="Storion-S5"), + _inverter_subentry("AL3", model=""), + ] + ) + assert _build_inverter_model_list(entry) == ["SMILE5-INV", "Storion-S5"] diff --git a/tests/test_init.py b/tests/test_init.py new file mode 100644 index 0000000..47ca5a5 --- /dev/null +++ b/tests/test_init.py @@ -0,0 +1,534 @@ +"""Tests for __init__.py setup, services, migration, unload and registry helpers.""" +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock + +import aiohttp +import pytest +from homeassistant.config_entries import ConfigEntryState, ConfigSubentry +from homeassistant.exceptions import ( + ConfigEntryAuthFailed, + ConfigEntryNotReady, + HomeAssistantError, +) + +import custom_components.alphaess as init_mod +from custom_components.alphaess import ( + _cleanup_stale_ev_entities, + _has_inverter_subentries, + _migrate_entity_ids, + _resolve_client_for_serial, + async_migrate_entry, + async_setup_entry, + async_unload_entry, +) +from custom_components.alphaess.const import ( + CONF_DISABLE_NOTIFICATIONS, + CONF_INVERTER_MODEL, + CONF_IP_ADDRESS, + CONF_SERIAL_NUMBER, + SUBENTRY_TYPE_EV_CHARGER, + SUBENTRY_TYPE_INVERTER, +) +from custom_components.alphaess.enums import AlphaESSNames + +from .conftest import FakeEntry + +SERIAL = "AL1000021000123" + + +def _inverter_subentry(serial=SERIAL, model="SMILE5-INV", ip=""): + return ConfigSubentry( + data={ + CONF_SERIAL_NUMBER: serial, + CONF_INVERTER_MODEL: model, + CONF_IP_ADDRESS: ip, + CONF_DISABLE_NOTIFICATIONS: True, + }, + subentry_type=SUBENTRY_TYPE_INVERTER, + title=f"{model} ({serial})", + unique_id=f"{SUBENTRY_TYPE_INVERTER}_{serial}", + ) + + +def _response_error(status): + return aiohttp.ClientResponseError( + request_info=MagicMock(), history=(), status=status, message="x" + ) + + +class TestHasInverterSubentries: + def test_true(self): + sub = _inverter_subentry() + entry = FakeEntry(subentries={sub.subentry_id: sub}) + assert _has_inverter_subentries(entry) is True + + def test_false(self): + assert _has_inverter_subentries(FakeEntry()) is False + + +class FakeRegistryEntry: + def __init__(self, unique_id, entity_id, domain="sensor", name=None): + self.unique_id = unique_id + self.entity_id = entity_id + self.domain = domain + self.name = name + + +class TestMigrateEntityIds: + def _run(self, monkeypatch, entities, existing_ids=()): + ent_reg = MagicMock() + ent_reg.async_get.side_effect = ( + lambda eid: MagicMock() if eid in existing_ids else None + ) + monkeypatch.setattr(init_mod.er, "async_get", MagicMock(return_value=ent_reg)) + monkeypatch.setattr( + init_mod.er, + "async_entries_for_config_entry", + MagicMock(return_value=entities), + ) + entry = FakeEntry() + _migrate_entity_ids(MagicMock(), entry) + return ent_reg + + def test_renames_matching_entity(self, monkeypatch): + entity = FakeRegistryEntry( + f"test_entry_{SERIAL} - Solar Production", "sensor.old_name" + ) + ent_reg = self._run(monkeypatch, [entity]) + ent_reg.async_update_entity.assert_called_once_with( + "sensor.old_name", + new_entity_id=f"sensor.{SERIAL.lower()}_solar_production", + ) + + def test_skips_no_uid_or_wrong_prefix(self, monkeypatch): + entities = [ + FakeRegistryEntry(None, "sensor.a"), + FakeRegistryEntry("other_prefix - Name", "sensor.b"), + ] + ent_reg = self._run(monkeypatch, entities) + ent_reg.async_update_entity.assert_not_called() + + def test_skips_without_separator(self, monkeypatch): + entity = FakeRegistryEntry(f"test_entry_{SERIAL}NoSeparator", "sensor.a") + ent_reg = self._run(monkeypatch, [entity]) + ent_reg.async_update_entity.assert_not_called() + + def test_skips_already_correct(self, monkeypatch): + entity = FakeRegistryEntry( + f"test_entry_{SERIAL} - Solar Production", + f"sensor.{SERIAL.lower()}_solar_production", + ) + ent_reg = self._run(monkeypatch, [entity]) + ent_reg.async_update_entity.assert_not_called() + + def test_skips_when_target_taken(self, monkeypatch): + entity = FakeRegistryEntry( + f"test_entry_{SERIAL} - Solar Production", "sensor.old" + ) + ent_reg = self._run( + monkeypatch, + [entity], + existing_ids=(f"sensor.{SERIAL.lower()}_solar_production",), + ) + ent_reg.async_update_entity.assert_not_called() + + +class TestCleanupStaleEvEntities: + def _run(self, monkeypatch, coordinator, entities): + ent_reg = MagicMock() + monkeypatch.setattr(init_mod.er, "async_get", MagicMock(return_value=ent_reg)) + monkeypatch.setattr( + init_mod.er, + "async_entries_for_config_entry", + MagicMock(return_value=entities), + ) + _cleanup_stale_ev_entities(MagicMock(), FakeEntry(), coordinator) + return ent_reg + + def test_removes_pev_without_charger(self, monkeypatch, make_coordinator): + coordinator = make_coordinator() + coordinator.data = {SERIAL: {"Model": "SMILE5-INV"}} + entity = FakeRegistryEntry( + f"test_entry_{SERIAL} - {AlphaESSNames.pev.value}", "sensor.pev" + ) + ent_reg = self._run(monkeypatch, coordinator, [entity]) + ent_reg.async_remove.assert_called_once_with("sensor.pev") + + def test_keeps_pev_with_connector(self, monkeypatch, make_coordinator): + coordinator = make_coordinator() + coordinator.data = { + SERIAL: { + AlphaESSNames.evchargersn: "EV123", + AlphaESSNames.ElectricVehiclePowerOne: 5, + } + } + entity = FakeRegistryEntry( + f"test_entry_{SERIAL} - {AlphaESSNames.pev.value}", "sensor.pev" + ) + ent_reg = self._run(monkeypatch, coordinator, [entity]) + ent_reg.async_remove.assert_not_called() + + def test_removes_stale_connector(self, monkeypatch, make_coordinator): + coordinator = make_coordinator() + coordinator.data = { + SERIAL: { + AlphaESSNames.evchargersn: "EV123", + AlphaESSNames.ElectricVehiclePowerOne: 5, + AlphaESSNames.ElectricVehiclePowerTwo: None, + } + } + entity = FakeRegistryEntry( + f"test_entry_{SERIAL} - {AlphaESSNames.ElectricVehiclePowerTwo.value}", + "sensor.ev2", + ) + ent_reg = self._run(monkeypatch, coordinator, [entity]) + ent_reg.async_remove.assert_called_once_with("sensor.ev2") + + def test_skips_unknown_serial_and_bad_uids(self, monkeypatch, make_coordinator): + coordinator = make_coordinator() + coordinator.data = {} + entities = [ + FakeRegistryEntry(None, "sensor.a"), + FakeRegistryEntry("wrong - format", "sensor.b"), + FakeRegistryEntry(f"test_entry_UNKNOWN - {AlphaESSNames.pev.value}", "sensor.c"), + FakeRegistryEntry(f"test_entry_{SERIAL} - Unrelated", "sensor.d"), + ] + ent_reg = self._run(monkeypatch, coordinator, entities) + ent_reg.async_remove.assert_not_called() + + +class TestResolveClient: + def test_resolves_by_serial(self, mock_hass, make_coordinator): + coordinator = make_coordinator() + coordinator.data = {SERIAL: {}} + entry = FakeEntry() + entry.runtime_data = coordinator + mock_hass.config_entries.async_entries.return_value = [entry] + + assert _resolve_client_for_serial(mock_hass, SERIAL) is coordinator.api + + def test_falls_back_to_first_coordinator(self, mock_hass, make_coordinator): + coordinator = make_coordinator() + coordinator.data = {"OTHER": {}} + entry = FakeEntry() + entry.runtime_data = coordinator + mock_hass.config_entries.async_entries.return_value = [entry] + + assert _resolve_client_for_serial(mock_hass, SERIAL) is coordinator.api + + def test_ignores_non_coordinator_runtime_data(self, mock_hass): + entry = FakeEntry() + entry.runtime_data = "not-a-coordinator" + mock_hass.config_entries.async_entries.return_value = [entry] + + with pytest.raises(HomeAssistantError): + _resolve_client_for_serial(mock_hass, SERIAL) + + def test_raises_without_entries(self, mock_hass): + mock_hass.config_entries.async_entries.return_value = [] + with pytest.raises(HomeAssistantError): + _resolve_client_for_serial(mock_hass, SERIAL) + + +class TestServices: + async def test_battery_charge_service(self, mock_hass, make_coordinator, mock_api): + coordinator = make_coordinator() + coordinator.data = {SERIAL: {}} + entry = FakeEntry() + entry.runtime_data = coordinator + mock_hass.config_entries.async_entries.return_value = [entry] + + call = SimpleNamespace( + hass=mock_hass, + data={ + "serial": SERIAL, + "chargestopsoc": 90, + "enabled": True, + "cp1start": "01:00", + "cp1end": "05:00", + "cp2start": "00:00", + "cp2end": "00:00", + }, + ) + await init_mod._async_service_battery_charge(call) + mock_api.updateChargeConfigInfo.assert_awaited_once_with( + SERIAL, 90, 1, "05:00", "00:00", "01:00", "00:00" + ) + + async def test_battery_discharge_service(self, mock_hass, make_coordinator, mock_api): + coordinator = make_coordinator() + coordinator.data = {SERIAL: {}} + entry = FakeEntry() + entry.runtime_data = coordinator + mock_hass.config_entries.async_entries.return_value = [entry] + + call = SimpleNamespace( + hass=mock_hass, + data={ + "serial": SERIAL, + "dischargecutoffsoc": 10, + "enabled": False, + "dp1start": "18:00", + "dp1end": "22:00", + "dp2start": "00:00", + "dp2end": "00:00", + }, + ) + await init_mod._async_service_battery_discharge(call) + mock_api.updateDisChargeConfigInfo.assert_awaited_once_with( + SERIAL, 10, 0, "22:00", "00:00", "18:00", "00:00" + ) + + +class TestUnloadAndListener: + async def test_unload_removes_services_when_last(self, mock_hass): + entry = FakeEntry() + mock_hass.config_entries.async_unload_platforms.return_value = True + mock_hass.config_entries.async_entries.return_value = [entry] + + assert await async_unload_entry(mock_hass, entry) is True + assert mock_hass.services.async_remove.call_count == 2 + + async def test_unload_keeps_services_when_others_loaded(self, mock_hass): + entry = FakeEntry(entry_id="one") + other = FakeEntry(entry_id="two") + other.state = ConfigEntryState.LOADED + mock_hass.config_entries.async_unload_platforms.return_value = True + mock_hass.config_entries.async_entries.return_value = [entry, other] + + assert await async_unload_entry(mock_hass, entry) is True + mock_hass.services.async_remove.assert_not_called() + + async def test_unload_failure(self, mock_hass): + entry = FakeEntry() + mock_hass.config_entries.async_unload_platforms.return_value = False + + assert await async_unload_entry(mock_hass, entry) is False + mock_hass.services.async_remove.assert_not_called() + + +class TestMigrateEntry: + async def test_version_above_two_unsupported(self, mock_hass): + entry = FakeEntry() + entry.version = 3 + assert await async_migrate_entry(mock_hass, entry) is False + + async def test_version_two_noop(self, mock_hass): + entry = FakeEntry() + entry.version = 2 + assert await async_migrate_entry(mock_hass, entry) is True + mock_hass.config_entries.async_update_entry.assert_not_called() + + async def test_version_one_with_ip(self, mock_hass): + entry = FakeEntry( + data={"AppID": "id", "AppSecret": "secret", "IPAddress": "192.168.1.4"}, + options={}, + ) + entry.version = 1 + + assert await async_migrate_entry(mock_hass, entry) is True + kwargs = mock_hass.config_entries.async_update_entry.call_args.kwargs + assert kwargs["version"] == 2 + assert kwargs["options"]["_migrated_ip"] == "192.168.1.4" + assert kwargs["options"]["_needs_device_cleanup"] is True + assert "IPAddress" not in kwargs["data"] + + async def test_version_one_without_ip(self, mock_hass): + entry = FakeEntry(data={"AppID": "id", "AppSecret": "secret"}) + entry.version = 1 + + assert await async_migrate_entry(mock_hass, entry) is True + kwargs = mock_hass.config_entries.async_update_entry.call_args.kwargs + assert "_migrated_ip" not in kwargs["options"] + + +class FakeCoordinator: + """Stand-in for AlphaESSDataUpdateCoordinator inside async_setup_entry.""" + + instances = [] + + def __init__(self, hass, client=None, ip_address_map=None, inverter_models=None, + entry=None, scan_interval=None, alt_polling_mode=False, + fast_scan_interval=None): + self.hass = hass + self.api = client + self.ip_address_map = ip_address_map + self.inverter_models = inverter_models + self.entry = entry + self.scan_interval = scan_interval + self.alt_polling_mode = alt_polling_mode + self.fast_scan_interval = fast_scan_interval + self.data = dict(type(self).next_data) + self.cloud_available = type(self).next_cloud_available + self.async_config_entry_first_refresh = AsyncMock() + type(self).instances.append(self) + + next_data = {} + next_cloud_available = True + + +@pytest.fixture +def setup_env(monkeypatch, mock_hass, mock_api): + """Patch __init__ collaborators for async_setup_entry tests.""" + FakeCoordinator.instances = [] + FakeCoordinator.next_data = {SERIAL: {"Model": "SMILE5-INV"}} + FakeCoordinator.next_cloud_available = True + + monkeypatch.setattr( + init_mod.alphaess, "alphaess", MagicMock(return_value=mock_api) + ) + monkeypatch.setattr( + init_mod, "async_get_clientsession", MagicMock(return_value=MagicMock()) + ) + monkeypatch.setattr(init_mod, "AlphaESSDataUpdateCoordinator", FakeCoordinator) + monkeypatch.setattr(init_mod, "_cleanup_stale_ev_entities", MagicMock()) + monkeypatch.setattr(init_mod, "_migrate_entity_ids", MagicMock()) + + mock_hass.services.has_service.return_value = False + mock_hass.config_entries.async_entries.return_value = [] + return mock_hass, mock_api + + +class TestAsyncSetupEntry: + async def test_basic_setup(self, setup_env): + mock_hass, mock_api = setup_env + sub = _inverter_subentry(ip="192.168.1.9") + entry = FakeEntry(subentries={sub.subentry_id: sub}) + mock_api.getESSList.return_value = [{"sysSn": SERIAL, "minv": "SMILE5-INV"}] + + assert await async_setup_entry(mock_hass, entry) is True + coordinator = FakeCoordinator.instances[0] + assert entry.runtime_data is coordinator + assert coordinator.ip_address_map == {SERIAL: "192.168.1.9"} + coordinator.async_config_entry_first_refresh.assert_awaited_once() + mock_hass.config_entries.async_forward_entry_setups.assert_awaited_once() + assert mock_hass.services.async_register.call_count == 2 + # EV cleanup flag stored + update_kwargs = mock_hass.config_entries.async_update_entry.call_args.kwargs + assert update_kwargs["options"]["_ev_entity_cleanup_done"] is True + + async def test_services_not_reregistered(self, setup_env): + mock_hass, mock_api = setup_env + mock_hass.services.has_service.return_value = True + sub = _inverter_subentry() + entry = FakeEntry( + subentries={sub.subentry_id: sub}, + options={"_ev_entity_cleanup_done": True}, + ) + mock_api.getESSList.return_value = [{"sysSn": SERIAL, "minv": "SMILE5-INV"}] + + await async_setup_entry(mock_hass, entry) + mock_hass.services.async_register.assert_not_called() + + async def test_auth_failure_raises(self, setup_env): + mock_hass, mock_api = setup_env + mock_api.getESSList.side_effect = _response_error(401) + with pytest.raises(ConfigEntryAuthFailed): + await async_setup_entry(mock_hass, FakeEntry()) + + async def test_response_error_raises_not_ready(self, setup_env): + mock_hass, mock_api = setup_env + mock_api.getESSList.side_effect = _response_error(503) + with pytest.raises(ConfigEntryNotReady): + await async_setup_entry(mock_hass, FakeEntry()) + + async def test_client_error_raises_not_ready(self, setup_env): + mock_hass, mock_api = setup_env + mock_api.getESSList.side_effect = aiohttp.ClientError("offline") + with pytest.raises(ConfigEntryNotReady): + await async_setup_entry(mock_hass, FakeEntry()) + + async def test_auto_creates_subentries_on_migration(self, setup_env): + mock_hass, mock_api = setup_env + entry = FakeEntry( + options={ + "_migrated_ip": "192.168.1.4", + "_needs_device_cleanup": True, + "_needs_entity_id_migration": True, + } + ) + mock_api.getESSList.return_value = [ + {"sysSn": SERIAL, "minv": "SMILE5-INV"}, + {"minv": "ignored-no-serial"}, + ] + + # device cleanup path + dev_reg = MagicMock() + device = MagicMock() + device.id = "device1" + with pytest.MonkeyPatch.context() as mp: + mp.setattr(init_mod.dr, "async_get", MagicMock(return_value=dev_reg)) + mp.setattr( + init_mod.dr, + "async_entries_for_config_entry", + MagicMock(return_value=[device]), + ) + assert await async_setup_entry(mock_hass, entry) is True + + # first inverter got the migrated IP via subentry creation + added = mock_hass.config_entries.async_add_subentry.call_args_list + assert added, "expected auto-created subentries" + created = added[0].args[1] + assert created.data[CONF_SERIAL_NUMBER] == SERIAL + assert created.data[CONF_IP_ADDRESS] == "192.168.1.4" + # device cleanup ran + dev_reg.async_update_device.assert_called_once_with( + "device1", remove_config_entry_id=entry.entry_id + ) + # entity-id migration ran + init_mod._migrate_entity_ids.assert_called_once() + + async def test_ev_subentry_auto_created(self, setup_env): + mock_hass, mock_api = setup_env + FakeCoordinator.next_data = { + SERIAL: { + "Model": "SMILE5-INV", + AlphaESSNames.evchargersn: "EV777", + AlphaESSNames.evchargermodel: "SMILE-EVCT11", + } + } + sub = _inverter_subentry() + entry = FakeEntry( + subentries={sub.subentry_id: sub}, + options={"_ev_entity_cleanup_done": True}, + ) + mock_api.getESSList.return_value = [{"sysSn": SERIAL, "minv": "SMILE5-INV"}] + + await async_setup_entry(mock_hass, entry) + added = mock_hass.config_entries.async_add_subentry.call_args_list + ev_subs = [ + call.args[1] for call in added + if call.args[1].subentry_type == SUBENTRY_TYPE_EV_CHARGER + ] + assert len(ev_subs) == 1 + assert ev_subs[0].data[CONF_SERIAL_NUMBER] == "EV777" + + async def test_cleanup_skipped_when_cloud_unavailable(self, setup_env): + mock_hass, mock_api = setup_env + FakeCoordinator.next_cloud_available = False + sub = _inverter_subentry() + entry = FakeEntry(subentries={sub.subentry_id: sub}) + mock_api.getESSList.return_value = [{"sysSn": SERIAL, "minv": "SMILE5-INV"}] + + await async_setup_entry(mock_hass, entry) + init_mod._cleanup_stale_ev_entities.assert_not_called() + + async def test_invalid_scan_interval_options(self, setup_env): + mock_hass, mock_api = setup_env + sub = _inverter_subentry() + entry = FakeEntry( + subentries={sub.subentry_id: sub}, + options={ + "scan_interval_seconds": "not-a-number", + "fast_scan_interval_seconds": "junk", + "alt_polling_mode": True, + "_ev_entity_cleanup_done": True, + }, + ) + mock_api.getESSList.return_value = [{"sysSn": SERIAL, "minv": "SMILE5-INV"}] + + await async_setup_entry(mock_hass, entry) + coordinator = FakeCoordinator.instances[0] + assert coordinator.scan_interval.total_seconds() == 60 + assert coordinator.fast_scan_interval.total_seconds() == 15 + assert coordinator.alt_polling_mode is True diff --git a/tests/test_platform_entities.py b/tests/test_platform_entities.py new file mode 100644 index 0000000..877f2f8 --- /dev/null +++ b/tests/test_platform_entities.py @@ -0,0 +1,468 @@ +"""Tests for binary_sensor, switch and time platforms.""" +from datetime import time as dt_time +from unittest.mock import MagicMock + +from homeassistant.config_entries import ConfigSubentry + +from custom_components.alphaess.binary_sensor import ( + AlphaEVReadinessBinarySensor, +) +from custom_components.alphaess.binary_sensor import ( + async_setup_entry as binary_setup, +) +from custom_components.alphaess.const import ( + CONF_INVERTER_MODEL, + CONF_IP_ADDRESS, + CONF_PARENT_INVERTER, + CONF_SERIAL_NUMBER, + SUBENTRY_TYPE_EV_CHARGER, + SUBENTRY_TYPE_INVERTER, +) +from custom_components.alphaess.enums import AlphaESSNames +from custom_components.alphaess.sensorlist import ( + CHARGE_DISCHARGE_SWITCHES, + CHARGE_DISCHARGE_TIMES, + EV_CHARGER_BINARY_SENSORS, +) +from custom_components.alphaess.switch import AlphaSwitch +from custom_components.alphaess.switch import async_setup_entry as switch_setup +from custom_components.alphaess.time import AlphaTime +from custom_components.alphaess.time import async_setup_entry as time_setup + +from .conftest import FakeEntry + +SERIAL = "AL1000021000123" + + +def _inverter_subentry(serial=SERIAL, model="SMILE5-INV"): + return ConfigSubentry( + data={ + CONF_SERIAL_NUMBER: serial, + CONF_INVERTER_MODEL: model, + CONF_IP_ADDRESS: "", + }, + subentry_type=SUBENTRY_TYPE_INVERTER, + title=f"{model} ({serial})", + unique_id=f"{SUBENTRY_TYPE_INVERTER}_{serial}", + ) + + +def _ev_subentry(ev_serial="EV123", parent=SERIAL): + return ConfigSubentry( + data={CONF_SERIAL_NUMBER: ev_serial, CONF_PARENT_INVERTER: parent}, + subentry_type=SUBENTRY_TYPE_EV_CHARGER, + title=f"EV ({ev_serial})", + unique_id=f"{SUBENTRY_TYPE_EV_CHARGER}_{ev_serial}", + ) + + +def _entry_for(subentries, coordinator): + entry = FakeEntry(subentries={sub.subentry_id: sub for sub in subentries}) + entry.runtime_data = coordinator + return entry + + +class TestBinarySensorSetup: + async def test_auto_discovered_ev(self, mock_hass, make_coordinator, add_entities): + coordinator = make_coordinator() + coordinator.data = { + SERIAL: { + AlphaESSNames.evchargersn: "EV123", + AlphaESSNames.evchargermodel: "SMILE-EVCT11", + } + } + entry = _entry_for([_inverter_subentry()], coordinator) + + await binary_setup(mock_hass, entry, add_entities) + assert len(add_entities.entities) == len(EV_CHARGER_BINARY_SENSORS) + + async def test_no_ev_charger_no_entities(self, mock_hass, make_coordinator, add_entities): + coordinator = make_coordinator() + coordinator.data = {SERIAL: {"Model": "SMILE5-INV"}} + entry = _entry_for([_inverter_subentry()], coordinator) + + await binary_setup(mock_hass, entry, add_entities) + assert add_entities.entities == [] + + async def test_serial_missing_skipped(self, mock_hass, make_coordinator, add_entities): + coordinator = make_coordinator() + coordinator.data = {} + entry = _entry_for([_inverter_subentry()], coordinator) + + await binary_setup(mock_hass, entry, add_entities) + assert add_entities.entities == [] + + async def test_ev_with_subentry_not_duplicated_on_inverter( + self, mock_hass, make_coordinator, add_entities + ): + coordinator = make_coordinator() + coordinator.data = { + SERIAL: { + AlphaESSNames.evchargersn: "EV123", + AlphaESSNames.evchargermodel: "SMILE-EVCT11", + } + } + entry = _entry_for([_inverter_subentry(), _ev_subentry()], coordinator) + + await binary_setup(mock_hass, entry, add_entities) + # only via the EV subentry path + assert len(add_entities.calls) == 1 + + async def test_ev_subentry_missing_parent(self, mock_hass, make_coordinator, add_entities): + coordinator = make_coordinator() + coordinator.data = {} + entry = _entry_for([_ev_subentry(parent="GONE")], coordinator) + + await binary_setup(mock_hass, entry, add_entities) + assert add_entities.entities == [] + + async def test_ev_subentry_parent_without_charger( + self, mock_hass, make_coordinator, add_entities + ): + coordinator = make_coordinator() + coordinator.data = {SERIAL: {"Model": "SMILE5-INV"}} + entry = _entry_for([_ev_subentry()], coordinator) + + await binary_setup(mock_hass, entry, add_entities) + assert add_entities.entities == [] + + +class TestBinarySensorEntity: + def _make(self, coordinator, direction_index=0, device_info=None): + return AlphaEVReadinessBinarySensor( + coordinator, + SERIAL, + FakeEntry(), + EV_CHARGER_BINARY_SENSORS[direction_index], + ev_serial="EV123", + device_info=device_info, + ) + + def test_is_on_states(self, make_coordinator): + coordinator = make_coordinator() + coordinator.data = {SERIAL: {AlphaESSNames.evchargerstatusraw: 2}} + start = self._make(coordinator, 0) # direction=1 + stop = self._make(coordinator, 1) # direction=0 + assert start.is_on is True + assert stop.is_on is False + + def test_is_on_none_when_no_status(self, make_coordinator): + coordinator = make_coordinator() + coordinator.data = {SERIAL: {}} + assert self._make(coordinator).is_on is None + + def test_is_on_none_when_no_direction(self, make_coordinator): + coordinator = make_coordinator() + coordinator.data = {SERIAL: {AlphaESSNames.evchargerstatusraw: 2}} + sensor = self._make(coordinator) + sensor._direction = None + assert sensor.is_on is None + + def test_available(self, make_coordinator): + coordinator = make_coordinator() + coordinator.data = {SERIAL: {"EV Charger S/N": "EV123"}} + sensor = self._make(coordinator, device_info={"identifiers": {("alphaess", "EV123")}}) + + coordinator.last_update_success = False + assert sensor.available is False + + coordinator.last_update_success = True + coordinator.cloud_available = False + assert sensor.available is False + + coordinator.cloud_available = True + assert sensor.available is True + + coordinator.data = {SERIAL: {}} + assert sensor.available is False + + def test_properties(self, make_coordinator): + coordinator = make_coordinator() + coordinator.data = {SERIAL: {}} + sensor = self._make(coordinator) + assert SERIAL in sensor.unique_id + assert sensor.name == "Can Start Charging" + assert sensor.suggested_object_id == f"{SERIAL} Can Start Charging" + assert sensor.entity_category is not None + assert sensor.icon + + +class TestSwitchSetup: + async def test_entities_created(self, mock_hass, make_coordinator, add_entities): + coordinator = make_coordinator() + coordinator.data = {SERIAL: {"Model": "SMILE5-INV"}} + entry = _entry_for([_inverter_subentry()], coordinator) + + await switch_setup(mock_hass, entry, add_entities) + assert len(add_entities.entities) == len(CHARGE_DISCHARGE_SWITCHES) + + async def test_blacklisted_model_skipped(self, mock_hass, make_coordinator, add_entities): + coordinator = make_coordinator() + coordinator.data = {SERIAL: {"Model": "VT1000"}} + entry = _entry_for([_inverter_subentry(model="VT1000")], coordinator) + + await switch_setup(mock_hass, entry, add_entities) + assert add_entities.entities == [] + + async def test_non_inverter_subentry_skipped(self, mock_hass, make_coordinator, add_entities): + coordinator = make_coordinator() + coordinator.data = {SERIAL: {"Model": "SMILE5-INV"}} + entry = _entry_for([_ev_subentry()], coordinator) + + await switch_setup(mock_hass, entry, add_entities) + assert add_entities.entities == [] + + async def test_serial_missing_skipped(self, mock_hass, make_coordinator, add_entities): + coordinator = make_coordinator() + coordinator.data = {} + entry = _entry_for([_inverter_subentry()], coordinator) + + await switch_setup(mock_hass, entry, add_entities) + assert add_entities.entities == [] + + +class TestSwitchEntity: + def _make(self, coordinator, index=0, device_info=None): + return AlphaSwitch( + coordinator, SERIAL, FakeEntry(), + CHARGE_DISCHARGE_SWITCHES[index], device_info=device_info, + ) + + def test_is_on_variants(self, make_coordinator): + coordinator = make_coordinator() + switch = self._make(coordinator) # gridCharge + key = switch._coordinator_key + + coordinator.data = {SERIAL: {}} + assert switch.is_on is None + + coordinator.data = {SERIAL: {key: 1}} + assert switch.is_on is True + + coordinator.data = {SERIAL: {key: 0}} + assert switch.is_on is False + + switch._optimistic_state = True + assert switch.is_on is True + + def test_coordinator_update_clears_optimistic(self, make_coordinator): + coordinator = make_coordinator() + coordinator.data = {SERIAL: {}} + switch = self._make(coordinator) + switch._optimistic_state = True + switch.async_write_ha_state = MagicMock() + switch._handle_coordinator_update() + assert switch._optimistic_state is None + + async def test_turn_on_grid_charge(self, make_coordinator, mock_api): + coordinator = make_coordinator() + coordinator.data = { + SERIAL: { + AlphaESSNames.batHighCap: 95, + "charge_timeChae1": "05:00", + } + } + switch = self._make(coordinator, 0) + switch.async_write_ha_state = MagicMock() + + await switch.async_turn_on() + assert switch._optimistic_state is True + args = mock_api.updateChargeConfigInfo.await_args.args + assert args == (SERIAL, 95, 1, "05:00", "00:00", "00:00", "00:00") + + async def test_turn_off_discharge(self, make_coordinator, mock_api): + coordinator = make_coordinator() + coordinator.data = {SERIAL: {AlphaESSNames.batUseCap: 15}} + # find the ctrDis switch + index = next( + i for i, d in enumerate(CHARGE_DISCHARGE_SWITCHES) + if d.coordinator_key == "ctrDis" + ) + switch = self._make(coordinator, index, device_info={"identifiers": set()}) + switch.async_write_ha_state = MagicMock() + + await switch.async_turn_off() + assert switch._optimistic_state is False + args = mock_api.updateDisChargeConfigInfo.await_args.args + assert args == (SERIAL, 15, 0, "00:00", "00:00", "00:00", "00:00") + + async def test_set_value_unknown_key_noop(self, make_coordinator, mock_api): + coordinator = make_coordinator() + coordinator.data = {SERIAL: {}} + switch = self._make(coordinator) + switch._coordinator_key = "unknown" + await switch._set_value(1) + mock_api.updateChargeConfigInfo.assert_not_awaited() + mock_api.updateDisChargeConfigInfo.assert_not_awaited() + + def test_available_and_properties(self, make_coordinator): + coordinator = make_coordinator() + coordinator.data = {SERIAL: {}} + switch = self._make(coordinator) + + coordinator.last_update_success = False + assert switch.available is False + coordinator.last_update_success = True + coordinator.cloud_available = False + assert switch.available is False + coordinator.cloud_available = True + assert switch.available is True + + assert SERIAL in switch.unique_id + assert switch.name + assert switch.entity_category is not None + assert switch.icon + + +class TestTimeSetup: + async def test_entities_created(self, mock_hass, make_coordinator, add_entities): + coordinator = make_coordinator() + coordinator.data = {SERIAL: {"Model": "SMILE5-INV"}} + entry = _entry_for([_inverter_subentry()], coordinator) + + await time_setup(mock_hass, entry, add_entities) + assert len(add_entities.entities) == len(CHARGE_DISCHARGE_TIMES) + + async def test_blacklisted_model_skipped(self, mock_hass, make_coordinator, add_entities): + coordinator = make_coordinator() + coordinator.data = {SERIAL: {"Model": "VT1000"}} + entry = _entry_for([_inverter_subentry(model="VT1000")], coordinator) + + await time_setup(mock_hass, entry, add_entities) + assert add_entities.entities == [] + + async def test_non_inverter_skipped(self, mock_hass, make_coordinator, add_entities): + coordinator = make_coordinator() + coordinator.data = {SERIAL: {}} + entry = _entry_for([_ev_subentry()], coordinator) + + await time_setup(mock_hass, entry, add_entities) + assert add_entities.entities == [] + + async def test_serial_missing_skipped(self, mock_hass, make_coordinator, add_entities): + coordinator = make_coordinator() + coordinator.data = {} + entry = _entry_for([_inverter_subentry()], coordinator) + + await time_setup(mock_hass, entry, add_entities) + assert add_entities.entities == [] + + +def _time_description(coordinator_key): + return next(d for d in CHARGE_DISCHARGE_TIMES if d.coordinator_key == coordinator_key) + + +class TestTimeEntity: + def _make(self, coordinator, coordinator_key="charge_timeChaf1", device_info=None): + return AlphaTime( + coordinator, SERIAL, FakeEntry(), + _time_description(coordinator_key), device_info=device_info, + ) + + def test_native_value_from_coordinator(self, make_coordinator): + coordinator = make_coordinator() + coordinator.data = {SERIAL: {"charge_timeChaf1": "06:30"}} + entity = self._make(coordinator, device_info={"identifiers": set()}) + assert entity.native_value == dt_time(6, 30) + + def test_native_value_invalid_string(self, make_coordinator): + coordinator = make_coordinator() + coordinator.data = {SERIAL: {"charge_timeChaf1": "garbage"}} + entity = self._make(coordinator) + assert entity.native_value is None + + def test_native_value_missing(self, make_coordinator): + coordinator = make_coordinator() + coordinator.data = {SERIAL: {}} + entity = self._make(coordinator) + assert entity.native_value is None + + def test_native_value_prefers_attr(self, make_coordinator): + coordinator = make_coordinator() + coordinator.data = {SERIAL: {"charge_timeChaf1": "06:30"}} + entity = self._make(coordinator) + entity._attr_native_value = dt_time(9, 15) + assert entity.native_value == dt_time(9, 15) + + def test_handle_coordinator_update(self, make_coordinator): + coordinator = make_coordinator() + coordinator.data = {SERIAL: {"charge_timeChaf1": "07:45"}} + entity = self._make(coordinator) + entity.async_write_ha_state = MagicMock() + entity._handle_coordinator_update() + assert entity._attr_native_value == dt_time(7, 45) + + async def test_set_value_rounds_to_quarter_hour(self, make_coordinator, mock_api): + coordinator = make_coordinator() + coordinator.data = { + SERIAL: { + AlphaESSNames.batHighCap: 90, + "gridCharge": 1, + "charge_timeChae1": "05:00", + } + } + entity = self._make(coordinator, "charge_timeChaf1") + entity.async_write_ha_state = MagicMock() + + await entity.async_set_value(dt_time(6, 7)) # rounds to 06:00 + args = mock_api.updateChargeConfigInfo.await_args.args + # updateChargeConfigInfo(serial, cap, enabled, chae1, chae2, chaf1, chaf2) + assert args == (SERIAL, 90, 1, "05:00", "00:00", "06:00", "00:00") + coordinator.async_request_refresh.assert_awaited_once() + + async def test_set_value_midnight_wrap(self, make_coordinator, mock_api): + coordinator = make_coordinator() + coordinator.data = {SERIAL: {}} + entity = self._make(coordinator, "charge_timeChaf1") + entity.async_write_ha_state = MagicMock() + + await entity.async_set_value(dt_time(23, 55)) # rounds to 24:00 -> 00:00 + args = mock_api.updateChargeConfigInfo.await_args.args + assert args[5] == "00:00" + + async def test_set_value_discharge(self, make_coordinator, mock_api): + coordinator = make_coordinator() + coordinator.data = { + SERIAL: { + AlphaESSNames.batUseCap: 12, + "ctrDis": 0, + "discharge_timeDise2": "22:00", + } + } + entity = self._make(coordinator, "discharge_timeDisf2") + entity.async_write_ha_state = MagicMock() + + await entity.async_set_value(dt_time(20, 0)) + args = mock_api.updateDisChargeConfigInfo.await_args.args + # updateDisChargeConfigInfo(serial, cap, enabled, dise1, dise2, disf1, disf2) + assert args == (SERIAL, 12, 0, "00:00", "22:00", "00:00", "20:00") + + async def test_set_value_failure_reverts(self, make_coordinator, mock_api): + coordinator = make_coordinator() + coordinator.data = {SERIAL: {"charge_timeChaf1": "03:00"}} + entity = self._make(coordinator, "charge_timeChaf1") + entity.async_write_ha_state = MagicMock() + entity._attr_native_value = dt_time(3, 0) + mock_api.updateChargeConfigInfo.side_effect = OSError("api down") + + await entity.async_set_value(dt_time(8, 0)) + assert entity._attr_native_value == dt_time(3, 0) + coordinator.async_request_refresh.assert_not_awaited() + + def test_available_and_properties(self, make_coordinator): + coordinator = make_coordinator() + coordinator.data = {SERIAL: {}} + entity = self._make(coordinator) + + coordinator.last_update_success = False + assert entity.available is False + coordinator.last_update_success = True + coordinator.cloud_available = False + assert entity.available is False + coordinator.cloud_available = True + assert entity.available is True + + assert SERIAL in entity.unique_id + assert entity.name == "Charge Start Time 1" + assert entity.entity_category is not None + assert entity.icon diff --git a/tests/test_sensor.py b/tests/test_sensor.py new file mode 100644 index 0000000..5f1aa0c --- /dev/null +++ b/tests/test_sensor.py @@ -0,0 +1,427 @@ +"""Tests for the sensor platform.""" + +from homeassistant.components.sensor import SensorDeviceClass +from homeassistant.config_entries import ConfigSubentry + +from custom_components.alphaess.const import ( + CONF_INVERTER_MODEL, + CONF_IP_ADDRESS, + CONF_PARENT_INVERTER, + CONF_SERIAL_NUMBER, + SUBENTRY_TYPE_EV_CHARGER, + SUBENTRY_TYPE_INVERTER, +) +from custom_components.alphaess.enums import AlphaESSNames +from custom_components.alphaess.sensor import AlphaESSSensor, async_setup_entry +from custom_components.alphaess.sensorlist import ( + EV_CHARGING_DETAILS, + FULL_SENSOR_DESCRIPTIONS, + LIMITED_SENSOR_DESCRIPTIONS, + LOCAL_IP_SYSTEM_SENSORS, +) + +from .conftest import FakeEntry + +SERIAL = "AL1000021000123" + +ALL_DESCRIPTIONS = ( + FULL_SENSOR_DESCRIPTIONS + + LIMITED_SENSOR_DESCRIPTIONS + + EV_CHARGING_DETAILS + + LOCAL_IP_SYSTEM_SENSORS +) + + +def _inverter_subentry(serial=SERIAL, model="SMILE5-INV", ip=""): + return ConfigSubentry( + data={ + CONF_SERIAL_NUMBER: serial, + CONF_INVERTER_MODEL: model, + CONF_IP_ADDRESS: ip, + }, + subentry_type=SUBENTRY_TYPE_INVERTER, + title=f"{model} ({serial})", + unique_id=f"{SUBENTRY_TYPE_INVERTER}_{serial}", + ) + + +def _ev_subentry(ev_serial="EV123", parent=SERIAL): + return ConfigSubentry( + data={ + CONF_SERIAL_NUMBER: ev_serial, + CONF_PARENT_INVERTER: parent, + }, + subentry_type=SUBENTRY_TYPE_EV_CHARGER, + title=f"EV ({ev_serial})", + unique_id=f"{SUBENTRY_TYPE_EV_CHARGER}_{ev_serial}", + ) + + +def _entry_for(subentries, coordinator=None): + entry = FakeEntry(subentries={sub.subentry_id: sub for sub in subentries}) + entry.runtime_data = coordinator + return entry + + +def _description(key): + return next(d for d in ALL_DESCRIPTIONS if d.key == key) + + +class TestSensorSetup: + async def test_full_model_creates_entities( + self, mock_hass, make_coordinator, add_entities + ): + coordinator = make_coordinator() + coordinator.data = {SERIAL: {"Model": "SMILE5-INV", "Currency": "USD"}} + entry = _entry_for([_inverter_subentry()], coordinator) + + await async_setup_entry(mock_hass, entry, add_entities) + assert len(add_entities.entities) > 30 + + async def test_limited_model_creates_fewer_entities( + self, mock_hass, make_coordinator, add_entities + ): + coordinator = make_coordinator() + coordinator.data = {SERIAL: {"Model": "Storion-S5"}} + entry = _entry_for([_inverter_subentry(model="Storion-S5")], coordinator) + + await async_setup_entry(mock_hass, entry, add_entities) + limited_count = len(add_entities.entities) + assert 0 < limited_count < len(FULL_SENSOR_DESCRIPTIONS) + + async def test_serial_missing_from_data_skipped( + self, mock_hass, make_coordinator, add_entities + ): + coordinator = make_coordinator() + coordinator.data = {} + entry = _entry_for([_inverter_subentry()], coordinator) + + await async_setup_entry(mock_hass, entry, add_entities) + assert add_entities.entities == [] + + async def test_local_ip_sensors_created( + self, mock_hass, make_coordinator, add_entities + ): + coordinator = make_coordinator() + coordinator.data = { + SERIAL: { + "Model": "SMILE5-INV", + AlphaESSNames.localIP: "192.168.1.5", + AlphaESSNames.deviceStatus: 1, + } + } + entry = _entry_for([_inverter_subentry()], coordinator) + + await async_setup_entry(mock_hass, entry, add_entities) + names = {e._name for e in add_entities.entities} + assert AlphaESSNames.localIP.value in names + + async def test_ev_connector_sensors_skipped_when_absent( + self, mock_hass, make_coordinator, add_entities + ): + coordinator = make_coordinator() + coordinator.data = { + SERIAL: { + "Model": "SMILE5-INV", + AlphaESSNames.ElectricVehiclePowerOne: None, + } + } + entry = _entry_for([_inverter_subentry()], coordinator) + + await async_setup_entry(mock_hass, entry, add_entities) + keys = {e._key for e in add_entities.entities} + assert AlphaESSNames.pev not in keys + assert AlphaESSNames.ElectricVehiclePowerOne not in keys + + async def test_ev_connector_sensors_included_when_present( + self, mock_hass, make_coordinator, add_entities + ): + coordinator = make_coordinator() + coordinator.data = { + SERIAL: { + "Model": "SMILE5-INV", + AlphaESSNames.ElectricVehiclePowerOne: 7000, + } + } + entry = _entry_for([_inverter_subentry()], coordinator) + + await async_setup_entry(mock_hass, entry, add_entities) + keys = {e._key for e in add_entities.entities} + assert AlphaESSNames.pev in keys + assert AlphaESSNames.ElectricVehiclePowerOne in keys + + async def test_ev_subentry_entities(self, mock_hass, make_coordinator, add_entities): + coordinator = make_coordinator() + coordinator.data = { + SERIAL: { + "Model": "SMILE5-INV", + AlphaESSNames.evchargersn: "EV123", + AlphaESSNames.evchargermodel: "SMILE-EVCT11", + } + } + entry = _entry_for([_inverter_subentry(), _ev_subentry()], coordinator) + + await async_setup_entry(mock_hass, entry, add_entities) + # second add_entities call is for the EV subentry + assert len(add_entities.calls) == 2 + + async def test_ev_subentry_missing_parent_skipped( + self, mock_hass, make_coordinator, add_entities + ): + coordinator = make_coordinator() + coordinator.data = {} + entry = _entry_for([_ev_subentry(parent="UNKNOWN")], coordinator) + + await async_setup_entry(mock_hass, entry, add_entities) + assert add_entities.entities == [] + + async def test_ev_subentry_without_charger_data_skipped( + self, mock_hass, make_coordinator, add_entities + ): + coordinator = make_coordinator() + coordinator.data = {SERIAL: {"Model": "SMILE5-INV"}} + entry = _entry_for([_ev_subentry()], coordinator) + + await async_setup_entry(mock_hass, entry, add_entities) + assert add_entities.entities == [] + + async def test_auto_discovered_ev_without_subentry( + self, mock_hass, make_coordinator, add_entities + ): + coordinator = make_coordinator() + coordinator.data = { + SERIAL: { + "Model": "SMILE5-INV", + AlphaESSNames.evchargersn: "EV999", + AlphaESSNames.evchargermodel: "SMILE-EVCS7", + } + } + entry = _entry_for([_inverter_subentry()], coordinator) + + await async_setup_entry(mock_hass, entry, add_entities) + # inverter batch + auto-discovered EV batch + assert len(add_entities.calls) == 2 + + async def test_inverter_subentry_without_serial( + self, mock_hass, make_coordinator, add_entities + ): + sub = ConfigSubentry( + data={CONF_SERIAL_NUMBER: ""}, + subentry_type=SUBENTRY_TYPE_INVERTER, + title="broken", + unique_id="inverter_broken", + ) + coordinator = make_coordinator() + coordinator.data = {} + entry = _entry_for([sub], coordinator) + + await async_setup_entry(mock_hass, entry, add_entities) + assert add_entities.entities == [] + + +def _make_sensor(coordinator, key, currency="USD", serial=SERIAL): + return AlphaESSSensor( + coordinator, FakeEntry(), serial, _description(key), currency + ) + + +class TestSensorEntity: + def test_basic_properties(self, make_coordinator): + coordinator = make_coordinator() + coordinator.data = {SERIAL: {AlphaESSNames.SolarProduction: 5.0}} + sensor = _make_sensor(coordinator, AlphaESSNames.SolarProduction) + + assert sensor.unique_id == f"test_entry_{SERIAL} - Solar Production" + assert sensor.name == "Solar Production" + assert sensor.suggested_object_id == f"{SERIAL} Solar Production" + assert sensor.native_value == 5.0 + assert sensor.device_class is not None + assert sensor.state_class is not None + assert sensor.icon is None or isinstance(sensor.icon, str) + assert sensor.entity_category is None or sensor.entity_category + + def test_currency_unit_substitution(self, make_coordinator): + coordinator = make_coordinator() + coordinator.data = {SERIAL: {}} + income = _make_sensor(coordinator, AlphaESSNames.Income, currency="EUR") + assert income.native_unit_of_measurement == "EUR" + + solar = _make_sensor(coordinator, AlphaESSNames.SolarProduction) + assert solar.native_unit_of_measurement == "kWh" + + def test_device_info_attached(self, make_coordinator): + coordinator = make_coordinator() + coordinator.data = {SERIAL: {}} + device_info = {"identifiers": {("alphaess", SERIAL)}} + sensor = AlphaESSSensor( + coordinator, FakeEntry(), SERIAL, + _description(AlphaESSNames.SolarProduction), "USD", + device_info=device_info, + ) + assert sensor._attr_device_info == device_info + + def test_available_requires_update_success(self, make_coordinator): + coordinator = make_coordinator() + coordinator.data = {SERIAL: {AlphaESSNames.SolarProduction: 1.0}} + sensor = _make_sensor(coordinator, AlphaESSNames.SolarProduction) + + coordinator.last_update_success = False + assert sensor.available is False + + coordinator.last_update_success = True + assert sensor.available is True + + def test_available_data_none(self, make_coordinator): + coordinator = make_coordinator() + coordinator.last_update_success = True + coordinator.data = None + sensor = _make_sensor(coordinator, AlphaESSNames.SolarProduction) + assert sensor.available is False + + def test_available_serial_missing(self, make_coordinator): + coordinator = make_coordinator() + coordinator.last_update_success = True + coordinator.data = {} + sensor = _make_sensor(coordinator, AlphaESSNames.SolarProduction) + assert sensor.available is False + + def test_available_ev_keys_require_charger(self, make_coordinator): + coordinator = make_coordinator() + coordinator.last_update_success = True + coordinator.data = {SERIAL: {AlphaESSNames.pev: 5}} + sensor = _make_sensor(coordinator, AlphaESSNames.pev) + assert sensor.available is False + + coordinator.data = { + SERIAL: { + AlphaESSNames.evchargersn: "EV123", + AlphaESSNames.pev: 5, + AlphaESSNames.ElectricVehiclePowerOne: 5, + } + } + assert sensor.available is True + + def test_available_pev_requires_connector_one(self, make_coordinator): + coordinator = make_coordinator() + coordinator.last_update_success = True + coordinator.data = { + SERIAL: { + AlphaESSNames.evchargersn: "EV123", + AlphaESSNames.pev: 5, + AlphaESSNames.ElectricVehiclePowerOne: None, + } + } + sensor = _make_sensor(coordinator, AlphaESSNames.pev) + assert sensor.available is False + + def test_available_connector_power(self, make_coordinator): + coordinator = make_coordinator() + coordinator.last_update_success = True + coordinator.data = { + SERIAL: { + AlphaESSNames.evchargersn: "EV123", + AlphaESSNames.ElectricVehiclePowerTwo: None, + } + } + sensor = _make_sensor(coordinator, AlphaESSNames.ElectricVehiclePowerTwo) + assert sensor.available is False + + coordinator.data[SERIAL][AlphaESSNames.ElectricVehiclePowerTwo] = 11 + assert sensor.available is True + + def test_available_au_nullable_keys(self, make_coordinator): + coordinator = make_coordinator() + coordinator.last_update_success = True + coordinator.data = {SERIAL: {AlphaESSNames.TotalLoad: None}} + sensor = _make_sensor(coordinator, AlphaESSNames.TotalLoad) + assert sensor.available is False + + coordinator.data[SERIAL][AlphaESSNames.TotalLoad] = 8.5 + assert sensor.available is True + + def test_available_key_presence(self, make_coordinator): + coordinator = make_coordinator() + coordinator.last_update_success = True + coordinator.data = {SERIAL: {}} + sensor = _make_sensor(coordinator, AlphaESSNames.SolarProduction) + assert sensor.available is False + + def test_native_value_data_none(self, make_coordinator): + coordinator = make_coordinator() + coordinator.data = None + sensor = _make_sensor(coordinator, AlphaESSNames.SolarProduction) + assert sensor.native_value is None + + def test_native_value_ev_status_enum(self, make_coordinator): + coordinator = make_coordinator() + coordinator.data = {SERIAL: {AlphaESSNames.evchargerstatus: 3}} + sensor = _make_sensor(coordinator, AlphaESSNames.evchargerstatus) + assert sensor.native_value == "charging" + + coordinator.data = {SERIAL: {AlphaESSNames.evchargerstatus: 77}} + assert sensor.native_value == "unknown" + + coordinator.data = {SERIAL: {AlphaESSNames.evchargerstatus: "junk"}} + assert sensor.native_value == "unknown" + + coordinator.data = {SERIAL: {}} + assert sensor.native_value is None + + def test_native_value_status_lookups(self, make_coordinator): + coordinator = make_coordinator() + coordinator.data = {SERIAL: {AlphaESSNames.cloudConnectionStatus: 0}} + sensor = _make_sensor(coordinator, AlphaESSNames.cloudConnectionStatus) + assert sensor.native_value == "connected_ok" + + coordinator.data = {SERIAL: {AlphaESSNames.cloudConnectionStatus: "x"}} + assert sensor.native_value == "connect_fail" + + coordinator.data = {SERIAL: {}} + assert sensor.native_value is None + + coordinator.data = {SERIAL: {AlphaESSNames.wifiStatus: 5}} + wifi = _make_sensor(coordinator, AlphaESSNames.wifiStatus) + assert wifi.native_value == "connected_ok" + + coordinator.data = {SERIAL: {AlphaESSNames.ethernetModule: 0}} + eth = _make_sensor(coordinator, AlphaESSNames.ethernetModule) + assert eth.native_value == "link_up" + + coordinator.data = {SERIAL: {AlphaESSNames.fourGModule: -1}} + fourg = _make_sensor(coordinator, AlphaESSNames.fourGModule) + assert fourg.native_value == "initialization" + + def test_native_value_charge_time(self, make_coordinator): + coordinator = make_coordinator() + coordinator.data = {SERIAL: {AlphaESSNames.ChargeTime1: "01:00 - 05:00"}} + sensor = _make_sensor(coordinator, AlphaESSNames.ChargeTime1) + assert sensor.native_value == "01:00 - 05:00" + + def test_options(self, make_coordinator): + coordinator = make_coordinator() + coordinator.data = {SERIAL: {}} + + assert "charging" in _make_sensor(coordinator, AlphaESSNames.evchargerstatus).options + assert "connected_ok" in _make_sensor(coordinator, AlphaESSNames.cloudConnectionStatus).options + assert "link_up" in _make_sensor(coordinator, AlphaESSNames.ethernetModule).options + assert "ok" in _make_sensor(coordinator, AlphaESSNames.fourGModule).options + assert "connecting" in _make_sensor(coordinator, AlphaESSNames.wifiStatus).options + assert _make_sensor(coordinator, AlphaESSNames.SolarProduction).options is None + + def test_translation_keys(self, make_coordinator): + coordinator = make_coordinator() + coordinator.data = {SERIAL: {}} + + cases = { + AlphaESSNames.evchargerstatus: "ev_charger_status", + AlphaESSNames.cloudConnectionStatus: "tcp_status", + AlphaESSNames.ethernetModule: "ethernet_status", + AlphaESSNames.fourGModule: "four_g_status", + AlphaESSNames.wifiStatus: "wifi_status", + } + for key, expected in cases.items(): + sensor = _make_sensor(coordinator, key) + if sensor._device_class == SensorDeviceClass.ENUM: + assert sensor.translation_key == expected + + assert _make_sensor(coordinator, AlphaESSNames.SolarProduction).translation_key is None