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/CHANGELOG.md +43 -0
- package/CHANGES-V3.md +103 -0
- package/LICENSE +201 -0
- package/README.md +139 -0
- package/SUMMARY.md +289 -0
- package/config.schema.json +91 -0
- package/dist/accessory.d.ts +31 -0
- package/dist/accessory.d.ts.map +1 -0
- package/dist/accessory.js +134 -0
- package/dist/accessory.js.map +1 -0
- package/dist/api.d.ts +37 -0
- package/dist/api.d.ts.map +1 -0
- package/dist/api.js +365 -0
- package/dist/api.js.map +1 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -0
- package/dist/platform.d.ts +41 -0
- package/dist/platform.d.ts.map +1 -0
- package/dist/platform.js +168 -0
- package/dist/platform.js.map +1 -0
- package/dist/settings.d.ts +9 -0
- package/dist/settings.d.ts.map +1 -0
- package/dist/settings.js +12 -0
- package/dist/settings.js.map +1 -0
- package/dist/types.d.ts +118 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +3 -0
- package/dist/types.js.map +1 -0
- package/package.json +42 -0
- package/src/accessory.ts +172 -0
- package/src/api.ts +409 -0
- package/src/index.ts +10 -0
- package/src/platform.ts +220 -0
- package/src/settings.ts +9 -0
- package/src/types.ts +119 -0
- package/tsconfig.json +25 -0
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
|
+
};
|
package/src/platform.ts
ADDED
|
@@ -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
|
+
}
|
package/src/settings.ts
ADDED
|
@@ -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';
|