New core integration for VegeHub (#129598)

* Initial commit for VegeHub integration

* Moved several pieces to library, continuing.

* All device contact moved to library

* Updated documentation link

* Fixed an error in strings.json

* Removed commented out code and unused file

* Removed unneeded info logging, and a few missed lines of commented code

* Added/removed comments for clarity

* Converted integration to use webhooks.

* Update __init__.py to remove unnecessary code.

Co-authored-by: Josef Zweck <24647999+zweckj@users.noreply.github.com>

* Remove unnecessary code from config_flow.py

Co-authored-by: Josef Zweck <24647999+zweckj@users.noreply.github.com>

* Simplify unique_id assertion.

* Switch to CONF_ constant for user input

* Added explanation for passing exception.

* Got rid of try-except, since I don't really handle the exceptions her anyway.

* Moved data transform to vegehub library

* Changed references to use HA constants.

* Fixed assigning and returning _attr properties.

* Moved temperature sensor transform to the library.

* Moved sensor names to strings.json

* Made webhook names unique to avoid collisions when multiple devices are added.

* Converted to using entry.runtime_data

* Removed options flow for first PR

* Removed switch support to limit PR to one platform

* Removed/updated outdated tests

* Update homeassistant/components/vegehub/__init__.py

Co-authored-by: Josef Zweck <josef@zweck.dev>

* Got rid of strings in favor of constants.

* Got rid of unnecessary check

* Imported constant directly.

* Added custom type for entry

* Expanded CONF_ constants into sensor.py

* Get rid of extra `str` and `get`

Co-authored-by: Josef Zweck <josef@zweck.dev>

* Added type to errors

* Added try/except to MAC address retrieval

* Moved functionality out of ConfigFlow that shouldn't have been there

* Removed IP:MAC tracking from ConfigFlow

* Added retries to VegeHub PyPI package, and implemented them in integration

* Removed different sensor types for now

* Fixed typo

* Changed abort to error

* Fixed error reporting in config flow

* Further simplify sensor.py to handle all sensors the same

* Added comment to clarify

* Got rid of unused constants

* Removed unused strings in strings.json

* Added quality_scale.yaml

* Fixed problems in sensor init

* Moved config url and sw version storage into vegehub package

* Get rid of extra declaration

Co-authored-by: Josef Zweck <josef@zweck.dev>

* Removed unnecessary task

* Fix type for entry

* Added a test before setup

* Fixed tests and got test coverage of config flow to 100%

* Fixed test descriptions

* Implemented a coordinator

* Removed unused property

* Fixed a few minor issues with the coordinator implementation

* Removed unused function

* Fixed some tests

* Trying to fix a problem with re-initialization when server reboots. Mostly working.

* Moved hub.setup from async_setup_entry to config flow to avoid running it on system reboot

* Delete tests/testing_config/.storage/http.auth

* Fixed errors in coordinator.py

* Added IP validation for manual input IP addresses

* Moved data into self._discovered to simplify

* Removed redundant typing

* Shortened sensor unique ID and added coordinator handler

* Added call to super()._handle_coordinator_update() so state gets handled correctly

* Fixed == and is

* Got rid of "slot" and moved functionality to lib

* Got rid of mocked aiohttp calls in favor of just mocking the vegehub library

* Rewrote config flow to make more sense.

* Changed order of data and data_description

* Changes to sensor.py

* Got rid of async_update_data in coordinator and moved async_set_updated_data into webhook callback

* Changed sensor updates so that they keep using last known values if update doesn't contain data for them

* Changed config flow to use homeassistant.helpers.service_info zeroconf instead of homeassistant.components zeroconf

* Added types to test parameters

* Changes and notes in config_flow.py

* Minor fix to get existing tests working before making changes to tests

* Removed unused data and simplified data passing

* Fixed tests, removed unused data, moved sensor tests to snapshots

* Mocked async_setup_entry and async_unload_entry

* Eliminated retry step so that retries just happen in the user flow or zeroconf_confirm

* Bumped the library version

* Bumped library version again

* Changed test-before-setup test

* Improved use of coordinator

* Almost done reworking tests. A few more changes still needed.

* Added via device to sensor.py and key reference to strings.json

* Webhook tests are almost, but not quite, working

* Fully functional again

* Change error to assert

* made identifiers and via_device the same

* made the via_device just be the mac

* Fixed strings.json and updated translations

* Fixed test_sensor.py

* Cleaned up tests and added autouse to several fixtures to simplify

* Switched from error to assert, and added exemption to quality scale.

* Cleaned up some tests and added update of IP if unique ID of discovered device is the same.

* Improved zeroconfig to update IP and hostname, and added a test to make sure those work.

* Fixed a comment.

* Improved ip/hostname update test.

* Changed Hub to VegeHub in strings.json for clarity.

* Switched to using a base entity to simplify and make adding platforms in the future easier.

* Moved the vegehub object into the coordinator to simplify.

* Removed actuators from sensors, and added unique name for battery sensor

* Changed coordinator to manage its own data, changed sensors to use descriptions and return their value as a property

* Updated data retrieval keys

* Minor updates to several files

* Fixed a few things for pytest

* Reverted to explicit check for None for pytest

* Fixed a comment and a variable name

* Fixed a comment

* Fix

* Bumped depenency version to eliminate pytest from dependencies.

---------

Co-authored-by: Josef Zweck <24647999+zweckj@users.noreply.github.com>
Co-authored-by: Josef Zweck <josef@zweck.dev>
Co-authored-by: Joostlek <joostlek@outlook.com>
This commit is contained in:
Geoff
2025-06-23 22:55:34 -06:00
committed by GitHub
parent 0cf7952964
commit eff35e93bd
21 changed files with 1342 additions and 0 deletions

View File

@@ -0,0 +1,103 @@
"""The Vegetronix VegeHub integration."""
from collections.abc import Awaitable, Callable
from http import HTTPStatus
from typing import Any
from aiohttp.hdrs import METH_POST
from aiohttp.web import Request, Response
from vegehub import VegeHub
from homeassistant.components.http import HomeAssistantView
from homeassistant.components.webhook import (
async_register as webhook_register,
async_unregister as webhook_unregister,
)
from homeassistant.const import (
CONF_DEVICE,
CONF_IP_ADDRESS,
CONF_MAC,
CONF_WEBHOOK_ID,
EVENT_HOMEASSISTANT_STOP,
)
from homeassistant.core import HomeAssistant
from .const import DOMAIN, NAME, PLATFORMS
from .coordinator import VegeHubConfigEntry, VegeHubCoordinator
async def async_setup_entry(hass: HomeAssistant, entry: VegeHubConfigEntry) -> bool:
"""Set up VegeHub from a config entry."""
device_mac = entry.data[CONF_MAC]
assert entry.unique_id
vegehub = VegeHub(
entry.data[CONF_IP_ADDRESS],
device_mac,
entry.unique_id,
info=entry.data[CONF_DEVICE],
)
# Initialize runtime data
entry.runtime_data = VegeHubCoordinator(
hass=hass, config_entry=entry, vegehub=vegehub
)
async def unregister_webhook(_: Any) -> None:
webhook_unregister(hass, entry.data[CONF_WEBHOOK_ID])
async def register_webhook() -> None:
webhook_name = f"{NAME} {device_mac}"
webhook_register(
hass,
DOMAIN,
webhook_name,
entry.data[CONF_WEBHOOK_ID],
get_webhook_handler(device_mac, entry.entry_id, entry.runtime_data),
allowed_methods=[METH_POST],
)
entry.async_on_unload(
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, unregister_webhook)
)
# Now add in all the entities for this device.
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
await register_webhook()
return True
async def async_unload_entry(hass: HomeAssistant, entry: VegeHubConfigEntry) -> bool:
"""Unload a VegeHub config entry."""
webhook_unregister(hass, entry.data[CONF_WEBHOOK_ID])
# Unload platforms
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
def get_webhook_handler(
device_mac: str, entry_id: str, coordinator: VegeHubCoordinator
) -> Callable[[HomeAssistant, str, Request], Awaitable[Response | None]]:
"""Return webhook handler."""
async def async_webhook_handler(
hass: HomeAssistant, webhook_id: str, request: Request
) -> Response | None:
# Handle http post calls to the path.
if not request.body_exists:
return HomeAssistantView.json(
result="No Body", status_code=HTTPStatus.BAD_REQUEST
)
data = await request.json()
if coordinator:
await coordinator.update_from_webhook(data)
return HomeAssistantView.json(result="OK", status_code=HTTPStatus.OK)
return async_webhook_handler

