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 +22 -0
- package/LICENSE +21 -0
- package/README.md +162 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +7 -0
- package/dist/mrcoolAccessory.d.ts +93 -0
- package/dist/mrcoolAccessory.js +748 -0
- package/package.json +58 -0
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
|
+
|
package/dist/index.d.ts
ADDED
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
|
+
}
|