Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
58 changes: 44 additions & 14 deletions src/services/midea/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,18 @@ 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
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 ──────────────────────────────────────────────────────────

Expand Down Expand Up @@ -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 () => {
Expand Down
19 changes: 19 additions & 0 deletions tests/midea_devices.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
Loading