diff --git a/CHANGELOG.md b/CHANGELOG.md index 73b217c2..a0c024fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## [Unreleased] + +### Änderungen +- Midea: Cloud-Gerätezustand wird bis zu 90 s aus dem Cache ausgeliefert (sofortiges Laden von `/midea` und Portal-Widget, Aktualisierung im Hintergrund) statt bei jedem Aufruf einen Cloud-Roundtrip/Re-Login auszulösen + +--- + ## [1.115.0] — 2026-07-03 ### Features diff --git a/src/services/midea/index.js b/src/services/midea/index.js index e5cf580e..581c1757 100644 --- a/src/services/midea/index.js +++ b/src/services/midea/index.js @@ -10,6 +10,10 @@ const logger = require('../../utils/logger'); const FEATURE = 'midea_integration'; const POLL_INTERVAL_MS = 30000; +// Cloud state is served from cache within this window (instant page/widget load, +// no cloud round-trip, no re-login); beyond it the cached state is still served +// but a background refresh is kicked off. LAN devices are local/fast and unaffected. +const CLOUD_STATE_TTL_MS = 90000; const cache = new Map(); // deviceId -> { state, online, lastAt } const locks = new Map(); // deviceId -> Promise chain tail @@ -17,6 +21,7 @@ const lockDepth = new Map(); // deviceId -> active operation count let pollTimer = null; let lastPollAt = null; let cloudNeedsReauth = false; // set true on 2FA error, cleared on successful cloud command +const cloudRefreshInFlight = new Set(); // device ids with a background cloud refresh running (dedupe) // ── Per-device mutex ────────────────────────────────────────────────────────── @@ -48,25 +53,50 @@ function lanFor(d) { // ── Device state operations ─────────────────────────────────────────────────── +// Fetch live cloud state, update the cache, and return the parsed state (or +// {offline:true} on failure). This is the ONLY place that hits the cloud for state. +function fetchCloudState(id, d) { + return withDeviceLock(id, async () => { + try { + const resp = await withCloud((c) => c.sendCommand(d.cloud_appliance_id, mideaAc.buildQuery())); + const state = mideaAc.parseState(resp); + cloudNeedsReauth = false; + cache.set(id, { state, online: true, lastAt: Date.now() }); + return state; + } catch (err) { + if (err.code === 'MIDEA_CLOUD_2FA_REQUIRED') cloudNeedsReauth = true; + logger.debug({ err: err.message, id }, 'midea getState (cloud) failed (offline)'); + cache.set(id, { state: null, online: false, lastAt: Date.now() }); + return { offline: true }; + } + }); +} + +// Kick a single background refresh for a stale-but-usable cache entry. Deduped +// per device so overlapping page/widget loads don't stack cloud calls. +function refreshCloudState(id, d) { + if (cloudRefreshInFlight.has(id)) return; + cloudRefreshInFlight.add(id); + Promise.resolve() + .then(() => fetchCloudState(id, d)) + .catch(() => {}) + .finally(() => cloudRefreshInFlight.delete(id)); +} + async function getState(id) { const d = devices.getDevice(id); if (!d) throw new Error('device not found'); if (d.transport === 'cloud') { - return withDeviceLock(id, async () => { - try { - const resp = await withCloud((c) => c.sendCommand(d.cloud_appliance_id, mideaAc.buildQuery())); - const state = mideaAc.parseState(resp); - cloudNeedsReauth = false; - cache.set(id, { state, online: true, lastAt: Date.now() }); - return state; - } catch (err) { - if (err.code === 'MIDEA_CLOUD_2FA_REQUIRED') cloudNeedsReauth = true; - logger.debug({ err: err.message, id }, 'midea getState (cloud) failed (offline)'); - cache.set(id, { state: null, online: false, lastAt: Date.now() }); - return { offline: true }; - } - }); + const cached = cache.get(id); + // Serve a known-online cached state instantly — no cloud round-trip, no + // re-login. Beyond the TTL still serve it, but refresh in the background. + if (cached && cached.online && cached.state) { + if (Date.now() - cached.lastAt >= CLOUD_STATE_TTL_MS) refreshCloudState(id, d); + return cached.state; + } + // No usable cache (first load / last known offline) → fetch synchronously. + return fetchCloudState(id, d); } return withDeviceLock(id, async () => { diff --git a/tests/midea_devices.test.js b/tests/midea_devices.test.js index a4c29dd8..dafb6b77 100644 --- a/tests/midea_devices.test.js +++ b/tests/midea_devices.test.js @@ -173,6 +173,25 @@ test('getState for a cloud device routes through mideaCloud.sendCommand, not Lan } }); +test('getState for a cloud device serves cached state within TTL (no second cloud round-trip / re-login)', async () => { + const midea = require('../src/services/midea'); + const cloud = require('../src/services/midea/mideaCloud'); + preconfigureCloudSession(); + const d = devices.createDevice({ name: 'C3', device_sn: 'c-3', transport: 'cloud', cloud_appliance_id: '555' }); + let calls = 0; + const orig = cloud.MideaCloud.prototype.sendCommand; + cloud.MideaCloud.prototype.sendCommand = async function () { calls += 1; return CLOUD_SAMPLE; }; + try { + const a = await midea.getState(d.id); // cold → one cloud round-trip, populates cache + const b = await midea.getState(d.id); // warm → served from cache, no cloud call + assert.equal(calls, 1, 'second getState within TTL must not hit the cloud'); + assert.deepEqual(b, a); // identical state returned from cache + } finally { + cloud.MideaCloud.prototype.sendCommand = orig; + midea.stopPolling(); + } +}); + test('setState for a cloud device does inline read-modify-write via sendCommand, not LanDevice', async () => { const midea = require('../src/services/midea'); const cloud = require('../src/services/midea/mideaCloud');