Skip to content

Commit

Permalink
Merge pull request #21 from mr-raw/v0.1.1
Browse files Browse the repository at this point in the history
V0.1.1
  • Loading branch information
mr-raw authored Apr 2, 2023
2 parents c3226ac + 743e18e commit cbb2f33
Show file tree
Hide file tree
Showing 11 changed files with 130 additions and 163 deletions.
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,17 +14,18 @@ This integration is currently in development. Basic functionality is up and runn

Version plans
- [x] 0.1.0 First release. Will have basic functionality. All the fractions will be shown. User mistakes will not be accounted for. This will break the integration and throw errors around.
- [x] 0.1.1 Small changes to the code. Did some refactoring. Using httpx instead of aiohttp.
- [ ] 1.0.0 Final release. You can choose which fractions to track. The integration has been thorougly tested.

#### Setup and configuration is done in the UI

## Examples

This example creates template sendor that shows how many days until pickup of the provided fraction:
``` yaml
```yaml
template:
- sensor:
- name: "Days until garbage pickup"
- name: "Days Until Matavfall"
state: >
{% set matavfall_date = as_timestamp(states('sensor.matavfall')) %}
{% set days_until = ((matavfall_date - as_timestamp(now())) // 86400)|round %}
Expand Down
14 changes: 4 additions & 10 deletions custom_components/hra_recycling/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,29 +3,23 @@
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession

from .const import CONF_ADDRESS, DOMAIN, LOGGER, STARTUP_MESSAGE
from .const import CONF_ADDRESS, DOMAIN
from .coordinator import HraDataUpdateCoordinator
from .hra_api import ApiClient
from .hra_api import HraApiClient

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


async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):
"""Set up this integration using UI."""
LOGGER.debug(STARTUP_MESSAGE)
hass.data.setdefault(DOMAIN, {})
hass.data[DOMAIN][entry.entry_id] = coordinator = HraDataUpdateCoordinator(
hass=hass,
client=ApiClient(
address=entry.data[CONF_ADDRESS], session=async_get_clientsession(hass)
),
client=HraApiClient(address=entry.data[CONF_ADDRESS]),
)

# This should probably be incorporated in a later version.
# await coordinator.async_config_entry_first_refresh()

# This is the first call. We should use async_config_entry_first_refresh here
await coordinator.async_refresh()

await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
Expand Down
109 changes: 51 additions & 58 deletions custom_components/hra_recycling/config_flow.py
Original file line number Diff line number Diff line change
@@ -1,74 +1,67 @@
"""config_flow.py"""
from homeassistant import config_entries
from homeassistant.helpers.aiohttp_client import async_create_clientsession
from __future__ import annotations

import voluptuous as vol
from .hra_api import ApiClient
from .const import CONF_ADDRESS, DOMAIN
from homeassistant import config_entries
from homeassistant.helpers import selector

from .const import CONF_ADDRESS, DOMAIN, LOGGER
from .hra_api import (
ApiClientNoPickupDataFound,
ApiClientCommunicationError,
ApiClientError,
HraApiClient,
)


class HRAConfigFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
"""Config flow for HRA Recycling."""

VERSION = 1
CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL

def __init__(self):
"""Initialize."""
self._errors = {}
self.api_client = None

async def async_step_user(self, user_input=None):
async def async_step_user(
self,
user_input: dict | None = None,
) -> config_entries.FlowResult:
"""Handle a flow initialized by the user."""
self._errors = {}

_errors = {}
if user_input is not None:
# Check if the address is correct here
valid = await self._check_if_address_is_correct(user_input[CONF_ADDRESS])
if valid:
try:
await self._check_if_address_is_correct(
address=user_input[CONF_ADDRESS]
)
except ApiClientNoPickupDataFound as exception:
LOGGER.warning(exception)
_errors["base"] = "invalid_address"
except ApiClientCommunicationError as exception:
LOGGER.warning(exception)
_errors["base"] = "comm_error"
except ApiClientError as exception:
LOGGER.error(exception)
_errors["base"] = "unknown_error"
else:
return self.async_create_entry(
title=self.api_client.address,
data={
"address": self.api_client.address,
"agreement_id": self.api_client.agreement_id,
},
title=user_input[CONF_ADDRESS], data=user_input
)
self._errors["base"] = "invalid_address"

return await self._show_config_form(user_input)

user_input = {}
# Provide defaults for form
user_input[CONF_ADDRESS] = "Rådhusvegen 39"

return await self._show_config_form(user_input)

async def _check_if_address_is_correct(self, address):
"""Return true if address is valid."""
try:
session = async_create_clientsession(self.hass)
client = ApiClient(address, session)
temp = await client.async_verify_address()
# Original code:
# if len(temp) == 1:
# client.agreement_id = temp[0].get("agreementGuid")
# self.api_client = client
# return True
# TODO: This does not check how many results, only return the first.
# We should probably do some more with this. Maybe show the results and make
# the user choose the correct one.
client.agreement_id = temp[0].get("agreementGuid")
self.api_client = client
return True
except Exception: # pylint: disable=broad-except
pass
return False

async def _show_config_form(self, user_input): # pylint: disable=unused-argument
"""Show the configuration form to edit location data."""
scheme = vol.Schema(
{vol.Required(CONF_ADDRESS, default=user_input[CONF_ADDRESS]): str}
)

return self.async_show_form(
step_id="user", data_schema=scheme, errors=self._errors
step_id="user",
data_schema=vol.Schema(
{
vol.Required(
CONF_ADDRESS,
default=(user_input or {}).get(CONF_ADDRESS),
): selector.TextSelector(
selector.TextSelectorConfig(
type=selector.TextSelectorType.TEXT
),
),
}
),
errors=_errors,
)

async def _check_if_address_is_correct(self, address):
"""Checks if the provided address is correct."""
client = HraApiClient(address=address)
await client.async_verify_address()
15 changes: 3 additions & 12 deletions custom_components/hra_recycling/const.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"""const.py"""
from logging import Logger, getLogger
from datetime import timedelta
from logging import Logger, getLogger

# Base component constants

Expand All @@ -20,16 +20,7 @@

# Configuration and options
CONF_ADDRESS = "address"
# CONF_GUID = ""

# Defaults
DEFAULT_NAME = DOMAIN

STARTUP_MESSAGE = f"""
-------------------------------------------------------------------
{NAME}
Version: {VERSION}
This is a custom integration!
If you have any issues with this you need to open an issue here:
{ISSUE_URL}
-------------------------------------------------------------------
"""
# DEFAULT_NAME = DOMAIN
8 changes: 4 additions & 4 deletions custom_components/hra_recycling/coordinator.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,22 +4,22 @@

from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.update_coordinator import (
DataUpdateCoordinator,
UpdateFailed,
)

from homeassistant.exceptions import ConfigEntryAuthFailed
from .const import DOMAIN, LOGGER, SCAN_INTERVAL
from .hra_api import ApiClient, ApiClientError, ApiClientAuthenticationError
from .hra_api import HraApiClient, ApiClientNoPickupDataFound, ApiClientError


class HraDataUpdateCoordinator(DataUpdateCoordinator):
"""Class to manage fetching data from the API."""

config_entry = ConfigEntry

def __init__(self, hass: HomeAssistant, client: ApiClient) -> None:
def __init__(self, hass: HomeAssistant, client: HraApiClient) -> None:
"""Initialize."""
self.client = client

Expand All @@ -34,7 +34,7 @@ async def _async_update_data(self):
if not self.client.agreement_id:
await self.client.async_verify_address()
return await self.client.async_retrieve_fraction_data()
except ApiClientAuthenticationError as exception:
except ApiClientNoPickupDataFound as exception:
raise ConfigEntryAuthFailed(exception) from exception
except ApiClientError as exception:
raise UpdateFailed(exception) from exception
102 changes: 45 additions & 57 deletions custom_components/hra_recycling/hra_api.py
Original file line number Diff line number Diff line change
@@ -1,19 +1,12 @@
"""hra_api.py"""
from collections import defaultdict
from datetime import datetime
import asyncio
import socket
import aiohttp
import async_timeout
from typing import Any, Dict, List

import httpx
from bs4 import BeautifulSoup
from .const import LOGGER


# https://api.hra.no//search/address?query=R%C3%A5dhusvegen%2039,%202770%20JAREN

TIMEOUT = 10
HEADERS = {"Content-type": "application/json; charset=UTF-8"}
# HEADERS = {"Content-type": "application/json; charset=UTF-8"}


class ApiClientError(Exception):
Expand All @@ -24,75 +17,72 @@ class ApiClientCommunicationError(ApiClientError):
"""Exception to indicate a communication error."""


class ApiClientAuthenticationError(ApiClientError):
class ApiClientNoPickupDataFound(ApiClientError):
"""Exception to indicate an authentication error."""


class ApiClient:
class HraApiClient:
"""ApiClient()"""

def __init__(self, address: str, session: aiohttp.ClientSession) -> None:
def __init__(self, address: str) -> None:
"""HRA API Client"""
self.address = address
self._session = session
self.agreement_id: str = ""
self.agreement_data: dict = {}
self.pickup_data: dict = {}

async def async_verify_address(self) -> str:
"""Verify that the provided address is valid."""
if self.address == "":
raise ApiClientError("The address field is empty.")
url = f"https://api.hra.no/search/address?query={self.address}"
data = await self._get_agreement_id_from_address(url)
self.agreement_data = data[0]
self.agreement_id = data[0]["agreementGuid"]
self.address = data[0]["name"]
self.agreement_id = data[0]["agreementGuid"]
self.agreement_data = data[0]
return data

async def _get_agreement_id_from_address(self, url: str) -> str:
"""Get information from the API."""
async with httpx.AsyncClient() as client:
try:
response = await client.get(url, timeout=10)
except httpx.TimeoutException as esc:
raise ApiClientCommunicationError("Request timed out") from esc
if response.status_code == 401:
raise ApiClientNoPickupDataFound("Authentication error")
return response.json()

async def async_retrieve_fraction_data(self) -> Dict[str, Any]:
"""
Get fraction data and update the pickup_data attribute with the retrieved data.
try:
async with async_timeout.timeout(TIMEOUT):
resp = await self._session.get(url)

if resp.status == 401:
raise ApiClientAuthenticationError("Authentication error")
return await resp.json()

except asyncio.TimeoutError as exception:
LOGGER.error(
"Timeout error fetching information from %s - %s",
url,
exception,
)

except (KeyError, TypeError) as exception:
LOGGER.error(
"Error parsing information from %s - %s",
url,
exception,
)
Returns:
data (Dict[str, Any]): The retrieved fraction data.
"""
self.pickup_data = await self._get_fraction_data()
return self.pickup_data

except (aiohttp.ClientError, socket.gaierror) as exception:
LOGGER.error(
"Error fetching information from %s - %s",
url,
exception,
)
except Exception as exception: # pylint: disable=broad-except
LOGGER.error("Something really wrong happened! - %s", exception)
async def _get_fraction_data(self) -> List[Dict[str, Any]]:
"""
Retrieve fraction data using the instance's address and agreement_id attributes.
async def async_retrieve_fraction_data(self):
"""Get fraction data"""
data = await self._get_fraction_data(self.agreement_id)
self.pickup_data = data
return data
Returns:
List[Dict[str, Any]]: A list containing the processed data as a dictionary.
async def _get_fraction_data(self, uid: str):
"""Actually retrieve data using the uid provied"""
address = self.address
uid = self.agreement_id # We need to add error handling here
url = f"https://hra.no/tommekalender/?query={address}&agreement={uid}"
Raises:
ApiClientError: If the address or agreementID fields are empty.
"""
if self.address == "":
raise ApiClientError("The address field is empty.")
if self.agreement_id == "":
raise ApiClientError("The agreementID field is empty.")

url = (
f"https://hra.no/tommekalender/?"
f"query={self.address}&"
f"agreement={self.agreement_id}"
)
html_doc = await self.download_html_file(url)
processed_data = await self.process_html_code(html_doc)
return [processed_data]
Expand All @@ -111,9 +101,7 @@ async def process_html_code(self, html_code: str) -> dict:
"""Process the HTML into json"""
soup = BeautifulSoup(html_code, "html.parser")
address = soup.find("h3").text

garbage_retrieval_rows = soup.find_all(class_="garbage-retrieval-row")

waste_types_data = defaultdict(list)

norwegian_weekdays = {
Expand Down
Loading

0 comments on commit cbb2f33

Please sign in to comment.