homebridge-ge-ac 1.1.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
@@ -48,7 +48,10 @@ the unit echoes the change back on `publish#erd`.
48
48
  Setpoint changes **stick**.
49
49
  - **Mode switches** with real names: Cool, Fan Only, Energy Saver, Dry. (No bogus "Heat" — these
50
50
  are cooling-only units. Each switch sets `ConfiguredName`, so HomeKit never shows "Switch 1–5".)
51
- - **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.)
52
55
  - **Live updates** — state is pushed from the WebSocket subscription, so HomeKit reflects changes
53
56
  made in the GE app or on the unit, in real time.
54
57
  - Resilient: app‑level keepalive, OAuth token refresh ahead of expiry, and exponential‑backoff
package/lib/accessory.js CHANGED
@@ -77,9 +77,9 @@ class ACAccessory {
77
77
  .onSet(this._wrap(async (v) => { await this.client.setErd(this.mac, C.ERD.HEAT_TARGET_TEMP, C.encodeTempF(this._clampF(v))); }));
78
78
  }
79
79
 
80
- hc.getCharacteristic(this.Ch.RotationSpeed)
81
- .onGet(() => this._fanPct())
82
- .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));
83
83
 
84
84
  hc.getCharacteristic(this.Ch.TemperatureDisplayUnits)
85
85
  .onGet(() => (this.client.getState(this.mac, C.ERD.TEMP_UNIT) === '01') ? this.Ch.TemperatureDisplayUnits.CELSIUS : this.Ch.TemperatureDisplayUnits.FAHRENHEIT);
@@ -106,6 +106,27 @@ class ACAccessory {
106
106
  return { sw, modeHex };
107
107
  });
108
108
 
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)); }));
129
+
109
130
  // live updates
110
131
  this._onErd = (m) => { if (m === this.mac) this._pushAll(); };
111
132
  client.on('erd', this._onErd);
@@ -133,12 +154,14 @@ class ACAccessory {
133
154
  if (this._isHeatMode()) return (this._ambientF() < this._setpointF(C.ERD.HEAT_TARGET_TEMP)) ? CHCS.HEATING : CHCS.IDLE;
134
155
  return (this._ambientF() > this._setpointF(C.ERD.TARGET_TEMP)) ? CHCS.COOLING : CHCS.IDLE;
135
156
  }
136
- _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() {
137
160
  const n = C.decodeInt(this.client.getState(this.mac, C.ERD.FAN));
138
- if (n == null) return 0;
139
- 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
140
163
  }
141
- _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; }
142
165
 
143
166
  _pushAll() {
144
167
  const u = (c, v) => { try { this.hc.updateCharacteristic(c, v); } catch (e) { /* ignore */ } };
@@ -148,7 +171,13 @@ class ACAccessory {
148
171
  u(this.Ch.CurrentTemperature, C.fToC(this._ambientF()));
149
172
  u(this.Ch.CoolingThresholdTemperature, this._threshC(C.ERD.TARGET_TEMP));
150
173
  if (this.heatCapable) u(this.Ch.HeatingThresholdTemperature, this._threshC(C.ERD.HEAT_TARGET_TEMP));
151
- u(this.Ch.RotationSpeed, this._fanPct());
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
+ }
152
181
  for (const { sw, modeHex } of this.switches) {
153
182
  try { sw.updateCharacteristic(this.Ch.On, this._power() && this._mode() === modeHex); } catch (e) { /* ignore */ }
154
183
  }
package/lib/platform.js CHANGED
@@ -57,6 +57,7 @@ class GEACPlatform {
57
57
  this.log.info(`Restoring AC "${name}" (${mac}) from cache`);
58
58
  accessory.context.mac = mac;
59
59
  new ACAccessory(this, accessory, this.client, mac);
60
+ this.api.updatePlatformAccessories([accessory]); // persist service changes (e.g. added Fan, removed stale chars)
60
61
  } else {
61
62
  this.log.info(`Adding AC "${name}" (${mac})`);
62
63
  accessory = new this.api.platformAccessory(name, uuid);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "homebridge-ge-ac",
3
3
  "displayName": "GE Profile AC (SmartHQ WebSocket)",
4
- "version": "1.1.0",
4
+ "version": "1.2.0",
5
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",