Skip to content
Merged
Show file tree
Hide file tree
Changes from 16 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
2496a30
Submit the solarman gold-level configuration file to the Home Assista…
solarmanpv Sep 16, 2025
7168fc1
Submit the solarman gold-level configuration file to the Home Assista…
solarmanpv Sep 16, 2025
f65a4df
Add integration for Indevolt energy storage devices.
solarmanpv Oct 13, 2025
88f24a6
Merge branch 'home-assistant:dev' into dev
solarmanpv Oct 13, 2025
ef30cb5
Merge branch 'home-assistant:dev' into dev
solarmanpv Oct 13, 2025
942e9cf
delete indevolt integration
solarmanpv Oct 14, 2025
0c4c923
Remove switch platform and use a proper development environment
xiaozhouhhh Nov 3, 2025
c8d5ec5
Apply suggestions from review
xiaozhouhhh Nov 17, 2025
db46545
Apply suggestions from review
xiaozhouhhh Dec 9, 2025
972d080
Update sensor name
xiaozhouhhh Dec 9, 2025
79e990e
Apply suggestions from review
xiaozhouhhh Jan 26, 2026
7630d11
Apply suggestions from review
xiaozhouhhh Mar 12, 2026
19ecbf9
Apply suggestions from review
xiaozhouhhh Mar 18, 2026
7eafaee
Update requirements
xiaozhouhhh Mar 18, 2026
92d1bae
Update the format
xiaozhouhhh Mar 19, 2026
5aa5d7e
Update the format
xiaozhouhhh Mar 19, 2026
35557f6
Update quality_scale.yaml
xiaozhouhhh Mar 19, 2026
0069cd7
Merge remote-tracking branch 'upstream/dev' into pr/solarmanpv/152525
xiaozhouhhh Mar 19, 2026
61af7f3
Update integrations.json
xiaozhouhhh Mar 20, 2026
aabf109
Update test_sensor.ambr
xiaozhouhhh Mar 20, 2026
08f7aab
Merge branch 'dev' into solarmanpv/dev
joostlek Mar 20, 2026
73a58e4
Fix
joostlek Mar 20, 2026
ced98e5
Update library release
xiaozhouhhh Mar 24, 2026
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
2 changes: 2 additions & 0 deletions CODEOWNERS

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

31 changes: 31 additions & 0 deletions homeassistant/components/solarman/__init__.py
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)
152 changes: 152 additions & 0 deletions homeassistant/components/solarman/config_flow.py
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:
Comment thread
joostlek marked this conversation as resolved.
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

Copilot AI Mar 20, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MODEL_NAME_MAP[self.model] will raise a KeyError if the device reports a model/type not present in the mapping (including unexpected capitalization/spacing). Since this value comes from the device, handle unknown models explicitly (e.g., abort with a clear “unsupported_device” reason or fall back to the raw model string for the title) to avoid crashing the config flow.

Copilot uses AI. Check for mistakes.

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})

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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 "",
},
)
23 changes: 23 additions & 0 deletions homeassistant/components/solarman/const.py
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",
}
50 changes: 50 additions & 0 deletions homeassistant/components/solarman/coordinator.py
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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. You don't initialize the client here
  2. But you could, and it would make it clearer IMO

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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:
Comment thread
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

Copilot AI Mar 24, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Only ConnectionError is converted into a translated UpdateFailed. If the library raises TimeoutError (which the tests already simulate during setup) or other common network exceptions, they’ll bypass the translated failure path and may produce noisier logs/less consistent availability behavior. Consider catching TimeoutError (and any other expected transport exceptions from solarman-opendata) and raising the same translated UpdateFailed.

Copilot uses AI. Check for mistakes.
34 changes: 34 additions & 0 deletions homeassistant/components/solarman/entity.py
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,
)
12 changes: 12 additions & 0 deletions homeassistant/components/solarman/manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"domain": "solarman",
"name": "Solarman",
Comment on lines +2 to +3

Copilot AI Mar 24, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PR description says “Add integration for Indevolt energy storage devices”, but the code adds the “Solarman” integration (domain solarman). Please update the PR description (and/or title) to match the actual integration being added so release notes and review context are accurate.

Copilot uses AI. Check for mistakes.
"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

Copilot AI Mar 20, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PR title mentions submitting a “gold-level configuration file”, but the integration is declared as quality_scale: "bronze" and a full new integration (code/tests/generated files) is being added. Please align the PR title/description with the actual scope, or update the quality-scale intent/claims accordingly.

Copilot uses AI. Check for mistakes.
}
Loading
Loading