View File

@@ -0,0 +1,168 @@
"""Config flow for the VegeHub integration."""
import logging
from typing import Any
from vegehub import VegeHub
import voluptuous as vol
from homeassistant.components.webhook import (
async_generate_id as webhook_generate_id,
async_generate_url as webhook_generate_url,
)
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import (
CONF_DEVICE,
CONF_HOST,
CONF_IP_ADDRESS,
CONF_MAC,
CONF_WEBHOOK_ID,
)
from homeassistant.helpers.service_info import zeroconf
from homeassistant.util.network import is_ip_address
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
class VegeHubConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for VegeHub integration."""
_hub: VegeHub
_hostname: str
webhook_id: str
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle a flow initiated by the user."""
errors: dict[str, str] = {}
if user_input is not None:
if not is_ip_address(user_input[CONF_IP_ADDRESS]):
# User-supplied IP address is invalid.
errors["base"] = "invalid_ip"
if not errors:
self._hub = VegeHub(user_input[CONF_IP_ADDRESS])
self._hostname = self._hub.ip_address
errors = await self._setup_device()
if not errors:
# Proceed to create the config entry
return await self._create_entry()
# Show the form to allow the user to manually enter the IP address
return self.async_show_form(
step_id="user",
data_schema=vol.Schema(
{
vol.Required(CONF_IP_ADDRESS): str,
}
),
errors=errors,
)
async def async_step_zeroconf(
self, discovery_info: zeroconf.ZeroconfServiceInfo
) -> ConfigFlowResult:
"""Handle zeroconf discovery."""
# Extract the IP address from the zeroconf discovery info
device_ip = discovery_info.host
self._async_abort_entries_match({CONF_IP_ADDRESS: device_ip})
self._hostname = discovery_info.hostname.removesuffix(".local.")
config_url = f"http://{discovery_info.hostname[:-1]}:{discovery_info.port}"
# Create a VegeHub object to interact with the device
self._hub = VegeHub(device_ip)
try:
await self._hub.retrieve_mac_address(retries=2)
except ConnectionError:
return self.async_abort(reason="cannot_connect")
except TimeoutError:
return self.async_abort(reason="timeout_connect")
if not self._hub.mac_address:
return self.async_abort(reason="cannot_connect")
# Check if this device already exists
await self.async_set_unique_id(self._hub.mac_address)
self._abort_if_unique_id_configured(
updates={CONF_IP_ADDRESS: device_ip, CONF_HOST: self._hostname}
)
# Add title and configuration URL to the context so that the device discovery
# tile has the correct title, and a "Visit Device" link available.
self.context.update(
{
"title_placeholders": {"host": self._hostname + " (" + device_ip + ")"},
"configuration_url": (config_url),
}
)
# If the device is new, allow the user to continue setup
return await self.async_step_zeroconf_confirm()
async def async_step_zeroconf_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle user confirmation for a discovered device."""
errors: dict[str, str] = {}
if user_input is not None:
errors = await self._setup_device()
if not errors:
return await self._create_entry()
# Show the confirmation form
self._set_confirm_only()
return self.async_show_form(step_id="zeroconf_confirm", errors=errors)
async def _setup_device(self) -> dict[str, str]:
"""Set up the VegeHub device."""
errors: dict[str, str] = {}
self.webhook_id = webhook_generate_id()
webhook_url = webhook_generate_url(
self.hass,
self.webhook_id,
allow_external=False,
allow_ip=True,
)
# Send the webhook address to the hub as its server target.
# This step can happen in the init, because that gets executed
# every time Home Assistant starts up, and this step should
# only happen in the initial setup of the VegeHub.
try:
await self._hub.setup("", webhook_url, retries=1)
except ConnectionError:
errors["base"] = "cannot_connect"
except TimeoutError:
errors["base"] = "timeout_connect"
if not self._hub.mac_address:
errors["base"] = "cannot_connect"
return errors
async def _create_entry(self) -> ConfigFlowResult:
"""Create a config entry for the device."""
# Check if this device already exists
await self.async_set_unique_id(self._hub.mac_address)
self._abort_if_unique_id_configured()
# Save Hub info to be used later when defining the VegeHub object
info_data = {
CONF_IP_ADDRESS: self._hub.ip_address,
CONF_HOST: self._hostname,
CONF_MAC: self._hub.mac_address,
CONF_DEVICE: self._hub.info,
CONF_WEBHOOK_ID: self.webhook_id,
}
# Create the config entry for the new device
return self.async_create_entry(title=self._hostname, data=info_data)

View File

@@ -0,0 +1,9 @@
"""Constants for the Vegetronix VegeHub integration."""
from homeassistant.const import Platform
DOMAIN = "vegehub"
NAME = "VegeHub"
PLATFORMS = [Platform.SENSOR]
MANUFACTURER = "vegetronix"
MODEL = "VegeHub"

View File

@@ -0,0 +1,52 @@
"""Coordinator for the Vegetronix VegeHub."""
from __future__ import annotations
import logging
from typing import Any
from vegehub import VegeHub, update_data_to_ha_dict
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
_LOGGER = logging.getLogger(__name__)
type VegeHubConfigEntry = ConfigEntry[VegeHub]
class VegeHubCoordinator(DataUpdateCoordinator[dict[str, Any]]):
"""The DataUpdateCoordinator for VegeHub."""
config_entry: VegeHubConfigEntry
def __init__(
self, hass: HomeAssistant, config_entry: VegeHubConfigEntry, vegehub: VegeHub
) -> None:
"""Initialize VegeHub data coordinator."""
super().__init__(
hass,
_LOGGER,
name=f"{config_entry.unique_id} DataUpdateCoordinator",
config_entry=config_entry,
)
self.vegehub = vegehub
self.device_id = config_entry.unique_id
assert self.device_id is not None, "Config entry is missing unique_id"
async def update_from_webhook(self, data: dict) -> None:
"""Process and update data from webhook."""
sensor_data = update_data_to_ha_dict(
data,
self.vegehub.num_sensors or 0,
self.vegehub.num_actuators or 0,
self.vegehub.is_ac or False,
)
if self.data:
existing_data: dict = self.data
existing_data.update(sensor_data)
if sensor_data:
self.async_set_updated_data(existing_data)
else:
self.async_set_updated_data(sensor_data)

View File

@@ -0,0 +1,28 @@
"""Base entity for VegeHub."""
from homeassistant.const import CONF_HOST, CONF_MAC
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import MANUFACTURER, MODEL
from .coordinator import VegeHubCoordinator
class VegeHubEntity(CoordinatorEntity[VegeHubCoordinator]):
"""Defines a base VegeHub entity."""
_attr_has_entity_name = True
def __init__(self, coordinator: VegeHubCoordinator) -> None:
"""Initialize the entity."""
super().__init__(coordinator)
config_entry = coordinator.config_entry
self._mac_address = config_entry.data[CONF_MAC]
self._attr_device_info = DeviceInfo(
connections={(CONNECTION_NETWORK_MAC, self._mac_address)},
name=config_entry.data[CONF_HOST],
manufacturer=MANUFACTURER,
model=MODEL,
sw_version=coordinator.vegehub.sw_version,
configuration_url=coordinator.vegehub.url,
)

View File

@@ -0,0 +1,12 @@
{
"domain": "vegehub",
"name": "Vegetronix VegeHub",
"codeowners": ["@ghowevege"],
"config_flow": true,
"dependencies": ["http", "webhook"],
"documentation": "https://www.home-assistant.io/integrations/vegehub",
"iot_class": "local_push",
"quality_scale": "bronze",
"requirements": ["vegehub==0.1.24"],
"zeroconf": ["_vege._tcp.local."]
}

View File

@@ -0,0 +1,84 @@
rules:
# Bronze
action-setup:
status: exempt
comment: |
This integration does not provide additional actions.
appropriate-polling:
status: exempt
comment: |
This integration does not poll.
brands: done
common-modules: done
config-flow-test-coverage: done
config-flow: done
dependency-transparency: done
docs-actions:
status: exempt
comment: |
This integration does not provide additional actions.
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
entity-event-setup:
status: exempt
comment: |
Entities of this integration do not explicitly subscribe to events.
entity-unique-id: done
has-entity-name: done
runtime-data: done
test-before-configure: done
test-before-setup:
status: exempt
comment: |
It is possible for this device to be offline at setup time and still be functioning correctly. It can not be tested at setup.
unique-config-entry: done
# Silver
action-exceptions: todo
config-entry-unloading: done
docs-configuration-parameters:
status: exempt
comment: No options to configure
docs-installation-parameters: todo
entity-unavailable: todo
integration-owner: todo
log-when-unavailable: todo
parallel-updates: todo
reauthentication-flow:
status: exempt
comment: |
This integration does not require authentication.
test-coverage: todo
# Gold
devices: done
diagnostics: todo
discovery-update-info: done
discovery: done
docs-data-update: todo
docs-examples: todo
docs-known-limitations: todo
docs-supported-devices: todo
docs-supported-functions: todo
docs-troubleshooting: todo
docs-use-cases: todo
dynamic-devices:
status: exempt
comment: |
This integration has a fixed single device.
entity-category: todo
entity-device-class: done
entity-disabled-by-default: done
entity-translations: todo
exception-translations: todo
icon-translations: todo
reconfiguration-flow: todo
repair-issues: todo
stale-devices:
status: exempt
comment: |
This integration has a fixed single device.
# Platinum
async-dependency: done
inject-websession: todo
strict-typing: todo

View File

@@ -0,0 +1,94 @@
"""Sensor configuration for VegeHub integration."""
from itertools import count
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
)
from homeassistant.const import UnitOfElectricPotential
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import VegeHubConfigEntry, VegeHubCoordinator
from .entity import VegeHubEntity
SENSOR_TYPES: dict[str, SensorEntityDescription] = {
"analog_sensor": SensorEntityDescription(
key="analog_sensor",
translation_key="analog_sensor",
device_class=SensorDeviceClass.VOLTAGE,
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
suggested_display_precision=2,
),
"battery_volts": SensorEntityDescription(
key="battery_volts",
translation_key="battery_volts",
device_class=SensorDeviceClass.VOLTAGE,
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
suggested_display_precision=1,
),
}
async def async_setup_entry(
hass: HomeAssistant,
config_entry: VegeHubConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Vegetronix sensors from a config entry."""
sensors: list[VegeHubSensor] = []
coordinator = config_entry.runtime_data
sensor_index = count(0)
# Add each analog sensor input
for _i in range(coordinator.vegehub.num_sensors):
sensor = VegeHubSensor(
index=next(sensor_index),
coordinator=coordinator,
description=SENSOR_TYPES["analog_sensor"],
)
sensors.append(sensor)
# Add the battery sensor
if not coordinator.vegehub.is_ac:
sensors.append(
VegeHubSensor(
index=next(sensor_index),
coordinator=coordinator,
description=SENSOR_TYPES["battery_volts"],
)
)
async_add_entities(sensors)
class VegeHubSensor(VegeHubEntity, SensorEntity):
"""Class for VegeHub Analog Sensors."""
def __init__(
self,
index: int,
coordinator: VegeHubCoordinator,
description: SensorEntityDescription,
) -> None:
"""Initialize the sensor."""
super().__init__(coordinator)
self.entity_description = description
# Set data key for pulling data from the coordinator
if description.key == "battery_volts":
self.data_key = "battery"
else:
self.data_key = f"analog_{index}"
self._attr_translation_placeholders = {"index": str(index + 1)}
self._attr_unique_id = f"{self._mac_address}_{self.data_key}"
self._attr_available = False
@property
def native_value(self) -> float | None:
"""Return the sensor's current value."""
if self.coordinator.data is None:
return None
return self.coordinator.data.get(self.data_key)

