homebridge-ge-ac 1.0.0 → 1.2.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/README.md +30 -4
- package/lib/accessory.js +96 -42
- package/lib/client.js +1 -0
- package/lib/const.js +17 -2
- package/lib/platform.js +14 -5
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,8 +1,31 @@
|
|
|
1
1
|
# homebridge-ge-ac
|
|
2
2
|
|
|
3
|
-
A [Homebridge](https://homebridge.io) plugin for **GE
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
A [Homebridge](https://homebridge.io) plugin for **GE SmartHQ Wi‑Fi window air conditioners**,
|
|
4
|
+
exposing them to HomeKit with **reliable** setpoint, mode, and fan control over GE's realtime
|
|
5
|
+
WebSocket channel.
|
|
6
|
+
|
|
7
|
+
> **Compatibility — please read.** This was built and **tested on one unit**: the GE Profile
|
|
8
|
+
> ClearView **AHTT06BC** (a *cooling‑only* window AC). The SmartHQ protocol and ERD codes are shared
|
|
9
|
+
> across GE's smart window/room AC line, so it will **probably** work on similar GE Wi‑Fi window ACs —
|
|
10
|
+
> but anything beyond the AHTT06BC is **unverified**. How it adapts:
|
|
11
|
+
> - **Temperature range** is read from the unit (`0x7B06`) — no longer hard‑coded — falling back to
|
|
12
|
+
> 64–86 °F if the unit doesn't report one.
|
|
13
|
+
> - **Cooling-only units** (no heating setpoint reported) get a Cool thermostat + Cool/Fan/Energy‑Saver/Dry
|
|
14
|
+
> switches. This is the **tested** path.
|
|
15
|
+
> - **Heat/cool units** — ⚠️ **experimental, untested.** If the unit reports a heating setpoint
|
|
16
|
+
> (`0x7002`), the plugin also exposes a Heat thermostat mode, a heating setpoint, and a Heat switch.
|
|
17
|
+
> No heat‑capable unit was available to test, and the heat setpoint encoding is assumed to match the
|
|
18
|
+
> (verified) cool one. **If you have a heat/cool GE smart AC, please test and
|
|
19
|
+
> [open an issue](https://github.com/fabricore-eng/homebridge-ge-ac/issues) with what works or breaks.**
|
|
20
|
+
> - **Modes**: the per‑model "available modes" ERD (`0x7B00`) is **deliberately ignored** — GE window
|
|
21
|
+
> ACs report a value that doesn't match the documented bitmask. The standard four switches are always
|
|
22
|
+
> shown; an `Auto` operation mode (if a unit uses it) isn't represented in the thermostat.
|
|
23
|
+
> - **Swing** isn't exposed; **portable** and **split / mini‑split** ACs use a different ERD layout
|
|
24
|
+
> and are **not** expected to work.
|
|
25
|
+
>
|
|
26
|
+
> Reports for any other model (works / doesn't + the model number) via a
|
|
27
|
+
> [GitHub issue](https://github.com/fabricore-eng/homebridge-ge-ac/issues) are very welcome — that's
|
|
28
|
+
> how this grows beyond one unit.
|
|
6
29
|
|
|
7
30
|
## Why this exists
|
|
8
31
|
|
|
@@ -25,7 +48,10 @@ the unit echoes the change back on `publish#erd`.
|
|
|
25
48
|
Setpoint changes **stick**.
|
|
26
49
|
- **Mode switches** with real names: Cool, Fan Only, Energy Saver, Dry. (No bogus "Heat" — these
|
|
27
50
|
are cooling-only units. Each switch sets `ConfiguredName`, so HomeKit never shows "Switch 1–5".)
|
|
28
|
-
- **Fan speed**
|
|
51
|
+
- **Fan speed** as a dedicated **Fan** accessory — an **Auto ⇄ Manual** toggle plus a Low/Med/High
|
|
52
|
+
speed slider, mirroring the GE app's Auto/Low/Med/High. (Apple's Home app won't render fan speed on
|
|
53
|
+
a thermostat tile, so it's exposed as its own Fan service on the same accessory — it groups with the
|
|
54
|
+
AC rather than appearing as the thermostat's rotation-speed slider.)
|
|
29
55
|
- **Live updates** — state is pushed from the WebSocket subscription, so HomeKit reflects changes
|
|
30
56
|
made in the GE app or on the unit, in real time.
|
|
31
57
|
- Resilient: app‑level keepalive, OAuth token refresh ahead of expiry, and exponential‑backoff
|
package/lib/accessory.js
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
const C = require('./const');
|
|
3
3
|
|
|
4
|
-
// Build a HeaterCooler +
|
|
4
|
+
// Build a HeaterCooler + mode switches for one GE AC, bound to the SmartHQ client.
|
|
5
|
+
// Capability-aware: temperature range comes from the unit (0x7B06), and Heat is exposed only
|
|
6
|
+
// when the unit reports a heating setpoint ERD (0x7002). A cooling-only unit (e.g. AHTT06BC,
|
|
7
|
+
// which reports neither heat nor a non-[64,86] range) behaves exactly as the cooling-only build.
|
|
5
8
|
class ACAccessory {
|
|
6
9
|
constructor(platform, accessory, client, mac) {
|
|
7
10
|
this.platform = platform;
|
|
@@ -14,15 +17,26 @@ class ACAccessory {
|
|
|
14
17
|
this.Ch = this.hap.Characteristic;
|
|
15
18
|
const name = accessory.displayName;
|
|
16
19
|
|
|
20
|
+
// ---- capability detection (state is already populated when we get here) ----
|
|
21
|
+
// heatCapable enables an EXPERIMENTAL, UNTESTED heat path: no heat-capable unit was available to
|
|
22
|
+
// verify it, and 0x7002's encoding is assumed identical to the (verified) 0x7003 cool setpoint.
|
|
23
|
+
this.heatCapable = client.getState(mac, C.ERD.HEAT_TARGET_TEMP) != null;
|
|
24
|
+
this.range = C.decodeRange(client.getState(mac, C.ERD.TEMP_RANGE)); // {minF, maxF}, fallback {64,86}
|
|
25
|
+
this.minC = C.fToC(this.range.minF);
|
|
26
|
+
this.maxC = C.fToC(this.range.maxF);
|
|
27
|
+
this.log.info(`[${name}] ${this.heatCapable ? 'heat+cool' : 'cooling-only'}, range ${this.range.minF}-${this.range.maxF}F`);
|
|
28
|
+
|
|
17
29
|
accessory.getService(this.S.AccessoryInformation)
|
|
18
30
|
.setCharacteristic(this.Ch.Manufacturer, 'GE Appliances')
|
|
19
|
-
.setCharacteristic(this.Ch.Model, 'Smart Window AC')
|
|
31
|
+
.setCharacteristic(this.Ch.Model, this.heatCapable ? 'Smart AC (heat/cool)' : 'Smart Window AC')
|
|
20
32
|
.setCharacteristic(this.Ch.SerialNumber, mac);
|
|
21
33
|
|
|
22
34
|
// ---- HeaterCooler ----
|
|
23
35
|
const hc = accessory.getService(this.S.HeaterCooler) || accessory.addService(this.S.HeaterCooler, name, 'GEAC_HC');
|
|
24
36
|
this.hc = hc;
|
|
25
37
|
this._configuredName(hc, name);
|
|
38
|
+
const CHCS = this.Ch.CurrentHeaterCoolerState;
|
|
39
|
+
const THCS = this.Ch.TargetHeaterCoolerState;
|
|
26
40
|
|
|
27
41
|
hc.getCharacteristic(this.Ch.Active)
|
|
28
42
|
.onGet(() => this._power() ? this.Ch.Active.ACTIVE : this.Ch.Active.INACTIVE)
|
|
@@ -32,47 +46,55 @@ class ACAccessory {
|
|
|
32
46
|
} else if (this._power()) { await this.client.setErd(this.mac, C.ERD.POWER, C.POWER.OFF); }
|
|
33
47
|
}));
|
|
34
48
|
|
|
35
|
-
hc.getCharacteristic(
|
|
36
|
-
.setProps({ validValues:
|
|
49
|
+
hc.getCharacteristic(CHCS)
|
|
50
|
+
.setProps({ validValues: this.heatCapable ? [CHCS.INACTIVE, CHCS.IDLE, CHCS.HEATING, CHCS.COOLING] : [CHCS.INACTIVE, CHCS.IDLE, CHCS.COOLING] })
|
|
37
51
|
.onGet(() => this._currentState());
|
|
38
52
|
|
|
39
|
-
hc.getCharacteristic(
|
|
40
|
-
.setProps({ validValues:
|
|
41
|
-
.onGet(() => this.
|
|
42
|
-
.onSet(this._wrap(async () => {
|
|
53
|
+
hc.getCharacteristic(THCS)
|
|
54
|
+
.setProps({ validValues: this.heatCapable ? [THCS.HEAT, THCS.COOL] : [THCS.COOL] })
|
|
55
|
+
.onGet(() => this._targetState())
|
|
56
|
+
.onSet(this._wrap(async (v) => {
|
|
43
57
|
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);
|
|
58
|
+
await this.client.setErd(this.mac, C.ERD.OPERATION_MODE, (this.heatCapable && v === THCS.HEAT) ? C.MODE.HEAT : C.MODE.COOL);
|
|
45
59
|
}));
|
|
46
60
|
|
|
47
61
|
hc.getCharacteristic(this.Ch.CurrentTemperature)
|
|
48
62
|
.onGet(() => C.fToC(this._ambientF()));
|
|
49
63
|
|
|
50
|
-
// Step = exactly 1 degF (5/9 degC), anchored at
|
|
51
|
-
// Fahrenheit degrees
|
|
52
|
-
//
|
|
64
|
+
// Step = exactly 1 degF (5/9 degC), anchored at the unit's min, so the HomeKit grid lands on
|
|
65
|
+
// whole Fahrenheit degrees. minValue/maxValue are COMPUTED so a pushed boundary value equals
|
|
66
|
+
// the prop exactly and HAP never rejects it (the 64F float-boundary lesson).
|
|
67
|
+
const tempProps = { minValue: this.minC, maxValue: this.maxC, minStep: 5 / 9 };
|
|
53
68
|
hc.getCharacteristic(this.Ch.CoolingThresholdTemperature)
|
|
54
|
-
.setProps(
|
|
55
|
-
.onGet(() => this.
|
|
56
|
-
.onSet(this._wrap(async (v) => {
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
69
|
+
.setProps(tempProps)
|
|
70
|
+
.onGet(() => this._threshC(C.ERD.TARGET_TEMP))
|
|
71
|
+
.onSet(this._wrap(async (v) => { await this.client.setErd(this.mac, C.ERD.TARGET_TEMP, C.encodeTempF(this._clampF(v))); }));
|
|
72
|
+
|
|
73
|
+
if (this.heatCapable) {
|
|
74
|
+
hc.getCharacteristic(this.Ch.HeatingThresholdTemperature)
|
|
75
|
+
.setProps(tempProps)
|
|
76
|
+
.onGet(() => this._threshC(C.ERD.HEAT_TARGET_TEMP))
|
|
77
|
+
.onSet(this._wrap(async (v) => { await this.client.setErd(this.mac, C.ERD.HEAT_TARGET_TEMP, C.encodeTempF(this._clampF(v))); }));
|
|
78
|
+
}
|
|
61
79
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
80
|
+
// Apple's Home app does NOT render RotationSpeed on a HeaterCooler, so fan speed lives on a
|
|
81
|
+
// dedicated Fanv2 service (below). Strip any stale cached RotationSpeed off the HeaterCooler.
|
|
82
|
+
if (hc.testCharacteristic(this.Ch.RotationSpeed)) hc.removeCharacteristic(hc.getCharacteristic(this.Ch.RotationSpeed));
|
|
65
83
|
|
|
66
84
|
hc.getCharacteristic(this.Ch.TemperatureDisplayUnits)
|
|
67
85
|
.onGet(() => (this.client.getState(this.mac, C.ERD.TEMP_UNIT) === '01') ? this.Ch.TemperatureDisplayUnits.CELSIUS : this.Ch.TemperatureDisplayUnits.FAHRENHEIT);
|
|
68
86
|
|
|
69
|
-
// ---- mode switches
|
|
70
|
-
|
|
87
|
+
// ---- mode switches ----
|
|
88
|
+
const defs = [
|
|
71
89
|
['Cool', 'GEAC_COOL', C.MODE.COOL],
|
|
72
90
|
['Fan Only', 'GEAC_FAN', C.MODE.FAN_ONLY],
|
|
73
91
|
['Energy Saver', 'GEAC_ECO', C.MODE.ENERGY_SAVER],
|
|
74
92
|
['Dry', 'GEAC_DRY', C.MODE.DRY],
|
|
75
|
-
]
|
|
93
|
+
];
|
|
94
|
+
if (this.heatCapable) defs.push(['Heat', 'GEAC_HEAT', C.MODE.HEAT]);
|
|
95
|
+
else { const stale = accessory.getService('GEAC_HEAT'); if (stale) accessory.removeService(stale); } // drop a stale Heat switch on cooling-only units
|
|
96
|
+
|
|
97
|
+
this.switches = defs.map(([label, subtype, modeHex]) => {
|
|
76
98
|
const sw = accessory.getService(subtype) || accessory.addService(this.S.Switch, label, subtype);
|
|
77
99
|
this._configuredName(sw, label);
|
|
78
100
|
sw.getCharacteristic(this.Ch.On)
|
|
@@ -84,13 +106,31 @@ class ACAccessory {
|
|
|
84
106
|
return { sw, modeHex };
|
|
85
107
|
});
|
|
86
108
|
|
|
87
|
-
//
|
|
88
|
-
|
|
89
|
-
|
|
109
|
+
// ---- Fan (Fanv2): Auto/Manual toggle + Low/Med/High slider, bound to fan ERD 0x7A00 ----
|
|
110
|
+
// Apple Home renders this as a proper Fan tile (with an Auto switch), unlike a HeaterCooler's
|
|
111
|
+
// RotationSpeed. Same accessory, so it groups with the thermostat + mode switches.
|
|
112
|
+
const fan = accessory.getService(this.S.Fanv2) || accessory.addService(this.S.Fanv2, `${name} Fan`, 'GEAC_FANV2');
|
|
113
|
+
this.fan = fan;
|
|
114
|
+
this._configuredName(fan, 'Fan');
|
|
115
|
+
const TFS = this.Ch.TargetFanState, CFS = this.Ch.CurrentFanState, ACT = this.Ch.Active;
|
|
116
|
+
fan.getCharacteristic(ACT)
|
|
117
|
+
.onGet(() => this._power() ? ACT.ACTIVE : ACT.INACTIVE)
|
|
118
|
+
.onSet(this._wrap(async (v) => { await this.client.setErd(this.mac, C.ERD.POWER, v === ACT.ACTIVE ? C.POWER.ON : C.POWER.OFF); }));
|
|
119
|
+
fan.getCharacteristic(CFS)
|
|
120
|
+
.onGet(() => this._power() ? CFS.BLOWING_AIR : CFS.INACTIVE);
|
|
121
|
+
fan.getCharacteristic(TFS)
|
|
122
|
+
.onGet(() => this._fanIsAuto() ? TFS.AUTO : TFS.MANUAL)
|
|
123
|
+
.onSet(this._wrap(async (v) => {
|
|
124
|
+
await this.client.setErd(this.mac, C.ERD.FAN, v === TFS.AUTO ? C.FAN.AUTO : this._fanFromPct(this._fanSpeedPct()));
|
|
125
|
+
}));
|
|
126
|
+
fan.getCharacteristic(this.Ch.RotationSpeed) // continuous slider; onSet maps to Low(<=34)/Med(<=67)/High
|
|
127
|
+
.onGet(() => this._fanSpeedPct())
|
|
128
|
+
.onSet(this._wrap(async (pct) => { await this.client.setErd(this.mac, C.ERD.FAN, this._fanFromPct(pct)); }));
|
|
90
129
|
|
|
91
130
|
// live updates
|
|
92
|
-
this._onErd = (
|
|
131
|
+
this._onErd = (m) => { if (m === this.mac) this._pushAll(); };
|
|
93
132
|
client.on('erd', this._onErd);
|
|
133
|
+
this._pushAll(); // seed valid initial values to avoid HAP init warnings
|
|
94
134
|
}
|
|
95
135
|
|
|
96
136
|
_configuredName(svc, name) {
|
|
@@ -99,31 +139,45 @@ class ACAccessory {
|
|
|
99
139
|
svc.getCharacteristic(this.Ch.ConfiguredName).onGet(() => name).updateValue(name);
|
|
100
140
|
}
|
|
101
141
|
|
|
142
|
+
// ---- state helpers ----
|
|
102
143
|
_power() { return this.client.getState(this.mac, C.ERD.POWER) === C.POWER.ON; }
|
|
103
144
|
_mode() { return this.client.getState(this.mac, C.ERD.OPERATION_MODE); }
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
_ambientF() { const v = C.decodeInt(this.client.getState(this.mac, C.ERD.AMBIENT_TEMP)); return (v != null && v >= 32 && v <= 120) ? v : this.
|
|
145
|
+
_isHeatMode() { return this.heatCapable && this._mode() === C.MODE.HEAT; }
|
|
146
|
+
_setpointF(erd) { const v = C.decodeInt(this.client.getState(this.mac, erd)); return (v != null && v >= 45 && v <= 100) ? v : Math.round((this.range.minF + this.range.maxF) / 2); }
|
|
147
|
+
_threshC(erd) { return Math.max(this.minC, Math.min(this.maxC, C.fToC(this._setpointF(erd)))); }
|
|
148
|
+
_clampF(c) { return Math.max(this.range.minF, Math.min(this.range.maxF, Math.round(C.cToF(c)))); }
|
|
149
|
+
_ambientF() { const v = C.decodeInt(this.client.getState(this.mac, C.ERD.AMBIENT_TEMP)); return (v != null && v >= 32 && v <= 120) ? v : this._setpointF(C.ERD.TARGET_TEMP); }
|
|
150
|
+
_targetState() { return this._isHeatMode() ? this.Ch.TargetHeaterCoolerState.HEAT : this.Ch.TargetHeaterCoolerState.COOL; }
|
|
109
151
|
_currentState() {
|
|
110
|
-
|
|
111
|
-
|
|
152
|
+
const CHCS = this.Ch.CurrentHeaterCoolerState;
|
|
153
|
+
if (!this._power()) return CHCS.INACTIVE;
|
|
154
|
+
if (this._isHeatMode()) return (this._ambientF() < this._setpointF(C.ERD.HEAT_TARGET_TEMP)) ? CHCS.HEATING : CHCS.IDLE;
|
|
155
|
+
return (this._ambientF() > this._setpointF(C.ERD.TARGET_TEMP)) ? CHCS.COOLING : CHCS.IDLE;
|
|
112
156
|
}
|
|
113
|
-
|
|
157
|
+
// GE fan enum: AUTO=1 LOW=2 LOW_AUTO=3 MED=4 MED_AUTO=5 HIGH=8 HIGH_AUTO=9. The low bit = "auto".
|
|
158
|
+
_fanIsAuto() { const n = C.decodeInt(this.client.getState(this.mac, C.ERD.FAN)); return n == null || (n & 1) === 1; }
|
|
159
|
+
_fanSpeedPct() {
|
|
114
160
|
const n = C.decodeInt(this.client.getState(this.mac, C.ERD.FAN));
|
|
115
|
-
if (n == null) return
|
|
116
|
-
if (n
|
|
161
|
+
if (n == null) return 33;
|
|
162
|
+
if (n & 8) return 100; if (n & 4) return 66; if (n & 2) return 33; return 33; // pure AUTO(1): default Low position
|
|
117
163
|
}
|
|
118
|
-
_fanFromPct(p) { if (p <= 0) return C.FAN.AUTO; if (p <=
|
|
164
|
+
_fanFromPct(p) { if (p <= 0) return C.FAN.AUTO; if (p <= 34) return C.FAN.LOW; if (p <= 67) return C.FAN.MED; return C.FAN.HIGH; }
|
|
119
165
|
|
|
120
166
|
_pushAll() {
|
|
121
167
|
const u = (c, v) => { try { this.hc.updateCharacteristic(c, v); } catch (e) { /* ignore */ } };
|
|
122
168
|
u(this.Ch.Active, this._power() ? this.Ch.Active.ACTIVE : this.Ch.Active.INACTIVE);
|
|
169
|
+
u(this.Ch.TargetHeaterCoolerState, this._targetState());
|
|
123
170
|
u(this.Ch.CurrentHeaterCoolerState, this._currentState());
|
|
124
171
|
u(this.Ch.CurrentTemperature, C.fToC(this._ambientF()));
|
|
125
|
-
u(this.Ch.CoolingThresholdTemperature, this.
|
|
126
|
-
u(this.Ch.
|
|
172
|
+
u(this.Ch.CoolingThresholdTemperature, this._threshC(C.ERD.TARGET_TEMP));
|
|
173
|
+
if (this.heatCapable) u(this.Ch.HeatingThresholdTemperature, this._threshC(C.ERD.HEAT_TARGET_TEMP));
|
|
174
|
+
if (this.fan) {
|
|
175
|
+
const uf = (c, v) => { try { this.fan.updateCharacteristic(c, v); } catch (e) { /* ignore */ } };
|
|
176
|
+
uf(this.Ch.Active, this._power() ? this.Ch.Active.ACTIVE : this.Ch.Active.INACTIVE);
|
|
177
|
+
uf(this.Ch.CurrentFanState, this._power() ? this.Ch.CurrentFanState.BLOWING_AIR : this.Ch.CurrentFanState.INACTIVE);
|
|
178
|
+
uf(this.Ch.TargetFanState, this._fanIsAuto() ? this.Ch.TargetFanState.AUTO : this.Ch.TargetFanState.MANUAL);
|
|
179
|
+
uf(this.Ch.RotationSpeed, this._fanSpeedPct());
|
|
180
|
+
}
|
|
127
181
|
for (const { sw, modeHex } of this.switches) {
|
|
128
182
|
try { sw.updateCharacteristic(this.Ch.On, this._power() && this._mode() === modeHex); } catch (e) { /* ignore */ }
|
|
129
183
|
}
|
package/lib/client.js
CHANGED
|
@@ -143,6 +143,7 @@ class SmartHQClient extends EventEmitter {
|
|
|
143
143
|
// id is "{mac}-allErd"
|
|
144
144
|
const mac = (m.id || '').replace(/-allErd$/, '');
|
|
145
145
|
for (const it of (m.body.items || [])) this._apply(mac, it.erd, it.value);
|
|
146
|
+
this.emit('ready', mac); // full state for this appliance is now in this.state
|
|
146
147
|
return;
|
|
147
148
|
}
|
|
148
149
|
if (kind === 'publish#erd' && m.item) {
|
package/lib/const.js
CHANGED
|
@@ -23,17 +23,25 @@ const SETERD_ACK_TIMEOUT_MS = 10000;
|
|
|
23
23
|
|
|
24
24
|
// AC ERD codes (gehome canonical, normalized to "0x" + uppercase digits to match the wire)
|
|
25
25
|
const ERD = {
|
|
26
|
-
TARGET_TEMP: '0x7003', // R/W 2-byte BE degrees F
|
|
26
|
+
TARGET_TEMP: '0x7003', // R/W 2-byte BE degrees F (cool setpoint)
|
|
27
|
+
HEAT_TARGET_TEMP: '0x7002', // R/W 2-byte BE degrees F (heat setpoint; only present on heat-capable units)
|
|
27
28
|
OPERATION_MODE: '0x7A01', // R/W 1-byte enum
|
|
28
29
|
AMBIENT_TEMP: '0x7A02', // R 1-byte degrees F
|
|
29
30
|
POWER: '0x7A0F', // R/W "00"/"01"
|
|
30
31
|
FAN: '0x7A00', // R/W enum
|
|
31
32
|
TEMP_UNIT: '0x0007', // R "00"=F "01"=C (panel only)
|
|
32
33
|
FILTER: '0x7A04', // R "00"=ok "01"=change
|
|
34
|
+
TEMP_RANGE: '0x7B06', // R 2-byte [minF, maxF] (unit-reported settable range)
|
|
35
|
+
AVAILABLE_FAN: '0x7B0B', // R bitmask auto/high/med/low (reliable)
|
|
36
|
+
// NOTE: AVAILABLE_MODES (0x7B00) is intentionally NOT used to gate modes: window ACs report a
|
|
37
|
+
// value that does not match gehome's documented bitmask (the AHTT06BC reports 0x06 ~ {dry,eco}
|
|
38
|
+
// while clearly supporting cool/fan). Heat capability is detected via HEAT_TARGET_TEMP presence.
|
|
33
39
|
};
|
|
34
40
|
|
|
35
41
|
const POWER = { OFF: '00', ON: '01' };
|
|
42
|
+
// ErdAcOperationMode (gehome): COOL=0 FAN_ONLY=1 ENERGY_SAVER=2 HEAT=3 DRY=4 AUTO=5 ...
|
|
36
43
|
const MODE = { COOL: '00', FAN_ONLY: '01', ENERGY_SAVER: '02', HEAT: '03', DRY: '04' };
|
|
44
|
+
// ErdAcFanSetting (gehome): AUTO=1 LOW=2 LOW_AUTO=3 MED=4 MED_AUTO=5 HIGH=8 HIGH_AUTO=9
|
|
37
45
|
const FAN = { AUTO: '01', LOW: '02', MED: '04', HIGH: '08' };
|
|
38
46
|
|
|
39
47
|
// encode/decode
|
|
@@ -44,6 +52,13 @@ const decodeInt = (v) => {
|
|
|
44
52
|
const encodeTempF = (f) => Math.round(f).toString(16).padStart(4, '0').toLowerCase(); // 72 -> "0048"
|
|
45
53
|
const fToC = (f) => (f - 32) * 5 / 9;
|
|
46
54
|
const cToF = (c) => (c * 9 / 5) + 32;
|
|
55
|
+
// decode TEMP_RANGE (0x7B06) "MMNN" -> {minF, maxF}; gehome: 0xFF sentinel -> [60,86] fallback
|
|
56
|
+
const decodeRange = (v) => {
|
|
57
|
+
const min = decodeInt((v || '').slice(0, 2));
|
|
58
|
+
const max = decodeInt((v || '').slice(2, 4));
|
|
59
|
+
if (min == null || max == null || min === 255 || max === 255 || min >= max) return { minF: 64, maxF: 86 };
|
|
60
|
+
return { minF: min, maxF: max };
|
|
61
|
+
};
|
|
47
62
|
|
|
48
63
|
// normalize an erd code to the on-wire form "0x" + UPPER digits ("0x7A01")
|
|
49
64
|
const normErd = (code) => code.toUpperCase().replace('0X', '0x');
|
|
@@ -53,5 +68,5 @@ module.exports = {
|
|
|
53
68
|
LOGIN_URL, API_URL, API_HOST, CLIENT_ID, CLIENT_SECRET, REDIRECT_URI, REGION_COOKIE,
|
|
54
69
|
KEEPALIVE_MS, RELIST_MS, REFRESH_SKEW_MS, RECONNECT_BASE_MS, RECONNECT_MAX_MS, SETERD_ACK_TIMEOUT_MS,
|
|
55
70
|
ERD, POWER, MODE, FAN,
|
|
56
|
-
decodeInt, encodeTempF, fToC, cToF, normErd,
|
|
71
|
+
decodeInt, encodeTempF, fToC, cToF, normErd, decodeRange,
|
|
57
72
|
};
|
package/lib/platform.js
CHANGED
|
@@ -18,8 +18,13 @@ class GEACPlatform {
|
|
|
18
18
|
this.log.error('Missing SmartHQ username/password in config — plugin disabled.');
|
|
19
19
|
return;
|
|
20
20
|
}
|
|
21
|
+
this.acMac = null;
|
|
22
|
+
this.acName = null;
|
|
21
23
|
this.client = new SmartHQClient(log, this.username, this.password);
|
|
22
|
-
|
|
24
|
+
// Pick the AC from the appliance list, but defer building until its full state arrives
|
|
25
|
+
// ('ready') so we can read capabilities (heat support, temp range) up front.
|
|
26
|
+
this.client.on('appliances', (items) => this._pickAc(items));
|
|
27
|
+
this.client.on('ready', (mac) => { if (mac === this.acMac) this._buildAc(mac); });
|
|
23
28
|
|
|
24
29
|
this.api.on('didFinishLaunching', () => {
|
|
25
30
|
this.log.info('Starting GE SmartHQ client…');
|
|
@@ -30,25 +35,29 @@ class GEACPlatform {
|
|
|
30
35
|
|
|
31
36
|
configureAccessory(accessory) { this.accessories.push(accessory); }
|
|
32
37
|
|
|
33
|
-
|
|
38
|
+
_pickAc(items) {
|
|
39
|
+
if (this.acMac) return; // already chosen
|
|
34
40
|
const wanted = (this.config.macAddress || '').toUpperCase().replace(/[^0-9A-F]/g, '');
|
|
35
41
|
let ac;
|
|
36
42
|
if (wanted) ac = items.find((i) => i.applianceId.toUpperCase().includes(wanted));
|
|
37
43
|
if (!ac) ac = items.find((i) => /air|cond|\bac\b/i.test(`${i.type} ${i.nickname}`));
|
|
38
44
|
if (!ac) ac = items[0];
|
|
39
45
|
if (!ac) { this.log.warn('No appliances found on SmartHQ account.'); return; }
|
|
46
|
+
this.acMac = ac.applianceId;
|
|
47
|
+
this.acName = this.config.name || ac.nickname || 'Air Conditioner';
|
|
48
|
+
}
|
|
40
49
|
|
|
41
|
-
|
|
50
|
+
_buildAc(mac) {
|
|
42
51
|
if (this.configured.has(mac)) return;
|
|
43
52
|
this.configured.add(mac);
|
|
44
|
-
|
|
45
|
-
const name = this.config.name || ac.nickname || 'Air Conditioner';
|
|
53
|
+
const name = this.acName || 'Air Conditioner';
|
|
46
54
|
const uuid = this.api.hap.uuid.generate(`${C.PLUGIN_NAME}:${mac}`);
|
|
47
55
|
let accessory = this.accessories.find((a) => a.UUID === uuid);
|
|
48
56
|
if (accessory) {
|
|
49
57
|
this.log.info(`Restoring AC "${name}" (${mac}) from cache`);
|
|
50
58
|
accessory.context.mac = mac;
|
|
51
59
|
new ACAccessory(this, accessory, this.client, mac);
|
|
60
|
+
this.api.updatePlatformAccessories([accessory]); // persist service changes (e.g. added Fan, removed stale chars)
|
|
52
61
|
} else {
|
|
53
62
|
this.log.info(`Adding AC "${name}" (${mac})`);
|
|
54
63
|
accessory = new this.api.platformAccessory(name, uuid);
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "homebridge-ge-ac",
|
|
3
3
|
"displayName": "GE Profile AC (SmartHQ WebSocket)",
|
|
4
|
-
"version": "1.
|
|
5
|
-
"description": "Homebridge plugin for GE
|
|
4
|
+
"version": "1.2.0",
|
|
5
|
+
"description": "Homebridge plugin for GE SmartHQ Wi-Fi window air conditioners over GE's realtime WebSocket channel (reliable setpoint/mode/fan control). Built and tested on the cooling-only GE Profile AHTT06BC; likely works on similar GE Wi-Fi window ACs (unverified).",
|
|
6
6
|
"author": "Fabricore",
|
|
7
7
|
"license": "ISC",
|
|
8
8
|
"homepage": "https://github.com/fabricore-eng/homebridge-ge-ac#readme",
|