homebridge-envoy-solar-sensor 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/.gitattributes +2 -0
- package/README.md +104 -0
- package/config.schema.json +74 -0
- package/index.js +213 -0
- package/package.json +27 -0
package/.gitattributes
ADDED
package/README.md
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
Homebridge Envoy Solar Sensor
|
|
2
|
+
|
|
3
|
+
Homebridge Envoy Solar Sensor is a Homebridge platform plugin that reads real time solar production data from an Enphase Envoy and exposes it to Apple HomeKit as a sensor.
|
|
4
|
+
|
|
5
|
+
By converting photovoltaic production into a simple active or inactive state, HomeKit automations can react to actual daylight conditions instead of fixed schedules or unreliable ambient light sensors. This makes it ideal for switching outdoor lighting, garden lights or other devices based on real solar output.
|
|
6
|
+
|
|
7
|
+
How it works
|
|
8
|
+
|
|
9
|
+
The plugin periodically polls the local Enphase Envoy for current solar production measured in watts.
|
|
10
|
+
|
|
11
|
+
When production rises above a configurable on threshold, the HomeKit sensor becomes active.
|
|
12
|
+
When production falls below a configurable off threshold, the sensor becomes inactive again.
|
|
13
|
+
|
|
14
|
+
Using two separate thresholds creates hysteresis, preventing rapid switching during clouds, shade or twilight conditions.
|
|
15
|
+
|
|
16
|
+
In HomeKit the sensor is exposed as a Contact Sensor.
|
|
17
|
+
Active solar production is represented as an open contact.
|
|
18
|
+
Low or no production is represented as a closed contact.
|
|
19
|
+
|
|
20
|
+
Supported Envoy endpoints
|
|
21
|
+
|
|
22
|
+
The plugin supports the most commonly available local Envoy endpoints.
|
|
23
|
+
|
|
24
|
+
Older Envoy firmware using the production.json endpoint.
|
|
25
|
+
Newer Envoy firmware using the api v1 production endpoint.
|
|
26
|
+
|
|
27
|
+
The correct endpoint can be selected directly in the Homebridge UI.
|
|
28
|
+
|
|
29
|
+
Installation
|
|
30
|
+
|
|
31
|
+
Install Homebridge if it is not already installed on your system.
|
|
32
|
+
|
|
33
|
+
Install the plugin using npm.
|
|
34
|
+
|
|
35
|
+
npm install homebridge-envoy-solar-sensor
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
Restart Homebridge after installation to load the plugin.
|
|
39
|
+
|
|
40
|
+
Configuration using Homebridge UI
|
|
41
|
+
|
|
42
|
+
This plugin is fully configurable using the Homebridge web interface.
|
|
43
|
+
|
|
44
|
+
Open the Homebridge UI and navigate to the plugin settings for Homebridge Envoy Solar Sensor.
|
|
45
|
+
All configuration options are presented as form fields and no manual editing of config.json is required.
|
|
46
|
+
|
|
47
|
+
The Envoy IP Address field should contain the local IP address or hostname of your Enphase Envoy.
|
|
48
|
+
|
|
49
|
+
The Protocol option allows selecting HTTP or HTTPS depending on your Envoy configuration.
|
|
50
|
+
|
|
51
|
+
The Envoy API Mode setting determines which endpoint is used to read production data.
|
|
52
|
+
|
|
53
|
+
The Poll Interval defines how often the Envoy is queried for new production values.
|
|
54
|
+
|
|
55
|
+
The On Threshold specifies the production level in watts above which the sensor becomes active.
|
|
56
|
+
|
|
57
|
+
The Off Threshold specifies the production level in watts below which the sensor becomes inactive again.
|
|
58
|
+
|
|
59
|
+
An optional authentication token can be provided for secured Envoy installations.
|
|
60
|
+
|
|
61
|
+
Example Homebridge configuration
|
|
62
|
+
|
|
63
|
+
When configured through the UI, Homebridge generates the following configuration internally.
|
|
64
|
+
|
|
65
|
+
{
|
|
66
|
+
"platform": "EnvoySolarSensor",
|
|
67
|
+
"name": "Solar Production",
|
|
68
|
+
"host": "192.168.1.50",
|
|
69
|
+
"protocol": "http",
|
|
70
|
+
"mode": "productionJson",
|
|
71
|
+
"pollIntervalSeconds": 10,
|
|
72
|
+
"onThresholdW": 80,
|
|
73
|
+
"offThresholdW": 30
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
HomeKit automations
|
|
77
|
+
|
|
78
|
+
Once the plugin is running, a Contact Sensor named Solar Production appears in the Home app.
|
|
79
|
+
|
|
80
|
+
Typical automations include turning outdoor lights off when solar production becomes active and turning outdoor lights on when solar production becomes inactive.
|
|
81
|
+
|
|
82
|
+
Because the automation is based on real solar output, lighting behavior naturally adapts to seasons, weather and cloud cover.
|
|
83
|
+
|
|
84
|
+
Error handling and reliability
|
|
85
|
+
|
|
86
|
+
If the Envoy cannot be reached or returns invalid data, the sensor reports a fault state in HomeKit.
|
|
87
|
+
Once communication is restored, the fault state is cleared automatically.
|
|
88
|
+
|
|
89
|
+
Polling is fully local and does not rely on cloud services.
|
|
90
|
+
|
|
91
|
+
Requirements
|
|
92
|
+
|
|
93
|
+
Node.js version 18 or higher is required.
|
|
94
|
+
Homebridge version 1.6 or higher is required.
|
|
95
|
+
An Enphase Envoy accessible on the local network is required.
|
|
96
|
+
|
|
97
|
+
License
|
|
98
|
+
|
|
99
|
+
This project is licensed under the MIT License.
|
|
100
|
+
|
|
101
|
+
Contributing
|
|
102
|
+
|
|
103
|
+
Contributions, improvements and feature requests are welcome.
|
|
104
|
+
Please open an issue or pull request on GitHub.
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
{
|
|
2
|
+
"pluginAlias": "EnvoySolarSensor",
|
|
3
|
+
"pluginType": "platform",
|
|
4
|
+
"singular": true,
|
|
5
|
+
"schema": {
|
|
6
|
+
"type": "object",
|
|
7
|
+
"required": ["host"],
|
|
8
|
+
"properties": {
|
|
9
|
+
"name": {
|
|
10
|
+
"title": "Accessory Name",
|
|
11
|
+
"type": "string",
|
|
12
|
+
"default": "Solar Production",
|
|
13
|
+
"description": "Name shown in HomeKit"
|
|
14
|
+
},
|
|
15
|
+
"host": {
|
|
16
|
+
"title": "Envoy IP Address",
|
|
17
|
+
"type": "string",
|
|
18
|
+
"description": "IP address or hostname of the Enphase Envoy on your local network"
|
|
19
|
+
},
|
|
20
|
+
"protocol": {
|
|
21
|
+
"title": "Protocol",
|
|
22
|
+
"type": "string",
|
|
23
|
+
"default": "http",
|
|
24
|
+
"oneOf": [
|
|
25
|
+
{ "title": "HTTP", "enum": ["http"] },
|
|
26
|
+
{ "title": "HTTPS", "enum": ["https"] }
|
|
27
|
+
],
|
|
28
|
+
"description": "Protocol used to connect to the Envoy"
|
|
29
|
+
},
|
|
30
|
+
"mode": {
|
|
31
|
+
"title": "Envoy API Mode",
|
|
32
|
+
"type": "string",
|
|
33
|
+
"default": "productionJson",
|
|
34
|
+
"oneOf": [
|
|
35
|
+
{
|
|
36
|
+
"title": "production.json (older Envoy)",
|
|
37
|
+
"enum": ["productionJson"]
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
"title": "api/v1/production (newer Envoy)",
|
|
41
|
+
"enum": ["v1Production"]
|
|
42
|
+
}
|
|
43
|
+
],
|
|
44
|
+
"description": "Select which Envoy API endpoint is available on your device"
|
|
45
|
+
},
|
|
46
|
+
"pollIntervalSeconds": {
|
|
47
|
+
"title": "Poll Interval",
|
|
48
|
+
"type": "integer",
|
|
49
|
+
"default": 10,
|
|
50
|
+
"minimum": 5,
|
|
51
|
+
"description": "How often the Envoy is polled for production data in seconds"
|
|
52
|
+
},
|
|
53
|
+
"onThresholdW": {
|
|
54
|
+
"title": "On Threshold (Watts)",
|
|
55
|
+
"type": "integer",
|
|
56
|
+
"default": 80,
|
|
57
|
+
"description": "Production level above which the sensor becomes active"
|
|
58
|
+
},
|
|
59
|
+
"offThresholdW": {
|
|
60
|
+
"title": "Off Threshold (Watts)",
|
|
61
|
+
"type": "integer",
|
|
62
|
+
"default": 30,
|
|
63
|
+
"description": "Production level below which the sensor becomes inactive"
|
|
64
|
+
},
|
|
65
|
+
"token": {
|
|
66
|
+
"title": "Authentication Token",
|
|
67
|
+
"type": "string",
|
|
68
|
+
"default": "",
|
|
69
|
+
"description": "Optional bearer token for secured Envoy endpoints"
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
package/index.js
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const PLUGIN_NAME = 'homebridge-envoy-solar-sensor';
|
|
4
|
+
const PLATFORM_NAME = 'EnvoySolarSensor';
|
|
5
|
+
|
|
6
|
+
module.exports = (api) => {
|
|
7
|
+
api.registerPlatform(PLUGIN_NAME, PLATFORM_NAME, EnvoySolarPlatform);
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
class EnvoySolarPlatform {
|
|
11
|
+
constructor(log, config, api) {
|
|
12
|
+
this.log = log;
|
|
13
|
+
this.config = config || {};
|
|
14
|
+
this.api = api;
|
|
15
|
+
|
|
16
|
+
this.Service = this.api.hap.Service;
|
|
17
|
+
this.Characteristic = this.api.hap.Characteristic;
|
|
18
|
+
|
|
19
|
+
this.accessory = null;
|
|
20
|
+
this.pollTimer = null;
|
|
21
|
+
|
|
22
|
+
this.api.on('didFinishLaunching', () => {
|
|
23
|
+
this.log.info('Platform gestart');
|
|
24
|
+
this.setupAccessory();
|
|
25
|
+
this.startPolling();
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
configureAccessory(accessory) {
|
|
30
|
+
this.accessory = accessory;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
setupAccessory() {
|
|
34
|
+
const name = this.config.name || 'Zon Opwekking';
|
|
35
|
+
const host = this.config.host;
|
|
36
|
+
if (!host) {
|
|
37
|
+
this.log.error('Config mist host. Voorbeeld: "host": "192.168.1.50"');
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const uuid = this.api.hap.uuid.generate(`envoy-solar-sensor:${host}`);
|
|
42
|
+
|
|
43
|
+
if (!this.accessory) {
|
|
44
|
+
this.accessory = new this.api.platformAccessory(name, uuid);
|
|
45
|
+
this.api.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [this.accessory]);
|
|
46
|
+
} else {
|
|
47
|
+
this.accessory.displayName = name;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const info = this.accessory.getService(this.Service.AccessoryInformation)
|
|
51
|
+
|| this.accessory.addService(this.Service.AccessoryInformation);
|
|
52
|
+
|
|
53
|
+
info
|
|
54
|
+
.setCharacteristic(this.Characteristic.Manufacturer, 'Enphase')
|
|
55
|
+
.setCharacteristic(this.Characteristic.Model, 'Envoy')
|
|
56
|
+
.setCharacteristic(this.Characteristic.SerialNumber, String(host));
|
|
57
|
+
|
|
58
|
+
const service = this.accessory.getService(this.Service.ContactSensor)
|
|
59
|
+
|| this.accessory.addService(this.Service.ContactSensor, 'Opwekking Actief', 'production-active');
|
|
60
|
+
|
|
61
|
+
service.setCharacteristic(this.Characteristic.Name, 'Opwekking Actief');
|
|
62
|
+
|
|
63
|
+
if (service.testCharacteristic(this.Characteristic.StatusActive)) {
|
|
64
|
+
service.updateCharacteristic(this.Characteristic.StatusActive, true);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
this.log.info(`Accessoire klaar: ${name} op host ${host}`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
startPolling() {
|
|
71
|
+
if (!this.accessory) return;
|
|
72
|
+
|
|
73
|
+
const interval = Math.max(5, Number(this.config.pollIntervalSeconds ?? 10));
|
|
74
|
+
this.log.info(`Poll interval: ${interval} seconden`);
|
|
75
|
+
|
|
76
|
+
const tick = async () => {
|
|
77
|
+
try {
|
|
78
|
+
const watts = await this.readProductionWatts();
|
|
79
|
+
this.updateState(watts);
|
|
80
|
+
} catch (err) {
|
|
81
|
+
this.log.warn(`Uitlezen mislukt: ${err && err.message ? err.message : String(err)}`);
|
|
82
|
+
this.markFault();
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
tick();
|
|
87
|
+
this.pollTimer = setInterval(tick, interval * 1000);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
getThresholds() {
|
|
91
|
+
const onT = Number(this.config.onThresholdW ?? 80);
|
|
92
|
+
const offT = Number(this.config.offThresholdW ?? 30);
|
|
93
|
+
return { onT, offT };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
getMode() {
|
|
97
|
+
return this.config.mode || 'productionJson';
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
getHost() {
|
|
101
|
+
return this.config.host;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
getToken() {
|
|
105
|
+
return this.config.token;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
getBaseUrl() {
|
|
109
|
+
const proto = this.config.protocol || 'http';
|
|
110
|
+
return `${proto}://${this.getHost()}`;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
markFault() {
|
|
114
|
+
if (!this.accessory) return;
|
|
115
|
+
const service = this.accessory.getService(this.Service.ContactSensor);
|
|
116
|
+
if (!service) return;
|
|
117
|
+
|
|
118
|
+
if (service.testCharacteristic(this.Characteristic.StatusFault)) {
|
|
119
|
+
service.updateCharacteristic(this.Characteristic.StatusFault, this.Characteristic.StatusFault.GENERAL_FAULT);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
clearFault() {
|
|
124
|
+
if (!this.accessory) return;
|
|
125
|
+
const service = this.accessory.getService(this.Service.ContactSensor);
|
|
126
|
+
if (!service) return;
|
|
127
|
+
|
|
128
|
+
if (service.testCharacteristic(this.Characteristic.StatusFault)) {
|
|
129
|
+
service.updateCharacteristic(this.Characteristic.StatusFault, this.Characteristic.StatusFault.NO_FAULT);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
updateState(productionWatts) {
|
|
134
|
+
if (!this.accessory) return;
|
|
135
|
+
|
|
136
|
+
const service = this.accessory.getService(this.Service.ContactSensor);
|
|
137
|
+
if (!service) return;
|
|
138
|
+
|
|
139
|
+
const { onT, offT } = this.getThresholds();
|
|
140
|
+
|
|
141
|
+
const current = service.getCharacteristic(this.Characteristic.ContactSensorState).value;
|
|
142
|
+
const isOpenNow = current === this.Characteristic.ContactSensorState.CONTACT_NOT_DETECTED;
|
|
143
|
+
|
|
144
|
+
let shouldBeOpen = isOpenNow;
|
|
145
|
+
|
|
146
|
+
if (!isOpenNow && productionWatts >= onT) {
|
|
147
|
+
shouldBeOpen = true;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (isOpenNow && productionWatts <= offT) {
|
|
151
|
+
shouldBeOpen = false;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const nextValue = shouldBeOpen
|
|
155
|
+
? this.Characteristic.ContactSensorState.CONTACT_NOT_DETECTED
|
|
156
|
+
: this.Characteristic.ContactSensorState.CONTACT_DETECTED;
|
|
157
|
+
|
|
158
|
+
service.updateCharacteristic(this.Characteristic.ContactSensorState, nextValue);
|
|
159
|
+
this.clearFault();
|
|
160
|
+
|
|
161
|
+
this.log.debug(`Productie ${productionWatts} W, aan ${onT} W, uit ${offT} W, actief ${shouldBeOpen}`);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async readProductionWatts() {
|
|
165
|
+
const mode = this.getMode();
|
|
166
|
+
const base = this.getBaseUrl();
|
|
167
|
+
|
|
168
|
+
let url = '';
|
|
169
|
+
if (mode === 'productionJson') url = `${base}/production.json`;
|
|
170
|
+
else if (mode === 'v1Production') url = `${base}/api/v1/production`;
|
|
171
|
+
else throw new Error(`Onbekende mode: ${mode}`);
|
|
172
|
+
|
|
173
|
+
const data = await this.fetchJson(url);
|
|
174
|
+
|
|
175
|
+
if (mode === 'productionJson') return this.extractWattsFromProductionJson(data);
|
|
176
|
+
return this.extractWattsFromV1Production(data);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
extractWattsFromProductionJson(data) {
|
|
180
|
+
const productionArray = data && data.production;
|
|
181
|
+
if (!Array.isArray(productionArray)) throw new Error('production.json mist production array');
|
|
182
|
+
|
|
183
|
+
const eim = productionArray.find((x) => x && x.type === 'eim');
|
|
184
|
+
const wNow = eim && eim.wNow;
|
|
185
|
+
|
|
186
|
+
if (typeof wNow !== 'number') throw new Error('production.json mist wNow (type eim)');
|
|
187
|
+
return Math.max(0, wNow);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
extractWattsFromV1Production(data) {
|
|
191
|
+
const wattsNow = data && (data.wattsNow ?? data.watts_now);
|
|
192
|
+
if (typeof wattsNow === 'number') return Math.max(0, wattsNow);
|
|
193
|
+
|
|
194
|
+
const wNow = data && data.production && Array.isArray(data.production) ? data.production?.[0]?.wNow : undefined;
|
|
195
|
+
if (typeof wNow !== 'number') throw new Error('api/v1/production mist wattsNow of production[0].wNow');
|
|
196
|
+
|
|
197
|
+
return Math.max(0, wNow);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async fetchJson(url) {
|
|
201
|
+
const headers = { Accept: 'application/json' };
|
|
202
|
+
const token = this.getToken();
|
|
203
|
+
if (token) headers.Authorization = `Bearer ${token}`;
|
|
204
|
+
|
|
205
|
+
const res = await fetch(url, { headers });
|
|
206
|
+
|
|
207
|
+
if (!res.ok) {
|
|
208
|
+
throw new Error(`HTTP ${res.status} op ${url}`);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
return await res.json();
|
|
212
|
+
}
|
|
213
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "homebridge-envoy-solar-sensor",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Expose Enphase Envoy PV production as a HomeKit sensor for automations",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"homebridge-plugin",
|
|
8
|
+
"homekit",
|
|
9
|
+
"enphase",
|
|
10
|
+
"envoy",
|
|
11
|
+
"solar"
|
|
12
|
+
],
|
|
13
|
+
"engines": {
|
|
14
|
+
"node": ">=18.0.0",
|
|
15
|
+
"homebridge": ">=1.6.0"
|
|
16
|
+
},
|
|
17
|
+
"license": "MIT",
|
|
18
|
+
"dependencies": {},
|
|
19
|
+
"repository": {
|
|
20
|
+
"type": "git",
|
|
21
|
+
"url": "https://github.com/yourname/homebridge-envoy-solar-sensor.git"
|
|
22
|
+
},
|
|
23
|
+
"bugs": {
|
|
24
|
+
"url": "https://github.com/yourname/homebridge-envoy-solar-sensor/issues"
|
|
25
|
+
},
|
|
26
|
+
"homepage": "https://github.com/yourname/homebridge-envoy-solar-sensor#readme"
|
|
27
|
+
}
|