View File

@@ -0,0 +1,44 @@
{
"title": "VegeHub",
"config": {
"flow_title": "{host}",
"step": {
"user": {
"title": "Set up VegeHub",
"description": "Do you want to set up this VegeHub?",
"data": {
"ip_address": "[%key:common::config_flow::data::ip%]"
},
"data_description": {
"ip_address": "IP address of target VegeHub"
}
},
"zeroconf_confirm": {
"title": "[%key:component::vegehub::config::step::user::title%]",
"description": "[%key:component::vegehub::config::step::user::description%]"
}
},
"error": {
"cannot_connect": "Failed to connect. Ensure VegeHub is awake, and try again.",
"timeout_connect": "Timeout establishing connection. Ensure VegeHub is awake, and try again.",
"invalid_ip": "Invalid IPv4 address."
},
"abort": {
"cannot_connect": "Failed to connect to the device. Please try again.",
"timeout_connect": "Timed out connecting. Ensure VegeHub is awake, and try again.",
"already_in_progress": "Device already detected. Check discovered devices.",
"already_configured": "Device is already configured.",
"unknown_error": "An unknown error has occurred."
}
},
"entity": {
"sensor": {
"analog_sensor": {
"name": "Input {index}"
},
"battery_volts": {
"name": "Battery voltage"
}
}
}
}

View File

@@ -683,6 +683,7 @@ FLOWS = {
"uptimerobot",
"v2c",
"vallox",
"vegehub",
"velbus",
"velux",
"venstar",

View File

@@ -7123,6 +7123,11 @@
"config_flow": false,
"iot_class": "cloud_polling"
},
"vegehub": {
"integration_type": "hub",
"config_flow": true,
"iot_class": "local_push"
},
"velbus": {
"name": "Velbus",
"integration_type": "hub",
@@ -7922,6 +7927,7 @@
"trend",
"uptime",
"utility_meter",
"vegehub",
"version",
"waze_travel_time",
"workday",

View File

@@ -915,6 +915,11 @@ ZEROCONF = {
"name": "uzg-01*",
},
],
"_vege._tcp.local.": [
{
"domain": "vegehub",
},
],
"_viziocast._tcp.local.": [
{
"domain": "vizio",