Skip to content

Commit 70f211c

Browse files
Feature/pets management (#204)
* Pets Management integration Changes: Replaced uses of 'datetime.utcnow()' (deprecated) with homeassistant.util.dt.utcnow() Moved the 'PetlibroHub.loaded_device_sn' set to the DevicesHelper. Moved the 'PetlibroHub.feed_number_unique_ids' and 'PetlibroHub.unit_entities_unique_ids' dicts to the Unit_Entities helper. Changed 'PetlibroHub.devices' to a dictionary with serial numbers as keys. New: Device serial numbers, device IDs and owned/shared status gets saved to Config Entry options. Pet IDs, pet device IDs and owned/shared status gets saved to Config Entry options. Added DevicesHelper. Added PetsHelper. Devices and Pets removed from the Petlibro account will be removed from the integration automatically. Added 'media' directory for images etc. Added option to Integration Settings: Show Pets from Shared Devices Added entity platforms: date image Added methods to API: device_get_bound_pets pets.get_list pets.get_details pets.get_bound_devices pets.save_or_update pets.goal_setting Added Pet object and pet devices with entities. * Added missing feed unit mismatch failsafe to pets entities --------- Co-authored-by: Jamie Jones <29973406+jjjonesjr33@users.noreply.github.com>
1 parent 9600c14 commit 70f211c

40 files changed

Lines changed: 3317 additions & 265 deletions

custom_components/petlibro/__init__.py

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -179,14 +179,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
179179
# Store the hub in hass.data
180180
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = hub
181181

182+
# Initialize Helpers
183+
await hub._initialize_helpers()
184+
182185
# Load member only once here
183186
await hub.load_member()
184187

185188
# Load devices only once here
186189
await hub.load_devices()
187-
188-
# Initialize Helpers
189-
await hub._initialize_helpers()
190+
191+
# Load pets only once here
192+
await hub.load_pets()
190193

191194
# Start the coordinator for periodic updates
192195
await hub.coordinator.async_config_entry_first_refresh()
@@ -236,6 +239,6 @@ async def async_remove_config_entry_device(hass: HomeAssistant, entry: ConfigEnt
236239
identifier
237240
for identifier in device_entry.identifiers
238241
if identifier[0] == DOMAIN
239-
for device in hub.devices
242+
for device in hub.devices.values()
240243
if device.serial == identifier[1]
241-
)
244+
)

custom_components/petlibro/api.py

Lines changed: 33 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,14 @@
1515

1616
from logging import getLogger
1717
from hashlib import md5
18+
import sys
1819
from urllib.parse import urljoin
1920
from typing import Any, Dict, List, TypeAlias
20-
from datetime import datetime, timedelta
21+
from datetime import timedelta
2122
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
22-
from homeassistant.exceptions import ConfigEntryAuthFailed
23-
from .exceptions import PetLibroAPIError, PetLibroInvalidAuth
24-
from aiohttp import ClientSession, ClientError
23+
from homeassistant.util.dt import utcnow
24+
from .exceptions import PetLibroAPIError
25+
from aiohttp import ClientSession
2526

