Skip to content

Commit

Permalink
Add SensorPush Cloud integration
Browse files Browse the repository at this point in the history
  • Loading branch information
sstallion committed Dec 30, 2024
1 parent bc76dc3 commit 8678976
Show file tree
Hide file tree
Showing 22 changed files with 762 additions and 3 deletions.
1 change: 1 addition & 0 deletions .strict-typing
Original file line number Diff line number Diff line change
Expand Up @@ -422,6 +422,7 @@ homeassistant.components.select.*
homeassistant.components.sensibo.*
homeassistant.components.sensirion_ble.*
homeassistant.components.sensor.*
homeassistant.components.sensorpush_cloud.*
homeassistant.components.sensoterra.*
homeassistant.components.senz.*
homeassistant.components.sfr_box.*
Expand Down
2 changes: 2 additions & 0 deletions CODEOWNERS
Original file line number Diff line number Diff line change
Expand Up @@ -1324,6 +1324,8 @@ build.json @home-assistant/supervisor
/tests/components/sensorpro/ @bdraco
/homeassistant/components/sensorpush/ @bdraco
/tests/components/sensorpush/ @bdraco
/homeassistant/components/sensorpush_cloud/ @sstallion
/tests/components/sensorpush_cloud/ @sstallion
/homeassistant/components/sensoterra/ @markruys
/tests/components/sensoterra/ @markruys
/homeassistant/components/sentry/ @dcramer @frenck
Expand Down
5 changes: 5 additions & 0 deletions homeassistant/brands/sensorpush.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"domain": "sensorpush",
"name": "SensorPush",
"integrations": ["sensorpush", "sensorpush_cloud"]
}
28 changes: 28 additions & 0 deletions homeassistant/components/sensorpush_cloud/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
"""The SensorPush Cloud integration."""

from __future__ import annotations

from homeassistant.const import Platform
from homeassistant.core import HomeAssistant

from .coordinator import SensorPushCloudConfigEntry, SensorPushCloudCoordinator

PLATFORMS: list[Platform] = [Platform.SENSOR]


async def async_setup_entry(
hass: HomeAssistant, entry: SensorPushCloudConfigEntry
) -> bool:
"""Set up SensorPush Cloud from a config entry."""
coordinator = SensorPushCloudCoordinator(hass, entry)
entry.runtime_data = coordinator
await coordinator.async_config_entry_first_refresh()
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True


async def async_unload_entry(
hass: HomeAssistant, entry: SensorPushCloudConfigEntry
) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
48 changes: 48 additions & 0 deletions homeassistant/components/sensorpush_cloud/config_flow.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
"""Config flow for the SensorPush Cloud integration."""

from __future__ import annotations

from typing import Any

from sensorpush_ha import SensorPushCloudApi, SensorPushCloudError
import voluptuous as vol

from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_EMAIL, CONF_PASSWORD

from .const import DOMAIN, LOGGER


class SensorPushCloudConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for SensorPush Cloud."""

async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
errors: dict[str, str] = {}
if user_input is not None:
email, password = user_input[CONF_EMAIL], user_input[CONF_PASSWORD]
await self.async_set_unique_id(email, raise_on_progress=False)
self._abort_if_unique_id_configured()
api = SensorPushCloudApi(self.hass, email, password)
try:
await api.async_authorize()
except SensorPushCloudError as e:
errors["base"] = str(e)
except Exception: # noqa: BLE001
LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
return self.async_create_entry(title=email, data=user_input)

return self.async_show_form(
step_id="user",
data_schema=vol.Schema(
{
vol.Required(CONF_EMAIL): str,
vol.Required(CONF_PASSWORD): str,
}
),
errors=errors,
)
12 changes: 12 additions & 0 deletions homeassistant/components/sensorpush_cloud/const.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
"""Constants for the SensorPush Cloud integration."""

from datetime import timedelta
import logging
from typing import Final

LOGGER = logging.getLogger(__package__)

DOMAIN: Final = "sensorpush_cloud"

UPDATE_INTERVAL: Final = timedelta(seconds=60)
MAX_TIME_BETWEEN_UPDATES: Final = UPDATE_INTERVAL * 60
41 changes: 41 additions & 0 deletions homeassistant/components/sensorpush_cloud/coordinator.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
"""Coordinator for the SensorPush Cloud integration."""

from __future__ import annotations

from collections.abc import Iterable

from sensorpush_ha import SensorPushCloudApi, SensorPushCloudData, SensorPushCloudHelper

from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_EMAIL, CONF_PASSWORD
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator

from .const import LOGGER, UPDATE_INTERVAL

type SensorPushCloudConfigEntry = ConfigEntry[SensorPushCloudCoordinator]


class SensorPushCloudCoordinator(DataUpdateCoordinator[dict[str, SensorPushCloudData]]):
"""SensorPush Cloud coordinator."""

device_ids: Iterable[str]

def __init__(self, hass: HomeAssistant, entry: SensorPushCloudConfigEntry) -> None:
"""Initialize the coordinator."""
super().__init__(
hass,
LOGGER,
name=entry.title,
update_interval=UPDATE_INTERVAL,
config_entry=entry,
)
email, password = entry.data[CONF_EMAIL], entry.data[CONF_PASSWORD]
api = SensorPushCloudApi(hass, email, password)
self.helper = SensorPushCloudHelper(api)

async def _async_update_data(self) -> dict[str, SensorPushCloudData]:
"""Fetch data from API endpoints."""
data = await self.helper.async_get_data()
self.device_ids = data.keys()
return data
10 changes: 10 additions & 0 deletions homeassistant/components/sensorpush_cloud/manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"domain": "sensorpush_cloud",
"name": "SensorPush Cloud",
"codeowners": ["@sstallion"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/sensorpush_cloud",
"iot_class": "cloud_polling",
"loggers": ["sensorpush_api", "sensorpush_ha"],
"requirements": ["sensorpush-api==2.1.1", "sensorpush-ha==1.1.1"]
}
60 changes: 60 additions & 0 deletions homeassistant/components/sensorpush_cloud/quality_scale.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
rules:
# Bronze
action-setup: done
appropriate-polling: done
brands: done
common-modules: done
config-flow-test-coverage: done
config-flow: done
dependency-transparency: done
docs-actions: done
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: done
config-entry-unloading: todo
docs-configuration-parameters: done
docs-installation-parameters: done
entity-unavailable: done
integration-owner: done
log-when-unavailable: todo
parallel-updates: done
reauthentication-flow: todo
test-coverage: todo

# Gold
devices: todo
diagnostics: todo
discovery-update-info: todo
discovery: todo
docs-data-update: todo
docs-examples: todo
docs-known-limitations: todo
docs-supported-devices: todo
docs-supported-functions: todo
docs-troubleshooting: todo
docs-use-cases: todo
dynamic-devices: todo
entity-category: todo
entity-device-class: done
entity-disabled-by-default: done
entity-translations: todo
exception-translations: todo
icon-translations: todo
reconfiguration-flow: todo
repair-issues: todo
stale-devices: todo

# Platinum
async-dependency: done
inject-websession: done
strict-typing: done
160 changes: 160 additions & 0 deletions homeassistant/components/sensorpush_cloud/sensor.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
"""Support for SensorPush Cloud sensors."""

from __future__ import annotations

from typing import Final

from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.const import (
ATTR_TEMPERATURE,
PERCENTAGE,
SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
UnitOfElectricPotential,
UnitOfLength,
UnitOfPressure,
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import StateType
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.util import dt as dt_util

from .const import DOMAIN, LOGGER, MAX_TIME_BETWEEN_UPDATES
from .coordinator import SensorPushCloudConfigEntry, SensorPushCloudCoordinator

# Coordinator is used to centralize the data updates
PARALLEL_UPDATES = 0

ATTR_ALTITUDE: Final = "altitude"
ATTR_ATMOSPHERIC_PRESSURE: Final = "atmospheric_pressure"
ATTR_BATTERY_VOLTAGE: Final = "battery_voltage"
ATTR_DEWPOINT: Final = "dewpoint"
ATTR_HUMIDITY: Final = "humidity"
ATTR_LAST_UPDATE: Final = "last_update"
ATTR_SIGNAL_STRENGTH: Final = "signal_strength"
ATTR_VAPOR_PRESSURE: Final = "vapor_pressure"

SENSORS: tuple[SensorEntityDescription, ...] = (
SensorEntityDescription(
key=ATTR_ALTITUDE,
device_class=SensorDeviceClass.DISTANCE,
entity_registry_enabled_default=False,
translation_key="altitude",
native_unit_of_measurement=UnitOfLength.FEET,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
key=ATTR_ATMOSPHERIC_PRESSURE,
device_class=SensorDeviceClass.ATMOSPHERIC_PRESSURE,
entity_registry_enabled_default=False,
native_unit_of_measurement=UnitOfPressure.INHG,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
key=ATTR_BATTERY_VOLTAGE,
device_class=SensorDeviceClass.VOLTAGE,
entity_registry_enabled_default=False,
translation_key="battery_voltage",
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
key=ATTR_DEWPOINT,
device_class=SensorDeviceClass.TEMPERATURE,
entity_registry_enabled_default=False,
translation_key="dewpoint",
native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
key=ATTR_HUMIDITY,
device_class=SensorDeviceClass.HUMIDITY,
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
key=ATTR_SIGNAL_STRENGTH,
device_class=SensorDeviceClass.SIGNAL_STRENGTH,
entity_registry_enabled_default=False,
native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
key=ATTR_TEMPERATURE,
device_class=SensorDeviceClass.TEMPERATURE,
native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
key=ATTR_VAPOR_PRESSURE,
device_class=SensorDeviceClass.PRESSURE,
entity_registry_enabled_default=False,
translation_key="vapor_pressure",
native_unit_of_measurement=UnitOfPressure.KPA,
state_class=SensorStateClass.MEASUREMENT,
),
)


async def async_setup_entry(
hass: HomeAssistant,
entry: SensorPushCloudConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up SensorPush Cloud sensors."""
coordinator: SensorPushCloudCoordinator = entry.runtime_data
async_add_entities(
SensorPushCloudSensor(coordinator, entity_description, device_id)
for entity_description in SENSORS
for device_id in coordinator.device_ids
)


