diff --git a/custom_components/isolarcloud/__init__.py b/custom_components/isolarcloud/__init__.py index 202d657..51861ce 100644 --- a/custom_components/isolarcloud/__init__.py +++ b/custom_components/isolarcloud/__init__.py @@ -2,6 +2,8 @@ from __future__ import annotations +import logging + import voluptuous as vol from homeassistant.config_entries import ConfigEntry @@ -12,6 +14,7 @@ from . import api from .const import DOMAIN +_LOGGER = logging.getLogger(__name__) _PLATFORMS: list[Platform] = [Platform.SENSOR] CONFIG_SCHEMA = vol.Schema( @@ -22,6 +25,7 @@ vol.Required("client_id"): str, vol.Required("client_secret"): str, vol.Required("plant"): str, + vol.Optional("plants"): vol.All(vol.Coerce(list), vol.Length(min=1)), vol.Optional("token"): dict, } ) @@ -65,6 +69,28 @@ async def async_unload_entry( return await hass.config_entries.async_unload_platforms(entry, _PLATFORMS) +async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry): + """Migrate old entry.""" + _LOGGER.debug( + "Migrating configuration from version %s.%s", + config_entry.version, + config_entry.minor_version, + ) + + if config_entry.version > 1: + return False + + if config_entry.minor_version < 2: + new_data = {**config_entry.data} + new_data["plants"] = [config_entry.data["plant"]] + + hass.config_entries.async_update_entry( + config_entry, data=new_data, minor_version=2, version=1 + ) + + return True + + async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: """Reload integration when options are updated (e.g. changed update_interval).""" hass.config_entries.async_schedule_reload(entry.entry_id) diff --git a/custom_components/isolarcloud/application_credentials.py b/custom_components/isolarcloud/application_credentials.py index 34f17f8..47a13a1 100644 --- a/custom_components/isolarcloud/application_credentials.py +++ b/custom_components/isolarcloud/application_credentials.py @@ -39,6 +39,7 @@ def __init__( websession = aiohttp_client.async_get_clientsession(hass) self.app_id, appkey = client_id.split("@") super().__init__(websession, server, appkey, client_secret, self.app_id) + self.access_token = None @property def name(self) -> str: @@ -95,6 +96,7 @@ async def async_resolve_external_data(self, external_data) -> dict: if "error" in result: _LOGGER.error("Error fetching tokens: %s", result) raise ConfigEntryAuthFailed("Failed to fetch tokens") + self.access_token = result.get("access_token") return result async def _async_refresh_token(self, token: dict) -> dict: @@ -107,11 +109,8 @@ async def _async_refresh_token(self, token: dict) -> dict: return result async def async_get_access_token(self) -> str: - """Return a valid access token. - - N/A in this class as it is only used for setting up the OAuth2 flow. - """ - return None + """Return a valid access token.""" + return self.access_token async def async_get_auth_implementation(hass: HomeAssistant, auth_domain, credential): diff --git a/custom_components/isolarcloud/config_flow.py b/custom_components/isolarcloud/config_flow.py index ceed1bc..ee68fac 100644 --- a/custom_components/isolarcloud/config_flow.py +++ b/custom_components/isolarcloud/config_flow.py @@ -3,6 +3,7 @@ import logging from pysolarcloud import Server +from pysolarcloud.plants import Plants import voluptuous as vol from homeassistant import config_entries @@ -63,6 +64,29 @@ class OAuth2FlowHandler( ) } + def _plant_schema(self, plants: list[dict]) -> vol.Schema: + """Return a schema for selecting multiple plants.""" + return vol.Schema( + { + vol.Required( + "plants", default=[str(plant["ps_id"]) for plant in plants] + ): selector( + { + "select": { + "options": [ + { + "label": plant["ps_name"], + "value": str(plant["ps_id"]), + } + for plant in plants + ], + "multiple": True, + } + } + ) + } + ) + @property def logger(self) -> logging.Logger: """Return logger.""" @@ -84,23 +108,43 @@ async def async_step_user(self, user_input=None): async def async_oauth_create_entry( self, data: dict ) -> config_entries.ConfigFlowResult: - """Create an entry for the flow.""" - plant = data["token"]["result_data"]["auth_ps_list"][0] + """Handle OAuth completion.""" + plants = data["token"]["result_data"]["auth_ps_list"] + if len(plants) > 1 and self.source != config_entries.SOURCE_REAUTH: + self._oauth_data = data + return await self.async_step_select_plants() + return await self._async_create_entry(data, plants) + + async def async_step_select_plants(self, user_input=None): + """Step to select plants.""" + if user_input is not None: + return await self._async_create_entry( + self._oauth_data, user_input["plants"] + ) + api = Plants(self.flow_impl) + plants = await api.async_get_plants() + self.logger.debug("Plants: %s", plants) + return self.async_show_form( + step_id="select_plants", + data_schema=self._plant_schema(plants), + ) + + async def _async_create_entry(self, data, plants): d = { **data, - "plant": plant, + "plant": plants[0], + "plants": plants, "server": self.hass.data[self.DOMAIN]["server"].value, "client_id": self.flow_impl.client_id, "client_secret": self.flow_impl.client_secret, } - await self.async_set_unique_id(str(plant)) + await self.async_set_unique_id(plants[0]) if self.source == config_entries.SOURCE_REAUTH: - self._abort_if_unique_id_mismatch(reason="plant_id_mismatch") self.logger.info( - "async_oauth_create_entry callled with sourece=REAUTH and data=%s", d + "async_oauth_create_entry called with source=REAUTH and data=%s", d ) return self.async_update_reload_and_abort(self._get_reauth_entry(), data=d) - self.logger.info("async_oauth_create_entry callled with data=%s", d) + self.logger.info("async_oauth_create_entry called with data=%s", d) return self.async_create_entry(title=self.flow_impl.name, data=d) async def async_step_reauth( diff --git a/custom_components/isolarcloud/manifest.json b/custom_components/isolarcloud/manifest.json index ab053be..0bb1793 100644 --- a/custom_components/isolarcloud/manifest.json +++ b/custom_components/isolarcloud/manifest.json @@ -22,6 +22,6 @@ "pysolarcloud==0.2.1" ], "ssdp": [], - "version": "0.7.0", + "version": "0.8.0-beta", "zeroconf": [] } \ No newline at end of file diff --git a/custom_components/isolarcloud/sensor.py b/custom_components/isolarcloud/sensor.py index 8a1c46a..79198fd 100644 --- a/custom_components/isolarcloud/sensor.py +++ b/custom_components/isolarcloud/sensor.py @@ -64,30 +64,40 @@ async def async_setup_entry( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the sensor platform.""" - coordinator = Coordinator(hass, config) + plants = config.data.get("plants", [config.data["plant"]]) + coordinator = Coordinator(hass, config, plants) await coordinator.async_config_entry_first_refresh() - device = DeviceInfo( - identifiers={(DOMAIN, config.data["plant"])}, - name=coordinator.plant_name, - ) # Register services from services.py - await async_register_services(hass, coordinator, import_sensors=ENERGY_SENSORS) - - async_add_entities( - [ - ISolarCloudSensor(coordinator, device, s, SensorDeviceClass.ENERGY) - for s in ENERGY_SENSORS - ] - + [ - ISolarCloudSensor(coordinator, device, s, SensorDeviceClass.POWER) - for s in POWER_SENSORS - ] - + [ - ISolarCloudSensor(coordinator, device, s, SensorDeviceClass.BATTERY) - for s in BATTERY_SENSORS - ], + await async_register_services( + hass, coordinator, plants, import_sensors=ENERGY_SENSORS ) + + for plant in plants: + device = DeviceInfo( + identifiers={(DOMAIN, plant)}, + name=coordinator.plant_name(plant), + ) + async_add_entities( + [ + ISolarCloudSensor( + coordinator, device, plant, s, SensorDeviceClass.ENERGY + ) + for s in ENERGY_SENSORS + ] + + [ + ISolarCloudSensor( + coordinator, device, plant, s, SensorDeviceClass.POWER + ) + for s in POWER_SENSORS + ] + + [ + ISolarCloudSensor( + coordinator, device, plant, s, SensorDeviceClass.BATTERY + ) + for s in BATTERY_SENSORS + ], + ) return True @@ -95,13 +105,19 @@ class ISolarCloudSensor(CoordinatorEntity, SensorEntity): """Generic Sensor for iSolarCloud.""" def __init__( - self, coordinator: Coordinator, device: DeviceInfo, id: str, sensor_type: str + self, + coordinator: Coordinator, + device: DeviceInfo, + plant_id: str, + id: str, + sensor_type: str, ) -> None: """Initialize the sensor.""" super().__init__(coordinator) self.id = id + self.plant_id = plant_id self._attr_device_info = device - self._attr_unique_id = f"{coordinator.plant_id}_{id}" + self._attr_unique_id = f"{plant_id}_{id}" self._attr_translation_key = id self._attr_has_entity_name = True self._attr_device_class = sensor_type @@ -123,11 +139,12 @@ def __init__( # Get initial sensor value from coordinator if ( self.coordinator.data - and self.id in self.coordinator.data - and self.coordinator.data[self.id].get("value") is not None + and self.plant_id in self.coordinator.data + and self.id in self.coordinator.data[self.plant_id] + and self.coordinator.data[self.plant_id][self.id].get("value") is not None ): self._attr_native_value = self._value_transform( - self.coordinator.data[self.id]["value"] + self.coordinator.data[self.plant_id][self.id]["value"] ) self._attr_available = True @@ -136,11 +153,12 @@ def _handle_coordinator_update(self) -> None: """Handle updated data from the coordinator.""" if ( self.coordinator.data - and self.id in self.coordinator.data - and self.coordinator.data[self.id].get("value") is not None + and self.plant_id in self.coordinator.data + and self.id in self.coordinator.data[self.plant_id] + and self.coordinator.data[self.plant_id][self.id].get("value") is not None ): self._attr_native_value = self._value_transform( - self.coordinator.data[self.id]["value"] + self.coordinator.data[self.plant_id][self.id]["value"] ) self._attr_available = True else: @@ -152,7 +170,9 @@ def _handle_coordinator_update(self) -> None: class Coordinator(DataUpdateCoordinator): """Update Coordinator.""" - def __init__(self, hass: HomeAssistant, config_entry: ConfigType) -> None: + def __init__( + self, hass: HomeAssistant, config_entry: ConfigType, plant_ids: list[str] + ) -> None: """Initialize my coordinator.""" if config_entry.options and "update_interval" in config_entry.options: update_interval = timedelta( @@ -173,48 +193,32 @@ def __init__(self, hass: HomeAssistant, config_entry: ConfigType) -> None: update_interval=update_interval, always_update=False, ) - self.plant_id = config_entry.data["plant"] + self.plant_ids = plant_ids self.plants_api: Plants = config_entry.runtime_data.api - self.plant_name = None + self.plant_names = {} async def _async_setup(self): - """Set up the coordinator. - - This is the place to set up your coordinator, - or to load data, that only needs to be loaded once. - - This method will be called automatically during - coordinator.async_config_entry_first_refresh. - """ - data = await self.plants_api.async_get_plant_details(self.plant_id) - pdata = data[0] - self.plant_name = pdata["ps_name"] + """Set up the coordinator for all plants.""" + data = await self.plants_api.async_get_plants() + for plant in data: + self.plant_names[str(plant["ps_id"])] = plant["ps_name"] async def _async_update_data(self): - """Fetch data from API endpoint. - - This is the place to pre-process the data to lookup tables - so entities can quickly look up their data. - """ + """Fetch data from API endpoint for all plants.""" try: - # Note: asyncio.TimeoutError and aiohttp.ClientError are already - # handled by the data update coordinator. async with asyncio.timeout(10): data = await self.plants_api.async_get_realtime_data( - self.plant_id, measure_points=ALL_SENSORS + self.plant_ids, measure_points=ALL_SENSORS ) - _LOGGER.debug("Data: %s", data) - p = self.plant_id - if p in data: - return data[p] - raise UpdateFailed(f"Plant not found in data: {data}") - # except ApiAuthError as err: - # Raising ConfigEntryAuthFailed will cancel future updates - # and start a config flow with SOURCE_REAUTH (async_step_reauth) - # raise ConfigEntryAuthFailed from err + _LOGGER.debug("Data retrieved: %s", data) + return data except PySolarCloudException as err: raise UpdateFailed(f"Error communicating with API: {err}") from err + def plant_name(self, plant_id: str) -> str: + """Get the name of a plant by its ID.""" + return self.plant_names.get(plant_id, f"Plant {plant_id}") + @lru_cache def get_entity_id(self, unique_id): """Get the entity id of a sensor.""" diff --git a/custom_components/isolarcloud/services.py b/custom_components/isolarcloud/services.py index 1354ef5..53552ca 100644 --- a/custom_components/isolarcloud/services.py +++ b/custom_components/isolarcloud/services.py @@ -163,7 +163,9 @@ async def get_baselines( return baselines -async def async_delete_statistics_range(hass, statistic_id, start_time, end_time): +async def async_delete_statistics_range( + hass: HomeAssistant, statistic_id, start_time, end_time +): """Delete existing statistics rows for a statistic_id in a given time range.""" # Add job to the executor to avoid blocking the event loop await get_instance(hass).async_add_executor_job( @@ -214,7 +216,7 @@ def _delete_statistics_range_blocking( async def async_register_services( - hass: HomeAssistant, coordinator, import_sensors: list[str] + hass: HomeAssistant, coordinator, plants, import_sensors: list[str] ): """Register Home Assistant services for the iSolarCloud integration.""" @@ -230,7 +232,7 @@ async def import_historical_data(call: ServiceCall): imported_count = await async_import_historical_data( hass, coordinator.plants_api, - coordinator.plant_id, + call.data.get("plant", coordinator.plant_ids[0]), coordinator.get_entity_id, import_sensors, start, @@ -251,6 +253,7 @@ async def import_historical_data(call: ServiceCall): vol.Required("start"): cv.datetime, vol.Required("end"): cv.datetime, vol.Optional("delete", default=False): cv.boolean, + vol.Optional("plant"): vol.In(plants), } ), ) @@ -261,14 +264,13 @@ async def list_data_points(call: ServiceCall): mp = call.data["measure_points"] else: mp = None + plant = call.data.get("plant", coordinator.plant_ids[0]) try: data_points = await coordinator.plants_api.async_get_realtime_data( - coordinator.plant_id, measure_points=mp + plant, measure_points=mp ) - if coordinator.plant_id in data_points: - _LOGGER.debug( - "Data points for plant %s: %s", coordinator.plant_id, data_points - ) + if plant in data_points: + _LOGGER.debug("Data points for plant %s: %s", plant, data_points) return { "data_points": [ { @@ -276,14 +278,14 @@ async def list_data_points(call: ServiceCall): "value": dp["value"], "unit": dp["unit"], } - for dp in data_points[coordinator.plant_id].values() + for dp in data_points[plant].values() if dp["value"] is not None ], } else: _LOGGER.error( "Plant ID %s not found in data points: %s", - coordinator.plant_id, + plant, data_points, ) return { @@ -302,6 +304,9 @@ async def list_data_points(call: ServiceCall): vol.Optional("measure_points", default=[]): vol.All( cv.ensure_list, [cv.string] ), + vol.Optional("plant", default=coordinator.plant_ids[0]): vol.In( + coordinator.plant_ids + ), } ), supports_response=SupportsResponse.ONLY, diff --git a/custom_components/isolarcloud/services.yaml b/custom_components/isolarcloud/services.yaml index e13b27e..ba619a7 100644 --- a/custom_components/isolarcloud/services.yaml +++ b/custom_components/isolarcloud/services.yaml @@ -20,6 +20,12 @@ import_historical_data: required: false selector: boolean: + plant: + name: Plant + description: The plant to import data for. (Needed if you have multiple plants.) + required: false + selector: + text: list_data_points: name: List data points description: "List all available data points from iSolarCloud." @@ -30,4 +36,10 @@ list_data_points: required: false selector: text: - multiple: true \ No newline at end of file + multiple: true + plant: + name: Plant + description: The plant to get data for. (Needed if you have multiple plants.) + required: false + selector: + text: diff --git a/custom_components/isolarcloud/strings.json b/custom_components/isolarcloud/strings.json index a1e0bce..963800c 100644 --- a/custom_components/isolarcloud/strings.json +++ b/custom_components/isolarcloud/strings.json @@ -7,7 +7,14 @@ "data": { "server": "iSolarCloud server" } + }, + "plant": { + "title": "Select Plant", + "description": "Select the plant you want to configure.", + "data": { + "plant": "Plant" + } } } } -} +} \ No newline at end of file diff --git a/custom_components/isolarcloud/translations/da.json b/custom_components/isolarcloud/translations/da.json index 256b032..63a3123 100644 --- a/custom_components/isolarcloud/translations/da.json +++ b/custom_components/isolarcloud/translations/da.json @@ -10,6 +10,13 @@ "data": { "server": "iSolarCloud server" } + }, + "select_plants": { + "title": "Vælg anlæg", + "description": "Vælg de anlæg, du vil konfigurere.", + "data": { + "plant": "Anlæg" + } } }, "abort": { @@ -28,6 +35,9 @@ "create_entry": { "default": "Godkendelse gennemført, og tjenesten er forbundet", "reauth_successful": "Godkendelse gennemført, forbindelsen til tjenesten er genoprettet" + }, + "error": { + "plant_id_mismatch": "Ingen adgang til det valgte anlæg." } }, "options": { diff --git a/custom_components/isolarcloud/translations/en.json b/custom_components/isolarcloud/translations/en.json index fff8c62..7445239 100644 --- a/custom_components/isolarcloud/translations/en.json +++ b/custom_components/isolarcloud/translations/en.json @@ -5,11 +5,18 @@ "title": "Choose authentication provider" }, "user": { - "title": "Select server", + "title": "Select iSolarCloud server", "description": "Select the server you want to connect to", "data": { "server": "iSolarCloud server" } + }, + "select_plants": { + "title": "Select Plants", + "description": "Select the plants you want to connect.", + "data": { + "plants": "Plants" + } } }, "abort": {