diff --git a/custom_components/localthings/const.py b/custom_components/localthings/const.py index d929e65..0fa612c 100644 --- a/custom_components/localthings/const.py +++ b/custom_components/localthings/const.py @@ -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" diff --git a/custom_components/localthings/coordinator.py b/custom_components/localthings/coordinator.py index 4351bef..aa28157 100644 --- a/custom_components/localthings/coordinator.py +++ b/custom_components/localthings/coordinator.py @@ -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 diff --git a/custom_components/localthings/entity.py b/custom_components/localthings/entity.py index c09bca7..26d7a2c 100644 --- a/custom_components/localthings/entity.py +++ b/custom_components/localthings/entity.py @@ -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 diff --git a/custom_components/localthings/ocf/coap_dtls.py b/custom_components/localthings/ocf/coap_dtls.py index 8d0d45f..f19b4f8 100644 --- a/custom_components/localthings/ocf/coap_dtls.py +++ b/custom_components/localthings/ocf/coap_dtls.py @@ -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 diff --git a/custom_components/localthings/ocf/registry/adapter.py b/custom_components/localthings/ocf/registry/adapter.py index ee5e35f..92f379e 100644 --- a/custom_components/localthings/ocf/registry/adapter.py +++ b/custom_components/localthings/ocf/registry/adapter.py @@ -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 diff --git a/custom_components/localthings/ocf/registry/by_type/refrigerator.py b/custom_components/localthings/ocf/registry/by_type/refrigerator.py index 8597b19..6a1e32d 100644 --- a/custom_components/localthings/ocf/registry/by_type/refrigerator.py +++ b/custom_components/localthings/ocf/registry/by_type/refrigerator.py @@ -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, diff --git a/custom_components/localthings/ocf/registry/capabilities/common.py b/custom_components/localthings/ocf/registry/capabilities/common.py index 6eeb23d..5539674 100644 --- a/custom_components/localthings/ocf/registry/capabilities/common.py +++ b/custom_components/localthings/ocf/registry/capabilities/common.py @@ -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'), ), ) diff --git a/custom_components/localthings/ocf/registry/capabilities/fridge.py b/custom_components/localthings/ocf/registry/capabilities/fridge.py index 95197ca..dc291e3 100644 --- a/custom_components/localthings/ocf/registry/capabilities/fridge.py +++ b/custom_components/localthings/ocf/registry/capabilities/fridge.py @@ -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), ), ) diff --git a/custom_components/localthings/ocf/registry/capabilities/laundry.py b/custom_components/localthings/ocf/registry/capabilities/laundry.py index a8cc49f..6841d07 100644 --- a/custom_components/localthings/ocf/registry/capabilities/laundry.py +++ b/custom_components/localthings/ocf/registry/capabilities/laundry.py @@ -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), ), ) diff --git a/custom_components/localthings/ocf/registry/capabilities/oven.py b/custom_components/localthings/ocf/registry/capabilities/oven.py index 5c26cfa..b64c1fa 100644 --- a/custom_components/localthings/ocf/registry/capabilities/oven.py +++ b/custom_components/localthings/ocf/registry/capabilities/oven.py @@ -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', diff --git a/custom_components/localthings/ocf/registry/entities.py b/custom_components/localthings/ocf/registry/entities.py index f1269db..f606742 100644 --- a/custom_components/localthings/ocf/registry/entities.py +++ b/custom_components/localthings/ocf/registry/entities.py @@ -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', } diff --git a/custom_components/localthings/strings.json b/custom_components/localthings/strings.json index 3fca73a..9a270af 100644 --- a/custom_components/localthings/strings.json +++ b/custom_components/localthings/strings.json @@ -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": { diff --git a/custom_components/localthings/time.py b/custom_components/localthings/time.py new file mode 100644 index 0000000..01831b6 --- /dev/null +++ b/custom_components/localthings/time.py @@ -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) diff --git a/custom_components/localthings/translations/en.json b/custom_components/localthings/translations/en.json index 3fca73a..9a270af 100644 --- a/custom_components/localthings/translations/en.json +++ b/custom_components/localthings/translations/en.json @@ -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": {