fix(weather): avoid loading state after reconnect (#4121)

When a client reconnects while the backend is still in its rate-limit
protection phase, the weather module has no data to show and stays on
`Loading...` until the next scheduled API call. This mainly affects
server mode setups, where the server keeps running while a remote client
temporarily loses its connection and reloads. It was [raised in the
forum](https://forum.magicmirror.builders/topic/20218/request-loop-loading...-in-standard-weather-module-open-meteo-after-update/11?_=1777106416020)
and is worthy of a fix to improve the user experience.

With this PR the node helper caches the last successful `WEATHER_DATA`
payload per instance and replays it immediately on reconnect. The client
gets its last known state right away instead of waiting for the next
fetch. The cache is cleaned up when the provider stops.

Tests are included to cover reconnect with and without cached data, and
the cleanup path.
This commit is contained in:
Kristjan ESPERANTO
2026-04-27 23:15:01 +02:00
committed by GitHub
parent b8548f2203
commit 4c2a373ae3
2 changed files with 125 additions and 5 deletions

View File

@@ -4,6 +4,7 @@ const Log = require("logger");
module.exports = NodeHelper.create({
providers: {},
lastData: {},
start () {
Log.log(`Starting node helper for: ${this.name}`);
@@ -37,6 +38,10 @@ module.exports = NodeHelper.create({
instanceId,
locationName: this.providers[instanceId].locationName
});
// Push cached data immediately so reconnecting clients don't wait for next scheduled fetch
if (this.lastData[instanceId]) {
this.sendSocketNotification("WEATHER_DATA", this.lastData[instanceId]);
}
return;
}
@@ -53,11 +58,9 @@ module.exports = NodeHelper.create({
provider.setCallbacks(
(data) => {
// On data received
this.sendSocketNotification("WEATHER_DATA", {
instanceId,
type: config.type,
data
});
const payload = { instanceId, type: config.type, data };
this.lastData[instanceId] = payload;
this.sendSocketNotification("WEATHER_DATA", payload);
},
(errorInfo) => {
// On error
@@ -101,6 +104,7 @@ module.exports = NodeHelper.create({
Log.log(`Stopping weather provider for instance ${instanceId}`);
provider.stop();
delete this.providers[instanceId];
delete this.lastData[instanceId];
} else {
Log.warn(`No provider found for instance ${instanceId}`);
}

View File

@@ -0,0 +1,116 @@
import Module from "node:module";
import { afterEach, describe, expect, it, vi } from "vitest";
/**
* Creates a fresh weather node helper instance with isolated mocks.
* @returns {Promise<object>} The mocked weather node helper.
*/
async function loadWeatherNodeHelper () {
vi.resetModules();
const loggerMock = {
log: vi.fn(),
warn: vi.fn(),
error: vi.fn()
};
const originalRequire = Module.prototype.require;
Module.prototype.require = function (id) {
if (id === "node_helper") {
return {
create: vi.fn((definition) => definition)
};
}
if (id === "logger") {
return loggerMock;
}
return originalRequire.apply(this, arguments);
};
let helper;
try {
const helperModule = await import("../../../../../defaultmodules/weather/node_helper");
helper = helperModule.default || helperModule;
} finally {
Module.prototype.require = originalRequire;
}
helper.providers = {};
helper.lastData = {};
helper.sendSocketNotification = vi.fn();
return helper;
}
afterEach(() => {
vi.resetAllMocks();
vi.resetModules();
});
describe("weather node_helper reconnect handling", () => {
it("re-sends cached weather data when a client reconnects", async () => {
const helper = await loadWeatherNodeHelper();
const instanceId = "weather-current";
const cachedPayload = {
instanceId,
type: "current",
data: { temperature: 8.5 }
};
helper.providers[instanceId] = { locationName: "Munich, BY" };
helper.lastData[instanceId] = cachedPayload;
await helper.initWeatherProvider({
weatherProvider: "openmeteo",
instanceId,
type: "current"
});
expect(helper.sendSocketNotification).toHaveBeenNthCalledWith(1, "WEATHER_INITIALIZED", {
instanceId,
locationName: "Munich, BY"
});
expect(helper.sendSocketNotification).toHaveBeenNthCalledWith(2, "WEATHER_DATA", cachedPayload);
expect(helper.sendSocketNotification).toHaveBeenCalledTimes(2);
});
it("does not send WEATHER_DATA on reconnect when no cached payload exists", async () => {
const helper = await loadWeatherNodeHelper();
const instanceId = "weather-current";
helper.providers[instanceId] = { locationName: "Munich, BY" };
await helper.initWeatherProvider({
weatherProvider: "openmeteo",
instanceId,
type: "current"
});
expect(helper.sendSocketNotification).toHaveBeenCalledWith("WEATHER_INITIALIZED", {
instanceId,
locationName: "Munich, BY"
});
expect(helper.sendSocketNotification).toHaveBeenCalledTimes(1);
});
it("cleans up provider and cached data when stopping an instance", async () => {
const helper = await loadWeatherNodeHelper();
const instanceId = "weather-current";
const stop = vi.fn();
helper.providers[instanceId] = { stop };
helper.lastData[instanceId] = {
instanceId,
type: "current",
data: { temperature: 8.5 }
};
helper.stopWeatherProvider(instanceId);
expect(stop).toHaveBeenCalledTimes(1);
expect(helper.providers[instanceId]).toBeUndefined();
expect(helper.lastData[instanceId]).toBeUndefined();
});
});