From 93f45cb356244238c88f9ef717649988d635f9f5 Mon Sep 17 00:00:00 2001 From: perseus177 Date: Wed, 5 Aug 2026 12:21:10 +0200 Subject: [PATCH 1/2] fix(airconditioner): good_sleep is hours, but the token counts half hours The Sleep_ token was published as if its value were hours. It is not: the appliance's own app pairs a duration picker with the values it puts on the wire, one to one, and the pairing is half hours. 0:00 0:30 1:00 1:30 2:00 2:30 3:00 4:00 5:00 ... 12:00 0 1 2 3 4 5 6 8 10 ... 24 So the entity capped at 12 hours actually set six, every value asked for was halved on the appliance, and twelve hours -- the app's own maximum, stated in its help text -- could not be reached at all. The reading is halved and the write doubled, and the step drops to 0.5 because that is the resolution the picker offers. Half-hour steps are what the app offers below three hours; above that it offers whole hours only, so a half hour up there is untested rather than known-bad. A Number cannot change step part-way, and turning this into a Select of the app's sixteen values would change the entity's domain on every unit that already has one, so the step stays 0.5 throughout and the comment says why. The descriptor's own comment used to admit the upper bound was a guess ("only 0 has been observed on hardware"). The guess of 12 was right; the unit it was expressed in was not. --- .../registry/capabilities/airconditioner.py | 39 +++++++++++++++---- tests/test_airconditioner_artik051_krac.py | 26 +++++++++++++ 2 files changed, 58 insertions(+), 7 deletions(-) diff --git a/custom_components/localthings/registry/capabilities/airconditioner.py b/custom_components/localthings/registry/capabilities/airconditioner.py index 2bc5cce..2416b5d 100644 --- a/custom_components/localthings/registry/capabilities/airconditioner.py +++ b/custom_components/localthings/registry/capabilities/airconditioner.py @@ -311,11 +311,18 @@ def _option_switch_write(prefix): return write -def _option_number_write(prefix): +def _option_number_write(prefix, factor=1): + """Write a numeric options token. `factor` converts the entity's unit into the + token's own: good_sleep is offered in hours while the token counts half hours.""" + def write(payload, rep, href=None): return ( ["mode", "vs", "0"], - {"x.com.samsung.da.options": option_write(prefix, str(round(float(payload))))}, + { + "x.com.samsung.da.options": option_write( + prefix, str(round(float(payload) * factor)) + ) + }, ) return write @@ -528,16 +535,34 @@ CLIMATE = Capability( icon="mdi:air-filter", entity_category="config", ), - # "Good Sleep" timer. 0 = off; the upper bound is a guess (only 0 has - # been observed on hardware), so a write above 0 is unverified. + # "Good Sleep" timer, offered in hours. 0 = off. + # + # The token counts *half* hours, so it is halved on the way in and doubled + # on the way out. The appliance's own app pairs a picker of durations with + # the values it puts on the wire, one to one: + # + # 0:00 0:30 1:00 1:30 2:00 2:30 3:00 4:00 5:00 ... 12:00 + # 0 1 2 3 4 5 6 8 10 ... 24 + # + # which also pins the maximum at 12 hours (its own help text says so: + # "will be turned off after a selected period of time (Max. 12 hours)"). + # Before this the token was published as if it were hours: the entity + # capped at 12, which set six, and twelve hours could not be asked for at + # all. + # + # Half-hour steps are what the app offers below three hours; above that it + # offers whole hours only, and a half hour up there is untested rather than + # known-bad. A Number cannot change step part-way, and turning this into a + # Select of the app's sixteen values would change the entity's domain on + # every unit that already has it, so the step stays 0.5 throughout. NumberDesc( key="good_sleep", - rep_fn=_option_token_num("Sleep"), + rep_fn=_option_token_num("Sleep", divisor=2), exists_fn=_has_option_token("Sleep"), - write_fn=_option_number_write("Sleep"), + write_fn=_option_number_write("Sleep", factor=2), native_min=0, native_max=12, - step=1, + step=0.5, unit="h", icon="mdi:sleep", entity_category="config", diff --git a/tests/test_airconditioner_artik051_krac.py b/tests/test_airconditioner_artik051_krac.py index 1034c94..d655aa0 100644 --- a/tests/test_airconditioner_artik051_krac.py +++ b/tests/test_airconditioner_artik051_krac.py @@ -217,6 +217,32 @@ def _desc(resources, key): return None +def test_good_sleep_is_hours_while_the_token_counts_half_hours(): + """The appliance's own app pairs its duration picker with the values it sends + one to one -- 0:30 -> 1, 1:00 -> 2, 3:00 -> 6, 12:00 -> 24 -- so the token is + half hours and the entity, which is in hours, has to halve and double. The + fixture's own Sleep_0 is the one value that reads the same either way, hence + the injected token here.""" + resources = _load_device(FIXTURE) + mode = resources["/mode/vs/0"] + mode["x.com.samsung.da.options"] = [ + option for option in mode["x.com.samsung.da.options"] if option != "Sleep_0" + ] + ["Sleep_5"] + bound, _ = _discover(resources) + assert flatten(bound, resources)["good_sleep"] == 2.5 + + desc = _desc(resources, "good_sleep") + assert (desc.native_max, desc.step) == (12, 0.5) + assert desc.write_fn(2.5, {}) == ( + ["mode", "vs", "0"], + {"x.com.samsung.da.options": ["Sleep_5"]}, + ) + # 12 hours is the app's maximum and has to be reachable -- it was not while + # the token was published as hours. + assert desc.write_fn(12, {})[1]["x.com.samsung.da.options"] == ["Sleep_24"] + assert desc.write_fn(0, {})[1]["x.com.samsung.da.options"] == ["Sleep_0"] + + def test_filter_alarm_time_reads_the_threshold_and_writes_one_token(): """The interval FilterTime_ is measured against, offered by the app as a 180/300/500/700 hour radio. All four were walked on hardware while watching From 75e985d392743dc2dc5aaad1e6422b15ea213abd Mon Sep 17 00:00:00 2001 From: perseus177 Date: Wed, 5 Aug 2026 12:27:24 +0200 Subject: [PATCH 2/2] style: let ruff format the write helper `ruff format --check` is part of the Validate workflow and my hand-wrapped version of the dict literal was not what it produces. No behaviour change. --- .../localthings/registry/capabilities/airconditioner.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/custom_components/localthings/registry/capabilities/airconditioner.py b/custom_components/localthings/registry/capabilities/airconditioner.py index 2416b5d..c3d227d 100644 --- a/custom_components/localthings/registry/capabilities/airconditioner.py +++ b/custom_components/localthings/registry/capabilities/airconditioner.py @@ -318,11 +318,7 @@ def _option_number_write(prefix, factor=1): def write(payload, rep, href=None): return ( ["mode", "vs", "0"], - { - "x.com.samsung.da.options": option_write( - prefix, str(round(float(payload) * factor)) - ) - }, + {"x.com.samsung.da.options": option_write(prefix, str(round(float(payload) * factor)))}, ) return write