From b7d2961fc927721375734dfd3b0003762bb25bc2 Mon Sep 17 00:00:00 2001 From: CallMeTechie <34693633+CallMeTechie@users.noreply.github.com> Date: Fri, 26 Jun 2026 09:37:53 +0200 Subject: [PATCH 01/14] feat(midea): V60 midea_devices migration + persistence/config service --- src/db/migrationList.js | 22 +++++ src/services/midea/mideaDevices.js | 131 +++++++++++++++++++++++++++++ tests/midea_devices.test.js | 65 ++++++++++++++ 3 files changed, 218 insertions(+) create mode 100644 src/services/midea/mideaDevices.js create mode 100644 tests/midea_devices.test.js 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/services/midea/mideaDevices.js b/src/services/midea/mideaDevices.js new file mode 100644 index 00000000..6e63ffec --- /dev/null +++ b/src/services/midea/mideaDevices.js @@ -0,0 +1,131 @@ +'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); + return { + ...DEFAULT_CONFIG, + ...parsed, + password: parsed.password ? decrypt(parsed.password) : '', + }; +} + +function saveConfig(cfg) { + const toStore = { + app: cfg.app || 'msmarthome', + email: cfg.email || '', + password: cfg.password ? encrypt(cfg.password) : '', + session: 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/tests/midea_devices.test.js b/tests/midea_devices.test.js new file mode 100644 index 00000000..cc78127b --- /dev/null +++ b/tests/midea_devices.test.js @@ -0,0 +1,65 @@ +'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', () => { + devices.saveConfig({ app: 'msmarthome', email: 'a@b.de', password: 'secret', session: null }); + const cfg = devices.loadConfig(); + assert.equal(cfg.password, 'secret'); + const red = devices.redactConfig(cfg); + assert.equal(red.password, undefined); + assert.equal(red.password_set, true); + assert.equal(red.email, 'a@b.de'); +}); From b69d4ee985cf6213c5f33fc407e99b9155601761 Mon Sep 17 00:00:00 2001 From: CallMeTechie <34693633+CallMeTechie@users.noreply.github.com> Date: Fri, 26 Jun 2026 09:49:06 +0200 Subject: [PATCH 02/14] feat(midea): crypto & checksum primitives (AES-ECB/CBC, CRC8, md5-sign) --- src/services/midea/mideaCrypto.js | 80 +++++++++++++++++++++++++++++++ tests/midea_crypto.test.js | 36 ++++++++++++++ 2 files changed, 116 insertions(+) create mode 100644 src/services/midea/mideaCrypto.js create mode 100644 tests/midea_crypto.test.js 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/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); +}); From 188925d448add53c6132b107a870e88afc9ccbeb Mon Sep 17 00:00:00 2001 From: CallMeTechie <34693633+CallMeTechie@users.noreply.github.com> Date: Fri, 26 Jun 2026 10:02:52 +0200 Subject: [PATCH 03/14] feat(midea): AC command builder + state response parser (0xAC) --- src/services/midea/mideaAc.js | 177 ++++++++++++++++++++++++++++++++++ tests/midea_ac.test.js | 71 ++++++++++++++ 2 files changed, 248 insertions(+) create mode 100644 src/services/midea/mideaAc.js create mode 100644 tests/midea_ac.test.js 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/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))); +}); From 5d5ad201b08479f09964c8b044035854fa2e9933 Mon Sep 17 00:00:00 2001 From: CallMeTechie <34693633+CallMeTechie@users.noreply.github.com> Date: Fri, 26 Jun 2026 10:12:21 +0200 Subject: [PATCH 04/14] feat(midea): V2 LAN packet encode/decode --- src/services/midea/mideaLan.js | 44 ++++++++++++++++++++++++++++++++++ tests/midea_lan.test.js | 17 +++++++++++++ 2 files changed, 61 insertions(+) create mode 100644 src/services/midea/mideaLan.js create mode 100644 tests/midea_lan.test.js diff --git a/src/services/midea/mideaLan.js b/src/services/midea/mideaLan.js new file mode 100644 index 00000000..ac90bf20 --- /dev/null +++ b/src/services/midea/mideaLan.js @@ -0,0 +1,44 @@ +'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 }; diff --git a/tests/midea_lan.test.js b/tests/midea_lan.test.js new file mode 100644 index 00000000..c1b0a2f4 --- /dev/null +++ b/tests/midea_lan.test.js @@ -0,0 +1,17 @@ +'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); +}); From b408d1ff12b5da17040ccb1262d97fe030b18add Mon Sep 17 00:00:00 2001 From: CallMeTechie <34693633+CallMeTechie@users.noreply.github.com> Date: Fri, 26 Jun 2026 10:22:33 +0200 Subject: [PATCH 05/14] feat(midea): V3 8370 framing + authenticate handshake (local key) --- src/services/midea/mideaLan.js | 63 ++++++++++++++++++++++++++++++++++ tests/midea_lan.test.js | 29 ++++++++++++++++ 2 files changed, 92 insertions(+) diff --git a/src/services/midea/mideaLan.js b/src/services/midea/mideaLan.js index ac90bf20..abf623e8 100644 --- a/src/services/midea/mideaLan.js +++ b/src/services/midea/mideaLan.js @@ -42,3 +42,66 @@ function decodePacket(packet) { } 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, +}); diff --git a/tests/midea_lan.test.js b/tests/midea_lan.test.js index c1b0a2f4..40421d1b 100644 --- a/tests/midea_lan.test.js +++ b/tests/midea_lan.test.js @@ -15,3 +15,32 @@ test('decodePacket decodes a known real V2 packet (test_lan.py:26)', () => { 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)); +}); From 39fd6e9d0fbd1e9fb339d3e5d2b33520f01a2be6 Mon Sep 17 00:00:00 2001 From: CallMeTechie <34693633+CallMeTechie@users.noreply.github.com> Date: Fri, 26 Jun 2026 10:33:16 +0200 Subject: [PATCH 06/14] feat(midea): UDP discovery + version detection + response parse --- src/services/midea/mideaLan.js | 59 ++++++++++++++++++++++++++++++++++ tests/midea_lan.test.js | 17 ++++++++++ 2 files changed, 76 insertions(+) diff --git a/src/services/midea/mideaLan.js b/src/services/midea/mideaLan.js index abf623e8..abc26642 100644 --- a/src/services/midea/mideaLan.js +++ b/src/services/midea/mideaLan.js @@ -105,3 +105,62 @@ 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 }); diff --git a/tests/midea_lan.test.js b/tests/midea_lan.test.js index 40421d1b..1a5ef2fd 100644 --- a/tests/midea_lan.test.js +++ b/tests/midea_lan.test.js @@ -44,3 +44,20 @@ test('getLocalKey inverts a synthetic handshake response', () => { 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); // ' Date: Fri, 26 Jun 2026 10:39:50 +0200 Subject: [PATCH 07/14] feat(midea): LanDevice TCP transport (connect/auth/getState/setState) --- src/services/midea/mideaLan.js | 102 +++++++++++++++++++++++++++++++++ tests/midea_lan.test.js | 31 ++++++++++ 2 files changed, 133 insertions(+) diff --git a/src/services/midea/mideaLan.js b/src/services/midea/mideaLan.js index abc26642..14183e19 100644 --- a/src/services/midea/mideaLan.js +++ b/src/services/midea/mideaLan.js @@ -164,3 +164,105 @@ function discover({ timeoutMs = 3000, broadcast = '255.255.255.255', ports = [64 } 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/tests/midea_lan.test.js b/tests/midea_lan.test.js index 1a5ef2fd..15caaa12 100644 --- a/tests/midea_lan.test.js +++ b/tests/midea_lan.test.js @@ -61,3 +61,34 @@ test('detectVersion by magic bytes', () => { 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'); +}); From 959038a07d78e830c5acd1793439e4e7e6f7704b Mon Sep 17 00:00:00 2001 From: CallMeTechie <34693633+CallMeTechie@users.noreply.github.com> Date: Fri, 26 Jun 2026 10:50:12 +0200 Subject: [PATCH 08/14] feat(midea): cloud client (login, sign, get_token, udpid) --- src/services/midea/mideaCloud.js | 272 +++++++++++++++++++++++++++++++ tests/midea_cloud.test.js | 43 +++++ 2 files changed, 315 insertions(+) create mode 100644 src/services/midea/mideaCloud.js create mode 100644 tests/midea_cloud.test.js 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/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)); +}); From 2043d90000d89d8a6ce9eb206372e1d29c85eb46 Mon Sep 17 00:00:00 2001 From: CallMeTechie <34693633+CallMeTechie@users.noreply.github.com> Date: Fri, 26 Jun 2026 11:05:16 +0200 Subject: [PATCH 09/14] feat(midea): orchestrator (registry, per-device mutex, poll loop, cloud add) --- src/services/midea/index.js | 267 ++++++++++++++++++++++++++++++++++++ tests/midea_devices.test.js | 47 +++++++ 2 files changed, 314 insertions(+) create mode 100644 src/services/midea/index.js 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/tests/midea_devices.test.js b/tests/midea_devices.test.js index cc78127b..c772feb4 100644 --- a/tests/midea_devices.test.js +++ b/tests/midea_devices.test.js @@ -63,3 +63,50 @@ test('config save/load encrypts password, redact hides it', () => { assert.equal(red.password_set, true); assert.equal(red.email, 'a@b.de'); }); + +// ── 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 + } +}); From 637a26baf486aa43167d06427efc07c1b4edfeb8 Mon Sep 17 00:00:00 2001 From: CallMeTechie <34693633+CallMeTechie@users.noreply.github.com> Date: Fri, 26 Jun 2026 11:19:34 +0200 Subject: [PATCH 10/14] feat(midea): license gating (community fallback, GC flag, sidebar nav) --- src/services/license.js | 1 + templates/aurora/layout.njk | 22 ++++++++++++++++++++-- templates/aurora/partials/sidebar.njk | 3 +++ templates/default/layout.njk | 22 ++++++++++++++++++++-- templates/default/partials/sidebar.njk | 6 ++++++ templates/pro/layout.njk | 22 ++++++++++++++++++++-- templates/pro/partials/sidebar.njk | 6 ++++++ tests/helpers/setup.js | 2 ++ 8 files changed, 78 insertions(+), 6 deletions(-) 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/templates/aurora/layout.njk b/templates/aurora/layout.njk index 26615cb3..dd5fc578 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,24 @@ '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 }} } }; 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..83f5560a 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,24 @@ '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 }} } }; 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..52aeb984 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,24 @@ '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 }} } }; 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(); From aea230e92b142acb8b13e73b60b4738d37ea8272 Mon Sep 17 00:00:00 2001 From: CallMeTechie <34693633+CallMeTechie@users.noreply.github.com> Date: Fri, 26 Jun 2026 11:26:56 +0200 Subject: [PATCH 11/14] feat(midea): admin API router (cloud/discover/devices/state/test) --- src/routes/api/index.js | 1 + src/routes/api/midea.js | 107 ++++++++++++++++++++++++++++++++++++++++ tests/midea_api.test.js | 45 +++++++++++++++++ 3 files changed, 153 insertions(+) create mode 100644 src/routes/api/midea.js create mode 100644 tests/midea_api.test.js 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..cc3cb5d2 --- /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: 'email and 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: '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/tests/midea_api.test.js b/tests/midea_api.test.js new file mode 100644 index 00000000..e456b4c8 --- /dev/null +++ b/tests/midea_api.test.js @@ -0,0 +1,45 @@ +'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); +}); From 0a693ea0c92f63ba5744d990745931ece97757f0 Mon Sep 17 00:00:00 2001 From: CallMeTechie <34693633+CallMeTechie@users.noreply.github.com> Date: Fri, 26 Jun 2026 11:37:16 +0200 Subject: [PATCH 12/14] feat(midea): admin page (3 themes), frontend JS, i18n --- public/js/midea.js | 114 ++++++++++++++++++++++++++++++ src/i18n/de.json | 34 ++++++++- src/i18n/en.json | 34 ++++++++- src/routes/api/midea.js | 4 +- src/routes/index.js | 1 + templates/aurora/layout.njk | 5 +- templates/aurora/pages/midea.njk | 35 +++++++++ templates/default/layout.njk | 5 +- templates/default/pages/midea.njk | 43 +++++++++++ templates/pro/layout.njk | 5 +- templates/pro/pages/midea.njk | 43 +++++++++++ tests/midea_api.test.js | 4 ++ 12 files changed, 320 insertions(+), 7 deletions(-) create mode 100644 public/js/midea.js create mode 100644 templates/aurora/pages/midea.njk create mode 100644 templates/default/pages/midea.njk create mode 100644 templates/pro/pages/midea.njk 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/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/midea.js b/src/routes/api/midea.js index cc3cb5d2..f7bef043 100644 --- a/src/routes/api/midea.js +++ b/src/routes/api/midea.js @@ -49,7 +49,7 @@ function wrap(fn) { 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: 'email and password required' }); + return res.status(400).json({ ok: false, error: req.t('error.midea.email_password_required') }); } res.json(await midea.connectCloud(email, password, app || 'msmarthome')); })); @@ -68,7 +68,7 @@ router.post('/discover', wrap(async (req, res) => { router.post('/devices', wrap(async (req, res) => { const { sn, name, ip } = req.body || {}; if (!sn && !ip) { - return res.status(400).json({ ok: false, error: 'sn or ip required' }); + 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 }) }); })); 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/templates/aurora/layout.njk b/templates/aurora/layout.njk index dd5fc578..8883811c 100644 --- a/templates/aurora/layout.njk +++ b/templates/aurora/layout.njk @@ -413,7 +413,10 @@ '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.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/default/layout.njk b/templates/default/layout.njk index 83f5560a..838c1fd6 100644 --- a/templates/default/layout.njk +++ b/templates/default/layout.njk @@ -406,7 +406,10 @@ '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.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/pro/layout.njk b/templates/pro/layout.njk index 52aeb984..39bad8c3 100644 --- a/templates/pro/layout.njk +++ b/templates/pro/layout.njk @@ -408,7 +408,10 @@ '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.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/tests/midea_api.test.js b/tests/midea_api.test.js index e456b4c8..f735a8d6 100644 --- a/tests/midea_api.test.js +++ b/tests/midea_api.test.js @@ -43,3 +43,7 @@ test('unauthenticated request → 401 (admin guard precedes requireFeature)', as 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); +}); From cea719e57ebb0b666271ab916f23b1887168edf4 Mon Sep 17 00:00:00 2001 From: CallMeTechie <34693633+CallMeTechie@users.noreply.github.com> Date: Fri, 26 Jun 2026 20:04:31 +0200 Subject: [PATCH 13/14] chore(midea): boot wiring, changelog, manual acceptance checklist --- CHANGELOG.md | 7 +++++++ src/server.js | 4 ++++ 2 files changed, 11 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f33b5b5..3fa11f2c 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.0] — 2026-06-26 ### Features 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 From d34d0be70311ef0ad3f77b37f74eedd14fa56564 Mon Sep 17 00:00:00 2001 From: CallMeTechie <34693633+CallMeTechie@users.noreply.github.com> Date: Fri, 26 Jun 2026 20:11:53 +0200 Subject: [PATCH 14/14] fix(midea): encrypt cloud session token at rest --- src/services/midea/mideaDevices.js | 10 +++++++++- tests/midea_devices.test.js | 13 ++++++++++++- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/src/services/midea/mideaDevices.js b/src/services/midea/mideaDevices.js index 6e63ffec..2c709a46 100644 --- a/src/services/midea/mideaDevices.js +++ b/src/services/midea/mideaDevices.js @@ -101,10 +101,18 @@ 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, }; } @@ -113,7 +121,7 @@ function saveConfig(cfg) { app: cfg.app || 'msmarthome', email: cfg.email || '', password: cfg.password ? encrypt(cfg.password) : '', - session: cfg.session || null, + session: cfg.session ? encrypt(JSON.stringify(cfg.session)) : null, }; settings.set(CONFIG_KEY, JSON.stringify(toStore)); } diff --git a/tests/midea_devices.test.js b/tests/midea_devices.test.js index c772feb4..c3e2e099 100644 --- a/tests/midea_devices.test.js +++ b/tests/midea_devices.test.js @@ -55,13 +55,24 @@ test('updateDevice patches fields, re-encrypts secrets, toggles enabled, clears }); test('config save/load encrypts password, redact hides it', () => { - devices.saveConfig({ app: 'msmarthome', email: 'a@b.de', password: 'secret', session: null }); + 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) ──────────────────────────────────────────────────