diff --git a/.strict-typing b/.strict-typing index 5e1549256616c9..7abebd2b11d3ae 100644 --- a/.strict-typing +++ b/.strict-typing @@ -138,6 +138,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 1662d1b3df0cc9..e980ad082e1096 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..56f302ee0b9c02 --- /dev/null +++ b/homeassistant/components/centriconnect/__init__.py @@ -0,0 +1,33 @@ +"""The CentriConnect/MyPropane API integration.""" + +from __future__ import annotations + +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.""" + _LOGGER.info("Unloading CentriConnect/MyPropane API integration") + 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..e79559f7d0ae43 --- /dev/null +++ b/homeassistant/components/centriconnect/config_flow.py @@ -0,0 +1,95 @@ +"""Config flow for the CentriConnect/MyPropane API integration.""" + +from __future__ import annotations + +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 import config_entries +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(config_entries.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 + ) -> config_entries.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: + errors["base"] = "cannot_connect" + except CentriConnectTooManyRequestsError: + errors["base"] = "cannot_connect" + except CentriConnectNotFoundError: + errors["base"] = "invalid_auth" + except CentriConnectEmptyResponseError: + errors["base"] = "unknown" + except 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..71566961f881df --- /dev/null +++ b/homeassistant/components/centriconnect/coordinator.py @@ -0,0 +1,89 @@ +"""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.exceptions import ConfigEntryNotReady +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() + 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, + ) + except CentriConnectError as err: + raise ConfigEntryNotReady("Could not fetch device info") from err + + 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..02e5be8166d774 --- /dev/null +++ b/homeassistant/components/centriconnect/icons.json @@ -0,0 +1,84 @@ +{ + "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_level": { + "default": "mdi:battery-unknown", + "range": { + "0": "mdi:battery-outline", + "10": "mdi:battery-10", + "20": "mdi:battery-20", + "30": "mdi:battery-30", + "40": "mdi:battery-40", + "50": "mdi:battery-50", + "60": "mdi:battery-60", + "70": "mdi:battery-70", + "80": "mdi:battery-80", + "90": "mdi:battery-90", + "100": "mdi:battery" + } + }, + "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..9a7e1065bf0583 --- /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.2"] +} 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..a79a79ecb6f4f8 --- /dev/null +++ b/homeassistant/components/centriconnect/sensor.py @@ -0,0 +1,301 @@ +"""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 ( + DEGREE, + 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: ( + # The battery level is estimated based on the battery voltage, + # with 3.5V or below being 0% and 4.05V or above being 100%. + min(1.0, max(((coord.data.battery_voltage - 3.5) / 0.5), 0.0)) * 100 + if coord.data.battery_voltage is not None + else None + ), + ), + 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.LAST_POST_TIME, + translation_key=CentriConnectSensorType.LAST_POST_TIME, + device_class=SensorDeviceClass.TIMESTAMP, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda coord: coord.data.last_post_time, + ), + CentriConnectSensorEntityDescription( + key=CentriConnectSensorType.LATITUDE, + translation_key=CentriConnectSensorType.LATITUDE, + native_unit_of_measurement=DEGREE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda coord: coord.data.latitude, + ), + CentriConnectSensorEntityDescription( + key=CentriConnectSensorType.LONGITUDE, + translation_key=CentriConnectSensorType.LONGITUDE, + native_unit_of_measurement=DEGREE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda coord: coord.data.longitude, + ), + 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: ( + # The LTE signal level is estimated based on the LTE signal strength, + # with -140 dBm or below being 0% and -70 dBm or above being 100%. + min(1.0, max(((coord.data.lte_signal_strength + 140.0) / 70.0), 0.0)) * 100 + if coord.data.lte_signal_strength is not None + else None + ), + ), + 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.NEXT_POST_TIME, + translation_key=CentriConnectSensorType.NEXT_POST_TIME, + device_class=SensorDeviceClass.TIMESTAMP, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda coord: coord.data.next_post_time, + ), + 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: ( + # The solar level is estimated based on the solar voltage, + # with 0V being 0% and 2.86V or above being 110%. + min(1.1, max((coord.data.solar_voltage / 2.6), 0.0)) * 100 + if coord.data.solar_voltage is not None + else None + ), + ), + 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_level * 0.01 * coord.device_info.tank_size + if ( + coord.data.tank_level is not None + and 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_level * 0.01 * coord.device_info.tank_size + if ( + coord.data.tank_level is not None + and 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 + ), + ), +) + + +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) + + +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 + ) diff --git a/homeassistant/components/centriconnect/strings.json b/homeassistant/components/centriconnect/strings.json new file mode 100644 index 00000000000000..229f9f123e5d5d --- /dev/null +++ b/homeassistant/components/centriconnect/strings.json @@ -0,0 +1,84 @@ +{ + "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_level": { + "name": "Battery level" + }, + "battery_voltage": { + "name": "Battery voltage" + }, + "device_temperature": { + "name": "Device temperature" + }, + "last_post_time": { + "name": "Last post time" + }, + "latitude": { + "name": "[%key:common::config_flow::data::latitude%]" + }, + "longitude": { + "name": "[%key:common::config_flow::data::longitude%]" + }, + "lte_signal_level": { + "name": "LTE signal level" + }, + "lte_signal_strength": { + "name": "LTE signal strength" + }, + "next_post_time": { + "name": "Next post time" + }, + "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 b28eb0a3c74e36..69c738c8f3dbb0 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -120,6 +120,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 a13d5b35294a18..c9c24ce19d2902 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 0ca25a2f94ba2b..5df9f40379a261 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1134,6 +1134,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 1a272b144d9460..fe37196789e7be 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.2 + # homeassistant.components.comelit aiocomelit==2.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2e4f05a776508c..d014daf688e49f 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.2 + # homeassistant.components.comelit aiocomelit==2.0.1 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..12ca3d04ce2017 --- /dev/null +++ b/tests/components/centriconnect/conftest.py @@ -0,0 +1,66 @@ +"""Common fixtures for the CentriConnect/MyPropane API tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +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 an CentriConnect/MyPropane client.""" + with patch( + "aiocentriconnect.api.API.async_request", + return_value={ + "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", + }, + ) as mock_client: + yield mock_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..d2f1e593dd826f --- /dev/null +++ b/tests/components/centriconnect/snapshots/test_sensor.ambr @@ -0,0 +1,905 @@ +# 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_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_battery_level', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Battery level', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery level', + '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_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'My Tank Battery level', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.my_tank_battery_level', + '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_last_post_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.my_tank_last_post_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Last post time', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Last post time', + 'platform': 'centriconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '123a4b5c-678d-9e0f-a123-4b567c8d901e_last_post_time', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.my_tank_last_post_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'My Tank Last post time', + }), + 'context': , + 'entity_id': 'sensor.my_tank_last_post_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2026-02-27T22:00:31+00:00', + }) +# --- +# name: test_all_entities[sensor.my_tank_latitude-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_latitude', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Latitude', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Latitude', + 'platform': 'centriconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '123a4b5c-678d-9e0f-a123-4b567c8d901e_latitude', + 'unit_of_measurement': '°', + }) +# --- +# name: test_all_entities[sensor.my_tank_latitude-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'My Tank Latitude', + 'state_class': , + 'unit_of_measurement': '°', + }), + 'context': , + 'entity_id': 'sensor.my_tank_latitude', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '40.7128', + }) +# --- +# name: test_all_entities[sensor.my_tank_longitude-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_longitude', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Longitude', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Longitude', + 'platform': 'centriconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '123a4b5c-678d-9e0f-a123-4b567c8d901e_longitude', + 'unit_of_measurement': '°', + }) +# --- +# name: test_all_entities[sensor.my_tank_longitude-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'My Tank Longitude', + 'state_class': , + 'unit_of_measurement': '°', + }), + 'context': , + 'entity_id': 'sensor.my_tank_longitude', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-74.006', + }) +# --- +# 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_next_post_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.my_tank_next_post_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Next post time', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Next post time', + 'platform': 'centriconnect', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '123a4b5c-678d-9e0f-a123-4b567c8d901e_next_post_time', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.my_tank_next_post_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'My Tank Next post time', + }), + 'context': , + 'entity_id': 'sensor.my_tank_next_post_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2026-02-28T10:00:00+00:00', + }) +# --- +# 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..53c6b78c4e2314 --- /dev/null +++ b/tests/components/centriconnect/test_config_flow.py @@ -0,0 +1,611 @@ +"""Test the CentriConnect/MyPropane API config flow.""" + +from unittest.mock import AsyncMock, patch + +from aiocentriconnect.exceptions import ( + CentriConnectConnectionError, + CentriConnectConnectionTimeoutError, + CentriConnectDecodeError, + CentriConnectEmptyResponseError, + CentriConnectNotFoundError, + CentriConnectTooManyRequestsError, +) + +from homeassistant import config_entries +from homeassistant.components.centriconnect.const import DOMAIN +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_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: + """Test we get the form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + with patch( + "aiocentriconnect.api.API.async_request", + return_value={ + "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", + }, + ): + 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, + }, + ) + await hass.async_block_till_done() + + 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 len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_not_found_error( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: + """Test we handle not found error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "aiocentriconnect.api.API.async_request", + side_effect=CentriConnectNotFoundError(), + ): + 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": "invalid_auth"} + + # 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. + with patch( + "aiocentriconnect.api.API.async_request", + return_value={ + "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", + }, + ): + 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, + }, + ) + await hass.async_block_till_done() + + 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 len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_decode_error( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: + """Test we handle decode error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "aiocentriconnect.api.API.async_request", + side_effect=CentriConnectDecodeError("Oh no!", "Bad response"), + ): + 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": "unknown"} + + # 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. + with patch( + "aiocentriconnect.api.API.async_request", + return_value={ + "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", + }, + ): + 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, + }, + ) + await hass.async_block_till_done() + + 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 len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_unknown_error( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: + """Test we handle unknown error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "aiocentriconnect.api.API.async_request", + side_effect=Exception("Something went wrong!"), + ): + 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": "unknown"} + + # 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. + with patch( + "aiocentriconnect.api.API.async_request", + return_value={ + "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", + }, + ): + 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, + }, + ) + await hass.async_block_till_done() + + 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 len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_timeout(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: + """Test we handle timeout error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "aiocentriconnect.api.API.async_request", + side_effect=CentriConnectConnectionTimeoutError, + ): + 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": "cannot_connect"} + + # 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. + with patch( + "aiocentriconnect.api.API.async_request", + return_value={ + "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", + }, + ): + 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, + }, + ) + await hass.async_block_till_done() + + 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 len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_cannot_connect( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: + """Test we handle cannot connect error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "aiocentriconnect.api.API.async_request", + side_effect=CentriConnectConnectionError, + ): + 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": "cannot_connect"} + + # 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. + with patch( + "aiocentriconnect.api.API.async_request", + return_value={ + "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", + }, + ): + 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, + }, + ) + await hass.async_block_till_done() + + 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 len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_too_many_requests( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: + """Test we handle too many requests error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "aiocentriconnect.api.API.async_request", + side_effect=CentriConnectTooManyRequestsError, + ): + 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": "cannot_connect"} + + # 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. + with patch( + "aiocentriconnect.api.API.async_request", + return_value={ + "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", + }, + ): + 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, + }, + ) + await hass.async_block_till_done() + + 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 len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_empty_response( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: + """Test we handle empty response error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "aiocentriconnect.api.API.async_request", + side_effect=CentriConnectEmptyResponseError, + ): + 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": "unknown"} + + # 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. + with patch( + "aiocentriconnect.api.API.async_request", + return_value={ + "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", + }, + ): + 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, + }, + ) + await hass.async_block_till_done() + + 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 len(mock_setup_entry.mock_calls) == 1 + + +async def test_user_flow_already_configured( + hass: HomeAssistant, + 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": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + with patch( + "aiocentriconnect.api.API.async_request", + return_value={ + "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", + }, + ): + 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, + }, + ) + await hass.async_block_till_done() + + 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)