ecoflow2mqtt 0.2.0 → 0.3.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/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
  *
@@ -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.2.0",
3
+ "version": "0.3.1",
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.8.0",
62
+ "mqtt-interfaces-core": "^0.12.0",
63
63
  "protobufjs": "^7.4.0"
64
64
  },
65
65
  "devDependencies": {