homebridge-ge-ac 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 +15 -0
- package/README.md +82 -0
- package/config.schema.json +34 -0
- package/index.js +7 -0
- package/lib/accessory.js +143 -0
- package/lib/auth.js +111 -0
- package/lib/client.js +191 -0
- package/lib/const.js +57 -0
- package/lib/platform.js +63 -0
- package/package.json +40 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
ISC License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 the homebridge-ge-ac contributors
|
|
4
|
+
|
|
5
|
+
Permission to use, copy, modify, and/or distribute this software for any
|
|
6
|
+
purpose with or without fee is hereby granted, provided that the above
|
|
7
|
+
copyright notice and this permission notice appear in all copies.
|
|
8
|
+
|
|
9
|
+
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
|
|
10
|
+
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
|
|
11
|
+
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
|
|
12
|
+
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
|
|
13
|
+
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
|
|
14
|
+
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
|
|
15
|
+
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# homebridge-ge-ac
|
|
2
|
+
|
|
3
|
+
A [Homebridge](https://homebridge.io) plugin for **GE Profile / SmartHQ Wi‑Fi window air conditioners**
|
|
4
|
+
(e.g. the GE Profile ClearView **AHTT06BC**), exposing them to HomeKit with **reliable** setpoint,
|
|
5
|
+
mode, and fan control.
|
|
6
|
+
|
|
7
|
+
## Why this exists
|
|
8
|
+
|
|
9
|
+
GE's SmartHQ appliances expose two interfaces:
|
|
10
|
+
|
|
11
|
+
- a **REST** API that works fine for *reading* ERD values, and
|
|
12
|
+
- a persistent authenticated **WebSocket** ("pseudo‑MQTT") channel that the GE app — and the
|
|
13
|
+
reference Python library [`simbaja/gehome`](https://github.com/simbaja/gehome) — use for
|
|
14
|
+
*control*.
|
|
15
|
+
|
|
16
|
+
Existing Homebridge SmartHQ plugins write setpoints over **REST**. On at least the ClearView
|
|
17
|
+
window-AC line, the backend *accepts* those REST writes (`200 OK`) but silently never relays them
|
|
18
|
+
to the unit — so the temperature you set in HomeKit snaps right back. This plugin sends writes over
|
|
19
|
+
the **WebSocket** channel (`setErd`), which the appliance actually honors. Confirmed end‑to‑end:
|
|
20
|
+
the unit echoes the change back on `publish#erd`.
|
|
21
|
+
|
|
22
|
+
## Features
|
|
23
|
+
|
|
24
|
+
- Proper **HeaterCooler** thermostat — on/off, target temperature (64–86 °F), current temperature.
|
|
25
|
+
Setpoint changes **stick**.
|
|
26
|
+
- **Mode switches** with real names: Cool, Fan Only, Energy Saver, Dry. (No bogus "Heat" — these
|
|
27
|
+
are cooling-only units. Each switch sets `ConfiguredName`, so HomeKit never shows "Switch 1–5".)
|
|
28
|
+
- **Fan speed** via the rotation-speed slider (Auto / Low / Med / High).
|
|
29
|
+
- **Live updates** — state is pushed from the WebSocket subscription, so HomeKit reflects changes
|
|
30
|
+
made in the GE app or on the unit, in real time.
|
|
31
|
+
- Resilient: app‑level keepalive, OAuth token refresh ahead of expiry, and exponential‑backoff
|
|
32
|
+
auto‑reconnect.
|
|
33
|
+
|
|
34
|
+
## Install
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
npm install -g homebridge-ge-ac # or install a local checkout: npm install /path/to/homebridge-ge-ac
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Then add the platform via the Homebridge UI (search "GE Profile AC") or in `config.json`:
|
|
41
|
+
|
|
42
|
+
```json
|
|
43
|
+
{
|
|
44
|
+
"platforms": [
|
|
45
|
+
{
|
|
46
|
+
"platform": "GEProfileAC",
|
|
47
|
+
"name": "Air Conditioner",
|
|
48
|
+
"username": "you@example.com",
|
|
49
|
+
"password": "your-smarthq-password"
|
|
50
|
+
}
|
|
51
|
+
]
|
|
52
|
+
}
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
- `username` / `password` — the same credentials you use in the GE **SmartHQ** app. The AC must
|
|
56
|
+
already be set up and online in that app.
|
|
57
|
+
- `macAddress` *(optional)* — only needed if more than one appliance is on the account; otherwise
|
|
58
|
+
the air conditioner is auto-detected.
|
|
59
|
+
|
|
60
|
+
Running it as a **child bridge** (Homebridge UI → Bridge Settings) is recommended for isolation.
|
|
61
|
+
|
|
62
|
+
## How it works
|
|
63
|
+
|
|
64
|
+
| Concern | Approach |
|
|
65
|
+
| --- | --- |
|
|
66
|
+
| Auth | OAuth2 authorization-code flow scripted through the Brillion login form (`/oauth2/auth` → `g_authenticate` → `/oauth2/token`), then `GET /v1/websocket` for the signed socket endpoint + `userId`. |
|
|
67
|
+
| Transport | One WebSocket: `subscribe` to `/appliance/*/erd/*`, list appliances, pull full ERD state, then receive live `publish#erd` updates. |
|
|
68
|
+
| Writes | `setErd` over the socket — `{kind:"websocket#api", method:"POST", path:"/v1/appliance/{mac}/erd/{code}", body:{kind:"appliance#erdListEntry", …, value}}`, acked by echoed `id`. |
|
|
69
|
+
| Encoding | Target temp ERD `0x7003` = 2‑byte big‑endian °F (`72 → "0048"`); mode `0x7A01`, power `0x7A0F`, ambient `0x7A02`, fan `0x7A00`. |
|
|
70
|
+
|
|
71
|
+
See [`dev/`](dev/) for the standalone proof-of-concept (`poc-setpoint.js`) and module smoke test
|
|
72
|
+
(`smoke.js`) used to validate the WebSocket write path before building the plugin.
|
|
73
|
+
|
|
74
|
+
## Credits
|
|
75
|
+
|
|
76
|
+
The GE SmartHQ / Brillion protocol details (OAuth flow, WebSocket envelopes, ERD codes and
|
|
77
|
+
encodings) were derived from the excellent [`simbaja/gehome`](https://github.com/simbaja/gehome)
|
|
78
|
+
Python library. This plugin is an independent JavaScript implementation.
|
|
79
|
+
|
|
80
|
+
## License
|
|
81
|
+
|
|
82
|
+
ISC — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"pluginAlias": "GEProfileAC",
|
|
3
|
+
"pluginType": "platform",
|
|
4
|
+
"singular": true,
|
|
5
|
+
"headerDisplay": "Controls a GE Profile / SmartHQ Wi-Fi window air conditioner over GE's realtime WebSocket channel (reliable setpoint, mode, and fan control). Enter the same email + password you use in the GE SmartHQ app.",
|
|
6
|
+
"schema": {
|
|
7
|
+
"type": "object",
|
|
8
|
+
"properties": {
|
|
9
|
+
"name": {
|
|
10
|
+
"title": "Accessory Name",
|
|
11
|
+
"type": "string",
|
|
12
|
+
"default": "Air Conditioner",
|
|
13
|
+
"description": "Name shown in HomeKit (you can also rename it in the Home app)."
|
|
14
|
+
},
|
|
15
|
+
"username": {
|
|
16
|
+
"title": "SmartHQ Email",
|
|
17
|
+
"type": "string",
|
|
18
|
+
"required": true,
|
|
19
|
+
"format": "email"
|
|
20
|
+
},
|
|
21
|
+
"password": {
|
|
22
|
+
"title": "SmartHQ Password",
|
|
23
|
+
"type": "string",
|
|
24
|
+
"required": true,
|
|
25
|
+
"x-schema-form": { "type": "password" }
|
|
26
|
+
},
|
|
27
|
+
"macAddress": {
|
|
28
|
+
"title": "Appliance ID / MAC (optional)",
|
|
29
|
+
"type": "string",
|
|
30
|
+
"description": "Only needed if more than one appliance is on the account. Leave blank to auto-detect the air conditioner."
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
package/index.js
ADDED
package/lib/accessory.js
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const C = require('./const');
|
|
3
|
+
|
|
4
|
+
// Build a HeaterCooler + 4 mode switches for one GE AC, bound to the SmartHQ client.
|
|
5
|
+
class ACAccessory {
|
|
6
|
+
constructor(platform, accessory, client, mac) {
|
|
7
|
+
this.platform = platform;
|
|
8
|
+
this.log = platform.log;
|
|
9
|
+
this.accessory = accessory;
|
|
10
|
+
this.client = client;
|
|
11
|
+
this.mac = mac;
|
|
12
|
+
this.hap = platform.api.hap;
|
|
13
|
+
this.S = this.hap.Service;
|
|
14
|
+
this.Ch = this.hap.Characteristic;
|
|
15
|
+
const name = accessory.displayName;
|
|
16
|
+
|
|
17
|
+
accessory.getService(this.S.AccessoryInformation)
|
|
18
|
+
.setCharacteristic(this.Ch.Manufacturer, 'GE Appliances')
|
|
19
|
+
.setCharacteristic(this.Ch.Model, 'Smart Window AC')
|
|
20
|
+
.setCharacteristic(this.Ch.SerialNumber, mac);
|
|
21
|
+
|
|
22
|
+
// ---- HeaterCooler ----
|
|
23
|
+
const hc = accessory.getService(this.S.HeaterCooler) || accessory.addService(this.S.HeaterCooler, name, 'GEAC_HC');
|
|
24
|
+
this.hc = hc;
|
|
25
|
+
this._configuredName(hc, name);
|
|
26
|
+
|
|
27
|
+
hc.getCharacteristic(this.Ch.Active)
|
|
28
|
+
.onGet(() => this._power() ? this.Ch.Active.ACTIVE : this.Ch.Active.INACTIVE)
|
|
29
|
+
.onSet(this._wrap(async (v) => {
|
|
30
|
+
if (v === this.Ch.Active.ACTIVE) {
|
|
31
|
+
if (!this._power()) { await this.client.setErd(this.mac, C.ERD.POWER, C.POWER.ON); await this.client.setErd(this.mac, C.ERD.OPERATION_MODE, this._mode() || C.MODE.COOL); }
|
|
32
|
+
} else if (this._power()) { await this.client.setErd(this.mac, C.ERD.POWER, C.POWER.OFF); }
|
|
33
|
+
}));
|
|
34
|
+
|
|
35
|
+
hc.getCharacteristic(this.Ch.CurrentHeaterCoolerState)
|
|
36
|
+
.setProps({ validValues: [this.Ch.CurrentHeaterCoolerState.INACTIVE, this.Ch.CurrentHeaterCoolerState.IDLE, this.Ch.CurrentHeaterCoolerState.COOLING] })
|
|
37
|
+
.onGet(() => this._currentState());
|
|
38
|
+
|
|
39
|
+
hc.getCharacteristic(this.Ch.TargetHeaterCoolerState)
|
|
40
|
+
.setProps({ validValues: [this.Ch.TargetHeaterCoolerState.COOL] })
|
|
41
|
+
.onGet(() => this.Ch.TargetHeaterCoolerState.COOL)
|
|
42
|
+
.onSet(this._wrap(async () => {
|
|
43
|
+
if (!this._power()) await this.client.setErd(this.mac, C.ERD.POWER, C.POWER.ON);
|
|
44
|
+
await this.client.setErd(this.mac, C.ERD.OPERATION_MODE, C.MODE.COOL);
|
|
45
|
+
}));
|
|
46
|
+
|
|
47
|
+
hc.getCharacteristic(this.Ch.CurrentTemperature)
|
|
48
|
+
.onGet(() => C.fToC(this._ambientF()));
|
|
49
|
+
|
|
50
|
+
// Step = exactly 1 degF (5/9 degC), anchored at 64F, so the HomeKit grid lands on whole
|
|
51
|
+
// Fahrenheit degrees (64,65,...,86). minValue/maxValue are COMPUTED (not rounded literals)
|
|
52
|
+
// so the pushed value at a boundary equals the prop exactly and HAP never rejects it.
|
|
53
|
+
hc.getCharacteristic(this.Ch.CoolingThresholdTemperature)
|
|
54
|
+
.setProps({ minValue: C.fToC(64), maxValue: C.fToC(86), minStep: 5 / 9 })
|
|
55
|
+
.onGet(() => this._coolThreshC())
|
|
56
|
+
.onSet(this._wrap(async (v) => {
|
|
57
|
+
let f = Math.round(C.cToF(v));
|
|
58
|
+
f = Math.max(64, Math.min(86, f));
|
|
59
|
+
await this.client.setErd(this.mac, C.ERD.TARGET_TEMP, C.encodeTempF(f));
|
|
60
|
+
}));
|
|
61
|
+
|
|
62
|
+
hc.getCharacteristic(this.Ch.RotationSpeed)
|
|
63
|
+
.onGet(() => this._fanPct())
|
|
64
|
+
.onSet(this._wrap(async (pct) => { await this.client.setErd(this.mac, C.ERD.FAN, this._fanFromPct(pct)); }));
|
|
65
|
+
|
|
66
|
+
hc.getCharacteristic(this.Ch.TemperatureDisplayUnits)
|
|
67
|
+
.onGet(() => (this.client.getState(this.mac, C.ERD.TEMP_UNIT) === '01') ? this.Ch.TemperatureDisplayUnits.CELSIUS : this.Ch.TemperatureDisplayUnits.FAHRENHEIT);
|
|
68
|
+
|
|
69
|
+
// ---- mode switches (no HEAT; cooling-only unit) ----
|
|
70
|
+
this.switches = [
|
|
71
|
+
['Cool', 'GEAC_COOL', C.MODE.COOL],
|
|
72
|
+
['Fan Only', 'GEAC_FAN', C.MODE.FAN_ONLY],
|
|
73
|
+
['Energy Saver', 'GEAC_ECO', C.MODE.ENERGY_SAVER],
|
|
74
|
+
['Dry', 'GEAC_DRY', C.MODE.DRY],
|
|
75
|
+
].map(([label, subtype, modeHex]) => {
|
|
76
|
+
const sw = accessory.getService(subtype) || accessory.addService(this.S.Switch, label, subtype);
|
|
77
|
+
this._configuredName(sw, label);
|
|
78
|
+
sw.getCharacteristic(this.Ch.On)
|
|
79
|
+
.onGet(() => this._power() && this._mode() === modeHex)
|
|
80
|
+
.onSet(this._wrap(async (on) => {
|
|
81
|
+
if (on) { if (!this._power()) await this.client.setErd(this.mac, C.ERD.POWER, C.POWER.ON); await this.client.setErd(this.mac, C.ERD.OPERATION_MODE, modeHex); }
|
|
82
|
+
else if (this._power() && this._mode() === modeHex) { await this.client.setErd(this.mac, C.ERD.POWER, C.POWER.OFF); }
|
|
83
|
+
}));
|
|
84
|
+
return { sw, modeHex };
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
// remove a stale HEAT switch if a previous version created one
|
|
88
|
+
const stale = accessory.getService('GEAC_HEAT');
|
|
89
|
+
if (stale) accessory.removeService(stale);
|
|
90
|
+
|
|
91
|
+
// live updates
|
|
92
|
+
this._onErd = (mac) => { if (mac === this.mac) this._pushAll(); };
|
|
93
|
+
client.on('erd', this._onErd);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
_configuredName(svc, name) {
|
|
97
|
+
svc.setCharacteristic(this.Ch.Name, name);
|
|
98
|
+
if (!svc.testCharacteristic(this.Ch.ConfiguredName)) svc.addCharacteristic(this.Ch.ConfiguredName);
|
|
99
|
+
svc.getCharacteristic(this.Ch.ConfiguredName).onGet(() => name).updateValue(name);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
_power() { return this.client.getState(this.mac, C.ERD.POWER) === C.POWER.ON; }
|
|
103
|
+
_mode() { return this.client.getState(this.mac, C.ERD.OPERATION_MODE); }
|
|
104
|
+
_targetF() { const v = C.decodeInt(this.client.getState(this.mac, C.ERD.TARGET_TEMP)); return (v != null && v >= 60 && v <= 90) ? v : 72; }
|
|
105
|
+
// cooling-threshold in Celsius, clamped to the characteristic's valid band so a boundary
|
|
106
|
+
// float (e.g. fToC(64)=17.7777 vs a 17.7778 min) can never be rejected by HAP
|
|
107
|
+
_coolThreshC() { return Math.max(C.fToC(64), Math.min(C.fToC(86), C.fToC(this._targetF()))); }
|
|
108
|
+
_ambientF() { const v = C.decodeInt(this.client.getState(this.mac, C.ERD.AMBIENT_TEMP)); return (v != null && v >= 32 && v <= 120) ? v : this._targetF(); }
|
|
109
|
+
_currentState() {
|
|
110
|
+
if (!this._power()) return this.Ch.CurrentHeaterCoolerState.INACTIVE;
|
|
111
|
+
return (this._ambientF() > this._targetF()) ? this.Ch.CurrentHeaterCoolerState.COOLING : this.Ch.CurrentHeaterCoolerState.IDLE;
|
|
112
|
+
}
|
|
113
|
+
_fanPct() {
|
|
114
|
+
const n = C.decodeInt(this.client.getState(this.mac, C.ERD.FAN));
|
|
115
|
+
if (n == null) return 0;
|
|
116
|
+
if (n >= 8) return 100; if (n >= 4) return 66; if (n >= 2) return 33; return 0; // 0/1 => AUTO
|
|
117
|
+
}
|
|
118
|
+
_fanFromPct(p) { if (p <= 0) return C.FAN.AUTO; if (p <= 33) return C.FAN.LOW; if (p <= 66) return C.FAN.MED; return C.FAN.HIGH; }
|
|
119
|
+
|
|
120
|
+
_pushAll() {
|
|
121
|
+
const u = (c, v) => { try { this.hc.updateCharacteristic(c, v); } catch (e) { /* ignore */ } };
|
|
122
|
+
u(this.Ch.Active, this._power() ? this.Ch.Active.ACTIVE : this.Ch.Active.INACTIVE);
|
|
123
|
+
u(this.Ch.CurrentHeaterCoolerState, this._currentState());
|
|
124
|
+
u(this.Ch.CurrentTemperature, C.fToC(this._ambientF()));
|
|
125
|
+
u(this.Ch.CoolingThresholdTemperature, this._coolThreshC());
|
|
126
|
+
u(this.Ch.RotationSpeed, this._fanPct());
|
|
127
|
+
for (const { sw, modeHex } of this.switches) {
|
|
128
|
+
try { sw.updateCharacteristic(this.Ch.On, this._power() && this._mode() === modeHex); } catch (e) { /* ignore */ }
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
_wrap(fn) {
|
|
133
|
+
return async (v) => {
|
|
134
|
+
try { await fn(v); }
|
|
135
|
+
catch (e) {
|
|
136
|
+
this.log.warn(`[${this.accessory.displayName}] set failed: ${e.message}`);
|
|
137
|
+
throw new this.hap.HapStatusError(this.hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE);
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
module.exports = { ACAccessory };
|
package/lib/auth.js
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// GE SmartHQ OAuth2 authorization-code login (form scrape) + refresh.
|
|
3
|
+
// Ported verbatim from the proven PoC / simbaja gehome async_login_flows.py.
|
|
4
|
+
const axios = require('axios');
|
|
5
|
+
const cheerio = require('cheerio');
|
|
6
|
+
const C = require('./const');
|
|
7
|
+
|
|
8
|
+
function makeJar() {
|
|
9
|
+
const jar = { abgea_region: C.REGION_COOKIE };
|
|
10
|
+
const header = () => Object.entries(jar).map(([k, v]) => `${k}=${v}`).join('; ');
|
|
11
|
+
const absorb = (res) => {
|
|
12
|
+
const sc = res.headers['set-cookie'];
|
|
13
|
+
if (!sc) return;
|
|
14
|
+
for (const line of sc) {
|
|
15
|
+
const pair = line.split(';')[0];
|
|
16
|
+
const i = pair.indexOf('=');
|
|
17
|
+
if (i > 0) jar[pair.slice(0, i).trim()] = pair.slice(i + 1).trim();
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
const req = async (cfg) => {
|
|
21
|
+
cfg.headers = Object.assign({ Cookie: header() }, cfg.headers || {});
|
|
22
|
+
cfg.maxRedirects = 0;
|
|
23
|
+
cfg.validateStatus = () => true;
|
|
24
|
+
cfg.timeout = 30000;
|
|
25
|
+
const res = await axios(cfg);
|
|
26
|
+
absorb(res);
|
|
27
|
+
return res;
|
|
28
|
+
};
|
|
29
|
+
return { req };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function formInputs(html, formId) {
|
|
33
|
+
const $ = cheerio.load(html);
|
|
34
|
+
const out = {};
|
|
35
|
+
$(`form#${formId}`).find('input').each((_, el) => {
|
|
36
|
+
const name = $(el).attr('name');
|
|
37
|
+
if (name) out[name] = $(el).attr('value') || '';
|
|
38
|
+
});
|
|
39
|
+
return out;
|
|
40
|
+
}
|
|
41
|
+
function codeFromLocation(loc) {
|
|
42
|
+
if (!loc) return null;
|
|
43
|
+
try { return new URL(loc, C.LOGIN_URL).searchParams.get('code'); } catch { return null; }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function handleResponse(req, res, depth = 0) {
|
|
47
|
+
if (depth > 10) throw new Error('login: too many redirects');
|
|
48
|
+
if ([301, 302, 303, 307, 308].includes(res.status)) {
|
|
49
|
+
const loc = res.headers.location || '';
|
|
50
|
+
const code = codeFromLocation(loc);
|
|
51
|
+
if (code) return code;
|
|
52
|
+
const next = new URL(loc, C.LOGIN_URL).toString();
|
|
53
|
+
return handleResponse(req, await req({ method: 'GET', url: next }), depth + 1);
|
|
54
|
+
}
|
|
55
|
+
if (res.status === 200) {
|
|
56
|
+
const html = res.data;
|
|
57
|
+
if (/addMfaForm|Multi-Factor Authentication|\/account\/active\/security\/add\/mfamethod/.test(html)) {
|
|
58
|
+
const f = formInputs(html, 'addMfaForm');
|
|
59
|
+
if (!Object.keys(f).length) throw new Error('MFA enrollment required — configure/disable MFA in the SmartHQ app, then restart Homebridge');
|
|
60
|
+
return handleResponse(req, await req({ method: 'POST', url: `${C.LOGIN_URL}/account/active/redirect`, headers: { 'content-type': 'application/x-www-form-urlencoded' }, data: new URLSearchParams(f).toString() }), depth + 1);
|
|
61
|
+
}
|
|
62
|
+
if (/\/oauth2\/terms\/accept/.test(html)) {
|
|
63
|
+
const $ = cheerio.load(html); const f = {};
|
|
64
|
+
$('form input').each((_, el) => { const n = $(el).attr('name'); if (n) f[n] = $(el).attr('value') || ''; });
|
|
65
|
+
f.developerTerms = 'on'; f.connected_terms = 'on';
|
|
66
|
+
return handleResponse(req, await req({ method: 'POST', url: `${C.LOGIN_URL}/oauth2/terms/accept`, headers: { 'content-type': 'application/x-www-form-urlencoded' }, data: new URLSearchParams(f).toString() }), depth + 1);
|
|
67
|
+
}
|
|
68
|
+
const f = formInputs(html, 'frmsignin');
|
|
69
|
+
if ('authorized' in f) {
|
|
70
|
+
f.authorized = 'yes';
|
|
71
|
+
return handleResponse(req, await req({ method: 'POST', url: `${C.LOGIN_URL}/oauth2/code`, headers: { 'content-type': 'application/x-www-form-urlencoded' }, data: new URLSearchParams(f).toString() }), depth + 1);
|
|
72
|
+
}
|
|
73
|
+
const $ = cheerio.load(html);
|
|
74
|
+
const alert = $('#alert_pane').text().trim();
|
|
75
|
+
throw new Error(alert ? `auth failed: ${alert}` : 'auth failed: unexpected login page');
|
|
76
|
+
}
|
|
77
|
+
throw new Error(`login: unexpected status ${res.status}`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function login(username, password) {
|
|
81
|
+
const { req } = makeJar();
|
|
82
|
+
const params = new URLSearchParams({ client_id: C.CLIENT_ID, response_type: 'code', access_type: 'offline', redirect_uri: C.REDIRECT_URI });
|
|
83
|
+
const page = await req({ method: 'GET', url: `${C.LOGIN_URL}/oauth2/auth?${params}` });
|
|
84
|
+
if (page.status >= 400) throw new Error(`GET /oauth2/auth -> ${page.status}`);
|
|
85
|
+
const post = formInputs(page.data, 'frmsignin');
|
|
86
|
+
if (!Object.keys(post).length) throw new Error('login page had no #frmsignin form');
|
|
87
|
+
post.username = String(username).trim();
|
|
88
|
+
post.password = password;
|
|
89
|
+
const res = await req({ method: 'POST', url: `${C.LOGIN_URL}/oauth2/g_authenticate`, headers: { 'content-type': 'application/x-www-form-urlencoded', origin: C.LOGIN_URL }, data: new URLSearchParams(post).toString() });
|
|
90
|
+
if (res.status >= 400 && res.status < 500) throw new Error(`g_authenticate -> ${res.status} (check username/password)`);
|
|
91
|
+
const code = await handleResponse(req, res);
|
|
92
|
+
return exchange(req, new URLSearchParams({ code, client_id: C.CLIENT_ID, client_secret: C.CLIENT_SECRET, redirect_uri: C.REDIRECT_URI, grant_type: 'authorization_code' }));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function refresh(refreshToken) {
|
|
96
|
+
const { req } = makeJar();
|
|
97
|
+
return exchange(req, new URLSearchParams({ refresh_token: refreshToken, client_id: C.CLIENT_ID, client_secret: C.CLIENT_SECRET, redirect_uri: C.REDIRECT_URI, grant_type: 'refresh_token' }));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function exchange(req, body) {
|
|
101
|
+
const basic = Buffer.from(`${C.CLIENT_ID}:${C.CLIENT_SECRET}`).toString('base64');
|
|
102
|
+
const tok = await req({ method: 'POST', url: `${C.LOGIN_URL}/oauth2/token`, headers: { authorization: `Basic ${basic}`, 'content-type': 'application/x-www-form-urlencoded' }, data: body.toString() });
|
|
103
|
+
if (tok.status >= 400 || !tok.data || !tok.data.access_token) {
|
|
104
|
+
const err = new Error(`token endpoint -> ${tok.status}: ${JSON.stringify(tok.data).slice(0, 160)}`);
|
|
105
|
+
err.invalidGrant = tok.data && tok.data.error === 'invalid_grant';
|
|
106
|
+
throw err;
|
|
107
|
+
}
|
|
108
|
+
return tok.data; // { access_token, refresh_token, expires_in, ... }
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
module.exports = { login, refresh };
|
package/lib/client.js
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const EventEmitter = require('events');
|
|
3
|
+
const axios = require('axios');
|
|
4
|
+
const WebSocket = require('ws');
|
|
5
|
+
const C = require('./const');
|
|
6
|
+
const auth = require('./auth');
|
|
7
|
+
|
|
8
|
+
// SmartHQClient: owns auth + the realtime websocket. Emits:
|
|
9
|
+
// 'appliances' (items[]) — appliance list received
|
|
10
|
+
// 'erd' (mac, erdNorm, value) — an ERD value updated (live)
|
|
11
|
+
// 'status' (text) — connection status for logging
|
|
12
|
+
class SmartHQClient extends EventEmitter {
|
|
13
|
+
constructor(log, username, password) {
|
|
14
|
+
super();
|
|
15
|
+
this.log = log;
|
|
16
|
+
this.username = username;
|
|
17
|
+
this.password = password;
|
|
18
|
+
this.token = null;
|
|
19
|
+
this.refreshToken = null;
|
|
20
|
+
this.userId = null;
|
|
21
|
+
this.endpoint = null;
|
|
22
|
+
this.ws = null;
|
|
23
|
+
this.state = new Map(); // mac -> { erdNorm: value }
|
|
24
|
+
this.pending = new Map(); // id -> {resolve, reject, timer}
|
|
25
|
+
this.reconnectDelay = C.RECONNECT_BASE_MS;
|
|
26
|
+
this.stopped = false;
|
|
27
|
+
this.connecting = false;
|
|
28
|
+
this._timers = { keepalive: null, relist: null, refresh: null, reconnect: null };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
start() { this._connect(false); }
|
|
32
|
+
|
|
33
|
+
stop() {
|
|
34
|
+
this.stopped = true;
|
|
35
|
+
for (const k of Object.keys(this._timers)) if (this._timers[k]) clearTimeout(this._timers[k]) || clearInterval(this._timers[k]);
|
|
36
|
+
if (this.ws) try { this.ws.close(); } catch (e) { /* ignore */ }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
getState(mac, erd) {
|
|
40
|
+
const m = this.state.get(mac);
|
|
41
|
+
return m ? m[C.normErd(erd)] : undefined;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async _connect(viaRefresh) {
|
|
45
|
+
if (this.stopped || this.connecting) return;
|
|
46
|
+
this.connecting = true;
|
|
47
|
+
if (this._timers.reconnect) { clearTimeout(this._timers.reconnect); this._timers.reconnect = null; }
|
|
48
|
+
try {
|
|
49
|
+
// 1. token
|
|
50
|
+
if (viaRefresh && this.refreshToken) {
|
|
51
|
+
this.log.debug('refreshing token');
|
|
52
|
+
try { this._setToken(await auth.refresh(this.refreshToken)); }
|
|
53
|
+
catch (e) { if (e.invalidGrant) { this.log.info('refresh token expired, full re-login'); this._setToken(await auth.login(this.username, this.password)); } else throw e; }
|
|
54
|
+
} else {
|
|
55
|
+
this.log.debug('logging in to SmartHQ');
|
|
56
|
+
this._setToken(await auth.login(this.username, this.password));
|
|
57
|
+
}
|
|
58
|
+
// 2. websocket credentials
|
|
59
|
+
const creds = (await axios.get(`${C.API_URL}/v1/websocket`, { headers: { authorization: `Bearer ${this.token}` }, timeout: 30000, validateStatus: () => true }));
|
|
60
|
+
if (creds.status >= 400 || !creds.data || !creds.data.endpoint) {
|
|
61
|
+
if (creds.status === 401 || creds.status === 403) { this.token = null; this.refreshToken = null; }
|
|
62
|
+
throw new Error(`GET /v1/websocket -> ${creds.status}`);
|
|
63
|
+
}
|
|
64
|
+
this.endpoint = creds.data.endpoint;
|
|
65
|
+
this.userId = creds.data.userId;
|
|
66
|
+
// 3. connect
|
|
67
|
+
this._openSocket();
|
|
68
|
+
} catch (e) {
|
|
69
|
+
this.connecting = false;
|
|
70
|
+
this.log.warn(`connect failed: ${e.message}`);
|
|
71
|
+
this._scheduleReconnect();
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
_setToken(tok) {
|
|
76
|
+
this.token = tok.access_token;
|
|
77
|
+
if (tok.refresh_token) this.refreshToken = tok.refresh_token;
|
|
78
|
+
const ms = ((tok.expires_in || 3600) * 1000) - C.REFRESH_SKEW_MS;
|
|
79
|
+
if (this._timers.refresh) clearTimeout(this._timers.refresh);
|
|
80
|
+
this._timers.refresh = setTimeout(() => { this.log.debug('token refresh timer'); this._reconnect(true); }, Math.max(ms, 60000));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
_openSocket() {
|
|
84
|
+
const ws = new WebSocket(this.endpoint);
|
|
85
|
+
this.ws = ws;
|
|
86
|
+
ws.on('open', () => {
|
|
87
|
+
this.connecting = false;
|
|
88
|
+
this.reconnectDelay = C.RECONNECT_BASE_MS;
|
|
89
|
+
this.log.info('SmartHQ websocket connected');
|
|
90
|
+
this.emit('status', 'connected');
|
|
91
|
+
this._send({ kind: 'websocket#subscribe', action: 'subscribe', resources: ['/appliance/*/erd/*'] });
|
|
92
|
+
this._send(this._api('GET', '/v1/appliance', 'List-appliances'));
|
|
93
|
+
if (this._timers.keepalive) clearInterval(this._timers.keepalive);
|
|
94
|
+
this._timers.keepalive = setInterval(() => this._send({ kind: 'websocket#ping', id: 'keepalive-ping', action: 'ping' }), C.KEEPALIVE_MS);
|
|
95
|
+
if (this._timers.relist) clearInterval(this._timers.relist);
|
|
96
|
+
this._timers.relist = setInterval(() => this._send(this._api('GET', '/v1/appliance', 'List-appliances')), C.RELIST_MS);
|
|
97
|
+
});
|
|
98
|
+
ws.on('message', (raw) => { try { this._process(JSON.parse(raw.toString())); } catch (e) { this.log.debug(`msg parse: ${e.message}`); } });
|
|
99
|
+
ws.on('error', (e) => this.log.debug(`ws error: ${e.message}`));
|
|
100
|
+
ws.on('close', () => {
|
|
101
|
+
this.connecting = false;
|
|
102
|
+
if (this._timers.keepalive) { clearInterval(this._timers.keepalive); this._timers.keepalive = null; }
|
|
103
|
+
if (this._timers.relist) { clearInterval(this._timers.relist); this._timers.relist = null; }
|
|
104
|
+
if (this.ws === ws) this.ws = null;
|
|
105
|
+
if (!this.stopped) { this.emit('status', 'disconnected'); this.log.warn('SmartHQ websocket closed; reconnecting'); this._scheduleReconnect(); }
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
_reconnect(viaRefresh) {
|
|
110
|
+
if (this.ws) { try { this.ws.close(); } catch (e) { /* ignore */ } }
|
|
111
|
+
this._viaRefresh = viaRefresh;
|
|
112
|
+
if (!this.connecting) this._scheduleReconnect();
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
_scheduleReconnect() {
|
|
116
|
+
if (this.stopped || this._timers.reconnect) return;
|
|
117
|
+
const delay = this.reconnectDelay;
|
|
118
|
+
this.reconnectDelay = Math.min(this.reconnectDelay * 2, C.RECONNECT_MAX_MS);
|
|
119
|
+
this._timers.reconnect = setTimeout(() => { this._timers.reconnect = null; this._connect(this._viaRefresh !== false); }, delay);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
_api(method, path, id, body) {
|
|
123
|
+
const m = { kind: 'websocket#api', action: 'api', host: C.API_HOST, method, path, id };
|
|
124
|
+
if (body) m.body = body;
|
|
125
|
+
return m;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
_send(obj) {
|
|
129
|
+
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return false;
|
|
130
|
+
try { this.ws.send(JSON.stringify(obj)); return true; } catch (e) { this.log.debug(`send failed: ${e.message}`); return false; }
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
_process(m) {
|
|
134
|
+
if ((m.code === 401 || m.code === 403) || m.reason === 'Access token expired') { this.log.info('socket auth expired; refreshing'); this.token = null; this._reconnect(true); return; }
|
|
135
|
+
const kind = m.kind;
|
|
136
|
+
if (kind === 'websocket#api' && m.body && m.body.kind === 'appliance#applianceList') {
|
|
137
|
+
const items = m.body.items || [];
|
|
138
|
+
this.emit('appliances', items);
|
|
139
|
+
for (const it of items) this._send(this._api('GET', `/v1/appliance/${it.applianceId}/erd`, `${it.applianceId}-allErd`));
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
if (kind === 'websocket#api' && m.body && m.body.kind === 'appliance#erdList') {
|
|
143
|
+
// id is "{mac}-allErd"
|
|
144
|
+
const mac = (m.id || '').replace(/-allErd$/, '');
|
|
145
|
+
for (const it of (m.body.items || [])) this._apply(mac, it.erd, it.value);
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
if (kind === 'publish#erd' && m.item) {
|
|
149
|
+
this._apply(m.item.applianceId, m.item.erd, m.item.value);
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
if (kind === 'websocket#api' && typeof m.id === 'string' && m.id.includes('-setErd-')) {
|
|
153
|
+
const p = this.pending.get(m.id);
|
|
154
|
+
if (p) {
|
|
155
|
+
this.pending.delete(m.id);
|
|
156
|
+
clearTimeout(p.timer);
|
|
157
|
+
const ok = (m.success === undefined || m.success === true) && (m.code === undefined || m.code === 200);
|
|
158
|
+
if (ok) p.resolve(); else p.reject(new Error(`setErd rejected: code=${m.code} success=${m.success}`));
|
|
159
|
+
}
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
_apply(mac, erd, value) {
|
|
165
|
+
if (!mac) return;
|
|
166
|
+
const e = C.normErd(erd);
|
|
167
|
+
let s = this.state.get(mac);
|
|
168
|
+
if (!s) { s = {}; this.state.set(mac, s); }
|
|
169
|
+
if (s[e] === value) return;
|
|
170
|
+
s[e] = value;
|
|
171
|
+
this.emit('erd', mac, e, value);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// setErd over the websocket; resolves on backend ack. Optimistically applies locally for snappy UI.
|
|
175
|
+
setErd(mac, erd, value) {
|
|
176
|
+
const e = C.normErd(erd);
|
|
177
|
+
const id = `${mac}-setErd-${e}`;
|
|
178
|
+
const body = { kind: 'appliance#erdListEntry', userId: this.userId, applianceId: mac, erd: e, value, ackTimeout: 10, delay: 0 };
|
|
179
|
+
const sent = this._send(this._api('POST', `/v1/appliance/${mac}/erd/${e}`, id, body));
|
|
180
|
+
if (!sent) return Promise.reject(new Error('websocket not connected'));
|
|
181
|
+
this._apply(mac, e, value); // optimistic; publish#erd will confirm/correct
|
|
182
|
+
return new Promise((resolve, reject) => {
|
|
183
|
+
const old = this.pending.get(id);
|
|
184
|
+
if (old) { clearTimeout(old.timer); old.reject(new Error('superseded')); }
|
|
185
|
+
const timer = setTimeout(() => { this.pending.delete(id); reject(new Error('setErd ack timeout')); }, C.SETERD_ACK_TIMEOUT_MS);
|
|
186
|
+
this.pending.set(id, { resolve, reject, timer });
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
module.exports = { SmartHQClient };
|
package/lib/const.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Plugin identity
|
|
4
|
+
const PLUGIN_NAME = 'homebridge-ge-ac';
|
|
5
|
+
const PLATFORM_NAME = 'GEProfileAC';
|
|
6
|
+
|
|
7
|
+
// GE SmartHQ / Brillion endpoints + OAuth client (from simbaja/gehome const.py)
|
|
8
|
+
const LOGIN_URL = 'https://accounts.brillion.geappliances.com';
|
|
9
|
+
const API_URL = 'https://api.brillion.geappliances.com';
|
|
10
|
+
const API_HOST = 'api.brillion.geappliances.com';
|
|
11
|
+
const CLIENT_ID = '564c31616c4f7474434b307435412b4d2f6e7672';
|
|
12
|
+
const CLIENT_SECRET = '6476512b5246446d452f697154444941387052645938466e5671746e5847593d';
|
|
13
|
+
const REDIRECT_URI = 'brillion.4e617a766474657344444e562b5935566e51324a://oauth/redirect';
|
|
14
|
+
const REGION_COOKIE = 'us-east-1';
|
|
15
|
+
|
|
16
|
+
// Timing
|
|
17
|
+
const KEEPALIVE_MS = 30000;
|
|
18
|
+
const RELIST_MS = 600000;
|
|
19
|
+
const REFRESH_SKEW_MS = 120000;
|
|
20
|
+
const RECONNECT_BASE_MS = 1000;
|
|
21
|
+
const RECONNECT_MAX_MS = 60000;
|
|
22
|
+
const SETERD_ACK_TIMEOUT_MS = 10000;
|
|
23
|
+
|
|
24
|
+
// AC ERD codes (gehome canonical, normalized to "0x" + uppercase digits to match the wire)
|
|
25
|
+
const ERD = {
|
|
26
|
+
TARGET_TEMP: '0x7003', // R/W 2-byte BE degrees F
|
|
27
|
+
OPERATION_MODE: '0x7A01', // R/W 1-byte enum
|
|
28
|
+
AMBIENT_TEMP: '0x7A02', // R 1-byte degrees F
|
|
29
|
+
POWER: '0x7A0F', // R/W "00"/"01"
|
|
30
|
+
FAN: '0x7A00', // R/W enum
|
|
31
|
+
TEMP_UNIT: '0x0007', // R "00"=F "01"=C (panel only)
|
|
32
|
+
FILTER: '0x7A04', // R "00"=ok "01"=change
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const POWER = { OFF: '00', ON: '01' };
|
|
36
|
+
const MODE = { COOL: '00', FAN_ONLY: '01', ENERGY_SAVER: '02', HEAT: '03', DRY: '04' };
|
|
37
|
+
const FAN = { AUTO: '01', LOW: '02', MED: '04', HIGH: '08' };
|
|
38
|
+
|
|
39
|
+
// encode/decode
|
|
40
|
+
const decodeInt = (v) => {
|
|
41
|
+
const n = parseInt(v, 16);
|
|
42
|
+
return Number.isNaN(n) ? null : n;
|
|
43
|
+
};
|
|
44
|
+
const encodeTempF = (f) => Math.round(f).toString(16).padStart(4, '0').toLowerCase(); // 72 -> "0048"
|
|
45
|
+
const fToC = (f) => (f - 32) * 5 / 9;
|
|
46
|
+
const cToF = (c) => (c * 9 / 5) + 32;
|
|
47
|
+
|
|
48
|
+
// normalize an erd code to the on-wire form "0x" + UPPER digits ("0x7A01")
|
|
49
|
+
const normErd = (code) => code.toUpperCase().replace('0X', '0x');
|
|
50
|
+
|
|
51
|
+
module.exports = {
|
|
52
|
+
PLUGIN_NAME, PLATFORM_NAME,
|
|
53
|
+
LOGIN_URL, API_URL, API_HOST, CLIENT_ID, CLIENT_SECRET, REDIRECT_URI, REGION_COOKIE,
|
|
54
|
+
KEEPALIVE_MS, RELIST_MS, REFRESH_SKEW_MS, RECONNECT_BASE_MS, RECONNECT_MAX_MS, SETERD_ACK_TIMEOUT_MS,
|
|
55
|
+
ERD, POWER, MODE, FAN,
|
|
56
|
+
decodeInt, encodeTempF, fToC, cToF, normErd,
|
|
57
|
+
};
|
package/lib/platform.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const C = require('./const');
|
|
3
|
+
const { SmartHQClient } = require('./client');
|
|
4
|
+
const { ACAccessory } = require('./accessory');
|
|
5
|
+
|
|
6
|
+
class GEACPlatform {
|
|
7
|
+
constructor(log, config, api) {
|
|
8
|
+
this.log = log;
|
|
9
|
+
this.config = config || {};
|
|
10
|
+
this.api = api;
|
|
11
|
+
this.accessories = []; // restored from cache
|
|
12
|
+
this.configured = new Set();
|
|
13
|
+
|
|
14
|
+
const creds = this.config.credentials || {};
|
|
15
|
+
this.username = this.config.username || creds.username;
|
|
16
|
+
this.password = this.config.password || creds.password;
|
|
17
|
+
if (!this.username || !this.password) {
|
|
18
|
+
this.log.error('Missing SmartHQ username/password in config — plugin disabled.');
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
this.client = new SmartHQClient(log, this.username, this.password);
|
|
22
|
+
this.client.on('appliances', (items) => this._setupAppliances(items));
|
|
23
|
+
|
|
24
|
+
this.api.on('didFinishLaunching', () => {
|
|
25
|
+
this.log.info('Starting GE SmartHQ client…');
|
|
26
|
+
this.client.start();
|
|
27
|
+
});
|
|
28
|
+
this.api.on('shutdown', () => { if (this.client) this.client.stop(); });
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
configureAccessory(accessory) { this.accessories.push(accessory); }
|
|
32
|
+
|
|
33
|
+
_setupAppliances(items) {
|
|
34
|
+
const wanted = (this.config.macAddress || '').toUpperCase().replace(/[^0-9A-F]/g, '');
|
|
35
|
+
let ac;
|
|
36
|
+
if (wanted) ac = items.find((i) => i.applianceId.toUpperCase().includes(wanted));
|
|
37
|
+
if (!ac) ac = items.find((i) => /air|cond|\bac\b/i.test(`${i.type} ${i.nickname}`));
|
|
38
|
+
if (!ac) ac = items[0];
|
|
39
|
+
if (!ac) { this.log.warn('No appliances found on SmartHQ account.'); return; }
|
|
40
|
+
|
|
41
|
+
const mac = ac.applianceId;
|
|
42
|
+
if (this.configured.has(mac)) return;
|
|
43
|
+
this.configured.add(mac);
|
|
44
|
+
|
|
45
|
+
const name = this.config.name || ac.nickname || 'Air Conditioner';
|
|
46
|
+
const uuid = this.api.hap.uuid.generate(`${C.PLUGIN_NAME}:${mac}`);
|
|
47
|
+
let accessory = this.accessories.find((a) => a.UUID === uuid);
|
|
48
|
+
if (accessory) {
|
|
49
|
+
this.log.info(`Restoring AC "${name}" (${mac}) from cache`);
|
|
50
|
+
accessory.context.mac = mac;
|
|
51
|
+
new ACAccessory(this, accessory, this.client, mac);
|
|
52
|
+
} else {
|
|
53
|
+
this.log.info(`Adding AC "${name}" (${mac})`);
|
|
54
|
+
accessory = new this.api.platformAccessory(name, uuid);
|
|
55
|
+
accessory.context.mac = mac;
|
|
56
|
+
new ACAccessory(this, accessory, this.client, mac);
|
|
57
|
+
this.api.registerPlatformAccessories(C.PLUGIN_NAME, C.PLATFORM_NAME, [accessory]);
|
|
58
|
+
this.accessories.push(accessory);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
module.exports = { GEACPlatform };
|
package/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "homebridge-ge-ac",
|
|
3
|
+
"displayName": "GE Profile AC (SmartHQ WebSocket)",
|
|
4
|
+
"version": "1.0.0",
|
|
5
|
+
"description": "Homebridge plugin for GE Profile / SmartHQ Wi-Fi window air conditioners, using the realtime WebSocket control channel for reliable setpoint/mode/fan control.",
|
|
6
|
+
"author": "Fabricore",
|
|
7
|
+
"license": "ISC",
|
|
8
|
+
"homepage": "https://github.com/fabricore-eng/homebridge-ge-ac#readme",
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/fabricore-eng/homebridge-ge-ac.git"
|
|
12
|
+
},
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/fabricore-eng/homebridge-ge-ac/issues"
|
|
15
|
+
},
|
|
16
|
+
"main": "index.js",
|
|
17
|
+
"engines": {
|
|
18
|
+
"node": ">=18.0.0",
|
|
19
|
+
"homebridge": ">=1.6.0"
|
|
20
|
+
},
|
|
21
|
+
"keywords": [
|
|
22
|
+
"homebridge-plugin",
|
|
23
|
+
"homebridge",
|
|
24
|
+
"homekit",
|
|
25
|
+
"ge",
|
|
26
|
+
"ge-appliances",
|
|
27
|
+
"smarthq",
|
|
28
|
+
"air-conditioner"
|
|
29
|
+
],
|
|
30
|
+
"files": [
|
|
31
|
+
"index.js",
|
|
32
|
+
"lib/",
|
|
33
|
+
"config.schema.json"
|
|
34
|
+
],
|
|
35
|
+
"dependencies": {
|
|
36
|
+
"axios": "^1.7.0",
|
|
37
|
+
"cheerio": "^1.0.0",
|
|
38
|
+
"ws": "^8.18.0"
|
|
39
|
+
}
|
|
40
|
+
}
|