ecoflow2mqtt 0.2.0 → 0.3.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 +31 -2
- package/config.js +6 -1
- package/index.js +25 -1
- package/lib/app/login.js +38 -0
- package/lib/discovery.js +72 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -83,20 +83,49 @@ sees a new client on every start.
|
|
|
83
83
|
|
|
84
84
|
- The **EcoFlow app account** the inverter is bound to (a shared device does not work — bind it to
|
|
85
85
|
this account in the app).
|
|
86
|
-
- The **serial number**, as shown in the app or on the device.
|
|
86
|
+
- The **serial number**, as shown in the app or on the device — or let `--discover` find it, below.
|
|
87
87
|
- The **region** the account was created in (`--region`, default `eu`). Accounts are region bound;
|
|
88
88
|
a wrong region answers "account doesn't exist or incorrect password", which is also what an
|
|
89
89
|
EcoFlow outage answers, so the adapter keeps retrying instead of giving up.
|
|
90
90
|
|
|
91
91
|
No developer account, no API keys: the adapter logs in the way the app does.
|
|
92
92
|
|
|
93
|
+
### Finding the serial number
|
|
94
|
+
|
|
95
|
+
```
|
|
96
|
+
ecoflow2mqtt --email you@example.com --password 'your-app-password' --discover
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
prints every device the account owns, with its serial, name and whether EcoFlow currently
|
|
100
|
+
considers it online:
|
|
101
|
+
|
|
102
|
+
```
|
|
103
|
+
BK01Z… Balcony (cloud)
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
`--discover-json` gives the same as JSON. `--sn auto` takes the serial when the account owns
|
|
107
|
+
exactly one device and refuses when it owns several, so nothing is guessed:
|
|
108
|
+
|
|
109
|
+
```
|
|
110
|
+
sudo ecoflow2mqtt --install -n balcony --email … --password … --sn auto
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
resolves it **once** and writes it into the instance's env file, rather than making every service
|
|
114
|
+
start depend on EcoFlow's API being reachable.
|
|
115
|
+
|
|
116
|
+
Note this is not a network scan, and there is nothing on your LAN to scan for: the inverter only
|
|
117
|
+
ever talks to EcoFlow (see [How it works](#how-it-works-and-what-that-means-for-you)). `--discover`
|
|
118
|
+
is a login, so unlike other adapters in the fleet it needs `--email` and `--password` — the two
|
|
119
|
+
options it cannot do without. An `online: false` device is still listed: EcoFlow's flag lags by up
|
|
120
|
+
to ~15 minutes, and an inverter that is dark at night is still the one you want to configure.
|
|
121
|
+
|
|
93
122
|
## Options
|
|
94
123
|
|
|
95
124
|
| Option | Default | Meaning |
|
|
96
125
|
| ------------------- | ------------------ | --------------------------------------------------------------- |
|
|
97
126
|
| `--email` | required | EcoFlow app account |
|
|
98
127
|
| `--password` | required | its password |
|
|
99
|
-
| `--sn` | required | serial number of the inverter
|
|
128
|
+
| `--sn` | required | serial number of the inverter, or `auto` (see `--discover`) |
|
|
100
129
|
| `--region` | `eu` | `eu`, `us`, `global`, `americas`, `cn` |
|
|
101
130
|
| `--api-host` | from region | override the API host |
|
|
102
131
|
| `--mqtt-host` | from login | override the EcoFlow broker (it occasionally names a wrong one) |
|
package/config.js
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
import {parseConfig} from 'mqtt-interfaces-core';
|
|
12
12
|
import pkg from './package.json' with {type: 'json'};
|
|
13
13
|
import {REGIONS} from './lib/app/login.js';
|
|
14
|
+
import {DISCOVERY_SHAPE} from './lib/discovery.js';
|
|
14
15
|
import {defaultStateDir} from './lib/clientid.js';
|
|
15
16
|
|
|
16
17
|
export const OPTIONS = {
|
|
@@ -28,9 +29,11 @@ export const OPTIONS = {
|
|
|
28
29
|
},
|
|
29
30
|
sn: {
|
|
30
31
|
type: 'string',
|
|
31
|
-
describe: 'serial number of the inverter (as shown in the app)',
|
|
32
|
+
describe: 'serial number of the inverter (as shown in the app), or "auto" (see --discover)',
|
|
32
33
|
demandOption: true,
|
|
33
34
|
secret: true,
|
|
35
|
+
// the option --discover fills; one inverter per instance, so it stays a single value
|
|
36
|
+
discover: true,
|
|
34
37
|
},
|
|
35
38
|
region: {
|
|
36
39
|
type: 'string',
|
|
@@ -100,9 +103,11 @@ export function parse(overrides = {}) {
|
|
|
100
103
|
pkg,
|
|
101
104
|
defaults: {name: 'ecoflow'},
|
|
102
105
|
options: OPTIONS,
|
|
106
|
+
discovery: DISCOVERY_SHAPE,
|
|
103
107
|
check,
|
|
104
108
|
examples: [
|
|
105
109
|
['$0 --email me@example.com --password secret --sn BK01Z... -u mqtt://broker', 'run in the foreground'],
|
|
110
|
+
['$0 --email me@example.com --password secret --discover', 'list the account’s devices and exit'],
|
|
106
111
|
['sudo $0 --install -n balcony', 'install as service ecoflow2mqtt@balcony'],
|
|
107
112
|
],
|
|
108
113
|
// the core's own epilog (environment variables) plus what is specific to this adapter
|
package/index.js
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* (ROADMAP.md §1); there are no settable items yet.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import {createAdapter} from 'mqtt-interfaces-core';
|
|
11
|
+
import {createAdapter, createLogger, runDiscovery, autoAddress} from 'mqtt-interfaces-core';
|
|
12
12
|
import config, {stateDirOf} from './config.js';
|
|
13
13
|
import pkg from './package.json' with {type: 'json'};
|
|
14
14
|
import {handle as handleInstall} from './lib/install.js';
|
|
@@ -19,6 +19,30 @@ import {createCapture} from './lib/capture.js';
|
|
|
19
19
|
import {discoveryModel} from './lib/hadiscovery.js';
|
|
20
20
|
import {maskSn, modelOf} from './lib/mask.js';
|
|
21
21
|
import {apiHostOf} from './lib/app/login.js';
|
|
22
|
+
import {discoveryHint} from './lib/discovery.js';
|
|
23
|
+
|
|
24
|
+
/*
|
|
25
|
+
* Finding the inverter (core B-2): there is nothing on the LAN to scan — the device only ever
|
|
26
|
+
* talks to EcoFlow's broker — so --discover logs into the account and lists what it owns, and
|
|
27
|
+
* `--sn auto` takes the serial when the account owns exactly one. Before handleInstall() so
|
|
28
|
+
* `--install --sn auto` persists the serial instead of resolving it on every service start,
|
|
29
|
+
* which would make each start depend on EcoFlow's api being up. The adapter does not exist yet,
|
|
30
|
+
* so discovery gets its own logger.
|
|
31
|
+
*/
|
|
32
|
+
if (config.discover || config.sn === 'auto') {
|
|
33
|
+
const discoveryLog = createLogger({envPrefix: config.$envPrefix || 'ECOFLOW2MQTT', level: config.verbosity});
|
|
34
|
+
const hint = discoveryHint(config);
|
|
35
|
+
if (config.discover) {
|
|
36
|
+
await runDiscovery({hint, config, log: discoveryLog}); // prints and exits
|
|
37
|
+
}
|
|
38
|
+
try {
|
|
39
|
+
config.sn = await autoAddress(hint, {config, log: discoveryLog});
|
|
40
|
+
} catch (err) {
|
|
41
|
+
// none, or several inverters on the account: bridging the wrong one is worse than stopping
|
|
42
|
+
discoveryLog.error('--sn auto:', err.message);
|
|
43
|
+
process.exit(1);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
22
46
|
|
|
23
47
|
handleInstall(config); // --install / --uninstall never reach the rest
|
|
24
48
|
|
package/lib/app/login.js
CHANGED
|
@@ -139,6 +139,44 @@ export async function certification({host, token, userId, fetchImpl = globalThis
|
|
|
139
139
|
};
|
|
140
140
|
}
|
|
141
141
|
|
|
142
|
+
/**
|
|
143
|
+
* GET /iot-service/user/device — the devices bound to the account, for `--discover` / `--sn auto`.
|
|
144
|
+
*
|
|
145
|
+
* Verified on the real account (ROADMAP §6.1): `data.bound` is an **object keyed by SN**, not an
|
|
146
|
+
* array, each value `{deviceName, model, productType, online, productSkuId, createTime}`.
|
|
147
|
+
* RESEARCH §4.1 records the shape as a list instead, so both are accepted — the endpoint is
|
|
148
|
+
* unofficial and has changed before, and the cost of tolerating the other shape is four lines.
|
|
149
|
+
*
|
|
150
|
+
* `model` is a number (1 on the STREAM Micro), not a model name, and nothing here knows what it
|
|
151
|
+
* enumerates; it is passed through as EcoFlow sends it. The readable model comes from the serial
|
|
152
|
+
* prefix instead (`modelOf()` in lib/mask.js). `productType` 55 is the STREAM Micro.
|
|
153
|
+
*
|
|
154
|
+
* `online` lags by up to ~15 minutes on EcoFlow's side, so it is reported and never used to drop
|
|
155
|
+
* a device: an inverter that is dark at night is still the one to configure.
|
|
156
|
+
*
|
|
157
|
+
* @returns {Promise<Array<{sn: string, name?: string, model?: string, productType?: number, online: boolean}>>}
|
|
158
|
+
*/
|
|
159
|
+
export async function deviceList({host, token, userId, fetchImpl = globalThis.fetch}) {
|
|
160
|
+
const data = await getJson({
|
|
161
|
+
url: `https://${host}/iot-service/user/device?userId=${encodeURIComponent(userId)}`,
|
|
162
|
+
headers: {authorization: `Bearer ${token}`},
|
|
163
|
+
fetchImpl,
|
|
164
|
+
});
|
|
165
|
+
const bound = data?.bound ?? data;
|
|
166
|
+
const entries = Array.isArray(bound)
|
|
167
|
+
? bound.map((entry) => [entry?.sn ?? entry?.deviceSn, entry])
|
|
168
|
+
: Object.entries(bound && typeof bound === 'object' ? bound : {});
|
|
169
|
+
return entries
|
|
170
|
+
.filter(([sn]) => typeof sn === 'string' && sn)
|
|
171
|
+
.map(([sn, entry = {}]) => ({
|
|
172
|
+
sn,
|
|
173
|
+
name: entry.deviceName || undefined,
|
|
174
|
+
model: entry.model || undefined,
|
|
175
|
+
productType: entry.productType,
|
|
176
|
+
online: entry.online === 1 || entry.online === true,
|
|
177
|
+
}));
|
|
178
|
+
}
|
|
179
|
+
|
|
142
180
|
/**
|
|
143
181
|
* Login + certification in one step.
|
|
144
182
|
*
|
package/lib/discovery.js
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Finding the inverter (core B-2) — by asking EcoFlow, because there is nothing to scan.
|
|
3
|
+
*
|
|
4
|
+
* A STREAM Micro never speaks to anything on the LAN: it opens an outbound TLS connection to
|
|
5
|
+
* EcoFlow's broker and talks only to that (R §4.2). No SSDP, no mDNS, no open port, no MAC of its
|
|
6
|
+
* own on the wire once it is behind the router — every network method the core offers would find
|
|
7
|
+
* exactly nothing. What the user has to configure is the serial number printed in the app, so
|
|
8
|
+
* "discovery" here means listing the devices the account owns, which is the core's `cloud` hint.
|
|
9
|
+
*
|
|
10
|
+
* That inverts the usual rule about mandatory options: `--discover` normally drops them, but this
|
|
11
|
+
* scan *is* an account login, so `needs` keeps `--email` and `--password` demanded. `--sn` is the
|
|
12
|
+
* one it fills and stays exempt.
|
|
13
|
+
*
|
|
14
|
+
* The SN is printed in full by `--discover` on purpose. E-2 masks it in logs, `<name>/info` and
|
|
15
|
+
* captures because those get shared; this output exists so the user can copy it into a config.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import {apiHostOf, login, deviceList} from './app/login.js';
|
|
19
|
+
import {modelOf} from './mask.js';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Log in and list what the account owns, in the shape the core's `cloud` hint expects: `id` is
|
|
23
|
+
* the identity of a candidate, the rest are fields `describe()` prints.
|
|
24
|
+
*
|
|
25
|
+
* Errors are not caught. The core lets a cloud failure propagate precisely so that a wrong
|
|
26
|
+
* password says so, instead of being reported as an empty network the way a silent ssdp search
|
|
27
|
+
* would be.
|
|
28
|
+
*
|
|
29
|
+
* @param {{email: string, password: string, region?: string, apiHost?: string}} config
|
|
30
|
+
* @returns {Promise<Array<{id: string, name?: string, model?: string, online: boolean}>>}
|
|
31
|
+
*/
|
|
32
|
+
export async function listAccountDevices(config, {fetchImpl = globalThis.fetch} = {}) {
|
|
33
|
+
const host = apiHostOf(config);
|
|
34
|
+
const {token, userId} = await login({
|
|
35
|
+
host,
|
|
36
|
+
email: config.email,
|
|
37
|
+
password: config.password,
|
|
38
|
+
fetchImpl,
|
|
39
|
+
});
|
|
40
|
+
const devices = await deviceList({host, token, userId, fetchImpl});
|
|
41
|
+
return devices.map(({sn, name, productType, online}) => ({
|
|
42
|
+
id: sn,
|
|
43
|
+
...(name && {name}),
|
|
44
|
+
// not EcoFlow's `model`, which is a bare number we cannot interpret — the serial prefix
|
|
45
|
+
// is what actually names the hardware (`BK01…` → STREAM Microinverter)
|
|
46
|
+
...(modelOf(sn) && {model: modelOf(sn)}),
|
|
47
|
+
...(productType !== undefined && {productType}),
|
|
48
|
+
online,
|
|
49
|
+
}));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Options the scan itself consumes, so `--discover` keeps demanding them. */
|
|
53
|
+
export const NEEDS = ['email', 'password'];
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* What `config.js` hands `parseConfig()`: the *kind* of discovery and what it needs, with no
|
|
57
|
+
* callable — the credentials it would run on are the very thing being parsed. That is enough for
|
|
58
|
+
* `--config-schema` (`x-discover: "cloud"`, which is what she reads) and for the `--discover*`
|
|
59
|
+
* options; the core skips a cloud spec without a `list` rather than calling it.
|
|
60
|
+
*/
|
|
61
|
+
export const DISCOVERY_SHAPE = {cloud: true, needs: NEEDS};
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The hint that actually scans, built in `index.js` once the config exists. Unlike the network
|
|
65
|
+
* adapters' hints this one closes over the config: the credentials are what the scan runs on.
|
|
66
|
+
*/
|
|
67
|
+
export function discoveryHint(config, deps = {}) {
|
|
68
|
+
return {
|
|
69
|
+
cloud: {list: () => listAccountDevices(config, deps)},
|
|
70
|
+
needs: NEEDS,
|
|
71
|
+
};
|
|
72
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ecoflow2mqtt",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Interface between EcoFlow STREAM / PowerStream micro-inverters and MQTT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
|
@@ -59,7 +59,7 @@
|
|
|
59
59
|
},
|
|
60
60
|
"dependencies": {
|
|
61
61
|
"mqtt": "^5.10.0",
|
|
62
|
-
"mqtt-interfaces-core": "^0.
|
|
62
|
+
"mqtt-interfaces-core": "^0.11.3",
|
|
63
63
|
"protobufjs": "^7.4.0"
|
|
64
64
|
},
|
|
65
65
|
"devDependencies": {
|