Compare commits
23
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
45931332d4 | ||
|
|
8c3250b163 | ||
|
|
aff7647460 | ||
|
|
705da781f1 | ||
|
|
6de1a4090e | ||
|
|
ef75db6697 | ||
|
|
9e290f27ee | ||
|
|
37bfff152d | ||
|
|
317b7d0c1b | ||
|
|
ba8e41d310 | ||
|
|
724686c42c | ||
|
|
edccb6ae3f | ||
|
|
4a91f62a36 | ||
|
|
1d4d38db14 | ||
|
|
365a1c4722 | ||
|
|
7be91b56e8 | ||
|
|
2c283218b0 | ||
|
|
ab874797fc | ||
|
|
f5d4e2219a | ||
|
|
86f07abf30 | ||
|
|
b00390c093 | ||
|
|
e913a3c2c0 | ||
|
|
b2db4bf357 |
@@ -20,6 +20,8 @@ back for, power, mode, temperature and wind resources alike.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from homeassistant.components.climate import (
|
||||
ClimateEntity,
|
||||
ClimateEntityFeature,
|
||||
@@ -53,6 +55,8 @@ from .const import DOMAIN
|
||||
from .coordinator import LocalThingsCoordinator
|
||||
from .entity import LocalThingsEntity, _is_included
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
_MODES_FIELD = 'x.com.samsung.da.modes'
|
||||
_SUPPORTED_FIELD = 'x.com.samsung.da.supportedModes'
|
||||
|
||||
@@ -68,6 +72,19 @@ _DEVICE_TO_HVAC: dict[str, HVACMode] = {
|
||||
}
|
||||
_HVAC_TO_DEVICE = {v: k for k, v in _DEVICE_TO_HVAC.items()}
|
||||
|
||||
# AI-driven auto-comfort mode (issue #93, A-CAWW-TP2-20-COMMON). Not a flat
|
||||
# _DEVICE_TO_HVAC entry: 'AIComfort' isn't a distinct thermodynamic operation
|
||||
# like Cool/Dry/Heat, it's an AI overlay on top of the device's own 'Auto'
|
||||
# behavior -- confirmed by this unit reporting both 'Auto' and 'AIComfort' as
|
||||
# separate, mutually-exclusive entries in /mode/vs/0's supportedModes. Modeled
|
||||
# the idiomatic HA way instead: hvac_mode reports AUTO (same as plain 'Auto'
|
||||
# would if it were ever mapped there) and a dedicated 'ai_comfort' preset
|
||||
# carries the distinction a bare hvac_mode can't. Not reachable via
|
||||
# async_set_hvac_mode -- entered/left only through the preset, since there's
|
||||
# no dedicated HVACMode value for it to write back to.
|
||||
_AI_COMFORT_MODE = 'AIComfort'
|
||||
PRESET_AI_COMFORT = 'ai_comfort'
|
||||
|
||||
# Fan (wind strength): device codes "0".."4" -> HA standard fan constants where
|
||||
# a clean match exists so they auto-localize; "turbo" is custom (translated).
|
||||
_DEVICE_TO_FAN: dict[str, str] = {
|
||||
@@ -164,6 +181,11 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
|
||||
| ClimateEntityFeature.TURN_ON
|
||||
| ClimateEntityFeature.TURN_OFF
|
||||
)
|
||||
# (href, raw device code) pairs already logged by _warn_unmapped --
|
||||
# these properties are read on every coordinator refresh, so an
|
||||
# un-deduped warning would spam the log for any device with a
|
||||
# genuinely unrecognized code.
|
||||
self._warned_unmapped: set[tuple[str, str]] = set()
|
||||
|
||||
# -- resource helpers ---------------------------------------------------
|
||||
|
||||
@@ -180,13 +202,34 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
|
||||
def _supported(self, href: str) -> list[str]:
|
||||
return list(self._rep(href).get(_SUPPORTED_FIELD) or [])
|
||||
|
||||
def _warn_unmapped(self, href: str, code: str) -> None:
|
||||
"""Log once per (href, code) when a device-reported mode has no
|
||||
entry in the relevant device<->HA map, so a real device gap surfaces
|
||||
in the log instead of silently vanishing (issue #93)."""
|
||||
key = (href, code)
|
||||
if key in self._warned_unmapped:
|
||||
return
|
||||
self._warned_unmapped.add(key)
|
||||
_LOGGER.warning(
|
||||
"%s: device mode %r on %s has no HA mapping and was dropped; "
|
||||
"please file an issue with your diagnostics dump",
|
||||
self.entity_id, code, href,
|
||||
)
|
||||
|
||||
def _read_mode(self, href: str, mapping: dict):
|
||||
"""Current mode of a wind/convenient resource, mapped to its HA value."""
|
||||
return mapping.get(_first(self._rep(href).get(_MODES_FIELD)))
|
||||
raw = _first(self._rep(href).get(_MODES_FIELD))
|
||||
if raw is not None and raw not in mapping:
|
||||
self._warn_unmapped(href, raw)
|
||||
return mapping.get(raw)
|
||||
|
||||
def _read_modes(self, href: str, mapping: dict) -> list[str]:
|
||||
"""Supported modes of a resource, mapped to HA values (unknowns dropped)."""
|
||||
return [mapping[c] for c in self._supported(href) if c in mapping]
|
||||
supported = self._supported(href)
|
||||
for c in supported:
|
||||
if c not in mapping:
|
||||
self._warn_unmapped(href, c)
|
||||
return [mapping[c] for c in supported if c in mapping]
|
||||
|
||||
# -- temperature --------------------------------------------------------
|
||||
|
||||
@@ -250,14 +293,23 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
|
||||
if not self._is_on():
|
||||
return HVACMode.OFF
|
||||
device = _first(self._rep(MODE_HREF).get(_MODES_FIELD))
|
||||
if device == _AI_COMFORT_MODE:
|
||||
return HVACMode.AUTO
|
||||
if device is not None and device not in _DEVICE_TO_HVAC:
|
||||
self._warn_unmapped(MODE_HREF, device)
|
||||
return _DEVICE_TO_HVAC.get(device, HVACMode.AUTO)
|
||||
|
||||
@property
|
||||
def hvac_modes(self) -> list[HVACMode]:
|
||||
modes = [HVACMode.OFF]
|
||||
for m in self._supported(MODE_HREF):
|
||||
if m == _AI_COMFORT_MODE:
|
||||
continue
|
||||
mapped = _DEVICE_TO_HVAC.get(m)
|
||||
if mapped is not None and mapped not in modes:
|
||||
if mapped is None:
|
||||
self._warn_unmapped(MODE_HREF, m)
|
||||
continue
|
||||
if mapped not in modes:
|
||||
modes.append(mapped)
|
||||
return modes
|
||||
|
||||
@@ -281,11 +333,16 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
|
||||
|
||||
@property
|
||||
def preset_mode(self):
|
||||
if _first(self._rep(MODE_HREF).get(_MODES_FIELD)) == _AI_COMFORT_MODE:
|
||||
return PRESET_AI_COMFORT
|
||||
return self._read_mode(CONVENIENT_HREF, _DEVICE_TO_PRESET)
|
||||
|
||||
@property
|
||||
def preset_modes(self) -> list[str]:
|
||||
return self._read_modes(CONVENIENT_HREF, _DEVICE_TO_PRESET)
|
||||
modes = self._read_modes(CONVENIENT_HREF, _DEVICE_TO_PRESET)
|
||||
if _AI_COMFORT_MODE in self._supported(MODE_HREF):
|
||||
modes.append(PRESET_AI_COMFORT)
|
||||
return modes
|
||||
|
||||
# -- writes -------------------------------------------------------------
|
||||
|
||||
@@ -324,4 +381,10 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
|
||||
await self._set_mapped('swing', _SWING_TO_DEVICE, swing_mode)
|
||||
|
||||
async def async_set_preset_mode(self, preset_mode: str) -> None:
|
||||
if preset_mode == PRESET_AI_COMFORT:
|
||||
# Writes the primary mode resource, not the convenient one --
|
||||
# 'AIComfort' lives in /mode/vs/0 alongside Cool/Dry/Auto, not in
|
||||
# /mode/convenient/vs/0 with Quiet/Smart/Speed/Sleep.
|
||||
await self.coordinator.async_send_command(self._bound, ('mode', _AI_COMFORT_MODE))
|
||||
return
|
||||
await self._set_mapped('preset', _PRESET_TO_DEVICE, preset_mode)
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
Shared by dryer/dishwasher/oven/washer families.
|
||||
"""
|
||||
from datetime import datetime, timezone, timedelta
|
||||
import math
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from ..capability import Capability
|
||||
from ..entities import BinarySensorDesc, ButtonDesc, NumberDesc, SensorDesc
|
||||
@@ -28,17 +29,41 @@ def _int(v):
|
||||
return None
|
||||
|
||||
|
||||
def _is_active(rep):
|
||||
"""Check if appliance is actively running and cycle is not finished."""
|
||||
return (
|
||||
_SAMSUNG_STATE_TO_OCF.get(rep.get('x.com.samsung.da.state')) == 'active'
|
||||
and rep.get('x.com.samsung.da.progress') != 'Finish'
|
||||
)
|
||||
|
||||
|
||||
def _remaining_seconds(raw):
|
||||
"""'HH:MM:SS' (or 'MM:SS') -> total seconds, or None."""
|
||||
if not isinstance(raw, str):
|
||||
return None
|
||||
try:
|
||||
parts = [int(p) for p in raw.split(':')]
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
if len(parts) == 3:
|
||||
h, m, s = parts
|
||||
elif len(parts) == 2:
|
||||
h, m, s = 0, *parts
|
||||
else:
|
||||
return None
|
||||
|
||||
return h * 3600 + m * 60 + s
|
||||
|
||||
|
||||
def _delay_hours(v):
|
||||
"""delayStartTime is a duration until the cycle starts, not a
|
||||
wall-clock time -- "01:00" means "1 hour from when you press start",
|
||||
not "1 AM"."""
|
||||
if not v:
|
||||
return 0.0
|
||||
try:
|
||||
h, m, s = v.split(':')
|
||||
return int(h) + int(m) / 60 + int(s) / 3600
|
||||
except Exception:
|
||||
return None
|
||||
total_seconds = _remaining_seconds(v)
|
||||
if total_seconds is None:
|
||||
return 0.0 if not v else None
|
||||
return total_seconds / 3600.0
|
||||
|
||||
|
||||
def _format_delay(hours):
|
||||
@@ -53,21 +78,33 @@ def _delay_field(rep):
|
||||
wall-clock time -- see _delay_hours). Write back whichever key the
|
||||
device itself is using; default to delayStartTime for hardware that
|
||||
reports neither yet (matches prior behavior)."""
|
||||
return ('x.com.samsung.da.delayEndTime' if 'x.com.samsung.da.delayEndTime' in rep
|
||||
else 'x.com.samsung.da.delayStartTime')
|
||||
return (
|
||||
'x.com.samsung.da.delayEndTime' if 'x.com.samsung.da.delayEndTime' in rep
|
||||
else 'x.com.samsung.da.delayStartTime'
|
||||
)
|
||||
|
||||
|
||||
def _finish_time(remaining_str):
|
||||
if not remaining_str:
|
||||
def _finish_time(rep):
|
||||
if not _is_active(rep):
|
||||
return None
|
||||
try:
|
||||
h, m, s = remaining_str.split(':')
|
||||
total_s = int(h) * 3600 + int(m) * 60 + int(s)
|
||||
if total_s == 0:
|
||||
return None
|
||||
return datetime.now(timezone.utc) + timedelta(seconds=total_s)
|
||||
except Exception:
|
||||
total_s = _remaining_seconds(rep.get('x.com.samsung.da.remainingTime'))
|
||||
if not total_s:
|
||||
return None
|
||||
return datetime.now(timezone.utc) + timedelta(seconds=total_s)
|
||||
|
||||
|
||||
def _completion_minutes(rep):
|
||||
"""Parse remaining time into minutes directly from device payload."""
|
||||
raw = rep.get('x.com.samsung.da.remainingTime') or rep.get('remainingTime')
|
||||
total_s = _remaining_seconds(raw)
|
||||
if total_s is None:
|
||||
return None
|
||||
|
||||
# Avoid the firmware bug where it freezes at 1 minute post-cycle
|
||||
if rep.get('x.com.samsung.da.progress') == 'Finish':
|
||||
return 0
|
||||
|
||||
return math.ceil(total_s / 60)
|
||||
|
||||
|
||||
# Shared by dryer/dishwasher/oven/washer -- oven.py imports this directly
|
||||
@@ -87,6 +124,7 @@ OPERATIONAL_STATE = Capability(
|
||||
device_class='enum',
|
||||
options=('idle', 'active', 'pause'),
|
||||
translation_key='machine_state', value_fn=_to_ocf),
|
||||
|
||||
# cycle_active is a bool derived from machine_state; used by the
|
||||
# adapter to gate oven writes (cycle_active_field='cycle_active').
|
||||
# Harmless for non-oven appliances — just an extra bool in state.
|
||||
@@ -106,21 +144,26 @@ OPERATIONAL_STATE = Capability(
|
||||
'Idle' if _SAMSUNG_STATE_TO_OCF.get(rep.get('x.com.samsung.da.state')) != 'active'
|
||||
else _progress(rep.get('x.com.samsung.da.progress'))
|
||||
)),
|
||||
|
||||
SensorDesc(key='progress_percentage',
|
||||
unit='%', state_class='measurement',
|
||||
rep_fn=lambda rep: (
|
||||
0 if _SAMSUNG_STATE_TO_OCF.get(rep.get('x.com.samsung.da.state')) != 'active'
|
||||
else _int(rep.get('x.com.samsung.da.progressPercentage'))
|
||||
)),
|
||||
|
||||
# Only show finish time when machine is actively running. Samsung
|
||||
# firmware leaves a stale remainingTime after a cycle ends, and
|
||||
# freezes it at '00:01:00' when progress reaches 'Finish'.
|
||||
SensorDesc(key='finish_time', device_class='timestamp',
|
||||
rep_fn=lambda rep: (
|
||||
None if _SAMSUNG_STATE_TO_OCF.get(rep.get('x.com.samsung.da.state')) != 'active'
|
||||
or rep.get('x.com.samsung.da.progress') == 'Finish'
|
||||
else _finish_time(rep.get('x.com.samsung.da.remainingTime'))
|
||||
)),
|
||||
rep_fn=_finish_time),
|
||||
|
||||
SensorDesc(key='completion_minutes',
|
||||
icon='mdi:clock-outline', unit='min',
|
||||
device_class='duration', state_class='measurement',
|
||||
exists_fn=lambda rep, resources: _completion_minutes(rep) is not None,
|
||||
rep_fn=_completion_minutes),
|
||||
|
||||
NumberDesc(key='delay_start_hours', icon='mdi:timer-plus-outline',
|
||||
device_class='duration', unit='h',
|
||||
native_min=0, native_max=24, step=1,
|
||||
@@ -140,6 +183,7 @@ OPERATIONAL_STATE = Capability(
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['operational', 'state', 'vs', '0'],
|
||||
{'x.com.samsung.da.state': p})),
|
||||
|
||||
STOP_BUTTON,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -95,6 +95,7 @@
|
||||
},
|
||||
"preset_mode": {
|
||||
"state": {
|
||||
"ai_comfort": "AI Comfort",
|
||||
"quiet": "Quiet",
|
||||
"smart": "Smart",
|
||||
"speed": "Speed"
|
||||
@@ -400,7 +401,9 @@
|
||||
"7e": "Self-Clean",
|
||||
"86": "Deep Wash",
|
||||
"8f": "Intense Cold",
|
||||
"96": "Less Microfiber"
|
||||
"96": "Less Microfiber",
|
||||
"2b": "AI Wash",
|
||||
"2a": "Jeans"
|
||||
}
|
||||
},
|
||||
"washer_dry_level": {
|
||||
@@ -509,6 +512,9 @@
|
||||
"finish_time": {
|
||||
"name": "Estimated finish"
|
||||
},
|
||||
"completion_minutes": {
|
||||
"name": "Completion time"
|
||||
},
|
||||
"freezer_temperature": {
|
||||
"name": "Freezer temperature"
|
||||
},
|
||||
|
||||
@@ -95,6 +95,7 @@
|
||||
},
|
||||
"preset_mode": {
|
||||
"state": {
|
||||
"ai_comfort": "AI-comfort",
|
||||
"quiet": "Stil",
|
||||
"smart": "Slim",
|
||||
"speed": "Snel"
|
||||
@@ -400,7 +401,9 @@
|
||||
"7e": "Zelfreinigend",
|
||||
"86": "Diep wassen",
|
||||
"8f": "Intensief koud",
|
||||
"96": "Minder microvezels"
|
||||
"96": "Minder microvezels",
|
||||
"2b": "AI Wash",
|
||||
"2a": "Spijkerbroek"
|
||||
}
|
||||
},
|
||||
"washer_dry_level": {
|
||||
@@ -509,6 +512,9 @@
|
||||
"finish_time": {
|
||||
"name": "Geschatte eindtijd"
|
||||
},
|
||||
"completion_minutes": {
|
||||
"name": "Voltooiingstijd"
|
||||
},
|
||||
"freezer_temperature": {
|
||||
"name": "Vriezertemperatuur"
|
||||
},
|
||||
|
||||
+1
@@ -3,6 +3,7 @@
|
||||
"alarm_code",
|
||||
"auto_release_dry",
|
||||
"child_lock",
|
||||
"completion_minutes",
|
||||
"cycle",
|
||||
"cycle_active",
|
||||
"delay_start_hours",
|
||||
|
||||
Vendored
+1
@@ -3,6 +3,7 @@
|
||||
"alarm_code",
|
||||
"buzzer_sound",
|
||||
"child_lock",
|
||||
"completion_minutes",
|
||||
"cycle",
|
||||
"cycle_active",
|
||||
"delay_start_hours",
|
||||
|
||||
Vendored
+1
@@ -5,6 +5,7 @@
|
||||
"bubble_soak",
|
||||
"buzzer_sound",
|
||||
"child_lock",
|
||||
"completion_minutes",
|
||||
"cycle",
|
||||
"cycle_active",
|
||||
"delay_start_hours",
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"alarm_code",
|
||||
"bubble_soak",
|
||||
"child_lock",
|
||||
"completion_minutes",
|
||||
"cycle",
|
||||
"cycle_active",
|
||||
"delay_start_hours",
|
||||
|
||||
+1
@@ -2,6 +2,7 @@
|
||||
"state_keys": [
|
||||
"alarm_code",
|
||||
"child_lock",
|
||||
"completion_minutes",
|
||||
"cycle",
|
||||
"cycle_active",
|
||||
"delay_start_hours",
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
"""Tests for the AC HVAC-mode/preset device<->HA maps in climate.py (issue #93).
|
||||
|
||||
Module-level dicts/constants with no coordinator/entity dependency, so --
|
||||
like `_temps_vs_item` in test_climate_temperature_fallback.py -- they're
|
||||
testable directly.
|
||||
"""
|
||||
from homeassistant.components.climate import HVACMode
|
||||
|
||||
from custom_components.localthings.climate import (
|
||||
_AI_COMFORT_MODE, _DEVICE_TO_HVAC, _HVAC_TO_DEVICE, PRESET_AI_COMFORT,
|
||||
)
|
||||
|
||||
|
||||
def test_auto_still_maps_to_heat_cool():
|
||||
"""'Auto' is unchanged -- AIComfort is handled separately, not folded
|
||||
into this map."""
|
||||
assert _DEVICE_TO_HVAC['Auto'] == HVACMode.HEAT_COOL
|
||||
|
||||
|
||||
def test_aicomfort_not_in_flat_hvac_map():
|
||||
"""AIComfort isn't a flat _DEVICE_TO_HVAC entry -- it's an AI overlay on
|
||||
top of 'Auto', modeled as hvac_mode=AUTO + a dedicated preset instead of
|
||||
a distinct HVACMode value (see the climate.py module comment)."""
|
||||
assert _AI_COMFORT_MODE not in _DEVICE_TO_HVAC
|
||||
|
||||
|
||||
def test_hvac_auto_not_writable_via_hvac_mode():
|
||||
"""HVACMode.AUTO has no _DEVICE_TO_HVAC entry, so it's unreachable via
|
||||
async_set_hvac_mode -- entered/left only through the ai_comfort preset."""
|
||||
assert HVACMode.AUTO not in _HVAC_TO_DEVICE
|
||||
|
||||
|
||||
def test_fan_only_still_reachable_via_wind():
|
||||
"""Guard against regressing the existing 'Wind' -> FAN_ONLY entry while
|
||||
editing this map."""
|
||||
assert _DEVICE_TO_HVAC['Wind'] == HVACMode.FAN_ONLY
|
||||
|
||||
|
||||
def test_preset_ai_comfort_constant():
|
||||
assert PRESET_AI_COMFORT == 'ai_comfort'
|
||||
@@ -4,10 +4,11 @@ from custom_components.localthings.registry.capabilities import laundry
|
||||
|
||||
class TestCourseHelpers:
|
||||
def test_parses_edit_course_list(self):
|
||||
raw = 'EditCourseList_1C1D211B1E29243328262722202325322F2E30662D8F96'
|
||||
raw = 'EditCourseList_1C1D211B1E29243328262722202325322F2E30662D8F962B2A'
|
||||
assert laundry.parse_edit_course_list(raw) == [
|
||||
'1C', '1D', '21', '1B', '1E', '29', '24', '33', '28', '26', '27',
|
||||
'22', '20', '23', '25', '32', '2F', '2E', '30', '66', '2D', '8F', '96',
|
||||
'2B', '2A'
|
||||
]
|
||||
|
||||
def test_parse_edit_course_list_handles_missing_or_malformed(self):
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
"""Unit tests for operational state capabilities."""
|
||||
|
||||
from custom_components.localthings.registry.capabilities.operational import OPERATIONAL_STATE
|
||||
|
||||
|
||||
@@ -24,6 +26,42 @@ class TestProgressPercentage:
|
||||
assert desc.rep_fn(rep) == 42
|
||||
|
||||
|
||||
class TestCompletionMinutes:
|
||||
"""Unit tests for completion_minutes entity."""
|
||||
|
||||
def test_completion_minutes_parsing(self):
|
||||
desc = next(e for e in OPERATIONAL_STATE.entities if e.key == 'completion_minutes')
|
||||
|
||||
# 1 hour 25 mins 30 secs -> 85 mins + 1 sec ceiling = 86 mins
|
||||
rep = {'x.com.samsung.da.remainingTime': '01:25:30'}
|
||||
assert desc.rep_fn(rep) == 86
|
||||
|
||||
# Exact minutes: 1 hour 30 mins 00 secs -> 90 mins
|
||||
rep_exact = {'x.com.samsung.da.remainingTime': '01:30:00'}
|
||||
assert desc.rep_fn(rep_exact) == 90
|
||||
|
||||
def test_completion_minutes_fallback_key(self):
|
||||
desc = next(e for e in OPERATIONAL_STATE.entities if e.key == 'completion_minutes')
|
||||
rep = {'remainingTime': '00:45:00'}
|
||||
assert desc.rep_fn(rep) == 45
|
||||
|
||||
def test_completion_minutes_stale_finish_gated(self):
|
||||
"""Firmware freezes remainingTime at '00:01:00' when progress reaches 'Finish'.
|
||||
Should return 0 to prevent stuck values."""
|
||||
desc = next(e for e in OPERATIONAL_STATE.entities if e.key == 'completion_minutes')
|
||||
rep = {
|
||||
'x.com.samsung.da.progress': 'Finish',
|
||||
'x.com.samsung.da.remainingTime': '00:01:00',
|
||||
}
|
||||
assert desc.rep_fn(rep) == 0
|
||||
|
||||
def test_completion_minutes_missing_or_invalid(self):
|
||||
desc = next(e for e in OPERATIONAL_STATE.entities if e.key == 'completion_minutes')
|
||||
|
||||
rep = {}
|
||||
assert desc.rep_fn(rep) is None
|
||||
|
||||
|
||||
class TestDelayFieldFallback:
|
||||
def test_reads_delay_end_time_when_delay_start_time_absent(self):
|
||||
from custom_components.localthings.registry.capabilities.operational import OPERATIONAL_STATE
|
||||
@@ -53,4 +91,4 @@ class TestDelayFieldFallback:
|
||||
desc = next(e for e in OPERATIONAL_STATE.entities if e.key == 'delay_start_hours')
|
||||
rep = {'x.com.samsung.da.delayStartTime': '00:00:00'}
|
||||
path, body = desc.write_fn(1.5, rep)
|
||||
assert body == {'x.com.samsung.da.delayStartTime': '1:30:00'}
|
||||
assert body == {'x.com.samsung.da.delayStartTime': '1:30:00'}
|
||||
Reference in New Issue
Block a user