From 7bc038d46f581bf6d8bb90bcc4bd3bcb4d09be75 Mon Sep 17 00:00:00 2001 From: Adam Jacob Muller Date: Mon, 4 Aug 2025 21:55:58 -0400 Subject: [PATCH 1/3] Add support for TryFi Series 3+ behavior metrics and enhanced device info - Add behavior tracking for barking, licking, scratching, eating, and drinking - Add daily, weekly, and monthly metrics for each behavior type - Add enhanced device fields: firmware/hardware version, GPS accuracy, signal info - Add backward compatibility for older collar models - Update GraphQL fragments and queries for new data - Initialize behavior metrics to 0 for all pets Fixes #7 --- pytryfi/__init__.py | 12 ++ pytryfi/common/query.py | 6 + pytryfi/const.py | 4 +- pytryfi/fiDevice.py | 23 ++++ pytryfi/fiPet.py | 249 ++++++++++++++++++++++++++++++++++++++++ 5 files changed, 293 insertions(+), 1 deletion(-) diff --git a/pytryfi/__init__.py b/pytryfi/__init__.py index ad8da95..1b29889 100644 --- a/pytryfi/__init__.py +++ b/pytryfi/__init__.py @@ -53,6 +53,12 @@ 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) + 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: @@ -105,6 +111,12 @@ 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']) + #get behavior stats if available (Series 3+ only) + 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}") updatedPets.append(p) self._pets = updatedPets diff --git a/pytryfi/common/query.py b/pytryfi/common/query.py index 1fe8034..c6aa5fd 100644 --- a/pytryfi/common/query.py +++ b/pytryfi/common/query.py @@ -53,6 +53,12 @@ def getCurrentPetRestStats(sessionId, petId): LOGGER.debug(f"getCurrentPetStats: {response}") return response['data']['pet'] +def getCurrentPetBehaviorStats(sessionId, petId): + qString = QUERY_PET_BEHAVIOR.replace(VAR_PET_ID, petId) + FRAGMENT_BEHAVIOR_DETAILS + response = query(sessionId, qString) + LOGGER.debug(f"getCurrentPetBehaviorStats: {response}") + return response['data']['pet'] + 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) diff --git a/pytryfi/const.py b/pytryfi/const.py index b4cd40c..1a4cf18 100644 --- a/pytryfi/const.py +++ b/pytryfi/const.py @@ -21,6 +21,7 @@ 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_BEHAVIOR = "query { pet (id: \""+VAR_PET_ID+"\") { __typename ...BehaviorDetails }}" 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 +30,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 } firmwareVersion hardwareVersion gpsAccuracy signalStrength signalType}" 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 +43,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_BEHAVIOR_DETAILS = "fragment BehaviorDetails on Pet { __typename behaviorMetrics { __typename dailyBarking: barking(period: DAILY) { __typename count duration lastDetected } weeklyBarking: barking(period: WEEKLY) { __typename count duration lastDetected } monthlyBarking: barking(period: MONTHLY) { __typename count duration lastDetected } dailyLicking: licking(period: DAILY) { __typename count duration lastDetected } weeklyLicking: licking(period: WEEKLY) { __typename count duration lastDetected } monthlyLicking: licking(period: MONTHLY) { __typename count duration lastDetected } dailyScratching: scratching(period: DAILY) { __typename count duration lastDetected } weeklyScratching: scratching(period: WEEKLY) { __typename count duration lastDetected } monthlyScratching: scratching(period: MONTHLY) { __typename count duration lastDetected } dailyEating: eating(period: DAILY) { __typename count duration lastDetected } weeklyEating: eating(period: WEEKLY) { __typename count duration lastDetected } monthlyEating: eating(period: MONTHLY) { __typename count duration lastDetected } dailyDrinking: drinking(period: DAILY) { __typename count duration lastDetected } weeklyDrinking: drinking(period: WEEKLY) { __typename count duration lastDetected } monthlyDrinking: drinking(period: MONTHLY) { __typename count duration lastDetected } }}" 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/fiDevice.py b/pytryfi/fiDevice.py index 8574e5c..198e4c5 100644 --- a/pytryfi/fiDevice.py +++ b/pytryfi/fiDevice.py @@ -2,6 +2,7 @@ import datetime from pytryfi.ledColors import ledColors from pytryfi.const import PET_MODE_NORMAL, PET_MODE_LOST +from sentry_sdk import capture_exception LOGGER = logging.getLogger(__name__) @@ -34,6 +35,13 @@ def setDeviceDetailsJSON(self, deviceJSON): for cString in deviceJSON['availableLedColors']: c = ledColors(int(cString['ledColorCode']),cString['hexCode'], cString['name'] ) self._availableLedColors.append(c) + + # Enhanced Series 3+ fields (may not be available on older collars) + self._firmwareVersion = deviceJSON.get('firmwareVersion', 'Unknown') + self._hardwareVersion = deviceJSON.get('hardwareVersion', 'Unknown') + self._gpsAccuracy = deviceJSON.get('gpsAccuracy', None) + self._signalStrength = deviceJSON.get('signalStrength', None) + self._signalType = deviceJSON.get('signalType', None) except Exception as e: LOGGER.debug(f"tryfi Error: {e}") capture_exception(e) @@ -93,6 +101,21 @@ def isLost(self): return True else: return False + @property + def firmwareVersion(self): + return self._firmwareVersion + @property + def hardwareVersion(self): + return self._hardwareVersion + @property + def gpsAccuracy(self): + return self._gpsAccuracy + @property + def signalStrength(self): + return self._signalStrength + @property + def signalType(self): + return self._signalType #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..c4ade37 100644 --- a/pytryfi/fiPet.py +++ b/pytryfi/fiPet.py @@ -48,6 +48,42 @@ def setPetDetailsJSON(self, petJSON: dict): self._device = FiDevice(petJSON['device']['id']) self._device.setDeviceDetailsJSON(petJSON['device']) self._connectedTo = self.setConnectedTo(petJSON['device']['lastConnectionState']) + + # 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 +147,107 @@ 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, behaviorJSON): + # 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: + if 'behaviorMetrics' in behaviorJSON: + metrics = behaviorJSON['behaviorMetrics'] + + # Barking + if 'dailyBarking' in metrics and metrics['dailyBarking']: + self._dailyBarkingCount = int(metrics['dailyBarking'].get('count', 0)) + self._dailyBarkingDuration = int(metrics['dailyBarking'].get('duration', 0)) + if 'weeklyBarking' in metrics and metrics['weeklyBarking']: + self._weeklyBarkingCount = int(metrics['weeklyBarking'].get('count', 0)) + self._weeklyBarkingDuration = int(metrics['weeklyBarking'].get('duration', 0)) + if 'monthlyBarking' in metrics and metrics['monthlyBarking']: + self._monthlyBarkingCount = int(metrics['monthlyBarking'].get('count', 0)) + self._monthlyBarkingDuration = int(metrics['monthlyBarking'].get('duration', 0)) + + # Licking + if 'dailyLicking' in metrics and metrics['dailyLicking']: + self._dailyLickingCount = int(metrics['dailyLicking'].get('count', 0)) + self._dailyLickingDuration = int(metrics['dailyLicking'].get('duration', 0)) + if 'weeklyLicking' in metrics and metrics['weeklyLicking']: + self._weeklyLickingCount = int(metrics['weeklyLicking'].get('count', 0)) + self._weeklyLickingDuration = int(metrics['weeklyLicking'].get('duration', 0)) + if 'monthlyLicking' in metrics and metrics['monthlyLicking']: + self._monthlyLickingCount = int(metrics['monthlyLicking'].get('count', 0)) + self._monthlyLickingDuration = int(metrics['monthlyLicking'].get('duration', 0)) + + # Scratching + if 'dailyScratching' in metrics and metrics['dailyScratching']: + self._dailyScratchingCount = int(metrics['dailyScratching'].get('count', 0)) + self._dailyScratchingDuration = int(metrics['dailyScratching'].get('duration', 0)) + if 'weeklyScratching' in metrics and metrics['weeklyScratching']: + self._weeklyScratchingCount = int(metrics['weeklyScratching'].get('count', 0)) + self._weeklyScratchingDuration = int(metrics['weeklyScratching'].get('duration', 0)) + if 'monthlyScratching' in metrics and metrics['monthlyScratching']: + self._monthlyScratchingCount = int(metrics['monthlyScratching'].get('count', 0)) + self._monthlyScratchingDuration = int(metrics['monthlyScratching'].get('duration', 0)) + + # Eating + if 'dailyEating' in metrics and metrics['dailyEating']: + self._dailyEatingCount = int(metrics['dailyEating'].get('count', 0)) + self._dailyEatingDuration = int(metrics['dailyEating'].get('duration', 0)) + if 'weeklyEating' in metrics and metrics['weeklyEating']: + self._weeklyEatingCount = int(metrics['weeklyEating'].get('count', 0)) + self._weeklyEatingDuration = int(metrics['weeklyEating'].get('duration', 0)) + if 'monthlyEating' in metrics and metrics['monthlyEating']: + self._monthlyEatingCount = int(metrics['monthlyEating'].get('count', 0)) + self._monthlyEatingDuration = int(metrics['monthlyEating'].get('duration', 0)) + + # Drinking + if 'dailyDrinking' in metrics and metrics['dailyDrinking']: + self._dailyDrinkingCount = int(metrics['dailyDrinking'].get('count', 0)) + self._dailyDrinkingDuration = int(metrics['dailyDrinking'].get('duration', 0)) + if 'weeklyDrinking' in metrics and metrics['weeklyDrinking']: + self._weeklyDrinkingCount = int(metrics['weeklyDrinking'].get('count', 0)) + self._weeklyDrinkingDuration = int(metrics['weeklyDrinking'].get('duration', 0)) + if 'monthlyDrinking' in metrics and metrics['monthlyDrinking']: + self._monthlyDrinkingCount = int(metrics['monthlyDrinking'].get('count', 0)) + self._monthlyDrinkingDuration = int(metrics['monthlyDrinking'].get('duration', 0)) + + 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 +314,17 @@ 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: + pBehaviorStatsJSON = query.getCurrentPetBehaviorStats(sessionId,self.petId) + self.setBehaviorStats(pBehaviorStatsJSON) + 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 +353,7 @@ def updateAllDetails(self, sessionId): self.updatePetLocation(sessionId) self.updateStats(sessionId) self.updateRestStats(sessionId) + self.updateBehaviorStats(sessionId) # set the color code of the led light on the pet collar def setLedColorCode(self, sessionId, colorCode): @@ -349,6 +498,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): From fd5dc31a6f1f382cdc69bb506cfb7ebec8ccdc2d Mon Sep 17 00:00:00 2001 From: Adam Jacob Muller Date: Tue, 5 Aug 2025 13:21:11 -0400 Subject: [PATCH 2/3] feat: Add Series 3+ device fields and fix pet without device handling - Add device fields: batteryVoltage, temperature, uptime, hardwareRevision - Add device statistics fields: wifiNetworkNames, capabilityFlags, deviceStats, etc. - Fix handling of pets without devices (returns None gracefully) - Add proper error handling in query methods - Remove commented behavior tracking code (not available in API) - Add logging for device info fields to aid future development --- pytryfi/__init__.py | 63 ++++++++++++--------- pytryfi/common/query.py | 57 ++++++++++++++++--- pytryfi/common/response_handlers.py | 4 ++ pytryfi/const.py | 6 +- pytryfi/exceptions.py | 9 ++- pytryfi/fiBase.py | 20 +++---- pytryfi/fiDevice.py | 87 +++++++++++++++++++++++------ pytryfi/fiPet.py | 22 +++++--- pytryfi/fiUser.py | 18 +++--- 9 files changed, 198 insertions(+), 88 deletions(-) create mode 100644 pytryfi/common/response_handlers.py diff --git a/pytryfi/__init__.py b/pytryfi/__init__.py index 1b29889..124825e 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,19 +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) - 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") + #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: @@ -111,14 +113,17 @@ 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']) - #get behavior stats if available (Series 3+ only) - 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}") - updatedPets.append(p) + #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") + 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) @@ -232,9 +237,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 c6aa5fd..6f92a34 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,11 +53,11 @@ def getCurrentPetRestStats(sessionId, petId): LOGGER.debug(f"getCurrentPetStats: {response}") return response['data']['pet'] -def getCurrentPetBehaviorStats(sessionId, petId): - qString = QUERY_PET_BEHAVIOR.replace(VAR_PET_ID, petId) + FRAGMENT_BEHAVIOR_DETAILS - response = query(sessionId, qString) - LOGGER.debug(f"getCurrentPetBehaviorStats: {response}") - return response['data']['pet'] +# def getCurrentPetBehaviorStats(sessionId, petId): +# qString = QUERY_PET_BEHAVIOR.replace(VAR_PET_ID, petId) + FRAGMENT_BEHAVIOR_DETAILS +# response = query(sessionId, qString) +# LOGGER.debug(f"getCurrentPetBehaviorStats: {response}") +# return response['data']['pet'] 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 @@ -98,14 +98,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 1a4cf18..559bbd4 100644 --- a/pytryfi/const.py +++ b/pytryfi/const.py @@ -21,7 +21,7 @@ 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_BEHAVIOR = "query { pet (id: \""+VAR_PET_ID+"\") { __typename ...BehaviorDetails }}" +# QUERY_PET_BEHAVIOR = "query { pet (id: \""+VAR_PET_ID+"\") { __typename ...BehaviorDetails }}" 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 }}" @@ -30,7 +30,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 } firmwareVersion hardwareVersion gpsAccuracy signalStrength signalType}" +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}" @@ -43,6 +43,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_BEHAVIOR_DETAILS = "fragment BehaviorDetails on Pet { __typename behaviorMetrics { __typename dailyBarking: barking(period: DAILY) { __typename count duration lastDetected } weeklyBarking: barking(period: WEEKLY) { __typename count duration lastDetected } monthlyBarking: barking(period: MONTHLY) { __typename count duration lastDetected } dailyLicking: licking(period: DAILY) { __typename count duration lastDetected } weeklyLicking: licking(period: WEEKLY) { __typename count duration lastDetected } monthlyLicking: licking(period: MONTHLY) { __typename count duration lastDetected } dailyScratching: scratching(period: DAILY) { __typename count duration lastDetected } weeklyScratching: scratching(period: WEEKLY) { __typename count duration lastDetected } monthlyScratching: scratching(period: MONTHLY) { __typename count duration lastDetected } dailyEating: eating(period: DAILY) { __typename count duration lastDetected } weeklyEating: eating(period: WEEKLY) { __typename count duration lastDetected } monthlyEating: eating(period: MONTHLY) { __typename count duration lastDetected } dailyDrinking: drinking(period: DAILY) { __typename count duration lastDetected } weeklyDrinking: drinking(period: WEEKLY) { __typename count duration lastDetected } monthlyDrinking: drinking(period: MONTHLY) { __typename count duration lastDetected } }}" +# FRAGMENT_BEHAVIOR_DETAILS = "fragment BehaviorDetails on Pet { __typename behaviorMetrics { __typename dailyBarking: barking(period: DAILY) { __typename count duration lastDetected } weeklyBarking: barking(period: WEEKLY) { __typename count duration lastDetected } monthlyBarking: barking(period: MONTHLY) { __typename count duration lastDetected } dailyLicking: licking(period: DAILY) { __typename count duration lastDetected } weeklyLicking: licking(period: WEEKLY) { __typename count duration lastDetected } monthlyLicking: licking(period: MONTHLY) { __typename count duration lastDetected } dailyScratching: scratching(period: DAILY) { __typename count duration lastDetected } weeklyScratching: scratching(period: WEEKLY) { __typename count duration lastDetected } monthlyScratching: scratching(period: MONTHLY) { __typename count duration lastDetected } dailyEating: eating(period: DAILY) { __typename count duration lastDetected } weeklyEating: eating(period: WEEKLY) { __typename count duration lastDetected } monthlyEating: eating(period: MONTHLY) { __typename count duration lastDetected } dailyDrinking: drinking(period: DAILY) { __typename count duration lastDetected } weeklyDrinking: drinking(period: WEEKLY) { __typename count duration lastDetected } monthlyDrinking: drinking(period: MONTHLY) { __typename count duration lastDetected } }}" 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 198e4c5..8abdc24 100644 --- a/pytryfi/fiDevice.py +++ b/pytryfi/fiDevice.py @@ -1,7 +1,8 @@ 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__) @@ -12,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']) @@ -36,12 +41,35 @@ def setDeviceDetailsJSON(self, deviceJSON): c = ledColors(int(cString['ledColorCode']),cString['hexCode'], cString['name'] ) self._availableLedColors.append(c) - # Enhanced Series 3+ fields (may not be available on older collars) - self._firmwareVersion = deviceJSON.get('firmwareVersion', 'Unknown') - self._hardwareVersion = deviceJSON.get('hardwareVersion', 'Unknown') - self._gpsAccuracy = deviceJSON.get('gpsAccuracy', None) - self._signalStrength = deviceJSON.get('signalStrength', None) - self._signalType = deviceJSON.get('signalType', None) + # 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) @@ -102,20 +130,43 @@ def isLost(self): else: return False @property - def firmwareVersion(self): - return self._firmwareVersion - @property - def hardwareVersion(self): - return self._hardwareVersion - @property - def gpsAccuracy(self): - return self._gpsAccuracy + 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 signalType(self): - return self._signalType + 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 c4ade37..4b2c68c 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,15 @@ 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 @@ -353,7 +359,7 @@ def updateAllDetails(self, sessionId): self.updatePetLocation(sessionId) self.updateStats(sessionId) self.updateRestStats(sessionId) - self.updateBehaviorStats(sessionId) + # self.updateBehaviorStats(sessionId) # Commented until API supports it # set the color code of the led light on the pet collar def setLedColorCode(self, sessionId, colorCode): 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}" From b162fcb187e3f4c915581a623a31874a9fecfc11 Mon Sep 17 00:00:00 2001 From: Adam Jacob Muller Date: Tue, 5 Aug 2025 19:18:42 -0400 Subject: [PATCH 3/3] feat: Add TryFi Series 3+ behavior tracking support - Enable behavior stats update in updatePets() method - Add health trends API support for behavior metrics (barking, licking, scratching, eating, drinking) - Map cleaning_self:DAY to licking behavior - Support both Series3+ and prod1 hardware revisions - Clean up all debug logging --- pytryfi/__init__.py | 11 ++-- pytryfi/common/query.py | 19 +++++-- pytryfi/const.py | 6 ++- pytryfi/fiPet.py | 113 +++++++++++++++++++--------------------- 4 files changed, 78 insertions(+), 71 deletions(-) diff --git a/pytryfi/__init__.py b/pytryfi/__init__.py index 124825e..bc408e5 100644 --- a/pytryfi/__init__.py +++ b/pytryfi/__init__.py @@ -113,12 +113,11 @@ 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']) - #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") + #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) diff --git a/pytryfi/common/query.py b/pytryfi/common/query.py index 6f92a34..7cd4905 100644 --- a/pytryfi/common/query.py +++ b/pytryfi/common/query.py @@ -53,11 +53,20 @@ def getCurrentPetRestStats(sessionId, petId): LOGGER.debug(f"getCurrentPetStats: {response}") return response['data']['pet'] -# def getCurrentPetBehaviorStats(sessionId, petId): -# qString = QUERY_PET_BEHAVIOR.replace(VAR_PET_ID, petId) + FRAGMENT_BEHAVIOR_DETAILS -# response = query(sessionId, qString) -# LOGGER.debug(f"getCurrentPetBehaviorStats: {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 diff --git a/pytryfi/const.py b/pytryfi/const.py index 559bbd4..f4250e4 100644 --- a/pytryfi/const.py +++ b/pytryfi/const.py @@ -21,7 +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_BEHAVIOR = "query { pet (id: \""+VAR_PET_ID+"\") { __typename ...BehaviorDetails }}" +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 }}" @@ -43,6 +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_BEHAVIOR_DETAILS = "fragment BehaviorDetails on Pet { __typename behaviorMetrics { __typename dailyBarking: barking(period: DAILY) { __typename count duration lastDetected } weeklyBarking: barking(period: WEEKLY) { __typename count duration lastDetected } monthlyBarking: barking(period: MONTHLY) { __typename count duration lastDetected } dailyLicking: licking(period: DAILY) { __typename count duration lastDetected } weeklyLicking: licking(period: WEEKLY) { __typename count duration lastDetected } monthlyLicking: licking(period: MONTHLY) { __typename count duration lastDetected } dailyScratching: scratching(period: DAILY) { __typename count duration lastDetected } weeklyScratching: scratching(period: WEEKLY) { __typename count duration lastDetected } monthlyScratching: scratching(period: MONTHLY) { __typename count duration lastDetected } dailyEating: eating(period: DAILY) { __typename count duration lastDetected } weeklyEating: eating(period: WEEKLY) { __typename count duration lastDetected } monthlyEating: eating(period: MONTHLY) { __typename count duration lastDetected } dailyDrinking: drinking(period: DAILY) { __typename count duration lastDetected } weeklyDrinking: drinking(period: WEEKLY) { __typename count duration lastDetected } monthlyDrinking: drinking(period: MONTHLY) { __typename count duration lastDetected } }}" +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/fiPet.py b/pytryfi/fiPet.py index 4b2c68c..ec19c49 100644 --- a/pytryfi/fiPet.py +++ b/pytryfi/fiPet.py @@ -154,7 +154,7 @@ 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, behaviorJSON): + def setBehaviorStats(self, behaviorTrends): # Initialize all behavior metrics to 0 as default self._dailyBarkingCount = 0 self._dailyBarkingDuration = 0 @@ -192,63 +192,58 @@ def setBehaviorStats(self, behaviorJSON): self._monthlyDrinkingDuration = 0 try: - if 'behaviorMetrics' in behaviorJSON: - metrics = behaviorJSON['behaviorMetrics'] - - # Barking - if 'dailyBarking' in metrics and metrics['dailyBarking']: - self._dailyBarkingCount = int(metrics['dailyBarking'].get('count', 0)) - self._dailyBarkingDuration = int(metrics['dailyBarking'].get('duration', 0)) - if 'weeklyBarking' in metrics and metrics['weeklyBarking']: - self._weeklyBarkingCount = int(metrics['weeklyBarking'].get('count', 0)) - self._weeklyBarkingDuration = int(metrics['weeklyBarking'].get('duration', 0)) - if 'monthlyBarking' in metrics and metrics['monthlyBarking']: - self._monthlyBarkingCount = int(metrics['monthlyBarking'].get('count', 0)) - self._monthlyBarkingDuration = int(metrics['monthlyBarking'].get('duration', 0)) - - # Licking - if 'dailyLicking' in metrics and metrics['dailyLicking']: - self._dailyLickingCount = int(metrics['dailyLicking'].get('count', 0)) - self._dailyLickingDuration = int(metrics['dailyLicking'].get('duration', 0)) - if 'weeklyLicking' in metrics and metrics['weeklyLicking']: - self._weeklyLickingCount = int(metrics['weeklyLicking'].get('count', 0)) - self._weeklyLickingDuration = int(metrics['weeklyLicking'].get('duration', 0)) - if 'monthlyLicking' in metrics and metrics['monthlyLicking']: - self._monthlyLickingCount = int(metrics['monthlyLicking'].get('count', 0)) - self._monthlyLickingDuration = int(metrics['monthlyLicking'].get('duration', 0)) + # 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', {}) - # Scratching - if 'dailyScratching' in metrics and metrics['dailyScratching']: - self._dailyScratchingCount = int(metrics['dailyScratching'].get('count', 0)) - self._dailyScratchingDuration = int(metrics['dailyScratching'].get('duration', 0)) - if 'weeklyScratching' in metrics and metrics['weeklyScratching']: - self._weeklyScratchingCount = int(metrics['weeklyScratching'].get('count', 0)) - self._weeklyScratchingDuration = int(metrics['weeklyScratching'].get('duration', 0)) - if 'monthlyScratching' in metrics and metrics['monthlyScratching']: - self._monthlyScratchingCount = int(metrics['monthlyScratching'].get('count', 0)) - self._monthlyScratchingDuration = int(metrics['monthlyScratching'].get('duration', 0)) + # 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 - # Eating - if 'dailyEating' in metrics and metrics['dailyEating']: - self._dailyEatingCount = int(metrics['dailyEating'].get('count', 0)) - self._dailyEatingDuration = int(metrics['dailyEating'].get('duration', 0)) - if 'weeklyEating' in metrics and metrics['weeklyEating']: - self._weeklyEatingCount = int(metrics['weeklyEating'].get('count', 0)) - self._weeklyEatingDuration = int(metrics['weeklyEating'].get('duration', 0)) - if 'monthlyEating' in metrics and metrics['monthlyEating']: - self._monthlyEatingCount = int(metrics['monthlyEating'].get('count', 0)) - self._monthlyEatingDuration = int(metrics['monthlyEating'].get('duration', 0)) + # 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 - # Drinking - if 'dailyDrinking' in metrics and metrics['dailyDrinking']: - self._dailyDrinkingCount = int(metrics['dailyDrinking'].get('count', 0)) - self._dailyDrinkingDuration = int(metrics['dailyDrinking'].get('duration', 0)) - if 'weeklyDrinking' in metrics and metrics['weeklyDrinking']: - self._weeklyDrinkingCount = int(metrics['weeklyDrinking'].get('count', 0)) - self._weeklyDrinkingDuration = int(metrics['weeklyDrinking'].get('duration', 0)) - if 'monthlyDrinking' in metrics and metrics['monthlyDrinking']: - self._monthlyDrinkingCount = int(metrics['monthlyDrinking'].get('count', 0)) - self._monthlyDrinkingDuration = int(metrics['monthlyDrinking'].get('duration', 0)) + # 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}") @@ -323,8 +318,10 @@ def updateRestStats(self, sessionId): # Update the behavior stats of the pet def updateBehaviorStats(self, sessionId): try: - pBehaviorStatsJSON = query.getCurrentPetBehaviorStats(sessionId,self.petId) - self.setBehaviorStats(pBehaviorStatsJSON) + # 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}") @@ -359,7 +356,7 @@ def updateAllDetails(self, sessionId): self.updatePetLocation(sessionId) self.updateStats(sessionId) self.updateRestStats(sessionId) - # self.updateBehaviorStats(sessionId) # Commented until API supports it + 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):