From 3afbe6c6c151f65b07fe99febd2b6c5466ae7dd0 Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Mon, 27 Jul 2026 00:33:35 +0000 Subject: [PATCH] Add humidity/temperature sensors and horizontal swing for AC (issue #75) Three gaps reported against an ARTIK051_PRAC_20K WindFree unit vs. the SmartThings integration: 1. Missing WindFree/motion convenient-mode presets -- left alone here. PR #91 replaces climate.py's static _DEVICE_TO_PRESET table with a generic resolver that reads any preset code straight off the unit's own supportedModes, which already covers this (and more generically than a per-model dict would) -- adding one here would just conflict. 2. No horizontal oscillation: /wind/direction/vs/0's supportedModes includes Left_And_Right, which _DEVICE_TO_SWING had no mapping for. Add it to HA's standard 'horizontal' swing constant. 3. No standalone humidity/current-temperature sensors: the climate card already reads both internally, but nothing exposed them as entities for history/automations. Add CURRENT_TEMPERATURE (OCF /temperature/current/0) with a CURRENT_TEMPERATURE_VS vendor fallback (same match_fn-gated pair shape as common.py's POWER_GENERIC/ POWER_VS_FALLBACK), and a HUMIDITY sensor reading /humidity/vs/0's fivepercentHumidity field -- the only one of the three humidity-shaped fields across /humidity/0 and /humidity/vs/0 that isn't permanently stuck at 0 on every dump seen. Regenerates the five existing AC goldens (all pick up current_temperature_c; most pick up humidity) and adds a dedicated fixture from the issue's WindFree dump, whose /humidity/vs/0 actually has live fivepercentHumidity data. --- custom_components/localthings/climate.py | 1 + .../registry/by_type/airconditioner.py | 3 + .../registry/capabilities/airconditioner.py | 69 +++- .../localthings/translations/en.json | 6 + .../localthings/translations/nl.json | 6 + .../airconditioner_windfree_device.json | 380 ++++++++++++++++++ tests/fixtures/golden/airconditioner.json | 2 + .../golden/airconditioner_caww_tp2.json | 2 + .../airconditioner_tp1x_da_ac_rac_01011.json | 2 + .../golden/airconditioner_tp1x_rac.json | 1 + .../golden/airconditioner_tp2x_rac_20k.json | 2 + .../golden/airconditioner_windfree.json | 15 + tests/test_airconditioner_capabilities.py | 53 ++- tests/test_climate_windfree.py | 23 ++ tests/test_golden_regression.py | 17 + 15 files changed, 579 insertions(+), 3 deletions(-) create mode 100644 tests/fixtures/airconditioner_windfree_device.json create mode 100644 tests/fixtures/golden/airconditioner_windfree.json create mode 100644 tests/test_climate_windfree.py diff --git a/custom_components/localthings/climate.py b/custom_components/localthings/climate.py index 79d38af..12e3cb6 100644 --- a/custom_components/localthings/climate.py +++ b/custom_components/localthings/climate.py @@ -84,6 +84,7 @@ _DEVICE_TO_SWING: dict[str, str] = { 'Fix': 'off', 'All': 'both', 'Up_And_Low': 'vertical', + 'Left_And_Right': 'horizontal', # issue #75 } _SWING_TO_DEVICE = {v: k for k, v in _DEVICE_TO_SWING.items()} diff --git a/custom_components/localthings/registry/by_type/airconditioner.py b/custom_components/localthings/registry/by_type/airconditioner.py index e218de5..7a7c38c 100644 --- a/custom_components/localthings/registry/by_type/airconditioner.py +++ b/custom_components/localthings/registry/by_type/airconditioner.py @@ -25,6 +25,9 @@ REGISTRY = DeviceRegistry( airconditioner.DISPLAY_LIGHT, airconditioner.MUTE_ONCE, airconditioner.CURRENT_LIMIT, + airconditioner.CURRENT_TEMPERATURE, + airconditioner.CURRENT_TEMPERATURE_VS, + airconditioner.HUMIDITY, *airconditioner.COVERAGE, ]), ) diff --git a/custom_components/localthings/registry/capabilities/airconditioner.py b/custom_components/localthings/registry/capabilities/airconditioner.py index 70f5e1b..599affd 100644 --- a/custom_components/localthings/registry/capabilities/airconditioner.py +++ b/custom_components/localthings/registry/capabilities/airconditioner.py @@ -16,6 +16,7 @@ by_type registry. """ from ..capability import Capability from ..entities import BinarySensorDesc, ClimateDesc, SensorDesc, SwitchDesc +from .common import normalize_temp_unit # --------------------------------------------------------------------------- # Canonical AC resource hrefs. The climate entity (climate.py) binds the @@ -50,6 +51,25 @@ def _num(v): return None +def _temps_vs_item(rep): + """First item of the vendor `/temperatures/vs/0` items[] array -- the + Tizen Lite board's only current-temperature source (see climate.py's + identical helper; duplicated rather than imported to avoid a + capabilities<->platform import cycle).""" + items = rep.get('x.com.samsung.da.items') + if isinstance(items, (list, tuple)) and items and isinstance(items[0], dict): + return items[0] + return {} + + +def _temps_vs_current(rep): + return _num(_temps_vs_item(rep).get('x.com.samsung.da.current')) + + +def _temps_vs_unit(rep): + return normalize_temp_unit(_temps_vs_item(rep).get('x.com.samsung.da.unit'), '°C') + + def _filter_usage_percent(rep): """Filter usage as a percentage of rated capacity. The device reports `filterUsage` as a raw count in `filterCapacityUnit` (Hours here, e.g. @@ -199,6 +219,48 @@ CURRENT_LIMIT = Capability( ), ) +# The climate entity already surfaces current_temperature as a card +# attribute, but that's not enough for history graphs/automations/ +# statistics -- issue #75 asked for a standalone sensor. Same OCF-standard- +# with-vendor-fallback shape as common.py's POWER_GENERIC/POWER_VS_FALLBACK +# pair: both share key='current_temperature_c' so only one ever binds +# (match_fn gates the vendor one off when the OCF resource is present). +CURRENT_TEMPERATURE = Capability( + href=HREF_TEMP_CURRENT, + poll_tier='warm', + entities=( + SensorDesc(key='current_temperature_c', field='temperature', + device_class='temperature', state_class='measurement', + unit_fn=lambda rep: normalize_temp_unit(rep.get('units'), '°C')), + ), +) + +CURRENT_TEMPERATURE_VS = Capability( + href=HREF_TEMPS_VS, + poll_tier='warm', + match_fn=lambda rep, resources: HREF_TEMP_CURRENT not in resources, + entities=( + SensorDesc(key='current_temperature_c', rep_fn=_temps_vs_current, + device_class='temperature', state_class='measurement', + unit_fn=_temps_vs_unit), + ), +) + +# Only the vendor resource's `fivepercentHumidity` (current reading, rounded +# to the nearest 5%) has live data on the issue #75 dump -- its `humidity` +# field, and the OCF-standard /humidity/0 resource entirely, both read a +# stuck "0" there and stay ignored per the 'don't guess' rule (see +# _AC_IGNORED below). +HUMIDITY = Capability( + href='/humidity/vs/0', + poll_tier='warm', + entities=( + SensorDesc(key='humidity', field='x.com.samsung.da.fivepercentHumidity', + device_class='humidity', state_class='measurement', unit='%', + value_fn=_num), + ), +) + # --------------------------------------------------------------------------- # AC-scoped coverage: the CLIMATE_CONSUMED_HREFS above (read by the climate # entity) plus vendor duplicates / all-zero-ambiguous / plumbing resources. @@ -221,8 +283,10 @@ _AC_IGNORED = [ # All-zero and ambiguously encoded on this model (2-value arrays); the # 'don't guess' rule -- leave unmodeled rather than invent entities. '/sensors/vs/0', + # Stuck at "0" on every dump seen -- HUMIDITY above reads the vendor + # resource's usable fivepercentHumidity field instead; this OCF-standard + # one has no corresponding live value confirmed yet. '/humidity/0', - '/humidity/vs/0', # Presence-personalization plumbing (empty item list here). '/personality/presence/vs/0', # --- TP1X/TP2X-class housekeeping / opaque blobs. These carry no @@ -251,8 +315,11 @@ _AC_IGNORED = [ ] # Built as bare no-entity caps; folded into the AC registry (not global). +# HREF_TEMP_CURRENT and HREF_TEMPS_VS are excluded here -- CURRENT_TEMPERATURE +# / CURRENT_TEMPERATURE_VS above already cover those two with real entities. COVERAGE = [ Capability(href=h, poll_tier='warm') for h in CLIMATE_CONSUMED_HREFS + if h not in (HREF_TEMP_CURRENT, HREF_TEMPS_VS) ] + [ Capability(href=h) for h in _AC_IGNORED ] diff --git a/custom_components/localthings/translations/en.json b/custom_components/localthings/translations/en.json index f990cc6..cf0e20d 100644 --- a/custom_components/localthings/translations/en.json +++ b/custom_components/localthings/translations/en.json @@ -457,6 +457,9 @@ "current_temp_c": { "name": "Temperature" }, + "current_temperature_c": { + "name": "Temperature" + }, "diagnosis": { "name": "Diagnosis" }, @@ -526,6 +529,9 @@ "hood_filter_usage": { "name": "Filter usage" }, + "humidity": { + "name": "Humidity" + }, "instance_temperature": { "name": "{instance_name} temperature" }, diff --git a/custom_components/localthings/translations/nl.json b/custom_components/localthings/translations/nl.json index c922d82..73d8eb0 100644 --- a/custom_components/localthings/translations/nl.json +++ b/custom_components/localthings/translations/nl.json @@ -457,6 +457,9 @@ "current_temp_c": { "name": "Temperatuur" }, + "current_temperature_c": { + "name": "Temperatuur" + }, "diagnosis": { "name": "Diagnose" }, @@ -526,6 +529,9 @@ "hood_filter_usage": { "name": "Filterverbruik" }, + "humidity": { + "name": "Luchtvochtigheid" + }, "instance_temperature": { "name": "Temperatuur {instance_name}" }, diff --git a/tests/fixtures/airconditioner_windfree_device.json b/tests/fixtures/airconditioner_windfree_device.json new file mode 100644 index 0000000..9fca817 --- /dev/null +++ b/tests/fixtures/airconditioner_windfree_device.json @@ -0,0 +1,380 @@ +{ + "device0": [ + { + "rt": [ + "x.com.samsung.devcol", + "oic.wk.col" + ], + "if": [ + "oic.if.baseline", + "oic.if.ll", + "oic.if.b" + ] + }, + { + "href": "/alarms/vs/0", + "rep": { + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "0", + "x.com.samsung.da.description": "Alarm", + "x.com.samsung.da.alarmType": "Device", + "x.com.samsung.da.code": "ErrorCode_OFF", + "x.com.samsung.da.triggeredTime": "2026-07-25T08:19:03" + }, + { + "x.com.samsung.da.id": "1", + "x.com.samsung.da.description": "Alarm", + "x.com.samsung.da.alarmType": "Device", + "x.com.samsung.da.code": "FilterAlarm_OFF", + "x.com.samsung.da.triggeredTime": "2026-07-25T08:19:03" + } + ] + } + }, + { + "href": "/configuration/vs/0", + "rep": { + "x.com.samsung.da.region": "0000000000", + "x.com.samsung.da.airconOptionList": [ + "SingleCommand_1", + "HOMECARE_WIZARD_V2", + "PRODUCT_GLOBAL", + "AI_RAC_GLOBAL_2.0", + "AI_2.0", + "Auto_To_AI", + "AI_Heat" + ] + } + }, + { + "href": "/diagnosis/vs/0", + "rep": { + "x.com.samsung.da.diagnosisStart": "Ready" + } + }, + { + "href": "/energy/consumption/0", + "rep": { + "power": 0.0 + } + }, + { + "href": "/energy/consumption/vs/0", + "rep": { + "x.com.samsung.da.instantaneousPower": "0", + "x.com.samsung.da.cumulativePower": "320466", + "x.com.samsung.da.cumulativeUnit": "Wh", + "x.com.samsung.da.instantaneousPowerUnit": "W" + } + }, + { + "href": "/file/information/vs/0", + "rep": { + "x.com.samsung.timeoffset": "+00:00", + "x.com.samsung.supprtedtype": 1 + } + }, + { + "href": "/filter/airdustfilter/vs/0", + "rep": { + "x.com.samsung.da.filterUsage": "41", + "x.com.samsung.da.filterUsageResolution": "1", + "x.com.samsung.da.filterDesiredUsage": "500", + "x.com.samsung.da.filterStatus": "normal", + "x.com.samsung.da.filterCapacity": "500", + "x.com.samsung.da.filterCapacityUnit": "Hour", + "x.com.samsung.da.filterResetType": [ + "replaceable", + "washable" + ] + } + }, + { + "href": "/humidity/0", + "rep": { + "humidity": 0 + } + }, + { + "href": "/humidity/vs/0", + "rep": { + "x.com.samsung.da.humidity": "0", + "x.com.samsung.da.fivepercentHumidity": "42" + } + }, + { + "href": "/information/vs/0", + "rep": { + "x.com.samsung.da.modelNum": "ARTIK051_PRAC_20K|10217841|60010519001411010200001000000000", + "x.com.samsung.da.description": "ARTIK051_PRAC_20K", + "x.com.samsung.da.serialNum": "**REDACTED**", + "x.com.samsung.da.otnDUID": "**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": "02181A230313", + "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.number": "20082000,FFFFFFFF", + "x.com.samsung.da.newVersionAvailable": "0" + }, + { + "x.com.samsung.da.id": "2", + "x.com.samsung.da.description": "Version", + "x.com.samsung.da.type": "Outdoor", + "x.com.samsung.da.number": "20091600,10000400" + } + ] + } + }, + { + "href": "/mode/convenient/vs/0", + "rep": { + "x.com.samsung.da.modes": "Off", + "x.com.samsung.da.supportedModes": [ + "Off", + "Sleep", + "Quiet", + "Smart", + "Speed", + "MotionIndirect", + "MotionDirect", + "Nano", + "NanoSleep" + ] + } + }, + { + "href": "/mode/vs/0", + "rep": { + "x.com.samsung.da.supportedModes": [ + "Cool", + "Dry", + "Wind", + "Auto", + "Heat" + ], + "x.com.samsung.da.modes": [ + "Dry" + ], + "x.com.samsung.da.options": [ + "Sleep_0", + "ArtificialWorking_Off", + "ComfortAICooling_Off", + "MotionState_Normal", + "AiTempChanged_Off", + "AiTemp_240", + "OutdoorTemp_87", + "CoolCapa_25", + "WarmCapa_32", + "Light_Off", + "Volume_100", + "MotionMode_Default", + "OptionCode_56376", + "ExtendOptionCode_781", + "RacInfo_None", + "UpdateAllow_NotAllowed", + "DurationOn_0", + "WelcomeCoolingState_Off" + ] + } + }, + { + "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.settingStatus": "On", + "x.com.samsung.da.progress": "0", + "x.com.samsung.da.supportedStatus": [ + "Start", + "Stop" + ], + "x.com.samsung.da.supportedSettingStatus": [ + "On", + "Off" + ] + } + }, + { + "href": "/personality/presence/vs/0", + "rep": { + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "", + "x.com.samsung.da.deviceId": "**REDACTED**", + "x.com.samsung.da.value": "" + } + ] + } + }, + { + "href": "/power/0", + "rep": { + "value": false + } + }, + { + "href": "/power/vs/0", + "rep": { + "x.com.samsung.da.power": "Off" + } + }, + { + "href": "/realtimenotiforclient/vs/0", + "rep": { + "x.com.samsung.da.timeforshortnoti": "0", + "x.com.samsung.da.longnotisubscription": "false", + "x.com.samsung.da.periodicnotisubscription": "true" + } + }, + { + "href": "/sensors/vs/0", + "rep": { + "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": [ + "0" + ] + }, + { + "x.com.samsung.da.id": "1", + "x.com.samsung.da.description": "Sensor for Odor", + "x.com.samsung.da.type": "Odor", + "x.com.samsung.da.value": [ + "0" + ] + }, + { + "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": [ + "0", + "0" + ] + }, + { + "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": [ + "0", + "0" + ] + }, + { + "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": [ + "0", + "0" + ] + } + ] + } + }, + { + "href": "/temperature/control/vs/0", + "rep": { + "x.com.samsung.da.increment": "1" + } + }, + { + "href": "/temperature/current/0", + "rep": { + "range": [ + 16.0, + 30.0 + ], + "units": "C", + "temperature": 27.0 + } + }, + { + "href": "/temperature/desired/0", + "rep": { + "range": [ + 16.0, + 30.0 + ], + "units": "C", + "temperature": 26.0 + } + }, + { + "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.desired": "26.0", + "x.com.samsung.da.current": "27.0", + "x.com.samsung.da.maximum": "30", + "x.com.samsung.da.minimum": "18", + "x.com.samsung.da.increment": "1.0", + "x.com.samsung.da.unit": "Celsius" + } + ] + } + }, + { + "href": "/timezone/vs/0", + "rep": {} + }, + { + "href": "/wind/direction/vs/0", + "rep": { + "x.com.samsung.da.modes": "Fix", + "x.com.samsung.da.supportedModes": [ + "Fix", + "All", + "Up_And_Low", + "Left_And_Right" + ] + } + }, + { + "href": "/wind/strength/vs/0", + "rep": { + "x.com.samsung.da.modes": "0", + "x.com.samsung.da.supportedModes": [ + "0", + "1", + "2", + "3", + "4" + ], + "x.com.samsung.da.modesName": [ + "Auto", + "Low", + "Mid", + "High", + "Turbo" + ] + } + } + ] +} \ No newline at end of file diff --git a/tests/fixtures/golden/airconditioner.json b/tests/fixtures/golden/airconditioner.json index 68abd0f..6a6596c 100644 --- a/tests/fixtures/golden/airconditioner.json +++ b/tests/fixtures/golden/airconditioner.json @@ -6,9 +6,11 @@ "alarm_code", "auto_clean", "climate", + "current_temperature_c", "diagnosis_status", "energy_kwh", "energy_saved_kwh", + "humidity", "power_watts" ] } diff --git a/tests/fixtures/golden/airconditioner_caww_tp2.json b/tests/fixtures/golden/airconditioner_caww_tp2.json index 290a269..c3cdd7c 100644 --- a/tests/fixtures/golden/airconditioner_caww_tp2.json +++ b/tests/fixtures/golden/airconditioner_caww_tp2.json @@ -5,10 +5,12 @@ "alarm_code", "auto_clean", "climate", + "current_temperature_c", "diagnosis_status", "energy_kwh", "energy_saved_kwh", "firmware_update", + "humidity", "mute_once", "power_watts" ] diff --git a/tests/fixtures/golden/airconditioner_tp1x_da_ac_rac_01011.json b/tests/fixtures/golden/airconditioner_tp1x_da_ac_rac_01011.json index b9643bf..af71051 100644 --- a/tests/fixtures/golden/airconditioner_tp1x_da_ac_rac_01011.json +++ b/tests/fixtures/golden/airconditioner_tp1x_da_ac_rac_01011.json @@ -6,10 +6,12 @@ "alarm_code", "auto_clean", "climate", + "current_temperature_c", "display_light", "energy_kwh", "energy_saved_kwh", "firmware_update", + "humidity", "mute_once", "selfcheck_error", "selfcheck_result", diff --git a/tests/fixtures/golden/airconditioner_tp1x_rac.json b/tests/fixtures/golden/airconditioner_tp1x_rac.json index b7ce7c3..b192cdf 100644 --- a/tests/fixtures/golden/airconditioner_tp1x_rac.json +++ b/tests/fixtures/golden/airconditioner_tp1x_rac.json @@ -8,6 +8,7 @@ "climate", "current_limit_enabled", "current_limit_level", + "current_temperature_c", "display_light", "energy_kwh", "energy_saved_kwh", diff --git a/tests/fixtures/golden/airconditioner_tp2x_rac_20k.json b/tests/fixtures/golden/airconditioner_tp2x_rac_20k.json index 4b50703..a2c11e4 100644 --- a/tests/fixtures/golden/airconditioner_tp2x_rac_20k.json +++ b/tests/fixtures/golden/airconditioner_tp2x_rac_20k.json @@ -5,8 +5,10 @@ "alarm_code", "auto_clean", "climate", + "current_temperature_c", "energy_kwh", "firmware_update", + "humidity", "mute_once", "power_watts" ] diff --git a/tests/fixtures/golden/airconditioner_windfree.json b/tests/fixtures/golden/airconditioner_windfree.json new file mode 100644 index 0000000..70ba9c5 --- /dev/null +++ b/tests/fixtures/golden/airconditioner_windfree.json @@ -0,0 +1,15 @@ +{ + "state_keys": [ + "air_filter_status", + "air_filter_usage", + "air_purify", + "alarm_code", + "auto_clean", + "climate", + "current_temperature_c", + "diagnosis_status", + "energy_kwh", + "humidity", + "power_watts" + ] +} diff --git a/tests/test_airconditioner_capabilities.py b/tests/test_airconditioner_capabilities.py index 981dc5d..a7ea9d6 100644 --- a/tests/test_airconditioner_capabilities.py +++ b/tests/test_airconditioner_capabilities.py @@ -114,11 +114,13 @@ def test_climate_write_targets(): def test_climate_consumed_hrefs_declared_as_coverage(): """The climate-consumed and ambiguous hrefs are declared in the AC registry (as no-entity coverage caps) so they don't leak as gaps -- but produce no - standalone entities.""" + standalone entities. /temperature/current/0 and /temperatures/vs/0 are + NOT in this list -- CURRENT_TEMPERATURE / CURRENT_TEMPERATURE_VS give + those two real sensor entities (issue #75).""" reg, _ = _ac() for href in ('/power/0', '/power/vs/0', '/temperature/desired/0', '/wind/strength/vs/0', '/mode/convenient/vs/0', - '/temperatures/vs/0', '/sensors/vs/0', '/humidity/0'): + '/sensors/vs/0', '/humidity/0'): caps = reg.capabilities.get(href) assert caps, href assert all(c.entities == () for c in caps), href @@ -257,3 +259,50 @@ def test_current_limit_is_read_only(): than a guessed writable control.""" for desc in airconditioner.CURRENT_LIMIT.entities: assert getattr(desc, 'write_fn', None) is None + + +# --------------------------------------------------------------------------- +# WindFree unit (issue #75): same ARTIK051_PRAC_20K modelNum family as the +# original issue #17 fixture, but its /mode/convenient/vs/0 additionally +# supports Nano/NanoSleep/MotionDirect/MotionIndirect, /wind/direction/vs/0 +# additionally supports Left_And_Right, and /humidity/vs/0's +# fivepercentHumidity is populated (unlike the all-zero original dump). +# --------------------------------------------------------------------------- + +def _ac_windfree(): + resources = _load_device('airconditioner_windfree') + info = resources['/information/vs/0'] + reg = for_device_by_model( + info['x.com.samsung.da.modelNum'], info['x.com.samsung.da.description'], + ) + return reg, resources + + +def test_windfree_no_unbound_hrefs(): + reg, resources = _ac_windfree() + unbound = [] + discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append) + assert unbound == [] + + +def test_windfree_humidity_and_temperature_sensors_present(): + reg, resources = _ac_windfree() + bound = discover(resources, reg.capabilities, reg.pattern_capabilities) + state = flatten(bound, resources) + assert state['humidity'] == 42.0 # fivepercentHumidity, not the stuck humidity=0 field + assert state['current_temperature_c'] == 27.0 + + +def test_current_temperature_vs_only_binds_when_ocf_href_absent(): + """CURRENT_TEMPERATURE_VS's match_fn must not double-bind alongside + CURRENT_TEMPERATURE when a device (like this one) reports both + /temperature/current/0 and /temperatures/vs/0.""" + match = airconditioner.CURRENT_TEMPERATURE_VS.match_fn + assert match({}, {'/temperature/current/0': {}}) is False + assert match({}, {}) is True + + +def test_humidity_reads_five_percent_field_not_stuck_humidity_field(): + desc = airconditioner.HUMIDITY.entities[0] + rep = {'x.com.samsung.da.humidity': '0', 'x.com.samsung.da.fivepercentHumidity': '42'} + assert desc.value_fn(rep.get(desc.field)) == 42.0 diff --git a/tests/test_climate_windfree.py b/tests/test_climate_windfree.py new file mode 100644 index 0000000..d9e74f0 --- /dev/null +++ b/tests/test_climate_windfree.py @@ -0,0 +1,23 @@ +"""Tests for the horizontal-swing mapping added to climate.py for issue #75 +-- pure dict lookups, testable without a coordinator/entity fixture (see +test_climate_temperature_fallback.py). + +The preset side of issue #75 (WindFree/motion convenient modes not +surfacing) is intentionally not addressed here: PR #91 replaces +climate.py's static _DEVICE_TO_PRESET table with a generic resolver that +reads any device preset code straight off the unit's own supportedModes, +which covers WindFree/motion generically instead of a per-model dict -- +duplicating that here would just conflict with it. +""" +from custom_components.localthings.climate import _DEVICE_TO_SWING, _SWING_TO_DEVICE + + +def test_horizontal_swing_maps_both_directions(): + assert _DEVICE_TO_SWING['Left_And_Right'] == 'horizontal' + assert _SWING_TO_DEVICE['horizontal'] == 'Left_And_Right' + + +def test_existing_swing_modes_unchanged(): + assert _DEVICE_TO_SWING['Fix'] == 'off' + assert _DEVICE_TO_SWING['All'] == 'both' + assert _DEVICE_TO_SWING['Up_And_Low'] == 'vertical' diff --git a/tests/test_golden_regression.py b/tests/test_golden_regression.py index 56ba878..05829b0 100644 --- a/tests/test_golden_regression.py +++ b/tests/test_golden_regression.py @@ -245,6 +245,23 @@ def test_registry_reproduces_golden_state_keys_for_tp1x_rac(): ) +def test_registry_reproduces_golden_state_keys_for_airconditioner_windfree(): + """ARTIK051_PRAC_20K, WindFree-capable unit (issue #75) -- same modelNum + family as the original issue #17 fixture, but its /mode/convenient/vs/0 + additionally reports Nano/NanoSleep/MotionDirect/MotionIndirect, its + /wind/direction/vs/0 reports Left_And_Right, and /humidity/vs/0's + fivepercentHumidity is actually populated.""" + from tests.conftest import _load_device + resources = _load_device('airconditioner_windfree') + golden = json.loads((GOLDEN / 'airconditioner_windfree.json').read_text()) + state_keys = _new_state_keys('airconditioner_windfree', 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_range(): """Range/cooktop-oven combo (model TP1X_DA-KS-RANGE-0102X, issue #44) -- reports no oneUiVersion; resolved via the '-RANGE-' modelNum token