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
1 change: 1 addition & 0 deletions .strict-typing
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ homeassistant.components.bang_olufsen.*
homeassistant.components.bayesian.*
homeassistant.components.binary_sensor.*
homeassistant.components.bitcoin.*
homeassistant.components.bitvis.*
homeassistant.components.blockchain.*
homeassistant.components.blue_current.*
homeassistant.components.blueprint.*
Expand Down
2 changes: 2 additions & 0 deletions CODEOWNERS

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

33 changes: 33 additions & 0 deletions homeassistant/components/bitvis/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
"""The Bitvis Power Hub integration."""

import logging

from homeassistant.const import CONF_HOST, CONF_PORT, Platform
from homeassistant.core import HomeAssistant

from .coordinator import BitvisConfigEntry, BitvisDataUpdateCoordinator

_LOGGER = logging.getLogger(__name__)

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


async def async_setup_entry(hass: HomeAssistant, entry: BitvisConfigEntry) -> bool:
"""Set up Bitvis Power Hub from a config entry."""
coordinator = BitvisDataUpdateCoordinator(
hass, entry, entry.data[CONF_HOST], entry.data[CONF_PORT]
)

await coordinator.async_config_entry_first_refresh()

entry.runtime_data = coordinator
entry.async_on_unload(coordinator.async_stop)

await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS)
Comment thread
MandusBorjesson marked this conversation as resolved.
Comment thread
MandusBorjesson marked this conversation as resolved.

return True


async def async_unload_entry(hass: HomeAssistant, entry: BitvisConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, _PLATFORMS)
137 changes: 137 additions & 0 deletions homeassistant/components/bitvis/config_flow.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
"""Config flow for the Bitvis Power Hub integration."""

import logging
from typing import Any, override

from bitvis_protobuf.utils import async_verify_udp_port_bindable, normalize_host
import voluptuous as vol

from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST, CONF_PORT
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo

from .const import DEFAULT_NAME, DEFAULT_PORT, DOMAIN, MODEL_NAME
from .coordinator import async_get_listener_registry

_LOGGER = logging.getLogger(__name__)

_SINGLE_INSTANCE_UNIQUE_ID = DOMAIN


async def _async_test_port(hass: HomeAssistant, port: int) -> None:
"""Verify the UDP port can be bound."""

if async_get_listener_registry(hass).has_listener(port):
return

await async_verify_udp_port_bindable(port)


class BitvisConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Bitvis Power Hub."""

def __init__(self) -> None:
"""Initialize the config flow."""
self._discovery_info: ZeroconfServiceInfo | None = None

def _get_friendly_name(self, name: str | None) -> str:
"""Return a user-friendly name derived from the zeroconf name."""
if not name:
return DEFAULT_NAME
instance = name.split(".", 1)[0]
return instance or DEFAULT_NAME

@override
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
errors: dict[str, str] = {}

if user_input is not None:
host = normalize_host(user_input[CONF_HOST])

await self.async_set_unique_id(_SINGLE_INSTANCE_UNIQUE_ID)
self._abort_if_unique_id_configured()
self._async_abort_entries_match({CONF_HOST: host})

try:
await _async_test_port(self.hass, DEFAULT_PORT)
except OSError:
errors["base"] = "cannot_connect"
else:
return self.async_create_entry(
title=MODEL_NAME,
data={
CONF_HOST: host,
CONF_PORT: DEFAULT_PORT,
},
)

data_schema = vol.Schema(
{
vol.Required(CONF_HOST): cv.string,
}
Comment thread
MandusBorjesson marked this conversation as resolved.
)

return self.async_show_form(
step_id="user",
data_schema=data_schema,
errors=errors,
)

@override
async def async_step_zeroconf(
self, discovery_info: ZeroconfServiceInfo
) -> ConfigFlowResult:
"""Handle zeroconf discovery."""
_LOGGER.debug("Discovered Bitvis Power Hub via Zeroconf: %s", discovery_info)

host = discovery_info.host

Comment thread
MandusBorjesson marked this conversation as resolved.
await self.async_set_unique_id(_SINGLE_INSTANCE_UNIQUE_ID)
self._abort_if_unique_id_configured()
Comment thread
MandusBorjesson marked this conversation as resolved.

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 instead of setting this, you can set "single_config_entry": true in the manifest. Is the reason we can't have multiple entries because of UDP?

self._async_abort_entries_match({CONF_HOST: host})

self._discovery_info = discovery_info

# Show confirmation to user
self.context["title_placeholders"] = {
"name": self._get_friendly_name(discovery_info.name),
"host": host,
}

return await self.async_step_zeroconf_confirm()

async def async_step_zeroconf_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Confirm discovery."""
if user_input is not None:
assert self._discovery_info is not None
host = self._discovery_info.host

try:
await _async_test_port(self.hass, DEFAULT_PORT)
except OSError:
return self.async_abort(reason="cannot_connect")

return self.async_create_entry(
title=self._get_friendly_name(self._discovery_info.name),
data={
Comment thread
MandusBorjesson marked this conversation as resolved.
CONF_HOST: host,
CONF_PORT: DEFAULT_PORT,
},
)
Comment thread
MandusBorjesson marked this conversation as resolved.

return self.async_show_form(
step_id="zeroconf_confirm",
description_placeholders={
"name": self._get_friendly_name(
self._discovery_info.name if self._discovery_info else None
),
"host": self._discovery_info.host if self._discovery_info else "",
},
)
19 changes: 19 additions & 0 deletions homeassistant/components/bitvis/const.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
"""Constants for the Bitvis Power Hub integration."""

from typing import TYPE_CHECKING

from homeassistant.util.hass_dict import HassKey

if TYPE_CHECKING:
from .coordinator import BitvisListenerRegistry

DOMAIN = "bitvis"
MANUFACTURER = "Bitvis"
MODEL_NAME = "Power Hub"

ZEROCONF_SERVICE_TYPE = "_powerhub._udp.local."

DEFAULT_NAME = "Bitvis Power Hub"
DEFAULT_PORT = 58220

DATA_LISTENER_REGISTRY: HassKey[BitvisListenerRegistry] = HassKey(DOMAIN)
Comment thread
MandusBorjesson marked this conversation as resolved.
184 changes: 184 additions & 0 deletions homeassistant/components/bitvis/coordinator.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
"""Data coordinator for Bitvis Power Hub."""

import asyncio
from dataclasses import dataclass
from datetime import datetime, timedelta
import logging
from typing import override

from bitvis_protobuf.listener import SharedListener
from bitvis_protobuf.parse import PayloadDiagnostic, PayloadSample
from bitvis_protobuf.utils import async_resolve_host

from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryError
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from homeassistant.util import dt as dt_util
from homeassistant.util.variance import ignore_variance

from .const import DATA_LISTENER_REGISTRY, DOMAIN

_LOGGER = logging.getLogger(__name__)

type BitvisConfigEntry = ConfigEntry[BitvisDataUpdateCoordinator]


def _uptime_to_boot_time(uptime_s: int) -> datetime:
"""Convert uptime in seconds to an absolute boot datetime."""
return dt_util.utcnow().replace(microsecond=0) - timedelta(seconds=uptime_s)


@dataclass(kw_only=True)
class BitvisData:
"""Data structure for Bitvis measurements."""

sample: PayloadSample | None = None
diagnostic: PayloadDiagnostic | None = None
mac_address: str | None = None
model_name: str | None = None
sw_version: str | None = None
boot_time: datetime | None = None


class BitvisListenerRegistry:
"""Registry that manages one shared UDP listener per port.

Stored at hass.data[DATA_LISTENER_REGISTRY] so all coordinators can
look it up without duplicating state-management logic.
"""

def __init__(self) -> None:
"""Initialize registry storage."""
self._listeners: dict[int, SharedListener] = {}
self._locks: dict[int, asyncio.Lock] = {}

async def async_get_or_create(self, port: int) -> SharedListener:
"""Return the listener for *port*, creating and starting it if needed."""
port_lock = self._locks.setdefault(port, asyncio.Lock())
async with port_lock:
if port not in self._listeners:
listener = SharedListener()
await listener.start(port)
self._listeners[port] = listener
return self._listeners[port]

