homebridge-mrcool-hvac 0.1.1

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/CHANGELOG.md ADDED
@@ -0,0 +1,22 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file. The format is inspired by Keep a Changelog and Semantic Versioning.
4
+
5
+ ## [0.1.0] - 2025-09-14
6
+ ### Added
7
+ - Initial public preview.
8
+ - SSE event subscription with automatic reconnect.
9
+ - Thermostat service (OFF / HEAT / COOL / AUTO) with 0.5°C step.
10
+ - Outdoor temperature sensor.
11
+ - Humidity reporting (if provided by device climate state).
12
+ - Beeper on/off switch + optional autoDisableBeeper.
13
+ - Differential debounced command sender & acknowledgement timeout warnings.
14
+ - Detailed debug logging (raw climate payload + mapped state, state id enumeration, missing climate warning).
15
+ - Optional experimental preset & swing switches (device currently ignores commands).
16
+
17
+ ### Removed
18
+ - Display Toggle and Swing Step button services (no observable device effect).
19
+
20
+ ### Notes
21
+ - Preset & swing retained as optional for future firmware support.
22
+ - Future roadmap: fan speed granularity, multi-unit discovery, config schema publication.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 YOUR NAME
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,162 @@
1
+ ## homebridge-mrcool-smartlight
2
+
3
+ Homebridge plugin providing local (cloud‑independent) control of a MrCool HVAC unit through the SMARTLIGHT SLWF‑01 Pro Wi‑Fi module using its Server‑Sent Events (SSE) event stream and simple HTTP command endpoints.
4
+
5
+ > Status: Early preview. Core thermostat + outdoor temperature + beeper work reliably. Preset / swing features are exposed optionally but the device currently ignores those commands. Removed non‑functional Display / Swing Step buttons to keep the UI clean.
6
+
7
+ ### Working Today
8
+ * Thermostat: OFF / HEAT / COOL / AUTO (HEAT_COOL) + current temperature, target temperature, and AUTO heating/cooling thresholds (16–30°C, 0.5° steps)
9
+ * Current relative humidity (if provided by climate entity)
10
+ * Outdoor temperature sensor (separate TemperatureSensor)
11
+ * Beeper on/off switch (stateful)
12
+ * Differential, debounced command sending with acknowledgement timeout
13
+ * Robust SSE subscription with automatic reconnect + missing‑climate warning
14
+ * Detailed debug logging (raw climate payload, mapped state, every SSE state id)
15
+
16
+ ### Optional / Experimental
17
+ * Preset switches (BOOST / ECO / SLEEP) – device accepts HTTP 200 but no observable effect
18
+ * Swing mode switch (BOTH / OFF) – currently not reflected by SSE (likely ignored)
19
+ * Fan Only & Dry “modes” (implemented as helper switches mapping to internal state logic)
20
+
21
+ ### Removed (No observable effect)
22
+ * Display Toggle button
23
+ * Swing Step button
24
+
25
+ ---
26
+
27
+ ## Installation
28
+
29
+ ### Via Homebridge UI (recommended)
30
+ When published to npm (e.g. `homebridge-mrcool-smartlight`):
31
+ 1. Open Homebridge UI -> Plugins.
32
+ 2. Search for `mrcool smartlight`.
33
+ 3. Install and configure.
34
+
35
+ ### Manual / Local (development)
36
+ ```bash
37
+ git clone https://github.com/your-user/homebridge-mrcool-smartlight.git
38
+ cd homebridge-mrcool-smartlight
39
+ npm install
40
+ npm run build
41
+ npm link # optional: makes it available globally
42
+ homebridge -D # start Homebridge and test
43
+ ```
44
+
45
+ Or inside your existing mapped custom-plugins folder (Docker):
46
+ ```bash
47
+ docker exec -it homebridge bash
48
+ cd /homebridge/custom-plugins/homebridge-mrcool-smartlight
49
+ npm install
50
+ npm run build
51
+ ```
52
+
53
+ ---
54
+ ## Configuration (config.json)
55
+
56
+ Minimal:
57
+ ```json
58
+ {
59
+ "accessory": "MrCoolSmartLight",
60
+ "name": "MrCool HVAC",
61
+ "ip": "192.168.40.149",
62
+ "mac": "f8:b3:b7:8a:d6:b8"
63
+ }
64
+ ```
65
+
66
+ Extended (with options):
67
+ ```json
68
+ {
69
+ "accessory": "MrCoolSmartLight",
70
+ "name": "MrCool HVAC",
71
+ "ip": "192.168.40.149",
72
+ "mac": "f8:b3:b7:8a:d6:b8",
73
+ "climateEntityId": "climate-air_conditioner",
74
+ "commandDebounceMs": 450,
75
+ "ackTimeoutMs": 5000,
76
+ "autoDisableBeeper": true,
77
+ "enablePresets": false,
78
+ "enableSwing": false,
79
+ "enableFanOnly": false,
80
+ "enableDryMode": false,
81
+ "debug": true
82
+ }
83
+ ```
84
+
85
+ ### Option Reference
86
+ | Key | Type | Default | Description |
87
+ |-----|------|---------|-------------|
88
+ | `ip` | string | (required) | IPv4/host of the SMARTLIGHT module. |
89
+ | `mac` | string | (recommended) | MAC used for stable UUID (prevents accessory duplication). |
90
+ | `climateEntityId` | string | auto-discovered | Explicit climate entity id (e.g. `climate-air_conditioner`) if SSE discovery is delayed/missing. |
91
+ | `commandDebounceMs` | number | 450 | Merge rapid HomeKit changes before sending to device. |
92
+ | `ackTimeoutMs` | number | 5000 | Warn if SSE doesn’t reflect sent change within this window. |
93
+ | `autoDisableBeeper` | boolean | false | Automatically turns beeper off after connecting. |
94
+ | `enablePresets` | boolean | false | Expose preset switches (experimental; device ignores). |
95
+ | `enableSwing` | boolean | false | Expose swing BOTH/OFF switch (experimental; ignored). |
96
+ | `enableFanOnly` | boolean | false | Expose Fan Only helper switch (maps to target state logic). |
97
+ | `enableDryMode` | boolean | false | Expose Dry helper switch. |
98
+ | `debug` | boolean | false | Verbose internal & raw SSE logging. |
99
+
100
+ ---
101
+ ## How It Works
102
+ * Subscribes to `/events` SSE endpoint; parses climate, sensor, and switch state ids.
103
+ * Maintains internal cached state; reconciles HomeKit commands vs. device reports.
104
+ * AUTO mode uses HomeKit heating/cooling threshold temperatures as a synthetic range and switches the device between HEAT/COOL/OFF as the room temperature crosses those bounds.
105
+ * Sends updates via `POST /climate/<id>/set?mode=...&target_temperature=...` (only when changed).
106
+ * Beeper switch maps to `/switch/air_conditioner_beeper/(turn_on|turn_off)`.
107
+ * Optional commands for presets/swing are issued but currently produce no state change.
108
+
109
+ If the module firmware begins honoring ignored parameters, the exposed optional switches will immediately become useful without code changes.
110
+
111
+ ---
112
+ ## Known Limitations
113
+ * No fan speed granularity (only implicit via thermostat modes).
114
+ * Preset & swing currently inert (device returns HTTP 200 without state mutation).
115
+ * AUTO is synthetic: the device still receives ordinary HEAT/COOL/OFF commands plus the matching setpoint for the active side of the range.
116
+ * No caching across restarts beyond what HomeKit retains.
117
+ * Single-unit support per accessory instance (add multiple accessories for multiple units).
118
+
119
+ ---
120
+ ## Troubleshooting
121
+ | Symptom | Suggestion |
122
+ |---------|------------|
123
+ | Thermostat stuck / no updates | Enable `debug`, restart, confirm climate SSE events appear. |
124
+ | Values don’t apply from HomeKit | Set `climateEntityId` explicitly (usually `climate-air_conditioner`) so writes don’t wait on SSE discovery. |
125
+ | Commands delayed | Lower `commandDebounceMs` (e.g. 200) but keep >150ms to avoid floods. |
126
+ | Ack timeout warnings | Verify device actually changed; network latency; increase `ackTimeoutMs`. |
127
+ | Duplicate accessories | Ensure `mac` is set and stable. Remove cached accessory from Homebridge UI. |
128
+
129
+ ---
130
+ ## Development
131
+ Scripts:
132
+ ```bash
133
+ npm run build # compile TypeScript
134
+ npm run watch # incremental build
135
+ npm pack # preview package tarball
136
+ ```
137
+
138
+ The build emits JavaScript + declarations into `dist/` (only contents published). Use `npm run pack:check` to review included files before publishing.
139
+
140
+ ---
141
+ ## Publishing (Maintainers)
142
+ 1. Update version in `package.json` (semver).
143
+ 2. Update `CHANGELOG.md`.
144
+ 3. `npm run build` & `npm run pack:check`.
145
+ 4. `npm publish` (ensure you are logged in and have 2FA if required).
146
+ 5. Create a Git tag: `git tag vX.Y.Z && git push origin vX.Y.Z`.
147
+
148
+ ---
149
+ ## Security
150
+ This plugin performs only local HTTP(S) calls to the configured IP. No data leaves your network unless your Homebridge instance uploads logs elsewhere.
151
+
152
+ ---
153
+ ## License
154
+ MIT — see `LICENSE` file.
155
+
156
+ ---
157
+ ## Changelog (Excerpt)
158
+ See full details in `CHANGELOG.md`.
159
+
160
+ ### 0.1.0
161
+ * Initial public preview: core thermostat, outdoor temperature, beeper, SSE integration, optional experimental switches.
162
+
@@ -0,0 +1,3 @@
1
+ import { API } from 'homebridge';
2
+ declare const _default: (api: API) => void;
3
+ export default _default;
package/dist/index.js ADDED
@@ -0,0 +1,7 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const mrcoolAccessory_1 = require("./mrcoolAccessory");
4
+ const ACCESSORY_NAME = 'MrCoolSmartLight';
5
+ exports.default = (api) => {
6
+ api.registerAccessory(ACCESSORY_NAME, mrcoolAccessory_1.MrCoolSmartLightAccessory);
7
+ };
@@ -0,0 +1,93 @@
1
+ import { API, AccessoryConfig, AccessoryPlugin, Logging, Service } from 'homebridge';
2
+ interface MrCoolConfig extends AccessoryConfig {
3
+ ip?: string;
4
+ mac?: string;
5
+ climateEntityId?: string;
6
+ name: string;
7
+ pollInterval?: number;
8
+ enableFanOnly?: boolean;
9
+ enableDryMode?: boolean;
10
+ enablePresets?: boolean;
11
+ enableSwing?: boolean;
12
+ autoDisableBeeper?: boolean;
13
+ commandDebounceMs?: number;
14
+ ackTimeoutMs?: number;
15
+ mock?: boolean;
16
+ debug?: boolean;
17
+ }
18
+ export declare class MrCoolSmartLightAccessory implements AccessoryPlugin {
19
+ private readonly log;
20
+ private readonly config;
21
+ private readonly hap;
22
+ private readonly api;
23
+ private thermostatService;
24
+ private informationService;
25
+ private humidityService?;
26
+ private outdoorTempService?;
27
+ private fanOnlySwitch?;
28
+ private dryModeSwitch?;
29
+ private presetSwitches;
30
+ private swingSwitch?;
31
+ private beeperSwitch?;
32
+ private currentTemp;
33
+ private targetTemp;
34
+ private heatingThresholdTemp;
35
+ private coolingThresholdTemp;
36
+ private outdoorTemp;
37
+ private beeperOn;
38
+ private currentState;
39
+ private targetState;
40
+ private humidity;
41
+ private internalMode;
42
+ private pollTimer?;
43
+ private es?;
44
+ private climateEntityId;
45
+ private pendingReconnectDelay;
46
+ private pendingSendTimer?;
47
+ private lastSentMode?;
48
+ private lastSentTarget?;
49
+ private debounceMs;
50
+ private ackTimeoutMs;
51
+ private pendingAck?;
52
+ private currentPreset;
53
+ private currentSwingMode;
54
+ private normalizeClimateEntityId;
55
+ constructor(log: Logging, config: MrCoolConfig, api: API);
56
+ private debug;
57
+ private setInternalMode;
58
+ private applyInternalModeToCurrent;
59
+ private scheduleSyncToDevice;
60
+ private buildDeviceMode;
61
+ private roundTemp;
62
+ private clampTemp;
63
+ private normalizeAutoThresholds;
64
+ private syncTargetTempFromAutoRange;
65
+ private setAutoRangeFromTarget;
66
+ private getEffectiveMode;
67
+ private getEffectiveTargetTemp;
68
+ private flushPendingSend;
69
+ private pollStatus;
70
+ private updateCharacteristics;
71
+ private handleCurrentStateGet;
72
+ private handleTargetStateGet;
73
+ private handleTargetStateSet;
74
+ private handleCurrentTempGet;
75
+ private handleTargetTempGet;
76
+ private handleTargetTempSet;
77
+ private handleHeatingThresholdTempGet;
78
+ private handleCoolingThresholdTempGet;
79
+ private handleHeatingThresholdTempSet;
80
+ private handleCoolingThresholdTempSet;
81
+ getServices(): Service[];
82
+ private connectEventStream;
83
+ private scheduleReconnect;
84
+ private cleanupEventStream;
85
+ private handleClimateState;
86
+ private requestPreset;
87
+ private requestSwing;
88
+ private disableBeeper;
89
+ private startAck;
90
+ private checkAckSatisfied;
91
+ private sendBeeper;
92
+ }
93
+ export {};
@@ -0,0 +1,748 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.MrCoolSmartLightAccessory = void 0;
7
+ const axios_1 = __importDefault(require("axios"));
8
+ const eventsource_1 = __importDefault(require("eventsource"));
9
+ class MrCoolSmartLightAccessory {
10
+ normalizeClimateEntityId(id) {
11
+ return id.startsWith('climate-') ? id : `climate-${id}`;
12
+ }
13
+ constructor(log, config, api) {
14
+ this.presetSwitches = {};
15
+ this.currentTemp = 22;
16
+ this.targetTemp = 22;
17
+ this.heatingThresholdTemp = 21;
18
+ this.coolingThresholdTemp = 23;
19
+ this.outdoorTemp = NaN;
20
+ this.beeperOn = false;
21
+ this.humidity = 45;
22
+ this.internalMode = 'off';
23
+ this.climateEntityId = null; // e.g. climate-air_conditioner
24
+ this.pendingReconnectDelay = 5000;
25
+ this.debounceMs = 450; // settle window for batching mode+temp
26
+ this.ackTimeoutMs = 5000;
27
+ this.currentPreset = 'NONE';
28
+ this.currentSwingMode = 'OFF';
29
+ this.log = log;
30
+ this.config = config;
31
+ this.api = api;
32
+ this.hap = api.hap;
33
+ if (!config.ip && !config.mock) {
34
+ throw new Error('MrCoolSmartLight: ip is required unless mock=true');
35
+ }
36
+ if (typeof config.climateEntityId === 'string' && config.climateEntityId.trim().length > 0) {
37
+ this.climateEntityId = this.normalizeClimateEntityId(config.climateEntityId.trim());
38
+ }
39
+ this.currentState = this.hap.Characteristic.CurrentHeatingCoolingState.OFF;
40
+ this.targetState = this.hap.Characteristic.TargetHeatingCoolingState.OFF;
41
+ // Thermostat service
42
+ this.thermostatService = new this.hap.Service.Thermostat(config.name);
43
+ this.thermostatService.getCharacteristic(this.hap.Characteristic.CurrentHeatingCoolingState)
44
+ .on('get', this.handleCurrentStateGet.bind(this));
45
+ this.thermostatService.getCharacteristic(this.hap.Characteristic.TargetHeatingCoolingState)
46
+ .on('get', this.handleTargetStateGet.bind(this))
47
+ .on('set', this.handleTargetStateSet.bind(this));
48
+ // Ensure AUTO is explicitly exposed (some Home apps hide it if props constrained elsewhere)
49
+ const tState = this.hap.Characteristic.TargetHeatingCoolingState;
50
+ this.thermostatService.getCharacteristic(tState).setProps({
51
+ validValues: [tState.OFF, tState.HEAT, tState.COOL, tState.AUTO],
52
+ });
53
+ this.thermostatService.getCharacteristic(this.hap.Characteristic.CurrentTemperature)
54
+ .on('get', this.handleCurrentTempGet.bind(this));
55
+ this.thermostatService.getCharacteristic(this.hap.Characteristic.TargetTemperature)
56
+ .on('get', this.handleTargetTempGet.bind(this))
57
+ .on('set', this.handleTargetTempSet.bind(this));
58
+ // Device only accepts 0.5C steps and (from testing) roughly 16-30C range (reports 29.0 when set to 30 sometimes)
59
+ this.thermostatService.getCharacteristic(this.hap.Characteristic.TargetTemperature).setProps({
60
+ minValue: 16,
61
+ maxValue: 30,
62
+ minStep: 0.5,
63
+ });
64
+ this.thermostatService.getCharacteristic(this.hap.Characteristic.HeatingThresholdTemperature)
65
+ .on('get', this.handleHeatingThresholdTempGet.bind(this))
66
+ .on('set', this.handleHeatingThresholdTempSet.bind(this));
67
+ this.thermostatService.getCharacteristic(this.hap.Characteristic.CoolingThresholdTemperature)
68
+ .on('get', this.handleCoolingThresholdTempGet.bind(this))
69
+ .on('set', this.handleCoolingThresholdTempSet.bind(this));
70
+ this.thermostatService.getCharacteristic(this.hap.Characteristic.HeatingThresholdTemperature).setProps({
71
+ minValue: 16,
72
+ maxValue: 30,
73
+ minStep: 0.5,
74
+ });
75
+ this.thermostatService.getCharacteristic(this.hap.Characteristic.CoolingThresholdTemperature).setProps({
76
+ minValue: 16,
77
+ maxValue: 30,
78
+ minStep: 0.5,
79
+ });
80
+ // Humidity (placeholder)
81
+ this.humidityService = new this.hap.Service.HumiditySensor(config.name + ' Humidity');
82
+ this.humidityService.getCharacteristic(this.hap.Characteristic.CurrentRelativeHumidity)
83
+ .on('get', (cb) => cb(null, this.humidity));
84
+ // Outdoor temperature (TemperatureSensor). Created eagerly; updated when SSE provides value.
85
+ this.outdoorTempService = new this.hap.Service.TemperatureSensor(config.name + ' Outdoor Temperature', 'outdoorTemp');
86
+ this.outdoorTempService.getCharacteristic(this.hap.Characteristic.CurrentTemperature)
87
+ .on('get', (cb) => cb(null, isNaN(this.outdoorTemp) ? 0 : this.outdoorTemp));
88
+ // Beeper switch (stateful)
89
+ this.beeperSwitch = new this.hap.Service.Switch(config.name + ' Beeper', 'beeper');
90
+ this.beeperSwitch.getCharacteristic(this.hap.Characteristic.On)
91
+ .on('get', (cb) => cb(null, this.beeperOn))
92
+ .on('set', (value, cb) => {
93
+ const on = !!value;
94
+ this.sendBeeper(on).catch(err => this.debug('beeper set error', err));
95
+ this.beeperOn = on; // optimistic
96
+ if (this.beeperSwitch)
97
+ this.beeperSwitch.updateCharacteristic(this.hap.Characteristic.On, this.beeperOn);
98
+ cb(null);
99
+ });
100
+ // Fan-only switch
101
+ if (config.enableFanOnly) {
102
+ this.fanOnlySwitch = new this.hap.Service.Switch(config.name + ' Fan Only', 'fanOnly');
103
+ this.fanOnlySwitch.getCharacteristic(this.hap.Characteristic.On)
104
+ .on('get', (cb) => cb(null, this.internalMode === 'fan'))
105
+ .on('set', (value, cb) => {
106
+ const on = !!value;
107
+ if (on) {
108
+ this.setInternalMode('fan');
109
+ }
110
+ else if (this.internalMode === 'fan') {
111
+ this.setInternalMode('off');
112
+ }
113
+ cb(null);
114
+ });
115
+ }
116
+ // Dry mode switch
117
+ if (config.enableDryMode) {
118
+ this.dryModeSwitch = new this.hap.Service.Switch(config.name + ' Dry Mode', 'dryMode');
119
+ this.dryModeSwitch.getCharacteristic(this.hap.Characteristic.On)
120
+ .on('get', (cb) => cb(null, this.internalMode === 'dry'))
121
+ .on('set', (value, cb) => {
122
+ const on = !!value;
123
+ if (on) {
124
+ this.setInternalMode('dry');
125
+ }
126
+ else if (this.internalMode === 'dry') {
127
+ this.setInternalMode('off');
128
+ }
129
+ cb(null);
130
+ });
131
+ }
132
+ // Optional preset switches (BOOST / ECO / SLEEP) – experimental: device appears to ignore preset changes in testing.
133
+ if (config.enablePresets) {
134
+ ['BOOST', 'ECO', 'SLEEP'].forEach(preset => {
135
+ const svc = new this.hap.Service.Switch(`${config.name} ${preset} Preset`, `preset-${preset}`);
136
+ svc.getCharacteristic(this.hap.Characteristic.On)
137
+ .on('get', (cb) => cb(null, this.currentPreset === preset))
138
+ .on('set', (value, cb) => {
139
+ const on = !!value;
140
+ // Only one preset active; turning one on turns others off; turning off reverts to NONE
141
+ if (on) {
142
+ this.requestPreset(preset);
143
+ }
144
+ else if (this.currentPreset === preset) {
145
+ this.requestPreset('NONE');
146
+ }
147
+ cb(null);
148
+ });
149
+ this.presetSwitches[preset] = svc;
150
+ });
151
+ }
152
+ // Optional swing switch toggles BOTH vs OFF (experimental; device ignored in testing)
153
+ if (config.enableSwing) {
154
+ this.swingSwitch = new this.hap.Service.Switch(`${config.name} Swing`, 'swing');
155
+ this.swingSwitch.getCharacteristic(this.hap.Characteristic.On)
156
+ .on('get', (cb) => cb(null, this.currentSwingMode === 'BOTH'))
157
+ .on('set', (value, cb) => {
158
+ const on = !!value;
159
+ this.requestSwing(on ? 'BOTH' : 'OFF');
160
+ cb(null);
161
+ });
162
+ }
163
+ // Accessory info
164
+ this.informationService = new this.hap.Service.AccessoryInformation()
165
+ .setCharacteristic(this.hap.Characteristic.Manufacturer, 'MrCool')
166
+ .setCharacteristic(this.hap.Characteristic.Model, 'SmartLight SLWF-01 Pro')
167
+ .setCharacteristic(this.hap.Characteristic.SerialNumber, config.mac || 'Unknown');
168
+ // Configurable debounce / ack timeouts
169
+ if (typeof config.commandDebounceMs === 'number' && config.commandDebounceMs >= 100 && config.commandDebounceMs <= 5000) {
170
+ this.debounceMs = config.commandDebounceMs;
171
+ }
172
+ if (typeof config.ackTimeoutMs === 'number' && config.ackTimeoutMs >= 500 && config.ackTimeoutMs <= 15000) {
173
+ this.ackTimeoutMs = config.ackTimeoutMs;
174
+ }
175
+ if (this.config.mock) {
176
+ const interval = (config.pollInterval || 30) * 1000;
177
+ this.pollTimer = setInterval(() => this.pollStatus().catch(err => this.debug('Poll error', err)), interval);
178
+ setTimeout(() => this.pollStatus().catch(() => undefined), 1000);
179
+ }
180
+ else {
181
+ this.connectEventStream();
182
+ }
183
+ }
184
+ // Debug logging disabled (stripped for production minimal logs)
185
+ debug(..._msg) { }
186
+ setInternalMode(mode) {
187
+ this.internalMode = mode;
188
+ this.debug('Set internal mode:', mode);
189
+ // Sync thermostat target when relevant
190
+ const t = this.hap.Characteristic.TargetHeatingCoolingState;
191
+ switch (mode) {
192
+ case 'cool':
193
+ this.targetState = t.COOL;
194
+ break;
195
+ case 'heat':
196
+ this.targetState = t.HEAT;
197
+ break;
198
+ case 'auto':
199
+ this.targetState = t.AUTO;
200
+ this.syncTargetTempFromAutoRange();
201
+ break;
202
+ case 'off':
203
+ case 'fan':
204
+ case 'dry':
205
+ this.targetState = t.OFF;
206
+ break; // fan & dry not native -> OFF target
207
+ }
208
+ this.applyInternalModeToCurrent();
209
+ this.scheduleSyncToDevice();
210
+ this.updateCharacteristics();
211
+ }
212
+ applyInternalModeToCurrent() {
213
+ const c = this.hap.Characteristic.CurrentHeatingCoolingState;
214
+ switch (this.getEffectiveMode()) {
215
+ case 'cool':
216
+ this.currentState = c.COOL;
217
+ break;
218
+ case 'heat':
219
+ this.currentState = c.HEAT;
220
+ break;
221
+ case 'fan':
222
+ case 'dry':
223
+ case 'off':
224
+ default:
225
+ this.currentState = c.OFF;
226
+ break;
227
+ }
228
+ }
229
+ // Schedule a debounced differential sync to device using query parameters only
230
+ scheduleSyncToDevice() {
231
+ if (this.config.mock)
232
+ return; // no outbound when mocking
233
+ if (this.pendingSendTimer)
234
+ clearTimeout(this.pendingSendTimer);
235
+ this.pendingSendTimer = setTimeout(() => this.flushPendingSend().catch(err => this.debug('flushPendingSend error', err)), this.debounceMs);
236
+ }
237
+ buildDeviceMode() {
238
+ const deviceModeMap = {
239
+ off: 'OFF',
240
+ cool: 'COOL',
241
+ heat: 'HEAT',
242
+ auto: 'HEAT_COOL',
243
+ fan: 'FAN_ONLY',
244
+ dry: 'DRY',
245
+ };
246
+ return deviceModeMap[this.getEffectiveMode()];
247
+ }
248
+ roundTemp(v) {
249
+ const stepped = Math.round(v * 2) / 2;
250
+ return stepped;
251
+ }
252
+ clampTemp(v) {
253
+ return Math.min(30, Math.max(16, this.roundTemp(v)));
254
+ }
255
+ normalizeAutoThresholds(changed = 'none') {
256
+ this.heatingThresholdTemp = this.clampTemp(this.heatingThresholdTemp);
257
+ this.coolingThresholdTemp = this.clampTemp(this.coolingThresholdTemp);
258
+ if (this.coolingThresholdTemp - this.heatingThresholdTemp < 0.5) {
259
+ if (changed === 'heat') {
260
+ this.coolingThresholdTemp = this.clampTemp(this.heatingThresholdTemp + 0.5);
261
+ if (this.coolingThresholdTemp - this.heatingThresholdTemp < 0.5) {
262
+ this.heatingThresholdTemp = this.clampTemp(this.coolingThresholdTemp - 0.5);
263
+ }
264
+ }
265
+ else if (changed === 'cool') {
266
+ this.heatingThresholdTemp = this.clampTemp(this.coolingThresholdTemp - 0.5);
267
+ if (this.coolingThresholdTemp - this.heatingThresholdTemp < 0.5) {
268
+ this.coolingThresholdTemp = this.clampTemp(this.heatingThresholdTemp + 0.5);
269
+ }
270
+ }
271
+ else {
272
+ const midpoint = this.roundTemp((this.heatingThresholdTemp + this.coolingThresholdTemp) / 2);
273
+ this.heatingThresholdTemp = this.clampTemp(midpoint - 0.5);
274
+ this.coolingThresholdTemp = this.clampTemp(midpoint + 0.5);
275
+ }
276
+ }
277
+ }
278
+ syncTargetTempFromAutoRange() {
279
+ this.targetTemp = this.roundTemp((this.heatingThresholdTemp + this.coolingThresholdTemp) / 2);
280
+ }
281
+ setAutoRangeFromTarget(center) {
282
+ const currentSpan = Math.max(0.5, this.coolingThresholdTemp - this.heatingThresholdTemp);
283
+ const halfSpan = Math.max(0.25, currentSpan / 2);
284
+ this.heatingThresholdTemp = this.clampTemp(center - halfSpan);
285
+ this.coolingThresholdTemp = this.clampTemp(center + halfSpan);
286
+ this.normalizeAutoThresholds();
287
+ this.syncTargetTempFromAutoRange();
288
+ }
289
+ getEffectiveMode() {
290
+ if (this.internalMode !== 'auto')
291
+ return this.internalMode;
292
+ this.normalizeAutoThresholds();
293
+ if (this.currentTemp <= this.heatingThresholdTemp)
294
+ return 'heat';
295
+ if (this.currentTemp >= this.coolingThresholdTemp)
296
+ return 'cool';
297
+ return 'off';
298
+ }
299
+ getEffectiveTargetTemp() {
300
+ if (this.internalMode !== 'auto') {
301
+ return this.roundTemp(this.targetTemp);
302
+ }
303
+ const effectiveMode = this.getEffectiveMode();
304
+ if (effectiveMode === 'heat')
305
+ return this.heatingThresholdTemp;
306
+ if (effectiveMode === 'cool')
307
+ return this.coolingThresholdTemp;
308
+ return this.lastSentTarget === undefined ? this.targetTemp : this.lastSentTarget;
309
+ }
310
+ async flushPendingSend() {
311
+ if (this.config.mock)
312
+ return;
313
+ const ip = this.config.ip;
314
+ if (!ip)
315
+ return;
316
+ if (!this.climateEntityId) {
317
+ this.debug('Deferring send; climate entity not yet discovered');
318
+ // retry shortly until SSE gives us the entity id
319
+ this.pendingSendTimer = setTimeout(() => this.flushPendingSend().catch(() => undefined), 1500);
320
+ return;
321
+ }
322
+ const deviceMode = this.buildDeviceMode();
323
+ const desiredTarget = this.getEffectiveTargetTemp();
324
+ const params = [];
325
+ if (deviceMode !== this.lastSentMode)
326
+ params.push(`mode=${encodeURIComponent(deviceMode)}`);
327
+ if (this.lastSentTarget === undefined || Math.abs(desiredTarget - this.lastSentTarget) > 0.001) {
328
+ params.push(`target_temperature=${desiredTarget.toFixed(1)}`);
329
+ }
330
+ if (params.length === 0) {
331
+ this.debug('No diff to send (mode/temperature unchanged)');
332
+ return;
333
+ }
334
+ // Device silently ignores fan_mode changes so we skip sending fan_mode entirely for now.
335
+ const endpoint = `http://${ip}/climate/${this.climateEntityId.replace('climate-', '')}/set?${params.join('&')}`;
336
+ this.debug('Sending diff to device', endpoint);
337
+ // sending diff to device (debug removed)
338
+ // no diff to send
339
+ // deferring send; climate entity not yet discovered
340
+ try {
341
+ this.startAck(params.some(p => p.startsWith('mode=')) ? deviceMode : undefined, params.some(p => p.startsWith('target_temperature=')) ? desiredTarget : undefined);
342
+ await axios_1.default.post(endpoint, undefined, { timeout: 5000 });
343
+ // Optimistically update lastSent markers (SSE confirmation will reconcile internalMode/targetTemp anyway)
344
+ if (params.some(p => p.startsWith('mode=')))
345
+ this.lastSentMode = deviceMode;
346
+ if (params.some(p => p.startsWith('target_temperature=')))
347
+ this.lastSentTarget = desiredTarget;
348
+ }
349
+ catch (e) {
350
+ this.pendingAck = undefined;
351
+ this.log.warn('Command POST failed', e.message || e);
352
+ }
353
+ }
354
+ async pollStatus() {
355
+ if (this.config.mock) {
356
+ // Simulate drift and cyclic modes
357
+ this.currentTemp += (Math.random() - 0.5) * 0.2;
358
+ this.applyInternalModeToCurrent();
359
+ if (this.internalMode === 'auto')
360
+ this.scheduleSyncToDevice();
361
+ this.updateCharacteristics();
362
+ return;
363
+ }
364
+ // When not in mock, we rely on SSE; no polling here.
365
+ }
366
+ updateCharacteristics() {
367
+ this.thermostatService.updateCharacteristic(this.hap.Characteristic.CurrentTemperature, this.currentTemp);
368
+ this.thermostatService.updateCharacteristic(this.hap.Characteristic.TargetTemperature, this.targetTemp);
369
+ this.thermostatService.updateCharacteristic(this.hap.Characteristic.HeatingThresholdTemperature, this.heatingThresholdTemp);
370
+ this.thermostatService.updateCharacteristic(this.hap.Characteristic.CoolingThresholdTemperature, this.coolingThresholdTemp);
371
+ this.thermostatService.updateCharacteristic(this.hap.Characteristic.CurrentHeatingCoolingState, this.currentState);
372
+ this.thermostatService.updateCharacteristic(this.hap.Characteristic.TargetHeatingCoolingState, this.targetState);
373
+ if (this.humidityService)
374
+ this.humidityService.updateCharacteristic(this.hap.Characteristic.CurrentRelativeHumidity, this.humidity);
375
+ if (this.fanOnlySwitch)
376
+ this.fanOnlySwitch.updateCharacteristic(this.hap.Characteristic.On, this.internalMode === 'fan');
377
+ if (this.dryModeSwitch)
378
+ this.dryModeSwitch.updateCharacteristic(this.hap.Characteristic.On, this.internalMode === 'dry');
379
+ Object.entries(this.presetSwitches).forEach(([preset, svc]) => {
380
+ svc.updateCharacteristic(this.hap.Characteristic.On, this.currentPreset === preset);
381
+ });
382
+ if (this.swingSwitch)
383
+ this.swingSwitch.updateCharacteristic(this.hap.Characteristic.On, this.currentSwingMode === 'BOTH');
384
+ }
385
+ // Handlers
386
+ handleCurrentStateGet(callback) { callback(null, this.currentState); }
387
+ handleTargetStateGet(callback) { callback(null, this.targetState); }
388
+ handleTargetStateSet(value, callback) {
389
+ const t = this.hap.Characteristic.TargetHeatingCoolingState;
390
+ if (value === t.COOL)
391
+ this.setInternalMode('cool');
392
+ else if (value === t.HEAT)
393
+ this.setInternalMode('heat');
394
+ else if (value === t.AUTO)
395
+ this.setInternalMode('auto');
396
+ else
397
+ this.setInternalMode('off');
398
+ callback(null);
399
+ }
400
+ handleCurrentTempGet(callback) { callback(null, this.currentTemp); }
401
+ handleTargetTempGet(callback) { callback(null, this.targetTemp); }
402
+ handleTargetTempSet(value, callback) {
403
+ const nextTarget = value;
404
+ if (this.internalMode === 'auto') {
405
+ this.setAutoRangeFromTarget(nextTarget);
406
+ }
407
+ else {
408
+ this.targetTemp = nextTarget;
409
+ }
410
+ this.applyInternalModeToCurrent();
411
+ this.scheduleSyncToDevice();
412
+ this.updateCharacteristics();
413
+ callback(null);
414
+ }
415
+ handleHeatingThresholdTempGet(callback) { callback(null, this.heatingThresholdTemp); }
416
+ handleCoolingThresholdTempGet(callback) { callback(null, this.coolingThresholdTemp); }
417
+ handleHeatingThresholdTempSet(value, callback) {
418
+ this.heatingThresholdTemp = this.clampTemp(value);
419
+ this.normalizeAutoThresholds('heat');
420
+ this.syncTargetTempFromAutoRange();
421
+ this.applyInternalModeToCurrent();
422
+ this.scheduleSyncToDevice();
423
+ this.updateCharacteristics();
424
+ callback(null);
425
+ }
426
+ handleCoolingThresholdTempSet(value, callback) {
427
+ this.coolingThresholdTemp = this.clampTemp(value);
428
+ this.normalizeAutoThresholds('cool');
429
+ this.syncTargetTempFromAutoRange();
430
+ this.applyInternalModeToCurrent();
431
+ this.scheduleSyncToDevice();
432
+ this.updateCharacteristics();
433
+ callback(null);
434
+ }
435
+ getServices() {
436
+ const base = [this.informationService, this.thermostatService];
437
+ if (this.humidityService)
438
+ base.push(this.humidityService);
439
+ if (this.outdoorTempService)
440
+ base.push(this.outdoorTempService);
441
+ if (this.fanOnlySwitch)
442
+ base.push(this.fanOnlySwitch);
443
+ if (this.dryModeSwitch)
444
+ base.push(this.dryModeSwitch);
445
+ Object.values(this.presetSwitches).forEach(s => base.push(s));
446
+ if (this.swingSwitch)
447
+ base.push(this.swingSwitch);
448
+ if (this.beeperSwitch)
449
+ base.push(this.beeperSwitch);
450
+ // display toggle & swing step removed
451
+ return base;
452
+ }
453
+ // --- Event Stream Handling ---
454
+ connectEventStream() {
455
+ if (!this.config.ip)
456
+ return;
457
+ const url = `http://${this.config.ip}/events`;
458
+ this.debug('Connecting SSE', url);
459
+ // connecting SSE
460
+ try {
461
+ this.es = new eventsource_1.default(url);
462
+ }
463
+ catch (e) {
464
+ this.log.error('Failed to create EventSource', e.message || e);
465
+ this.scheduleReconnect();
466
+ return;
467
+ }
468
+ // If we don't discover a climate entity within 12s, emit a warning (helps diagnose missing HEAT_COOL reflection)
469
+ setTimeout(() => {
470
+ if (!this.climateEntityId) {
471
+ this.log.warn('No climate state received yet (still waiting for climate-* SSE events). Check device IP / connectivity.');
472
+ }
473
+ }, 12000);
474
+ this.es.onmessage = (evt) => {
475
+ // Generic messages (without event field) might not appear; we rely on typed handlers below
476
+ this.debug('SSE message', evt.data?.slice(0, 120));
477
+ };
478
+ this.es.addEventListener('ping', (ev) => {
479
+ // keep-alive; could parse general metadata
480
+ });
481
+ this.es.addEventListener('state', (ev) => {
482
+ try {
483
+ const data = JSON.parse(ev.data);
484
+ if (this.config.debug && data && data.id) {
485
+ // debug removed: SSE state id
486
+ }
487
+ if (!data || !data.id)
488
+ return;
489
+ if (data.id.startsWith('climate-')) {
490
+ this.handleClimateState(data);
491
+ }
492
+ else if (data.id === 'sensor-air_conditioner_indoor_humidity' && typeof data.value === 'number') {
493
+ this.humidity = data.value;
494
+ if (this.humidityService)
495
+ this.humidityService.updateCharacteristic(this.hap.Characteristic.CurrentRelativeHumidity, this.humidity);
496
+ }
497
+ else if (data.id.startsWith('sensor-air_conditioner_outdoor_temperature')) {
498
+ const valRaw = data.value;
499
+ let val = NaN;
500
+ if (typeof valRaw === 'number')
501
+ val = valRaw;
502
+ else if (typeof valRaw === 'string') {
503
+ const parsed = parseFloat(valRaw);
504
+ if (!isNaN(parsed))
505
+ val = parsed;
506
+ }
507
+ if (!isNaN(val)) {
508
+ this.outdoorTemp = val;
509
+ if (this.outdoorTempService)
510
+ this.outdoorTempService.updateCharacteristic(this.hap.Characteristic.CurrentTemperature, this.outdoorTemp);
511
+ this.debug('Updated outdoor temperature', this.outdoorTemp);
512
+ // updated outdoor temperature
513
+ }
514
+ }
515
+ else if (data.id === 'switch-air_conditioner_beeper' && typeof data.value === 'string') {
516
+ const v = data.value.toLowerCase();
517
+ const on = v === 'on';
518
+ if (this.beeperOn !== on) {
519
+ this.beeperOn = on;
520
+ this.beeperSwitch?.updateCharacteristic(this.hap.Characteristic.On, this.beeperOn);
521
+ this.debug('Beeper state update', this.beeperOn);
522
+ // beeper state update
523
+ }
524
+ }
525
+ }
526
+ catch (e) {
527
+ this.debug('Failed to parse state event', e.message || e);
528
+ // failed to parse state event
529
+ }
530
+ });
531
+ this.es.addEventListener('log', (ev) => {
532
+ // Could parse internal debug; ignore for now unless debug enabled
533
+ if (this.config.debug) {
534
+ // device internal log ignored
535
+ }
536
+ });
537
+ this.es.onerror = (err) => {
538
+ this.log.warn('SSE error; will reconnect', err ? JSON.stringify(err) : 'unknown');
539
+ this.cleanupEventStream();
540
+ this.scheduleReconnect();
541
+ };
542
+ }
543
+ scheduleReconnect() {
544
+ if (this.config.mock)
545
+ return;
546
+ setTimeout(() => this.connectEventStream(), this.pendingReconnectDelay);
547
+ // Exponential backoff up to 60s
548
+ this.pendingReconnectDelay = Math.min(this.pendingReconnectDelay * 2, 60000);
549
+ }
550
+ cleanupEventStream() {
551
+ if (this.es) {
552
+ try {
553
+ this.es.close();
554
+ }
555
+ catch { /* ignore */ }
556
+ this.es = undefined;
557
+ }
558
+ }
559
+ handleClimateState(data) {
560
+ // Raw debug before any mapping so we can diagnose mismatches
561
+ this.debug('SSE climate raw', {
562
+ /* debug removed: SSE climate raw */
563
+ id: data.id,
564
+ mode: data.mode,
565
+ current_temperature: data.current_temperature,
566
+ target_temperature: data.target_temperature,
567
+ preset: data.preset,
568
+ swing_mode: data.swing_mode,
569
+ });
570
+ if (!this.climateEntityId) {
571
+ this.climateEntityId = this.normalizeClimateEntityId(data.id); // store full id e.g. climate-air_conditioner
572
+ this.debug('Discovered climate entity', this.climateEntityId);
573
+ // discovered climate entity
574
+ // Optionally auto-disable beeper once after discovery
575
+ if (this.config.autoDisableBeeper) {
576
+ this.disableBeeper().catch(err => this.debug('autoDisableBeeper failed', err));
577
+ this.disableBeeper().catch(() => undefined);
578
+ }
579
+ }
580
+ // Extract numbers (some come as strings)
581
+ const parseNum = (v) => typeof v === 'number' ? v : (typeof v === 'string' ? parseFloat(v) : NaN);
582
+ const current = parseNum(data.current_temperature);
583
+ const target = parseNum(data.target_temperature);
584
+ if (!isNaN(current))
585
+ this.currentTemp = current;
586
+ if (!isNaN(target) && this.internalMode !== 'auto')
587
+ this.targetTemp = target;
588
+ if (this.internalMode === 'auto')
589
+ this.syncTargetTempFromAutoRange();
590
+ if (typeof data.mode === 'string') {
591
+ const mode = data.mode.toUpperCase();
592
+ const map = {
593
+ 'OFF': 'off',
594
+ 'COOL': 'cool',
595
+ 'HEAT': 'heat',
596
+ 'HEAT_COOL': 'auto',
597
+ 'FAN_ONLY': 'fan',
598
+ 'DRY': 'dry',
599
+ };
600
+ const internal = map[mode];
601
+ if (internal && this.internalMode !== 'auto')
602
+ this.internalMode = internal;
603
+ // Also update targetState to reflect device-reported mode (previously only updated when user initiated change)
604
+ const t = this.hap.Characteristic.TargetHeatingCoolingState;
605
+ switch (this.internalMode === 'auto' ? 'auto' : this.internalMode) {
606
+ case 'cool':
607
+ this.targetState = t.COOL;
608
+ break;
609
+ case 'heat':
610
+ this.targetState = t.HEAT;
611
+ break;
612
+ case 'auto':
613
+ this.targetState = t.AUTO;
614
+ break;
615
+ default:
616
+ this.targetState = t.OFF;
617
+ break; // fan/dry/off map to OFF target for HomeKit
618
+ }
619
+ // If device reports a mode different from what we last sent, allow future diff (do not overwrite lastSentMode here unless matches)
620
+ if (this.buildDeviceMode() !== this.lastSentMode) {
621
+ // divergence - keep lastSentMode as is so a subsequent local change will trigger send
622
+ }
623
+ else {
624
+ // matched; nothing to adjust
625
+ }
626
+ }
627
+ if (typeof data.preset === 'string') {
628
+ this.currentPreset = data.preset.toUpperCase();
629
+ }
630
+ if (typeof data.swing_mode === 'string') {
631
+ this.currentSwingMode = data.swing_mode.toUpperCase();
632
+ }
633
+ // Update lastSentTarget only if matches device (ensures we don't suppress future attempted changes if device rejected)
634
+ if (!isNaN(target) && (this.lastSentTarget === undefined || Math.abs(target - this.lastSentTarget) < 0.001)) {
635
+ this.lastSentTarget = target; // confirm
636
+ }
637
+ this.checkAckSatisfied();
638
+ this.applyInternalModeToCurrent();
639
+ if (this.internalMode === 'auto')
640
+ this.scheduleSyncToDevice();
641
+ this.updateCharacteristics();
642
+ this.debug('SSE climate mapped', {
643
+ /* debug removed: SSE climate mapped */
644
+ internalMode: this.internalMode,
645
+ currentTemp: this.currentTemp,
646
+ targetTemp: this.targetTemp,
647
+ currentState: this.currentState,
648
+ targetState: this.targetState,
649
+ preset: this.currentPreset,
650
+ swing: this.currentSwingMode,
651
+ });
652
+ }
653
+ // --- Experimental feature helpers ---
654
+ async requestPreset(preset) {
655
+ if (this.config.mock) {
656
+ this.currentPreset = preset;
657
+ this.updateCharacteristics();
658
+ return;
659
+ }
660
+ if (!this.config.ip || !this.climateEntityId)
661
+ return;
662
+ const endpoint = `http://${this.config.ip}/climate/${this.climateEntityId.replace('climate-', '')}/set?preset=${encodeURIComponent(preset)}`;
663
+ this.debug('Sending preset request', endpoint);
664
+ // sending preset request (debug removed)
665
+ try {
666
+ await axios_1.default.post(endpoint, undefined, { timeout: 4000 });
667
+ }
668
+ catch (e) {
669
+ this.log.warn('Preset request failed', e.message || e);
670
+ }
671
+ // We rely on SSE to update; if not changed, switch will revert on next updateCharacteristics call triggered by other events.
672
+ }
673
+ async requestSwing(swing) {
674
+ if (this.config.mock) {
675
+ this.currentSwingMode = swing;
676
+ this.updateCharacteristics();
677
+ return;
678
+ }
679
+ if (!this.config.ip || !this.climateEntityId)
680
+ return;
681
+ const endpoint = `http://${this.config.ip}/climate/${this.climateEntityId.replace('climate-', '')}/set?swing_mode=${encodeURIComponent(swing)}`;
682
+ this.debug('Sending swing request', endpoint);
683
+ // sending swing request (debug removed)
684
+ try {
685
+ await axios_1.default.post(endpoint, undefined, { timeout: 4000 });
686
+ }
687
+ catch (e) {
688
+ this.log.warn('Swing request failed', e.message || e);
689
+ }
690
+ }
691
+ async disableBeeper() {
692
+ if (this.config.mock || !this.config.ip)
693
+ return;
694
+ const endpoint = `http://${this.config.ip}/switch/air_conditioner_beeper/turn_off`;
695
+ this.debug('Auto disabling beeper');
696
+ // auto disabling beeper
697
+ try {
698
+ await axios_1.default.post(endpoint, undefined, { timeout: 3000 });
699
+ }
700
+ catch (e) {
701
+ this.debug('disableBeeper error', e.message || e);
702
+ }
703
+ try {
704
+ await axios_1.default.post(endpoint, undefined, { timeout: 3000 });
705
+ }
706
+ catch { /* ignore */ }
707
+ }
708
+ startAck(expectedMode, expectedTarget) {
709
+ this.pendingAck = { sentAt: Date.now(), expectedMode, expectedTarget };
710
+ setTimeout(() => {
711
+ if (this.pendingAck && Date.now() - this.pendingAck.sentAt >= this.ackTimeoutMs) {
712
+ this.log.warn('No SSE acknowledgement within timeout for mode/temperature change');
713
+ this.pendingAck = undefined;
714
+ }
715
+ }, this.ackTimeoutMs + 50);
716
+ }
717
+ checkAckSatisfied() {
718
+ if (!this.pendingAck)
719
+ return;
720
+ const expected = this.pendingAck;
721
+ let satisfied = true;
722
+ if (expected.expectedMode && expected.expectedMode !== this.buildDeviceMode())
723
+ satisfied = false;
724
+ if (typeof expected.expectedTarget === 'number' && Math.abs(this.targetTemp - expected.expectedTarget) > 0.001)
725
+ satisfied = false;
726
+ if (satisfied) {
727
+ this.debug('Ack satisfied for mode/temperature change');
728
+ // ack satisfied
729
+ this.pendingAck = undefined;
730
+ }
731
+ }
732
+ // --- Device control for new switch/button services ---
733
+ async sendBeeper(on) {
734
+ if (this.config.mock || !this.config.ip)
735
+ return;
736
+ const action = on ? 'turn_on' : 'turn_off';
737
+ const endpoint = `http://${this.config.ip}/switch/air_conditioner_beeper/${action}`;
738
+ this.debug('Beeper request', endpoint);
739
+ // beeper request
740
+ try {
741
+ await axios_1.default.post(endpoint, undefined, { timeout: 3000 });
742
+ }
743
+ catch (err) {
744
+ this.log.warn('Beeper request failed', err.message || err);
745
+ }
746
+ }
747
+ }
748
+ exports.MrCoolSmartLightAccessory = MrCoolSmartLightAccessory;
package/package.json ADDED
@@ -0,0 +1,58 @@
1
+ {
2
+ "name": "homebridge-mrcool-hvac",
3
+ "version": "0.1.1",
4
+ "description": "Homebridge plugin for local MrCool HVAC control via SMARTLIGHT SLWF-01 Pro module (SSE + HTTP).",
5
+ "license": "MIT",
6
+ "author": "Nilesh <nilesh@cloudgeni.us>",
7
+ "homepage": "https://github.com/lvnilesh/homebridge-mrcool-smartlight#readme",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/lvnilesh/homebridge-mrcool-smartlight.git"
11
+ },
12
+ "bugs": {
13
+ "url": "https://github.com/lvnilesh/homebridge-mrcool-smartlight/issues"
14
+ },
15
+ "keywords": [
16
+ "homebridge-plugin",
17
+ "homekit",
18
+ "mrcool",
19
+ "hvac",
20
+ "thermostat",
21
+ "air-conditioner",
22
+ "smartlight",
23
+ "sse"
24
+ ],
25
+ "engines": {
26
+ "node": ">=16.0.0",
27
+ "homebridge": ">=1.6.0"
28
+ },
29
+ "main": "dist/index.js",
30
+ "types": "dist/index.d.ts",
31
+ "files": [
32
+ "dist",
33
+ "README.md",
34
+ "LICENSE",
35
+ "CHANGELOG.md"
36
+ ],
37
+ "scripts": {
38
+ "build": "tsc -p .",
39
+ "watch": "tsc -w -p .",
40
+ "prepare": "npm run build",
41
+ "pack:check": "npm pack --dry-run"
42
+ },
43
+ "devDependencies": {
44
+ "typescript": "^5.4.0",
45
+ "@types/node": "^20.11.0",
46
+ "@types/eventsource": "^1.1.11"
47
+ },
48
+ "dependencies": {
49
+ "axios": "^1.7.0",
50
+ "eventsource": "^2.0.2"
51
+ },
52
+ "peerDependencies": {
53
+ "homebridge": ">=1.6.0"
54
+ },
55
+ "publishConfig": {
56
+ "access": "public"
57
+ }
58
+ }