Skip to content

Commit 8ce0cda

Browse files
[weather] refactor: migrate to server-side providers with centralized HTTPFetcher (#4032)
This migrates the Weather module from client-side fetching to use the server-side centralized HTTPFetcher (introduced in #4016), following the same pattern as the Calendar and Newsfeed modules. ## Motivation This brings consistent error handling and better maintainability and completes the refactoring effort to centralize HTTP error handling across all default modules. Migrating to server-side providers with HTTPFetcher brings: - **Centralized error handling**: Inherits smart retry strategies (401/403, 429, 5xx backoff) and timeout handling (30s) - **Consistency**: Same architecture as Calendar and Newsfeed modules - **Security**: Possibility to hide API keys/secrets from client-side - **Performance**: Reduced API calls in multi-client setups - one server fetch instead of one per client - **Enabling possible future features**: e.g. server-side caching, rate limit monitoring, and data sharing with third-party modules ## Changes - All 10 weather providers now use HTTPFetcher for server-side fetching - Consistent error handling like Calendar and Newsfeed modules ## Breaking Changes None. Existing configurations continue to work. ## Testing To ensure proper functionality, I obtained API keys and credentials for all providers that require them. I configured all 10 providers in a carousel setup and tested each one individually. Screenshots for each provider are attached below demonstrating their working state. I even requested developer access from the Tempest/WeatherFlow team to properly test this provider. **Comprehensive test coverage**: A major advantage of the server-side architecture is the ability to thoroughly test providers with unit tests using real API response snapshots. Don't be alarmed by the many lines added in this PR - they are primarily test files and real-data mocks that ensure provider reliability. ## Review Notes I know this is an enormous change - I've been working on this for quite some time. Unfortunately, breaking it into smaller incremental PRs wasn't feasible due to the interdependencies between providers and the shared architecture. Given the scope, it's nearly impossible to manually review every change. To ensure quality, I've used both CodeRabbit and GitHub Copilot to review the code multiple times in my fork, and both provided extensive and valuable feedback. Most importantly, my test setup with all 10 providers working successfully is very encouraging. ## Related Part of the HTTPFetcher migration #4016. ## Screenshots <img width="1920" height="1080" alt="Ekrankopio de 2026-02-08 13-06-54" src="https://github.com/user-attachments/assets/2139f4d2-2a9b-4e49-8d0a-e4436983ed6e" /> <img width="1920" height="1080" alt="Ekrankopio de 2026-02-08 13-07-02" src="https://github.com/user-attachments/assets/880f7ce2-4e44-42d5-bfe4-5ce475cca7c2" /> <img width="1920" height="1080" alt="Ekrankopio de 2026-02-08 13-07-07" src="https://github.com/user-attachments/assets/abd89933-fe03-40ab-8a7c-41ae1ff99255" /> <img width="1920" height="1080" alt="Ekrankopio de 2026-02-08 13-07-12" src="https://github.com/user-attachments/assets/22225852-f0a9-4d33-87ab-0733ba30fad3" /> <img width="1920" height="1080" alt="Ekrankopio de 2026-02-08 13-07-17" src="https://github.com/user-attachments/assets/7a7192a5-f237-4060-85d7-6f50b9bef5af" /> <img width="1920" height="1080" alt="Ekrankopio de 2026-02-08 13-07-22" src="https://github.com/user-attachments/assets/df84d9f1-e531-4995-8da8-d6f2601b6a08" /> <img width="1920" height="1080" alt="Ekrankopio de 2026-02-08 13-07-27" src="https://github.com/user-attachments/assets/4cf391ac-db43-4b52-95f4-f5eadc5ea34d" /> <img width="1920" height="1080" alt="Ekrankopio de 2026-02-08 13-07-32" src="https://github.com/user-attachments/assets/8dd8e688-d47f-4815-87f6-7f2630f15d58" /> <img width="1920" height="1080" alt="Ekrankopio de 2026-02-08 13-07-37" src="https://github.com/user-attachments/assets/ee84a8bc-6b35-405a-b311-88658d9268dd" /> <img width="1920" height="1080" alt="Ekrankopio de 2026-02-08 13-07-42" src="https://github.com/user-attachments/assets/f941f341-453f-4d4d-a8d9-6b9158eb2681" /> Provider "Weather API" added later: <img width="1910" height="1080" alt="Ekrankopio de 2026-02-15 19-39-06" src="https://github.com/user-attachments/assets/3f0c8ba3-105c-4f90-8b2e-3a1be543d3d2" />
1 parent 80c4879 commit 8ce0cda

File tree

79 files changed

+28083
-3981
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

79 files changed

+28083
-3981
lines changed

defaultmodules/utils.js

Lines changed: 0 additions & 152 deletions
Original file line numberDiff line numberDiff line change
@@ -1,154 +1,3 @@
1-
/**
2-
* A function to make HTTP requests via the server to avoid CORS-errors.
3-
* @param {string} url the url to fetch from
4-
* @param {string} type what content-type to expect in the response, can be "json" or "xml"
5-
* @param {boolean} useCorsProxy A flag to indicate
6-
* @param {Array.<{name: string, value:string}>} requestHeaders the HTTP headers to send
7-
* @param {Array.<string>} expectedResponseHeaders the expected HTTP headers to receive
8-
* @param {string} basePath The base path, default is "/"
9-
* @returns {Promise} resolved when the fetch is done. The response headers is placed in a headers-property (provided the response does not already contain a headers-property).
10-
*/
11-
async function performWebRequest (url, type = "json", useCorsProxy = false, requestHeaders = undefined, expectedResponseHeaders = undefined, basePath = "/") {
12-
const request = {};
13-
let requestUrl;
14-
if (useCorsProxy) {
15-
requestUrl = getCorsUrl(url, requestHeaders, expectedResponseHeaders, basePath);
16-
} else {
17-
requestUrl = url;
18-
request.headers = getHeadersToSend(requestHeaders);
19-
}
20-
21-
try {
22-
const response = await fetch(requestUrl, request);
23-
if (response.ok) {
24-
const data = await response.text();
25-
26-
if (type === "xml") {
27-
return new DOMParser().parseFromString(data, "text/html");
28-
} else {
29-
if (!data || !data.length > 0) return undefined;
30-
31-
const dataResponse = JSON.parse(data);
32-
if (!dataResponse.headers) {
33-
dataResponse.headers = getHeadersFromResponse(expectedResponseHeaders, response);
34-
}
35-
return dataResponse;
36-
}
37-
} else {
38-
throw new Error(`Response status: ${response.status}`);
39-
}
40-
} catch (error) {
41-
Log.error(`Error fetching data from ${url}: ${error}`);
42-
return undefined;
43-
}
44-
}
45-
46-
/**
47-
* Gets a URL that will be used when calling the CORS-method on the server.
48-
* @param {string} url the url to fetch from
49-
* @param {Array.<{name: string, value:string}>} requestHeaders the HTTP headers to send
50-
* @param {Array.<string>} expectedResponseHeaders the expected HTTP headers to receive
51-
* @param {string} basePath The base path, default is "/"
52-
* @returns {string} to be used as URL when calling CORS-method on server.
53-
*/
54-
const getCorsUrl = function (url, requestHeaders, expectedResponseHeaders, basePath = "/") {
55-
if (!url || url.length < 1) {
56-
throw new Error(`Invalid URL: ${url}`);
57-
} else {
58-
let corsUrl = `${location.protocol}//${location.host}${basePath}cors?`;
59-
60-
const requestHeaderString = getRequestHeaderString(requestHeaders);
61-
if (requestHeaderString) corsUrl = `${corsUrl}sendheaders=${requestHeaderString}`;
62-
63-
const expectedResponseHeadersString = getExpectedResponseHeadersString(expectedResponseHeaders);
64-
if (requestHeaderString && expectedResponseHeadersString) {
65-
corsUrl = `${corsUrl}&expectedheaders=${expectedResponseHeadersString}`;
66-
} else if (expectedResponseHeadersString) {
67-
corsUrl = `${corsUrl}expectedheaders=${expectedResponseHeadersString}`;
68-
}
69-
70-
if (requestHeaderString || expectedResponseHeadersString) {
71-
return `${corsUrl}&url=${url}`;
72-
}
73-
return `${corsUrl}url=${url}`;
74-
}
75-
};
76-
77-
/**
78-
* Gets the part of the CORS URL that represents the HTTP headers to send.
79-
* @param {Array.<{name: string, value:string}>} requestHeaders the HTTP headers to send
80-
* @returns {string} to be used as request-headers component in CORS URL.
81-
*/
82-
const getRequestHeaderString = function (requestHeaders) {
83-
let requestHeaderString = "";
84-
if (requestHeaders) {
85-
for (const header of requestHeaders) {
86-
if (requestHeaderString.length === 0) {
87-
requestHeaderString = `${header.name}:${encodeURIComponent(header.value)}`;
88-
} else {
89-
requestHeaderString = `${requestHeaderString},${header.name}:${encodeURIComponent(header.value)}`;
90-
}
91-
}
92-
return requestHeaderString;
93-
}
94-
return undefined;
95-
};
96-
97-
/**
98-
* Gets headers and values to attach to the web request.
99-
* @param {Array.<{name: string, value:string}>} requestHeaders the HTTP headers to send
100-
* @returns {object} An object specifying name and value of the headers.
101-
*/
102-
const getHeadersToSend = (requestHeaders) => {
103-
const headersToSend = {};
104-
if (requestHeaders) {
105-
for (const header of requestHeaders) {
106-
headersToSend[header.name] = header.value;
107-
}
108-
}
109-
110-
return headersToSend;
111-
};
112-
113-
/**
114-
* Gets the part of the CORS URL that represents the expected HTTP headers to receive.
115-
* @param {Array.<string>} expectedResponseHeaders the expected HTTP headers to receive
116-
* @returns {string} to be used as the expected HTTP-headers component in CORS URL.
117-
*/
118-
const getExpectedResponseHeadersString = function (expectedResponseHeaders) {
119-
let expectedResponseHeadersString = "";
120-
if (expectedResponseHeaders) {
121-
for (const header of expectedResponseHeaders) {
122-
if (expectedResponseHeadersString.length === 0) {
123-
expectedResponseHeadersString = `${header}`;
124-
} else {
125-
expectedResponseHeadersString = `${expectedResponseHeadersString},${header}`;
126-
}
127-
}
128-
return expectedResponseHeaders;
129-
}
130-
return undefined;
131-
};
132-
133-
/**
134-
* Gets the values for the expected headers from the response.
135-
* @param {Array.<string>} expectedResponseHeaders the expected HTTP headers to receive
136-
* @param {Response} response the HTTP response
137-
* @returns {string} to be used as the expected HTTP-headers component in CORS URL.
138-
*/
139-
const getHeadersFromResponse = (expectedResponseHeaders, response) => {
140-
const responseHeaders = [];
141-
142-
if (expectedResponseHeaders) {
143-
for (const header of expectedResponseHeaders) {
144-
const headerValue = response.headers.get(header);
145-
responseHeaders.push({ name: header, value: headerValue });
146-
}
147-
}
148-
149-
return responseHeaders;
150-
};
151-
1521
/**
1532
* Format the time according to the config
1543
* @param {object} config The config of the module
@@ -178,6 +27,5 @@ const formatTime = (config, time) => {
17827
};
17928

18029
if (typeof module !== "undefined") module.exports = {
181-
performWebRequest,
18230
formatTime
18331
};

defaultmodules/weather/current.njk

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
{% if config.showHumidity === "wind" %}
2626
{{ humidity() }}
2727
{% endif %}
28-
{% if config.showSun %}
28+
{% if config.showSun and current.nextSunAction() %}
2929
<span class="wi dimmed wi-{{ current.nextSunAction() }}"></span>
3030
<span>
3131
{% if current.nextSunAction() === "sunset" %}
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
const path = require("node:path");
2+
const NodeHelper = require("node_helper");
3+
const Log = require("logger");
4+
5+
module.exports = NodeHelper.create({
6+
providers: {},
7+
8+
start () {
9+
Log.log(`Starting node helper for: ${this.name}`);
10+
},
11+
12+
socketNotificationReceived (notification, payload) {
13+
if (notification === "INIT_WEATHER") {
14+
Log.log(`Received INIT_WEATHER for instance ${payload.instanceId}`);
15+
this.initWeatherProvider(payload);
16+
} else if (notification === "STOP_WEATHER") {
17+
Log.log(`Received STOP_WEATHER for instance ${payload.instanceId}`);
18+
this.stopWeatherProvider(payload.instanceId);
19+
}
20+
// FETCH_WEATHER is no longer needed - HTTPFetcher handles periodic fetching
21+
},
22+
23+
/**
24+
* Initialize a weather provider
25+
* @param {object} config The configuration object
26+
*/
27+
async initWeatherProvider (config) {
28+
const identifier = config.weatherProvider.toLowerCase();
29+
const instanceId = config.instanceId;
30+
31+
Log.log(`Attempting to initialize provider ${identifier} for instance ${instanceId}`);
32+
33+
if (this.providers[instanceId]) {
34+
Log.log(`Weather provider ${identifier} already initialized for instance ${instanceId}`);
35+
return;
36+
}
37+
38+
try {
39+
// Dynamically load the provider module
40+
const providerPath = path.join(__dirname, "providers", `${identifier}.js`);
41+
Log.log(`Loading provider from: ${providerPath}`);
42+
const ProviderClass = require(providerPath);
43+
44+
// Create provider instance
45+
const provider = new ProviderClass(config);
46+
47+
// Set up callbacks before initializing
48+
provider.setCallbacks(
49+
(data) => {
50+
// On data received
51+
this.sendSocketNotification("WEATHER_DATA", {
52+
instanceId,
53+
type: config.type,
54+
data
55+
});
56+
},
57+
(errorInfo) => {
58+
// On error
59+
this.sendSocketNotification("WEATHER_ERROR", {
60+
instanceId,
61+
error: errorInfo.message || "Unknown error",
62+
translationKey: errorInfo.translationKey
63+
});
64+
}
65+
);
66+
67+
await provider.initialize();
68+
this.providers[instanceId] = provider;
69+
70+
this.sendSocketNotification("WEATHER_INITIALIZED", {
71+
instanceId,
72+
locationName: provider.locationName
73+
});
74+
75+
// Start periodic fetching
76+
provider.start();
77+
78+
Log.log(`Weather provider ${identifier} initialized for instance ${instanceId}`);
79+
} catch (error) {
80+
Log.error(`Failed to initialize weather provider ${identifier}:`, error);
81+
this.sendSocketNotification("WEATHER_ERROR", {
82+
instanceId,
83+
error: error.message
84+
});
85+
}
86+
},
87+
88+
/**
89+
* Stop and cleanup a weather provider
90+
* @param {string} instanceId The instance identifier
91+
*/
92+
stopWeatherProvider (instanceId) {
93+
const provider = this.providers[instanceId];
94+
95+
if (provider) {
96+
Log.log(`Stopping weather provider for instance ${instanceId}`);
97+
provider.stop();
98+
delete this.providers[instanceId];
99+
} else {
100+
Log.warn(`No provider found for instance ${instanceId}`);
101+
}
102+
}
103+
});

0 commit comments

Comments
 (0)