Add standalone induction-cooktop support (issue #86)

TP1X_DA-KS-COOKTOP-01011 (NV8500T-/KO4) is the same board family and
/cooktop/status/vs/0 resource shape as issue #44's range combo, minus
the oven -- but its modelNum uses the hyphenated '-COOKTOP-' token,
which for_device_by_model's existing '_COOKTOP' check (underscore-
delimited, matching the unrelated NA9300K gas-cooktop family in
cooktop.py) doesn't match. The device fell back to 'unknown' with only
energy/alarms from the global fallback.

Add the hyphenated token check, routing to a new 'induction_cooktop'
registry (by_type/induction_cooktop.py) that reuses range.py's
COOKTOP_STATUS/COOKTOP_SPEC/COOKTOP_SAFETY/PROBE_STATUS and
cooktop.PAIRED_HOOD_STATUS wholesale rather than pulling in range.py's
oven capabilities, which this device has no hrefs for at all.

Along the way, three fields the reporter asked for turned out to be
gaps in the shared range.py capability itself, not just missing
routing -- also present (and previously unmodeled) on issue #44's
original combo-range dump:
- /cooktop/status/vs/0's own `power` and `childLock` fields (distinct
  from common.POWER's /power/0 or /power/vs/0, which a combo range
  additionally carries for the whole appliance) -- childLock gets a
  write_fn (a safe lock toggle, direct single-field PUT), power stays
  read-only (no live device to confirm a remote write wouldn't leave a
  burner active unattended).
- Each burner's `panDetection` field.

