Merge pull request #401 from vmonkey/feature/add-wash
Add AddWash controls and sensors for washers
This commit is contained in:
@@ -16,7 +16,7 @@ array.
|
||||
"""
|
||||
|
||||
from ..capability import Capability
|
||||
from ..entities import BinarySensorDesc, SelectDesc, SensorDesc
|
||||
from ..entities import BinarySensorDesc, SelectDesc, SensorDesc, SwitchDesc
|
||||
from .laundry import (
|
||||
bool_option_exists,
|
||||
bool_option_switch,
|
||||
@@ -264,6 +264,105 @@ 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):
|
||||
# Gated on the mask being readable, like the per-moment writes: a device
|
||||
# reporting a wider mask than these three bits would otherwise have it
|
||||
# truncated to 7 here, silently dropping a moment it supports.
|
||||
mask = _add_wash_mask(rep, "AddWashSet")
|
||||
if p not in ("On", "Off") or mask is None:
|
||||
return None
|
||||
if p == "On" and mask:
|
||||
# Already on, so "on" is a no-op rather than a rewrite to 7. Home
|
||||
# Assistant calls turn_on regardless of current state, so an
|
||||
# automation asserting the alarm on over a rinse-only mask would
|
||||
# otherwise widen it to all three moments with no state change on
|
||||
# this switch to point at. Distinct from the off-then-on case in
|
||||
# _add_wash_bit_switch, where there is no subset left to keep.
|
||||
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. The corollary is that switching the master off and on again
|
||||
writes 7, resetting a rinse-only selection to all three moments -- the
|
||||
appliance remembers no previous subset either, so there is nothing to
|
||||
restore.
|
||||
"""
|
||||
|
||||
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(
|
||||
href="/course/vs/0",
|
||||
entities=(
|
||||
@@ -350,5 +449,33 @@ WASHER_COURSE = Capability(
|
||||
_bool_option_switch(
|
||||
"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),
|
||||
# On at rest: an idle washer reports AddWashAvailable_7 and the mask
|
||||
# only empties as the cycle consumes each moment. This says the cycle
|
||||
# permits AddWash, not that laundry can go in now -- that is
|
||||
# add_wash_indicator.
|
||||
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": {
|
||||
"name": "Málo aviváže"
|
||||
},
|
||||
"add_wash_available": {
|
||||
"name": "AddWash povoleno"
|
||||
},
|
||||
"add_wash_indicator": {
|
||||
"name": "AddWash připraveno"
|
||||
},
|
||||
"stick_ble_connected": {
|
||||
"name": "Tyč připojena přes BLE"
|
||||
}
|
||||
@@ -1364,6 +1370,18 @@
|
||||
"intensive": {
|
||||
"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": {
|
||||
"name": "Lampa"
|
||||
},
|
||||
|
||||
@@ -103,6 +103,12 @@
|
||||
"softener_low": {
|
||||
"name": "Weichspüler niedrig"
|
||||
},
|
||||
"add_wash_available": {
|
||||
"name": "AddWash erlaubt"
|
||||
},
|
||||
"add_wash_indicator": {
|
||||
"name": "AddWash bereit"
|
||||
},
|
||||
"pouring": {
|
||||
"name": "Ausgabe läuft"
|
||||
},
|
||||
@@ -1358,6 +1364,18 @@
|
||||
"intensive": {
|
||||
"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": {
|
||||
"name": "Lampe"
|
||||
},
|
||||
|
||||
@@ -109,6 +109,12 @@
|
||||
"softener_low": {
|
||||
"name": "Softener low"
|
||||
},
|
||||
"add_wash_available": {
|
||||
"name": "AddWash allowed"
|
||||
},
|
||||
"add_wash_indicator": {
|
||||
"name": "AddWash ready"
|
||||
},
|
||||
"stick_ble_connected": {
|
||||
"name": "Stick BLE connected"
|
||||
}
|
||||
@@ -1364,6 +1370,18 @@
|
||||
"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": {
|
||||
"name": "Lamp"
|
||||
},
|
||||
|
||||
@@ -303,6 +303,12 @@
|
||||
"softener_low": {
|
||||
"name": "Poco suavizante"
|
||||
},
|
||||
"add_wash_available": {
|
||||
"name": "AddWash permitido"
|
||||
},
|
||||
"add_wash_indicator": {
|
||||
"name": "AddWash disponible"
|
||||
},
|
||||
"auto_clean_running": {
|
||||
"name": "Limpieza automática en curso"
|
||||
},
|
||||
@@ -1561,6 +1567,18 @@
|
||||
"intensive": {
|
||||
"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": {
|
||||
"name": "Lámpara"
|
||||
},
|
||||
|
||||
@@ -109,6 +109,12 @@
|
||||
"softener_low": {
|
||||
"name": "Aggiungi ammorbidente"
|
||||
},
|
||||
"add_wash_available": {
|
||||
"name": "AddWash consentito"
|
||||
},
|
||||
"add_wash_indicator": {
|
||||
"name": "AddWash disponibile"
|
||||
},
|
||||
"stick_ble_connected": {
|
||||
"name": "Scopa elettrica connessa via BLE"
|
||||
}
|
||||
@@ -1364,6 +1370,18 @@
|
||||
"intensive": {
|
||||
"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": {
|
||||
"name": "Lampada"
|
||||
},
|
||||
|
||||
@@ -109,6 +109,12 @@
|
||||
"softener_low": {
|
||||
"name": "섬유유연제 부족"
|
||||
},
|
||||
"add_wash_available": {
|
||||
"name": "애드워시 허용"
|
||||
},
|
||||
"add_wash_indicator": {
|
||||
"name": "애드워시 가능"
|
||||
},
|
||||
"stick_ble_connected": {
|
||||
"name": "스틱 BLE 연결됨"
|
||||
}
|
||||
@@ -1364,6 +1370,18 @@
|
||||
"intensive": {
|
||||
"name": "강력"
|
||||
},
|
||||
"add_wash_alarm": {
|
||||
"name": "애드워시 알림"
|
||||
},
|
||||
"add_wash_alarm_rinse": {
|
||||
"name": "애드워시 헹굼"
|
||||
},
|
||||
"add_wash_alarm_final_rinse": {
|
||||
"name": "애드워시 마지막 헹굼"
|
||||
},
|
||||
"add_wash_alarm_spin": {
|
||||
"name": "애드워시 탈수"
|
||||
},
|
||||
"lamp": {
|
||||
"name": "램프"
|
||||
},
|
||||
|
||||
@@ -109,6 +109,12 @@
|
||||
"softener_low": {
|
||||
"name": "Wasverzachter bijna op"
|
||||
},
|
||||
"add_wash_available": {
|
||||
"name": "AddWash toegestaan"
|
||||
},
|
||||
"add_wash_indicator": {
|
||||
"name": "AddWash beschikbaar"
|
||||
},
|
||||
"stick_ble_connected": {
|
||||
"name": "Steel via BLE verbonden"
|
||||
}
|
||||
@@ -1364,6 +1370,18 @@
|
||||
"intensive": {
|
||||
"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": {
|
||||
"name": "Lamp"
|
||||
},
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
{
|
||||
"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",
|
||||
"bubble_soak",
|
||||
"child_lock",
|
||||
|
||||
+28
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -408,6 +408,25 @@ 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, typed solely by the WW consumer prefix in
|
||||
its /information/vs/0 description: A51 is not a board token, so with the
|
||||
description blanked this device resolves to nothing and drops to the
|
||||
unknown-device fallback. That is the fragile route this test pins.
|
||||
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():
|
||||
"""ARTIK051_REF_17K's Cool Select Zone pantry compartment
|
||||
(/status/pantry/one/vs/0) -- issue #20."""
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
"""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 _exists(key, rep):
|
||||
"""Whether `key`'s descriptor gates itself in for `rep`."""
|
||||
desc = next(e for e in washer.WASHER_COURSE.entities if e.key == key)
|
||||
assert desc.exists_fn is not None
|
||||
return desc.exists_fn(rep, {})
|
||||
|
||||
|
||||
def _flatten(fixture):
|
||||
resources = _load_device(fixture)
|
||||
reg = resolve(resources)
|
||||
assert reg is not None
|
||||
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"]
|
||||
|
||||
@pytest.mark.parametrize("mask", range(1, 8))
|
||||
def test_on_over_an_alarm_already_on_keeps_the_chosen_moments(self, mask):
|
||||
"""Home Assistant calls turn_on regardless of current state, so
|
||||
re-asserting "on" over a rinse-only mask must not widen it to all
|
||||
three -- this switch reads on either way, so no state change would
|
||||
point at the loss. Reaching 7 from a subset still means off, then
|
||||
on."""
|
||||
desc = _desc("add_wash_alarm", SwitchDesc)
|
||||
rep = _rep(f"AddWashSet_{mask}")
|
||||
assert desc.rep_fn(rep) is True
|
||||
assert _write(desc, "On", rep) is None
|
||||
assert _options(_write(desc, "Off", rep)) == ["AddWashSet_0"]
|
||||
assert _options(_write(desc, "On", _rep("AddWashSet_0"))) == ["AddWashSet_7"]
|
||||
|
||||
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
|
||||
|
||||
@pytest.mark.parametrize("raw", ["15", "On", "-1"])
|
||||
def test_refuses_to_write_over_a_mask_it_cannot_read(self, raw):
|
||||
"""A device reporting a wider mask must not have it truncated to 7.
|
||||
The master is gated exactly like the per-moment writes, so an
|
||||
unrecognized mask leaves every AddWash switch read-only."""
|
||||
desc = _desc("add_wash_alarm", SwitchDesc)
|
||||
rep = _rep(f"AddWashSet_{raw}")
|
||||
assert desc.rep_fn(rep) is None
|
||||
assert _write(desc, "On", rep) is None
|
||||
assert _write(desc, "Off", rep) 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):
|
||||
assert _exists(key, _rep("Course_5C")) is False
|
||||
|
||||
@pytest.mark.parametrize("key,token", ENTITY_TOKENS.items())
|
||||
def test_present_once_the_token_appears(self, key, token):
|
||||
assert _exists(key, _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 _exists(e.key, _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_types_only_by_the_description_consumer_prefix(self):
|
||||
"""A51 is not a board token, so the WW prefix in the description is
|
||||
the only thing routing this device -- see the golden test's docstring.
|
||||
Pinned here so the claim can't quietly stop being true."""
|
||||
from custom_components.localthings.registry.by_type import for_device_by_model
|
||||
|
||||
info = _load_device("washer_ww6500")["/information/vs/0"]
|
||||
model = info["x.com.samsung.da.modelNum"]
|
||||
reg = for_device_by_model(model, info["x.com.samsung.da.description"])
|
||||
assert reg is not None and reg.name == "washer"
|
||||
assert for_device_by_model(model, "") is None
|
||||
|
||||
def test_no_unbound_hrefs(self):
|
||||
resources = _load_device("washer_ww6500")
|
||||
reg = resolve(resources)
|
||||
assert reg is not None
|
||||
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
|
||||
Reference in New Issue
Block a user