feat: fridge entity improvements, CoAP reliability fixes, time platform

Fridge entities:
- Add strip_prefix_in_key to pattern caps; rename temp entities to
  "Cooler/Freezer Temperature" and "Cooler/Freezer Setpoint"
- Add number sliders (NumberMode.SLIDER)
- Remove redundant sensors/binary_sensors that shadow switches
- Add entity_category='config' to setpoints, refrigeration modes,
  autofill, sabbath, sound, LED, flex zone, nighttime ice, ice type,
  beverage zone
- Add WELCOME_LIGHTING, STATUS_LOCK (auto door opener + fridge sound),
  DOOR_ALERT (alarm tone select 1-4), CABINET_LIGHT_ENHANCED (daytime
  brightness, night brightness, brighten gradually, start/end time),
  ICEMAKER_NIGHTTIME, FLEX_ZONE
- Add time platform with rep_fn support for derived end-time entity;
  apply timezone.offset when reading/writing night schedule times
- Fix ice_making_status translation: move to entity.sensor section

CoAP/DTLS reliability:
- Fix Block2 SZX negotiation: adopt server's block size for block 1+
- Add per-block retransmission (3 attempts, 4s each)
- Add 5s asyncio.sleep before DTLS reconnect for TizenRT session teardown

Translations:
- Add HA translation_key system for ice type, beverage zone, flex zone,
  night brightness, door alarm; remove Python-level value_fn translation
