Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aee19fd953 | ||
|
|
37bd063c0a | ||
|
|
e875266405 |
No files matched your search
@@ -115,6 +115,13 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
|||||||
def last_resources(self) -> dict:
|
def last_resources(self) -> dict:
|
||||||
return self._cache.snapshot()
|
return self._cache.snapshot()
|
||||||
|
|
||||||
|
def resource(self, href: str) -> dict:
|
||||||
|
"""A single href's current rep. Cheaper than `last_resources.get(href)`
|
||||||
|
for callers that only need one href — `last_resources` copies every
|
||||||
|
tracked href's rep to build the snapshot dict, while this is a
|
||||||
|
direct O(1) cache lookup."""
|
||||||
|
return self._cache.get(href) or {}
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def observe_mode(self) -> str:
|
def observe_mode(self) -> str:
|
||||||
return self._observe.mode
|
return self._observe.mode
|
||||||
|
|||||||
@@ -12,5 +12,5 @@
|
|||||||
"pyOpenSSL>=23.0",
|
"pyOpenSSL>=23.0",
|
||||||
"smartthings-local>=0.1.0"
|
"smartthings-local>=0.1.0"
|
||||||
],
|
],
|
||||||
"version": "0.2.1"
|
"version": "0.3.0"
|
||||||
}
|
}
|
||||||
@@ -42,12 +42,18 @@ class LocalThingsNumber(LocalThingsEntity, NumberEntity):
|
|||||||
if desc.step is not None:
|
if desc.step is not None:
|
||||||
self._attr_native_step = desc.step
|
self._attr_native_step = desc.step
|
||||||
|
|
||||||
|
@property
|
||||||
|
def native_unit_of_measurement(self):
|
||||||
|
desc: NumberDesc = self._bound.desc
|
||||||
|
if desc.unit_fn is not None:
|
||||||
|
return desc.unit_fn(self.coordinator.resource(self._bound.href))
|
||||||
|
return self._attr_native_unit_of_measurement
|
||||||
|
|
||||||
def _range_from_resource(self) -> list | None:
|
def _range_from_resource(self) -> list | None:
|
||||||
desc: NumberDesc = self._bound.desc
|
desc: NumberDesc = self._bound.desc
|
||||||
if not desc.range_field:
|
if not desc.range_field:
|
||||||
return None
|
return None
|
||||||
rep = self.coordinator.last_resources.get(self._bound.href) or {}
|
r = self.coordinator.resource(self._bound.href).get(desc.range_field)
|
||||||
r = rep.get(desc.range_field)
|
|
||||||
return r if (isinstance(r, (list, tuple)) and len(r) == 2) else None
|
return r if (isinstance(r, (list, tuple)) and len(r) == 2) else None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ REGISTRY = DeviceRegistry(
|
|||||||
fridge.DOORS_FALLBACK,
|
fridge.DOORS_FALLBACK,
|
||||||
fridge.TEMPERATURES_FALLBACK,
|
fridge.TEMPERATURES_FALLBACK,
|
||||||
fridge.ICEMAKER_STATUS_FALLBACK,
|
fridge.ICEMAKER_STATUS_FALLBACK,
|
||||||
|
fridge.REFRIGERATION_FALLBACK,
|
||||||
]),
|
]),
|
||||||
pattern_capabilities=[
|
pattern_capabilities=[
|
||||||
fridge.TEMP_CURRENT_GENERIC,
|
fridge.TEMP_CURRENT_GENERIC,
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ REGISTRY = DeviceRegistry(
|
|||||||
washer.BUZZER_SOUND,
|
washer.BUZZER_SOUND,
|
||||||
washer.WASHER_JOB_BEGINNING_STATUS,
|
washer.WASHER_JOB_BEGINNING_STATUS,
|
||||||
common.ALARMS,
|
common.ALARMS,
|
||||||
common.ENERGY_METER,
|
washer.WASHER_ENERGY_METER,
|
||||||
common.WATER_METER,
|
common.WATER_METER,
|
||||||
operational.OPERATIONAL_STATE,
|
operational.OPERATIONAL_STATE,
|
||||||
dishwasher.DIAGNOSIS,
|
dishwasher.DIAGNOSIS,
|
||||||
|
|||||||
@@ -21,16 +21,29 @@ def _num(v):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _clamp_power(v):
|
def clamp_power(v):
|
||||||
n = _num(v)
|
n = _num(v)
|
||||||
return 0.0 if (n is not None and n < 0) else n
|
return 0.0 if (n is not None and n < 0) else n
|
||||||
|
|
||||||
|
|
||||||
def _wh_to_kwh(v):
|
def wh_to_kwh(v):
|
||||||
n = _num(v)
|
n = _num(v)
|
||||||
return round(n / 1000.0, 2) if n is not None else None
|
return round(n / 1000.0, 2) if n is not None else None
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_temp_unit(raw, default='°F'):
|
||||||
|
"""'C'/'Celsius' -> '°C', 'F'/'Fahrenheit' -> '°F'. Falls back to
|
||||||
|
`default` for any other/missing value. Shared by fridge.py and oven.py,
|
||||||
|
both of which read a per-device unit off a `/temperature*` resource
|
||||||
|
instead of assuming one (see fridge.py's module docstring, issue #7)."""
|
||||||
|
raw = (raw or '').strip().upper()
|
||||||
|
if raw.startswith('C'):
|
||||||
|
return '°C'
|
||||||
|
if raw.startswith('F'):
|
||||||
|
return '°F'
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
def _ml_to_l(v):
|
def _ml_to_l(v):
|
||||||
n = _num(v)
|
n = _num(v)
|
||||||
return round(n / 1000.0, 1) if n is not None else None
|
return round(n / 1000.0, 1) if n is not None else None
|
||||||
@@ -90,10 +103,10 @@ ENERGY_METER = Capability(
|
|||||||
entities=(
|
entities=(
|
||||||
SensorDesc(key='power_watts', field='x.com.samsung.da.instantaneousPower',
|
SensorDesc(key='power_watts', field='x.com.samsung.da.instantaneousPower',
|
||||||
name='Power', device_class='power', state_class='measurement',
|
name='Power', device_class='power', state_class='measurement',
|
||||||
unit='W', value_fn=_clamp_power),
|
unit='W', value_fn=clamp_power),
|
||||||
SensorDesc(key='energy_kwh', field='x.com.samsung.da.cumulativePower',
|
SensorDesc(key='energy_kwh', field='x.com.samsung.da.cumulativePower',
|
||||||
name='Energy', device_class='energy',
|
name='Energy', device_class='energy',
|
||||||
state_class='total_increasing', unit='kWh', value_fn=_wh_to_kwh),
|
state_class='total_increasing', unit='kWh', value_fn=wh_to_kwh),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -2,9 +2,13 @@
|
|||||||
|
|
||||||
Resources verified against the dump at local-tools/dumps/10.0.0.254.json.
|
Resources verified against the dump at local-tools/dumps/10.0.0.254.json.
|
||||||
|
|
||||||
Temperature fields in all /temperature/* resources are in Fahrenheit on this
|
Temperature unit is read live from each resource, not assumed: the RF9000B
|
||||||
model. Setpoints are NumberDesc with direct-write write_fn — generic caps
|
dump reports Fahrenheit ("units": "F" / "x.com.samsung.da.unit": "Fahrenheit"),
|
||||||
derive the CoAP PUT path from href at write time.
|
but a TP1X_REF_21K dump (issue #7) reports the same fields in Celsius for the
|
||||||
|
exact same resources — the device tells you which one it is, it's just never
|
||||||
|
been read before. See `_temp_unit`/`_temp_item_unit` below. Setpoints are
|
||||||
|
NumberDesc with direct-write write_fn — generic caps derive the CoAP PUT path
|
||||||
|
from href at write time.
|
||||||
|
|
||||||
Multi-instance note: the two door resources (/door/cooler/0 and
|
Multi-instance note: the two door resources (/door/cooler/0 and
|
||||||
/door/freezer/0) and the two ice-maker resources (/icemaker/one/vs/0 and
|
/door/freezer/0) and the two ice-maker resources (/icemaker/one/vs/0 and
|
||||||
@@ -18,6 +22,7 @@ from ..entities import (
|
|||||||
BinarySensorDesc, ButtonDesc, NumberDesc, SelectDesc, SensorDesc,
|
BinarySensorDesc, ButtonDesc, NumberDesc, SelectDesc, SensorDesc,
|
||||||
SwitchDesc, TimeDesc,
|
SwitchDesc, TimeDesc,
|
||||||
)
|
)
|
||||||
|
from .common import normalize_temp_unit
|
||||||
|
|
||||||
# Display names for the beverage zone, flex zone, ice type, and
|
# Display names for the beverage zone, flex zone, ice type, and
|
||||||
# ice-making-status enums below live in strings.json / translations/en.json,
|
# ice-making-status enums below live in strings.json / translations/en.json,
|
||||||
@@ -33,6 +38,12 @@ def _int(v):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _temp_unit(rep):
|
||||||
|
"""'units': 'C'/'F' (or 'Celsius'/'Fahrenheit') -> '°C'/'°F'. Defaults to
|
||||||
|
°F (this module's original assumption) if the device omits the field."""
|
||||||
|
return normalize_temp_unit(rep.get('units'))
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Temperature (generic — covers /temperature/current/* and /temperature/desired/*)
|
# Temperature (generic — covers /temperature/current/* and /temperature/desired/*)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -45,7 +56,7 @@ TEMP_CURRENT_GENERIC = Capability(
|
|||||||
entities=(
|
entities=(
|
||||||
SensorDesc(key='temperature', field='temperature',
|
SensorDesc(key='temperature', field='temperature',
|
||||||
name=None, icon='mdi:thermometer',
|
name=None, icon='mdi:thermometer',
|
||||||
device_class='temperature', unit='°F',
|
device_class='temperature', unit_fn=_temp_unit,
|
||||||
state_class='measurement'),
|
state_class='measurement'),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -57,7 +68,7 @@ TEMP_SETPOINT_GENERIC = Capability(
|
|||||||
poll_tier='warm',
|
poll_tier='warm',
|
||||||
entities=(
|
entities=(
|
||||||
NumberDesc(key='setpoint', field='temperature',
|
NumberDesc(key='setpoint', field='temperature',
|
||||||
name=None, device_class='temperature', unit='°F',
|
name=None, device_class='temperature', unit_fn=_temp_unit,
|
||||||
native_min=-20.0, native_max=50.0,
|
native_min=-20.0, native_max=50.0,
|
||||||
range_field='range', entity_category='config',
|
range_field='range', entity_category='config',
|
||||||
write_fn=lambda p, rep, href=None: (
|
write_fn=lambda p, rep, href=None: (
|
||||||
@@ -172,12 +183,16 @@ STATUS_LOCK = Capability(
|
|||||||
)
|
)
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Defrost delay
|
# Defrost delay / active-defrost status
|
||||||
#
|
#
|
||||||
# /defrost/delay/vs/0 is the writable toggle. /defrost/block/vs/0 just
|
# /defrost/delay/vs/0 is the writable toggle to postpone a scheduled
|
||||||
# reports whether the defrost cycle is currently blocked as a result (a
|
# defrost. /defrost/block/vs/0 is an unrelated, independently-varying
|
||||||
# derived status, not an independent control) — exposed as a read-only
|
# status: despite its "block" naming (originally assumed to mean "defrost
|
||||||
# diagnostic binary sensor.
|
# is being withheld"), live dumps confirm DEFROST_BLOCK_ON means the
|
||||||
|
# defrost cycle is *actively running* right now, seen with defrost_delay
|
||||||
|
# off -- i.e. "block" refers to the evaporator/coil block being defrosted,
|
||||||
|
# not a blocking/prevention state. Exposed as a read-only diagnostic
|
||||||
|
# binary sensor.
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
DEFROST_DELAY = Capability(
|
DEFROST_DELAY = Capability(
|
||||||
@@ -198,8 +213,8 @@ DEFROST_BLOCK_STATUS = Capability(
|
|||||||
href='/defrost/block/vs/0',
|
href='/defrost/block/vs/0',
|
||||||
poll_tier='warm',
|
poll_tier='warm',
|
||||||
entities=(
|
entities=(
|
||||||
BinarySensorDesc(key='defrost_blocked', field='x.com.samsung.da.modes',
|
BinarySensorDesc(key='defrost_active', field='x.com.samsung.da.modes',
|
||||||
name='Defrost blocked', icon='mdi:snowflake-off',
|
name='Defrost active', icon='mdi:snowflake-melt',
|
||||||
entity_category='diagnostic',
|
entity_category='diagnostic',
|
||||||
value_fn=lambda modes: bool(modes) and modes[0] == 'DEFROST_BLOCK_ON'),
|
value_fn=lambda modes: bool(modes) and modes[0] == 'DEFROST_BLOCK_ON'),
|
||||||
),
|
),
|
||||||
@@ -590,6 +605,13 @@ def _temp_item_value(items, keyword):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _temp_item_unit(items, keyword):
|
||||||
|
for item in (items or []):
|
||||||
|
if keyword.lower() in (item.get('x.com.samsung.da.description') or '').lower():
|
||||||
|
return normalize_temp_unit(item.get('x.com.samsung.da.unit'))
|
||||||
|
return '°F'
|
||||||
|
|
||||||
|
|
||||||
TEMPERATURES_FALLBACK = Capability(
|
TEMPERATURES_FALLBACK = Capability(
|
||||||
href='/temperatures/vs/0',
|
href='/temperatures/vs/0',
|
||||||
match_fn=lambda rep, resources: not _any_temperature_generic(resources),
|
match_fn=lambda rep, resources: not _any_temperature_generic(resources),
|
||||||
@@ -597,11 +619,15 @@ TEMPERATURES_FALLBACK = Capability(
|
|||||||
entities=(
|
entities=(
|
||||||
SensorDesc(key='freezer_temperature', field='x.com.samsung.da.items',
|
SensorDesc(key='freezer_temperature', field='x.com.samsung.da.items',
|
||||||
name='Freezer temperature', icon='mdi:thermometer',
|
name='Freezer temperature', icon='mdi:thermometer',
|
||||||
device_class='temperature', unit='°F', state_class='measurement',
|
device_class='temperature', state_class='measurement',
|
||||||
|
unit_fn=lambda rep: _temp_item_unit(
|
||||||
|
rep.get('x.com.samsung.da.items'), 'Freezer'),
|
||||||
value_fn=lambda items: _temp_item_value(items, 'Freezer')),
|
value_fn=lambda items: _temp_item_value(items, 'Freezer')),
|
||||||
SensorDesc(key='fridge_temperature', field='x.com.samsung.da.items',
|
SensorDesc(key='fridge_temperature', field='x.com.samsung.da.items',
|
||||||
name='Fridge temperature', icon='mdi:thermometer',
|
name='Fridge temperature', icon='mdi:thermometer',
|
||||||
device_class='temperature', unit='°F', state_class='measurement',
|
device_class='temperature', state_class='measurement',
|
||||||
|
unit_fn=lambda rep: _temp_item_unit(
|
||||||
|
rep.get('x.com.samsung.da.items'), 'Fridge'),
|
||||||
value_fn=lambda items: _temp_item_value(items, 'Fridge')),
|
value_fn=lambda items: _temp_item_value(items, 'Fridge')),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -628,3 +654,34 @@ ICEMAKER_STATUS_FALLBACK = Capability(
|
|||||||
{'x.com.samsung.da.iceMaker': 'On' if p else 'Off'})),
|
{'x.com.samsung.da.iceMaker': 'On' if p else 'Off'})),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# OCF-native /refrigeration/0 (issue #7's unbound_hrefs) -- the odd one out
|
||||||
|
# in this section: its three fields duplicate two *different* richer
|
||||||
|
# hrefs (REFRIGERATION's rapidFridge/rapidFreezing and
|
||||||
|
# DEFROST_BLOCK_STATUS's defrost_active), each absent independently, so a
|
||||||
|
# single capability-level match_fn can't express it. Gated per-entity
|
||||||
|
# (exists_fn) instead: rapid_fridge/rapid_freezing back off only when
|
||||||
|
# REFRIGERATION's href is present; defrost_active only when
|
||||||
|
# DEFROST_BLOCK_STATUS's is. No write path confirmed for this href, so
|
||||||
|
# these are read-only, unlike REFRIGERATION's switches.
|
||||||
|
REFRIGERATION_FALLBACK = Capability(
|
||||||
|
href='/refrigeration/0',
|
||||||
|
poll_tier='warm',
|
||||||
|
entities=(
|
||||||
|
BinarySensorDesc(key='defrost_active', field='defrost',
|
||||||
|
name='Defrost active', icon='mdi:snowflake-melt',
|
||||||
|
entity_category='diagnostic',
|
||||||
|
value_fn=lambda v: bool(v),
|
||||||
|
exists_fn=lambda rep, resources: '/defrost/block/vs/0' not in resources),
|
||||||
|
BinarySensorDesc(key='rapid_fridge', field='rapidCool',
|
||||||
|
name='Rapid fridge', icon='mdi:fridge-industrial',
|
||||||
|
entity_category='config',
|
||||||
|
value_fn=lambda v: bool(v),
|
||||||
|
exists_fn=lambda rep, resources: '/refrigeration/vs/0' not in resources),
|
||||||
|
BinarySensorDesc(key='rapid_freezing', field='rapidFreeze',
|
||||||
|
name='Rapid freezing', icon='mdi:snowflake',
|
||||||
|
entity_category='config',
|
||||||
|
value_fn=lambda v: bool(v),
|
||||||
|
exists_fn=lambda rep, resources: '/refrigeration/vs/0' not in resources),
|
||||||
|
),
|
||||||
|
)
|
||||||
@@ -89,6 +89,11 @@ IGNORED: list[Capability] = [
|
|||||||
# supportedAiLevel on hardware seen so far, so there's no real choice
|
# supportedAiLevel on hardware seen so far, so there's no real choice
|
||||||
# to expose. Revisit if a device surfaces more than one supported level.
|
# to expose. Revisit if a device surfaces more than one supported level.
|
||||||
Capability(href='/energy/ailevel/vs/0'),
|
Capability(href='/energy/ailevel/vs/0'),
|
||||||
|
# OCF-native vacation-mode flag (fridge). Only one value ('RVACATION_OFF')
|
||||||
|
# has ever been seen in `modes` (issue #7's dump), same "no real choice
|
||||||
|
# to expose yet" situation as /energy/ailevel/vs/0 above. Revisit if a
|
||||||
|
# device surfaces it toggled on.
|
||||||
|
Capability(href='/mode/0'),
|
||||||
# Opaque integer with no supportedModes/options list to interpret it
|
# Opaque integer with no supportedModes/options list to interpret it
|
||||||
# against — meaning unclear from the raw resource alone.
|
# against — meaning unclear from the raw resource alone.
|
||||||
Capability(href='/runningmode/vs/0'),
|
Capability(href='/runningmode/vs/0'),
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ from ..entities import (
|
|||||||
BinarySensorDesc, ButtonDesc, NumberDesc, SelectDesc, SensorDesc,
|
BinarySensorDesc, ButtonDesc, NumberDesc, SelectDesc, SensorDesc,
|
||||||
SwitchDesc,
|
SwitchDesc,
|
||||||
)
|
)
|
||||||
|
from .common import normalize_temp_unit
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Constants
|
# Constants
|
||||||
@@ -255,13 +256,26 @@ OVEN_CAVITY = Capability(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _oven_temp_unit(rep):
|
||||||
|
"""Same shape/risk as fridge.py's TEMPERATURES_FALLBACK: this is the
|
||||||
|
same aggregate `/temperatures/vs/0` items[] resource type, which on
|
||||||
|
fridge hardware carries a per-item `x.com.samsung.da.unit` field
|
||||||
|
('Celsius'/'Fahrenheit') that was previously hardcoded away (issue #7).
|
||||||
|
No live oven dump has surfaced a non-Celsius reading yet, so this keeps
|
||||||
|
the verified '°C' default when the field is absent, but reads it live
|
||||||
|
if a device ever reports otherwise."""
|
||||||
|
items = rep.get('x.com.samsung.da.items') or []
|
||||||
|
unit = items[0].get('x.com.samsung.da.unit') if items else None
|
||||||
|
return normalize_temp_unit(unit, default='°C')
|
||||||
|
|
||||||
|
|
||||||
OVEN_SETPOINT = Capability(
|
OVEN_SETPOINT = Capability(
|
||||||
href='/temperatures/vs/0',
|
href='/temperatures/vs/0',
|
||||||
poll_tier='hot',
|
poll_tier='hot',
|
||||||
entities=(
|
entities=(
|
||||||
# NumberDesc first — test_oven_setpoint_write_is_read_modify_write uses entities[0]
|
# NumberDesc first — test_oven_setpoint_write_is_read_modify_write uses entities[0]
|
||||||
NumberDesc(key='oven_setpoint', field='x.com.samsung.da.items',
|
NumberDesc(key='oven_setpoint', field='x.com.samsung.da.items',
|
||||||
name='Setpoint', device_class='temperature', unit='°C',
|
name='Setpoint', device_class='temperature', unit_fn=_oven_temp_unit,
|
||||||
native_min=float(SETPOINT_MIN_C), native_max=float(SETPOINT_MAX_C),
|
native_min=float(SETPOINT_MIN_C), native_max=float(SETPOINT_MAX_C),
|
||||||
step=float(SETPOINT_STEP_C), icon='mdi:thermometer-chevron-up',
|
step=float(SETPOINT_STEP_C), icon='mdi:thermometer-chevron-up',
|
||||||
value_fn=lambda items: _int(
|
value_fn=lambda items: _int(
|
||||||
@@ -269,7 +283,7 @@ OVEN_SETPOINT = Capability(
|
|||||||
write_fn=_oven_setpoint_write),
|
write_fn=_oven_setpoint_write),
|
||||||
SensorDesc(key='current_temp_c', field='x.com.samsung.da.items',
|
SensorDesc(key='current_temp_c', field='x.com.samsung.da.items',
|
||||||
name='Temperature', device_class='temperature',
|
name='Temperature', device_class='temperature',
|
||||||
state_class='measurement', unit='°C',
|
state_class='measurement', unit_fn=_oven_temp_unit,
|
||||||
value_fn=lambda items: _int(
|
value_fn=lambda items: _int(
|
||||||
(items[0].get('x.com.samsung.da.current') if items else None))),
|
(items[0].get('x.com.samsung.da.current') if items else None))),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ from datetime import datetime, timezone
|
|||||||
from ..capability import Capability
|
from ..capability import Capability
|
||||||
from ..entities import BinarySensorDesc, SelectDesc, SensorDesc, SwitchDesc
|
from ..entities import BinarySensorDesc, SelectDesc, SensorDesc, SwitchDesc
|
||||||
|
|
||||||
|
from .common import clamp_power, wh_to_kwh
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Course_XX hex codes. The 23 codes named in strings.json/translations
|
# Course_XX hex codes. The 23 codes named in strings.json/translations
|
||||||
# under entity.select.washer_cycle.state.<id, lowercased> were captured
|
# under entity.select.washer_cycle.state.<id, lowercased> were captured
|
||||||
@@ -292,3 +294,40 @@ REMOTE_CONTROL_VS_FALLBACK = Capability(
|
|||||||
value_fn=lambda v: str(v).lower() == 'true'),
|
value_fn=lambda v: str(v).lower() == 'true'),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# /energy/consumption/vs/0 -- washer-specific override of common.ENERGY_METER
|
||||||
|
# (issue #6).
|
||||||
|
#
|
||||||
|
# instantaneousPower is a dead field on every TP1-class washer dump collected
|
||||||
|
# so far (7 dumps, 3 physical devices): always the literal sentinel '-500',
|
||||||
|
# unchanged between off/idle-on/mid-cycle states and across different
|
||||||
|
# courses. common.clamp_power floors that to a misleading "0 W" that reads
|
||||||
|
# as a real (if idle) measurement rather than "unsupported". Gate the entity
|
||||||
|
# out entirely when the sentinel is seen, but only then -- if some washer
|
||||||
|
# model ever reports a real value, this still shows it.
|
||||||
|
#
|
||||||
|
# cumulativePower is absent outright on at least one washer model (issue #6),
|
||||||
|
# unlike every other washer dump. entity.py's generic field-presence gate
|
||||||
|
# already excludes the entity in that case; the exists_fn here just makes
|
||||||
|
# that explicit at the capability level instead of relying on the fallback.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_DEAD_INSTANTANEOUS_POWER = '-500'
|
||||||
|
|
||||||
|
|
||||||
|
WASHER_ENERGY_METER = Capability(
|
||||||
|
href='/energy/consumption/vs/0',
|
||||||
|
entities=(
|
||||||
|
SensorDesc(key='power_watts', field='x.com.samsung.da.instantaneousPower',
|
||||||
|
name='Power', device_class='power', state_class='measurement',
|
||||||
|
unit='W', value_fn=clamp_power,
|
||||||
|
exists_fn=lambda rep, resources: (
|
||||||
|
rep.get('x.com.samsung.da.instantaneousPower')
|
||||||
|
!= _DEAD_INSTANTANEOUS_POWER)),
|
||||||
|
SensorDesc(key='energy_kwh', field='x.com.samsung.da.cumulativePower',
|
||||||
|
name='Energy', device_class='energy',
|
||||||
|
state_class='total_increasing', unit='kWh', value_fn=wh_to_kwh,
|
||||||
|
exists_fn=lambda rep, resources: 'x.com.samsung.da.cumulativePower' in rep),
|
||||||
|
),
|
||||||
|
)
|
||||||
@@ -38,6 +38,7 @@ class SensorDesc(SamsungEntityDescription):
|
|||||||
device_class: Optional[str] = None
|
device_class: Optional[str] = None
|
||||||
state_class: Optional[str] = None
|
state_class: Optional[str] = None
|
||||||
unit: Optional[str] = None
|
unit: Optional[str] = None
|
||||||
|
unit_fn: Optional[Callable[[dict], str]] = None # overrides `unit` from the live rep, when set
|
||||||
options: Optional[tuple] = None # required by HA when device_class == 'enum'
|
options: Optional[tuple] = None # required by HA when device_class == 'enum'
|
||||||
|
|
||||||
|
|
||||||
@@ -72,6 +73,7 @@ class ButtonDesc(SamsungEntityDescription):
|
|||||||
class NumberDesc(SamsungEntityDescription):
|
class NumberDesc(SamsungEntityDescription):
|
||||||
device_class: Optional[str] = None
|
device_class: Optional[str] = None
|
||||||
unit: Optional[str] = None
|
unit: Optional[str] = None
|
||||||
|
unit_fn: Optional[Callable[[dict], str]] = None # overrides `unit` from the live rep, when set
|
||||||
native_min: Optional[float] = None
|
native_min: Optional[float] = None
|
||||||
native_max: Optional[float] = None
|
native_max: Optional[float] = None
|
||||||
step: Optional[float] = None
|
step: Optional[float] = None
|
||||||
|
|||||||
@@ -43,6 +43,13 @@ class LocalThingsSensor(LocalThingsEntity, SensorEntity):
|
|||||||
if desc.options:
|
if desc.options:
|
||||||
self._attr_options = list(desc.options)
|
self._attr_options = list(desc.options)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def native_unit_of_measurement(self):
|
||||||
|
desc: SensorDesc = self._bound.desc
|
||||||
|
if desc.unit_fn is not None:
|
||||||
|
return desc.unit_fn(self.coordinator.resource(self._bound.href))
|
||||||
|
return self._attr_native_unit_of_measurement
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def native_value(self):
|
def native_value(self):
|
||||||
return (self.coordinator.data or {}).get(self._state_key)
|
return (self.coordinator.data or {}).get(self._state_key)
|
||||||
|
|||||||
+2
-2
@@ -10,7 +10,7 @@
|
|||||||
"cooler_setpoint",
|
"cooler_setpoint",
|
||||||
"cooler_temperature",
|
"cooler_temperature",
|
||||||
"day_brightness",
|
"day_brightness",
|
||||||
"defrost_blocked",
|
"defrost_active",
|
||||||
"defrost_delay",
|
"defrost_delay",
|
||||||
"door_alert",
|
"door_alert",
|
||||||
"door_cooler_open",
|
"door_cooler_open",
|
||||||
@@ -50,7 +50,7 @@
|
|||||||
"samsung_refrigerator_cooler_setpoint",
|
"samsung_refrigerator_cooler_setpoint",
|
||||||
"samsung_refrigerator_cooler_temperature",
|
"samsung_refrigerator_cooler_temperature",
|
||||||
"samsung_refrigerator_day_brightness",
|
"samsung_refrigerator_day_brightness",
|
||||||
"samsung_refrigerator_defrost_blocked",
|
"samsung_refrigerator_defrost_active",
|
||||||
"samsung_refrigerator_defrost_delay",
|
"samsung_refrigerator_defrost_delay",
|
||||||
"samsung_refrigerator_door_alert",
|
"samsung_refrigerator_door_alert",
|
||||||
"samsung_refrigerator_door_cooler_open",
|
"samsung_refrigerator_door_cooler_open",
|
||||||
|
|||||||
Vendored
-1
@@ -15,7 +15,6 @@
|
|||||||
"job_beginning_status",
|
"job_beginning_status",
|
||||||
"machine_state",
|
"machine_state",
|
||||||
"power_switch",
|
"power_switch",
|
||||||
"power_watts",
|
|
||||||
"progress",
|
"progress",
|
||||||
"progress_percentage",
|
"progress_percentage",
|
||||||
"remote_control",
|
"remote_control",
|
||||||
|
|||||||
@@ -154,6 +154,20 @@ class TestForDeviceByModel:
|
|||||||
assert reg is not None
|
assert reg is not None
|
||||||
assert reg.name == 'refrigerator'
|
assert reg.name == 'refrigerator'
|
||||||
|
|
||||||
|
def test_refrigerator_rl_series_via_ref_token(self):
|
||||||
|
"""Issue #7: RL38C6B0CWW/EG (a bottom-freezer RL-series fridge, not
|
||||||
|
the RF9000-style french-door this module was originally verified
|
||||||
|
against) reports description/modelNum 'TP1X_REF_21K' -- same
|
||||||
|
internal platform code as any other TP1X-based fridge, so the
|
||||||
|
existing '_REF_' fallback already resolves it correctly."""
|
||||||
|
from custom_components.localthings.registry.by_type import for_device_by_model
|
||||||
|
reg = for_device_by_model(
|
||||||
|
'TP1X_REF_21K|00156941|00050126001611304100000031010000',
|
||||||
|
'TP1X_REF_21K',
|
||||||
|
)
|
||||||
|
assert reg is not None
|
||||||
|
assert reg.name == 'refrigerator'
|
||||||
|
|
||||||
def test_unknown_model_returns_none(self):
|
def test_unknown_model_returns_none(self):
|
||||||
from custom_components.localthings.registry.by_type import for_device_by_model
|
from custom_components.localthings.registry.by_type import for_device_by_model
|
||||||
reg = for_device_by_model('SOME-UNKNOWN-BOARD', 'SOME-UNKNOWN-BOARD')
|
reg = for_device_by_model('SOME-UNKNOWN-BOARD', 'SOME-UNKNOWN-BOARD')
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
"""Tests for fridge-specific capabilities."""
|
||||||
|
from custom_components.localthings.registry.capabilities import fridge
|
||||||
|
|
||||||
|
|
||||||
|
class TestTempCurrentGeneric:
|
||||||
|
"""Issue #7: unit must be read live from the device, not assumed to be
|
||||||
|
Fahrenheit -- a TP1X_REF_21K fridge (RL38C6B0CWW/EG) reports the same
|
||||||
|
resource in Celsius."""
|
||||||
|
|
||||||
|
def test_unit_reads_celsius(self):
|
||||||
|
desc = fridge.TEMP_CURRENT_GENERIC.entities[0]
|
||||||
|
assert desc.unit_fn({'temperature': 3.0, 'units': 'C'}) == '°C'
|
||||||
|
|
||||||
|
def test_unit_reads_fahrenheit(self):
|
||||||
|
desc = fridge.TEMP_CURRENT_GENERIC.entities[0]
|
||||||
|
assert desc.unit_fn({'temperature': 5.0, 'units': 'F'}) == '°F'
|
||||||
|
|
||||||
|
def test_unit_defaults_to_fahrenheit_when_missing(self):
|
||||||
|
desc = fridge.TEMP_CURRENT_GENERIC.entities[0]
|
||||||
|
assert desc.unit_fn({'temperature': 5.0}) == '°F'
|
||||||
|
|
||||||
|
|
||||||
|
class TestTempSetpointGeneric:
|
||||||
|
def test_unit_reads_celsius(self):
|
||||||
|
desc = fridge.TEMP_SETPOINT_GENERIC.entities[0]
|
||||||
|
assert desc.unit_fn({'temperature': -19.0, 'units': 'C'}) == '°C'
|
||||||
|
|
||||||
|
|
||||||
|
class TestTemperaturesFallback:
|
||||||
|
"""Aggregate /temperatures/vs/0 -- per-item unit from
|
||||||
|
x.com.samsung.da.unit ('Celsius'/'Fahrenheit')."""
|
||||||
|
|
||||||
|
def test_freezer_unit_celsius(self):
|
||||||
|
desc = next(e for e in fridge.TEMPERATURES_FALLBACK.entities
|
||||||
|
if e.key == 'freezer_temperature')
|
||||||
|
rep = {'x.com.samsung.da.items': [
|
||||||
|
{'x.com.samsung.da.description': 'Freezer', 'x.com.samsung.da.current': '-19',
|
||||||
|
'x.com.samsung.da.unit': 'Celsius'},
|
||||||
|
{'x.com.samsung.da.description': 'Fridge', 'x.com.samsung.da.current': '3',
|
||||||
|
'x.com.samsung.da.unit': 'Celsius'},
|
||||||
|
]}
|
||||||
|
assert desc.unit_fn(rep) == '°C'
|
||||||
|
assert desc.value_fn(rep['x.com.samsung.da.items']) == -19
|
||||||
|
|
||||||
|
def test_fridge_unit_fahrenheit(self):
|
||||||
|
desc = next(e for e in fridge.TEMPERATURES_FALLBACK.entities
|
||||||
|
if e.key == 'fridge_temperature')
|
||||||
|
rep = {'x.com.samsung.da.items': [
|
||||||
|
{'x.com.samsung.da.description': 'Fridge', 'x.com.samsung.da.current': '37',
|
||||||
|
'x.com.samsung.da.unit': 'Fahrenheit'},
|
||||||
|
]}
|
||||||
|
assert desc.unit_fn(rep) == '°F'
|
||||||
|
|
||||||
|
def test_unit_defaults_to_fahrenheit_when_item_missing(self):
|
||||||
|
desc = next(e for e in fridge.TEMPERATURES_FALLBACK.entities
|
||||||
|
if e.key == 'freezer_temperature')
|
||||||
|
assert desc.unit_fn({'x.com.samsung.da.items': []}) == '°F'
|
||||||
|
|
||||||
|
|
||||||
|
class TestDefrostBlockStatus:
|
||||||
|
"""DEFROST_BLOCK_ON means the defrost cycle is actively running, not
|
||||||
|
that defrost is being withheld -- confirmed against live dumps showing
|
||||||
|
it ON while defrost_delay is off."""
|
||||||
|
|
||||||
|
def test_key_and_name_reflect_active_defrosting(self):
|
||||||
|
desc = fridge.DEFROST_BLOCK_STATUS.entities[0]
|
||||||
|
assert desc.key == 'defrost_active'
|
||||||
|
|
||||||
|
def test_value_fn(self):
|
||||||
|
desc = fridge.DEFROST_BLOCK_STATUS.entities[0]
|
||||||
|
assert desc.value_fn(['DEFROST_BLOCK_ON']) is True
|
||||||
|
assert desc.value_fn(['DEFROST_BLOCK_OFF']) is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestRefrigerationFallback:
|
||||||
|
"""/refrigeration/0 duplicates two of REFRIGERATION's (/refrigeration/vs/0)
|
||||||
|
fields under different names (rapidFreeze/rapidCool vs
|
||||||
|
rapidFreezing/rapidFridge) but also carries one genuinely new field
|
||||||
|
(defrost) that has no vs-href equivalent."""
|
||||||
|
|
||||||
|
def test_href(self):
|
||||||
|
assert fridge.REFRIGERATION_FALLBACK.href == '/refrigeration/0'
|
||||||
|
|
||||||
|
def test_defrost_active_is_fallback_of_defrost_block_status(self):
|
||||||
|
# Duplicates DEFROST_BLOCK_STATUS's defrost_active
|
||||||
|
# (/defrost/block/vs/0) -- only a true fallback when that richer
|
||||||
|
# href is absent (issue #7's device: /refrigeration/0 only).
|
||||||
|
desc = next(e for e in fridge.REFRIGERATION_FALLBACK.entities
|
||||||
|
if e.key == 'defrost_active')
|
||||||
|
assert desc.value_fn(True) is True
|
||||||
|
assert desc.value_fn(False) is False
|
||||||
|
assert desc.exists_fn({}, {'/refrigeration/0': {}}) is True
|
||||||
|
assert desc.exists_fn(
|
||||||
|
{}, {'/refrigeration/0': {}, '/defrost/block/vs/0': {}}) is False
|
||||||
|
|
||||||
|
def test_rapid_switches_hidden_when_vs_href_present(self):
|
||||||
|
for key in ('rapid_fridge', 'rapid_freezing'):
|
||||||
|
desc = next(e for e in fridge.REFRIGERATION_FALLBACK.entities if e.key == key)
|
||||||
|
assert desc.exists_fn({}, {'/refrigeration/vs/0': {}, '/refrigeration/0': {}}) is False
|
||||||
|
|
||||||
|
def test_rapid_switches_shown_when_vs_href_absent(self):
|
||||||
|
for key in ('rapid_fridge', 'rapid_freezing'):
|
||||||
|
desc = next(e for e in fridge.REFRIGERATION_FALLBACK.entities if e.key == key)
|
||||||
|
assert desc.exists_fn({}, {'/refrigeration/0': {}}) is True
|
||||||
@@ -238,3 +238,29 @@ class TestRemoteControlFallback:
|
|||||||
assert washer.REMOTE_CONTROL_VS_FALLBACK.match_fn({}, {'/remotectrl/vs/0': {}}) is True
|
assert washer.REMOTE_CONTROL_VS_FALLBACK.match_fn({}, {'/remotectrl/vs/0': {}}) is True
|
||||||
assert washer.REMOTE_CONTROL_VS_FALLBACK.match_fn(
|
assert washer.REMOTE_CONTROL_VS_FALLBACK.match_fn(
|
||||||
{}, {'/remotectrl/0': {}, '/remotectrl/vs/0': {}}) is False
|
{}, {'/remotectrl/0': {}, '/remotectrl/vs/0': {}}) is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestWasherEnergyMeter:
|
||||||
|
"""Issue #6: instantaneousPower is a dead sentinel ('-500', unchanged
|
||||||
|
across off/idle-on/running-eco/running-fabrics states and across 3
|
||||||
|
physical devices) on every TP1-class washer dump collected so far;
|
||||||
|
cumulativePower is outright absent on at least one washer model."""
|
||||||
|
|
||||||
|
def test_href(self):
|
||||||
|
assert washer.WASHER_ENERGY_METER.href == '/energy/consumption/vs/0'
|
||||||
|
|
||||||
|
def test_power_watts_hidden_for_dead_sentinel(self):
|
||||||
|
desc = next(e for e in washer.WASHER_ENERGY_METER.entities if e.key == 'power_watts')
|
||||||
|
assert desc.exists_fn({'x.com.samsung.da.instantaneousPower': '-500'}, {}) is False
|
||||||
|
|
||||||
|
def test_power_watts_shown_for_real_value(self):
|
||||||
|
desc = next(e for e in washer.WASHER_ENERGY_METER.entities if e.key == 'power_watts')
|
||||||
|
assert desc.exists_fn({'x.com.samsung.da.instantaneousPower': '150'}, {}) is True
|
||||||
|
|
||||||
|
def test_energy_kwh_hidden_when_cumulative_power_absent(self):
|
||||||
|
desc = next(e for e in washer.WASHER_ENERGY_METER.entities if e.key == 'energy_kwh')
|
||||||
|
assert desc.exists_fn({'x.com.samsung.da.instantaneousPower': '-500'}, {}) is False
|
||||||
|
|
||||||
|
def test_energy_kwh_shown_when_present(self):
|
||||||
|
desc = next(e for e in washer.WASHER_ENERGY_METER.entities if e.key == 'energy_kwh')
|
||||||
|
assert desc.exists_fn({'x.com.samsung.da.cumulativePower': '58900'}, {}) is True
|
||||||
Reference in new issue
Block a user