diff --git a/README.md b/README.md index c55d87d7..58e2e68c 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,7 @@ The following appliances are currently supported in rethink: - ๐Ÿ‘ WKEX200HBA (WTL_FXU_BDV_NA_01), WashTower - mostly working - Dehumidifiers - ๐Ÿ‘ MD19GQGE0, Smart Dehumidifier - mostly working + - ๐Ÿ‘ DHUM_231006_WW, Dehumidifier - mostly working - Range Hoods: - ๐Ÿ‘ HCED3015D (STUDIO_HOOD), Generic identifier and probably works with multiple models. Working. diff --git a/cloud/devices/DHUM_056905_WW.ts b/cloud/devices/DHUM_056905_WW.ts index 5793d3ac..12237b09 100644 --- a/cloud/devices/DHUM_056905_WW.ts +++ b/cloud/devices/DHUM_056905_WW.ts @@ -61,7 +61,7 @@ const HA_TO_CLIP_MODE: Record = { type ModeFanCap = { mode: number /** When set, 0x2d9 is always this value; otherwise it follows the requested fan. */ - fixedFan?: 2 | 6 + fixedFan?: number } const MODE_FAN_CAPS: readonly ModeFanCap[] = [ @@ -72,14 +72,57 @@ const MODE_FAN_CAPS: readonly ModeFanCap[] = [ { mode: 22 }, // present on-wire; not exposed in app ] +/** + * Everything model-specific on this platform, so a sibling model can reuse the whole + * driver and override just its enums. DHUM_231006_WW shares the TLV map but numbers + * its modes differently and offers five fan speeds where this model has two. + */ +export type ModeTables = { + haModes: readonly string[] + clipToHa: Record + haToClip: Record + /** Mode codes that drop the fan to its lowest setting on entry. */ + silent: ReadonlySet + fanOptions: readonly string[] + fanToHa: Record + fanToClip: Record + /** Label and code of the lowest fan speed. */ + lowFan: string + lowFanClip: number + /** Codes this model accepts for a fan write; anything else is dropped. */ + fanClipValues: ReadonlySet + /** Capability rows to send alongside 0x1fa; empty for a model with no capture of them. */ + modeFanCaps: readonly ModeFanCap[] +} + function normalizeHaMode(val: string): string { - return val.charAt(0).toUpperCase() + val.slice(1).toLowerCase() + return val.replace(/\S+/g, (w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase()) } /** * LG Dehumidifier DHUM_056905_WW (e.g. models using 056905 platform, deviceType 403) */ export default class Device extends TLVDevice { + /** Override in a subclass for a model whose codes differ. */ + static modeTables: ModeTables = { + haModes: HA_MODES, + clipToHa: CLIP_TO_HA_MODE, + haToClip: HA_TO_CLIP_MODE, + silent: SILENT_MODES, + fanOptions: ['low', 'high'], + fanToHa: { 2: 'low', 6: 'high' }, + fanToClip: { low: 2, high: 6 }, + lowFan: 'low', + lowFanClip: 2, + fanClipValues: new Set([2, 6]), + modeFanCaps: MODE_FAN_CAPS, + } + + /** Resolves against the actual class, so a subclass override wins. */ + modes(): ModeTables { + return (this.constructor as typeof Device).modeTables + } + powerStatePrev?: boolean modePrev?: string modeClipPrev?: number @@ -97,7 +140,7 @@ export default class Device extends TLVDevice { unique_id: '$deviceid-humidifier', name: null, device_class: 'dehumidifier', - modes: [...HA_MODES], + modes: [...this.modes().haModes], min_humidity: 30, max_humidity: 70, } satisfies HumidifierComponent, @@ -125,7 +168,7 @@ export default class Device extends TLVDevice { unique_id: '$deviceid-fan_speed', name: 'Fan speed', icon: 'mdi:fan', - options: ['low', 'high'], + options: [...this.modes().fanOptions], }, current_humidity: { platform: 'sensor', @@ -176,15 +219,15 @@ export default class Device extends TLVDevice { id: 0x1f9, name: 'mode', comp: 'humidifier', - read_xform: (raw) => CLIP_TO_HA_MODE[raw] ?? `mode${raw}`, + read_xform: (raw) => this.modes().clipToHa[raw] ?? `mode${raw}`, read_callback: () => { const mode = this.raw_clip_state[0x1f9] if ( mode != null && - SILENT_MODES.has(mode) && - (this.modeClipPrev == null || !SILENT_MODES.has(this.modeClipPrev)) + this.modes().silent.has(mode) && + (this.modeClipPrev == null || !this.modes().silent.has(this.modeClipPrev)) ) { - this.publishFanSpeedState('low') + this.publishFanSpeedState(this.modes().lowFan) } if (mode != null) this.modeClipPrev = mode return true @@ -199,10 +242,13 @@ export default class Device extends TLVDevice { this.raw_clip_state[0x1f7] = 1 const mode = normalizeHaMode(val) - const clip = HA_TO_CLIP_MODE[mode] ?? Number(val) - if (mode === 'Silent' && (this.modeClipPrev == null || !SILENT_MODES.has(this.modeClipPrev))) { - this.raw_clip_state[0x1fa] = 2 - this.publishFanSpeedState('low') + const clip = this.modes().haToClip[mode] ?? Number(val) + if ( + this.modes().silent.has(clip) && + (this.modeClipPrev == null || !this.modes().silent.has(this.modeClipPrev)) + ) { + this.raw_clip_state[0x1fa] = this.modes().lowFanClip + this.publishFanSpeedState(this.modes().lowFan) } if (typeof clip === 'number') this.modeClipPrev = clip return clip @@ -214,20 +260,14 @@ export default class Device extends TLVDevice { id: 0x1fa, name: '', comp: 'fan_speed', - read_xform: (raw) => { - const modes2ha: Record = { 2: 'low', 6: 'high' } - return modes2ha[raw] ?? raw.toString() - }, + read_xform: (raw) => this.modes().fanToHa[raw] ?? raw.toString(), read_callback: (val) => { this.publishFanSpeedState(typeof val === 'string' ? val : String(val)) return false }, - write_xform: (val) => { - const modes2clip: Record = { low: 2, high: 6 } - return modes2clip[val] ?? Number(val) - }, + write_xform: (val) => this.modes().fanToClip[val] ?? Number(val), write_callback: (val) => { - if (val !== 2 && val !== 6) return false + if (!this.modes().fanClipValues.has(val)) return false this.sendFanSpeedTlvs(val) return false }, @@ -334,9 +374,7 @@ export default class Device extends TLVDevice { private fanSpeedFromClip(raw?: number): string { const v = raw ?? this.raw_clip_state[0x1fa] - if (v === 6) return 'high' - if (v === 2) return 'low' - return v != null ? String(v) : 'low' + return this.modes().fanToHa[v] ?? (v != null ? String(v) : this.modes().lowFan) } private publishFanSpeedState(override?: string) { @@ -352,7 +390,7 @@ export default class Device extends TLVDevice { * modeFan(21, 6), // Laundry always high * modeFan(22, 2) */ - private buildFanSpeedTlvs(fan: 2 | 6): TLV.TLV[] { + private buildFanSpeedTlvs(fan: number): TLV.TLV[] { const modeFanRow = (mode: number, fanSpeed: number): TLV.TLV[] => [ { t: 0x2d7, v: mode }, { t: 0x2d8, v: 0 }, @@ -360,7 +398,7 @@ export default class Device extends TLVDevice { ] const tlvs: TLV.TLV[] = [{ t: 0x1fa, v: fan }] - for (const { mode, fixedFan } of MODE_FAN_CAPS) { + for (const { mode, fixedFan } of this.modes().modeFanCaps) { tlvs.push(...modeFanRow(mode, fixedFan ?? fan)) } @@ -369,7 +407,7 @@ export default class Device extends TLVDevice { return tlvs } - private sendFanSpeedTlvs(fan: 2 | 6) { + private sendFanSpeedTlvs(fan: number) { this.send([1, 1, 2, 1, 1], this.buildFanSpeedTlvs(fan)) } diff --git a/cloud/devices/DHUM_231006_WW.ts b/cloud/devices/DHUM_231006_WW.ts new file mode 100644 index 00000000..53d7d110 --- /dev/null +++ b/cloud/devices/DHUM_231006_WW.ts @@ -0,0 +1,63 @@ +import Device056905, { type ModeTables } from './DHUM_056905_WW' + +/** + * LG Dehumidifier DHUM_231006_WW (deviceType 403, BEKEN_BK7234 module, protocolVer 7 โ€” + * a newer generation than the RTL8720cm the wiki documents). + * + * Shares the 056905 TLV map. Verified against the LG cloud's own decode of the same unit + * while bridged, every field agreeing: + * + * 0x1f7 power airState.operation 1 -> ON + * 0x253 target humidity tracked 45 / 55 / 65 exactly on each write + * 0x1fa fan speed airState.windStrength + * 0x1f9 mode airState.opMode + * ionizer / UVnano / bucket light matched airState.* one for one + * + * Only the two enums differ, so that is all this overrides. Codes were measured by driving + * each option through the cloud integration and reading back the matching airState field: + * + * mode Silent 19 ยท Intensive 20 ยท Quick 85 ยท Smart Plus 86 + * fan mid 4 ยท high 6 ยท turbo 7 ยท auto 8 + * + * 19/20 coincide with the 056905's silent/spot modes; 85/86 are where this model diverges + * (056905 uses 21/17). There is no Jet equivalent here, so it is left out rather than + * offered and silently rejected. + * + * low = 2 is carried over from the 056905: a direct write of it read back as 8, but only + * while in Smart Plus, where the appliance forces the fan to automatic โ€” so the read was + * the coercion, not the code. Worth re-checking from a manual mode if low ever misbehaves. + */ +export default class Device extends Device056905 { + static modeTables: ModeTables = { + haModes: ['Smart Plus', 'Silent', 'Intensive', 'Quick'], + clipToHa: { + 19: 'Silent', + 20: 'Intensive', + 85: 'Quick', + 86: 'Smart Plus', + }, + haToClip: { + 'Smart Plus': 86, + Silent: 19, + Intensive: 20, + Quick: 85, + }, + // Entering Silent drops the fan to its lowest setting, as the 056905 does. + silent: new Set([19]), + + fanOptions: ['auto', 'low', 'mid', 'high', 'turbo'], + fanToHa: { 2: 'low', 4: 'mid', 6: 'high', 7: 'turbo', 8: 'auto' }, + fanToClip: { low: 2, mid: 4, high: 6, turbo: 7, auto: 8 }, + lowFan: 'low', + lowFanClip: 2, + + // Five speeds, not the 056905's two. + fanClipValues: new Set([2, 4, 6, 7, 8]), + + // The 056905 rewrites its whole per-mode fan-memory table on every fan change. No + // capture shows this model doing that, and its mode codes are different anyway, so + // emitting that table here would be inventing traffic. Send 0x1fa alone until a + // panel capture says otherwise. + modeFanCaps: [], + } +} diff --git a/cloud/ha_bridge.ts b/cloud/ha_bridge.ts index 163229de..3c406f04 100644 --- a/cloud/ha_bridge.ts +++ b/cloud/ha_bridge.ts @@ -22,6 +22,7 @@ import RV13B6BSD_D_US_WIFI from './devices/RV13B6BSD_D_US_WIFI' import RV13B6ES_D_US_WIFI from './devices/RV13B6ES_D_US_WIFI' import WTL_FXU_BDV_NA_01 from './devices/WTL_FXU_BDV_NA_01' import DHUM_056905_WW from './devices/DHUM_056905_WW' +import DHUM_231006_WW from './devices/DHUM_231006_WW' import { Device as T1Device } from './thinq1/device' import { Device as T2Device } from './thinq2/device' import { type Connection } from './homeassistant' @@ -67,6 +68,7 @@ const t2deviceTypes: Record = { // Wrinkle Care sits in a different bitfield, so it needs its own handler rather than an alias WTL_FXU_BDV_NA_01, // LG WashTower DHUM_056905_WW, + DHUM_231006_WW, // same TLV map as the 056905, different mode and fan enums } class Bridge { diff --git a/tests/cloud/devices/DHUM_231006_WW.test.ts b/tests/cloud/devices/DHUM_231006_WW.test.ts new file mode 100644 index 00000000..e681afb1 --- /dev/null +++ b/tests/cloud/devices/DHUM_231006_WW.test.ts @@ -0,0 +1,106 @@ +import { describe, test } from 'node:test' +import assert from 'node:assert/strict' +import DUT from '@/cloud/devices/DHUM_231006_WW' +import type { Metadata } from '@/cloud/thinq' +import * as TLV from '@/util/tlv' +import { MockHAConnection, MockThinq2Device, buf } from '@/tests/helpers/mocks' +import { enableMockTimers, tickMockTimers } from '@/tests/helpers/timers' + +const DEVICE_ID = 'test-id' +const MODEL_ID = 'DHUM_231006_WW' +const META: Metadata = { modelId: MODEL_ID, modelName: 'TEST DHUM', swVersion: '1.0' } + +// Real 056905-platform captures; this model shares the initialization path. +const CAPS_RESPONSE_HEX = '000004000000A70201000AB6A00A7CB541B5A004023220' +const QUERY_RESPONSE_HEX = '00000400000087020400117DC17E50117E827F503094D023D801A8803F5E' + +const MODES = [ + ['Smart Plus', 86], + ['Silent', 19], + ['Intensive', 20], + ['Quick', 85], +] as const + +const FANS = [ + ['auto', 8], + ['low', 2], + ['mid', 4], + ['high', 6], + ['turbo', 7], +] as const + +function writtenFields(thinq: MockThinq2Device) { + const packet = thinq.outbox[thinq.outbox.length - 1] + assert.ok(packet, 'a packet was sent') + return TLV.parse(packet.subarray(11, packet.length - 2)).map(({ t, v }) => ({ t, v })) +} + +function buildReadyDevice(t: import('node:test').TestContext) { + enableMockTimers(t) + const ha = new MockHAConnection() + const thinq = new MockThinq2Device(DEVICE_ID, META) + const dev = new DUT(ha.asConnection(), thinq, META) + thinq.resetRecorder() + thinq.emit('data', buf(CAPS_RESPONSE_HEX)) + thinq.emit('data', buf(QUERY_RESPONSE_HEX)) + tickMockTimers(t, 6000) + thinq.resetRecorder() + return { ha, thinq, dev } +} + +describe(MODEL_ID, () => { + test('config exposes the four measured modes and five measured fan speeds', (t) => { + const { ha, dev } = buildReadyDevice(t) + const components = ha.devices[DEVICE_ID]!.config!.components as Record> + assert.deepEqual( + components.humidifier.modes, + MODES.map(([label]) => label), + ) + assert.deepEqual( + components.fan_speed.options, + FANS.map(([label]) => label), + ) + dev.drop() + }) + + test('every measured mode code decodes to its label', (t) => { + const { ha, dev } = buildReadyDevice(t) + for (const [label, wire] of MODES) { + dev.processKeyValue(0x1f9, wire) + assert.equal(ha.devices[DEVICE_ID]!.properties['humidifier-mode'], label) + } + dev.drop() + }) + + test('every mode label writes its measured wire code', (t) => { + const { thinq, dev } = buildReadyDevice(t) + for (const [label, wire] of MODES) { + thinq.resetRecorder() + dev.setProperty('humidifier-mode', label) + assert.deepEqual(writtenFields(thinq), [ + { t: 0x1f9, v: wire }, + { t: 0x1f7, v: 1 }, + ]) + } + dev.drop() + }) + + test('every measured fan code decodes to its label', (t) => { + const { ha, dev } = buildReadyDevice(t) + for (const [label, wire] of FANS) { + dev.processKeyValue(0x1fa, wire) + assert.equal(ha.devices[DEVICE_ID]!.properties['fan_speed-'], label) + } + dev.drop() + }) + + test('fan writes send 0x1fa alone, without the 056905 per-mode table', (t) => { + const { thinq, dev } = buildReadyDevice(t) + for (const [label, wire] of FANS) { + thinq.resetRecorder() + dev.setProperty('fan_speed-', label) + assert.deepEqual(writtenFields(thinq), [{ t: 0x1fa, v: wire }]) + } + dev.drop() + }) +})