diff --git a/CHANGELOG.md b/CHANGELOG.md
index a882954e..8894dfcf 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,12 @@
# Changelog
+## [Unreleased]
+
+### Features
+- Midea-Klimasteuerung (TP1): nativer LAN-Protokoll-Port (Discovery/V2/V3) + Midea-Cloud-Login, Admin-Seite `/midea` zum Verbinden, Entdecken und Live-Testen von Klimageräten. Lizenz-gegated über `midea_integration`.
+
+---
+
## [1.101.1] — 2026-06-26
### Änderungen
diff --git a/public/js/midea.js b/public/js/midea.js
new file mode 100644
index 00000000..8d129bc5
--- /dev/null
+++ b/public/js/midea.js
@@ -0,0 +1,114 @@
+'use strict';
+(function () {
+ const GC = window.GC || {};
+ const headers = { 'Content-Type': 'application/json', 'x-csrf-token': GC.csrfToken };
+ const T = (k) => (GC.t && GC.t[k]) || k;
+ function esc(s) {
+ return String(s == null ? '' : s).replace(/[&<>"']/g, (c) =>
+ ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
+ }
+
+ async function api(method, path, body) {
+ const res = await fetch('/api/v1/midea' + path, {
+ method, headers, body: body ? JSON.stringify(body) : undefined,
+ });
+ const json = await res.json().catch(() => ({}));
+ if (!res.ok) throw Object.assign(new Error(json.error || res.statusText), { code: json.code });
+ return json;
+ }
+
+ const $ = (sel) => document.querySelector(sel);
+
+ async function loadDevices() {
+ const { devices } = await api('GET', '/devices');
+ const el = $('#midea-devices');
+ if (!devices.length) { el.innerHTML = `
${T('midea.devices.none')}
`; return; }
+ el.innerHTML = devices.map((d) => `
+
+ ${esc(d.name)} ${esc(d.ip || '')} · v${d.protocol_version}
+
+
+
+
+
+
+
`).join('');
+ }
+
+ async function refreshState(id, row) {
+ try {
+ const { state } = await api('GET', `/devices/${id}/state`);
+ row.querySelector('.device-state').textContent = state.offline
+ ? T('midea.device.offline')
+ : `${state.power ? T('midea.device.on') : T('midea.device.off')} · ${T('midea.device.indoor')} ${state.indoorTemp}° · → ${state.targetTemp}° · ${T('midea.mode.' + state.mode)}`;
+ } catch (e) { row.querySelector('.device-state').textContent = e.message; }
+ }
+
+ document.addEventListener('click', async (ev) => {
+ const btn = ev.target.closest('button[data-act]');
+ if (!btn) return;
+ const row = btn.closest('.device-row');
+ const id = row.dataset.id;
+ try {
+ if (btn.dataset.act === 'test') { await api('POST', `/devices/${id}/test`); await refreshState(id, row); }
+ if (btn.dataset.act === 'power') {
+ const { state } = await api('GET', `/devices/${id}/state`);
+ await api('POST', `/devices/${id}/state`, { patch: { power: !(state && state.power) } });
+ await refreshState(id, row);
+ }
+ if (btn.dataset.act === 'remove') { await api('DELETE', `/devices/${id}`); await loadDevices(); }
+ } catch (e) { alert(e.message); }
+ });
+
+ document.addEventListener('change', async (ev) => {
+ const ctrl = ev.target.closest('[data-act="target"],[data-act="mode"]');
+ if (!ctrl) return;
+ const row = ctrl.closest('.device-row'); const id = row.dataset.id;
+ const patch = ctrl.dataset.act === 'target' ? { targetTemp: Number(ctrl.value) } : { mode: ctrl.value };
+ try { await api('POST', `/devices/${id}/state`, { patch }); await refreshState(id, row); }
+ catch (e) { alert(e.message); }
+ });
+
+ $('#midea-cloud-form').addEventListener('submit', async (ev) => {
+ ev.preventDefault();
+ const f = ev.target;
+ const msg = $('#midea-cloud-msg');
+ msg.textContent = '…';
+ try {
+ await api('POST', '/cloud/connect', { app: f.app.value, email: f.email.value, password: f.password.value });
+ msg.textContent = T('midea.cloud.connected');
+ await loadCloudDevices();
+ } catch (e) {
+ msg.textContent = e.code === 'MIDEA_CLOUD_2FA_REQUIRED' ? T('midea.cloud.twofa') : e.message;
+ }
+ });
+
+ async function loadCloudDevices() {
+ try {
+ const { devices } = await api('GET', '/cloud/devices');
+ $('#midea-cloud-list').innerHTML = devices.map((d) => `
+
+ ${esc(d.name)} ${esc(d.sn)}
+
+
`).join('');
+ } catch { /* not connected yet */ }
+ }
+
+ document.addEventListener('click', async (ev) => {
+ const add = ev.target.closest('button[data-add]');
+ if (!add) return;
+ add.disabled = true;
+ try { await api('POST', '/devices', { sn: add.dataset.add, name: add.dataset.name }); await loadDevices(); }
+ catch (e) { alert(e.message); } finally { add.disabled = false; }
+ });
+
+ $('#midea-discover').addEventListener('click', async () => {
+ try { const { devices } = await api('POST', '/discover'); alert(`${devices.length} ${T('midea.discover.result')}`); }
+ catch (e) { alert(e.message); }
+ });
+
+ loadDevices();
+ loadCloudDevices();
+})();
diff --git a/src/db/migrationList.js b/src/db/migrationList.js
index 62e1184f..1d60096e 100644
--- a/src/db/migrationList.js
+++ b/src/db/migrationList.js
@@ -1058,6 +1058,28 @@ const migrations = [
`,
detect: (db) => hasColumn(db, 'peers', 'user_id'),
},
+ {
+ version: 60,
+ name: 'create_midea_devices',
+ sql: `CREATE TABLE IF NOT EXISTS midea_devices (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ name TEXT NOT NULL,
+ device_sn TEXT NOT NULL UNIQUE,
+ device_id TEXT,
+ ip TEXT,
+ port INTEGER NOT NULL DEFAULT 6444,
+ protocol_version INTEGER NOT NULL DEFAULT 3,
+ token_enc TEXT,
+ key_enc TEXT,
+ model TEXT,
+ enabled INTEGER NOT NULL DEFAULT 1,
+ last_seen_at TEXT,
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
+ );
+ CREATE INDEX IF NOT EXISTS idx_midea_enabled ON midea_devices(enabled);`,
+ detect: (db) => !!db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='midea_devices'").get(),
+ },
];
module.exports = { migrations };
diff --git a/src/i18n/de.json b/src/i18n/de.json
index f57e2a20..19ac4723 100644
--- a/src/i18n/de.json
+++ b/src/i18n/de.json
@@ -2040,5 +2040,37 @@
"settings.domains.server_ip_override": "Server-IP überschreiben",
"settings.domains.server_ip_warning": "Keine Domain konnte gegen die Server-IP bestätigt werden — bitte Server-IP prüfen/überschreiben. Bestehende Routen laufen weiter.",
"settings.domains.points_note": "Verifiziert heißt: öffentliches DNS zeigt auf die Server-IP. Bestehende Routen/Portal laufen unabhängig weiter.",
- "settings.domains.in_use_portal": "Domain wird vom Portal genutzt — bitte zuerst die Portal-Adresse ändern."
+ "settings.domains.in_use_portal": "Domain wird vom Portal genutzt — bitte zuerst die Portal-Adresse ändern.",
+ "nav.midea": "Klimaanlage",
+ "midea.title": "Klimaanlage",
+ "midea.subtitle": "Midea-Klimageräte im Heimnetz steuern",
+ "midea.cloud.title": "Midea-Konto",
+ "midea.cloud.app": "App",
+ "midea.cloud.email": "E-Mail",
+ "midea.cloud.password": "Passwort",
+ "midea.cloud.connect": "Verbinden",
+ "midea.cloud.connected": "Konto verbunden",
+ "midea.cloud.twofa": "Zwei-Faktor/Captcha nötig — in der Midea-App bestätigen, dann erneut versuchen",
+ "midea.cloud.ratelimit": "Zu viele Anfragen — kurz warten und erneut versuchen",
+ "midea.discover": "Geräte entdecken",
+ "midea.discover.result": "Gerät(e) gefunden",
+ "midea.devices.title": "Geräte",
+ "midea.devices.add": "Hinzufügen",
+ "midea.devices.none": "Noch keine Geräte",
+ "midea.device.test": "Testen",
+ "midea.device.power": "Ein/Aus",
+ "midea.device.target": "Zieltemperatur",
+ "midea.device.indoor": "Innen",
+ "midea.device.mode": "Modus",
+ "midea.device.remove": "Entfernen",
+ "midea.device.offline": "Offline",
+ "midea.device.on": "Ein",
+ "midea.device.off": "Aus",
+ "midea.mode.auto": "Automatik",
+ "midea.mode.cool": "Kühlen",
+ "midea.mode.heat": "Heizen",
+ "midea.mode.dry": "Entfeuchten",
+ "midea.mode.fan": "Lüften",
+ "error.midea.email_password_required": "E-Mail und Passwort sind erforderlich",
+ "error.midea.sn_or_ip_required": "Geräte-SN oder IP ist erforderlich"
}
diff --git a/src/i18n/en.json b/src/i18n/en.json
index 91c0e2c8..80deee77 100644
--- a/src/i18n/en.json
+++ b/src/i18n/en.json
@@ -2040,5 +2040,37 @@
"settings.domains.server_ip_override": "Override server IP",
"settings.domains.server_ip_warning": "Could not confirm any domain against the server IP — please check/override the server IP. Existing routes keep working.",
"settings.domains.points_note": "Verified means: public DNS points to this server's IP. Existing routes/portal keep working regardless.",
- "settings.domains.in_use_portal": "Domain is in use by the portal — change the portal address first."
+ "settings.domains.in_use_portal": "Domain is in use by the portal — change the portal address first.",
+ "nav.midea": "Air Conditioning",
+ "midea.title": "Air Conditioning",
+ "midea.subtitle": "Control Midea climate devices on your LAN",
+ "midea.cloud.title": "Midea account",
+ "midea.cloud.app": "App",
+ "midea.cloud.email": "Email",
+ "midea.cloud.password": "Password",
+ "midea.cloud.connect": "Connect",
+ "midea.cloud.connected": "Account connected",
+ "midea.cloud.twofa": "Two-factor/captcha required — complete it in the Midea app, then retry",
+ "midea.cloud.ratelimit": "Rate limited — wait a moment and retry",
+ "midea.discover": "Discover devices",
+ "midea.discover.result": "device(s) found",
+ "midea.devices.title": "Devices",
+ "midea.devices.add": "Add",
+ "midea.devices.none": "No devices yet",
+ "midea.device.test": "Test",
+ "midea.device.power": "Power",
+ "midea.device.target": "Target temperature",
+ "midea.device.indoor": "Indoor",
+ "midea.device.mode": "Mode",
+ "midea.device.remove": "Remove",
+ "midea.device.offline": "Offline",
+ "midea.device.on": "On",
+ "midea.device.off": "Off",
+ "midea.mode.auto": "Auto",
+ "midea.mode.cool": "Cool",
+ "midea.mode.heat": "Heat",
+ "midea.mode.dry": "Dry",
+ "midea.mode.fan": "Fan",
+ "error.midea.email_password_required": "Email and password are required",
+ "error.midea.sn_or_ip_required": "Device SN or IP is required"
}
diff --git a/src/routes/api/index.js b/src/routes/api/index.js
index 083cfce2..f20795b9 100644
--- a/src/routes/api/index.js
+++ b/src/routes/api/index.js
@@ -48,5 +48,6 @@ router.use('/license', require('./license'));
router.use('/client', require('./client'));
router.use('/rdp', require('./rdp'));
router.use('/pihole', require('./pihole'));
+router.use('/midea', require('./midea'));
module.exports = router;
diff --git a/src/routes/api/midea.js b/src/routes/api/midea.js
new file mode 100644
index 00000000..f7bef043
--- /dev/null
+++ b/src/routes/api/midea.js
@@ -0,0 +1,107 @@
+'use strict';
+
+const { Router } = require('express');
+const { requireFeature } = require('../../middleware/license');
+const users = require('../../services/users');
+const midea = require('../../services/midea');
+
+const router = Router();
+
+// Admin-only (Spec §9): reject token auth, require an admin session.
+// Guard order: admin check FIRST, then requireFeature — mirrors routes/api/users.js lines 35-50.
+router.use((req, res, next) => {
+ if (req.tokenAuth) {
+ return res.status(403).json({ ok: false, error: req.t('error.users.session_required') });
+ }
+
+ if (!req.session || !req.session.userId) {
+ return res.status(401).json({ ok: false, error: req.t('error.users.unauthorized') });
+ }
+
+ const user = users.getById(req.session.userId);
+ if (!user || user.role !== 'admin') {
+ return res.status(403).json({ ok: false, error: req.t('error.users.admin_required') });
+ }
+
+ next();
+});
+
+router.use(requireFeature('midea_integration'));
+
+// Async error mapper: maps typed error codes and messages to HTTP status codes.
+function wrap(fn) {
+ return async (req, res) => {
+ try {
+ await fn(req, res);
+ } catch (err) {
+ const status =
+ err.code === 'MIDEA_CLOUD_2FA_REQUIRED' ? 409
+ : err.code === 'MIDEA_DEVICE_EXISTS' ? 409
+ : err.code === 'MIDEA_CLOUD_RATE_LIMITED' ? 429
+ : /not found/i.test(err.message) ? 404
+ : 502;
+ res.status(status).json({ ok: false, error: err.message, code: err.code || null });
+ }
+ };
+}
+
+// POST /cloud/connect — authenticate with Midea cloud
+router.post('/cloud/connect', wrap(async (req, res) => {
+ const { email, password, app } = req.body || {};
+ if (!email || !password) {
+ return res.status(400).json({ ok: false, error: req.t('error.midea.email_password_required') });
+ }
+ res.json(await midea.connectCloud(email, password, app || 'msmarthome'));
+}));
+
+// GET /cloud/devices — list devices from cloud account
+router.get('/cloud/devices', wrap(async (req, res) => {
+ res.json({ devices: await midea.listCloudDevices() });
+}));
+
+// POST /discover — LAN discovery scan
+router.post('/discover', wrap(async (req, res) => {
+ res.json({ devices: await midea.discoverLan({}) });
+}));
+
+// POST /devices — add a device (sn or ip required)
+router.post('/devices', wrap(async (req, res) => {
+ const { sn, name, ip } = req.body || {};
+ if (!sn && !ip) {
+ return res.status(400).json({ ok: false, error: req.t('error.midea.sn_or_ip_required') });
+ }
+ res.json({ device: await midea.addDevice({ sn, name, ip }) });
+}));
+
+// GET /devices — list all devices (redacted: no token/key)
+router.get('/devices', wrap(async (req, res) => {
+ res.json({ devices: midea.getDevices() });
+}));
+
+// GET /devices/:id/state — fetch live state from device
+router.get('/devices/:id/state', wrap(async (req, res) => {
+ res.json({ state: await midea.getState(Number(req.params.id)) });
+}));
+
+// POST /devices/:id/state — push state patch to device
+router.post('/devices/:id/state', wrap(async (req, res) => {
+ const patch = (req.body && req.body.patch) || req.body || {};
+ res.json({ state: await midea.setState(Number(req.params.id), patch) });
+}));
+
+// POST /devices/:id/test — connectivity test
+router.post('/devices/:id/test', wrap(async (req, res) => {
+ res.json(await midea.testConnection(Number(req.params.id)));
+}));
+
+// DELETE /devices/:id — remove device
+router.delete('/devices/:id', wrap(async (req, res) => {
+ res.json(midea.removeDevice(Number(req.params.id)));
+}));
+
+// GET /status — orchestrator status (all devices + lastPollAt)
+router.get('/status', wrap(async (req, res) => {
+ res.json(midea.getStatus());
+}));
+
+module.exports = router;
diff --git a/src/routes/index.js b/src/routes/index.js
index 08210217..3d2d1aac 100644
--- a/src/routes/index.js
+++ b/src/routes/index.js
@@ -193,6 +193,7 @@ const pages = [
{ path: '/users', template: 'users', titleKey: 'nav.users' },
{ path: '/dns', template: 'dns', titleKey: 'nav.dns' },
{ path: '/pihole', template: 'pihole', titleKey: 'pihole.title' },
+ { path: '/midea', template: 'midea', titleKey: 'midea.title' },
{ path: '/gateway-pools', template: 'gateway-pools', titleKey: 'gateway_pools.title' },
{ path: '/gateways', template: 'gateways', titleKey: 'nav.gateways' },
];
diff --git a/src/server.js b/src/server.js
index 772e4ce5..050b9b2b 100644
--- a/src/server.js
+++ b/src/server.js
@@ -135,6 +135,10 @@ async function start() {
try { require('./services/pihole').start(); }
catch (err) { logger.warn({ err: err.message }, 'pihole start failed'); }
+ // Midea poll loop — best-effort; no-op without license or enrolled devices.
+ try { require('./services/midea').startPolling(); }
+ catch (err) { logger.warn({ err: err.message }, 'midea start failed'); }
+
// Internal DNS — rebuild the addn-hosts file on boot so route domains
// resolve to the gateway immediately. Without this, the file only gets
// its route A-records on the next peer/route mutation, leaving internal
diff --git a/src/services/license.js b/src/services/license.js
index ffafcb4c..ba0617ac 100644
--- a/src/services/license.js
+++ b/src/services/license.js
@@ -47,6 +47,7 @@ const COMMUNITY_FALLBACK = {
split_tunnel_preset: false,
internal_dns: false,
pihole_integration: false,
+ midea_integration: false,
gateway_peers: 1,
gateway_http_targets: 3,
gateway_tcp_routing: false,
diff --git a/src/services/midea/index.js b/src/services/midea/index.js
new file mode 100644
index 00000000..591c58a3
--- /dev/null
+++ b/src/services/midea/index.js
@@ -0,0 +1,267 @@
+'use strict';
+
+const devices = require('./mideaDevices');
+const { LanDevice, discover } = require('./mideaLan');
+const { MideaCloud } = require('./mideaCloud');
+const eventBus = require('../eventBus');
+const license = require('../license');
+const logger = require('../../utils/logger');
+
+const FEATURE = 'midea_integration';
+const POLL_INTERVAL_MS = 30000;
+
+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;
+
+// ── Per-device mutex ──────────────────────────────────────────────────────────
+
+function withDeviceLock(id, fn) {
+ const prev = locks.get(id) || Promise.resolve();
+ lockDepth.set(id, (lockDepth.get(id) || 0) + 1);
+ const done = () => {
+ const n = (lockDepth.get(id) || 1) - 1;
+ if (n <= 0) lockDepth.delete(id); else lockDepth.set(id, n);
+ };
+ const next = prev.then(fn, fn);
+ next.then(done, done);
+ locks.set(id, next.catch(() => {}));
+ return next;
+}
+
+// ── LAN helpers ───────────────────────────────────────────────────────────────
+
+function lanFor(d) {
+ return new LanDevice({
+ ip: d.ip,
+ port: d.port,
+ deviceId: d.device_id,
+ protocolVersion: d.protocol_version,
+ token: d.token,
+ key: d.key,
+ });
+}
+
+// ── Device state operations ───────────────────────────────────────────────────
+
+async function getState(id) {
+ const d = devices.getDevice(id);
+ if (!d) throw new Error('device not found');
+ return withDeviceLock(id, async () => {
+ try {
+ const state = await lanFor(d).getState();
+ cache.set(id, { state, online: true, lastAt: Date.now() });
+ devices.updateDevice(id, { last_seen_at: new Date().toISOString() });
+ return state;
+ } catch (err) {
+ logger.debug({ err: err.message, id }, 'midea getState failed (offline)');
+ cache.set(id, { state: null, online: false, lastAt: Date.now() });
+ return { offline: true };
+ }
+ });
+}
+
+async function setState(id, patch) {
+ const d = devices.getDevice(id);
+ if (!d) throw new Error('device not found');
+ return withDeviceLock(id, async () => {
+ const state = await lanFor(d).setState(patch);
+ cache.set(id, { state, online: true, lastAt: Date.now() });
+ eventBus.publish('midea:state', { deviceId: id, state });
+ return state;
+ });
+}
+
+async function testConnection(id) {
+ const d = devices.getDevice(id);
+ if (!d) throw new Error('device not found');
+ const t0 = Date.now();
+ const state = await withDeviceLock(id, () => lanFor(d).getState());
+ return { ok: true, version: d.protocol_version, latencyMs: Date.now() - t0, state };
+}
+
+// ── Cloud operations ──────────────────────────────────────────────────────────
+
+async function discoverLan(opts) { return discover(opts || {}); }
+
+async function connectCloud(email, password, app = 'msmarthome') {
+ const c = new MideaCloud(app);
+ const res = await c.login(email, password); // throws typed MideaCloudError (e.g. 2FA)
+ devices.saveConfig({ app, email, password, session: c.getSession() });
+ return res;
+}
+
+function cloudFromConfig() {
+ const cfg = devices.loadConfig();
+ if (!cfg.email) throw new Error('cloud not configured');
+ const c = new MideaCloud(cfg.app);
+ if (cfg.session) c.setSession(cfg.session);
+ return { c, cfg };
+}
+
+async function withCloud(fn) {
+ const { c, cfg } = cloudFromConfig();
+ const ensure = async () => {
+ if (!c.getSession()) {
+ await c.login(cfg.email, cfg.password);
+ devices.saveConfig({ ...cfg, session: c.getSession() });
+ }
+ };
+ await ensure();
+ try {
+ return await fn(c, cfg);
+ } catch (e) {
+ if (e.code === 'MIDEA_CLOUD_ERROR') {
+ c.setSession(null);
+ await ensure();
+ return fn(c, cfg);
+ }
+ throw e;
+ }
+}
+
+async function listCloudDevices() {
+ return withCloud((c) => c.listDevices());
+}
+
+// ── Add device (V3 transactional: token fetched BEFORE persistence) ───────────
+
+async function addDevice({ sn, name, ip }) {
+ if (!sn && !ip) throw new Error('sn or ip required');
+
+ // Duplicate pre-check BEFORE expensive cloud calls
+ if (sn && devices.listDevices().some((x) => x.device_sn === sn)) {
+ const e = new Error('device already added');
+ e.code = 'MIDEA_DEVICE_EXISTS';
+ throw e;
+ }
+
+ // LAN-only onboarding (no sn): discover by IP
+ let info = null;
+ if (!sn && ip) {
+ const found = await discover({});
+ info = found.find((f) => f.ip === ip) || null;
+ }
+
+ // Cloud resolution (only when sn provided and cloud configured)
+ const { c, cfg } = (() => {
+ try { return cloudFromConfig(); } catch { return { c: null, cfg: null }; }
+ })();
+
+ let match = null;
+ if (c && sn) {
+ if (!c.getSession()) {
+ await c.login(cfg.email, cfg.password);
+ devices.saveConfig({ ...cfg, session: c.getSession() });
+ }
+ const cloudList = await c.listDevices();
+ match = cloudList.find((x) => x.sn === sn);
+ if (!match) throw new Error('device not found in cloud account');
+ if (!ip) {
+ const f = await discover({});
+ info = f.find((x) => String(x.deviceId) === String(match.id)) || info;
+ }
+ }
+
+ const protocolVersion = info ? info.version : (sn ? 3 : 2);
+ const resolvedIp = ip || (info && info.ip) || null;
+ if (!resolvedIp) throw new Error('device not found on LAN — power it on, same subnet, then retry');
+
+ // ── TRANSACTIONAL BOUNDARY ──
+ // For V3 devices, getToken MUST succeed BEFORE createDevice.
+ // Any failure here leaves the DB untouched (Spec §5).
+ let token = null;
+ let key = null;
+ if (protocolVersion === 3) {
+ if (!c) throw new Error('cloud not configured — required for V3 token');
+ const tk = await c.getToken(match ? match.id : info.deviceId); // throws → nothing persisted
+ token = tk.token;
+ key = tk.key;
+ }
+
+ const d = devices.createDevice({
+ name: name || (match && match.name) || `Midea ${sn || resolvedIp}`,
+ device_sn: sn || `lan-${info ? info.deviceId : resolvedIp}`,
+ device_id: String(match ? match.id : (info && info.deviceId) || ''),
+ ip: resolvedIp,
+ port: (info && info.port) || 6444,
+ protocol_version: protocolVersion,
+ token,
+ key,
+ });
+
+ ensurePolling();
+
+ const { token: _t, key: _k, ...redacted } = d;
+ return { ...redacted, has_credentials: Boolean(token && key) };
+}
+
+// ── Registry ──────────────────────────────────────────────────────────────────
+
+function getDevices() { return devices.listDevicesRedacted(); }
+
+function removeDevice(id) {
+ cache.delete(id);
+ const res = devices.removeDevice(id);
+ if (devices.listDevices().length === 0) stopPolling();
+ return res;
+}
+
+function getStatus() {
+ return {
+ devices: devices.listDevicesRedacted().map((d) => {
+ const c = cache.get(d.id) || {};
+ return { id: d.id, name: d.name, enabled: d.enabled, online: Boolean(c.online), state: c.state || null };
+ }),
+ lastPollAt,
+ };
+}
+
+// ── Poll loop (license-gated, unref'd) ───────────────────────────────────────
+
+let pollRunning = false;
+
+async function pollTick() {
+ if (pollRunning) return; // no overlapping ticks
+ if (!license.hasFeature(FEATURE)) {
+ stopPolling();
+ cache.clear();
+ return;
+ }
+ pollRunning = true;
+ lastPollAt = new Date().toISOString();
+ try {
+ for (const d of devices.listDevices()) {
+ if (!d.enabled) continue;
+ if (lockDepth.get(d.id)) continue; // skip devices with active queue (Spec §8)
+ try { await getState(d.id); } catch { /* offline handled internally */ }
+ }
+ } finally {
+ pollRunning = false;
+ }
+}
+
+function ensurePolling() {
+ if (pollTimer) return; // idempotent
+ if (!license.hasFeature(FEATURE)) return;
+ if (devices.listDevices().length === 0) return;
+ pollTimer = setInterval(() => { pollTick().catch(() => {}); }, POLL_INTERVAL_MS);
+ if (pollTimer.unref) pollTimer.unref(); // never hold the process open
+}
+
+function startPolling() { ensurePolling(); }
+
+function stopPolling() {
+ if (pollTimer) {
+ clearInterval(pollTimer);
+ pollTimer = null;
+ }
+}
+
+module.exports = {
+ connectCloud, listCloudDevices, addDevice, discoverLan,
+ getDevices, getState, setState, testConnection, removeDevice,
+ getStatus, startPolling, stopPolling, withDeviceLock,
+};
diff --git a/src/services/midea/mideaAc.js b/src/services/midea/mideaAc.js
new file mode 100644
index 00000000..43df822d
--- /dev/null
+++ b/src/services/midea/mideaAc.js
@@ -0,0 +1,177 @@
+'use strict';
+
+const { crc8, frameChecksum } = require('./mideaCrypto');
+
+const DEVICE_TYPE = 0xac;
+const FRAME_QUERY = 0x03;
+const FRAME_CONTROL = 0x02;
+
+const MODES = { auto: 1, cool: 2, dry: 3, heat: 4, fan: 5 };
+const MODE_BY_NUM = { 1: 'auto', 2: 'cool', 3: 'dry', 4: 'heat', 5: 'fan' };
+const FAN = { auto: 102, high: 80, medium: 60, low: 40, silent: 20 };
+const SWING = { off: 0x0, vertical: 0xc, horizontal: 0x3, both: 0xf };
+
+// Wrap a 0xAC payload into a full 0xAA frame.
+// Layout: AA | len | AC | 00 00 00 00 00 | proto(0) | frameType | | msgId | crc8 | frameChecksum
+function buildFrame(frameType, payload, messageId) {
+ const body = Buffer.concat([payload, Buffer.from([messageId & 0xff])]);
+ const bodyWithCrc = Buffer.concat([body, Buffer.from([crc8(body)])]);
+ const header = Buffer.from([
+ 0xaa,
+ bodyWithCrc.length + 10, // total frame length byte
+ DEVICE_TYPE, 0, 0, 0, 0, 0,
+ 0, // protocol version
+ frameType,
+ ]);
+ const noChecksum = Buffer.concat([header, bodyWithCrc]);
+ return Buffer.concat([noChecksum, Buffer.from([frameChecksum(noChecksum.slice(1))])]);
+}
+
+// GetStateCommand — verbatim from msmart/device/AC/command.py GetStateCommand.tobytes()
+function buildQuery({ messageId = 0, tempType = 0x02 } = {}) {
+ const payload = Buffer.from([
+ 0x41, 0x81, 0x00, 0xff, 0x03, 0xff, 0x00, tempType,
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+ 0x00, 0x00, 0x00, 0x00, 0x03,
+ ]);
+ return buildFrame(FRAME_QUERY, payload, messageId);
+}
+
+// Encode target temperature for SetStateCommand
+function encodeTarget(target) {
+ const intg = Math.floor(target);
+ const frac = target - intg;
+ let temperature = 0;
+ let temperatureAlt = 0;
+ if (intg >= 17 && intg <= 30) {
+ temperature = (intg - 16) & 0x0f;
+ } else {
+ temperatureAlt = (intg - 12) & 0x1f;
+ }
+ if (frac > 0) temperature |= 0x10;
+ return { temperature, temperatureAlt };
+}
+
+// SetStateCommand — verbatim from msmart/device/AC/command.py SetStateCommand.tobytes()
+function buildSet(state, { messageId = 0, beep = true } = {}) {
+ const modeNum = MODES[state.mode] ?? MODES.cool;
+ const { temperature, temperatureAlt } = encodeTarget(state.targetTemp ?? 24);
+ const fan = typeof state.fanSpeed === 'number' ? state.fanSpeed : (FAN[state.fanSpeed] ?? FAN.auto);
+
+ let swing = SWING.off;
+ if (state.swingV && state.swingH) swing = SWING.both;
+ else if (state.swingV) swing = SWING.vertical;
+ else if (state.swingH) swing = SWING.horizontal;
+
+ // p[0] = 0x40 (SetState command id)
+ // p[1] = CONTROL_SOURCE(0x02) | beep | power
+ // p[2] = temperature bits | mode bits
+ // p[3] = fan speed
+ // p[4-5] = timer off (0x7F 0x7F)
+ // p[6] = 0x00
+ // p[7] = swing_mode (0x30 | swing & 0x3F)
+ // p[8] = follow_me | turbo_alt
+ // p[9] = eco | purifier | force_aux_heat | aux_heat
+ // p[10] = sleep | turbo | fahrenheit
+ // p[11-17] = 0x00 (unknown)
+ // p[18] = temperatureAlt
+ // p[19] = humidity (0x00 default)
+ // p[20] = 0x00
+ // p[21] = freeze_protection
+ // p[22] = independent_aux_heat
+ // p[23] = 0x00
+ const p = Buffer.alloc(24);
+ p[0] = 0x40;
+ p[1] = 0x02 | (beep ? 0x40 : 0x00) | (state.power ? 0x01 : 0x00);
+ p[2] = (temperature & 0x1f) | ((modeNum & 0x07) << 5);
+ p[3] = fan & 0xff;
+ p[4] = 0x7f;
+ p[5] = 0x7f;
+ p[6] = 0x00;
+ p[7] = 0x30 | (swing & 0x3f);
+ p[8] = state.turbo ? 0x20 : 0x00; // follow_me=0 | turbo_alt
+ p[9] = state.eco ? 0x80 : 0x00; // eco | (purifier/aux_heat=0)
+ p[10] = state.turbo ? 0x02 : 0x00; // sleep=0 | turbo | fahrenheit=0
+ // p[11..17] = 0 (already zeroed by alloc)
+ p[18] = temperatureAlt & 0x1f;
+ // p[19] = humidity = 40 & 0x7F = 40 (default target_humidity)
+ p[19] = 40 & 0x7f;
+ // p[20..23] = 0 (zeroed)
+ return buildFrame(FRAME_CONTROL, p, messageId);
+}
+
+// Parse a temperature value following msmart StateResponse._parse_temperature:
+// if data == 0xFF → null
+// temp = (data - 50) / 2
+// if not fahrenheit and decimals > 0 → int(temp) + (decimals if temp >= 0 else -decimals)
+// elif decimals >= 0.5 → int(temp) + (0.5 if temp >= 0 else -0.5)
+// else → temp
+function parseTemperature(data, decimals, fahrenheit) {
+ if (data === 0xff) return null;
+ const temp = (data - 50) / 2;
+ if (!fahrenheit && decimals) {
+ return temp >= 0
+ ? Math.trunc(temp) + decimals
+ : Math.trunc(temp) - decimals;
+ }
+ if (decimals >= 0.5) {
+ return temp >= 0
+ ? Math.trunc(temp) + 0.5
+ : Math.trunc(temp) - 0.5;
+ }
+ return temp;
+}
+
+// Accepts a full 0xAA frame OR a raw payload starting at 0xC0.
+// Follows StateResponse._parse() from msmart/device/AC/command.py exactly.
+function parseState(frame) {
+ // Strip 10-byte header + 2-byte trailer (msgId + crc8) from full AA frame
+ const p = frame[0] === 0xaa ? frame.slice(10, -2) : frame;
+ // p[0] === 0xC0
+
+ const power = (p[1] & 0x01) !== 0;
+ const modeNum = (p[2] >> 5) & 0x07;
+
+ // Primary target temp from p[2]
+ let targetTemp = (p[2] & 0x0f) + 16.0 + ((p[2] & 0x10) ? 0.5 : 0.0);
+
+ const fanSpeed = p[3] & 0x7f;
+ const swing = p[7] & 0x0f;
+
+ // follow_me=p[8]&0x80, turbo uses two bits
+ const turbo = ((p[8] & 0x20) !== 0) || ((p[10] & 0x02) !== 0);
+ const eco = (p[9] & 0x10) !== 0;
+ const sleep = (p[10] & 0x01) !== 0; // eslint-disable-line no-unused-vars
+ const fahrenheit = (p[10] & 0x04) !== 0;
+
+ // Indoor/outdoor temps with additional precision from p[15] nibbles
+ const indoorDecimals = (p[15] & 0x0f) / 10;
+ const outdoorDecimals = (p[15] >> 4) / 10;
+ const indoorTemp = parseTemperature(p[11], indoorDecimals, fahrenheit);
+ const outdoorTemp = parseTemperature(p[12], outdoorDecimals, fahrenheit);
+
+ // Alternate target temperature (larger range), overrides primary if non-zero
+ const targetAlt = p[13] & 0x1f;
+ if (targetAlt !== 0) {
+ targetTemp = targetAlt + 12.0 + ((p[2] & 0x10) ? 0.5 : 0.0);
+ }
+
+ return {
+ power,
+ mode: MODE_BY_NUM[modeNum] || 'auto',
+ targetTemp,
+ indoorTemp,
+ outdoorTemp,
+ fanSpeed,
+ swingV: (swing & 0xc) !== 0,
+ swingH: (swing & 0x3) !== 0,
+ eco,
+ turbo,
+ };
+}
+
+module.exports = {
+ MODES, MODE_BY_NUM, FAN, SWING, DEVICE_TYPE,
+ buildQuery, buildSet, parseState,
+ encodeTarget, parseTemperature, buildFrame,
+};
diff --git a/src/services/midea/mideaCloud.js b/src/services/midea/mideaCloud.js
new file mode 100644
index 00000000..f1f42cde
--- /dev/null
+++ b/src/services/midea/mideaCloud.js
@@ -0,0 +1,272 @@
+'use strict';
+
+const crypto = require('node:crypto');
+const { sha256, md5, strxor } = require('./mideaCrypto');
+
+// PUBLIC constants extracted from the official apps (msmart/cloud.py) — NOT secrets.
+// References: SmartHomeCloud._Security (cloud.py:377+) and NetHomePlusCloud._Security (cloud.py:556+)
+const APP_VARIANTS = {
+ msmarthome: {
+ appId: '1010',
+ base: 'https://mp-prod.appsmb.com',
+ proxied: true,
+ hmacKey: 'PROD_VnoClJI9aikS8dyy',
+ iotKey: 'meicloud',
+ loginKey: 'ac21b9f9cbfe4ca5a88562ef25e2b768',
+ },
+ nethome: {
+ appId: '1017',
+ base: 'https://mapp.appsmb.com',
+ proxied: false,
+ appKey: '3742e9e5842d4ad59c2db887e12449f9',
+ },
+};
+
+class MideaCloudError extends Error {
+ constructor(message, code) {
+ super(message);
+ this.name = 'MideaCloudError';
+ this.code = code;
+ }
+}
+
+function hexToken(n) { return crypto.randomBytes(n).toString('hex'); }
+
+/**
+ * Compute the udpid for a device id byte buffer (6 bytes).
+ * udpid = strxor(sha256(idBytes)[0:16], sha256(idBytes)[16:32]).hex()
+ * Matches msmart compute_device_udpid (cloud.py / device.py).
+ */
+function computeUdpid(deviceIdBytes) {
+ const h = sha256(deviceIdBytes); // 32 bytes
+ return strxor(h.slice(0, 16), h.slice(16, 32)).toString('hex');
+}
+
+/** Format a UTC timestamp: YYYYMMDDHHmmss */
+function timestamp() {
+ return new Date().toISOString().replace(/[-T:.Z]/g, '').slice(0, 14);
+}
+
+class MideaCloud {
+ constructor(app = 'msmarthome') {
+ if (!APP_VARIANTS[app]) throw new Error(`unknown midea app variant: ${app}`);
+ this.app = app;
+ this.cfg = APP_VARIANTS[app];
+ this.deviceId = hexToken(8);
+ this.session = null; // { accessToken|sessionId, loginId, ... }
+ }
+
+ getSession() { return this.session; }
+ setSession(s) { this.session = s; }
+
+ // ---- low-level request: returns parsed result object or throws MideaCloudError ----
+ async _request(endpoint, body, opts = {}) {
+ if (this.app === 'msmarthome') return this._requestMSmart(endpoint, body, opts);
+ return this._requestNetHome(endpoint, body);
+ }
+
+ /**
+ * NetHome Plus request.
+ * Sign = sha256(path + unquote_plus(urlencode(sorted(form))) + appKey)
+ * Matches NetHomePlusCloud._Security.sign (cloud.py:562-573).
+ */
+ async _requestNetHome(endpoint, data) {
+ const c = this.cfg;
+ // Common fields per BaseCloud._build_request_body (cloud.py:124-141) +
+ // NetHomePlusCloud._build_request_body (cloud.py:507-518): sessionId is
+ // ALWAYS part of the signed form ('' before login, real value after).
+ const form = {
+ ...data,
+ appId: c.appId,
+ format: '2',
+ clientType: '1',
+ language: 'en_US',
+ src: c.appId,
+ stamp: timestamp(),
+ deviceId: this.deviceId,
+ sessionId: (this.session && this.session.sessionId) || '',
+ };
+ // sign = sha256(path + unquote_plus(urlencode(sorted(form))) + appKey)
+ // For typical Midea values (hex, email) this reduces to: path + sorted(k=v).join('&') + appKey
+ const path = new URL(c.base + endpoint).pathname;
+ const sorted = Object.keys(form).sort().map((k) => `${k}=${form[k]}`).join('&');
+ form.sign = sha256(Buffer.from(path + sorted + c.appKey, 'ascii')).toString('hex');
+
+ const res = await fetch(c.base + endpoint, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
+ body: new URLSearchParams(form).toString(),
+ });
+ if (res.status === 429) throw new MideaCloudError('rate limited', 'MIDEA_CLOUD_RATE_LIMITED');
+ const json = await res.json();
+ if (String(json.errorCode) !== '0') {
+ throw this._mapError(json.errorCode, json.msg || json.message);
+ }
+ return json.result;
+ }
+
+ /**
+ * MSmartHome request.
+ * sign = HMAC-SHA256(hmacKey, iotKey + jsonBody + random)
+ * Matches SmartHomeCloud._Security.sign (cloud.py:404-410).
+ * URL = base + /mas/v5/app/proxy?alias=
+ */
+ async _requestMSmart(endpoint, data, opts = {}) {
+ const c = this.cfg;
+ // Per SmartHomeCloud._build_request_body (cloud.py:256-267) every API body
+ // carries the common fields + reqId + stamp. The /mj/user/login body is the
+ // ONE exception in cloud.py (built manually, sent raw), so login passes raw.
+ const body = opts.raw ? data : {
+ appId: c.appId,
+ src: c.appId,
+ format: '2',
+ clientType: '1',
+ language: 'en_US',
+ deviceId: this.deviceId,
+ stamp: timestamp(),
+ reqId: hexToken(16),
+ ...data,
+ };
+ const random = hexToken(16);
+ const jsonBody = JSON.stringify(body);
+ const sign = crypto.createHmac('sha256', Buffer.from(c.hmacKey, 'ascii'))
+ .update(c.iotKey + jsonBody + random, 'ascii').digest('hex');
+ // accessToken header is ALWAYS present ('' before login) per cloud.py:246.
+ const headers = {
+ 'Content-Type': 'application/json',
+ secretVersion: '1',
+ sign,
+ random,
+ accessToken: (this.session && this.session.accessToken) || '',
+ };
+ const url = `${c.base}/mas/v5/app/proxy?alias=${endpoint}`;
+ const res = await fetch(url, { method: 'POST', headers, body: jsonBody });
+ if (res.status === 429) throw new MideaCloudError('rate limited', 'MIDEA_CLOUD_RATE_LIMITED');
+ const json = await res.json();
+ if (String(json.code) !== '0') throw this._mapError(json.code, json.msg || json.message);
+ return json.data;
+ }
+
+ _mapError(code, msg) {
+ const m = String(msg || '').toLowerCase();
+ if (m.includes('2fa') || m.includes('verification') || m.includes('captcha')) {
+ return new MideaCloudError(msg || '2FA required', 'MIDEA_CLOUD_2FA_REQUIRED');
+ }
+ return new MideaCloudError(msg || `cloud error ${code}`, 'MIDEA_CLOUD_ERROR');
+ }
+
+ async _getLoginId(account) {
+ const r = await this._request('/v1/user/login/id/get', { loginAccount: account });
+ return r.loginId;
+ }
+
+ /**
+ * Hash password for cloud auth.
+ * encrypt_password: sha256(loginId + sha256(password).hex + loginKey/appKey)
+ * Matches SmartHomeCloud._Security.encrypt_password (cloud.py:412-421) and
+ * NetHomePlusCloud._Security.encrypt_password (cloud.py:575-584).
+ */
+ _hashPassword(loginId, password) {
+ const c = this.cfg;
+ const inner = sha256(Buffer.from(password, 'ascii')).toString('hex');
+ const key = this.app === 'msmarthome' ? c.loginKey : c.appKey;
+ return sha256(Buffer.from(loginId + inner + key, 'ascii')).toString('hex');
+ }
+
+ /**
+ * Hash iampwd for MSmartHome cloud auth.
+ * encrypt_iam_password: sha256(loginId + md5(md5(password).hex).hex + loginKey)
+ * Matches SmartHomeCloud._Security.encrypt_iam_password (cloud.py:423-438).
+ */
+ _hashIamPassword(loginId, password) {
+ const c = this.cfg;
+ const m1 = md5(Buffer.from(password, 'ascii')).toString('hex');
+ const m2 = md5(Buffer.from(m1, 'ascii')).toString('hex');
+ return sha256(Buffer.from(loginId + m2 + c.loginKey, 'ascii')).toString('hex');
+ }
+
+ /**
+ * Login to the cloud.
+ * NetHome: POST /v1/user/login → session.sessionId
+ * MSmartHome: POST /mj/user/login → session.accessToken (via mdata.accessToken)
+ */
+ async login(email, password) {
+ const loginId = await this._getLoginId(email);
+ if (this.app === 'nethome') {
+ // cloud.py:532-539 sends only loginAccount + password (+ common fields).
+ const r = await this._request('/v1/user/login', {
+ loginAccount: email,
+ password: this._hashPassword(loginId, password),
+ });
+ this.session = { sessionId: r.sessionId, loginId, email };
+ } else {
+ // MSmartHome: nested data/iotData per cloud.py:281-306, sent raw (the one
+ // request cloud.py does NOT pass through _build_request_body).
+ const r = await this._request('/mj/user/login', {
+ data: {
+ platform: '2',
+ deviceId: this.deviceId,
+ },
+ iotData: {
+ appId: this.cfg.appId,
+ src: this.cfg.appId,
+ clientType: '1',
+ loginAccount: email,
+ iampwd: this._hashIamPassword(loginId, password),
+ password: this._hashPassword(loginId, password),
+ // cloud.py uses secrets.token_urlsafe(120) → base64url, not hex.
+ pushToken: crypto.randomBytes(120).toString('base64url'),
+ stamp: timestamp(),
+ reqId: hexToken(16),
+ },
+ }, { raw: true });
+ // accessToken lives in mdata per cloud.py:306
+ const accessToken = r.mdata ? r.mdata.accessToken : r.accessToken;
+ this.session = { accessToken, loginId, email };
+ }
+ return { ok: true, accountId: loginId };
+ }
+
+ /**
+ * List AC devices from the cloud.
+ * Filters by type 0xac/172 (air conditioner).
+ */
+ async listDevices() {
+ // NetHome carries sessionId via the common signed form; MSmart via header.
+ const r = await this._request('/v1/appliance/user/list/get', {});
+ const list = r.list || r.appliances || [];
+ return list.map((a) => ({
+ sn: a.sn || a.applianceCode,
+ name: a.name,
+ type: a.type,
+ id: a.id,
+ online: a.onlineStatus === '1',
+ })).filter((a) => {
+ const t = String(a.type).toLowerCase();
+ return t === '0xac' || t === '172' || Number(a.type) === 0xac;
+ });
+ }
+
+ /**
+ * Get V3 token + key for a device.
+ * Tries both big-endian and little-endian device id bytes to compute udpid.
+ * Matches BaseCloud.get_token (cloud.py:163-183) with endianness fallback.
+ */
+ async getToken(deviceId) {
+ const idNum = BigInt(deviceId);
+ for (const endian of ['le', 'be']) {
+ const idBytes = Buffer.alloc(6);
+ if (endian === 'le') idBytes.writeUIntLE(Number(idNum), 0, 6);
+ else idBytes.writeUIntBE(Number(idNum), 0, 6);
+ const udpid = computeUdpid(idBytes);
+ // A genuine API/network error (429/2FA/etc) propagates; only a "no matching
+ // udpId" result falls through to try the other endianness.
+ const r = await this._request('/v1/iot/secure/getToken', { udpid });
+ const entry = (r.tokenlist || []).find((t) => t.udpId === udpid);
+ if (entry) return { token: entry.token, key: entry.key };
+ }
+ throw new MideaCloudError('no token for device', 'MIDEA_CLOUD_NO_TOKEN');
+ }
+}
+
+module.exports = { APP_VARIANTS, MideaCloud, MideaCloudError, computeUdpid };
diff --git a/src/services/midea/mideaCrypto.js b/src/services/midea/mideaCrypto.js
new file mode 100644
index 00000000..57659754
--- /dev/null
+++ b/src/services/midea/mideaCrypto.js
@@ -0,0 +1,80 @@
+'use strict';
+
+const crypto = require('node:crypto');
+
+const SIGN_KEY = Buffer.from('xhdiwjnchekd4d512chdjx5d8e4c394D2D7S', 'ascii');
+const ENC_KEY = crypto.createHash('md5').update(SIGN_KEY).digest(); // 16 bytes
+
+function md5(buf) { return crypto.createHash('md5').update(buf).digest(); }
+function sha256(buf) { return crypto.createHash('sha256').update(buf).digest(); }
+
+// AES-128-ECB with PKCS7 (matches Security.encrypt_aes/decrypt_aes)
+function encryptAesEcb(buf) {
+ const cipher = crypto.createCipheriv('aes-128-ecb', ENC_KEY, null);
+ cipher.setAutoPadding(true);
+ return Buffer.concat([cipher.update(buf), cipher.final()]);
+}
+function decryptAesEcb(buf) {
+ const decipher = crypto.createDecipheriv('aes-128-ecb', ENC_KEY, null);
+ decipher.setAutoPadding(true);
+ return Buffer.concat([decipher.update(buf), decipher.final()]);
+}
+
+// AES-CBC, IV = 16 zero bytes, NO padding (caller pre-pads). key length 16 or 32.
+function cbcAlgo(key) { return key.length === 32 ? 'aes-256-cbc' : 'aes-128-cbc'; }
+function encryptAesCbc(key, buf) {
+ const cipher = crypto.createCipheriv(cbcAlgo(key), key, Buffer.alloc(16, 0));
+ cipher.setAutoPadding(false);
+ return Buffer.concat([cipher.update(buf), cipher.final()]);
+}
+function decryptAesCbc(key, buf) {
+ const decipher = crypto.createDecipheriv(cbcAlgo(key), key, Buffer.alloc(16, 0));
+ decipher.setAutoPadding(false);
+ return Buffer.concat([decipher.update(buf), decipher.final()]);
+}
+
+function signMd5(buf) { return md5(Buffer.concat([buf, SIGN_KEY])); }
+
+function strxor(a, b) {
+ const out = Buffer.alloc(a.length);
+ for (let i = 0; i < a.length; i++) out[i] = a[i] ^ b[i];
+ return out;
+}
+
+// Midea CRC8 table — verbatim from msmart/crc8.py _CRC8_854_TABLE
+const CRC8_TABLE = [
+ 0x00, 0x5E, 0xBC, 0xE2, 0x61, 0x3F, 0xDD, 0x83, 0xC2, 0x9C, 0x7E, 0x20, 0xA3, 0xFD, 0x1F, 0x41,
+ 0x9D, 0xC3, 0x21, 0x7F, 0xFC, 0xA2, 0x40, 0x1E, 0x5F, 0x01, 0xE3, 0xBD, 0x3E, 0x60, 0x82, 0xDC,
+ 0x23, 0x7D, 0x9F, 0xC1, 0x42, 0x1C, 0xFE, 0xA0, 0xE1, 0xBF, 0x5D, 0x03, 0x80, 0xDE, 0x3C, 0x62,
+ 0xBE, 0xE0, 0x02, 0x5C, 0xDF, 0x81, 0x63, 0x3D, 0x7C, 0x22, 0xC0, 0x9E, 0x1D, 0x43, 0xA1, 0xFF,
+ 0x46, 0x18, 0xFA, 0xA4, 0x27, 0x79, 0x9B, 0xC5, 0x84, 0xDA, 0x38, 0x66, 0xE5, 0xBB, 0x59, 0x07,
+ 0xDB, 0x85, 0x67, 0x39, 0xBA, 0xE4, 0x06, 0x58, 0x19, 0x47, 0xA5, 0xFB, 0x78, 0x26, 0xC4, 0x9A,
+ 0x65, 0x3B, 0xD9, 0x87, 0x04, 0x5A, 0xB8, 0xE6, 0xA7, 0xF9, 0x1B, 0x45, 0xC6, 0x98, 0x7A, 0x24,
+ 0xF8, 0xA6, 0x44, 0x1A, 0x99, 0xC7, 0x25, 0x7B, 0x3A, 0x64, 0x86, 0xD8, 0x5B, 0x05, 0xE7, 0xB9,
+ 0x8C, 0xD2, 0x30, 0x6E, 0xED, 0xB3, 0x51, 0x0F, 0x4E, 0x10, 0xF2, 0xAC, 0x2F, 0x71, 0x93, 0xCD,
+ 0x11, 0x4F, 0xAD, 0xF3, 0x70, 0x2E, 0xCC, 0x92, 0xD3, 0x8D, 0x6F, 0x31, 0xB2, 0xEC, 0x0E, 0x50,
+ 0xAF, 0xF1, 0x13, 0x4D, 0xCE, 0x90, 0x72, 0x2C, 0x6D, 0x33, 0xD1, 0x8F, 0x0C, 0x52, 0xB0, 0xEE,
+ 0x32, 0x6C, 0x8E, 0xD0, 0x53, 0x0D, 0xEF, 0xB1, 0xF0, 0xAE, 0x4C, 0x12, 0x91, 0xCF, 0x2D, 0x73,
+ 0xCA, 0x94, 0x76, 0x28, 0xAB, 0xF5, 0x17, 0x49, 0x08, 0x56, 0xB4, 0xEA, 0x69, 0x37, 0xD5, 0x8B,
+ 0x57, 0x09, 0xEB, 0xB5, 0x36, 0x68, 0x8A, 0xD4, 0x95, 0xCB, 0x29, 0x77, 0xF4, 0xAA, 0x48, 0x16,
+ 0xE9, 0xB7, 0x55, 0x0B, 0x88, 0xD6, 0x34, 0x6A, 0x2B, 0x75, 0x97, 0xC9, 0x4A, 0x14, 0xF6, 0xA8,
+ 0x74, 0x2A, 0xC8, 0x96, 0x15, 0x4B, 0xA9, 0xF7, 0xB6, 0xE8, 0x0A, 0x54, 0xD7, 0x89, 0x6B, 0x35,
+];
+function crc8(buf) {
+ let crc = 0;
+ for (const b of buf) crc = CRC8_TABLE[(crc ^ b) & 0xff];
+ return crc & 0xff;
+}
+
+function frameChecksum(buf) {
+ let sum = 0;
+ for (const b of buf) sum = (sum + b) & 0xff;
+ return (~sum + 1) & 0xff;
+}
+
+module.exports = {
+ SIGN_KEY, ENC_KEY,
+ md5, sha256,
+ encryptAesEcb, decryptAesEcb, encryptAesCbc, decryptAesCbc,
+ signMd5, strxor, crc8, frameChecksum,
+};
diff --git a/src/services/midea/mideaDevices.js b/src/services/midea/mideaDevices.js
new file mode 100644
index 00000000..2c709a46
--- /dev/null
+++ b/src/services/midea/mideaDevices.js
@@ -0,0 +1,139 @@
+'use strict';
+
+const { getDb } = require('../../db/connection');
+const settings = require('../settings');
+const { encrypt, decrypt } = require('../../utils/crypto');
+
+const CONFIG_KEY = 'midea_config';
+const DEFAULT_CONFIG = { app: 'msmarthome', email: '', password: '', session: null };
+
+// Shared non-secret column mapping. Deliberately omits token/key so the
+// redacted path never touches ciphertext (and never throws on corrupt data).
+function rowToPublic(row) {
+ return {
+ id: row.id,
+ name: row.name,
+ device_sn: row.device_sn,
+ device_id: row.device_id,
+ ip: row.ip,
+ port: row.port,
+ protocol_version: row.protocol_version,
+ model: row.model,
+ enabled: row.enabled === 1,
+ last_seen_at: row.last_seen_at,
+ created_at: row.created_at,
+ updated_at: row.updated_at,
+ };
+}
+
+function rowToDevice(row) {
+ if (!row) return null;
+ return {
+ ...rowToPublic(row),
+ token: row.token_enc ? decrypt(row.token_enc) : null,
+ key: row.key_enc ? decrypt(row.key_enc) : null,
+ };
+}
+
+function listDevices() {
+ return getDb().prepare('SELECT * FROM midea_devices ORDER BY id').all().map(rowToDevice);
+}
+
+function getDevice(id) {
+ return rowToDevice(getDb().prepare('SELECT * FROM midea_devices WHERE id = ?').get(id));
+}
+
+// Reads raw rows and computes has_credentials from the encrypted columns
+// WITHOUT decrypting — safe even if a stored ciphertext is corrupted.
+function listDevicesRedacted() {
+ return getDb().prepare('SELECT * FROM midea_devices ORDER BY id').all().map((row) => ({
+ ...rowToPublic(row),
+ has_credentials: Boolean(row.token_enc && row.key_enc),
+ }));
+}
+
+function createDevice(data) {
+ const db = getDb();
+ const info = db.prepare(`
+ INSERT INTO midea_devices
+ (name, device_sn, device_id, ip, port, protocol_version, token_enc, key_enc, model, enabled)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ `).run(
+ data.name,
+ data.device_sn,
+ data.device_id ?? null,
+ data.ip ?? null,
+ data.port ?? 6444,
+ data.protocol_version ?? 3,
+ data.token ? encrypt(data.token) : null,
+ data.key ? encrypt(data.key) : null,
+ data.model ?? null,
+ data.enabled === false ? 0 : 1,
+ );
+ return getDevice(info.lastInsertRowid);
+}
+
+const FIELD_MAP = { name: 'name', ip: 'ip', port: 'port', model: 'model', device_id: 'device_id', last_seen_at: 'last_seen_at' };
+
+function updateDevice(id, patch) {
+ const db = getDb();
+ const sets = [];
+ const vals = [];
+ for (const [k, col] of Object.entries(FIELD_MAP)) {
+ if (k in patch) { sets.push(`${col} = ?`); vals.push(patch[k]); }
+ }
+ if ('enabled' in patch) { sets.push('enabled = ?'); vals.push(patch.enabled ? 1 : 0); }
+ if ('token' in patch) { sets.push('token_enc = ?'); vals.push(patch.token ? encrypt(patch.token) : null); }
+ if ('key' in patch) { sets.push('key_enc = ?'); vals.push(patch.key ? encrypt(patch.key) : null); }
+ if (sets.length) {
+ sets.push("updated_at = datetime('now')");
+ db.prepare(`UPDATE midea_devices SET ${sets.join(', ')} WHERE id = ?`).run(...vals, id);
+ }
+ return getDevice(id);
+}
+
+function removeDevice(id) {
+ getDb().prepare('DELETE FROM midea_devices WHERE id = ?').run(id);
+ return { ok: true };
+}
+
+function loadConfig() {
+ const raw = settings.get(CONFIG_KEY);
+ if (!raw) return { ...DEFAULT_CONFIG };
+ const parsed = JSON.parse(raw);
+ // session is an OBJECT stored as encrypt(JSON.stringify(...)). A malformed
+ // or legacy (pre-encryption) value must not brick loadConfig — null it so
+ // the app simply re-logs in.
+ let session = null;
+ if (parsed.session) {
+ try { session = JSON.parse(decrypt(parsed.session)); } catch { session = null; }
+ }
+ return {
+ ...DEFAULT_CONFIG,
+ ...parsed,
+ password: parsed.password ? decrypt(parsed.password) : '',
+ session,
+ };
+}
+
+function saveConfig(cfg) {
+ const toStore = {
+ app: cfg.app || 'msmarthome',
+ email: cfg.email || '',
+ password: cfg.password ? encrypt(cfg.password) : '',
+ session: cfg.session ? encrypt(JSON.stringify(cfg.session)) : null,
+ };
+ settings.set(CONFIG_KEY, JSON.stringify(toStore));
+}
+
+function redactConfig(cfg) {
+ const { password, session, ...rest } = cfg;
+ return { ...rest, password_set: Boolean(password), session_active: Boolean(session) };
+}
+
+module.exports = {
+ CONFIG_KEY,
+ listDevices, getDevice, listDevicesRedacted,
+ createDevice, updateDevice, removeDevice,
+ loadConfig, saveConfig, redactConfig,
+};
diff --git a/src/services/midea/mideaLan.js b/src/services/midea/mideaLan.js
new file mode 100644
index 00000000..14183e19
--- /dev/null
+++ b/src/services/midea/mideaLan.js
@@ -0,0 +1,268 @@
+'use strict';
+
+const crypto = require('node:crypto');
+const { encryptAesEcb, decryptAesEcb, signMd5 } = require('./mideaCrypto');
+
+// ---- V2 packet (_Packet, lan.py:686) ----
+function buildTimestamp(now = new Date()) {
+ // order: [microsecond//10000, second, minute, hour, day, month, year%100, year//100]
+ const cs = Math.floor(now.getMilliseconds() * 1000 / 10000); // ms→µs→/10000 ≈ centiseconds
+ return Buffer.from([
+ cs & 0xff,
+ now.getSeconds(), now.getMinutes(), now.getHours(),
+ now.getDate(), now.getMonth() + 1,
+ now.getFullYear() % 100, Math.floor(now.getFullYear() / 100),
+ ]);
+}
+
+function encodePacket(deviceId, frame, now = new Date()) {
+ const enc = encryptAesEcb(frame);
+ const total = 40 + enc.length + 16;
+ const header = Buffer.alloc(40);
+ header[0] = 0x5a; header[1] = 0x5a; // start
+ header[2] = 0x01; header[3] = 0x11; // message type
+ header.writeUInt16LE(total, 4); // total length
+ header[6] = 0x20; header[7] = 0x00; // magic
+ // [8..11] message id = 0
+ buildTimestamp(now).copy(header, 12); // [12..19] timestamp
+ const idBuf = Buffer.alloc(8);
+ idBuf.writeBigUInt64LE(BigInt(deviceId));
+ idBuf.copy(header, 20); // [20..27] device id (8 LE)
+ // [28..39] zero padding
+ const headPlusEnc = Buffer.concat([header, enc]);
+ return Buffer.concat([headPlusEnc, signMd5(headPlusEnc)]);
+}
+
+function decodePacket(packet) {
+ if (!(packet[0] === 0x5a && packet[1] === 0x5a)) throw new Error('not a 5a5a packet');
+ const encrypted = packet.slice(40, -16);
+ const expectSign = packet.slice(-16);
+ if (!signMd5(packet.slice(0, -16)).equals(expectSign)) throw new Error('packet sign mismatch');
+ return decryptAesEcb(encrypted);
+}
+
+module.exports = { encodePacket, decodePacket, buildTimestamp };
+
+// ---- V3 8370 framing (_LanProtocolV3, lan.py) ----
+const { encryptAesCbc, decryptAesCbc, sha256, strxor } = require('./mideaCrypto');
+
+const V3_MAGIC = 0x20;
+const TYPE_HANDSHAKE_REQ = 0x0;
+const TYPE_ENCRYPTED_REQ = 0x6;
+
+function buildV3Header(payloadLenWithSign, pad, type) {
+ // 8370 | size(2 big) | 20 | (pad<<4 | type)
+ const h = Buffer.alloc(6);
+ h[0] = 0x83; h[1] = 0x70;
+ h.writeUInt16BE(payloadLenWithSign, 2); // = len(payload)+pad+32, packet-id NOT counted
+ h[4] = V3_MAGIC;
+ h[5] = ((pad & 0x0f) << 4) | (type & 0x0f);
+ return h;
+}
+
+function encodeEncryptedRequest(localKey, data, packetId) {
+ const remainder = (data.length + 2) % 16;
+ const pad = remainder ? 16 - remainder : 0;
+ const size = data.length + pad + 32;
+ const header = buildV3Header(size, pad, TYPE_ENCRYPTED_REQ);
+ const pidBuf = Buffer.alloc(2); pidBuf.writeUInt16BE(packetId & 0xfff);
+ const payload = Buffer.concat([pidBuf, data, crypto.randomBytes(pad)]);
+ const enc = encryptAesCbc(localKey, payload);
+ const hash = sha256(Buffer.concat([header, payload]));
+ return Buffer.concat([header, enc, hash]);
+}
+
+function decodeEncryptedResponse(localKey, packet) {
+ const header = packet.slice(0, 6);
+ const enc = packet.slice(6, -32);
+ const rxHash = packet.slice(-32);
+ const dec = decryptAesCbc(localKey, enc);
+ if (!sha256(Buffer.concat([header, dec])).equals(rxHash)) throw new Error('v3 hash mismatch');
+ const pad = header[5] >> 4;
+ return dec.slice(2, pad ? -pad : undefined); // strip 2-byte packet id + padding
+}
+
+function encodeHandshakeRequest(token, packetId) {
+ const pidBuf = Buffer.alloc(2); pidBuf.writeUInt16BE(packetId & 0xfff);
+ const payload = Buffer.concat([pidBuf, token]);
+ const header = buildV3Header(payload.length, 0, TYPE_HANDSHAKE_REQ);
+ return Buffer.concat([header, payload]);
+}
+
+function decodeHandshakeResponse(packet) {
+ return packet.slice(8); // strip 6-byte header + 2-byte packet id → 64 bytes
+}
+
+function getLocalKey(key, handshakeData) {
+ const payload = handshakeData.slice(0, 32);
+ const rxHash = handshakeData.slice(32);
+ const decrypted = decryptAesCbc(key, payload);
+ if (!sha256(decrypted).equals(rxHash)) throw new Error('handshake hash mismatch');
+ return strxor(decrypted, key); // 32-byte session key
+}
+
+module.exports = Object.assign(module.exports, {
+ encodeEncryptedRequest, decodeEncryptedResponse,
+ encodeHandshakeRequest, decodeHandshakeResponse, getLocalKey,
+});
+
+// ---- Part C: UDP discovery (discover.py) ----
+const dgram = require('node:dgram');
+
+// 72-byte broadcast probe — from const.py DISCOVERY_MSG
+const DISCOVERY_MSG = Buffer.from(
+ '5a5a011148009200' +
+ '0000000000000000000000000000000000000000000000000000000000000000' +
+ '7f75bd6b3e4f8b762e849c6e578d6590036e9d4342a50f1f569eb8ec918e92e5',
+ 'hex'
+);
+// assert DISCOVERY_MSG.length === 72 (verified: 16+64+64 hex chars = 72 bytes)
+
+function detectVersion(d) {
+ if (d[0] === 0x5a && d[1] === 0x5a) return 2;
+ if (d[0] === 0x83 && d[1] === 0x70) return 3;
+ return 1; // V1 XML (unsupported for AC)
+}
+
+function parseDiscoveryResponse(datagram) {
+ const version = detectVersion(datagram);
+ let data = datagram;
+ if (version === 3) data = data.slice(8, -16); // strip 8370 header + 16-byte hash → inner 5a5a
+ // 6-byte LE device id (bytes 20..25):
+ let devId = 0n;
+ for (let i = 0; i < 6; i++) devId += BigInt(data[20 + i]) << BigInt(8 * i);
+ const encrypted = data.slice(40, -16);
+ const decrypted = decryptAesEcb(encrypted);
+ const ip = `${decrypted[3]}.${decrypted[2]}.${decrypted[1]}.${decrypted[0]}`;
+ const port = decrypted.readUInt16LE(4);
+ const sn = decrypted.slice(8, 40).toString('ascii');
+ const nameLen = decrypted[40];
+ const name = decrypted.slice(41, 41 + nameLen).toString('ascii');
+ const deviceType = parseInt(name.split('_')[1], 16);
+ return { ip, port, deviceId: devId.toString(), sn, deviceType, version };
+}
+
+function discover({ timeoutMs = 3000, broadcast = '255.255.255.255', ports = [6445, 20086] } = {}) {
+ return new Promise((resolve) => {
+ const sock = dgram.createSocket('udp4');
+ const found = new Map();
+ sock.on('message', (msg) => {
+ try {
+ const v = detectVersion(msg);
+ if (v === 1) return;
+ const info = parseDiscoveryResponse(msg);
+ if (info.deviceType === 0xac) found.set(info.deviceId, info);
+ } catch { /* ignore malformed */ }
+ });
+ sock.on('error', () => { try { sock.close(); } catch {} resolve([]); });
+ sock.bind(() => {
+ sock.setBroadcast(true);
+ for (const port of ports) for (let i = 0; i < 3; i++) sock.send(DISCOVERY_MSG, port, broadcast);
+ });
+ setTimeout(() => { try { sock.close(); } catch {} resolve([...found.values()]); }, timeoutMs);
+ });
+}
+
+module.exports = Object.assign(module.exports, { detectVersion, parseDiscoveryResponse, discover, DISCOVERY_MSG });
+
+// ---- Part D: TCP transport LanDevice ----
+const net = require('node:net');
+const mideaAc = require('./mideaAc');
+
+function withTimeout(promise, ms, label) {
+ return Promise.race([
+ promise,
+ new Promise((_, rej) => setTimeout(() => rej(new Error(`midea ${label} timeout`)), ms)),
+ ]);
+}
+
+class LanDevice {
+ constructor({ ip, port = 6444, deviceId, protocolVersion = 3, token = null, key = null, timeoutMs = 8000 }) {
+ if (protocolVersion === 3 && (!token || !key)) {
+ throw new Error('token and key are required for protocol version 3');
+ }
+ this.ip = ip; this.port = port; this.deviceId = deviceId;
+ this.version = protocolVersion;
+ this.token = token ? Buffer.from(token, 'hex') : null;
+ this.key = key ? Buffer.from(key, 'hex') : null;
+ this.timeoutMs = timeoutMs;
+ this._packetId = 0;
+ }
+
+ _nextPid() { this._packetId = (this._packetId + 1) & 0xfff; return this._packetId; }
+
+ _connect() {
+ return new Promise((resolve, reject) => {
+ const sock = net.createConnection({ host: this.ip, port: this.port }, () => resolve(sock));
+ sock.setTimeout(this.timeoutMs);
+ sock.on('timeout', () => { sock.destroy(); reject(new Error('midea connect timeout')); });
+ sock.on('error', reject);
+ });
+ }
+
+ _readOnce(sock) {
+ return new Promise((resolve, reject) => {
+ let buf = Buffer.alloc(0);
+ const need = (b) => {
+ if (b.length < 6) return Infinity;
+ if (b[0] === 0x83 && b[1] === 0x70) return b.readUInt16BE(2) + 8; // 8370
+ if (b[0] === 0x5a && b[1] === 0x5a) return b.readUInt16LE(4); // 5a5a
+ return b.length;
+ };
+ const onData = (d) => {
+ buf = Buffer.concat([buf, d]);
+ if (buf.length >= need(buf)) { sock.off('data', onData); resolve(buf); }
+ };
+ sock.on('data', onData);
+ sock.once('error', reject);
+ });
+ }
+
+ async _send(sock, payload) {
+ sock.write(payload);
+ return withTimeout(this._readOnce(sock), this.timeoutMs, 'read');
+ }
+
+ async _authenticate(sock) {
+ const req = encodeHandshakeRequest(this.token, this._nextPid());
+ const resp = await this._send(sock, req);
+ const data = decodeHandshakeResponse(resp);
+ return getLocalKey(this.key, data); // localKey
+ }
+
+ // Sends a 0xAA frame, returns parsed AcState.
+ async _command(frame) {
+ const sock = await this._connect();
+ try {
+ let localKey = null;
+ if (this.version === 3) localKey = await this._authenticate(sock);
+ const v2 = encodePacket(Number(this.deviceId) || 0, frame);
+ let reply;
+ if (this.version === 3) {
+ const req = encodeEncryptedRequest(localKey, v2, this._nextPid());
+ const raw = await this._send(sock, req);
+ const innerV2 = decodeEncryptedResponse(localKey, raw);
+ reply = decodePacket(innerV2);
+ } else {
+ const raw = await this._send(sock, v2);
+ reply = decodePacket(raw);
+ }
+ return mideaAc.parseState(reply);
+ } finally {
+ try { sock.destroy(); } catch {}
+ }
+ }
+
+ async getState() {
+ return this._command(mideaAc.buildQuery({ messageId: this._nextPid() & 0xff }));
+ }
+
+ async setState(patch) {
+ const current = await this.getState(); // read
+ const merged = { ...current, ...patch }; // modify
+ await this._command(mideaAc.buildSet(merged, { messageId: this._nextPid() & 0xff })); // write
+ return this.getState(); // confirm
+ }
+}
+
+module.exports = Object.assign(module.exports, { LanDevice });
diff --git a/templates/aurora/layout.njk b/templates/aurora/layout.njk
index 26615cb3..8883811c 100644
--- a/templates/aurora/layout.njk
+++ b/templates/aurora/layout.njk
@@ -50,7 +50,8 @@
gateway_scan_egress: {{ ('true' if license.features.gateway_scan_egress else 'false') | safe }},
l4_routes: {{ license.features.l4_routes | default(0) }},
http_routes: {{ license.features.http_routes | default(0) }},
- browser_sessions: {{ ('true' if license.hasFeature('browser_sessions') else 'false') | safe }}
+ browser_sessions: {{ ('true' if license.hasFeature('browser_sessions') else 'false') | safe }},
+ midea_integration: {{ ('true' if license.features.midea_integration else 'false') | safe }}
},
t: {
'peers.no_peers': {{ t('peers.no_peers') | dump | safe }},
@@ -395,7 +396,27 @@
'printer_preset.step_print': {{ t('printer_preset.step_print') | dump | safe }},
'printer_preset.step_scan': {{ t('printer_preset.step_scan') | dump | safe }},
'printer_preset.step_review': {{ t('printer_preset.step_review') | dump | safe }},
- 'printer_preset.step_device': {{ t('printer_preset.step_device') | dump | safe }}
+ 'printer_preset.step_device': {{ t('printer_preset.step_device') | dump | safe }},
+ 'midea.devices.none': {{ t('midea.devices.none') | dump | safe }},
+ 'midea.devices.add': {{ t('midea.devices.add') | dump | safe }},
+ 'midea.device.test': {{ t('midea.device.test') | dump | safe }},
+ 'midea.device.power': {{ t('midea.device.power') | dump | safe }},
+ 'midea.device.remove': {{ t('midea.device.remove') | dump | safe }},
+ 'midea.device.offline': {{ t('midea.device.offline') | dump | safe }},
+ 'midea.device.indoor': {{ t('midea.device.indoor') | dump | safe }},
+ 'midea.device.target': {{ t('midea.device.target') | dump | safe }},
+ 'midea.device.mode': {{ t('midea.device.mode') | dump | safe }},
+ 'midea.cloud.connected': {{ t('midea.cloud.connected') | dump | safe }},
+ 'midea.cloud.twofa': {{ t('midea.cloud.twofa') | dump | safe }},
+ 'midea.cloud.ratelimit': {{ t('midea.cloud.ratelimit') | dump | safe }},
+ 'midea.mode.auto': {{ t('midea.mode.auto') | dump | safe }},
+ 'midea.mode.cool': {{ t('midea.mode.cool') | dump | safe }},
+ 'midea.mode.heat': {{ t('midea.mode.heat') | dump | safe }},
+ 'midea.mode.dry': {{ t('midea.mode.dry') | dump | safe }},
+ 'midea.mode.fan': {{ t('midea.mode.fan') | dump | safe }},
+ 'midea.discover.result': {{ t('midea.discover.result') | dump | safe }},
+ 'midea.device.on': {{ t('midea.device.on') | dump | safe }},
+ 'midea.device.off': {{ t('midea.device.off') | dump | safe }}
}
};
diff --git a/templates/aurora/pages/midea.njk b/templates/aurora/pages/midea.njk
new file mode 100644
index 00000000..d1afc980
--- /dev/null
+++ b/templates/aurora/pages/midea.njk
@@ -0,0 +1,35 @@
+{% extends theme + "/layout.njk" %}
+
+{% block content %}
+
+
+
+ {{ t('midea.cloud.title') }}
+
+
+
+
+
+ {{ t('midea.devices.title') }}
+
+
+
+{% endblock %}
+
+{% block scripts %}
+
+{% endblock %}
diff --git a/templates/aurora/partials/sidebar.njk b/templates/aurora/partials/sidebar.njk
index 194c83b8..ede5c290 100644
--- a/templates/aurora/partials/sidebar.njk
+++ b/templates/aurora/partials/sidebar.njk
@@ -24,6 +24,9 @@
{% if license.features.pihole_integration %}
{{ t('nav.pihole') }}
{% endif %}
+ {% if license.features.midea_integration %}
+ {{ t('nav.midea') }}
+ {% endif %}
{{ t('nav.logs') }}
{{ t('nav.settings') }}
diff --git a/templates/default/layout.njk b/templates/default/layout.njk
index a07e83a1..838c1fd6 100644
--- a/templates/default/layout.njk
+++ b/templates/default/layout.njk
@@ -49,7 +49,8 @@
gateway_scan_egress: {{ ('true' if license.features.gateway_scan_egress else 'false') | safe }},
l4_routes: {{ license.features.l4_routes | default(0) }},
http_routes: {{ license.features.http_routes | default(0) }},
- browser_sessions: {{ ('true' if license.hasFeature('browser_sessions') else 'false') | safe }}
+ browser_sessions: {{ ('true' if license.hasFeature('browser_sessions') else 'false') | safe }},
+ midea_integration: {{ ('true' if license.features.midea_integration else 'false') | safe }}
},
t: {
'peers.no_peers': {{ t('peers.no_peers') | dump | safe }},
@@ -388,7 +389,27 @@
'printer_preset.step_print': {{ t('printer_preset.step_print') | dump | safe }},
'printer_preset.step_scan': {{ t('printer_preset.step_scan') | dump | safe }},
'printer_preset.step_review': {{ t('printer_preset.step_review') | dump | safe }},
- 'printer_preset.step_device': {{ t('printer_preset.step_device') | dump | safe }}
+ 'printer_preset.step_device': {{ t('printer_preset.step_device') | dump | safe }},
+ 'midea.devices.none': {{ t('midea.devices.none') | dump | safe }},
+ 'midea.devices.add': {{ t('midea.devices.add') | dump | safe }},
+ 'midea.device.test': {{ t('midea.device.test') | dump | safe }},
+ 'midea.device.power': {{ t('midea.device.power') | dump | safe }},
+ 'midea.device.remove': {{ t('midea.device.remove') | dump | safe }},
+ 'midea.device.offline': {{ t('midea.device.offline') | dump | safe }},
+ 'midea.device.indoor': {{ t('midea.device.indoor') | dump | safe }},
+ 'midea.device.target': {{ t('midea.device.target') | dump | safe }},
+ 'midea.device.mode': {{ t('midea.device.mode') | dump | safe }},
+ 'midea.cloud.connected': {{ t('midea.cloud.connected') | dump | safe }},
+ 'midea.cloud.twofa': {{ t('midea.cloud.twofa') | dump | safe }},
+ 'midea.cloud.ratelimit': {{ t('midea.cloud.ratelimit') | dump | safe }},
+ 'midea.mode.auto': {{ t('midea.mode.auto') | dump | safe }},
+ 'midea.mode.cool': {{ t('midea.mode.cool') | dump | safe }},
+ 'midea.mode.heat': {{ t('midea.mode.heat') | dump | safe }},
+ 'midea.mode.dry': {{ t('midea.mode.dry') | dump | safe }},
+ 'midea.mode.fan': {{ t('midea.mode.fan') | dump | safe }},
+ 'midea.discover.result': {{ t('midea.discover.result') | dump | safe }},
+ 'midea.device.on': {{ t('midea.device.on') | dump | safe }},
+ 'midea.device.off': {{ t('midea.device.off') | dump | safe }}
}
};
diff --git a/templates/default/pages/midea.njk b/templates/default/pages/midea.njk
new file mode 100644
index 00000000..eea01793
--- /dev/null
+++ b/templates/default/pages/midea.njk
@@ -0,0 +1,43 @@
+{% extends theme + "/layout.njk" %}
+
+{% block content %}
+
+
+
+
+ {{ t('midea.cloud.title') }}
+
+
+
+
+
+
+ {{ t('midea.devices.title') }}
+
+
+
+{% endblock %}
+
+{% block scripts %}
+
+{% endblock %}
diff --git a/templates/default/partials/sidebar.njk b/templates/default/partials/sidebar.njk
index 7dd573b2..4f8ce2a0 100644
--- a/templates/default/partials/sidebar.njk
+++ b/templates/default/partials/sidebar.njk
@@ -58,6 +58,12 @@
{{ t('nav.pihole') }}
{% endif %}
+ {% if license.features.midea_integration %}
+
+
+ {{ t('nav.midea') }}
+
+ {% endif %}
{{ t('nav.logs') }}
diff --git a/templates/pro/layout.njk b/templates/pro/layout.njk
index 05fd7f83..39bad8c3 100644
--- a/templates/pro/layout.njk
+++ b/templates/pro/layout.njk
@@ -51,7 +51,8 @@
gateway_scan_egress: {{ ('true' if license.features.gateway_scan_egress else 'false') | safe }},
l4_routes: {{ license.features.l4_routes | default(0) }},
http_routes: {{ license.features.http_routes | default(0) }},
- browser_sessions: {{ ('true' if license.hasFeature('browser_sessions') else 'false') | safe }}
+ browser_sessions: {{ ('true' if license.hasFeature('browser_sessions') else 'false') | safe }},
+ midea_integration: {{ ('true' if license.features.midea_integration else 'false') | safe }}
},
t: {
'peers.no_peers': {{ t('peers.no_peers') | dump | safe }},
@@ -390,7 +391,27 @@
'printer_preset.step_print': {{ t('printer_preset.step_print') | dump | safe }},
'printer_preset.step_scan': {{ t('printer_preset.step_scan') | dump | safe }},
'printer_preset.step_review': {{ t('printer_preset.step_review') | dump | safe }},
- 'printer_preset.step_device': {{ t('printer_preset.step_device') | dump | safe }}
+ 'printer_preset.step_device': {{ t('printer_preset.step_device') | dump | safe }},
+ 'midea.devices.none': {{ t('midea.devices.none') | dump | safe }},
+ 'midea.devices.add': {{ t('midea.devices.add') | dump | safe }},
+ 'midea.device.test': {{ t('midea.device.test') | dump | safe }},
+ 'midea.device.power': {{ t('midea.device.power') | dump | safe }},
+ 'midea.device.remove': {{ t('midea.device.remove') | dump | safe }},
+ 'midea.device.offline': {{ t('midea.device.offline') | dump | safe }},
+ 'midea.device.indoor': {{ t('midea.device.indoor') | dump | safe }},
+ 'midea.device.target': {{ t('midea.device.target') | dump | safe }},
+ 'midea.device.mode': {{ t('midea.device.mode') | dump | safe }},
+ 'midea.cloud.connected': {{ t('midea.cloud.connected') | dump | safe }},
+ 'midea.cloud.twofa': {{ t('midea.cloud.twofa') | dump | safe }},
+ 'midea.cloud.ratelimit': {{ t('midea.cloud.ratelimit') | dump | safe }},
+ 'midea.mode.auto': {{ t('midea.mode.auto') | dump | safe }},
+ 'midea.mode.cool': {{ t('midea.mode.cool') | dump | safe }},
+ 'midea.mode.heat': {{ t('midea.mode.heat') | dump | safe }},
+ 'midea.mode.dry': {{ t('midea.mode.dry') | dump | safe }},
+ 'midea.mode.fan': {{ t('midea.mode.fan') | dump | safe }},
+ 'midea.discover.result': {{ t('midea.discover.result') | dump | safe }},
+ 'midea.device.on': {{ t('midea.device.on') | dump | safe }},
+ 'midea.device.off': {{ t('midea.device.off') | dump | safe }}
}
};
diff --git a/templates/pro/pages/midea.njk b/templates/pro/pages/midea.njk
new file mode 100644
index 00000000..eea01793
--- /dev/null
+++ b/templates/pro/pages/midea.njk
@@ -0,0 +1,43 @@
+{% extends theme + "/layout.njk" %}
+
+{% block content %}
+
+
+
+
+ {{ t('midea.cloud.title') }}
+
+
+
+
+
+
+ {{ t('midea.devices.title') }}
+
+
+
+{% endblock %}
+
+{% block scripts %}
+
+{% endblock %}
diff --git a/templates/pro/partials/sidebar.njk b/templates/pro/partials/sidebar.njk
index f249e58d..16ad457a 100644
--- a/templates/pro/partials/sidebar.njk
+++ b/templates/pro/partials/sidebar.njk
@@ -75,6 +75,12 @@
{{ t('nav.pihole') }}
{% endif %}
+ {% if license.features.midea_integration %}
+
+
+ {{ t('nav.midea') }}
+
+ {% endif %}
{{ t('nav.logs') }}
diff --git a/tests/helpers/setup.js b/tests/helpers/setup.js
index 91a9a23f..ddad3bac 100644
--- a/tests/helpers/setup.js
+++ b/tests/helpers/setup.js
@@ -44,6 +44,7 @@ let csrfToken = null;
* Initialize test app, run migrations, seed admin, authenticate
*/
async function setup() {
+ process.env.GC_ENCRYPTION_KEY = process.env.GC_ENCRYPTION_KEY || '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef';
runMigrations();
await seedAdminUser();
@@ -76,6 +77,7 @@ async function setup() {
gateway_pools_limit: 100,
share_links: true,
access_windows: true,
+ midea_integration: true,
});
app = createApp();
diff --git a/tests/midea_ac.test.js b/tests/midea_ac.test.js
new file mode 100644
index 00000000..ae0e3cac
--- /dev/null
+++ b/tests/midea_ac.test.js
@@ -0,0 +1,71 @@
+'use strict';
+const { test } = require('node:test');
+const assert = require('node:assert/strict');
+const ac = require('../src/services/midea/mideaAc');
+
+test('buildQuery matches GetStateCommand vector (msg_id 0x11)', () => {
+ const frame = ac.buildQuery({ messageId: 0x11, tempType: 0x02 });
+ // frame[2] = device type 0xAC, frame[9] = QUERY 0x03
+ assert.equal(frame[2], 0xac);
+ assert.equal(frame[9], 0x03);
+ // documented inner payload incl. msg_id + crc8: ...0311f4
+ const inner = frame.slice(10, -1).toString('hex'); // ohne frame-checksum
+ assert.equal(inner, '418100ff03ff00020000000000000000000000000311f4');
+});
+
+test('parseState decodes all 8 raw C0 payloads', () => {
+ // All vectors from midea-vectors.md Task 3 — C0 payloads (no header)
+ const cases = [
+ ['c00181667f7f003c00000060560400420000000000000048', 16.0, 23.2, 18.4],
+ ['c00191667f7f003c00000060560400440000000000000049', 16.5, 23.4, 18.4],
+ ['c00181667f7f003c0000006156050036000000000000004a', 17.0, 23.6, 18.3],
+ ['c00191667f7f003c0000006156050028000000000000004b', 17.5, 23.8, 18.2],
+ ['c00182667f7f003c0000006156060028000000000000004c', 18.0, 23.8, 18.2],
+ ['c00192667f7f003c0000006156060028000000000000004d', 18.5, 23.8, 18.2],
+ ['c00183667f7f003c0000006156070028000000000000004e', 19.0, 23.8, 18.2],
+ ['c00193667f7f003c00000061570700550000000000000050', 19.5, 23.5, 18.5],
+ ];
+ for (const [hex, target, indoor, outdoor] of cases) {
+ const st = ac.parseState(Buffer.from(hex, 'hex'));
+ assert.equal(st.targetTemp, target, `target for ${hex}`);
+ assert.equal(st.indoorTemp, indoor, `indoor for ${hex}`);
+ assert.equal(st.outdoorTemp, outdoor, `outdoor for ${hex}`);
+ }
+});
+
+test('parseState decodes all 6 full 0xAA frames (strips header/trailer)', () => {
+ // All vectors from midea-vectors.md Task 3 — full AA frames
+ const cases = [
+ ['aa22ac00000000000303c0014566000000300010045eff00000000000000000069fdb9', 21.0, 22.0, null],
+ ['aa23ac00000000000303c00145660000003c0010045c6b20000000000000000000020d79', 21.0, 21.0, 28.5],
+ ['aa1eac00000000000003c0004b1e7f7f000000000069630000000000000d33', 27.0, 27.5, 24.5],
+ ['aa23ac00000000000203c00188647f7f000000000063450c0056190000000000000497c3', 24.0, 24.6, 9.5],
+ ['aa23ac00000000000203c00188647f7f000000000067450c00750000000000000001a3b0', 24.0, 26.5, 9.7],
+ ['aa23ac00000000000203c00188647f7f000080000064450c00501d00000000000001508e', 24.0, 25.0, 9.5],
+ ];
+ for (const [hex, target, indoor, outdoor] of cases) {
+ const st = ac.parseState(Buffer.from(hex, 'hex'));
+ assert.equal(st.targetTemp, target, `target for ${hex.slice(0, 20)}…`);
+ assert.equal(st.indoorTemp, indoor, `indoor for ${hex.slice(0, 20)}…`);
+ assert.equal(st.outdoorTemp, outdoor, `outdoor for ${hex.slice(0, 20)}…`);
+ }
+});
+
+test('parseState sets power boolean on full 0xAA frame', () => {
+ const full = 'aa23ac00000000000303c00145660000003c0010045c6b20000000000000000000020d79';
+ const st = ac.parseState(Buffer.from(full, 'hex'));
+ assert.equal(st.targetTemp, 21.0);
+ assert.equal(typeof st.power, 'boolean');
+});
+
+test('buildSet sets power+target+mode bytes and valid checksum', () => {
+ const frame = ac.buildSet(
+ { power: true, mode: 'cool', targetTemp: 22.0, fanSpeed: ac.FAN.auto },
+ { messageId: 1, beep: false },
+ );
+ assert.equal(frame[2], 0xac);
+ assert.equal(frame[9], 0x02); // CONTROL
+ // Integrity: last byte = frameChecksum(frame[1..-1])
+ const { frameChecksum } = require('../src/services/midea/mideaCrypto');
+ assert.equal(frame[frame.length - 1], frameChecksum(frame.slice(1, -1)));
+});
diff --git a/tests/midea_api.test.js b/tests/midea_api.test.js
new file mode 100644
index 00000000..f735a8d6
--- /dev/null
+++ b/tests/midea_api.test.js
@@ -0,0 +1,49 @@
+'use strict';
+const { test, before, after } = require('node:test');
+const assert = require('node:assert/strict');
+const nodeCrypto = require('node:crypto');
+process.env.GC_ENCRYPTION_KEY = process.env.GC_ENCRYPTION_KEY || nodeCrypto.randomBytes(32).toString('hex');
+const supertest = require('supertest');
+const { setup, teardown } = require('./helpers/setup');
+
+let app, agent, csrfToken;
+before(async () => { ({ app, agent, csrfToken } = await setup()); });
+after(async () => { await teardown(); });
+
+test('GET /api/v1/midea/devices returns array (feature enabled in test)', async () => {
+ const res = await agent.get('/api/v1/midea/devices').expect(200);
+ assert.ok(Array.isArray(res.body.devices));
+});
+
+test('GET /api/v1/midea/status returns shape', async () => {
+ const res = await agent.get('/api/v1/midea/status').expect(200);
+ assert.ok('devices' in res.body);
+ assert.ok('lastPollAt' in res.body);
+});
+
+test('POST /api/v1/midea/devices/:id/test 404 for missing device', async () => {
+ await agent.post('/api/v1/midea/devices/99999/test')
+ .set('x-csrf-token', csrfToken).send({}).expect(404);
+});
+
+test('feature disabled → 403 on midea API', async () => {
+ const license = require('../src/services/license');
+ try {
+ license._overrideForTest({ midea_integration: false });
+ await agent.get('/api/v1/midea/devices').expect(403);
+ } finally {
+ license._overrideForTest({ midea_integration: true });
+ }
+});
+
+// Admin guard test (Spec §9): unauthenticated request must hit the session
+// check (admin guard) BEFORE requireFeature — proves guard ordering is correct.
+test('unauthenticated request → 401 (admin guard precedes requireFeature)', async () => {
+ const anonAgent = supertest(app);
+ const res = await anonAgent.get('/api/v1/midea/devices');
+ assert.equal(res.status, 401);
+});
+
+test('GET /midea renders without 500', async () => {
+ await agent.get('/midea').expect(200);
+});
diff --git a/tests/midea_cloud.test.js b/tests/midea_cloud.test.js
new file mode 100644
index 00000000..2ac32382
--- /dev/null
+++ b/tests/midea_cloud.test.js
@@ -0,0 +1,43 @@
+'use strict';
+
+const { test } = require('node:test');
+const assert = require('node:assert/strict');
+const cloud = require('../src/services/midea/mideaCloud');
+
+test('APP_VARIANTS expose public constants, not secrets', () => {
+ assert.equal(cloud.APP_VARIANTS.msmarthome.appId, '1010');
+ assert.equal(cloud.APP_VARIANTS.nethome.appId, '1017');
+ assert.ok(cloud.APP_VARIANTS.nethome.appKey); // public app key present
+});
+
+test('computeUdpid matches the real msmart vector (big-endian device id)', () => {
+ // device_id 147334558165565 → big-endian 6-byte id = 86000000aa3d
+ // udpid = strxor(sha256(idBytes)[:16], sha256(idBytes)[16:]).hex
+ // This is the SHARP vector — verified against the V3 discovery response tail.
+ const idBytes = Buffer.from('86000000aa3d', 'hex');
+ assert.equal(cloud.computeUdpid(idBytes), '4fbe0d4139de99cc88a0285e14657045');
+ // little-endian sibling, for completeness:
+ assert.equal(cloud.computeUdpid(Buffer.from('3daa00000086', 'hex')), 'b617531f693d3380eed45a7fa2e257b2');
+});
+
+test('getToken builds idBytes for both endians without RangeError', async () => {
+ const c = new cloud.MideaCloud('msmarthome');
+ // Stub the network: always return an empty tokenlist so both endianness
+ // branches run their buffer construction, then fall through to NO_TOKEN.
+ const calls = [];
+ c._request = async (endpoint, body) => { calls.push(body.udpid); return { tokenlist: [] }; };
+ await assert.rejects(
+ () => c.getToken('147334558165565'),
+ (e) => e.code === 'MIDEA_CLOUD_NO_TOKEN',
+ );
+ // Both endianness udpids were attempted (no synchronous RangeError aborted it).
+ assert.equal(calls.length, 2);
+});
+
+test('live login + listDevices', { skip: !process.env.GC_MIDEA_CLOUD }, async () => {
+ const { email, password, app } = JSON.parse(process.env.GC_MIDEA_CLOUD);
+ const c = new cloud.MideaCloud(app);
+ await c.login(email, password);
+ const devs = await c.listDevices();
+ assert.ok(Array.isArray(devs));
+});
diff --git a/tests/midea_crypto.test.js b/tests/midea_crypto.test.js
new file mode 100644
index 00000000..e22079ff
--- /dev/null
+++ b/tests/midea_crypto.test.js
@@ -0,0 +1,36 @@
+'use strict';
+const { test } = require('node:test');
+const assert = require('node:assert/strict');
+const c = require('../src/services/midea/mideaCrypto');
+
+test('ENC_KEY = md5(SIGN_KEY), 16 bytes', () => {
+ assert.equal(c.ENC_KEY.length, 16);
+ assert.equal(c.SIGN_KEY.toString(), 'xhdiwjnchekd4d512chdjx5d8e4c394D2D7S');
+});
+
+test('AES-ECB round-trip', () => {
+ const pt = Buffer.from('hello midea lan!', 'utf8');
+ assert.deepEqual(c.decryptAesEcb(c.encryptAesEcb(pt)), pt);
+});
+
+test('AES-CBC zero-IV no-pad round-trip on 32-byte block', () => {
+ const key = Buffer.alloc(32, 7);
+ const data = Buffer.alloc(32, 9); // bereits 16er-Vielfaches
+ assert.deepEqual(c.decryptAesCbc(key, c.encryptAesCbc(key, data)), data);
+});
+
+test('strxor', () => {
+ assert.deepEqual(
+ c.strxor(Buffer.from([0xff, 0x0f]), Buffer.from([0x0f, 0xff])),
+ Buffer.from([0xf0, 0xf0]),
+ );
+});
+
+test('frameChecksum matches GetStateCommand vector tail', () => {
+ // frame[10:-1] of GetStateCommand (msg_id 0x11) = ...0311f4
+ // CRC8 input is the 22-byte payload (without the trailing CRC byte itself).
+ // Source: msmart/device/AC/test_command.py EXPECTED_PAYLOAD
+ // "418100ff03ff00020000000000000000000000000311f4"
+ const payload = Buffer.from('418100ff03ff00020000000000000000000000000311', 'hex'); // 22 bytes, incl. msg_id 0x11
+ assert.equal(c.crc8(payload), 0xf4);
+});
diff --git a/tests/midea_devices.test.js b/tests/midea_devices.test.js
new file mode 100644
index 00000000..c3e2e099
--- /dev/null
+++ b/tests/midea_devices.test.js
@@ -0,0 +1,123 @@
+'use strict';
+const { test, before, after, beforeEach } = require('node:test');
+const assert = require('node:assert/strict');
+const nodeCrypto = require('node:crypto');
+process.env.GC_ENCRYPTION_KEY = process.env.GC_ENCRYPTION_KEY || nodeCrypto.randomBytes(32).toString('hex');
+const { setup, teardown } = require('./helpers/setup');
+
+let devices;
+before(async () => {
+ await setup(); // läuft Migrationen + seedet Admin
+ devices = require('../src/services/midea/mideaDevices');
+});
+after(async () => { await teardown(); });
+beforeEach(() => {
+ for (const d of devices.listDevices()) devices.removeDevice(d.id);
+});
+
+test('createDevice + getDevice round-trips and decrypts secrets', () => {
+ const d = devices.createDevice({
+ name: 'Wohnzimmer', device_sn: 'SN-TEST-1', device_id: '123456',
+ ip: '192.168.1.50', protocol_version: 3, token: 'deadbeef', key: 'cafef00d', model: 'PortaSplit',
+ });
+ assert.equal(d.name, 'Wohnzimmer');
+ assert.equal(d.enabled, true);
+ const got = devices.getDevice(d.id);
+ assert.equal(got.token, 'deadbeef'); // entschlüsselt zurück
+ assert.equal(got.key, 'cafef00d');
+ assert.equal(got.port, 6444); // Default
+});
+
+test('listDevicesRedacted hides secrets', () => {
+ devices.createDevice({ name: 'X', device_sn: 'SN-2', token: 'aa', key: 'bb' });
+ const [r] = devices.listDevicesRedacted();
+ assert.equal(r.token, undefined);
+ assert.equal(r.key, undefined);
+ assert.equal(r.has_credentials, true);
+});
+
+test('updateDevice patches fields, re-encrypts secrets, toggles enabled, clears token via null', () => {
+ const d = devices.createDevice({
+ name: 'Old', device_sn: 'SN-UPD', ip: '10.0.0.1', token: 'aaaa', key: 'bbbb',
+ });
+ devices.updateDevice(d.id, { name: 'NewName', ip: '10.0.0.9', enabled: false, token: 'feedface', key: '00ff' });
+ let got = devices.getDevice(d.id);
+ assert.equal(got.name, 'NewName');
+ assert.equal(got.ip, '10.0.0.9');
+ assert.equal(got.enabled, false);
+ assert.equal(got.token, 'feedface'); // re-encrypted, round-trips through decrypt
+ assert.equal(got.key, '00ff');
+
+ devices.updateDevice(d.id, { token: null });
+ got = devices.getDevice(d.id);
+ assert.equal(got.token, null); // cleared
+ assert.equal(got.key, '00ff'); // key unchanged
+});
+
+test('config save/load encrypts password, redact hides it', () => {
+ const settings = require('../src/services/settings');
+ const sessionObj = { accessToken: 'tok123', loginId: 'x' };
+ devices.saveConfig({ app: 'msmarthome', email: 'a@b.de', password: 'secret', session: sessionObj });
+ const cfg = devices.loadConfig();
+ assert.equal(cfg.password, 'secret');
+ assert.deepEqual(cfg.session, sessionObj); // round-trips through encryption
+ const red = devices.redactConfig(cfg);
+ assert.equal(red.password, undefined);
+ assert.equal(red.password_set, true);
+ assert.equal(red.email, 'a@b.de');
+ assert.equal(red.session, undefined);
+ assert.equal(red.session_active, true);
+
+ // RAW stored value must be ciphertext (iv:tag:ct), never plaintext secrets.
+ const stored = JSON.parse(settings.get('midea_config'));
+ assert.ok(!stored.password.includes('secret'), 'password stored as ciphertext');
+ assert.ok(!stored.session.includes('tok123'), 'session stored as ciphertext');
+ assert.match(stored.session, /^[0-9a-f]+:[0-9a-f]+:[0-9a-f]+$/);
+});
+
+// ── Orchestrator (Task 9) ──────────────────────────────────────────────────
+
+test('withDeviceLock serializes concurrent operations per device', async () => {
+ const midea = require('../src/services/midea');
+ const order = [];
+ const slow = (tag, ms) => midea.withDeviceLock(1, async () => {
+ order.push(`start-${tag}`); await new Promise((r) => setTimeout(r, ms)); order.push(`end-${tag}`);
+ });
+ await Promise.all([slow('a', 30), slow('b', 5)]);
+ assert.deepEqual(order, ['start-a', 'end-a', 'start-b', 'end-b']); // b waits for a
+});
+
+test('getState returns offline marker when device unreachable', async () => {
+ const midea = require('../src/services/midea');
+ const d = devices.createDevice({ name: 'Z', device_sn: 'SN-OFF', ip: '127.0.0.1', port: 1, protocol_version: 3, token: 'aa', key: 'bb' });
+ const st = await midea.getState(d.id);
+ assert.equal(st.offline, true);
+});
+
+test('addDevice is transactional: a V3 device with no cloud config persists nothing', async () => {
+ const midea = require('../src/services/midea');
+ await assert.rejects(() => midea.addDevice({ sn: 'SN-NOCLOUD', ip: '127.0.0.1' }));
+ assert.equal(devices.listDevices().some((d) => d.device_sn === 'SN-NOCLOUD'), false);
+});
+
+test('getStatus returns the documented shape', () => {
+ const midea = require('../src/services/midea');
+ const status = midea.getStatus();
+ assert.ok(Array.isArray(status.devices));
+ assert.ok('lastPollAt' in status);
+});
+
+test('startPolling is a no-op under revoked license (feature gate)', () => {
+ const license = require('../src/services/license');
+ const midea = require('../src/services/midea');
+ const saved = license.hasFeature('midea_integration');
+ try {
+ license._overrideForTest({ midea_integration: false });
+ midea.startPolling(); // ensurePolling must bail: no timer created
+ const status = midea.getStatus();
+ assert.ok(Array.isArray(status.devices)); // no throw under revoked license
+ } finally {
+ license._overrideForTest({ midea_integration: saved });
+ midea.stopPolling(); // ensure no timer leaks out of the test
+ }
+});
diff --git a/tests/midea_lan.test.js b/tests/midea_lan.test.js
new file mode 100644
index 00000000..15caaa12
--- /dev/null
+++ b/tests/midea_lan.test.js
@@ -0,0 +1,94 @@
+'use strict';
+const { test } = require('node:test');
+const assert = require('node:assert/strict');
+const lan = require('../src/services/midea/mideaLan');
+
+test('V2 packet encode→decode round-trips the frame', () => {
+ const FRAME = Buffer.from('aa21ac8d000000000003418100ff03ff000200000000000000000000000003016971', 'hex');
+ const packet = lan.encodePacket(123456, FRAME);
+ assert.equal(packet.slice(0, 2).toString('hex'), '5a5a');
+ assert.deepEqual(lan.decodePacket(packet), FRAME);
+});
+
+test('decodePacket decodes a known real V2 packet (test_lan.py:26)', () => {
+ const PACKET = Buffer.from('5a5a01116800208000000000000000000000000060ca0000000e0000000000000000000001000000c6a90377a364cb55af337259514c6f96bf084e8c7a899b50b68920cdea36cecf11c882a88861d1f46cd87912f201218c66151f0c9fbe5941c5384e707c36ff76', 'hex');
+ const EXPECTED_FRAME = Buffer.from('aa22ac00000000000303c0014566000000300010045cff2070000000000000008bed19', 'hex');
+ assert.deepEqual(lan.decodePacket(PACKET), EXPECTED_FRAME);
+});
+
+test('V3 decodeEncryptedResponse decodes a known real packet → inner V2 payload (test_lan.py:37)', () => {
+ const LOCAL_KEY = Buffer.from('55a0a178746a424bf1fc6bb74b9fb9e4515965048d24ce8dc72aca91597d05ab', 'hex');
+ const PACKET = Buffer.from('8370008e2063ec2b8aeb17d4e3aff77094dde7fa65cf22671adf807f490a97b927347943626e9b4f58362cf34b97a0d641f8bf0c8fcbf69ad8cca131d2d7baa70ef048c5e3f3dc78da8af4598ff47aee762a0345c18815d91b50a24dedcacde0663c4ec5e73a963dc8bbbea9a593859996eb79dcfcc6a29b96262fcaa8ea6346366efea214e4a2e48caf83489475246b6fef90192b00', 'hex');
+ const EXPECTED_PAYLOAD = Buffer.from('5a5a01116800208000000000eaa908020c0817143daa0000008600000000000000000180000000003e99f93bb0cf9ffa100cb24dbae7838641d6e63ccbcd366130cd74a372932526d98479ff1725dce7df687d32e1776bf68a3fa6fd6259d7eb25f32769fcffef78', 'hex');
+ const innerV2 = lan.decodeEncryptedResponse(LOCAL_KEY, PACKET);
+ assert.deepEqual(innerV2, EXPECTED_PAYLOAD);
+ // and the inner V2 payload decodes to the expected 0xAA frame:
+ const frame = lan.decodePacket(innerV2);
+ assert.equal(frame.slice(0, 3).toString('hex'), 'aa23ac');
+});
+
+test('V3 encodeEncryptedRequest→decodeEncryptedResponse round-trips inner V2 payload', () => {
+ const localKey = Buffer.alloc(32, 0x11);
+ const innerV2 = lan.encodePacket(123456, Buffer.from('aa21ac8d000000000003418100ff03ff000200000000000000000000000003016971', 'hex'));
+ const req = lan.encodeEncryptedRequest(localKey, innerV2, 5555);
+ assert.equal(req.slice(0, 2).toString('hex'), '8370');
+ assert.deepEqual(lan.decodeEncryptedResponse(localKey, req), innerV2);
+});
+
+test('getLocalKey inverts a synthetic handshake response', () => {
+ const { encryptAesCbc, sha256, strxor } = require('../src/services/midea/mideaCrypto');
+ const key = Buffer.alloc(32, 0x42);
+ const session = Buffer.alloc(32, 0x77);
+ const handshakeData = Buffer.concat([encryptAesCbc(key, session), sha256(session)]);
+ const localKey = lan.getLocalKey(key, handshakeData);
+ assert.equal(localKey.length, 32);
+ assert.deepEqual(localKey, strxor(session, key));
+});
+
+test('parseDiscoveryResponse decodes a real V2 discovery response (test_discover.py:12)', () => {
+ const V2 = Buffer.from('5a5a011178007a8000000000000000000000000060ca0000000e0000000000000000000001000000c08651cb1b88a167bdcf7d37534ef81312d39429bf9b2673f200b635fae369a560fa9655eab8344be22b1e3b024ef5dfd392dc3db64dbffb6a66fb9cd5ec87a78000cd9043833b9f76991e8af29f3496', 'hex');
+ const info = lan.parseDiscoveryResponse(V2);
+ assert.equal(info.version, 2);
+ assert.equal(info.port, 6444);
+ assert.equal(info.deviceType, 0xac);
+ assert.equal(info.sn, '000000P0000000Q1F0C9D153F7B40000');
+ assert.equal(String(info.deviceId), '15393162840672');
+ assert.equal(info.ip, '10.100.1.140');
+});
+
+test('detectVersion by magic bytes', () => {
+ assert.equal(lan.detectVersion(Buffer.from('5a5a0111', 'hex')), 2);
+ assert.equal(lan.detectVersion(Buffer.from('837000c8', 'hex')), 3);
+ assert.equal(lan.detectVersion(Buffer.from('3c3f786d', 'hex')), 1); // ' {
+ const { LanDevice } = require('../src/services/midea/mideaLan');
+ assert.throws(() => new LanDevice({ ip: '1.2.3.4', deviceId: '1', protocolVersion: 3 }),
+ /token.*required|key.*required/i);
+});
+
+test('LanDevice V2 constructs without token/key', () => {
+ const { LanDevice } = require('../src/services/midea/mideaLan');
+ const dev = new LanDevice({ ip: '1.2.3.4', deviceId: '42', protocolVersion: 2 });
+ assert.equal(dev.ip, '1.2.3.4');
+ assert.equal(dev.version, 2);
+ assert.equal(dev.token, null);
+ assert.equal(dev.key, null);
+});
+
+test('LanDevice _nextPid wraps at 0xfff', () => {
+ const { LanDevice } = require('../src/services/midea/mideaLan');
+ const dev = new LanDevice({ ip: '1.2.3.4', deviceId: '1', protocolVersion: 2 });
+ dev._packetId = 0xfff;
+ assert.equal(dev._nextPid(), 0); // (0xfff + 1) & 0xfff === 0
+});
+
+test('LanDevice live getState', { skip: !process.env.GC_MIDEA_LIVE }, async () => {
+ const { LanDevice } = require('../src/services/midea/mideaLan');
+ const dev = new LanDevice(JSON.parse(process.env.GC_MIDEA_LIVE)); // {ip,port,deviceId,protocolVersion,token,key}
+ const st = await dev.getState();
+ assert.equal(typeof st.indoorTemp, 'number');
+});