diff --git a/.strict-typing b/.strict-typing index c82292964337fb..d5746a27795fa4 100644 --- a/.strict-typing +++ b/.strict-typing @@ -139,6 +139,7 @@ homeassistant.components.cambridge_audio.* homeassistant.components.camera.* homeassistant.components.canary.* homeassistant.components.casper_glow.* +homeassistant.components.centriconnect.* homeassistant.components.cert_expiry.* homeassistant.components.clickatell.* homeassistant.components.clicksend.* diff --git a/CODEOWNERS b/CODEOWNERS index 5777712c1e8c7a..0bec04df61673e 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -288,6 +288,8 @@ CLAUDE.md @home-assistant/core /tests/components/cast/ @emontnemery /homeassistant/components/ccm15/ @ocalvo /tests/components/ccm15/ @ocalvo +/homeassistant/components/centriconnect/ @gresrun +/tests/components/centriconnect/ @gresrun /homeassistant/components/cert_expiry/ @jjlawren /tests/components/cert_expiry/ @jjlawren /homeassistant/components/chacon_dio/ @cnico diff --git a/homeassistant/components/centriconnect/__init__.py b/homeassistant/components/centriconnect/__init__.py new file mode 100644 index 00000000000000..510c03a46c14d7 --- /dev/null +++ b/homeassistant/components/centriconnect/__init__.py @@ -0,0 +1,30 @@ +"""The CentriConnect/MyPropane API integration.""" + +import logging + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from .coordinator import CentriConnectConfigEntry, CentriConnectCoordinator + +_LOGGER = logging.getLogger(__name__) + +PLATFORMS: list[Platform] = [Platform.SENSOR] + + +async def async_setup_entry( + hass: HomeAssistant, entry: CentriConnectConfigEntry +) -> bool: + """Set up CentriConnect/MyPropane API from a config entry.""" + coordinator = CentriConnectCoordinator(hass, entry) + await coordinator.async_config_entry_first_refresh() + entry.runtime_data = coordinator + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True + + +async def async_unload_entry( + hass: HomeAssistant, entry: CentriConnectConfigEntry +) -> bool: + """Unload CentriConnect/MyPropane API integration platforms and coordinator.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/centriconnect/config_flow.py b/homeassistant/components/centriconnect/config_flow.py new file mode 100644 index 00000000000000..104131f5b040c3 --- /dev/null +++ b/homeassistant/components/centriconnect/config_flow.py @@ -0,0 +1,89 @@ +"""Config flow for the CentriConnect/MyPropane API integration.""" + +import logging +from typing import Any + +from aiocentriconnect import CentriConnect +from aiocentriconnect.exceptions import ( + CentriConnectConnectionError, + CentriConnectDecodeError, + CentriConnectEmptyResponseError, + CentriConnectNotFoundError, + CentriConnectTooManyRequestsError, +) +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_DEVICE_ID, CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import CENTRICONNECT_DEVICE_ID, DOMAIN + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_DEVICE_ID): str, + vol.Required(CONF_PASSWORD): str, + } +) + + +async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, Any]: + """Validate the user input allows us to connect. + + Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user. + """ + # Validate the user-supplied data can be used to set up a connection. + hub = CentriConnect( + data[CONF_USERNAME], + data[CONF_DEVICE_ID], + data[CONF_PASSWORD], + session=async_get_clientsession(hass), + ) + + tank_data = await hub.async_get_tank_data() + + # Return info to store in the config entry. + return { + "title": tank_data.device_name, + CENTRICONNECT_DEVICE_ID: tank_data.device_id, + } + + +class CentriConnectConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for CentriConnect/MyPropane API.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + if user_input is not None: + try: + info = await validate_input(self.hass, user_input) + except CentriConnectConnectionError, CentriConnectTooManyRequestsError: + errors["base"] = "cannot_connect" + except CentriConnectNotFoundError: + errors["base"] = "invalid_auth" + except CentriConnectEmptyResponseError, CentriConnectDecodeError: + errors["base"] = "unknown" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + await self.async_set_unique_id( + unique_id=info[CENTRICONNECT_DEVICE_ID], raise_on_progress=True + ) + self._abort_if_unique_id_configured( + updates=user_input, reload_on_update=True + ) + return self.async_create_entry(title=info["title"], data=user_input) + + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) diff --git a/homeassistant/components/centriconnect/const.py b/homeassistant/components/centriconnect/const.py new file mode 100644 index 00000000000000..1ba4fdc6597563 --- /dev/null +++ b/homeassistant/components/centriconnect/const.py @@ -0,0 +1,5 @@ +"""Constants for the CentriConnect/MyPropane API integration.""" + +DOMAIN = "centriconnect" + +CENTRICONNECT_DEVICE_ID = "device_id" diff --git a/homeassistant/components/centriconnect/coordinator.py b/homeassistant/components/centriconnect/coordinator.py new file mode 100644 index 00000000000000..87ad54919002a6 --- /dev/null +++ b/homeassistant/components/centriconnect/coordinator.py @@ -0,0 +1,88 @@ +"""Coordinator for CentriConnect/MyPropane API integration. + +Responsible for polling the device API endpoint and normalizing data for entities. +""" + +from dataclasses import dataclass +from datetime import timedelta +import logging + +from aiocentriconnect import CentriConnect, Tank +from aiocentriconnect.exceptions import CentriConnectConnectionError, CentriConnectError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_DEVICE_ID, CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +COORDINATOR_NAME = f"{DOMAIN} Coordinator" +# Maximum update frequency is every 6 hours. The API will return 429 Too Many Requests if polled frequently. +# The device updates its data every 8-12 hours, so there's no need to poll more frequently. +UPDATE_INTERVAL = timedelta(hours=6) + +type CentriConnectConfigEntry = ConfigEntry[CentriConnectCoordinator] + + +@dataclass +class CentriConnectDeviceInfo: + """Data about the CentriConnect device.""" + + device_id: str + device_name: str + hardware_version: str + lte_version: str + tank_size: int + tank_size_unit: str + + +class CentriConnectCoordinator(DataUpdateCoordinator[Tank]): + """Data update coordinator for CentriConnect/MyPropane devices.""" + + config_entry: CentriConnectConfigEntry + device_info: CentriConnectDeviceInfo + + def __init__(self, hass: HomeAssistant, entry: CentriConnectConfigEntry) -> None: + """Initialize the CentriConnect data update coordinator.""" + super().__init__( + hass, + logger=_LOGGER, + name=COORDINATOR_NAME, + update_interval=UPDATE_INTERVAL, + config_entry=entry, + ) + + self.api_client = CentriConnect( + entry.data[CONF_USERNAME], + entry.data[CONF_DEVICE_ID], + entry.data[CONF_PASSWORD], + session=async_get_clientsession(hass), + ) + + async def _async_setup(self) -> None: + try: + tank_data = await self.api_client.async_get_tank_data() + except CentriConnectError as err: + raise UpdateFailed("Could not fetch device info") from err + self.device_info = CentriConnectDeviceInfo( + device_id=tank_data.device_id, + device_name=tank_data.device_name, + hardware_version=tank_data.hardware_version, + lte_version=tank_data.lte_version, + tank_size=tank_data.tank_size, + tank_size_unit=tank_data.tank_size_unit, + ) + + async def _async_update_data(self) -> Tank: + """Fetch device state.""" + try: + state = await self.api_client.async_get_tank_data() + except CentriConnectConnectionError as err: + raise UpdateFailed(f"Error communicating with device: {err}") from err + except CentriConnectError as err: + raise UpdateFailed(f"Unexpected response: {err}") from err + return state diff --git a/homeassistant/components/centriconnect/entity.py b/homeassistant/components/centriconnect/entity.py new file mode 100644 index 00000000000000..97f4a3d7a831b5 --- /dev/null +++ b/homeassistant/components/centriconnect/entity.py @@ -0,0 +1,37 @@ +"""Defines a base CentriConnect entity.""" + +from typing import TYPE_CHECKING + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import EntityDescription +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import CentriConnectCoordinator + + +class CentriConnectBaseEntity(CoordinatorEntity[CentriConnectCoordinator]): + """Defines a base CentriConnect entity.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: CentriConnectCoordinator, + description: EntityDescription, + ) -> None: + """Initialize the CentriConnect entity.""" + super().__init__(coordinator) + if TYPE_CHECKING: + assert coordinator.config_entry.unique_id + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, coordinator.config_entry.unique_id)}, + name=coordinator.device_info.device_name, + serial_number=coordinator.device_info.device_id, + hw_version=coordinator.device_info.hardware_version, + sw_version=coordinator.device_info.lte_version, + manufacturer="CentriConnect", + ) + self._attr_unique_id = f"{coordinator.config_entry.unique_id}_{description.key}" + self.entity_description = description diff --git a/homeassistant/components/centriconnect/icons.json b/homeassistant/components/centriconnect/icons.json new file mode 100644 index 00000000000000..3fa1372a580382 --- /dev/null +++ b/homeassistant/components/centriconnect/icons.json @@ -0,0 +1,68 @@ +{ + "entity": { + "sensor": { + "alert_status": { + "default": "mdi:alert-circle-outline", + "state": { + "critical_level": "mdi:alert-circle", + "low_level": "mdi:alert-circle-outline", + "no_alert": "mdi:check-circle-outline" + } + }, + "altitude": { + "default": "mdi:altimeter" + }, + "battery_voltage": { + "default": "mdi:car-battery" + }, + "device_temperature": { + "default": "mdi:thermometer" + }, + "last_post_time": { + "default": "mdi:clock-end" + }, + "latitude": { + "default": "mdi:latitude" + }, + "longitude": { + "default": "mdi:longitude" + }, + "lte_signal_level": { + "default": "mdi:signal", + "range": { + "0": "mdi:signal-cellular-outline", + "25": "mdi:signal-cellular-1", + "50": "mdi:signal-cellular-2", + "75": "mdi:signal-cellular-3" + } + }, + "lte_signal_strength": { + "default": "mdi:signal-variant" + }, + "next_post_time": { + "default": "mdi:clock-start" + }, + "solar_level": { + "default": "mdi:sun-wireless" + }, + "solar_voltage": { + "default": "mdi:solar-power" + }, + "tank_level": { + "default": "mdi:gauge", + "range": { + "0": "mdi:gauge-empty", + "25": "mdi:gauge-low", + "50": "mdi:gauge", + "75": "mdi:gauge-full" + } + }, + "tank_remaining_volume": { + "default": "mdi:storage-tank-outline" + }, + "tank_size": { + "default": "mdi:storage-tank" + } + } + } +} diff --git a/homeassistant/components/centriconnect/manifest.json b/homeassistant/components/centriconnect/manifest.json new file mode 100644 index 00000000000000..c3392d99e697e9 --- /dev/null +++ b/homeassistant/components/centriconnect/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "centriconnect", + "name": "CentriConnect/MyPropane", + "codeowners": ["@gresrun"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/centriconnect", + "integration_type": "device", + "iot_class": "cloud_polling", + "quality_scale": "bronze", + "requirements": ["aiocentriconnect==0.2.3"] +} diff --git a/homeassistant/components/centriconnect/quality_scale.yaml b/homeassistant/components/centriconnect/quality_scale.yaml new file mode 100644 index 00000000000000..0fadea22539597 --- /dev/null +++ b/homeassistant/components/centriconnect/quality_scale.yaml @@ -0,0 +1,78 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: This integration does not provide actions. + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: This integration does not provide actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: done + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: + status: exempt + comment: This integration does not provide actions. + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: This integration does not provide an options flow. + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: todo + test-coverage: done + + # Gold + devices: done + diagnostics: todo + discovery: + status: exempt + comment: This is a cloud polling integration with no local discovery mechanism. + discovery-update-info: + status: exempt + comment: This is a cloud polling integration with no local discovery mechanism. + docs-data-update: done + docs-examples: done + docs-known-limitations: done + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: done + docs-use-cases: done + dynamic-devices: + status: exempt + comment: This integration is not a hub and only represents a single device. + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: done + exception-translations: todo + icon-translations: done + reconfiguration-flow: todo + repair-issues: + status: exempt + comment: No user-actionable repair scenarios identified for this integration. + stale-devices: + status: exempt + comment: Devices removed from account stop appearing in API responses and become unavailable. + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/centriconnect/sensor.py b/homeassistant/components/centriconnect/sensor.py new file mode 100644 index 00000000000000..ed41f41e595947 --- /dev/null +++ b/homeassistant/components/centriconnect/sensor.py @@ -0,0 +1,242 @@ +"""Sensor platform for CentriConnect/MyPropane API integration.""" + +from collections.abc import Callable +from dataclasses import dataclass +from datetime import datetime +from enum import StrEnum + +from homeassistant.components.sensor import ( + EntityCategory, + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, + StateType, + UnitOfTemperature, +) +from homeassistant.const import ( + PERCENTAGE, + SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + UnitOfElectricPotential, + UnitOfLength, + UnitOfVolume, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import CentriConnectConfigEntry, CentriConnectCoordinator +from .entity import CentriConnectBaseEntity + +# Coordinator is used to centralize the data updates. +PARALLEL_UPDATES = 0 + + +_ALERT_STATUS_VALUES = { + "No Alert": "no_alert", + "Low Level": "low_level", + "Critical Level": "critical_level", +} + + +class CentriConnectSensorType(StrEnum): + """Enumerates CentriConnect sensor types exposed by the device.""" + + ALERT_STATUS = "alert_status" + ALTITUDE = "altitude" + BATTERY_LEVEL = "battery_level" + BATTERY_VOLTAGE = "battery_voltage" + DEVICE_TEMPERATURE = "device_temperature" + LAST_POST_TIME = "last_post_time" + LATITUDE = "latitude" + LONGITUDE = "longitude" + LTE_SIGNAL_LEVEL = "lte_signal_level" + LTE_SIGNAL_STRENGTH = "lte_signal_strength" + NEXT_POST_TIME = "next_post_time" + SOLAR_LEVEL = "solar_level" + SOLAR_VOLTAGE = "solar_voltage" + TANK_LEVEL = "tank_level" + TANK_REMAINING_VOLUME = "tank_remaining_volume" + TANK_SIZE = "tank_size" + + +@dataclass(frozen=True, kw_only=True) +class CentriConnectSensorEntityDescription(SensorEntityDescription): + """Description of a CentriConnect sensor entity.""" + + key: CentriConnectSensorType + value_fn: Callable[[CentriConnectCoordinator], StateType | datetime | None] + + +ENTITIES: tuple[CentriConnectSensorEntityDescription, ...] = ( + CentriConnectSensorEntityDescription( + key=CentriConnectSensorType.ALERT_STATUS, + translation_key=CentriConnectSensorType.ALERT_STATUS, + device_class=SensorDeviceClass.ENUM, + options=list(_ALERT_STATUS_VALUES.values()), + value_fn=lambda coord: _ALERT_STATUS_VALUES.get(coord.data.alert_status), + ), + CentriConnectSensorEntityDescription( + key=CentriConnectSensorType.ALTITUDE, + translation_key=CentriConnectSensorType.ALTITUDE, + native_unit_of_measurement=UnitOfLength.METERS, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.DISTANCE, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + suggested_display_precision=2, + value_fn=lambda coord: coord.data.altitude, + ), + CentriConnectSensorEntityDescription( + key=CentriConnectSensorType.BATTERY_LEVEL, + translation_key=CentriConnectSensorType.BATTERY_LEVEL, + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.BATTERY, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda coord: coord.data.battery_level, + ), + CentriConnectSensorEntityDescription( + key=CentriConnectSensorType.BATTERY_VOLTAGE, + translation_key=CentriConnectSensorType.BATTERY_VOLTAGE, + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.VOLTAGE, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + suggested_display_precision=0, + value_fn=lambda coord: coord.data.battery_voltage, + ), + CentriConnectSensorEntityDescription( + key=CentriConnectSensorType.DEVICE_TEMPERATURE, + translation_key=CentriConnectSensorType.DEVICE_TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.TEMPERATURE, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + suggested_display_precision=1, + value_fn=lambda coord: coord.data.device_temperature, + ), + CentriConnectSensorEntityDescription( + key=CentriConnectSensorType.LTE_SIGNAL_LEVEL, + translation_key=CentriConnectSensorType.LTE_SIGNAL_LEVEL, + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + suggested_display_precision=0, + value_fn=lambda coord: coord.data.lte_signal_level, + ), + CentriConnectSensorEntityDescription( + key=CentriConnectSensorType.LTE_SIGNAL_STRENGTH, + translation_key=CentriConnectSensorType.LTE_SIGNAL_STRENGTH, + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.SIGNAL_STRENGTH, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda coord: coord.data.lte_signal_strength, + ), + CentriConnectSensorEntityDescription( + key=CentriConnectSensorType.SOLAR_LEVEL, + translation_key=CentriConnectSensorType.SOLAR_LEVEL, + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + suggested_display_precision=0, + value_fn=lambda coord: coord.data.solar_level, + ), + CentriConnectSensorEntityDescription( + key=CentriConnectSensorType.SOLAR_VOLTAGE, + translation_key=CentriConnectSensorType.SOLAR_VOLTAGE, + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.VOLTAGE, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + suggested_display_precision=0, + value_fn=lambda coord: coord.data.solar_voltage, + ), + CentriConnectSensorEntityDescription( + key=CentriConnectSensorType.TANK_LEVEL, + translation_key=CentriConnectSensorType.TANK_LEVEL, + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda coord: coord.data.tank_level, + ), + CentriConnectSensorEntityDescription( + key=CentriConnectSensorType.TANK_REMAINING_VOLUME, + translation_key=CentriConnectSensorType.TANK_REMAINING_VOLUME, + native_unit_of_measurement=UnitOfVolume.GALLONS, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.VOLUME_STORAGE, + suggested_display_precision=2, + value_fn=lambda coord: ( + coord.data.tank_remaining_volume + if coord.device_info.tank_size_unit == "Gallons" + else None + ), + ), + CentriConnectSensorEntityDescription( + key=CentriConnectSensorType.TANK_REMAINING_VOLUME, + translation_key=CentriConnectSensorType.TANK_REMAINING_VOLUME, + native_unit_of_measurement=UnitOfVolume.LITERS, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.VOLUME_STORAGE, + suggested_display_precision=2, + value_fn=lambda coord: ( + coord.data.tank_remaining_volume + if coord.device_info.tank_size_unit == "Liters" + else None + ), + ), + CentriConnectSensorEntityDescription( + key=CentriConnectSensorType.TANK_SIZE, + translation_key=CentriConnectSensorType.TANK_SIZE, + native_unit_of_measurement=UnitOfVolume.GALLONS, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.VOLUME_STORAGE, + suggested_display_precision=2, + value_fn=lambda coord: ( + coord.device_info.tank_size + if (coord.device_info.tank_size_unit == "Gallons") + else None + ), + ), + CentriConnectSensorEntityDescription( + key=CentriConnectSensorType.TANK_SIZE, + translation_key=CentriConnectSensorType.TANK_SIZE, + native_unit_of_measurement=UnitOfVolume.LITERS, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.VOLUME_STORAGE, + suggested_display_precision=2, + value_fn=lambda coord: ( + coord.device_info.tank_size + if (coord.device_info.tank_size_unit == "Liters") + else None + ), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: CentriConnectConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up CentriConnect sensor entities from a config entry.""" + async_add_entities( + CentriConnectSensor(entry.runtime_data, description) + for description in ENTITIES + if description.value_fn(entry.runtime_data) is not None + ) + + +class CentriConnectSensor(CentriConnectBaseEntity, SensorEntity): + """Representation of a CentriConnect sensor entity.""" + + entity_description: CentriConnectSensorEntityDescription + + @property + def native_value(self) -> StateType | datetime | None: + """Return the state of the sensor.""" + return self.entity_description.value_fn(self.coordinator) diff --git a/homeassistant/components/centriconnect/strings.json b/homeassistant/components/centriconnect/strings.json new file mode 100644 index 00000000000000..fffe7a037a52a0 --- /dev/null +++ b/homeassistant/components/centriconnect/strings.json @@ -0,0 +1,69 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "step": { + "user": { + "data": { + "device_id": "Device ID", + "password": "Device Authentication Code", + "username": "User ID" + }, + "data_description": { + "device_id": "Your CentriConnect/MyPropane device ID", + "password": "Your CentriConnect/MyPropane device authentication code", + "username": "Your CentriConnect/MyPropane user ID" + }, + "description": "Enter your CentriConnect/MyPropane device credentials." + } + } + }, + "entity": { + "sensor": { + "alert_status": { + "name": "Alert status", + "state": { + "critical_level": "Critical level", + "low_level": "Low level", + "no_alert": "No alert" + } + }, + "altitude": { + "name": "Altitude" + }, + "battery_voltage": { + "name": "Battery voltage" + }, + "device_temperature": { + "name": "Device temperature" + }, + "lte_signal_level": { + "name": "LTE signal level" + }, + "lte_signal_strength": { + "name": "LTE signal strength" + }, + "solar_level": { + "name": "Solar level" + }, + "solar_voltage": { + "name": "Solar voltage" + }, + "tank_level": { + "name": "Tank level" + }, + "tank_remaining_volume": { + "name": "Tank remaining volume" + }, + "tank_size": { + "name": "Tank size" + } + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index d876cc517d00a5..59a8877cb28230 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -121,6 +121,7 @@ "casper_glow", "cast", "ccm15", + "centriconnect", "cert_expiry", "chacon_dio", "chess_com", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 94edfc1ccaf0b2..6dc48b9aa647ac 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -991,6 +991,12 @@ "config_flow": true, "iot_class": "local_polling" }, + "centriconnect": { + "name": "CentriConnect/MyPropane", + "integration_type": "device", + "config_flow": true, + "iot_class": "cloud_polling" + }, "cert_expiry": { "integration_type": "service", "config_flow": true, diff --git a/mypy.ini b/mypy.ini index dafa5166091715..b75391c744211d 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1144,6 +1144,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.centriconnect.*] +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.cert_expiry.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index eb4c09bc9ddb29..7905aca60cf38f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -223,6 +223,9 @@ aiobafi6==0.9.0 # homeassistant.components.idrive_e2 aiobotocore==2.21.1 +# homeassistant.components.centriconnect +aiocentriconnect==0.2.3 + # homeassistant.components.comelit aiocomelit==2.0.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 72ddc371d6a1e7..789997726eed31 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -214,6 +214,9 @@ aiobafi6==0.9.0 # homeassistant.components.idrive_e2 aiobotocore==2.21.1 +# homeassistant.components.centriconnect +aiocentriconnect==0.2.3 + # homeassistant.components.comelit aiocomelit==2.0.3 diff --git a/tests/components/centriconnect/__init__.py b/tests/components/centriconnect/__init__.py new file mode 100644 index 00000000000000..22e7e5a17ac628 --- /dev/null +++ b/tests/components/centriconnect/__init__.py @@ -0,0 +1,12 @@ +"""Tests for the CentriConnect/MyPropane API integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Set up the CentriConnect/MyPropane integration for testing.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/centriconnect/conftest.py b/tests/components/centriconnect/conftest.py new file mode 100644 index 00000000000000..aac8eba67b8c64 --- /dev/null +++ b/tests/components/centriconnect/conftest.py @@ -0,0 +1,77 @@ +"""Common fixtures for the CentriConnect/MyPropane API tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +from aiocentriconnect import Tank +import pytest + +from homeassistant.components.centriconnect.const import DOMAIN +from homeassistant.const import CONF_DEVICE_ID, CONF_PASSWORD, CONF_USERNAME + +from .const import TEST_PASSWORD, TEST_TANK_ID, TEST_TANK_NAME, TEST_USERNAME + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return a mock config entry.""" + return MockConfigEntry( + domain=DOMAIN, + unique_id=TEST_TANK_ID, + data={ + CONF_DEVICE_ID: TEST_TANK_ID, + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + }, + title=TEST_TANK_NAME, + ) + + +@pytest.fixture +def mock_centriconnect_client() -> Generator[AsyncMock]: + """Mock a CentriConnect/MyPropane client.""" + with ( + patch( + "homeassistant.components.centriconnect.coordinator.CentriConnect", + autospec=True, + ) as mock_client, + patch( + "homeassistant.components.centriconnect.config_flow.CentriConnect", + new=mock_client, + ), + ): + client = mock_client.return_value + client.async_get_tank_data.return_value = Tank( + { + "AlertStatus": "No Alert", + "Altitude": 123.456, + "BatteryVolts": 4.19, + "DeviceID": TEST_TANK_ID, + "DeviceName": TEST_TANK_NAME, + "DeviceTempCelsius": 17.0, + "DeviceTempFahrenheit": 63.0, + "LastPostTimeIso": "2026-02-27 22:00:31.000", + "Latitude": 40.7128, + "Longitude": -74.0060, + "NextPostTimeIso": "2026-02-28 10:00:00.000", + "SignalQualLTE": -107.0, + "SolarVolts": 2.46, + "TankLevel": 75.0, + "TankSize": 1000, + "TankSizeUnit": "Gallons", + "VersionHW": "4.1", + "VersionLTE": "1.1.2", + } + ) + yield client + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.centriconnect.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry diff --git a/tests/components/centriconnect/const.py b/tests/components/centriconnect/const.py new file mode 100644 index 00000000000000..de5b2d77535a30 --- /dev/null +++ b/tests/components/centriconnect/const.py @@ -0,0 +1,7 @@ +"""Constants for the CentriConnect/MyPropane integration tests.""" + +TEST_TANK_ID = "123a4b5c-678d-9e0f-a123-4b567c8d901e" +TEST_USERNAME = "12345678-9012-3456-7a89-b012345cde6f" +TEST_PASSWORD = "123456" + +TEST_TANK_NAME = "My Tank" diff --git a/tests/components/centriconnect/snapshots/test_sensor.ambr b/tests/components/centriconnect/snapshots/test_sensor.ambr new file mode 100644 index 00000000000000..9d1e87bb809894 --- /dev/null +++ b/tests/components/centriconnect/snapshots/test_sensor.ambr @@ -0,0 +1,695 @@ +# serializer version: 1 +# name: test_all_entities[sensor.my_tank_alert_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'no_alert', + 'low_level', + 'critical_level', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.my_tank_alert_status', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Alert status', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Alert status', + 'platform': 'centriconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '123a4b5c-678d-9e0f-a123-4b567c8d901e_alert_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.my_tank_alert_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'My Tank Alert status', + 'options': list([ + 'no_alert', + 'low_level', + 'critical_level', + ]), + }), + 'context': , + 'entity_id': 'sensor.my_tank_alert_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'no_alert', + }) +# --- +# name: test_all_entities[sensor.my_tank_altitude-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.my_tank_altitude', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Altitude', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Altitude', + 'platform': 'centriconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '123a4b5c-678d-9e0f-a123-4b567c8d901e_altitude', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.my_tank_altitude-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'My Tank Altitude', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.my_tank_altitude', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '123.456', + }) +# --- +# name: test_all_entities[sensor.my_tank_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.my_tank_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Battery', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'centriconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '123a4b5c-678d-9e0f-a123-4b567c8d901e_battery_level', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[sensor.my_tank_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'My Tank Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.my_tank_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100.0', + }) +# --- +# name: test_all_entities[sensor.my_tank_battery_voltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.my_tank_battery_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Battery voltage', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery voltage', + 'platform': 'centriconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '123a4b5c-678d-9e0f-a123-4b567c8d901e_battery_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.my_tank_battery_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'My Tank Battery voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.my_tank_battery_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '4.19', + }) +# --- +# name: test_all_entities[sensor.my_tank_device_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.my_tank_device_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Device temperature', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Device temperature', + 'platform': 'centriconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '123a4b5c-678d-9e0f-a123-4b567c8d901e_device_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.my_tank_device_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'My Tank Device temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.my_tank_device_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '17.2222222222222', + }) +# --- +# name: test_all_entities[sensor.my_tank_lte_signal_level-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.my_tank_lte_signal_level', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'LTE signal level', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'LTE signal level', + 'platform': 'centriconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '123a4b5c-678d-9e0f-a123-4b567c8d901e_lte_signal_level', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[sensor.my_tank_lte_signal_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'My Tank LTE signal level', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.my_tank_lte_signal_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '47.1428571428571', + }) +# --- +# name: test_all_entities[sensor.my_tank_lte_signal_strength-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.my_tank_lte_signal_strength', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'LTE signal strength', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'LTE signal strength', + 'platform': 'centriconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '123a4b5c-678d-9e0f-a123-4b567c8d901e_lte_signal_strength', + 'unit_of_measurement': 'dBm', + }) +# --- +# name: test_all_entities[sensor.my_tank_lte_signal_strength-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'signal_strength', + 'friendly_name': 'My Tank LTE signal strength', + 'state_class': , + 'unit_of_measurement': 'dBm', + }), + 'context': , + 'entity_id': 'sensor.my_tank_lte_signal_strength', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-107.0', + }) +# --- +# name: test_all_entities[sensor.my_tank_solar_level-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.my_tank_solar_level', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Solar level', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Solar level', + 'platform': 'centriconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '123a4b5c-678d-9e0f-a123-4b567c8d901e_solar_level', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[sensor.my_tank_solar_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'My Tank Solar level', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.my_tank_solar_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '94.6153846153846', + }) +# --- +# name: test_all_entities[sensor.my_tank_solar_voltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.my_tank_solar_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Solar voltage', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Solar voltage', + 'platform': 'centriconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '123a4b5c-678d-9e0f-a123-4b567c8d901e_solar_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.my_tank_solar_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'My Tank Solar voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.my_tank_solar_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2.46', + }) +# --- +# name: test_all_entities[sensor.my_tank_tank_level-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.my_tank_tank_level', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Tank level', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Tank level', + 'platform': 'centriconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '123a4b5c-678d-9e0f-a123-4b567c8d901e_tank_level', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[sensor.my_tank_tank_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'My Tank Tank level', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.my_tank_tank_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '75.0', + }) +# --- +# name: test_all_entities[sensor.my_tank_tank_remaining_volume-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.my_tank_tank_remaining_volume', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Tank remaining volume', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Tank remaining volume', + 'platform': 'centriconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '123a4b5c-678d-9e0f-a123-4b567c8d901e_tank_remaining_volume', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.my_tank_tank_remaining_volume-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'volume_storage', + 'friendly_name': 'My Tank Tank remaining volume', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.my_tank_tank_remaining_volume', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2839.058838', + }) +# --- +# name: test_all_entities[sensor.my_tank_tank_size-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.my_tank_tank_size', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Tank size', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Tank size', + 'platform': 'centriconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '123a4b5c-678d-9e0f-a123-4b567c8d901e_tank_size', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.my_tank_tank_size-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'volume_storage', + 'friendly_name': 'My Tank Tank size', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.my_tank_tank_size', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3785.411784', + }) +# --- diff --git a/tests/components/centriconnect/test_config_flow.py b/tests/components/centriconnect/test_config_flow.py new file mode 100644 index 00000000000000..73c2eed7ef22df --- /dev/null +++ b/tests/components/centriconnect/test_config_flow.py @@ -0,0 +1,153 @@ +"""Test the CentriConnect/MyPropane API config flow.""" + +from unittest.mock import AsyncMock + +from aiocentriconnect.exceptions import ( + CentriConnectConnectionError, + CentriConnectConnectionTimeoutError, + CentriConnectDecodeError, + CentriConnectEmptyResponseError, + CentriConnectNotFoundError, + CentriConnectTooManyRequestsError, +) +import pytest + +from homeassistant.components.centriconnect.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_DEVICE_ID, CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from .const import TEST_PASSWORD, TEST_TANK_ID, TEST_TANK_NAME, TEST_USERNAME + +from tests.common import MockConfigEntry + + +async def test_full_flow( + hass: HomeAssistant, + mock_centriconnect_client: AsyncMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test full flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_DEVICE_ID: TEST_TANK_ID, + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == TEST_TANK_NAME + assert result["data"] == { + CONF_DEVICE_ID: TEST_TANK_ID, + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + } + assert result["result"].unique_id == TEST_TANK_ID + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("exception", "error"), + [ + (CentriConnectNotFoundError, "invalid_auth"), + (CentriConnectDecodeError("Oh no!", "Bad response"), "unknown"), + (CentriConnectConnectionTimeoutError, "cannot_connect"), + (CentriConnectConnectionError, "cannot_connect"), + (CentriConnectTooManyRequestsError, "cannot_connect"), + (CentriConnectEmptyResponseError, "unknown"), + (Exception, "unknown"), + ], +) +async def test_form_errors( + hass: HomeAssistant, + mock_centriconnect_client: AsyncMock, + mock_setup_entry: AsyncMock, + exception: Exception, + error: str, +) -> None: + """Test flow errors.""" + mock_centriconnect_client.async_get_tank_data.side_effect = exception + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_DEVICE_ID: TEST_TANK_ID, + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": error} + + # Make sure the config flow tests finish with either an + # FlowResultType.CREATE_ENTRY or FlowResultType.ABORT so + # we can show the config flow is able to recover from an error. + mock_centriconnect_client.async_get_tank_data.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_DEVICE_ID: TEST_TANK_ID, + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == TEST_TANK_NAME + assert result["data"] == { + CONF_DEVICE_ID: TEST_TANK_ID, + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + } + assert result["result"].unique_id == TEST_TANK_ID + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_user_flow_already_configured( + hass: HomeAssistant, + mock_centriconnect_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that duplicate devices are rejected.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_DEVICE_ID: TEST_TANK_ID, + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" diff --git a/tests/components/centriconnect/test_sensor.py b/tests/components/centriconnect/test_sensor.py new file mode 100644 index 00000000000000..673e4c8fc7a3b5 --- /dev/null +++ b/tests/components/centriconnect/test_sensor.py @@ -0,0 +1,29 @@ +"""Tests for the CentriConnect/MyPropane sensor platform.""" + +from unittest.mock import AsyncMock, patch + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_centriconnect_client: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + with patch("homeassistant.components.centriconnect.PLATFORMS", [Platform.SENSOR]): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)