node-red-contrib-hoymiles-home 0.1.1-dev.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/.dockerignore +3 -0
- package/.github/workflows/publish.yml +35 -0
- package/Dockerfile +23 -0
- package/LICENSE +21 -0
- package/docker/flows.json +43 -0
- package/docker/settings.js +35 -0
- package/docker-compose.yml +19 -0
- package/nodes/hoymiles-config/hoymiles-config.html +47 -0
- package/nodes/hoymiles-config/hoymiles-config.js +153 -0
- package/nodes/hoymiles-watch/hoymiles-watch.html +92 -0
- package/nodes/hoymiles-watch/hoymiles-watch.js +130 -0
- package/nodes/hoymiles-watch/icons/bolt.svg +3 -0
- package/package.json +29 -0
package/.dockerignore
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
name: Publish to npm
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
tags:
|
|
6
|
+
- 'v*'
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
publish:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
permissions:
|
|
12
|
+
contents: read
|
|
13
|
+
steps:
|
|
14
|
+
- uses: actions/checkout@v4
|
|
15
|
+
|
|
16
|
+
- uses: actions/setup-node@v4
|
|
17
|
+
with:
|
|
18
|
+
node-version: '20'
|
|
19
|
+
registry-url: 'https://registry.npmjs.org'
|
|
20
|
+
|
|
21
|
+
- run: npm ci --production
|
|
22
|
+
|
|
23
|
+
- name: Determine npm tag
|
|
24
|
+
id: npm-tag
|
|
25
|
+
run: |
|
|
26
|
+
VERSION="${GITHUB_REF#refs/tags/v}"
|
|
27
|
+
if [[ "$VERSION" == *"-"* ]]; then
|
|
28
|
+
echo "tag=dev" >> $GITHUB_OUTPUT
|
|
29
|
+
else
|
|
30
|
+
echo "tag=latest" >> $GITHUB_OUTPUT
|
|
31
|
+
fi
|
|
32
|
+
|
|
33
|
+
- run: npm publish --access public --tag ${{ steps.npm-tag.outputs.tag }}
|
|
34
|
+
env:
|
|
35
|
+
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
package/Dockerfile
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
FROM nodered/node-red:latest
|
|
2
|
+
|
|
3
|
+
USER root
|
|
4
|
+
|
|
5
|
+
# Copy the custom node source
|
|
6
|
+
COPY --chown=node-red:node-red . /usr/src/node-red-contrib-hoymiles-home/
|
|
7
|
+
|
|
8
|
+
# Install the node's own dependencies (axios etc.)
|
|
9
|
+
RUN cd /usr/src/node-red-contrib-hoymiles-home && npm install --production --no-fund
|
|
10
|
+
|
|
11
|
+
# Bootstrap the Node-RED data dir and install the custom node into it.
|
|
12
|
+
# Named volumes are initialised from the image on first run, so npm packages
|
|
13
|
+
# installed here survive container restarts without a rebuild.
|
|
14
|
+
RUN cd /data && \
|
|
15
|
+
echo '{"name":"nodered-data","private":true}' > package.json && \
|
|
16
|
+
npm install /usr/src/node-red-contrib-hoymiles-home --no-fund --no-save && \
|
|
17
|
+
chown -R node-red:node-red /data
|
|
18
|
+
|
|
19
|
+
# Copy dev settings and sample flows (only applied when /data volume is fresh)
|
|
20
|
+
COPY --chown=node-red:node-red docker/settings.js /data/settings.js
|
|
21
|
+
COPY --chown=node-red:node-red docker/flows.json /data/flows.json
|
|
22
|
+
|
|
23
|
+
USER node-red
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 pinguin45orga
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
[
|
|
2
|
+
{
|
|
3
|
+
"id": "tab-hoymiles",
|
|
4
|
+
"type": "tab",
|
|
5
|
+
"label": "Hoymiles Test",
|
|
6
|
+
"disabled": false,
|
|
7
|
+
"info": "1. Open 'Live Power', set credentials (email + password) and station ID\n2. Deploy and watch the Debug sidebar"
|
|
8
|
+
},
|
|
9
|
+
{
|
|
10
|
+
"id": "cfg-hoymiles",
|
|
11
|
+
"type": "hoymiles-config",
|
|
12
|
+
"name": "Hoymiles Account"
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"id": "node-watch",
|
|
16
|
+
"type": "hoymiles-watch",
|
|
17
|
+
"name": "Live Power",
|
|
18
|
+
"server": "cfg-hoymiles",
|
|
19
|
+
"sid": "",
|
|
20
|
+
"interval": "",
|
|
21
|
+
"z": "tab-hoymiles",
|
|
22
|
+
"x": 180,
|
|
23
|
+
"y": 200,
|
|
24
|
+
"wires": [["node-debug"]]
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
"id": "node-debug",
|
|
28
|
+
"type": "debug",
|
|
29
|
+
"name": "Power (W)",
|
|
30
|
+
"active": true,
|
|
31
|
+
"tosidebar": true,
|
|
32
|
+
"console": false,
|
|
33
|
+
"tostatus": true,
|
|
34
|
+
"complete": "payload",
|
|
35
|
+
"targetType": "msg",
|
|
36
|
+
"statusVal": "payload.pv",
|
|
37
|
+
"statusType": "msg",
|
|
38
|
+
"z": "tab-hoymiles",
|
|
39
|
+
"x": 380,
|
|
40
|
+
"y": 200,
|
|
41
|
+
"wires": []
|
|
42
|
+
}
|
|
43
|
+
]
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
module.exports = {
|
|
2
|
+
// ── Server ────────────────────────────────────────────────────────────────
|
|
3
|
+
uiPort: process.env.PORT || 1880,
|
|
4
|
+
uiHost: '0.0.0.0',
|
|
5
|
+
|
|
6
|
+
// ── Auth ──────────────────────────────────────────────────────────────────
|
|
7
|
+
// No admin auth for local dev. Add adminAuth to lock down in production.
|
|
8
|
+
// adminAuth: { type: 'credentials', users: [{ username: 'admin', password: '...', permissions: '*' }] },
|
|
9
|
+
|
|
10
|
+
// ── Credential encryption ─────────────────────────────────────────────────
|
|
11
|
+
// Value comes from NODE_RED_CREDENTIAL_SECRET env var (set in docker-compose).
|
|
12
|
+
credentialSecret: process.env.NODE_RED_CREDENTIAL_SECRET || 'hoymiles-dev-secret',
|
|
13
|
+
|
|
14
|
+
// ── Flows ─────────────────────────────────────────────────────────────────
|
|
15
|
+
flowFile: 'flows.json',
|
|
16
|
+
flowFilePretty: true,
|
|
17
|
+
|
|
18
|
+
// ── Editor ────────────────────────────────────────────────────────────────
|
|
19
|
+
editorTheme: {
|
|
20
|
+
tours: false,
|
|
21
|
+
projects: { enabled: false },
|
|
22
|
+
},
|
|
23
|
+
|
|
24
|
+
// ── Logging ───────────────────────────────────────────────────────────────
|
|
25
|
+
logging: {
|
|
26
|
+
console: {
|
|
27
|
+
level: 'info',
|
|
28
|
+
metric: false,
|
|
29
|
+
audit: false,
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
|
|
33
|
+
// ── Node settings ─────────────────────────────────────────────────────────
|
|
34
|
+
functionExternalModules: true,
|
|
35
|
+
};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
services:
|
|
2
|
+
node-red:
|
|
3
|
+
build: .
|
|
4
|
+
image: node-red-contrib-hoymiles-home-dev
|
|
5
|
+
ports:
|
|
6
|
+
- "1880:1880"
|
|
7
|
+
volumes:
|
|
8
|
+
# Persistent data (flows, credentials, settings).
|
|
9
|
+
# Initialised from the image on first run; survives container restarts.
|
|
10
|
+
- node-red-data:/data
|
|
11
|
+
# Live-reload: mount source directly so node code changes are reflected
|
|
12
|
+
# after a container restart (no full rebuild needed).
|
|
13
|
+
- ./nodes:/usr/src/node-red-contrib-hoymiles-home/nodes:ro
|
|
14
|
+
environment:
|
|
15
|
+
NODE_RED_CREDENTIAL_SECRET: "${NODE_RED_CREDENTIAL_SECRET:-hoymiles-dev-secret}"
|
|
16
|
+
restart: unless-stopped
|
|
17
|
+
|
|
18
|
+
volumes:
|
|
19
|
+
node-red-data:
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
<script type="text/javascript">
|
|
2
|
+
RED.nodes.registerType('hoymiles-config', {
|
|
3
|
+
category: 'config',
|
|
4
|
+
defaults: {
|
|
5
|
+
name: { value: '' },
|
|
6
|
+
},
|
|
7
|
+
credentials: {
|
|
8
|
+
email: { type: 'text' },
|
|
9
|
+
password: { type: 'password' },
|
|
10
|
+
},
|
|
11
|
+
label: function () {
|
|
12
|
+
return this.name || this.credentials?.email || 'Hoymiles Account';
|
|
13
|
+
},
|
|
14
|
+
});
|
|
15
|
+
</script>
|
|
16
|
+
|
|
17
|
+
<script type="text/html" data-template-name="hoymiles-config">
|
|
18
|
+
<div class="form-row">
|
|
19
|
+
<label for="node-config-input-name">
|
|
20
|
+
<i class="fa fa-tag"></i> Name
|
|
21
|
+
</label>
|
|
22
|
+
<input type="text" id="node-config-input-name" placeholder="Hoymiles Account" />
|
|
23
|
+
</div>
|
|
24
|
+
<div class="form-row">
|
|
25
|
+
<label for="node-config-input-email">
|
|
26
|
+
<i class="fa fa-envelope"></i> E-Mail
|
|
27
|
+
</label>
|
|
28
|
+
<input type="email" id="node-config-input-email" placeholder="you@example.com" />
|
|
29
|
+
</div>
|
|
30
|
+
<div class="form-row">
|
|
31
|
+
<label for="node-config-input-password">
|
|
32
|
+
<i class="fa fa-lock"></i> Password
|
|
33
|
+
</label>
|
|
34
|
+
<input type="password" id="node-config-input-password" placeholder="S-Miles Home password" />
|
|
35
|
+
</div>
|
|
36
|
+
<div class="form-tips">
|
|
37
|
+
Use the same credentials as in the <b>S-Miles Home</b> app.
|
|
38
|
+
The node logs in automatically and refreshes the session token as needed.
|
|
39
|
+
</div>
|
|
40
|
+
</script>
|
|
41
|
+
|
|
42
|
+
<script type="text/html" data-help-name="hoymiles-config">
|
|
43
|
+
<p>Stores the S-Miles Home login credentials and manages the API session token.</p>
|
|
44
|
+
<p>On deploy the node performs the same two-step login as the S-Miles Home app
|
|
45
|
+
(pre-inspection → Argon2ID or MD5·SHA256 encoding → token).</p>
|
|
46
|
+
<p>The resulting token is shared with all connected <b>hoymiles-watch</b> nodes.</p>
|
|
47
|
+
</script>
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
const axios = require('axios');
|
|
2
|
+
const crypto = require('crypto');
|
|
3
|
+
|
|
4
|
+
const DOMAINS = {
|
|
5
|
+
eu: 'https://euapi.hoymiles.com',
|
|
6
|
+
'eu-rt': 'https://eud0.hoymiles.com',
|
|
7
|
+
global: 'https://neapi.hoymiles.com',
|
|
8
|
+
in: 'https://indapi.hoymiles.com',
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
const EU_PREFIXES = ['/iam/', '/dict/', '/hlm/', '/csc/', '/tpa/', '/pvm-ext/', '/iuc/'];
|
|
12
|
+
const EU_RT_PATHS = new Set([
|
|
13
|
+
'/pvmc/api/0/station_data/real_g_c',
|
|
14
|
+
'/pvmc/api/0/station_data/eq_day_month_s_c',
|
|
15
|
+
'/pvmc/api/0/station_data/eq_month_year_s_c',
|
|
16
|
+
'/pvmc/api/0/station_data/eq_total_year_s_c',
|
|
17
|
+
'/pvmc/api/0/station_data/p_day_s_c',
|
|
18
|
+
]);
|
|
19
|
+
|
|
20
|
+
function resolveDomain(path) {
|
|
21
|
+
if (EU_RT_PATHS.has(path)) return 'eu-rt';
|
|
22
|
+
if (EU_PREFIXES.some(p => path.startsWith(p))) return 'eu';
|
|
23
|
+
return 'global';
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
class APIError extends Error {
|
|
27
|
+
constructor(message, status) {
|
|
28
|
+
super(message);
|
|
29
|
+
this.status = status;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
class HoymilesClient {
|
|
34
|
+
constructor(token, language = 'en_us') {
|
|
35
|
+
this.token = token;
|
|
36
|
+
this.language = language;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
_headers() {
|
|
40
|
+
const h = {
|
|
41
|
+
'Content-Type': 'application/json',
|
|
42
|
+
'Charset': 'UTF-8',
|
|
43
|
+
'language': this.language,
|
|
44
|
+
'User-Agent': 'sma/ad/2.9.0/159/0',
|
|
45
|
+
};
|
|
46
|
+
if (this.token) h['Authorization'] = this.token;
|
|
47
|
+
return h;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async post(path, data = {}, domain = null) {
|
|
51
|
+
const base = DOMAINS[domain || resolveDomain(path)];
|
|
52
|
+
return this._doPost(`${base}${path}`, data);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async postUrl(url, data = {}) {
|
|
56
|
+
return this._doPost(url, data);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async _doPost(url, data) {
|
|
60
|
+
const resp = await axios.post(url, data, {
|
|
61
|
+
headers: this._headers(),
|
|
62
|
+
timeout: 30000,
|
|
63
|
+
maxRedirects: 5,
|
|
64
|
+
});
|
|
65
|
+
const result = resp.data;
|
|
66
|
+
if (!['0', '100'].includes(String(result.status))) {
|
|
67
|
+
throw new APIError(result.message || 'Unknown error', String(result.status ?? '???'));
|
|
68
|
+
}
|
|
69
|
+
return result.data;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ── Password encoding (mirrors the CLI exactly) ────────────────────────────
|
|
74
|
+
|
|
75
|
+
function encodePasswordFallback(password) {
|
|
76
|
+
const md5 = crypto.createHash('md5').update(password, 'utf8').digest('hex');
|
|
77
|
+
const sha256 = crypto.createHash('sha256').update(password, 'utf8').digest();
|
|
78
|
+
return `${md5}.${sha256.toString('base64')}`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function encodePasswordArgon2(password, saltHex) {
|
|
82
|
+
// Lazy-load hash-wasm so the module doesn't fail if the package isn't installed yet
|
|
83
|
+
const { argon2id } = require('hash-wasm');
|
|
84
|
+
return argon2id({
|
|
85
|
+
password,
|
|
86
|
+
salt: Buffer.from(saltHex, 'hex'),
|
|
87
|
+
parallelism: 1,
|
|
88
|
+
iterations: 3,
|
|
89
|
+
memorySize: 32768,
|
|
90
|
+
hashLength: 32,
|
|
91
|
+
outputType: 'hex',
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function loginWithCredentials(email, password) {
|
|
96
|
+
const client = new HoymilesClient(); // no token needed for auth endpoints
|
|
97
|
+
|
|
98
|
+
// Step 1: pre-inspection — determine password encoding scheme
|
|
99
|
+
const preinsp = await client.post('/iam/pub/3/auth/pre-insp', { u: email });
|
|
100
|
+
const { v, a, n } = preinsp || {};
|
|
101
|
+
|
|
102
|
+
// Step 2: encode password (Argon2ID when v=3, MD5.Base64(SHA256) otherwise)
|
|
103
|
+
const ch = (v === 3 && a)
|
|
104
|
+
? await encodePasswordArgon2(password, a)
|
|
105
|
+
: encodePasswordFallback(password);
|
|
106
|
+
|
|
107
|
+
// Step 3: login
|
|
108
|
+
const data = await client.post('/iam/pub/3/auth/login', { u: email, ch, n });
|
|
109
|
+
const token = data?.token;
|
|
110
|
+
if (!token) throw new Error('Login succeeded but no token in response');
|
|
111
|
+
return token;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ── Node-RED registration ──────────────────────────────────────────────────
|
|
115
|
+
|
|
116
|
+
module.exports = function (RED) {
|
|
117
|
+
function HoymilesConfigNode(config) {
|
|
118
|
+
RED.nodes.createNode(this, config);
|
|
119
|
+
const node = this;
|
|
120
|
+
|
|
121
|
+
const email = node.credentials?.email || '';
|
|
122
|
+
const password = node.credentials?.password || '';
|
|
123
|
+
|
|
124
|
+
node.client = null;
|
|
125
|
+
|
|
126
|
+
if (!email || !password) {
|
|
127
|
+
node.error('Hoymiles config: email and password are required');
|
|
128
|
+
node._loginReady = Promise.resolve(); // resolve immediately so dependents don't hang
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// loginReady resolves (never rejects) once the login attempt finishes.
|
|
133
|
+
// Dependents await this, then check node.client for success/failure.
|
|
134
|
+
node._loginReady = loginWithCredentials(email, password)
|
|
135
|
+
.then(token => {
|
|
136
|
+
node.client = new HoymilesClient(token);
|
|
137
|
+
node.log(`Logged in as ${email}`);
|
|
138
|
+
})
|
|
139
|
+
.catch(err => {
|
|
140
|
+
node.error(`Login failed: ${err.message}`);
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
RED.nodes.registerType('hoymiles-config', HoymilesConfigNode, {
|
|
145
|
+
credentials: {
|
|
146
|
+
email: { type: 'text' },
|
|
147
|
+
password: { type: 'password' },
|
|
148
|
+
},
|
|
149
|
+
});
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
module.exports.APIError = APIError;
|
|
153
|
+
module.exports.HoymilesClient = HoymilesClient;
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
<script type="text/javascript">
|
|
2
|
+
RED.nodes.registerType('hoymiles-watch', {
|
|
3
|
+
category: 'input',
|
|
4
|
+
color: '#f0a500',
|
|
5
|
+
defaults: {
|
|
6
|
+
name: { value: '' },
|
|
7
|
+
server: { value: '', type: 'hoymiles-config', required: true },
|
|
8
|
+
sid: { value: '', required: true, validate: RED.validators.number() },
|
|
9
|
+
interval: { value: '' },
|
|
10
|
+
},
|
|
11
|
+
inputs: 0,
|
|
12
|
+
outputs: 1,
|
|
13
|
+
icon: 'bolt.svg',
|
|
14
|
+
label: function () {
|
|
15
|
+
return this.name || `Hoymiles Watch (${this.sid || '?'})`;
|
|
16
|
+
},
|
|
17
|
+
labelStyle: function () {
|
|
18
|
+
return this.name ? 'node_label_italic' : '';
|
|
19
|
+
},
|
|
20
|
+
outputLabels: ['live power data'],
|
|
21
|
+
});
|
|
22
|
+
</script>
|
|
23
|
+
|
|
24
|
+
<script type="text/html" data-template-name="hoymiles-watch">
|
|
25
|
+
<div class="form-row">
|
|
26
|
+
<label for="node-input-name">
|
|
27
|
+
<i class="fa fa-tag"></i> Name
|
|
28
|
+
</label>
|
|
29
|
+
<input type="text" id="node-input-name" placeholder="Hoymiles Watch" />
|
|
30
|
+
</div>
|
|
31
|
+
<div class="form-row">
|
|
32
|
+
<label for="node-input-server">
|
|
33
|
+
<i class="fa fa-key"></i> Credentials
|
|
34
|
+
</label>
|
|
35
|
+
<input type="text" id="node-input-server" />
|
|
36
|
+
</div>
|
|
37
|
+
<div class="form-row">
|
|
38
|
+
<label for="node-input-sid">
|
|
39
|
+
<i class="fa fa-bolt"></i> Station ID
|
|
40
|
+
</label>
|
|
41
|
+
<input type="number" id="node-input-sid" placeholder="e.g. 123456" />
|
|
42
|
+
</div>
|
|
43
|
+
<div class="form-row">
|
|
44
|
+
<label for="node-input-interval">
|
|
45
|
+
<i class="fa fa-clock-o"></i> Interval (s)
|
|
46
|
+
</label>
|
|
47
|
+
<input type="number" id="node-input-interval" placeholder="leave empty to use server value (dly)" min="1" />
|
|
48
|
+
</div>
|
|
49
|
+
<div class="form-tips">
|
|
50
|
+
Leave <b>Interval</b> empty to use the <code>dly</code> field from each server response (matches the S-Miles Home app behaviour).
|
|
51
|
+
</div>
|
|
52
|
+
</script>
|
|
53
|
+
|
|
54
|
+
<script type="text/html" data-help-name="hoymiles-watch">
|
|
55
|
+
<p>Continuously polls the Hoymiles S-Miles Home live power API and emits a message for every new data point.</p>
|
|
56
|
+
|
|
57
|
+
<h3>Configuration</h3>
|
|
58
|
+
<dl class="message-properties">
|
|
59
|
+
<dt>Credentials</dt>
|
|
60
|
+
<dd>Select or create a <em>Hoymiles Credentials</em> config node with your API token.</dd>
|
|
61
|
+
<dt>Station ID</dt>
|
|
62
|
+
<dd>The numeric station ID (<code>sid</code>). Use the CLI (<code>hoymiles station list</code>) to find it.</dd>
|
|
63
|
+
<dt>Interval</dt>
|
|
64
|
+
<dd>Poll interval in seconds. Leave empty to use the <code>dly</code> value returned by the server (recommended — this mirrors the S-Miles Home app).</dd>
|
|
65
|
+
</dl>
|
|
66
|
+
|
|
67
|
+
<h3>Output message</h3>
|
|
68
|
+
<dl class="message-properties">
|
|
69
|
+
<dt>payload <span class="property-type">object</span></dt>
|
|
70
|
+
<dd>
|
|
71
|
+
Power values from the station:
|
|
72
|
+
<ul>
|
|
73
|
+
<li><code>payload.pv</code> – Solar generation (W)</li>
|
|
74
|
+
<li><code>payload.load</code> – Consumption (W)</li>
|
|
75
|
+
<li><code>payload.grid</code> – Grid exchange (W, negative = export)</li>
|
|
76
|
+
<li><code>payload.bat</code> – Battery power (W)</li>
|
|
77
|
+
</ul>
|
|
78
|
+
</dd>
|
|
79
|
+
<dt>topic <span class="property-type">string</span></dt>
|
|
80
|
+
<dd><code>hoymiles/watch/{sid}</code></dd>
|
|
81
|
+
<dt>sid <span class="property-type">number</span></dt>
|
|
82
|
+
<dd>The station ID for this message.</dd>
|
|
83
|
+
</dl>
|
|
84
|
+
|
|
85
|
+
<h3>Status indicators</h3>
|
|
86
|
+
<ul>
|
|
87
|
+
<li><span style="color:green">●</span> <b>watching</b> – Polling normally</li>
|
|
88
|
+
<li><span style="color:yellow">◌</span> <b>connecting / reconnecting</b> – Fetching a fresh live URI</li>
|
|
89
|
+
<li><span style="color:red">●</span> <b>error</b> – Initial connect failed; check credentials and station ID</li>
|
|
90
|
+
<li><span style="color:grey">◌</span> <b>stopped</b> – Node was disabled or redeployed</li>
|
|
91
|
+
</ul>
|
|
92
|
+
</script>
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
const { APIError } = require('../hoymiles-config/hoymiles-config');
|
|
2
|
+
|
|
3
|
+
const MIN_INTERVAL_MS = 1000;
|
|
4
|
+
const DEFAULT_DELAY_MS = 5000;
|
|
5
|
+
const URI_RETRY_DELAY_MS = 3000;
|
|
6
|
+
|
|
7
|
+
function sleep(ms) {
|
|
8
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
module.exports = function (RED) {
|
|
12
|
+
function HoymilesWatchNode(config) {
|
|
13
|
+
RED.nodes.createNode(this, config);
|
|
14
|
+
const node = this;
|
|
15
|
+
|
|
16
|
+
const configNode = RED.nodes.getNode(config.server);
|
|
17
|
+
if (!configNode) {
|
|
18
|
+
node.error('No Hoymiles credentials configured');
|
|
19
|
+
node.status({ fill: 'red', shape: 'ring', text: 'no credentials' });
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const sid = parseInt(config.sid, 10);
|
|
24
|
+
const intervalOverride = config.interval ? parseInt(config.interval, 10) * 1000 : null;
|
|
25
|
+
|
|
26
|
+
if (!sid) {
|
|
27
|
+
node.error('Station ID (sid) is required');
|
|
28
|
+
node.status({ fill: 'red', shape: 'ring', text: 'no station ID' });
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
let running = false;
|
|
33
|
+
let stopResolve = null;
|
|
34
|
+
let currentTimer = null;
|
|
35
|
+
|
|
36
|
+
async function getLiveUri(client) {
|
|
37
|
+
const data = await client.post('/pvmc/api/0/station/get_sd_uri_c', { sid });
|
|
38
|
+
const uri = data?.uri;
|
|
39
|
+
if (!uri) throw new Error('No live URI returned from get_sd_uri_c');
|
|
40
|
+
return uri;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function fetchLive(client, uri) {
|
|
44
|
+
return client.postUrl(uri, { sid, m: 0 });
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function interruptibleSleep(ms) {
|
|
48
|
+
await new Promise(resolve => {
|
|
49
|
+
stopResolve = resolve;
|
|
50
|
+
currentTimer = setTimeout(resolve, ms);
|
|
51
|
+
});
|
|
52
|
+
stopResolve = null;
|
|
53
|
+
currentTimer = null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function watchLoop(client) {
|
|
57
|
+
node.status({ fill: 'yellow', shape: 'ring', text: 'connecting…' });
|
|
58
|
+
|
|
59
|
+
let uri, data;
|
|
60
|
+
try {
|
|
61
|
+
uri = await getLiveUri(client);
|
|
62
|
+
data = await fetchLive(client, uri);
|
|
63
|
+
} catch (err) {
|
|
64
|
+
node.error(`Initial connect failed: ${err.message}`);
|
|
65
|
+
node.status({ fill: 'red', shape: 'dot', text: 'connect error' });
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
node.status({ fill: 'green', shape: 'dot', text: `watching sid=${sid}` });
|
|
70
|
+
|
|
71
|
+
while (running) {
|
|
72
|
+
node.send({ payload: data?.power ?? {}, topic: `hoymiles/watch/${sid}`, sid });
|
|
73
|
+
|
|
74
|
+
const dly = intervalOverride ?? Math.max(MIN_INTERVAL_MS, data?.dly ?? DEFAULT_DELAY_MS);
|
|
75
|
+
await interruptibleSleep(dly);
|
|
76
|
+
|
|
77
|
+
if (!running) break;
|
|
78
|
+
|
|
79
|
+
try {
|
|
80
|
+
data = await fetchLive(client, uri);
|
|
81
|
+
|
|
82
|
+
// No "flow" field → URI has expired, refresh
|
|
83
|
+
if (!data || !('flow' in data)) {
|
|
84
|
+
uri = await getLiveUri(client);
|
|
85
|
+
data = await fetchLive(client, uri);
|
|
86
|
+
}
|
|
87
|
+
} catch (err) {
|
|
88
|
+
node.warn(`Poll error: ${err.message} — refreshing URI`);
|
|
89
|
+
node.status({ fill: 'yellow', shape: 'ring', text: 'reconnecting…' });
|
|
90
|
+
try { uri = await getLiveUri(client); } catch (_) { /* ignore */ }
|
|
91
|
+
await sleep(URI_RETRY_DELAY_MS);
|
|
92
|
+
node.status({ fill: 'green', shape: 'dot', text: `watching sid=${sid}` });
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
node.status({ fill: 'grey', shape: 'ring', text: 'stopped' });
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function stop() {
|
|
100
|
+
running = false;
|
|
101
|
+
if (currentTimer) { clearTimeout(currentTimer); currentTimer = null; }
|
|
102
|
+
if (stopResolve) { stopResolve(); stopResolve = null; }
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function start() {
|
|
106
|
+
// Wait for the config node to finish logging in
|
|
107
|
+
if (configNode._loginReady) {
|
|
108
|
+
node.status({ fill: 'yellow', shape: 'ring', text: 'authenticating…' });
|
|
109
|
+
await configNode._loginReady;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (!configNode.client) {
|
|
113
|
+
node.status({ fill: 'red', shape: 'ring', text: 'auth failed' });
|
|
114
|
+
return; // error already logged by the config node
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
running = true;
|
|
118
|
+
await watchLoop(configNode.client);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
node.on('close', (done) => { stop(); done(); });
|
|
122
|
+
|
|
123
|
+
start().catch(err => {
|
|
124
|
+
node.error(`Startup failed: ${err.message}`);
|
|
125
|
+
node.status({ fill: 'red', shape: 'dot', text: 'crashed' });
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
RED.nodes.registerType('hoymiles-watch', HoymilesWatchNode);
|
|
130
|
+
};
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-110 -160 540 832">
|
|
2
|
+
<path fill="white" d="M296 160H180.6l42.6-129.8C227.2 15 215.7 0 200 0H56C44 0 33.8 8.9 32.2 20.8l-32 240C-1.7 275.2 9.5 288 24 288h118.7L96.6 482.5c-3.6 15.2 8 29.5 23.3 29.5 8.4 0 16.4-4.4 20.8-12l176-304c9.3-15.9-2.2-36-20.7-36z"/>
|
|
3
|
+
</svg>
|
package/package.json
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "node-red-contrib-hoymiles-home",
|
|
3
|
+
"version": "0.1.1-dev.0",
|
|
4
|
+
"description": "Node-RED nodes for Hoymiles S-Miles Home API – live power data watch",
|
|
5
|
+
"author": "Robin Lenz <pinguin45@web.de>",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"keywords": [
|
|
8
|
+
"node-red",
|
|
9
|
+
"hoymiles",
|
|
10
|
+
"solar",
|
|
11
|
+
"photovoltaic",
|
|
12
|
+
"smiles",
|
|
13
|
+
"smiles home"
|
|
14
|
+
],
|
|
15
|
+
"node-red": {
|
|
16
|
+
"version": ">=3.0.0",
|
|
17
|
+
"nodes": {
|
|
18
|
+
"hoymiles-config": "nodes/hoymiles-config/hoymiles-config.js",
|
|
19
|
+
"hoymiles-watch": "nodes/hoymiles-watch/hoymiles-watch.js"
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
"dependencies": {
|
|
23
|
+
"axios": "^1.7.0",
|
|
24
|
+
"hash-wasm": "^4.11.0"
|
|
25
|
+
},
|
|
26
|
+
"engines": {
|
|
27
|
+
"node": ">=18.0.0"
|
|
28
|
+
}
|
|
29
|
+
}
|