Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions custom_components/isolarcloud/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

from __future__ import annotations

import logging

import voluptuous as vol

from homeassistant.config_entries import ConfigEntry
Expand All @@ -12,6 +14,7 @@
from . import api
from .const import DOMAIN

_LOGGER = logging.getLogger(__name__)
_PLATFORMS: list[Platform] = [Platform.SENSOR]

CONFIG_SCHEMA = vol.Schema(
Expand All @@ -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,
}
)
Expand Down Expand Up @@ -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)
9 changes: 4 additions & 5 deletions custom_components/isolarcloud/application_credentials.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand All @@ -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):
Expand Down
58 changes: 51 additions & 7 deletions custom_components/isolarcloud/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import logging

from pysolarcloud import Server
from pysolarcloud.plants import Plants
import voluptuous as vol

from homeassistant import config_entries
Expand Down Expand Up @@ -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."""
Expand All @@ -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(
Expand Down
2 changes: 1 addition & 1 deletion custom_components/isolarcloud/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,6 @@
"pysolarcloud==0.2.1"
],
"ssdp": [],
"version": "0.7.0",
"version": "0.8.0-beta",
"zeroconf": []
}
122 changes: 63 additions & 59 deletions custom_components/isolarcloud/sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,44 +64,60 @@ 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


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
Expand All @@ -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

Expand All @@ -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:
Expand All @@ -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(
Expand All @@ -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."""
Expand Down
Loading