"""Pet profiles.""" from __future__ import annotations import logging from dataclasses import dataclass from datetime import date, datetime from enum import Enum, unique from typing import Any, cast from deepdiff import DeepDiff from .event import EVENT_UPDATE, Event from .exceptions import InvalidCommandException from .session import Session from .utils import dig, to_timestamp _LOGGER = logging.getLogger(__name__) PET_PROFILE_ENDPOINT = "https://pet-profile.iothings.site/graphql/" PET_MODEL = """ { petId userId createdAt name type gender weight weightLastUpdated lastWeightReading breeds age birthday adoptionDate s3ImageURL diet isFixed environmentType healthConcerns isHealthy isActive whiskerProducts petTagAssigned { petTag { petTagId } } weightIdFeatureEnabled weightHistory { weight timestamp } weightHistoryErrorType } """ @unique class PetDiet(Enum): """Pet diet.""" WET = "WET_FOOD" DRY = "DRY_FOOD" BOTH = "BOTH" def __str__(self) -> str: """Return str(self).""" return "wet/dry" if self == PetDiet.BOTH else self.name.lower() @unique class PetEnvironment(Enum): """Pet environment.""" INDOOR = "INDOOR" OUTDOOR = "OUTDOOR" BOTH = "BOTH" def __str__(self) -> str: """Return str(self).""" return "indoor/outdoor" if self == PetEnvironment.BOTH else self.name.lower() @unique class PetGender(Enum): """Pet gender.""" FEMALE = "FEMALE" MALE = "MALE" def __str__(self) -> str: """Return str(self).""" return self.name.lower() @unique class PetType(Enum): """Pet type.""" CAT = "CAT" DOG = "DOG" def __str__(self) -> str: """Return str(self).""" return self.name.lower() @dataclass(frozen=True) class WeightMeasurement: """Weight measurement.""" timestamp: datetime weight: float def __str__(self) -> str: """Return self(str).""" return f"{self.timestamp.isoformat()}: {self.weight} lbs" def parse_weight_history( weight_data: list[dict[str, Any]] | None, ) -> list[WeightMeasurement]: """Parse weight history from a list.""" if not weight_data: return [] return [ WeightMeasurement( cast(datetime, to_timestamp(entry["timestamp"])), entry["weight"] ) for entry in weight_data if entry["timestamp"] ] class Pet(Event): """Pet profile.""" def __init__(self, data: dict, session: Session) -> None: """Initialize a pet profile.""" super().__init__() self._data: dict = data self._session: Session = session self._weight_history: list[WeightMeasurement] = [] if weight_data := data.get("weightHistory"): self._weight_history = parse_weight_history(weight_data) def __str__(self) -> str: """Return str(self).""" return f"Name: {self.name}, Gender: {self.gender}, Type: {self.pet_type}, Breed: {self.breeds}, id: {self.id}" def to_dict(self) -> dict[str, Any]: """Return the pet data dict.""" return self._data @property def id(self) -> str: """Return the id of the pet.""" return cast(str, self._data.get("petId")) @property def name(self) -> str: """Return the name of the pet.""" return cast(str, self._data.get("name")) @property def pet_type(self) -> PetType | None: """Return the type of pet.""" if (pet_type := self._data.get("type")) not in PetType.__members__: _LOGGER.error('Unknown pet type "%s"', pet_type) return None return PetType(pet_type) @property def gender(self) -> PetGender | None: """Return the gender.""" if (gender := self._data.get("gender")) not in PetGender.__members__: _LOGGER.error('Unknown gender "%s"', gender) return None return PetGender(gender) @property def estimated_weight(self) -> float: """Return the estimated weight in pounds (lbs).""" return cast(float, self._data.get("weight")) @property def last_weight_reading(self) -> float | None: """Return the last weight reading in pounds (lbs), if any.""" return self._data.get("lastWeightReading") @property def weight(self) -> float: """Return the weight in pounds (lbs).""" return self.last_weight_reading or self.estimated_weight @property def breeds(self) -> list[str] | None: """Return the breeds, if known.""" return self._data.get("breeds") @property def age(self) -> int | None: """Return the age, if known.""" return self._data.get("age") @property def birthday(self) -> date | None: """Return the birthday, if known.""" birthday_str = self._data.get("birthday") return datetime.fromisoformat(birthday_str).date() if birthday_str else None @property def adoption_date(self) -> date | None: """Return the adoption date, if known.""" adoption_date_str = self._data.get("adoptionDate") return ( datetime.fromisoformat(adoption_date_str).date() if adoption_date_str else None ) @property def diet(self) -> PetDiet | None: """Return the diet, if any.""" if (diet := self._data.get("diet")) not in PetDiet.__members__: _LOGGER.error('Unknown diet "%s"', diet) return None return PetDiet(diet) @property def environment_type(self) -> PetEnvironment | None: """Return the environment type, if any.""" if ( environment := self._data.get("environmentType") ) not in PetEnvironment.__members__: _LOGGER.error('Unknown environment type "%s"', environment) return None return PetEnvironment(environment) @property def health_concerns(self) -> list[str] | None: """Return a list of health concerns, if any.""" return self._data.get("healthConcerns") @property def image_url(self) -> str | None: """Return image url, if any.""" return self._data.get("s3ImageURL") @property def is_active(self) -> bool | None: """Return if the pet profile is active.""" return cast(bool, self._data.get("isActive", False)) @property def is_fixed(self) -> bool | None: """Return `True` if the pet is fixed.""" return cast(bool, self._data.get("isFixed", False)) @property def is_healthy(self) -> bool | None: """Return `True` if the pet is healthy.""" return cast(bool, self._data.get("isHealthy", False)) @property def pet_tag_id(self) -> str | None: """Return the pet tag id, if any.""" return cast(str | None, dig(self._data, "petTagAssigned.petTag.petTagId")) @property def weight_id_feature_enabled(self) -> bool: """Return `True` if the weight id feature is enabled.""" return cast(bool, self._data.get("weightIdFeatureEnabled", False)) @property def weight_history(self) -> list[WeightMeasurement]: """Return the weight history for the pet.""" return self._weight_history def get_visits_since(self, start: datetime) -> int: """Return the number of visits (recorded via weight history) since the given datetime.""" return sum(entry.timestamp >= start for entry in self.weight_history) def _update_data(self, data: dict, partial: bool = False) -> None: """Save the pet info from a data dictionary.""" if diff := DeepDiff( self._data, {**self._data, **data} if partial else data, ignore_order=True, report_repetition=True, verbose_level=2, ): _LOGGER.debug("%s updated: %s", self.name, diff) if weight_data := data.get("weightHistory"): self._weight_history = parse_weight_history(weight_data) self._data.update(data) self.emit(EVENT_UPDATE) async def fetch_weight_history(self, limit: int = 50) -> list[WeightMeasurement]: """Fetch a pet's weight history.""" weight_data = await self.query_weight_history(self._session, self.id, limit) self._weight_history = parse_weight_history(weight_data) return self._weight_history async def refresh(self) -> None: """Refresh the data for the pet.""" if data := await self.query_by_id(self._session, self.id): self._update_data(data) @classmethod async def fetch_pets_for_user(cls, session: Session, user_id: str) -> list[Pet]: """Fetch pets for a user.""" if pets_data := await cls.query_by_user(session, user_id): return [cls(pet_data, session) for pet_data in pets_data] return [] @classmethod async def fetch_pet_by_id(cls, session: Session, pet_id: str) -> Pet | None: """Fetch a pet by id.""" if pet_data := await cls.query_by_id(session, pet_id): return cls(pet_data, session) return None @staticmethod async def query_by_user(session: Session, user_id: str) -> list[dict]: """Query pets for a user.""" query = f""" query GetPetsByUser($userId: String!) {{ getPetsByUser(userId: $userId ) {PET_MODEL} }} """ variables = {"userId": user_id} res = cast(dict, await Pet.query_graphql_api(session, query, variables)) return cast(list[dict], dig(res, "data.getPetsByUser") or []) @staticmethod async def query_by_id(session: Session, pet_id: str) -> dict | None: """Query a pet by id.""" query = f""" query GetPetByPetId($petId: String!) {{ getPetByPetId(petId: $petId ) {PET_MODEL} }} """ variables = {"petId": pet_id} res = cast(dict, await Pet.query_graphql_api(session, query, variables)) return cast(dict | None, dig(res, "data.getPetByPetId")) @staticmethod async def query_weight_history( session: Session, pet_id: str, limit: int = 50 ) -> list[dict]: """Query a pet's weight history.""" if limit < 1: raise InvalidCommandException( f"Invalid range for parameter limit, value: {limit}, valid range: 1-inf" ) query = """ query GetWeightHistoryByPetId($petId: String!, $limit: Int) { getWeightHistoryByPetId(petId: $petId, limit: $limit) { weight timestamp } } """ variables = {"petId": pet_id, "limit": limit} res = cast(dict, await Pet.query_graphql_api(session, query, variables)) return cast(list[dict], dig(res, "data.getWeightHistoryByPetId") or []) @staticmethod async def query_graphql_api( session: Session, query: str, variables: dict | None = None, endpoint: str = PET_PROFILE_ENDPOINT, ) -> dict | list[dict] | None: """Query GraphQL API.""" return await session.post( endpoint, json={"query": query, "variables": variables} )