From c88516aba12040213a80d8efd8e5bda7c57f4848 Mon Sep 17 00:00:00 2001 From: tunarabuuu <304476436+tunarabuuu@users.noreply.github.com> Date: Mon, 3 Aug 2026 06:28:32 +0900 Subject: [PATCH] Add LG CordZero A9 stick vacuum HWWA9K_F2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An AA..BB ThinQ2 stick vacuum (deviceType 504). The byte layout was recovered by injecting single-byte-changed state frames through the management API and reading LG cloud's decode back, one offset per observation. The appliance sends the current record last, with the previous record ahead of it after a setting changes, so the trailing record is decoded. Reads go through the base class's processAABB — the frame arrives with its leading AA/length and trailing CRC/BB stripped, so byte 0 is the class tag and the record offsets need no whole-frame adjustment. The 44-byte capability list the appliance also sends is rejected by its record-count guard. Warnings go through the logging utility. Co-Authored-By: Claude Opus 5 --- cloud/devices/HWWA9K_F2.ts | 329 ++++++++++++++++++++++++++ cloud/ha_bridge.ts | 2 + tests/cloud/devices/HWWA9K_F2.test.ts | 307 ++++++++++++++++++++++++ 3 files changed, 638 insertions(+) create mode 100644 cloud/devices/HWWA9K_F2.ts create mode 100644 tests/cloud/devices/HWWA9K_F2.test.ts diff --git a/cloud/devices/HWWA9K_F2.ts b/cloud/devices/HWWA9K_F2.ts new file mode 100644 index 0000000..6db56f4 --- /dev/null +++ b/cloud/devices/HWWA9K_F2.ts @@ -0,0 +1,329 @@ +import { Device as Thinq2Device } from '../thinq2/device' +import log from '@/util/logging' +import { DeviceDiscovery, type Connection } from '../homeassistant' +import { type Metadata } from '../thinq' +import { allowExtendedType } from '@/util/casting' +import HADevice from './base' +import AABBDevice from './aabb_device' + +/* + * LG CordZero A9 Stick Vacuum, ThinQ model HWWA9K_F2, deviceType 504. + * + * The byte layout was recovered by injecting single-byte-changed state frames through the + * management API and reading LG cloud's decode back, one offset per observation: + * + * byte[6]=3 -> monStatus CHARGING(3) byte[7]=3 -> cleanMode HIGH(3) + * byte[8]=3 -> filterState 3 byte[9]=3 -> passageClogged 3 + * byte[10]=3 -> nozzle WATER_MOP(3) byte[11]=3 -> batteryLevel LOW(3) + * byte[12]=3 -> mopWithSucking 3 byte[13]=3 -> completeClean 3 + * byte[14]=3 -> suctionForce TURBO(3) byte[15]=3 -> chargingMelody MELODY_3(3) + * byte[16]=3 -> volume LOW(3) byte[17]=2 -> brightness HIGH(2) + * + * Base frame (real, captured 2026-07-28): + * aa14d2eb00 0c 04 01 01 01 ff 00 01 01 02 01 01 03 c3bb + * + * byte[5] = 0x0c = 12 = the number of fields that follow, and twelve is exactly how many + * `qmState.*` keys LG publishes for this unit. One byte per field, no padding. + * + * The other frame this appliance sends, `aa ff d2 0a 00 2c 00 …` (44 bytes), announces which + * settings exist: a count of 4 followed by `[len]["VC-n"][value]` records, and modelJSON's own + * comments name suctionForce, chargingMelody, volume and brightness as "VC-1".."VC-4". Its + * values are NOT the live settings — every record reads 01 in captures where the state frame + * says suction 2 and brightness 3 — so it is a capability list and is deliberately ignored. + * + * WRITE DIRECTION + * --------------- + * modelJSON declares five commands (MOP_SETTING, SUCTION_FORCE, CHARGING_MELODY, VOLUME, + * BRIGHTNESS), each `controlDataValueLength: 1`, and all five were captured on 2026-07-28 — + * every option of every one, seventeen frames. + * + * They came from LG's own cloud. `convert/control` converts a capability command into this + * appliance's protocol and sends it down, and rethink relays it, so the frame below is LG's + * encoding rather than this driver's guess: + * + * aa 09 f0 24 bb + * + * 01 MOP_SETTING off -> 1 on -> 2 + * 02 SUCTION_FORCE normal 1 high 2 turbo 3 + * 03 CHARGING_MELODY lucky 1 bead 2 ice 3 brisa 4 nebula 5 + * 04 VOLUME high 1 medium 2 low 3 + * 05 BRIGHTNESS veryHigh 1 high 2 medium 3 low 4 off 5 + * + * THE VALUE IS THE READ MAP'S OWN CODE, in every case — so the write map and the read map + * check each other, and the appliance's echo (below) confirms it a third time. + * + * The earlier note here said control was refused. It was: with the argument names the aircon + * uses (`{"setOnOff":{"onOff":"on"}}`). This family wants what `convert/capabilitySchema` + * declares, and answers CL-0000 once asked that way. + * + * `entity_category: 'config'` is used ONLY on the settings that are now selects. + * Home Assistant accepts that category only on a platform that can change something. On a + * sensor or binary_sensor it writes the entity into the registry and then never brings it up: + * the first live rebuild of these drivers lost ten entities that way, across this model and the + * styler, registered but missing from the state machine and silent in the log. Read-only + * settings use 'diagnostic'. The test suite asserts it, because nothing on the add-on side can. + */ + +/** processAABB receives the frame with its leading AA/length and trailing CRC/BB stripped, + * so byte 0 is the class tag and there are no leftover header/trailer bytes to skip. */ +const HEADER_LEN = 2 +const TRAILER_LEN = 0 +/** buf[2] on the frames this appliance sends. */ +const CLASS_TAG = 0xd2 +/** + * One state record: ` <12 field bytes>`. + * + * THE RECORD COUNT VARIES, SO IT IS COUNTED RATHER THAN ASSUMED. + * At rest the appliance sends ONE record (20-byte frame, tag `d2 eb`). Right after a setting + * changes it sends TWO (34-byte frame, tag `d2 ec`) — previous record, then current: + * + * aa22d2ec00 0c 04 01 01 01 ff 00 02 01 01 03 03 04 + * 00 0c 04 01 01 01 ff 00 02 01 02 03 03 04 9ebb + * + * That is the same eb/ec convention the water purifier and the styler use in this repo, and + * the same trap: a decoder keyed on "20 bytes, tag eb" ignores the echo and so goes blind at + * exactly the moment a write lands. Measured 2026-07-28 — the frame above is the appliance's + * answer to five commands this driver had just sent. + */ +const RECORD_LEN = 14 +/** Fields start 2 bytes into the record, after its index and field-count bytes. */ +const FIELDS_OFF = 2 +const FIELD_COUNT = 12 + +/** Offsets WITHIN a record. On the 20-byte single-record frame the record starts at 4, which + * is why the header comment states these as byte[6]..byte[17] — that is how the probes + * recorded them. */ +const OFF = { + monStatus: 2, + cleanMode: 3, + filterState: 4, + passageClogged: 5, + nozzle: 6, + batteryLevel: 7, + mopWithSucking: 8, + completeClean: 9, + suctionForce: 10, + chargingMelody: 11, + volume: 12, + brightness: 13, +} as const + +/** Command frame opcode: `aa 09 f0 24 01 bb`. See the header note. */ +const SET_STATE = [0xf0, 0x24] + +/** modelJSON `ControlWifi` controlDataType ids, as the captured frames carry them. */ +const CTRL = { + mopSetting: 0x01, + suctionForce: 0x02, + chargingMelody: 0x03, + volume: 0x04, + brightness: 0x05, +} as const + +// Every table below is modelJSON `MonitoringValue..valueMapping`, code for code, with +// English labels for the text resolved from this model's ko-KR language pack. Where LG's `label` is blank +// or points at another field's key (cleanMode OFF and NORMAL share @HS_TREM_NOR_W; filterState +// and passageClogged and batteryLevel have literal English labels), the mapping's own +// `_comment` is used instead — that is LG's text either way. + +/** qmState.monStatus */ +const MON_STATUS: Record = { 1: 'Standby', 2: 'Cleaning', 3: 'Charging', 4: 'Charging complete' } + +/** qmState.cleanMode — the suction level actually running right now. */ +const CLEAN_MODE: Record = { 1: 'Off', 2: 'Standard', 3: 'High', 4: 'Turbo', 5: 'Mop', 6: 'Auto' } + +/** qmState.filterState */ +const FILTER_STATE: Record = { 1: 'Normal', 2: 'Cleaning needed' } + +/** qmState.passageClogged */ +const PASSAGE: Record = { 1: 'Normal', 2: 'Check for debris' } + +/** qmState.nozzle — which head is attached. */ +const NOZZLE: Record = { + 0: 'Auxiliary inlet', + 1: 'PowerDrive Floor', + 2: 'PowerDrive Carpet', + 3: 'PowerDrive Mop', +} + +/** qmState.batteryLevel. This unit reports 0 while docked, which LG surfaces as NOT_USE. */ +const BATTERY: Record = { 0: 'Off', 1: 'High', 2: 'Mid', 3: 'Low', 4: 'Warning' } + +/** qmState.mopWithSucking */ +const MOP_WITH_SUCKING: Record = { 1: 'Mop only', 2: 'Mop and suction together' } + +/** qmState.suctionForce — the default suction level the product starts at (LG's "VC-1"). */ +const SUCTION_FORCE: Record = { 1: 'Standard', 2: 'High', 3: 'Turbo' } + +/** qmState.chargingMelody (LG's "VC-2"); names from @HS_UX30_CHARGING_MELODY_n_W. */ +const CHARGING_MELODY: Record = { + 1: 'Lucky', + 2: 'Marble', + 3: 'Ice', + 4: 'Breeze', + 5: 'Nebula', +} + +/** qmState.volume (LG's "VC-3") */ +const VOLUME: Record = { 1: 'High', 2: 'Normal', 3: 'Low' } + +/** qmState.brightness (LG's "VC-4"). Code 5 is OFF; LG's own label key for it is missing + * from this model's pack, so the mapping's `_comment` ("LED Brightness off") supplies the text. */ +const BRIGHTNESS: Record = { + 1: 'Very bright', + 2: 'Bright', + 3: 'Normal', + 4: 'Dim', + 5: 'Off', +} + +/** LG's sentinel for "not reported": 0 on most of this model's enums, 255 on nozzle. */ +const enumOf = (table: Record, raw: number) => + table[raw] ?? (raw === 0 || raw === 0xff ? 'Unknown' : `Code ${raw}`) + +export default class Device extends AABBDevice { + constructor(HA: Connection, thinq: Thinq2Device, meta: Metadata) { + super(HA, thinq) + + const sensor = (id: string, name: string, extra: object = {}) => ({ + platform: 'sensor', + unique_id: `$deviceid-${id}`, + state_topic: `$this/${id}`, + name, + ...extra, + }) + + /** A setting the appliance takes a command for: the options are the English labels of + * its own read table, so the state it reports is always one of them. */ + const select = (id: string, name: string, table: Record, extra: object = {}) => ({ + platform: 'select', + unique_id: `$deviceid-${id}`, + state_topic: `$this/${id}`, + command_topic: `$this/${id}/set`, + name, + options: Object.values(table), + ...extra, + }) + + this.setConfig( + allowExtendedType({ + ...HADevice.config(meta, { name: 'LG Stick Vacuum' }), + components: { + status: sensor('status', 'State', { icon: 'mdi:robot-vacuum' }), + clean_mode: sensor('clean_mode', 'Operation level', { icon: 'mdi:fan' }), + suction_force: select('suction_force', 'Default suction power', SUCTION_FORCE, { + icon: 'mdi:weather-windy', + }), + battery: sensor('battery', 'Battery', { icon: 'mdi:battery' }), + nozzle: sensor('nozzle', 'Attached nozzle', { icon: 'mdi:vacuum' }), + filter_state: sensor('filter_state', 'Filter state', { icon: 'mdi:air-filter' }), + passage: sensor('passage', 'Inlet blocked', { icon: 'mdi:pipe-disconnected' }), + mop_with_sucking: select('mop_with_sucking', 'Mop usage mode', MOP_WITH_SUCKING, { + icon: 'mdi:water', + }), + charging_melody: select('charging_melody', 'Charging melody', CHARGING_MELODY, { + icon: 'mdi:music-note', + entity_category: 'config', + }), + volume: select('volume', 'Volume', VOLUME, { + icon: 'mdi:volume-high', + entity_category: 'config', + }), + brightness: select('brightness', 'LED Brightness', BRIGHTNESS, { + icon: 'mdi:brightness-6', + entity_category: 'config', + }), + complete_clean: { + platform: 'binary_sensor', + unique_id: '$deviceid-complete-clean', + state_topic: '$this/complete_clean', + name: 'Cleaning complete', + icon: 'mdi:check-circle-outline', + payload_on: 'ON', + payload_off: 'OFF', + }, + }, + }), + ) + } + + /* + * The base class strips the AA/length prefix before handing the body on, but every offset + * above is stated against the whole frame, the way the probes recorded them, so the frame is + * taken here unstripped. + */ + // AABBDevice strips the leading AA/length and trailing CRC/BB, so byte 0 here is the class tag + // and the record offsets are two less than the whole-frame positions. + processAABB(buf: Buffer) { + if (buf[0] !== CLASS_TAG) return + const payload = buf.length - HEADER_LEN - TRAILER_LEN + if (payload <= 0 || payload % RECORD_LEN !== 0) return + + // The trailing record is the current state; a leading one, when present, is the previous. + const record = buf.length - TRAILER_LEN - RECORD_LEN + // A different count would mean a different record shape; decoding it with this map would + // publish nonsense, so leave the previous values standing instead. + if (buf[record + 1] !== FIELD_COUNT) return + + const at = (o: number) => buf[record + o] + + this.publishProperty('status', enumOf(MON_STATUS, at(OFF.monStatus))) + this.publishProperty('clean_mode', enumOf(CLEAN_MODE, at(OFF.cleanMode))) + this.publishProperty('battery', enumOf(BATTERY, at(OFF.batteryLevel))) + this.publishProperty('nozzle', enumOf(NOZZLE, at(OFF.nozzle))) + this.publishProperty('filter_state', enumOf(FILTER_STATE, at(OFF.filterState))) + this.publishProperty('passage', enumOf(PASSAGE, at(OFF.passageClogged))) + // completeClean: 1 = not finished, 2 = finished (LG's own polarity, not a 0/1 flag). + this.publishProperty('complete_clean', at(OFF.completeClean) === 2 ? 'ON' : 'OFF') + + // The five settings are selects, and a select's state has to be one of its options — + // publishing 'Unknown' for a code outside the table would make Home Assistant reject + // the state and log it, so an unreported code leaves the previous value standing. + this.publishOption('suction_force', SUCTION_FORCE, at(OFF.suctionForce)) + this.publishOption('mop_with_sucking', MOP_WITH_SUCKING, at(OFF.mopWithSucking)) + this.publishOption('charging_melody', CHARGING_MELODY, at(OFF.chargingMelody)) + this.publishOption('volume', VOLUME, at(OFF.volume)) + this.publishOption('brightness', BRIGHTNESS, at(OFF.brightness)) + } + + private publishOption(prop: string, table: Record, raw: number) { + const label = table[raw] + if (label !== undefined) this.publishProperty(prop, label) + } + + /** + * Build and send a command frame: `aa 09 f0 24 01 bb`. + * + * The base class's `send` supplies the AA/length prefix and the checksum, so what goes in is + * `f0 24` plus the three-byte body — which reproduces the captured frames byte for byte. + */ + private setField(type: number, value: number) { + this.send(Buffer.from([...SET_STATE, type, 0x01, value])) + } + + setProperty(prop: string, mqttValue: string) { + const write = (type: number, table: Record, what: string) => { + const code = Object.entries(table).find(([, label]) => label === mqttValue)?.[0] + if (code === undefined) return log('status', this.id, `Unknown ${what} ${mqttValue}`) + this.setField(type, Number(code)) + // The appliance echoes a two-record state frame within a second, so the entity + // settles on what it actually took; this only keeps the UI from snapping back first. + this.publishProperty(prop, mqttValue) + } + + switch (prop) { + case 'suction_force': + return write(CTRL.suctionForce, SUCTION_FORCE, 'Default suction power') + case 'mop_with_sucking': + return write(CTRL.mopSetting, MOP_WITH_SUCKING, 'Mop usage mode') + case 'charging_melody': + return write(CTRL.chargingMelody, CHARGING_MELODY, 'Charging melody') + case 'volume': + return write(CTRL.volume, VOLUME, 'Volume') + case 'brightness': + return write(CTRL.brightness, BRIGHTNESS, 'LED Brightness') + default: + log('status', this.id, `Item does not support writing ${prop}`) + } + } +} diff --git a/cloud/ha_bridge.ts b/cloud/ha_bridge.ts index 2e28ab1..21c6645 100644 --- a/cloud/ha_bridge.ts +++ b/cloud/ha_bridge.ts @@ -18,6 +18,7 @@ import RV13U6AM8W_D_US_WIFI from './devices/RV13U6AM8W_D_US_WIFI' import F3L2CYU__ from './devices/F3L2CYU__' import RV13B6BSD_D_US_WIFI from './devices/RV13B6BSD_D_US_WIFI' import WTL_FXU_BDV_NA_01 from './devices/WTL_FXU_BDV_NA_01' +import HWWA9K_F2 from './devices/HWWA9K_F2' import { Device as T1Device } from './thinq1/device' import { Device as T2Device } from './thinq2/device' import { type Connection } from './homeassistant' @@ -55,6 +56,7 @@ const t2deviceTypes: Record = { ['F3L2CYU__']: F3L2CYU__, // LG front-load washer ['RV13B6BSD_D_US_WIFI']: RV13B6BSD_D_US_WIFI, // LG electric dryer WTL_FXU_BDV_NA_01, // LG WashTower + HWWA9K_F2, // LG CordZero A9 stick vacuum (deviceType 504) } class Bridge { diff --git a/tests/cloud/devices/HWWA9K_F2.test.ts b/tests/cloud/devices/HWWA9K_F2.test.ts new file mode 100644 index 0000000..c2a4254 --- /dev/null +++ b/tests/cloud/devices/HWWA9K_F2.test.ts @@ -0,0 +1,307 @@ +import { describe, test } from 'node:test' +import assert from 'node:assert/strict' +import DUT from '@/cloud/devices/HWWA9K_F2' +import type { Metadata } from '@/cloud/thinq' +import { MockHAConnection, MockThinq2Device, buf } from '@/tests/helpers/mocks' + +const DEVICE_ID = 'test-id' +const MODEL_ID = 'HWWA9K_F2' +const META: Metadata = { modelId: MODEL_ID, modelName: MODEL_ID, swVersion: '4.5' } + +/* + * Fixtures. + * + * STATE is a REAL frame, captured from the appliance on 2026-07-28. Every other frame is that + * same frame with ONE byte changed and the checksum recomputed — and each of those was actually + * injected into LG's cloud that day, so the expected value below is not a reading of this + * driver's own code but what LG's decoder answered. That is the whole point of the oracle: the + * fixtures carry LG's verdict, so a wrong offset fails here rather than in the house. + */ + +// Real: docked and fully charged. LG's snapshot for this exact frame read +// monStatus CHARGING_COMPLETE, cleanMode OFF, filterState NORMAL, passageClogged NORMAL, +// nozzle IGNORE, batteryLevel NOT_USE, mopWithSucking OFF, completeClean OFF, +// suctionForce HIGH, chargingMelody MELODY_1, volume HIGH, brightness LOW. +const STATE = buf('aa14d2eb000c04010101ff00010102010103c3bb') + +const MON_CHARGING = buf('aa14d2eb000c03010101ff00010102010103c0bb') // byte[6]=3 -> monStatus CHARGING +const SUCTION_TURBO = buf('aa14d2eb000c04010101ff00010103010103c2bb') // byte[14]=3 -> suctionForce TURBO +const BRIGHT_HIGH = buf('aa14d2eb000c04010101ff00010102010102c0bb') // byte[17]=2 -> brightness HIGH +const NOZZLE_MOP = buf('aa14d2eb000c040101010300010102010103cfbb') // byte[10]=3 -> nozzle WATER_MOP +// completeClean's OFFSET is from the probe (byte[13]=3 read back as code 3); its ON code is LG's +// own valueMapping (ON = index 2, and note OFF is 1, not 0). +const CLEAN_DONE = buf('aa14d2eb000c04010101ff00010202010103c2bb') // byte[13]=2 -> completeClean ON + +/* + * Real, and the frame that exposed the record-count bug: the appliance's echo after five + * settings were written on 2026-07-28. TWO records — previous, then current — under tag + * `d2 ec`, where the resting frame uses `d2 eb` and carries one. A decoder keyed on "20 bytes, + * tag eb" drops this, which is to say it goes blind at exactly the moment a write lands. + * + * Its trailing record holds the five values that had just been sent (mopWithSucking 2, + * suctionForce 2, chargingMelody 3, volume 3, brightness 4), each on the offset the read probes + * had predicted. + */ +const ECHO_TWO_RECORDS = buf('aa22d2ec000c04010101ff00020101030304000c04010101ff000201020303049ebb') + +// The 44-byte settings frame the appliance also sends. It is a capability list, not values: +// all four "VC-n" records read 01 while the state frame above says suction 2 and brightness 3. +// It must therefore be ignored, not decoded. +const CAPABILITY = buf('aaffd20a002c00005200010a85001a00040456432d31010456432d32010456432d33010456432d34014880bb') + +function makeDevice() { + const ha = new MockHAConnection() + const thinq = new MockThinq2Device(DEVICE_ID, META) + const dev = new DUT(ha.asConnection(), thinq, META) + return { ha, thinq, dev } +} + +describe(MODEL_ID, () => { + test('publishes only entities the 12-field frame actually fills', () => { + const { ha } = makeDevice() + const cfg = ha.devices[DEVICE_ID].config + assert.ok(cfg, 'config published') + const components = cfg!.components as Record> + for (const c of [ + 'status', + 'clean_mode', + 'suction_force', + 'battery', + 'nozzle', + 'filter_state', + 'passage', + 'mop_with_sucking', + 'charging_melody', + 'volume', + 'brightness', + 'complete_clean', + ]) { + assert.ok(components[c], `component ${c} present`) + } + // Twelve fields decoded, twelve entities — no field is published twice and none is + // invented. The remaining entries are the generic driver's removals, which carry a + // platform and nothing else. + const real = Object.values(components).filter((c) => c.unique_id !== undefined) + assert.equal(real.length, 12) + }) + + // Writable exactly where a command frame was captured off LG's own capability API and seen + // to take (CL-0000). modelJSON declares five commands for this model and all five are here; + // anything else would be a guess. + const WRITABLE = new Set(['suction_force', 'mop_with_sucking', 'charging_melody', 'volume', 'brightness']) + + test('exactly the fields with a captured command frame are writable', () => { + const { ha } = makeDevice() + const components = ha.devices[DEVICE_ID].config!.components as Record> + for (const [name, comp] of Object.entries(components)) { + if (comp.unique_id === undefined) continue // a removal, not an entity + if (WRITABLE.has(name)) { + assert.equal(comp.command_topic, `$this/${name}/set`, `${name} is writable`) + assert.equal(comp.platform, 'select', `${name} is a select`) + } else { + assert.equal(comp.command_topic, undefined, `${name} stays read-only`) + assert.ok( + comp.platform === 'sensor' || comp.platform === 'binary_sensor', + `${name} is a sensor platform, got ${comp.platform}`, + ) + } + } + }) + + test("every select's options are exactly the labels it can report", () => { + const { ha } = makeDevice() + const components = ha.devices[DEVICE_ID].config!.components as Record< + string, + { platform?: string; options?: string[] } + > + // A select whose reported state is not among its options is rejected by Home Assistant + // and logged, so the two lists have to be the same list. + for (const [name, comp] of Object.entries(components)) { + if (comp.platform !== 'select') continue + assert.ok(comp.options && comp.options.length > 0, `${name} offers options`) + for (const o of comp.options!) assert.match(o, /[A-Za-z]/, `${name} option is English: ${o}`) + } + }) + + /* + * The frames below are REAL. LG's capability API was asked to change each setting + * (`convert/control`; every one answered CL-0000) and these are the cloud→appliance frames + * rethink relayed as a result, on 2026-07-28. So the assertion is that this driver emits byte + * for byte what LG emits — not that it agrees with its own idea of the format. + * + * Every option of every command is here, seventeen frames, because a select that ships an + * option nobody ever drove is shipping a guess for that option. + */ + test('each write reproduces the frame LG itself sent for that command', () => { + for (const [prop, value, want] of [ + // MOP_SETTING (controlDataType 0x01) + ['mop_with_sucking', 'Mop only', 'aa09f0240101019fbb'], + ['mop_with_sucking', 'Mop and suction together', 'aa09f0240101029ebb'], + // SUCTION_FORCE (0x02) + ['suction_force', 'Standard', 'aa09f0240201019ebb'], + ['suction_force', 'High', 'aa09f02402010299bb'], + ['suction_force', 'Turbo', 'aa09f02402010398bb'], + // CHARGING_MELODY (0x03) + ['charging_melody', 'Lucky', 'aa09f02403010199bb'], + ['charging_melody', 'Marble', 'aa09f02403010298bb'], + ['charging_melody', 'Ice', 'aa09f0240301039bbb'], + ['charging_melody', 'Breeze', 'aa09f0240301049abb'], + ['charging_melody', 'Nebula', 'aa09f02403010585bb'], + // VOLUME (0x04) + ['volume', 'High', 'aa09f02404010198bb'], + ['volume', 'Normal', 'aa09f0240401029bbb'], + ['volume', 'Low', 'aa09f0240401039abb'], + // BRIGHTNESS (0x05) + ['brightness', 'Very bright', 'aa09f0240501019bbb'], + ['brightness', 'Bright', 'aa09f0240501029abb'], + ['brightness', 'Normal', 'aa09f02405010385bb'], + ['brightness', 'Dim', 'aa09f02405010484bb'], + ['brightness', 'Off', 'aa09f02405010587bb'], + ] as [string, string, string][]) { + const { thinq, dev } = makeDevice() + thinq.resetRecorder() + dev.setProperty(prop, value) + assert.equal(thinq.outbox.length, 1, `${prop}=${value} sent one frame`) + assert.equal(thinq.outbox[0].toString('hex'), want, `${prop}=${value}`) + } + }) + + test('an unknown option is refused rather than guessed', () => { + const { thinq, dev } = makeDevice() + thinq.resetRecorder() + dev.setProperty('suction_force', 'InvalidForce') + dev.setProperty('brightness', 'InvalidBrightness') + dev.setProperty('status', 'Charging') // read-only + assert.equal(thinq.outbox.length, 0) + }) + + test('every user-facing name and option is English', () => { + const { ha, thinq } = makeDevice() + thinq.emit('data', STATE) + const components = ha.devices[DEVICE_ID].config!.components as Record + for (const [key, comp] of Object.entries(components)) { + if (comp.name == null) continue + assert.match(comp.name, /[A-Za-z]/, `${key} name is English: ${comp.name}`) + } + const p = ha.devices[DEVICE_ID].properties + for (const key of ['status', 'clean_mode', 'suction_force', 'battery', 'nozzle', 'volume', 'brightness']) { + assert.match(String(p[key]), /[A-Za-z]/, `${key} value is English: ${p[key]}`) + } + }) + + test('real captured frame decodes to what LG read from the same frame', () => { + const { ha, thinq } = makeDevice() + thinq.emit('data', STATE) + const p = ha.devices[DEVICE_ID].properties + assert.equal(p.status, 'Charging complete') + assert.equal(p.clean_mode, 'Off') + assert.equal(p.filter_state, 'Normal') + assert.equal(p.passage, 'Normal') + assert.equal(p.mop_with_sucking, 'Mop only') + assert.equal(p.suction_force, 'High') + assert.equal(p.charging_melody, 'Lucky') + assert.equal(p.volume, 'High') + assert.equal(p.brightness, 'Normal') + assert.equal(p.complete_clean, 'OFF') + // nozzle 0xff and batteryLevel 0 are LG's IGNORE sentinels on this model, not real values. + assert.equal(p.nozzle, 'Unknown') + assert.equal(p.battery, 'Off') + }) + + test('each probed offset moves exactly the field LG said it moves', () => { + for (const [frame, key, want] of [ + [MON_CHARGING, 'status', 'Charging'], + [SUCTION_TURBO, 'suction_force', 'Turbo'], + [BRIGHT_HIGH, 'brightness', 'Bright'], + [NOZZLE_MOP, 'nozzle', 'PowerDrive Mop'], + [CLEAN_DONE, 'complete_clean', 'ON'], + ] as [Buffer, string, string][]) { + const { ha, thinq } = makeDevice() + thinq.emit('data', STATE) + thinq.emit('data', frame) + assert.equal(ha.devices[DEVICE_ID].properties[key], want, `${key} after single-byte probe`) + } + }) + + test('a single-byte probe changes nothing else', () => { + const { ha, thinq } = makeDevice() + thinq.emit('data', STATE) + const before = { ...ha.devices[DEVICE_ID].properties } + thinq.emit('data', SUCTION_TURBO) + const after = ha.devices[DEVICE_ID].properties + for (const key of Object.keys(before)) { + if (key === 'suction_force') continue + assert.equal(after[key], before[key], `${key} unchanged`) + } + }) + + test('the 44-byte capability frame is ignored, not decoded as state', () => { + const { ha, thinq } = makeDevice() + thinq.emit('data', STATE) + const before = { ...ha.devices[DEVICE_ID].properties } + thinq.emit('data', CAPABILITY) + assert.deepEqual(ha.devices[DEVICE_ID].properties, before) + }) + + test('the two-record echo is read, and its LAST record is the current state', () => { + const { ha, thinq } = makeDevice() + thinq.emit('data', STATE) + thinq.emit('data', ECHO_TWO_RECORDS) + const p = ha.devices[DEVICE_ID].properties + // The trailing record's five settings, not the leading record's (which still says + // mopWithSucking 1 and suctionForce 1). + assert.equal(p.mop_with_sucking, 'Mop and suction together') + assert.equal(p.suction_force, 'High') + assert.equal(p.charging_melody, 'Ice') + assert.equal(p.volume, 'Low') + assert.equal(p.brightness, 'Dim') + // ...and the rest of the record still decodes on the same offsets. + assert.equal(p.status, 'Charging complete') + assert.equal(p.clean_mode, 'Off') + assert.equal(p.battery, 'Off') + assert.equal(p.nozzle, 'Unknown') + }) + + test('a code the table does not list leaves the select where it was', () => { + const { ha, thinq } = makeDevice() + thinq.emit('data', STATE) + assert.equal(ha.devices[DEVICE_ID].properties.suction_force, 'High') + const odd = Buffer.from(STATE) + odd[14] = 9 // not a suctionForce code; publishing it would be an invalid select state + odd[odd.length - 2] = (odd.subarray(0, odd.length - 2).reduce((a, c) => a + c, 0) & 0xff) ^ 0x55 + thinq.emit('data', odd) + assert.equal(ha.devices[DEVICE_ID].properties.suction_force, 'High') + }) + + test('a frame whose field count is not 12 is left alone', () => { + const { ha, thinq } = makeDevice() + thinq.emit('data', STATE) + const before = { ...ha.devices[DEVICE_ID].properties } + const short = Buffer.from(STATE) + short[5] = 3 // a different record shape; decoding it with this map would publish nonsense + thinq.emit('data', short) + assert.deepEqual(ha.devices[DEVICE_ID].properties, before) + }) +}) + +/* + * Two failure modes that are invisible from the add-on side: Home Assistant accepts the + * discovery payload, writes the entity into its registry, and then never creates it. Both cost + * real entities on the first live rebuild of these drivers, so both are pinned here and in the + * styler's and purifier's suites. + */ +describe(`${MODEL_ID} discovery contract`, () => { + test("no read-only component claims entity_category 'config'", () => { + const ha = new MockHAConnection() + const thinq = new MockThinq2Device(DEVICE_ID, META) + new DUT(ha.asConnection(), thinq, META) + const components = ha.devices[DEVICE_ID].config!.components as Record< + string, + { platform?: string; entity_category?: string } + > + for (const [name, comp] of Object.entries(components)) { + if (comp.platform !== 'sensor' && comp.platform !== 'binary_sensor') continue + assert.notEqual(comp.entity_category, 'config', `${name} must not be entity_category 'config'`) + } + }) +})