Skip to content

Add nine Korean LG appliances, an ac_common base, and persistent driver stores - #111

Closed
tunarabuuu wants to merge 11 commits into
anszom:masterfrom
tunarabuuu:korean-appliances
Closed

Add nine Korean LG appliances, an ac_common base, and persistent driver stores#111
tunarabuuu wants to merge 11 commits into
anszom:masterfrom
tunarabuuu:korean-appliances

Conversation

@tunarabuuu

@tunarabuuu tunarabuuu commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

This adds support for nine Korean-market LG appliances I've been running daily through rethink, each verified against a live unit with bridge mode on (so every mapping had three witnesses: the value HA set, the tag the appliance reported, and lge_thinq's decode of the same appliance), and each covered by decode-vector regression tests.

Credit where it's due

DHUM_056905_WW builds directly on @BluSyn's #64 — that driver, the tlv_device 0xA7 acceptance, and the HumidifierComponent discovery type all originate there (the in-code comments say so too). This PR's version extends it: properties beyond the panel's reach mapped from the model JSON, English labels, and a ModeTables export that the newer DHUM_231006_WW generation reuses. If #64 lands first I'll gladly rebase on top of it — it deserves to land on its own merits.

New device handlers

model appliance deviceType notes
CST_170004_WW wall/built-in DualCool IDU 401 on a new ac_common base
DHUM_056905_WW dehumidifier 402 builds on #64, see above
DHUM_231006_WW dehumidifier 402 RTL8720cm generation, reuses the 056905 tables
HUM_056905_WW PuriCare humidifier 404 wire map from LG's own modelJSON tlv_* labels
ST_B_E4H01Y_APL Styler S5BBP 203 AA FF frame layout
HWWA9K_F2 CordZero A9 stick vacuum 504 12-field AA..BB frame
1WPU4CIGCR__2 water purifier ATOM-U 103 with local usage history
FAKPK21021 WashTower washer (KR) 221 dedicated 0xEB/0xEC decoder
BDH_D39301_KR WashTower dryer (KR) 222 shared washtower_common base

About ac_common: in #101 you suggested splitting a shared AC base class instead of translating packets to RAC's expectations — this PR contains that split, and CST_170004_WW is its first user. @3735943886's CST_570004_WW ceiling cassette should be able to sit on the same base.

The Korean WashTower models are complementary to #102's WTL_FXU_BDV_NA_01 (different generation, different frame format — these speak a dedicated 0xEB/0xEC encoding decoded in washtower_common).

