@mgurreta/homebridge-molekule 1.2.4
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/LICENSE +24 -0
- package/README.md +55 -0
- package/config.schema.json +38 -0
- package/dist/cognito.js +129 -0
- package/dist/cognito.js.map +1 -0
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -0
- package/dist/platform.js +107 -0
- package/dist/platform.js.map +1 -0
- package/dist/platformAccessory.js +216 -0
- package/dist/platformAccessory.js.map +1 -0
- package/dist/settings.js +12 -0
- package/dist/settings.js.map +1 -0
- package/eslint.config.js +45 -0
- package/package.json +45 -0
- package/src/cognito.ts +145 -0
- package/src/index.ts +11 -0
- package/src/platform.ts +119 -0
- package/src/platformAccessory.ts +299 -0
- package/src/settings.ts +9 -0
- package/tsconfig.json +15 -0
package/eslint.config.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
const js = require('@eslint/js');
|
|
2
|
+
const tseslint = require('@typescript-eslint/eslint-plugin');
|
|
3
|
+
const tsparser = require('@typescript-eslint/parser');
|
|
4
|
+
|
|
5
|
+
module.exports = [
|
|
6
|
+
js.configs.recommended,
|
|
7
|
+
{
|
|
8
|
+
files: ['**/*.ts'],
|
|
9
|
+
languageOptions: {
|
|
10
|
+
parser: tsparser,
|
|
11
|
+
parserOptions: {
|
|
12
|
+
ecmaVersion: 'latest',
|
|
13
|
+
sourceType: 'module',
|
|
14
|
+
project: './tsconfig.json',
|
|
15
|
+
},
|
|
16
|
+
globals: {
|
|
17
|
+
// Node.js globals
|
|
18
|
+
process: 'readonly',
|
|
19
|
+
__dirname: 'readonly',
|
|
20
|
+
__filename: 'readonly',
|
|
21
|
+
Buffer: 'readonly',
|
|
22
|
+
console: 'readonly',
|
|
23
|
+
global: 'readonly',
|
|
24
|
+
module: 'readonly',
|
|
25
|
+
require: 'readonly',
|
|
26
|
+
exports: 'readonly',
|
|
27
|
+
setInterval: 'readonly',
|
|
28
|
+
clearInterval: 'readonly',
|
|
29
|
+
setTimeout: 'readonly',
|
|
30
|
+
clearTimeout: 'readonly',
|
|
31
|
+
// Web APIs available in Node.js
|
|
32
|
+
fetch: 'readonly',
|
|
33
|
+
Response: 'readonly',
|
|
34
|
+
Request: 'readonly',
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
plugins: {
|
|
38
|
+
'@typescript-eslint': tseslint,
|
|
39
|
+
},
|
|
40
|
+
rules: {
|
|
41
|
+
...tseslint.configs.recommended.rules,
|
|
42
|
+
'@typescript-eslint/no-non-null-assertion': 'off',
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
];
|
package/package.json
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"private": false,
|
|
3
|
+
"displayName": "Homebridge Molekule",
|
|
4
|
+
"name": "@mgurreta/homebridge-molekule",
|
|
5
|
+
"version": "1.2.4",
|
|
6
|
+
"description": "A Plugin for Molekule Air Purifiers for use with Homebridge.",
|
|
7
|
+
"license": "Unlicense",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git@github.com:mgurreta/homebridge-molekule.git"
|
|
11
|
+
},
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/mgurreta/homebridge-molekule/issues"
|
|
14
|
+
},
|
|
15
|
+
"engines": {
|
|
16
|
+
"node": "^18.20.4 || ^20.15.1 || ^22",
|
|
17
|
+
"homebridge": "^1.6.0 || ^2.0.0-beta.0"
|
|
18
|
+
},
|
|
19
|
+
"type": "commonjs",
|
|
20
|
+
"main": "dist/index.js",
|
|
21
|
+
"scripts": {
|
|
22
|
+
"lint": "eslint src/**.ts --max-warnings=0",
|
|
23
|
+
"watch": "npm run build && npm link && nodemon",
|
|
24
|
+
"build": "rimraf ./dist && tsc",
|
|
25
|
+
"prepublishOnly": "npm run lint && npm run build"
|
|
26
|
+
},
|
|
27
|
+
"keywords": [
|
|
28
|
+
"homebridge-plugin"
|
|
29
|
+
],
|
|
30
|
+
"dependencies": {
|
|
31
|
+
"amazon-cognito-identity-js": "^6.3.16"
|
|
32
|
+
},
|
|
33
|
+
"devDependencies": {
|
|
34
|
+
"@eslint/js": "^10.0.1",
|
|
35
|
+
"@types/node": "^25.3.0",
|
|
36
|
+
"@typescript-eslint/eslint-plugin": "^8.56.0",
|
|
37
|
+
"@typescript-eslint/parser": "^8.56.0",
|
|
38
|
+
"eslint": "^10.0.1",
|
|
39
|
+
"homebridge": "^1.8.0 || ^2.0.0-beta.0",
|
|
40
|
+
"nodemon": "^3.1.14",
|
|
41
|
+
"rimraf": "^6.1.3",
|
|
42
|
+
"ts-node": "^10.9.2",
|
|
43
|
+
"typescript": "^5.9.3"
|
|
44
|
+
}
|
|
45
|
+
}
|
package/src/cognito.ts
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import {
|
|
2
|
+
AuthenticationDetails,
|
|
3
|
+
CognitoRefreshToken,
|
|
4
|
+
CognitoUser,
|
|
5
|
+
CognitoUserPool,
|
|
6
|
+
} from 'amazon-cognito-identity-js';
|
|
7
|
+
import { Logger, PlatformConfig } from 'homebridge';
|
|
8
|
+
|
|
9
|
+
let token = '';
|
|
10
|
+
let refreshToken: CognitoRefreshToken;
|
|
11
|
+
let authError: boolean;
|
|
12
|
+
// Molekule API settings
|
|
13
|
+
const ClientId = '1ec4fa3oriciupg94ugoi84kkk';
|
|
14
|
+
const PoolId = 'us-west-2_KqrEZKC6r';
|
|
15
|
+
const url = 'https://api.molekule.com/users/me/devices/';
|
|
16
|
+
|
|
17
|
+
export class HttpAJAX {
|
|
18
|
+
private readonly log: Logger;
|
|
19
|
+
email: string;
|
|
20
|
+
pass: string;
|
|
21
|
+
authenticationData;
|
|
22
|
+
userData;
|
|
23
|
+
userPool;
|
|
24
|
+
authenticationDetails;
|
|
25
|
+
cognitoUser;
|
|
26
|
+
userPoolData;
|
|
27
|
+
constructor(log: Logger, config: PlatformConfig) {
|
|
28
|
+
this.log = log;
|
|
29
|
+
this.email = config.email;
|
|
30
|
+
this.pass = config.password;
|
|
31
|
+
this.authenticationData = {
|
|
32
|
+
Username: this.email,
|
|
33
|
+
Password: this.pass,
|
|
34
|
+
};
|
|
35
|
+
this.userPoolData = {
|
|
36
|
+
UserPoolId: PoolId,
|
|
37
|
+
ClientId,
|
|
38
|
+
};
|
|
39
|
+
this.userPool = new CognitoUserPool(this.userPoolData);
|
|
40
|
+
this.userData = {
|
|
41
|
+
Username: this.email,
|
|
42
|
+
Pool: this.userPool,
|
|
43
|
+
};
|
|
44
|
+
this.authenticationDetails = new AuthenticationDetails(
|
|
45
|
+
this.authenticationData
|
|
46
|
+
);
|
|
47
|
+
this.cognitoUser = new CognitoUser(this.userData);
|
|
48
|
+
}
|
|
49
|
+
refreshAuthToken() {
|
|
50
|
+
return new Promise((resolve, reject) =>
|
|
51
|
+
this.cognitoUser.refreshSession(refreshToken, (err, session) => {
|
|
52
|
+
if (err) {
|
|
53
|
+
this.log.info(
|
|
54
|
+
'Auth token fetch using refresh token failed. Fallback to username/password'
|
|
55
|
+
);
|
|
56
|
+
this.log.debug(err);
|
|
57
|
+
reject(err);
|
|
58
|
+
} else {
|
|
59
|
+
this.log.info('✓ Token refresh successful');
|
|
60
|
+
authError = false;
|
|
61
|
+
token = session.getAccessToken().getJwtToken();
|
|
62
|
+
resolve(session);
|
|
63
|
+
}
|
|
64
|
+
})
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
initiateAuth() {
|
|
68
|
+
this.log.debug('email: ' + this.email);
|
|
69
|
+
this.log.debug('password: ' + this.pass);
|
|
70
|
+
return new Promise((resolve, reject) =>
|
|
71
|
+
this.cognitoUser.authenticateUser(this.authenticationDetails, {
|
|
72
|
+
onSuccess: (result) => {
|
|
73
|
+
token = result.getAccessToken().getJwtToken();
|
|
74
|
+
refreshToken = result.getRefreshToken();
|
|
75
|
+
this.log.info('✓ Valid Login Credentials');
|
|
76
|
+
authError = false;
|
|
77
|
+
resolve(result.getAccessToken().getJwtToken());
|
|
78
|
+
},
|
|
79
|
+
onFailure: (err) => {
|
|
80
|
+
this.log.error(
|
|
81
|
+
'API Authentication Failure, possibly a password/username error.'
|
|
82
|
+
);
|
|
83
|
+
reject(err);
|
|
84
|
+
},
|
|
85
|
+
})
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
async httpCall(
|
|
89
|
+
method: string,
|
|
90
|
+
extraUrl: string,
|
|
91
|
+
send: string,
|
|
92
|
+
retry: number
|
|
93
|
+
): Promise<Response> {
|
|
94
|
+
let response: Response;
|
|
95
|
+
if (authError)
|
|
96
|
+
await this.refreshAuthToken().catch((e) => {
|
|
97
|
+
this.initiateAuth().catch((e) => {
|
|
98
|
+
this.log.error(e);
|
|
99
|
+
return;
|
|
100
|
+
});
|
|
101
|
+
this.log.debug(e);
|
|
102
|
+
});
|
|
103
|
+
if (token === '' || authError)
|
|
104
|
+
await this.initiateAuth().catch((err) => {
|
|
105
|
+
this.log.error(err);
|
|
106
|
+
return;
|
|
107
|
+
});
|
|
108
|
+
if (method === 'GET') {
|
|
109
|
+
const contents = {
|
|
110
|
+
method,
|
|
111
|
+
headers: {
|
|
112
|
+
authorization: token,
|
|
113
|
+
'x-api-version': '1.0',
|
|
114
|
+
'content-type': 'application/json',
|
|
115
|
+
},
|
|
116
|
+
};
|
|
117
|
+
response = await fetch(url + extraUrl, contents);
|
|
118
|
+
this.log.debug('HTTP GET STATUS: ' + response.status);
|
|
119
|
+
this.log.debug('HTTP GET CONTENTS: ' + JSON.stringify(response));
|
|
120
|
+
if (response.status === 401 && retry > 0) {
|
|
121
|
+
authError = true;
|
|
122
|
+
return await this.httpCall(method, extraUrl, send, retry - 1);
|
|
123
|
+
} else return response;
|
|
124
|
+
} else {
|
|
125
|
+
const contents = {
|
|
126
|
+
method,
|
|
127
|
+
body: send,
|
|
128
|
+
headers: {
|
|
129
|
+
authorization: token,
|
|
130
|
+
'x-api-version': '1.0',
|
|
131
|
+
'content-type': 'application/json',
|
|
132
|
+
},
|
|
133
|
+
};
|
|
134
|
+
response = await fetch(url + extraUrl, contents);
|
|
135
|
+
this.log.debug(
|
|
136
|
+
'HTTP POST STATUS: ' + response.status + ' With contents: ' + send
|
|
137
|
+
);
|
|
138
|
+
if (response.status === 401 && retry > 0) {
|
|
139
|
+
authError = true;
|
|
140
|
+
return await this.httpCall(method, extraUrl, send, retry - 1);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return response;
|
|
144
|
+
}
|
|
145
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { API } from 'homebridge'
|
|
2
|
+
|
|
3
|
+
import { PLATFORM_NAME } from './settings'
|
|
4
|
+
import { MolekuleHomebridgePlatform } from './platform'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* This method registers the platform with Homebridge
|
|
8
|
+
*/
|
|
9
|
+
export = (api: API) => {
|
|
10
|
+
api.registerPlatform(PLATFORM_NAME, MolekuleHomebridgePlatform)
|
|
11
|
+
};
|
package/src/platform.ts
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { API, DynamicPlatformPlugin, Logger, PlatformAccessory, PlatformConfig, Service, Characteristic } from 'homebridge'
|
|
2
|
+
import { PLATFORM_NAME, PLUGIN_NAME } from './settings'
|
|
3
|
+
import { MolekulePlatformAccessory } from './platformAccessory'
|
|
4
|
+
import { HttpAJAX } from './cognito'
|
|
5
|
+
interface deviceData {
|
|
6
|
+
name: string
|
|
7
|
+
serialNumber: string
|
|
8
|
+
}
|
|
9
|
+
let intervalID: ReturnType<typeof setInterval>
|
|
10
|
+
const refreshInterval = 60 //token refresh interval in minutes
|
|
11
|
+
/**
|
|
12
|
+
* HomebridgePlatform
|
|
13
|
+
* This class is the main constructor for your plugin, this is where you should
|
|
14
|
+
* parse the user config and discover/register accessories with Homebridge.
|
|
15
|
+
*/
|
|
16
|
+
export class MolekuleHomebridgePlatform implements DynamicPlatformPlugin {
|
|
17
|
+
public readonly Service: typeof Service
|
|
18
|
+
public readonly Characteristic: typeof Characteristic
|
|
19
|
+
|
|
20
|
+
// this is used to track restored cached accessories
|
|
21
|
+
public readonly accessories: PlatformAccessory[] = []
|
|
22
|
+
|
|
23
|
+
constructor (
|
|
24
|
+
public readonly log: Logger,
|
|
25
|
+
public readonly config: PlatformConfig,
|
|
26
|
+
public readonly api: API,
|
|
27
|
+
private caller = new HttpAJAX(log, config),
|
|
28
|
+
) {
|
|
29
|
+
this.Service = this.api.hap.Service
|
|
30
|
+
this.Characteristic = this.api.hap.Characteristic
|
|
31
|
+
// When this event is fired it means Homebridge has restored all cached accessories from disk.
|
|
32
|
+
// Dynamic Platform plugins should only register new accessories after this event was fired,
|
|
33
|
+
// in order to ensure they weren't added to homebridge already. This event can also be used
|
|
34
|
+
// to start discovery of new accessories.
|
|
35
|
+
this.api.on('didFinishLaunching', () => {
|
|
36
|
+
log.debug('Executed didFinishLaunching callback')
|
|
37
|
+
// run the method to discover / register your devices as accessories
|
|
38
|
+
this.discoverDevices()
|
|
39
|
+
if (!intervalID) intervalID = setInterval(() => this.caller.refreshAuthToken(), refreshInterval*60*1000)
|
|
40
|
+
})
|
|
41
|
+
this.log.debug('Finished initializing platform ', PLATFORM_NAME)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* This function is invoked when homebridge restores cached accessories from disk at startup.
|
|
46
|
+
* It should be used to setup event handlers for characteristics and update respective values.
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
configureAccessory (accessory: PlatformAccessory) {
|
|
50
|
+
this.log.info('Loading accessory from cache:', accessory.displayName)
|
|
51
|
+
|
|
52
|
+
// add the restored accessory to the accessories cache so we can track if it has already been registered
|
|
53
|
+
this.accessories.push(accessory)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* This is an example method showing how to register discovered accessories.
|
|
58
|
+
* Accessories must only be registered once, previously created accessories
|
|
59
|
+
* must not be registered again to prevent "duplicate UUID" errors.
|
|
60
|
+
*/
|
|
61
|
+
async discoverDevices () {
|
|
62
|
+
this.log.debug('Discover Devices Called')
|
|
63
|
+
const response = this.caller.httpCall('GET', '', '', 1);
|
|
64
|
+
const devices = await (await response).json() as { content: deviceData[] };
|
|
65
|
+
// loop over the discovered devices and register each one if it has not already been registered
|
|
66
|
+
if ((await response).status !== 200)
|
|
67
|
+
{
|
|
68
|
+
this.log.error('Fatal error, discover devices failed. Try running homebridge in debug mode to see HTTP status code.')
|
|
69
|
+
return; //prevent crashes
|
|
70
|
+
}
|
|
71
|
+
devices.content.forEach((device : deviceData) => {
|
|
72
|
+
// generate a unique id for the accessory this should be generated from
|
|
73
|
+
// something globally unique, but constant, for example, the device serial
|
|
74
|
+
// number or MAC address
|
|
75
|
+
this.log.debug('found device from API: ' + JSON.stringify(device))
|
|
76
|
+
const uuid = this.api.hap.uuid.generate(device.serialNumber)
|
|
77
|
+
|
|
78
|
+
// see if an accessory with the same uuid has already been registered and restored from
|
|
79
|
+
// the cached devices we stored in the `configureAccessory` method above
|
|
80
|
+
const existingAccessory = this.accessories.find(accessory => accessory.UUID === uuid)
|
|
81
|
+
|
|
82
|
+
if (existingAccessory) {
|
|
83
|
+
// the accessory already exists
|
|
84
|
+
this.log.info('Restoring existing accessory from cache:', existingAccessory.displayName)
|
|
85
|
+
|
|
86
|
+
// if you need to update the accessory.context then you should run `api.updatePlatformAccessories`. eg.:
|
|
87
|
+
// existingAccessory.context.device = device;
|
|
88
|
+
// this.api.updatePlatformAccessories([existingAccessory]);
|
|
89
|
+
|
|
90
|
+
// create the accessory handler for the restored accessory
|
|
91
|
+
// this is imported from `platformAccessory.ts`
|
|
92
|
+
new MolekulePlatformAccessory(this, existingAccessory, this.config, this.log)
|
|
93
|
+
|
|
94
|
+
// it is possible to remove platform accessories at any time using `api.unregisterPlatformAccessories`, eg.:
|
|
95
|
+
// remove platform accessories when no longer present
|
|
96
|
+
// this.api.unregisterPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [existingAccessory]);
|
|
97
|
+
// this.log.info('Removing existing accessory from cache:', existingAccessory.displayName);
|
|
98
|
+
} else {
|
|
99
|
+
// the accessory does not yet exist, so we need to create it
|
|
100
|
+
this.log.info('Adding new accessory:', device.name)
|
|
101
|
+
|
|
102
|
+
// create a new accessory
|
|
103
|
+
const accessory = new this.api.platformAccessory(device.name, uuid)
|
|
104
|
+
|
|
105
|
+
// store a copy of the device object in the `accessory.context`
|
|
106
|
+
// the `context` property can be used to store any data about the accessory you may need
|
|
107
|
+
accessory.context.device = device
|
|
108
|
+
|
|
109
|
+
// create the accessory handler for the newly create accessory
|
|
110
|
+
// this is imported from `platformAccessory.ts`
|
|
111
|
+
new MolekulePlatformAccessory(this, accessory, this.config, this.log)
|
|
112
|
+
|
|
113
|
+
// link the accessory to your platform
|
|
114
|
+
this.api.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [accessory])
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
)
|
|
118
|
+
}
|
|
119
|
+
}
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Service,
|
|
3
|
+
PlatformAccessory,
|
|
4
|
+
CharacteristicValue,
|
|
5
|
+
Logger,
|
|
6
|
+
PlatformConfig,
|
|
7
|
+
} from 'homebridge';
|
|
8
|
+
import { HttpAJAX } from './cognito';
|
|
9
|
+
import { MolekuleHomebridgePlatform } from './platform';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Platform Accessory
|
|
13
|
+
* An instance of this class is created for each accessory your platform registers
|
|
14
|
+
* Each accessory may expose multiple services of different service types.
|
|
15
|
+
*/
|
|
16
|
+
export class MolekulePlatformAccessory {
|
|
17
|
+
private service: Service;
|
|
18
|
+
/**
|
|
19
|
+
* These are just used to create a working example
|
|
20
|
+
* You should implement your own code to track the state of your accessory
|
|
21
|
+
*/
|
|
22
|
+
private state = {
|
|
23
|
+
state: 0,
|
|
24
|
+
Speed: 0,
|
|
25
|
+
Filter: 100,
|
|
26
|
+
On: 0,
|
|
27
|
+
Auto: 0,
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
constructor(
|
|
31
|
+
private readonly platform: MolekuleHomebridgePlatform,
|
|
32
|
+
private readonly accessory: PlatformAccessory,
|
|
33
|
+
private readonly config: PlatformConfig,
|
|
34
|
+
private readonly log: Logger
|
|
35
|
+
) {
|
|
36
|
+
this.caller = new HttpAJAX(this.log, this.config);
|
|
37
|
+
|
|
38
|
+
// set accessory information
|
|
39
|
+
this.accessory
|
|
40
|
+
.getService(this.platform.Service.AccessoryInformation)!
|
|
41
|
+
.setCharacteristic(this.platform.Characteristic.Manufacturer, 'Molekule')
|
|
42
|
+
.setCharacteristic(
|
|
43
|
+
this.platform.Characteristic.Model,
|
|
44
|
+
accessory.context.device.model
|
|
45
|
+
)
|
|
46
|
+
.setCharacteristic(
|
|
47
|
+
this.platform.Characteristic.SerialNumber,
|
|
48
|
+
accessory.context.device.serialNumber
|
|
49
|
+
)
|
|
50
|
+
.setCharacteristic(
|
|
51
|
+
this.platform.Characteristic.FirmwareRevision,
|
|
52
|
+
accessory.context.device.firmwareVersion
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
// get the AirPurifier service if it exists, otherwise create a new AirPurifier service
|
|
56
|
+
// you can create multiple services for each accessory
|
|
57
|
+
this.service =
|
|
58
|
+
this.accessory.getService(this.platform.Service.AirPurifier) ||
|
|
59
|
+
this.accessory.addService(this.platform.Service.AirPurifier);
|
|
60
|
+
// set the service name, this is what is displayed as the default name on the Home app
|
|
61
|
+
// in this example we are using the name we stored in the `accessory.context` in the `discoverDevices` method.
|
|
62
|
+
this.service.setCharacteristic(
|
|
63
|
+
this.platform.Characteristic.Name,
|
|
64
|
+
accessory.context.device.name
|
|
65
|
+
);
|
|
66
|
+
// each service must implement at-minimum the "required characteristics" for the given service type
|
|
67
|
+
// see https://developers.homebridge.io/#/service/AirPurifier
|
|
68
|
+
|
|
69
|
+
// register handlers for the On/Off Characteristic
|
|
70
|
+
this.service
|
|
71
|
+
.getCharacteristic(this.platform.Characteristic.Active)
|
|
72
|
+
.onSet(this.handleActiveSet.bind(this)) // SET - bind to the `handleActiveSet` method below
|
|
73
|
+
.onGet(this.handleActiveGet.bind(this)); // GET - bind to the `handleActiveGet` method below
|
|
74
|
+
// register handlers for the CurrentAirPurifierState Characteristic
|
|
75
|
+
this.service
|
|
76
|
+
.getCharacteristic(this.platform.Characteristic.CurrentAirPurifierState)
|
|
77
|
+
.onGet(this.getState.bind(this)); // GET - bind to the `getState` method below
|
|
78
|
+
// register handlers for the TargetAirPurifierState Characteristic
|
|
79
|
+
this.service
|
|
80
|
+
.getCharacteristic(this.platform.Characteristic.TargetAirPurifierState)
|
|
81
|
+
.onSet(this.handleAutoSet.bind(this))
|
|
82
|
+
.onGet(this.handleAutoGet.bind(this));
|
|
83
|
+
this.service
|
|
84
|
+
.getCharacteristic(this.platform.Characteristic.RotationSpeed)
|
|
85
|
+
.onSet(this.setSpeed.bind(this))
|
|
86
|
+
.onGet(this.getSpeed.bind(this));
|
|
87
|
+
|
|
88
|
+
this.service
|
|
89
|
+
.getCharacteristic(this.platform.Characteristic.FilterChangeIndication)
|
|
90
|
+
.onGet(this.getFilterChange.bind(this));
|
|
91
|
+
this.service
|
|
92
|
+
.getCharacteristic(this.platform.Characteristic.FilterLifeLevel)
|
|
93
|
+
.onGet(this.getFilterStatus.bind(this));
|
|
94
|
+
/**
|
|
95
|
+
* Creating multiple services of the same type.
|
|
96
|
+
*
|
|
97
|
+
* To avoid "Cannot add a Service with the same UUID another Service without also defining a unique 'subtype' property." error,
|
|
98
|
+
* when creating multiple services of the same type, you need to use the following syntax to specify a name and subtype id:
|
|
99
|
+
* this.accessory.getService('NAME') || this.accessory.addService(this.platform.Service.Lightbulb, 'NAME', 'USER_DEFINED_SUBTYPE_ID');
|
|
100
|
+
*
|
|
101
|
+
* The USER_DEFINED_SUBTYPE must be unique to the platform accessory (if you platform exposes multiple accessories, each accessory
|
|
102
|
+
* can use the same sub type id.)
|
|
103
|
+
*/
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
private caller: HttpAJAX;
|
|
107
|
+
/**
|
|
108
|
+
* Handle "SET" requests from HomeKit
|
|
109
|
+
* These are sent when the user changes the state of an accessory, for example, turning on a Light bulb.
|
|
110
|
+
*/
|
|
111
|
+
async handleActiveSet(value: CharacteristicValue) {
|
|
112
|
+
// implement your own code to turn your device on/off
|
|
113
|
+
let data = '"on"}';
|
|
114
|
+
if (!value) data = '"off"}';
|
|
115
|
+
const response = await this.caller.httpCall(
|
|
116
|
+
'POST',
|
|
117
|
+
this.accessory.context.device.serialNumber + '/actions/set-power-status',
|
|
118
|
+
'{"status":' + data,
|
|
119
|
+
1
|
|
120
|
+
);
|
|
121
|
+
if (response.status === 204) {
|
|
122
|
+
this.service.updateCharacteristic(
|
|
123
|
+
this.platform.Characteristic.Active,
|
|
124
|
+
value
|
|
125
|
+
);
|
|
126
|
+
if (value) {
|
|
127
|
+
this.service.updateCharacteristic(
|
|
128
|
+
this.platform.Characteristic.CurrentAirPurifierState,
|
|
129
|
+
2
|
|
130
|
+
);
|
|
131
|
+
this.state.state = 2;
|
|
132
|
+
this.state.On = 1;
|
|
133
|
+
} else {
|
|
134
|
+
this.service.updateCharacteristic(
|
|
135
|
+
this.platform.Characteristic.CurrentAirPurifierState,
|
|
136
|
+
0
|
|
137
|
+
);
|
|
138
|
+
this.state.On = 0;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
this.platform.log.info(
|
|
142
|
+
'Attempted to set: ' +
|
|
143
|
+
value +
|
|
144
|
+
' state on device: ' +
|
|
145
|
+
this.accessory.context.device.name +
|
|
146
|
+
' Server Reply: ' +
|
|
147
|
+
JSON.stringify(response)
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Handle the "GET" requests from HomeKit
|
|
153
|
+
* These are sent when HomeKit wants to know the current state of the accessory, for example, checking if a Light bulb is on.
|
|
154
|
+
*
|
|
155
|
+
* GET requests should return as fast as possbile. A long delay here will result in
|
|
156
|
+
* HomeKit being unresponsive and a bad user experience() in general.
|
|
157
|
+
*
|
|
158
|
+
* If your device takes time to respond you should update the status of your device
|
|
159
|
+
* asynchronously instead using the `updateCharacteristic` method instead.
|
|
160
|
+
* @example
|
|
161
|
+
* this.service.updateCharacteristic(this.platform.Characteristic.On, true)
|
|
162
|
+
*/
|
|
163
|
+
async handleActiveGet(): Promise<CharacteristicValue> {
|
|
164
|
+
if ((await this.updateStates()) === 1)
|
|
165
|
+
throw new this.platform.api.hap.HapStatusError(
|
|
166
|
+
this.platform.api.hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE
|
|
167
|
+
);
|
|
168
|
+
this.log.info(
|
|
169
|
+
this.accessory.context.device.name + ' state is: ' + this.state.On
|
|
170
|
+
);
|
|
171
|
+
return this.state.On;
|
|
172
|
+
// if you need to return an error to show the device as "Not Responding" in the Home app:
|
|
173
|
+
// throw new this.platform.api.hap.HapStatusError(this.platform.api.hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async getState(): Promise<CharacteristicValue> {
|
|
177
|
+
return this.state.state;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async handleAutoSet(value: CharacteristicValue) {
|
|
181
|
+
try {
|
|
182
|
+
if (value === 1) {
|
|
183
|
+
await this.setSpeed((100 / 3) * 2);
|
|
184
|
+
}
|
|
185
|
+
} catch {
|
|
186
|
+
throw new this.platform.api.hap.HapStatusError(
|
|
187
|
+
this.platform.api.hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
this.service.updateCharacteristic(
|
|
192
|
+
this.platform.Characteristic.TargetAirPurifierState,
|
|
193
|
+
value
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async handleAutoGet() {
|
|
198
|
+
if ((await this.updateStates()) === 1)
|
|
199
|
+
throw new this.platform.api.hap.HapStatusError(
|
|
200
|
+
this.platform.api.hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE
|
|
201
|
+
);
|
|
202
|
+
return this.state.Auto;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Handle "SET" requests from HomeKit
|
|
207
|
+
* These are sent when the user changes the state of an accessory, for example, changing the speed
|
|
208
|
+
*/
|
|
209
|
+
async setSpeed(value: CharacteristicValue) {
|
|
210
|
+
const clamp = Math.round(
|
|
211
|
+
Math.min(Math.max((value as number) / 33.33333333, 1), 3)
|
|
212
|
+
);
|
|
213
|
+
|
|
214
|
+
const setFanSpeedRequest = await this.caller.httpCall(
|
|
215
|
+
'POST',
|
|
216
|
+
this.accessory.context.device.serialNumber + '/actions/set-fan-speed',
|
|
217
|
+
'{"fanSpeed": ' + clamp + '}',
|
|
218
|
+
1
|
|
219
|
+
);
|
|
220
|
+
|
|
221
|
+
if (setFanSpeedRequest.status === 204) {
|
|
222
|
+
this.state.Speed = clamp * 33.33333333;
|
|
223
|
+
this.state.Auto = clamp === 2 ? 1 : 0;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
this.platform.log.info(
|
|
227
|
+
this.accessory.context.device.name + ' set speed -> ',
|
|
228
|
+
'{"fanSpeed":' + clamp + '}'
|
|
229
|
+
);
|
|
230
|
+
this.service.updateCharacteristic(
|
|
231
|
+
this.platform.Characteristic.RotationSpeed,
|
|
232
|
+
this.state.Speed
|
|
233
|
+
);
|
|
234
|
+
this.service.updateCharacteristic(
|
|
235
|
+
this.platform.Characteristic.TargetAirPurifierState,
|
|
236
|
+
this.state.Auto
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
async getSpeed(): Promise<CharacteristicValue> {
|
|
241
|
+
return this.state.Speed;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
async getFilterChange(): Promise<CharacteristicValue> {
|
|
245
|
+
if (this.state.Filter > this.config.threshold) return 0;
|
|
246
|
+
else return 1;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
async getFilterStatus(): Promise<CharacteristicValue> {
|
|
250
|
+
this.log.debug('Check Filter State: ' + this.state.Filter);
|
|
251
|
+
return this.state.Filter;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
async updateStates() {
|
|
255
|
+
const re = await this.caller.httpCall('GET', '', '', 1);
|
|
256
|
+
const response = await re.json() as { content: { serialNumber: string; fanspeed: string; pecoFilter: number; online: string; mode: string }[] } | null;
|
|
257
|
+
if (!response) return 1;
|
|
258
|
+
for (let i = 0; i < Object.keys(response.content).length; i++) {
|
|
259
|
+
if (
|
|
260
|
+
response.content[i].serialNumber ===
|
|
261
|
+
this.accessory.context.device.serialNumber
|
|
262
|
+
) {
|
|
263
|
+
this.platform.log.info('Get Speed ->', response.content[i].fanspeed);
|
|
264
|
+
|
|
265
|
+
this.state.Speed = Number(response.content[i].fanspeed) * 33.33333333;
|
|
266
|
+
this.state.Filter = response.content[i].pecoFilter;
|
|
267
|
+
this.state.Auto = response.content[i].fanspeed === '2' ? 1 : 0;
|
|
268
|
+
|
|
269
|
+
if (response.content[i].online === 'false') {
|
|
270
|
+
this.log.error(
|
|
271
|
+
this.accessory.context.device.name +
|
|
272
|
+
' was reported to be offline by the Molekule API.'
|
|
273
|
+
);
|
|
274
|
+
return 1;
|
|
275
|
+
}
|
|
276
|
+
if (response.content[i].mode !== 'off') {
|
|
277
|
+
this.state.On = 1;
|
|
278
|
+
this.state.state = 2;
|
|
279
|
+
} else {
|
|
280
|
+
this.state.On = 0;
|
|
281
|
+
this.state.state = 0;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
this.service.updateCharacteristic(
|
|
286
|
+
this.platform.Characteristic.RotationSpeed,
|
|
287
|
+
this.state.Speed
|
|
288
|
+
);
|
|
289
|
+
this.service.updateCharacteristic(
|
|
290
|
+
this.platform.Characteristic.CurrentAirPurifierState,
|
|
291
|
+
this.state.state
|
|
292
|
+
);
|
|
293
|
+
this.service.updateCharacteristic(
|
|
294
|
+
this.platform.Characteristic.Active,
|
|
295
|
+
this.state.On
|
|
296
|
+
);
|
|
297
|
+
return 0;
|
|
298
|
+
}
|
|
299
|
+
}
|
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 = 'Molekule'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* This must match the name of your plugin as defined the package.json
|
|
8
|
+
*/
|
|
9
|
+
export const PLUGIN_NAME = '@mgurreta/homebridge-molekule'
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "node16",
|
|
5
|
+
"lib": ["ES2022"],
|
|
6
|
+
"strict": true,
|
|
7
|
+
"esModuleInterop": true,
|
|
8
|
+
"forceConsistentCasingInFileNames": true,
|
|
9
|
+
"sourceMap": true,
|
|
10
|
+
"rootDir": "src",
|
|
11
|
+
"outDir": "dist",
|
|
12
|
+
"noImplicitAny": false
|
|
13
|
+
},
|
|
14
|
+
"include": ["src"]
|
|
15
|
+
}
|