Skip to content
Draft
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
2 changes: 2 additions & 0 deletions CODEOWNERS

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

115 changes: 115 additions & 0 deletions homeassistant/components/powersensor/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
"""The Powersensor integration."""

import logging

from powersensor_local import VirtualHousehold

from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.loader import async_get_integration

from .config_flow import PowersensorConfigFlow
from .const import (
CFG_DEVICES,
CFG_ROLES,
DOMAIN,
ROLE_SOLAR,
RT_DISPATCHER,
RT_VHH,
RT_ZEROCONF,
)
from .powersensor_discovery_service import PowersensorDiscoveryService
from .powersensor_message_dispatcher import PowersensorMessageDispatcher

_LOGGER = logging.getLogger(__name__)

PLATFORMS: list[Platform] = [Platform.SENSOR]

#
# config entry.data structure (version 2.2):
# {
# devices = {
# mac = {
# name =,
# display_name =,
# mac =,
# host =,
# port =,
# }
# roles = {
# mac = role,
# }
# }
#


async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up integration from a config entry."""

hass.data.setdefault(DOMAIN, {})
hass.data[DOMAIN][entry.entry_id] = {}

integration = await async_get_integration(hass, DOMAIN)
manifest = integration.manifest

try:
# Create the zeroconf discovery service
zeroconf_domain: str = str(manifest["zeroconf"][0])
zeroconf_service = PowersensorDiscoveryService(hass, zeroconf_domain)
await zeroconf_service.start()

# Establish our virtual household
with_solar = ROLE_SOLAR in entry.data.get(CFG_ROLES, {}).values()
vhh = VirtualHousehold(with_solar)

# Set up message dispatcher
dispatcher = PowersensorMessageDispatcher(hass, entry, vhh)
for network_info in entry.data.get(CFG_DEVICES, {}).values():
await dispatcher.enqueue_plug_for_adding(network_info)
except Exception as err:
raise ConfigEntryNotReady(f"Unexpected error during setup: {err}") from err

Comment on lines +57 to +73
Copy link

Copilot AI Feb 23, 2026

Choose a reason for hiding this comment

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

async_setup_entry wraps the whole setup in except Exception and converts it to ConfigEntryNotReady. Catching all exceptions here can mask programming errors and cause the entry to retry forever. Prefer catching specific expected exceptions from zeroconf/PlugApi startup (e.g., timeouts/OSError) and let unexpected exceptions bubble up.

Copilot uses AI. Check for mistakes.
entry.runtime_data = {
RT_VHH: vhh,
RT_DISPATCHER: dispatcher,
RT_ZEROCONF: zeroconf_service,
}
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True


async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
_LOGGER.debug("Started unloading for %s", entry.entry_id)
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
if hasattr(entry, "runtime_data"):
if RT_DISPATCHER in entry.runtime_data:
await entry.runtime_data[RT_DISPATCHER].disconnect()
if RT_ZEROCONF in entry.runtime_data:
await entry.runtime_data[RT_ZEROCONF].stop()

if entry.entry_id in hass.data[DOMAIN]:
hass.data[DOMAIN].pop(entry.entry_id)

return unload_ok


async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Migrate old config entry."""
_LOGGER.debug("Upgrading config from %s.%s", entry.version, entry.minor_version)
if entry.version > PowersensorConfigFlow.VERSION:
# Downgrade from future version
return False

if entry.version == 1:
# Move device info into subkey
devices = {**entry.data}
new_data = {CFG_DEVICES: devices, CFG_ROLES: {}}
hass.config_entries.async_update_entry(
entry, data=new_data, version=2, minor_version=2
)

Comment thread
bookman-dius marked this conversation as resolved.
_LOGGER.debug("Upgrading config to %s.%s", entry.version, entry.minor_version)
return True
212 changes: 212 additions & 0 deletions homeassistant/components/powersensor/config_flow.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
"""Config flow for the integration."""

import logging
from typing import Any

import voluptuous as vol

from homeassistant import config_entries
from homeassistant.core import HomeAssistant
from homeassistant.config_entries import ConfigEntry, ConfigFlowResult
from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.selector import selector
from homeassistant.helpers.service_info import zeroconf
from homeassistant.helpers.translation import async_get_cached_translations

from .const import (
CFG_DEVICES,
CFG_ROLES,
DEFAULT_PORT,
DOMAIN,
ROLE_APPLIANCE,
ROLE_HOUSENET,
ROLE_SOLAR,
ROLE_UPDATE_SIGNAL,
ROLE_WATER,
RT_DISPATCHER,
)

_LOGGER = logging.getLogger(__name__)


def get_translated_sensor_name(hass: HomeAssistant, config_entry: ConfigEntry, mac: str) -> str|None:
"""Helper to neatly format the translated name, for user input."""
translations = async_get_cached_translations(
hass, hass.config.language, "device", config_entry.domain
)
format_string = translations.get(
"component.powersensor.device.unknown_sensor.name",
"Powersensor Sensor (ID: {id})"
)
return format_string.replace("{id}", mac)


class PowersensorConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle a config flow."""

VERSION = 2
Copy link

Copilot AI Feb 23, 2026

Choose a reason for hiding this comment

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

The config flow sets VERSION = 2 but does not define MINOR_VERSION. Your migration code and tests expect a minor_version of 2 (i.e., config version 2.2). Define MINOR_VERSION = 2 (and keep it in sync with async_migrate_entry) so newly created entries start at the latest schema version.

Suggested change
VERSION = 2
VERSION = 2
MINOR_VERSION = 2

Copilot uses AI. Check for mistakes.
MINOR_VERSION = 2

def __init__(self) -> None:
"""Initialize the config flow."""

Comment on lines +50 to +52
Copy link

Copilot AI Feb 26, 2026

Choose a reason for hiding this comment

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

Empty __init__ method that provides no functionality. The __init__ method on lines 37-38 is empty and serves no purpose. It can be removed entirely as Python will use the default constructor from the parent class.

Suggested change
def __init__(self) -> None:
"""Initialize the config flow."""

Copilot uses AI. Check for mistakes.
async def async_step_reconfigure(
self, user_input: dict | None = None
) -> ConfigFlowResult:
"""Handle reconfigure step. The primary use case is adding missing roles to sensors."""
entry = self.hass.config_entries.async_get_entry(self.context["entry_id"])
if entry is None or not hasattr(entry, "runtime_data"):
return self.async_abort(reason="cannot_reconfigure")

dispatcher = entry.runtime_data[RT_DISPATCHER]
if dispatcher is None:
return self.async_abort(reason="cannot_reconfigure")

Comment on lines +58 to +64
Copy link

Copilot AI Feb 23, 2026

Choose a reason for hiding this comment

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

Config flow abort reasons are currently full sentences (e.g. "Cannot reconfigure..." and "Plug firmware not compatible"). Abort reason values are expected to be stable translation keys (backed by strings.json), otherwise users will see raw strings and translations cannot be provided. Replace these with short keys (e.g. cannot_reconfigure, incompatible_firmware) and add corresponding translations.

Copilot uses AI. Check for mistakes.
mac2name = {mac: get_translated_sensor_name(self.hass, entry, mac) for mac in dispatcher.sensors}

unknown = "unknown"
if user_input is not None:
name2mac = {name: mac for mac, name in mac2name.items()}
for name, role in user_input.items():
mac = name2mac.get(name)
if role == unknown:
role = None
_LOGGER.debug("Applying %s to %s", role, mac)
async_dispatcher_send(self.hass, ROLE_UPDATE_SIGNAL, mac, role)
return self.async_abort(reason="roles_applied")

Comment thread
bookman-dius marked this conversation as resolved.
sensor_roles = {}
description_placeholders = {}
for sensor_mac in dispatcher.sensors:
role = entry.data.get(CFG_ROLES, {}).get(sensor_mac, unknown)
sel = selector(
{
"select": {
"options": [
# Note: these strings are NOT subject to translation
ROLE_HOUSENET,
ROLE_SOLAR,
ROLE_WATER,
ROLE_APPLIANCE,
unknown,
],
"mode": "dropdown",
}
}
)
sensor_name = mac2name[sensor_mac]
sensor_roles[
vol.Optional(
sensor_name,
description={"suggested_value": role, "name": sensor_name},
)
] = sel
description_placeholders[sensor_name] = sensor_name

description_placeholders["device_count"] = str(len(sensor_roles))
description_placeholders["docs_url"] = (
"https://dius.github.io/homeassistant-powersensor/data.html#virtual-household"
)

return self.async_show_form(
step_id="reconfigure",
data_schema=vol.Schema(sensor_roles),
description_placeholders={
"device_count": str(len(sensor_roles)),
"docs_url": "https://dius.github.io/homeassistant-powersensor/data.html#virtual-household",
},
)

async def _common_setup(self):
if DOMAIN not in self.hass.data:
self.hass.data[DOMAIN] = {}

discovered_plugs_key = "discovered_plugs"
if discovered_plugs_key not in self.hass.data[DOMAIN]:
self.hass.data[DOMAIN][discovered_plugs_key] = {}

# register a unique id for the single power sensor entry
await self.async_set_unique_id(DOMAIN)

# abort now if configuration is on going in another thread (i.e. this thread isn't the first)
if self._async_current_entries() or self._async_in_progress():
_LOGGER.warning("Aborting - found existing entry!")
return self.async_abort(reason="already_configured")

display_name = "⚡ Powersensor 🔌\n"
self.context.update({"title_placeholders": {"name": display_name}})
return None
Comment thread
bookman-dius marked this conversation as resolved.

async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle zeroconf discovery."""
if result := await self._common_setup():
return result
return await self.async_step_manual_confirm()

async def async_step_zeroconf(
self, discovery_info: zeroconf.ZeroconfServiceInfo
) -> ConfigFlowResult:
"""Handle zeroconf discovery."""
if result := await self._common_setup():
return result
discovered_plugs_key = "discovered_plugs"
host = discovery_info.host
port = discovery_info.port or DEFAULT_PORT
properties = discovery_info.properties or {}
mac = None
if "id" in properties:
mac = properties["id"].strip()
else:
return self.async_abort(reason="firmware_not_compatible")

