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 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
@@ -0,0 +1,7 @@
1
+ 'use strict';
2
+ const { PLUGIN_NAME, PLATFORM_NAME } = require('./lib/const');
3
+ const { GEACPlatform } = require('./lib/platform');
4
+
5
+ module.exports = (api) => {
6
+ api.registerPlatform(PLUGIN_NAME, PLATFORM_NAME, GEACPlatform);
7
+ };
@@ -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
+ };
@@ -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
+ }