Add bluecurrent integration (#82483)

* Add bluecurrent integration

* Apply feedback

* Rename integration

* changed constants and removed strings.sensor.json

* update blue_current integration

* update bluecurrent-api to 1.0.4

* Update bluecurrent-api to 1.0.5

* Apply feedback

* Remove translation

* Apply feedback

* Use customer_id as unique id

* Apply feedback

* Add @pytest.mark.parametrize

* Replace loop.create_task with async_create_task
This commit is contained in:
Floris272 2023-12-22 16:34:16 +01:00 committed by GitHub
parent 989a7e7b10
commit 8b0d19aca2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 1268 additions and 0 deletions

View File

@ -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.*

View File

@ -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

View File

@ -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()

View File

@ -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
)

View File

@ -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"

View File

@ -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}")

View File

@ -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"]
}

View File

@ -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

View File

@ -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"
}
}
}
}

View File

@ -66,6 +66,7 @@ FLOWS = {
"balboa",
"blebox",
"blink",
"blue_current",
"bluemaestro",
"bluetooth",
"bmw_connected_drive",

View File

@ -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",

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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"}

View File

@ -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
)

View File

@ -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]
)