2627
import aiohttp
2728
import uuid # To generate unique request IDs
@@ -204,6 +205,10 @@ def __init__(self, session: ClientSession, time_zone: str, region: str, email: s
204205
self._last_api_call_times = {} # To store last call time per device
205206
self._cached_responses = {} # To store cached responses for short periods
206207

208+
if "PL_PetAPI" not in sys.modules:
209+
from .pets.api import PL_PetAPI
210+
self.pets = PL_PetAPI(self.hass, self.config_entry, self.session)
211+
207212
@staticmethod
208213
def hash_password(password: str) -> str:
209214
"""Generate the password hash for the API"""
@@ -242,7 +247,7 @@ async def login(self, email: str, password: str) -> str:
242247

243248
async def get_device_real_info(self, device_id: str) -> dict:
244249
"""Fetch real-time information for a device, with caching to prevent frequent requests."""
245-
now = datetime.utcnow()
250+
now = utcnow()
246251
last_call_time = self._last_api_call_times.get(f"{device_id}_realInfo")
247252

248253
# If we made the request within the last 10 seconds, return cached response
@@ -268,7 +273,7 @@ async def get_device_real_info(self, device_id: str) -> dict:
268273

269274
async def get_device_data_real_info(self, device_id: str) -> dict:
270275
"""Fetch real-time information for a device, with caching to prevent frequent requests."""
271-
now = datetime.utcnow()
276+
now = utcnow()
272277
last_call_time = self._last_api_call_times.get(f"{device_id}_dataRealInfo")
273278

274279
# If we made the request within the last 10 seconds, return cached response
@@ -294,7 +299,7 @@ async def get_device_data_real_info(self, device_id: str) -> dict:
294299

295300
async def get_device_drink_water(self, device_id: str) -> dict:
296301
"""Fetch real-time information for a device, with caching to prevent frequent requests."""
297-
now = datetime.utcnow()
302+
now = utcnow()
298303
last_call_time = self._last_api_call_times.get(f"{device_id}_drinkWater")
299304

300305
# If we made the request within the last 10 seconds, return cached response
@@ -320,7 +325,7 @@ async def get_device_drink_water(self, device_id: str) -> dict:
320325

321326
async def get_device_attribute_settings(self, device_id: str) -> dict:
322327
"""Fetch real-time information for a device, with caching to prevent frequent requests."""
323-
now = datetime.utcnow()
328+
now = utcnow()
324329
last_call_time = self._last_api_call_times.get(f"{device_id}_getAttributeSetting")
325330

326331
# If we made the request within the last 10 seconds, return cached response
@@ -345,7 +350,7 @@ async def get_device_attribute_settings(self, device_id: str) -> dict:
345350

346351
async def get_device_upgrade(self, device_id: str) -> dict:
347352
"""Fetch real-time information for a device, with caching to prevent frequent requests."""
348-
now = datetime.utcnow()
353+
now = utcnow()
349354
last_call_time = self._last_api_call_times.get(f"{device_id}_getUpgrade")
350355

351356
# If we made the request within the last 10 seconds, return cached response
@@ -370,7 +375,7 @@ async def get_device_upgrade(self, device_id: str) -> dict:
370375

371376
async def get_device_base_info(self, device_id: str) -> dict:
372377
"""Fetch real-time information for a device, with caching to prevent frequent requests."""
373-
now = datetime.utcnow()
378+
now = utcnow()
374379
last_call_time = self._last_api_call_times.get(f"{device_id}_baseInfo")
375380

376381
# If we made the request within the last 10 seconds, return cached response
@@ -395,7 +400,7 @@ async def get_device_base_info(self, device_id: str) -> dict:
395400

396401
async def get_device_work_record(self, device_id: str) -> dict:
397402
"""Fetch real-time information for a device, with caching to prevent frequent requests."""
398-
now = datetime.utcnow()
403+
now = utcnow()
399404
last_call_time = self._last_api_call_times.get(f"{device_id}_work_record")
400405

401406
if last_call_time and (now - last_call_time) < timedelta(seconds=10):
@@ -432,7 +437,7 @@ async def get_device_work_record(self, device_id: str) -> dict:
432437

433438
async def get_device_events(self, device_id: str) -> dict:
434439
"""Fetch real-time information for a device, with caching to prevent frequent requests."""
435-
now = datetime.utcnow()
440+
now = utcnow()
436441
last_call_time = self._last_api_call_times.get(f"{device_id}_events")
437442

438443
# If we made the request within the last 10 seconds, return cached response
@@ -463,7 +468,7 @@ async def get_default_matrix(self, device_sn: str) -> dict:
463468
:return: The default matrix data.
464469
"""
465470
# Check cache for recently fetched data
466-
now = datetime.utcnow()
471+
now = utcnow()
467472
cache_key = f"{device_sn}_getDefaultMatrix"
468473
last_call_time = self._last_api_call_times.get(cache_key)
469474

@@ -542,6 +547,21 @@ async def device_feeding_plan_list(self, serial: str) -> List[Dict[str, Any]]:
542547
async def device_wet_feeding_plan(self, serial: str) -> Dict[str, Any]:
543548
return await self.session.post_serial("/device/wetFeedingPlan/wetListV3", serial)
544549

550+
async def device_get_bound_pets(self, device_sn: str) -> list[dict]:
551+
"""Get pets bound to a device."""
552+
_LOGGER.debug("Requesting pets bound to device sn: %s", device_sn)
553+
554+
try:
555+
data = await self.session.post("/device/devicePetRelation/getBoundPets", json={"deviceSn": device_sn})
556+
except Exception as exc:
557+
raise PetLibroAPIError("Failed to fetch list of bound pets") from exc
558+
559+
if data and not isinstance(data, list):
560+
raise PetLibroAPIError(f"Invalid bound pets response format: {data}")
561+
562+
_LOGGER.debug("Bound pets retrieved successfully")
563+
return data or []
564+
545565
# Support for new switch functions
546566
async def set_feeding_plan(self, serial: str, enable: bool):
547567
"""Set the feeding plan on/off."""

custom_components/petlibro/binary_sensor.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -612,7 +612,7 @@ async def async_setup_entry(
612612
) -> None:
613613
"""Set up PETLIBRO binary sensors using config entry."""
614614
# Retrieve the hub from hass.data that was set up in __init__.py
615-
hub = hass.data[DOMAIN].get(entry.entry_id)
615+
hub: PetLibroHub = hass.data[DOMAIN].get(entry.entry_id)
616616

617617
if not hub:
618618
_LOGGER.error("Hub not found for entry: %s", entry.entry_id)
@@ -632,7 +632,7 @@ async def async_setup_entry(
632632
# Create binary sensor entities for each device based on the binary sensor map
633633
entities = [
634634
PetLibroBinarySensorEntity(device, hub, description)
635-
for device in devices # Iterate through devices from the hub
635+
for device in devices.values() # Iterate through devices from the hub
636636
for device_type, entity_descriptions in DEVICE_BINARY_SENSOR_MAP.items()
637637
if isinstance(device, device_type)
638638
for description in entity_descriptions
@@ -648,4 +648,3 @@ async def async_setup_entry(
648648

649649
# Add binary sensor entities to Home Assistant
650650
async_add_entities(entities)
651-

custom_components/petlibro/button.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -484,7 +484,7 @@ async def async_setup_entry(
484484
) -> None:
485485
"""Set up PETLIBRO buttons using config entry."""
486486
# Retrieve the hub from hass.data that was set up in __init__.py
487-
hub = hass.data[DOMAIN].get(entry.entry_id)
487+
hub: PetLibroHub = hass.data[DOMAIN].get(entry.entry_id)
488488

489489
if not hub:
490490
_LOGGER.error("Hub not found for entry: %s", entry.entry_id)
@@ -504,7 +504,7 @@ async def async_setup_entry(
504504
# Create button entities for each device based on the button map
505505
entities = [
506506
PetLibroButtonEntity(device, hub, description)
507-
for device in devices # Iterate through devices from the hub
507+
for device in devices.values() # Iterate through devices from the hub
508508
for device_type, entity_descriptions in DEVICE_BUTTON_MAP.items()
509509
if isinstance(device, device_type)
510510
for description in entity_descriptions

custom_components/petlibro/config_flow.py

Lines changed: 46 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from enum import Enum
77
import logging
88
from typing import Any
9+
from types import MappingProxyType
910

1011
import voluptuous as vol
1112

@@ -23,11 +24,11 @@
2324
DEFAULT_FEED,
2425
DEFAULT_WATER,
2526
DEFAULT_WEIGHT,
26-
MANUAL_FEED_PORTIONS,
2727
DOMAIN,
2828
APIKey as API,
2929
Gender,
3030
Unit,
31+
IntegrationSetting,
3132
)
3233
from .exceptions import PetLibroCannotConnect, PetLibroInvalidAuth
3334
from .hub import PetLibroHub
@@ -201,33 +202,48 @@ async def async_step_integration_settings(
201202
user_input = user_input or {}
202203

203204
if user_input:
204-
manual_feed_portions = user_input.get(MANUAL_FEED_PORTIONS)
205-
current_value = self.entry.options.get(MANUAL_FEED_PORTIONS, False)
206-
205+
# --- Extract updates
206+
updates = self.collect_updates(
207+
fields=IntegrationSetting,
208+
user_input=user_input,
209+
local_data=self.entry.options,
210+
)
211+
207212
# --- Nothing changed
208-
if manual_feed_portions == current_value:
213+
if not updates:
209214
_LOGGER.debug("No integration settings changed.")
210215
return self.async_abort(reason=self.get_common_translation("no_settings_changed", "No settings were changed"))
211216

212-
# --- Update option
213-
self.hub.update_options({MANUAL_FEED_PORTIONS: manual_feed_portions})
214-
_LOGGER.debug("Updated %s to %s", MANUAL_FEED_PORTIONS, manual_feed_portions)
217+
# --- Update options
218+
self.hub.update_options(updates)
219+
_LOGGER.debug("Updated integration settings: %s", updates)
215220

216221
abort_messages = [self.get_common_translation("settings_updated", "Settings updated")]
222+
auto_reload = manual_reload = False
217223

218-
# --- Update entities and warn user if feed unit is cups
219-
if self.member.feedUnitType == Unit.CUPS:
220-
reload_needed = await self.hub.unit_entities.sync_manual_feed_entity_visibility(Unit.CUPS)
221-
222-
if reload_needed:
223-
abort_messages.append(
224-
self.get_common_translation("reloading_integration", "The integration will reload shortly")
225-
)
224+
# --- Update entities and warn user if MANUAL_FEED_PORTIONS was changed and feed unit is cups
225+
if IntegrationSetting.MANUAL_FEED_PORTIONS in updates and self.member.feedUnitType == Unit.CUPS:
226+
if await self.hub.unit_entities.sync_manual_feed_entity_visibility(Unit.CUPS):
226227
_LOGGER.debug("'manual_feed_portions' value changed while feed unit is 'cups', reloading integration.")
228+
auto_reload = True
227229
else:
228230
_LOGGER.debug("No Manual Feed entities found — nothing to reload.")
231+
232+
if IntegrationSetting.ENABLE_SHARED_PETS in updates:
233+
if updates[IntegrationSetting.ENABLE_SHARED_PETS]:
234+
manual_reload = True
235+
else:
236+
await self.hub.pets_helper.remove_shared_pets()
237+
229238
await self.hub.async_refresh()
230239

240+
if auto_reload or manual_reload:
241+
abort_messages.append(
242+
self.get_common_translation("reloading_integration", "The integration will reload shortly")
243+
)
244+
if not auto_reload:
245+
self.hass.config_entries.async_schedule_reload(self.handler)
246+
231247
# --- Done
232248
return self.async_abort(
233249
reason="integration_settings_abort",
@@ -256,12 +272,14 @@ async def async_step_account_settings(
256272
unit_updates = self.collect_updates(
257273
fields=(API.FEED_UNIT, API.WATER_UNIT, API.WEIGHT_UNIT),
258274
user_input=measurement_units_raw,
275+
local_data=self.member,
259276
enum_cls=Unit,
260277
)
261278

262279
info_updates = self.collect_updates(
263280
fields=(API.NICKNAME, API.GENDER),
264281
user_input=account_info_raw,
282+
local_data=self.member,
265283
special={
266284
API.NICKNAME: lambda v: v or "",
267285
API.GENDER: lambda v: self.validate_enum(API.GENDER, v, Gender),
@@ -317,10 +335,11 @@ def _show_integration_settings_form(self, user_input: dict[str, Any]) -> ConfigF
317335
return self.async_show_form(
318336
data_schema=vol.Schema(
319337
{
320-
vol.Optional(
321-
MANUAL_FEED_PORTIONS,
322-
default=self.entry.options.get(MANUAL_FEED_PORTIONS, False),
338+
vol.Required(
339+
setting.value,
340+
default=self.entry.options.get(setting, setting.default),
323341
): selector({"boolean": {}})
342+
for setting in IntegrationSetting
324343
}
325344
),
326345
description_placeholders={
@@ -425,6 +444,7 @@ def collect_updates(
425444
self,
426445
fields: tuple[str, ...],
427446
user_input: dict[str, Any],
447+
local_data: dict[str, Any],
428448
enum_cls: type[Enum] | None = None,
429449
special: dict[str, Callable[[Any], Any]] | None = None,
430450
) -> dict[str, Any]:
@@ -433,10 +453,15 @@ def collect_updates(
433453

434454
for api_key in fields:
435455
form_value = user_input.get(api_key)
436-
current_value = getattr(self.member, api_key, SENTINEL)
456+
457+
current_value = (
458+
local_data.get(api_key, SENTINEL)
459+
if isinstance(local_data, MappingProxyType)
460+
else getattr(local_data, api_key, SENTINEL)
461+
)
437462

438463
if current_value is SENTINEL:
439-
_LOGGER.error("Unsupported API key: %s", api_key)
464+
_LOGGER.warning("Unsupported API key: %s", api_key)
440465
continue
441466
if form_value == current_value:
442467
continue

0 commit comments

Comments
 (0)