Infrastructure the drivers stand on

  • TLVDevice: byte 6 may be 0xA7 as well as 0x87 — the dehumidifier family frames with 0xA7 (from Add support for LG dehumidifier DHUM_056905_WW #64).
  • Persistent driver stores: a ReservationStore (absolute reservation deadlines survive restarts, used by the AC) and a PurifierHistoryStore (water-purifier usage history), both small JSON stores threaded through the bridge as optional constructor parameters. Without a bridge storage path the drivers run stateless.
  • Discovery types: HumidifierComponent (from Add support for LG dehumidifier DHUM_056905_WW #64), plus modes/preset_modes on ClimateComponent.

Verification

  • npm test502 passed, 0 failed (251 upstream + 251 new)
  • tsc -p tsconfig.build.json --noEmit — clean
  • prettier --check — clean

Independent of #110 (only trivially-mergeable overlap in cloud/homeassistant.ts type declarations). Happy to split this into per-device PRs if that's easier to review.

🤖 Generated with Claude Code

@maciejsszmigiero

Copy link
Copy Markdown
Collaborator

publishes platform-only tombstone components so an installation that previously ran a raw-capture build has the leftover diagnostic entities cleaned up

Was this raw-capture build upstream code or someone's development code?

If that code was never upstream, then we really needn't/shouldn't include cleanup for it upstream.

@maciejsszmigiero

Copy link
Copy Markdown
Collaborator

As in #110, please split this giant commit (14k lines changed) into smaller one, one commit per logical change as this is basically non-reviewable in this form.

@tunarabuuu

Copy link
Copy Markdown
Contributor Author

You're right — it was never upstream code. Those diagnostic entities only ever existed on my own add-on's development builds (and the drivers here graduated out of that setup), so the cleanup belongs on my side, not here.

Removed in the latest push: dropGenericComponents and cloud/devices/generic.ts are gone, the five drivers publish their discovery config directly, and the tests that asserted the tombstones went with them. Tests are now 502 passed (251 upstream + 251 new), tsc and prettier still clean.

@tunarabuuu

Copy link
Copy Markdown
Contributor Author

Done — split into 13 commits: four infrastructure pieces first (discovery types and the 0xA7 acceptance, both from #64; the two JSON stores; the bridge threading), then one commit per appliance, each carrying its driver, its tests, and its own registry entry. Every intermediate commit type-checks on its own, and the final tree is byte-identical to the previous push — 502 tests passing.

Comment thread cloud/devices/korean.ts Outdated
* Rename a device's components into Korean.
*
* The appliances here are Korean-market units: their own panel, the LG app and the cloud
* integration all use Korean, so a driver that labels half its entities in English leaves the

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can't this be done in HA instead? I know one can re-name devices and their individual components in HA.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It can, but only for the display names, and one entity at a time. These appliances publish a lot of entities (the WashTower pair alone exposes ~230), so "rename it in HA" means a per-installation manual pass that resets on a fresh install or a re-discovery.

The bigger half can't be renamed in HA at all: the state values and select options these drivers publish are Korean labels decoded from the appliance's own enums (the same strings the unit's panel and the LG app show — that's what the tests pin). HA renaming covers an entity's name, not the values flowing through it, so without applyKoreanNames the device reads half-Korean/half-English no matter what you do in the UI.

So the helper only makes the last bit — component display names — consistent with what the rest of the device already speaks. That said, if you'd rather have English defaults with Korean as an option (or a config switch), I'm happy to restructure it that way.

@maciejsszmigiero

Copy link
Copy Markdown
Collaborator

About ac_common: in #101 you suggested splitting a shared AC base class instead of translating packets to RAC's expectations — this PR contains that split

I don't see RAC_056905_WW being covered to ac_common here and that was kind-of the point of having common AC base class.

I also think that introducing ac_common is such an invasive and risky change that it should have its own PR implementing issue #105 - probably coordinating with @3735943886 so it works for their devices too.

tunarabuuu and others added 4 commits August 2, 2026 22:09
Originates in PR anszom#64 by BluSyn; carried here for the humidifier and
dehumidifier drivers.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The dehumidifier family frames with 0xA7 where RAC/WIN use 0x87. From PR anszom#64
by BluSyn.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Usage history accumulates locally in a small JSON store keyed by device id so
daily counters survive restarts; without a storage path drivers run
stateless.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Rides as an optional constructor parameter and reaches any driver exposing a
setter for it; rethink-cloud constructs it from the bridge storage path when
one is configured.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@tunarabuuu

Copy link
Copy Markdown
Contributor Author

Good call — ac_common is out of this PR entirely.

You're right on both counts: I hadn't converted RAC_056905_WW onto the base (so it wasn't actually shared yet), and #105 already exists to do this properly, with @3735943886 and @kkqq9320 working out the design against real captures from several deviceType-401 units. Dropping a parallel ac_common in here would have cut across that.

So I removed ac_common.ts, CST_170004_WW, and the reservation store that only the AC used (its persistence isn't needed by anything else here). This PR is now just the nine non-AC appliances — dehumidifiers, humidifier, styler, vacuum, water purifier, and the WashTower pair — plus the two small shared bits they need (HumidifierComponent and the 0xA7 tag acceptance, both from #64). CST will come back as its own PR on top of whatever #105 lands, and I'm glad to bring my CST_170004_WW captures to that thread.

Rebased to 11 commits (one per appliance + the shared pieces), each type-checks on its own; 485 tests passing.

*
* Only the two enums differ, so that is all this overrides.
*
* Labels are Korean because this model is sold in Korea and its panel, the LG

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The _WW suffix would suggest this is a World Wide device.

It may be sold with panel localized to particular market, but I wouldn't take it as a guarantee that device identifying as DHUM_231006_WW isn't sold in any other market with a different panel language.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same remark for other _WW devices in this PR.

* Only the two enums differ, so that is all this overrides.
*
* Labels are Korean because this model is sold in Korea and its panel, the LG
* app, and the cloud integration all use these names — matching them keeps one

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The official app and HA integration both separate translations from code.

tunarabuuu and others added 7 commits August 2, 2026 23:46
The driver originates in BluSyn's anszom#64 and extends it: properties beyond the
panel's reach mapped from the model JSON, and a ModeTables export the newer
231006 generation reuses. If anszom#64 lands first this rebases on top of it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The RTL8720cm generation of the 056905 dehumidifier; reuses the 056905 mode tables and sends fan writes as bare 0x1fa without the per-mode table.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Wire map taken from LG's own modelJSON tlv_* labels rather than guessed tags.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
AA FF frame layout established with the LG decode oracle; every enum verified against a live unit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Same decode oracle as the Styler; a 12-field AA..BB frame.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Capability-proven settings only, with usage history persisted through the purifier history store.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
FAKPK21021 (washer, deviceType 221) and BDH_D39301_KR (dryer, 222) speak a dedicated 0xEB/0xEC frame encoding decoded in a shared washtower_common base. Complementary to the NA WashTower in anszom#102, which is a different generation with a different frame format.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@tunarabuuu

Copy link
Copy Markdown
Contributor Author

Done — everything is English now, no Korean strings anywhere in these drivers.

You're right that _WW isn't a Korea-only guarantee and that translations belong out of the code. The wire enums are still decoded from LG's ko-KR modelJSON/langpack (that's where the codes and their meanings come from), but every published label — device names, state values, and select options — is now an English translation of that, not the Korean text. The korean.ts helper is gone, and the tests that used to assert labels were Korean now assert they're English.

If a proper i18n layer (canonical keys + a translation table, like the app/HA integration do) is something you'd want, I'm happy to build that as a follow-up — but this at least gets the defaults right for any market.

Re-pushed: still 11 commits (one per appliance + the shared pieces), each type-checks on its own, 485 tests passing.

@anszom anszom left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll submit partial feedback for now. I've tried to review this in one go but it would take much more time than I expected.

After you go through the partial comments, please split this into one PR per device-class (so, one for humidifers, one for washtowers, etc).

/*
* LG PuriCare Water Purifier (ATOM-U STS T20 Cold/Hot/Purified/Steril), ThinQ model 1WPU4CIGCR__2, deviceType 103.
*
* THE COMMAND FRAME, AND WHY IT LOOKED LIKE THERE WASN'T ONE

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's quite a lot of prose, with references to out-of-repo files, and various terms which aren't immediately obvious (Config.supportControl, legacy control path, capability API - I don't know what this refers to, and it isn't necessary to understand the device-side protocol).

Please shorten/remove. Focus on what is needed to understand the protocol.

Comment on lines +160 to +201
const pad2 = (n: number) => String(n).padStart(2, '0')
const kstParts = (time: number) => {
const date = new Date(time + KST_OFFSET_MS)
return {
year: date.getUTCFullYear(),
month: date.getUTCMonth() + 1,
day: date.getUTCDate(),
hour: date.getUTCHours(),
minute: date.getUTCMinutes(),
}
}
const kstDay = (time: number) => {
const p = kstParts(time)
return `${p.year}-${pad2(p.month)}-${pad2(p.day)}`
}
const kstMonth = (time: number) => {
const p = kstParts(time)
return `${p.year}-${pad2(p.month)}`
}
const previousKstMonth = (time: number) => {
const p = kstParts(time)
return kstMonth(Date.UTC(p.year, p.month - 2, 15) - KST_OFFSET_MS)
}
const previousKstMonthStart = (time: number) => {
const p = kstParts(time)
return Date.UTC(p.year, p.month - 2, 1) - KST_OFFSET_MS
}
const nextKstMidnight = (time: number) => {
const shifted = new Date(time + KST_OFFSET_MS)
return Date.UTC(shifted.getUTCFullYear(), shifted.getUTCMonth(), shifted.getUTCDate() + 1) - KST_OFFSET_MS
}
const formatClock = (hour: number, minute: number) => {
const period = hour < 12 ? 'AM' : 'PM'
return `${hour % 12 || 12}:${pad2(minute)} ${period}`
}
const formatKst = (time: number | undefined) => {
// This bridge can only attest to completions it observed. An absent timestamp does not
// prove the appliance has never completed a cycle before collection began.
if (time == null) return 'Not yet observed'
const p = kstParts(time)
return `${p.year}-${pad2(p.month)}-${pad2(p.day)} ${pad2(p.hour)}:${pad2(p.minute)}`
}

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A few comments here.

  1. Time formatting utilities should be moved to somewhere in util/...
  2. Is anything here strictly bound to the KST timezone?
  3. If not, then it would be both simpler & more general to simply operate in the user's local time.

* The base class strips the AA/length prefix before handing the body on, but every offset
* above is stated against the WHOLE frame — that is how the probes recorded them, and
* keeping the two in the same coordinate system is what makes the map checkable. So the
* frame is taken here, unstripped.

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can simply subtract two and now you have offsets relative to the internal AABB payload. No reason to circumvent the regular code path used by all other devices.

Comment on lines +560 to +587
applyPurifierUsageBaseline(day: string, baseline: PurifierDailyUsage) {
if (!this.history || this.historyUnavailable || day !== kstDay(this.clock.now())) return false

const next = copyHistory(this.history)
if (next.usage.day !== day) next.usage = emptyUsage(day)

const pendingUsage = this.pendingUsage
const localUsage = { ...next.usage }
if (pendingUsage?.day === day) {
for (const key of Object.keys(pendingUsage.delta) as (keyof UsageDelta)[])
localUsage[key] += pendingUsage.delta[key]
}
for (const key of Object.keys(baseline) as (keyof PurifierDailyUsage)[])
next.usage[key] = Math.max(localUsage[key], baseline[key])
next.usageComplete = true

// The live driver is the sole owner of both its memory snapshot and JSON file.
// Suppress saveHistory's normal pending-delta fold because it is already included
// in localUsage above; restore it if the atomic save fails.
this.pendingUsage = undefined
if (!this.saveHistory(next)) {
this.pendingUsage = pendingUsage
return false
}
this.history = next
this.publishHistory()
return true
}

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this, along with some other parts appear to be used only in tests. What is the purpose?


export default class Device extends AABBDevice {
private readonly legacyPlatformCleanup: DeviceDiscovery
private historyStore: PurifierHistoryStore | undefined

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was trying to avoid the need to store any persistent history in rethink - usually these things can be supported by HA, and rethink serves only as a simple protocol translator.

So far I think the purifier can be supported in a stateless manner too. HA can handle deltas/accumulated values (even with reset-to-zero, see for example energy consumption for the RAC_...). Any historical data should similarly be retained by HA instead of rethink.

setProperty(prop: string, mqttValue: string) {
const write = (type: number, table: Record<number, string>, what: string) => {
const code = Object.entries(table).find(([, label]) => label === mqttValue)?.[0]
if (code === undefined) return console.warn(`Stick Vacuum: Unknown ${what} '${mqttValue}'`)

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

use the logging utility

*
* WHERE THE BYTE OFFSETS COME FROM
* --------------------------------
* This model's modelJSON has no tlv_* labels (unlike the clip/TLV humidifier and

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please shorten this.

* Offsets above are stated against the whole frame, the way the probes recorded them, so
* the frame is read unstripped rather than through the base class's body view.
*/
processData(buf: Buffer) {

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

processAABB

return this.echo(prop, mqttValue.trim())
}
default:
console.warn(`Styler: Item does not support writing ${prop}`)

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

use the logging utility

Comment on lines +269 to +272

/* LG official maps: FAKPK21021 WASHER_PROTOCOL_EX 8.3 / WASHER_CONVERT_EX 2.3;
* BDH_D39301_KR DRYER_PROTOCOL_EX 4.1 / DRYER_CONVERT_EX 7.8. */
export const WASHER_MODEL: WashTowerModel = {

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Lots of keywords that have no meaning if you don't know the context.

How was this table created?

@anszom

anszom commented Aug 2, 2026

Copy link
Copy Markdown
Owner

I also should have stated this at the beginning: Thank you! I appreciate the effort, this will significantly expand our support base. I apologize if my initial comments sounded dismissive - I got a bit overwhelmed with the scope of the review.

@tunarabuuu

Copy link
Copy Markdown
Contributor Author

Thanks — this is exactly the kind of review I was hoping for. I'll do the split: one PR per device-class, and go through the comments on each as I carve them out. Concretely I'm planning:

  • dehumidifiers (DHUM_056905 + 231006, on the TLV path)
  • humidifier (HUM_056905)
  • styler
  • stick vacuum
  • water purifier
  • washtower (I'll fold washtower_common into a single class as you suggested, since the two are identical today)

On the recurring points, so you don't have to repeat them per PR:

  • processAABB — agreed, I'll route the AABB devices through it instead of the custom decode.
  • logging utility — will switch the stray console.* calls over.
  • long header comments — will trim them to what's needed to read the code.
  • purifier history / stateless — makes sense; I'll drop the history store and see how far a stateless purifier gets. (I keep the persistent version in my own add-on, so no loss on my end.)
  • time utils / KST — I'll move the formatting into util/ and check whether anything is actually KST-bound (I don't think it needs to be).

I'll close this PR once the per-device ones are up, and link them here. Starting with the dehumidifiers.

@tunarabuuu

Copy link
Copy Markdown
Contributor Author

Split complete — closing this in favour of the six per-device-class PRs, each rebased on master with your review comments addressed:

The HumidifierComponent type and the 0xA7 TLV acceptance ride along in #113/#114 and are originally from @BluSyn's #64. Thanks again for the thorough review.

@tunarabuuu tunarabuuu closed this Aug 2, 2026
@3735943886

Copy link
Copy Markdown
Contributor

I would like to integrate your ac_common and cst_170004_ww into my work. Please let me know if you are okay with this. Thank you.

@tunarabuuu

Copy link
Copy Markdown
Contributor Author

@3735943886 Yes, absolutely — please go ahead, and thank you for asking. I'd be glad to see ac_common and CST_170004_WW land as part of your #105 work; that's exactly where they belong.

To make it easy to pull from, I've pushed the code to a reference branch on my fork: tunarabuuu/rethink@share-ac-common (https://github.com/tunarabuuu/rethink/tree/share-ac-common). It has the ac_common base, the CST_170004_WW wall/built-in IDU driver (deviceType 401), and the two small things they lean on — the reservation store and the modes/preset_modes climate discovery fields. It type-checks on current master.

Two notes for integration:

  • Labels there are still Korean (that branch predates the English pass I did on the other drivers); the enum maps are the same protocol either way, and translate the same way. Happy to push an English-labelled version if that's easier for you.
  • The CST_170004_WW test is from before anszom's multi-unit MQTT change merged to master, so it'll need adapting to the new HA mock — nothing about the driver logic, just the harness.

Every enum in CST_170004_WW was swept end to end on a live unit (single-byte injections read back through lge_thinq), and I still have those capture notes — say the word if any of the mappings would help. Whatever shape ac_common ends up in for the 401 family, I'm happy to rebase my wall-AC driver onto it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants