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:
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user