Compare commits
22
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eb07af3c9a | ||
|
|
aff7647460 | ||
|
|
705da781f1 | ||
|
|
6de1a4090e | ||
|
|
ef75db6697 | ||
|
|
9e290f27ee | ||
|
|
37bfff152d | ||
|
|
317b7d0c1b | ||
|
|
ba8e41d310 | ||
|
|
724686c42c | ||
|
|
edccb6ae3f | ||
|
|
4a91f62a36 | ||
|
|
1d4d38db14 | ||
|
|
365a1c4722 | ||
|
|
7be91b56e8 | ||
|
|
2c283218b0 | ||
|
|
ab874797fc | ||
|
|
f5d4e2219a | ||
|
|
86f07abf30 | ||
|
|
b00390c093 | ||
|
|
e913a3c2c0 | ||
|
|
b2db4bf357 |
No files matched your search
@@ -103,7 +103,38 @@ sub-polled between summary polls. Pick descriptor types from `entities.py`
|
|||||||
as a gap for a human, or ignore it with a documented reason — never invent an
|
as a gap for a human, or ignore it with a documented reason — never invent an
|
||||||
entity on a hunch (`ignored.py`'s rule).
|
entity on a hunch (`ignored.py`'s rule).
|
||||||
|
|
||||||
## 5. Names and enum labels live in translations, never in Python
|
## 5. Never hard-code the one dump's values
|
||||||
|
|
||||||
|
A single `/device/0` dump is **one device on one firmware** — its select options,
|
||||||
|
temperature range/increment, and any other "what values are valid here" data are
|
||||||
|
**that unit's snapshot**, not the field's universe. Other units of the same model
|
||||||
|
(different region, firmware, board revision) can support more, fewer, or
|
||||||
|
differently-stepped values. If the dump reports the live option/range list, wire
|
||||||
|
the descriptor to read it live — don't transcribe what you saw into a Python
|
||||||
|
literal:
|
||||||
|
|
||||||
|
- **Selects**: use `options_field` (a resource field holding the live options
|
||||||
|
list, e.g. `supportedWaterTemperature`, `iceType.supported`) so `select.py`
|
||||||
|
reads the current device's real options every time, not `options=(...)` typed
|
||||||
|
from the dump. Reach for a callable `options` only when the values require
|
||||||
|
cross-resource computation the field alone can't give you — a static tuple is
|
||||||
|
right only for genuinely fixed, spec-defined enums (e.g. an OCF-standard field
|
||||||
|
with a closed value set), never for vendor `supported*` lists.
|
||||||
|
- **Number ranges/steps**: use `range_field` (a `[min, max]`-shaped field) or
|
||||||
|
`native_min_fn`/`native_max_fn`/`step_fn` to read bounds from the live rep —
|
||||||
|
see `oven.py`'s `_setpoint_bounds`. Only fall back to static `native_min`/
|
||||||
|
`native_max`/`step` when the dump has no such field and the bound is genuinely
|
||||||
|
fixed by spec, not just "the only value this one unit happened to report."
|
||||||
|
- **Anywhere else** a field's presence, count, or shape looks like it could vary
|
||||||
|
by model/config (course lists, capability flags, supported-mode arrays):
|
||||||
|
check whether the resource carries its own `supported*` companion field before
|
||||||
|
assuming the observed value is exhaustive.
|
||||||
|
|
||||||
|
When you do hard-code something (a genuinely fixed enum, a spec constant), that's
|
||||||
|
a judgement call worth a one-line comment saying why it's safe — the default
|
||||||
|
assumption should be "derive it," not "copy it."
|
||||||
|
|
||||||
|
## 6. Names and enum labels live in translations, never in Python
|
||||||
|
|
||||||
Descriptors have **no `name` field**. Every entity is named from the shipped
|
Descriptors have **no `name` field**. Every entity is named from the shipped
|
||||||
catalog, keyed by `translation_key` — which defaults to the descriptor's own
|
catalog, keyed by `translation_key` — which defaults to the descriptor's own
|
||||||
@@ -142,7 +173,7 @@ no `[%key:...%]` resolution (that's Core build tooling). Every other language
|
|||||||
must mirror `en.json` key for key — also enforced by
|
must mirror `en.json` key for key — also enforced by
|
||||||
`tests/test_translations.py`.
|
`tests/test_translations.py`.
|
||||||
|
|
||||||
## 6. Coverage discipline: bound or ignored
|
## 7. Coverage discipline: bound or ignored
|
||||||
|
|
||||||
Every href in the dump must resolve, or the repair fires. If a resource isn't
|
Every href in the dump must resolve, or the repair fires. If a resource isn't
|
||||||
worth an entity, add it to `capabilities/ignored.py` (a no-entity `Capability`)
|
worth an entity, add it to `capabilities/ignored.py` (a no-entity `Capability`)
|
||||||
@@ -156,7 +187,7 @@ friendlier href**.
|
|||||||
ignored because washers bind it. When only one family should ignore an href
|
ignored because washers bind it. When only one family should ignore an href
|
||||||
that another binds, scope the ignore to that family's registry.
|
that another binds, scope the ignore to that family's registry.
|
||||||
|
|
||||||
## 7. Reuse before writing new code
|
## 8. Reuse before writing new code
|
||||||
|
|
||||||
Check `common.py` (generic OCF: power, energy, alarms, water) and `laundry.py`
|
Check `common.py` (generic OCF: power, energy, alarms, water) and `laundry.py`
|
||||||
(shared washer/dryer/dishwasher: buzzer, job status, `cycle_select` + course
|
(shared washer/dryer/dishwasher: buzzer, job status, `cycle_select` + course
|
||||||
@@ -165,7 +196,7 @@ registry uses `fridge.FIRMWARE_UPDATE`; all three laundry families share
|
|||||||
`laundry.cycle_select`. If two families hand-roll the same helper, hoist it to a
|
`laundry.cycle_select`. If two families hand-roll the same helper, hoist it to a
|
||||||
shared module rather than copying.
|
shared module rather than copying.
|
||||||
|
|
||||||
## 8. Lock it in
|
## 9. Lock it in
|
||||||
|
|
||||||
1. Add a **scrubbed** fixture `tests/fixtures/<type>_device.json`
|
1. Add a **scrubbed** fixture `tests/fixtures/<type>_device.json`
|
||||||
(`{"device0": [ {devcol rep}, {href, rep}, ... ]}`) — replace serials, MACs,
|
(`{"device0": [ {devcol rep}, {href, rep}, ... ]}`) — replace serials, MACs,
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
|
|
||||||
Shared by dryer/dishwasher/oven/washer families.
|
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 ..capability import Capability
|
||||||
from ..entities import BinarySensorDesc, ButtonDesc, NumberDesc, SensorDesc
|
from ..entities import BinarySensorDesc, ButtonDesc, NumberDesc, SensorDesc
|
||||||
@@ -28,17 +29,41 @@ def _int(v):
|
|||||||
return None
|
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):
|
def _delay_hours(v):
|
||||||
"""delayStartTime is a duration until the cycle starts, not a
|
"""delayStartTime is a duration until the cycle starts, not a
|
||||||
wall-clock time -- "01:00" means "1 hour from when you press start",
|
wall-clock time -- "01:00" means "1 hour from when you press start",
|
||||||
not "1 AM"."""
|
not "1 AM"."""
|
||||||
if not v:
|
total_seconds = _remaining_seconds(v)
|
||||||
return 0.0
|
if total_seconds is None:
|
||||||
try:
|
return 0.0 if not v else None
|
||||||
h, m, s = v.split(':')
|
return total_seconds / 3600.0
|
||||||
return int(h) + int(m) / 60 + int(s) / 3600
|
|
||||||
except Exception:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _format_delay(hours):
|
def _format_delay(hours):
|
||||||
@@ -53,21 +78,33 @@ def _delay_field(rep):
|
|||||||
wall-clock time -- see _delay_hours). Write back whichever key the
|
wall-clock time -- see _delay_hours). Write back whichever key the
|
||||||
device itself is using; default to delayStartTime for hardware that
|
device itself is using; default to delayStartTime for hardware that
|
||||||
reports neither yet (matches prior behavior)."""
|
reports neither yet (matches prior behavior)."""
|
||||||
return ('x.com.samsung.da.delayEndTime' if 'x.com.samsung.da.delayEndTime' in rep
|
return (
|
||||||
else 'x.com.samsung.da.delayStartTime')
|
'x.com.samsung.da.delayEndTime' if 'x.com.samsung.da.delayEndTime' in rep
|
||||||
|
else 'x.com.samsung.da.delayStartTime'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _finish_time(remaining_str):
|
def _finish_time(rep):
|
||||||
if not remaining_str:
|
if not _is_active(rep):
|
||||||
return None
|
return None
|
||||||
try:
|
total_s = _remaining_seconds(rep.get('x.com.samsung.da.remainingTime'))
|
||||||
h, m, s = remaining_str.split(':')
|
if not total_s:
|
||||||
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:
|
|
||||||
return None
|
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
|
# Shared by dryer/dishwasher/oven/washer -- oven.py imports this directly
|
||||||
@@ -87,6 +124,7 @@ OPERATIONAL_STATE = Capability(
|
|||||||
device_class='enum',
|
device_class='enum',
|
||||||
options=('idle', 'active', 'pause'),
|
options=('idle', 'active', 'pause'),
|
||||||
translation_key='machine_state', value_fn=_to_ocf),
|
translation_key='machine_state', value_fn=_to_ocf),
|
||||||
|
|
||||||
# cycle_active is a bool derived from machine_state; used by the
|
# cycle_active is a bool derived from machine_state; used by the
|
||||||
# adapter to gate oven writes (cycle_active_field='cycle_active').
|
# adapter to gate oven writes (cycle_active_field='cycle_active').
|
||||||
# Harmless for non-oven appliances — just an extra bool in state.
|
# 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'
|
'Idle' if _SAMSUNG_STATE_TO_OCF.get(rep.get('x.com.samsung.da.state')) != 'active'
|
||||||
else _progress(rep.get('x.com.samsung.da.progress'))
|
else _progress(rep.get('x.com.samsung.da.progress'))
|
||||||
)),
|
)),
|
||||||
|
|
||||||
SensorDesc(key='progress_percentage',
|
SensorDesc(key='progress_percentage',
|
||||||
unit='%', state_class='measurement',
|
unit='%', state_class='measurement',
|
||||||
rep_fn=lambda rep: (
|
rep_fn=lambda rep: (
|
||||||
0 if _SAMSUNG_STATE_TO_OCF.get(rep.get('x.com.samsung.da.state')) != 'active'
|
0 if _SAMSUNG_STATE_TO_OCF.get(rep.get('x.com.samsung.da.state')) != 'active'
|
||||||
else _int(rep.get('x.com.samsung.da.progressPercentage'))
|
else _int(rep.get('x.com.samsung.da.progressPercentage'))
|
||||||
)),
|
)),
|
||||||
|
|
||||||
# Only show finish time when machine is actively running. Samsung
|
# Only show finish time when machine is actively running. Samsung
|
||||||
# firmware leaves a stale remainingTime after a cycle ends, and
|
# firmware leaves a stale remainingTime after a cycle ends, and
|
||||||
# freezes it at '00:01:00' when progress reaches 'Finish'.
|
# freezes it at '00:01:00' when progress reaches 'Finish'.
|
||||||
SensorDesc(key='finish_time', device_class='timestamp',
|
SensorDesc(key='finish_time', device_class='timestamp',
|
||||||
rep_fn=lambda rep: (
|
rep_fn=_finish_time),
|
||||||
None if _SAMSUNG_STATE_TO_OCF.get(rep.get('x.com.samsung.da.state')) != 'active'
|
|
||||||
or rep.get('x.com.samsung.da.progress') == 'Finish'
|
SensorDesc(key='completion_minutes',
|
||||||
else _finish_time(rep.get('x.com.samsung.da.remainingTime'))
|
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',
|
NumberDesc(key='delay_start_hours', icon='mdi:timer-plus-outline',
|
||||||
device_class='duration', unit='h',
|
device_class='duration', unit='h',
|
||||||
native_min=0, native_max=24, step=1,
|
native_min=0, native_max=24, step=1,
|
||||||
@@ -140,6 +183,7 @@ OPERATIONAL_STATE = Capability(
|
|||||||
write_fn=lambda p, rep, href=None: (
|
write_fn=lambda p, rep, href=None: (
|
||||||
['operational', 'state', 'vs', '0'],
|
['operational', 'state', 'vs', '0'],
|
||||||
{'x.com.samsung.da.state': p})),
|
{'x.com.samsung.da.state': p})),
|
||||||
|
|
||||||
STOP_BUTTON,
|
STOP_BUTTON,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -400,7 +400,9 @@
|
|||||||
"7e": "Self-Clean",
|
"7e": "Self-Clean",
|
||||||
"86": "Deep Wash",
|
"86": "Deep Wash",
|
||||||
"8f": "Intense Cold",
|
"8f": "Intense Cold",
|
||||||
"96": "Less Microfiber"
|
"96": "Less Microfiber",
|
||||||
|
"2b": "AI Wash",
|
||||||
|
"2a": "Jeans"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"washer_dry_level": {
|
"washer_dry_level": {
|
||||||
@@ -509,6 +511,9 @@
|
|||||||
"finish_time": {
|
"finish_time": {
|
||||||
"name": "Estimated finish"
|
"name": "Estimated finish"
|
||||||
},
|
},
|
||||||
|
"completion_minutes": {
|
||||||
|
"name": "Completion time"
|
||||||
|
},
|
||||||
"freezer_temperature": {
|
"freezer_temperature": {
|
||||||
"name": "Freezer temperature"
|
"name": "Freezer temperature"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -400,7 +400,9 @@
|
|||||||
"7e": "Zelfreinigend",
|
"7e": "Zelfreinigend",
|
||||||
"86": "Diep wassen",
|
"86": "Diep wassen",
|
||||||
"8f": "Intensief koud",
|
"8f": "Intensief koud",
|
||||||
"96": "Minder microvezels"
|
"96": "Minder microvezels",
|
||||||
|
"2b": "AI Wash",
|
||||||
|
"2a": "Spijkerbroek"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"washer_dry_level": {
|
"washer_dry_level": {
|
||||||
@@ -509,6 +511,9 @@
|
|||||||
"finish_time": {
|
"finish_time": {
|
||||||
"name": "Geschatte eindtijd"
|
"name": "Geschatte eindtijd"
|
||||||
},
|
},
|
||||||
|
"completion_minutes": {
|
||||||
|
"name": "Voltooiingstijd"
|
||||||
|
},
|
||||||
"freezer_temperature": {
|
"freezer_temperature": {
|
||||||
"name": "Vriezertemperatuur"
|
"name": "Vriezertemperatuur"
|
||||||
},
|
},
|
||||||
|
|||||||
+1
@@ -3,6 +3,7 @@
|
|||||||
"alarm_code",
|
"alarm_code",
|
||||||
"auto_release_dry",
|
"auto_release_dry",
|
||||||
"child_lock",
|
"child_lock",
|
||||||
|
"completion_minutes",
|
||||||
"cycle",
|
"cycle",
|
||||||
"cycle_active",
|
"cycle_active",
|
||||||
"delay_start_hours",
|
"delay_start_hours",
|
||||||
|
|||||||
Vendored
+1
@@ -3,6 +3,7 @@
|
|||||||
"alarm_code",
|
"alarm_code",
|
||||||
"buzzer_sound",
|
"buzzer_sound",
|
||||||
"child_lock",
|
"child_lock",
|
||||||
|
"completion_minutes",
|
||||||
"cycle",
|
"cycle",
|
||||||
"cycle_active",
|
"cycle_active",
|
||||||
"delay_start_hours",
|
"delay_start_hours",
|
||||||
|
|||||||
Vendored
+1
@@ -5,6 +5,7 @@
|
|||||||
"bubble_soak",
|
"bubble_soak",
|
||||||
"buzzer_sound",
|
"buzzer_sound",
|
||||||
"child_lock",
|
"child_lock",
|
||||||
|
"completion_minutes",
|
||||||
"cycle",
|
"cycle",
|
||||||
"cycle_active",
|
"cycle_active",
|
||||||
"delay_start_hours",
|
"delay_start_hours",
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
"alarm_code",
|
"alarm_code",
|
||||||
"bubble_soak",
|
"bubble_soak",
|
||||||
"child_lock",
|
"child_lock",
|
||||||
|
"completion_minutes",
|
||||||
"cycle",
|
"cycle",
|
||||||
"cycle_active",
|
"cycle_active",
|
||||||
"delay_start_hours",
|
"delay_start_hours",
|
||||||
|
|||||||
+1
@@ -2,6 +2,7 @@
|
|||||||
"state_keys": [
|
"state_keys": [
|
||||||
"alarm_code",
|
"alarm_code",
|
||||||
"child_lock",
|
"child_lock",
|
||||||
|
"completion_minutes",
|
||||||
"cycle",
|
"cycle",
|
||||||
"cycle_active",
|
"cycle_active",
|
||||||
"delay_start_hours",
|
"delay_start_hours",
|
||||||
|
|||||||
@@ -4,10 +4,11 @@ from custom_components.localthings.registry.capabilities import laundry
|
|||||||
|
|
||||||
class TestCourseHelpers:
|
class TestCourseHelpers:
|
||||||
def test_parses_edit_course_list(self):
|
def test_parses_edit_course_list(self):
|
||||||
raw = 'EditCourseList_1C1D211B1E29243328262722202325322F2E30662D8F96'
|
raw = 'EditCourseList_1C1D211B1E29243328262722202325322F2E30662D8F962B2A'
|
||||||
assert laundry.parse_edit_course_list(raw) == [
|
assert laundry.parse_edit_course_list(raw) == [
|
||||||
'1C', '1D', '21', '1B', '1E', '29', '24', '33', '28', '26', '27',
|
'1C', '1D', '21', '1B', '1E', '29', '24', '33', '28', '26', '27',
|
||||||
'22', '20', '23', '25', '32', '2F', '2E', '30', '66', '2D', '8F', '96',
|
'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):
|
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
|
from custom_components.localthings.registry.capabilities.operational import OPERATIONAL_STATE
|
||||||
|
|
||||||
|
|
||||||
@@ -24,6 +26,42 @@ class TestProgressPercentage:
|
|||||||
assert desc.rep_fn(rep) == 42
|
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:
|
class TestDelayFieldFallback:
|
||||||
def test_reads_delay_end_time_when_delay_start_time_absent(self):
|
def test_reads_delay_end_time_when_delay_start_time_absent(self):
|
||||||
from custom_components.localthings.registry.capabilities.operational import OPERATIONAL_STATE
|
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')
|
desc = next(e for e in OPERATIONAL_STATE.entities if e.key == 'delay_start_hours')
|
||||||
rep = {'x.com.samsung.da.delayStartTime': '00:00:00'}
|
rep = {'x.com.samsung.da.delayStartTime': '00:00:00'}
|
||||||
path, body = desc.write_fn(1.5, rep)
|
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