Skip to content

Commit d3c1c28

Browse files
eifingerjoostlek
andauthored
Add integration fressnapf_tracker (home-assistant#157480)
Co-authored-by: Joost Lekkerkerker <[email protected]>
1 parent d4e1f77 commit d3c1c28

File tree

21 files changed

+1274
-0
lines changed

21 files changed

+1274
-0
lines changed

CODEOWNERS

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
"""The Fressnapf Tracker integration."""
2+
3+
from fressnapftracker import AuthClient
4+
5+
from homeassistant.const import CONF_ACCESS_TOKEN, Platform
6+
from homeassistant.core import HomeAssistant
7+
from homeassistant.helpers.httpx_client import get_async_client
8+
9+
from .const import CONF_USER_ID
10+
from .coordinator import (
11+
FressnapfTrackerConfigEntry,
12+
FressnapfTrackerDataUpdateCoordinator,
13+
)
14+
15+
PLATFORMS: list[Platform] = [Platform.DEVICE_TRACKER]
16+
17+
18+
async def async_setup_entry(
19+
hass: HomeAssistant, entry: FressnapfTrackerConfigEntry
20+
) -> bool:
21+
"""Set up Fressnapf Tracker from a config entry."""
22+
auth_client = AuthClient(client=get_async_client(hass))
23+
devices = await auth_client.get_devices(
24+
user_id=entry.data[CONF_USER_ID],
25+
user_access_token=entry.data[CONF_ACCESS_TOKEN],
26+
)
27+
28+
coordinators: list[FressnapfTrackerDataUpdateCoordinator] = []
29+
for device in devices:
30+
coordinator = FressnapfTrackerDataUpdateCoordinator(
31+
hass,
32+
entry,
33+
device,
34+
)
35+
await coordinator.async_config_entry_first_refresh()
36+
coordinators.append(coordinator)
37+
38+
entry.runtime_data = coordinators
39+
40+
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
41+
42+
return True
43+
44+
45+
async def async_unload_entry(
46+
hass: HomeAssistant, entry: FressnapfTrackerConfigEntry
47+
) -> bool:
48+
"""Unload a config entry."""
49+
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
"""Config flow for the Fressnapf Tracker integration."""
2+
3+
import logging
4+
from typing import Any
5+
6+
from fressnapftracker import (
7+
AuthClient,
8+
FressnapfTrackerInvalidPhoneNumberError,
9+
FressnapfTrackerInvalidTokenError,
10+
)
11+
import voluptuous as vol
12+
13+
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
14+
from homeassistant.const import CONF_ACCESS_TOKEN
15+
from homeassistant.helpers.httpx_client import get_async_client
16+
17+
from .const import CONF_PHONE_NUMBER, CONF_SMS_CODE, CONF_USER_ID, DOMAIN
18+
19+
_LOGGER = logging.getLogger(__name__)
20+
21+
STEP_USER_DATA_SCHEMA = vol.Schema(
22+
{
23+
vol.Required(CONF_PHONE_NUMBER): str,
24+
}
25+
)
26+
STEP_SMS_CODE_DATA_SCHEMA = vol.Schema(
27+
{
28+
vol.Required(CONF_SMS_CODE): int,
29+
}
30+
)
31+
32+
33+
class FressnapfTrackerConfigFlow(ConfigFlow, domain=DOMAIN):
34+
"""Handle a config flow for Fressnapf Tracker."""
35+
36+
VERSION = 1
37+
38+
def __init__(self) -> None:
39+
"""Init Config Flow."""
40+
self._context: dict[str, Any] = {}
41+
self._auth_client: AuthClient | None = None
42+
43+
@property
44+
def auth_client(self) -> AuthClient:
45+
"""Return the auth client, creating it if needed."""
46+
if self._auth_client is None:
47+
self._auth_client = AuthClient(client=get_async_client(self.hass))
48+
return self._auth_client
49+
50+
async def _async_request_sms_code(
51+
self, phone_number: str
52+
) -> tuple[dict[str, str], bool]:
53+
"""Request SMS code and return errors dict and success flag."""
54+
errors: dict[str, str] = {}
55+
try:
56+
response = await self.auth_client.request_sms_code(
57+
phone_number=phone_number
58+
)
59+
except FressnapfTrackerInvalidPhoneNumberError:
60+
errors["base"] = "invalid_phone_number"
61+
except Exception:
62+
_LOGGER.exception("Unexpected exception")
63+
errors["base"] = "unknown"
64+
else:
65+
_LOGGER.debug("SMS code request response: %s", response)
66+
self._context[CONF_USER_ID] = response.id
67+
self._context[CONF_PHONE_NUMBER] = phone_number
68+
return errors, True
69+
return errors, False
70+
71+
async def _async_verify_sms_code(
72+
self, sms_code: int
73+
) -> tuple[dict[str, str], str | None]:
74+
"""Verify SMS code and return errors and access_token."""
75+
errors: dict[str, str] = {}
76+
try:
77+
verification_response = await self.auth_client.verify_phone_number(
78+
user_id=self._context[CONF_USER_ID],
79+
sms_code=sms_code,
80+
)
81+
except FressnapfTrackerInvalidTokenError:
82+
errors["base"] = "invalid_sms_code"
83+
except Exception:
84+
_LOGGER.exception("Unexpected exception during SMS code verification")
85+
errors["base"] = "unknown"
86+
else:
87+
_LOGGER.debug(
88+
"Phone number verification response: %s", verification_response
89+
)
90+
return errors, verification_response.user_token.access_token
91+
return errors, None
92+
93+
async def async_step_user(
94+
self, user_input: dict[str, Any] | None = None
95+
) -> ConfigFlowResult:
96+
"""Handle the initial step."""
97+
errors: dict[str, str] = {}
98+
if user_input is not None:
99+
self._async_abort_entries_match(
100+
{CONF_PHONE_NUMBER: user_input[CONF_PHONE_NUMBER]}
101+
)
102+
errors, success = await self._async_request_sms_code(
103+
user_input[CONF_PHONE_NUMBER]
104+
)
105+
if success:
106+
await self.async_set_unique_id(str(self._context[CONF_USER_ID]))
107+
self._abort_if_unique_id_configured()
108+
return await self.async_step_sms_code()
109+
110+
return self.async_show_form(
111+
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
112+
)
113+
114+
async def async_step_sms_code(
115+
self, user_input: dict[str, Any] | None = None
116+
) -> ConfigFlowResult:
117+
"""Handle the SMS code step."""
118+
errors: dict[str, str] = {}
119+
if user_input is not None:
120+
errors, access_token = await self._async_verify_sms_code(
121+
user_input[CONF_SMS_CODE]
122+
)
123+
if access_token:
124+
return self.async_create_entry(
125+
title=self._context[CONF_PHONE_NUMBER],
126+
data={
127+
CONF_PHONE_NUMBER: self._context[CONF_PHONE_NUMBER],
128+
CONF_USER_ID: self._context[CONF_USER_ID],
129+
CONF_ACCESS_TOKEN: access_token,
130+
},
131+
)
132+
133+
return self.async_show_form(
134+
step_id="sms_code",
135+
data_schema=STEP_SMS_CODE_DATA_SCHEMA,
136+
errors=errors,
137+
)
138+
139+
async def async_step_reconfigure(
140+
self, user_input: dict[str, Any] | None = None
141+
) -> ConfigFlowResult:
142+
"""Handle reconfiguration of the integration."""
143+
errors: dict[str, str] = {}
144+
reconfigure_entry = self._get_reconfigure_entry()
145+
146+
if user_input is not None:
147+
errors, success = await self._async_request_sms_code(
148+
user_input[CONF_PHONE_NUMBER]
149+
)
150+
if success:
151+
if reconfigure_entry.data[CONF_USER_ID] != self._context[CONF_USER_ID]:
152+
errors["base"] = "account_change_not_allowed"
153+
else:
154+
return await self.async_step_reconfigure_sms_code()
155+
156+
return self.async_show_form(
157+
step_id="reconfigure",
158+
data_schema=vol.Schema(
159+
{
160+
vol.Required(
161+
CONF_PHONE_NUMBER,
162+
default=reconfigure_entry.data.get(CONF_PHONE_NUMBER),
163+
): str,
164+
}
165+
),
166+
errors=errors,
167+
)
168+
169+
async def async_step_reconfigure_sms_code(
170+
self, user_input: dict[str, Any] | None = None
171+
) -> ConfigFlowResult:
172+
"""Handle the SMS code step during reconfiguration."""
173+
errors: dict[str, str] = {}
174+
175+
if user_input is not None:
176+
errors, access_token = await self._async_verify_sms_code(
177+
user_input[CONF_SMS_CODE]
178+
)
179+
if access_token:
180+
return self.async_update_reload_and_abort(
181+
self._get_reconfigure_entry(),
182+
data={
183+
CONF_PHONE_NUMBER: self._context[CONF_PHONE_NUMBER],
184+
CONF_USER_ID: self._context[CONF_USER_ID],
185+
CONF_ACCESS_TOKEN: access_token,
186+
},
187+
)
188+
189+
return self.async_show_form(
190+
step_id="reconfigure_sms_code",
191+
data_schema=STEP_SMS_CODE_DATA_SCHEMA,
192+
errors=errors,
193+
)
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
"""Constants for the Fressnapf Tracker integration."""
2+
3+
DOMAIN = "fressnapf_tracker"
4+
CONF_PHONE_NUMBER = "phone_number"
5+
CONF_SMS_CODE = "sms_code"
6+
CONF_USER_ID = "user_id"
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
"""Data update coordinator for Fressnapf Tracker integration."""
2+
3+
from datetime import timedelta
4+
import logging
5+
6+
from fressnapftracker import ApiClient, Device, FressnapfTrackerError, Tracker
7+
8+
from homeassistant.config_entries import ConfigEntry
9+
from homeassistant.core import HomeAssistant
10+
from homeassistant.helpers.httpx_client import get_async_client
11+
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
12+
13+
from .const import DOMAIN
14+
15+
_LOGGER = logging.getLogger(__name__)
16+
17+
type FressnapfTrackerConfigEntry = ConfigEntry[
18+
list[FressnapfTrackerDataUpdateCoordinator]
19+
]
20+
21+
22+
class FressnapfTrackerDataUpdateCoordinator(DataUpdateCoordinator[Tracker]):
23+
"""Class to manage fetching data from the API."""
24+
25+
def __init__(
26+
self,
27+
hass: HomeAssistant,
28+
config_entry: FressnapfTrackerConfigEntry,
29+
device: Device,
30+
) -> None:
31+
"""Initialize."""
32+
super().__init__(
33+
hass,
34+
_LOGGER,
35+
name=DOMAIN,
36+
update_interval=timedelta(minutes=15),
37+
config_entry=config_entry,
38+
)
39+
self.device = device
40+
self.client = ApiClient(
41+
serial_number=device.serialnumber,
42+
device_token=device.token,
43+
client=get_async_client(hass),
44+
)
45+
46+
async def _async_update_data(self) -> Tracker:
47+
try:
48+
return await self.client.get_tracker()
49+
except FressnapfTrackerError as exception:
50+
raise UpdateFailed(exception) from exception
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
"""Device tracker platform for fressnapf_tracker."""
2+
3+
from homeassistant.components.device_tracker import SourceType
4+
from homeassistant.components.device_tracker.config_entry import TrackerEntity
5+
from homeassistant.core import HomeAssistant
6+
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
7+
8+
from . import FressnapfTrackerConfigEntry, FressnapfTrackerDataUpdateCoordinator
9+
from .entity import FressnapfTrackerBaseEntity
10+
11+
12+
async def async_setup_entry(
13+
hass: HomeAssistant,
14+
entry: FressnapfTrackerConfigEntry,
15+
async_add_entities: AddConfigEntryEntitiesCallback,
16+
) -> None:
17+
"""Set up the fressnapf_tracker device_trackers."""
18+
async_add_entities(
19+
FressnapfTrackerDeviceTracker(coordinator) for coordinator in entry.runtime_data
20+
)
21+
22+
23+
class FressnapfTrackerDeviceTracker(FressnapfTrackerBaseEntity, TrackerEntity):
24+
"""fressnapf_tracker device tracker."""
25+
26+
_attr_name = None
27+
_attr_translation_key = "pet"
28+
29+
def __init__(
30+
self,
31+
coordinator: FressnapfTrackerDataUpdateCoordinator,
32+
) -> None:
33+
"""Initialize the device tracker."""
34+
super().__init__(coordinator)
35+
self._attr_unique_id = coordinator.device.serialnumber
36+
37+
@property
38+
def available(self) -> bool:
39+
"""Return if entity is available."""
40+
return super().available and self.coordinator.data.position is not None
41+
42+
@property
43+
def latitude(self) -> float | None:
44+
"""Return latitude value of the device."""
45+
if self.coordinator.data.position is not None:
46+
return self.coordinator.data.position.lat
47+
return None
48+
49+
@property
50+
def longitude(self) -> float | None:
51+
"""Return longitude value of the device."""
52+
if self.coordinator.data.position is not None:
53+
return self.coordinator.data.position.lng
54+
return None
55+
56+
@property
57+
def source_type(self) -> SourceType:
58+
"""Return the source type, eg gps or router, of the device."""
59+
return SourceType.GPS
60+
61+
@property
62+
def location_accuracy(self) -> float:
63+
"""Return the location accuracy of the device.
64+
65+
Value in meters.
66+
"""
67+
if self.coordinator.data.position is not None:
68+
return float(self.coordinator.data.position.accuracy)
69+
return 0

0 commit comments

Comments
 (0)