New: a Bluetooth meat-probe capability for /bluetooth/probe/status/vs/0
(read-only -- connection, battery, current/target temperature), and
/cooktop/recipe/status/vs/0 is ignored (every field empty on this idle
dump, same treatment as the microwave family's /recipe/cook/vs/0).

Regenerates the range golden fixture (gains cooktop_power/
cooktop_child_lock/burner_N_pan_detected) and adds a dedicated fixture
for the standalone cooktop.
This commit is contained in:
Marc Billow committed 2026-07-27 01:12:16 +00:00
1 parent aff7647460
commit a453dc9ece
13 files changed
+574 -2

No files matched your search

@@ -3,7 +3,7 @@ from typing import Optional
from ._base import DeviceRegistry
from . import (
air_purifier, airconditioner, cooktop, dishwasher, dryer, oven,
air_purifier, airconditioner, cooktop, dishwasher, dryer, induction_cooktop, oven,
range as _range, range_hood, refrigerator, washer,
)
@@ -21,6 +21,7 @@ _REGISTRY_BY_KEY: dict[str, DeviceRegistry] = {
'cooktop': cooktop.REGISTRY,
'dishwasher': dishwasher.REGISTRY,
'dryer': dryer.REGISTRY,
'induction_cooktop': induction_cooktop.REGISTRY,
'oven': oven.REGISTRY,
'hood': range_hood.REGISTRY,
'range': _range.REGISTRY,
@@ -141,6 +142,16 @@ def for_device_by_model(model_num: str, description: str) -> Optional[DeviceRegi
# oneUiVersion and doesn't match the washer/dryer/dishwasher prefix map.
if key is None and '-OVEN-' in (model_num or '').upper():
key = 'oven'
# Standalone induction cooktops (e.g. TP1X_DA-KS-COOKTOP-01011, issue
# #86) -- same board family and '/cooktop/status/vs/0' resource shape
# as the range combo above, but no oven attached at all. Distinct from
# the '_COOKTOP'/'_GB_CT_' underscore-delimited check above: that one
# matches a different, older gas-cooktop family (cooktop.py's NA9300K
# class, burner state embedded in /mode/vs/0's options array) whose
# modelNum token is underscore-delimited, not hyphenated like this
# board family's.
if key is None and '-COOKTOP-' in (model_num or '').upper():
key = 'induction_cooktop'
return _REGISTRY_BY_KEY.get(key) if key else None
@@ -0,0 +1,30 @@
"""Standalone induction-cooktop device registry (Samsung
TP1X_DA-KS-COOKTOP-class, issue #86) -- the cooktop half of the
range/oven board family (see capabilities/range.py), but with no oven
attached at all.
Reuses range.py's COOKTOP_STATUS/COOKTOP_SPEC/COOKTOP_SAFETY/PROBE_STATUS
wholesale (identical resource shapes to the range combo's cooktop half)
and cooktop.PAIRED_HOOD_STATUS for the Bluetooth-paired range hood some
units pair with. Distinct registry key from cooktop.REGISTRY ('cooktop')
-- that family is the unrelated NA9300K-class gas cooktop (burner state
embedded in /mode/vs/0's options array, a completely different OCF
surface that happens to share the English word "cooktop").
"""
from ..capabilities import common, cooktop as cooktop_caps, ignored
from ..capabilities import range as range_caps
from ._base import DeviceRegistry, _build
REGISTRY = DeviceRegistry(
name='induction_cooktop',
capabilities=_build([
*ignored.IGNORED,
*common.UNIVERSAL,
*common.POWER,
range_caps.COOKTOP_STATUS,
range_caps.COOKTOP_SPEC,
range_caps.COOKTOP_SAFETY,
range_caps.PROBE_STATUS,
cooktop_caps.PAIRED_HOOD_STATUS,
]),
)
@@ -131,4 +131,12 @@ IGNORED: list[Capability] = [
# parallel write-capable capability around an unverified generic OCF
# write contract.
Capability(href='/operational/state/0'),
# Cooktop guided-cooking/recipe status (issue #86, TP1X_DA-KS-COOKTOP
# family): sequenceNumber, operationBurnerNumber, a stageInfo block, and
# a textData.menu string -- every field empty/zero on the only dump
# seen so far (device idle, no guided-cooking program active). Same
# "don't guess" treatment as the microwave family's /recipe/cook/vs/0.
# Revisit if a dump with an active recipe surfaces.
Capability(href='/cooktop/recipe/status/vs/0'),
]
@@ -24,7 +24,8 @@ write pattern already proven safe elsewhere in this codebase (oven setpoint,
icemaker toggles).
"""
from ..capability import Capability
from ..entities import BinarySensorDesc, SelectDesc, SensorDesc
from ..entities import BinarySensorDesc, SelectDesc, SensorDesc, SwitchDesc
from .common import normalize_temp_unit
# Observed as high as 4 (this issue's dump); user-reported hardware with 5
# burners exists. Kept a little above both since exists_fn gates unused
@@ -52,6 +53,10 @@ def _burner_hot_surface_fn(i):
return lambda burner_list: get_state(burner_list) not in (None, 'normal')
def _burner_pan_detection_fn(i):
return lambda burner_list: bool(_burner_field_fn(i, 'panDetection')(burner_list))
def _power_level_options(resources):
spec = resources.get('/cooktop/spec/vs/0') or {}
return list(spec.get('supportedPowerLevelList') or [])
@@ -100,15 +105,44 @@ def _burner_entities(i):
translation_placeholders={'number': str(n)},
exists_fn=exists,
value_fn=_burner_hot_surface_fn(i)),
BinarySensorDesc(key=f'burner_{i}_pan_detected', field='burnerList',
icon='mdi:pot',
translation_key='burner_pan_detected',
translation_placeholders={'number': str(n)},
entity_category='diagnostic',
exists_fn=exists,
value_fn=_burner_pan_detection_fn(i)),
)
def _child_lock_write(p, rep, href=None):
if p not in ('On', 'Off'):
return None
return ['cooktop', 'status', 'vs', '0'], {'childLock': p.lower()}
COOKTOP_STATUS = Capability(
href='/cooktop/status/vs/0',
poll_tier='hot',
entities=(
SensorDesc(key='cooktop_state', field='operationState',
icon='mdi:pot-steam'),
# The cooktop section's own on/off (issue #86) -- distinct from
# common.POWER's /power/0 or /power/vs/0, which some boards in this
# family (the range combo) additionally carry for the whole
# appliance. Read-only: no live device to confirm remotely turning
# a cooktop on wouldn't leave a burner active unattended.
BinarySensorDesc(key='cooktop_power', field='power',
device_class='power', icon='mdi:pot-steam',
value_fn=lambda v: str(v).lower() == 'on'),
# Safe to write -- a lock toggle, not a heat control -- via a
# direct single-field PUT (no RMW needed; unlike burnerList this
# is a lone scalar, not an array of siblings to preserve).
SwitchDesc(key='cooktop_child_lock', field='childLock',
device_class='lock', entity_category='config',
icon='mdi:lock',
value_fn=lambda v: str(v).lower() == 'on',
write_fn=_child_lock_write),
*[e for i in range(MAX_BURNERS) for e in _burner_entities(i)],
),
)
@@ -133,3 +167,27 @@ COOKTOP_SAFETY = Capability(
value_fn=lambda v: (v or {}).get('state') == 'on'),
),
)
# Bluetooth meat probe (issue #86). All-idle sentinel values when
# disconnected (operationBurnerNumber -1, temperatures 0) -- no special
# gating on those, matching cooktop.PAIRED_HOOD_STATUS's own precedent of
# showing a disconnected accessory's fields plainly rather than hiding the
# whole capability.
PROBE_STATUS = Capability(
href='/bluetooth/probe/status/vs/0',
poll_tier='warm',
entities=(
BinarySensorDesc(key='probe_connected', field='connectionState',
device_class='connectivity',
value_fn=lambda v: str(v).lower() == 'connected'),
SensorDesc(key='probe_battery', field='batteryPercentage',
device_class='battery', state_class='measurement',
unit='%', entity_category='diagnostic'),
SensorDesc(key='probe_temperature', field='currentTemperature',
device_class='temperature', state_class='measurement',
unit_fn=lambda rep: normalize_temp_unit(rep.get('temperatureUnit'), '°C')),
SensorDesc(key='probe_target_temperature', field='targetTemperature',
device_class='temperature', entity_category='diagnostic',
unit_fn=lambda rep: normalize_temp_unit(rep.get('temperatureUnit'), '°C')),
),
)
@@ -10,9 +10,15 @@
"burner_hot_surface": {
"name": "Burner {number} hot surface"
},
"burner_pan_detected": {
"name": "Burner {number} pan detected"
},
"cloud_connected": {
"name": "Cloud connected"
},
"cooktop_power": {
"name": "Cooktop power"
},
"cooktop_safety_shutoff_enabled": {
"name": "Hot surface auto-shutoff enabled"
},
@@ -55,6 +61,9 @@
"power_state": {
"name": "Power state"
},
"probe_connected": {
"name": "Probe connected"
},
"rapid_freezing": {
"name": "Rapid freezing"
},
@@ -574,6 +583,15 @@
"power_watts": {
"name": "Power"
},
"probe_battery": {
"name": "Probe battery"
},
"probe_target_temperature": {
"name": "Probe target temperature"
},
"probe_temperature": {
"name": "Probe temperature"
},
"progress": {
"name": "Progress"
},
@@ -650,6 +668,9 @@
"child_lock": {
"name": "Child lock"
},
"cooktop_child_lock": {
"name": "Child lock"
},
"defrost_delay": {
"name": "Defrost delay"
},
@@ -10,9 +10,15 @@
"burner_hot_surface": {
"name": "Brander {number} heet oppervlak"
},
"burner_pan_detected": {
"name": "Brander {number} pan gedetecteerd"
},
"cloud_connected": {
"name": "Verbonden met cloud"
},
"cooktop_power": {
"name": "Kookplaat voeding"
},
"cooktop_safety_shutoff_enabled": {
"name": "Automatische uitschakeling heet oppervlak ingeschakeld"
},
@@ -55,6 +61,9 @@
"power_state": {
"name": "Voedingsstatus"
},
"probe_connected": {
"name": "Sonde verbonden"
},
"rapid_freezing": {
"name": "Snelvriezen"
},
@@ -574,6 +583,15 @@
"power_watts": {
"name": "Vermogen"
},
"probe_battery": {
"name": "Sonde batterij"
},
"probe_target_temperature": {
"name": "Doeltemperatuur sonde"
},
"probe_temperature": {
"name": "Temperatuur sonde"
},
"progress": {
"name": "Voortgang"
},
@@ -650,6 +668,9 @@
"child_lock": {
"name": "Kinderslot"
},
"cooktop_child_lock": {
"name": "Kinderslot"
},
"defrost_delay": {
"name": "Ontdooien uitstellen"
},