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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
92 changes: 65 additions & 27 deletions cloud/devices/DHUM_056905_WW.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ const HA_TO_CLIP_MODE: Record<string, number> = {
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[] = [
Expand All @@ -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<number, string>
haToClip: Record<string, number>
/** Mode codes that drop the fan to its lowest setting on entry. */
silent: ReadonlySet<number>
fanOptions: readonly string[]
fanToHa: Record<number, string>
fanToClip: Record<string, number>
/** 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<number>
/** 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
Expand All @@ -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,
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -214,20 +260,14 @@ export default class Device extends TLVDevice {
id: 0x1fa,
name: '',
comp: 'fan_speed',
read_xform: (raw) => {
const modes2ha: Record<number, string> = { 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<string, number> = { 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
},
Expand Down Expand Up @@ -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) {
Expand All @@ -352,15 +390,15 @@ 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 },
{ t: 0x2d9, v: fanSpeed },
]

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))
}

Expand All @@ -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))
}

Expand Down
63 changes: 63 additions & 0 deletions cloud/devices/DHUM_231006_WW.ts
Original file line number Diff line number Diff line change
@@ -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: [],
}
}
2 changes: 2 additions & 0 deletions cloud/ha_bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -67,6 +68,7 @@ const t2deviceTypes: Record<string, T2Factory> = {
// 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 {
Expand Down
106 changes: 106 additions & 0 deletions tests/cloud/devices/DHUM_231006_WW.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, Record<string, unknown>>
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()
})
})
Loading