Skip to content

Commit

Permalink
initial commit
Browse files Browse the repository at this point in the history
  • Loading branch information
markvader committed Jun 16, 2022
1 parent 27780fc commit 916fa43
Show file tree
Hide file tree
Showing 16 changed files with 632 additions and 0 deletions.
3 changes: 3 additions & 0 deletions .idea/.gitignore

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions .idea/misc.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 8 additions & 0 deletions .idea/modules.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 9 additions & 0 deletions .idea/sonic_hacs.iml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions .idea/vcs.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

59 changes: 59 additions & 0 deletions custom_components/sonic/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
"""The Sonic Water Shut-off Valve integration."""
import logging
import asyncio

from herolabsapi import (
InvalidCredentialsError,
Client,
ServiceUnavailableError,
TooManyRequestsError,
)

from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers.aiohttp_client import async_get_clientsession

from .const import CLIENT, DOMAIN
from .device import SonicDeviceDataUpdateCoordinator

_LOGGER = logging.getLogger(__name__)

PLATFORMS: list[str] = ["switch", "sensor"]

async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Sonic Water Shut-off Valve from a config entry."""
session = async_get_clientsession(hass)
hass.data.setdefault(DOMAIN, {})
hass.data[DOMAIN][entry.entry_id] = {}
try:
hass.data[DOMAIN][entry.entry_id][CLIENT] = client = await Client.async_login(
entry.data[CONF_USERNAME], entry.data[CONF_PASSWORD], session=session
)
except InvalidCredentialsError as err:
raise ConfigEntryNotReady from err

sonic_data = await client.sonic.async_get_all_sonic_details()

_LOGGER.debug("Sonic device data information: %s", sonic_data)

hass.data[DOMAIN][entry.entry_id]["devices"] = devices = [
SonicDeviceDataUpdateCoordinator(hass, client, device["id"])
for device in sonic_data["data"]
]

tasks = [device.async_refresh() for device in devices]
await asyncio.gather(*tasks)

hass.config_entries.async_setup_platforms(entry, PLATFORMS)

return True


async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if unload_ok:
hass.data[DOMAIN].pop(entry.entry_id)
return unload_ok
67 changes: 67 additions & 0 deletions custom_components/sonic/config_flow.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
"""Config flow for Sonic Integration."""
from herolabsapi import Client, InvalidCredentialsError
import voluptuous as vol

from homeassistant import config_entries, core, exceptions
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from homeassistant.helpers.aiohttp_client import async_get_clientsession

from .const import DOMAIN, LOGGER

DATA_SCHEMA = vol.Schema({vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str})


async def validate_input(hass: core.HomeAssistant, data):
"""Validate the user input allows us to connect.
Data has the keys from DATA_SCHEMA with values provided by the user.
"""

session = async_get_clientsession(hass)
try:
api = await Client.async_login(
data[CONF_USERNAME], data[CONF_PASSWORD], session=session
)
except InvalidCredentialsError as request_error:
LOGGER.error("Error connecting to the Sonic API: %s", request_error)
raise CannotConnect from request_error

# Use the verified session to discover the first sonic device's name
sonic_data = await api.sonic.async_get_all_sonic_details()
first_sonic_id = sonic_data["data"][0]["id"]
sonic_info = await api.sonic.async_get_sonic_details(first_sonic_id)
return {"title": sonic_info["name"]}


class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Sonic."""

VERSION = 1

async def async_step_user(self, user_input=None):
"""Handle the initial step."""
errors = {}
if user_input is not None:
try:
info = await validate_input(self.hass, user_input)

return self.async_create_entry(title=info["title"], data=user_input)
except CannotConnect:
errors["base"] = "cannot_connect"
except InvalidHost:
errors["host"] = "cannot_connect"
except Exception: # pylint: disable=broad-except
LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"

return self.async_show_form(
step_id="user", data_schema=DATA_SCHEMA, errors=errors
)


class CannotConnect(exceptions.HomeAssistantError):
"""Error to indicate we cannot connect."""


class InvalidHost(exceptions.HomeAssistantError):
"""Error to indicate there is an invalid hostname."""
7 changes: 7 additions & 0 deletions custom_components/sonic/const.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
"""Constants for the Sonic Water Shut-off Valve integration."""
import logging

LOGGER = logging.getLogger(__package__)

CLIENT = "client"
DOMAIN = "sonic"
156 changes: 156 additions & 0 deletions custom_components/sonic/device.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
"""Sonic device object."""
from __future__ import annotations

import asyncio
from datetime import timedelta
from typing import Any

from async_timeout import timeout
from herolabsapi.client import Client
from herolabsapi.errors import RequestError

from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed

from .const import DOMAIN as SONIC_DOMAIN, LOGGER


class SonicDeviceDataUpdateCoordinator(DataUpdateCoordinator):
"""Sonic device object."""

def __init__(self, hass: HomeAssistant, api_client: Client, device_id: str) -> None:
"""Initialize the device."""
self.hass: HomeAssistant = hass
self.api_client: Client = api_client
self._sonic_device_id: str = device_id
self._device_information: dict[str, Any] = {}
self._telemetry_information: dict[str, Any] = {}
super().__init__(
hass,
LOGGER,
name=f"{SONIC_DOMAIN}-{device_id}",
update_interval=timedelta(seconds=60),
)

