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 CHANGED
@@ -1,14 +1,146 @@
1
1
  # ecoflow2mqtt
2
2
 
3
- Interface between EcoFlow PowerStream / STREAM micro-inverters and MQTT, following the
4
- [mqtt-smarthome](https://github.com/mqtt-smarthome/mqtt-smarthome) convention, with Home Assistant
5
- discovery. Built on [mqtt-interfaces-core](https://github.com/hobbyquaker/mqtt-interfaces-core).
3
+ Interface between EcoFlow **STREAM Microinverter** / **PowerStream** micro-inverters and MQTT,
4
+ following the [mqtt-smarthome](https://github.com/mqtt-smarthome/mqtt-smarthome) convention, with
5
+ Home Assistant discovery. Built on
6
+ [mqtt-interfaces-core](https://github.com/hobbyquaker/mqtt-interfaces-core).
6
7
 
7
- **Status: placeholder release to reserve the package name. Work in progress nothing usable yet.**
8
+ **0.2.0 publishes everything the inverter reports: PV power per input and in total, what goes into
9
+ the grid, the voltages, currents, frequency, feed-in limit and Wi-Fi signal.** It is still read
10
+ only — writing the feed-in limit is the next milestone ([ROADMAP.md](ROADMAP.md)); the protocol
11
+ research is in [RESEARCH.md](RESEARCH.md). Values arrive every few seconds, the way the EcoFlow app
12
+ shows them, without the app having to be open.
8
13
 
9
- Planned: continuous PV / inverter / battery readings via the official EcoFlow Developer API and the
10
- EcoFlow app MQTT protocol (protobuf), with keep-alive polling so values update every few seconds
11
- like in the app. See [RESEARCH.md](RESEARCH.md) in the repository for the protocol research.
14
+ ## Topics
15
+
16
+ All under `<name>/status/`, retained, payload `{"val": …, "ts": …, "lc": …}`:
17
+
18
+ | Item | Example | Meaning |
19
+ | ------------------------------------ | ------------------- | ---------------------------------------------- |
20
+ | `pv1_watts` | `114.7` | PV input 1, W |
21
+ | `pv2_watts` | `112` | PV input 2, W |
22
+ | `pv_watts` | `226.7` | both inputs together, W |
23
+ | `grid_watts` | `226` | what actually goes into the grid, W |
24
+ | `grid_status` | `feed_grid` | `feed_grid`, `grid_in`, `offline` or `invalid` |
25
+ | `pv1_volts`, `pv1_amps` | `33.71`, `3.64` | PV input 1, V and A |
26
+ | `pv2_volts`, `pv2_amps` | `33.38`, `3.58` | PV input 2, V and A |
27
+ | `grid_volts`, `grid_amps`, `grid_hz` | `238`, `1.01`, `50` | the mains side |
28
+ | `feed_limit_watts` | `600` | feed-in limit the inverter runs with, W |
29
+ | `feed_limit_max_watts` | `600` | highest limit it accepts, W |
30
+ | `wifi_rssi` | `-59` | Wi-Fi signal of the inverter, dBm |
31
+
32
+ Plus, next to `status/`:
33
+
34
+ | Topic | Payload | Meaning |
35
+ | ---------------------------------------------- | ----------------- | ------------------------------ |
36
+ | `<name>/connected` | `0` \| `1` \| `2` | 2 = frames are arriving |
37
+ | `<name>/info` | JSON | version, region, masked serial |
38
+ | `<name>/maintenance/set/loglevel`, `…/restart` | `debug` … | provided by the core |
39
+
40
+ `<name>` is the instance name, `ecoflow` by default (`--name`). Payloads are `{val, ts, lc}` JSON
41
+ unless the instance runs with `--no-json-payloads`. There are no `set/` topics yet — 0.2.0 is read
42
+ only.
43
+
44
+ ## Install
45
+
46
+ Needs Node.js >= 20.19.
47
+
48
+ ```bash
49
+ npm install -g ecoflow2mqtt
50
+ ecoflow2mqtt --email you@example.com --password 'your-app-password' --sn BK01Z... -u mqtt://broker
51
+ ```
52
+
53
+ As a systemd service (one instance per inverter, credentials in `/etc/ecoflow2mqtt/<name>.env`
54
+ with mode 600, state in `/var/lib/ecoflow2mqtt/<name>/`):
55
+
56
+ ```bash
57
+ sudo ecoflow2mqtt --install --name balcony \
58
+ --email you@example.com --password 'your-app-password' --sn BK01Z... -u mqtt://broker
59
+ sudo systemctl status ecoflow2mqtt@balcony
60
+ journalctl -fu ecoflow2mqtt@balcony
61
+ ```
62
+
63
+ Every option can also be set as an environment variable (`ECOFLOW2MQTT_SN`, `ECOFLOW2MQTT_POLL`,
64
+ …); `MQTT_URL`, `MQTT_USERNAME` and `MQTT_PASSWORD` are used as a fallback, so several adapters can
65
+ share `/etc/mqtt-interfaces/broker.env`. Prefer the env file over the command line — a process list
66
+ is world readable.
67
+
68
+ ### Docker
69
+
70
+ Multi-arch image (amd64, arm64, armv7):
71
+
72
+ ```
73
+ docker run -d --name ecoflow2mqtt --restart unless-stopped -v ecoflow2mqtt:/data \
74
+ -e ECOFLOW2MQTT_EMAIL=you@example.com -e ECOFLOW2MQTT_PASSWORD='your-app-password' \
75
+ -e ECOFLOW2MQTT_SN=BK01Z... -e ECOFLOW2MQTT_MQTT_URL=mqtt://broker \
76
+ ghcr.io/hobbyquaker/ecoflow2mqtt
77
+ ```
78
+
79
+ The `/data` volume keeps the mqtt client id stable across restarts — without it EcoFlow's broker
80
+ sees a new client on every start.
81
+
82
+ ## What you need
83
+
84
+ - The **EcoFlow app account** the inverter is bound to (a shared device does not work — bind it to
85
+ this account in the app).
86
+ - The **serial number**, as shown in the app or on the device.
87
+ - The **region** the account was created in (`--region`, default `eu`). Accounts are region bound;
88
+ a wrong region answers "account doesn't exist or incorrect password", which is also what an
89
+ EcoFlow outage answers, so the adapter keeps retrying instead of giving up.
90
+
91
+ No developer account, no API keys: the adapter logs in the way the app does.
92
+
93
+ ## Options
94
+
95
+ | Option | Default | Meaning |
96
+ | ------------------- | ------------------ | --------------------------------------------------------------- |
97
+ | `--email` | required | EcoFlow app account |
98
+ | `--password` | required | its password |
99
+ | `--sn` | required | serial number of the inverter |
100
+ | `--region` | `eu` | `eu`, `us`, `global`, `americas`, `cn` |
101
+ | `--api-host` | from region | override the API host |
102
+ | `--mqtt-host` | from login | override the EcoFlow broker (it occasionally names a wrong one) |
103
+ | `--poll` | `60` | seconds between full-frame refreshes; `0` = passive |
104
+ | `--stream-interval` | `0` | seconds between "activate stream" frames; try `20` if it stalls |
105
+ | `--timeout` | `300` | seconds without a frame before `connected` drops to `1` |
106
+ | `--capture` | off | directory for raw frames (serial and account id removed) |
107
+ | `--state-dir` | `$STATE_DIRECTORY` | where the MQTT client id is kept |
108
+
109
+ Plus the shared options of every adapter (`--name`, `-u/--mqtt-url`, `--json-payloads`,
110
+ `--ha-discovery`, `--verbosity`, `--install`, `--config-schema`, …): `ecoflow2mqtt --help`.
111
+
112
+ ## Home Assistant
113
+
114
+ Discovery is on by default: the inverter shows up as one device with fifteen sensors, available
115
+ while `<name>/connected` is `2`. The four power values and the grid status are the primary
116
+ entities; voltages, currents, frequency, the limits and the Wi-Fi signal are filed under
117
+ diagnostics. Readings carry `state_class: measurement`, so HA keeps long-term statistics for them.
118
+ Turn discovery off with `--no-ha-discovery`.
119
+
120
+ For the energy dashboard you need kWh, and this firmware sends no energy counters — add a
121
+ [Riemann sum helper](https://www.home-assistant.io/integrations/integration/) on `pv_watts` (or on
122
+ `grid_watts` for what you actually feed in).
123
+
124
+ ## How it works, and what that means for you
125
+
126
+ The adapter logs into EcoFlow's cloud with your app account, asks for the MQTT credentials the app
127
+ uses, and subscribes to the inverter's telemetry topic. Frames are protobuf, obfuscated with a
128
+ one-byte XOR; the decoder and the field numbers are in `lib/proto/`.
129
+
130
+ - **This is the unofficial API the app uses**, not EcoFlow's developer platform. It has been stable
131
+ since 2023 and is what most EcoFlow integrations use, but EcoFlow can change it at any time.
132
+ ecoflow2mqtt keeps the protocol in data files so a change is a small fix, not a rewrite.
133
+ - **Values update every 2–6 seconds** as long as the adapter is subscribed — measured over 20
134
+ minutes with the app closed. Reports of the stream throttling when nobody is watching did not
135
+ reproduce on this path; if yours does stall, set `--stream-interval 20` and please open an issue.
136
+ - **Your credentials stay on your machine**; they go to EcoFlow's login endpoint only.
137
+ - **Nothing is written to the inverter.** 0.2.0 only reads.
138
+
139
+ ## Contributing a capture
140
+
141
+ If your firmware or your model sends different fields, run with `--capture <dir>` for a minute and
142
+ attach the file to an issue. The capture module removes the serial number and the account id
143
+ before writing, so it does not identify your device — the test suite checks that.
12
144
 
13
145
  ## License
14
146
 
package/config.js ADDED
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Adapter options on top of the core's parseConfig(): the shared MQTT / name / discovery /
3
+ * maintenance options, ECOFLOW2MQTT_* environment variables and --config-schema come from
4
+ * mqtt-interfaces-core; only the EcoFlow specific options are defined here.
5
+ *
6
+ * Credentials and the serial number are marked `secret: true`: they identify the account and the
7
+ * device, and management UIs mask them (ROADMAP.md E-2). Put them in the instance's env file
8
+ * rather than on the command line, where every process list would show them.
9
+ */
10
+
11
+ import {parseConfig} from 'mqtt-interfaces-core';
12
+ import pkg from './package.json' with {type: 'json'};
13
+ import {REGIONS} from './lib/app/login.js';
14
+ import {defaultStateDir} from './lib/clientid.js';
15
+
16
+ export const OPTIONS = {
17
+ email: {
18
+ type: 'string',
19
+ describe: 'ecoflow app account the inverter is bound to',
20
+ demandOption: true,
21
+ secret: true,
22
+ },
23
+ password: {
24
+ type: 'string',
25
+ describe: 'password of the ecoflow app account',
26
+ demandOption: true,
27
+ secret: true,
28
+ },
29
+ sn: {
30
+ type: 'string',
31
+ describe: 'serial number of the inverter (as shown in the app)',
32
+ demandOption: true,
33
+ secret: true,
34
+ },
35
+ region: {
36
+ type: 'string',
37
+ describe: 'region the account was created in (accounts are region bound)',
38
+ choices: REGIONS,
39
+ default: 'eu',
40
+ },
41
+ 'api-host': {
42
+ type: 'string',
43
+ describe: 'override the api host derived from --region',
44
+ },
45
+ 'mqtt-host': {
46
+ type: 'string',
47
+ describe: 'override the ecoflow broker host returned at login',
48
+ },
49
+ poll: {
50
+ type: 'number',
51
+ describe: 'seconds between full-frame refreshes (0 = passive, only what the device pushes)',
52
+ default: 60,
53
+ },
54
+ 'stream-interval': {
55
+ type: 'number',
56
+ describe: 'seconds between energy stream activate frames (0 = off; try 20 if the stream stalls)',
57
+ default: 0,
58
+ },
59
+ timeout: {
60
+ type: 'number',
61
+ describe: 'seconds without a frame before the device counts as disconnected',
62
+ default: 300,
63
+ },
64
+ capture: {
65
+ type: 'string',
66
+ describe: 'directory to append raw frames to, serial number removed (development)',
67
+ },
68
+ 'state-dir': {
69
+ type: 'string',
70
+ describe: 'directory for the persisted mqtt client id (default: $STATE_DIRECTORY)',
71
+ default: process.env.STATE_DIRECTORY,
72
+ },
73
+ };
74
+
75
+ /** yargs .check(): value ranges the option types cannot express. */
76
+ export function check(argv) {
77
+ if (!(argv.poll >= 0)) {
78
+ throw new Error('--poll must be >= 0 seconds');
79
+ }
80
+ if (argv.poll > 0 && argv.poll < 5) {
81
+ throw new Error('--poll must be 0 or >= 5 seconds');
82
+ }
83
+ if (!(argv.streamInterval >= 0)) {
84
+ throw new Error('--stream-interval must be >= 0 seconds');
85
+ }
86
+ if (!(argv.timeout >= 30)) {
87
+ throw new Error('--timeout must be >= 30 seconds');
88
+ }
89
+ return true;
90
+ }
91
+
92
+ /** The state directory actually used (option, $STATE_DIRECTORY, or ~/.ecoflow2mqtt). */
93
+ export function stateDirOf(config) {
94
+ return config.stateDir || defaultStateDir();
95
+ }
96
+
97
+ /** Parse the command line + environment; `overrides` (argv, env, exit, print) are for tests. */
98
+ export function parse(overrides = {}) {
99
+ return parseConfig({
100
+ pkg,
101
+ defaults: {name: 'ecoflow'},
102
+ options: OPTIONS,
103
+ check,
104
+ examples: [
105
+ ['$0 --email me@example.com --password secret --sn BK01Z... -u mqtt://broker', 'run in the foreground'],
106
+ ['sudo $0 --install -n balcony', 'install as service ecoflow2mqtt@balcony'],
107
+ ],
108
+ // the core's own epilog (environment variables) plus what is specific to this adapter
109
+ epilog:
110
+ 'Every option can also be set via environment variable, e.g. ECOFLOW2MQTT_MQTT_URL, ' +
111
+ 'ECOFLOW2MQTT_SN. The unprefixed MQTT_URL, MQTT_USERNAME, MQTT_PASSWORD are used as fallback.\n' +
112
+ 'Credentials belong in /etc/ecoflow2mqtt/<name>.env (see --install), not on the command line —\n' +
113
+ 'a process list is world readable.\n' +
114
+ 'ecoflow2mqtt uses the same unofficial cloud api as the ecoflow app; ecoflow may change it.\n' +
115
+ pkg.homepage,
116
+ ...overrides,
117
+ });
118
+ }
119
+
120
+ export default parse();
package/index.js CHANGED
@@ -1,4 +1,140 @@
1
1
  #!/usr/bin/env node
2
- console.error('ecoflow2mqtt 0.0.1 is a placeholder release; the adapter is work in progress.');
3
- console.error('See https://github.com/hobbyquaker/ecoflow2mqtt');
4
- process.exit(1);
2
+
3
+ /**
4
+ * ecoflow2mqtt — EcoFlow micro-inverter to MQTT.
5
+ *
6
+ * Wiring only: the cloud client (lib/app/) emits decoded frames, the item table (lib/items.js)
7
+ * turns them into values, the core publishes them. 0.1.0 publishes PV power and nothing else
8
+ * (ROADMAP.md §1); there are no settable items yet.
9
+ */
10
+
11
+ import {createAdapter} from 'mqtt-interfaces-core';
12
+ import config, {stateDirOf} from './config.js';
13
+ import pkg from './package.json' with {type: 'json'};
14
+ import {handle as handleInstall} from './lib/install.js';
15
+ import {EcoflowClient} from './lib/app/mqtt.js';
16
+ import {loadClientUuid} from './lib/clientid.js';
17
+ import {createItems} from './lib/items.js';
18
+ import {createCapture} from './lib/capture.js';
19
+ import {discoveryModel} from './lib/hadiscovery.js';
20
+ import {maskSn, modelOf} from './lib/mask.js';
21
+ import {apiHostOf} from './lib/app/login.js';
22
+
23
+ handleInstall(config); // --install / --uninstall never reach the rest
24
+
25
+ const items = createItems();
26
+ /** (cmd_func, cmd_id) pairs already logged, so unknown frames are reported once (E-7) */
27
+ const seenFrames = new Set();
28
+ let lastFrame = 0;
29
+ let lastProductId = null;
30
+ let staleTimer = null;
31
+ let capture = null;
32
+
33
+ const adapter = createAdapter({
34
+ pkg,
35
+ config,
36
+ deviceLabel: 'inverter',
37
+ info: () => ({
38
+ sn: maskSn(config.sn),
39
+ model: modelOf(config.sn),
40
+ region: config.region,
41
+ api: apiHostOf(config),
42
+ broker: client?.broker?.host,
43
+ product_id: lastProductId,
44
+ poll: config.poll,
45
+ }),
46
+ discovery: () => discoveryModel({name: config.name, sn: config.sn, jsonPayloads: config.jsonPayloads}),
47
+ onSet: (parts) => {
48
+ throw new Error(`no settable items in ${pkg.version} (read only): set/${parts.join('/')}`);
49
+ },
50
+ onShutdown: async () => {
51
+ clearInterval(staleTimer);
52
+ await capture?.close();
53
+ await client.stop();
54
+ },
55
+ });
56
+
57
+ const {log, pubStatus, setDeviceConnected, publishInfo} = adapter;
58
+
59
+ const client = new EcoflowClient({
60
+ config,
61
+ log,
62
+ uuid: loadClientUuid({stateDir: stateDirOf(config), name: config.name, log}),
63
+ });
64
+
65
+ if (config.capture) {
66
+ capture = createCapture({dir: config.capture, sn: config.sn, userId: () => client.userId, log});
67
+ client.on('raw', (topic, payload) => capture.write(topic, payload));
68
+ }
69
+
70
+ client.on('frames', (frames) => {
71
+ for (const frame of frames) {
72
+ handleFrame(frame);
73
+ }
74
+ });
75
+
76
+ client.on('close', () => setDeviceConnected(false));
77
+
78
+ function handleFrame(frame) {
79
+ if (frame.error) {
80
+ log.debug(`inverter < ${frame.name}: undecodable (${frame.error})`);
81
+ return;
82
+ }
83
+ if (!frame.data) {
84
+ // not mapped in this version: log the pair once with its payload so it can be added later
85
+ if (!seenFrames.has(frame.key)) {
86
+ seenFrames.add(frame.key);
87
+ log.debug(
88
+ `inverter < ${frame.name} (${frame.key}), ${frame.pdata.length} bytes: ` +
89
+ `${frame.pdata.toString('hex')}`,
90
+ );
91
+ }
92
+ return;
93
+ }
94
+ if (!frame.items) {
95
+ // decoded, but nothing to publish (RuntimePropertyUpload: the device's upload periods)
96
+ if (!seenFrames.has(frame.key)) {
97
+ seenFrames.add(frame.key);
98
+ log.debug(`inverter < ${frame.name}: ${JSON.stringify(frame.data)}`);
99
+ }
100
+ return;
101
+ }
102
+
103
+ lastFrame = Date.now();
104
+ setDeviceConnected(true);
105
+ if (frame.productId && frame.productId !== lastProductId) {
106
+ // <name>/info is published before the first frame arrives; refresh it once the device is known
107
+ lastProductId = frame.productId;
108
+ publishInfo();
109
+ }
110
+
111
+ const updates = items.update(frame.data);
112
+ if (updates.length === 0) {
113
+ return;
114
+ }
115
+ log.debug(`inverter < ${frame.name}: ${updates.map(({item, value}) => `${item}=${value}`).join(' ')}`);
116
+ for (const {item, value} of updates) {
117
+ pubStatus(item, value);
118
+ }
119
+ }
120
+
121
+ /** The device counts as connected while frames keep arriving (E-5) — the cloud's own flag lags. */
122
+ function watchStaleness() {
123
+ clearInterval(staleTimer);
124
+ staleTimer = setInterval(() => {
125
+ if (lastFrame === 0 || !client.connected) {
126
+ return;
127
+ }
128
+ const age = (Date.now() - lastFrame) / 1000;
129
+ if (age > config.timeout) {
130
+ log.warn(`no frame from the inverter for ${Math.round(age)} s`);
131
+ setDeviceConnected(false);
132
+ }
133
+ }, 30_000);
134
+ staleTimer.unref?.();
135
+ }
136
+
137
+ log.info(`${pkg.name} ${pkg.version} starting, device ${maskSn(config.sn)} (${modelOf(config.sn) ?? 'unknown model'})`);
138
+ adapter.start();
139
+ watchStaleness();
140
+ client.start();
@@ -0,0 +1,161 @@
1
+ /**
2
+ * Step 1-2 of the app path (ROADMAP.md §2): log in with the EcoFlow app account and ask for the
3
+ * MQTT credentials the app itself uses. No signature, no OAuth — the password goes base64 encoded
4
+ * (not encrypted) over TLS, exactly like the Android app (R §4.1).
5
+ *
6
+ * Accounts are region bound: an EU account must talk to `api-e.ecoflow.com`, and a wrong region
7
+ * answers "Account doesn't exist or incorrect password" — the same message a real outage returns
8
+ * (tolwi #902). Nothing here ever discards credentials because of an error; the caller retries.
9
+ */
10
+
11
+ /** Region -> API host. Only `eu` is verified on hardware; `--api-host` overrides any of them. */
12
+ export const API_HOSTS = {
13
+ eu: 'api-e.ecoflow.com',
14
+ us: 'api.ecoflow.com',
15
+ global: 'api.ecoflow.com',
16
+ americas: 'api-a.ecoflow.com',
17
+ cn: 'api-cn.ecoflow.com',
18
+ };
19
+
20
+ export const REGIONS = Object.keys(API_HOSTS);
21
+
22
+ /** Headers the app sends; some EcoFlow endpoints answer differently without them. */
23
+ const APP_HEADERS = {
24
+ 'content-type': 'application/json',
25
+ lang: 'en_US',
26
+ platform: 'android',
27
+ version: '4.1.2.02',
28
+ 'user-agent': 'okhttp/3.14.9',
29
+ };
30
+
31
+ export class EcoflowApiError extends Error {
32
+ constructor(message, {code, status, endpoint} = {}) {
33
+ super(message);
34
+ this.name = 'EcoflowApiError';
35
+ this.code = code;
36
+ this.status = status;
37
+ this.endpoint = endpoint;
38
+ }
39
+ }
40
+
41
+ /** The API host for a config: explicit `--api-host` wins, otherwise the region table. */
42
+ export function apiHostOf({apiHost, region} = {}) {
43
+ if (apiHost) {
44
+ return apiHost;
45
+ }
46
+ const host = API_HOSTS[String(region ?? '').toLowerCase()];
47
+ if (!host) {
48
+ throw new EcoflowApiError(`unknown region '${region}' — use --api-host`, {code: 'EREGION'});
49
+ }
50
+ return host;
51
+ }
52
+
53
+ async function postJson({url, body, headers, fetchImpl}) {
54
+ let response;
55
+ try {
56
+ response = await fetchImpl(url, {method: 'POST', headers: {...APP_HEADERS, ...headers}, body});
57
+ } catch (error) {
58
+ throw new EcoflowApiError(`request failed: ${error.message}`, {code: 'ENETWORK', endpoint: url});
59
+ }
60
+ return finish(response, url);
61
+ }
62
+
63
+ async function getJson({url, headers, fetchImpl}) {
64
+ let response;
65
+ try {
66
+ response = await fetchImpl(url, {headers: {...APP_HEADERS, ...headers}});
67
+ } catch (error) {
68
+ throw new EcoflowApiError(`request failed: ${error.message}`, {code: 'ENETWORK', endpoint: url});
69
+ }
70
+ return finish(response, url);
71
+ }
72
+
73
+ async function finish(response, url) {
74
+ let json;
75
+ try {
76
+ json = await response.json();
77
+ } catch {
78
+ throw new EcoflowApiError(`http ${response.status}, no json body`, {
79
+ code: 'EFORMAT',
80
+ status: response.status,
81
+ endpoint: url,
82
+ });
83
+ }
84
+ if (String(json.code) !== '0') {
85
+ throw new EcoflowApiError(json.message || `api error ${json.code}`, {
86
+ code: `E${json.code}`,
87
+ status: response.status,
88
+ endpoint: url,
89
+ });
90
+ }
91
+ return json.data ?? {};
92
+ }
93
+
94
+ /**
95
+ * POST /auth/login — the app account.
96
+ *
97
+ * @returns {Promise<{token: string, userId: string}>}
98
+ */
99
+ export async function login({host, email, password, fetchImpl = globalThis.fetch}) {
100
+ const data = await postJson({
101
+ url: `https://${host}/auth/login`,
102
+ fetchImpl,
103
+ body: JSON.stringify({
104
+ email,
105
+ password: Buffer.from(String(password)).toString('base64'),
106
+ scene: 'IOT_APP',
107
+ userType: 'ECOFLOW',
108
+ os: 'android',
109
+ osVersion: '30',
110
+ appVersion: '4.1.2.02',
111
+ }),
112
+ });
113
+ if (!data.token || !data.user?.userId) {
114
+ throw new EcoflowApiError('login succeeded but returned no token', {code: 'EFORMAT'});
115
+ }
116
+ return {token: data.token, userId: String(data.user.userId)};
117
+ }
118
+
119
+ /**
120
+ * GET /iot-auth/app/certification — the broker and the credentials for it.
121
+ *
122
+ * @returns {Promise<{host: string, port: number, protocol: string, username: string, password: string}>}
123
+ */
124
+ export async function certification({host, token, userId, fetchImpl = globalThis.fetch}) {
125
+ const data = await getJson({
126
+ url: `https://${host}/iot-auth/app/certification?userId=${encodeURIComponent(userId)}`,
127
+ headers: {authorization: `Bearer ${token}`},
128
+ fetchImpl,
129
+ });
130
+ if (!data.url || !data.certificateAccount || !data.certificatePassword) {
131
+ throw new EcoflowApiError('certification returned no broker credentials', {code: 'EFORMAT'});
132
+ }
133
+ return {
134
+ host: data.url,
135
+ port: Number(data.port) || 8883,
136
+ protocol: data.protocol || 'mqtts',
137
+ username: data.certificateAccount,
138
+ password: data.certificatePassword,
139
+ };
140
+ }
141
+
142
+ /**
143
+ * Login + certification in one step.
144
+ *
145
+ * @param {{email: string, password: string, region?: string, apiHost?: string, mqttHost?: string}} config
146
+ * @returns {Promise<{userId: string, apiHost: string, broker: object}>}
147
+ */
148
+ export async function authenticate(config, {fetchImpl = globalThis.fetch} = {}) {
149
+ const host = apiHostOf(config);
150
+ const {token, userId} = await login({
151
+ host,
152
+ email: config.email,
153
+ password: config.password,
154
+ fetchImpl,
155
+ });
156
+ const broker = await certification({host, token, userId, fetchImpl});
157
+ if (config.mqttHost) {
158
+ broker.host = config.mqttHost; // certification occasionally names the wrong region (R §4.1)
159
+ }
160
+ return {userId, apiHost: host, broker};
161
+ }