Skip to content
Draft
Show file tree
Hide file tree
Changes from 2 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}"
Comment thread
MandusBorjesson marked this conversation as resolved.
) 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
Comment thread
MandusBorjesson marked this conversation as resolved.
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