Add AddWash controls and sensors for washers

This commit is contained in:
Marek Tyburec
2026-08-19 10:02:07 +02:00
parent 66b4f1400b
commit 6003394cb2
13 changed files with 741 additions and 1 deletions
@@ -16,7 +16,7 @@ array.
""" """
from ..capability import Capability from ..capability import Capability
from ..entities import BinarySensorDesc, SelectDesc, SensorDesc from ..entities import BinarySensorDesc, SelectDesc, SensorDesc, SwitchDesc
from .laundry import ( from .laundry import (
bool_option_exists, bool_option_exists,
bool_option_switch, bool_option_switch,
@@ -264,6 +264,90 @@ def _bool_option_switch(key, icon, prefix, availability_field):
) )
# AddWash -- the little door for adding a forgotten sock mid-cycle -- rides
# three independent tokens on the same options[] array:
#
# AddWashSet_<0-7> the alarm setting, and the only writable one:
# a 3-bit mask over the moments it fires, bit 0
# rinse, bit 1 final rinse, bit 2 spin.
# AddWashAvailable_<0-7> the same three bits, but what the running
# course still permits.
# AddWashIndicator_On/Off the panel lamp: laundry may go in right now.
#
# Bit order confirmed by watching a WW6500 run a cycle: AddWashAvailable
# shed one bit as each moment passed (7 through Rinse, then 6, 4, and 0 as
# Spin began) and reset to 7 at the end, while the lamp tracked the phase
# with the alarm switched off throughout.
def _add_wash_mask(rep, prefix):
"""One of the 3-bit AddWash masks, or None when its token is absent,
malformed, or outside 0-7. Never 0 for a missing token: 0 is a real
value, and a mask this model can't represent is a wrong model rather
than something to write back."""
raw = option_value(rep.get("x.com.samsung.da.options"), prefix)
try:
mask = int(raw)
except (TypeError, ValueError):
return None
return mask if 0 <= mask <= 0b111 else None
def _add_wash_any(prefix):
"""Whether any of the three moments is set in `prefix`'s mask."""
def read(rep):
mask = _add_wash_mask(rep, prefix)
return None if mask is None else mask != 0
return read
def _add_wash_set_write(mask):
return ["course", "vs", "0"], {
"x.com.samsung.da.options": option_write("AddWashSet", str(mask)),
}
def _add_wash_alarm_write(p, rep, href=None):
if p not in ("On", "Off") or not rep.get("x.com.samsung.da.options"):
return None
return _add_wash_set_write(0b111 if p == "On" else 0)
def _add_wash_bit_switch(key, icon, bit):
"""One moment the alarm fires at, as its own bit of the mask.
The mask is the only state, so switching the last moment off lands on 0
and takes the alarm with it, and switching one on from 0 turns the alarm
back on.
"""
def read(rep):
mask = _add_wash_mask(rep, "AddWashSet")
return None if mask is None else bool(mask >> bit & 1)
def write(p, rep, href=None):
mask = _add_wash_mask(rep, "AddWashSet")
if p not in ("On", "Off") or mask is None:
return None
return _add_wash_set_write(mask | 1 << bit if p == "On" else mask & ~(1 << bit))
return SwitchDesc(
key=key,
icon=icon,
entity_category="config",
exists_fn=bool_option_exists("AddWashSet"),
rep_fn=read,
write_fn=write,
)
def _add_wash_indicator(rep):
raw = option_value(rep.get("x.com.samsung.da.options"), "AddWashIndicator")
return raw.lower() == "on" if isinstance(raw, str) else None
WASHER_COURSE = Capability( WASHER_COURSE = Capability(
href="/course/vs/0", href="/course/vs/0",
entities=( entities=(
@@ -350,5 +434,29 @@ WASHER_COURSE = Capability(
_bool_option_switch( _bool_option_switch(
"intensive", "mdi:washing-machine", "IntensiveSetting", "IntensiveAvailableSet" "intensive", "mdi:washing-machine", "IntensiveSetting", "IntensiveAvailableSet"
), ),
SwitchDesc(
key="add_wash_alarm",
icon="mdi:bell-ring",
entity_category="config",
exists_fn=bool_option_exists("AddWashSet"),
rep_fn=_add_wash_any("AddWashSet"),
write_fn=_add_wash_alarm_write,
),
_add_wash_bit_switch("add_wash_alarm_rinse", "mdi:water", 0),
_add_wash_bit_switch("add_wash_alarm_final_rinse", "mdi:water-check", 1),
_add_wash_bit_switch("add_wash_alarm_spin", "mdi:sync", 2),
BinarySensorDesc(
key="add_wash_available",
icon="mdi:tshirt-crew-outline",
entity_category="diagnostic",
exists_fn=bool_option_exists("AddWashAvailable"),
rep_fn=_add_wash_any("AddWashAvailable"),
),
BinarySensorDesc(
key="add_wash_indicator",
icon="mdi:door-open",
exists_fn=bool_option_exists("AddWashIndicator"),
rep_fn=_add_wash_indicator,
),
), ),
) )
@@ -109,6 +109,12 @@
"softener_low": { "softener_low": {
"name": "Málo aviváže" "name": "Málo aviváže"
}, },
"add_wash_available": {
"name": "AddWash povoleno"
},
"add_wash_indicator": {
"name": "AddWash připraveno"
},
"stick_ble_connected": { "stick_ble_connected": {
"name": "Tyč připojena přes BLE" "name": "Tyč připojena přes BLE"
} }
@@ -1351,6 +1357,18 @@
"intensive": { "intensive": {
"name": "Intenzivní" "name": "Intenzivní"
}, },
"add_wash_alarm": {
"name": "Alarm AddWash"
},
"add_wash_alarm_rinse": {
"name": "AddWash při máchání"
},
"add_wash_alarm_final_rinse": {
"name": "AddWash při posledním máchání"
},
"add_wash_alarm_spin": {
"name": "AddWash při odstřeďování"
},
"lamp": { "lamp": {
"name": "Lampa" "name": "Lampa"
}, },
@@ -103,6 +103,12 @@
"softener_low": { "softener_low": {
"name": "Weichspüler niedrig" "name": "Weichspüler niedrig"
}, },
"add_wash_available": {
"name": "AddWash erlaubt"
},
"add_wash_indicator": {
"name": "AddWash bereit"
},
"pouring": { "pouring": {
"name": "Ausgabe läuft" "name": "Ausgabe läuft"
}, },
@@ -1345,6 +1351,18 @@
"intensive": { "intensive": {
"name": "Intensiv" "name": "Intensiv"
}, },
"add_wash_alarm": {
"name": "AddWash-Hinweis"
},
"add_wash_alarm_rinse": {
"name": "AddWash Spülen"
},
"add_wash_alarm_final_rinse": {
"name": "AddWash letztes Spülen"
},
"add_wash_alarm_spin": {
"name": "AddWash Schleudern"
},
"lamp": { "lamp": {
"name": "Lampe" "name": "Lampe"
}, },
@@ -109,6 +109,12 @@
"softener_low": { "softener_low": {
"name": "Softener low" "name": "Softener low"
}, },
"add_wash_available": {
"name": "AddWash allowed"
},
"add_wash_indicator": {
"name": "AddWash ready"
},
"stick_ble_connected": { "stick_ble_connected": {
"name": "Stick BLE connected" "name": "Stick BLE connected"
} }
@@ -1351,6 +1357,18 @@
"intensive": { "intensive": {
"name": "Intensive" "name": "Intensive"
}, },
"add_wash_alarm": {
"name": "AddWash alarm"
},
"add_wash_alarm_rinse": {
"name": "AddWash rinse"
},
"add_wash_alarm_final_rinse": {
"name": "AddWash final rinse"
},
"add_wash_alarm_spin": {
"name": "AddWash spin"
},
"lamp": { "lamp": {
"name": "Lamp" "name": "Lamp"
}, },
@@ -303,6 +303,12 @@
"softener_low": { "softener_low": {
"name": "Poco suavizante" "name": "Poco suavizante"
}, },
"add_wash_available": {
"name": "AddWash permitido"
},
"add_wash_indicator": {
"name": "AddWash disponible"
},
"auto_clean_running": { "auto_clean_running": {
"name": "Limpieza automática en curso" "name": "Limpieza automática en curso"
}, },
@@ -1548,6 +1554,18 @@
"intensive": { "intensive": {
"name": "Intensivo" "name": "Intensivo"
}, },
"add_wash_alarm": {
"name": "Aviso AddWash"
},
"add_wash_alarm_rinse": {
"name": "AddWash en aclarado"
},
"add_wash_alarm_final_rinse": {
"name": "AddWash en último aclarado"
},
"add_wash_alarm_spin": {
"name": "AddWash en centrifugado"
},
"lamp": { "lamp": {
"name": "Lámpara" "name": "Lámpara"
}, },
@@ -109,6 +109,12 @@
"softener_low": { "softener_low": {
"name": "Aggiungi ammorbidente" "name": "Aggiungi ammorbidente"
}, },
"add_wash_available": {
"name": "AddWash consentito"
},
"add_wash_indicator": {
"name": "AddWash disponibile"
},
"stick_ble_connected": { "stick_ble_connected": {
"name": "Scopa elettrica connessa via BLE" "name": "Scopa elettrica connessa via BLE"
} }
@@ -1351,6 +1357,18 @@
"intensive": { "intensive": {
"name": "Intensivo" "name": "Intensivo"
}, },
"add_wash_alarm": {
"name": "Avviso AddWash"
},
"add_wash_alarm_rinse": {
"name": "AddWash al risciacquo"
},
"add_wash_alarm_final_rinse": {
"name": "AddWash all'ultimo risciacquo"
},
"add_wash_alarm_spin": {
"name": "AddWash alla centrifuga"
},
"lamp": { "lamp": {
"name": "Lampada" "name": "Lampada"
}, },
@@ -109,6 +109,12 @@
"softener_low": { "softener_low": {
"name": "섬유유연제 부족" "name": "섬유유연제 부족"
}, },
"add_wash_available": {
"name": "애드워시 허용"
},
"add_wash_indicator": {
"name": "애드워시 가능"
},
"stick_ble_connected": { "stick_ble_connected": {
"name": "스틱 BLE 연결됨" "name": "스틱 BLE 연결됨"
} }
@@ -1351,6 +1357,18 @@
"intensive": { "intensive": {
"name": "강력" "name": "강력"
}, },
"add_wash_alarm": {
"name": "애드워시 알림"
},
"add_wash_alarm_rinse": {
"name": "애드워시 헹굼"
},
"add_wash_alarm_final_rinse": {
"name": "애드워시 마지막 헹굼"
},
"add_wash_alarm_spin": {
"name": "애드워시 탈수"
},
"lamp": { "lamp": {
"name": "램프" "name": "램프"
}, },
@@ -109,6 +109,12 @@
"softener_low": { "softener_low": {
"name": "Wasverzachter bijna op" "name": "Wasverzachter bijna op"
}, },
"add_wash_available": {
"name": "AddWash toegestaan"
},
"add_wash_indicator": {
"name": "AddWash beschikbaar"
},
"stick_ble_connected": { "stick_ble_connected": {
"name": "Steel via BLE verbonden" "name": "Steel via BLE verbonden"
} }
@@ -1351,6 +1357,18 @@
"intensive": { "intensive": {
"name": "Intensief" "name": "Intensief"
}, },
"add_wash_alarm": {
"name": "AddWash-melding"
},
"add_wash_alarm_rinse": {
"name": "AddWash bij spoelen"
},
"add_wash_alarm_final_rinse": {
"name": "AddWash laatste spoeling"
},
"add_wash_alarm_spin": {
"name": "AddWash centrifugeren"
},
"lamp": { "lamp": {
"name": "Lamp" "name": "Lamp"
}, },
+6
View File
@@ -1,5 +1,11 @@
{ {
"state_keys": [ "state_keys": [
"add_wash_alarm",
"add_wash_alarm_final_rinse",
"add_wash_alarm_rinse",
"add_wash_alarm_spin",
"add_wash_available",
"add_wash_indicator",
"alarm_code", "alarm_code",
"bubble_soak", "bubble_soak",
"child_lock", "child_lock",
+28
View File
@@ -0,0 +1,28 @@
{
"state_keys": [
"add_wash_alarm",
"add_wash_alarm_final_rinse",
"add_wash_alarm_rinse",
"add_wash_alarm_spin",
"add_wash_available",
"add_wash_indicator",
"alarm_code",
"child_lock",
"completion_minutes",
"cycle",
"cycle_active",
"delay_start_hours",
"diagnosis_status",
"energy_kwh",
"finish_time",
"job_beginning_status",
"machine_state",
"power_switch",
"progress",
"progress_percentage",
"remote_control",
"rinse_cycles",
"spin_speed",
"wash_temperature"
]
}
+233
View File
@@ -0,0 +1,233 @@
{
"device0": [
{},
{
"href": "/realtimenotiforclient/vs/0",
"rep": {
"x.com.samsung.da.timeforshortnoti": "0",
"x.com.samsung.da.periodicnotisubscription": "true"
}
},
{
"href": "/alarms/vs/0",
"rep": {}
},
{
"href": "/diagnosis/vs/0",
"rep": {
"x.com.samsung.da.diagnosisStart": "Ready"
}
},
{
"href": "/energy/consumption/vs/0",
"rep": {
"x.com.samsung.da.instantaneousPowerUnit": "W",
"x.com.samsung.da.instantaneousPower": "-500",
"x.com.samsung.da.cumulativePower": "2016700",
"x.com.samsung.da.cumulativeUnit": "Wh",
"x.com.samsung.da.cumulativeDate": "1787050800",
"x.com.samsung.da.cumulativeDateUTC": "1787050800"
}
},
{
"href": "/energy/consumption/0",
"rep": {}
},
{
"href": "/course/vs/0",
"rep": {
"x.com.samsung.da.options": [
"DeviceType_0167",
"Course_5C",
"LaundryOutTime_0",
"AddWashSet_0",
"AddWashAvailable_7",
"AddWashIndicator_Off",
"QuickWash_Not_Used",
"QuickWashSet_5B847E933FA53F",
"UsagesDB_ok",
"EnergyKW_396",
"DrumCleanLog_Empty",
"TimeSync_NotSupported"
],
"x.com.samsung.da.supportedOptions": [
"35B847E933FA53F5C841E923FA53F5D8102923FA43F66841E930FA30F5E831E920FA2075F867E943FA53F60831E930FA43F61841E943FA43F6385209204A204648000913FA53F6B80009000A53E65841E920FA30F67843E923FA43F688430923FA53F"
]
}
},
{
"href": "/power/vs/0",
"rep": {
"x.com.samsung.da.power": "On"
}
},
{
"href": "/power/0",
"rep": {
"value": true
}
},
{
"href": "/cycleinterface/vs/0",
"rep": {}
},
{
"href": "/kidslock/vs/0",
"rep": {
"x.com.samsung.da.kidsLock": "Ready"
}
},
{
"href": "/kidslock/0",
"rep": {
"value": false
}
},
{
"href": "/operational/state/vs/0",
"rep": {
"x.com.samsung.da.state": "Ready",
"x.com.samsung.da.remainingTime": "01:07:00",
"x.com.samsung.da.progressPercentage": "1",
"x.com.samsung.da.progress": "None",
"x.com.samsung.da.supportedProgress": [
"None",
"Wash",
"Rinse",
"Spin",
"Finish"
]
}
},
{
"href": "/operational/state/0",
"rep": {
"currentMachineState": "idle",
"machineStates": [
"pause",
"active",
"idle"
],
"jobStates": [
"None",
"Wash",
"Rinse",
"Spin",
"Finish"
],
"currentJobState": "None",
"remainingTime": "01:07:00",
"progressPercentage": "1"
}
},
{
"href": "/information/vs/0",
"rep": {
"x.com.samsung.da.modelNum": "DA_WM_A51_20_COMMON|FFFFFFFF|20010102001011070000000000000000",
"x.com.samsung.da.description": "DA_WM_A51_20_COMMON_WW6500",
"x.com.samsung.da.serialNum": "**REDACTED**",
"x.com.samsung.da.otnDUID": "**REDACTED**",
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "DA_WM_A51_20_COMMON|FFFFFFFF|20010102001011070000000000000000",
"x.com.samsung.da.type": "Software",
"x.com.samsung.da.number": "02198A230708(E257)",
"x.com.samsung.da.newVersionAvailable": "0"
},
{
"x.com.samsung.da.id": "1",
"x.com.samsung.da.description": "DA_WM_A51_20_COMMON",
"x.com.samsung.da.type": "Firmware",
"x.com.samsung.da.number": "Unknown",
"x.com.samsung.da.newVersionAvailable": "0"
}
]
}
},
{
"href": "/file/information/vs/0",
"rep": {
"x.com.samsung.timeoffset": "+00:00"
}
},
{
"href": "/washer/vs/0",
"rep": {
"x.com.samsung.da.waterTemperature": "30",
"x.com.samsung.da.supportedWaterTemperature": [
"None",
"Cold",
"20",
"30",
"40",
"60",
"95"
],
"x.com.samsung.da.spinLevel": "1400",
"x.com.samsung.da.supportedSpinLevel": [
"RinseHold",
"NoSpin",
"400",
"800",
"1200",
"1400"
],
"x.com.samsung.da.rinseCycles": "3",
"x.com.samsung.da.supportedRinseCycles": [
"0",
"1",
"2",
"3",
"4",
"5"
]
}
},
{
"href": "/st/washercourse/vs/0",
"rep": {
"x.com.samsung.da.st.washerMode": "Table_00_Course_5C",
"x.com.samsung.da.st.courseTable": "Table_00"
}
},
{
"href": "/setting/vs/0",
"rep": {}
},
{
"href": "/wm/editcourse/vs/0",
"rep": {}
},
{
"href": "/wm/setinfo/vs/0",
"rep": {
"x.com.samsung.da.isModelSettingWithoutSC": "false",
"x.com.samsung.da.isModelSettingPowerOnOff": "false"
}
},
{
"href": "/wm/jobbeginingstatus/vs/0",
"rep": {}
},
{
"href": "/remotectrl/vs/0",
"rep": {
"x.com.samsung.da.remoteControlEnabled": "true"
}
},
{
"href": "/remotectrl/0",
"rep": {
"value": true
}
},
{
"href": "/configuration/vs/0",
"rep": {
"x.com.samsung.da.region": "0000000000",
"x.com.samsung.da.countryCode": "CZ"
}
}
]
}
+16
View File
@@ -389,6 +389,22 @@ def test_registry_reproduces_golden_state_keys_for_washer_dryer_combo():
) )
def test_registry_reproduces_golden_state_keys_for_washer_ww6500():
"""DA_WM_A51_20 front-loader, routed by /oic/d's oic.d.washer rather
than a board token. Reports the AddWash tokens and an empty
/wm/editcourse/vs/0, so its cycle list comes from supportedOptions."""
from tests.conftest import _load_device
resources = _load_device("washer_ww6500")
golden = json.loads((GOLDEN / "washer_ww6500.json").read_text())
state_keys = _new_state_keys("washer_ww6500", resources)
assert set(state_keys) == set(golden["state_keys"]), (
f"state_keys mismatch:\n"
f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n"
f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}"
)
def test_registry_reproduces_golden_state_keys_for_artik051_ref_17k(): def test_registry_reproduces_golden_state_keys_for_artik051_ref_17k():
"""ARTIK051_REF_17K's Cool Select Zone pantry compartment """ARTIK051_REF_17K's Cool Select Zone pantry compartment
(/status/pantry/one/vs/0) -- issue #20.""" (/status/pantry/one/vs/0) -- issue #20."""
+223
View File
@@ -0,0 +1,223 @@
"""Tests for the AddWash entities on /course/vs/0.
Three independent tokens: AddWashSet (the alarm's 3-bit mask, the only
writable one), AddWashAvailable (what the loaded course permits) and
AddWashIndicator (the live panel lamp). See washer.py for where the bit
meanings and the write contract come from.
"""
import pytest
from custom_components.localthings.registry.adapter import flatten
from custom_components.localthings.registry.by_type import resolve
from custom_components.localthings.registry.capabilities import washer
from custom_components.localthings.registry.discovery import discover
from custom_components.localthings.registry.entities import BinarySensorDesc, SwitchDesc
from tests.conftest import _load_device
COURSE = ["course", "vs", "0"]
BITS = {
"add_wash_alarm_rinse": 0,
"add_wash_alarm_final_rinse": 1,
"add_wash_alarm_spin": 2,
}
ENTITY_TOKENS = {
"add_wash_alarm": "AddWashSet",
"add_wash_alarm_rinse": "AddWashSet",
"add_wash_alarm_final_rinse": "AddWashSet",
"add_wash_alarm_spin": "AddWashSet",
"add_wash_available": "AddWashAvailable",
"add_wash_indicator": "AddWashIndicator",
}
# A value each token really carries, so presence gating is exercised against
# what a washer reports rather than a synthetic one.
SAMPLE = {"AddWashSet": "0", "AddWashAvailable": "7", "AddWashIndicator": "Off"}
def _desc(key, kind):
return next(e for e in washer.WASHER_COURSE.entities if e.key == key and isinstance(e, kind))
def _rep(*tokens):
return {"x.com.samsung.da.options": list(tokens)}
def _write(desc, payload, rep):
return desc.write_fn(payload, rep)
def _options(result):
"""The tokens a write_fn result carries, asserting it targets /course/vs/0."""
path, body = result
assert path == COURSE
return body["x.com.samsung.da.options"]
def _flatten(fixture):
resources = _load_device(fixture)
reg = resolve(resources)
return flatten(discover(resources, reg.capabilities, reg.pattern_capabilities), resources)
class TestAlarmMasterSwitch:
def test_zero_reads_off_and_seven_reads_on(self):
desc = _desc("add_wash_alarm", SwitchDesc)
assert desc.rep_fn(_rep("AddWashSet_0")) is False
assert desc.rep_fn(_rep("AddWashSet_7")) is True
@pytest.mark.parametrize("mask", range(8))
def test_any_non_zero_mask_reads_on(self, mask):
desc = _desc("add_wash_alarm", SwitchDesc)
assert desc.rep_fn(_rep(f"AddWashSet_{mask}")) is (mask != 0)
def test_turning_on_enables_every_moment(self):
desc = _desc("add_wash_alarm", SwitchDesc)
assert _options(_write(desc, "On", _rep("AddWashSet_0"))) == ["AddWashSet_7"]
def test_turning_off_clears_the_mask(self):
desc = _desc("add_wash_alarm", SwitchDesc)
assert _options(_write(desc, "Off", _rep("AddWashSet_5"))) == ["AddWashSet_0"]
def test_rejects_a_payload_that_is_not_on_or_off(self):
desc = _desc("add_wash_alarm", SwitchDesc)
assert _write(desc, "7", _rep("AddWashSet_0")) is None
def test_rejects_a_write_against_an_empty_options_array(self):
desc = _desc("add_wash_alarm", SwitchDesc)
assert _write(desc, "On", {}) is None
class TestAlarmMomentSwitches:
@pytest.mark.parametrize("key,bit", BITS.items())
@pytest.mark.parametrize("mask", range(8))
def test_every_mask_decodes_to_the_right_bits(self, key, bit, mask):
desc = _desc(key, SwitchDesc)
assert desc.rep_fn(_rep(f"AddWashSet_{mask}")) is bool(mask >> bit & 1)
def test_setting_one_moment_leaves_the_others_alone(self):
desc = _desc("add_wash_alarm_final_rinse", SwitchDesc)
# 5 is rinse + spin; adding the final rinse must reach 7, not 2.
assert _options(_write(desc, "On", _rep("AddWashSet_5"))) == ["AddWashSet_7"]
def test_clearing_one_moment_leaves_the_others_alone(self):
desc = _desc("add_wash_alarm_rinse", SwitchDesc)
assert _options(_write(desc, "Off", _rep("AddWashSet_7"))) == ["AddWashSet_6"]
def test_clearing_the_last_moment_yields_zero(self):
desc = _desc("add_wash_alarm_spin", SwitchDesc)
assert _options(_write(desc, "Off", _rep("AddWashSet_4"))) == ["AddWashSet_0"]
def test_enabling_a_moment_from_zero_turns_the_alarm_on(self):
"""The mask is the only state, so this is the intended outcome --
there is no remembered combination to restore."""
moment = _desc("add_wash_alarm_spin", SwitchDesc)
written = _options(_write(moment, "On", _rep("AddWashSet_0")))
assert written == ["AddWashSet_4"]
assert _desc("add_wash_alarm", SwitchDesc).rep_fn(_rep(*written)) is True
@pytest.mark.parametrize("rep", [{}, _rep("AddWashSet_x"), _rep("Course_5C")])
def test_refuses_to_write_when_the_mask_is_unreadable(self, rep):
desc = _desc("add_wash_alarm_rinse", SwitchDesc)
assert _write(desc, "On", rep) is None
class TestMaskParsing:
def test_a_missing_token_is_unavailable_not_zero(self):
assert washer._add_wash_mask(_rep("Course_5C"), "AddWashSet") is None
assert _desc("add_wash_alarm", SwitchDesc).rep_fn(_rep("Course_5C")) is None
def test_a_malformed_token_is_unavailable(self):
assert washer._add_wash_mask(_rep("AddWashSet_On"), "AddWashSet") is None
def test_zero_is_a_real_value(self):
assert washer._add_wash_mask(_rep("AddWashSet_0"), "AddWashSet") == 0
@pytest.mark.parametrize("raw", ["8", "255", "-1"])
def test_a_mask_outside_three_bits_is_unavailable(self, raw):
"""This models exactly three moments, so a wider value means the
model is wrong -- refuse it rather than read-modify-write it back."""
assert washer._add_wash_mask(_rep(f"AddWashSet_{raw}"), "AddWashSet") is None
assert _desc("add_wash_alarm_rinse", SwitchDesc).rep_fn(_rep(f"AddWashSet_{raw}")) is None
assert (
_write(_desc("add_wash_alarm_spin", SwitchDesc), "On", _rep(f"AddWashSet_{raw}"))
is None
)
class TestReadOnlySensors:
@pytest.mark.parametrize("raw,expected", [("0", False), ("6", True), ("7", True)])
def test_available_is_true_for_any_permitted_moment(self, raw, expected):
desc = _desc("add_wash_available", BinarySensorDesc)
assert desc.rep_fn(_rep(f"AddWashAvailable_{raw}")) is expected
@pytest.mark.parametrize("raw,expected", [("On", True), ("Off", False)])
def test_indicator_maps_on_off(self, raw, expected):
desc = _desc("add_wash_indicator", BinarySensorDesc)
assert desc.rep_fn(_rep(f"AddWashIndicator_{raw}")) is expected
def test_indicator_ships_enabled_and_uncategorised(self):
desc = _desc("add_wash_indicator", BinarySensorDesc)
assert desc.enabled_default is True
assert desc.entity_category is None
def test_missing_tokens_are_unavailable(self):
assert _desc("add_wash_available", BinarySensorDesc).rep_fn(_rep()) is None
assert _desc("add_wash_indicator", BinarySensorDesc).rep_fn(_rep()) is None
class TestCapabilityDetection:
"""Each entity self-gates on its own token, so a washer advertising only
a subset gets only that subset."""
@pytest.mark.parametrize("key,token", ENTITY_TOKENS.items())
def test_absent_on_a_washer_that_never_reports_the_token(self, key, token):
desc = next(e for e in washer.WASHER_COURSE.entities if e.key == key)
assert desc.exists_fn(_rep("Course_5C"), {}) is False
@pytest.mark.parametrize("key,token", ENTITY_TOKENS.items())
def test_present_once_the_token_appears(self, key, token):
desc = next(e for e in washer.WASHER_COURSE.entities if e.key == key)
assert desc.exists_fn(_rep(f"{token}_{SAMPLE[token]}"), {}) is True
def test_a_washer_with_only_the_indicator_gets_only_that_entity(self):
present = {
e.key
for e in washer.WASHER_COURSE.entities
if e.key in ENTITY_TOKENS and e.exists_fn(_rep("AddWashIndicator_On"), {})
}
assert present == {"add_wash_indicator"}
def test_pre_add_wash_washers_gain_nothing(self):
"""washer_device is a DA_WM_TP1_21 dump with no AddWash tokens."""
state = _flatten("washer")
assert not [key for key in state if key.startswith("add_wash")]
class TestAgainstTheWW6500Dump:
"""DA_WM_A51_20_COMMON_WW6500, captured with the alarm off, the course
permitting all three moments and the lamp dark."""
def test_no_unbound_hrefs(self):
resources = _load_device("washer_ww6500")
reg = resolve(resources)
unbound = []
discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append)
assert unbound == []
def test_reports_every_add_wash_entity(self):
state = _flatten("washer_ww6500")
assert {key for key in state if key.startswith("add_wash")} == {
"add_wash_alarm",
"add_wash_alarm_rinse",
"add_wash_alarm_final_rinse",
"add_wash_alarm_spin",
"add_wash_available",
"add_wash_indicator",
}
def test_alarm_off_course_permits_lamp_dark(self):
state = _flatten("washer_ww6500")
assert state["add_wash_alarm"] is False
assert all(state[f"add_wash_alarm_{m}"] is False for m in ("rinse", "final_rinse", "spin"))
assert state["add_wash_available"] is True
assert state["add_wash_indicator"] is False