From 8c8a8638670fb939ea230f0ab529689e2a261fc0 Mon Sep 17 00:00:00 2001 From: Matthew Gibson <64029882+frogman85978@users.noreply.github.com> Date: Mon, 4 May 2026 16:15:52 -0400 Subject: [PATCH] Add ptdevices Integration (#156307) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Joost Lekkerkerker --- .strict-typing | 1 + CODEOWNERS | 2 + .../components/ptdevices/__init__.py | 46 + .../components/ptdevices/config_flow.py | 118 +++ homeassistant/components/ptdevices/const.py | 4 + .../components/ptdevices/coordinator.py | 88 ++ homeassistant/components/ptdevices/entity.py | 49 ++ homeassistant/components/ptdevices/icons.json | 30 + .../components/ptdevices/manifest.json | 12 + .../components/ptdevices/quality_scale.yaml | 75 ++ homeassistant/components/ptdevices/sensor.py | 203 +++++ .../components/ptdevices/strings.json | 69 ++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + mypy.ini | 10 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/ptdevices/__init__.py | 13 + tests/components/ptdevices/conftest.py | 68 ++ .../ptdevices/fixtures/ptdevices_level.json | 86 ++ .../ptdevices/snapshots/test_sensor.ambr | 811 ++++++++++++++++++ .../components/ptdevices/test_config_flow.py | 151 ++++ tests/components/ptdevices/test_sensor.py | 31 + 23 files changed, 1880 insertions(+) create mode 100644 homeassistant/components/ptdevices/__init__.py create mode 100644 homeassistant/components/ptdevices/config_flow.py create mode 100644 homeassistant/components/ptdevices/const.py create mode 100644 homeassistant/components/ptdevices/coordinator.py create mode 100644 homeassistant/components/ptdevices/entity.py create mode 100644 homeassistant/components/ptdevices/icons.json create mode 100644 homeassistant/components/ptdevices/manifest.json create mode 100644 homeassistant/components/ptdevices/quality_scale.yaml create mode 100644 homeassistant/components/ptdevices/sensor.py create mode 100644 homeassistant/components/ptdevices/strings.json create mode 100644 tests/components/ptdevices/__init__.py create mode 100644 tests/components/ptdevices/conftest.py create mode 100644 tests/components/ptdevices/fixtures/ptdevices_level.json create mode 100644 tests/components/ptdevices/snapshots/test_sensor.ambr create mode 100644 tests/components/ptdevices/test_config_flow.py create mode 100644 tests/components/ptdevices/test_sensor.py diff --git a/.strict-typing b/.strict-typing index 43ddeb282dd..65d63a74258 100644 --- a/.strict-typing +++ b/.strict-typing @@ -442,6 +442,7 @@ homeassistant.components.private_ble_device.* homeassistant.components.prometheus.* homeassistant.components.proximity.* homeassistant.components.prusalink.* +homeassistant.components.ptdevices.* homeassistant.components.pure_energie.* homeassistant.components.purpleair.* homeassistant.components.pushbullet.* diff --git a/CODEOWNERS b/CODEOWNERS index 715903bcffe..ee0d64341ec 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1378,6 +1378,8 @@ CLAUDE.md @home-assistant/core /tests/components/proxmoxve/ @Corbeno @erwindouna @CoMPaTech /homeassistant/components/ps4/ @ktnrg45 /tests/components/ps4/ @ktnrg45 +/homeassistant/components/ptdevices/ @ParemTech-Inc @frogman85978 +/tests/components/ptdevices/ @ParemTech-Inc @frogman85978 /homeassistant/components/pterodactyl/ @elmurato /tests/components/pterodactyl/ @elmurato /homeassistant/components/pure_energie/ @klaasnicolaas diff --git a/homeassistant/components/ptdevices/__init__.py b/homeassistant/components/ptdevices/__init__.py new file mode 100644 index 00000000000..9a557749494 --- /dev/null +++ b/homeassistant/components/ptdevices/__init__.py @@ -0,0 +1,46 @@ +"""The PTDevices integration.""" + +from aioptdevices.configuration import Configuration +from aioptdevices.interface import Interface + +from homeassistant.const import CONF_API_TOKEN, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DEFAULT_URL +from .coordinator import PTDevicesConfigEntry, PTDevicesCoordinator + +_PLATFORMS: list[Platform] = [ + Platform.SENSOR, +] + + +async def async_setup_entry( + hass: HomeAssistant, config_entry: PTDevicesConfigEntry +) -> bool: + """Set up PTDevices from a config entry.""" + auth_token: str = config_entry.data[CONF_API_TOKEN] + session = async_get_clientsession(hass) + ptdevices_interface = Interface( + Configuration( + auth_token=auth_token, + device_id="*", # Retrieve data for all devices in account + url=DEFAULT_URL, + session=session, + ) + ) + + config_entry.runtime_data = coordinator = PTDevicesCoordinator( + hass, + config_entry, + ptdevices_interface, + ) + await coordinator.async_config_entry_first_refresh() + await hass.config_entries.async_forward_entry_setups(config_entry, _PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: PTDevicesConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, _PLATFORMS) diff --git a/homeassistant/components/ptdevices/config_flow.py b/homeassistant/components/ptdevices/config_flow.py new file mode 100644 index 00000000000..505ed7053bd --- /dev/null +++ b/homeassistant/components/ptdevices/config_flow.py @@ -0,0 +1,118 @@ +"""Config flow for PTDevices integration.""" + +import logging +from typing import Any + +import aioptdevices +from aioptdevices.configuration import Configuration +from aioptdevices.interface import Interface +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_API_TOKEN +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DEFAULT_URL, DOMAIN + +_LOGGER = logging.getLogger(__name__) + +_CONF_SCHEMA = vol.Schema( + { + vol.Required(CONF_API_TOKEN): str, + } +) + + +async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> tuple[str, str]: + """Validate the user input allows us to connect. + + Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user. + """ + + session = async_get_clientsession(hass) + ptdevices_interface = Interface( + Configuration( + auth_token=data[CONF_API_TOKEN], + device_id="*", # Retrieve data for all devices in account + url=DEFAULT_URL, + session=session, + ) + ) + + # Test Connection + try: + response = await ptdevices_interface.get_data() + except aioptdevices.PTDevicesRequestError as err: + raise CannotConnect from err + + except aioptdevices.PTDevicesUnauthorizedError as err: + raise InvalidAuth from err + + body = response["body"] + + # Ensure the first device exists + first_device = next(iter(body.values()), None) + if first_device is None: + raise NoDevicesFound + + user_name = first_device.get("user_name") + user_id = first_device.get("user_id") + + title: str = str(user_name) + unique_id: str = str(user_id) + + # Return title to be used for hub name + return (title, unique_id) + + +class PTDevicesConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for PTDevices.""" + + VERSION = 1 + MINOR_VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + + errors: dict[str, str] = {} + + # Test connection when user data is available + if user_input is not None: + # Test connection + try: + title, unique_id = await validate_input(self.hass, user_input) + except CannotConnect: + errors["base"] = "cannot_connect" + except InvalidAuth: + errors["base"] = "invalid_access_token" + except NoDevicesFound: + errors["base"] = "no_devices_found" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + # Connection Successful + await self.async_set_unique_id(unique_id) + self._abort_if_unique_id_configured() + return self.async_create_entry(title=title, data=user_input) + + # Show setup form + return self.async_show_form( + step_id="user", data_schema=_CONF_SCHEMA, errors=errors + ) + + +class CannotConnect(HomeAssistantError): + """Error to indicate we cannot connect.""" + + +class InvalidAuth(HomeAssistantError): + """Error to indicate there is invalid auth.""" + + +class NoDevicesFound(HomeAssistantError): + """No devices were found in the account.""" diff --git a/homeassistant/components/ptdevices/const.py b/homeassistant/components/ptdevices/const.py new file mode 100644 index 00000000000..829272fc271 --- /dev/null +++ b/homeassistant/components/ptdevices/const.py @@ -0,0 +1,4 @@ +"""Constants for the PTDevices integration.""" + +DOMAIN = "ptdevices" +DEFAULT_URL = "https://api.ptdevices.com/token/v1" diff --git a/homeassistant/components/ptdevices/coordinator.py b/homeassistant/components/ptdevices/coordinator.py new file mode 100644 index 00000000000..353918356f9 --- /dev/null +++ b/homeassistant/components/ptdevices/coordinator.py @@ -0,0 +1,88 @@ +"""Coordinator for PTDevices integration.""" + +from datetime import timedelta +import logging +from typing import Final + +import aioptdevices +from aioptdevices.interface import Interface, PTDevicesResponseData + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.debounce import Debouncer +from homeassistant.helpers.update_coordinator import ( + REQUEST_REFRESH_DEFAULT_IMMEDIATE, + DataUpdateCoordinator, + UpdateFailed, +) + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) +REFRESH_COOLDOWN: Final = 30 +UPDATE_INTERVAL = timedelta(seconds=60) + +type PTDevicesConfigEntry = ConfigEntry[PTDevicesCoordinator] + + +class PTDevicesCoordinator(DataUpdateCoordinator[PTDevicesResponseData]): + """Class for interacting with PTDevices get_data.""" + + config_entry: PTDevicesConfigEntry + + def __init__( + self, + hass: HomeAssistant, + config_entry: PTDevicesConfigEntry, + ptdevices_interface: Interface, + ) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, + _LOGGER, + config_entry=config_entry, + name=DOMAIN, + update_interval=UPDATE_INTERVAL, + request_refresh_debouncer=Debouncer( + hass, + _LOGGER, + immediate=REQUEST_REFRESH_DEFAULT_IMMEDIATE, + cooldown=REFRESH_COOLDOWN, + ), + ) + + self.interface = ptdevices_interface + + async def _async_update_data(self) -> PTDevicesResponseData: + try: + data = await self.interface.get_data() + except aioptdevices.PTDevicesRequestError as err: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="cannot_connect", + translation_placeholders={"error": repr(err)}, + ) from err + except aioptdevices.PTDevicesUnauthorizedError as err: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="invalid_access_token", + translation_placeholders={"error": repr(err)}, + ) from err + + # Purge stale devices + device_reg = dr.async_get(self.hass) + identifiers = { + (DOMAIN, f"{device_data['user_id']}_{device_id}") + for device_id, device_data in data["body"].items() + } + for device in dr.async_entries_for_config_entry( + device_reg, self.config_entry.entry_id + ): + if not set(device.identifiers) & identifiers: + _LOGGER.debug("Removing stale device entry %s", device.name) + device_reg.async_update_device( + device.id, remove_config_entry_id=self.config_entry.entry_id + ) + + return data["body"] diff --git a/homeassistant/components/ptdevices/entity.py b/homeassistant/components/ptdevices/entity.py new file mode 100644 index 00000000000..f8df42c330e --- /dev/null +++ b/homeassistant/components/ptdevices/entity.py @@ -0,0 +1,49 @@ +"""PTDevices integration.""" + +from typing import Any + +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import PTDevicesCoordinator + + +class PTDevicesEntity(CoordinatorEntity[PTDevicesCoordinator]): + """Defines a base PTDevices entity.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: PTDevicesCoordinator, + sensor_key: str, + device_id: str, + ) -> None: + """Initialize.""" + super().__init__(coordinator=coordinator) + self._sensor_key = sensor_key + self._device_id = device_id + self._user_id = coordinator.data[self._device_id]["user_id"] + + self._attr_unique_id = f"{self._user_id}_{device_id}_{sensor_key}" + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, f"{self._user_id}_{self._device_id}")}, + connections={(CONNECTION_NETWORK_MAC, self._device_id)}, + configuration_url=f"https://www.ptdevices.com/device/level/{self.device['id']}", + manufacturer="ParemTech Inc.", + model=self.device["device_type"], + sw_version=str(self.device["version"]), + name=self.device["title"], + ) + + @property + def device(self) -> dict[str, Any]: + """Return the device data.""" + return self.coordinator.data[self._device_id] + + @property + def available(self) -> bool: + """Return if the device is available.""" + return super().available and self._device_id in self.coordinator.data diff --git a/homeassistant/components/ptdevices/icons.json b/homeassistant/components/ptdevices/icons.json new file mode 100644 index 00000000000..8c17cf0a8a8 --- /dev/null +++ b/homeassistant/components/ptdevices/icons.json @@ -0,0 +1,30 @@ +{ + "entity": { + "sensor": { + "battery_voltage": { + "default": "mdi:battery" + }, + "depth_level": { + "default": "mdi:water" + }, + "percent_level": { + "default": "mdi:water-percent" + }, + "probe_temperature": { + "default": "mdi:thermometer" + }, + "status": { + "default": "mdi:information-outline" + }, + "tx_signal": { + "default": "mdi:wifi" + }, + "volume_level": { + "default": "mdi:water" + }, + "wifi_signal": { + "default": "mdi:wifi" + } + } + } +} diff --git a/homeassistant/components/ptdevices/manifest.json b/homeassistant/components/ptdevices/manifest.json new file mode 100644 index 00000000000..149e6710618 --- /dev/null +++ b/homeassistant/components/ptdevices/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "ptdevices", + "name": "PTDevices", + "codeowners": ["@ParemTech-Inc", "@frogman85978"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/ptdevices", + "integration_type": "hub", + "iot_class": "cloud_polling", + "loggers": ["aioptdevices"], + "quality_scale": "bronze", + "requirements": ["aioptdevices==2026.03.2"] +} diff --git a/homeassistant/components/ptdevices/quality_scale.yaml b/homeassistant/components/ptdevices/quality_scale.yaml new file mode 100644 index 00000000000..5a6ae39af27 --- /dev/null +++ b/homeassistant/components/ptdevices/quality_scale.yaml @@ -0,0 +1,75 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: | + This integration does not provide any 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 any actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: | + Entities of this integration do not explicitly subscribe to events. + 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 any actions. + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: | + This integration does not provide any additional options. + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: todo + test-coverage: todo + + # Gold + devices: done + diagnostics: todo + discovery-update-info: todo + discovery: todo + docs-data-update: done + docs-examples: done + docs-known-limitations: done + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: todo + docs-use-cases: done + dynamic-devices: done + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: done + exception-translations: done + icon-translations: done + reconfiguration-flow: todo + repair-issues: todo + stale-devices: done + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/ptdevices/sensor.py b/homeassistant/components/ptdevices/sensor.py new file mode 100644 index 00000000000..df9549ac228 --- /dev/null +++ b/homeassistant/components/ptdevices/sensor.py @@ -0,0 +1,203 @@ +"""Sensors for PTDevices device.""" + +from collections.abc import Callable +from dataclasses import dataclass +from enum import StrEnum +from typing import cast + +from aioptdevices.interface import PTDevicesStatusStates + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import ( + PERCENTAGE, + SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + EntityCategory, + UnitOfElectricPotential, + UnitOfLength, + UnitOfTemperature, + UnitOfVolume, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import PTDevicesConfigEntry, PTDevicesCoordinator +from .entity import PTDevicesEntity + +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + + +class PTDevicesSensors(StrEnum): + """Store keys for PTDevices sensors.""" + + LEVEL_PERCENT = "percent_level" + LEVEL_VOLUME = "volume_level" + LEVEL_DEPTH = "depth_level" + PROBE_TEMPERATURE = "probe_temperature" + DEVICE_STATUS = "status" + DEVICE_WIFI_STRENGTH = "wifi_signal" + DEVICE_BATTERY_VOLTAGE = "battery_voltage" + TX_SIGNAL_STRENGTH = "tx_signal" + + +@dataclass(kw_only=True, frozen=True) +class PTDevicesSensorEntityDescription(SensorEntityDescription): + """Description for PTDevices sensor entities.""" + + value_fn: Callable[[dict[str, str | int | float | None]], str | int | float | None] + + +SENSOR_DESCRIPTIONS: tuple[PTDevicesSensorEntityDescription, ...] = ( + # Percent of water in the tank + PTDevicesSensorEntityDescription( + key=PTDevicesSensors.LEVEL_PERCENT, + translation_key=PTDevicesSensors.LEVEL_PERCENT, + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: cast(float, data.get(PTDevicesSensors.LEVEL_PERCENT)), + ), + # Volume of water in the tank (Liters) + PTDevicesSensorEntityDescription( + key=PTDevicesSensors.LEVEL_VOLUME, + translation_key=PTDevicesSensors.LEVEL_VOLUME, + native_unit_of_measurement=UnitOfVolume.LITERS, + device_class=SensorDeviceClass.VOLUME_STORAGE, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: cast(float, data.get(PTDevicesSensors.LEVEL_VOLUME)), + ), + # Depth of water in the tank (Meters) + PTDevicesSensorEntityDescription( + key=PTDevicesSensors.LEVEL_DEPTH, + translation_key=PTDevicesSensors.LEVEL_DEPTH, + native_unit_of_measurement=UnitOfLength.METERS, + device_class=SensorDeviceClass.DISTANCE, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: cast(float, data.get(PTDevicesSensors.LEVEL_DEPTH)), + suggested_display_precision=3, + ), + # Temperature measured by external temperature probe (Celsius) + PTDevicesSensorEntityDescription( + key=PTDevicesSensors.PROBE_TEMPERATURE, + translation_key=PTDevicesSensors.PROBE_TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: cast(float, data.get(PTDevicesSensors.PROBE_TEMPERATURE)), + ), + # Status of the device + PTDevicesSensorEntityDescription( + key=PTDevicesSensors.DEVICE_STATUS, + translation_key=PTDevicesSensors.DEVICE_STATUS, + device_class=SensorDeviceClass.ENUM, + options=[ + member.value + for member in PTDevicesStatusStates + if member.value != "unknown" + ], + value_fn=lambda data: ( + cast(str, data.get(PTDevicesSensors.DEVICE_STATUS)) + if cast(str, data.get(PTDevicesSensors.DEVICE_STATUS)) != "unknown" + else None + ), + ), + # Wifi signal strength (%) + PTDevicesSensorEntityDescription( + key=PTDevicesSensors.DEVICE_WIFI_STRENGTH, + translation_key=PTDevicesSensors.DEVICE_WIFI_STRENGTH, + native_unit_of_measurement=PERCENTAGE, + entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: cast( + int, data.get(PTDevicesSensors.DEVICE_WIFI_STRENGTH) + ), + ), + # LoRa signal strength (dBm) + PTDevicesSensorEntityDescription( + key=PTDevicesSensors.TX_SIGNAL_STRENGTH, + translation_key=PTDevicesSensors.TX_SIGNAL_STRENGTH, + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + entity_registry_enabled_default=False, + device_class=SensorDeviceClass.SIGNAL_STRENGTH, + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: cast( + float, data.get(PTDevicesSensors.TX_SIGNAL_STRENGTH) + ), + ), + # Battery voltage (Volts) + PTDevicesSensorEntityDescription( + key=PTDevicesSensors.DEVICE_BATTERY_VOLTAGE, + translation_key=PTDevicesSensors.DEVICE_BATTERY_VOLTAGE, + device_class=SensorDeviceClass.VOLTAGE, + entity_registry_enabled_default=False, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda data: cast( + float, data.get(PTDevicesSensors.DEVICE_BATTERY_VOLTAGE) + ), + suggested_display_precision=2, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: PTDevicesConfigEntry, + async_add_entity: AddConfigEntryEntitiesCallback, +) -> None: + """Set up PTDevices sensors from config entries.""" + coordinator = config_entry.runtime_data + + known_sensors: set[tuple[str, str]] = set() + + def _check_device() -> None: + for device_id in sorted(coordinator.data): + device = coordinator.data[device_id] + new_sensors = [ + sensor + for sensor in SENSOR_DESCRIPTIONS + if sensor.key in device and (device_id, sensor.key) not in known_sensors + ] + if not new_sensors: + continue + known_sensors.update((device_id, sensor.key) for sensor in new_sensors) + async_add_entity( + PTDevicesSensorEntity(config_entry.runtime_data, sensor, device_id) + for sensor in new_sensors + ) + + _check_device() + config_entry.async_on_unload(coordinator.async_add_listener(_check_device)) + + +class PTDevicesSensorEntity(PTDevicesEntity, SensorEntity): + """Sensor entity for PTDevices Integration.""" + + entity_description: PTDevicesSensorEntityDescription + + def __init__( + self, + coordinator: PTDevicesCoordinator, + description: PTDevicesSensorEntityDescription, + device_id: str, + ) -> None: + """Initialize sensor.""" + super().__init__( + coordinator, + description.key, + device_id, + ) + + self.entity_description = description + + @property + def native_value(self) -> float | int | str | None: + """Return the state of the sensor.""" + return self.entity_description.value_fn(self.device) diff --git a/homeassistant/components/ptdevices/strings.json b/homeassistant/components/ptdevices/strings.json new file mode 100644 index 00000000000..4b0fd67ac66 --- /dev/null +++ b/homeassistant/components/ptdevices/strings.json @@ -0,0 +1,69 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "invalid_access_token": "[%key:common::config_flow::error::invalid_access_token%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_access_token": "[%key:common::config_flow::error::invalid_access_token%]", + "no_devices_found": "No devices are registered to your PTDevices account.", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "step": { + "user": { + "data": { + "api_token": "[%key:common::config_flow::data::api_token%]" + }, + "data_description": { + "api_token": "The API token for your PTDevices account." + }, + "description": "Enter the API token for your PTDevices account" + } + } + }, + "entity": { + "sensor": { + "battery_voltage": { + "name": "Battery voltage" + }, + "depth_level": { + "name": "Level depth" + }, + "percent_level": { + "name": "Level percent" + }, + "probe_temperature": { + "name": "Probe temperature" + }, + "status": { + "name": "Status", + "state": { + "not_connected": "Not connected", + "not_connected_yet": "Not connected yet", + "power_internet_out_or_receiver_not_working": "Power or internet out or receiver not working", + "press_transmitter_connect_button": "Press transmitter connect button", + "transmitter_not_reporting": "Transmitter not reporting", + "working": "Working" + } + }, + "tx_signal": { + "name": "LoRa signal strength" + }, + "volume_level": { + "name": "Level volume" + }, + "wifi_signal": { + "name": "Wi-Fi signal strength" + } + } + }, + "exceptions": { + "cannot_connect": { + "message": "[%key:common::config_flow::error::cannot_connect%]" + }, + "invalid_access_token": { + "message": "[%key:common::config_flow::error::invalid_access_token%]" + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index d981856b0e4..2478179f023 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -579,6 +579,7 @@ FLOWS = { "proxmoxve", "prusalink", "ps4", + "ptdevices", "pterodactyl", "pure_energie", "purpleair", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 9c76b93ae46..acd878735ef 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -5501,6 +5501,12 @@ "integration_type": "virtual", "supported_by": "opower" }, + "ptdevices": { + "name": "PTDevices", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling" + }, "pterodactyl": { "name": "Pterodactyl", "integration_type": "service", diff --git a/mypy.ini b/mypy.ini index 9f64f3e5650..dd2d50072f1 100644 --- a/mypy.ini +++ b/mypy.ini @@ -4175,6 +4175,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.ptdevices.*] +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.pure_energie.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index 17a3175ba4d..650e7db0723 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -359,6 +359,9 @@ aiopegelonline==0.1.1 # homeassistant.components.opnsense aiopnsense==1.0.8 +# homeassistant.components.ptdevices +aioptdevices==2026.03.2 + # homeassistant.components.acmeda aiopulse==0.4.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 30c238e51ea..15ccc2bcd3b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -344,6 +344,9 @@ aiopegelonline==0.1.1 # homeassistant.components.opnsense aiopnsense==1.0.8 +# homeassistant.components.ptdevices +aioptdevices==2026.03.2 + # homeassistant.components.acmeda aiopulse==0.4.6 diff --git a/tests/components/ptdevices/__init__.py b/tests/components/ptdevices/__init__.py new file mode 100644 index 00000000000..25780fff64d --- /dev/null +++ b/tests/components/ptdevices/__init__.py @@ -0,0 +1,13 @@ +"""Tests for the PTDevices component.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Method for setting up the component.""" + 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/ptdevices/conftest.py b/tests/components/ptdevices/conftest.py new file mode 100644 index 00000000000..c8c014eeead --- /dev/null +++ b/tests/components/ptdevices/conftest.py @@ -0,0 +1,68 @@ +"""Common fixtures for the PTDevices tests.""" + +from collections.abc import Generator +from typing import cast +from unittest.mock import AsyncMock, patch + +from aioptdevices.interface import PTDevicesResponse, PTDevicesResponseData +import pytest + +from homeassistant.components.ptdevices.const import DOMAIN +from homeassistant.const import CONF_API_TOKEN + +from tests.common import MockConfigEntry, load_json_object_fixture + + +@pytest.fixture +def mock_ptdevices_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.ptdevices.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_ptdevices_level() -> PTDevicesResponse: + """Mock a PTLevel device.""" + data = load_json_object_fixture("ptdevices_level.json", DOMAIN) + return PTDevicesResponse( + code=200, + body=cast(PTDevicesResponseData, data), + ) + + +@pytest.fixture +def mock_ptdevices_interface( + mock_ptdevices_level: PTDevicesResponse, +) -> Generator[AsyncMock]: + """Mock a PTDevices Interface.""" + with ( + patch( + "homeassistant.components.ptdevices.Interface", + autospec=True, + ) as mock_interface, + patch( + "homeassistant.components.ptdevices.config_flow.Interface", + new=mock_interface, + ), + ): + interface = mock_interface.return_value + interface.get_data.return_value = mock_ptdevices_level + + yield interface + + +@pytest.fixture +def mock_ptdevices_config_entry() -> MockConfigEntry: + """Return a mocked ptdevice configuration entry.""" + return MockConfigEntry( + version=1, + domain=DOMAIN, + title="User Name", + data={ + CONF_API_TOKEN: "test-api-token", + }, + unique_id="1234", + ) diff --git a/tests/components/ptdevices/fixtures/ptdevices_level.json b/tests/components/ptdevices/fixtures/ptdevices_level.json new file mode 100644 index 00000000000..c69e7049696 --- /dev/null +++ b/tests/components/ptdevices/fixtures/ptdevices_level.json @@ -0,0 +1,86 @@ +{ + "C0FFEEC0FFEE": { + "id": 1, + "device_id": "C0FFEEC0FFEE", + "share_id": "someID", + "user_id": 1234, + "user_name": "User Name", + "user_email": "userEmail@email.com", + "device_type": "level", + "local_ip": "192.168.1.100", + "title": "Home", + "version": 208, + "lat": 40.62669, + "lng": -82.031121, + "address": "1234 Test Road, City, Country", + "supplier_code": null, + "status_number": 2, + "status": "working", + "delivery_notes": null, + "units": "Metric", + "reported": "Dec 17th, 9:37 AM", + "tx_reported": "Dec 17th, 8:15 AM", + "last_updated_on": "1 hour ago", + "wifi_signal": 100, + "tx_signal": -76.0, + "percent_level": 50, + "battery_voltage": 5.69, + "battery_status": "good", + "battery_status_number": 1, + "volume_level": 2387.837753, + "volume_level_oz": 80742.4, + "max_volume": 1269, + "max_volume_oz": 162432, + "enclosure_temperature": -0.3, + "depth": 6, + "power_x": 30, + "power_y": 7, + "power_z": 12, + "shape": "vertical cylinder", + "diameter": 6, + "width": null, + "length": null, + "temperature_units": "C", + "depth_level": 0.909066 + }, + "C0FFEFC0FFEF": { + "id": 2, + "device_id": "C0FFEFC0FFEF", + "share_id": "someID", + "created": "Jan 1st 1970, 0:00 AM", + "user_id": 1234, + "user_name": "User Name", + "user_email": "userEmail@email.com", + "device_type": "level", + "local_ip": "192.168.1.101", + "title": "Garden rain barrel", + "version": "407", + "lat": 43.147985, + "lng": -29.17074, + "address": "1234 Test Road, City, Country", + "supplier_code": null, + "status_number": 2, + "status": "working", + "delivery_notes": null, + "units": "US Imperial", + "reported": "Dec 17th, 9:38 AM", + "tx_reported": "Dec 17th, 9:38 AM", + "last_updated_on": "13 seconds ago", + "wifi_signal": 42, + "tx_signal": 0.0, + "percent_level": 141, + "volume_level": 4.542494, + "volume_level_oz": 153.6, + "max_volume": 1.2, + "max_volume_oz": 153.6, + "enclosure_temperature": 37.3, + "probe_temperature": 18.9, + "depth": 0.85, + "shape": "vertical cylinder", + "diameter": 0.5, + "width": null, + "length": null, + "temperature_units": "C", + "depth_level": 0.363474 + } +} diff --git a/tests/components/ptdevices/snapshots/test_sensor.ambr b/tests/components/ptdevices/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..ff9f574801e --- /dev/null +++ b/tests/components/ptdevices/snapshots/test_sensor.ambr @@ -0,0 +1,811 @@ +# serializer version: 1 +# name: test_all_entities[sensor.garden_rain_barrel_level_depth-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.garden_rain_barrel_level_depth', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Level depth', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Level depth', + 'platform': 'ptdevices', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '1234_C0FFEFC0FFEF_depth_level', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.garden_rain_barrel_level_depth-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'Garden rain barrel Level depth', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.garden_rain_barrel_level_depth', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.363474', + }) +# --- +# name: test_all_entities[sensor.garden_rain_barrel_level_percent-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.garden_rain_barrel_level_percent', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Level percent', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Level percent', + 'platform': 'ptdevices', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '1234_C0FFEFC0FFEF_percent_level', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[sensor.garden_rain_barrel_level_percent-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Garden rain barrel Level percent', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.garden_rain_barrel_level_percent', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '141', + }) +# --- +# name: test_all_entities[sensor.garden_rain_barrel_level_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.garden_rain_barrel_level_volume', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Level volume', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Level volume', + 'platform': 'ptdevices', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '1234_C0FFEFC0FFEF_volume_level', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.garden_rain_barrel_level_volume-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'volume_storage', + 'friendly_name': 'Garden rain barrel Level volume', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.garden_rain_barrel_level_volume', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '4.542494', + }) +# --- +# name: test_all_entities[sensor.garden_rain_barrel_lora_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.garden_rain_barrel_lora_signal_strength', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'LoRa signal strength', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'LoRa signal strength', + 'platform': 'ptdevices', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '1234_C0FFEFC0FFEF_tx_signal', + 'unit_of_measurement': 'dBm', + }) +# --- +# name: test_all_entities[sensor.garden_rain_barrel_lora_signal_strength-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'signal_strength', + 'friendly_name': 'Garden rain barrel LoRa signal strength', + 'state_class': , + 'unit_of_measurement': 'dBm', + }), + 'context': , + 'entity_id': 'sensor.garden_rain_barrel_lora_signal_strength', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[sensor.garden_rain_barrel_probe_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': None, + 'entity_id': 'sensor.garden_rain_barrel_probe_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Probe temperature', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Probe temperature', + 'platform': 'ptdevices', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '1234_C0FFEFC0FFEF_probe_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.garden_rain_barrel_probe_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Garden rain barrel Probe temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.garden_rain_barrel_probe_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '18.9', + }) +# --- +# name: test_all_entities[sensor.garden_rain_barrel_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'working', + 'not_connected_yet', + 'not_connected', + 'transmitter_not_reporting', + 'press_transmitter_connect_button', + 'power_internet_out_or_receiver_not_working', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.garden_rain_barrel_status', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Status', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Status', + 'platform': 'ptdevices', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '1234_C0FFEFC0FFEF_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.garden_rain_barrel_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Garden rain barrel Status', + 'options': list([ + 'working', + 'not_connected_yet', + 'not_connected', + 'transmitter_not_reporting', + 'press_transmitter_connect_button', + 'power_internet_out_or_receiver_not_working', + ]), + }), + 'context': , + 'entity_id': 'sensor.garden_rain_barrel_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'working', + }) +# --- +# name: test_all_entities[sensor.garden_rain_barrel_wi_fi_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.garden_rain_barrel_wi_fi_signal_strength', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Wi-Fi signal strength', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Wi-Fi signal strength', + 'platform': 'ptdevices', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '1234_C0FFEFC0FFEF_wifi_signal', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[sensor.garden_rain_barrel_wi_fi_signal_strength-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Garden rain barrel Wi-Fi signal strength', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.garden_rain_barrel_wi_fi_signal_strength', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '42', + }) +# --- +# name: test_all_entities[sensor.home_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.home_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': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery voltage', + 'platform': 'ptdevices', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '1234_C0FFEEC0FFEE_battery_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.home_battery_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Home Battery voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_battery_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5.69', + }) +# --- +# name: test_all_entities[sensor.home_level_depth-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.home_level_depth', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Level depth', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Level depth', + 'platform': 'ptdevices', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '1234_C0FFEEC0FFEE_depth_level', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.home_level_depth-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'Home Level depth', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_level_depth', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.909066', + }) +# --- +# name: test_all_entities[sensor.home_level_percent-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.home_level_percent', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Level percent', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Level percent', + 'platform': 'ptdevices', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '1234_C0FFEEC0FFEE_percent_level', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[sensor.home_level_percent-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Home Level percent', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.home_level_percent', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '50', + }) +# --- +# name: test_all_entities[sensor.home_level_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.home_level_volume', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Level volume', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Level volume', + 'platform': 'ptdevices', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '1234_C0FFEEC0FFEE_volume_level', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.home_level_volume-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'volume_storage', + 'friendly_name': 'Home Level volume', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_level_volume', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2387.837753', + }) +# --- +# name: test_all_entities[sensor.home_lora_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.home_lora_signal_strength', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'LoRa signal strength', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'LoRa signal strength', + 'platform': 'ptdevices', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '1234_C0FFEEC0FFEE_tx_signal', + 'unit_of_measurement': 'dBm', + }) +# --- +# name: test_all_entities[sensor.home_lora_signal_strength-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'signal_strength', + 'friendly_name': 'Home LoRa signal strength', + 'state_class': , + 'unit_of_measurement': 'dBm', + }), + 'context': , + 'entity_id': 'sensor.home_lora_signal_strength', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-76.0', + }) +# --- +# name: test_all_entities[sensor.home_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'working', + 'not_connected_yet', + 'not_connected', + 'transmitter_not_reporting', + 'press_transmitter_connect_button', + 'power_internet_out_or_receiver_not_working', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_status', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Status', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Status', + 'platform': 'ptdevices', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '1234_C0FFEEC0FFEE_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.home_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Home Status', + 'options': list([ + 'working', + 'not_connected_yet', + 'not_connected', + 'transmitter_not_reporting', + 'press_transmitter_connect_button', + 'power_internet_out_or_receiver_not_working', + ]), + }), + 'context': , + 'entity_id': 'sensor.home_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'working', + }) +# --- +# name: test_all_entities[sensor.home_wi_fi_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.home_wi_fi_signal_strength', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Wi-Fi signal strength', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Wi-Fi signal strength', + 'platform': 'ptdevices', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '1234_C0FFEEC0FFEE_wifi_signal', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[sensor.home_wi_fi_signal_strength-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Home Wi-Fi signal strength', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.home_wi_fi_signal_strength', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100', + }) +# --- diff --git a/tests/components/ptdevices/test_config_flow.py b/tests/components/ptdevices/test_config_flow.py new file mode 100644 index 00000000000..7ef17cb9563 --- /dev/null +++ b/tests/components/ptdevices/test_config_flow.py @@ -0,0 +1,151 @@ +"""Test the PTDevices config flow.""" + +from unittest.mock import AsyncMock + +from aioptdevices import PTDevicesRequestError, PTDevicesUnauthorizedError +from aioptdevices.interface import PTDevicesResponse +import pytest + +from homeassistant.components.ptdevices.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_API_TOKEN +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + + +async def test_flow_success( + hass: HomeAssistant, + mock_ptdevices_interface: AsyncMock, + mock_ptdevices_setup_entry: AsyncMock, +) -> None: + """Test a successful creation of config entries via user configuration.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_TOKEN: "test-api-token"}, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "User Name" + assert result["result"].unique_id == "1234" + assert result["data"] == { + CONF_API_TOKEN: "test-api-token", + } + + assert len(mock_ptdevices_interface.mock_calls) == 1 + + +async def test_flow_duplicate_device( + hass: HomeAssistant, + mock_ptdevices_interface: AsyncMock, + mock_ptdevices_setup_entry: AsyncMock, + mock_ptdevices_config_entry: MockConfigEntry, +) -> None: + """Test a duplicate config flow.""" + mock_ptdevices_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" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_TOKEN: "test-api-token"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.parametrize( + ("exception", "error"), + [ + (PTDevicesUnauthorizedError, "invalid_access_token"), + (PTDevicesRequestError, "cannot_connect"), + (Exception, "unknown"), + ], +) +async def test_flow_errors( + hass: HomeAssistant, + mock_ptdevices_interface: AsyncMock, + mock_ptdevices_setup_entry: AsyncMock, + exception: Exception, + error: str, +) -> None: + """Test flow errors.""" + mock_ptdevices_interface.get_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" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_TOKEN: "test-api-token"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": error} + + mock_ptdevices_interface.get_data.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_TOKEN: "test-api-token"}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + + +async def test_flow_no_devices( + hass: HomeAssistant, + mock_ptdevices_interface: AsyncMock, + mock_ptdevices_setup_entry: AsyncMock, + mock_ptdevices_level: PTDevicesResponse, +) -> None: + """Test A flow with no devices in the account.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + # No devices + mock_ptdevices_interface.get_data.return_value = PTDevicesResponse( + code=200, + body={}, + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_TOKEN: "test-api-token"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "no_devices_found"} + + # Reset the mock to the default return value + mock_ptdevices_interface.get_data.return_value = mock_ptdevices_level + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_TOKEN: "test-api-token"}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY diff --git a/tests/components/ptdevices/test_sensor.py b/tests/components/ptdevices/test_sensor.py new file mode 100644 index 00000000000..494fc632e55 --- /dev/null +++ b/tests/components/ptdevices/test_sensor.py @@ -0,0 +1,31 @@ +"""Test for PTDevices sensors.""" + +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_ptdevices_interface: AsyncMock, + mock_ptdevices_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + with patch("homeassistant.components.ptdevices._PLATFORMS", [Platform.SENSOR]): + await setup_integration(hass, mock_ptdevices_config_entry) + + await snapshot_platform( + hass, entity_registry, snapshot, mock_ptdevices_config_entry.entry_id + )