homebridge-tryfi 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/api.ts ADDED
@@ -0,0 +1,409 @@
1
+ import axios, { AxiosInstance } from 'axios';
2
+ import { wrapper } from 'axios-cookiejar-support';
3
+ import { CookieJar } from 'tough-cookie';
4
+ import { Logger } from 'homebridge';
5
+ import {
6
+ TryFiSession,
7
+ TryFiPet,
8
+ GraphQLResponse,
9
+ CurrentUserResponse,
10
+ } from './types';
11
+
12
+ /**
13
+ * TryFi API Client - matches pytryfi implementation exactly
14
+ */
15
+ export class TryFiAPI {
16
+ private readonly apiUrl = 'https://api.tryfi.com';
17
+ private readonly client: AxiosInstance;
18
+ private readonly jar: CookieJar;
19
+ private session: TryFiSession | null = null;
20
+
21
+ constructor(
22
+ private readonly username: string,
23
+ private readonly password: string,
24
+ private readonly log: Logger,
25
+ ) {
26
+ // Create cookie jar to persist cookies like Python requests.Session()
27
+ this.jar = new CookieJar();
28
+
29
+ // Wrap axios with cookie jar support
30
+ this.client = wrapper(axios.create({
31
+ baseURL: this.apiUrl,
32
+ timeout: 30000,
33
+ jar: this.jar,
34
+ withCredentials: true,
35
+ }));
36
+ }
37
+
38
+ /**
39
+ * Login using REST API (matches pytryfi)
40
+ */
41
+ async login(): Promise<void> {
42
+ try {
43
+ const formData = new URLSearchParams();
44
+ formData.append('email', this.username);
45
+ formData.append('password', this.password);
46
+
47
+ const response = await this.client.post('/auth/login', formData, {
48
+ headers: {
49
+ 'Content-Type': 'application/x-www-form-urlencoded',
50
+ },
51
+ });
52
+
53
+ if (response.data.error) {
54
+ throw new Error(`Login failed: ${response.data.error.message}`);
55
+ }
56
+
57
+ if (!response.data.userId || !response.data.sessionId) {
58
+ throw new Error('Login failed: No session data returned');
59
+ }
60
+
61
+ this.session = {
62
+ userId: response.data.userId,
63
+ sessionId: response.data.sessionId,
64
+ };
65
+
66
+ // Set JSON header for subsequent GraphQL requests (matches pytryfi.setHeaders())
67
+ this.client.defaults.headers.common['Content-Type'] = 'application/json';
68
+
69
+ this.log.info('Successfully authenticated with TryFi');
70
+ } catch (error) {
71
+ this.log.error('Failed to login to TryFi:', error);
72
+ throw error;
73
+ }
74
+ }
75
+
76
+ /**
77
+ * Get all pets using EXACT pytryfi query structure
78
+ */
79
+ async getPets(): Promise<TryFiPet[]> {
80
+ await this.ensureAuthenticated();
81
+
82
+ // This matches pytryfi's QUERY_CURRENT_USER_FULL_DETAIL + fragments
83
+ const query = `
84
+ query {
85
+ currentUser {
86
+ __typename
87
+ id
88
+ email
89
+ firstName
90
+ lastName
91
+ userHouseholds {
92
+ __typename
93
+ household {
94
+ __typename
95
+ pets {
96
+ __typename
97
+ id
98
+ name
99
+ homeCityState
100
+ gender
101
+ breed {
102
+ __typename
103
+ id
104
+ name
105
+ }
106
+ device {
107
+ __typename
108
+ id
109
+ moduleId
110
+ info
111
+ operationParams {
112
+ __typename
113
+ mode
114
+ ledEnabled
115
+ ledOffAt
116
+ }
117
+ lastConnectionState {
118
+ __typename
119
+ date
120
+ ... on ConnectedToUser {
121
+ user {
122
+ __typename
123
+ id
124
+ firstName
125
+ lastName
126
+ }
127
+ }
128
+ ... on ConnectedToBase {
129
+ chargingBase {
130
+ __typename
131
+ id
132
+ }
133
+ }
134
+ ... on ConnectedToCellular {
135
+ signalStrengthPercent
136
+ }
137
+ }
138
+ }
139
+ }
140
+ }
141
+ }
142
+ }
143
+ }
144
+ `;
145
+
146
+ try {
147
+ const response = await this.client.post<GraphQLResponse<CurrentUserResponse>>(
148
+ '/graphql',
149
+ { query },
150
+ );
151
+
152
+ if (response.data.errors) {
153
+ throw new Error(`GraphQL error: ${response.data.errors[0].message}`);
154
+ }
155
+
156
+ if (!response.data.data?.currentUser?.userHouseholds) {
157
+ return [];
158
+ }
159
+
160
+ // Flatten pets from all households
161
+ const pets: TryFiPet[] = [];
162
+ for (const userHousehold of response.data.data.currentUser.userHouseholds) {
163
+ if (userHousehold.household?.pets) {
164
+ for (const pet of userHousehold.household.pets) {
165
+ if (!pet.device) {
166
+ this.log.warn(`Pet ${pet.name} has no device, skipping`);
167
+ continue;
168
+ }
169
+
170
+ // Parse device info JSON object
171
+ const deviceInfo = pet.device.info || {};
172
+ const batteryPercent = parseInt(deviceInfo.batteryPercent) || 0;
173
+ const isCharging = deviceInfo.isCharging || false;
174
+
175
+ // Get location data for this pet
176
+ const location = await this.getPetLocation(pet.id);
177
+
178
+ // Determine connection status
179
+ const connectionState = pet.device.lastConnectionState;
180
+ const isCharging2 = connectionState?.__typename === 'ConnectedToBase';
181
+ const connectedToUser =
182
+ connectionState?.__typename === 'ConnectedToUser'
183
+ ? (connectionState as any).user?.firstName || null
184
+ : null;
185
+
186
+ pets.push({
187
+ petId: pet.id,
188
+ name: pet.name,
189
+ breed: pet.breed?.name || 'Unknown',
190
+ moduleId: pet.device.moduleId,
191
+ batteryPercent,
192
+ isCharging: isCharging || isCharging2,
193
+ ledEnabled: pet.device.operationParams?.ledEnabled || false,
194
+ mode: pet.device.operationParams?.mode || 'NORMAL',
195
+ connectedToUser,
196
+ ...location,
197
+ });
198
+ }
199
+ }
200
+ }
201
+
202
+ this.log.debug(`Retrieved ${pets.length} pet(s) from TryFi`);
203
+ return pets;
204
+ } catch (error) {
205
+ this.log.error('Failed to get pets:', error);
206
+ throw error;
207
+ }
208
+ }
209
+
210
+ /**
211
+ * Get pet location - matches pytryfi's getCurrentPetLocation
212
+ */
213
+ private async getPetLocation(petId: string): Promise<{
214
+ latitude: number;
215
+ longitude: number;
216
+ areaName: string | null;
217
+ placeName: string | null;
218
+ placeAddress: string | null;
219
+ }> {
220
+ const query = `
221
+ query {
222
+ pet(id: "${petId}") {
223
+ ongoingActivity {
224
+ __typename
225
+ start
226
+ areaName
227
+ ... on OngoingWalk {
228
+ positions {
229
+ __typename
230
+ date
231
+ position {
232
+ __typename
233
+ latitude
234
+ longitude
235
+ }
236
+ }
237
+ }
238
+ ... on OngoingRest {
239
+ position {
240
+ __typename
241
+ latitude
242
+ longitude
243
+ }
244
+ place {
245
+ __typename
246
+ id
247
+ name
248
+ address
249
+ }
250
+ }
251
+ }
252
+ }
253
+ }
254
+ `;
255
+
256
+ try {
257
+ const response = await this.client.post<GraphQLResponse<any>>(
258
+ '/graphql',
259
+ { query },
260
+ );
261
+
262
+ if (response.data.errors) {
263
+ this.log.warn(`Failed to get location for pet ${petId}:`, response.data.errors[0].message);
264
+ return {
265
+ latitude: 0,
266
+ longitude: 0,
267
+ areaName: null,
268
+ placeName: null,
269
+ placeAddress: null,
270
+ };
271
+ }
272
+
273
+ const activity = response.data.data?.pet?.ongoingActivity;
274
+ if (!activity) {
275
+ return {
276
+ latitude: 0,
277
+ longitude: 0,
278
+ areaName: null,
279
+ placeName: null,
280
+ placeAddress: null,
281
+ };
282
+ }
283
+
284
+ const areaName = activity.areaName || null;
285
+ let latitude = 0;
286
+ let longitude = 0;
287
+ let placeName = null;
288
+ let placeAddress = null;
289
+
290
+ if (activity.__typename === 'OngoingRest') {
291
+ latitude = activity.position?.latitude || 0;
292
+ longitude = activity.position?.longitude || 0;
293
+ placeName = activity.place?.name || null;
294
+ placeAddress = activity.place?.address || null;
295
+ } else if (activity.__typename === 'OngoingWalk' && activity.positions?.length > 0) {
296
+ const lastPosition = activity.positions[activity.positions.length - 1];
297
+ latitude = lastPosition.position?.latitude || 0;
298
+ longitude = lastPosition.position?.longitude || 0;
299
+ }
300
+
301
+ return { latitude, longitude, areaName, placeName, placeAddress };
302
+ } catch (error) {
303
+ this.log.warn(`Failed to get location for pet ${petId}:`, error);
304
+ return {
305
+ latitude: 0,
306
+ longitude: 0,
307
+ areaName: null,
308
+ placeName: null,
309
+ placeAddress: null,
310
+ };
311
+ }
312
+ }
313
+
314
+ /**
315
+ * Set LED on/off - matches pytryfi's turnOnOffLed
316
+ */
317
+ async setLedState(moduleId: string, ledEnabled: boolean): Promise<void> {
318
+ await this.ensureAuthenticated();
319
+
320
+ const mutation = `
321
+ mutation UpdateDeviceOperationParams($input: UpdateDeviceOperationParamsInput!) {
322
+ updateDeviceOperationParams(input: $input) {
323
+ __typename
324
+ id
325
+ moduleId
326
+ operationParams {
327
+ __typename
328
+ mode
329
+ ledEnabled
330
+ ledOffAt
331
+ }
332
+ }
333
+ }
334
+ `;
335
+
336
+ try {
337
+ const response = await this.client.post('/graphql', {
338
+ query: mutation,
339
+ variables: {
340
+ input: {
341
+ moduleId,
342
+ ledEnabled,
343
+ },
344
+ },
345
+ });
346
+
347
+ if (response.data.errors) {
348
+ throw new Error(`Failed to set LED: ${response.data.errors[0].message}`);
349
+ }
350
+
351
+ this.log.debug(`Set LED ${ledEnabled ? 'on' : 'off'} for module ${moduleId}`);
352
+ } catch (error) {
353
+ this.log.error('Failed to set LED state:', error);
354
+ throw error;
355
+ }
356
+ }
357
+
358
+ /**
359
+ * Set Lost Dog Mode - matches pytryfi's setLostDogMode
360
+ */
361
+ async setLostDogMode(moduleId: string, isLost: boolean): Promise<void> {
362
+ await this.ensureAuthenticated();
363
+
364
+ const mode = isLost ? 'LOST_DOG' : 'NORMAL';
365
+
366
+ const mutation = `
367
+ mutation UpdateDeviceOperationParams($input: UpdateDeviceOperationParamsInput!) {
368
+ updateDeviceOperationParams(input: $input) {
369
+ __typename
370
+ id
371
+ moduleId
372
+ operationParams {
373
+ __typename
374
+ mode
375
+ ledEnabled
376
+ ledOffAt
377
+ }
378
+ }
379
+ }
380
+ `;
381
+
382
+ try {
383
+ const response = await this.client.post('/graphql', {
384
+ query: mutation,
385
+ variables: {
386
+ input: {
387
+ moduleId,
388
+ mode,
389
+ },
390
+ },
391
+ });
392
+
393
+ if (response.data.errors) {
394
+ throw new Error(`Failed to set lost mode: ${response.data.errors[0].message}`);
395
+ }
396
+
397
+ this.log.info(`Set Lost Dog Mode ${isLost ? 'ON' : 'OFF'} for module ${moduleId}`);
398
+ } catch (error) {
399
+ this.log.error('Failed to set lost dog mode:', error);
400
+ throw error;
401
+ }
402
+ }
403
+
404
+ private async ensureAuthenticated(): Promise<void> {
405
+ if (!this.session) {
406
+ await this.login();
407
+ }
408
+ }
409
+ }
package/src/index.ts ADDED
@@ -0,0 +1,10 @@
1
+ import { API } from 'homebridge';
2
+ import { TryFiPlatform } from './platform';
3
+ import { PLATFORM_NAME, PLUGIN_NAME } from './settings';
4
+
5
+ /**
6
+ * This method registers the platform with Homebridge
7
+ */
8
+ export = (api: API) => {
9
+ api.registerPlatform(PLUGIN_NAME, PLATFORM_NAME, TryFiPlatform);
10
+ };
@@ -0,0 +1,220 @@
1
+ import {
2
+ API,
3
+ DynamicPlatformPlugin,
4
+ Logger,
5
+ PlatformAccessory,
6
+ PlatformConfig,
7
+ Service,
8
+ Characteristic,
9
+ } from 'homebridge';
10
+ import { TryFiAPI } from './api';
11
+ import { TryFiCollarAccessory } from './accessory';
12
+ import { TryFiPlatformConfig } from './types';
13
+
14
+ /**
15
+ * TryFi Platform Plugin
16
+ */
17
+ export class TryFiPlatform implements DynamicPlatformPlugin {
18
+ public readonly Service: typeof Service;
19
+ public readonly Characteristic: typeof Characteristic;
20
+
21
+ public readonly config: TryFiPlatformConfig;
22
+ public readonly accessories: PlatformAccessory[] = [];
23
+ public readonly collarAccessories: Map<string, TryFiCollarAccessory> = new Map();
24
+
25
+ public readonly tryfiApi: TryFiAPI;
26
+ public readonly api: TryFiAPI; // Alias for accessory use
27
+ private pollingInterval?: NodeJS.Timeout;
28
+
29
+ constructor(
30
+ public readonly log: Logger,
31
+ config: PlatformConfig,
32
+ public readonly homebridgeApi: API,
33
+ ) {
34
+ this.Service = this.homebridgeApi.hap.Service;
35
+ this.Characteristic = this.homebridgeApi.hap.Characteristic;
36
+
37
+ // Cast config to our platform config type
38
+ this.config = config as TryFiPlatformConfig;
39
+
40
+ // Validate config
41
+ if (!this.config.username || !this.config.password) {
42
+ this.log.error('TryFi username and password are required in config');
43
+ throw new Error('Missing required config');
44
+ }
45
+
46
+ // Create API client
47
+ this.tryfiApi = new TryFiAPI(this.config.username, this.config.password, log);
48
+ this.api = this.tryfiApi; // Alias for accessory use
49
+
50
+ this.log.debug('Finished initializing platform:', config.name);
51
+
52
+ // When this event is fired it means Homebridge has restored all cached accessories
53
+ this.homebridgeApi.on('didFinishLaunching', () => {
54
+ log.debug('Executed didFinishLaunching callback');
55
+ this.discoverDevices();
56
+ });
57
+ }
58
+
59
+ /**
60
+ * This function is invoked when homebridge restores cached accessories from disk at startup.
61
+ */
62
+ configureAccessory(accessory: PlatformAccessory) {
63
+ this.log.info('Loading accessory from cache:', accessory.displayName);
64
+ this.accessories.push(accessory);
65
+ }
66
+
67
+ /**
68
+ * Discover TryFi collars and create accessories
69
+ */
70
+ async discoverDevices() {
71
+ try {
72
+ // Login and get pets
73
+ await this.tryfiApi.login();
74
+ const allPets = await this.tryfiApi.getPets();
75
+
76
+ // Filter out ignored pets (case-insensitive)
77
+ const ignoredPets = (this.config.ignoredPets || []).map(name => name.toLowerCase());
78
+ const pets = allPets.filter(pet => !ignoredPets.includes(pet.name.toLowerCase()));
79
+
80
+ if (ignoredPets.length > 0) {
81
+ const ignoredCount = allPets.length - pets.length;
82
+ if (ignoredCount > 0) {
83
+ this.log.info(`Ignoring ${ignoredCount} pet(s) based on configuration`);
84
+ }
85
+ }
86
+
87
+ this.log.info(`Discovered ${pets.length} TryFi collar(s)`);
88
+
89
+ // Track discovered pet IDs
90
+ const discoveredPetIds = new Set<string>();
91
+
92
+ for (const pet of pets) {
93
+ discoveredPetIds.add(pet.petId);
94
+
95
+ // Generate unique ID for this accessory
96
+ const uuid = this.homebridgeApi.hap.uuid.generate(pet.petId);
97
+
98
+ // Check if accessory already exists
99
+ const existingAccessory = this.accessories.find(
100
+ (accessory) => accessory.UUID === uuid,
101
+ );
102
+
103
+ if (existingAccessory) {
104
+ // Restore existing accessory
105
+ this.log.info('Restoring existing accessory from cache:', pet.name);
106
+ existingAccessory.context.pet = pet;
107
+
108
+ // Create collar accessory handler
109
+ const collarAccessory = new TryFiCollarAccessory(
110
+ this,
111
+ existingAccessory,
112
+ pet,
113
+ );
114
+ this.collarAccessories.set(pet.petId, collarAccessory);
115
+
116
+ // Update reachability
117
+ this.homebridgeApi.updatePlatformAccessories([existingAccessory]);
118
+ } else {
119
+ // Create new accessory
120
+ this.log.info('Adding new accessory:', pet.name);
121
+
122
+ const accessory = new this.homebridgeApi.platformAccessory(pet.name, uuid);
123
+ accessory.context.pet = pet;
124
+
125
+ // Create collar accessory handler
126
+ const collarAccessory = new TryFiCollarAccessory(this, accessory, pet);
127
+ this.collarAccessories.set(pet.petId, collarAccessory);
128
+
129
+ // Register new accessory
130
+ this.homebridgeApi.registerPlatformAccessories('homebridge-tryfi', 'TryFi', [
131
+ accessory,
132
+ ]);
133
+ this.accessories.push(accessory);
134
+ }
135
+ }
136
+
137
+ // Remove accessories for pets that no longer exist
138
+ const accessoriesToRemove = this.accessories.filter(
139
+ (accessory) => !discoveredPetIds.has(accessory.context.pet?.petId),
140
+ );
141
+
142
+ if (accessoriesToRemove.length > 0) {
143
+ this.log.info(
144
+ `Removing ${accessoriesToRemove.length} accessory(ies) for deleted pets`,
145
+ );
146
+ this.homebridgeApi.unregisterPlatformAccessories(
147
+ 'homebridge-tryfi',
148
+ 'TryFi',
149
+ accessoriesToRemove,
150
+ );
151
+
152
+ // Remove from our tracking
153
+ for (const accessory of accessoriesToRemove) {
154
+ const index = this.accessories.indexOf(accessory);
155
+ if (index > -1) {
156
+ this.accessories.splice(index, 1);
157
+ }
158
+ this.collarAccessories.delete(accessory.context.pet?.petId);
159
+ }
160
+ }
161
+
162
+ // Start polling for updates
163
+ this.startPolling();
164
+ } catch (error) {
165
+ this.log.error('Failed to discover TryFi devices:', error);
166
+ }
167
+ }
168
+
169
+ /**
170
+ * Start polling TryFi API for updates
171
+ */
172
+ private startPolling() {
173
+ const pollingInterval = (this.config.pollingInterval || 60) * 1000;
174
+ this.log.info(`Starting polling every ${pollingInterval / 1000} seconds`);
175
+
176
+ // Clear any existing interval
177
+ if (this.pollingInterval) {
178
+ clearInterval(this.pollingInterval);
179
+ }
180
+
181
+ // Poll immediately, then at intervals
182
+ this.pollDevices();
183
+ this.pollingInterval = setInterval(() => {
184
+ this.pollDevices();
185
+ }, pollingInterval);
186
+ }
187
+
188
+ /**
189
+ * Poll TryFi API for device updates
190
+ */
191
+ private async pollDevices() {
192
+ try {
193
+ const allPets = await this.tryfiApi.getPets();
194
+
195
+ // Filter out ignored pets (case-insensitive)
196
+ const ignoredPets = (this.config.ignoredPets || []).map(name => name.toLowerCase());
197
+ const pets = allPets.filter(pet => !ignoredPets.includes(pet.name.toLowerCase()));
198
+
199
+ for (const pet of pets) {
200
+ const accessory = this.collarAccessories.get(pet.petId);
201
+ if (accessory) {
202
+ accessory.updatePetData(pet);
203
+ }
204
+ }
205
+
206
+ this.log.debug(`Updated ${pets.length} collar(s)`);
207
+ } catch (error) {
208
+ this.log.error('Failed to poll TryFi API:', error);
209
+ }
210
+ }
211
+
212
+ /**
213
+ * Stop polling when platform is shutting down
214
+ */
215
+ shutdown() {
216
+ if (this.pollingInterval) {
217
+ clearInterval(this.pollingInterval);
218
+ }
219
+ }
220
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * This is the name of the platform that users will use to register the plugin in the Homebridge config.json
3
+ */
4
+ export const PLATFORM_NAME = 'TryFi';
5
+
6
+ /**
7
+ * This must match the name of your plugin as defined the package.json
8
+ */
9
+ export const PLUGIN_NAME = 'homebridge-tryfi';