diff --git a/.strict-typing b/.strict-typing index d23da1c2fd2..a95981c9b65 100644 --- a/.strict-typing +++ b/.strict-typing @@ -81,6 +81,7 @@ homeassistant.components.bayesian.* homeassistant.components.binary_sensor.* homeassistant.components.bitcoin.* homeassistant.components.blockchain.* +homeassistant.components.blue_current.* homeassistant.components.bluetooth.* homeassistant.components.bluetooth_tracker.* homeassistant.components.bmw_connected_drive.* diff --git a/CODEOWNERS b/CODEOWNERS index d5ae7848b15..052d3b9258e 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -155,6 +155,8 @@ build.json @home-assistant/supervisor /tests/components/blebox/ @bbx-a @riokuu /homeassistant/components/blink/ @fronzbot @mkmer /tests/components/blink/ @fronzbot @mkmer +/homeassistant/components/blue_current/ @Floris272 @gleeuwen +/tests/components/blue_current/ @Floris272 @gleeuwen /homeassistant/components/bluemaestro/ @bdraco /tests/components/bluemaestro/ @bdraco /homeassistant/components/blueprint/ @home-assistant/core diff --git a/homeassistant/components/blue_current/__init__.py b/homeassistant/components/blue_current/__init__.py new file mode 100644 index 00000000000..0dfa67f097d --- /dev/null +++ b/homeassistant/components/blue_current/__init__.py @@ -0,0 +1,178 @@ +"""The Blue Current integration.""" +from __future__ import annotations + +from contextlib import suppress +from datetime import datetime +from typing import Any + +from bluecurrent_api import Client +from bluecurrent_api.exceptions import ( + BlueCurrentException, + InvalidApiToken, + RequestLimitReached, + WebsocketError, +) + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ATTR_NAME, CONF_API_TOKEN, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.event import async_call_later + +from .const import DOMAIN, EVSE_ID, LOGGER, MODEL_TYPE + +PLATFORMS = [Platform.SENSOR] +CHARGE_POINTS = "CHARGE_POINTS" +DATA = "data" +SMALL_DELAY = 1 +LARGE_DELAY = 20 + +GRID = "GRID" +OBJECT = "object" +VALUE_TYPES = ["CH_STATUS"] + + +async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + """Set up Blue Current as a config entry.""" + hass.data.setdefault(DOMAIN, {}) + client = Client() + api_token = config_entry.data[CONF_API_TOKEN] + connector = Connector(hass, config_entry, client) + + try: + await connector.connect(api_token) + except InvalidApiToken: + LOGGER.error("Invalid Api token") + return False + except BlueCurrentException as err: + raise ConfigEntryNotReady from err + + hass.async_create_task(connector.start_loop()) + await client.get_charge_points() + + await client.wait_for_response() + hass.data[DOMAIN][config_entry.entry_id] = connector + await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) + + config_entry.async_on_unload(connector.disconnect) + + return True + + +async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + """Unload the Blue Current config entry.""" + + unload_ok = await hass.config_entries.async_unload_platforms( + config_entry, PLATFORMS + ) + if unload_ok: + hass.data[DOMAIN].pop(config_entry.entry_id) + + return unload_ok + + +class Connector: + """Define a class that connects to the Blue Current websocket API.""" + + def __init__( + self, hass: HomeAssistant, config: ConfigEntry, client: Client + ) -> None: + """Initialize.""" + self.config: ConfigEntry = config + self.hass: HomeAssistant = hass + self.client: Client = client + self.charge_points: dict[str, dict] = {} + self.grid: dict[str, Any] = {} + self.available = False + + async def connect(self, token: str) -> None: + """Register on_data and connect to the websocket.""" + await self.client.connect(token) + self.available = True + + async def on_data(self, message: dict) -> None: + """Handle received data.""" + + async def handle_charge_points(data: list) -> None: + """Loop over the charge points and get their data.""" + for entry in data: + evse_id = entry[EVSE_ID] + model = entry[MODEL_TYPE] + name = entry[ATTR_NAME] + self.add_charge_point(evse_id, model, name) + await self.get_charge_point_data(evse_id) + await self.client.get_grid_status(data[0][EVSE_ID]) + + object_name: str = message[OBJECT] + + # gets charge point ids + if object_name == CHARGE_POINTS: + charge_points_data: list = message[DATA] + await handle_charge_points(charge_points_data) + + # gets charge point key / values + elif object_name in VALUE_TYPES: + value_data: dict = message[DATA] + evse_id = value_data.pop(EVSE_ID) + self.update_charge_point(evse_id, value_data) + + # gets grid key / values + elif GRID in object_name: + data: dict = message[DATA] + self.grid = data + self.dispatch_grid_update_signal() + + async def get_charge_point_data(self, evse_id: str) -> None: + """Get all the data of a charge point.""" + await self.client.get_status(evse_id) + + def add_charge_point(self, evse_id: str, model: str, name: str) -> None: + """Add a charge point to charge_points.""" + self.charge_points[evse_id] = {MODEL_TYPE: model, ATTR_NAME: name} + + def update_charge_point(self, evse_id: str, data: dict) -> None: + """Update the charge point data.""" + self.charge_points[evse_id].update(data) + self.dispatch_value_update_signal(evse_id) + + def dispatch_value_update_signal(self, evse_id: str) -> None: + """Dispatch a value signal.""" + async_dispatcher_send(self.hass, f"{DOMAIN}_value_update_{evse_id}") + + def dispatch_grid_update_signal(self) -> None: + """Dispatch a grid signal.""" + async_dispatcher_send(self.hass, f"{DOMAIN}_grid_update") + + async def start_loop(self) -> None: + """Start the receive loop.""" + try: + await self.client.start_loop(self.on_data) + except BlueCurrentException as err: + LOGGER.warning( + "Disconnected from the Blue Current websocket. Retrying to connect in background. %s", + err, + ) + + async_call_later(self.hass, SMALL_DELAY, self.reconnect) + + async def reconnect(self, _event_time: datetime | None = None) -> None: + """Keep trying to reconnect to the websocket.""" + try: + await self.connect(self.config.data[CONF_API_TOKEN]) + LOGGER.info("Reconnected to the Blue Current websocket") + self.hass.async_create_task(self.start_loop()) + await self.client.get_charge_points() + except RequestLimitReached: + self.available = False + async_call_later( + self.hass, self.client.get_next_reset_delta(), self.reconnect + ) + except WebsocketError: + self.available = False + async_call_later(self.hass, LARGE_DELAY, self.reconnect) + + async def disconnect(self) -> None: + """Disconnect from the websocket.""" + with suppress(WebsocketError): + await self.client.disconnect() diff --git a/homeassistant/components/blue_current/config_flow.py b/homeassistant/components/blue_current/config_flow.py new file mode 100644 index 00000000000..32a6c177b49 --- /dev/null +++ b/homeassistant/components/blue_current/config_flow.py @@ -0,0 +1,61 @@ +"""Config flow for Blue Current integration.""" +from __future__ import annotations + +from typing import Any + +from bluecurrent_api import Client +from bluecurrent_api.exceptions import ( + AlreadyConnected, + InvalidApiToken, + RequestLimitReached, + WebsocketError, +) +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_API_TOKEN +from homeassistant.data_entry_flow import FlowResult + +from .const import DOMAIN, LOGGER + +DATA_SCHEMA = vol.Schema({vol.Required(CONF_API_TOKEN): str}) + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle the config flow for Blue Current.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + errors = {} + if user_input is not None: + client = Client() + api_token = user_input[CONF_API_TOKEN] + + try: + customer_id = await client.validate_api_token(api_token) + email = await client.get_email() + except WebsocketError: + errors["base"] = "cannot_connect" + except RequestLimitReached: + errors["base"] = "limit_reached" + except AlreadyConnected: + errors["base"] = "already_connected" + except InvalidApiToken: + errors["base"] = "invalid_token" + except Exception: # pylint: disable=broad-except + LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + + else: + await self.async_set_unique_id(customer_id) + self._abort_if_unique_id_configured() + + return self.async_create_entry(title=email, data=user_input) + + return self.async_show_form( + step_id="user", data_schema=DATA_SCHEMA, errors=errors + ) diff --git a/homeassistant/components/blue_current/const.py b/homeassistant/components/blue_current/const.py new file mode 100644 index 00000000000..008e6efa872 --- /dev/null +++ b/homeassistant/components/blue_current/const.py @@ -0,0 +1,10 @@ +"""Constants for the Blue Current integration.""" + +import logging + +DOMAIN = "blue_current" + +LOGGER = logging.getLogger(__package__) + +EVSE_ID = "evse_id" +MODEL_TYPE = "model_type" diff --git a/homeassistant/components/blue_current/entity.py b/homeassistant/components/blue_current/entity.py new file mode 100644 index 00000000000..300f2191cdc --- /dev/null +++ b/homeassistant/components/blue_current/entity.py @@ -0,0 +1,63 @@ +"""Entity representing a Blue Current charge point.""" +from homeassistant.const import ATTR_NAME +from homeassistant.core import callback +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity import Entity + +from . import Connector +from .const import DOMAIN, MODEL_TYPE + + +class BlueCurrentEntity(Entity): + """Define a base Blue Current entity.""" + + _attr_has_entity_name = True + _attr_should_poll = False + + def __init__(self, connector: Connector, signal: str) -> None: + """Initialize the entity.""" + self.connector: Connector = connector + self.signal: str = signal + self.has_value: bool = False + + async def async_added_to_hass(self) -> None: + """Register callbacks.""" + + @callback + def update() -> None: + """Update the state.""" + self.update_from_latest_data() + self.async_write_ha_state() + + self.async_on_remove(async_dispatcher_connect(self.hass, self.signal, update)) + + self.update_from_latest_data() + + @property + def available(self) -> bool: + """Return entity availability.""" + return self.connector.available and self.has_value + + @callback + def update_from_latest_data(self) -> None: + """Update the entity from the latest data.""" + raise NotImplementedError + + +class ChargepointEntity(BlueCurrentEntity): + """Define a base charge point entity.""" + + def __init__(self, connector: Connector, evse_id: str) -> None: + """Initialize the entity.""" + chargepoint_name = connector.charge_points[evse_id][ATTR_NAME] + + self.evse_id = evse_id + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, evse_id)}, + name=chargepoint_name if chargepoint_name != "" else evse_id, + manufacturer="Blue Current", + model=connector.charge_points[evse_id][MODEL_TYPE], + ) + + super().__init__(connector, f"{DOMAIN}_value_update_{self.evse_id}") diff --git a/homeassistant/components/blue_current/manifest.json b/homeassistant/components/blue_current/manifest.json new file mode 100644 index 00000000000..bff8a057f08 --- /dev/null +++ b/homeassistant/components/blue_current/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "blue_current", + "name": "Blue Current", + "codeowners": ["@Floris272", "@gleeuwen"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/blue_current", + "iot_class": "cloud_push", + "issue_tracker": "https://github.com/bluecurrent/ha-bluecurrent/issues", + "requirements": ["bluecurrent-api==1.0.6"] +} diff --git a/homeassistant/components/blue_current/sensor.py b/homeassistant/components/blue_current/sensor.py new file mode 100644 index 00000000000..326caa70f54 --- /dev/null +++ b/homeassistant/components/blue_current/sensor.py @@ -0,0 +1,296 @@ +"""Support for Blue Current sensors.""" +from __future__ import annotations + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + CURRENCY_EURO, + UnitOfElectricCurrent, + UnitOfElectricPotential, + UnitOfEnergy, + UnitOfPower, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import Connector +from .const import DOMAIN +from .entity import BlueCurrentEntity, ChargepointEntity + +TIMESTAMP_KEYS = ("start_datetime", "stop_datetime", "offline_since") + +SENSORS = ( + SensorEntityDescription( + key="actual_v1", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + translation_key="actual_v1", + entity_registry_enabled_default=False, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="actual_v2", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + translation_key="actual_v2", + entity_registry_enabled_default=False, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="actual_v3", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + translation_key="actual_v3", + entity_registry_enabled_default=False, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="avg_voltage", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + translation_key="avg_voltage", + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="actual_p1", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + translation_key="actual_p1", + entity_registry_enabled_default=False, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="actual_p2", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + translation_key="actual_p2", + entity_registry_enabled_default=False, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="actual_p3", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + translation_key="actual_p3", + entity_registry_enabled_default=False, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="avg_current", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + translation_key="avg_current", + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="total_kw", + native_unit_of_measurement=UnitOfPower.KILO_WATT, + device_class=SensorDeviceClass.POWER, + translation_key="total_kw", + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="actual_kwh", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + translation_key="actual_kwh", + state_class=SensorStateClass.TOTAL_INCREASING, + ), + SensorEntityDescription( + key="start_datetime", + device_class=SensorDeviceClass.TIMESTAMP, + translation_key="start_datetime", + ), + SensorEntityDescription( + key="stop_datetime", + device_class=SensorDeviceClass.TIMESTAMP, + translation_key="stop_datetime", + ), + SensorEntityDescription( + key="offline_since", + device_class=SensorDeviceClass.TIMESTAMP, + translation_key="offline_since", + ), + SensorEntityDescription( + key="total_cost", + native_unit_of_measurement=CURRENCY_EURO, + device_class=SensorDeviceClass.MONETARY, + translation_key="total_cost", + ), + SensorEntityDescription( + key="vehicle_status", + icon="mdi:car", + device_class=SensorDeviceClass.ENUM, + options=["standby", "vehicle_detected", "ready", "no_power", "vehicle_error"], + translation_key="vehicle_status", + ), + SensorEntityDescription( + key="activity", + icon="mdi:ev-station", + device_class=SensorDeviceClass.ENUM, + options=["available", "charging", "unavailable", "error", "offline"], + translation_key="activity", + ), + SensorEntityDescription( + key="max_usage", + translation_key="max_usage", + icon="mdi:gauge-full", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="smartcharging_max_usage", + translation_key="smartcharging_max_usage", + icon="mdi:gauge-full", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + entity_registry_enabled_default=False, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="max_offline", + translation_key="max_offline", + icon="mdi:gauge-full", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + entity_registry_enabled_default=False, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="current_left", + translation_key="current_left", + icon="mdi:gauge", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + entity_registry_enabled_default=False, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + ), +) + +GRID_SENSORS = ( + SensorEntityDescription( + key="grid_actual_p1", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + translation_key="grid_actual_p1", + entity_registry_enabled_default=False, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="grid_actual_p2", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + translation_key="grid_actual_p2", + entity_registry_enabled_default=False, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="grid_actual_p3", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + translation_key="grid_actual_p3", + entity_registry_enabled_default=False, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="grid_avg_current", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + translation_key="grid_avg_current", + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="grid_max_current", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + translation_key="grid_max_current", + state_class=SensorStateClass.MEASUREMENT, + ), +) + +PARALLEL_UPDATES = 1 + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up Blue Current sensors.""" + connector: Connector = hass.data[DOMAIN][entry.entry_id] + sensor_list: list[SensorEntity] = [] + for evse_id in connector.charge_points: + for sensor in SENSORS: + sensor_list.append(ChargePointSensor(connector, sensor, evse_id)) + + for grid_sensor in GRID_SENSORS: + sensor_list.append(GridSensor(connector, grid_sensor)) + + async_add_entities(sensor_list) + + +class ChargePointSensor(ChargepointEntity, SensorEntity): + """Define a charge point sensor.""" + + def __init__( + self, + connector: Connector, + sensor: SensorEntityDescription, + evse_id: str, + ) -> None: + """Initialize the sensor.""" + super().__init__(connector, evse_id) + + self.key = sensor.key + self.entity_description = sensor + self._attr_unique_id = f"{sensor.key}_{evse_id}" + + @callback + def update_from_latest_data(self) -> None: + """Update the sensor from the latest data.""" + + new_value = self.connector.charge_points[self.evse_id].get(self.key) + + if new_value is not None: + if self.key in TIMESTAMP_KEYS and not ( + self._attr_native_value is None or self._attr_native_value < new_value + ): + return + self.has_value = True + self._attr_native_value = new_value + + elif self.key not in TIMESTAMP_KEYS: + self.has_value = False + + +class GridSensor(BlueCurrentEntity, SensorEntity): + """Define a grid sensor.""" + + def __init__( + self, + connector: Connector, + sensor: SensorEntityDescription, + ) -> None: + """Initialize the sensor.""" + super().__init__(connector, f"{DOMAIN}_grid_update") + + self.key = sensor.key + self.entity_description = sensor + self._attr_unique_id = sensor.key + + @callback + def update_from_latest_data(self) -> None: + """Update the grid sensor from the latest data.""" + + new_value = self.connector.grid.get(self.key) + + if new_value is not None: + self.has_value = True + self._attr_native_value = new_value + + else: + self.has_value = False diff --git a/homeassistant/components/blue_current/strings.json b/homeassistant/components/blue_current/strings.json new file mode 100644 index 00000000000..10c114e5f1c --- /dev/null +++ b/homeassistant/components/blue_current/strings.json @@ -0,0 +1,117 @@ +{ + "config": { + "step": { + "user": { + "data": { + "api_token": "[%key:common::config_flow::data::api_token%]" + }, + "description": "Enter your Blue Current api token", + "title": "Authentication" + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "limit_reached": "Request limit reached", + "invalid_token": "Invalid token", + "no_cards_found": "No charge cards found", + "already_connected": "Already connected", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" + } + }, + "entity": { + "sensor": { + "activity": { + "name": "Activity", + "state": { + "available": "Available", + "charging": "Charging", + "unavailable": "Unavailable", + "error": "Error", + "offline": "Offline" + } + }, + "vehicle_status": { + "name": "Vehicle status", + "state": { + "standby": "Standby", + "vehicle_detected": "Detected", + "ready": "Ready", + "no_power": "No power", + "vehicle_error": "Error" + } + }, + "actual_v1": { + "name": "Voltage phase 1" + }, + "actual_v2": { + "name": "Voltage phase 2" + }, + "actual_v3": { + "name": "Voltage phase 3" + }, + "avg_voltage": { + "name": "Average voltage" + }, + "actual_p1": { + "name": "Current phase 1" + }, + "actual_p2": { + "name": "Current phase 2" + }, + "actual_p3": { + "name": "Current phase 3" + }, + "avg_current": { + "name": "Average current" + }, + "total_kw": { + "name": "Total power" + }, + "actual_kwh": { + "name": "Energy usage" + }, + "start_datetime": { + "name": "Started on" + }, + "stop_datetime": { + "name": "Stopped on" + }, + "offline_since": { + "name": "Offline since" + }, + "total_cost": { + "name": "Total cost" + }, + "max_usage": { + "name": "Max usage" + }, + "smartcharging_max_usage": { + "name": "Smart charging max usage" + }, + "max_offline": { + "name": "Offline max usage" + }, + "current_left": { + "name": "Remaining current" + }, + "grid_actual_p1": { + "name": "Grid current phase 1" + }, + "grid_actual_p2": { + "name": "Grid current phase 2" + }, + "grid_actual_p3": { + "name": "Grid current phase 3" + }, + "grid_avg_current": { + "name": "Average grid current" + }, + "grid_max_current": { + "name": "Max grid current" + } + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 9fcc3ea93b9..eaeff88f5ed 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -66,6 +66,7 @@ FLOWS = { "balboa", "blebox", "blink", + "blue_current", "bluemaestro", "bluetooth", "bmw_connected_drive", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 61cb665af2f..5c19a418853 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -650,6 +650,12 @@ "config_flow": false, "iot_class": "cloud_polling" }, + "blue_current": { + "name": "Blue Current", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_push" + }, "bluemaestro": { "name": "BlueMaestro", "integration_type": "hub", diff --git a/mypy.ini b/mypy.ini index 45ad5207078..3aa2e5dfdbf 100644 --- a/mypy.ini +++ b/mypy.ini @@ -570,6 +570,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.blue_current.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.bluetooth.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index 4c542511f67..0e82fbedfc5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -552,6 +552,9 @@ blinkpy==0.22.4 # homeassistant.components.bitcoin blockchain==1.4.4 +# homeassistant.components.blue_current +bluecurrent-api==1.0.6 + # homeassistant.components.bluemaestro bluemaestro-ble==0.2.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c25939d2063..e5483fce899 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -468,6 +468,9 @@ blebox-uniapi==2.2.0 # homeassistant.components.blink blinkpy==0.22.4 +# homeassistant.components.blue_current +bluecurrent-api==1.0.6 + # homeassistant.components.bluemaestro bluemaestro-ble==0.2.3 diff --git a/tests/components/blue_current/__init__.py b/tests/components/blue_current/__init__.py new file mode 100644 index 00000000000..901c776a894 --- /dev/null +++ b/tests/components/blue_current/__init__.py @@ -0,0 +1,52 @@ +"""Tests for the Blue Current integration.""" +from __future__ import annotations + +from unittest.mock import patch + +from bluecurrent_api import Client + +from homeassistant.components.blue_current import DOMAIN, Connector +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.dispatcher import async_dispatcher_send + +from tests.common import MockConfigEntry + + +async def init_integration( + hass: HomeAssistant, platform, data: dict, grid=None +) -> MockConfigEntry: + """Set up the Blue Current integration in Home Assistant.""" + + if grid is None: + grid = {} + + def init( + self: Connector, hass: HomeAssistant, config: ConfigEntry, client: Client + ) -> None: + """Mock grid and charge_points.""" + + self.config = config + self.hass = hass + self.client = client + self.charge_points = data + self.grid = grid + self.available = True + + with patch( + "homeassistant.components.blue_current.PLATFORMS", [platform] + ), patch.object(Connector, "__init__", init), patch( + "homeassistant.components.blue_current.Client", autospec=True + ): + config_entry = MockConfigEntry( + domain=DOMAIN, + entry_id="uuid", + unique_id="uuid", + data={"api_token": "123", "card": {"123"}}, + ) + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + async_dispatcher_send(hass, "blue_current_value_update_101") + return config_entry diff --git a/tests/components/blue_current/test_config_flow.py b/tests/components/blue_current/test_config_flow.py new file mode 100644 index 00000000000..c510aeada4f --- /dev/null +++ b/tests/components/blue_current/test_config_flow.py @@ -0,0 +1,89 @@ +"""Test the Blue Current config flow.""" +from unittest.mock import patch + +import pytest + +from homeassistant import config_entries +from homeassistant.components.blue_current import DOMAIN +from homeassistant.components.blue_current.config_flow import ( + AlreadyConnected, + InvalidApiToken, + RequestLimitReached, + WebsocketError, +) +from homeassistant.core import HomeAssistant + + +async def test_form(hass: HomeAssistant) -> None: + """Test if the form is created.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["errors"] == {} + + +async def test_user(hass: HomeAssistant) -> None: + """Test if the api token is set.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["errors"] == {} + + with patch("bluecurrent_api.Client.validate_api_token", return_value=True), patch( + "bluecurrent_api.Client.get_email", return_value="test@email.com" + ), patch( + "homeassistant.components.blue_current.async_setup_entry", + return_value=True, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "api_token": "123", + }, + ) + await hass.async_block_till_done() + + assert result2["title"] == "test@email.com" + assert result2["data"] == {"api_token": "123"} + + +@pytest.mark.parametrize( + ("error", "message"), + [ + (InvalidApiToken(), "invalid_token"), + (RequestLimitReached(), "limit_reached"), + (AlreadyConnected(), "already_connected"), + (Exception(), "unknown"), + (WebsocketError(), "cannot_connect"), + ], +) +async def test_flow_fails(hass: HomeAssistant, error: Exception, message: str) -> None: + """Test user initialized flow with invalid username.""" + with patch( + "bluecurrent_api.Client.validate_api_token", + side_effect=error, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data={"api_token": "123"}, + ) + assert result["errors"]["base"] == message + + with patch("bluecurrent_api.Client.validate_api_token", return_value=True), patch( + "bluecurrent_api.Client.get_email", return_value="test@email.com" + ), patch( + "homeassistant.components.blue_current.async_setup_entry", + return_value=True, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "api_token": "123", + }, + ) + await hass.async_block_till_done() + + assert result2["title"] == "test@email.com" + assert result2["data"] == {"api_token": "123"} diff --git a/tests/components/blue_current/test_init.py b/tests/components/blue_current/test_init.py new file mode 100644 index 00000000000..fe40f58077f --- /dev/null +++ b/tests/components/blue_current/test_init.py @@ -0,0 +1,185 @@ +"""Test Blue Current Init Component.""" + +from datetime import timedelta +from unittest.mock import patch + +from bluecurrent_api.client import Client +from bluecurrent_api.exceptions import RequestLimitReached, WebsocketError +import pytest + +from homeassistant.components.blue_current import DOMAIN, Connector, async_setup_entry +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady + +from . import init_integration + +from tests.common import MockConfigEntry + + +async def test_load_unload_entry(hass: HomeAssistant) -> None: + """Test load and unload entry.""" + config_entry = await init_integration(hass, "sensor", {}) + assert config_entry.state == ConfigEntryState.LOADED + assert isinstance(hass.data[DOMAIN][config_entry.entry_id], Connector) + + await hass.config_entries.async_unload(config_entry.entry_id) + await hass.async_block_till_done() + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert hass.data[DOMAIN] == {} + + +async def test_config_not_ready(hass: HomeAssistant) -> None: + """Tests if ConfigEntryNotReady is raised when connect raises a WebsocketError.""" + with patch( + "bluecurrent_api.Client.connect", + side_effect=WebsocketError, + ), pytest.raises(ConfigEntryNotReady): + config_entry = MockConfigEntry( + domain=DOMAIN, + entry_id="uuid", + unique_id="uuid", + data={"api_token": "123", "card": {"123"}}, + ) + config_entry.add_to_hass(hass) + + await async_setup_entry(hass, config_entry) + + +async def test_on_data(hass: HomeAssistant) -> None: + """Test on_data.""" + + await init_integration(hass, "sensor", {}) + + with patch( + "homeassistant.components.blue_current.async_dispatcher_send" + ) as test_async_dispatcher_send: + connector: Connector = hass.data[DOMAIN]["uuid"] + + # test CHARGE_POINTS + data = { + "object": "CHARGE_POINTS", + "data": [{"evse_id": "101", "model_type": "hidden", "name": ""}], + } + await connector.on_data(data) + assert connector.charge_points == {"101": {"model_type": "hidden", "name": ""}} + + # test CH_STATUS + data2 = { + "object": "CH_STATUS", + "data": { + "actual_v1": 12, + "actual_v2": 14, + "actual_v3": 15, + "actual_p1": 12, + "actual_p2": 14, + "actual_p3": 15, + "activity": "charging", + "start_datetime": "2021-11-18T14:12:23", + "stop_datetime": "2021-11-18T14:32:23", + "offline_since": "2021-11-18T14:32:23", + "total_cost": 10.52, + "vehicle_status": "standby", + "actual_kwh": 10, + "evse_id": "101", + }, + } + await connector.on_data(data2) + assert connector.charge_points == { + "101": { + "model_type": "hidden", + "name": "", + "actual_v1": 12, + "actual_v2": 14, + "actual_v3": 15, + "actual_p1": 12, + "actual_p2": 14, + "actual_p3": 15, + "activity": "charging", + "start_datetime": "2021-11-18T14:12:23", + "stop_datetime": "2021-11-18T14:32:23", + "offline_since": "2021-11-18T14:32:23", + "total_cost": 10.52, + "vehicle_status": "standby", + "actual_kwh": 10, + } + } + + test_async_dispatcher_send.assert_called_with( + hass, "blue_current_value_update_101" + ) + + # test GRID_STATUS + data3 = { + "object": "GRID_STATUS", + "data": { + "grid_actual_p1": 12, + "grid_actual_p2": 14, + "grid_actual_p3": 15, + }, + } + await connector.on_data(data3) + assert connector.grid == { + "grid_actual_p1": 12, + "grid_actual_p2": 14, + "grid_actual_p3": 15, + } + test_async_dispatcher_send.assert_called_with(hass, "blue_current_grid_update") + + +async def test_start_loop(hass: HomeAssistant) -> None: + """Tests start_loop.""" + + with patch( + "homeassistant.components.blue_current.async_call_later" + ) as test_async_call_later: + config_entry = MockConfigEntry( + domain=DOMAIN, + entry_id="uuid", + unique_id="uuid", + data={"api_token": "123", "card": {"123"}}, + ) + + connector = Connector(hass, config_entry, Client) + + with patch( + "bluecurrent_api.Client.start_loop", + side_effect=WebsocketError("unknown command"), + ): + await connector.start_loop() + test_async_call_later.assert_called_with(hass, 1, connector.reconnect) + + with patch( + "bluecurrent_api.Client.start_loop", side_effect=RequestLimitReached + ): + await connector.start_loop() + test_async_call_later.assert_called_with(hass, 1, connector.reconnect) + + +async def test_reconnect(hass: HomeAssistant) -> None: + """Tests reconnect.""" + + with patch("bluecurrent_api.Client.connect"), patch( + "bluecurrent_api.Client.connect", side_effect=WebsocketError + ), patch( + "bluecurrent_api.Client.get_next_reset_delta", return_value=timedelta(hours=1) + ), patch( + "homeassistant.components.blue_current.async_call_later" + ) as test_async_call_later: + config_entry = MockConfigEntry( + domain=DOMAIN, + entry_id="uuid", + unique_id="uuid", + data={"api_token": "123", "card": {"123"}}, + ) + + connector = Connector(hass, config_entry, Client) + await connector.reconnect() + + test_async_call_later.assert_called_with(hass, 20, connector.reconnect) + + with patch("bluecurrent_api.Client.connect", side_effect=RequestLimitReached): + await connector.reconnect() + test_async_call_later.assert_called_with( + hass, timedelta(hours=1), connector.reconnect + ) diff --git a/tests/components/blue_current/test_sensor.py b/tests/components/blue_current/test_sensor.py new file mode 100644 index 00000000000..a4bcbfcda00 --- /dev/null +++ b/tests/components/blue_current/test_sensor.py @@ -0,0 +1,181 @@ +"""The tests for Blue current sensors.""" +from datetime import datetime +from typing import Any + +from homeassistant.components.blue_current import Connector +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.dispatcher import async_dispatcher_send + +from . import init_integration + +TIMESTAMP_KEYS = ("start_datetime", "stop_datetime", "offline_since") + + +charge_point = { + "actual_v1": 14, + "actual_v2": 18, + "actual_v3": 15, + "actual_p1": 19, + "actual_p2": 14, + "actual_p3": 15, + "activity": "available", + "start_datetime": datetime.strptime("20211118 14:12:23+08:00", "%Y%m%d %H:%M:%S%z"), + "stop_datetime": datetime.strptime("20211118 14:32:23+00:00", "%Y%m%d %H:%M:%S%z"), + "offline_since": datetime.strptime("20211118 14:32:23+00:00", "%Y%m%d %H:%M:%S%z"), + "total_cost": 13.32, + "avg_current": 16, + "avg_voltage": 15.7, + "total_kw": 251.2, + "vehicle_status": "standby", + "actual_kwh": 11, + "max_usage": 10, + "max_offline": 7, + "smartcharging_max_usage": 6, + "current_left": 10, +} + +data: dict[str, Any] = { + "101": { + "model_type": "hidden", + "evse_id": "101", + "name": "", + **charge_point, + } +} + + +charge_point_entity_ids = { + "voltage_phase_1": "actual_v1", + "voltage_phase_2": "actual_v2", + "voltage_phase_3": "actual_v3", + "current_phase_1": "actual_p1", + "current_phase_2": "actual_p2", + "current_phase_3": "actual_p3", + "activity": "activity", + "started_on": "start_datetime", + "stopped_on": "stop_datetime", + "offline_since": "offline_since", + "total_cost": "total_cost", + "average_current": "avg_current", + "average_voltage": "avg_voltage", + "total_power": "total_kw", + "vehicle_status": "vehicle_status", + "energy_usage": "actual_kwh", + "max_usage": "max_usage", + "offline_max_usage": "max_offline", + "smart_charging_max_usage": "smartcharging_max_usage", + "remaining_current": "current_left", +} + +grid = { + "grid_actual_p1": 12, + "grid_actual_p2": 14, + "grid_actual_p3": 15, + "grid_max_current": 15, + "grid_avg_current": 13.7, +} + +grid_entity_ids = { + "grid_current_phase_1": "grid_actual_p1", + "grid_current_phase_2": "grid_actual_p2", + "grid_current_phase_3": "grid_actual_p3", + "max_grid_current": "grid_max_current", + "average_grid_current": "grid_avg_current", +} + + +async def test_sensors(hass: HomeAssistant) -> None: + """Test the underlying sensors.""" + await init_integration(hass, "sensor", data, grid) + + entity_registry = er.async_get(hass) + for entity_id, key in charge_point_entity_ids.items(): + entry = entity_registry.async_get(f"sensor.101_{entity_id}") + assert entry + assert entry.unique_id == f"{key}_101" + + # skip sensors that are disabled by default. + if not entry.disabled: + state = hass.states.get(f"sensor.101_{entity_id}") + assert state is not None + + value = charge_point[key] + + if key in TIMESTAMP_KEYS: + assert datetime.strptime(state.state, "%Y-%m-%dT%H:%M:%S%z") == value + else: + assert state.state == str(value) + + for entity_id, key in grid_entity_ids.items(): + entry = entity_registry.async_get(f"sensor.{entity_id}") + assert entry + assert entry.unique_id == key + + # skip sensors that are disabled by default. + if not entry.disabled: + state = hass.states.get(f"sensor.{entity_id}") + assert state is not None + assert state.state == str(grid[key]) + + sensors = er.async_entries_for_config_entry(entity_registry, "uuid") + assert len(charge_point.keys()) + len(grid.keys()) == len(sensors) + + +async def test_sensor_update(hass: HomeAssistant) -> None: + """Test if the sensors get updated when there is new data.""" + await init_integration(hass, "sensor", data, grid) + key = "avg_voltage" + entity_id = "average_voltage" + timestamp_key = "start_datetime" + timestamp_entity_id = "started_on" + grid_key = "grid_avg_current" + grid_entity_id = "average_grid_current" + + connector: Connector = hass.data["blue_current"]["uuid"] + + connector.charge_points = {"101": {key: 20, timestamp_key: None}} + connector.grid = {grid_key: 20} + async_dispatcher_send(hass, "blue_current_value_update_101") + await hass.async_block_till_done() + async_dispatcher_send(hass, "blue_current_grid_update") + await hass.async_block_till_done() + + # test data updated + state = hass.states.get(f"sensor.101_{entity_id}") + assert state is not None + assert state.state == str(20) + + # grid + state = hass.states.get(f"sensor.{grid_entity_id}") + assert state + assert state.state == str(20) + + # test unavailable + state = hass.states.get("sensor.101_energy_usage") + assert state + assert state.state == "unavailable" + + # test if timestamp keeps old value + state = hass.states.get(f"sensor.101_{timestamp_entity_id}") + assert state + assert ( + datetime.strptime(state.state, "%Y-%m-%dT%H:%M:%S%z") + == charge_point[timestamp_key] + ) + + # test if older timestamp is ignored + connector.charge_points = { + "101": { + timestamp_key: datetime.strptime( + "20211118 14:11:23+08:00", "%Y%m%d %H:%M:%S%z" + ) + } + } + async_dispatcher_send(hass, "blue_current_value_update_101") + state = hass.states.get(f"sensor.101_{timestamp_entity_id}") + assert state + assert ( + datetime.strptime(state.state, "%Y-%m-%dT%H:%M:%S%z") + == charge_point[timestamp_key] + )