homebridge-smarthq-client 1.0.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.
Files changed (84) hide show
  1. package/LICENSE +21 -0
  2. package/Readme.md +138 -0
  3. package/config.schema.json +146 -0
  4. package/dist/index.d.ts +6 -0
  5. package/dist/index.js +9 -0
  6. package/dist/index.js.map +1 -0
  7. package/dist/platform.d.ts +40 -0
  8. package/dist/platform.js +243 -0
  9. package/dist/platform.js.map +1 -0
  10. package/dist/refrigerator/controlLock.d.ts +19 -0
  11. package/dist/refrigerator/controlLock.js +85 -0
  12. package/dist/refrigerator/controlLock.js.map +1 -0
  13. package/dist/refrigerator/convertibleDrawer.d.ts +27 -0
  14. package/dist/refrigerator/convertibleDrawer.js +317 -0
  15. package/dist/refrigerator/convertibleDrawer.js.map +1 -0
  16. package/dist/refrigerator/dispenserLight.d.ts +19 -0
  17. package/dist/refrigerator/dispenserLight.js +85 -0
  18. package/dist/refrigerator/dispenserLight.js.map +1 -0
  19. package/dist/refrigerator/energy.d.ts +19 -0
  20. package/dist/refrigerator/energy.js +93 -0
  21. package/dist/refrigerator/energy.js.map +1 -0
  22. package/dist/refrigerator/freezer.d.ts +41 -0
  23. package/dist/refrigerator/freezer.js +188 -0
  24. package/dist/refrigerator/freezer.js.map +1 -0
  25. package/dist/refrigerator/iceMaker.d.ts +19 -0
  26. package/dist/refrigerator/iceMaker.js +81 -0
  27. package/dist/refrigerator/iceMaker.js.map +1 -0
  28. package/dist/refrigerator/interiorLight.d.ts +21 -0
  29. package/dist/refrigerator/interiorLight.js +100 -0
  30. package/dist/refrigerator/interiorLight.js.map +1 -0
  31. package/dist/refrigerator/refrigerator.d.ts +41 -0
  32. package/dist/refrigerator/refrigerator.js +204 -0
  33. package/dist/refrigerator/refrigerator.js.map +1 -0
  34. package/dist/refrigerator/refrigeratorAlerts.d.ts +36 -0
  35. package/dist/refrigerator/refrigeratorAlerts.js +204 -0
  36. package/dist/refrigerator/refrigeratorAlerts.js.map +1 -0
  37. package/dist/refrigerator/sabbathMode.d.ts +20 -0
  38. package/dist/refrigerator/sabbathMode.js +91 -0
  39. package/dist/refrigerator/sabbathMode.js.map +1 -0
  40. package/dist/refrigerator/temperatureUnits.d.ts +21 -0
  41. package/dist/refrigerator/temperatureUnits.js +147 -0
  42. package/dist/refrigerator/temperatureUnits.js.map +1 -0
  43. package/dist/refrigerator/turboCoolMode.d.ts +23 -0
  44. package/dist/refrigerator/turboCoolMode.js +134 -0
  45. package/dist/refrigerator/turboCoolMode.js.map +1 -0
  46. package/dist/refrigerator/waterFilter.d.ts +19 -0
  47. package/dist/refrigerator/waterFilter.js +89 -0
  48. package/dist/refrigerator/waterFilter.js.map +1 -0
  49. package/dist/refrigeratorServices.d.ts +5 -0
  50. package/dist/refrigeratorServices.js +54 -0
  51. package/dist/refrigeratorServices.js.map +1 -0
  52. package/dist/settings.d.ts +12 -0
  53. package/dist/settings.js +13 -0
  54. package/dist/settings.js.map +1 -0
  55. package/dist/smartHqApi.d.ts +29 -0
  56. package/dist/smartHqApi.js +267 -0
  57. package/dist/smartHqApi.js.map +1 -0
  58. package/dist/smarthq-types.d.ts +29 -0
  59. package/dist/smarthq-types.js +2 -0
  60. package/dist/smarthq-types.js.map +1 -0
  61. package/eslint.config.js +12 -0
  62. package/images/homebridge.svg +1 -0
  63. package/package.json +61 -0
  64. package/src/@types/homebridge-lib.d.ts +14 -0
  65. package/src/index.ts +11 -0
  66. package/src/platform.ts +300 -0
  67. package/src/refrigerator/controlLock.ts +103 -0
  68. package/src/refrigerator/convertibleDrawer.ts +381 -0
  69. package/src/refrigerator/dispenserLight.ts +103 -0
  70. package/src/refrigerator/energy.ts +106 -0
  71. package/src/refrigerator/freezer.ts +227 -0
  72. package/src/refrigerator/iceMaker.ts +100 -0
  73. package/src/refrigerator/interiorLight.ts +118 -0
  74. package/src/refrigerator/refrigerator.ts +241 -0
  75. package/src/refrigerator/refrigeratorAlerts.ts +228 -0
  76. package/src/refrigerator/sabbathMode.ts +111 -0
  77. package/src/refrigerator/temperatureUnits.ts +178 -0
  78. package/src/refrigerator/turboCoolMode.ts +164 -0
  79. package/src/refrigerator/waterFilter.ts +107 -0
  80. package/src/refrigeratorServices.ts +60 -0
  81. package/src/settings.ts +14 -0
  82. package/src/smartHqApi.ts +305 -0
  83. package/src/smarthq-types.ts +31 -0
  84. package/tsconfig.json +24 -0
