Merge remote-tracking branch 'upstream/main' into contrib/cooktop-range-hood

# Conflicts:
#	custom_components/localthings/coordinator.py
#	custom_components/localthings/registry/by_type/__init__.py
This commit is contained in:
themaanda committed 2026-07-22 21:59:23 -05:00
commit f7db568a43
17 files changed
+1055 -10

No files matched your search

+39
View File
@@ -0,0 +1,39 @@
{
"state_keys": [
"alarm_code",
"burner_0_hot_surface",
"burner_0_power_level",
"burner_0_state",
"burner_1_hot_surface",
"burner_1_power_level",
"burner_1_state",
"burner_2_hot_surface",
"burner_2_power_level",
"burner_2_state",
"burner_3_hot_surface",
"burner_3_power_level",
"burner_3_state",
"child_lock",
"cloud_connected",
"cook_time",
"cooktop_safety_shutoff_enabled",
"cooktop_state",
"current_temp_c",
"cycle_active",
"door_open",
"fast_preheat",
"finish_time",
"firmware_update",
"lamp",
"machine_state",
"natural_steam",
"operation_time_minutes",
"oven_mode",
"oven_setpoint",
"oven_state",
"power_switch",
"progress_percentage",
"remote_control",
"sound"
]
}
+466
View File
@@ -0,0 +1,466 @@
{
"meta": {
"model": "TP1X_DA-KS-RANGE-0102X (NSI6DG9100SRAA)",
"device_type": "range",
"source": "issue #44 diagnostics (scrubbed)",
"note": "Range/cooktop-oven combo, first dump of this device type. 4 burners reported (numberOfBurners=4)."
},
"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",
"flashingProgress": "",
"otnStatus": "None",
"otnList": [
{
"type": "WIFI",
"modelId": "AKS-WW-TP1-23-OVEN-3",
"versions": [
"40241114"
],
"visVersion": "241114"
},
{
"type": "Micom",
"modelId": "07174047694140477041",
"versions": [
"24052400",
"24012300"
],
"visVersion": "240524"
},
{
"type": "Micom",
"modelId": "071760141141FFFFFFFF",
"versions": [
"24010500",
"FFFFFFFF"
],
"visVersion": "240105"
},
{
"type": "Micom",
"modelId": "071740476841FFFFFFFF",
"versions": [
"23121600",
"FFFFFFFF"
],
"visVersion": "231216"
},
{
"type": "Micom",
"modelId": "07174047484140474941",
"versions": [
"24020500",
"24012600"
],
"visVersion": "240205"
}
]
}
},
{
"href": "/connected/vs/0",
"rep": {
"x.com.samsung.da.connected": "On"
}
},
{
"href": "/doors/vs/0",
"rep": {
"rt": [
"x.com.samsung.da.doors"
],
"if": [
"oic.if.baseline",
"oic.if.s"
],
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "Door",
"x.com.samsung.da.openState": "Close",
"x.com.samsung.da.lock": "Unlock"
}
]
}
},
{
"href": "/information/vs/0",
"rep": {
"x.com.samsung.da.modelNum": "TP1X_DA-KS-RANGE-0102X|40476941|5001011E041811010A00000000000000",
"x.com.samsung.da.description": "NI9100D-/AA0",
"x.com.samsung.da.serialNum": "**REDACTED**",
"x.com.samsung.da.otnDUID": "**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": "24111400",
"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": "04769A24052400, 04770A24012300",
"x.com.samsung.da.newVersionAvailable": "0"
},
{
"x.com.samsung.da.id": "2",
"x.com.samsung.da.description": "Version",
"x.com.samsung.da.type": "Firmware",
"x.com.samsung.da.number": "01411A24010500",
"x.com.samsung.da.newVersionAvailable": "0"
},
{
"x.com.samsung.da.id": "3",
"x.com.samsung.da.description": "Version",
"x.com.samsung.da.type": "Firmware",
"x.com.samsung.da.number": "04768A23121600",
"x.com.samsung.da.newVersionAvailable": "0"
},
{
"x.com.samsung.da.id": "4",
"x.com.samsung.da.description": "Version",
"x.com.samsung.da.type": "Firmware",
"x.com.samsung.da.number": "04748A24020500, 04749A24012600",
"x.com.samsung.da.newVersionAvailable": "0"
}
],
"x.com.samsung.da.diagProtocolType": "BLE_OCF",
"x.com.samsung.da.diagLogType": [
"errCode",
"dump"
],
"x.com.samsung.da.diagDumpType": "file",
"x.com.samsung.da.diagEndPoint": "SSM",
"x.com.samsung.da.diagMnid": "0AJT",
"x.com.samsung.da.diagSetupid": "KR3",
"x.com.samsung.da.diagMinVersion": "3.0",
"x.com.samsung.da.diagTsId": "DA01"
}
},
{
"href": "/mode/vs/0",
"rep": {
"rt": [
"x.com.samsung.da.mode"
],
"if": [
"oic.if.baseline",
"oic.if.a"
],
"x.com.samsung.da.supportedModes": [
"Bake",
"Broil",
"KeepWarm",
"BreadProof",
"SelfClean",
"SteamClean"
],
"x.com.samsung.da.modes": [
"NoOperation"
],
"x.com.samsung.da.modeStateList": [
{
"mode": "Bake",
"state": "Fixed"
},
{
"mode": "Broil",
"state": "Show"
},
{
"mode": "KeepWarm",
"state": "Show"
},
{
"mode": "BreadProof",
"state": "Show"
},
{
"mode": "SelfClean",
"state": "Fixed"
},
{
"mode": "SteamClean",
"state": "Fixed"
}
],
"x.com.samsung.da.options": [
"DeviceType_NI9100D-/AA0",
"SettingPossible_5",
"TimeAutoSync_On",
"UpperLamp_On",
"TimeSystem_12",
"Sound_On",
"AdjustingTemp_0",
"Sabbath_Off",
"EnergySaving_On"
],
"x.com.samsung.da.defaultMode": "Bake",
"x.com.samsung.da.modeSpec": "[{\"mode\":\"Bake\",\"version\":\"0101\",\"default\":\"Default\",\"control\":\"Start&Setting\",\"cavity\":\"Single\",\"tempMinC\":\"80\",\"tempMaxC\":\"285\",\"tempDefaultC\":\"175\",\"tempListLengthC\":\"0\",\"tempMinF\":\"175\",\"tempMaxF\":\"550\",\"tempDefaultF\":\"350\",\"tempListLengthF\":\"0\",\"timeMin\":\"00:01:00\",\"timeMax\":\"09:59:00\",\"timeDefault\":\"01:00:00\",\"probeMinC\":\"40\",\"probeMaxC\":\"90\",\"probeDefaultC\":\"65\",\"probeMinF\":\"100\",\"probeMaxF\":\"200\",\"probeDefaultF\":\"150\",\"powerDefault\":\"NotSupported\",\"powerListLength\":\"0\",\"tempIntervalC\":\"5\",\"tempIntervalF\":\"5\",\"probeIntervalC\":\"1\",\"probeIntervalF\":\"1\"},{\"mode\":\"Broil\",\"version\":\"0101\",\"default\":\"Normal\",\"control\":\"Setting\",\"cavity\":\"Single\",\"tempMinC\":\"61442\",\"tempMaxC\":\"61441\",\"tempDefaultC\":\"61441\",\"tempListLengthC\":\"2\",\"tempListDataC\":[\"61441\",\"61442\"],\"tempMinF\":\"61442\",\"tempMaxF\":\"61441\",\"tempDefaultF\":\"61441\",\"tempListLengthF\":\"2\",\"tempListDataF\":[\"61441\",\"61442\"],\"timeMin\":\"NotSupported\",\"timeMax\":\"NotSupported\",\"timeDefault\":\"NotSupported\",\"probeMinC\":\"NotSupported\",\"probeMaxC\":\"NotSupported\",\"probeDefaultC\":\"NotSupported\",\"probeMinF\":\"NotSupported\",\"probeMaxF\":\"NotSupported\",\"probeDefaultF\":\"NotSupported\",\"powerDefault\":\"NotSupported\",\"powerListLength\":\"0\",\"tempIntervalC\":\"NotSupported\",\"tempIntervalF\":\"NotSupported\",\"probeIntervalC\":\"NotSupported\",\"probeIntervalF\":\"NotSupported\"},{\"mode\":\"KeepWarm\",\"version\":\"0101\",\"default\":\"Normal\",\"control\":\"Setting\",\"cavity\":\"Single\",\"tempMinC\":\"80\",\"tempMaxC\":\"80\",\"tempDefaultC\":\"80\",\"tempListLengthC\":\"1\",\"tempListDataC\":[\"80\"],\"tempMinF\":\"175\",\"tempMaxF\":\"175\",\"tempDefaultF\":\"175\",\"tempListLengthF\":\"1\",\"tempListDataF\":[\"175\"],\"timeMin\":\"NotSupported\",\"timeMax\":\"NotSupported\",\"timeDefault\":\"NotSupported\",\"probeMinC\":\"NotSupported\",\"probeMaxC\":\"NotSupported\",\"probeDefaultC\":\"NotSupported\",\"probeMinF\":\"NotSupported\",\"probeMaxF\":\"NotSupported\",\"probeDefaultF\":\"NotSupported\",\"powerDefault\":\"NotSupported\",\"powerListLength\":\"0\",\"tempIntervalC\":\"NotSupported\",\"tempIntervalF\":\"NotSupported\",\"probeIntervalC\":\"NotSupported\",\"probeIntervalF\":\"NotSupported\"},{\"mode\":\"BreadProof\",\"version\":\"0101\",\"default\":\"Normal\",\"control\":\"Setting\",\"cavity\":\"Single\",\"tempMinC\":\"35\",\"tempMaxC\":\"35\",\"tempDefaultC\":\"35\",\"tempListLengthC\":\"1\",\"tempListDataC\":[\"35\"],\"tempMinF\":\"95\",\"tempMaxF\":\"95\",\"tempDefaultF\":\"95\",\"tempListLengthF\":\"1\",\"tempListDataF\":[\"95\"],\"timeMin\":\"NotSupported\",\"timeMax\":\"NotSupported\",\"timeDefault\":\"NotSupported\",\"probeMinC\":\"NotSupported\",\"probeMaxC\":\"NotSupported\",\"probeDefaultC\":\"NotSupported\",\"probeMinF\":\"NotSupported\",\"probeMaxF\":\"NotSupported\",\"probeDefaultF\":\"NotSupported\",\"powerDefault\":\"NotSupported\",\"powerListLength\":\"0\",\"tempIntervalC\":\"NotSupported\",\"tempIntervalF\":\"NotSupported\",\"probeIntervalC\":\"NotSupported\",\"probeIntervalF\":\"NotSupported\"},{\"mode\":\"SelfClean\",\"version\":\"0101\",\"default\":\"Normal\",\"control\":\"NotSupported\",\"cavity\":\"Single\",\"tempMinC\":\"NotSupported\",\"tempMaxC\":\"NotSupported\",\"tempDefaultC\":\"NotSupported\",\"tempListLengthC\":\"0\",\"tempMinF\":\"NotSupported\",\"tempMaxF\":\"NotSupported\",\"tempDefaultF\":\"NotSupported\",\"tempListLengthF\":\"0\",\"timeMin\":\"NotSupported\",\"timeMax\":\"NotSupported\",\"timeDefault\":\"NotSupported\",\"probeMinC\":\"NotSupported\",\"probeMaxC\":\"NotSupported\",\"probeDefaultC\":\"NotSupported\",\"probeMinF\":\"NotSupported\",\"probeMaxF\":\"NotSupported\",\"probeDefaultF\":\"NotSupported\",\"powerDefault\":\"NotSupported\",\"powerListLength\":\"0\",\"tempIntervalC\":\"NotSupported\",\"tempIntervalF\":\"NotSupported\",\"probeIntervalC\":\"NotSupported\",\"probeIntervalF\":\"NotSupported\"},{\"mode\":\"SteamClean\",\"version\":\"0101\",\"default\":\"Normal\",\"control\":\"NotSupported\",\"cavity\":\"Single\",\"tempMinC\":\"NotSupported\",\"tempMaxC\":\"NotSupported\",\"tempDefaultC\":\"NotSupported\",\"tempListLengthC\":\"0\",\"tempMinF\":\"NotSupported\",\"tempMaxF\":\"NotSupported\",\"tempDefaultF\":\"NotSupported\",\"tempListLengthF\":\"0\",\"timeMin\":\"NotSupported\",\"timeMax\":\"NotSupported\",\"timeDefault\":\"NotSupported\",\"probeMinC\":\"NotSupported\",\"probeMaxC\":\"NotSupported\",\"probeDefaultC\":\"NotSupported\",\"probeMinF\":\"NotSupported\",\"probeMaxF\":\"NotSupported\",\"probeDefaultF\":\"NotSupported\",\"powerDefault\":\"NotSupported\",\"powerListLength\":\"0\",\"tempIntervalC\":\"NotSupported\",\"tempIntervalF\":\"NotSupported\",\"probeIntervalC\":\"NotSupported\",\"probeIntervalF\":\"NotSupported\"}]"
}
},
{
"href": "/operational/state/vs/0",
"rep": {
"rt": [
"x.com.samsung.da.operation"
],
"if": [
"oic.if.baseline",
"oic.if.a"
],
"x.com.samsung.da.state": "Ready",
"causeSource.state": "SETB_Ready",
"x.com.samsung.da.operationTime": "00:00:00",
"x.com.samsung.da.remainingTime": "00:00:00",
"x.com.samsung.da.progressPercentage": "1"
}
},
{
"href": "/alarms/vs/0",
"rep": {
"rt": [
"x.com.samsung.da.alarms"
],
"if": [
"oic.if.baseline",
"oic.if.s"
],
"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": "OV_E_OFF",
"x.com.samsung.da.triggeredTime": "2026-07-22T23:55:41"
},
{
"x.com.samsung.da.id": "1",
"x.com.samsung.da.description": "Alarm",
"x.com.samsung.da.alarmType": "Device",
"x.com.samsung.da.code": "CT_E_OFF",
"x.com.samsung.da.triggeredTime": "2026-07-22T23:55:41"
}
]
}
},
{
"href": "/oven/vs/0",
"rep": {
"rt": [
"x.com.samsung.da.oven"
],
"if": [
"oic.if.baseline",
"oic.if.s"
],
"x.com.samsung.da.state": "Ready"
}
},
{
"href": "/temperatures/vs/0",
"rep": {
"rt": [
"x.com.samsung.da.temperatures"
],
"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.desired": "0",
"x.com.samsung.da.current": "175",
"x.com.samsung.da.increment": "5",
"x.com.samsung.da.unit": "Fahrenheit"
}
]
}
},
{
"href": "/timezone/vs/0",
"rep": {
"timezoneid": "America/Chicago",
"offset": "-05:00",
"DST": "ON"
}
},
{
"href": "/oven/spec/vs/0",
"rep": {
"cavityInfo": {
"numberOfCavity": 1,
"type": "single",
"serviceMainCavity": "single",
"cavityList": [
{
"name": "single",
"flexSupport": false,
"single": {
"resourceId": 0,
"supportedFeatureList": [
""
]
}
}
]
}
}
},
{
"href": "/kidslock/vs/0",
"rep": {
"x.com.samsung.da.kidsLock": "Ready"
}
},
{
"href": "/power/vs/0",
"rep": {
"x.com.samsung.da.power": "On"
}
},
{
"href": "/remotectrl/vs/0",
"rep": {
"x.com.samsung.da.remoteControlEnabled": "false"
}
},
{
"href": "/cooktop/settings/status/vs/0",
"rep": {
"safetyAlert": {
"state": "on",
"settingTime": 1200
}
}
},
{
"href": "/cooktop/spec/vs/0",
"rep": {
"supportedFeatureList": [
""
],
"numberOfBurners": 4,
"supportedBurnerList": [
0,
1,
2,
3
],
"supportedPowerLevelList": [
"1",
"2",
"3",
"4",
"5",
"6",
"7",
"8",
"9",
"boost",
"0",
"simmer"
],
"supportedRecipeProtocolVersion": 0
}
},
{
"href": "/cooktop/status/vs/0",
"rep": {
"power": "off",
"childLock": "off",
"operationState": "ready",
"additionalStateList": [
""
],
"burnerList": [
{
"burnerNumber": 0,
"mode": "normal",
"operationState": "ready",
"powerLevel": "0",
"hotSurfaceState": "normal",
"panDetection": true,
"timer": {
"operationState": "ready",
"cookingTime": 0,
"remainingTime": 0
}
},
{
"burnerNumber": 1,
"mode": "normal",
"operationState": "ready",
"powerLevel": "0",
"hotSurfaceState": "normal",
"panDetection": true,
"timer": {
"operationState": "ready",
"cookingTime": 0,
"remainingTime": 0
}
},
{
"burnerNumber": 2,
"mode": "normal",
"operationState": "ready",
"powerLevel": "0",
"hotSurfaceState": "normal",
"panDetection": true,
"timer": {
"operationState": "ready",
"cookingTime": 0,
"remainingTime": 0
}
},
{
"burnerNumber": 3,
"mode": "normal",
"operationState": "ready",
"powerLevel": "0",
"hotSurfaceState": "normal",
"panDetection": true,
"timer": {
"operationState": "ready",
"cookingTime": 0,
"remainingTime": 0
}
}
]
}
},
{
"href": "/wirelessinfo/vs/0",
"rep": {
"macaddressWiFi": "**REDACTED**",
"macaddressBLE": "**REDACTED**"
}
},
{
"href": "/quickcontrol/info/vs/0",
"rep": {
"supportedVersion": "1.0"
}
}
]
}
+138
View File
@@ -7,12 +7,16 @@ from unittest.mock import AsyncMock, patch
import cbor2
import pytest
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import issue_registry as ir
from custom_components.localthings.const import (
CONF_HOST, DOMAIN, SUMMARY_INTERVAL_S,
)
from custom_components.localthings.coordinator import LocalThingsCoordinator
from custom_components.localthings.registry.capabilities.common import (
remote_control_enabled,
)
from custom_components.localthings.observe import MODE_OBSERVE, MODE_POLL, PUSH_HEALTH_WINDOW_S
from .conftest import ENTRY_DATA, MOCK_SERIAL
@@ -659,3 +663,137 @@ async def test_write_marks_href_pending_before_post(
await coordinator.async_send_command(bound, 5)
assert coordinator._observe._settle_until.get('/test/vs/0') is not None
class TestRemoteControlEnabled:
"""remote_control_enabled (registry/capabilities/common.py) is the
single source of truth for the /remotectrl on/off signal, shared by
the Smart Control binary_sensor descriptors and the coordinator's
write guard alike."""
def test_vs_fallback_href_true_is_enabled(self):
resources = {'/remotectrl/vs/0': {'x.com.samsung.da.remoteControlEnabled': 'true'}}
assert remote_control_enabled(resources) is True
def test_vs_fallback_href_false_is_disabled(self):
resources = {'/remotectrl/vs/0': {'x.com.samsung.da.remoteControlEnabled': 'false'}}
assert remote_control_enabled(resources) is False
def test_generic_href_true_is_enabled(self):
resources = {'/remotectrl/0': {'value': True}}
assert remote_control_enabled(resources) is True
def test_generic_href_false_is_disabled(self):
resources = {'/remotectrl/0': {'value': False}}
assert remote_control_enabled(resources) is False
def test_generic_href_wins_when_both_present(self):
resources = {
'/remotectrl/0': {'value': True},
'/remotectrl/vs/0': {'x.com.samsung.da.remoteControlEnabled': 'false'},
}
assert remote_control_enabled(resources) is True
def test_assumes_enabled_when_capability_absent(self):
assert remote_control_enabled({}) is True
async def test_send_command_blocked_when_remote_control_disabled(
hass: HomeAssistant, mock_entry, mock_coordinator_observe_session
) -> None:
"""A device reporting remote control off must reject every write with
a user-facing message, ahead of write_fn's silent-no-op path and any
description-level validate_fn."""
from custom_components.localthings.registry.discovery import BoundEntity
from custom_components.localthings.registry.entities import NumberDesc
fake = mock_coordinator_observe_session
await hass.config_entries.async_setup(mock_entry.entry_id)
await hass.async_block_till_done()
coordinator: LocalThingsCoordinator = hass.data[DOMAIN][mock_entry.entry_id]
coordinator._cache.apply_rep(
'/remotectrl/vs/0',
{'x.com.samsung.da.remoteControlEnabled': 'false'},
source='test',
)
def _write_fn(payload, rep, href):
return (['some', 'path'], {'value': payload})
desc = NumberDesc(key='test', field='value', write_fn=_write_fn)
bound = BoundEntity(href='/test/vs/0', capability=coordinator.bound[0].capability, desc=desc)
posted = False
def _post(*a, **k):
nonlocal posted
posted = True
return (0x44, b'')
with patch.object(fake, 'subscribe'):
fake.post = _post
with pytest.raises(ServiceValidationError, match="Remote control is turned off"):
await coordinator.async_send_command(bound, 5)
assert posted is False
async def test_send_command_allowed_when_remote_control_enabled(
hass: HomeAssistant, mock_entry, mock_coordinator_observe_session
) -> None:
"""The same write goes through once remote control reports enabled."""
from custom_components.localthings.registry.discovery import BoundEntity
from custom_components.localthings.registry.entities import NumberDesc
fake = mock_coordinator_observe_session
await hass.config_entries.async_setup(mock_entry.entry_id)
await hass.async_block_till_done()
coordinator: LocalThingsCoordinator = hass.data[DOMAIN][mock_entry.entry_id]
coordinator._cache.apply_rep(
'/remotectrl/vs/0',
{'x.com.samsung.da.remoteControlEnabled': 'true'},
source='test',
)
def _write_fn(payload, rep, href):
return (['some', 'path'], {'value': payload})
desc = NumberDesc(key='test', field='value', write_fn=_write_fn)
bound = BoundEntity(href='/test/vs/0', capability=coordinator.bound[0].capability, desc=desc)
with patch.object(fake, 'subscribe'):
fake.post = lambda *a, **k: (0x44, b'')
await coordinator.async_send_command(bound, 5)
assert coordinator._observe._settle_until.get('/test/vs/0') is not None
async def test_send_command_remote_control_check_precedes_validate_fn(
hass: HomeAssistant, mock_entry, mock_coordinator_observe_session
) -> None:
"""When both a disabled remote-control state and a failing validate_fn
apply to the same write, the remote-control message wins -- it's the
more actionable of the two and is checked first."""
from custom_components.localthings.registry.discovery import BoundEntity
from custom_components.localthings.registry.entities import SwitchDesc
await hass.config_entries.async_setup(mock_entry.entry_id)
await hass.async_block_till_done()
coordinator: LocalThingsCoordinator = hass.data[DOMAIN][mock_entry.entry_id]
coordinator._cache.apply_rep(
'/remotectrl/vs/0',
{'x.com.samsung.da.remoteControlEnabled': 'false'},
source='test',
)
def _write_fn(payload, rep, href=None):
return (['some', 'path'], {'value': payload})
def _validate_fn(payload, rep, resources):
return "always rejected"
desc = SwitchDesc(key='test', field='value', write_fn=_write_fn, validate_fn=_validate_fn)
bound = BoundEntity(href='/test/vs/0', capability=coordinator.bound[0].capability, desc=desc)
with pytest.raises(ServiceValidationError, match="Remote control is turned off"):
await coordinator.async_send_command(bound, 'On')
+8
View File
@@ -84,6 +84,14 @@ class TestRemoteControlFallback:
assert common.REMOTE_CONTROL_VS_FALLBACK.match_fn(
{}, {'/remotectrl/0': {}, '/remotectrl/vs/0': {}}) is False
def test_polled_warm_so_write_gating_stays_fresh(self):
"""coordinator.async_send_command blocks writes on this signal, so
it can't sit in the default 'cold' tier (refreshed only once per
30s summary poll) -- it needs the subscribe/subpoll cadence 'warm'
and 'hot' hrefs get instead."""
assert common.REMOTE_CONTROL_GENERIC.poll_tier == 'warm'
assert common.REMOTE_CONTROL_VS_FALLBACK.poll_tier == 'warm'
# ---------------------------------------------------------------------------
# Energy meter. instantaneousPower clamps negatives to 0, but the constant
+17
View File
@@ -227,6 +227,23 @@ def test_registry_reproduces_golden_state_keys_for_tp1x_rac():
)
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
fallback in for_device_by_model. Reuses the oven family's cavity/
setpoint/mode/operational-state capabilities and adds range.py's
per-burner capabilities for the 4 burners this dump reports."""
from tests.conftest import _load_device
resources = _load_device('range')
golden = json.loads((GOLDEN / 'range.json').read_text())
state_keys = _new_state_keys('range', 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 = {
+40
View File
@@ -53,6 +53,46 @@ def test_oven_setpoint_rejects_missing_items():
assert desc.write_fn(200, {}) is None
# ---------------------------------------------------------------------------
# OVEN_SETPOINT — Fahrenheit bounds (issue #44 range dump reports
# unit="Fahrenheit"; bounds/step must track the live unit, not stay pinned
# to the Celsius defaults)
# ---------------------------------------------------------------------------
def _fahrenheit_rep(desired='0'):
return {'x.com.samsung.da.items': [{
'x.com.samsung.da.desired': desired,
'x.com.samsung.da.unit': 'Fahrenheit',
}]}
def test_oven_setpoint_write_uses_fahrenheit_bounds():
desc = oven.OVEN_SETPOINT.entities[0]
rep = _fahrenheit_rep()
# 350 is within F bounds (175-550) but above the C max (270) --
# confirms the write path isn't silently still clamping to Celsius.
_, body = desc.write_fn(350, rep)
assert body['x.com.samsung.da.items'][0]['x.com.samsung.da.desired'] == '350'
def test_oven_setpoint_rejects_out_of_range_fahrenheit():
desc = oven.OVEN_SETPOINT.entities[0]
rep = _fahrenheit_rep()
assert desc.write_fn(100, rep) is None # below F min (175)
assert desc.write_fn(600, rep) is None # above F max (550)
def test_oven_setpoint_native_bounds_track_live_unit():
desc = oven.OVEN_SETPOINT.entities[0]
celsius_rep = {'x.com.samsung.da.items': [{'x.com.samsung.da.unit': 'Celsius'}]}
assert desc.native_min_fn(celsius_rep) == 30.0
assert desc.native_max_fn(celsius_rep) == 270.0
fahrenheit_rep = _fahrenheit_rep()
assert desc.native_min_fn(fahrenheit_rep) == 175.0
assert desc.native_max_fn(fahrenheit_rep) == 550.0
assert desc.step_fn(fahrenheit_rep) == 5.0
# ---------------------------------------------------------------------------
# OVEN_MODE — SelectDesc with non-empty options
# ---------------------------------------------------------------------------
+84
View File
@@ -0,0 +1,84 @@
"""Tests for range/cooktop-oven combo support (issue #44)."""
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 as range_caps
from custom_components.localthings.registry.discovery import discover
from tests.conftest import _load_device
def _range():
resources = _load_device('range')
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 _state():
reg, resources = _range()
bound = discover(resources, reg.capabilities, reg.pattern_capabilities)
return flatten(bound, resources)
def test_range_model_resolves_to_range_registry():
reg, _ = _range()
assert reg is not None and reg.name == 'range'
def test_no_unbound_hrefs():
"""Every resource in the issue #44 dump binds or is ignored -- clears the
coverage-gap repair."""
reg, resources = _range()
unbound = []
discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append)
assert unbound == []
def test_expected_entities_present():
state = _state()
for key in (
'power_switch', 'oven_setpoint', 'current_temp_c', 'oven_mode',
'machine_state', 'door_open', 'cloud_connected', 'cooktop_state',
'cooktop_safety_shutoff_enabled',
'burner_0_power_level', 'burner_0_state', 'burner_0_hot_surface',
'burner_3_power_level', 'burner_3_state', 'burner_3_hot_surface',
):
assert key in state, key
def test_unreported_burners_gated_out():
"""Dump reports numberOfBurners=4 (indices 0-3) -- burner slots 4/5 of
MAX_BURNERS must not appear as entities."""
state = _state()
for key in ('burner_4_power_level', 'burner_5_power_level'):
assert key not in state, key
def test_burner_power_level_write_is_read_modify_write():
desc = next(e for e in range_caps.COOKTOP_STATUS.entities
if e.key == 'burner_1_power_level')
rep = {'burnerList': [
{'burnerNumber': 0, 'powerLevel': '3'},
{'burnerNumber': 1, 'powerLevel': '0'},
]}
path, body = desc.write_fn('boost', rep)
assert path == ['cooktop', 'status', 'vs', '0']
burners = {b['burnerNumber']: b['powerLevel'] for b in body['burnerList']}
assert burners[1] == 'boost'
assert burners[0] == '3' # sibling burner untouched
def test_burner_power_level_write_rejects_missing_burner():
desc = next(e for e in range_caps.COOKTOP_STATUS.entities
if e.key == 'burner_2_power_level')
rep = {'burnerList': [{'burnerNumber': 0, 'powerLevel': '0'}]}
assert desc.write_fn('5', rep) is None
def test_burner_hot_surface_true_when_not_normal():
desc = next(e for e in range_caps.COOKTOP_STATUS.entities
if e.key == 'burner_0_hot_surface')
assert desc.value_fn([{'burnerNumber': 0, 'hotSurfaceState': 'hot'}]) is True
assert desc.value_fn([{'burnerNumber': 0, 'hotSurfaceState': 'normal'}]) is False