async def async_remove_if_unused(self, port: int) -> None:
"""Stop and remove the listener for *port* when no coordinators remain."""
port_lock = self._locks.setdefault(port, asyncio.Lock())
async with port_lock:
listener = self._listeners.get(port)
if listener is None or not listener.is_empty:
return
await listener.stop()
del self._listeners[port]

def get(self, port: int) -> SharedListener | None:
"""Return an existing listener for *port*, or None."""
return self._listeners.get(port)

def has_listener(self, port: int) -> bool:
"""Return True if a listener is already active on *port*."""
return port in self._listeners


def async_get_listener_registry(hass: HomeAssistant) -> BitvisListenerRegistry:
"""Return (creating if needed) the Bitvis listener registry for this HA instance."""
if DATA_LISTENER_REGISTRY not in hass.data:
hass.data[DATA_LISTENER_REGISTRY] = BitvisListenerRegistry()
return hass.data[DATA_LISTENER_REGISTRY]


class BitvisDataUpdateCoordinator(DataUpdateCoordinator[BitvisData]):
"""Coordinator to manage data updates from UDP packets."""

def __init__(
self, hass: HomeAssistant, config_entry: BitvisConfigEntry, host: str, port: int
) -> None:
"""Initialize the coordinator."""
super().__init__(
hass,
_LOGGER,
name=DOMAIN,
config_entry=config_entry,
)
self.host = host
self.port = port
self._registered_ips: set[str] = set()
self._stable_boot_time = ignore_variance(
_uptime_to_boot_time, timedelta(minutes=5)
)
self.data = BitvisData()

@override
async def _async_setup(self) -> None:
"""Set up the coordinator by registering with the shared UDP listener."""
try:
self._registered_ips = await async_resolve_host(self.host)
listener_registry = async_get_listener_registry(self.hass)
listener = await listener_registry.async_get_or_create(self.port)
listener.register(self._registered_ips, self._handle_payload)
except (OSError, ValueError) as err:
await self.async_stop()
raise UpdateFailed(
f"Failed to start UDP listener on port {self.port}"
) from err
except RuntimeError as err:
await self.async_stop()
raise ConfigEntryError(
f"Failed to start UDP listener on port {self.port}"
) from err

async def async_stop(self) -> None:
"""Unregister from the shared listener, stopping it when no longer needed."""
if listener_registry := self.hass.data.get(DATA_LISTENER_REGISTRY):
if listener := listener_registry.get(self.port):
listener.unregister(self._registered_ips)
await listener_registry.async_remove_if_unused(self.port)

self._registered_ips = set()
_LOGGER.debug(
"Unregistered coordinator from shared UDP listener for port %s", self.port
)

@callback
def _handle_payload(
self,
payload: PayloadSample | PayloadDiagnostic,
addr: tuple[str, int],
) -> None:
"""Handle a parsed payload dispatched by the shared listener."""
_LOGGER.debug("Received payload from %s", addr)
if isinstance(payload, PayloadSample):
self._handle_sample(payload)
else:
self._handle_diagnostic(payload)

@callback
def _handle_sample(self, payload: PayloadSample) -> None:
"""Update sample data and notify listeners."""
self.data.sample = payload
self.async_set_updated_data(self.data)

@callback
def _handle_diagnostic(self, payload: PayloadDiagnostic) -> None:
"""Update diagnostic data and notify listeners."""
self.data.diagnostic = payload
diagnostic = payload.diagnostic
if diagnostic.HasField("device_info"):
device_info = diagnostic.device_info
self.data.mac_address = device_info.mac_address.hex(sep=":")
self.data.model_name = device_info.model_name
self.data.sw_version = device_info.sw_version
else:
self.data.mac_address = None
self.data.model_name = None
self.data.sw_version = None
self.data.boot_time = self._stable_boot_time(diagnostic.uptime_s)

self.async_set_updated_data(self.data)

@override
async def _async_update_data(self) -> BitvisData:
"""Return current data (updates are push-based via UDP datagrams)."""
return self.data
Loading
Loading