ecoflow2mqtt 0.0.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +139 -7
- package/config.js +120 -0
- package/index.js +139 -3
- package/lib/app/login.js +161 -0
- package/lib/app/mqtt.js +267 -0
- package/lib/capture.js +85 -0
- package/lib/clientid.js +60 -0
- package/lib/hadiscovery.js +71 -0
- package/lib/install.js +21 -0
- package/lib/items.js +211 -0
- package/lib/mask.js +40 -0
- package/lib/proto/bk_series.proto +50 -0
- package/lib/proto/decode.js +121 -0
- package/lib/proto/encode.js +57 -0
- package/lib/proto/header.proto +43 -0
- package/package.json +35 -5
package/lib/app/mqtt.js
ADDED
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Step 3-6 of the app path (ROADMAP.md §2): the client on EcoFlow's own MQTT broker.
|
|
3
|
+
*
|
|
4
|
+
* It authenticates, connects with the client id the broker expects (`ANDROID_<UUID>_<userId>`,
|
|
5
|
+
* the UUID persisted so reconnects do not pile up sessions — E-6), subscribes to the device's
|
|
6
|
+
* property topic and emits decoded frames. A protobuf get every `--poll` seconds pulls a full
|
|
7
|
+
* frame; it is a refresh, not a keep-alive (E-9).
|
|
8
|
+
*
|
|
9
|
+
* An unreachable cloud is normal operation for a daemon: everything retries with backoff, and
|
|
10
|
+
* credentials are never discarded because of an error (R §4.1).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import {EventEmitter} from 'node:events';
|
|
14
|
+
import mqtt from 'mqtt';
|
|
15
|
+
import {decodeFrames} from '../proto/decode.js';
|
|
16
|
+
import {encodeGet, encodeEnergyStreamSwitch} from '../proto/encode.js';
|
|
17
|
+
import {authenticate as defaultAuthenticate} from './login.js';
|
|
18
|
+
import {maskSn} from '../mask.js';
|
|
19
|
+
|
|
20
|
+
const MIN_BACKOFF = 10_000;
|
|
21
|
+
const MAX_BACKOFF = 300_000;
|
|
22
|
+
/** connack return codes that mean "these credentials are not (or no longer) valid" */
|
|
23
|
+
const AUTH_ERRORS = new Set([4, 5]);
|
|
24
|
+
|
|
25
|
+
export class EcoflowClient extends EventEmitter {
|
|
26
|
+
/**
|
|
27
|
+
* @param {object} options
|
|
28
|
+
* @param {object} options.config parsed config (email, password, sn, region, poll, ...)
|
|
29
|
+
* @param {object} options.log core logger
|
|
30
|
+
* @param {string} options.uuid stable UUID for the client id (lib/clientid.js)
|
|
31
|
+
* @param {Function} [options.authenticate] injected for tests
|
|
32
|
+
* @param {Function} [options.connect] injected for tests (mqtt.connect)
|
|
33
|
+
*/
|
|
34
|
+
constructor({config, log, uuid, authenticate = defaultAuthenticate, connect = mqtt.connect}) {
|
|
35
|
+
super();
|
|
36
|
+
this.config = config;
|
|
37
|
+
this.log = log;
|
|
38
|
+
this.uuid = uuid;
|
|
39
|
+
this.authenticate = authenticate;
|
|
40
|
+
this.connectImpl = connect;
|
|
41
|
+
|
|
42
|
+
this.client = null;
|
|
43
|
+
this.userId = null;
|
|
44
|
+
this.broker = null;
|
|
45
|
+
this.stopped = false;
|
|
46
|
+
this.backoff = MIN_BACKOFF;
|
|
47
|
+
this.timers = [];
|
|
48
|
+
this.retryTimer = null;
|
|
49
|
+
this.lastAuthError = null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
get connected() {
|
|
53
|
+
return Boolean(this.client?.connected);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Topics of this instance's device; the SN is masked in everything we log. */
|
|
57
|
+
topics() {
|
|
58
|
+
const {sn} = this.config;
|
|
59
|
+
return {
|
|
60
|
+
property: `/app/device/property/${sn}`,
|
|
61
|
+
get: `/app/${this.userId}/${sn}/thing/property/get`,
|
|
62
|
+
getReply: `/app/${this.userId}/${sn}/thing/property/get_reply`,
|
|
63
|
+
set: `/app/${this.userId}/${sn}/thing/property/set`,
|
|
64
|
+
setReply: `/app/${this.userId}/${sn}/thing/property/set_reply`,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
start() {
|
|
69
|
+
this.stopped = false;
|
|
70
|
+
return this.#run();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async stop() {
|
|
74
|
+
this.stopped = true;
|
|
75
|
+
this.#clearTimers();
|
|
76
|
+
clearTimeout(this.retryTimer);
|
|
77
|
+
const {client} = this;
|
|
78
|
+
this.client = null;
|
|
79
|
+
if (client) {
|
|
80
|
+
await new Promise((resolve) => client.end(false, {}, resolve));
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Authenticate, then connect. Any failure schedules a retry — the daemon never gives up. */
|
|
85
|
+
async #run() {
|
|
86
|
+
if (this.stopped) {
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
try {
|
|
90
|
+
const {userId, broker, apiHost} = await this.authenticate(this.config);
|
|
91
|
+
this.userId = userId;
|
|
92
|
+
this.broker = broker;
|
|
93
|
+
this.lastAuthError = null;
|
|
94
|
+
this.log.info(`ecoflow api ${apiHost}: logged in, broker ${broker.host}:${broker.port}`);
|
|
95
|
+
this.#connect();
|
|
96
|
+
} catch (error) {
|
|
97
|
+
this.#authFailed(error);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Log an authentication failure once at error level, repeats at debug — a wrong password and
|
|
103
|
+
* an EcoFlow outage are indistinguishable here (R §4.1), so this never stops the adapter.
|
|
104
|
+
*/
|
|
105
|
+
#authFailed(error) {
|
|
106
|
+
const message = `ecoflow login failed: ${error.message} (${error.code ?? 'no code'})`;
|
|
107
|
+
if (this.lastAuthError === message) {
|
|
108
|
+
this.log.debug(message);
|
|
109
|
+
} else {
|
|
110
|
+
this.log.error(`${message} — check --email / --password / --region, or the cloud is down`);
|
|
111
|
+
this.lastAuthError = message;
|
|
112
|
+
}
|
|
113
|
+
this.#retry();
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
#retry() {
|
|
117
|
+
if (this.stopped) {
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
const delay = this.backoff;
|
|
121
|
+
this.backoff = Math.min(this.backoff * 2, MAX_BACKOFF);
|
|
122
|
+
this.log.debug(`ecoflow: retrying in ${Math.round(delay / 1000)} s`);
|
|
123
|
+
clearTimeout(this.retryTimer);
|
|
124
|
+
this.retryTimer = setTimeout(() => this.#run(), delay);
|
|
125
|
+
this.retryTimer.unref?.();
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
#connect() {
|
|
129
|
+
const {broker, config} = this;
|
|
130
|
+
const url = `${broker.protocol}://${broker.host}:${broker.port}`;
|
|
131
|
+
const clientId = `ANDROID_${this.uuid}_${this.userId}`;
|
|
132
|
+
this.log.debug(`ecoflow > connecting to ${url} as ${clientId}`);
|
|
133
|
+
|
|
134
|
+
const client = this.connectImpl(url, {
|
|
135
|
+
clientId,
|
|
136
|
+
username: broker.username,
|
|
137
|
+
password: broker.password,
|
|
138
|
+
protocolVersion: 4,
|
|
139
|
+
clean: true,
|
|
140
|
+
keepalive: 30,
|
|
141
|
+
reconnectPeriod: MIN_BACKOFF,
|
|
142
|
+
connectTimeout: 30_000,
|
|
143
|
+
});
|
|
144
|
+
this.client = client;
|
|
145
|
+
|
|
146
|
+
client.on('connect', () => {
|
|
147
|
+
this.backoff = MIN_BACKOFF;
|
|
148
|
+
this.log.info(`ecoflow broker ${broker.host} connected, device ${maskSn(config.sn)}`);
|
|
149
|
+
this.#subscribe();
|
|
150
|
+
this.#startTimers();
|
|
151
|
+
this.emit('connect');
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
client.on('message', (topic, payload) => this.#onMessage(topic, payload));
|
|
155
|
+
|
|
156
|
+
client.on('error', (error) => {
|
|
157
|
+
const code = error?.code;
|
|
158
|
+
this.log.warn(`ecoflow broker error: ${error.message}`);
|
|
159
|
+
if (AUTH_ERRORS.has(code)) {
|
|
160
|
+
// credentials rejected: certification again, then keep trying
|
|
161
|
+
this.log.warn('ecoflow broker rejected the credentials, re-authenticating');
|
|
162
|
+
this.#restart();
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
client.on('close', () => {
|
|
167
|
+
this.#clearTimers();
|
|
168
|
+
this.log.debug('ecoflow broker connection closed');
|
|
169
|
+
this.emit('close');
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
client.on('reconnect', () => this.log.debug('ecoflow broker reconnecting'));
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Tear the client down and start over at step 1 (login), after a backoff. */
|
|
176
|
+
#restart() {
|
|
177
|
+
const {client} = this;
|
|
178
|
+
this.client = null;
|
|
179
|
+
this.#clearTimers();
|
|
180
|
+
client?.end(true);
|
|
181
|
+
this.#retry();
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
#subscribe() {
|
|
185
|
+
const topics = this.topics();
|
|
186
|
+
for (const topic of [topics.property, topics.getReply]) {
|
|
187
|
+
this.client.subscribe(topic, {qos: 1}, (error) => {
|
|
188
|
+
if (error) {
|
|
189
|
+
this.log.warn(`ecoflow subscribe ${this.#safe(topic)} failed: ${error.message}`);
|
|
190
|
+
} else {
|
|
191
|
+
this.log.debug(`ecoflow < subscribed ${this.#safe(topic)}`);
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
#startTimers() {
|
|
198
|
+
this.#clearTimers();
|
|
199
|
+
const {poll, streamInterval} = this.config;
|
|
200
|
+
if (poll > 0) {
|
|
201
|
+
this.requestFullFrame();
|
|
202
|
+
this.timers.push(setInterval(() => this.requestFullFrame(), poll * 1000));
|
|
203
|
+
}
|
|
204
|
+
if (streamInterval > 0) {
|
|
205
|
+
this.activateStream();
|
|
206
|
+
this.timers.push(setInterval(() => this.activateStream(), streamInterval * 1000));
|
|
207
|
+
}
|
|
208
|
+
for (const timer of this.timers) {
|
|
209
|
+
timer.unref?.();
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
#clearTimers() {
|
|
214
|
+
for (const timer of this.timers) {
|
|
215
|
+
clearInterval(timer);
|
|
216
|
+
}
|
|
217
|
+
this.timers = [];
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** Ask for a full DisplayPropertyUpload (arrives on the get_reply topic). */
|
|
221
|
+
requestFullFrame() {
|
|
222
|
+
if (!this.connected) {
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
const topic = this.topics().get;
|
|
226
|
+
this.log.debug(`ecoflow > get ${this.#safe(topic)}`);
|
|
227
|
+
this.client.publish(topic, encodeGet(), {qos: 1}, (error) => {
|
|
228
|
+
if (error) {
|
|
229
|
+
this.log.warn(`ecoflow get failed: ${error.message}`);
|
|
230
|
+
}
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** EnergyStreamSwitch — only when `--stream-interval` is set (E-9). */
|
|
235
|
+
activateStream() {
|
|
236
|
+
if (!this.connected) {
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
const topic = this.topics().set;
|
|
240
|
+
this.log.debug(`ecoflow > EnergyStreamSwitch ${this.#safe(topic)}`);
|
|
241
|
+
this.client.publish(topic, encodeEnergyStreamSwitch({sn: this.config.sn}), {qos: 1}, (error) => {
|
|
242
|
+
if (error) {
|
|
243
|
+
this.log.warn(`ecoflow EnergyStreamSwitch failed: ${error.message}`);
|
|
244
|
+
}
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
#onMessage(topic, payload) {
|
|
249
|
+
this.emit('raw', topic, payload);
|
|
250
|
+
let frames;
|
|
251
|
+
try {
|
|
252
|
+
frames = decodeFrames(payload);
|
|
253
|
+
} catch (error) {
|
|
254
|
+
this.log.debug(`ecoflow < ${this.#safe(topic)}: not a protobuf frame (${error.message}), ignored`);
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
const mine = frames.filter((frame) => !frame.deviceSn || frame.deviceSn === this.config.sn);
|
|
258
|
+
if (mine.length > 0) {
|
|
259
|
+
this.emit('frames', mine, topic);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** A topic with the serial masked — safe for info/warn level. */
|
|
264
|
+
#safe(topic) {
|
|
265
|
+
return this.config.sn ? topic.replaceAll(this.config.sn, maskSn(this.config.sn)) : topic;
|
|
266
|
+
}
|
|
267
|
+
}
|
package/lib/capture.js
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `--capture <dir>`: append every frame the cloud sends as one base64 line, so new fields and new
|
|
3
|
+
* firmware can be turned into test fixtures without a device.
|
|
4
|
+
*
|
|
5
|
+
* Everything that identifies the device or the account is removed **here**, not later by hand
|
|
6
|
+
* (ROADMAP.md E-2): the header fields `device_sn` / `module_sn` are replaced before re-encoding,
|
|
7
|
+
* and the serial number and the account's user id are replaced in the topic and in any remaining
|
|
8
|
+
* literal occurrence in the payload. A capture file is therefore safe to attach to an issue.
|
|
9
|
+
*
|
|
10
|
+
* Line format (test/fixtures/README.md): `<epoch ms> <topic> <base64 HeaderMessage>`.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import fs from 'node:fs';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
import {HeaderMessage} from './proto/decode.js';
|
|
16
|
+
import {placeholderSn} from './mask.js';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @param {{dir: string, sn: string, userId?: string|(() => string), log: object,
|
|
20
|
+
* now?: () => number}} options `userId` may be a getter: it is only known after login.
|
|
21
|
+
* @returns {{write: (topic: string, payload: Buffer) => void, close: () => Promise<void>, file: string}}
|
|
22
|
+
*/
|
|
23
|
+
export function createCapture({dir, sn, userId, log, now = () => Date.now()}) {
|
|
24
|
+
const placeholder = placeholderSn(sn);
|
|
25
|
+
const stamp = new Date(now()).toISOString().slice(0, 19).replaceAll(':', '-');
|
|
26
|
+
const file = path.join(dir, `frames-${stamp}.b64`);
|
|
27
|
+
|
|
28
|
+
fs.mkdirSync(dir, {recursive: true});
|
|
29
|
+
const stream = fs.createWriteStream(file, {flags: 'a'});
|
|
30
|
+
log.info(`capturing frames to ${file} (serial replaced by ${placeholder})`);
|
|
31
|
+
|
|
32
|
+
/** Same-length replacement for the SN, so buffers stay valid wherever it is embedded; the
|
|
33
|
+
* account id only ever appears in topics, where length does not matter. */
|
|
34
|
+
function scrubText(text) {
|
|
35
|
+
let out = String(text);
|
|
36
|
+
if (sn) {
|
|
37
|
+
out = out.replaceAll(sn, placeholder);
|
|
38
|
+
}
|
|
39
|
+
const id = typeof userId === 'function' ? userId() : userId;
|
|
40
|
+
if (id) {
|
|
41
|
+
out = out.replaceAll(String(id), 'USERID');
|
|
42
|
+
}
|
|
43
|
+
return out;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function scrubPayload(payload) {
|
|
47
|
+
try {
|
|
48
|
+
const message = HeaderMessage.decode(payload);
|
|
49
|
+
let touched = false;
|
|
50
|
+
for (const header of message.header ?? []) {
|
|
51
|
+
if (header.deviceSn) {
|
|
52
|
+
header.deviceSn = placeholder;
|
|
53
|
+
touched = true;
|
|
54
|
+
}
|
|
55
|
+
if (header.moduleSn) {
|
|
56
|
+
header.moduleSn = placeholder;
|
|
57
|
+
touched = true;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
if (touched) {
|
|
61
|
+
return Buffer.from(HeaderMessage.encode(message).finish());
|
|
62
|
+
}
|
|
63
|
+
} catch {
|
|
64
|
+
// not a HeaderMessage (a JSON ping, a truncated frame): fall through to the text pass
|
|
65
|
+
}
|
|
66
|
+
const text = payload.toString('binary');
|
|
67
|
+
const scrubbed = scrubText(text);
|
|
68
|
+
return scrubbed === text ? payload : Buffer.from(scrubbed, 'binary');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
file,
|
|
73
|
+
write(topic, payload) {
|
|
74
|
+
try {
|
|
75
|
+
stream.write(`${now()} ${scrubText(topic)} ${scrubPayload(payload).toString('base64')}\n`);
|
|
76
|
+
} catch (error) {
|
|
77
|
+
log.warn(`capture write failed: ${error.message}`);
|
|
78
|
+
}
|
|
79
|
+
},
|
|
80
|
+
/** Resolves once everything written is on disk (shutdown waits for it). */
|
|
81
|
+
close() {
|
|
82
|
+
return new Promise((resolve) => stream.end(resolve));
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
}
|
package/lib/clientid.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The UUID inside the MQTT client id `ANDROID_<UUID>_<userId>`.
|
|
3
|
+
*
|
|
4
|
+
* EcoFlow's broker filters on that shape (R §4.2) and keeps a session per client id, so the UUID
|
|
5
|
+
* is generated once and then reused for the life of the instance (ROADMAP.md E-6). It lives in
|
|
6
|
+
* the state directory (`$STATE_DIRECTORY` under systemd, `~/.ecoflow2mqtt` otherwise) — it is
|
|
7
|
+
* state, not configuration, and the service user must be able to write it.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import fs from 'node:fs';
|
|
11
|
+
import os from 'node:os';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
import crypto from 'node:crypto';
|
|
14
|
+
|
|
15
|
+
/** Where the client id of an instance is kept when `--state-dir` is not given. */
|
|
16
|
+
export function defaultStateDir() {
|
|
17
|
+
return process.env.STATE_DIRECTORY || path.join(os.homedir(), '.ecoflow2mqtt');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function clientIdFile({stateDir, name}) {
|
|
21
|
+
return path.join(stateDir || defaultStateDir(), `${name}.client-id`);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** EcoFlow's client ids use the hex uppercase form without dashes. */
|
|
25
|
+
export function newUuid() {
|
|
26
|
+
return crypto.randomUUID().replaceAll('-', '').toUpperCase();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Read the instance's UUID, creating (and persisting) one on first start. A state directory that
|
|
31
|
+
* cannot be written is a warning, not a failure: the adapter then runs with a fresh UUID per
|
|
32
|
+
* start, which works but leaves stale sessions behind.
|
|
33
|
+
*
|
|
34
|
+
* @returns {string}
|
|
35
|
+
*/
|
|
36
|
+
export function loadClientUuid({stateDir, name, log = {debug() {}, warn() {}}}) {
|
|
37
|
+
const file = clientIdFile({stateDir, name});
|
|
38
|
+
try {
|
|
39
|
+
const stored = fs.readFileSync(file, 'utf8').trim();
|
|
40
|
+
if (/^[0-9A-F]{32}$/.test(stored)) {
|
|
41
|
+
log.debug(`client id uuid read from ${file}`);
|
|
42
|
+
return stored;
|
|
43
|
+
}
|
|
44
|
+
log.warn(`ignoring malformed client id in ${file}`);
|
|
45
|
+
} catch (error) {
|
|
46
|
+
if (error.code !== 'ENOENT') {
|
|
47
|
+
log.warn(`cannot read ${file}: ${error.message}`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const uuid = newUuid();
|
|
52
|
+
try {
|
|
53
|
+
fs.mkdirSync(path.dirname(file), {recursive: true, mode: 0o750});
|
|
54
|
+
fs.writeFileSync(file, `${uuid}\n`, {mode: 0o640});
|
|
55
|
+
log.debug(`client id uuid created in ${file}`);
|
|
56
|
+
} catch (error) {
|
|
57
|
+
log.warn(`cannot persist the client id in ${file}: ${error.message} — using a temporary one`);
|
|
58
|
+
}
|
|
59
|
+
return uuid;
|
|
60
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Home Assistant MQTT discovery (device-based, HA >= 2024.4) — the device block for the core's
|
|
3
|
+
* `discovery()` hook. Pure: config in, {device, components} out.
|
|
4
|
+
*
|
|
5
|
+
* Every row of the item table becomes a sensor: readings carry `state_class: measurement` so HA
|
|
6
|
+
* keeps statistics for them, settings (the feed-in limits) and the grid status do not. Volts,
|
|
7
|
+
* amps, hertz, the limits and the Wi-Fi signal are marked as diagnostics so the device page keeps
|
|
8
|
+
* the four power values in front.
|
|
9
|
+
*
|
|
10
|
+
* An energy (kWh) sensor for the energy dashboard would need the device's energy counters, which
|
|
11
|
+
* this firmware does not send (ROADMAP.md OQ-E3); a Riemann sum helper on `pv_watts` is the way
|
|
12
|
+
* to get one today.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import {entity, discoveryId} from 'mqtt-interfaces-core';
|
|
16
|
+
import {ITEMS, GRID_STATUS} from './items.js';
|
|
17
|
+
import {modelOf} from './mask.js';
|
|
18
|
+
|
|
19
|
+
export const ADAPTER = 'ecoflow2mqtt';
|
|
20
|
+
|
|
21
|
+
/** The HA attributes a row implies: unit, device class, state class, enum options. */
|
|
22
|
+
export function extraFor(row) {
|
|
23
|
+
const extra = {};
|
|
24
|
+
if (row.unit) {
|
|
25
|
+
extra.unit_of_meas = row.unit;
|
|
26
|
+
}
|
|
27
|
+
if (row.deviceClass) {
|
|
28
|
+
extra.dev_cla = row.deviceClass;
|
|
29
|
+
}
|
|
30
|
+
if (row.map) {
|
|
31
|
+
// an enum sensor: HA validates the state against the option list
|
|
32
|
+
extra.dev_cla = 'enum';
|
|
33
|
+
extra.options = Object.values(row.map);
|
|
34
|
+
} else if (row.measurement !== false) {
|
|
35
|
+
extra.stat_cla = 'measurement';
|
|
36
|
+
extra.sug_dsp_prc = row.precision ?? 1;
|
|
37
|
+
}
|
|
38
|
+
return extra;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* @param {{name: string, sn?: string, jsonPayloads?: boolean}} options
|
|
43
|
+
* @returns {{id: string, device: object, components: object}}
|
|
44
|
+
*/
|
|
45
|
+
export function discoveryModel({name, sn, jsonPayloads = true}) {
|
|
46
|
+
const id = discoveryId(ADAPTER, name);
|
|
47
|
+
const model = modelOf(sn);
|
|
48
|
+
const components = {};
|
|
49
|
+
|
|
50
|
+
for (const row of ITEMS) {
|
|
51
|
+
components[row.item] = entity({
|
|
52
|
+
id,
|
|
53
|
+
name,
|
|
54
|
+
item: row.item,
|
|
55
|
+
platform: 'sensor',
|
|
56
|
+
label: row.label,
|
|
57
|
+
icon: row.icon, // no fallback: without one HA picks the icon of the device class
|
|
58
|
+
category: row.category,
|
|
59
|
+
jsonPayloads,
|
|
60
|
+
extra: extraFor(row),
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
id,
|
|
66
|
+
device: {mf: 'EcoFlow', ...(model && {mdl: model})},
|
|
67
|
+
components,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export {GRID_STATUS};
|
package/lib/install.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* --install / --uninstall: systemd template service ecoflow2mqtt@<name>, one instance per
|
|
3
|
+
* inverter (mqtt-interfaces-core installer): /etc/ecoflow2mqtt/<name>.env (mode 600 — it holds
|
|
4
|
+
* the account credentials and the serial), /var/lib/ecoflow2mqtt/<name>/ for the persisted mqtt
|
|
5
|
+
* client id, system user ecoflow2mqtt, optional shared /etc/mqtt-interfaces/broker.env.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import {createInstaller} from 'mqtt-interfaces-core';
|
|
9
|
+
|
|
10
|
+
export const SERVICE = 'ecoflow2mqtt';
|
|
11
|
+
export const ENV_PREFIX = 'ECOFLOW2MQTT';
|
|
12
|
+
|
|
13
|
+
const installer = createInstaller({
|
|
14
|
+
service: SERVICE,
|
|
15
|
+
envPrefix: ENV_PREFIX,
|
|
16
|
+
description: `${SERVICE} %i - EcoFlow micro-inverter to MQTT bridge`,
|
|
17
|
+
documentation: 'https://github.com/hobbyquaker/ecoflow2mqtt',
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
export const {unitFile, envFile, installService, uninstallService, handle, CONF_DIR, STATE_DIR} = installer;
|
|
21
|
+
export {envVarName, instanceName} from 'mqtt-interfaces-core';
|