From 6d64f4dfea6ccd2de09a0c71b0f8aa01ca494847 Mon Sep 17 00:00:00 2001 From: martijndhondt Date: Thu, 13 Aug 2026 14:24:01 +0200 Subject: [PATCH] Add support for F_C__Y___W.A__QEUK washing machine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LG front-loading washer (A-generation UK), using a 62-byte AABB status packet (0x20 0xEC subtype) with a compact 32-byte variant (0x20 0xEB) sent after commands/reconnect, and a 3-byte door-state packet (0x20 0xD8) that is the authoritative door-lock source during Off/Ready. Exposes power, status, course, temp, spin, remote_start, door_lock, child_lock, steam, wrinkle_care, active, pre_state, tub_clean, initial/remaining/delay time, and error/error_message — all offsets confirmed against live packet captures, including a real dE2 (door lock error) capture. Also fixes send_packet to emit uppercase hex, matching what this device's firmware expects on the wire. Developed with the assistance of Claude (Anthropic), using live packet captures analysed and cross-checked against known machine settings and HA UI state. Co-Authored-By: Claude Sonnet 5 --- cloud/devices/F_C__Y___W.A__QEUK.ts | 318 ++++++++++++ cloud/ha_bridge.ts | 2 + cloud/thinq2/device.ts | 2 +- .../cloud/devices/F_C__Y___W.A__QEUK.test.ts | 482 ++++++++++++++++++ 4 files changed, 803 insertions(+), 1 deletion(-) create mode 100644 cloud/devices/F_C__Y___W.A__QEUK.ts create mode 100644 tests/cloud/devices/F_C__Y___W.A__QEUK.test.ts diff --git a/cloud/devices/F_C__Y___W.A__QEUK.ts b/cloud/devices/F_C__Y___W.A__QEUK.ts new file mode 100644 index 00000000..51dbfbd6 --- /dev/null +++ b/cloud/devices/F_C__Y___W.A__QEUK.ts @@ -0,0 +1,318 @@ +import HADevice from './base' +import { Device as Thinq2Device } from '../thinq2/device' +import { type Connection } from '../homeassistant' +import { type Metadata } from '../thinq' +import { allowExtendedType } from '@/util/casting' +import AABBDevice from './aabb_device' +import { ERRORS, STATES, COURSES, TEMPERATURES, SPINS } from './washer_common' + +// F_C__Y___W.A__QEUK — LG front-loading washer, A-generation UK +// 62-byte AABB status packet (0x20 0xEC subtype). +// Offsets confirmed from live captures against known settings: +// Cotton/40°C/1400RPM/Normal-rinse, delayed-start 4h, tub-clean-counter=9, remote-start=off. +export default class Device extends AABBDevice { + // Tracks the last status byte from 0xEC/0xEB so the 0xD8 handler can + // suppress spurious door_lock=OFF messages during active-cycle states. + private lastStatus = -1 + + constructor(HA: Connection, thinq: Thinq2Device, meta: Metadata) { + super(HA, thinq) + this.setConfig( + allowExtendedType({ + ...HADevice.config(meta, { name: 'LG Washer' }), + components: { + power: { + platform: 'switch', + unique_id: '$deviceid-power', + state_topic: '$this/power', + command_topic: '$this/power/set', + name: '', + icon: 'mdi:washing-machine', + }, + start: { + platform: 'button', + unique_id: '$deviceid-start', + command_topic: '$this/start/set', + payload_press: '', + name: 'Start', + icon: 'mdi:play-circle-outline', + }, + pause: { + platform: 'button', + unique_id: '$deviceid-pause', + command_topic: '$this/pause/set', + payload_press: '', + name: 'Pause', + icon: 'mdi:pause-circle-outline', + }, + status: { + platform: 'sensor', + unique_id: '$deviceid-status', + state_topic: '$this/status', + name: 'Status', + icon: 'mdi:state-machine', + device_class: 'enum', + options: STATES.filter((a) => a !== undefined), + }, + error: { + platform: 'binary_sensor', + unique_id: '$deviceid-error', + state_topic: '$this/error', + name: 'Error', + icon: 'mdi:check-circle', + device_class: 'problem', + entity_category: 'diagnostic', + }, + error_message: { + platform: 'sensor', + unique_id: '$deviceid-error-message', + state_topic: '$this/error_message', + name: 'Error message', + icon: 'mdi:alert-circle-outline', + device_class: 'enum', + entity_category: 'diagnostic', + options: ERRORS.filter((a) => a !== undefined), + }, + course: { + platform: 'sensor', + unique_id: '$deviceid-course', + state_topic: '$this/course', + name: 'Course', + icon: 'mdi:pin-outline', + }, + temp: { + platform: 'sensor', + unique_id: '$deviceid-temp', + state_topic: '$this/temp', + name: 'Temperature', + device_class: 'temperature', + unit_of_measurement: '°C', + suggested_display_precision: 0, + value_template: "{{ value if value | is_number else 'None' }}", + }, + spin: { + platform: 'sensor', + unique_id: '$deviceid-spin', + state_topic: '$this/spin', + name: 'Spin', + icon: 'mdi:autorenew', + unit_of_measurement: 'RPM', + value_template: "{{ value if value | is_number else 'None' }}", + }, + remote_start: { + platform: 'binary_sensor', + unique_id: '$deviceid-remote_start', + state_topic: '$this/remote_start', + name: 'Remote start', + icon: 'mdi:play-circle-outline', + }, + door_lock: { + platform: 'binary_sensor', + unique_id: '$deviceid-door_lock', + state_topic: '$this/door_lock', + name: 'Door lock', + device_class: 'lock', + }, + steam: { + platform: 'binary_sensor', + unique_id: '$deviceid-steam', + state_topic: '$this/steam', + name: 'Steam', + icon: 'mdi:weather-fog', + }, + wrinkle_care: { + platform: 'binary_sensor', + unique_id: '$deviceid-wrinkle_care', + state_topic: '$this/wrinkle_care', + name: 'Wrinkle care', + icon: 'mdi:iron-outline', + }, + child_lock: { + platform: 'binary_sensor', + unique_id: '$deviceid-child_lock', + state_topic: '$this/child_lock', + name: 'Child lock', + icon: 'mdi:account-lock', + device_class: 'lock', + entity_category: 'diagnostic', + }, + active: { + platform: 'binary_sensor', + unique_id: '$deviceid-active', + state_topic: '$this/active', + name: 'Active', + icon: 'mdi:washing-machine', + }, + pre_state: { + platform: 'sensor', + unique_id: '$deviceid-pre_state', + state_topic: '$this/pre_state', + name: 'Pre state', + icon: 'mdi:state-machine', + device_class: 'enum', + options: STATES.filter((a) => a !== undefined), + }, + tub_clean: { + platform: 'sensor', + unique_id: '$deviceid-tub-clean', + state_topic: '$this/tub_clean', + name: 'Tub clean counter', + icon: 'mdi:washing-machine-alert', + entity_category: 'diagnostic', + }, + initial_time: { + platform: 'sensor', + unique_id: '$deviceid-initial_time', + state_topic: '$this/initial_time', + device_class: 'duration', + unit_of_measurement: 'min', + name: 'Initial time', + }, + remaining_time: { + platform: 'sensor', + unique_id: '$deviceid-remaining_time', + state_topic: '$this/remaining_time', + device_class: 'duration', + unit_of_measurement: 'min', + name: 'Remaining time', + }, + delay_remaining: { + platform: 'sensor', + unique_id: '$deviceid-delay_remaining', + state_topic: '$this/delay_remaining', + device_class: 'duration', + unit_of_measurement: 'min', + name: 'Delay remaining', + icon: 'mdi:clock-start', + }, + }, + }), + ) + } + + start() { + this.send(Buffer.from('F0ED1121010000001800', 'hex')) + } + + processAABB(buf: Buffer) { + // Log every packet — remove once error/energy offsets are confirmed + console.log(`[F_C__Y___W.A__QEUK] AABB packet (${buf.length} bytes): ${buf.toString('hex')}`) + + // 0xEC = 62-byte dual-section status packet (normal polling response) + // 0xEB = 32-byte single-section compact packet (sent after commands/reconnect) + // Both share the same field layout in their first section. + // 0xE2 = 32-byte end-of-cycle alert packet: floods at ~2s intervals during End + // state. Has different field layout ([4]≠status, [5][6]≠time); [12][13][14] + // and [25] coincidentally sit at the same offsets but [4]=0x04='Measuring' + // would be wrong to publish. Silently ignored. + // 0xD8 = 3-byte door-state packet: floods at ~2s intervals during door interaction + // and cycle startup. buf[2]=0x00=door not machine-locked (accessible); + // non-zero=door machine-locked (0x30 observed at cycle start, 0x0B also seen). + // This is the sole source of door_lock state — buf[9] bit6 is unused on + // this model and never changes. + const isEC = buf.length === 62 && buf[0] === 0x20 && buf[1] === 0xec + const isEB = buf.length === 32 && buf[0] === 0x20 && buf[1] === 0xeb + const isE2 = buf.length === 32 && buf[0] === 0x20 && buf[1] === 0xe2 + const isD8 = buf.length === 3 && buf[0] === 0x20 && buf[1] === 0xd8 + if (isE2) return + + if (isD8) { + // Non-zero = door machine-locked; 0x00 = not machine-locked. + // HA binary_sensor device_class='lock': OFF=Locked (Vergrendeld), ON=Unlocked (Ontgrendeld). + // Only authoritative during Off (0) and Ready (1); ignored once an active-cycle + // state is established because the machine can send 0xD8 buf[2]=0x00 spuriously + // during washing/rinsing (e.g. on child-lock toggle), which would otherwise + // incorrectly override the status-derived door_lock=OFF. + if (this.lastStatus <= 1) this.publishProperty('door_lock', buf[2] ? 'OFF' : 'ON') + return + } + + if (isEC || isEB) { + // Confirmed offsets (A-gen status packet — both 0xEC and 0xEB): + // [4] status — STATES[3]='Delayed' confirmed + // [5][6] remaining_time — counts down during wash; equals initial when delayed + // NOTE: shows 0 briefly at wash start (load-measuring phase) + // [7][8] initial_time — fixed total program duration (72 min confirmed, stays constant) + // [9] lock_status — bit1=remote_start confirmed (0x32 seen with remote start ON); + // other bits vary by program/state; bit6 always 0 — door is via 0xD8 + // [12] spin index — SPINS[10]=1400 RPM confirmed + // [13] temp index — TEMPERATURES[4]=40°C confirmed + // [14] course — COURSES[0x01]='Cotton' confirmed + // [16] delay hours — 4 confirmed + // [17] delay minutes — counts down 1/min confirmed + // [18] bit7=steam — 0x80=steam ON; confirmed via steam-toggle experiment + // bit5=wrinkle_care — 0x20=wrinkle care ON; confirmed via toggle (+31 min to duration) + // [19] bit6=active — set once start is pressed; through Measuring/Delayed/Washing/Rinsing/Spinning/End + // bit7=child_lock — confirmed via live capture 2026-07-15 (0xC0=active+child_lock ON, 0x40=active only) + // [10] error_code — ERRORS index; 0=OK; 0x01=DE2 (door lock error) confirmed via + // live dE2 capture 2026-08-02: buf[10]=0x01 in all Error packets, + // buf[10]=0x00 in all non-error states (Off/Ready/Delayed/Measuring). + // [22] unknown — varies; 0x03 in Off/Washing, 0x06 in Delayed/Spinning/End + // [23] pre_state — last run state; mirrors buf[4] during active cycle; + // retains last state after power-off (e.g. End→Off transition shows End) + // [25] tub_clean — 9 during wash; increments to 10 on End packet confirmed (NOTE: NOT buf[26]) + // The 62-byte 0xEC packet has a second section [32..61] that during option + // selection holds the alternative configuration (the two sections alternate + // between current and previous settings as the user scrolls). Only [0..31] + // (first section) is read here; the second section is ignored. + // End state: status=0x0A, spin/temp/course all go to 0x00 → 'unknown', remaining=0, tub_clean++. + // Power stays ON during End (status>0); goes OFF only when status=0x00 ('Off'). + const status = buf[4] + this.lastStatus = status + const remain_h = buf[5] // remaining_time hours (counts down; 0 briefly during load-measuring) + const remain_m = buf[6] // remaining_time minutes + const initial_h = buf[7] // initial_time hours (fixed for the lifetime of the program) + const initial_m = buf[8] // initial_time minutes + const lock_status = buf[9] + const error_code = buf[10] + const spin = buf[12] + const temp = buf[13] // 0x00 during rinse (cold water) → publishes 'unknown', expected + const course = buf[14] + const delay_h = buf[16] + const delay_m = buf[17] + const steam = buf[18] & 0x80 // bit7: 0x80=steam ON + const wrinkle_care = buf[18] & 0x20 // bit5: 0x20=wrinkle care ON + const active = buf[19] & 0x40 // bit6: program active (set once start pressed, through End) + const child_lock = buf[19] & 0x80 // bit7: child lock engaged + const pre_state = buf[23] + const tub_clean = buf[25] // confirmed at buf[25], not buf[26] + + this.publishProperty('power', status > 0 ? 'ON' : 'OFF') + this.publishProperty('status', STATES[status] ?? 'unknown') + this.publishProperty('error', error_code ? 'ON' : 'OFF') + this.publishProperty('error_message', ERRORS[error_code] ?? 'unknown') + this.publishProperty('course', COURSES[course] ?? 'unknown') + this.publishProperty('spin', SPINS[spin] ?? 'unknown') + this.publishProperty('temp', TEMPERATURES[temp] ?? 'unknown') + this.publishProperty('remaining_time', remain_h * 60 + remain_m) + this.publishProperty('initial_time', initial_h * 60 + initial_m) + this.publishProperty('delay_remaining', delay_h * 60 + delay_m) + this.publishProperty('remote_start', lock_status & 2 ? 'ON' : 'OFF') + this.publishProperty('steam', steam ? 'ON' : 'OFF') + this.publishProperty('wrinkle_care', wrinkle_care ? 'ON' : 'OFF') + this.publishProperty('active', active ? 'ON' : 'OFF') + this.publishProperty('child_lock', child_lock ? 'OFF' : 'ON') + this.publishProperty('pre_state', STATES[pre_state] ?? 'unknown') + this.publishProperty('tub_clean', tub_clean) + + // Derive door_lock from status for Delayed and active-cycle states where + // 0xD8 packets are not emitted. HA device_class='lock': OFF=Locked, ON=Unlocked. + // Off(0) → unlocked → ON; Ready(1) → 0xD8 authoritative; everything else → locked → OFF. + if (status === 0) this.publishProperty('door_lock', 'ON') + else if (status !== 1) this.publishProperty('door_lock', 'OFF') + } + } + + setProperty(prop: string, mqttValue: string) { + if (prop === 'power') { + if (mqttValue === 'ON') { + this.send(Buffer.from('F02A0100', 'hex')) + } else if (mqttValue === 'OFF') { + this.send(Buffer.from('F024010100', 'hex')) + } + } + + if (prop === 'pause') this.send(Buffer.from('F024040100', 'hex')) + if (prop === 'start') this.send(Buffer.from(mqttValue || 'F024050100', 'hex')) + } +} diff --git a/cloud/ha_bridge.ts b/cloud/ha_bridge.ts index 2e28ab11..1649097b 100644 --- a/cloud/ha_bridge.ts +++ b/cloud/ha_bridge.ts @@ -11,6 +11,7 @@ import Y_V8_Y___W_B32QEUK from './devices/Y_V8_Y___W.B32QEUK' import F_V8_Y___W_B_2QEUK from './devices/F_V8_Y___W.B_2QEUK' import Y_V8_F___W_B_2QEUK from './devices/Y_V8_F___W.B_2QEUK' import F_V__F___W_B_1QEUK from './devices/F_V__F___W.B_1QEUK' +import F_C__Y___W_A__QEUK from './devices/F_C__Y___W.A__QEUK' import F_VB_F___W_B_2QEUK from './devices/F_VB_F___W.B_2QEUK' import VCDWL2QEUK from './devices/VCDWL2QEUK' import T1789EFH_F from './devices/T1789EFH_F' @@ -49,6 +50,7 @@ const t2deviceTypes: Record = { ['F_V__Y___W.B_2QEUK']: F_V8_Y___W_B_2QEUK, // NOTE: we reuse F_V8_Y___W_B_2QEUK as the models appear to be compatible ['VCDWL2QEUK']: VCDWL2QEUK, // LG F4X7511TWS front-load washer (matched on modelId VCDWL2QEUK) ['F_V__F___W.B_1QEUK']: F_V__F___W_B_1QEUK, + ['F_C__Y___W.A__QEUK']: F_C__Y___W_A__QEUK, ['F_VB_F___W.B_2QEUK']: F_VB_F___W_B_2QEUK, // LG CV74J7S2QA washer/dryer combo ['T1789EFH_F']: T1789EFH_F, // LG WT7300CW top-loading washer ['RV13U6AM8W_D_US_WIFI']: RV13U6AM8W_D_US_WIFI, // LG DLE7300WE dryer diff --git a/cloud/thinq2/device.ts b/cloud/thinq2/device.ts index 2ca29214..61cc9b5e 100644 --- a/cloud/thinq2/device.ts +++ b/cloud/thinq2/device.ts @@ -38,7 +38,7 @@ export class Device extends TypedEmitter { send_packet(buf: Buffer) { this.emit('sendData', buf) - this.send('packet', 1, buf.toString('hex')) + this.send('packet', 1, buf.toString('hex').toUpperCase()) } } diff --git a/tests/cloud/devices/F_C__Y___W.A__QEUK.test.ts b/tests/cloud/devices/F_C__Y___W.A__QEUK.test.ts new file mode 100644 index 00000000..7eeb604e --- /dev/null +++ b/tests/cloud/devices/F_C__Y___W.A__QEUK.test.ts @@ -0,0 +1,482 @@ +import { describe, test } from 'node:test' +import assert from 'node:assert/strict' +import DUT from '@/cloud/devices/F_C__Y___W.A__QEUK' +import type { Metadata } from '@/cloud/thinq' +import { MockHAConnection, MockThinq2Device, buf, hex } from '@/tests/helpers/mocks' + +const DEVICE_ID = 'test-id' +const MODEL_ID = 'F_C__Y___W.A__QEUK' +const META: Metadata = { modelId: MODEL_ID, modelName: 'F_C__Y___W.A__QEUK', swVersion: '1.0.0' } + +// Real packet captures from an F_C__Y___W.A__QEUK washer (A-gen UK front-loader). +// Frame format: AA 20 EC BB (62-byte 0xEC inner block = 66 bytes total). +// Confirmed offsets — see device file header. + +// Real capture: Cotton/60°C/1400 RPM, 104 min remaining, 121 min initial, delay=0, tub_clean=10. +// buf[18]=0x00 → steam=OFF. +const SAMPLE_WASHING_EC = buf( + 'AA4220EC001C06012C02010100030A0601000000004000000306000A003400000500001C06012B02010100030A0601000000004000000306000A003400000500FEBB', +) + +// Derived from SAMPLE_WASHING_EC with raw[20] (= buf[18]) changed 0x00→0x80 to set the steam flag. +// All other fields identical: Cotton/60°C/1400 RPM, 104 min remaining, 121 min initial, delay=0. +const SAMPLE_STEAM_ON_EC = buf( + 'AA4220EC001C06012C02010100030A0601000000804000000306000A003400000500001C06012B02010100030A0601000000004000000306000A003400000500FEBB', +) + +// Derived from SAMPLE_WASHING_EC with raw[20] (= buf[18]) changed 0x00→0x20 to set the wrinkle_care flag. +// All other fields identical: Cotton/60°C/1400 RPM, 104 min remaining, initial=121 min, steam=OFF. +const SAMPLE_WRINKLE_CARE_ON_EC = buf( + 'AA4220EC001C06012C02010100030A0601000000204000000306000A003400000500001C06012B02010100030A0601000000004000000306000A003400000500FEBB', +) + +// Derived from SAMPLE_WASHING_EC with raw[21] (= buf[19]) changed 0x40→0xC0 to set the child_lock flag (bit7). +// Real-capture confirmation: 2026-07-15T16:24 buf[19]=0xC0 observed immediately after user enabled child lock. +// All other fields identical: Cotton/60°C/1400 RPM, 104 min remaining, initial=121 min, delay=0. +const SAMPLE_CHILD_LOCK_ON_EC = buf( + 'AA4220EC001C06012C02010100030A060100000000C000000306000A003400000500001C06012B02010100030A0601000000004000000306000A003400000500FEBB', +) + +// Real capture: Ready state, Ease Care/40°C/1200 RPM, 59 min, remote_start=ON. +// buf[9]=0x32 (bit1 set) — captured while machine was in Ready state with remote start enabled. +// Section 1 shows this config; section 2 shows Cotton/60°C/1400/267 min (user was scrolling options). +const SAMPLE_REMOTE_START_ON_EC = buf( + 'AA4220EC001C01003B003B3200030904010000000100000001010030003400000000001C01041B041B0400030A0601000000000000000201003000340000020044BB', +) + +// Synthetic packet: Cotton/40°C/1400 RPM delayed-start, delay=4h, 72 min program, tub_clean=9. +const SAMPLE_DELAYED_EC = buf( + 'AA4220EC001C03004800480100000A04010004000000000006030009003400000500001C03004700480100000A040100040000000000060300090034000005009BBB', +) + +// Real capture: Cotton/60°C/1400 RPM, 3 min remaining (final spin), tub_clean=10. +const SAMPLE_SPINNING_EC = buf( + 'AA4220EC001C08000302010100000A0000000000004000000608000A003400000500001C08000202010100000A0000000000004000000608000A003400000500D6BB', +) + +// Real capture: End state — status=End, remaining=0, spin/temp/course all cleared, tub_clean=10. +const SAMPLE_END_EC = buf( + 'AA4220EC001C0A0000020101000000000000000000400000060A000A003400000500001C0A0000020101000000000000000000000000060A000A00340000050067BB', +) + +// Real capture: Off state — machine powered off after cycle, tub_clean=10. +const SAMPLE_OFF_EC = buf( + 'AA4220EC001C000000020101000000000000000000000000030A000A003400000500001C0000000201010000000000000000000000000300000A0034000005009BBB', +) + +// Real capture: 0xE2 end-of-cycle alert packet — floods at ~2s intervals at End. +// Must be silently ignored (different field layout from 0xEC/0xEB). +const SAMPLE_E2_IGNORED = buf('AA2420E2091C04032603260100030A0601000000400000000604000A003400000500B8BB') + +// Synthetic: 0xEB compact status packet (32-byte, sent after commands/reconnect). +// Same field layout as the first section of 0xEC. Cotton/60°C/1400 RPM, 50 min remaining, tub_clean=10. +const SAMPLE_WASHING_EB = buf('AA2420EB001C06003200480100000A0601000000000000000606000A003400000500C4BB') + +// Real captures: 0xD8 door-state packets (3-byte). +// 0x00 = door not machine-locked (accessible); non-zero = door machine-locked. +const SAMPLE_DOOR_UNLOCKED = buf('AA0720D800FCBB') // buf[2]=0x00 → not machine-locked → ON (Unlocked) +const SAMPLE_DOOR_LOCKED = buf('AA0720D80BE1BB') // buf[2]=0x0B → machine-locked → OFF (Locked) +// Real capture from cycle start: buf[2]=0x30 → door sealed by machine → OFF (Locked) +const SAMPLE_DOOR_LOCKED_0x30 = buf('AA0720D8308CBB') + +// Real capture: Error state — dE2 (door lock error), Cotton/60°C/1400 RPM, delay=7h, tub_clean=50. +// buf[4]=0x12=Error, buf[10]=0x01=DE2, buf[23]=0x12=Error. +// Captured 2026-08-02T19:42:33Z after machine failed to lock door during delayed-start attempt. +const SAMPLE_ERROR_EC = buf( + 'AA4220EC001C12031503150101030A06010007000000000004120032003400000400001C00031503150100000000000000000000000004010032003400000400FABB', +) + +// Expected outgoing packets emitted by the device file. +const WRITE_INIT = 'AA0EF0ED1121010000001800B5BB' +const WRITE_POWER_ON = 'AA08F02A010098BB' +const WRITE_POWER_OFF = 'AA09F0240101009CBB' +const WRITE_PAUSE = 'AA09F02404010099BB' +const WRITE_START = 'AA09F02405010098BB' + +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('config exposes expected components on construction', () => { + 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 [ + 'power', + 'start', + 'pause', + 'status', + 'error', + 'error_message', + 'course', + 'temp', + 'spin', + 'remote_start', + 'door_lock', + 'steam', + 'wrinkle_care', + 'child_lock', + 'active', + 'pre_state', + 'tub_clean', + 'initial_time', + 'remaining_time', + 'delay_remaining', + ]) { + assert.ok(components[c], `component ${c} present`) + } + assert.ok((components.status.options as string[]).includes('Washing')) + assert.ok((components.status.options as string[]).includes('Error')) + }) + + test('delayed-start state decodes status, course, spin, temp, times and delay', () => { + const { ha, thinq } = makeDevice() + thinq.emit('data', SAMPLE_DELAYED_EC) + const props = ha.devices[DEVICE_ID].properties + assert.equal(props.power, 'ON') + assert.equal(props.status, 'Delayed') + assert.equal(props.course, 'Cotton') + assert.equal(props.spin, 1400) + assert.equal(props.temp, 40) + assert.equal(props.remaining_time, 72) // 0h 72m + assert.equal(props.initial_time, 72) + assert.equal(props.delay_remaining, 4 * 60) // 4h 0m + assert.equal(props.remote_start, 'OFF') + assert.equal(props.active, 'OFF') // synthetic packet: start not yet pressed + assert.equal(props.door_lock, 'OFF') // derived from status: Delayed → locked → OFF (HA device_class=lock) + assert.equal(props.pre_state, 'Delayed') + assert.equal(props.tub_clean, 9) + }) + + test('washing state decodes status, course, spin, temp, times (real capture)', () => { + const { ha, thinq } = makeDevice() + thinq.emit('data', SAMPLE_WASHING_EC) + const props = ha.devices[DEVICE_ID].properties + assert.equal(props.power, 'ON') + assert.equal(props.status, 'Washing') + assert.equal(props.course, 'Cotton') + assert.equal(props.spin, 1400) + assert.equal(props.temp, 60) + assert.equal(props.remaining_time, 1 * 60 + 44) // 1h 44m = 104 min + assert.equal(props.initial_time, 2 * 60 + 1) // 2h 1m = 121 min + assert.equal(props.delay_remaining, 0) + assert.equal(props.remote_start, 'OFF') + assert.equal(props.steam, 'OFF') + assert.equal(props.wrinkle_care, 'OFF') + assert.equal(props.active, 'ON') + assert.equal(props.child_lock, 'ON') // bit7=0 → disengaged → Unlocked → ON (HA device_class=lock) + assert.equal(props.door_lock, 'OFF') // derived from status: Washing → locked → OFF + assert.equal(props.pre_state, 'Washing') + assert.equal(props.tub_clean, 10) + }) + + test('spinning state decodes status and remaining time', () => { + const { ha, thinq } = makeDevice() + thinq.emit('data', SAMPLE_SPINNING_EC) + const props = ha.devices[DEVICE_ID].properties + assert.equal(props.status, 'Spinning') + assert.equal(props.remaining_time, 3) + assert.equal(props.spin, 1400) // spin index still populated during final spin + }) + + test('end state: status=End, power still ON, spin/temp/course cleared', () => { + const { ha, thinq } = makeDevice() + thinq.emit('data', SAMPLE_END_EC) + const props = ha.devices[DEVICE_ID].properties + assert.equal(props.status, 'End') + assert.equal(props.power, 'ON') // power stays ON until status goes to 0 + assert.equal(props.remaining_time, 0) + assert.equal(props.spin, 'unknown') + assert.equal(props.temp, 'unknown') + assert.equal(props.course, 'unknown') + assert.equal(props.door_lock, 'OFF') // still locked at End → OFF; becomes ON when status→Off + assert.equal(props.pre_state, 'End') + assert.equal(props.tub_clean, 10) + }) + + test('steam=OFF when buf[18] bit7 is clear (standard washing packet)', () => { + const { ha, thinq } = makeDevice() + thinq.emit('data', SAMPLE_WASHING_EC) + assert.equal(ha.devices[DEVICE_ID].properties.steam, 'OFF') + }) + + test('steam=ON when buf[18] bit7 is set', () => { + const { ha, thinq } = makeDevice() + thinq.emit('data', SAMPLE_STEAM_ON_EC) + const props = ha.devices[DEVICE_ID].properties + assert.equal(props.steam, 'ON') + // All other fields identical to SAMPLE_WASHING_EC + assert.equal(props.status, 'Washing') + assert.equal(props.temp, 60) + assert.equal(props.spin, 1400) + assert.equal(props.remaining_time, 104) + }) + + test('steam toggles correctly across ON→OFF sequence', () => { + const { ha, thinq } = makeDevice() + thinq.emit('data', SAMPLE_STEAM_ON_EC) + assert.equal(ha.devices[DEVICE_ID].properties.steam, 'ON') + thinq.emit('data', SAMPLE_WASHING_EC) + assert.equal(ha.devices[DEVICE_ID].properties.steam, 'OFF') + }) + + test('wrinkle_care=OFF when buf[18] bit5 is clear (standard washing packet)', () => { + const { ha, thinq } = makeDevice() + thinq.emit('data', SAMPLE_WASHING_EC) + assert.equal(ha.devices[DEVICE_ID].properties.wrinkle_care, 'OFF') + }) + + test('wrinkle_care=ON when buf[18] bit5 is set', () => { + const { ha, thinq } = makeDevice() + thinq.emit('data', SAMPLE_WRINKLE_CARE_ON_EC) + const props = ha.devices[DEVICE_ID].properties + assert.equal(props.wrinkle_care, 'ON') + // steam is still OFF (bit7 clear), other fields unchanged + assert.equal(props.steam, 'OFF') + assert.equal(props.status, 'Washing') + assert.equal(props.temp, 60) + assert.equal(props.spin, 1400) + }) + + test('wrinkle_care toggles correctly across ON→OFF sequence', () => { + const { ha, thinq } = makeDevice() + thinq.emit('data', SAMPLE_WRINKLE_CARE_ON_EC) + assert.equal(ha.devices[DEVICE_ID].properties.wrinkle_care, 'ON') + thinq.emit('data', SAMPLE_WASHING_EC) + assert.equal(ha.devices[DEVICE_ID].properties.wrinkle_care, 'OFF') + }) + + test('child_lock=ON (Unlocked) when buf[19] bit7 is clear (disengaged)', () => { + const { ha, thinq } = makeDevice() + thinq.emit('data', SAMPLE_WASHING_EC) + assert.equal(ha.devices[DEVICE_ID].properties.child_lock, 'ON') + }) + + test('child_lock=OFF (Locked) when buf[19] bit7 is set (engaged)', () => { + const { ha, thinq } = makeDevice() + thinq.emit('data', SAMPLE_CHILD_LOCK_ON_EC) + const props = ha.devices[DEVICE_ID].properties + assert.equal(props.child_lock, 'OFF') + // All other fields identical to SAMPLE_WASHING_EC + assert.equal(props.status, 'Washing') + assert.equal(props.temp, 60) + assert.equal(props.spin, 1400) + assert.equal(props.remaining_time, 104) + }) + + test('child_lock toggles correctly when engaged/disengaged', () => { + const { ha, thinq } = makeDevice() + thinq.emit('data', SAMPLE_CHILD_LOCK_ON_EC) + assert.equal(ha.devices[DEVICE_ID].properties.child_lock, 'OFF') // engaged → Locked → OFF + thinq.emit('data', SAMPLE_WASHING_EC) + assert.equal(ha.devices[DEVICE_ID].properties.child_lock, 'ON') // disengaged → Unlocked → ON + }) + + test('remote_start=OFF when buf[9] bit1 is clear (standard washing packet)', () => { + const { ha, thinq } = makeDevice() + thinq.emit('data', SAMPLE_WASHING_EC) + assert.equal(ha.devices[DEVICE_ID].properties.remote_start, 'OFF') + }) + + test('remote_start=ON when buf[9] bit1 is set (real capture, Ready state)', () => { + const { ha, thinq } = makeDevice() + thinq.emit('data', SAMPLE_REMOTE_START_ON_EC) + const props = ha.devices[DEVICE_ID].properties + assert.equal(props.remote_start, 'ON') + // Verify other fields decoded correctly from this packet + assert.equal(props.status, 'Ready') + assert.equal(props.course, 'Cotton') + assert.equal(props.spin, 1200) // SPINS[9] + assert.equal(props.temp, 40) + assert.equal(props.remaining_time, 59) + assert.equal(props.initial_time, 59) + assert.equal(props.steam, 'OFF') + assert.equal(props.wrinkle_care, 'OFF') + assert.equal(props.active, 'OFF') + }) + + test('remote_start toggles correctly across ON→OFF sequence', () => { + const { ha, thinq } = makeDevice() + thinq.emit('data', SAMPLE_REMOTE_START_ON_EC) + assert.equal(ha.devices[DEVICE_ID].properties.remote_start, 'ON') + thinq.emit('data', SAMPLE_DELAYED_EC) // buf[9]=0x01 → bit1=0 → OFF + assert.equal(ha.devices[DEVICE_ID].properties.remote_start, 'OFF') + }) + + test('off state: power=OFF, status=Off, pre_state retains last run state (End)', () => { + const { ha, thinq } = makeDevice() + thinq.emit('data', SAMPLE_OFF_EC) + const props = ha.devices[DEVICE_ID].properties + assert.equal(props.power, 'OFF') + assert.equal(props.status, 'Off') + assert.equal(props.active, 'OFF') + assert.equal(props.door_lock, 'ON') // derived from status: Off → unlocked → ON + assert.equal(props.pre_state, 'End') // buf[23] retains End even after power-off + }) + + test('0xEB compact packet is parsed identically to 0xEC first section', () => { + const { ha, thinq } = makeDevice() + thinq.emit('data', SAMPLE_WASHING_EB) + const props = ha.devices[DEVICE_ID].properties + assert.equal(props.status, 'Washing') + assert.equal(props.remaining_time, 50) + assert.equal(props.spin, 1400) + assert.equal(props.temp, 60) + assert.equal(props.tub_clean, 10) + }) + + test('0xE2 end-of-cycle alert packet is silently ignored', () => { + const { ha, thinq } = makeDevice() + thinq.emit('data', SAMPLE_WASHING_EC) // establish a known state + const before = { ...ha.devices[DEVICE_ID].properties } + thinq.emit('data', SAMPLE_E2_IGNORED) // must not alter any property + assert.deepEqual(ha.devices[DEVICE_ID].properties, before) + }) + + test('door_lock=OFF (Locked) derived from 0xEC for active/delayed states even without 0xD8', () => { + const { ha, thinq } = makeDevice() + // No 0xD8 ever arrives — door_lock must still reflect the cycle state. + thinq.emit('data', SAMPLE_DELAYED_EC) + assert.equal(ha.devices[DEVICE_ID].properties.door_lock, 'OFF') // Delayed → locked + thinq.emit('data', SAMPLE_WASHING_EC) + assert.equal(ha.devices[DEVICE_ID].properties.door_lock, 'OFF') // Washing → locked + thinq.emit('data', SAMPLE_OFF_EC) + assert.equal(ha.devices[DEVICE_ID].properties.door_lock, 'ON') // Off → unlocked + }) + + test('0xD8 door_lock=ON (Unlocked) is ignored during active wash (status-derived OFF prevails)', () => { + const { ha, thinq } = makeDevice() + thinq.emit('data', SAMPLE_WASHING_EC) // status=Washing → door_lock=OFF (Locked) + assert.equal(ha.devices[DEVICE_ID].properties.door_lock, 'OFF') + thinq.emit('data', SAMPLE_DOOR_UNLOCKED) // 0xD8 buf[2]=0x00 must not override + assert.equal(ha.devices[DEVICE_ID].properties.door_lock, 'OFF') + }) + + test('0xD8 overrides door_lock during Ready/startup phase', () => { + const { ha, thinq } = makeDevice() + thinq.emit('data', SAMPLE_DOOR_LOCKED) // 0xD8 says locked → OFF + assert.equal(ha.devices[DEVICE_ID].properties.door_lock, 'OFF') + thinq.emit('data', SAMPLE_DOOR_UNLOCKED) // 0xD8 says unlocked (door opened) → ON + assert.equal(ha.devices[DEVICE_ID].properties.door_lock, 'ON') + }) + + test('0xD8 buf[2]=0x00 publishes door_lock=ON (not machine-locked → Unlocked)', () => { + const { ha, thinq } = makeDevice() + thinq.emit('data', SAMPLE_DOOR_UNLOCKED) + assert.equal(ha.devices[DEVICE_ID].properties.door_lock, 'ON') + }) + + test('0xD8 buf[2]=non-zero (0x0B) publishes door_lock=OFF (machine-locked → Locked)', () => { + const { ha, thinq } = makeDevice() + thinq.emit('data', SAMPLE_DOOR_LOCKED) + assert.equal(ha.devices[DEVICE_ID].properties.door_lock, 'OFF') + }) + + test('0xD8 buf[2]=0x30 (cycle-start lock) publishes door_lock=OFF (real capture)', () => { + const { ha, thinq } = makeDevice() + thinq.emit('data', SAMPLE_DOOR_LOCKED_0x30) + assert.equal(ha.devices[DEVICE_ID].properties.door_lock, 'OFF') + }) + + test('door_lock toggles correctly across lock/unlock sequence', () => { + const { ha, thinq } = makeDevice() + thinq.emit('data', SAMPLE_DOOR_UNLOCKED) + assert.equal(ha.devices[DEVICE_ID].properties.door_lock, 'ON') // unlocked → ON + thinq.emit('data', SAMPLE_DOOR_LOCKED) + assert.equal(ha.devices[DEVICE_ID].properties.door_lock, 'OFF') // locked → OFF + thinq.emit('data', SAMPLE_DOOR_UNLOCKED) + assert.equal(ha.devices[DEVICE_ID].properties.door_lock, 'ON') // unlocked → ON + }) + + test('frames not matching the AA..BB envelope are ignored', () => { + const { ha, thinq } = makeDevice() + thinq.emit('data', SAMPLE_WASHING_EC) + const before = { ...ha.devices[DEVICE_ID].properties } + thinq.emit('data', buf('001122')) // no AA/BB wrapper + assert.deepEqual(ha.devices[DEVICE_ID].properties, before) + }) + + test('frames with unrecognised inner length are ignored', () => { + const { ha, thinq } = makeDevice() + thinq.emit('data', SAMPLE_WASHING_EC) + const before = { ...ha.devices[DEVICE_ID].properties } + thinq.emit('data', buf('AA0820EC010203040506BB')) // valid envelope, wrong payload length + assert.deepEqual(ha.devices[DEVICE_ID].properties, before) + }) + + test('start() sends the F0ED initialisation packet', () => { + const { thinq, dev } = makeDevice() + thinq.resetRecorder() + dev.start() + assert.equal(thinq.outbox.length, 1) + assert.equal(hex(thinq.outbox[0]), WRITE_INIT) + }) + + test('HA write power=ON', () => { + const { thinq, dev } = makeDevice() + thinq.resetRecorder() + dev.setProperty('power', 'ON') + assert.equal(hex(thinq.outbox[0]), WRITE_POWER_ON) + }) + + test('HA write power=OFF', () => { + const { thinq, dev } = makeDevice() + thinq.resetRecorder() + dev.setProperty('power', 'OFF') + assert.equal(hex(thinq.outbox[0]), WRITE_POWER_OFF) + }) + + test('HA write pause button', () => { + const { thinq, dev } = makeDevice() + thinq.resetRecorder() + dev.setProperty('pause', '') + assert.equal(hex(thinq.outbox[0]), WRITE_PAUSE) + }) + + test('HA write start button with default payload', () => { + const { thinq, dev } = makeDevice() + thinq.resetRecorder() + dev.setProperty('start', '') + assert.equal(hex(thinq.outbox[0]), WRITE_START) + }) + + test('HA write to unknown property emits no packet', () => { + const { thinq, dev } = makeDevice() + thinq.resetRecorder() + dev.setProperty('does-not-exist', 'whatever') + assert.equal(thinq.outbox.length, 0) + }) + + test('error=OFF, error_message=OK when buf[10]=0x00 (no error, standard washing packet)', () => { + const { ha, thinq } = makeDevice() + thinq.emit('data', SAMPLE_WASHING_EC) + const props = ha.devices[DEVICE_ID].properties + assert.equal(props.error, 'OFF') + assert.equal(props.error_message, 'OK') + }) + + test('error=ON, error_message=DE2 when buf[10]=0x01 (dE2 door lock error, real capture)', () => { + const { ha, thinq } = makeDevice() + thinq.emit('data', SAMPLE_ERROR_EC) + const props = ha.devices[DEVICE_ID].properties + assert.equal(props.error, 'ON') + assert.equal(props.error_message, 'Door lock error (DE2)') + assert.equal(props.status, 'Error') + assert.equal(props.pre_state, 'Error') + assert.equal(props.tub_clean, 50) + }) + + test('error clears when machine recovers from error state', () => { + const { ha, thinq } = makeDevice() + thinq.emit('data', SAMPLE_ERROR_EC) + assert.equal(ha.devices[DEVICE_ID].properties.error, 'ON') + thinq.emit('data', SAMPLE_WASHING_EC) // buf[10]=0x00 + assert.equal(ha.devices[DEVICE_ID].properties.error, 'OFF') + assert.equal(ha.devices[DEVICE_ID].properties.error_message, 'OK') + }) +})