async def _async_update_data(self):
"""Update data via library."""
try:
async with timeout(10):
await asyncio.gather(
*[
self._update_device(),
]
)
except (RequestError) as error:
raise UpdateFailed(error) from error

@property
def id(self) -> str:
"""Return Sonic device id."""
return self._sonic_device_id

@property
def device_name(self) -> str:
"""Return device name."""
return self._device_information.get("name", f"{self.model}")

@property
def manufacturer(self) -> str:
"""Return manufacturer for device."""
return "Hero Labs"

@property
def serial_number(self) -> str:
"""Return serial number for device."""
return self._device_information["serial_no"]

@property
def model(self) -> str:
"""Return model for device."""
return "Sonic"

@property
def rssi(self) -> float:
"""Return rssi for device."""
return self._device_information["radio_rssi"]

@property
def last_heard_from_time(self) -> str:
"""Return Unix timestamp in seconds when the sonic took measurements
Will need to do conversion from timestamp to datetime if HomeAssistant doesn't do it automatically"""
return self._telemetry_information["probed_at"]

@property
def available(self) -> bool:
"""Return True if device is available."""
return (
self.last_update_success
and self._device_information["radio_connection"] == "connected"
)

@property
def current_flow_rate(self) -> float:
"""Return current flow rate in ml/min."""
return self._telemetry_information["water_flow"]

@property
def current_mbar(self) -> int:
"""Return the current pressure in mbar."""
return self._telemetry_information["pressure"]

@property
def temperature(self) -> float:
"""Return the current temperature in degrees C."""
return self._telemetry_information["water_temp"]

@property
def battery_state(self) -> str:
"""Return the battery level "high","mid","low" or returns "external_power_supply" """
return self._device_information["battery"]

@property
def auto_shut_off_enabled(self) -> bool:
"""Return the auto shut off enabled boolean"""
return self._device_information["auto_shut_off_enabled"]

@property
def auto_shut_off_time_limit(self) -> int:
"""Return the Sonic offline auto shut off water usage time limit in seconds[0;integer::max).
When set to 0 usage time check is not performed."""
return self._device_information["auto_shut_off_time_limit"]

@property
def auto_shut_off_volume_limit(self) -> int:
"""Return the Sonic offline auto shut off used water volume limit in millilitres [0;integer::max).
When set to 0 volume used check is not performed."""
return self._device_information["auto_shut_off_volume_limit"]

@property
def signal_id(self) -> str:
"""Return the associated signal device id
A Signal device (sometimes called hub) communicates with WiFi and the Sonic device"""
return self._device_information["signal_id"]

@property
def sonic_status(self) -> str:
"""Return the any sonic status message"""
return self._device_information["status"]

@property
def last_known_valve_state(self) -> str:
"""Return the current valve state
Options are: 'open, closed, opening, closing, faulty, pressure_test, requested_open, requested_closed'"""
return self._device_information["valve_state"]

async def _update_device(self, *_) -> None:
"""Update the device information from the API."""
self._device_information = await self.api_client.sonic.async_get_sonic_details(
self._sonic_device_id
)
self._telemetry_information = (
await self.api_client.sonic.async_sonic_telemetry_by_id(
self._sonic_device_id
)
)
LOGGER.debug("Sonic device data: %s", self._device_information)
LOGGER.debug("Sonic telemetry data: %s", self._telemetry_information)
53 changes: 53 additions & 0 deletions custom_components/sonic/entity.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
"""Base entity class for Sonic entities."""
from __future__ import annotations

from typing import Any

from homeassistant.helpers.entity import DeviceInfo, Entity

from .const import DOMAIN as SONIC_DOMAIN
from .device import SonicDeviceDataUpdateCoordinator


class SonicEntity(Entity):
"""A base class for Sonic entities."""

_attr_force_update = False
_attr_should_poll = False

def __init__(
self,
entity_type: str,
name: str,
device: SonicDeviceDataUpdateCoordinator,
**kwargs,
) -> None:
"""Init Sonic entity."""
self._attr_name = name
self._attr_unique_id = f"{device.serial_number}_{entity_type}"

self._device: SonicDeviceDataUpdateCoordinator = device
self._state: Any = None

@property
def device_info(self) -> DeviceInfo:
"""Return a device description for device registry."""
return DeviceInfo(
identifiers={(SONIC_DOMAIN, self._device.id)},
manufacturer=self._device.manufacturer,
model=self._device.model,
name=self._device.device_name,
)

@property
def available(self) -> bool:
"""Return True if device is available."""
return self._device.available

async def async_update(self):
"""Update Sonic entity."""
await self._device.async_request_refresh()

async def async_added_to_hass(self):
"""When entity is added to hass."""
self.async_on_remove(self._device.async_add_listener(self.async_write_ha_state))
10 changes: 10 additions & 0 deletions custom_components/sonic/manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"domain": "sonic",
"name": "Sonic by Hero Labs",
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/sonic",
"requirements": ["herolabsapi==0.3.6"],
"codeowners": ["@markvader"],
"iot_class": "cloud_polling",
"loggers": ["sonic"]
}
Loading

0 comments on commit 916fa43

Please sign in to comment.