-
-
Notifications
You must be signed in to change notification settings - Fork 37.7k
Add solarman integration #152525
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Add solarman integration #152525
Changes from 16 commits
2496a30
7168fc1
f65a4df
88f24a6
ef30cb5
942e9cf
0c4c923
c8d5ec5
db46545
972d080
79e990e
7630d11
19ecbf9
7eafaee
92d1bae
5aa5d7e
35557f6
0069cd7
61af7f3
aabf109
08f7aab
73a58e4
ced98e5
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,31 @@ | ||
| """Home Assistant integration for SOLARMAN devices.""" | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| from solarman_opendata.solarman import Solarman | ||
|
|
||
| from homeassistant.const import CONF_HOST | ||
| from homeassistant.core import HomeAssistant | ||
| from homeassistant.helpers.aiohttp_client import async_get_clientsession | ||
|
|
||
| from .const import DEFAULT_PORT, PLATFORMS | ||
| from .coordinator import SolarmanConfigEntry, SolarmanDeviceUpdateCoordinator | ||
|
|
||
|
|
||
| async def async_setup_entry(hass: HomeAssistant, entry: SolarmanConfigEntry) -> bool: | ||
| """Set up Solarman from a config entry.""" | ||
| client = Solarman( | ||
| async_get_clientsession(hass), entry.data[CONF_HOST], DEFAULT_PORT | ||
| ) | ||
| coordinator = SolarmanDeviceUpdateCoordinator(hass, entry, client) | ||
|
|
||
| await coordinator.async_config_entry_first_refresh() | ||
|
|
||
| entry.runtime_data = coordinator | ||
| await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) | ||
| return True | ||
|
|
||
|
|
||
| async def async_unload_entry(hass: HomeAssistant, entry: SolarmanConfigEntry) -> bool: | ||
| """Unload a config entry.""" | ||
| return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,152 @@ | ||
| """Config flow for solarman integration.""" | ||
|
|
||
| import logging | ||
| from typing import Any | ||
|
|
||
| from solarman_opendata.solarman import Solarman | ||
| import voluptuous as vol | ||
|
|
||
| from homeassistant.config_entries import ConfigFlow, ConfigFlowResult | ||
| from homeassistant.const import CONF_DEVICE, CONF_HOST, CONF_MAC, CONF_MODEL, CONF_TYPE | ||
| from homeassistant.helpers import device_registry as dr | ||
| from homeassistant.helpers.aiohttp_client import async_get_clientsession | ||
| from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo | ||
|
|
||
| from .const import ( | ||
| CONF_PRODUCT_TYPE, | ||
| CONF_SERIAL, | ||
| CONF_SN, | ||
| DEFAULT_PORT, | ||
| DOMAIN, | ||
| MODEL_NAME_MAP, | ||
| ) | ||
|
|
||
| _LOGGER = logging.getLogger(__name__) | ||
|
|
||
|
|
||
| class SolarmanConfigFlow(ConfigFlow, domain=DOMAIN): | ||
| """Handle a config flow for Solarman.""" | ||
|
|
||
| VERSION = 1 | ||
|
|
||
| host: str | None = None | ||
| model: str | None = None | ||
| device_sn: str | None = None | ||
| mac: str | None = None | ||
| client: Solarman | None = None | ||
|
|
||
| async def async_step_user( | ||
| self, user_input: dict[str, Any] | None = None | ||
| ) -> ConfigFlowResult: | ||
| """Handle the initial step via user interface.""" | ||
| errors = {} | ||
| if user_input is not None: | ||
| self.host = user_input[CONF_HOST] | ||
|
|
||
| self.client = Solarman( | ||
| async_get_clientsession(self.hass), self.host, DEFAULT_PORT | ||
| ) | ||
|
|
||
| try: | ||
| config_data = await self.client.get_config() | ||
| except TimeoutError: | ||
| errors["base"] = "timeout" | ||
| except ConnectionError: | ||
| errors["base"] = "cannot_connect" | ||
| except Exception: | ||
| _LOGGER.exception("Unknown error occurred while verifying device") | ||
| errors["base"] = "unknown" | ||
| else: | ||
| device_info = config_data.get(CONF_DEVICE, config_data) | ||
|
|
||
| self.device_sn = device_info[CONF_SN] | ||
| self.model = device_info[CONF_TYPE] | ||
| self.mac = dr.format_mac(device_info[CONF_MAC]) | ||
|
|
||
| await self.async_set_unique_id(self.device_sn) | ||
| self._abort_if_unique_id_configured() | ||
|
|
||
| if not errors: | ||
| return self.async_create_entry( | ||
| title=f"{MODEL_NAME_MAP[self.model]} ({self.host})", | ||
| data={ | ||
| CONF_HOST: self.host, | ||
| CONF_SN: self.device_sn, | ||
| CONF_MODEL: self.model, | ||
| CONF_MAC: self.mac, | ||
| }, | ||
|
Comment on lines
+60
to
+77
|
||
| ) | ||
|
Comment on lines
+60
to
+78
|
||
|
|
||
| return self.async_show_form( | ||
| step_id="user", | ||
| data_schema=vol.Schema( | ||
| { | ||
| vol.Required(CONF_HOST): str, | ||
| } | ||
| ), | ||
| errors=errors, | ||
| ) | ||
|
|
||
| async def async_step_zeroconf( | ||
| self, discovery_info: ZeroconfServiceInfo | ||
| ) -> ConfigFlowResult: | ||
| """Handle zeroconf discovery.""" | ||
| self.host = discovery_info.host | ||
| self.model = discovery_info.properties[CONF_PRODUCT_TYPE] | ||
| self.device_sn = discovery_info.properties[CONF_SERIAL] | ||
|
|
||
| self.client = Solarman( | ||
| async_get_clientsession(self.hass), self.host, DEFAULT_PORT | ||
|
Comment on lines
+98
to
+99
|
||
| ) | ||
|
|
||
| try: | ||
| config_data = await self.client.get_config() | ||
| except TimeoutError: | ||
| return self.async_abort(reason="timeout") | ||
| except ConnectionError: | ||
| return self.async_abort(reason="cannot_connect") | ||
| except Exception: | ||
| _LOGGER.exception("Unknown error occurred while verifying device") | ||
| return self.async_abort(reason="unknown") | ||
|
|
||
| device_info = config_data.get(CONF_DEVICE, config_data) | ||
| self.mac = dr.format_mac(device_info[CONF_MAC]) | ||
|
|
||
| await self.async_set_unique_id(self.device_sn) | ||
| self._abort_if_unique_id_configured(updates={CONF_HOST: discovery_info.host}) | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. So as the serial number comes from the discovery info, would it make sense to do this before fetching the config? Because we'd already be sure that the device exists on that field |
||
|
|
||
| return await self.async_step_discovery_confirm() | ||
|
Comment on lines
+90
to
+118
|
||
|
|
||
| async def async_step_discovery_confirm( | ||
| self, user_input: dict[str, Any] | None = None | ||
| ) -> ConfigFlowResult: | ||
| """Confirm discovery.""" | ||
| assert self.host | ||
| assert self.model | ||
| assert self.device_sn | ||
|
|
||
| if user_input is not None: | ||
| return self.async_create_entry( | ||
| title=f"{MODEL_NAME_MAP[self.model]} ({self.host})", | ||
| data={ | ||
| CONF_HOST: self.host, | ||
| CONF_SN: self.device_sn, | ||
| CONF_MODEL: self.model, | ||
| CONF_MAC: self.mac, | ||
| }, | ||
| ) | ||
|
|
||
| self._set_confirm_only() | ||
|
|
||
| name = f"{self.model} ({self.device_sn})" | ||
| self.context["title_placeholders"] = {"name": name} | ||
|
|
||
| return self.async_show_form( | ||
| step_id="discovery_confirm", | ||
| description_placeholders={ | ||
| CONF_MODEL: self.model, | ||
| CONF_SN: self.device_sn, | ||
| CONF_HOST: self.host, | ||
| CONF_MAC: self.mac or "", | ||
| }, | ||
| ) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,23 @@ | ||
| """Constants for the solarman integration.""" | ||
|
|
||
| from datetime import timedelta | ||
|
|
||
| from homeassistant.const import Platform | ||
|
|
||
| DOMAIN = "solarman" | ||
| DEFAULT_PORT = 8080 | ||
| UPDATE_INTERVAL = timedelta(seconds=30) | ||
| PLATFORMS = [ | ||
| Platform.SENSOR, | ||
| ] | ||
|
|
||
| CONF_SERIAL = "serial" | ||
| CONF_SN = "sn" | ||
| CONF_FW = "fw" | ||
| CONF_PRODUCT_TYPE = "product_type" | ||
|
|
||
| MODEL_NAME_MAP = { | ||
| "SP-2W-EU": "Smart Plug", | ||
| "P1-2W": "P1 Meter Reader", | ||
| "gl meter": "Smart Meter", | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,50 @@ | ||
| """Coordinator for solarman integration.""" | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import logging | ||
| from typing import Any | ||
|
|
||
| from solarman_opendata.solarman import Solarman | ||
|
|
||
| from homeassistant.config_entries import ConfigEntry | ||
| from homeassistant.core import HomeAssistant | ||
| from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed | ||
|
|
||
| from .const import DOMAIN, UPDATE_INTERVAL | ||
|
|
||
| _LOGGER = logging.getLogger(__name__) | ||
|
|
||
| type SolarmanConfigEntry = ConfigEntry[SolarmanDeviceUpdateCoordinator] | ||
|
|
||
|
|
||
| class SolarmanDeviceUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): | ||
| """Coordinator for managing Solarman device data updates and control operations.""" | ||
|
|
||
| config_entry: SolarmanConfigEntry | ||
|
|
||
| def __init__( | ||
| self, hass: HomeAssistant, config_entry: SolarmanConfigEntry, client: Solarman | ||
| ) -> None: | ||
| """Initialize the Solarman device coordinator.""" | ||
|
|
||
| super().__init__( | ||
| hass, | ||
| logger=_LOGGER, | ||
| config_entry=config_entry, | ||
| name=DOMAIN, | ||
| update_interval=UPDATE_INTERVAL, | ||
| ) | ||
|
|
||
| # Initialize the API client for communicating with the Solarman device. | ||
| self.api = client | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Still relevant |
||
|
|
||
| async def _async_update_data(self) -> dict[str, Any]: | ||
| """Fetch and update device data.""" | ||
| try: | ||
|
joostlek marked this conversation as resolved.
|
||
| return await self.api.fetch_data() | ||
| except ConnectionError as e: | ||
| raise UpdateFailed( | ||
| translation_domain=DOMAIN, | ||
| translation_key="update_failed", | ||
| ) from e | ||
|
Comment on lines
+48
to
+51
Comment on lines
+43
to
+51
Comment on lines
+45
to
+51
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,34 @@ | ||
| """Base entity for the Solarman integration.""" | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| from homeassistant.const import CONF_MAC, CONF_MODEL | ||
| from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo | ||
| from homeassistant.helpers.update_coordinator import CoordinatorEntity | ||
|
|
||
| from .const import CONF_SN, DOMAIN, MODEL_NAME_MAP | ||
| from .coordinator import SolarmanDeviceUpdateCoordinator | ||
|
|
||
|
|
||
| class SolarmanEntity(CoordinatorEntity[SolarmanDeviceUpdateCoordinator]): | ||
| """Defines a Solarman entity.""" | ||
|
|
||
| _attr_has_entity_name = True | ||
|
|
||
| def __init__(self, coordinator: SolarmanDeviceUpdateCoordinator) -> None: | ||
| """Initialize the Solarman entity.""" | ||
| super().__init__(coordinator) | ||
|
|
||
| entry = coordinator.config_entry | ||
|
|
||
| sn = entry.data[CONF_SN] | ||
| model_id = entry.data[CONF_MODEL] | ||
|
|
||
| self._attr_device_info = DeviceInfo( | ||
| connections={(CONNECTION_NETWORK_MAC, entry.data[CONF_MAC])}, | ||
| identifiers={(DOMAIN, sn)}, | ||
| manufacturer="SOLARMAN", | ||
| model=MODEL_NAME_MAP[model_id], | ||
| model_id=model_id, | ||
| serial_number=sn, | ||
| ) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,12 @@ | ||
| { | ||
| "domain": "solarman", | ||
| "name": "Solarman", | ||
|
Comment on lines
+2
to
+3
|
||
| "codeowners": ["@solarmanpv"], | ||
| "config_flow": true, | ||
| "documentation": "https://www.home-assistant.io/integrations/solarman", | ||
| "integration_type": "device", | ||
| "iot_class": "local_polling", | ||
| "quality_scale": "bronze", | ||
| "requirements": ["solarman-opendata==0.0.2"], | ||
| "zeroconf": ["_solarman._tcp.local."] | ||
|
Comment on lines
+9
to
+11
|
||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.