This commit is contained in:
Marc Billow
2026-06-30 16:34:39 -05:00
parent 98fa7196f2
commit da573a725f
14 changed files with 468 additions and 83 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
DOMAIN = "localthings"
PLATFORMS = ["sensor", "binary_sensor", "switch", "number", "select", "button"]
PLATFORMS = ["sensor", "binary_sensor", "switch", "number", "select", "button", "time"]
CONF_HOST = "host"
CONF_PORT = "port"
+5 -2
View File
@@ -1,6 +1,7 @@
"""Coordinator for Local Things integration."""
from __future__ import annotations
import asyncio
import logging
from datetime import timedelta
from typing import Any
@@ -158,10 +159,12 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
try:
resources = await self.hass.async_add_executor_job(self._poll_once)
except Exception as e:
# One reconnect attempt
# One reconnect attempt — pause briefly so the device can
# clean up its DTLS session state before we knock again.
_LOGGER.warning("poll failed, reconnecting: %s", e)
await self.hass.async_add_executor_job(self._close_session)
await asyncio.sleep(5.0)
try:
await self.hass.async_add_executor_job(self._close_session)
resources = await self.hass.async_add_executor_job(self._poll_once)
except Exception as e2:
raise UpdateFailed(f"poll failed after reconnect: {e2}") from e2
+1
View File
@@ -35,6 +35,7 @@ class LocalThingsEntity(CoordinatorEntity[LocalThingsCoordinator]):
self._state_key = _key(bound)
self._attr_unique_id = f"{DOMAIN}_{coordinator.device_serial}_{self._state_key}"
self._attr_name = bound.desc.name if bound.desc.name is not None else _derive_name(self._state_key)
self._attr_translation_key = bound.desc.translation_key
self._attr_icon = bound.desc.icon
raw_cat = bound.desc.entity_category
self._attr_entity_category = EntityCategory(raw_cat) if raw_cat else None
+61 -36
View File
@@ -96,6 +96,12 @@ OBSERVE_DEREGISTER = bytes([1]) # deregister
# will honour and the only one the probes have validated end-to-end.
BLOCK_SZX = 6
# Per-block retransmission: send up to this many times before giving up.
# Each attempt waits at most _BLOCK_ACK_TIMEOUT seconds (capped by the
# overall deadline). Matches RFC 7252 CON retransmit behaviour.
_BLOCK_MAX_ATTEMPTS = 3
_BLOCK_ACK_TIMEOUT = 4.0
def _vlen(v):
"""Variable-length integer encoder used in option deltas + lengths."""
@@ -565,50 +571,62 @@ class DtlsCoapSession:
)
blob = b''
num = 0
szx = BLOCK_SZX # server may negotiate down; track per-transfer
last_code = None
last_opts = []
deadline = time.time() + timeout
while True:
ev = threading.Event()
container = {}
self._pending[tok] = (ev, container)
try:
mid = self._next_mid()
opts = [(URI_PATH, s.encode()) for s in path_segs]
for q in query:
opts.append((URI_QUERY, q.encode()))
opts.append((ACCEPT, CF_CBOR))
if num > 0:
opts.append((BLOCK2, block_value(num, 0, BLOCK_SZX)))
self._send_dgram(
build_coap(TYPE_CON, METHOD_GET, mid, tok, opts))
wait = max(0.1, deadline - time.time())
if not ev.wait(wait):
for attempt in range(_BLOCK_MAX_ATTEMPTS):
ev = threading.Event()
container = {}
self._pending[tok] = (ev, container)
try:
mid = self._next_mid()
opts = [(URI_PATH, s.encode()) for s in path_segs]
for q in query:
opts.append((URI_QUERY, q.encode()))
opts.append((ACCEPT, CF_CBOR))
if num > 0:
opts.append((BLOCK2, block_value(num, 0, szx)))
self._send_dgram(
build_coap(TYPE_CON, METHOD_GET, mid, tok, opts))
per_wait = min(_BLOCK_ACK_TIMEOUT,
max(0.1, deadline - time.time()))
if ev.wait(per_wait):
break # got a response
remaining = deadline - time.time()
if remaining <= 0 or attempt == _BLOCK_MAX_ATTEMPTS - 1:
logger.debug(
"GET /%s block %d: timed out after %d attempt(s)",
'/'.join(path_segs), num, attempt + 1,
)
raise TimeoutError(
f"GET /{'/'.join(path_segs)} block {num} timeout")
logger.debug(
"GET /%s block %d: timed out after %.2fs",
'/'.join(path_segs), num, wait,
)
raise TimeoutError(
f"GET /{'/'.join(path_segs)} block {num} timeout")
code_seen = container.get('code', 0)
# 4.01 = OCF cert/ACL rejection — surface at WARNING
# so it's visible without debug logging.
if code_seen == 0x81:
logger.warning(
"GET /%s → 4.01 Unauthorized",
'/'.join(path_segs),
)
else:
logger.debug(
"GET /%s block %d: %s (%d bytes)",
"GET /%s block %d: attempt %d/%d timeout, retrying",
'/'.join(path_segs), num,
fmt_code(code_seen),
len(container.get('payload', b'')),
attempt + 1, _BLOCK_MAX_ATTEMPTS,
)
if 'err' in container:
raise ConnectionError(container['err'])
finally:
self._pending.pop(tok, None)
finally:
self._pending.pop(tok, None)
code_seen = container.get('code', 0)
# 4.01 = OCF cert/ACL rejection — surface at WARNING
# so it's visible without debug logging.
if code_seen == 0x81:
logger.warning(
"GET /%s → 4.01 Unauthorized",
'/'.join(path_segs),
)
else:
logger.debug(
"GET /%s block %d: %s (%d bytes)",
'/'.join(path_segs), num,
fmt_code(code_seen),
len(container.get('payload', b'')),
)
if 'err' in container:
raise ConnectionError(container['err'])
code = container['code']
payload = container['payload']
@@ -625,6 +643,13 @@ class DtlsCoapSession:
if b2:
bv = int.from_bytes(b2[0], 'big')
more = (bv >> 3) & 1
server_szx = bv & 0x07
if server_szx != szx:
logger.debug(
"GET /%s: server negotiated SZX %d→%d (%d-byte blocks)",
'/'.join(path_segs), szx, server_szx, 1 << (server_szx + 4),
)
szx = server_szx
if not more:
break
num += 1
@@ -14,12 +14,13 @@ def flatten(bound: list[BoundEntity], resources: dict) -> dict[str, Any]:
"""Map bound entities to their current scalar values."""
out: dict[str, Any] = {}
for b in bound:
if not b.desc.field:
continue
rep = resources.get(b.href) or {}
if b.desc.exists_fn is not None and not b.desc.exists_fn(rep):
continue
out[_key(b)] = b.desc.value_fn(rep.get(b.desc.field))
if b.desc.rep_fn is not None:
out[_key(b)] = b.desc.rep_fn(rep)
elif b.desc.field:
out[_key(b)] = b.desc.value_fn(rep.get(b.desc.field))
return out
@@ -6,15 +6,20 @@ REGISTRY = DeviceRegistry(
name='refrigerator',
capabilities=_build([
common.POWER,
fridge.STATUS_LOCK,
fridge.DOOR_ALERT,
common.KIDS_LOCK,
common.REMOTE_CONTROL,
common.ALARMS,
common.ENERGY_METER,
common.WATER_FILTER,
fridge.DOORS_STATUS,
fridge.ICEMAKER_NIGHTTIME,
fridge.FLEX_ZONE,
fridge.REFRIGERATION,
fridge.AUTOFILL,
fridge.WELCOME_LIGHTING,
fridge.CABINET_LIGHT,
fridge.CABINET_LIGHT_ENHANCED,
fridge.SABBATH,
fridge.BEVERAGE_ZONE,
fridge.FIRMWARE_UPDATE,
@@ -48,9 +48,6 @@ KIDS_LOCK = Capability(
BinarySensorDesc(key='child_lock', field='x.com.samsung.da.kidsLock',
name='Child lock', device_class='lock',
value_fn=lambda v: v != 'Ready'),
BinarySensorDesc(key='child_lock_binary', field='x.com.samsung.da.kidsLock',
name='Child lock (binary)', device_class='lock',
value_fn=lambda v: v != 'Ready'),
),
)
@@ -61,10 +58,6 @@ REMOTE_CONTROL = Capability(
field='x.com.samsung.da.remoteControlEnabled',
name='Remote control', device_class='connectivity',
value_fn=lambda v: str(v).lower() == 'true'),
BinarySensorDesc(key='remote_control_binary',
field='x.com.samsung.da.remoteControlEnabled',
name='Remote control (binary)', device_class='connectivity',
value_fn=lambda v: str(v).lower() == 'true'),
),
)
@@ -75,11 +68,6 @@ POWER = Capability(
name='Power',
value_fn=lambda v: v == 'On',
write_fn=lambda p, rep, href=None: (['power', 'vs', '0'], {'x.com.samsung.da.power': 'On' if p == 'On' else 'Off'})),
SensorDesc(key='power_state', field='x.com.samsung.da.power',
name='Power state'),
BinarySensorDesc(key='power_state_binary', field='x.com.samsung.da.power',
name='Power state (binary)',
value_fn=lambda v: v == 'On'),
),
)
@@ -11,13 +11,15 @@ Multi-instance note: the two door resources (/door/cooler/0 and
/icemaker/two/vs/0) use named path segments, so they are modelled via
pattern capabilities that auto-derive distinct entity keys from href segments.
"""
import datetime
from ..capability import Capability
from ..entities import (
BinarySensorDesc, NumberDesc, SelectDesc, SensorDesc, SwitchDesc,
BinarySensorDesc, NumberDesc, SelectDesc, SensorDesc, SwitchDesc, TimeDesc,
)
# Beverage zone flex modes.
BZONE_MODES = ('SP_TTYPE_BEER_DRINKS', 'SP_TTYPE_WINE_DESSERT')
_BZONE_MODES = ('SP_TTYPE_BEER_DRINKS', 'SP_TTYPE_WINE_DESSERT')
def _int(v):
@@ -53,7 +55,7 @@ TEMP_SETPOINT_GENERIC = Capability(
NumberDesc(key='setpoint', field='temperature',
name=None, device_class='temperature', unit='°F',
native_min=-20.0, native_max=50.0,
range_field='range',
range_field='range', entity_category='config',
write_fn=lambda p, rep, href=None: (
[s for s in href.strip('/').split('/') if s],
{'temperature': int(round(float(p)))}
@@ -61,6 +63,24 @@ TEMP_SETPOINT_GENERIC = Capability(
),
)
# ---------------------------------------------------------------------------
# Icemaker nighttime quiet mode
# ---------------------------------------------------------------------------
ICEMAKER_NIGHTTIME = Capability(
href='/icemaker/nighttime/vs/0',
poll_tier='warm',
entities=(
SwitchDesc(key='ice_night_mode', field='ice.night.status',
name='Nighttime ice quiet mode', icon='mdi:weather-night',
entity_category='config',
value_fn=lambda v: v == 'On',
write_fn=lambda p, rep, href=None: (
['icemaker', 'nighttime', 'vs', '0'],
{'ice.night.status': 'On' if p else 'Off'})),
),
)
# ---------------------------------------------------------------------------
# Icemaker (generic — covers /icemaker/one/vs/0, /icemaker/two/vs/0)
# /icemaker/status/vs/0 is kept as exact-href cap and binds first.
@@ -81,13 +101,16 @@ ICEMAKER_GENERIC = Capability(
entities=(
SensorDesc(key='making_status',
field='x.com.samsung.da.iceMaker.iceMakingStatus',
name=None, icon='mdi:cube-outline'),
name=None, icon='mdi:cube-outline',
translation_key='ice_making_status'),
SwitchDesc(key='enabled', field='x.com.samsung.da.iceMaker.state',
name=None, icon='mdi:cube-outline',
value_fn=lambda v: v == 'On',
write_fn=_icemaker_write('x.com.samsung.da.iceMaker.state')),
SelectDesc(key='type', field='x.com.samsung.da.iceType.desired',
name=None, icon='mdi:cube-outline',
translation_key='ice_type',
entity_category='config',
options_field='x.com.samsung.da.iceType.supported',
exists_fn=lambda rep: bool(rep.get('x.com.samsung.da.iceType.supported')),
write_fn=_icemaker_write('x.com.samsung.da.iceType.desired')),
@@ -95,23 +118,49 @@ ICEMAKER_GENERIC = Capability(
)
# ---------------------------------------------------------------------------
# Doors
# Door alert tone
# ---------------------------------------------------------------------------
DOORS_STATUS = Capability(
href='/doors/vs/0',
poll_tier='hot',
DOOR_ALERT = Capability(
href='/settings/sound/alert/door/vs/0',
poll_tier='warm',
entities=(
BinarySensorDesc(
key='any_door_open',
field='x.com.samsung.da.items',
name='Any door open',
device_class='door',
value_fn=lambda items: any(
it.get('x.com.samsung.da.openState') == 'Open'
for it in (items or [])
),
),
SelectDesc(key='door_alert', field='alert.door',
name='Door alarm', icon='mdi:bell-alert',
translation_key='door_alert',
entity_category='config',
options_field='supportedAlert.door',
write_fn=lambda p, rep, href=None: (
['settings', 'sound', 'alert', 'door', 'vs', '0'],
{'alert.door': p})),
),
)
# ---------------------------------------------------------------------------
# Status/lock — auto door opener and fridge sound
# ---------------------------------------------------------------------------
def _status_lock_write(field):
return lambda p, rep, href=None: (
['status', 'lock', 'vs', '0'],
{field: 'On' if p else 'Off'}
)
STATUS_LOCK = Capability(
href='/status/lock/vs/0',
poll_tier='warm',
entities=(
SwitchDesc(key='auto_door_opener', field='x.com.samsung.da.ado.devicecontrol',
name='Auto door opener', icon='mdi:door-open',
entity_category='config',
value_fn=lambda v: v == 'On',
write_fn=_status_lock_write('x.com.samsung.da.ado.devicecontrol')),
SwitchDesc(key='fridge_sound', field='x.com.samsung.da.device.sound',
name='Sound', icon='mdi:volume-high',
entity_category='config',
value_fn=lambda v: v == 'On',
write_fn=_status_lock_write('x.com.samsung.da.device.sound')),
),
)
@@ -133,10 +182,12 @@ REFRIGERATION = Capability(
entities=(
SwitchDesc(key='rapid_fridge', field='x.com.samsung.da.rapidFridge',
name='Rapid fridge', icon='mdi:fridge-industrial',
entity_category='config',
value_fn=lambda v: v == 'On',
write_fn=_refrigeration_write('x.com.samsung.da.rapidFridge')),
SwitchDesc(key='rapid_freezing', field='x.com.samsung.da.rapidFreezing',
name='Rapid freezing', icon='mdi:snowflake',
entity_category='config',
value_fn=lambda v: v == 'On',
write_fn=_refrigeration_write('x.com.samsung.da.rapidFreezing')),
),
@@ -157,12 +208,141 @@ AUTOFILL = Capability(
poll_tier='warm',
entities=(
SwitchDesc(key='autofill', field='x.com.samsung.da.autofill',
name='Autofill', icon='mdi:cup-water',
name='Autofill pitcher', icon='mdi:cup-water',
entity_category='config',
value_fn=lambda v: v == 'On',
write_fn=_autofill_write),
),
)
# ---------------------------------------------------------------------------
# Welcome lighting (proximity-triggered cabinet light)
# ---------------------------------------------------------------------------
WELCOME_LIGHTING = Capability(
href='/proximity/vs/0',
poll_tier='warm',
entities=(
SwitchDesc(key='welcome_lighting', field='status',
name='Welcome lighting', icon='mdi:motion-sensor',
entity_category='config',
value_fn=lambda v: v == 'On',
write_fn=lambda p, rep, href=None: (
['proximity', 'vs', '0'],
{'status': 'On' if p else 'Off'})),
),
)
# ---------------------------------------------------------------------------
# Enhanced cabinet light — nighttime lighting schedule
#
# night.starttime is an ISO datetime; only the time portion is meaningful.
# night.duration.minute encodes the window length. End time is derived so
# both time entities write back to the same resource without stepping on each
# other: writing start preserves duration; writing end recalculates duration.
# ---------------------------------------------------------------------------
_NIGHT_BRIGHTNESS_OPTIONS = ('33', '66', '100')
def _tz_offset(rep) -> datetime.timedelta:
s = rep.get('timezone.offset', '+00:00')
try:
sign = 1 if s[0] == '+' else -1
h, m = map(int, s[1:].split(':'))
return datetime.timedelta(hours=h, minutes=m) * sign
except (ValueError, TypeError, IndexError):
return datetime.timedelta(0)
def _parse_night_time(iso_str, offset=datetime.timedelta(0)) -> 'datetime.time | None':
"""Convert a UTC ISO datetime string to local time using offset."""
if not iso_str:
return None
try:
return (datetime.datetime.fromisoformat(iso_str) + offset).time().replace(second=0)
except (ValueError, TypeError):
return None
def _night_start_value(rep) -> 'datetime.time | None':
return _parse_night_time(rep.get('night.starttime'), _tz_offset(rep))
def _night_end_value(rep) -> 'datetime.time | None':
start_t = _parse_night_time(rep.get('night.starttime'), _tz_offset(rep))
duration_str = rep.get('night.duration.minute')
if start_t is None or duration_str is None:
return None
try:
duration = int(duration_str)
except (ValueError, TypeError):
return None
return (datetime.datetime.combine(datetime.date.today(), start_t)
+ datetime.timedelta(minutes=duration)).time().replace(second=0)
def _night_start_write(p, rep, href=None):
# p is local time; subtract offset to get UTC for storage
offset = _tz_offset(rep)
utc_dt = datetime.datetime.combine(datetime.date.today(), p) - offset
old_iso = rep.get('night.starttime') or ''
try:
new_dt = datetime.datetime.fromisoformat(old_iso).replace(
hour=utc_dt.hour, minute=utc_dt.minute, second=0)
except (ValueError, TypeError):
new_dt = utc_dt
return ['cabinet', 'light', 'enhanced', 'vs', '0'], {
'night.starttime': new_dt.strftime('%Y-%m-%dT%H:%M:%S'),
}
def _night_end_write(p, rep, href=None):
# duration = end_local - start_local; offset cancels so work in local time
start_t = _parse_night_time(rep.get('night.starttime'), _tz_offset(rep))
if start_t is None:
return None
start_min = start_t.hour * 60 + start_t.minute
end_min = p.hour * 60 + p.minute
duration = (end_min - start_min) % (24 * 60)
return ['cabinet', 'light', 'enhanced', 'vs', '0'], {
'night.duration.minute': str(duration),
}
CABINET_LIGHT_ENHANCED = Capability(
href='/cabinet/light/enhanced/vs/0',
poll_tier='warm',
entities=(
SelectDesc(key='day_brightness', field='level.brightness.daytime',
name='Cabinet brightness', icon='mdi:brightness-5',
translation_key='brightness_level',
entity_category='config',
options=_NIGHT_BRIGHTNESS_OPTIONS,
write_fn=lambda p, rep, href=None: (
['cabinet', 'light', 'enhanced', 'vs', '0'],
{'level.brightness.daytime': p})),
SelectDesc(key='brightness_level', field='level.brightness.nighttime',
name='Night brightness', icon='mdi:brightness-4',
translation_key='brightness_level',
entity_category='config',
options=_NIGHT_BRIGHTNESS_OPTIONS,
write_fn=lambda p, rep, href=None: (
['cabinet', 'light', 'enhanced', 'vs', '0'],
{'level.brightness.nighttime': p})),
TimeDesc(key='night_start', field='',
name='Night light start', icon='mdi:clock-start',
entity_category='config',
rep_fn=_night_start_value,
write_fn=_night_start_write),
TimeDesc(key='night_end', field='',
name='Night light end', icon='mdi:clock-end',
entity_category='config',
rep_fn=_night_end_value,
write_fn=_night_end_write),
),
)
# ---------------------------------------------------------------------------
# Cabinet light
# ---------------------------------------------------------------------------
@@ -183,6 +363,13 @@ CABINET_LIGHT = Capability(
name='Cabinet light', icon='mdi:fridge-outline',
value_fn=lambda v: v == 'On',
write_fn=_cabinet_light_write),
SwitchDesc(key='cabinet_light_dim', field='light.dimming.status',
name='Brighten gradually', icon='mdi:brightness-auto',
entity_category='config',
value_fn=lambda v: v == 'On',
write_fn=lambda p, rep, href=None: (
['cabinet', 'light', 'total', 'vs', '0'],
{'light.dimming.status': 'On' if p else 'Off'})),
),
)
@@ -202,6 +389,7 @@ SABBATH = Capability(
entities=(
SwitchDesc(key='sabbath_mode', field='x.com.samsung.da.sabbathMode',
name='Sabbath mode', icon='mdi:hands-pray',
entity_category='config',
value_fn=lambda v: v == 'On',
write_fn=_sabbath_write),
),
@@ -212,7 +400,7 @@ SABBATH = Capability(
# ---------------------------------------------------------------------------
def _bzone_write(p, rep, href=None):
if p not in BZONE_MODES:
if p not in _BZONE_MODES:
return None
return ['specialzone', 'one', 'vs', '0'], {'roomDesiredMode': p}
@@ -223,7 +411,43 @@ BEVERAGE_ZONE = Capability(
entities=(
SelectDesc(key='beverage_zone_mode', field='roomDesiredMode',
name='Beverage zone mode', icon='mdi:glass-wine',
options=BZONE_MODES, write_fn=_bzone_write),
translation_key='beverage_zone_mode',
entity_category='config',
options=_BZONE_MODES, write_fn=_bzone_write),
),
)
# ---------------------------------------------------------------------------
# Flex zone (convertible drawer — /mode/vs/0 on RF9000-class fridges)
#
# x.com.samsung.da.modes holds multiple orthogonal flags in one list.
# The flex zone entry is identified by the CV_TTYPE_RF9000A_ prefix.
# Write replaces only that item; other flags are preserved.
# ---------------------------------------------------------------------------
def _flex_zone_write(p, rep, href=None):
modes = list(rep.get('x.com.samsung.da.modes') or [])
modes = [m for m in modes if not m.startswith('CV_TTYPE_RF9000A_')]
modes.append(p)
return ['mode', 'vs', '0'], {'x.com.samsung.da.modes': modes}
FLEX_ZONE = Capability(
href='/mode/vs/0',
poll_tier='warm',
entities=(
SelectDesc(key='flex_zone_mode',
field='x.com.samsung.da.modes',
name='Flex zone mode', icon='mdi:thermostat',
translation_key='flex_zone_mode',
entity_category='config',
options_field='x.com.samsung.da.supportedOptions',
exists_fn=lambda rep: bool(
rep.get('x.com.samsung.da.supportedOptions')),
value_fn=lambda modes: next(
(m for m in (modes or [])
if m.startswith('CV_TTYPE_RF9000A_')), None),
write_fn=_flex_zone_write),
),
)
@@ -34,9 +34,11 @@ DOOR_LED = Capability(
entities=(
SelectDesc(key='led_brightness', field='setBrightness',
name='Door LED brightness', icon='mdi:brightness-6',
entity_category='config',
options=_LED_LEVELS, write_fn=_led_brightness_write),
SwitchDesc(key='led_night_light', field='setNightLight',
name='Door LED night light', icon='mdi:weather-night',
entity_category='config',
value_fn=lambda v: v == 'On',
write_fn=_led_night_write),
),
@@ -47,6 +49,7 @@ SOUND_MODE = Capability(
entities=(
SelectDesc(key='sound_mode', field='mode',
name='Sound mode', icon='mdi:volume-high',
entity_category='config',
options=_SOUND_MODES, write_fn=_sound_mode_write),
),
)
@@ -264,8 +264,6 @@ OVEN_OPERATIONAL_STATE = Capability(
field='x.com.samsung.da.progressPercentage',
name='Progress percent', unit='%', state_class='measurement',
value_fn=_int),
SensorDesc(key='operation_time', field='x.com.samsung.da.operationTime',
name='Elapsed time', icon='mdi:timer'),
SensorDesc(key='operation_time_minutes',
field='x.com.samsung.da.operationTime',
name='Operation time (minutes)', unit='min',
@@ -317,11 +315,6 @@ OVEN_DOOR = Capability(
href='/doors/vs/0',
poll_tier='hot',
entities=(
SensorDesc(key='door', field='x.com.samsung.da.items',
name='Door state',
value_fn=lambda items: (
items[0].get('x.com.samsung.da.openState')
if items else None)),
BinarySensorDesc(key='door_open', field='x.com.samsung.da.items',
name='Door', device_class='door',
value_fn=lambda items: (
@@ -357,6 +350,7 @@ OVEN_MODE = Capability(
write_fn=_lamp_write),
SwitchDesc(key='sound', field='x.com.samsung.da.options',
name='Sound', icon='mdi:volume-high',
entity_category='config',
value_fn=lambda opts: _option_value(opts, 'Sound') == 'On',
write_fn=_sound_write),
SwitchDesc(key='fast_preheat', field='x.com.samsung.da.options',
@@ -26,6 +26,7 @@ class SamsungEntityDescription:
entity_category: Optional[str] = None # 'diagnostic' | 'config' | None
enabled_default: bool = True
value_fn: Callable[[Any], Any] = _identity
rep_fn: Optional[Callable[[dict], Any]] = None # replaces field+value_fn; receives full rep
exists_fn: Optional[Callable[[dict], bool]] = None
@@ -71,6 +72,11 @@ class NumberDesc(SamsungEntityDescription):
write_fn: WriteFn = None
@dataclass(frozen=True, kw_only=True)
class TimeDesc(SamsungEntityDescription):
write_fn: WriteFn = None
PLATFORM_OF: dict[type, str] = {
SensorDesc: 'sensor',
BinarySensorDesc: 'binary_sensor',
@@ -78,4 +84,5 @@ PLATFORM_OF: dict[type, str] = {
SwitchDesc: 'switch',
ButtonDesc: 'button',
NumberDesc: 'number',
TimeDesc: 'time',
}
@@ -1,4 +1,52 @@
{
"entity": {
"sensor": {
"ice_making_status": {
"state": {
"ICESTATUS_STOP": "Idle"
}
}
},
"select": {
"door_alert": {
"state": {
"1": "Alarm 1",
"2": "Alarm 2",
"3": "Alarm 3",
"4": "Alarm 4"
}
},
"brightness_level": {
"state": {
"33": "Low",
"66": "Medium",
"100": "High"
}
},
"ice_type": {
"state": {
"WHISKEY_ICEBALL_3": "3 Balls/Day",
"WHISKEY_ICEBALL_6": "6 Balls/Day",
"WHISKEY_ICEBALL_9": "9 Balls/Day"
}
},
"beverage_zone_mode": {
"state": {
"SP_TTYPE_BEER_DRINKS": "Beverage",
"SP_TTYPE_WINE_DESSERT": "Wine and Dessert"
}
},
"flex_zone_mode": {
"state": {
"CV_TTYPE_RF9000A_FREEZE": "Freeze",
"CV_TTYPE_RF9000A_SOFTFREEZE": "Soft Freeze",
"CV_TTYPE_RF9000A_MEAT_FISH": "Meat/Fish",
"CV_TTYPE_RF9000A_FRUIT_VEGGIES": "Fruit & Veggies",
"CV_TTYPE_RF9000A_BEVERAGE": "Beverage"
}
}
}
},
"config": {
"step": {
"user": {
+38
View File
@@ -0,0 +1,38 @@
"""Time platform for Local Things."""
from __future__ import annotations
import datetime
from homeassistant.components.time import TimeEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .ocf.registry.entities import TimeDesc
from .const import DOMAIN
from .coordinator import LocalThingsCoordinator
from .entity import LocalThingsEntity
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
coordinator: LocalThingsCoordinator = hass.data[DOMAIN][entry.entry_id]
async_add_entities(
LocalThingsTime(coordinator, b)
for b in coordinator.bound
if isinstance(b.desc, TimeDesc)
)
class LocalThingsTime(LocalThingsEntity, TimeEntity):
@property
def native_value(self) -> datetime.time | None:
return (self.coordinator.data or {}).get(self._state_key)
async def async_set_value(self, value: datetime.time) -> None:
await self.coordinator.async_send_command(self._bound, value)
@@ -1,4 +1,52 @@
{
"entity": {
"sensor": {
"ice_making_status": {
"state": {
"ICESTATUS_STOP": "Idle"
}
}
},
"select": {
"door_alert": {
"state": {
"1": "Alarm 1",
"2": "Alarm 2",
"3": "Alarm 3",
"4": "Alarm 4"
}
},
"brightness_level": {
"state": {
"33": "Low",
"66": "Medium",
"100": "High"
}
},
"ice_type": {
"state": {
"WHISKEY_ICEBALL_3": "3 Balls/Day",
"WHISKEY_ICEBALL_6": "6 Balls/Day",
"WHISKEY_ICEBALL_9": "9 Balls/Day"
}
},
"beverage_zone_mode": {
"state": {
"SP_TTYPE_BEER_DRINKS": "Beverage",
"SP_TTYPE_WINE_DESSERT": "Wine and Dessert"
}
},
"flex_zone_mode": {
"state": {
"CV_TTYPE_RF9000A_FREEZE": "Freeze",
"CV_TTYPE_RF9000A_SOFTFREEZE": "Soft Freeze",
"CV_TTYPE_RF9000A_MEAT_FISH": "Meat/Fish",
"CV_TTYPE_RF9000A_FRUIT_VEGGIES": "Fruit & Veggies",
"CV_TTYPE_RF9000A_BEVERAGE": "Beverage"
}
}
}
},
"config": {
"step": {
"user": {