class SensorPushCloudSensor(
CoordinatorEntity[SensorPushCloudCoordinator], SensorEntity
):
"""SensorPush Cloud sensor."""

_attr_has_entity_name = True

def __init__(
self,
coordinator: SensorPushCloudCoordinator,
entity_description: SensorEntityDescription,
device_id: str,
) -> None:
"""Initialize the sensor."""
super().__init__(coordinator)
self.entity_description = entity_description
self.device_id = device_id

if device_id not in self.coordinator.data:
LOGGER.warning("Ignoring inactive device: %s", device_id)
self._attr_available = False
return

device = self.coordinator.data[device_id]
self._attr_unique_id = f"{device.device_id}_{entity_description.key}"
self._attr_device_info = device.device_info(DOMAIN)

@property
def available(self) -> bool:
"""Return true if entity is available."""
if self.device_id not in self.coordinator.data:
return False # inactive device
last_update = self.coordinator.data[self.device_id][ATTR_LAST_UPDATE]
return bool(dt_util.utcnow() < (last_update + MAX_TIME_BETWEEN_UPDATES))

@property
def native_value(self) -> StateType:
"""Return the value reported by the sensor."""
value: StateType = self.coordinator.data[self.device_id][
self.entity_description.key
]
return value
Loading

0 comments on commit 8678976

Please sign in to comment.