Add cooktop and range hood support

This commit is contained in:
themaanda committed 2026-07-21 16:57:45 -05:00
1 parent bedc662b47
commit c64f58aaa5
25 files changed
+1399 -14

No files matched your search

+73
View File
@@ -0,0 +1,73 @@
{
"device0": [
{
"rt": [
"x.com.samsung.devcol",
"oic.wk.col"
],
"if": [
"oic.if.baseline",
"oic.if.ll",
"oic.if.b"
]
},
{
"href": "/otninformation/vs/0",
"rep": {
"x.com.samsung.da.target": "",
"x.com.samsung.da.newVersionAvailable": "false"
}
},
{
"href": "/power/vs/0",
"rep": {
"x.com.samsung.da.power": "On"
}
},
{
"href": "/mode/vs/0",
"rep": {
"rt": [
"x.com.samsung.da.mode"
],
"if": [
"oic.if.baseline",
"oic.if.a"
],
"x.com.samsung.da.options": [
"DeviceType_NA9300K-/AA1",
"MainTimerCurrent_0",
"MainTimerSet_0",
"MainTimerState_Ready",
"OperationState0_Ready",
"OperationState1_Ready",
"OperationState3_Ready",
"OperationState4_Ready",
"OperationState5_Ready"
]
}
},
{
"href": "/connected/vs/0",
"rep": {
"x.com.samsung.da.connected": "On"
}
},
{
"href": "/configuration/vs/0",
"rep": {}
},
{
"href": "/bluetooth/hood/status/vs/0",
"rep": {
"connectionState": "disconnected",
"micomModelId": "",
"firmwareVersion": "",
"power": "off",
"fanSpeed": 0,
"lampState": "off",
"timer": {}
}
}
]
}
+21
View File
@@ -0,0 +1,21 @@
{
"state_keys": [
"any_burner_active",
"burner_0_state",
"burner_1_state",
"burner_3_state",
"burner_4_state",
"burner_5_state",
"cloud_connected",
"firmware_update",
"main_timer_current",
"main_timer_state",
"paired_hood_connected",
"paired_hood_fan_speed",
"paired_hood_firmware",
"paired_hood_light",
"paired_hood_model",
"paired_hood_power",
"power_state"
]
}
+24
View File
@@ -0,0 +1,24 @@
{
"state_keys": [
"air_sensing_state",
"alarm_code",
"auto_ventilation_action",
"automatic_operation",
"automatic_ventilation_state",
"clean_level",
"energy_kwh",
"fan",
"firmware_update",
"hood_filter_capacity",
"hood_filter_status",
"hood_filter_usage",
"lamp",
"lamp_brightness",
"last_air_sensing_level",
"last_air_sensing_time",
"periodic_air_sensing",
"dust",
"fine_dust",
"super_fine_dust"
]
}
+199
View File
@@ -0,0 +1,199 @@
{
"device0": [
{
"rt": ["x.com.samsung.devcol", "oic.wk.col"],
"if": ["oic.if.baseline", "oic.if.ll", "oic.if.b"]
},
{
"href": "/personality/presence/vs/0",
"rep": {
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "",
"x.com.samsung.da.deviceId": "TEST-DEVICE-ID",
"x.com.samsung.da.value": ""
}
]
}
},
{
"href": "/realtimenotiforclient/vs/0",
"rep": {
"x.com.samsung.da.timeforshortnoti": "0",
"x.com.samsung.da.longnotisubscription": "false",
"x.com.samsung.da.periodicnotisubscription": "true"
}
},
{
"href": "/airlevelcheck/vs/0",
"rep": {
"x.com.samsung.da.periodicSensingActivationState": "Off",
"x.com.samsung.da.periodicSensingInterval": "3600",
"x.com.samsung.da.startSensingOnce": "Off",
"x.com.samsung.da.sensingState": "NonProcessing",
"x.com.samsung.da.lastSensingTime": "1784651378",
"x.com.samsung.da.lastSensingLevel": "Kr2",
"x.com.samsung.da.autoExeState": "Sensing",
"x.com.samsung.da.supportedAutoExeState": ["Airpurify", "Alarm"],
"x.com.samsung.da.periodicSensingSkipStatus": "Off",
"x.com.samsung.da.periodicSensingSkipTime": "00000000"
}
},
{
"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-21T20:18:51",
"x.com.samsung.da.state": "Deleted"
}
]
}
},
{
"href": "/energy/consumption/vs/0",
"rep": {
"x.com.samsung.da.cumulativePower": "165620",
"x.com.samsung.da.cumulativeSavedPower": "0",
"x.com.samsung.da.cumulativeDate": "1784647130",
"x.com.samsung.da.cumulativeDateUTC": "1784665130",
"x.com.samsung.da.cumulativeUnit": "Wh"
}
},
{"href": "/energy/consumption/0", "rep": {}},
{
"href": "/mode/vs/0",
"rep": {
"x.com.samsung.da.options": ["OptionCode_19322", "UpdateAllow_NotAllowed"]
}
},
{
"href": "/power/vs/0",
"rep": {"x.com.samsung.da.power": "Off"}
},
{"href": "/power/0", "rep": {"value": false}},
{
"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": ["2"]
},
{
"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": ["18", "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": ["14", "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": ["9", "1"]
}
]
}
},
{
"href": "/information/vs/0",
"rep": {
"x.com.samsung.da.modelNum": "AHD-WW-TP1-22-COMMON|20136141|7800006B001713C44D00090001030000",
"x.com.samsung.da.description": "AHD-WW-TP1-22-COMMON",
"x.com.samsung.da.serialNum": "TEST-HOOD-SERIAL",
"x.com.samsung.da.diagProtocolType": "WIFI_HTTPS",
"x.com.samsung.da.diagSetupid": "KH0"
}
},
{
"href": "/file/information/vs/0",
"rep": {"x.com.samsung.timeoffset": "-05:00"}
},
{
"href": "/configuration/vs/0",
"rep": {
"x.com.samsung.da.region": "0000000000",
"x.com.samsung.da.airconOptionList": ["DR"],
"x.com.samsung.da.countryCode": ""
}
},
{
"href": "/drlc/0",
"rep": {
"DRLevel": 0,
"start": "1970-01-01T00:00:00Z",
"durationminutes": 0,
"override": false
}
},
{
"href": "/drlc/vs/0",
"rep": {
"x.com.samsung.da.drlcLevel": "0",
"x.com.samsung.da.durationminutes": "0",
"x.com.samsung.da.start": "1970-01-01T00:00:00Z",
"x.com.samsung.da.override": "Off",
"x.com.samsung.da.realSaving": "Off"
}
},
{
"href": "/availablecontrolsets/vs/0",
"rep": {
"x.com.samsung.da.sets": "0000000000012000000000000000",
"x.com.samsung.da.id": "HOOD",
"x.com.samsung.da.version": "1.0"
}
},
{
"href": "/da/softreset/vs/0",
"rep": {"x.com.samsung.da.softwarereset": "false"}
},
{
"href": "/filter/hoodfilter/vs/0",
"rep": {
"x.com.samsung.da.filterUsage": "100",
"x.com.samsung.da.filterUsageResolution": "1",
"x.com.samsung.da.filterStatus": "wash",
"x.com.samsung.da.filterCapacity": "60",
"x.com.samsung.da.filterCapacityUnit": "Hour",
"x.com.samsung.da.filterResetType": ["replaceable", "washable"]
}
},
{
"href": "/hood/fanspeed/vs/0",
"rep": {
"x.com.samsung.da.hood.autoOperation": "Off",
"x.com.samsung.da.hood.fanSpeed": "14",
"x.com.samsung.da.hood.supportedFanSpeed": ["14", "15", "16", "17"]
}
},
{
"href": "/hood/lamp/vs/0",
"rep": {
"x.com.samsung.lamp.power": "Off",
"x.com.samsung.lamp.current": "2",
"x.com.samsung.lamp.range": ["1", "2"]
}
},
{"href": "/autoventilation/vs/0", "rep": {"action": "Start"}},
{
"href": "/otninformation/vs/0",
"rep": {
"x.com.samsung.da.target": "",
"x.com.samsung.da.newVersionAvailable": "false"
}
}
]
}
+25
View File
@@ -134,6 +134,31 @@ def test_run_discovery_detects_washer_via_model_fallback(
assert coordinator.device_type_name == 'washer'
def test_run_discovery_detects_cooktop_via_resource_signature(
hass: HomeAssistant, mock_entry
) -> None:
"""NA9300K omits oneUiVersion and information but has burner options."""
from tests.conftest import _load_device
coordinator = LocalThingsCoordinator(hass, mock_entry)
coordinator._run_discovery(_load_device('cooktop'))
assert coordinator.device_type_name == 'cooktop'
assert coordinator._unbound_hrefs == []
def test_run_discovery_detects_range_hood_via_model(
hass: HomeAssistant, mock_entry
) -> None:
from tests.conftest import _load_device
coordinator = LocalThingsCoordinator(hass, mock_entry)
coordinator._run_discovery(_load_device('range_hood'))
assert coordinator.device_type_name == 'range_hood'
assert coordinator._unbound_hrefs == []
def test_update_coverage_gap_issue_creates_issue_for_unknown_type(
hass: HomeAssistant, mock_entry
) -> None:
+64
View File
@@ -41,6 +41,16 @@ class TestForDevice:
assert isinstance(registry, DeviceRegistry)
assert registry.name == 'refrigerator'
def test_for_device_returns_cooktop_registry(self):
registry = for_device('7.0 Cooktop')
assert registry is not None
assert registry.name == 'cooktop'
def test_for_device_returns_range_hood_registry(self):
registry = for_device('7.0 Range Hood')
assert registry is not None
assert registry.name == 'range_hood'
class TestDeviceRegistries:
"""Tests for device registries themselves."""
@@ -180,6 +190,25 @@ class TestForDeviceByModel:
assert reg is not None
assert reg.name == 'airconditioner'
def test_cooktop_via_legacy_model_description(self):
"""Older cooktops identify themselves as ARTIK051_GLOBAL_COOKTOP."""
from custom_components.localthings.registry.by_type import for_device_by_model
reg = for_device_by_model(
'ARTIK051_GB_CT_001|40424141|50000204001211000200000000000000',
'ARTIK051_GLOBAL_COOKTOP',
)
assert reg is not None
assert reg.name == 'cooktop'
def test_range_hood_via_ahd_model(self):
from custom_components.localthings.registry.by_type import for_device_by_model
reg = for_device_by_model(
'AHD-WW-TP1-22-COMMON|20136141|7800006B001713C44D00090001030000',
'AHD-WW-TP1-22-COMMON',
)
assert reg is not None
assert reg.name == 'range_hood'
def test_unknown_model_returns_none(self):
from custom_components.localthings.registry.by_type import for_device_by_model
reg = for_device_by_model('SOME-UNKNOWN-BOARD', 'SOME-UNKNOWN-BOARD')
@@ -188,3 +217,38 @@ class TestForDeviceByModel:
def test_empty_inputs_return_none(self):
from custom_components.localthings.registry.by_type import for_device_by_model
assert for_device_by_model('', '') is None
class TestForDeviceByResources:
def test_na9300k_without_one_ui_or_information_is_cooktop(self):
from custom_components.localthings.registry.by_type import for_device_by_resources
from tests.conftest import _load_device
reg = for_device_by_resources(_load_device('cooktop'))
assert reg is not None
assert reg.name == 'cooktop'
def test_unrelated_mode_options_are_not_cooktop(self):
from custom_components.localthings.registry.by_type import for_device_by_resources
resources = {
'/mode/vs/0': {
'x.com.samsung.da.options': [
'DeviceType_SOME_OVEN',
'UpperLamp_Off',
],
},
}
assert for_device_by_resources(resources) is None
def test_hood_resource_signature(self):
from custom_components.localthings.registry.by_type import for_device_by_resources
resources = {
'/hood/fanspeed/vs/0': {},
'/hood/lamp/vs/0': {},
}
reg = for_device_by_resources(resources)
assert reg is not None
assert reg.name == 'range_hood'
+68
View File
@@ -0,0 +1,68 @@
"""Tests for the read-only cooktop capability profile."""
from custom_components.localthings.registry.adapter import flatten
from custom_components.localthings.registry.by_type import cooktop
from custom_components.localthings.registry.capabilities.cooktop import (
COOKTOP_MODE,
)
from custom_components.localthings.registry.discovery import discover
from tests.conftest import _load_device
def _state(resources):
bound = discover(
resources,
cooktop.REGISTRY.capabilities,
cooktop.REGISTRY.pattern_capabilities,
)
return flatten(bound, resources)
def test_real_cooktop_fixture_has_expected_idle_state():
state = _state(_load_device('cooktop'))
assert state['power_state'] == 'On'
assert state['any_burner_active'] is False
assert state['burner_0_state'] == 'Ready'
assert state['burner_5_state'] == 'Ready'
assert state['main_timer_state'] == 'Ready'
assert state['main_timer_current'] == 0
assert state['cloud_connected'] is True
assert state['paired_hood_connected'] is False
assert state['paired_hood_power'] is False
assert state['paired_hood_fan_speed'] == 0
assert state['paired_hood_light'] is False
def test_run_operation_marks_any_burner_active():
resources = _load_device('cooktop')
options = resources['/mode/vs/0']['x.com.samsung.da.options']
options[options.index('OperationState3_Ready')] = 'OperationState3_Run'
state = _state(resources)
assert state['burner_3_state'] == 'Run'
assert state['any_burner_active'] is True
def test_cooktop_profile_has_no_write_functions():
"""A local integration must never expose unverified remote ignition."""
descriptions = [
desc
for capabilities in cooktop.REGISTRY.capabilities.values()
for capability in capabilities
for desc in capability.entities
]
assert descriptions
assert all(not hasattr(desc, 'write_fn') or desc.write_fn is None
for desc in descriptions)
def test_known_na9300k_slots_are_present():
keys = {desc.key for desc in COOKTOP_MODE.entities}
assert {
'burner_0_state', 'burner_1_state', 'burner_3_state',
'burner_4_state', 'burner_5_state',
} <= keys
+2 -1
View File
@@ -1,6 +1,6 @@
from custom_components.localthings.registry.entities import (
SensorDesc, BinarySensorDesc, SelectDesc, SwitchDesc, ButtonDesc,
NumberDesc, TimeDesc, ClimateDesc, PLATFORM_OF,
NumberDesc, TimeDesc, ClimateDesc, FanDesc, PLATFORM_OF,
)
@@ -28,6 +28,7 @@ def test_platform_mapping_covers_all_subclasses():
assert PLATFORM_OF[NumberDesc] == 'number'
assert PLATFORM_OF[TimeDesc] == 'time'
assert PLATFORM_OF[ClimateDesc] == 'climate'
assert PLATFORM_OF[FanDesc] == 'fan'
def test_select_carries_options_and_write_fn():
+29 -1
View File
@@ -7,7 +7,9 @@ GOLDEN = Path(__file__).parent / 'fixtures' / 'golden'
def _new_state_keys(name, resources):
from custom_components.localthings.registry.by_type import for_device, for_device_by_model
from custom_components.localthings.registry.by_type import (
for_device, for_device_by_model, for_device_by_resources,
)
from custom_components.localthings.registry.discovery import discover
from custom_components.localthings.registry.adapter import flatten
otn = resources.get('/otninformation/vs/0', {})
@@ -19,6 +21,8 @@ def _new_state_keys(name, resources):
info.get('x.com.samsung.da.modelNum', ''),
info.get('x.com.samsung.da.description', ''),
)
if reg is None:
reg = for_device_by_resources(resources)
if reg is None:
from custom_components.localthings.registry.registry import CAPABILITIES
caps, pats = CAPABILITIES, []
@@ -81,6 +85,30 @@ def test_registry_reproduces_golden_state_keys_for_airconditioner():
)
def test_registry_reproduces_golden_state_keys_for_cooktop():
from tests.conftest import _load_device
resources = _load_device('cooktop')
golden = json.loads((GOLDEN / 'cooktop.json').read_text())
state_keys = _new_state_keys('cooktop', 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_hood():
from tests.conftest import _load_device
resources = _load_device('range_hood')
golden = json.loads((GOLDEN / 'range_hood.json').read_text())
state_keys = _new_state_keys('range_hood', 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_resources_from_batch_preferred_over_flat():
from tests.conftest import _resources_from_dump
dump = {
+109
View File
@@ -0,0 +1,109 @@
"""Tests for the AHD-WW-TP1-22 range-hood profile."""
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.capabilities import range_hood
from custom_components.localthings.registry.discovery import discover
from custom_components.localthings.registry.entities import FanDesc
from tests.conftest import _load_device
def _hood():
resources = _load_device('range_hood')
info = resources['/information/vs/0']
registry = for_device_by_model(
info['x.com.samsung.da.modelNum'],
info['x.com.samsung.da.description'],
)
return registry, resources
def _state():
registry, resources = _hood()
bound = discover(
resources, registry.capabilities, registry.pattern_capabilities,
)
return flatten(bound, resources)
def test_ahd_model_resolves_to_range_hood_registry():
registry, _ = _hood()
assert registry is not None
assert registry.name == 'range_hood'
def test_range_hood_has_no_unbound_hrefs():
registry, resources = _hood()
unbound = []
discover(
resources,
registry.capabilities,
registry.pattern_capabilities,
log=unbound.append,
)
assert unbound == []
def test_range_hood_fixture_values():
state = _state()
assert state['fan'] == '14'
assert state['alarm_code'] == 'none'
assert state['automatic_operation'] is False
assert state['lamp'] is False
assert state['lamp_brightness'] == '2'
assert state['hood_filter_usage'] == 100
assert state['hood_filter_status'] == 'wash'
assert state['energy_kwh'] == 165.62
assert state['clean_level'] == 2
assert state['dust'] == 18
assert state['fine_dust'] == 14
assert state['super_fine_dust'] == 9
assert state['periodic_air_sensing'] is False
assert state['air_sensing_state'] == 'NonProcessing'
assert state['last_air_sensing_level'] == 'Kr2'
def test_one_composite_fan_is_bound():
registry, resources = _hood()
bound = discover(
resources, registry.capabilities, registry.pattern_capabilities,
)
fans = [entity for entity in bound if isinstance(entity.desc, FanDesc)]
assert len(fans) == 1
assert fans[0].href == '/hood/fanspeed/vs/0'
def test_fan_write_contract():
desc = range_hood.HOOD_FAN.entities[0]
rep = {
'x.com.samsung.da.hood.supportedFanSpeed': ['14', '15', '16', '17'],
}
assert desc.write_fn(('power', True), rep) == (
['power', '0'], {'value': True},
)
assert desc.write_fn(('power', False), rep) == (
['power', '0'], {'value': False},
)
assert desc.write_fn(('speed', '16'), rep) == (
['hood', 'fanspeed', 'vs', '0'],
{'x.com.samsung.da.hood.fanSpeed': '16'},
)
assert desc.write_fn(('speed', '99'), rep) is None
def test_lamp_write_contract():
lamp, brightness = range_hood.HOOD_LAMP.entities
rep = {'x.com.samsung.lamp.range': ['1', '2']}
assert lamp.write_fn('On', rep) == (
['hood', 'lamp', 'vs', '0'], {'x.com.samsung.lamp.power': 'On'},
)
assert lamp.write_fn('Off', rep) == (
['hood', 'lamp', 'vs', '0'], {'x.com.samsung.lamp.power': 'Off'},
)
assert brightness.write_fn('1', rep) == (
['hood', 'lamp', 'vs', '0'], {'x.com.samsung.lamp.current': '1'},
)
assert brightness.write_fn('2', rep) == (
['hood', 'lamp', 'vs', '0'], {'x.com.samsung.lamp.current': '2'},
)
assert brightness.write_fn('Unsupported', rep) is None
+61
View File
@@ -0,0 +1,61 @@
"""HA fan-entity mapping tests for the range hood."""
from custom_components.localthings.fan import LocalThingsRangeHoodFan
from custom_components.localthings.registry.by_type import range_hood
from custom_components.localthings.registry.discovery import discover
from custom_components.localthings.registry.entities import FanDesc
from tests.conftest import _load_device
class _FakeCoordinator:
device_serial = 'TEST-HOOD-SERIAL'
device_info = {}
data = {}
def __init__(self, resources):
self.last_resources = resources
def resource(self, href):
return self.last_resources.get(href, {})
def _entity(resources):
bound = discover(
resources,
range_hood.REGISTRY.capabilities,
range_hood.REGISTRY.pattern_capabilities,
)
fan_bound = next(item for item in bound if isinstance(item.desc, FanDesc))
return LocalThingsRangeHoodFan(_FakeCoordinator(resources), fan_bound)
def test_power_off_maps_to_zero_percent_and_four_retained_speeds():
entity = _entity(_load_device('range_hood'))
assert entity.is_on is False
assert entity.speed_count == 4
assert entity.percentage == 0
def test_active_codes_map_to_ordered_percentages():
resources = _load_device('range_hood')
resources['/power/0']['value'] = True
resources['/hood/fanspeed/vs/0'][
'x.com.samsung.da.hood.fanSpeed'
] = '14'
assert _entity(resources).percentage == 25
resources['/hood/fanspeed/vs/0'][
'x.com.samsung.da.hood.fanSpeed'
] = '15'
assert _entity(resources).percentage == 50
resources['/hood/fanspeed/vs/0'][
'x.com.samsung.da.hood.fanSpeed'
] = '16'
assert _entity(resources).percentage == 75
resources['/hood/fanspeed/vs/0'][
'x.com.samsung.da.hood.fanSpeed'
] = '17'
assert _entity(resources).percentage == 100