Skip to content

Commit 7ec464d

Browse files
authored
feat: replace node-fronius-solar with modern axios-based API client (#41)
- Add fronius-api.js module using axios for HTTP requests - Remove deprecated node-fronius-solar dependency (v0.0.10) - Add axios ^1.7.0 as production dependency - Move mocha from dependencies to devDependencies - Add missing API methods: GetStorageRealtimeData, GetMeterRealtimeData - Improve error handling with user-friendly messages - Remove deprecated getReq.abort() in favor of modern patterns - Maintain request serialization to avoid overwhelming inverters - Update test mocks to use new local module path
1 parent 3358fd0 commit 7ec464d

6 files changed

Lines changed: 613 additions & 48 deletions

File tree

fronius/fronius-api.js

Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
"use strict";
2+
3+
const axios = require("axios");
4+
const https = require("https");
5+
6+
const DEFAULT_DEVICE_ID = 1;
7+
const DEFAULT_TIMEOUT = 20000;
8+
9+
const debug =
10+
process.env.FRONIUS_DEBUG === "true"
11+
? (...args) => console.log("[fronius-api]", ...args)
12+
: () => {};
13+
14+
// Request queue to serialize requests (Fronius inverters can be overwhelmed by concurrent requests)
15+
let lastRequest = Promise.resolve();
16+
17+
/**
18+
* Creates an axios instance configured for Fronius API requests
19+
* @param {Object} options - Connection options
20+
* @returns {import('axios').AxiosInstance}
21+
*/
22+
function createClient(options) {
23+
const protocol = options.protocol === "https:" ? "https" : "http";
24+
const baseURL = `${protocol}://${options.host}:${options.port || (protocol === "https" ? 443 : 80)}`;
25+
26+
const config = {
27+
baseURL,
28+
timeout: options.timeout || DEFAULT_TIMEOUT,
29+
headers: {
30+
Accept: "application/json",
31+
},
32+
// Allow self-signed certificates (common for inverter web interfaces)
33+
httpsAgent: new https.Agent({
34+
rejectUnauthorized: false,
35+
}),
36+
};
37+
38+
// Add digest auth if credentials are provided
39+
if (options.username && options.password) {
40+
config.auth = {
41+
username: options.username,
42+
password: options.password,
43+
};
44+
}
45+
46+
const client = axios.create(config);
47+
48+
// Request interceptor for debugging
49+
client.interceptors.request.use((config) => {
50+
debug("REQUEST:", config.method?.toUpperCase(), config.url);
51+
return config;
52+
});
53+
54+
// Response interceptor for debugging
55+
client.interceptors.response.use(
56+
(response) => {
57+
debug("RESPONSE:", response.status, response.config.url);
58+
return response;
59+
},
60+
(error) => {
61+
debug("ERROR:", error.message);
62+
return Promise.reject(error);
63+
},
64+
);
65+
66+
return client;
67+
}
68+
69+
/**
70+
* Validates that the response has the expected Fronius API structure
71+
* @param {Object} data - Response data
72+
* @returns {boolean}
73+
*/
74+
function isValidResponse(data) {
75+
return data && typeof data === "object" && "Head" in data && "Body" in data;
76+
}
77+
78+
/**
79+
* Makes a serialized request to the Fronius API
80+
* @param {Object} options - Request options
81+
* @param {string} path - API endpoint path
82+
* @returns {Promise<Object>}
83+
*/
84+
async function makeRequest(options, path) {
85+
const client = createClient(options);
86+
87+
// Serialize requests to avoid overwhelming the inverter
88+
const request = lastRequest.then(async () => {
89+
try {
90+
const response = await client.get(path);
91+
92+
if (!isValidResponse(response.data)) {
93+
throw new Error("Invalid response body format: Head and Body expected");
94+
}
95+
96+
return response.data;
97+
} catch (error) {
98+
// Transform axios errors to user-friendly messages
99+
if (error.response) {
100+
if (error.response.status === 401) {
101+
throw new Error("Unauthorized: check username/password");
102+
}
103+
throw new Error(
104+
`Request failed. HTTP Status Code: ${error.response.status}`,
105+
);
106+
} else if (error.code === "ECONNABORTED") {
107+
throw new Error("Request timeout occurred - request aborted");
108+
} else if (error.code === "ECONNREFUSED") {
109+
throw new Error(
110+
`Connection refused - check if inverter is reachable at ${options.host}`,
111+
);
112+
} else if (error.code === "ENOTFOUND") {
113+
throw new Error(`Host not found: ${options.host}`);
114+
}
115+
throw error;
116+
}
117+
});
118+
119+
// Update lastRequest but don't let failures block future requests
120+
lastRequest = request.catch(() => {});
121+
122+
return request;
123+
}
124+
125+
/**
126+
* Validates required properties are present in options
127+
* @param {Object} options - Options object
128+
* @param {string[]} required - Required property names
129+
*/
130+
function validateOptions(options, required) {
131+
for (const prop of required) {
132+
if (options[prop] === undefined || options[prop] === null) {
133+
throw new Error(`Request options lacks required property=${prop}`);
134+
}
135+
}
136+
}
137+
138+
/**
139+
* Get inverter realtime data
140+
* @param {Object} options - Connection options
141+
* @returns {Promise<Object>}
142+
*/
143+
async function GetInverterRealtimeData(options) {
144+
validateOptions(options, ["host", "deviceId"]);
145+
146+
const deviceId = options.deviceId || DEFAULT_DEVICE_ID;
147+
let path;
148+
149+
if (options.version === 0) {
150+
path = `/solar_api/GetInverterRealtimeData.cgi?Scope=Device&DeviceIndex=${deviceId}&DataCollection=CommonInverterData`;
151+
} else {
152+
path = `/solar_api/v1/GetInverterRealtimeData.cgi?Scope=Device&DeviceId=${deviceId}&DataCollection=CommonInverterData`;
153+
}
154+
155+
return makeRequest(options, path);
156+
}
157+
158+
/**
159+
* Get components data (undocumented API for Symo inverters)
160+
* @param {Object} options - Connection options
161+
* @returns {Promise<Object>}
162+
*/
163+
async function GetComponentsData(options) {
164+
validateOptions(options, ["host"]);
165+
return makeRequest(options, "/components/5/0/?print=names");
166+
}
167+
168+
/**
169+
* Get power flow realtime data
170+
* @param {Object} options - Connection options
171+
* @returns {Promise<Object>}
172+
*/
173+
async function GetPowerFlowRealtimeData(options) {
174+
validateOptions(options, ["host"]);
175+
return makeRequest(options, "/solar_api/v1/GetPowerFlowRealtimeData.fcgi");
176+
}
177+
178+
/**
179+
* Get storage realtime data (for battery systems)
180+
* @param {Object} options - Connection options
181+
* @returns {Promise<Object>}
182+
*/
183+
async function GetStorageRealtimeData(options) {
184+
validateOptions(options, ["host"]);
185+
186+
const deviceId = options.deviceId || DEFAULT_DEVICE_ID;
187+
return makeRequest(
188+
options,
189+
`/solar_api/v1/GetStorageRealtimeData.cgi?Scope=Device&DeviceId=${deviceId}`,
190+
);
191+
}
192+
193+
/**
194+
* Get meter realtime data (for power meter / smart meter)
195+
* @param {Object} options - Connection options
196+
* @returns {Promise<Object>}
197+
*/
198+
async function GetMeterRealtimeData(options) {
199+
validateOptions(options, ["host"]);
200+
201+
const deviceId = options.deviceId || DEFAULT_DEVICE_ID;
202+
return makeRequest(
203+
options,
204+
`/solar_api/v1/GetMeterRealtimeData.cgi?Scope=Device&DeviceId=${deviceId}`,
205+
);
206+
}
207+
208+
module.exports = {
209+
GetInverterRealtimeData,
210+
GetComponentsData,
211+
GetPowerFlowRealtimeData,
212+
GetStorageRealtimeData,
213+
GetMeterRealtimeData,
214+
};

fronius/fronius.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
module.exports = function (RED) {
22
"use strict";
3-
const fronius = require("node-fronius-solar");
3+
const fronius = require("./fronius-api");
44

55
/**
66
* fronius inverter node

0 commit comments

Comments
 (0)