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 CHANGED
@@ -1,8 +1,31 @@
1
1
  # homebridge-ge-ac
2
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.
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** via the rotation-speed slider (Auto / Low / Med / High).
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 mode switches for one GE AC, bound to the SmartHQ client.
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(this.Ch.CurrentHeaterCoolerState)
36
- .setProps({ validValues: [this.Ch.CurrentHeaterCoolerState.INACTIVE, this.Ch.CurrentHeaterCoolerState.IDLE, this.Ch.CurrentHeaterCoolerState.COOLING] })
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(this.Ch.TargetHeaterCoolerState)
40
- .setProps({ validValues: [this.Ch.TargetHeaterCoolerState.COOL] })
41
- .onGet(() => this.Ch.TargetHeaterCoolerState.COOL)
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 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.
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({ 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
- }));
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
- 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)); }));
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 (no HEAT; cooling-only unit) ----
70
- this.switches = [
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
- ].map(([label, subtype, modeHex]) => {
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
- // remove a stale HEAT switch if a previous version created one
88
- const stale = accessory.getService('GEAC_HEAT');
89
- if (stale) accessory.removeService(stale);
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 = (mac) => { if (mac === this.mac) this._pushAll(); };
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
- _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(); }
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
- if (!this._power()) return this.Ch.CurrentHeaterCoolerState.INACTIVE;
111
- return (this._ambientF() > this._targetF()) ? this.Ch.CurrentHeaterCoolerState.COOLING : this.Ch.CurrentHeaterCoolerState.IDLE;
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
- _fanPct() {
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 0;
116
- if (n >= 8) return 100; if (n >= 4) return 66; if (n >= 2) return 33; return 0; // 0/1 => AUTO
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 <= 33) return C.FAN.LOW; if (p <= 66) return C.FAN.MED; return C.FAN.HIGH; }
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._coolThreshC());
126
- u(this.Ch.RotationSpeed, this._fanPct());
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
- this.client.on('appliances', (items) => this._setupAppliances(items));
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
- _setupAppliances(items) {
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
- const mac = ac.applianceId;
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.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.",
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",