Skip to content

Commit

Permalink
Upgrade thethingsnetwork to v3 (#113375)
Browse files Browse the repository at this point in the history
* thethingsnetwork upgrade to v3

* add en translations and requirements_all

* fix most of the findings

* hassfest

* use ttn_client v0.0.3

* reduce content of initial release

* remove features that trigger errors

* remove unneeded

* add initial testcases

* Exception warning

* add strict type checking

* add strict type checking

* full coverage

* rename to conftest

* review changes

* avoid using private attributes - use protected instead

* simplify config_flow

* remove unused options

* review changes

* upgrade client

* add types client library - no need to cast

* use add_suggested_values_to_schema

* add ruff fix

* review changes

* remove unneeded comment

* use typevar for TTN value

* use typevar for TTN value

* review

* ruff error not detected in local

* test review

* re-order fixture

* fix test

* reviews

* fix case

* split testcases

* review feedback

* Update homeassistant/components/thethingsnetwork/__init__.py

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>

* Update homeassistant/components/thethingsnetwork/__init__.py

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>

* Update tests/components/thethingsnetwork/test_config_flow.py

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>

* Remove deprecated var

* Update tests/components/thethingsnetwork/test_config_flow.py

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>

* Remove unused import

* fix ruff warning

---------

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
  • Loading branch information
angelnu and joostlek committed May 26, 2024
1 parent a793809 commit b85cf36
Show file tree
Hide file tree
Showing 21 changed files with 724 additions and 164 deletions.
1 change: 0 additions & 1 deletion .coveragerc
Original file line number Diff line number Diff line change
Expand Up @@ -1422,7 +1422,6 @@ omit =
homeassistant/components/tensorflow/image_processing.py
homeassistant/components/tfiac/climate.py
homeassistant/components/thermoworks_smoke/sensor.py
homeassistant/components/thethingsnetwork/*
homeassistant/components/thingspeak/*
homeassistant/components/thinkingcleaner/*
homeassistant/components/thomson/device_tracker.py
Expand Down
1 change: 1 addition & 0 deletions .strict-typing
Original file line number Diff line number Diff line change
Expand Up @@ -428,6 +428,7 @@ homeassistant.components.tcp.*
homeassistant.components.technove.*
homeassistant.components.tedee.*
homeassistant.components.text.*
homeassistant.components.thethingsnetwork.*
homeassistant.components.threshold.*
homeassistant.components.tibber.*
homeassistant.components.tile.*
Expand Down
3 changes: 2 additions & 1 deletion CODEOWNERS
Validating CODEOWNERS rules …
Original file line number Diff line number Diff line change
Expand Up @@ -1421,7 +1421,8 @@ build.json @home-assistant/supervisor
/tests/components/thermobeacon/ @bdraco
/homeassistant/components/thermopro/ @bdraco @h3ss
/tests/components/thermopro/ @bdraco @h3ss
/homeassistant/components/thethingsnetwork/ @fabaff
/homeassistant/components/thethingsnetwork/ @angelnu
/tests/components/thethingsnetwork/ @angelnu
/homeassistant/components/thread/ @home-assistant/core
/tests/components/thread/ @home-assistant/core
/homeassistant/components/tibber/ @danielhiversen
Expand Down
76 changes: 61 additions & 15 deletions homeassistant/components/thethingsnetwork/__init__.py
Original file line number Diff line number Diff line change
@@ -1,29 +1,28 @@
"""Support for The Things network."""

import logging

import voluptuous as vol

from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_API_KEY, CONF_HOST
from homeassistant.core import HomeAssistant
from homeassistant.helpers import issue_registry as ir
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.typing import ConfigType

CONF_ACCESS_KEY = "access_key"
CONF_APP_ID = "app_id"

DATA_TTN = "data_thethingsnetwork"
DOMAIN = "thethingsnetwork"
from .const import CONF_APP_ID, DOMAIN, PLATFORMS, TTN_API_HOST
from .coordinator import TTNCoordinator

TTN_ACCESS_KEY = "ttn_access_key"
TTN_APP_ID = "ttn_app_id"
TTN_DATA_STORAGE_URL = (
"https://{app_id}.data.thethingsnetwork.org/{endpoint}/{device_id}"
)
_LOGGER = logging.getLogger(__name__)

CONFIG_SCHEMA = vol.Schema(
{
# Configuration via yaml not longer supported - keeping to warn about migration
DOMAIN: vol.Schema(
{
vol.Required(CONF_APP_ID): cv.string,
vol.Required(CONF_ACCESS_KEY): cv.string,
vol.Required("access_key"): cv.string,
}
)
},
Expand All @@ -33,10 +32,57 @@

async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Initialize of The Things Network component."""
conf = config[DOMAIN]
app_id = conf.get(CONF_APP_ID)
access_key = conf.get(CONF_ACCESS_KEY)

hass.data[DATA_TTN] = {TTN_ACCESS_KEY: access_key, TTN_APP_ID: app_id}
if DOMAIN in config:
ir.async_create_issue(
hass,
DOMAIN,
"manual_migration",
breaks_in_ha_version="2024.12.0",
is_fixable=False,
severity=ir.IssueSeverity.ERROR,
translation_key="manual_migration",
translation_placeholders={
"domain": DOMAIN,
"v2_v3_migration_url": "https://www.thethingsnetwork.org/forum/c/v2-to-v3-upgrade/102",
"v2_deprecation_url": "https://www.thethingsnetwork.org/forum/t/the-things-network-v2-is-permanently-shutting-down-completed/50710",
},
)

return True


async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Establish connection with The Things Network."""

_LOGGER.debug(
"Set up %s at %s",
entry.data[CONF_API_KEY],
entry.data.get(CONF_HOST, TTN_API_HOST),
)

coordinator = TTNCoordinator(hass, entry)

await coordinator.async_config_entry_first_refresh()

hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator

await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)

return True


async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""

_LOGGER.debug(
"Remove %s at %s",
entry.data[CONF_API_KEY],
entry.data.get(CONF_HOST, TTN_API_HOST),
)

# Unload entities created for each supported platform
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if unload_ok:
del hass.data[DOMAIN][entry.entry_id]
return True
108 changes: 108 additions & 0 deletions homeassistant/components/thethingsnetwork/config_flow.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
"""The Things Network's integration config flow."""

from collections.abc import Mapping
import logging
from typing import Any

from ttn_client import TTNAuthError, TTNClient
import voluptuous as vol

from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_API_KEY, CONF_HOST
from homeassistant.helpers.selector import (
TextSelector,
TextSelectorConfig,
TextSelectorType,
)

from .const import CONF_APP_ID, DOMAIN, TTN_API_HOST

_LOGGER = logging.getLogger(__name__)


class TTNFlowHandler(ConfigFlow, domain=DOMAIN):
"""Handle a config flow."""

VERSION = 1

_reauth_entry: ConfigEntry | None = None

async def async_step_user(
self, user_input: Mapping[str, Any] | None = None
) -> ConfigFlowResult:
"""User initiated config flow."""

errors = {}
if user_input is not None:
client = TTNClient(
user_input[CONF_HOST],
user_input[CONF_APP_ID],
user_input[CONF_API_KEY],
0,
)
try:
await client.fetch_data()
except TTNAuthError:
_LOGGER.exception("Error authenticating with The Things Network")
errors["base"] = "invalid_auth"
except Exception:
_LOGGER.exception("Unknown error occurred")
errors["base"] = "unknown"

if not errors:
# Create entry
if self._reauth_entry:
return self.async_update_reload_and_abort(
self._reauth_entry,
data=user_input,
reason="reauth_successful",
)
await self.async_set_unique_id(user_input[CONF_APP_ID])
self._abort_if_unique_id_configured()

return self.async_create_entry(
title=str(user_input[CONF_APP_ID]),
data=user_input,
)

# Show form for user to provide settings
if not user_input:
if self._reauth_entry:
user_input = self._reauth_entry.data
else:
user_input = {CONF_HOST: TTN_API_HOST}

schema = self.add_suggested_values_to_schema(
vol.Schema(
{
vol.Required(CONF_HOST): str,
vol.Required(CONF_APP_ID): str,
vol.Required(CONF_API_KEY): TextSelector(
TextSelectorConfig(
type=TextSelectorType.PASSWORD, autocomplete="api_key"
)
),
}
),
user_input,
)
return self.async_show_form(step_id="user", data_schema=schema, errors=errors)

async def async_step_reauth(
self, user_input: Mapping[str, Any]
) -> ConfigFlowResult:
"""Handle a flow initialized by a reauth event."""

self._reauth_entry = self.hass.config_entries.async_get_entry(
self.context["entry_id"]
)

return await self.async_step_reauth_confirm()

async def async_step_reauth_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Dialog that informs the user that reauth is required."""
if user_input is None:
return self.async_show_form(step_id="reauth_confirm")
return await self.async_step_user()
12 changes: 12 additions & 0 deletions homeassistant/components/thethingsnetwork/const.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
"""The Things Network's integration constants."""

from homeassistant.const import Platform

DOMAIN = "thethingsnetwork"
TTN_API_HOST = "eu1.cloud.thethings.network"

PLATFORMS = [Platform.SENSOR]

CONF_APP_ID = "app_id"

POLLING_PERIOD_S = 60
66 changes: 66 additions & 0 deletions homeassistant/components/thethingsnetwork/coordinator.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
"""The Things Network's integration DataUpdateCoordinator."""

from datetime import timedelta
import logging

from ttn_client import TTNAuthError, TTNClient

from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_API_KEY, CONF_HOST
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator

from .const import CONF_APP_ID, POLLING_PERIOD_S

_LOGGER = logging.getLogger(__name__)


class TTNCoordinator(DataUpdateCoordinator[TTNClient.DATA_TYPE]):
"""TTN coordinator."""

def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Initialize my coordinator."""
super().__init__(
hass,
_LOGGER,
# Name of the data. For logging purposes.
name=f"TheThingsNetwork_{entry.data[CONF_APP_ID]}",
# Polling interval. Will only be polled if there are subscribers.
update_interval=timedelta(
seconds=POLLING_PERIOD_S,
),
)

self._client = TTNClient(
entry.data[CONF_HOST],
entry.data[CONF_APP_ID],
entry.data[CONF_API_KEY],
push_callback=self._push_callback,
)

async def _async_update_data(self) -> TTNClient.DATA_TYPE:
"""Fetch data from API endpoint.
This is the place to pre-process the data to lookup tables
so entities can quickly look up their data.
"""
try:
# Note: asyncio.TimeoutError and aiohttp.ClientError are already
# handled by the data update coordinator.
measurements = await self._client.fetch_data()
except TTNAuthError as err:
# Raising ConfigEntryAuthFailed will cancel future updates
# and start a config flow with SOURCE_REAUTH (async_step_reauth)
_LOGGER.error("TTNAuthError")
raise ConfigEntryAuthFailed from err
else:
# Return measurements
_LOGGER.debug("fetched data: %s", measurements)
return measurements

async def _push_callback(self, data: TTNClient.DATA_TYPE) -> None:
_LOGGER.debug("pushed data: %s", data)

# Push data to entities
self.async_set_updated_data(data)
71 changes: 71 additions & 0 deletions homeassistant/components/thethingsnetwork/entity.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
"""Support for The Things Network entities."""

import logging

from ttn_client import TTNBaseValue

from homeassistant.core import callback
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity

from .const import DOMAIN
from .coordinator import TTNCoordinator

_LOGGER = logging.getLogger(__name__)


class TTNEntity(CoordinatorEntity[TTNCoordinator]):
"""Representation of a The Things Network Data Storage sensor."""

_attr_has_entity_name = True
_ttn_value: TTNBaseValue

def __init__(
self,
coordinator: TTNCoordinator,
app_id: str,
ttn_value: TTNBaseValue,
) -> None:
"""Initialize a The Things Network Data Storage sensor."""

# Pass coordinator to CoordinatorEntity
super().__init__(coordinator)

self._ttn_value = ttn_value

self._attr_unique_id = f"{self.device_id}_{self.field_id}"
self._attr_name = self.field_id

self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, f"{app_id}_{self.device_id}")},
name=self.device_id,
)

@callback
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""

my_entity_update = self.coordinator.data.get(self.device_id, {}).get(
self.field_id
)
if (
my_entity_update
and my_entity_update.received_at > self._ttn_value.received_at
):
_LOGGER.debug(
"Received update for %s: %s", self.unique_id, my_entity_update
)
# Check that the type of an entity has not changed since the creation
assert isinstance(my_entity_update, type(self._ttn_value))
self._ttn_value = my_entity_update
self.async_write_ha_state()

@property
def device_id(self) -> str:
"""Return device_id."""
return str(self._ttn_value.device_id)

@property
def field_id(self) -> str:
"""Return field_id."""
return str(self._ttn_value.field_id)
Loading

0 comments on commit b85cf36

Please sign in to comment.