homebridge-interqr 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.
- package/LICENSE +21 -0
- package/README.md +76 -0
- package/config.schema.json +37 -0
- package/dist/api.js +144 -0
- package/dist/index.js +6 -0
- package/dist/platform.js +127 -0
- package/dist/platformAccessory.js +54 -0
- package/dist/settings.js +11 -0
- package/homebridge-ui/public/index.html +117 -0
- package/homebridge-ui/server.js +50 -0
- package/package.json +50 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Or Evron
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="https://raw.githubusercontent.com/orevron/ha-interqr/main/logo.png" alt="InterQR Logo" width="180" />
|
|
3
|
+
</p>
|
|
4
|
+
|
|
5
|
+
# homebridge-interqr
|
|
6
|
+
|
|
7
|
+
<p align="center">
|
|
8
|
+
<a href="https://github.com/orevron/homebridge-interqr/releases"><img alt="Release" src="https://img.shields.io/github/v/release/orevron/homebridge-interqr?style=flat-square" /></a>
|
|
9
|
+
<a href="https://github.com/homebridge/homebridge"><img alt="Homebridge Compatible" src="https://img.shields.io/badge/homebridge-compatible-4db8ea.svg?style=flat-square" /></a>
|
|
10
|
+
<a href="https://github.com/orevron/homebridge-interqr/blob/main/LICENSE"><img alt="License" src="https://img.shields.io/github/license/orevron/homebridge-interqr?style=flat-square" /></a>
|
|
11
|
+
</p>
|
|
12
|
+
|
|
13
|
+
<p align="center">Control your InterQR smart locks directly from Homebridge.</p>
|
|
14
|
+
|
|
15
|
+
## Description
|
|
16
|
+
|
|
17
|
+
**InterQR** is a QR-code-based building access and smart lock system commonly used in residential and office buildings. The `homebridge-interqr` plugin allows you to integrate your InterQR locks seamlessly into your Apple Home ecosystem.
|
|
18
|
+
|
|
19
|
+
You can easily add your locks using the same phone number and SMS verification code used in the InterQR mobile app. Once authenticated, your authorized locks automatically populate as physical Lock mechanisms within Homebridge and Apple Home.
|
|
20
|
+
|
|
21
|
+
## Features
|
|
22
|
+
|
|
23
|
+
- **Unlock InterQR Locks:** Open connected building or office doors with a tap from the Home app or via Siri.
|
|
24
|
+
- **Auto-Relock State Tracking:** InterQR systems are typically "unlock-only" access controls (the physical door locks automatically). To ensure consistency, Homebridge will automatically revert the device state back to "locked" 5 seconds after an unlock command is issued.
|
|
25
|
+
- **Seamless Authentication:** Log in securely via the Homebridge UI settings using your mobile number and SMS 2FA code. The plugin securely manages session tokens in the background and attempts automatic silent re-authentication.
|
|
26
|
+
- **Multi-Lock Support:** Automatically discovers and creates accessory entities for all locks assigned to your InterQR account.
|
|
27
|
+
|
|
28
|
+
## Supported Devices
|
|
29
|
+
|
|
30
|
+
- InterQR Smart Locks (including Palgate-compatible locks managed via InterQR)
|
|
31
|
+
|
|
32
|
+
## Installation & Setup
|
|
33
|
+
|
|
34
|
+
1. Install the plugin using `npm`:
|
|
35
|
+
```sh
|
|
36
|
+
npm install -g homebridge-interqr
|
|
37
|
+
```
|
|
38
|
+
Or install it directly via the Homebridge Config UI X (search for `homebridge-interqr`).
|
|
39
|
+
|
|
40
|
+
2. Navigate to the Plugins tab in your Homebridge Config UI X.
|
|
41
|
+
|
|
42
|
+
3. Open the **Settings** for `homebridge-interqr`.
|
|
43
|
+
|
|
44
|
+
4. Use the custom configuration interface to:
|
|
45
|
+
- Enter your phone number (with the country code, e.g., `+1234567890`)
|
|
46
|
+
- Click "Send SMS Code"
|
|
47
|
+
- Enter the 4-8 digit SMS verification code received
|
|
48
|
+
- Click "Verify Code"
|
|
49
|
+
|
|
50
|
+
5. Restart Homebridge. Your connected locks will be discovered and added to Apple Home automatically.
|
|
51
|
+
|
|
52
|
+
## Usage
|
|
53
|
+
|
|
54
|
+
Once installed and configured, your InterQR lock(s) will appear as standard Lock accessories in Homebridge and Apple Home, displaying a Locked/Unlocked status.
|
|
55
|
+
|
|
56
|
+
### Behaviours
|
|
57
|
+
|
|
58
|
+
- **Unlocking:** Toggling the lock to "Unlocked" will trigger the physical unlock command via the InterQR cloud API.
|
|
59
|
+
- **Auto Re-Locking:** As the physical InterQR locks are unlock-only, the Apple Home accessory status will briefly show "Unlocked" and wait 5 seconds before reverting to "Locked" to reflect the actual state of the entrance door.
|
|
60
|
+
- **Actioning "Lock":** Manually toggling the lock to "Locked" does not send a command but correctly establishes the consistent state in HomeKit.
|
|
61
|
+
|
|
62
|
+
## Error Handling & Re-authentication
|
|
63
|
+
|
|
64
|
+
If a session token naturally expires, the plugin will log an error and attempt to silently refresh the token via the background API. If your token was significantly revoked, you may see an `Authentication failed` log. In this case, simply revisit the Homebridge UI Plugin Settings, walk through the SMS login workflow again, and restart the service.
|
|
65
|
+
|
|
66
|
+
## Security
|
|
67
|
+
|
|
68
|
+
This plugin:
|
|
69
|
+
- Employs secure SMS-based 2FA.
|
|
70
|
+
- Communicates directly with the official InterQR Cloud HTTPS API.
|
|
71
|
+
- Stores zero personally identifiable credential data in your public repositories.
|
|
72
|
+
- Only maintains minimal session payloads inside the standard config UI context JSON.
|
|
73
|
+
|
|
74
|
+
## Disclaimer
|
|
75
|
+
|
|
76
|
+
This plugin is not officially affiliated with or endorsed by InterQR. It was developed independently. Use at your own risk.
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"pluginAlias": "InterQR",
|
|
3
|
+
"pluginType": "platform",
|
|
4
|
+
"singular": true,
|
|
5
|
+
"customUi": true,
|
|
6
|
+
"headerDisplay": "Manage your InterQR locks.",
|
|
7
|
+
"schema": {
|
|
8
|
+
"type": "object",
|
|
9
|
+
"properties": {
|
|
10
|
+
"phoneNumber": {
|
|
11
|
+
"title": "Phone Number",
|
|
12
|
+
"type": "string",
|
|
13
|
+
"description": "The phone number used to login to InterQR (with country code, e.g., +1234567890)",
|
|
14
|
+
"required": true
|
|
15
|
+
},
|
|
16
|
+
"token": {
|
|
17
|
+
"title": "Authentication Token",
|
|
18
|
+
"type": "string",
|
|
19
|
+
"description": "Auth token (automatically generated via the login UI)",
|
|
20
|
+
"required": true
|
|
21
|
+
},
|
|
22
|
+
"deviceUuid": {
|
|
23
|
+
"title": "Device UUID",
|
|
24
|
+
"type": "string",
|
|
25
|
+
"description": "Device UUID (automatically generated via the login UI)",
|
|
26
|
+
"required": true
|
|
27
|
+
},
|
|
28
|
+
"updateInterval": {
|
|
29
|
+
"title": "Update Interval (minutes)",
|
|
30
|
+
"type": "integer",
|
|
31
|
+
"default": 5,
|
|
32
|
+
"description": "How often to poll the InterQR API for lock changes.",
|
|
33
|
+
"minimum": 1
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
package/dist/api.js
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.InterQRApiClient = void 0;
|
|
7
|
+
const crypto_1 = __importDefault(require("crypto"));
|
|
8
|
+
const DEFAULT_BASE_URL = "https://www.interqr.com/api";
|
|
9
|
+
const ENDPOINT_INIT = "/init";
|
|
10
|
+
const ENDPOINT_LOGIN = "/login";
|
|
11
|
+
const ENDPOINT_LOGOUT = "/logout";
|
|
12
|
+
const ENDPOINT_TWOFA_START = "/twofa/start";
|
|
13
|
+
const ENDPOINT_TWOFA_VERIFY = "/twofa/verify";
|
|
14
|
+
const ENDPOINT_USER_DETAILS = "/resource/user/details";
|
|
15
|
+
const ENDPOINT_UNLOCK = "/locks/{uuid}/unlock";
|
|
16
|
+
const ENDPOINT_UNLOCK_LONG = "/locks/{uuid}/unlock-long";
|
|
17
|
+
const APP_VERSION = "3.5.8";
|
|
18
|
+
const DEVICE_MANUFACTURER = "Athom";
|
|
19
|
+
const DEVICE_MODEL = "Integration";
|
|
20
|
+
const DEVICE_PLATFORM = "Homebridge";
|
|
21
|
+
class InterQRApiClient {
|
|
22
|
+
baseUrl;
|
|
23
|
+
token = null;
|
|
24
|
+
deviceUuid = null;
|
|
25
|
+
constructor(baseUrl = DEFAULT_BASE_URL) {
|
|
26
|
+
this.baseUrl = baseUrl.replace(/\/$/, '');
|
|
27
|
+
}
|
|
28
|
+
setToken(token) {
|
|
29
|
+
this.token = token;
|
|
30
|
+
}
|
|
31
|
+
setDeviceUuid(uuid) {
|
|
32
|
+
this.deviceUuid = uuid;
|
|
33
|
+
}
|
|
34
|
+
async _request(method, endpoint, options = {}) {
|
|
35
|
+
const url = `${this.baseUrl}${endpoint}`;
|
|
36
|
+
const headers = {
|
|
37
|
+
'Content-Type': 'application/json',
|
|
38
|
+
};
|
|
39
|
+
if (options.authenticated && this.token) {
|
|
40
|
+
headers['Authorization'] = `Bearer ${this.token}`;
|
|
41
|
+
}
|
|
42
|
+
const fetchOptions = {
|
|
43
|
+
method,
|
|
44
|
+
headers,
|
|
45
|
+
};
|
|
46
|
+
if (options.jsonData) {
|
|
47
|
+
fetchOptions.body = JSON.stringify(options.jsonData);
|
|
48
|
+
}
|
|
49
|
+
const response = await fetch(url, fetchOptions);
|
|
50
|
+
const contentType = response.headers.get('Content-Type') || '';
|
|
51
|
+
if (!contentType.includes('application/json')) {
|
|
52
|
+
throw new Error(`Unexpected response type from server: ${contentType}`);
|
|
53
|
+
}
|
|
54
|
+
const data = await response.json();
|
|
55
|
+
if (response.status === 401) {
|
|
56
|
+
throw new Error('Authentication failed');
|
|
57
|
+
}
|
|
58
|
+
if (response.status >= 400) {
|
|
59
|
+
throw new Error(data.message || `API error (HTTP ${response.status})`);
|
|
60
|
+
}
|
|
61
|
+
return data;
|
|
62
|
+
}
|
|
63
|
+
async initDevice(deviceUuid = null) {
|
|
64
|
+
if (!deviceUuid) {
|
|
65
|
+
deviceUuid = crypto_1.default.randomUUID();
|
|
66
|
+
}
|
|
67
|
+
this.deviceUuid = deviceUuid;
|
|
68
|
+
const payload = {
|
|
69
|
+
device_uuid: this.deviceUuid,
|
|
70
|
+
manufacturer: DEVICE_MANUFACTURER,
|
|
71
|
+
model: DEVICE_MODEL,
|
|
72
|
+
platform: DEVICE_PLATFORM,
|
|
73
|
+
os_version: "1.0",
|
|
74
|
+
app_version: APP_VERSION,
|
|
75
|
+
};
|
|
76
|
+
const result = await this._request('POST', ENDPOINT_INIT, { jsonData: payload });
|
|
77
|
+
if (result && result.data && result.data.device_uuid) {
|
|
78
|
+
this.deviceUuid = result.data.device_uuid;
|
|
79
|
+
}
|
|
80
|
+
return result;
|
|
81
|
+
}
|
|
82
|
+
async start2FA(phoneNumber, deviceUuid) {
|
|
83
|
+
const payload = {
|
|
84
|
+
number: phoneNumber,
|
|
85
|
+
device_uuid: deviceUuid,
|
|
86
|
+
};
|
|
87
|
+
return await this._request('POST', ENDPOINT_TWOFA_START, { jsonData: payload });
|
|
88
|
+
}
|
|
89
|
+
async verify2FA(phoneNumber, code, deviceUuid, secondAuthToken = null) {
|
|
90
|
+
const payload = {
|
|
91
|
+
number: phoneNumber,
|
|
92
|
+
code: code,
|
|
93
|
+
device_uuid: deviceUuid,
|
|
94
|
+
};
|
|
95
|
+
if (secondAuthToken) {
|
|
96
|
+
payload.second_auth_token = secondAuthToken;
|
|
97
|
+
}
|
|
98
|
+
const result = await this._request('POST', ENDPOINT_TWOFA_VERIFY, { jsonData: payload });
|
|
99
|
+
if (result && result.data && result.data.token) {
|
|
100
|
+
this.token = result.data.token;
|
|
101
|
+
}
|
|
102
|
+
else {
|
|
103
|
+
throw new Error('No token in verify response');
|
|
104
|
+
}
|
|
105
|
+
return result;
|
|
106
|
+
}
|
|
107
|
+
async login(deviceUuid = null) {
|
|
108
|
+
const uuidToUse = deviceUuid || this.deviceUuid;
|
|
109
|
+
if (!uuidToUse) {
|
|
110
|
+
throw new Error('No device_uuid available for login');
|
|
111
|
+
}
|
|
112
|
+
const payload = { device_uuid: uuidToUse };
|
|
113
|
+
const result = await this._request('POST', ENDPOINT_LOGIN, { jsonData: payload });
|
|
114
|
+
if (result && result.data && result.data.token) {
|
|
115
|
+
this.token = result.data.token;
|
|
116
|
+
}
|
|
117
|
+
return result;
|
|
118
|
+
}
|
|
119
|
+
async logout() {
|
|
120
|
+
if (!this.token)
|
|
121
|
+
return;
|
|
122
|
+
try {
|
|
123
|
+
await this._request('POST', ENDPOINT_LOGOUT, { authenticated: true });
|
|
124
|
+
}
|
|
125
|
+
catch (err) {
|
|
126
|
+
// Best-effort logout
|
|
127
|
+
}
|
|
128
|
+
finally {
|
|
129
|
+
this.token = null;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
async getUserDetails() {
|
|
133
|
+
return await this._request('GET', ENDPOINT_USER_DETAILS, { authenticated: true });
|
|
134
|
+
}
|
|
135
|
+
async unlock(lockUuid) {
|
|
136
|
+
const endpoint = ENDPOINT_UNLOCK.replace('{uuid}', lockUuid);
|
|
137
|
+
return await this._request('POST', endpoint, { authenticated: true });
|
|
138
|
+
}
|
|
139
|
+
async unlockLong(lockUuid) {
|
|
140
|
+
const endpoint = ENDPOINT_UNLOCK_LONG.replace('{uuid}', lockUuid);
|
|
141
|
+
return await this._request('POST', endpoint, { authenticated: true });
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
exports.InterQRApiClient = InterQRApiClient;
|
package/dist/index.js
ADDED
package/dist/platform.js
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.InterQRPlatform = void 0;
|
|
4
|
+
const settings_1 = require("./settings");
|
|
5
|
+
const platformAccessory_1 = require("./platformAccessory");
|
|
6
|
+
const api_1 = require("./api");
|
|
7
|
+
class InterQRPlatform {
|
|
8
|
+
log;
|
|
9
|
+
config;
|
|
10
|
+
api;
|
|
11
|
+
Service;
|
|
12
|
+
Characteristic;
|
|
13
|
+
accessories = new Map();
|
|
14
|
+
apiClient;
|
|
15
|
+
updateIntervalMs;
|
|
16
|
+
refreshTimer;
|
|
17
|
+
constructor(log, config, api) {
|
|
18
|
+
this.log = log;
|
|
19
|
+
this.config = config;
|
|
20
|
+
this.api = api;
|
|
21
|
+
this.Service = this.api.hap.Service;
|
|
22
|
+
this.Characteristic = this.api.hap.Characteristic;
|
|
23
|
+
this.log.debug('Finished initializing platform:', this.config.name);
|
|
24
|
+
this.apiClient = new api_1.InterQRApiClient();
|
|
25
|
+
this.updateIntervalMs = (this.config.updateInterval || 5) * 60 * 1000;
|
|
26
|
+
this.api.on('didFinishLaunching', () => {
|
|
27
|
+
this.log.debug('Executed didFinishLaunching callback');
|
|
28
|
+
if (!this.config.token || !this.config.deviceUuid) {
|
|
29
|
+
this.log.error('Authentication token or Device UUID missing. Please configure the plugin via the Homebridge UI.');
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
this.apiClient.setToken(this.config.token);
|
|
33
|
+
this.apiClient.setDeviceUuid(this.config.deviceUuid);
|
|
34
|
+
this.discoverDevices();
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
configureAccessory(accessory) {
|
|
38
|
+
this.log.info('Loading accessory from cache:', accessory.displayName);
|
|
39
|
+
this.accessories.set(accessory.UUID, accessory);
|
|
40
|
+
}
|
|
41
|
+
async discoverDevices() {
|
|
42
|
+
try {
|
|
43
|
+
const details = await this.apiClient.getUserDetails();
|
|
44
|
+
if (!details || !details.data || !details.data.locks) {
|
|
45
|
+
this.log.warn('No locks found for this account.');
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
const locks = details.data.locks;
|
|
49
|
+
const discoveredUUIDs = [];
|
|
50
|
+
for (const lock of locks) {
|
|
51
|
+
const uuid = this.api.hap.uuid.generate(lock.uuid);
|
|
52
|
+
discoveredUUIDs.push(uuid);
|
|
53
|
+
let accessory = this.accessories.get(uuid);
|
|
54
|
+
if (accessory) {
|
|
55
|
+
this.log.info('Restoring existing accessory from cache:', accessory.displayName);
|
|
56
|
+
accessory.context.device = lock;
|
|
57
|
+
this.api.updatePlatformAccessories([accessory]);
|
|
58
|
+
new platformAccessory_1.InterQRAccessory(this, accessory);
|
|
59
|
+
}
|
|
60
|
+
else {
|
|
61
|
+
this.log.info('Adding new accessory:', lock.description || lock.name);
|
|
62
|
+
accessory = new this.api.platformAccessory(lock.description || lock.name, uuid);
|
|
63
|
+
accessory.context.device = lock;
|
|
64
|
+
this.api.registerPlatformAccessories(settings_1.PLUGIN_NAME, settings_1.PLATFORM_NAME, [accessory]);
|
|
65
|
+
this.accessories.set(uuid, accessory);
|
|
66
|
+
new platformAccessory_1.InterQRAccessory(this, accessory);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
// Remove stale accessories
|
|
70
|
+
for (const [uuid, accessory] of this.accessories.entries()) {
|
|
71
|
+
if (!discoveredUUIDs.includes(uuid)) {
|
|
72
|
+
this.log.info('Removing existing accessory from cache:', accessory.displayName);
|
|
73
|
+
this.api.unregisterPlatformAccessories(settings_1.PLUGIN_NAME, settings_1.PLATFORM_NAME, [accessory]);
|
|
74
|
+
this.accessories.delete(uuid);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
this.scheduleNextUpdate();
|
|
78
|
+
}
|
|
79
|
+
catch (error) {
|
|
80
|
+
if (error.message.includes('Authentication failed')) {
|
|
81
|
+
this.log.warn('Authentication failed. Attempting silent token refresh via login...');
|
|
82
|
+
const success = await this.refreshAuthToken();
|
|
83
|
+
if (success) {
|
|
84
|
+
this.log.info('Token refreshed successfully. Retrying discovery...');
|
|
85
|
+
return this.discoverDevices();
|
|
86
|
+
}
|
|
87
|
+
else {
|
|
88
|
+
this.log.error('Failed to refresh token. Please re-authenticate via Homebridge Settings or check configuration.');
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
else {
|
|
92
|
+
this.log.error('Error discovering devices:', error.message);
|
|
93
|
+
}
|
|
94
|
+
// Try again later even if failed
|
|
95
|
+
this.scheduleNextUpdate();
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
async refreshAuthToken() {
|
|
99
|
+
try {
|
|
100
|
+
if (!this.config.deviceUuid)
|
|
101
|
+
return false;
|
|
102
|
+
const result = await this.apiClient.login(this.config.deviceUuid);
|
|
103
|
+
if (result && result.data && result.data.token) {
|
|
104
|
+
// Technically, this token is stored in memory. We'd ideally save it back to config.
|
|
105
|
+
// Since homebridge doesn't officially support programmatic config updates easily,
|
|
106
|
+
// we'll at least keep it working until restart. The user should update config if they restart.
|
|
107
|
+
this.config.token = result.data.token;
|
|
108
|
+
this.log.warn('Token refreshed in memory. Please note that if you restart Homebridge, you might need to re-login via UI if the old token is permanently revoked.');
|
|
109
|
+
return true;
|
|
110
|
+
}
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
catch (error) {
|
|
114
|
+
this.log.error('Silent refresh failed:', error.message);
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
scheduleNextUpdate() {
|
|
119
|
+
if (this.refreshTimer) {
|
|
120
|
+
clearTimeout(this.refreshTimer);
|
|
121
|
+
}
|
|
122
|
+
this.refreshTimer = setTimeout(() => {
|
|
123
|
+
this.discoverDevices();
|
|
124
|
+
}, this.updateIntervalMs);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
exports.InterQRPlatform = InterQRPlatform;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.InterQRAccessory = void 0;
|
|
4
|
+
class InterQRAccessory {
|
|
5
|
+
platform;
|
|
6
|
+
accessory;
|
|
7
|
+
service;
|
|
8
|
+
autoRelockTimer;
|
|
9
|
+
constructor(platform, accessory) {
|
|
10
|
+
this.platform = platform;
|
|
11
|
+
this.accessory = accessory;
|
|
12
|
+
const device = accessory.context.device;
|
|
13
|
+
this.accessory.getService(this.platform.Service.AccessoryInformation)
|
|
14
|
+
.setCharacteristic(this.platform.Characteristic.Manufacturer, 'InterQR')
|
|
15
|
+
.setCharacteristic(this.platform.Characteristic.Model, 'Smart Lock')
|
|
16
|
+
.setCharacteristic(this.platform.Characteristic.SerialNumber, device.uuid);
|
|
17
|
+
this.service = this.accessory.getService(this.platform.Service.LockMechanism) || this.accessory.addService(this.platform.Service.LockMechanism);
|
|
18
|
+
this.service.setCharacteristic(this.platform.Characteristic.Name, device.description || device.name);
|
|
19
|
+
// Initial state is Locked
|
|
20
|
+
this.service.updateCharacteristic(this.platform.Characteristic.LockCurrentState, this.platform.Characteristic.LockCurrentState.SECURED);
|
|
21
|
+
this.service.updateCharacteristic(this.platform.Characteristic.LockTargetState, this.platform.Characteristic.LockTargetState.SECURED);
|
|
22
|
+
this.service.getCharacteristic(this.platform.Characteristic.LockTargetState)
|
|
23
|
+
.onSet(this.setLockTargetState.bind(this));
|
|
24
|
+
}
|
|
25
|
+
async setLockTargetState(value) {
|
|
26
|
+
if (value === this.platform.Characteristic.LockTargetState.SECURED) {
|
|
27
|
+
this.platform.log.debug(`[${this.accessory.displayName}] Ignoring lock command since InterQR is unlock-only.`);
|
|
28
|
+
this.service.updateCharacteristic(this.platform.Characteristic.LockCurrentState, this.platform.Characteristic.LockCurrentState.SECURED);
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
// Unsecured requested
|
|
32
|
+
try {
|
|
33
|
+
this.platform.log.info(`[${this.accessory.displayName}] Sending unlock command...`);
|
|
34
|
+
await this.platform.apiClient.unlock(this.accessory.context.device.uuid);
|
|
35
|
+
this.platform.log.info(`[${this.accessory.displayName}] Unlocked successfully.`);
|
|
36
|
+
this.service.updateCharacteristic(this.platform.Characteristic.LockCurrentState, this.platform.Characteristic.LockCurrentState.UNSECURED);
|
|
37
|
+
if (this.autoRelockTimer) {
|
|
38
|
+
clearTimeout(this.autoRelockTimer);
|
|
39
|
+
}
|
|
40
|
+
this.autoRelockTimer = setTimeout(() => {
|
|
41
|
+
this.platform.log.debug(`[${this.accessory.displayName}] Auto-relocking homebridge state.`);
|
|
42
|
+
this.service.updateCharacteristic(this.platform.Characteristic.LockTargetState, this.platform.Characteristic.LockTargetState.SECURED);
|
|
43
|
+
this.service.updateCharacteristic(this.platform.Characteristic.LockCurrentState, this.platform.Characteristic.LockCurrentState.SECURED);
|
|
44
|
+
}, 5000);
|
|
45
|
+
}
|
|
46
|
+
catch (error) {
|
|
47
|
+
this.platform.log.error(`[${this.accessory.displayName}] Failed to unlock: ${error.message}`);
|
|
48
|
+
// Revert to locked state
|
|
49
|
+
this.service.updateCharacteristic(this.platform.Characteristic.LockTargetState, this.platform.Characteristic.LockTargetState.SECURED);
|
|
50
|
+
this.service.updateCharacteristic(this.platform.Characteristic.LockCurrentState, this.platform.Characteristic.LockCurrentState.SECURED);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
exports.InterQRAccessory = InterQRAccessory;
|
package/dist/settings.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.PLUGIN_NAME = exports.PLATFORM_NAME = void 0;
|
|
4
|
+
/**
|
|
5
|
+
* This is the name of the platform that users will use to register the plugin in the Homebridge config.json
|
|
6
|
+
*/
|
|
7
|
+
exports.PLATFORM_NAME = 'InterQR';
|
|
8
|
+
/**
|
|
9
|
+
* This must match the name of your plugin as defined the package.json
|
|
10
|
+
*/
|
|
11
|
+
exports.PLUGIN_NAME = 'homebridge-interqr';
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
<div class="card text-center" id="intro-card">
|
|
2
|
+
<div class="card-body">
|
|
3
|
+
<h5 class="card-title">InterQR Login</h5>
|
|
4
|
+
<p class="card-text">Connect to your InterQR account to control your locks from Homebridge.</p>
|
|
5
|
+
<button id="btn-start" class="btn btn-primary">Login with Phone Number</button>
|
|
6
|
+
</div>
|
|
7
|
+
</div>
|
|
8
|
+
|
|
9
|
+
<div class="card text-center d-none" id="phone-card">
|
|
10
|
+
<div class="card-body">
|
|
11
|
+
<h5 class="card-title">Enter Phone Number</h5>
|
|
12
|
+
<p class="card-text">Enter your phone number in international format (e.g. +1234567890).</p>
|
|
13
|
+
<div class="form-group mb-3">
|
|
14
|
+
<input type="text" class="form-control" id="phone-input" placeholder="+1234567890">
|
|
15
|
+
</div>
|
|
16
|
+
<button id="btn-request-sms" class="btn btn-primary">Send SMS Code</button>
|
|
17
|
+
</div>
|
|
18
|
+
</div>
|
|
19
|
+
|
|
20
|
+
<div class="card text-center d-none" id="code-card">
|
|
21
|
+
<div class="card-body">
|
|
22
|
+
<h5 class="card-title">Enter SMS Code</h5>
|
|
23
|
+
<p class="card-text">Enter the verification code sent to your phone.</p>
|
|
24
|
+
<div class="form-group mb-3">
|
|
25
|
+
<input type="text" class="form-control" id="code-input" placeholder="1234">
|
|
26
|
+
</div>
|
|
27
|
+
<button id="btn-verify-sms" class="btn btn-primary">Verify Code</button>
|
|
28
|
+
</div>
|
|
29
|
+
</div>
|
|
30
|
+
|
|
31
|
+
<div class="card text-center d-none" id="success-card">
|
|
32
|
+
<div class="card-body">
|
|
33
|
+
<h5 class="card-title text-success">Login Successful!</h5>
|
|
34
|
+
<p class="card-text">Your configuration has been updated. You can now close this window and restart Homebridge.</p>
|
|
35
|
+
</div>
|
|
36
|
+
</div>
|
|
37
|
+
|
|
38
|
+
<script>
|
|
39
|
+
(async () => {
|
|
40
|
+
// 1. Get plugin config to check if already logged in
|
|
41
|
+
const pluginConfig = await homebridge.getPluginConfig();
|
|
42
|
+
const isConfigured = pluginConfig && pluginConfig.length > 0 && pluginConfig[0].token;
|
|
43
|
+
|
|
44
|
+
if (isConfigured) {
|
|
45
|
+
document.getElementById('intro-card').innerHTML = `
|
|
46
|
+
<div class="card-body text-success">
|
|
47
|
+
<h5 class="card-title">Already Logged In</h5>
|
|
48
|
+
<p class="card-text">You are already connected to InterQR. Do you want to re-authenticate?</p>
|
|
49
|
+
<button id="btn-start" class="btn btn-warning">Re-Authenticate</button>
|
|
50
|
+
</div>
|
|
51
|
+
`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// 2. Start flow
|
|
55
|
+
document.getElementById('btn-start')?.addEventListener('click', () => {
|
|
56
|
+
document.getElementById('intro-card').classList.add('d-none');
|
|
57
|
+
document.getElementById('phone-card').classList.remove('d-none');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
let currentPhone = '';
|
|
61
|
+
|
|
62
|
+
// 3. Request SMS
|
|
63
|
+
document.getElementById('btn-request-sms').addEventListener('click', async () => {
|
|
64
|
+
currentPhone = document.getElementById('phone-input').value.trim();
|
|
65
|
+
if (!currentPhone) {
|
|
66
|
+
homebridge.toast.error('Please enter a phone number', 'Error');
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
homebridge.showSpinner();
|
|
71
|
+
try {
|
|
72
|
+
await homebridge.request('/request-sms', { phone: currentPhone });
|
|
73
|
+
homebridge.hideSpinner();
|
|
74
|
+
|
|
75
|
+
document.getElementById('phone-card').classList.add('d-none');
|
|
76
|
+
document.getElementById('code-card').classList.remove('d-none');
|
|
77
|
+
homebridge.toast.success('SMS code sent to your phone', 'Success');
|
|
78
|
+
} catch (err) {
|
|
79
|
+
homebridge.hideSpinner();
|
|
80
|
+
homebridge.toast.error(err.message || 'Failed to request SMS', 'Error');
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// 4. Verify SMS
|
|
85
|
+
document.getElementById('btn-verify-sms').addEventListener('click', async () => {
|
|
86
|
+
const code = document.getElementById('code-input').value.trim();
|
|
87
|
+
if (!code) {
|
|
88
|
+
homebridge.toast.error('Please enter the SMS code', 'Error');
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
homebridge.showSpinner();
|
|
93
|
+
try {
|
|
94
|
+
const response = await homebridge.request('/verify-sms', { phone: currentPhone, code: code });
|
|
95
|
+
|
|
96
|
+
// Update plugin config
|
|
97
|
+
const config = pluginConfig && pluginConfig.length > 0 ? pluginConfig[0] : { name: "InterQR" };
|
|
98
|
+
config.phoneNumber = currentPhone;
|
|
99
|
+
config.token = response.token;
|
|
100
|
+
config.deviceUuid = response.deviceUuid;
|
|
101
|
+
|
|
102
|
+
await homebridge.updatePluginConfig([config]);
|
|
103
|
+
await homebridge.savePluginConfig();
|
|
104
|
+
|
|
105
|
+
homebridge.hideSpinner();
|
|
106
|
+
|
|
107
|
+
document.getElementById('code-card').classList.add('d-none');
|
|
108
|
+
document.getElementById('success-card').classList.remove('d-none');
|
|
109
|
+
homebridge.toast.success('Login Successful! Please restart Homebridge.', 'Success');
|
|
110
|
+
} catch (err) {
|
|
111
|
+
homebridge.hideSpinner();
|
|
112
|
+
homebridge.toast.error(err.message || 'Failed to verify SMS', 'Error');
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
})();
|
|
117
|
+
</script>
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
const { HomebridgePluginUiServer } = require('@homebridge/plugin-ui-utils');
|
|
2
|
+
|
|
3
|
+
class PluginUiServer extends HomebridgePluginUiServer {
|
|
4
|
+
constructor() {
|
|
5
|
+
super();
|
|
6
|
+
let api;
|
|
7
|
+
try {
|
|
8
|
+
api = require('../dist/api').InterQRApiClient;
|
|
9
|
+
} catch (e) {
|
|
10
|
+
this.pushEvent('print-log', 'Failed to load api client. Please ensure you built the plugin.');
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
this.apiClient = new api();
|
|
14
|
+
this.currentDeviceUuid = null;
|
|
15
|
+
|
|
16
|
+
this.onRequest('/request-sms', this.handleRequestSms.bind(this));
|
|
17
|
+
this.onRequest('/verify-sms', this.handleVerifySms.bind(this));
|
|
18
|
+
|
|
19
|
+
this.ready();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async handleRequestSms(payload) {
|
|
23
|
+
try {
|
|
24
|
+
const initRes = await this.apiClient.initDevice();
|
|
25
|
+
this.currentDeviceUuid = initRes.data.device_uuid;
|
|
26
|
+
|
|
27
|
+
await this.apiClient.start2FA(payload.phone, this.currentDeviceUuid);
|
|
28
|
+
return { success: true };
|
|
29
|
+
} catch (e) {
|
|
30
|
+
throw new Error(e.message || 'Failed to request SMS code');
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async handleVerifySms(payload) {
|
|
35
|
+
try {
|
|
36
|
+
const res = await this.apiClient.verify2FA(payload.phone, payload.code, this.currentDeviceUuid);
|
|
37
|
+
return {
|
|
38
|
+
success: true,
|
|
39
|
+
token: res.data.token,
|
|
40
|
+
deviceUuid: this.currentDeviceUuid
|
|
41
|
+
};
|
|
42
|
+
} catch (e) {
|
|
43
|
+
throw new Error(e.message || 'Failed to verify SMS code');
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
(() => {
|
|
49
|
+
return new PluginUiServer();
|
|
50
|
+
})();
|
package/package.json
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "homebridge-interqr",
|
|
3
|
+
"displayName": "Homebridge InterQR",
|
|
4
|
+
"version": "1.0.0",
|
|
5
|
+
"description": "Homebridge plugin for InterQR smart locks",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git://github.com/orevron/homebridge-interqr.git"
|
|
10
|
+
},
|
|
11
|
+
"bugs": {
|
|
12
|
+
"url": "https://github.com/orevron/homebridge-interqr/issues"
|
|
13
|
+
},
|
|
14
|
+
"engines": {
|
|
15
|
+
"node": "^18.14.0 || ^20.0.0 || ^22.0.0",
|
|
16
|
+
"homebridge": "^1.6.0"
|
|
17
|
+
},
|
|
18
|
+
"main": "dist/index.js",
|
|
19
|
+
"scripts": {
|
|
20
|
+
"lint": "eslint src/**.ts --max-warnings=0",
|
|
21
|
+
"watch": "npm run build && npm run link && nodemon",
|
|
22
|
+
"build": "rimraf dist && tsc",
|
|
23
|
+
"prepublishOnly": "npm run lint && npm run build"
|
|
24
|
+
},
|
|
25
|
+
"keywords": [
|
|
26
|
+
"homebridge-plugin",
|
|
27
|
+
"interqr",
|
|
28
|
+
"smart-lock",
|
|
29
|
+
"lock"
|
|
30
|
+
],
|
|
31
|
+
"files": [
|
|
32
|
+
"dist",
|
|
33
|
+
"homebridge-ui",
|
|
34
|
+
"config.schema.json"
|
|
35
|
+
],
|
|
36
|
+
"dependencies": {
|
|
37
|
+
"axios": "^1.6.0"
|
|
38
|
+
},
|
|
39
|
+
"devDependencies": {
|
|
40
|
+
"@homebridge/plugin-ui-utils": "^1.0.1",
|
|
41
|
+
"@types/node": "^20.0.0",
|
|
42
|
+
"@typescript-eslint/eslint-plugin": "^7.0.0",
|
|
43
|
+
"@typescript-eslint/parser": "^7.0.0",
|
|
44
|
+
"eslint": "^8.0.0",
|
|
45
|
+
"homebridge": "^1.8.0",
|
|
46
|
+
"nodemon": "^3.0.0",
|
|
47
|
+
"rimraf": "^5.0.0",
|
|
48
|
+
"typescript": "^5.4.0"
|
|
49
|
+
}
|
|
50
|
+
}
|