mirror of
https://github.com/home-assistant/core.git
synced 2025-06-24 09:01:55 +02:00
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:
2
CODEOWNERS
generated
2
CODEOWNERS
generated
@ -1672,6 +1672,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/vallox/ @andre-richter @slovdahl @viiru- @yozik04
|
||||
/homeassistant/components/valve/ @home-assistant/core
|
||||
/tests/components/valve/ @home-assistant/core
|
||||
/homeassistant/components/vegehub/ @ghowevege
|
||||
/tests/components/vegehub/ @ghowevege
|
||||
/homeassistant/components/velbus/ @Cereal2nd @brefra
|
||||
/tests/components/velbus/ @Cereal2nd @brefra
|
||||
/homeassistant/components/velux/ @Julius2342 @DeerMaximum @pawlizio
|
||||
|
103
homeassistant/components/vegehub/__init__.py
Normal file
103
homeassistant/components/vegehub/__init__.py
Normal 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
|
168
homeassistant/components/vegehub/config_flow.py
Normal file
168
homeassistant/components/vegehub/config_flow.py
Normal 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)
|
9
homeassistant/components/vegehub/const.py
Normal file
9
homeassistant/components/vegehub/const.py
Normal 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"
|
52
homeassistant/components/vegehub/coordinator.py
Normal file
52
homeassistant/components/vegehub/coordinator.py
Normal 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)
|
28
homeassistant/components/vegehub/entity.py
Normal file
28
homeassistant/components/vegehub/entity.py
Normal 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,
|
||||
)
|
12
homeassistant/components/vegehub/manifest.json
Normal file
12
homeassistant/components/vegehub/manifest.json
Normal 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."]
|
||||
}
|
84
homeassistant/components/vegehub/quality_scale.yaml
Normal file
84
homeassistant/components/vegehub/quality_scale.yaml
Normal 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
|
94
homeassistant/components/vegehub/sensor.py
Normal file
94
homeassistant/components/vegehub/sensor.py
Normal 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)
|
44
homeassistant/components/vegehub/strings.json
Normal file
44
homeassistant/components/vegehub/strings.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
1
homeassistant/generated/config_flows.py
generated
1
homeassistant/generated/config_flows.py
generated
@ -683,6 +683,7 @@ FLOWS = {
|
||||
"uptimerobot",
|
||||
"v2c",
|
||||
"vallox",
|
||||
"vegehub",
|
||||
"velbus",
|
||||
"velux",
|
||||
"venstar",
|
||||
|
@ -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",
|
||||
|
5
homeassistant/generated/zeroconf.py
generated
5
homeassistant/generated/zeroconf.py
generated
@ -915,6 +915,11 @@ ZEROCONF = {
|
||||
"name": "uzg-01*",
|
||||
},
|
||||
],
|
||||
"_vege._tcp.local.": [
|
||||
{
|
||||
"domain": "vegehub",
|
||||
},
|
||||
],
|
||||
"_viziocast._tcp.local.": [
|
||||
{
|
||||
"domain": "vizio",
|
||||
|
3
requirements_all.txt
generated
3
requirements_all.txt
generated
@ -3034,6 +3034,9 @@ vacuum-map-parser-roborock==0.1.4
|
||||
# homeassistant.components.vallox
|
||||
vallox-websocket-api==5.3.0
|
||||
|
||||
# homeassistant.components.vegehub
|
||||
vegehub==0.1.24
|
||||
|
||||
# homeassistant.components.rdw
|
||||
vehicle==2.2.2
|
||||
|
||||
|
3
requirements_test_all.txt
generated
3
requirements_test_all.txt
generated
@ -2499,6 +2499,9 @@ vacuum-map-parser-roborock==0.1.4
|
||||
# homeassistant.components.vallox
|
||||
vallox-websocket-api==5.3.0
|
||||
|
||||
# homeassistant.components.vegehub
|
||||
vegehub==0.1.24
|
||||
|
||||
# homeassistant.components.rdw
|
||||
vehicle==2.2.2
|
||||
|
||||
|
14
tests/components/vegehub/__init__.py
Normal file
14
tests/components/vegehub/__init__.py
Normal file
@ -0,0 +1,14 @@
|
||||
"""Tests for the Vegetronix VegeHub integration."""
|
||||
|
||||
from homeassistant.components.vegehub.coordinator import VegeHubConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
|
||||
async def init_integration(
|
||||
hass: HomeAssistant,
|
||||
config_entry: VegeHubConfigEntry,
|
||||
) -> None:
|
||||
"""Load the VegeHub integration."""
|
||||
config_entry.add_to_hass(hass)
|
||||
assert await hass.config_entries.async_setup(config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
82
tests/components/vegehub/conftest.py
Normal file
82
tests/components/vegehub/conftest.py
Normal file
@ -0,0 +1,82 @@
|
||||
"""Fixtures and test data for VegeHub test methods."""
|
||||
|
||||
from collections.abc import Generator
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from homeassistant.const import (
|
||||
CONF_DEVICE,
|
||||
CONF_HOST,
|
||||
CONF_IP_ADDRESS,
|
||||
CONF_MAC,
|
||||
CONF_WEBHOOK_ID,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from tests.common import MockConfigEntry, load_fixture
|
||||
|
||||
TEST_IP = "192.168.0.100"
|
||||
TEST_UNIQUE_ID = "aabbccddeeff"
|
||||
TEST_SERVER = "http://example.com"
|
||||
TEST_MAC = "A1:B2:C3:D4:E5:F6"
|
||||
TEST_SIMPLE_MAC = "A1B2C3D4E5F6"
|
||||
TEST_HOSTNAME = "VegeHub"
|
||||
TEST_WEBHOOK_ID = "webhook_id"
|
||||
HUB_DATA = {
|
||||
"first_boot": False,
|
||||
"page_updated": False,
|
||||
"error_message": 0,
|
||||
"num_channels": 2,
|
||||
"num_actuators": 2,
|
||||
"version": "3.4.5",
|
||||
"agenda": 1,
|
||||
"batt_v": 9.0,
|
||||
"num_vsens": 0,
|
||||
"is_ac": 0,
|
||||
"has_sd": 0,
|
||||
"on_ap": 0,
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def mock_vegehub() -> Generator[Any, Any, Any]:
|
||||
"""Mock the VegeHub library."""
|
||||
with patch(
|
||||
"homeassistant.components.vegehub.config_flow.VegeHub", autospec=True
|
||||
) as mock_vegehub_class:
|
||||
mock_instance = mock_vegehub_class.return_value
|
||||
# Simulate successful API calls
|
||||
mock_instance.retrieve_mac_address = AsyncMock(return_value=True)
|
||||
mock_instance.setup = AsyncMock(return_value=True)
|
||||
|
||||
# Mock properties
|
||||
mock_instance.ip_address = TEST_IP
|
||||
mock_instance.mac_address = TEST_SIMPLE_MAC
|
||||
mock_instance.unique_id = TEST_UNIQUE_ID
|
||||
mock_instance.url = f"http://{TEST_IP}"
|
||||
mock_instance.info = load_fixture("vegehub/info_hub.json")
|
||||
mock_instance.num_sensors = 2
|
||||
mock_instance.num_actuators = 2
|
||||
mock_instance.sw_version = "3.4.5"
|
||||
|
||||
yield mock_instance
|
||||
|
||||
|
||||
@pytest.fixture(name="mocked_config_entry")
|
||||
async def fixture_mocked_config_entry(hass: HomeAssistant) -> MockConfigEntry:
|
||||
"""Create a mock VegeHub config entry."""
|
||||
return MockConfigEntry(
|
||||
domain="vegehub",
|
||||
data={
|
||||
CONF_MAC: TEST_SIMPLE_MAC,
|
||||
CONF_IP_ADDRESS: TEST_IP,
|
||||
CONF_HOST: TEST_HOSTNAME,
|
||||
CONF_DEVICE: HUB_DATA,
|
||||
CONF_WEBHOOK_ID: TEST_WEBHOOK_ID,
|
||||
},
|
||||
unique_id=TEST_SIMPLE_MAC,
|
||||
title="VegeHub",
|
||||
entry_id="12345",
|
||||
)
|
24
tests/components/vegehub/fixtures/info_hub.json
Normal file
24
tests/components/vegehub/fixtures/info_hub.json
Normal file
@ -0,0 +1,24 @@
|
||||
{
|
||||
"hub": {
|
||||
"first_boot": false,
|
||||
"page_updated": false,
|
||||
"error_message": 0,
|
||||
"num_channels": 2,
|
||||
"num_actuators": 2,
|
||||
"version": "3.4.5",
|
||||
"agenda": 1,
|
||||
"batt_v": 9.0,
|
||||
"num_vsens": 0,
|
||||
"is_ac": 0,
|
||||
"has_sd": 0,
|
||||
"on_ap": 0
|
||||
},
|
||||
"wifi": {
|
||||
"ssid": "YourWiFiName",
|
||||
"strength": "-29",
|
||||
"chan": "4",
|
||||
"ip": "192.168.0.100",
|
||||
"status": "3",
|
||||
"mac_addr": "A1:B2:C3:D4:E5:F6"
|
||||
}
|
||||
}
|
160
tests/components/vegehub/snapshots/test_sensor.ambr
Normal file
160
tests/components/vegehub/snapshots/test_sensor.ambr
Normal file
@ -0,0 +1,160 @@
|
||||
# serializer version: 1
|
||||
# name: test_sensor_entities[sensor.vegehub_battery_voltage-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
'area_id': None,
|
||||
'capabilities': None,
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'sensor',
|
||||
'entity_category': None,
|
||||
'entity_id': 'sensor.vegehub_battery_voltage',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'options': dict({
|
||||
'sensor': dict({
|
||||
'suggested_display_precision': 1,
|
||||
}),
|
||||
}),
|
||||
'original_device_class': <SensorDeviceClass.VOLTAGE: 'voltage'>,
|
||||
'original_icon': None,
|
||||
'original_name': 'Battery voltage',
|
||||
'platform': 'vegehub',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'battery_volts',
|
||||
'unique_id': 'A1B2C3D4E5F6_battery',
|
||||
'unit_of_measurement': <UnitOfElectricPotential.VOLT: 'V'>,
|
||||
})
|
||||
# ---
|
||||
# name: test_sensor_entities[sensor.vegehub_battery_voltage-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'device_class': 'voltage',
|
||||
'friendly_name': 'VegeHub Battery voltage',
|
||||
'unit_of_measurement': <UnitOfElectricPotential.VOLT: 'V'>,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'sensor.vegehub_battery_voltage',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': '1.330000043',
|
||||
})
|
||||
# ---
|
||||
# name: test_sensor_entities[sensor.vegehub_input_1-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
'area_id': None,
|
||||
'capabilities': None,
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'sensor',
|
||||
'entity_category': None,
|
||||
'entity_id': 'sensor.vegehub_input_1',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'options': dict({
|
||||
'sensor': dict({
|
||||
'suggested_display_precision': 2,
|
||||
}),
|
||||
}),
|
||||
'original_device_class': <SensorDeviceClass.VOLTAGE: 'voltage'>,
|
||||
'original_icon': None,
|
||||
'original_name': 'Input 1',
|
||||
'platform': 'vegehub',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'analog_sensor',
|
||||
'unique_id': 'A1B2C3D4E5F6_analog_0',
|
||||
'unit_of_measurement': <UnitOfElectricPotential.VOLT: 'V'>,
|
||||
})
|
||||
# ---
|
||||
# name: test_sensor_entities[sensor.vegehub_input_1-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'device_class': 'voltage',
|
||||
'friendly_name': 'VegeHub Input 1',
|
||||
'unit_of_measurement': <UnitOfElectricPotential.VOLT: 'V'>,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'sensor.vegehub_input_1',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': '1.5',
|
||||
})
|
||||
# ---
|
||||
# name: test_sensor_entities[sensor.vegehub_input_2-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
'area_id': None,
|
||||
'capabilities': None,
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'sensor',
|
||||
'entity_category': None,
|
||||
'entity_id': 'sensor.vegehub_input_2',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'options': dict({
|
||||
'sensor': dict({
|
||||
'suggested_display_precision': 2,
|
||||
}),
|
||||
}),
|
||||
'original_device_class': <SensorDeviceClass.VOLTAGE: 'voltage'>,
|
||||
'original_icon': None,
|
||||
'original_name': 'Input 2',
|
||||
'platform': 'vegehub',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'analog_sensor',
|
||||
'unique_id': 'A1B2C3D4E5F6_analog_1',
|
||||
'unit_of_measurement': <UnitOfElectricPotential.VOLT: 'V'>,
|
||||
})
|
||||
# ---
|
||||
# name: test_sensor_entities[sensor.vegehub_input_2-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'device_class': 'voltage',
|
||||
'friendly_name': 'VegeHub Input 2',
|
||||
'unit_of_measurement': <UnitOfElectricPotential.VOLT: 'V'>,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'sensor.vegehub_input_2',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': '1.45599997',
|
||||
})
|
||||
# ---
|
385
tests/components/vegehub/test_config_flow.py
Normal file
385
tests/components/vegehub/test_config_flow.py
Normal file
@ -0,0 +1,385 @@
|
||||
"""Tests for VegeHub config flow."""
|
||||
|
||||
from collections.abc import Generator
|
||||
from ipaddress import ip_address
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.components import zeroconf
|
||||
from homeassistant.components.vegehub.const import DOMAIN
|
||||
from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF
|
||||
from homeassistant.const import (
|
||||
CONF_DEVICE,
|
||||
CONF_HOST,
|
||||
CONF_IP_ADDRESS,
|
||||
CONF_MAC,
|
||||
CONF_WEBHOOK_ID,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.data_entry_flow import FlowResultType
|
||||
|
||||
from .conftest import TEST_HOSTNAME, TEST_IP, TEST_SIMPLE_MAC
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
DISCOVERY_INFO = zeroconf.ZeroconfServiceInfo(
|
||||
ip_address=ip_address(TEST_IP),
|
||||
ip_addresses=[ip_address(TEST_IP)],
|
||||
port=80,
|
||||
hostname=f"{TEST_HOSTNAME}.local.",
|
||||
type="mock_type",
|
||||
name="myVege",
|
||||
properties={
|
||||
zeroconf.ATTR_PROPERTIES_ID: TEST_HOSTNAME,
|
||||
"version": "5.1.1",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def mock_setup_entry() -> Generator[Any, Any, Any]:
|
||||
"""Prevent the actual integration from being set up."""
|
||||
with (
|
||||
patch("homeassistant.components.vegehub.async_setup_entry", return_value=True),
|
||||
):
|
||||
yield
|
||||
|
||||
|
||||
# Tests for flows where the user manually inputs an IP address
|
||||
async def test_user_flow_success(hass: HomeAssistant) -> None:
|
||||
"""Test the user flow with successful configuration."""
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_USER}
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "user"
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], {CONF_IP_ADDRESS: TEST_IP}
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert result["title"] == TEST_IP
|
||||
assert result["data"][CONF_MAC] == TEST_SIMPLE_MAC
|
||||
assert result["data"][CONF_IP_ADDRESS] == TEST_IP
|
||||
assert result["data"][CONF_DEVICE] is not None
|
||||
assert result["data"][CONF_WEBHOOK_ID] is not None
|
||||
|
||||
# Since this is user flow, there is no hostname, so hostname should be the IP address
|
||||
assert result["data"][CONF_HOST] == TEST_IP
|
||||
assert result["result"].unique_id == TEST_SIMPLE_MAC
|
||||
|
||||
# Confirm that the entry was created
|
||||
entries = hass.config_entries.async_entries(domain=DOMAIN)
|
||||
assert len(entries) == 1
|
||||
|
||||
|
||||
async def test_user_flow_cannot_connect(
|
||||
hass: HomeAssistant,
|
||||
mock_vegehub: MagicMock,
|
||||
) -> None:
|
||||
"""Test the user flow with bad data."""
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_USER}
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "user"
|
||||
|
||||
mock_vegehub.mac_address = ""
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], {CONF_IP_ADDRESS: TEST_IP}
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["errors"]["base"] == "cannot_connect"
|
||||
|
||||
mock_vegehub.mac_address = TEST_SIMPLE_MAC
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], {CONF_IP_ADDRESS: TEST_IP}
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("side_effect", "expected_error"),
|
||||
[
|
||||
(TimeoutError, "timeout_connect"),
|
||||
(ConnectionError, "cannot_connect"),
|
||||
],
|
||||
)
|
||||
async def test_user_flow_device_bad_connection_then_success(
|
||||
hass: HomeAssistant,
|
||||
mock_vegehub: MagicMock,
|
||||
side_effect: Exception,
|
||||
expected_error: str,
|
||||
) -> None:
|
||||
"""Test the user flow with a timeout."""
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_USER}
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "user"
|
||||
|
||||
mock_vegehub.setup.side_effect = side_effect
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], {CONF_IP_ADDRESS: TEST_IP}
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "user"
|
||||
assert "errors" in result
|
||||
assert result["errors"] == {"base": expected_error}
|
||||
|
||||
mock_vegehub.setup.side_effect = None # Clear the error
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], {CONF_IP_ADDRESS: TEST_IP}
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert result["title"] == TEST_IP
|
||||
assert result["data"][CONF_IP_ADDRESS] == TEST_IP
|
||||
assert result["data"][CONF_MAC] == TEST_SIMPLE_MAC
|
||||
|
||||
|
||||
async def test_user_flow_no_ip_entered(hass: HomeAssistant) -> None:
|
||||
"""Test the user flow with blank IP."""
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_USER}
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "user"
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], {CONF_IP_ADDRESS: ""}
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["errors"]["base"] == "invalid_ip"
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], {CONF_IP_ADDRESS: TEST_IP}
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
|
||||
|
||||
async def test_user_flow_bad_ip_entered(hass: HomeAssistant) -> None:
|
||||
"""Test the user flow with badly formed IP."""
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_USER}
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "user"
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], {CONF_IP_ADDRESS: "192.168.0"}
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["errors"]["base"] == "invalid_ip"
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], {CONF_IP_ADDRESS: TEST_IP}
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
|
||||
|
||||
async def test_user_flow_duplicate_device(
|
||||
hass: HomeAssistant, mocked_config_entry: MockConfigEntry
|
||||
) -> None:
|
||||
"""Test when user flow gets the same device twice."""
|
||||
|
||||
mocked_config_entry.add_to_hass(hass)
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], {CONF_IP_ADDRESS: TEST_IP}
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
|
||||
|
||||
# Tests for flows that start in zeroconf
|
||||
async def test_zeroconf_flow_success(hass: HomeAssistant) -> None:
|
||||
"""Test the zeroconf discovery flow with successful configuration."""
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_ZEROCONF}, data=DISCOVERY_INFO
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "zeroconf_confirm"
|
||||
|
||||
# Display the confirmation form
|
||||
result = await hass.config_entries.flow.async_configure(result["flow_id"], None)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "zeroconf_confirm"
|
||||
|
||||
# Proceed to creating the entry
|
||||
result = await hass.config_entries.flow.async_configure(result["flow_id"], {})
|
||||
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert result["title"] == TEST_HOSTNAME
|
||||
assert result["data"][CONF_HOST] == TEST_HOSTNAME
|
||||
assert result["data"][CONF_MAC] == TEST_SIMPLE_MAC
|
||||
assert result["result"].unique_id == TEST_SIMPLE_MAC
|
||||
|
||||
|
||||
async def test_zeroconf_flow_abort_device_asleep(
|
||||
hass: HomeAssistant,
|
||||
mock_vegehub: MagicMock,
|
||||
) -> None:
|
||||
"""Test when zeroconf tries to contact a device that is asleep."""
|
||||
|
||||
mock_vegehub.retrieve_mac_address.side_effect = TimeoutError
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_ZEROCONF}, data=DISCOVERY_INFO
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "timeout_connect"
|
||||
|
||||
|
||||
async def test_zeroconf_flow_abort_same_id(
|
||||
hass: HomeAssistant,
|
||||
mocked_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test when zeroconf gets the same device twice."""
|
||||
|
||||
mocked_config_entry.add_to_hass(hass)
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_ZEROCONF}, data=DISCOVERY_INFO
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
|
||||
|
||||
async def test_zeroconf_flow_abort_cannot_connect(
|
||||
hass: HomeAssistant,
|
||||
mock_vegehub: MagicMock,
|
||||
) -> None:
|
||||
"""Test when zeroconf gets bad data."""
|
||||
|
||||
mock_vegehub.mac_address = ""
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_ZEROCONF}, data=DISCOVERY_INFO
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "cannot_connect"
|
||||
|
||||
|
||||
async def test_zeroconf_flow_abort_cannot_connect_404(
|
||||
hass: HomeAssistant,
|
||||
mock_vegehub: MagicMock,
|
||||
) -> None:
|
||||
"""Test when zeroconf gets bad responses."""
|
||||
|
||||
mock_vegehub.retrieve_mac_address.side_effect = ConnectionError
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_ZEROCONF}, data=DISCOVERY_INFO
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "cannot_connect"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("side_effect", "expected_error"),
|
||||
[
|
||||
(TimeoutError, "timeout_connect"),
|
||||
(ConnectionError, "cannot_connect"),
|
||||
],
|
||||
)
|
||||
async def test_zeroconf_flow_device_error_response(
|
||||
hass: HomeAssistant,
|
||||
mock_vegehub: MagicMock,
|
||||
side_effect: Exception,
|
||||
expected_error: str,
|
||||
) -> None:
|
||||
"""Test when zeroconf detects the device, but the communication fails at setup."""
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_ZEROCONF}, data=DISCOVERY_INFO
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "zeroconf_confirm"
|
||||
|
||||
# Part way through the process, we simulate getting bad responses
|
||||
mock_vegehub.setup.side_effect = side_effect
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(result["flow_id"], {})
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["errors"]["base"] == expected_error
|
||||
|
||||
mock_vegehub.setup.side_effect = None
|
||||
|
||||
# Proceed to creating the entry
|
||||
result = await hass.config_entries.flow.async_configure(result["flow_id"], {})
|
||||
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
|
||||
|
||||
async def test_zeroconf_flow_update_ip_hostname(
|
||||
hass: HomeAssistant,
|
||||
mocked_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test when zeroconf gets the same device with a new IP and hostname."""
|
||||
|
||||
mocked_config_entry.add_to_hass(hass)
|
||||
|
||||
# Use the same discovery info, but change the IP and hostname
|
||||
new_ip = "192.168.0.99"
|
||||
new_hostname = "new_hostname"
|
||||
new_discovery_info = zeroconf.ZeroconfServiceInfo(
|
||||
ip_address=ip_address(new_ip),
|
||||
ip_addresses=[ip_address(new_ip)],
|
||||
port=DISCOVERY_INFO.port,
|
||||
hostname=f"{new_hostname}.local.",
|
||||
type=DISCOVERY_INFO.type,
|
||||
name=DISCOVERY_INFO.name,
|
||||
properties=DISCOVERY_INFO.properties,
|
||||
)
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": SOURCE_ZEROCONF},
|
||||
data=new_discovery_info,
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
|
||||
# Check if the original config entry has been updated
|
||||
entries = hass.config_entries.async_entries(domain=DOMAIN)
|
||||
assert len(entries) == 1
|
||||
assert mocked_config_entry.data[CONF_IP_ADDRESS] == new_ip
|
||||
assert mocked_config_entry.data[CONF_HOST] == new_hostname
|
63
tests/components/vegehub/test_sensor.py
Normal file
63
tests/components/vegehub/test_sensor.py
Normal file
@ -0,0 +1,63 @@
|
||||
"""Unit tests for the VegeHub integration's sensor.py."""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
from syrupy.assertion import SnapshotAssertion
|
||||
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
|
||||
from . import init_integration
|
||||
from .conftest import TEST_SIMPLE_MAC, TEST_WEBHOOK_ID
|
||||
|
||||
from tests.common import MockConfigEntry, snapshot_platform
|
||||
from tests.typing import ClientSessionGenerator
|
||||
|
||||
UPDATE_DATA = {
|
||||
"api_key": "",
|
||||
"mac": TEST_SIMPLE_MAC,
|
||||
"error_code": 0,
|
||||
"sensors": [
|
||||
{"slot": 1, "samples": [{"v": 1.5, "t": "2025-01-15T16:51:23Z"}]},
|
||||
{"slot": 2, "samples": [{"v": 1.45599997, "t": "2025-01-15T16:51:23Z"}]},
|
||||
{"slot": 3, "samples": [{"v": 1.330000043, "t": "2025-01-15T16:51:23Z"}]},
|
||||
{"slot": 4, "samples": [{"v": 0.075999998, "t": "2025-01-15T16:51:23Z"}]},
|
||||
{"slot": 5, "samples": [{"v": 9.314800262, "t": "2025-01-15T16:51:23Z"}]},
|
||||
],
|
||||
"send_time": 1736959883,
|
||||
"wifi_str": -27,
|
||||
}
|
||||
|
||||
|
||||
async def test_sensor_entities(
|
||||
hass: HomeAssistant,
|
||||
snapshot: SnapshotAssertion,
|
||||
hass_client_no_auth: ClientSessionGenerator,
|
||||
entity_registry: er.EntityRegistry,
|
||||
mocked_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test all entities."""
|
||||
|
||||
with patch("homeassistant.components.vegehub.PLATFORMS", [Platform.SENSOR]):
|
||||
await init_integration(hass, mocked_config_entry)
|
||||
|
||||
assert TEST_WEBHOOK_ID in hass.data["webhook"], "Webhook was not registered"
|
||||
|
||||
# Verify the webhook handler
|
||||
webhook_info = hass.data["webhook"][TEST_WEBHOOK_ID]
|
||||
assert webhook_info["handler"], "Webhook handler is not set"
|
||||
|
||||
client = await hass_client_no_auth()
|
||||
resp = await client.post(f"/api/webhook/{TEST_WEBHOOK_ID}", json=UPDATE_DATA)
|
||||
|
||||
# Send the same update again so that the coordinator modifies existing data
|
||||
# instead of creating new data.
|
||||
resp = await client.post(f"/api/webhook/{TEST_WEBHOOK_ID}", json=UPDATE_DATA)
|
||||
|
||||
# Wait for remaining tasks to complete.
|
||||
await hass.async_block_till_done()
|
||||
assert resp.status == 200, f"Unexpected status code: {resp.status}"
|
||||
await snapshot_platform(
|
||||
hass, entity_registry, snapshot, mocked_config_entry.entry_id
|
||||
)
|
Reference in New Issue
Block a user