fix(airconditioner): model legacy-board beep as a switch, not a volume (#136)

Every ARTIK051_KRAC_18K-generation unit confirmed on hardware (three units
across two reporters) only ever carries Volume_100 or Volume_Mute in
/mode/vs/0's options -- never an intermediate value -- so the existing
buzzer_volume Number (0-100, step 10) modeled a control this firmware
doesn't have. Worse, its write path could never produce the literal
'Mute' token needed to actually turn the beep off, since it only ever
wrote a plain integer string. The 'beep' switch already used on newer
boards is the correct model here too; it's no longer gated off the legacy
board generation, and the Number entity is removed.

The WindFree-preset-not-applying report earlier in this issue self-resolved
per the reporter's own follow-up testing, so no code change was needed for
that part.
This commit is contained in:
Marc Billow committed 2026-07-29 13:16:09 +00:00
1 parent b5c5c056f4
commit 487ac748bd
7 files changed
+49 -28

No files matched your search

@@ -486,14 +486,19 @@ CLIMATE = Capability(
icon='mdi:led-on', entity_category='config'),
# Beep on/off from the `Volume_*` option token (Volume_Mute/Volume_100).
# Single-token option_write; a full options RMW reverts on ARTIK051_PRAC.
# Gated off the legacy ARTIK051 board generation (see is_legacy_board):
# that generation's own Volume_ token is already modeled as the
# buzzer_volume Number below, and both reading the same options[] slot
# into two entities would be redundant.
#
# Applies uniformly across board generations, including legacy
# ARTIK051 boards -- issue #136: this token was originally modeled as
# a 0-100 buzzer_volume Number for legacy boards on the assumption it
# was a real graduated volume level, but every unit confirmed on
# hardware (three units across two reporters) only ever carries
# Volume_100 or Volume_Mute, never an intermediate value, and the
# Number's write path (a plain integer string) could never produce
# the literal 'Mute' token needed to actually turn it off -- writing
# '0' is simply not a token this firmware recognizes. A switch is
# both the correct model and the actual fix.
SwitchDesc(key='beep', rep_fn=_beep_on,
exists_fn=lambda rep, resources: (
not is_legacy_board(resources)
and _option_token(rep, 'Volume') is not None),
exists_fn=lambda rep, resources: _option_token(rep, 'Volume') is not None,
write_fn=_beep_write,
icon='mdi:volume-high', entity_category='config'),
# Tropical night mode level (0-16) from the `Sleep_<N>` option token.
@@ -539,11 +544,6 @@ CLIMATE = Capability(
exists_fn=_has_option_token('AirMonitoring'),
write_fn=_option_switch_write('AirMonitoring'),
icon='mdi:air-filter', entity_category='config'),
NumberDesc(key='buzzer_volume', rep_fn=_option_token_num('Volume'),
exists_fn=_has_option_token('Volume'),
write_fn=_option_number_write('Volume'),
native_min=0, native_max=100, step=10,
icon='mdi:volume-high', entity_category='config'),
# "Good Sleep" timer. 0 = off; the upper bound is a guess (the token
# carries no range hint and only 0 has been observed on hardware), so a
# write above 0 is unverified.
@@ -158,9 +158,6 @@
}
},
"number": {
"buzzer_volume": {
"name": "Beep volume"
},
"cook_time": {
"name": "Cook time"
},
@@ -158,9 +158,6 @@
}
},
"number": {
"buzzer_volume": {
"name": "Zoemervolume"
},
"cook_time": {
"name": "Bereidingstijd"
},
@@ -3,7 +3,7 @@
"air_monitoring",
"alarm_code",
"auto_clean_legacy",
"buzzer_volume",
"beep",
"climate",
"current_temperature_c",
"diagnosis_status",
@@ -15,4 +15,4 @@
"outdoor_temperature",
"spi"
]
}
}
@@ -3,7 +3,7 @@
"air_monitoring",
"alarm_code",
"auto_clean_legacy",
"buzzer_volume",
"beep",
"climate",
"current_temperature_c",
"diagnosis_status",
+2 -2
View File
@@ -102,7 +102,7 @@ def test_token_entities_present_with_calibrated_values():
assert state['filter_time'] == 171.5
# token - 55 == 19 C, against a 19.4 C forecast at the time of the dump.
assert state['outdoor_temperature'] == 19.0
assert state['buzzer_volume'] == 100.0
assert state['beep'] is True
assert state['good_sleep'] == 0.0
assert state['spi'] is False
assert state['auto_clean_legacy'] is False
@@ -115,7 +115,7 @@ def test_token_entities_stay_off_newer_boards():
token entities would duplicate them (auto clean) or apply a scale
calibrated on another board generation (outdoor temperature)."""
state = _state('airconditioner_tp1x_rac')
for key in ('spi', 'auto_clean_legacy', 'air_monitoring', 'buzzer_volume',
for key in ('spi', 'auto_clean_legacy', 'air_monitoring',
'good_sleep', 'outdoor_temperature', 'filter_time'):
assert key not in state, key
+32 -5
View File
@@ -814,18 +814,45 @@ def test_sensor_item_value_picks_first_value():
def test_beep_and_tropical_night_stay_off_legacy_krac_board():
"""ARTIK051_KRAC_18K (issue #136) reports both a Volume_ and a Sleep_
option token, but they're already modeled as buzzer_volume/good_sleep
(see airconditioner.CLIMATE) -- beep/tropical_night_mode must not also
bind there, or the same options[] slot would surface as two entities."""
option token. Volume_ is modeled as the shared 'beep' switch (issue #136's
buzzer_volume Number never correctly modeled this board -- see
test_legacy_krac_board_beep_is_a_switch_not_a_volume_number below);
Sleep_ is still good_sleep, not tropical_night_mode, on this board (see
airconditioner.CLIMATE)."""
reg, resources = _resolve('airconditioner_artik051_krac_18k')
state = flatten(
discover(resources, reg.capabilities, reg.pattern_capabilities), resources)
assert 'beep' not in state
assert 'buzzer_volume' not in state
assert 'tropical_night_mode' not in state
assert state['buzzer_volume'] == 100.0
assert state['beep'] is True
assert state['good_sleep'] == 0.0
def test_legacy_krac_board_beep_is_a_switch_not_a_volume_number():
"""Issue #136: three real units across two reporters only ever reported
Volume_100 or Volume_Mute -- never an intermediate value -- and the old
buzzer_volume Number's write path (a plain integer string) could never
produce the literal 'Mute' token needed to turn it off. 'beep' now
applies uniformly across board generations instead of being gated off
the legacy board."""
reg, resources = _resolve('airconditioner_artik051_krac_18k')
state = flatten(
discover(resources, reg.capabilities, reg.pattern_capabilities), resources)
assert state['beep'] is True
resources['/mode/vs/0']['x.com.samsung.da.options'] = [
o.replace('Volume_100', 'Volume_Mute')
for o in resources['/mode/vs/0']['x.com.samsung.da.options']
]
state = flatten(
discover(resources, reg.capabilities, reg.pattern_capabilities), resources)
assert state['beep'] is False
path, body = airconditioner._beep_write('Off', resources['/mode/vs/0'])
assert path == ['mode', 'vs', '0']
assert body == {'x.com.samsung.da.options': ['Volume_Mute']}
def test_legacy_krac_board_energy_kwh_uses_centiwatt_hour_scale():
"""Issue #193: this legacy ARTIK051 board reports cumulativePower in
centiwatt-hours (raw '117430000'), not the plain Wh common.wh_to_kwh