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_taskpull/104206/head
parent
989a7e7b10
commit
8b0d19aca2
|
@ -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.*
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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()
|
|
@ -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
|
||||
)
|
|
@ -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"
|
|
@ -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}")
|
|
@ -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"]
|
||||
}
|
|
@ -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
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -66,6 +66,7 @@ FLOWS = {
|
|||
"balboa",
|
||||
"blebox",
|
||||
"blink",
|
||||
"blue_current",
|
||||
"bluemaestro",
|
||||
"bluetooth",
|
||||
"bmw_connected_drive",
|
||||
|
|
|
@ -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",
|
||||
|
|
10
mypy.ini
10
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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
|
@ -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"}
|
|
@ -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
|
||||
)
|
|
@ -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]
|
||||
)
|
Loading…
Reference in New Issue