@@ -0,0 +1,300 @@
1
+
2
+ import { API, Logging, PlatformConfig, PlatformAccessory, Service, Characteristic } from 'homebridge';
3
+ import chalk from 'chalk';
4
+ import express from 'express';
5
+ import fs from 'fs';
6
+ import path from 'path';
7
+ import { stringify, parse } from 'querystring';
8
+ import { SmartHqApi } from './smartHqApi.js';
9
+ import { EventEmitter } from 'node:events';
10
+ import { PLATFORM_NAME, PLUGIN_NAME, AUTH_URL, TOKEN_STORE } from './settings.js';
11
+ import { setupRefrigeratorServices } from './refrigeratorServices.js';
12
+ import { DevDevice, DevService } from './smarthq-types.js';
13
+ import { Server } from 'node:http';
14
+
15
+ export class SmartHqPlatform {
16
+ public readonly Service: typeof Service;
17
+ public readonly Characteristic: typeof Characteristic;
18
+
19
+ // this is used to track restored cached accessories
20
+ public readonly accessories: Map<string, PlatformAccessory> = new Map();
21
+ public readonly discoveredCacheUUIDs: string[] = [];
22
+ public readonly tokenPath: string;
23
+ private oauthServer?: Server;
24
+ public expires: number;
25
+ public readonly smartHqApi: SmartHqApi;
26
+ public authEmitter: EventEmitter
27
+ public serviceDeviceType: string = ''
28
+ public serviceType: string = ''
29
+
30
+ constructor(
31
+ public readonly log: Logging,
32
+ public readonly config: PlatformConfig,
33
+ private readonly api: API
34
+ ) {
35
+ this.api = api;
36
+ this.Service = api.hap.Service;
37
+ this.Characteristic = api.hap.Characteristic;
38
+ this.expires = 0;
39
+
40
+ this.tokenPath = path.join(
41
+ this.api.user.persistPath(),
42
+ TOKEN_STORE
43
+ );
44
+
45
+
46
+ this.authEmitter = new EventEmitter();
47
+
48
+ chalk.level = 1; // Enable chalk colors
49
+
50
+ this.smartHqApi = new SmartHqApi(this);
51
+
52
+ // Validate required configuration parameters
53
+ if (!config.clientId || !config.clientSecret || !config.redirectUri) {
54
+ this.log.error('Missing required config parameter.');
55
+ return;
56
+ }
57
+
58
+ this.api.on('didFinishLaunching', async () => {
59
+ try {
60
+ this.debug('red', '(SmartHQ OAuth2 authentication starting)');
61
+ await this.startOAuth();
62
+ this.debug('blue', '(SmartHQ OAuth2 authentication completed)');
63
+ } catch (error) {
64
+ this.log.error(chalk.red('SmartHQ OAuth2 authentication failed:'), error);
65
+ }
66
+ });
67
+
68
+ // Listen for authComplete event to start device discovery
69
+ this.authEmitter.on('authComplete', async () => {
70
+ this.debug('green', 'Auth complete event received, starting device discovery');
71
+ await this.discoverDevices();
72
+ });
73
+ }
74
+
75
+ /**================================================================
76
+ * If no token file exists, start the OAuth process.
77
+ * In this process, we start a local express server to handle the redirect
78
+ * from the SmartHQ authorization endpoint.
79
+ * When the user clicks the link in Homebridge UI, they will be directed to the /login route
80
+ * which will redirect to SmartHQ authorization endpoint to obtain authorization code.
81
+ * After user login, SmartHQ authorization endpoint will redirect to whatever url:port/path was configured
82
+ * with authorization code in the query parameter.
83
+ * We will use this code to obtain access token and refresh token
84
+ *================================================================*/
85
+
86
+ async startOAuth() {
87
+ const path: string = this.tokenPath;
88
+
89
+ if (!fs.existsSync(path)) {
90
+ this.debug('blue', '=== file does not exist starting OAuth process ===');
91
+ this.debug('red', ' Starting localhost server to handle OAuth redirects');
92
+ } else {
93
+ this.debug('red', ' Returning because token path exists, emitting authComplete');
94
+ this.authEmitter.emit('authComplete');
95
+ return;
96
+ }
97
+
98
+ const url = new URL(this.config.redirectUri);
99
+ const pathname: string = url.pathname;
100
+ const port: number = parseInt(url.port, 10);
101
+ this.debug('blue', ' Redirect URI port: ' + port + ' , ' + pathname);
102
+
103
+ const app = express();
104
+
105
+ // By clicking on the link in Homebridge UI (Logs), user will be directed to the /login route
106
+ // which will redirect to SmartHQ authorization endpoint to obtain authorization code
107
+
108
+ this.log.info(chalk.blue("======================================================================="));
109
+ this.log.info("Click to login for SmartHQ Auth setup ===>: " + chalk.red("http://localhost:" + port + "/login"));
110
+ this.log.info(chalk.blue("======================================================================="));
111
+
112
+
113
+ // Clicking above link in Homebridge UI will redirect to SmartHQ authorization endpoint
114
+
115
+ app.get('/login', async (_req, res) => {
116
+
117
+ try {
118
+
119
+ const url = AUTH_URL + '?' + stringify({
120
+ response_type: 'code',
121
+ client_id: this.config.clientId,
122
+ redirect_uri: this.config.redirectUri
123
+ });
124
+
125
+ this.debug('blue', "Redirecting to: " + url);
126
+ res.redirect(url); ///====> Redirect to SmartHQ authorization endpoint
127
+
128
+ } catch (error) {
129
+ this.log.error('SmartHQ /login error:', error);
130
+ res.status(500).send('Authentication /login failed: ' + error);};
131
+ });
132
+
133
+ // After user login, SmartHQ authorization endpoint will redirect to whatever url:port/path was configured
134
+ // with authorization code in the query parameter.
135
+ // We will use this code to obtain access token and refresh token
136
+
137
+ app.get(pathname, async (req, res) => {
138
+ try {
139
+ // Parse the request URL
140
+ const query = parse(req.url!.split('?')[1]);
141
+
142
+ const code = query.code?.toString() || '';
143
+ this.debug('blue', 'An authorization code was returned: ' + code);
144
+ this.debug('blue', 'Now exchanging code for access token...');
145
+ if (!code) {
146
+ this.log.error('No code found in callback URL for SmartHQ OAuth');
147
+ }
148
+
149
+ await this.smartHqApi.exchangeCodeForToken(code);
150
+
151
+ res.send(`
152
+ <h2>SmartHQ Connected</h2>
153
+ <p>Authorization token saved to file.</p>
154
+ <p>You may close this window.</p>
155
+ `);
156
+ // Emit event to indicate authentication is complete
157
+ this.authEmitter.emit('authComplete');
158
+
159
+ this.log.success('SmartHQ authentication completed');
160
+
161
+ setTimeout(() => this.oauthServer?.close(), 1000);
162
+ this.debug('blue', 'OAuth server has been closed.');
163
+ return;
164
+
165
+ } catch (error) {
166
+ this.log.error('SmartHQ OAuth failed', error);
167
+ res.status(500).send('Authentication failed: ' + error);
168
+ }
169
+ });
170
+
171
+ this.oauthServer = app.listen(port, () => {
172
+ this.log.info(`SmartHQ OAuth listening on ${port}`);
173
+ });
174
+ }
175
+
176
+ // Extract port and pathname from redirect URI
177
+
178
+ extractPortFromRedirectUri(redirectUri: string): { port: number; pathname: string } {
179
+ const url = new URL(redirectUri);
180
+ const pathname: string = url.pathname;
181
+ const port: number = parseInt(url.port, 10);
182
+ return { port, pathname };
183
+ }
184
+
185
+
186
+ configureAccessory(accessory: PlatformAccessory) {
187
+ this.log.info('Loading accessory from cache:', accessory.displayName);
188
+
189
+ // add the restored accessory to the accessories cache, so we can track if it has already been registered
190
+ this.accessories.set(accessory.UUID, accessory);
191
+ }
192
+
193
+
194
+ private async discoverDevices() {
195
+ this.debug('yellow', '(discoverDevices) Starting device discovery...');
196
+ let devices: DevDevice[] = [];
197
+ devices = await this.smartHqApi.getAppliances();
198
+
199
+ // loop over the discovered devices and register each one if it has not already been registered
200
+
201
+ if (!devices || devices.length === 0) {
202
+ this.log.warn(chalk.yellow('No SmartHQ devices found for this account.'));
203
+ return;
204
+ }
205
+ for (const device of devices) {
206
+ this.log.info(chalk.yellow(`SmartHQ Discovered device: ${device.nickname} Model: ${device.model}`));
207
+
208
+ // Used to acquire service IDs and service deviceTypes for deviceServiceState queries
209
+ const deviceServices = await this.smartHqApi.getDeviceServices(device.deviceId);
210
+
211
+ let accessoryType: PlatformAccessory | undefined;
212
+
213
+ const uuid = this.api.hap.uuid.generate(device.deviceId);
214
+ const existingAccessory = this.accessories.get(uuid);
215
+
216
+ // for existing accessories restore from cache
217
+
218
+ if (existingAccessory) {
219
+ this.log.info('Restoring existing accessory from cache:', existingAccessory.displayName);
220
+ accessoryType = existingAccessory;
221
+ existingAccessory.context.device = device;
222
+ this.api.updatePlatformAccessories([existingAccessory]);
223
+ } else {
224
+
225
+ // create new accessory
226
+
227
+ this.log.info('Adding new accessory:', device.nickname);
228
+ const accessory = new this.api.platformAccessory(device.nickname, uuid);
229
+ accessoryType = accessory;
230
+
231
+ accessory.context.device = device;
232
+ this.api.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [accessory]);
233
+ }
234
+ this.discoveredCacheUUIDs.push(uuid);
235
+
236
+ // Setup services based on device type when there are multiple device types in account
237
+ // add more case statements e.g. Washer, Dryer, Oven, etc.
238
+
239
+ switch (device.nickname) {
240
+ case 'Refrigerator':
241
+ this.debug('green', `Setting up Refrigerator services for ${device.nickname}`);
242
+ setupRefrigeratorServices.call(this, accessoryType, device, deviceServices);
243
+ break;
244
+ case 'someNewAppliance':
245
+ this.debug('green', `Logic not implemented for ${device.nickname}`);
246
+ break;
247
+ default:
248
+ this.debug('red', `not implemented device : for device ${device.nickname}`);
249
+ }
250
+ }
251
+
252
+ // remove accessories from the cache which are no longer present
253
+
254
+ for (const [uuid, accessory] of this.accessories) {
255
+ if (!this.discoveredCacheUUIDs.includes(uuid)) {
256
+ this.log.info('Removing existing accessory from cache:', accessory.displayName);
257
+ this.api.unregisterPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [accessory]);
258
+ }
259
+ }
260
+ }
261
+
262
+ public debug(color: string, message: string) {
263
+ if (this.config.debugLogging) {
264
+ switch(color) {
265
+ case 'red':
266
+ this.log.info(chalk.red('[Smarthq] ' + message));
267
+ break;
268
+ case 'blue':
269
+ this.log.info(chalk.blue('[Smarthq] ' + message));
270
+ break;
271
+ case 'green':
272
+ this.log.info(chalk.green('[Smarthq] ' + message));
273
+ break;
274
+ case 'yellow':
275
+ this.log.info(chalk.yellow('[Smarthq] ' + message));
276
+ break;
277
+ default:
278
+ this.log.info('[Smarthq] ' + message);
279
+ }
280
+ } else {
281
+ return;
282
+ }
283
+ }
284
+
285
+ // Some models may not have all services available so don't add service if not supported
286
+
287
+ public deviceSupportsThisService(deviceServices: DevService[],
288
+ serviceDeviceType: string,
289
+ serviceType: string,
290
+ domainType: string): boolean {
291
+ for (const service of deviceServices) {
292
+ if (service.serviceDeviceType === serviceDeviceType
293
+ && service.serviceType === serviceType
294
+ && service.domainType === domainType) {
295
+ return true;
296
+ }
297
+ }
298
+ return false;
299
+ }
300
+ }
@@ -0,0 +1,103 @@
1
+ import { CharacteristicValue, PlatformAccessory, Logging } from 'homebridge';
2
+ import { SmartHqPlatform } from '../platform.js';
3
+ import { SmartHqApi } from '../smartHqApi.js';
4
+ import { DevService } from '../smarthq-types.js';
5
+
6
+ /**
7
+ * Platform Accessory
8
+ * An instance of this class is created for each accessory your platform registers
9
+ * Each accessory may expose multiple services of different service types.
10
+ */
11
+ export class ControlLock {
12
+ private readonly smartHqApi: SmartHqApi;
13
+ private log : Logging;
14
+
15
+ constructor(
16
+ private readonly platform: SmartHqPlatform,
17
+ private readonly accessory: PlatformAccessory,
18
+ public readonly deviceServices: DevService[],
19
+ public readonly deviceId: string
20
+ ) {
21
+ this.platform = platform;
22
+ this.accessory = accessory;
23
+ this.deviceServices = deviceServices;
24
+ this.deviceId = deviceId;
25
+ this.log = platform.log;
26
+
27
+ this.smartHqApi = new SmartHqApi(this.platform);
28
+ //=====================================================================================
29
+ // Check to see if the device has any supported Convertible Drawer services
30
+ // If not, then don't add services for device that doesn't support it
31
+ //=====================================================================================
32
+
33
+ if (!this.platform.deviceSupportsThisService(this.deviceServices,
34
+ 'cloud.smarthq.device.appliance',
35
+ 'cloud.smarthq.service.toggle',
36
+ 'cloud.smarthq.domain.controls.lock')) {
37
+ this.log.info('No supported Control Lock service found for device: ' + this.accessory.displayName);
38
+ return;
39
+ }
40
+ this.platform.debug('green', 'Adding Controls Lock Switch');
41
+
42
+ //=====================================================================================
43
+ // create a Control Lock switch for the Refrigerator
44
+ //=====================================================================================
45
+ const displayName = "Controls Lock";
46
+
47
+ const controlsLock = this.accessory.getService(displayName)
48
+ || this.accessory.addService(this.platform.Service.Switch, displayName, 'control-lock-123');
49
+ controlsLock.setCharacteristic(this.platform.Characteristic.Name, displayName);
50
+
51
+ controlsLock.addOptionalCharacteristic(this.platform.Characteristic.ConfiguredName)
52
+ controlsLock.setCharacteristic(this.platform.Characteristic.ConfiguredName, displayName)
53
+
54
+ controlsLock.getCharacteristic(this.platform.Characteristic.On)
55
+ .onGet(this.getcontrolsLock.bind(this))
56
+ .onSet(this.setcontrolsLock.bind(this));
57
+
58
+ }
59
+
60
+ //=====================================================================================
61
+ async getcontrolsLock(): Promise<CharacteristicValue> {
62
+
63
+ let isOn = false;
64
+
65
+ for (const service of this.deviceServices) {
66
+ if (service.serviceDeviceType === 'cloud.smarthq.device.appliance'
67
+ && service.serviceType === 'cloud.smarthq.service.toggle'
68
+ && service.domainType === 'cloud.smarthq.domain.controls.lock') {
69
+ const state = await this.smartHqApi.getServiceState(this.deviceId, service.serviceId);
70
+ if (state?.on == null) {
71
+ this.platform.debug('blue', 'No response from setcontrolsLock command');
72
+ return false;
73
+ }
74
+ isOn = state?.on;
75
+ }
76
+ }
77
+ return isOn;
78
+ }
79
+
80
+ //=====================================================================================
81
+ async setcontrolsLock(value: CharacteristicValue) {
82
+
83
+ const cmdBody = {
84
+ command: {
85
+ commandType: 'cloud.smarthq.command.toggle.set',
86
+ on: value
87
+ },
88
+ kind: 'service#command',
89
+ deviceId: this.deviceId,
90
+ serviceDeviceType: 'cloud.smarthq.device.appliance',
91
+ serviceType: 'cloud.smarthq.service.toggle',
92
+ domainType: 'cloud.smarthq.domain.controls.lock'
93
+ };
94
+
95
+ const response = await this.smartHqApi.command(JSON.stringify(cmdBody));
96
+
97
+ if (response == null) {
98
+ this.platform.debug('blue', 'No response from setcontrolsLock command');
99
+ return;
100
+ }
101
+ this.platform.debug('blue', 'setcontrolsLock response: ' + response.outcome);
102
+ }
103
+ }