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'`) + } + }) +})