diff --git a/custom_components/localthings/config_flow.py b/custom_components/localthings/config_flow.py index f0ca90b..66cad38 100644 --- a/custom_components/localthings/config_flow.py +++ b/custom_components/localthings/config_flow.py @@ -248,14 +248,13 @@ def _probe_and_validate(host: str, ca_cert_pem: str, ca_key_pem: str) -> dict: candidates = _find_live_ports( host, PROBE_PORT_RANGE, LIVENESS_PROBE_TIMEOUT_S ) - if not candidates: - # Every port in the range actively refused: there is no DTLS/CoAP - # listener on this host, so a handshake can't succeed. Fail now with a - # clear message instead of retrying doomed ports. - raise CannotConnect( - f"no live DTLS port found on {host} " - f"in {PROBE_PORT_RANGE[0]}-{PROBE_PORT_RANGE[-1]}" - ) + # No early "every port refused" fast-fail here: _find_live_ports always + # rescues PREFERRED_PROBE_PORTS (issue #192), so candidates is never + # empty as long as that table is non-empty and within PROBE_PORT_RANGE -- + # both true today, which made this branch permanently unreachable. A + # genuinely dead host now fails via the handshake loop's own error below, + # which carries the actual per-port timeout/refusal reason instead of a + # generic "no live port found" message. _LOGGER.debug("Live DTLS port candidates on %s: %s", host, candidates) last_exc = None diff --git a/custom_components/localthings/registry/capabilities/airconditioner.py b/custom_components/localthings/registry/capabilities/airconditioner.py index 1d46e4f..1ed642a 100644 --- a/custom_components/localthings/registry/capabilities/airconditioner.py +++ b/custom_components/localthings/registry/capabilities/airconditioner.py @@ -288,13 +288,17 @@ def _option_token(rep, prefix): def is_legacy_board(resources): """True for the board generation whose airflow lives in /airflow/vs/0. - Newer families carry several of the same option tokens (Volume, Sleep, - OutdoorTemp, Autoclean) *alongside* dedicated resources for those settings, - so an ungated token entity would either duplicate an existing one or apply a - scale calibrated elsewhere. Every AC dump on record has one shape or the - other: /airflow/vs/0 with no /wind/* at all, or /wind/strength/vs/0 with no - /airflow/vs/0. Same test as climate.py's _legacy_airflow(), so the entities - below and the climate entity can never disagree about the generation. + Newer families carry several of the same option tokens (Sleep, OutdoorTemp, + Autoclean) *alongside* dedicated resources for those settings, so an + ungated token entity would either duplicate an existing one or apply a + scale calibrated elsewhere. (Volume used to be in this list too, back when + it had its own gated buzzer_volume Number for this board generation -- + issue #136 replaced that with the unified 'beep' switch, which applies + across every generation and isn't gated here at all.) Every AC dump on + record has one shape or the other: /airflow/vs/0 with no /wind/* at all, + or /wind/strength/vs/0 with no /airflow/vs/0. Same test as climate.py's + _legacy_airflow(), so the entities below and the climate entity can never + disagree about the generation. """ return HREF_AIRFLOW in resources and HREF_WIND_STRENGTH not in resources @@ -310,13 +314,18 @@ def is_legacy_board(resources): # 'energy_kwh' needs a replacement value_fn here; the rest pass through # unchanged in case a future legacy dump ever reports them. def _legacy_cumulative_power_kwh(v): - n = _int(v) - return round(n / 100000.0, 2) if n is not None else None + # float, not _int -- matches common.wh_to_kwh's own numeric parsing + # (float via _num) rather than this module's integer-only _int, so a + # decimal-formatted reading doesn't raise and silently go 'unknown'. + try: + n = float(v) + except (TypeError, ValueError): + return None + return round(n / 100000.0, 2) -ENERGY_METER_LEGACY = Capability( - href=common.ENERGY_METER.href, - poll_tier=common.ENERGY_METER.poll_tier, +ENERGY_METER_LEGACY = replace( + common.ENERGY_METER, match_fn=lambda rep, resources: is_legacy_board(resources), entities=tuple( replace(e, value_fn=_legacy_cumulative_power_kwh) diff --git a/custom_components/localthings/registry/capabilities/common.py b/custom_components/localthings/registry/capabilities/common.py index 82fe42b..45889b4 100644 --- a/custom_components/localthings/registry/capabilities/common.py +++ b/custom_components/localthings/registry/capabilities/common.py @@ -611,15 +611,22 @@ SELF_CHECK = Capability( # level: 2 of 6 families confirmed, blanket-added everywhere else). # # POWER is kept separate -- airconditioner is the one family that opts out -# of it. Canonical reason (see by_type/airconditioner.py and its test for -# pointers back here, not restatements): AC's climate entity already owns -# /power/0 and /power/vs/0 via bare, no-entity Capability objects -# (airconditioner.COVERAGE), and a second, real POWER_GENERIC/ +# of it entirely. Canonical reason (see by_type/airconditioner.py and its +# test for pointers back here, not restatements): AC's climate entity +# already owns /power/0 and /power/vs/0 via bare, no-entity Capability +# objects (airconditioner.COVERAGE), and a second, real POWER_GENERIC/ # POWER_VS_FALLBACK cap on the same href would make _build() raise (a href # with >1 cap must have every cap discriminated by rt_filter/match_fn, and # the bare COVERAGE cap has neither). Kids-lock/remote-control don't have # this conflict -- no AC dump has ever reported those hrefs -- so they stay # in UNIVERSAL. +# +# Airconditioner also partially opts out of UNIVERSAL itself, not just +# POWER: issue #193 needs ENERGY_METER's cumulativePower scale to differ by +# board generation, so by_type/airconditioner.py excludes just that one +# member (`*[c for c in common.UNIVERSAL if c is not common.ENERGY_METER]`) +# and substitutes airconditioner.ENERGY_METER_GENERIC/ENERGY_METER_LEGACY in +# its place -- every other registry still unpacks UNIVERSAL wholesale. # --------------------------------------------------------------------------- UNIVERSAL = ( diff --git a/custom_components/localthings/registry/capabilities/oven.py b/custom_components/localthings/registry/capabilities/oven.py index 8c20b7f..252416f 100644 --- a/custom_components/localthings/registry/capabilities/oven.py +++ b/custom_components/localthings/registry/capabilities/oven.py @@ -24,6 +24,7 @@ but is effectively read-only in practice. """ from datetime import datetime, timezone, timedelta +from ..batch import is_stub_rep from ..capability import Capability from ..entities import ( BinarySensorDesc, NumberDesc, SelectDesc, SensorDesc, SwitchDesc, @@ -140,8 +141,15 @@ def _has_option(prefix): options[] at all, so both switches were phantom controls: always read as off, and toggling them wrote a token the firmware never recognized in the first place, hence "does not appear to do anything." + + `is_stub_rep(rep) or` keeps the same stub carve-out as cooktop.py's + identical per-token exists_fn on its own options[]-array href: a stub + /device/0 seed rep (not yet sub-polled) has no options[] at all, and + without this an entity whose token is genuinely present would never get + a first chance to bind, since exists_fn runs before that first real + fetch lands. """ - return lambda rep, resources: _option_value( + return lambda rep, resources: is_stub_rep(rep) or _option_value( rep.get('x.com.samsung.da.options'), prefix) is not None @@ -214,64 +222,19 @@ def _oven_mode_write(p, rep, href=None): return ['mode', 'vs', '0'], {'x.com.samsung.da.modes': [p]} -def _lamp_write(p, rep, href=None): - if p not in ('On', 'Off'): - return None - if not rep.get('x.com.samsung.da.options'): - return None - return ['mode', 'vs', '0'], { - 'x.com.samsung.da.options': _option_write('UpperLamp', p), - } - - -def _sound_write(p, rep, href=None): - if p not in ('On', 'Off'): - return None - if not rep.get('x.com.samsung.da.options'): - return None - return ['mode', 'vs', '0'], { - 'x.com.samsung.da.options': _option_write('Sound', p), - } - - -def _fastpreheat_write(p, rep, href=None): - if p not in ('On', 'Off'): - return None - if not rep.get('x.com.samsung.da.options'): - return None - return ['mode', 'vs', '0'], { - 'x.com.samsung.da.options': _option_write('fastpreheat', p), - } - - -def _naturalsteam_write(p, rep, href=None): - if p not in ('On', 'Off'): - return None - if not rep.get('x.com.samsung.da.options'): - return None - return ['mode', 'vs', '0'], { - 'x.com.samsung.da.options': _option_write('NaturalSteam', p), - } - - -def _energysaving_write(p, rep, href=None): - if p not in ('On', 'Off'): - return None - if not rep.get('x.com.samsung.da.options'): - return None - return ['mode', 'vs', '0'], { - 'x.com.samsung.da.options': _option_write('EnergySaving', p), - } - - -def _burneronalert_write(p, rep, href=None): - if p not in ('On', 'Off'): - return None - if not rep.get('x.com.samsung.da.options'): - return None - return ['mode', 'vs', '0'], { - 'x.com.samsung.da.options': _option_write('BurnerOnAlert', p), - } +def _option_switch_write(prefix): + """Factory for a single-token on/off options-array write -- lamp, sound, + fast_preheat, natural_steam, energy_saving, and cooktop_on_alert were all + a byte-for-byte copy of this same shape, one per prefix.""" + def write(p, rep, href=None): + if p not in ('On', 'Off'): + return None + if not rep.get('x.com.samsung.da.options'): + return None + return ['mode', 'vs', '0'], { + 'x.com.samsung.da.options': _option_write(prefix, p), + } + return write # --------------------------------------------------------------------------- @@ -412,22 +375,22 @@ OVEN_MODE = Capability( SwitchDesc(key='lamp', field='x.com.samsung.da.options', icon='mdi:track-light', value_fn=lambda opts: _option_value(opts, 'UpperLamp') == 'On', - write_fn=_lamp_write), + write_fn=_option_switch_write('UpperLamp')), SwitchDesc(key='sound', field='x.com.samsung.da.options', icon='mdi:volume-high', entity_category='config', value_fn=lambda opts: _option_value(opts, 'Sound') == 'On', - write_fn=_sound_write), + write_fn=_option_switch_write('Sound')), SwitchDesc(key='fast_preheat', field='x.com.samsung.da.options', icon='mdi:fire', exists_fn=_has_option('fastpreheat'), value_fn=lambda opts: _option_value(opts, 'fastpreheat') == 'On', - write_fn=_fastpreheat_write), + write_fn=_option_switch_write('fastpreheat')), SwitchDesc(key='natural_steam', field='x.com.samsung.da.options', icon='mdi:kettle-steam', exists_fn=_has_option('NaturalSteam'), value_fn=lambda opts: _option_value(opts, 'NaturalSteam') == 'On', - write_fn=_naturalsteam_write), + write_fn=_option_switch_write('NaturalSteam')), # 120-hour energy-saving standby (issue #183): confirmed present in # this unit's options[] (EnergySaving_On) and directly requested -- # unlike fast_preheat/natural_steam above, this token is real on this @@ -436,7 +399,7 @@ OVEN_MODE = Capability( icon='mdi:leaf', entity_category='config', exists_fn=_has_option('EnergySaving'), value_fn=lambda opts: _option_value(opts, 'EnergySaving') == 'On', - write_fn=_energysaving_write), + write_fn=_option_switch_write('EnergySaving')), # Cooktop-on alert (issue #183): also confirmed present # (BurnerOnAlert_Off) though the reporter noted it mainly matters for # the SmartThings app's own alerting, not local automation. @@ -444,6 +407,6 @@ OVEN_MODE = Capability( icon='mdi:alert-circle-outline', entity_category='config', exists_fn=_has_option('BurnerOnAlert'), value_fn=lambda opts: _option_value(opts, 'BurnerOnAlert') == 'On', - write_fn=_burneronalert_write), + write_fn=_option_switch_write('BurnerOnAlert')), ), ) diff --git a/custom_components/localthings/translations/en.json b/custom_components/localthings/translations/en.json index 4e5b414..45382cb 100644 --- a/custom_components/localthings/translations/en.json +++ b/custom_components/localthings/translations/en.json @@ -212,14 +212,7 @@ } }, "cooler_temperature_setpoint": { - "name": "Cooler temperature", - "state": { - "1": "1 °C", - "2": "2 °C", - "3": "3 °C", - "4": "4 °C", - "7": "7 °C" - } + "name": "Cooler temperature" }, "day_brightness": { "name": "Cabinet brightness", diff --git a/custom_components/localthings/translations/nl.json b/custom_components/localthings/translations/nl.json index 9e64254..777c496 100644 --- a/custom_components/localthings/translations/nl.json +++ b/custom_components/localthings/translations/nl.json @@ -212,14 +212,7 @@ } }, "cooler_temperature_setpoint": { - "name": "Koeler temperatuur", - "state": { - "1": "1 °C", - "2": "2 °C", - "3": "3 °C", - "4": "4 °C", - "7": "7 °C" - } + "name": "Koeler temperatuur" }, "day_brightness": { "name": "Helderheid kast", diff --git a/tests/fixtures/airconditioner_cac_device.json b/tests/fixtures/airconditioner_cac_device.json new file mode 100644 index 0000000..1cfe5ff --- /dev/null +++ b/tests/fixtures/airconditioner_cac_device.json @@ -0,0 +1,919 @@ +{ + "device0": [ + { + "rt": [ + "x.com.samsung.devcol", + "oic.wk.col" + ], + "if": [ + "oic.if.baseline", + "oic.if.ll", + "oic.if.b" + ] + }, + { + "href": "/airlevelcheck/vs/0", + "rep": { + "x.com.samsung.da.periodicSensingInterval": "1800", + "x.com.samsung.da.sensingState": "NonProcessing", + "x.com.samsung.da.lastSensingTime": "1785327477", + "x.com.samsung.da.lastSensingLevel": "Kr1", + "x.com.samsung.da.periodicSensingSkipStatus": "Off", + "x.com.samsung.da.periodicSensingSkipTime": "00000000", + "x.com.samsung.da.periodicSensingActivationState": "On", + "x.com.samsung.da.autoExeState": "Alarm", + "x.com.samsung.da.supportedAutoExeState": [ + "Airpurify", + "Alarm", + "Sensing" + ], + "x.com.samsung.da.startSensingOnce": "Off", + "x.com.samsung.da.autoExeSetting": "On", + "x.com.samsung.da.supportedAutoExeSetting": [ + "On", + "Off" + ] + } + }, + { + "href": "/aisleep/vs/0", + "rep": { + "x.com.samsung.da.displayNightMode": "Off", + "x.com.samsung.da.elapsedTime": "0", + "x.com.samsung.da.requestFeedback": "Off", + "x.com.samsung.da.resultFeedback": 0, + "x.com.samsung.da.statusFeedback": "Idle", + "x.com.samsung.da.sleepTime": "14002200" + } + }, + { + "href": "/alarms/vs/0", + "rep": { + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "0", + "x.com.samsung.da.alarmType": "Device", + "x.com.samsung.da.code": "ErrorCode_OFF", + "x.com.samsung.da.triggeredTime": "2026-07-29T12:19:55", + "x.com.samsung.da.state": "Deleted" + }, + { + "x.com.samsung.da.id": "1", + "x.com.samsung.da.alarmType": "Device", + "x.com.samsung.da.code": "FilterAlarm_OFF", + "x.com.samsung.da.triggeredTime": "2026-07-29T12:19:55", + "x.com.samsung.da.state": "Deleted" + }, + { + "x.com.samsung.da.id": "2", + "x.com.samsung.da.alarmType": "Device", + "x.com.samsung.da.code": "DA_SAC_M_OFF", + "x.com.samsung.da.triggeredTime": "2026-07-29T12:19:55", + "x.com.samsung.da.state": "Deleted" + } + ], + "rt": [ + "x.com.samsung.da.alarms" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ] + } + }, + { + "href": "/availablecontrolsets/vs/0", + "rep": { + "x.com.samsung.da.sets": "000100B4012C0000B20001000000", + "x.com.samsung.da.id": "CAC", + "x.com.samsung.da.version": "1.0" + } + }, + { + "href": "/configuration/vs/0", + "rep": { + "x.com.samsung.da.region": "4146100000", + "x.com.samsung.da.airconOptionList": [ + "AI_KOREA_COOLONLY_4.0", + "HOMECARE_WIZARD_V2", + "CLEAN_OPTION_SUPPORT", + "DR", + "SingleCommand_1" + ] + } + }, + { + "href": "/da/softreset/vs/0", + "rep": { + "x.com.samsung.da.softwarereset": "false" + } + }, + { + "href": "/drlc/vs/0", + "rep": { + "x.com.samsung.da.start": "2026-07-29T07:26:56Z", + "x.com.samsung.da.override": "Off", + "x.com.samsung.da.durationminutes": "1019", + "x.com.samsung.da.drlcLevel": "1", + "x.com.samsung.da.realSaving": "Off" + } + }, + { + "href": "/edgelighting/vs/0", + "rep": { + "status": "On", + "convenientMode": "On", + "operatingOption": "On", + "welcomeLightStatus": "Off", + "colorOption": "3000K", + "mode": "Smart", + "modeSupportedList": [ + "Smart", + "High", + "Low" + ], + "colorSupportedList": [ + "3000K", + "4000K", + "6500K" + ] + } + }, + { + "href": "/energy/consumption/vs/0", + "rep": { + "x.com.samsung.da.cumulativeDate": "1785327589", + "x.com.samsung.da.instantaneousPower": "15", + "x.com.samsung.da.cumulativeSavedPower": "1611", + "x.com.samsung.da.cumulativePower": "84044", + "x.com.samsung.da.cumulativeUnit": "Wh", + "x.com.samsung.da.instantaneousPowerUnit": "W", + "x.com.samsung.da.cumulativePowerType": "individual" + } + }, + { + "href": "/file/information/vs/0", + "rep": { + "x.com.samsung.timeoffset": "+09:00", + "x.com.samsung.supprtedtype": 1 + } + }, + { + "href": "/filter/airdustPM1filter/vs/0", + "rep": { + "x.com.samsung.da.filterUsageResolution": "1", + "x.com.samsung.da.filterCapacity": "2400", + "x.com.samsung.da.filterCapacityUnit": "Hour", + "x.com.samsung.da.filterResetType": [ + "washable" + ], + "x.com.samsung.da.filterStatus": "normal", + "x.com.samsung.da.filterUsage": "11" + } + }, + { + "href": "/filter/airdustfilter/vs/0", + "rep": { + "x.com.samsung.da.filterUsageResolution": "1", + "x.com.samsung.da.filterCapacity": "180", + "x.com.samsung.da.filterCapacityUnit": "Hour", + "x.com.samsung.da.filterResetType": [ + "washable" + ], + "x.com.samsung.da.filterStatus": "normal", + "x.com.samsung.da.filterUsage": "26" + } + }, + { + "href": "/humidity/vs/0", + "rep": { + "x.com.samsung.da.humidity": "0", + "x.com.samsung.da.fivepercentHumidity": "59" + } + }, + { + "href": "/information/vs/0", + "rep": { + "x.com.samsung.da.otnDUID": "**REDACTED**", + "x.com.samsung.da.modelNum": "TP1X_DA-AC-CAC-01001_0000|10255541|60030748171811DF42005F2A00F2ED00", + "x.com.samsung.da.description": "TP1X_DA-AC-CAC-01001_0000", + "x.com.samsung.da.serialNum": "**REDACTED**", + "x.com.samsung.da.serialNumOption": "**REDACTED**", + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "0", + "x.com.samsung.da.description": "Version", + "x.com.samsung.da.type": "Software", + "x.com.samsung.da.number": "02706A260401", + "x.com.samsung.da.newVersionAvailable": "0" + }, + { + "x.com.samsung.da.id": "1", + "x.com.samsung.da.description": "Version", + "x.com.samsung.da.type": "Firmware", + "x.com.samsung.da.newVersionAvailable": "0", + "x.com.samsung.da.number": "02555A25052600,02556A24040200" + }, + { + "x.com.samsung.da.id": "2", + "x.com.samsung.da.description": "Version", + "x.com.samsung.da.type": "Outdoor", + "x.com.samsung.da.newVersionAvailable": "0", + "x.com.samsung.da.number": "FFFFFFFFFFFFFF,FFFFFFFFFFFFFF" + } + ], + "x.com.samsung.da.diagDumpType": "file", + "x.com.samsung.da.diagEndPoint": "SSM", + "x.com.samsung.da.diagLogType": [ + "errCode", + "dump" + ], + "x.com.samsung.da.diagMnid": "0AJT", + "x.com.samsung.da.diagSetupid": "AS3", + "x.com.samsung.da.diagProtocolType": "BLE_OCF", + "x.com.samsung.da.diagMinVersion": "3.0", + "x.com.samsung.da.diagTsId": "DA01" + } + }, + { + "href": "/keepnormalstate/vs/0", + "rep": { + "x.com.samsung.da.keepnormal": 0 + } + }, + { + "href": "/light/stateful/vs/0", + "rep": { + "status": "On", + "mode": "Smart", + "supportedModes": [ + "Smart", + "Low", + "High" + ] + } + }, + { + "href": "/mds/absenceclean/vs/0", + "rep": { + "mode": "Off", + "supportedModes": [ + "On", + "Off" + ] + } + }, + { + "href": "/mds/absencemonitoring/vs/0", + "rep": { + "status": "Off" + } + }, + { + "href": "/mds/absencepowersaving/vs/0", + "rep": { + "status": "On", + "modes": "Cooling", + "switchPowerSaveMode": "Normal", + "supportedModes": [ + "Standby", + "Cooling" + ], + "supportedSwitchPowerSaveMode": [ + "Eco", + "Normal", + "Comfort" + ] + } + }, + { + "href": "/mds/absencestate/vs/0", + "rep": { + "status": "On", + "absenceTime": "0", + "maxDetectCount": [ + "0", + "0", + "0", + "0", + "0", + "0", + "3", + "3", + "3", + "2", + "3", + "0", + "0", + "3", + "0", + "3", + "3", + "3", + "0", + "3", + "1", + "2", + "3", + "3", + "0", + "0", + "0", + "0", + "0", + "0", + "0", + "0", + "0", + "0", + "0", + "0", + "1", + "0", + "3", + "2", + "3", + "1", + "2", + "1", + "0", + "0", + "0", + "0" + ], + "supportedTimes": [ + "0", + "30", + "60", + "120" + ] + } + }, + { + "href": "/mode/convenient/vs/0", + "rep": { + "x.com.samsung.da.modes": "Off", + "x.com.samsung.da.supportedModes": [ + "Off", + "Nano", + "LongWind", + "Speed", + "Sleep", + "NanoSleep" + ] + } + }, + { + "href": "/mode/vs/0", + "rep": { + "x.com.samsung.da.modes": [ + "Cool" + ], + "workingMode": "Cool", + "x.com.samsung.da.supportedModes": [ + "AIComfort", + "Auto", + "Cool", + "Dry", + "Fan" + ], + "x.com.samsung.da.options": [ + "OptionCode_512", + "Sleep_0", + "ArtificialWorking_Off", + "ComfortAICooling_Off", + "AiTempChanged_Off", + "AiTemp_240", + "welcomecareElapsedTime_0", + "welcomecareThresholdTemp_0", + "welcomecareStartDate_0000", + "welcomecareEndDate_0000", + "welcomecareSeason_None", + "welcomecare_Off", + "DiagnosisAI_Off", + "ProgressDiagnosisAI_0", + "ResultDiagnosisAI_Normal", + "SmartCoolClean_Off", + "ProgressSmartClean_0", + "FreezeAlarmSetting_On", + "DesiredFreezeAlarm_112", + "ExtendOptionCode_0", + "UpdateAllow_NotAllowed", + "DurationOn_0", + "OutdoorTemp_55", + "KeyInputPermit_On", + "ModePermit_NoLimit" + ], + "rt": [ + "x.com.samsung.da.mode" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ], + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "0", + "x.com.samsung.da.description": "Temperature", + "x.com.samsung.da.current": "28.5", + "x.com.samsung.da.desired": "28.0", + "x.com.samsung.da.minimum": "18", + "x.com.samsung.da.maximum": "30", + "x.com.samsung.da.increment": "0.5", + "x.com.samsung.da.unit": "Celsius" + } + ] + } + }, + { + "href": "/option/airpurify/vs/0", + "rep": { + "x.com.samsung.da.modes": "Off", + "x.com.samsung.da.supportedModes": [ + "On", + "Off" + ] + } + }, + { + "href": "/option/autoclean/vs/0", + "rep": { + "x.com.samsung.da.status": "Stop", + "x.com.samsung.da.supportedStatus": [ + "Start", + "Stop" + ], + "x.com.samsung.da.progress": "0", + "x.com.samsung.da.settingStatus": "On", + "x.com.samsung.da.supportedSettingStatus": [ + "On", + "Off" + ], + "defaultSettingStatus": "On" + } + }, + { + "href": "/option/motiondetectwind/stateful/vs/0", + "rep": { + "status": "On", + "modes": "Indirect", + "supportedModes": [ + "Indirect", + "Direct" + ] + } + }, + { + "href": "/option/muteonce/vs/0", + "rep": { + "muteonce": "Off" + } + }, + { + "href": "/personality/presence/vs/0", + "rep": {} + }, + { + "href": "/power/vs/0", + "rep": { + "x.com.samsung.da.power": "Off", + "operationNumber": "46" + } + }, + { + "href": "/realtimenotiforclient/vs/0", + "rep": { + "x.com.samsung.da.timeforshortnoti": "0", + "x.com.samsung.da.longnotisubscription": "true", + "x.com.samsung.da.periodicnotisubscription": "true" + } + }, + { + "href": "/remotedatacontrol/vs/0", + "rep": { + "x.com.samsung.da.status": "Off", + "x.com.samsung.da.connectionStatus": "Disconnected" + } + }, + { + "href": "/remotetemperature/vs/0", + "rep": { + "x.com.samsung.da.temperature": "", + "x.com.samsung.da.unit": "", + "x.com.samsung.da.error": "" + } + }, + { + "href": "/reserverulesets/vs/0", + "rep": { + "x.com.samsung.da.sets": "1F121E121E121E121EFFFFFFFF010001000F0001000E0000001C3E", + "x.com.samsung.da.id": "CAC", + "x.com.samsung.da.version": "1.0" + } + }, + { + "href": "/sac/installationinfo/vs/0", + "rep": { + "installationOptions": [ + { + "id": "0", + "option": "184449945E0484800017" + }, + { + "id": "1", + "option": "28010000010000000100" + }, + { + "id": "2", + "option": "50000000000000000001" + }, + { + "id": "3", + "option": "30000000000000000000" + } + ], + "installationDeviceNum": [ + { + "id": "0", + "number": "0" + }, + { + "id": "1", + "number": "5" + }, + { + "id": "2", + "number": "0" + } + ], + "outdoorInfo": [ + { + "id": "0", + "info": "02571A250514", + "serial": "**REDACTED**" + }, + { + "id": "1", + "info": "000000000000", + "serial": "**REDACTED**" + }, + { + "id": "2", + "info": "000000000000", + "serial": "**REDACTED**" + }, + { + "id": "3", + "info": "000000000000", + "serial": "**REDACTED**" + } + ], + "mcuInfo": [ + { + "id": "0", + "info": "000000000000" + }, + { + "id": "1", + "info": "000000000000" + }, + { + "id": "2", + "info": "000000000000" + }, + { + "id": "3", + "info": "000000000000" + }, + { + "id": "4", + "info": "000000000000" + }, + { + "id": "5", + "info": "000000000000" + }, + { + "id": "6", + "info": "000000000000" + }, + { + "id": "7", + "info": "000000000000" + }, + { + "id": "8", + "info": "000000000000" + }, + { + "id": "9", + "info": "000000000000" + }, + { + "id": "10", + "info": "000000000000" + }, + { + "id": "11", + "info": "000000000000" + }, + { + "id": "12", + "info": "000000000000" + }, + { + "id": "13", + "info": "000000000000" + }, + { + "id": "14", + "info": "000000000000" + } + ] + } + }, + { + "href": "/selfcheck/vs/0", + "rep": { + "x.com.samsung.da.start": "Cancel", + "x.com.samsung.da.status": "Ready", + "x.com.samsung.da.progress": "0", + "x.com.samsung.da.result": "NA", + "x.com.samsung.da.supportedActions": [ + "Start", + "Cancel" + ], + "x.com.samsung.da.error": [ + "DA_ERROR_NONE" + ] + } + }, + { + "href": "/sensors/vs/0", + "rep": { + "x.com.samsung.da.cleanLevel": "1", + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "0", + "x.com.samsung.da.description": "Sensor for CleanLevel", + "x.com.samsung.da.type": "CleanLevel", + "x.com.samsung.da.value": [ + "1" + ] + }, + { + "x.com.samsung.da.id": "2", + "x.com.samsung.da.description": "Sensor for Dust", + "x.com.samsung.da.type": "Dust", + "x.com.samsung.da.value": [ + "7", + "1" + ] + }, + { + "x.com.samsung.da.id": "3", + "x.com.samsung.da.description": "Sensor for FineDust", + "x.com.samsung.da.type": "FineDust", + "x.com.samsung.da.value": [ + "5", + "1" + ] + }, + { + "x.com.samsung.da.id": "4", + "x.com.samsung.da.description": "Sensor for SuperFineDust", + "x.com.samsung.da.type": "SuperFineDust", + "x.com.samsung.da.value": [ + "5", + "1" + ] + } + ] + } + }, + { + "href": "/settings/sound/mode/vs/0", + "rep": { + "mode": "voice", + "supportedModes": [ + "mute", + "tone", + "voice" + ] + } + }, + { + "href": "/settings/sound/optimization/vs/0", + "rep": { + "status": "Off", + "supportedModes": [ + "On", + "Off" + ] + } + }, + { + "href": "/settings/sound/output/vs/0", + "rep": { + "deviceType": "speaker" + } + }, + { + "href": "/settings/sound/volume/vs/0", + "rep": { + "level": "2", + "minLevel": "0", + "maxLevel": "3", + "resolution": "1" + } + }, + { + "href": "/smartsensingcooling/vs/0", + "rep": { + "status": "On", + "supportedModes": [ + "On", + "Off" + ] + } + }, + { + "href": "/temperature/control/vs/0", + "rep": { + "x.com.samsung.da.increment": "0.5" + } + }, + { + "href": "/temperatures/vs/0", + "rep": { + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "0", + "x.com.samsung.da.description": "Temperature", + "x.com.samsung.da.current": "28.5", + "x.com.samsung.da.desired": "28.0", + "x.com.samsung.da.minimum": "18", + "x.com.samsung.da.maximum": "30", + "x.com.samsung.da.increment": "0.5", + "x.com.samsung.da.unit": "Celsius" + } + ] + } + }, + { + "href": "/uvled/vs/0", + "rep": { + "x.com.samsung.da.modes": "On", + "x.com.samsung.da.start": "Cancel", + "x.com.samsung.da.pushAlarmStatus": "Off", + "x.com.samsung.da.supportedModes": [ + "On", + "Off" + ] + } + }, + { + "href": "/wind/direction/vs/0", + "rep": { + "x.com.samsung.da.modes": "Fix", + "x.com.samsung.da.supportedModes": [ + "Left_And_Right", + "All" + ] + } + }, + { + "href": "/wind/strength/vs/0", + "rep": { + "x.com.samsung.da.modes": "0", + "x.com.samsung.da.supportedModes": [ + "0", + "1", + "2", + "3" + ], + "x.com.samsung.da.modesName": [ + "Auto", + "Low", + "Mid", + "High" + ] + } + }, + { + "href": "/otninformation/vs/0", + "rep": { + "x.com.samsung.da.target": "", + "x.com.samsung.da.newVersionAvailable": "false", + "otnStatus": "None", + "flashingProgress": "0", + "otnCompleteDate": "noHistory", + "scheduledTime": "None", + "swVersionInfo": { + "platform": "Tizen Lite", + "oneUiVersion": "7.0 Air conditioner", + "osVersion": "4.0" + }, + "otnList": [ + { + "type": "WIFI", + "modelId": "ASA-WW-TP1-24-INFSAC", + "versions": [ + "14260401" + ], + "visVersion": "260401" + }, + { + "type": "Micom", + "modelId": "04001025554110255641", + "versions": [ + "25052600", + "24040200" + ], + "visVersion": "250526" + }, + { + "type": "Micom", + "modelId": "FGCAN2", + "versions": [ + "", + "" + ], + "visVersion": "999999" + } + ] + } + }, + { + "href": "/timezone/vs/0", + "rep": { + "timezoneid": "Asia/Seoul", + "offset": "+09:00", + "DST": "OFF" + } + }, + { + "href": "/connectionconfig/vs/0", + "rep": { + "autoReconnectionMinVersion": "1.0", + "autoReconnection": "true", + "autoReconnectionProtocolType": [ + "helper_hotspot", + "ble_ocf" + ], + "supportedWiFiAuthType": [ + "OPEN", + "WEP", + "WPA-PSK", + "WPA2-PSK", + "SAE" + ], + "supportedWiFiCryptoType": [ + "TKIP", + "AES", + "WEP-64", + "WEP-128" + ], + "supportedWiFiFreq": [ + "2.4G" + ], + "calmConnectionCare": { + "version": "1.0", + "role": [ + "things" + ] + } + } + }, + { + "href": "/wirelessinfo/vs/0", + "rep": { + "macaddressWiFi": "**REDACTED**", + "macaddressBLE": "**REDACTED**", + "connectedApSsid": "boksil" + } + }, + { + "href": "/quickcontrol/info/vs/0", + "rep": { + "supportedVersion": "1.0" + } + }, + { + "href": "/dginformation/vs/0", + "rep": { + "enrolmentstatus": "Unknown", + "devicestate": "Unknown", + "lockstatus": "Unknown", + "nextduedate": "", + "workingminutes": 0, + "paymentinfo": { + "emiplan": "Unknown", + "currency": "Unknown", + "totalemi": 0, + "totalemipaid": 0 + } + } + } + ] +} \ No newline at end of file diff --git a/tests/fixtures/golden/airconditioner_cac.json b/tests/fixtures/golden/airconditioner_cac.json new file mode 100644 index 0000000..e26afee --- /dev/null +++ b/tests/fixtures/golden/airconditioner_cac.json @@ -0,0 +1,30 @@ +{ + "state_keys": [ + "absence_power_saving_active", + "absence_power_saving_mode", + "air_filter_status", + "air_filter_usage", + "air_filter_usage_hours", + "air_purify", + "alarm_code", + "auto_clean", + "clean_level", + "climate", + "current_temperature_c", + "dust", + "energy_kwh", + "energy_saved_kwh", + "fine_dust", + "firmware_update", + "humidity", + "motion_detect_wind_active", + "motion_detect_wind_mode", + "mute_once", + "power_watts", + "selfcheck_error", + "selfcheck_result", + "selfcheck_status", + "super_fine_dust", + "tropical_night_mode" + ] +} \ No newline at end of file diff --git a/tests/localthings/test_config_flow.py b/tests/localthings/test_config_flow.py index bafeebc..8d40bd3 100644 --- a/tests/localthings/test_config_flow.py +++ b/tests/localthings/test_config_flow.py @@ -106,35 +106,44 @@ def test_find_live_ports_detects_silent_port(socket_enabled) -> None: def test_find_live_ports_rescues_preferred_ports_the_sweep_missed( - socket_enabled, + socket_enabled, monkeypatch, ) -> None: """Issue #192: a segregated VLAN made the ICMP-based sweep call three - closed ports live while missing the one port (49154, a historically - confirmed DTLS port) that nmap showed as genuinely open|filtered. The - sweep's verdict on a preferred port shouldn't be trusted blindly -- - it must always come back as a candidate even if the sweep marked it - dead, so the config flow gets a real handshake attempt against it.""" + closed ports live while missing the one port (a historically confirmed + DTLS port) that nmap showed as genuinely open|filtered. The sweep's + verdict on a preferred port shouldn't be trusted blindly -- it must + always come back as a candidate even if the sweep marked it dead, so the + config flow gets a real handshake attempt against it. + + Uses an OS-assigned port monkeypatched into PREFERRED_PROBE_PORTS rather + than the real 49154/49155, so this doesn't depend on those specific + system ports being free on whatever machine runs the suite. + """ import socket + from custom_components.localthings import config_flow from custom_components.localthings.config_flow import _find_live_ports - # Bind and immediately close a socket on 49154 so loopback refuses - # datagrams to it -- standing in for the sweep wrongly ruling out a - # port we have strong prior evidence for. + # Bind an OS-assigned port and immediately close it, same technique + # test_find_live_ports_detects_silent_port uses for its "closed" ports -- + # once closed, loopback refuses datagrams to it, standing in for the + # sweep wrongly ruling out a port we have strong prior evidence for. reserved = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - reserved.bind(('127.0.0.1', 49154)) + reserved.bind(('127.0.0.1', 0)) + preferred_port = reserved.getsockname()[1] reserved.close() + monkeypatch.setattr(config_flow, 'PREFERRED_PROBE_PORTS', [preferred_port]) live_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) live_sock.bind(('127.0.0.1', 0)) live_port = live_sock.getsockname()[1] try: - result = _find_live_ports('127.0.0.1', [49154, live_port], 0.8) + result = _find_live_ports('127.0.0.1', [preferred_port, live_port], 0.8) finally: live_sock.close() - assert set(result) == {49154, live_port} + assert set(result) == {preferred_port, live_port} async def test_probe_uses_discovered_low_port(hass: HomeAssistant, monkeypatch) -> None: diff --git a/tests/test_airconditioner_cac.py b/tests/test_airconditioner_cac.py new file mode 100644 index 0000000..586c365 --- /dev/null +++ b/tests/test_airconditioner_cac.py @@ -0,0 +1,70 @@ +"""Tests for the TP1X_DA-AC-CAC-01001_0000 cassette AC (issue #191). + +0.16.0's device-type simplification dropped oneUiVersion detection on the +assumption every device it typed was already reachable via a modelNum board +token -- this board was the one exception (its oneUiVersion self-reports +"7.0 Air conditioner", but 'CAC' had never been added to the board-token +table), so it silently fell back to common caps and lost its climate entity. + +This dump is NOT fully covered yet -- ten hrefs remain unbound (edge +lighting, PM1 filter, a second stateful light resource, absence-clean, four +`/settings/sound/*` resources, smart-sensing-cooling, UV LED), all genuinely +new to this board generation. That's a real device-support gap, left +documented here rather than guessed at, per the 'don't guess' rule -- fixing +the routing regression was the scope of #191. +""" +from custom_components.localthings.registry.adapter import flatten +from custom_components.localthings.registry.by_type import for_device_by_model +from custom_components.localthings.registry.discovery import discover + +from tests.conftest import _load_device + +_STILL_UNBOUND = frozenset({ + '/edgelighting/vs/0', + '/filter/airdustPM1filter/vs/0', + '/light/stateful/vs/0', + '/mds/absenceclean/vs/0', + '/settings/sound/mode/vs/0', + '/settings/sound/optimization/vs/0', + '/settings/sound/output/vs/0', + '/settings/sound/volume/vs/0', + '/smartsensingcooling/vs/0', + '/uvled/vs/0', +}) + + +def _resources(): + return _load_device('airconditioner_cac') + + +def _reg(resources): + info = resources['/information/vs/0'] + return for_device_by_model( + info['x.com.samsung.da.modelNum'], info['x.com.samsung.da.description']) + + +def test_resolves_to_airconditioner_registry(): + assert _reg(_resources()).name == 'airconditioner' + + +def test_documented_coverage_gap_is_exactly_this_set(): + """Locks in the current, known-incomplete coverage so a future fix to + any of these hrefs shows up as a golden-regression diff (extra keys) to + update here, rather than silently shrinking this list unnoticed.""" + resources = _resources() + reg = _reg(resources) + unbound = [] + discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append) + assert set(unbound) == _STILL_UNBOUND + + +def test_non_legacy_board_uses_the_generic_energy_scale(): + """This board reports /wind/strength/vs/0 (not /airflow/vs/0), so + is_legacy_board() is False and it must use the plain wh_to_kwh scale, + not the /100000 correction added for the unrelated ARTIK051_KRAC-class + board in issue #193.""" + resources = _resources() + reg = _reg(resources) + bound = discover(resources, reg.capabilities, reg.pattern_capabilities) + state = flatten(bound, resources) + assert state['energy_kwh'] == round(84044 / 1000.0, 2) diff --git a/tests/test_golden_regression.py b/tests/test_golden_regression.py index 4024099..6510c5d 100644 --- a/tests/test_golden_regression.py +++ b/tests/test_golden_regression.py @@ -859,6 +859,26 @@ def test_registry_reproduces_golden_state_keys_for_airconditioner_fac_bora(): ) +def test_registry_reproduces_golden_state_keys_for_airconditioner_cac(): + """TP1X_DA-AC-CAC-01001_0000 (issue #191) -- fell back to 'unknown' in + 0.16.0 when oneUiVersion detection was dropped, since 'CAC' had never + been added to the modelNum board-token table. Resolved via the new 'CAC' + token onto the existing airconditioner registry. Not fully covered yet -- + ten hrefs remain unbound (edge lighting, PM1 filter, stateful light, + absence-clean, four sound-settings resources, smart-sensing-cooling, UV + LED), all genuinely new to this board generation and out of scope for + the routing fix; see test_airconditioner_cac.py for the documented gap.""" + from tests.conftest import _load_device + resources = _load_device('airconditioner_cac') + golden = json.loads((GOLDEN / 'airconditioner_cac.json').read_text()) + state_keys = _new_state_keys('airconditioner_cac', resources) + assert set(state_keys) == set(golden['state_keys']), ( + f"state_keys mismatch:\n" + f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n" + f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}" + ) + + def test_registry_reproduces_golden_state_keys_for_air_purifier_avt_ww(): """AVT-WW-TP1-23-AXX500 (issue #190) -- next-gen BESPOKE Cube Air board; reports device_type 'unknown' with empty oneUiVersion because 'VTWW' as a