diff --git a/CODEOWNERS b/CODEOWNERS index 022eda001233e8..df5137917f764a 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1598,6 +1598,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 diff --git a/homeassistant/components/vegehub/__init__.py b/homeassistant/components/vegehub/__init__.py new file mode 100644 index 00000000000000..2481fde4cfb832 --- /dev/null +++ b/homeassistant/components/vegehub/__init__.py @@ -0,0 +1,149 @@ +"""The Vegetronix VegeHub integration.""" + +from collections.abc import Awaitable, Callable +from http import HTTPStatus +import logging +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.config_entries import ConfigEntry +from homeassistant.const import CONF_WEBHOOK_ID, EVENT_HOMEASSISTANT_STOP +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from .const import DOMAIN, NAME, PLATFORMS + +_LOGGER = logging.getLogger(__name__) + + +# The integration is only set up through the UI (config flow) +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up VegeHub from a config entry.""" + + # Register the device in the device registry + device_registry = dr.async_get(hass) + + device_mac = str(entry.data.get("mac_address")) + device_ip = str(entry.data.get("ip_addr")) + + assert entry.unique_id + + if device_registry.async_get_device(identifiers={(DOMAIN, entry.entry_id)}): + _LOGGER.error("Device %s is already registered", entry.entry_id) + return False + + # Register the device + device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, device_mac)}, + identifiers={(DOMAIN, device_mac)}, + manufacturer="Vegetronix", + model="VegeHub", + name=entry.data.get("hostname"), + sw_version=entry.data.get("sw_ver"), + configuration_url=entry.data.get("config_url"), + ) + + # Initialize runtime data + entry.runtime_data = VegeHub(device_ip, device_mac, entry.unique_id) + + 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), + allowed_methods=[METH_POST], + ) + _LOGGER.debug( + "Registered VegeHub webhook at hass: %s", entry.data.get("webhook_url") + ) + + 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) + + entry.async_create_background_task( + hass, register_webhook(), "vegehub_register_webhook" + ) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a VegeHub config entry.""" + + # Unload platforms + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + +def get_webhook_handler( + device_mac: str, entry_id: str +) -> 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() + + # Process sensor data + if "sensors" in data: + for sensor in data["sensors"]: + slot = sensor.get("slot") + latest_sample = sensor["samples"][-1] + value = latest_sample["v"] + + # Use the slot number and key to find entity + entity_id = f"vegehub_{device_mac}_{slot}".lower() + + # Update entity with the new sensor data + await _update_sensor_entity(hass, value, entity_id, entry_id) + + return HomeAssistantView.json(result="OK", status_code=HTTPStatus.OK) + + return async_webhook_handler + + +async def _update_sensor_entity( + hass: HomeAssistant, value: float, entity_id: str, entry_id: str +): + """Update the corresponding Home Assistant entity with the latest sensor value.""" + entry = hass.config_entries.async_get_entry(entry_id) + if entry is None: + _LOGGER.error("Entry %s not found", entry_id) + return + + # Find the sensor entity and update its state + entity = None + try: + entity = entry.runtime_data.entities.get(entity_id) + if not entity: + _LOGGER.error("Sensor entity %s not found", entity_id) + else: + await entity.async_update_sensor(value) + except Exception as e: + _LOGGER.error("Sensor entity %s not found: %s", entity_id, e) + raise diff --git a/homeassistant/components/vegehub/config_flow.py b/homeassistant/components/vegehub/config_flow.py new file mode 100644 index 00000000000000..cc394fdd5c016c --- /dev/null +++ b/homeassistant/components/vegehub/config_flow.py @@ -0,0 +1,192 @@ +"""Config flow for the VegeHub integration.""" + +import logging +from typing import Any + +from vegehub import VegeHub +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.components import zeroconf +from homeassistant.components.webhook import ( + async_generate_id as webhook_generate_id, + async_generate_url as webhook_generate_url, +) +from homeassistant.config_entries import ConfigFlowResult +from homeassistant.const import CONF_IP_ADDRESS, CONF_WEBHOOK_ID + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +ip_dict: dict[str, str] = {} + + +class VegeHubConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for VegeHub integration.""" + + def __init__(self) -> None: + """Initialize the VegeHub config flow.""" + self._hub: VegeHub | None = None + self._hostname: str = "" + self._properties: dict = {} + self._config_url: str = "" + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial confirmation step with no inputs.""" + errors = {} + + if user_input is not None: + if CONF_IP_ADDRESS in user_input and self._hub is None: + # When the user has input the IP manually, we need to gather more information + # from the Hub before we can continue setup. + self._hub = VegeHub(str(user_input.get(CONF_IP_ADDRESS))) + + await self._hub.retrieve_mac_address() + + if len(self._hub.mac_address) <= 0: + _LOGGER.error( + "Failed to get device config from %s", self._hub.ip_address + ) + return self.async_abort(reason="cannot_connect") + + try: + # Check to see if this MAC address is already in the list. + entry = list(ip_dict.keys())[ + list(ip_dict.values()).index(self._hub.mac_address) + ] + # If the mac address is on the list, pop it so we can give it a new IP + if entry: + ip_dict.pop(entry) + except ValueError: + # If the MAC address is not in the list, a ValueError will be thrown, + # which just means that we don't need to remove it from the list. + pass + + # Add a new entry to the list of IP:MAC pairs that we have seen + ip_dict[self._hub.ip_address] = self._hub.mac_address + + # Set the unique ID for the manual configuration + await self.async_set_unique_id(self._hub.mac_address) + # Abort if this device is already configured + self._abort_if_unique_id_configured() + + self._hostname = self._hub.ip_address + self._config_url = f"http://{self._hub.ip_address}" + + if self._hub is not None: + webhook_id = webhook_generate_id() + webhook_url = webhook_generate_url( + self.hass, + webhook_id, + allow_external=False, + allow_ip=True, + ) + + # Send the webhook address to the hub as its server target + await self._hub.setup( + "", + webhook_url, + ) + + info_data = self._hub.info + + info_data["mac_address"] = self._hub.mac_address + info_data["ip_addr"] = self._hub.ip_address + info_data["hostname"] = self._hostname + info_data["sw_ver"] = self._properties.get("version") + info_data["config_url"] = self._config_url + info_data["webhook_url"] = webhook_url + info_data[CONF_WEBHOOK_ID] = webhook_id + + # Create a task to ask the hub for an update when it can, + # so that we have initial data + self.hass.async_create_task(self._hub.request_update()) + + # Create the config entry for the new device + return self.async_create_entry( + title=f"{self._hostname}", data=info_data + ) + + _LOGGER.error("No IP address for device") + errors["base"] = "cannot_connect" + + if self._hub is None: + # 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={}, + ) + + # If we already have an IP address, we can just ask the user if they want to continue + return self.async_show_form(step_id="user", 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 + + # Keep track of which IP addresses have already had their MAC addresses + # discovered. This allows us to skip the MAC address retrieval for devices + # that don't need it. This stops us from waking up a hub every time we see + # it come on-line. + have_mac = False + if device_ip in ip_dict: + have_mac = True + + self._hostname = discovery_info.hostname.removesuffix(".local.") + self._config_url = ( + f"http://{discovery_info.hostname[:-1]}:{discovery_info.port}" + ) + self._properties = discovery_info.properties + + if not have_mac: + self._hub = VegeHub(device_ip) + + await self._hub.retrieve_mac_address() + + if len(self._hub.mac_address) <= 0: + _LOGGER.error("Failed to get device config from %s", device_ip) + return self.async_abort(reason="cannot_connect") + + try: + # Check to see if this MAC address is already in the list. + entry = list(ip_dict.keys())[ + list(ip_dict.values()).index(self._hub.mac_address) + ] + if entry: + # If it's already in the list, then it is connected to another + # IP address. Remove that entry. + ip_dict.pop(entry) + except ValueError: + _LOGGER.info("Zeroconf found new device at %s", device_ip) + + # Add a new entry to the list of IP:MAC pairs that we have seen + ip_dict[device_ip] = self._hub.mac_address + else: + self._hub = VegeHub(device_ip, mac_address=ip_dict[device_ip]) + + # Check if this device already exists + await self.async_set_unique_id(self._hub.mac_address) + self._abort_if_unique_id_configured() + + self.context.update( + { + "title_placeholders": {"host": self._hostname + " (" + device_ip + ")"}, + "configuration_url": (self._config_url), + } + ) + + # If the device is new, allow the user to continue setup + return await self.async_step_user() diff --git a/homeassistant/components/vegehub/const.py b/homeassistant/components/vegehub/const.py new file mode 100644 index 00000000000000..aa44682b1b55fe --- /dev/null +++ b/homeassistant/components/vegehub/const.py @@ -0,0 +1,18 @@ +"""Constants for the Vegetronix VegeHub integration.""" + +from homeassistant.const import Platform + +DOMAIN = "vegehub" +NAME = "VegeHub" +PLATFORMS = [Platform.SENSOR] +MANUFACTURER = "vegetronix" +MODEL = "VegeHub" +OPTION_DATA_TYPE_CHOICES = [ + "Raw Voltage", + "VH400", + "THERM200", +] +CHAN_TYPE_SENSOR = "sensor" +CHAN_TYPE_ACTUATOR = "actuator" +CHAN_TYPE_BATTERY = "battery" +API_PATH = "/api/vegehub/update" diff --git a/homeassistant/components/vegehub/manifest.json b/homeassistant/components/vegehub/manifest.json new file mode 100644 index 00000000000000..4275a1137b68e9 --- /dev/null +++ b/homeassistant/components/vegehub/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "vegehub", + "name": "Vegetronix VegeHub", + "codeowners": ["@ghowevege"], + "config_flow": true, + "dependencies": ["http"], + "documentation": "https://www.home-assistant.io/integrations/vegehub/", + "iot_class": "local_push", + "requirements": ["vegehub==0.1.11"], + "zeroconf": ["_vege._tcp.local."] +} diff --git a/homeassistant/components/vegehub/sensor.py b/homeassistant/components/vegehub/sensor.py new file mode 100644 index 00000000000000..4cd448643696d5 --- /dev/null +++ b/homeassistant/components/vegehub/sensor.py @@ -0,0 +1,141 @@ +"""Sensor configuration for VegeHub integration.""" + +from vegehub import therm200_transform, vh400_transform + +from homeassistant.components.sensor import SensorDeviceClass, SensorEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import PERCENTAGE, UnitOfElectricPotential, UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import ( + CHAN_TYPE_BATTERY, + CHAN_TYPE_SENSOR, + DOMAIN, + MANUFACTURER, + MODEL, + OPTION_DATA_TYPE_CHOICES, +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Vegetronix sensors from a config entry.""" + sensors = [] + mac_address = str(config_entry.data.get("mac_address")) + ip_addr = str(config_entry.data.get("ip_addr")) + num_sensors = int(config_entry.data.get("hub", {}).get("num_channels") or 0) + is_ac = int(config_entry.data.get("hub", {}).get("is_ac") or 0) + + # We add up the number of sensors, plus the number of actuators, then add one + # for battery reading, and one because the array is 1 based instead of 0 based. + for i in range(num_sensors + 1): # Add 1 for battery + if i > num_sensors: # Now we're into actuators + continue # Those will be taken care of by switch.py + + if i == num_sensors and is_ac: + # Skipping battery slot for AC hub + continue + + chan_type = CHAN_TYPE_SENSOR + if i == num_sensors: + chan_type = CHAN_TYPE_BATTERY + + sensor = VegeHubSensor( + mac_address=mac_address, + slot=i + 1, + ip_addr=ip_addr, + dev_name=str(config_entry.data.get("hostname")), + data_type=str(config_entry.options.get(f"data_type_{i + 1}", None)), + chan_type=chan_type, + ) + + # Store the entity by ID in runtime_data + config_entry.runtime_data.entities[sensor.unique_id] = sensor + + sensors.append(sensor) + + if sensors: + async_add_entities(sensors) + + +class VegeHubSensor(SensorEntity): + """Class for VegeHub Analog Sensors.""" + + def __init__( + self, + mac_address: str, + slot: int, + ip_addr: str, + dev_name: str, + data_type: str, + chan_type: str, + ) -> None: + """Initialize the sensor.""" + new_id = ( + f"vegehub_{mac_address}_{slot}".lower() + ) # Generate a unique_id using mac and slot + + self._attr_has_entity_name = True + self._attr_translation_placeholders = {"index": str(slot)} + self._data_type: str = data_type + self._unit_of_measurement: str = "" + self._attr_native_value = None + + if chan_type == CHAN_TYPE_BATTERY: + self._unit_of_measurement = UnitOfElectricPotential.VOLT + self._attr_device_class = SensorDeviceClass.VOLTAGE + self._attr_translation_key = "battery" + elif data_type == OPTION_DATA_TYPE_CHOICES[1]: + self._unit_of_measurement = PERCENTAGE + self._attr_device_class = SensorDeviceClass.MOISTURE + self._attr_translation_key = "vh400_sensor" + elif data_type == OPTION_DATA_TYPE_CHOICES[2]: + self._unit_of_measurement = UnitOfTemperature.CELSIUS + self._attr_device_class = SensorDeviceClass.TEMPERATURE + self._attr_translation_key = "therm200_temp" + else: + self._unit_of_measurement = UnitOfElectricPotential.VOLT + self._attr_device_class = SensorDeviceClass.VOLTAGE + self._attr_translation_key = "analog_sensor" + + self._attr_suggested_unit_of_measurement = self._unit_of_measurement + self._attr_native_unit_of_measurement = self._unit_of_measurement + self._mac_address: str = mac_address + self._slot: int = slot + self._attr_unique_id: str = new_id + self._ip_addr: str = ip_addr + self._dev_name: str = dev_name + self._attr_suggested_display_precision = 2 + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self._mac_address)}, + name=self._dev_name, + manufacturer=MANUFACTURER, + model=MODEL, + ) + + @property + def native_value(self) -> float | None: + """Return the state of the sensor.""" + if ( + self._data_type == OPTION_DATA_TYPE_CHOICES[1] and self._attr_native_value + ): # Percentage + return vh400_transform(self._attr_native_value) + if ( + self._data_type == OPTION_DATA_TYPE_CHOICES[2] and self._attr_native_value + ): # Temperature C + return therm200_transform(self._attr_native_value) + + if isinstance(self._attr_native_value, (int, str, float)): + return float(self._attr_native_value) + return None + + async def async_update_sensor(self, value): + """Update the sensor state with the latest value.""" + + self._attr_native_value = value + self.async_write_ha_state() diff --git a/homeassistant/components/vegehub/strings.json b/homeassistant/components/vegehub/strings.json new file mode 100644 index 00000000000000..391e801c2eea84 --- /dev/null +++ b/homeassistant/components/vegehub/strings.json @@ -0,0 +1,55 @@ +{ + "title": "VegeHub", + "config": { + "flow_title": "{host}", + "step": { + "user": { + "title": "Set up VegeHub", + "description": "Do you want to set up this VegeHub?", + "data": { + "ip_address": "Enter the IP address of target VegeHub" + } + } + }, + "abort": { + "cannot_connect": "Failed to connect to the device. Please try again." + } + }, + "options": { + "step": { + "init": { + "title": "Configure VegeHub", + "data": { + "user_act_duration": "Default actuator duration (in seconds)", + "data_type_1": "Chan 1 Sensor Type", + "data_type_2": "Chan 2 Sensor Type", + "data_type_3": "Chan 3 Sensor Type", + "data_type_4": "Chan 4 Sensor Type", + "data_type_5": "Chan 5 Sensor Type", + "data_type_6": "Chan 6 Sensor Type", + "data_type_7": "Chan 7 Sensor Type", + "data_type_8": "Chan 8 Sensor Type" + }, + "data_description": { + "user_act_duration": "Length of time that an actuator command will endure" + } + } + } + }, + "entity": { + "sensor": { + "analog_sensor": { + "name": "VegeHub Sensor {index}" + }, + "vh400_sensor": { + "name": "VH400 Moisture {index}" + }, + "therm200_temp": { + "name": "THERM200 Temperature {index}" + }, + "battery": { + "name": "Battery" + } + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index cbd30b560ce755..39063affc0942e 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -643,6 +643,7 @@ "uptimerobot", "v2c", "vallox", + "vegehub", "velbus", "velux", "venstar", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index a1fdb9478f3ab6..ef615efeb0b7f7 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -6707,6 +6707,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", @@ -7487,6 +7492,7 @@ "trend", "uptime", "utility_meter", + "vegehub", "version", "waze_travel_time", "workday", diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index 1fbd6337fdb0fb..6f1ebce7352ac6 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -840,6 +840,11 @@ "name": "uzg-01*", }, ], + "_vege._tcp.local.": [ + { + "domain": "vegehub", + }, + ], "_viziocast._tcp.local.": [ { "domain": "vizio", diff --git a/requirements_all.txt b/requirements_all.txt index 45e2077abf8ada..893e87eda08476 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2928,6 +2928,9 @@ vacuum-map-parser-roborock==0.1.2 # homeassistant.components.vallox vallox-websocket-api==5.3.0 +# homeassistant.components.vegehub +vegehub==0.1.11 + # homeassistant.components.rdw vehicle==2.2.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9e34403c87b067..ef56268382f9fd 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2335,6 +2335,9 @@ vacuum-map-parser-roborock==0.1.2 # homeassistant.components.vallox vallox-websocket-api==5.3.0 +# homeassistant.components.vegehub +vegehub==0.1.11 + # homeassistant.components.rdw vehicle==2.2.2 diff --git a/tests/components/vegehub/__init__.py b/tests/components/vegehub/__init__.py new file mode 100644 index 00000000000000..007c68568f885d --- /dev/null +++ b/tests/components/vegehub/__init__.py @@ -0,0 +1 @@ +"""Tests for the Vegetronix VegeHub integration.""" diff --git a/tests/components/vegehub/test_config_flow.py b/tests/components/vegehub/test_config_flow.py new file mode 100644 index 00000000000000..a4706739773d4d --- /dev/null +++ b/tests/components/vegehub/test_config_flow.py @@ -0,0 +1,214 @@ +"""Tests for VegeHub config flow.""" + +import asyncio +from ipaddress import ip_address +from unittest.mock import patch + +import pytest + +from homeassistant import config_entries +from homeassistant.components import zeroconf +from homeassistant.components.vegehub.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.test_util.aiohttp import AiohttpClientMocker + +# Mock data for testing +TEST_IP = "192.168.0.100" +TEST_MAC = "A1:B2:C3:D4:E5:F6" +TEST_SIMPLE_MAC = "A1B2C3D4E5F6" +TEST_HOSTNAME = "VegeHub" + +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 +def mock_aiohttp_session(): + """Mock aiohttp.ClientSession.""" + mocker = AiohttpClientMocker() + + with patch( + "aiohttp.ClientSession", + side_effect=lambda *args, **kwargs: mocker.create_session( + asyncio.get_event_loop() + ), + ): + mocker.get( + "http://192.168.0.100/api/update/send", + status=200, + text="", + ) + mocker.post( + "http://192.168.0.100/api/config/set", + status=200, + json={"error": "success"}, + ) + mocker.post( + "http://192.168.0.100/api/info/get", + status=200, + json={ + "wifi": { + "ssid": "YourWiFiName", + "strength": "-25", + "chan": "4", + "ip": TEST_IP, + "status": "3", + "mac_addr": TEST_MAC, + }, + "hub": { + "first_boot": False, + "page_updated": False, + "error_message": 0, + "num_channels": 4, + "num_actuators": 1, + "version": "5.1.2", + "agenda": 1, + "batt_v": 9.0, + "num_vsens": 0, + "is_ac": 0, + "has_sd": 0, + "on_ap": 0, + }, + "error": "success", + }, + ) + mocker.post( + "http://192.168.0.100/api/config/get", + status=200, + json={ + "hub": { + "name": TEST_HOSTNAME, + "model": "Undefined", + "firmware_version": "5.1.2", + "firmware_url": "https://vegecloud.com/firmware/VG-HUB/latest.json", + "utc_offset": -21600, + "sample_period": 10, + "update_period": 60, + "blink_update": 1, + "report_voltage": 1, + "server_url": "http://homeassistant.local:8123/api/vegehub/update", + "update_urls": [], + "server_type": 3, + "server_channel": "", + "server_user": "", + "static_ip_addr": "", + "dns": "", + "subnet": "", + "gateway": "", + "current_ip_addr": "192.168.0.123", + "power_mode": 1, + "agenda": 1, + "onboard_sensor_poll_rate": 1800, + "remote_sensor_poll_rate": 1800, + }, + "api_key": "7C9EBD4B49D8", + "wifi": { + "type": 0, + "wifi_ssid": "YourWiFiName", + "wifi_pw": "your secure wifi passphrase", + "ap_pw": "vegetronix", + }, + "error": "success", + }, + ) + yield mocker + + +@pytest.fixture +def setup_mock_config_flow(): + """Fixture to set up the mock config flow.""" + with ( + patch( + "socket.gethostname", + return_value=TEST_HOSTNAME, + ), + ): + yield + + +async def test_user_flow_success( + hass: HomeAssistant, setup_mock_config_flow, mock_aiohttp_session +) -> None: + """Test the user flow with successful configuration.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"ip_address": TEST_IP} + ) + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == TEST_IP + assert result["data"]["mac_address"] == 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, setup_mock_config_flow: None, mock_aiohttp_session +) -> None: + """Test the user flow when the device cannot be connected.""" + + mocker = AiohttpClientMocker() + + with patch( + "aiohttp.ClientSession", + side_effect=lambda *args, **kwargs: mocker.create_session( + asyncio.get_event_loop() + ), + ): + mocker.post( + "http://192.168.0.100/api/info/get", + status=200, + json={}, + ) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"ip_address": TEST_IP} + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "cannot_connect" + + +async def test_zeroconf_flow_success( + hass: HomeAssistant, setup_mock_config_flow: None, mock_aiohttp_session +) -> None: + """Test the zeroconf discovery flow with successful configuration.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=DISCOVERY_INFO + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == TEST_HOSTNAME + assert result["data"]["mac_address"] == TEST_SIMPLE_MAC diff --git a/tests/components/vegehub/test_sensor.py b/tests/components/vegehub/test_sensor.py new file mode 100644 index 00000000000000..d3d228c6b43591 --- /dev/null +++ b/tests/components/vegehub/test_sensor.py @@ -0,0 +1,112 @@ +"""Unit tests for the VegeHub integration's sensor.py.""" + +from unittest.mock import MagicMock, patch + +import pytest +from vegehub import vh400_transform + +from homeassistant.components.sensor import SensorDeviceClass +from homeassistant.components.vegehub.const import DOMAIN, OPTION_DATA_TYPE_CHOICES +from homeassistant.components.vegehub.sensor import VegeHubSensor, async_setup_entry +from homeassistant.const import PERCENTAGE +from homeassistant.core import HomeAssistant + + +@pytest.fixture +def config_entry(): + """Mock a config entry.""" + return MagicMock( + data={ + "mac_address": "1234567890AB", + "ip_addr": "192.168.1.10", + "hub": {"num_channels": 2, "is_ac": 0}, + "hostname": "VegeHub1", + }, + options={ + "data_type_1": OPTION_DATA_TYPE_CHOICES[1], + "data_type_2": OPTION_DATA_TYPE_CHOICES[2], + }, + ) + + +@pytest.fixture +def hass(): + """Mock a HomeAssistant instance.""" + hass_mock = MagicMock() + hass_mock.data = {DOMAIN: {}} + return hass_mock + + +async def test_async_setup_entry(hass: HomeAssistant, config_entry) -> None: + """Test async_setup_entry for adding sensors.""" + async_add_entities = MagicMock() + + # Call async_setup_entry to add sensors + await async_setup_entry(hass, config_entry, async_add_entities) + + # Assert that sensors were added correctly + assert async_add_entities.call_count == 1 + added_sensors = async_add_entities.call_args[0][0] + + assert len(added_sensors) == 3 # 2 sensors + 1 battery + + +def test_vegehub_sensor_properties() -> None: + """Test VegeHubSensor properties.""" + sensor = VegeHubSensor( + mac_address="1234567890AB", + slot=1, + ip_addr="192.168.1.10", + dev_name="VegeHub1", + data_type=OPTION_DATA_TYPE_CHOICES[1], + chan_type="sensor", + ) + + assert sensor.device_class == SensorDeviceClass.MOISTURE + assert sensor.native_unit_of_measurement == PERCENTAGE + assert sensor.unique_id == "vegehub_1234567890ab_1" + + +def test_native_value() -> None: + """Test the native_value property for VegeHubSensor.""" + sensor = VegeHubSensor( + mac_address="1234567890AB", + slot=1, + ip_addr="192.168.1.10", + dev_name="VegeHub1", + data_type=OPTION_DATA_TYPE_CHOICES[2], + chan_type="sensor", + ) + + # Test with temperature conversion + sensor._attr_native_value = 1.0 + assert sensor.native_value == pytest.approx(1.0 * 41.67 - 40, 0.01) + + # Test with other data type (voltage) + sensor._data_type = OPTION_DATA_TYPE_CHOICES[0] + sensor._attr_native_value = 2.0 + assert sensor.native_value == 2.0 + + # Test with percentage conversion + sensor._data_type = OPTION_DATA_TYPE_CHOICES[1] + sensor._attr_native_value = 1.5 + assert sensor.native_value == vh400_transform(1.5) + + +@pytest.mark.asyncio +async def test_async_update_sensor() -> None: + """Test async_update_sensor method.""" + sensor = VegeHubSensor( + mac_address="1234567890AB", + slot=1, + ip_addr="192.168.1.10", + dev_name="VegeHub1", + data_type=OPTION_DATA_TYPE_CHOICES[1], + chan_type="sensor", + ) + + with patch.object(sensor, "async_write_ha_state") as mock_write_ha_state: + sensor._attr_native_value = 2.0 + await sensor.async_update_sensor(2.0) + mock_write_ha_state.assert_called_once() + assert sensor._attr_native_value == 2.0 diff --git a/tests/testing_config/.storage/http.auth b/tests/testing_config/.storage/http.auth new file mode 100644 index 00000000000000..4d28e0dc05c469 --- /dev/null +++ b/tests/testing_config/.storage/http.auth @@ -0,0 +1,8 @@ +{ + "version": 1, + "minor_version": 1, + "key": "http.auth", + "data": { + "content_user": "a385bd5936224823af128ec608abeac0" + } +} \ No newline at end of file