Comment thread
bookman-dius marked this conversation as resolved.
display_name = f"🔌 Mac({mac})"
plug_data = {
"host": host,
"port": port,
"display_name": display_name,
"mac": mac,
"name": discovery_info.name,
}

if mac in self.hass.data[DOMAIN][discovered_plugs_key]:
_LOGGER.debug("Mac found existing in data!")
else:
self.hass.data[DOMAIN][discovered_plugs_key][mac] = plug_data

return await self.async_step_discovery_confirm()
Comment on lines +148 to +178
Copy link

Copilot AI Feb 23, 2026

Choose a reason for hiding this comment

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

_common_setup() can return an abort FlowResult, but async_step_zeroconf ignores the return value and continues. Return early when _common_setup() aborts to avoid continuing the flow when an entry already exists / another flow is in progress.

Copilot uses AI. Check for mistakes.

async def async_step_confirm(
self, step_id: str, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"Confirm user wants to add the powersensor integration with the plugs stored in hass.data['powersensor']."
if user_input is not None:
_LOGGER.debug(
"Creating entry with discovered plugs: %s",
self.hass.data[DOMAIN]["discovered_plugs"],
)
return self.async_create_entry(
Comment on lines +184 to +189
Copy link

Copilot AI Feb 26, 2026

Choose a reason for hiding this comment

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

This debug log call passes a dict as the message (_LOGGER.debug(self.hass.data[DOMAIN]["discovered_plugs"])) which bypasses lazy formatting and produces an unstructured message. Use a descriptive format string with %s so the message is clear and avoids unnecessary stringification when debug logging is disabled.

Copilot uses AI. Check for mistakes.
title="Powersensor",
data={
CFG_DEVICES: self.hass.data[DOMAIN]["discovered_plugs"],
CFG_ROLES: {},
},
)
return self.async_show_form(step_id=step_id)

async def async_step_discovery_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"Confirm user wants to add the powersensor integration with the plugs discovered."
return await self.async_step_confirm(
step_id="discovery_confirm", user_input=user_input
)

async def async_step_manual_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"Confirm user wants to add the powersensor integration with manual configuration (typically no plugs available)."
return await self.async_step_confirm(
step_id="manual_confirm", user_input=user_input
)
35 changes: 35 additions & 0 deletions homeassistant/components/powersensor/const.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
"""Constants for the Powersensor integration."""

DOMAIN = "powersensor"
DEFAULT_NAME = "Powersensor"
DEFAULT_PORT = 49476

# Internal signals
CREATE_PLUG_SIGNAL = f"{DOMAIN}_create_plug"
CREATE_SENSOR_SIGNAL = f"{DOMAIN}_create_sensor"
DATA_UPDATE_SIGNAL_FMT_MAC_EVENT = f"{DOMAIN}_data_update_%s_%s"
ROLE_UPDATE_SIGNAL = f"{DOMAIN}_update_role"
PLUG_ADDED_TO_HA_SIGNAL = f"{DOMAIN}_plug_added_to_homeassistant"
SENSOR_ADDED_TO_HA_SIGNAL = f"{DOMAIN}_sensor_added_to_homeassistant"
UPDATE_VHH_SIGNAL = f"{DOMAIN}_update_vhh"
ZEROCONF_ADD_PLUG_SIGNAL = f"{DOMAIN}_zeroconf_add_plug"
ZEROCONF_REMOVE_PLUG_SIGNAL = f"{DOMAIN}_zeroconf_remove_plug"
ZEROCONF_UPDATE_PLUG_SIGNAL = f"{DOMAIN}_zeroconf_update_plug"

# Config entry keys
CFG_DEVICES = "devices"
CFG_ROLES = "roles"

# Role names (fixed, as-received from plug API)
ROLE_APPLIANCE = "appliance"
ROLE_HOUSENET = "house-net"
ROLE_SOLAR = "solar"
ROLE_WATER = "water"

# runtime_data keys
RT_DISPATCHER = "dispatcher"
RT_VHH = "vhh"
RT_VHH_LOCK = "vhh_update_lock"
RT_VHH_MAINS_ADDED = "vhh_main_added"
RT_VHH_SOLAR_ADDED = "vhh_solar_added"
RT_ZEROCONF = "zeroconf"
17 changes: 17 additions & 0 deletions homeassistant/components/powersensor/manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
"domain": "powersensor",
"name": "Powersensor",
"codeowners": [
"@bookman-dius",
"@jmattsson"
],
"config_flow": true,
"dependencies": [],
"documentation": "https://www.home-assistant.io/integrations/powersensor",
"integration_type": "hub",
"iot_class": "local_push",
"quality_scale": "bronze",
"requirements": ["powersensor-local==2.1.1"],
"single_config_entry": true,
"zeroconf": ["_powersensor._udp.local."]
}
Comment thread
bookman-dius marked this conversation as resolved.
Loading
Loading