diff --git a/custom_components/localthings/registry/capabilities/airconditioner.py b/custom_components/localthings/registry/capabilities/airconditioner.py index d55e81a..1d46e4f 100644 --- a/custom_components/localthings/registry/capabilities/airconditioner.py +++ b/custom_components/localthings/registry/capabilities/airconditioner.py @@ -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_` 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. diff --git a/custom_components/localthings/translations/en.json b/custom_components/localthings/translations/en.json index c904275..811f2c9 100644 --- a/custom_components/localthings/translations/en.json +++ b/custom_components/localthings/translations/en.json @@ -158,9 +158,6 @@ } }, "number": { - "buzzer_volume": { - "name": "Beep volume" - }, "cook_time": { "name": "Cook time" }, diff --git a/custom_components/localthings/translations/nl.json b/custom_components/localthings/translations/nl.json index e864461..10883ed 100644 --- a/custom_components/localthings/translations/nl.json +++ b/custom_components/localthings/translations/nl.json @@ -158,9 +158,6 @@ } }, "number": { - "buzzer_volume": { - "name": "Zoemervolume" - }, "cook_time": { "name": "Bereidingstijd" }, diff --git a/tests/fixtures/golden/airconditioner_artik051_krac_18k.json b/tests/fixtures/golden/airconditioner_artik051_krac_18k.json index c7fabfb..e525909 100644 --- a/tests/fixtures/golden/airconditioner_artik051_krac_18k.json +++ b/tests/fixtures/golden/airconditioner_artik051_krac_18k.json @@ -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" ] -} +} \ No newline at end of file diff --git a/tests/fixtures/golden/airconditioner_artik051_krac_energy.json b/tests/fixtures/golden/airconditioner_artik051_krac_energy.json index f125489..e525909 100644 --- a/tests/fixtures/golden/airconditioner_artik051_krac_energy.json +++ b/tests/fixtures/golden/airconditioner_artik051_krac_energy.json @@ -3,7 +3,7 @@ "air_monitoring", "alarm_code", "auto_clean_legacy", - "buzzer_volume", + "beep", "climate", "current_temperature_c", "diagnosis_status", diff --git a/tests/test_airconditioner_artik051_krac.py b/tests/test_airconditioner_artik051_krac.py index d6b4b98..5e314fd 100644 --- a/tests/test_airconditioner_artik051_krac.py +++ b/tests/test_airconditioner_artik051_krac.py @@ -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 diff --git a/tests/test_airconditioner_capabilities.py b/tests/test_airconditioner_capabilities.py index f3e83bb..e97bb25 100644 --- a/tests/test_airconditioner_capabilities.py +++ b/tests/test_airconditioner_capabilities.py @@ -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