diff --git a/CODEOWNERS b/CODEOWNERS index 445a3ba93179d9..b7958146ab39f8 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1597,6 +1597,8 @@ build.json @home-assistant/supervisor /tests/components/vesync/ @markperdue @webdjoe @thegardenmonkey @cdnninja /homeassistant/components/vicare/ @CFenner /tests/components/vicare/ @CFenner +/homeassistant/components/victron_ble/ @rajlaud +/tests/components/victron_ble/ @rajlaud /homeassistant/components/vilfo/ @ManneW /tests/components/vilfo/ @ManneW /homeassistant/components/vivotek/ @HarlemSquirrel diff --git a/homeassistant/components/victron_ble/__init__.py b/homeassistant/components/victron_ble/__init__.py new file mode 100644 index 00000000000000..2a8763ec99b031 --- /dev/null +++ b/homeassistant/components/victron_ble/__init__.py @@ -0,0 +1,66 @@ +"""The Victron Bluetooth Low Energy integration.""" + +from __future__ import annotations + +from dataclasses import dataclass +import logging + +from victron_ble_ha_parser import VictronBluetoothDeviceData + +from homeassistant.components.bluetooth import ( + BluetoothScanningMode, + async_rediscover_address, +) +from homeassistant.components.bluetooth.passive_update_processor import ( + PassiveBluetoothProcessorCoordinator, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_ACCESS_TOKEN, Platform +from homeassistant.core import HomeAssistant + +_LOGGER = logging.getLogger(__name__) + + +type VictronBLEConfigEntry = ConfigEntry[VictronBLEData] + + +@dataclass +class VictronBLEData: + """Class to hold data for the Victron BLE device.""" + + coordinator: PassiveBluetoothProcessorCoordinator | None = None + + +async def async_setup_entry(hass: HomeAssistant, entry: VictronBLEConfigEntry) -> bool: + """Set up Victron BLE device from a config entry.""" + address = entry.unique_id + assert address is not None + key = entry.data[CONF_ACCESS_TOKEN] + data = VictronBluetoothDeviceData(key) + coordinator = PassiveBluetoothProcessorCoordinator( + hass, + _LOGGER, + address=address, + mode=BluetoothScanningMode.ACTIVE, + update_method=data.update, + ) + entry.runtime_data = VictronBLEData(coordinator) + + await hass.config_entries.async_forward_entry_setups(entry, [Platform.SENSOR]) + entry.async_on_unload(coordinator.async_start()) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + unload_ok = False + + unload_ok = await hass.config_entries.async_forward_entry_unload( + entry, Platform.SENSOR + ) + + if unload_ok: + async_rediscover_address(hass, entry.entry_id) + + return unload_ok diff --git a/homeassistant/components/victron_ble/config_flow.py b/homeassistant/components/victron_ble/config_flow.py new file mode 100644 index 00000000000000..321f1d234dc2b5 --- /dev/null +++ b/homeassistant/components/victron_ble/config_flow.py @@ -0,0 +1,125 @@ +"""Config flow for Victron Bluetooth Low Energy integration.""" + +from __future__ import annotations + +import logging +from typing import Any + +from victron_ble_ha_parser import VictronBluetoothDeviceData +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.components.bluetooth import ( + BluetoothServiceInfoBleak, + async_discovered_service_info, +) +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_ADDRESS + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +STEP_ACCESS_TOKEN_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_ACCESS_TOKEN): str, + } +) + + +class VictronBLEConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Victron Bluetooth Low Energy.""" + + VERSION = 1 + + def __init__(self) -> None: + """Initialize the config flow.""" + self._discovered_device: str | None = None + self._discovered_devices: dict[str, str] = {} + self._discovered_devices_info: dict[str, BluetoothServiceInfoBleak] = {} + + async def async_step_bluetooth( + self, discovery_info: BluetoothServiceInfoBleak + ) -> config_entries.ConfigFlowResult: + """Handle the bluetooth discovery step.""" + _LOGGER.debug("async_step_bluetooth: %s", discovery_info.address) + await self.async_set_unique_id(discovery_info.address) + self._abort_if_unique_id_configured() + device = VictronBluetoothDeviceData() + if not device.supported(discovery_info): + _LOGGER.debug("device %s not supported", discovery_info.address) + return self.async_abort(reason="not_supported") + + self._discovered_device = discovery_info.address + self._discovered_devices_info[discovery_info.address] = discovery_info + self._discovered_devices[discovery_info.address] = discovery_info.name + + self.context["title_placeholders"] = {"title": discovery_info.name} + + return await self.async_step_access_token() + + async def async_step_access_token( + self, user_input: dict[str, Any] | None = None + ) -> config_entries.ConfigFlowResult: + """Handle advertisement key input.""" + # should only be called if there are discovered devices + assert self._discovered_device is not None + assert self._discovered_devices_info is not None + discovery_info = self._discovered_devices_info[self._discovered_device] + assert discovery_info is not None + title = discovery_info.name + + if user_input is not None: + # see if we can create a device with the access token + device = VictronBluetoothDeviceData(user_input[CONF_ACCESS_TOKEN]) + if device.validate_advertisement_key( + discovery_info.manufacturer_data[0x02E1] + ): + return self.async_create_entry( + title=title, + data=user_input, + ) + return self.async_abort(reason="invalid_access_token") + + return self.async_show_form( + step_id="access_token", + data_schema=STEP_ACCESS_TOKEN_DATA_SCHEMA, + description_placeholders={"title": title}, + ) + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> config_entries.ConfigFlowResult: + """Handle select a device to set up.""" + if user_input is not None: + address = user_input[CONF_ADDRESS] + await self.async_set_unique_id(address, raise_on_progress=False) + self._abort_if_unique_id_configured() + self._discovered_device = address + title = self._discovered_devices_info[address].name + return self.async_show_form( + step_id="access_token", + data_schema=STEP_ACCESS_TOKEN_DATA_SCHEMA, + description_placeholders={"title": title}, + ) + + current_addresses = self._async_current_ids() + for discovery_info in async_discovered_service_info(self.hass, False): + address = discovery_info.address + if address in current_addresses or address in self._discovered_devices: + continue + device = VictronBluetoothDeviceData() + if device.supported(discovery_info): + self._discovered_devices_info[address] = discovery_info + self._discovered_devices[address] = discovery_info.name + + if len(self._discovered_devices) < 1: + return self.async_abort(reason="no_devices_found") + + _LOGGER.debug("Discovered %s devices", len(self._discovered_devices)) + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + {vol.Required(CONF_ADDRESS): vol.In(self._discovered_devices)} + ), + ) diff --git a/homeassistant/components/victron_ble/const.py b/homeassistant/components/victron_ble/const.py new file mode 100644 index 00000000000000..8ea195eb17a1ea --- /dev/null +++ b/homeassistant/components/victron_ble/const.py @@ -0,0 +1,4 @@ +"""Constants for the Victron Bluetooth Low Energy integration.""" + +DOMAIN = "victron_ble" +VICTRON_IDENTIFIER = 0x02E1 diff --git a/homeassistant/components/victron_ble/manifest.json b/homeassistant/components/victron_ble/manifest.json new file mode 100644 index 00000000000000..c36974f980e644 --- /dev/null +++ b/homeassistant/components/victron_ble/manifest.json @@ -0,0 +1,18 @@ +{ + "domain": "victron_ble", + "name": "Victron Bluetooth Low Energy", + "bluetooth": [ + { + "connectable": false, + "manufacturer_id": 737, + "manufacturer_data_start": [16] + } + ], + "codeowners": ["@rajlaud"], + "config_flow": true, + "dependencies": ["bluetooth_adapters"], + "documentation": "https://www.home-assistant.io/integrations/victron_ble", + "integration_type": "device", + "iot_class": "local_push", + "requirements": ["victron-ble-ha-parser==0.4.4"] +} diff --git a/homeassistant/components/victron_ble/sensor.py b/homeassistant/components/victron_ble/sensor.py new file mode 100644 index 00000000000000..64708e238a48b8 --- /dev/null +++ b/homeassistant/components/victron_ble/sensor.py @@ -0,0 +1,269 @@ +"""Sensor platform for Victron BLE.""" + +import logging + +from sensor_state_data import DeviceKey +from victron_ble_ha_parser import Keys, Units + +from homeassistant import config_entries +from homeassistant.components.bluetooth.passive_update_processor import ( + PassiveBluetoothDataProcessor, + PassiveBluetoothDataUpdate, + PassiveBluetoothEntityKey, + PassiveBluetoothProcessorCoordinator, + PassiveBluetoothProcessorEntity, +) +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import ( + PERCENTAGE, + SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + UnitOfElectricCurrent, + UnitOfElectricPotential, + UnitOfEnergy, + UnitOfPower, + UnitOfTemperature, + UnitOfTime, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.sensor import sensor_device_info_to_hass_device_info + +LOGGER = logging.getLogger(__name__) + +SENSOR_DESCRIPTIONS = { + Keys.AC_IN_POWER: SensorEntityDescription( + key=Keys.AC_IN_POWER, + device_class=SensorDeviceClass.POWER, + native_unit_of_measurement=UnitOfPower.WATT, + state_class=SensorStateClass.MEASUREMENT, + ), + Keys.AC_IN_STATE: SensorEntityDescription( + key=Keys.AC_IN_STATE, + device_class=SensorDeviceClass.ENUM, + ), + Keys.AC_OUT_POWER: SensorEntityDescription( + key=Keys.AC_OUT_POWER, + device_class=SensorDeviceClass.POWER, + native_unit_of_measurement=UnitOfPower.WATT, + state_class=SensorStateClass.MEASUREMENT, + ), + Keys.AC_OUT_STATE: SensorEntityDescription( + key=Keys.AC_OUT_STATE, + device_class=SensorDeviceClass.ENUM, + ), + Keys.ALARM: SensorEntityDescription( + key=Keys.ALARM, + device_class=SensorDeviceClass.ENUM, + ), + Keys.AUX_MODE: SensorEntityDescription( + key=Keys.AUX_MODE, + device_class=SensorDeviceClass.ENUM, + ), + Keys.BALANCER_STATUS: SensorEntityDescription( + key=Keys.BALANCER_STATUS, + device_class=SensorDeviceClass.ENUM, + ), + Keys.BATTERY_CURRENT: SensorEntityDescription( + key=Keys.BATTERY_CURRENT, + device_class=SensorDeviceClass.CURRENT, + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + state_class=SensorStateClass.MEASUREMENT, + ), + Keys.BATTERY_TEMPERATURE: SensorEntityDescription( + key=Keys.BATTERY_TEMPERATURE, + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + state_class=SensorStateClass.MEASUREMENT, + ), + Keys.BATTERY_VOLTAGE: SensorEntityDescription( + key=Keys.BATTERY_VOLTAGE, + device_class=SensorDeviceClass.VOLTAGE, + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + state_class=SensorStateClass.MEASUREMENT, + ), + Keys.CHARGE_STATE: SensorEntityDescription( + key=Keys.CHARGE_STATE, + device_class=SensorDeviceClass.ENUM, + ), + Keys.CHARGER_ERROR: SensorEntityDescription( + key=Keys.CHARGER_ERROR, + device_class=SensorDeviceClass.ENUM, + ), + Keys.CONSUMED_AMPERE_HOURS: SensorEntityDescription( + key=Keys.CONSUMED_AMPERE_HOURS, + native_unit_of_measurement=Units.ELECTRIC_CURRENT_FLOW_AMPERE_HOUR, + state_class=SensorStateClass.MEASUREMENT, + ), + Keys.CURRENT: SensorEntityDescription( + key=Keys.CURRENT, + device_class=SensorDeviceClass.CURRENT, + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + state_class=SensorStateClass.MEASUREMENT, + ), + Keys.DEVICE_STATE: SensorEntityDescription( + key=Keys.DEVICE_STATE, + device_class=SensorDeviceClass.ENUM, + ), + Keys.ERROR_CODE: SensorEntityDescription( + key=Keys.ERROR_CODE, + device_class=SensorDeviceClass.ENUM, + ), + Keys.EXTERNAL_DEVICE_LOAD: SensorEntityDescription( + key=Keys.EXTERNAL_DEVICE_LOAD, + device_class=SensorDeviceClass.CURRENT, + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + state_class=SensorStateClass.MEASUREMENT, + ), + Keys.INPUT_VOLTAGE: SensorEntityDescription( + key=Keys.INPUT_VOLTAGE, + device_class=SensorDeviceClass.VOLTAGE, + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + state_class=SensorStateClass.MEASUREMENT, + ), + Keys.METER_TYPE: SensorEntityDescription( + key=Keys.METER_TYPE, + device_class=SensorDeviceClass.ENUM, + ), + Keys.MIDPOINT_VOLTAGE: SensorEntityDescription( + key=Keys.MIDPOINT_VOLTAGE, + device_class=SensorDeviceClass.VOLTAGE, + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + state_class=SensorStateClass.MEASUREMENT, + ), + Keys.OFF_REASON: SensorEntityDescription( + key=Keys.OFF_REASON, + device_class=SensorDeviceClass.ENUM, + ), + Keys.OUTPUT_STATE: SensorEntityDescription( + key=Keys.OUTPUT_STATE, + device_class=SensorDeviceClass.ENUM, + ), + Keys.OUTPUT_VOLTAGE: SensorEntityDescription( + key=Keys.OUTPUT_VOLTAGE, + device_class=SensorDeviceClass.VOLTAGE, + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + state_class=SensorStateClass.MEASUREMENT, + ), + Keys.REMAINING_MINUTES: SensorEntityDescription( + key=Keys.REMAINING_MINUTES, + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.MINUTES, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorDeviceClass.SIGNAL_STRENGTH: SensorEntityDescription( + key=SensorDeviceClass.SIGNAL_STRENGTH.value, + device_class=SensorDeviceClass.SIGNAL_STRENGTH, + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + state_class=SensorStateClass.MEASUREMENT, + ), + Keys.SOLAR_POWER: SensorEntityDescription( + key=Keys.SOLAR_POWER, + device_class=SensorDeviceClass.POWER, + native_unit_of_measurement=UnitOfPower.WATT, + state_class=SensorStateClass.MEASUREMENT, + ), + Keys.STARTER_VOLTAGE: SensorEntityDescription( + key=Keys.STARTER_VOLTAGE, + device_class=SensorDeviceClass.VOLTAGE, + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + state_class=SensorStateClass.MEASUREMENT, + ), + Keys.STATE_OF_CHARGE: SensorEntityDescription( + key=Keys.STATE_OF_CHARGE, + device_class=SensorDeviceClass.BATTERY, + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + ), + Keys.TEMPERATURE: SensorEntityDescription( + key=Keys.TEMPERATURE, + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + state_class=SensorStateClass.MEASUREMENT, + ), + Keys.VOLTAGE: SensorEntityDescription( + key=Keys.VOLTAGE, + device_class=SensorDeviceClass.VOLTAGE, + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + state_class=SensorStateClass.MEASUREMENT, + ), + Keys.WARNING: SensorEntityDescription( + key=Keys.WARNING, + device_class=SensorDeviceClass.ENUM, + ), + Keys.YIELD_TODAY: SensorEntityDescription( + key=Keys.YIELD_TODAY, + device_class=SensorDeviceClass.ENERGY, + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + state_class=SensorStateClass.TOTAL_INCREASING, + ), +} + +for i in range(1, 8): + cell_key = getattr(Keys, f"CELL_{i}_VOLTAGE") + SENSOR_DESCRIPTIONS[cell_key] = SensorEntityDescription( + key=cell_key, + device_class=SensorDeviceClass.VOLTAGE, + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + state_class=SensorStateClass.MEASUREMENT, + ) + + +def _device_key_to_bluetooth_entity_key( + device_key: DeviceKey, +) -> PassiveBluetoothEntityKey: + """Convert a device key to an entity key.""" + return PassiveBluetoothEntityKey(device_key.key, device_key.device_id) + + +def sensor_update_to_bluetooth_data_update(sensor_update): + """Convert a sensor update to a bluetooth data update.""" + return PassiveBluetoothDataUpdate( + devices={ + device_id: sensor_device_info_to_hass_device_info(device_info) + for device_id, device_info in sensor_update.devices.items() + }, + entity_descriptions={ + _device_key_to_bluetooth_entity_key(device_key): SENSOR_DESCRIPTIONS[ + device_key.key + ] + for device_key in sensor_update.entity_descriptions + }, + entity_data={ + _device_key_to_bluetooth_entity_key(device_key): sensor_values.native_value + for device_key, sensor_values in sensor_update.entity_values.items() + }, + entity_names={ + _device_key_to_bluetooth_entity_key(device_key): sensor_values.name + for device_key, sensor_values in sensor_update.entity_values.items() + }, + ) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: config_entries.ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Victron BLE sensor.""" + coordinator: PassiveBluetoothProcessorCoordinator = entry.runtime_data.coordinator + processor = PassiveBluetoothDataProcessor(sensor_update_to_bluetooth_data_update) + entry.async_on_unload( + processor.async_add_entities_listener( + VictronBLESensorEntity, async_add_entities + ) + ) + entry.async_on_unload(coordinator.async_register_processor(processor)) + + +class VictronBLESensorEntity(PassiveBluetoothProcessorEntity, SensorEntity): + """Representation of Victron BLE sensor.""" + + @property + def native_value(self) -> float | int | str | None: + """Return the state of the sensor.""" + return self.processor.entity_data.get(self.entity_key) diff --git a/homeassistant/components/victron_ble/strings.json b/homeassistant/components/victron_ble/strings.json new file mode 100644 index 00000000000000..d5aebee708de68 --- /dev/null +++ b/homeassistant/components/victron_ble/strings.json @@ -0,0 +1,23 @@ +{ + "config": { + "flow_title": "{title}", + "step": { + "user": { + "data": { + "address": "[%key:common::config_flow::data::device%]" + } + }, + "access_token": { + "title": "{title}", + "data": { + "access_token": "[%key:common::config_flow::data::access_token%]" + } + } + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "invalid_access_token": "[%key:common::config_flow::error::invalid_access_token%]", + "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]" + } + } +} diff --git a/homeassistant/generated/bluetooth.py b/homeassistant/generated/bluetooth.py index 2ea604a91a2a28..66d1cec835f3b1 100644 --- a/homeassistant/generated/bluetooth.py +++ b/homeassistant/generated/bluetooth.py @@ -725,6 +725,14 @@ ], "manufacturer_id": 76, }, + { + "connectable": False, + "domain": "victron_ble", + "manufacturer_data_start": [ + 16, + ], + "manufacturer_id": 737, + }, { "connectable": False, "domain": "xiaomi_ble", diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index f399b0922f13ca..1834da8ec204a2 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -643,6 +643,7 @@ "version", "vesync", "vicare", + "victron_ble", "vilfo", "vizio", "vlc_telnet", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 3cde3573ff7782..2bae0192ce8f4e 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -6742,6 +6742,12 @@ "config_flow": true, "iot_class": "cloud_polling" }, + "victron_ble": { + "name": "Victron Bluetooth Low Energy", + "integration_type": "device", + "config_flow": true, + "iot_class": "local_push" + }, "vilfo": { "name": "Vilfo Router", "integration_type": "hub", diff --git a/requirements_all.txt b/requirements_all.txt index c7f6d1ca8be3d8..610bb81eaf5f65 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2918,6 +2918,9 @@ velbus-aio==2024.7.6 # homeassistant.components.venstar venstarcolortouch==0.19 +# homeassistant.components.victron_ble +victron-ble-ha-parser==0.4.4 + # homeassistant.components.vilfo vilfo-api-client==0.5.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4d7ebe5a2e964f..01d7b9ade1a80f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2319,6 +2319,9 @@ velbus-aio==2024.7.6 # homeassistant.components.venstar venstarcolortouch==0.19 +# homeassistant.components.victron_ble +victron-ble-ha-parser==0.4.4 + # homeassistant.components.vilfo vilfo-api-client==0.5.0 diff --git a/tests/components/victron_ble/__init__.py b/tests/components/victron_ble/__init__.py new file mode 100644 index 00000000000000..90b3e67966b819 --- /dev/null +++ b/tests/components/victron_ble/__init__.py @@ -0,0 +1 @@ +"""Tests for the Victron Bluetooth Low Energy integration.""" diff --git a/tests/components/victron_ble/conftest.py b/tests/components/victron_ble/conftest.py new file mode 100644 index 00000000000000..08fbcf49464c6a --- /dev/null +++ b/tests/components/victron_ble/conftest.py @@ -0,0 +1,15 @@ +"""Test the Victron Bluetooth Low Energy config flow.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.victron_ble.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry diff --git a/tests/components/victron_ble/fixtures.py b/tests/components/victron_ble/fixtures.py new file mode 100644 index 00000000000000..1ec8f1755eb127 --- /dev/null +++ b/tests/components/victron_ble/fixtures.py @@ -0,0 +1,147 @@ +"""Fixtures for testing victron_ble.""" + +from home_assistant_bluetooth import BluetoothServiceInfo + +NOT_VICTRON_SERVICE_INFO = BluetoothServiceInfo( + name="Not it", + address="61DE521B-F0BF-9F44-64D4-75BBE1738105", + rssi=-63, + manufacturer_data={3234: b"\x00\x01"}, + service_data={}, + service_uuids=[], + source="local", +) + +VICTRON_TEST_WRONG_TOKEN = "00000000000000000000000000000000" + +# battery monitor +VICTRON_BATTERY_MONITOR_SERVICE_INFO = BluetoothServiceInfo( + name="Battery Monitor", + address="01:02:03:04:05:07", + rssi=-60, + manufacturer_data={ + 0x02E1: bytes.fromhex("100289a302b040af925d09a4d89aa0128bdef48c6298a9") + }, + service_data={}, + service_uuids=[], + source="local", +) +VICTRON_BATTERY_MONITOR_TOKEN = "aff4d0995b7d1e176c0c33ecb9e70dcd" +VICTRON_BATTERY_MONITOR_SENSORS = { + "battery_monitor_aux_mode": "DISABLED", + "battery_monitor_consumed_ampere_hours": "-50.0", + "battery_monitor_current": "0.0", + "battery_monitor_remaining_minutes": "unknown", + "battery_monitor_state_of_charge": "50.0", + "battery_monitor_voltage": "12.53", + "battery_monitor_alarm": "no alarm", + "battery_monitor_temperature": "unknown", + "battery_monitor_starter_voltage": "unknown", + "battery_monitor_midpoint_voltage": "unknown", +} + +# DC/DC converter + +VICTRON_DC_DC_CONVERTER_SERVICE_INFO = BluetoothServiceInfo( + name="DC/DC Converter", + address="01:02:03:04:05:08", + rssi=-60, + manufacturer_data={ + 0x02E1: bytes.fromhex("1000c0a304121d64ca8d442b90bbdf6a8cba"), + }, + service_data={}, + service_uuids=[], + source="local", +) + +# DC energy meter + +VICTRON_DC_ENERGY_METER_SERVICE_INFO = BluetoothServiceInfo( + name="DC Energy Meter", + address="01:02:03:04:05:09", + rssi=-60, + manufacturer_data={ + 0x02E1: bytes.fromhex("100289a30d787fafde83ccec982199fd815286"), + }, + service_data={}, + service_uuids=[], + source="local", +) + +VICTRON_DC_ENERGY_METER_TOKEN = "aff4d0995b7d1e176c0c33ecb9e70dcd" + +VICTRON_DC_ENERGY_METER_SENSORS = { + "dc_energy_meter_meter_type": "DC_DC_CHARGER", + "dc_energy_meter_aux_mode": "STARTER_VOLTAGE", + "dc_energy_meter_current": "0.0", + "dc_energy_meter_voltage": "12.52", + "dc_energy_meter_starter_voltage": "-0.01", + "dc_energy_meter_alarm": "no alarm", + "dc_energy_meter_temperature": "unknown", +} + +# Inverter - unsupported by victron-ble library - for testing + +VICTRON_INVERTER_SERVICE_INFO = BluetoothServiceInfo( + name="Inverter", + address="01:02:03:04:05:10", + rssi=-60, + manufacturer_data={ + 0x02E1: bytes.fromhex("1003a2a2031252dad26f0b8eb39162074d140df410"), + }, # not a valid advertisement, but model id mangled to match inverter + service_data={}, + service_uuids=[], + source="local", +) + +# Solar charger + +VICTRON_SOLAR_CHARGER_SERVICE_INFO = BluetoothServiceInfo( + name="Solar Charger", + address="01:02:03:04:05:11", + rssi=-60, + manufacturer_data={ + 0x02E1: bytes.fromhex("100242a0016207adceb37b605d7e0ee21b24df5c"), + }, + service_data={}, + service_uuids=[], + source="local", +) + +VICTRON_SOLAR_CHARGER_TOKEN = "adeccb947395801a4dd45a2eaa44bf17" + +VICTRON_SOLAR_CHARGER_SENSORS = { + "solar_charger_charge_state": "ABSORPTION", + "solar_charger_battery_voltage": "13.88", + "solar_charger_battery_current": "1.4", + "solar_charger_yield_today": "30", + "solar_charger_solar_power": "19", + "solar_charger_external_device_load": "0.0", +} + +# ve.bus + +VICTRON_VEBUS_SERVICE_INFO = BluetoothServiceInfo( + name="Inverter Charger", + address="01:02:03:04:05:06", + rssi=-60, + manufacturer_data={ + 0x02E1: bytes.fromhex("100380270c1252dad26f0b8eb39162074d140df410") + }, + service_data={}, + service_uuids=[], + source="local", +) + +VICTRON_VEBUS_TOKEN = "da3f5fa2860cb1cf86ba7a6d1d16b9dd" + +VICTRON_VEBUS_SENSORS = { + "inverter_charger_device_state": "FLOAT", + "inverter_charger_battery_voltage": "14.45", + "inverter_charger_battery_current": "23.2", + "inverter_charger_ac_in_state": "AC_IN_1", + "inverter_charger_ac_in_power": "1459", + "inverter_charger_ac_out_power": "1046", + "inverter_charger_battery_temperature": "32", + "inverter_charger_state_of_charge": "unknown", +} diff --git a/tests/components/victron_ble/test_config_flow.py b/tests/components/victron_ble/test_config_flow.py new file mode 100644 index 00000000000000..ba19fdbdf9361c --- /dev/null +++ b/tests/components/victron_ble/test_config_flow.py @@ -0,0 +1,201 @@ +"""Test the Victron Bluetooth Low Energy config flow.""" + +from unittest.mock import AsyncMock, patch + +import pytest + +from homeassistant import config_entries +from homeassistant.components.victron_ble.const import DOMAIN +from homeassistant.config_entries import SOURCE_BLUETOOTH +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_ADDRESS +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from .fixtures import ( + NOT_VICTRON_SERVICE_INFO, + VICTRON_INVERTER_SERVICE_INFO, + VICTRON_TEST_WRONG_TOKEN, + VICTRON_VEBUS_SERVICE_INFO, + VICTRON_VEBUS_TOKEN, +) + +from tests.common import MockConfigEntry + +pytestmark = pytest.mark.usefixtures("mock_setup_entry") + + +@pytest.fixture(autouse=True) +def mock_bluetooth(enable_bluetooth: None) -> None: + """Mock bluetooth for all tests in this module.""" + + +async def test_async_step_bluetooth_valid_device( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: + """Test discovery via bluetooth with a valid device.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_BLUETOOTH}, + data=VICTRON_VEBUS_SERVICE_INFO, + ) + assert result.get("type") is FlowResultType.FORM + assert result.get("step_id") == "access_token" + + # test valid access token + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_ACCESS_TOKEN: VICTRON_VEBUS_TOKEN}, + ) + assert result.get("type") is FlowResultType.CREATE_ENTRY + assert result.get("title") == VICTRON_VEBUS_SERVICE_INFO.name + flow_result = result.get("result") + assert flow_result is not None + assert flow_result.unique_id == VICTRON_VEBUS_SERVICE_INFO.address + + +async def test_async_step_bluetooth_not_victron(hass: HomeAssistant) -> None: + """Test discovery via bluetooth not a victron device.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_BLUETOOTH}, + data=NOT_VICTRON_SERVICE_INFO, + ) + assert result.get("type") is FlowResultType.ABORT + assert result.get("reason") == "not_supported" + + +async def test_async_step_bluetooth_unsupported_by_library(hass: HomeAssistant) -> None: + """Test discovery via bluetooth of a victron device unsupported by the underlying library.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_BLUETOOTH}, + data=VICTRON_INVERTER_SERVICE_INFO, + ) + assert result.get("type") is FlowResultType.ABORT + assert result.get("reason") == "not_supported" + + +async def test_async_step_user_no_devices_found(hass: HomeAssistant) -> None: + """Test setup from service info cache with no devices found.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + ) + assert result.get("type") is FlowResultType.ABORT + assert result.get("reason") == "no_devices_found" + + +async def test_async_step_user_with_devices_found(hass: HomeAssistant) -> None: + """Test setup from service info cache with devices found.""" + with patch( + "homeassistant.components.victron_ble.config_flow.async_discovered_service_info", + return_value=[VICTRON_VEBUS_SERVICE_INFO], + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + ) + assert result.get("type") is FlowResultType.FORM + assert result.get("step_id") == "user" + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_ADDRESS: VICTRON_VEBUS_SERVICE_INFO.address}, + ) + assert result2.get("type") is FlowResultType.FORM + assert result2.get("step_id") == "access_token" + + # test invalid access token (valid already tested above) + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], user_input={CONF_ACCESS_TOKEN: VICTRON_TEST_WRONG_TOKEN} + ) + assert result3.get("type") is FlowResultType.ABORT + assert result3.get("reason") == "invalid_access_token" + + +async def test_async_step_user_device_added_between_steps(hass: HomeAssistant) -> None: + """Test the device gets added via another flow between steps.""" + with patch( + "homeassistant.components.victron_ble.config_flow.async_discovered_service_info", + return_value=[VICTRON_VEBUS_SERVICE_INFO], + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + ) + assert result.get("type") is FlowResultType.FORM + assert result.get("step_id") == "user" + + entry = MockConfigEntry( + domain=DOMAIN, + unique_id=VICTRON_VEBUS_SERVICE_INFO.address, + ) + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.victron_ble.async_setup_entry", return_value=True + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={"address": VICTRON_VEBUS_SERVICE_INFO.address}, + ) + assert result2.get("type") is FlowResultType.ABORT + assert result2.get("reason") == "already_configured" + + +async def test_async_step_user_with_found_devices_already_setup( + hass: HomeAssistant, +) -> None: + """Test setup from service info cache with devices found.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id=VICTRON_VEBUS_SERVICE_INFO.address, + ) + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.victron_ble.config_flow.async_discovered_service_info", + return_value=[VICTRON_VEBUS_SERVICE_INFO], + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + ) + assert result.get("type") is FlowResultType.ABORT + assert result.get("reason") == "no_devices_found" + + +async def test_async_step_bluetooth_devices_already_setup(hass: HomeAssistant) -> None: + """Test we can't start a flow if there is already a config entry.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id=VICTRON_VEBUS_SERVICE_INFO.address, + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_BLUETOOTH}, + data=VICTRON_VEBUS_SERVICE_INFO, + ) + assert result.get("type") is FlowResultType.ABORT + assert result.get("reason") == "already_configured" + + +async def test_async_step_bluetooth_already_in_progress(hass: HomeAssistant) -> None: + """Test we can't start a flow for the same device twice.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_BLUETOOTH}, + data=VICTRON_VEBUS_SERVICE_INFO, + ) + assert result.get("type") is FlowResultType.FORM + assert result.get("step_id") == "access_token" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_BLUETOOTH}, + data=VICTRON_VEBUS_SERVICE_INFO, + ) + assert result.get("type") is FlowResultType.ABORT + assert result.get("reason") == "already_in_progress" diff --git a/tests/components/victron_ble/test_sensors.py b/tests/components/victron_ble/test_sensors.py new file mode 100644 index 00000000000000..f1c1e6d49fd0f4 --- /dev/null +++ b/tests/components/victron_ble/test_sensors.py @@ -0,0 +1,92 @@ +"""Test updating sensors in the victron_ble integration.""" + +from home_assistant_bluetooth import BluetoothServiceInfo +import pytest + +from homeassistant.components.victron_ble.const import DOMAIN +from homeassistant.const import CONF_ACCESS_TOKEN +from homeassistant.core import HomeAssistant + +from .fixtures import ( + VICTRON_BATTERY_MONITOR_SENSORS, + VICTRON_BATTERY_MONITOR_SERVICE_INFO, + VICTRON_BATTERY_MONITOR_TOKEN, + VICTRON_DC_ENERGY_METER_SENSORS, + VICTRON_DC_ENERGY_METER_SERVICE_INFO, + VICTRON_DC_ENERGY_METER_TOKEN, + VICTRON_SOLAR_CHARGER_SENSORS, + VICTRON_SOLAR_CHARGER_SERVICE_INFO, + VICTRON_SOLAR_CHARGER_TOKEN, + VICTRON_VEBUS_SENSORS, + VICTRON_VEBUS_SERVICE_INFO, + VICTRON_VEBUS_TOKEN, +) + +from tests.common import MockConfigEntry +from tests.components.bluetooth import inject_bluetooth_service_info + + +@pytest.mark.usefixtures("enable_bluetooth") +@pytest.mark.parametrize( + "sensor_test_info", + [ + ( + VICTRON_BATTERY_MONITOR_SERVICE_INFO, + VICTRON_BATTERY_MONITOR_TOKEN, + VICTRON_BATTERY_MONITOR_SENSORS, + ), + ( + VICTRON_DC_ENERGY_METER_SERVICE_INFO, + VICTRON_DC_ENERGY_METER_TOKEN, + VICTRON_DC_ENERGY_METER_SENSORS, + ), + ( + VICTRON_SOLAR_CHARGER_SERVICE_INFO, + VICTRON_SOLAR_CHARGER_TOKEN, + VICTRON_SOLAR_CHARGER_SENSORS, + ), + ( + VICTRON_VEBUS_SERVICE_INFO, + VICTRON_VEBUS_TOKEN, + VICTRON_VEBUS_SENSORS, + ), + ], +) +async def test_sensors( + hass: HomeAssistant, + sensor_test_info: tuple[BluetoothServiceInfo, str, dict[str, str]], +) -> None: + """Test updating sensors for a battery monitor.""" + service_info = sensor_test_info[0] + token = sensor_test_info[1] + sensors = sensor_test_info[2] + + entry = MockConfigEntry( + domain=DOMAIN, + unique_id=service_info.address, + data={CONF_ACCESS_TOKEN: token}, + ) + entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert len(hass.states.async_all()) == 0 + + inject_bluetooth_service_info( + hass, + service_info, + ) + + await hass.async_block_till_done() + assert ( + len(hass.states.async_all()) == len(sensors) + 1 + ) # device-specific sensors, plus RSSI + + for key, value in sensors.items(): + state = hass.states.get(f"sensor.{key}") + assert state is not None + assert state.state == value + + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done()