diff --git a/pytryfi/__init__.py b/pytryfi/__init__.py index ad8da95..bc408e5 100644 --- a/pytryfi/__init__.py +++ b/pytryfi/__init__.py @@ -1,12 +1,12 @@ import logging import requests -from pytryfi.const import (API_HOST_URL_BASE, API_LOGIN, PYTRYFI_VERSION, HEADER) -from pytryfi.fiUser import FiUser -from pytryfi.fiPet import FiPet -from pytryfi.fiBase import FiBase -from pytryfi.common import query -from pytryfi.const import SENTRY_URL +from .const import (API_HOST_URL_BASE, API_LOGIN, PYTRYFI_VERSION, HEADER) +from .fiUser import FiUser +from .fiPet import FiPet +from .fiBase import FiBase +from .common import query +from .const import SENTRY_URL import sentry_sdk from sentry_sdk import capture_message, capture_exception @@ -18,6 +18,10 @@ class PyTryFi(object): """base object for TryFi""" def __init__(self, username=None, password=None): + # Initialize empty lists first to avoid AttributeError + self._pets = [] + self._bases = [] + try: sentry = sentry_sdk.init( SENTRY_URL, @@ -37,7 +41,6 @@ def __init__(self, username=None, password=None): petListJSON = query.getPetList(self._session) h = 0 - self._pets = [] for house in petListJSON: for pet in petListJSON[h]['household']['pets']: #If pet doesn't have a collar then ignore it. What good is a pet without a collar! @@ -53,13 +56,18 @@ def __init__(self, username=None, password=None): #get the daily, weekly and monthly rest stats and set pRestStatsJSON = query.getCurrentPetRestStats(self._session,p._petId) p.setRestStats(pRestStatsJSON['dailyStat'],pRestStatsJSON['weeklyStat'],pRestStatsJSON['monthlyStat']) + #get behavior stats if available (Series 3+ only) - Commented until API supports it + # try: + # pBehaviorStatsJSON = query.getCurrentPetBehaviorStats(self._session,p._petId) + # p.setBehaviorStats(pBehaviorStatsJSON) + # except Exception as e: + # LOGGER.debug(f"Behavior stats not available for {p._name} - likely not a Series 3+ collar") LOGGER.debug(f"Adding Pet: {p._name} with Device: {p._device._deviceId}") self._pets.append(p) else: LOGGER.warning(f"Pet {pet['name']} - {pet['id']} has no collar. Ignoring Pet!") h = h + 1 - self._bases = [] baseListJSON = query.getBaseList(self._session) h = 0 for house in baseListJSON: @@ -105,8 +113,16 @@ def updatePets(self): #get the daily, weekly and monthly rest stats and set pRestStatsJSON = query.getCurrentPetRestStats(self._session,p._petId) p.setRestStats(pRestStatsJSON['dailyStat'],pRestStatsJSON['weeklyStat'],pRestStatsJSON['monthlyStat']) - LOGGER.debug(f"Adding Pet: {p._name} with Device: {p._device._deviceId}") - updatedPets.append(p) + #get behavior stats if available - Now supported via health trends API + try: + p.updateBehaviorStats(self._session) + except Exception as e: + LOGGER.debug(f"Behavior stats not available for {p._name}: {e}") + if p._device: + LOGGER.debug(f"Adding Pet: {p._name} with Device: {p._device._deviceId}") + updatedPets.append(p) + else: + LOGGER.info(f"Skipping Pet: {p._name} - no device assigned") self._pets = updatedPets except Exception as e: capture_exception(e) @@ -220,9 +236,17 @@ def login(self): #storing cookies but don't need them. Handled by session mgmt self._cookies = response.cookies #store unique userId from login for future use - self._userId = response.json()['userId'] - self._sessionId = response.json()['sessionId'] - LOGGER.debug(f"Successfully logged in. UserId: {self._userId}") + try: + login_data = response.json() + self._userId = login_data.get('userId') + self._sessionId = login_data.get('sessionId') + if not self._userId or not self._sessionId: + LOGGER.error(f"Login response missing userId or sessionId: {login_data}") + raise Exception("Invalid login response - missing userId or sessionId") + LOGGER.debug(f"Successfully logged in. UserId: {self._userId}") + except (KeyError, json.JSONDecodeError) as e: + LOGGER.error(f"Failed to parse login response: {response.text}") + raise Exception(f"Failed to parse login response: {e}") except requests.RequestException as e: LOGGER.error(f"Cannot login, error: ({e})") raise e diff --git a/pytryfi/common/query.py b/pytryfi/common/query.py index 1fe8034..7cd4905 100644 --- a/pytryfi/common/query.py +++ b/pytryfi/common/query.py @@ -1,5 +1,5 @@ -from pytryfi.const import * -from pytryfi.exceptions import * +from ..const import * +from ..exceptions import * import json import logging import requests @@ -53,6 +53,21 @@ def getCurrentPetRestStats(sessionId, petId): LOGGER.debug(f"getCurrentPetStats: {response}") return response['data']['pet'] +def getPetHealthTrends(sessionId, petId, period='DAY'): + """Get pet health trends including behavior data""" + if period == 'DAY': + qString = QUERY_PET_HEALTH_TRENDS_DAILY.replace(VAR_PET_ID, petId) + FRAGMENT_PET_HEALTH_TREND_DETAILS + elif period == 'WEEK': + qString = QUERY_PET_HEALTH_TRENDS_WEEKLY.replace(VAR_PET_ID, petId) + FRAGMENT_PET_HEALTH_TREND_DETAILS + elif period == 'MONTH': + qString = QUERY_PET_HEALTH_TRENDS_MONTHLY.replace(VAR_PET_ID, petId) + FRAGMENT_PET_HEALTH_TREND_DETAILS + else: + raise ValueError(f"Invalid period: {period}") + + response = query(sessionId, qString) + LOGGER.debug(f"getPetHealthTrends: {response}") + return response['data']['getPetHealthTrendsForPet'] + def getDevicedetails(sessionId, petId): qString = QUERY_PET_DEVICE_DETAILS.replace(VAR_PET_ID, petId) + FRAGMENT_PET_PROFILE + FRAGEMENT_BASE_PET_PROFILE + FRAGMENT_DEVICE_DETAILS + FRAGMENT_LED_DETAILS + FRAGMENT_OPERATIONAL_DETAILS + FRAGMENT_CONNECTION_STATE_DETAILS + FRAGMENT_USER_DETAILS + FRAGMENT_BREED_DETAILS + FRAGMENT_PHOTO_DETAILS response = query(sessionId, qString) @@ -92,14 +107,53 @@ def mutation(sessionId, qString, qVariables): url = getGraphqlURL() params = {"query": qString, "variables": json.loads(qVariables)} - jsonObject = execute(url, sessionId, params=params, method='POST').json() + response = execute(url, sessionId, params=params, method='POST') + + # Check HTTP status + if response.status_code != 200: + LOGGER.error(f"GraphQL mutation failed with status {response.status_code}: {response.text}") + raise TryFiError(f"GraphQL mutation failed with status {response.status_code}") + + try: + jsonObject = response.json() + except json.JSONDecodeError as e: + LOGGER.error(f"Failed to decode JSON response: {response.text}") + raise TryFiError(f"Invalid JSON response from API: {e}") + + # Check for GraphQL errors + if 'errors' in jsonObject: + LOGGER.error(f"GraphQL errors: {jsonObject['errors']}") + raise TryFiError(f"GraphQL errors: {jsonObject['errors']}") + return jsonObject def query(sessionId : requests.Session, qString): jsonObject = None url = getGraphqlURL() params={'query': qString} - jsonObject = execute(url, sessionId, params=params).json() + response = execute(url, sessionId, params=params) + + # Check HTTP status + if response.status_code != 200: + LOGGER.error(f"GraphQL query failed with status {response.status_code}: {response.text}") + raise TryFiError(f"GraphQL query failed with status {response.status_code}") + + try: + jsonObject = response.json() + except json.JSONDecodeError as e: + LOGGER.error(f"Failed to decode JSON response: {response.text}") + raise TryFiError(f"Invalid JSON response from API: {e}") + + # Check for GraphQL errors + if 'errors' in jsonObject: + LOGGER.error(f"GraphQL errors: {jsonObject['errors']}") + raise TryFiError(f"GraphQL errors: {jsonObject['errors']}") + + # Check if data exists + if 'data' not in jsonObject: + LOGGER.error(f"No data in GraphQL response: {jsonObject}") + raise TryFiError("No data in GraphQL response") + return jsonObject def execute(url : str, sessionId : requests.Session, method: Literal['GET', 'POST'] = 'GET', params=None, cookies=None): diff --git a/pytryfi/common/response_handlers.py b/pytryfi/common/response_handlers.py new file mode 100644 index 0000000..b9d51a6 --- /dev/null +++ b/pytryfi/common/response_handlers.py @@ -0,0 +1,4 @@ +import datetime + +def parse_fi_date(input: str) -> datetime.datetime: + return datetime.datetime.fromisoformat(input.replace('Z', '+00:00')) \ No newline at end of file diff --git a/pytryfi/const.py b/pytryfi/const.py index b4cd40c..f4250e4 100644 --- a/pytryfi/const.py +++ b/pytryfi/const.py @@ -21,6 +21,9 @@ QUERY_PET_CURRENT_LOCATION = "query { pet (id: \""+VAR_PET_ID+"\") { ongoingActivity { __typename ...OngoingActivityDetails } }}" QUERY_PET_ACTIVITY = "query { pet (id: \""+VAR_PET_ID+"\") { dailyStat: currentActivitySummary (period: DAILY) { ...ActivitySummaryDetails } weeklyStat: currentActivitySummary (period: WEEKLY) { ...ActivitySummaryDetails } monthlyStat: currentActivitySummary (period: MONTHLY) { ...ActivitySummaryDetails } }}" QUERY_PET_REST = "query { pet (id: \""+VAR_PET_ID+"\") { dailyStat: restSummaryFeed(cursor: null, period: DAILY, limit: 1) { __typename restSummaries { __typename ...RestSummaryDetails } } weeklyStat: restSummaryFeed(cursor: null, period: WEEKLY, limit: 1) { __typename restSummaries { __typename ...RestSummaryDetails } } monthlyStat: restSummaryFeed(cursor: null, period: MONTHLY, limit: 1) { __typename restSummaries { __typename ...RestSummaryDetails } } }}" +QUERY_PET_HEALTH_TRENDS_DAILY = "query { getPetHealthTrendsForPet(petId: \""+VAR_PET_ID+"\", period: DAY) { __typename behaviorTrends { __typename ...PetHealthTrendDetails } }}" +QUERY_PET_HEALTH_TRENDS_WEEKLY = "query { getPetHealthTrendsForPet(petId: \""+VAR_PET_ID+"\", period: WEEK) { __typename behaviorTrends { __typename ...PetHealthTrendDetails } }}" +QUERY_PET_HEALTH_TRENDS_MONTHLY = "query { getPetHealthTrendsForPet(petId: \""+VAR_PET_ID+"\", period: MONTH) { __typename behaviorTrends { __typename ...PetHealthTrendDetails } }}" QUERY_PET_DEVICE_DETAILS = "query { pet (id: \""+VAR_PET_ID+"\") { __typename ...PetProfile }}" FRAGMENT_USER_DETAILS = "fragment UserDetails on User { __typename id email firstName lastName phoneNumber fiNewsNotificationsEnabled chipReseller { __typename id }}" @@ -29,7 +32,7 @@ FRAGMENT_BREED_DETAILS = "fragment BreedDetails on Breed { __typename id name popularityScore}" FRAGMENT_PHOTO_DETAILS = "fragment PhotoDetails on Photo { __typename id caption date likeCount liked image { __typename fullSize }}" FRAGMENT_PET_PROFILE = "fragment PetProfile on Pet { __typename ...BasePetProfile chip { __typename shortId } device { __typename ...DeviceDetails }}" -FRAGMENT_DEVICE_DETAILS = "fragment DeviceDetails on Device { __typename id moduleId info subscriptionId hasActiveSubscription hasSubscriptionOverride nextLocationUpdateExpectedBy operationParams { __typename ...OperationParamsDetails } lastConnectionState { __typename ...ConnectionStateDetails } ledColor { __typename ...LedColorDetails } availableLedColors { __typename ...LedColorDetails }}" +FRAGMENT_DEVICE_DETAILS = "fragment DeviceDetails on Device { __typename id moduleId info subscriptionId hasActiveSubscription hasSubscriptionOverride nextLocationUpdateExpectedBy operationParams { __typename ...OperationParamsDetails } lastConnectionState { __typename ...ConnectionStateDetails } ledColor { __typename ...LedColorDetails } availableLedColors { __typename ...LedColorDetails } hardwareRevision}" FRAGMENT_LED_DETAILS = "fragment LedColorDetails on LedColor { __typename ledColorCode hexCode name}" FRAGMENT_CONNECTION_STATE_DETAILS = "fragment ConnectionStateDetails on ConnectionState { __typename date ... on ConnectedToUser { user { __typename ...UserDetails } } ... on ConnectedToBase { chargingBase { __typename id } } ... on ConnectedToCellular { signalStrengthPercent } ... on UnknownConnectivity { unknownConnectivity }}" FRAGMENT_OPERATIONAL_DETAILS = "fragment OperationParamsDetails on OperationParams { __typename mode ledEnabled ledOffAt}" @@ -42,5 +45,6 @@ FRAGMENT_PLACE_DETAILS = "fragment PlaceDetails on Place { __typename id name address position { __typename ...PositionCoordinates } radius}" FRAGMENT_ACTIVITY_SUMMARY_DETAILS = "fragment ActivitySummaryDetails on ActivitySummary { __typename start end totalSteps stepGoal dailySteps { __typename date totalSteps stepGoal } totalDistance}" FRAGMENT_REST_SUMMARY_DETAILS = "fragment RestSummaryDetails on RestSummary { __typename start end data { __typename ... on ConcreteRestSummaryData { sleepAmounts { __typename type duration } } }}" +FRAGMENT_PET_HEALTH_TREND_DETAILS = "fragment PetHealthTrendDetails on PetHealthTrend { __typename id title summaryComponents { __typename eventsSummary durationSummary }}" MUTATION_DEVICE_OPS = "mutation UpdateDeviceOperationParams($input: UpdateDeviceOperationParamsInput!) { updateDeviceOperationParams(input: $input) { __typename ...DeviceDetails }}" MUTATION_SET_LED_COLOR = "mutation SetDeviceLed($moduleId: String!, $ledColorCode: Int!) { setDeviceLed(moduleId: $moduleId, ledColorCode: $ledColorCode) { __typename ...DeviceDetails }}" diff --git a/pytryfi/exceptions.py b/pytryfi/exceptions.py index cbe3aa8..5d99954 100644 --- a/pytryfi/exceptions.py +++ b/pytryfi/exceptions.py @@ -1,6 +1,11 @@ class Error(Exception): """base exception class""" -class TryFiError(Error): +class TryFiError(Exception): """Generic error for TryFi""" - \ No newline at end of file + +class RemoteApiError(TryFiError): + """tryfi.com returned an unexpected result""" + +class ApiNotAuthorizedError(TryFiError): + """tryfi.com reports not authorized""" \ No newline at end of file diff --git a/pytryfi/fiBase.py b/pytryfi/fiBase.py index 37e74f3..dae5fac 100644 --- a/pytryfi/fiBase.py +++ b/pytryfi/fiBase.py @@ -1,22 +1,18 @@ import datetime -from sentry_sdk import capture_exception class FiBase(object): def __init__(self, baseId): self._baseId = baseId def setBaseDetailsJSON(self, baseJSON): - try: - self._name = baseJSON['name'] - self._latitude = baseJSON['position']['latitude'] - self._longitude = baseJSON['position']['longitude'] - self._online = baseJSON['online'] - self._onlineQuality = baseJSON['onlineQuality'] - self._lastUpdated = baseJSON['infoLastUpdated'] - self._networkName = baseJSON['networkName'] - self._lastUpdated = datetime.datetime.now() - except Exception as e: - capture_exception(e) + self._name = baseJSON['name'] + self._latitude = baseJSON['position']['latitude'] + self._longitude = baseJSON['position']['longitude'] + self._online = baseJSON['online'] + self._onlineQuality = baseJSON['onlineQuality'] + self._lastUpdated = baseJSON['infoLastUpdated'] + self._networkName = baseJSON['networkName'] + self._lastUpdated = datetime.datetime.now() def __str__(self): return f"Last Updated - {self.lastUpdated} - Base ID: {self.baseId} Name: {self.name} Online Status: {self.online} Wifi Network: {self.networkname} Located: {self.latitude},{self.longitude}" diff --git a/pytryfi/fiDevice.py b/pytryfi/fiDevice.py index 8574e5c..8abdc24 100644 --- a/pytryfi/fiDevice.py +++ b/pytryfi/fiDevice.py @@ -1,7 +1,9 @@ import logging import datetime -from pytryfi.ledColors import ledColors -from pytryfi.const import PET_MODE_NORMAL, PET_MODE_LOST +import json +from .ledColors import ledColors +from .const import PET_MODE_NORMAL, PET_MODE_LOST +from sentry_sdk import capture_exception LOGGER = logging.getLogger(__name__) @@ -11,6 +13,10 @@ def __init__(self, deviceId): def setDeviceDetailsJSON(self, deviceJSON): try: + # Log the raw device JSON to see what fields are available + LOGGER.info(f"TryFi Device {deviceJSON.get('moduleId', 'unknown')} info field contains: {list(deviceJSON.get('info', {}).keys())}") + LOGGER.info(f"TryFi Device {deviceJSON.get('moduleId', 'unknown')} root fields: {list(deviceJSON.keys())}") + self._moduleId = deviceJSON['moduleId'] self._buildId = deviceJSON['info']['buildId'] self._batteryPercent = int(deviceJSON['info']['batteryPercent']) @@ -34,6 +40,36 @@ def setDeviceDetailsJSON(self, deviceJSON): for cString in deviceJSON['availableLedColors']: c = ledColors(int(cString['ledColorCode']),cString['hexCode'], cString['name'] ) self._availableLedColors.append(c) + + # Enhanced device fields (may not be available on older collars) + self._hardwareRevision = deviceJSON.get('hardwareRevision', 'Unknown') + + # Extract additional info fields if available + info = deviceJSON.get('info', {}) + self._batteryVoltage = info.get('batteryVoltage', None) + self._temperature = info.get('temperature', None) + self._uptime = info.get('uptime', None) + + # Log other available fields for investigation + LOGGER.info(f"TryFi Device - wifiNetworkNames: {info.get('wifiNetworkNames', 'N/A')}") + LOGGER.info(f"TryFi Device - capabilityFlags: {info.get('capabilityFlags', 'N/A')}") + LOGGER.info(f"TryFi Device - deviceStats: {info.get('deviceStats', 'N/A')}") + LOGGER.info(f"TryFi Device - selfTestResults: {info.get('selfTestResults', 'N/A')}") + LOGGER.info(f"TryFi Device - wallClockSec: {info.get('wallClockSec', 'N/A')}") + LOGGER.info(f"TryFi Device - batteryResistanceProfile: {info.get('batteryResistanceProfile', 'N/A')}") + + # Extract more useful fields + self._wifiNetworkNames = info.get('wifiNetworkNames', []) + self._capabilityFlags = info.get('capabilityFlags', {}) + self._deviceStats = info.get('deviceStats', {}) + self._selfTestResults = info.get('selfTestResults', {}) + self._wallClockSec = info.get('wallClockSec', None) + + # Try to get signal strength from connection state if available + if 'lastConnectionState' in deviceJSON and deviceJSON['lastConnectionState'].get('__typename') == 'ConnectedToCellular': + self._signalStrength = deviceJSON['lastConnectionState'].get('signalStrengthPercent', None) + else: + self._signalStrength = None except Exception as e: LOGGER.debug(f"tryfi Error: {e}") capture_exception(e) @@ -93,6 +129,44 @@ def isLost(self): return True else: return False + @property + def hardwareRevision(self): + return self._hardwareRevision + @property + def signalStrength(self): + return self._signalStrength + + @property + def batteryVoltage(self): + return self._batteryVoltage + + @property + def temperature(self): + return self._temperature + + @property + def uptime(self): + return self._uptime + + @property + def wifiNetworkNames(self): + return self._wifiNetworkNames + + @property + def capabilityFlags(self): + return self._capabilityFlags + + @property + def deviceStats(self): + return self._deviceStats + + @property + def selfTestResults(self): + return self._selfTestResults + + @property + def wallClockSec(self): + return self._wallClockSec #This is created because if TryFi automatically turns off the LED, the status is still set to true in the api. #This will compare the dates to see if the current date/time is greater than the turnoffat time in the api. diff --git a/pytryfi/fiPet.py b/pytryfi/fiPet.py index 2e7c4a7..ec19c49 100644 --- a/pytryfi/fiPet.py +++ b/pytryfi/fiPet.py @@ -1,9 +1,9 @@ import datetime import logging -from pytryfi.common import query -from pytryfi.const import PET_ACTIVITY_ONGOINGWALK -from pytryfi.exceptions import * -from pytryfi.fiDevice import FiDevice +from .common import query +from .const import PET_ACTIVITY_ONGOINGWALK +from .exceptions import * +from .fiDevice import FiDevice from sentry_sdk import capture_exception LOGGER = logging.getLogger(__name__) @@ -45,9 +45,51 @@ def setPetDetailsJSON(self, petJSON: dict): #capture_exception(e) LOGGER.warning(f"Cannot find photo of your pet. Defaulting to empty string.") self._photoLink = "" - self._device = FiDevice(petJSON['device']['id']) - self._device.setDeviceDetailsJSON(petJSON['device']) - self._connectedTo = self.setConnectedTo(petJSON['device']['lastConnectionState']) + # Check if pet has a device + if petJSON.get('device'): + self._device = FiDevice(petJSON['device']['id']) + self._device.setDeviceDetailsJSON(petJSON['device']) + self._connectedTo = self.setConnectedTo(petJSON['device']['lastConnectionState']) + else: + self._device = None + self._connectedTo = None + LOGGER.info(f"Pet {self._name} has no device assigned") + + # Initialize behavior metrics to 0 (will be updated later if available) + self._dailyBarkingCount = 0 + self._dailyBarkingDuration = 0 + self._weeklyBarkingCount = 0 + self._weeklyBarkingDuration = 0 + self._monthlyBarkingCount = 0 + self._monthlyBarkingDuration = 0 + + self._dailyLickingCount = 0 + self._dailyLickingDuration = 0 + self._weeklyLickingCount = 0 + self._weeklyLickingDuration = 0 + self._monthlyLickingCount = 0 + self._monthlyLickingDuration = 0 + + self._dailyScratchingCount = 0 + self._dailyScratchingDuration = 0 + self._weeklyScratchingCount = 0 + self._weeklyScratchingDuration = 0 + self._monthlyScratchingCount = 0 + self._monthlyScratchingDuration = 0 + + self._dailyEatingCount = 0 + self._dailyEatingDuration = 0 + self._weeklyEatingCount = 0 + self._weeklyEatingDuration = 0 + self._monthlyEatingCount = 0 + self._monthlyEatingDuration = 0 + + self._dailyDrinkingCount = 0 + self._dailyDrinkingDuration = 0 + self._weeklyDrinkingCount = 0 + self._weeklyDrinkingDuration = 0 + self._monthlyDrinkingCount = 0 + self._monthlyDrinkingDuration = 0 def __str__(self): return f"Last Updated - {self.lastUpdated} - Pet ID: {self.petId} Name: {self.name} Is Lost: {self.isLost} From: {self.homeCityState} ActivityType: {self.activityType} Located: {self.currLatitude},{self.currLongitude} Last Updated: {self.currStartTime}\n \ @@ -111,6 +153,102 @@ def setStats(self, activityJSONDaily, activityJSONWeekly, activityJSONMonthly): self._lastUpdated = datetime.datetime.now() + # set the Pet's behavior stats for daily, weekly and monthly + def setBehaviorStats(self, behaviorTrends): + # Initialize all behavior metrics to 0 as default + self._dailyBarkingCount = 0 + self._dailyBarkingDuration = 0 + self._weeklyBarkingCount = 0 + self._weeklyBarkingDuration = 0 + self._monthlyBarkingCount = 0 + self._monthlyBarkingDuration = 0 + + self._dailyLickingCount = 0 + self._dailyLickingDuration = 0 + self._weeklyLickingCount = 0 + self._weeklyLickingDuration = 0 + self._monthlyLickingCount = 0 + self._monthlyLickingDuration = 0 + + self._dailyScratchingCount = 0 + self._dailyScratchingDuration = 0 + self._weeklyScratchingCount = 0 + self._weeklyScratchingDuration = 0 + self._monthlyScratchingCount = 0 + self._monthlyScratchingDuration = 0 + + self._dailyEatingCount = 0 + self._dailyEatingDuration = 0 + self._weeklyEatingCount = 0 + self._weeklyEatingDuration = 0 + self._monthlyEatingCount = 0 + self._monthlyEatingDuration = 0 + + self._dailyDrinkingCount = 0 + self._dailyDrinkingDuration = 0 + self._weeklyDrinkingCount = 0 + self._weeklyDrinkingDuration = 0 + self._monthlyDrinkingCount = 0 + self._monthlyDrinkingDuration = 0 + + try: + # Parse the new health trends API format + for trend in behaviorTrends: + if not isinstance(trend, dict): + continue + + trend_id = trend.get('id', '') + summary = trend.get('summaryComponents', {}) + + # Extract events count from eventsSummary (e.g., "10 events") + events_summary = summary.get('eventsSummary', '0 events') + events_count = 0 + if 'event' in events_summary: + try: + events_count = int(events_summary.split()[0]) + except: + pass + + # Extract duration from durationSummary (e.g., "7m" or "1h 5m") + duration_summary = summary.get('durationSummary', '0m') + duration_seconds = 0 + try: + # Handle "<1m" case + if duration_summary.startswith('<'): + duration_seconds = 30 # Assume 30 seconds for <1m + else: + # Parse hours and minutes + parts = duration_summary.replace('h', '').replace('m', '').split() + if len(parts) == 2: # "1h 5m" format + duration_seconds = int(parts[0]) * 3600 + int(parts[1]) * 60 + elif 'h' in duration_summary: # "1h" format + duration_seconds = int(parts[0]) * 3600 + else: # "5m" format + duration_seconds = int(parts[0]) * 60 + except: + pass + + # Map trend IDs to our attributes (daily stats only for now) + if trend_id == 'barking:DAY': + self._dailyBarkingCount = events_count + self._dailyBarkingDuration = duration_seconds + elif trend_id == 'cleaning_self:DAY': # This is licking + self._dailyLickingCount = events_count + self._dailyLickingDuration = duration_seconds + elif trend_id == 'scratching:DAY': + self._dailyScratchingCount = events_count + self._dailyScratchingDuration = duration_seconds + elif trend_id == 'eating:DAY': + self._dailyEatingCount = events_count + self._dailyEatingDuration = duration_seconds + elif trend_id == 'drinking:DAY': + self._dailyDrinkingCount = events_count + self._dailyDrinkingDuration = duration_seconds + + except Exception as e: + LOGGER.warning(f"Unable to parse behavior metrics for Pet {self.name}. This may be an older collar model.\nException: {e}") + capture_exception(e) + # set the Pet's current rest details for daily, weekly and monthly def setRestStats(self, restJSONDaily, restJSONWeekly, restJSONMonthly): #setting default values to zero in case this feature is not supported by older collars @@ -177,6 +315,19 @@ def updateRestStats(self, sessionId): LOGGER.error(f"Could not update rest stats for Pet {self.name}.\n{e}") capture_exception(e) + # Update the behavior stats of the pet + def updateBehaviorStats(self, sessionId): + try: + # Get health trends which include behavior data + healthTrendsJSON = query.getPetHealthTrends(sessionId, self.petId, 'DAY') + behavior_trends = healthTrendsJSON.get('behaviorTrends', []) + self.setBehaviorStats(behavior_trends) + return True + except Exception as e: + LOGGER.warning(f"Could not update behavior stats for Pet {self.name}. This may be an older collar model.\n{e}") + # Don't capture exception for behavior stats as they may not be available on older collars + return False + # Update the Pet's GPS location def updatePetLocation(self, sessionId): try: @@ -205,6 +356,7 @@ def updateAllDetails(self, sessionId): self.updatePetLocation(sessionId) self.updateStats(sessionId) self.updateRestStats(sessionId) + self.updateBehaviorStats(sessionId) # Now supported via health trends API # set the color code of the led light on the pet collar def setLedColorCode(self, sessionId, colorCode): @@ -349,6 +501,106 @@ def weeklyNap(self): @property def monthlyNap(self): return self._monthlyNap + + # Barking properties + @property + def dailyBarkingCount(self): + return self._dailyBarkingCount + @property + def dailyBarkingDuration(self): + return self._dailyBarkingDuration + @property + def weeklyBarkingCount(self): + return self._weeklyBarkingCount + @property + def weeklyBarkingDuration(self): + return self._weeklyBarkingDuration + @property + def monthlyBarkingCount(self): + return self._monthlyBarkingCount + @property + def monthlyBarkingDuration(self): + return self._monthlyBarkingDuration + + # Licking properties + @property + def dailyLickingCount(self): + return self._dailyLickingCount + @property + def dailyLickingDuration(self): + return self._dailyLickingDuration + @property + def weeklyLickingCount(self): + return self._weeklyLickingCount + @property + def weeklyLickingDuration(self): + return self._weeklyLickingDuration + @property + def monthlyLickingCount(self): + return self._monthlyLickingCount + @property + def monthlyLickingDuration(self): + return self._monthlyLickingDuration + + # Scratching properties + @property + def dailyScratchingCount(self): + return self._dailyScratchingCount + @property + def dailyScratchingDuration(self): + return self._dailyScratchingDuration + @property + def weeklyScratchingCount(self): + return self._weeklyScratchingCount + @property + def weeklyScratchingDuration(self): + return self._weeklyScratchingDuration + @property + def monthlyScratchingCount(self): + return self._monthlyScratchingCount + @property + def monthlyScratchingDuration(self): + return self._monthlyScratchingDuration + + # Eating properties + @property + def dailyEatingCount(self): + return self._dailyEatingCount + @property + def dailyEatingDuration(self): + return self._dailyEatingDuration + @property + def weeklyEatingCount(self): + return self._weeklyEatingCount + @property + def weeklyEatingDuration(self): + return self._weeklyEatingDuration + @property + def monthlyEatingCount(self): + return self._monthlyEatingCount + @property + def monthlyEatingDuration(self): + return self._monthlyEatingDuration + + # Drinking properties + @property + def dailyDrinkingCount(self): + return self._dailyDrinkingCount + @property + def dailyDrinkingDuration(self): + return self._dailyDrinkingDuration + @property + def weeklyDrinkingCount(self): + return self._weeklyDrinkingCount + @property + def weeklyDrinkingDuration(self): + return self._weeklyDrinkingDuration + @property + def monthlyDrinkingCount(self): + return self._monthlyDrinkingCount + @property + def monthlyDrinkingDuration(self): + return self._monthlyDrinkingDuration @property def lastUpdated(self): diff --git a/pytryfi/fiUser.py b/pytryfi/fiUser.py index 0ee4f56..bacb9ac 100644 --- a/pytryfi/fiUser.py +++ b/pytryfi/fiUser.py @@ -1,21 +1,17 @@ -from pytryfi.common import query +from .common import query import datetime -from sentry_sdk import capture_exception class FiUser(object): def __init__(self, userId): self._userId = userId def setUserDetails(self, sessionId): - try: - response = query.getUserDetail(sessionId) - self._email = response['email'] - self._firstName = response['firstName'] - self._lastName = response['lastName'] - self._phoneNumber = response['phoneNumber'] - self._lastUpdated = datetime.datetime.now() - except Exception as e: - capture_exception(e) + response = query.getUserDetail(sessionId) + self._email = response['email'] + self._firstName = response['firstName'] + self._lastName = response['lastName'] + self._phoneNumber = response['phoneNumber'] + self._lastUpdated = datetime.datetime.now() def __str__(self): return f"User ID: {self.userId} Name: {self.fullName} Email: {self.email}"