Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 37 additions & 13 deletions pytryfi/__init__.py
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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,
Expand All @@ -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!
Expand All @@ -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:
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
62 changes: 58 additions & 4 deletions pytryfi/common/query.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from pytryfi.const import *
from pytryfi.exceptions import *
from ..const import *
from ..exceptions import *
import json
import logging
import requests
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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):
Expand Down
4 changes: 4 additions & 0 deletions pytryfi/common/response_handlers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import datetime

def parse_fi_date(input: str) -> datetime.datetime:
return datetime.datetime.fromisoformat(input.replace('Z', '+00:00'))
6 changes: 5 additions & 1 deletion pytryfi/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}"
Expand All @@ -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}"
Expand All @@ -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 }}"
9 changes: 7 additions & 2 deletions pytryfi/exceptions.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
class Error(Exception):
"""base exception class"""

class TryFiError(Error):
class TryFiError(Exception):
"""Generic error for TryFi"""


class RemoteApiError(TryFiError):
"""tryfi.com returned an unexpected result"""

class ApiNotAuthorizedError(TryFiError):
"""tryfi.com reports not authorized"""
20 changes: 8 additions & 12 deletions pytryfi/fiBase.py
Original file line number Diff line number Diff line change
@@ -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}"
Expand Down
Loading
Loading