Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4cc073c88a | ||
|
|
91013ece60 | ||
|
|
795763f3ca | ||
|
|
a7ed230569 | ||
|
|
9fee5b5ec0 | ||
|
|
f24d6a0ae6 | ||
|
|
29b145ccdd | ||
|
|
5fbf58af30 | ||
|
|
9ae59889d4 | ||
|
|
35f76ce2df | ||
|
|
2a6bbc9dfb | ||
|
|
aee19fd953 | ||
|
|
37bd063c0a | ||
|
|
e875266405 | ||
|
|
9b48ca0e82 | ||
|
|
e66f4fd2ff | ||
|
|
bf3b3a8bbd | ||
|
|
dd541b19a1 |
@@ -2,31 +2,27 @@
|
||||
|
||||
**A native Home Assistant custom integration for local control of newer-generation Samsung connected appliances.** No cloud round-trip. Add a device through HA's normal *Settings > Devices & Services* flow and it talks CoAP-over-DTLS straight to the appliance on your LAN.
|
||||
|
||||
> ### Where things live
|
||||
>
|
||||
> This project split into two repos partway through development.
|
||||
>
|
||||
> - **[`smartthings-local`](https://github.com/QuiteYellow/SmartThings-Local)** (PyPI package): the reusable protocol layer. DTLS session handling, CoAP wire encoding, Block2 reads, bounded retry/retransmit, inter-request rate limiting, cert-chain validation. No HA dependency; usable from any Python project.
|
||||
> - **This repo**: the Home Assistant integration built on top of it. Config flow, a per-device-type capability registry, the polling coordinator, and all the HA entity classes.
|
||||
>
|
||||
> `custom_components/localthings/manifest.json` pulls in `smartthings-local` from PyPI like any other HA integration dependency.
|
||||
This integration uses the [`smartthings-local`](https://github.com/QuiteYellow/SmartThings-Local) library to handle the low-level DTLS/CoAP communication with devices.
|
||||
|
||||
### What you get
|
||||
|
||||
Adding a device just needs a host IP and your CA credentials in the UI. The integration reads the appliance's `oneUiVersion` and picks the matching capability registry (dryer, oven, dishwasher, refrigerator) on its own, so there's no per-model descriptor to write for a new unit of a type that's already supported.
|
||||
Adding a device just needs a host IP and your CA credentials in the UI. The integration reads the appliance's identity and picks the matching capability registry on its own, so there's no per-model descriptor to write for a new unit of a type that's already supported.
|
||||
|
||||
Credential setup is one-time. The first device you add asks for the AC14K_M CA cert and key (see Part 2); every device after that reuses the same stored CA and only asks for the host IP, minting its own per-device leaf cert automatically.
|
||||
Credential setup is one-time. The first device you add asks for a CA certificate and key (see Part 2); every device after that reuses the same stored CA and only asks for the host IP, minting its own per-device leaf cert automatically.
|
||||
|
||||
Your state stays on your LAN: HA talks to the appliance over a direct DTLS session, and Samsung's cloud sees nothing from this integration. (The appliance itself still maintains its own connection to Samsung; that's firmware behavior on the device side, not something this integration controls.)
|
||||
|
||||
### Supported appliance types
|
||||
|
||||
| Type | Registry | Notable capabilities |
|
||||
|---|---|---|
|
||||
| Dryer | `by_type/dryer.py` | Power, kids lock, remote control, alarms, energy meter, operational state, door LED, sound mode, dryer settings/course, job-beginning status, diagnosis, firmware-update sensor |
|
||||
| Oven | `by_type/oven.py` | Power, kids lock, remote control, alarms, cavity state, setpoint, mode, operational state, door, connectivity, firmware-update sensor |
|
||||
| Dishwasher | `by_type/dishwasher.py` | Power, kids lock, remote control, alarms, energy + water meters, water filter, operational state, cycle options/settings, door LED, sound mode/volume, firmware-update sensor |
|
||||
| Refrigerator | `by_type/refrigerator.py` | Power, kids lock, remote control, alarms, energy meter, water filter, status lock, door alert, icemaker (nighttime + generic per-compartment), flex zone, refrigeration mode, autofill, welcome/cabinet lighting, Sabbath mode, beverage zone, plus pattern-matched per-compartment temperature/setpoint/icemaker/door capabilities for multi-cavity fridges, firmware-update sensor |
|
||||
| Type | Registry |
|
||||
|---|---|
|
||||
| Dryer | `by_type/dryer.py` |
|
||||
| Oven | `by_type/oven.py` |
|
||||
| Dishwasher | `by_type/dishwasher.py` |
|
||||
| Refrigerator | `by_type/refrigerator.py` |
|
||||
| Washer | `by_type/washer.py` |
|
||||
|
||||
Each registry composes shared and family-specific `Capability` objects from `registry/capabilities/`; those modules document the individual resources/entities in more depth than a README table can stay current with.
|
||||
|
||||
Other Tizen RT / DAWIT-family appliances almost certainly speak the same protocol underneath, since the auth path and CoAP primitives are shared across the fleet. Adding a new type means writing a new `by_type/<name>.py` registry file; it doesn't require reverse-engineering the protocol again. See **Adding a new appliance type** below.
|
||||
|
||||
@@ -46,16 +42,10 @@ nmap -Pn -sU -p 49152-49160 "$APPLIANCE_IP"
|
||||
|
||||
## Part 2: One-time setup, get the AC14K_M CA credentials
|
||||
|
||||
The config flow (Part 3) needs a **CA certificate and CA private key** to mint each device's leaf cert itself. Specifically, it needs the `AC14K_M` intermediate CA: a cert chain that's been public for years and still ships in current Samsung firmware trust stores. It's required because every Samsung Tizen/RT-OCF appliance's factory ACL grants full CRUDN access (`perm=31` on `href=*`) to whatever identity is chained to that CA, so a cert signed by it is the one thing that lets HA talk to your appliance without Samsung's cloud in the loop. HA doesn't need the *device's* original cert or key, only something `AC14K_M` has signed, and it mints that itself once you give it the CA.
|
||||
The config flow (Part 3) needs a **CA certificate and CA private key** to mint each device's leaf cert itself. Specifically, it needs the `AC14K_M` intermediate CA — a cert chain that's been public for years and still ships in current Samsung firmware trust stores. Every Samsung Tizen/RT-OCF appliance trusts identities chained to that CA with full access by default, so a cert signed by it is what lets HA talk to your appliance without Samsung's cloud in the loop. HA doesn't need the *device's* original cert or key, only something `AC14K_M` has signed, and it mints that itself once you give it the CA.
|
||||
|
||||
This repo doesn't include the needed CA bundle. For an example of how to obtain it, including fetching the AC14K_M cert and key and verifying they pair, see the `smartthings-local` protocol project's [`setup_cert.py`](https://github.com/QuiteYellow/SmartThings-Local/blob/main/setup_cert.py). However you obtain the CA cert and key, paste their PEM contents into the HA config flow's "CA Certificate (PEM)" and "CA Private Key (PEM)" fields in Part 3. You only need to do this once, since every appliance you add afterward reuses the same stored CA.
|
||||
|
||||
### Why this works
|
||||
|
||||
- Every Samsung Tizen/RT-OCF appliance has a factory-baked ACE in `/oic/sec/acl` granting the AC14K_M-chained identity `perm=31` on `href=*`.
|
||||
- TizenRT iotivity derives the peer ID via `memmem(subject_dn, "uuid:")`, which is RDN-agnostic, so a cert with the UUID in any RDN authenticates the same way.
|
||||
- You don't need the original keyholder's private key. The config flow mints its own key and has `AC14K_M` sign the leaf: different key, same identity, same access.
|
||||
|
||||
---
|
||||
|
||||
## Part 3: Add the integration in Home Assistant
|
||||
@@ -80,7 +70,7 @@ docker compose up -d --build
|
||||
docker compose logs -f
|
||||
```
|
||||
|
||||
The `Dockerfile` builds on the official `home-assistant/home-assistant:stable` image and pre-installs `smartthings-local`, so the dependency is present at container start instead of depending on HA's own runtime pip-install step (which needs outbound network access at exactly the moment the integration loads, and repeats on every container recreate). Re-run with `--build` whenever the pinned `smartthings-local` version changes.
|
||||
The `Dockerfile` builds on the official `home-assistant/home-assistant:stable` image and pre-installs `smartthings-local`, so the dependency is present at container start instead of depending on HA's own runtime pip-install step. Re-run with `--build` whenever the pinned `smartthings-local` version changes.
|
||||
|
||||
`docker-compose.yml` sets `network_mode: host`, which is required since DTLS is UDP and won't traverse Docker's bridge NAT to reach LAN appliances, and bind-mounts `custom_components/localthings/` read-only into `ha_config/custom_components/`. Bump `custom_components.localthings` to `debug` in `ha_config/configuration.yaml` for verbose protocol logging.
|
||||
|
||||
@@ -93,7 +83,7 @@ python3 -m venv .venv
|
||||
.venv/bin/pytest tests/ -q
|
||||
```
|
||||
|
||||
`requirements-dev.txt` pins `smartthings-local` the same way `manifest.json` does, so tests exercise the real published protocol layer rather than a vendored copy.
|
||||
A large suite covering registry composition, discovery, entity descriptors, and golden-file regression against captured device dumps. `requirements-dev.txt` pins `smartthings-local` the same way `manifest.json` does, so tests exercise the real published protocol layer rather than a vendored copy.
|
||||
|
||||
---
|
||||
|
||||
@@ -101,29 +91,32 @@ python3 -m venv .venv
|
||||
|
||||
```
|
||||
custom_components/localthings/
|
||||
manifest.json Requirements (incl. the smartthings-local PyPI dep), version, domain
|
||||
__init__.py async_setup_entry / async_unload_entry
|
||||
config_flow.py UUID fetch, leaf cert minting, port probing, config entry creation
|
||||
coordinator.py DataUpdateCoordinator: polling, stale-state fallback, write dispatch
|
||||
const.py Domain, config keys, probe ports
|
||||
entity.py Base entity wiring capability registry -> HA entity
|
||||
manifest.json Requirements (incl. the smartthings-local PyPI dep), version, domain
|
||||
__init__.py async_setup_entry / async_unload_entry
|
||||
config_flow.py UUID fetch, leaf cert minting, port probing, config entry creation
|
||||
coordinator.py Polling + push update coordination, stale-state fallback, write dispatch
|
||||
observe.py CoAP OBSERVE (push-mode) support layered on the coordinator
|
||||
diagnostics.py Redacted diagnostics download (device state + coverage metadata)
|
||||
const.py Domain, config keys, probe ports
|
||||
entity.py Base entity wiring capability registry -> HA entity
|
||||
sensor.py / binary_sensor.py / switch.py / number.py / select.py / button.py / time.py
|
||||
One module per HA platform
|
||||
One module per HA platform
|
||||
strings.json / translations/ Config-flow copy + entity state translations
|
||||
registry/
|
||||
batch.py /device/0 batch response parsing
|
||||
capability.py Capability dataclass (href, entities, transforms)
|
||||
entities.py Per-platform entity descriptor dataclasses
|
||||
discovery.py Binds a device's live resources to registered capabilities
|
||||
adapter.py Flattens bound entities into HA-ready state
|
||||
identity.py Reads device identity (serial, oneUiVersion) for type detection
|
||||
capabilities/ Shared + per-family Capability definitions (common, dryer, oven,
|
||||
dishwasher, fridge, laundry, operational)
|
||||
by_type/ One DeviceRegistry per appliance type, composed from capabilities/
|
||||
tests/ 80+ tests: registry composition, discovery, entity descriptors,
|
||||
golden-file regression against captured device dumps
|
||||
requirements-dev.txt Test deps, including the smartthings-local package
|
||||
docker-compose.yml / ha_config/ Local HA dev environment
|
||||
registry.py Builds the global capability registry, validates href collisions
|
||||
capability.py Capability dataclass (href, entities, transforms)
|
||||
entities.py Per-platform entity descriptor dataclasses
|
||||
discovery.py Binds a device's live resources to registered capabilities
|
||||
adapter.py Flattens bound entities into HA-ready state
|
||||
identity.py Reads device identity for type detection
|
||||
redact.py Strips account/identity data before diagnostics leave HA
|
||||
capabilities/ Shared + per-family Capability definitions (common, dryer, oven,
|
||||
dishwasher, fridge, washer, laundry, operational, ignored)
|
||||
by_type/ One DeviceRegistry per appliance type, composed from capabilities/
|
||||
tests/ Registry composition, discovery, entity descriptors, coordinator/observe
|
||||
behavior, and golden-file regression against captured device dumps
|
||||
requirements-dev.txt Test deps, including the smartthings-local package
|
||||
docker-compose.yml / ha_config/ Local HA dev environment
|
||||
```
|
||||
|
||||
---
|
||||
@@ -141,21 +134,21 @@ support for hardware the maintainers don't have.
|
||||
|
||||
## Adding a new appliance type
|
||||
|
||||
1. Capture the appliance's `/device/0` response to see what resources/fields it exposes. An authenticated `DtlsCoapSession` from `smartthings_local.protocol.dtls_session` GET is enough; `local-tools/probe_device.py` wraps this.
|
||||
1. Get a capture of the appliance's `/device/0` response. The easiest way: add the device to HA (type detection failing is fine) and pull its Diagnostics download from Settings > Devices & Services > the device > the menu > Download diagnostics — it already contains a redacted dump of the device's resources.
|
||||
2. Reuse existing `Capability` objects from `registry/capabilities/` wherever the resource matches one already declared. Most `common.py` capabilities (power, kids lock, remote control, alarms, energy/water meters) are shared verbatim across families; add new ones only for resources unique to the new type.
|
||||
3. Create `registry/by_type/<name>.py` with a `DeviceRegistry(name=..., capabilities=_build([...]))`. Use `pattern_capabilities` instead of `capabilities` for any resource whose `href` isn't fixed (for example per-compartment fridge resources); see `refrigerator.py` for the pattern.
|
||||
4. Register it in `_REGISTRY_BY_KEY` in `registry/by_type/__init__.py`, keyed on the lowercased, space/hyphen-to-underscore-converted suffix of the device's `oneUiVersion` string (see `_type_key()` in that file for the exact transform).
|
||||
4. Register it in `_REGISTRY_BY_KEY` in `registry/by_type/__init__.py`, keyed on the lowercased, space/hyphen-to-underscore-converted suffix of the device's `oneUiVersion` string (see `_type_key()` in that file for the exact transform). If the device never reports `oneUiVersion` — confirmed true for washers — add its consumer-model prefix to `_CONSUMER_PREFIX_TO_KEY` instead, so `for_device_by_model()` can route it.
|
||||
5. Add golden-file coverage in `tests/` against a captured `/device/0` dump for the new type.
|
||||
|
||||
No config-flow or coordinator changes are needed. Device-type detection and entity wiring are fully driven by the registry.
|
||||
No config-flow changes are needed. Device-type detection and entity wiring are fully driven by the registry.
|
||||
|
||||
---
|
||||
|
||||
## Known DTLS behavior
|
||||
## Known device behavior
|
||||
|
||||
Samsung's RT-OCF DTLS stack occasionally closes sessions actively, usually right after a Block2 GET or in the seconds after a POST. Retry/retransmit bounds and inter-request pacing live in the `smartthings-local` protocol layer (tuned against measured per-firmware request-rate ceilings); reconnect-with-backoff and stale-state fallback live in this repo's `coordinator.py`. From HA's perspective a brief reconnect looks like an entity holding its last value for one poll cycle rather than going `unavailable`.
|
||||
Samsung's firmware occasionally drops the DTLS session briefly — this is normal appliance-side behavior, not a bug. The integration reconnects automatically, and from HA's perspective a brief reconnect looks like an entity holding its last value for one poll cycle rather than going `unavailable`. When an appliance supports it, the integration prefers push-based updates (instant, via `observe.py`) over polling, falling back to polling otherwise.
|
||||
|
||||
If reconnects become persistent (more than a handful per minute), something's actually wrong. Check the appliance's Wi-Fi link first, then look for a competing DTLS client on the LAN: Samsung's RT-OCF DTLS allows only one active session per peer.
|
||||
If reconnects become persistent (more than a handful per minute), something's actually wrong. Check the appliance's Wi-Fi link first, then look for a competing DTLS client on the LAN — only one active session per appliance is allowed at a time.
|
||||
|
||||
---
|
||||
|
||||
@@ -163,7 +156,7 @@ If reconnects become persistent (more than a handful per minute), something's ac
|
||||
|
||||
Patches are welcome, especially:
|
||||
|
||||
- New `by_type/` registries for appliance families not yet covered (washer, AC, microwave, etc.) on the same Tizen RT 3.x firmware family.
|
||||
- New `by_type/` registries for appliance families not yet covered (AC, microwave, etc.) on the same Tizen RT 3.x firmware family.
|
||||
- Confirmation or refutation of compatibility on additional models within an already-supported type.
|
||||
- Protocol-level fixes, which belong upstream in [`smartthings-local`](https://github.com/QuiteYellow/SmartThings-Local) rather than here. HA-side fixes (entities, config flow, coordinator, registry) belong in this repo.
|
||||
|
||||
|
||||
@@ -115,6 +115,13 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
def last_resources(self) -> dict:
|
||||
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
|
||||
def observe_mode(self) -> str:
|
||||
return self._observe.mode
|
||||
|
||||
@@ -5,12 +5,12 @@
|
||||
"config_flow": true,
|
||||
"dependencies": [],
|
||||
"documentation": "https://github.com/mbillow/localthings",
|
||||
"iot_class": "local_polling",
|
||||
"iot_class": "local_push",
|
||||
"issue_tracker": "https://github.com/mbillow/localthings/issues",
|
||||
"requirements": [
|
||||
"cbor2>=5.4.6",
|
||||
"pyOpenSSL>=23.0",
|
||||
"smartthings-local>=0.1.0"
|
||||
],
|
||||
"version": "0.1.0"
|
||||
"version": "0.4.1"
|
||||
}
|
||||
|
||||
@@ -42,12 +42,18 @@ class LocalThingsNumber(LocalThingsEntity, NumberEntity):
|
||||
if desc.step is not None:
|
||||
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:
|
||||
desc: NumberDesc = self._bound.desc
|
||||
if not desc.range_field:
|
||||
return None
|
||||
rep = self.coordinator.last_resources.get(self._bound.href) or {}
|
||||
r = rep.get(desc.range_field)
|
||||
r = self.coordinator.resource(self._bound.href).get(desc.range_field)
|
||||
return r if (isinstance(r, (list, tuple)) and len(r) == 2) else None
|
||||
|
||||
@property
|
||||
|
||||
@@ -30,6 +30,7 @@ REGISTRY = DeviceRegistry(
|
||||
fridge.DOORS_FALLBACK,
|
||||
fridge.TEMPERATURES_FALLBACK,
|
||||
fridge.ICEMAKER_STATUS_FALLBACK,
|
||||
fridge.REFRIGERATION_FALLBACK,
|
||||
]),
|
||||
pattern_capabilities=[
|
||||
fridge.TEMP_CURRENT_GENERIC,
|
||||
|
||||
@@ -17,7 +17,7 @@ REGISTRY = DeviceRegistry(
|
||||
washer.BUZZER_SOUND,
|
||||
washer.WASHER_JOB_BEGINNING_STATUS,
|
||||
common.ALARMS,
|
||||
common.ENERGY_METER,
|
||||
washer.WASHER_ENERGY_METER,
|
||||
common.WATER_METER,
|
||||
operational.OPERATIONAL_STATE,
|
||||
dishwasher.DIAGNOSIS,
|
||||
|
||||
@@ -21,16 +21,29 @@ def _num(v):
|
||||
return None
|
||||
|
||||
|
||||
def _clamp_power(v):
|
||||
def clamp_power(v):
|
||||
n = _num(v)
|
||||
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)
|
||||
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):
|
||||
n = _num(v)
|
||||
return round(n / 1000.0, 1) if n is not None else None
|
||||
@@ -90,10 +103,10 @@ ENERGY_METER = Capability(
|
||||
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),
|
||||
unit='W', value_fn=clamp_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),
|
||||
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.
|
||||
|
||||
Temperature fields in all /temperature/* resources are in Fahrenheit on this
|
||||
model. Setpoints are NumberDesc with direct-write write_fn — generic caps
|
||||
derive the CoAP PUT path from href at write time.
|
||||
Temperature unit is read live from each resource, not assumed: the RF9000B
|
||||
dump reports Fahrenheit ("units": "F" / "x.com.samsung.da.unit": "Fahrenheit"),
|
||||
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
|
||||
/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,
|
||||
SwitchDesc, TimeDesc,
|
||||
)
|
||||
from .common import normalize_temp_unit
|
||||
|
||||
# Display names for the beverage zone, flex zone, ice type, and
|
||||
# ice-making-status enums below live in strings.json / translations/en.json,
|
||||
@@ -33,6 +38,12 @@ def _int(v):
|
||||
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/*)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -45,7 +56,7 @@ TEMP_CURRENT_GENERIC = Capability(
|
||||
entities=(
|
||||
SensorDesc(key='temperature', field='temperature',
|
||||
name=None, icon='mdi:thermometer',
|
||||
device_class='temperature', unit='°F',
|
||||
device_class='temperature', unit_fn=_temp_unit,
|
||||
state_class='measurement'),
|
||||
),
|
||||
)
|
||||
@@ -57,7 +68,7 @@ TEMP_SETPOINT_GENERIC = Capability(
|
||||
poll_tier='warm',
|
||||
entities=(
|
||||
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,
|
||||
range_field='range', entity_category='config',
|
||||
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
|
||||
# reports whether the defrost cycle is currently blocked as a result (a
|
||||
# derived status, not an independent control) — exposed as a read-only
|
||||
# diagnostic binary sensor.
|
||||
# /defrost/delay/vs/0 is the writable toggle to postpone a scheduled
|
||||
# defrost. /defrost/block/vs/0 is an unrelated, independently-varying
|
||||
# status: despite its "block" naming (originally assumed to mean "defrost
|
||||
# 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(
|
||||
@@ -198,8 +213,8 @@ DEFROST_BLOCK_STATUS = Capability(
|
||||
href='/defrost/block/vs/0',
|
||||
poll_tier='warm',
|
||||
entities=(
|
||||
BinarySensorDesc(key='defrost_blocked', field='x.com.samsung.da.modes',
|
||||
name='Defrost blocked', icon='mdi:snowflake-off',
|
||||
BinarySensorDesc(key='defrost_active', field='x.com.samsung.da.modes',
|
||||
name='Defrost active', icon='mdi:snowflake-melt',
|
||||
entity_category='diagnostic',
|
||||
value_fn=lambda modes: bool(modes) and modes[0] == 'DEFROST_BLOCK_ON'),
|
||||
),
|
||||
@@ -590,6 +605,13 @@ def _temp_item_value(items, keyword):
|
||||
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(
|
||||
href='/temperatures/vs/0',
|
||||
match_fn=lambda rep, resources: not _any_temperature_generic(resources),
|
||||
@@ -597,11 +619,15 @@ TEMPERATURES_FALLBACK = Capability(
|
||||
entities=(
|
||||
SensorDesc(key='freezer_temperature', field='x.com.samsung.da.items',
|
||||
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')),
|
||||
SensorDesc(key='fridge_temperature', field='x.com.samsung.da.items',
|
||||
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')),
|
||||
),
|
||||
)
|
||||
@@ -628,3 +654,34 @@ ICEMAKER_STATUS_FALLBACK = Capability(
|
||||
{'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
|
||||
# to expose. Revisit if a device surfaces more than one supported level.
|
||||
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
|
||||
# against — meaning unclear from the raw resource alone.
|
||||
Capability(href='/runningmode/vs/0'),
|
||||
|
||||
@@ -75,7 +75,9 @@ OPERATIONAL_STATE = Capability(
|
||||
poll_tier='hot',
|
||||
entities=(
|
||||
SensorDesc(key='machine_state', field='x.com.samsung.da.state',
|
||||
name='Machine state', value_fn=_to_ocf),
|
||||
name='Machine state', device_class='enum',
|
||||
options=('idle', 'active', 'pause'),
|
||||
translation_key='machine_state', value_fn=_to_ocf),
|
||||
# cycle_active is a bool derived from machine_state; used by the
|
||||
# adapter to gate oven writes (cycle_active_field='cycle_active').
|
||||
# Harmless for non-oven appliances — just an extra bool in state.
|
||||
@@ -93,9 +95,11 @@ OPERATIONAL_STATE = Capability(
|
||||
else _progress(rep.get('x.com.samsung.da.progress'))
|
||||
)),
|
||||
SensorDesc(key='progress_percentage',
|
||||
field='x.com.samsung.da.progressPercentage',
|
||||
name='Progress percent', unit='%', state_class='measurement',
|
||||
value_fn=_int),
|
||||
rep_fn=lambda rep: (
|
||||
0 if _SAMSUNG_STATE_TO_OCF.get(rep.get('x.com.samsung.da.state')) != 'active'
|
||||
else _int(rep.get('x.com.samsung.da.progressPercentage'))
|
||||
)),
|
||||
# Only show finish time when machine is actively running. Samsung
|
||||
# firmware leaves a stale remainingTime after a cycle ends, and
|
||||
# freezes it at '00:01:00' when progress reaches 'Finish'.
|
||||
|
||||
@@ -29,6 +29,7 @@ from ..entities import (
|
||||
BinarySensorDesc, ButtonDesc, NumberDesc, SelectDesc, SensorDesc,
|
||||
SwitchDesc,
|
||||
)
|
||||
from .common import normalize_temp_unit
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constants
|
||||
@@ -222,7 +223,9 @@ OVEN_OPERATIONAL_STATE = Capability(
|
||||
poll_tier='hot',
|
||||
entities=(
|
||||
SensorDesc(key='machine_state', field='x.com.samsung.da.state',
|
||||
name='Machine state', icon='mdi:stove', value_fn=_to_ocf),
|
||||
name='Machine state', icon='mdi:stove',
|
||||
device_class='enum', options=('idle', 'active', 'pause'),
|
||||
translation_key='machine_state', value_fn=_to_ocf),
|
||||
BinarySensorDesc(key='cycle_active', field='x.com.samsung.da.state',
|
||||
name='Cycle active', device_class='running',
|
||||
value_fn=lambda v: _SAMSUNG_STATE_TO_OCF.get(v) == 'active'),
|
||||
@@ -255,13 +258,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(
|
||||
href='/temperatures/vs/0',
|
||||
poll_tier='hot',
|
||||
entities=(
|
||||
# NumberDesc first — test_oven_setpoint_write_is_read_modify_write uses entities[0]
|
||||
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),
|
||||
step=float(SETPOINT_STEP_C), icon='mdi:thermometer-chevron-up',
|
||||
value_fn=lambda items: _int(
|
||||
@@ -269,7 +285,7 @@ OVEN_SETPOINT = Capability(
|
||||
write_fn=_oven_setpoint_write),
|
||||
SensorDesc(key='current_temp_c', field='x.com.samsung.da.items',
|
||||
name='Temperature', device_class='temperature',
|
||||
state_class='measurement', unit='°C',
|
||||
state_class='measurement', unit_fn=_oven_temp_unit,
|
||||
value_fn=lambda items: _int(
|
||||
(items[0].get('x.com.samsung.da.current') if items else None))),
|
||||
),
|
||||
|
||||
@@ -6,9 +6,13 @@ family). Washers never report `oneUiVersion` -- see
|
||||
`registry/by_type/__init__.py`'s `for_device_by_model()` for the fallback
|
||||
detection this device type requires.
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from ..capability import Capability
|
||||
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
|
||||
# under entity.select.washer_cycle.state.<id, lowercased> were captured
|
||||
@@ -43,12 +47,16 @@ from ..entities import BinarySensorDesc, SelectDesc, SensorDesc, SwitchDesc
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _hex_pairs(codes):
|
||||
"""'1C1D211B1E29...' -> ['1C', '1D', '21', '1B', '1E', '29', ...]."""
|
||||
return [codes[i:i + 2] for i in range(0, len(codes) - 1, 2)]
|
||||
|
||||
|
||||
def _parse_edit_course_list(raw):
|
||||
"""'EditCourseList_1C1D211B1E29...' -> ['1C', '1D', '21', '1B', '1E', '29', ...]."""
|
||||
if not isinstance(raw, str) or '_' not in raw:
|
||||
return []
|
||||
codes = raw.split('_', 1)[1]
|
||||
return [codes[i:i + 2] for i in range(0, len(codes) - 1, 2)]
|
||||
return _hex_pairs(raw.split('_', 1)[1])
|
||||
|
||||
|
||||
def _cycle_options(resources):
|
||||
@@ -114,6 +122,95 @@ def _cycle_write(p, rep, href=None):
|
||||
}
|
||||
|
||||
|
||||
# Drum Clean+ maintenance tracking, from the same options[] array as the
|
||||
# selected course. DrumCleanProposal_<N> is the wash-cycle interval between
|
||||
# recommended cleans; WashingTimes_<N> is the count since the last one --
|
||||
# their difference is exactly the "N cycles until due" figure the Samsung
|
||||
# app shows (verified: DrumCleanProposal_40 - WashingTimes_3 == 37, matching
|
||||
# a live app screenshot's "Potreba cistenia po 37 cykloch"). DrumCleanLog_
|
||||
# is the last-clean timestamp (verified against the same screenshot's "10
|
||||
# days ago"); no explicit timezone field accompanies it on this resource,
|
||||
# so it's treated as UTC, matching this integration's convention for other
|
||||
# bare ISO datetime fields (see fridge.py's night-light schedule comment).
|
||||
def _drum_clean_cycles_remaining(rep):
|
||||
opts = rep.get('x.com.samsung.da.options') or []
|
||||
proposal = _option_value(opts, 'DrumCleanProposal')
|
||||
washed = _option_value(opts, 'WashingTimes')
|
||||
if proposal is None or washed is None:
|
||||
return None
|
||||
try:
|
||||
return max(int(proposal) - int(washed), 0)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _drum_clean_last_cleaned(rep):
|
||||
raw = _option_value(rep.get('x.com.samsung.da.options'), 'DrumCleanLog')
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
return datetime.fromisoformat(raw).replace(tzinfo=timezone.utc)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
# Detergent/softener auto-dispense dosing, from the same options[] array
|
||||
# (issue #9). '<Prefix>LevelCtrl_<code>' is the selected dose quantity;
|
||||
# '<Prefix>Level2Ctrl_<code>' is a second dial -- water hardness for
|
||||
# detergent, concentration for softener -- matching the SmartThings app's
|
||||
# two-field dispenser screens ("Distributeur de lessive": Quantité + Dureté
|
||||
# de l'eau; "Distributeur d'adoucissant": Quantité + Concentration, per
|
||||
# issue #9's screenshots). 'Supported<Prefix>Ctrl_<hexpairs>' lists the
|
||||
# valid raw codes for its field, same hex-pair shape as EditCourseList.
|
||||
# '<Prefix>Alarm_<On/Off>' is a low-reservoir warning flag.
|
||||
#
|
||||
# Label mapping (entity.select.washer_dosing_quantity/washer_detergent_
|
||||
# water_hardness/washer_softener_concentration in strings.json) is an
|
||||
# assumed, not cross-device-verified, reading of the single issue #9 dump +
|
||||
# screenshots: LevelCtrl's 4 codes as None/Low/Medium/High (00 has no
|
||||
# on-screen equivalent -- the app's Quantité picker only offers
|
||||
# Faible/Moyen/Élevé, i.e. codes 01-03; 00 is assumed to be what
|
||||
# "Activation" off collapses to) matches DetergentLevelCtrl_3/
|
||||
# SoftenerLevelCtrl_3 = "Élevé" on both dispensers. Level2Ctrl's 3 codes as
|
||||
# Soft/Medium/Hard for detergent (Dureté de l'eau: Douce/Moyenne/Dure)
|
||||
# matches DetergentLevel2Ctrl_2 = "Moyenne". The same 3-code shape as
|
||||
# 1x/2x/3x for softener concentration does *not* cleanly match
|
||||
# SoftenerLevel2Ctrl_2 against the screenshot's "3x" -- assumed to be a
|
||||
# setting the user changed in the app between the dump (issue body) and the
|
||||
# screenshots (a later comment), not a different code scheme, since it's
|
||||
# otherwise identical in shape to the detergent side. Revisit if a second
|
||||
# device's dump contradicts this.
|
||||
def _supported_level_options(resources, prefix):
|
||||
rep = resources.get('/course/vs/0') or {}
|
||||
raw = _option_value(rep.get('x.com.samsung.da.options'), f'Supported{prefix}')
|
||||
return _hex_pairs(raw) if raw else []
|
||||
|
||||
|
||||
def _level_options(prefix):
|
||||
return lambda resources: _supported_level_options(resources, prefix)
|
||||
|
||||
|
||||
def _level_write(prefix):
|
||||
def write(p, rep, href=None):
|
||||
opts = list(rep.get('x.com.samsung.da.options') or [])
|
||||
if not opts:
|
||||
return None
|
||||
return ['course', 'vs', '0'], {
|
||||
'x.com.samsung.da.options': _replace_in_options(opts, prefix, p),
|
||||
}
|
||||
return write
|
||||
|
||||
|
||||
def _dosing_low(prefix):
|
||||
return lambda rep: _option_value(
|
||||
rep.get('x.com.samsung.da.options'), prefix) not in (None, 'Off')
|
||||
|
||||
|
||||
def _dosing_alarm_exists(prefix):
|
||||
return lambda rep, resources: _option_value(
|
||||
rep.get('x.com.samsung.da.options'), prefix) is not None
|
||||
|
||||
|
||||
WASHER_COURSE = Capability(
|
||||
href='/course/vs/0',
|
||||
entities=(
|
||||
@@ -124,6 +221,62 @@ WASHER_COURSE = Capability(
|
||||
rep_fn=lambda rep: _option_value(
|
||||
rep.get('x.com.samsung.da.options'), 'Course'),
|
||||
write_fn=_cycle_write),
|
||||
SensorDesc(key='drum_clean_cycles_remaining', name='Drum clean due in',
|
||||
icon='mdi:washing-machine-alert', unit='cycles',
|
||||
state_class='measurement',
|
||||
exists_fn=lambda rep, resources: _drum_clean_cycles_remaining(rep) is not None,
|
||||
rep_fn=_drum_clean_cycles_remaining),
|
||||
SensorDesc(key='drum_clean_last_cleaned', name='Drum last cleaned',
|
||||
icon='mdi:calendar-clock', device_class='timestamp',
|
||||
entity_category='diagnostic',
|
||||
exists_fn=lambda rep, resources: _drum_clean_last_cleaned(rep) is not None,
|
||||
rep_fn=_drum_clean_last_cleaned),
|
||||
SelectDesc(key='detergent_quantity', name='Detergent quantity', icon='mdi:cup-water',
|
||||
translation_key='washer_dosing_quantity',
|
||||
entity_category='config',
|
||||
options=_level_options('DetergentLevelCtrl'),
|
||||
exists_fn=lambda rep, resources: bool(
|
||||
_level_options('DetergentLevelCtrl')(resources)),
|
||||
rep_fn=lambda rep: _option_value(
|
||||
rep.get('x.com.samsung.da.options'), 'DetergentLevelCtrl'),
|
||||
write_fn=_level_write('DetergentLevelCtrl')),
|
||||
SelectDesc(key='detergent_water_hardness', name='Detergent water hardness',
|
||||
icon='mdi:water-opacity',
|
||||
translation_key='washer_detergent_water_hardness',
|
||||
entity_category='config',
|
||||
options=_level_options('DetergentLevel2Ctrl'),
|
||||
exists_fn=lambda rep, resources: bool(
|
||||
_level_options('DetergentLevel2Ctrl')(resources)),
|
||||
rep_fn=lambda rep: _option_value(
|
||||
rep.get('x.com.samsung.da.options'), 'DetergentLevel2Ctrl'),
|
||||
write_fn=_level_write('DetergentLevel2Ctrl')),
|
||||
SelectDesc(key='softener_quantity', name='Softener quantity', icon='mdi:flask-outline',
|
||||
translation_key='washer_dosing_quantity',
|
||||
entity_category='config',
|
||||
options=_level_options('SoftenerLevelCtrl'),
|
||||
exists_fn=lambda rep, resources: bool(
|
||||
_level_options('SoftenerLevelCtrl')(resources)),
|
||||
rep_fn=lambda rep: _option_value(
|
||||
rep.get('x.com.samsung.da.options'), 'SoftenerLevelCtrl'),
|
||||
write_fn=_level_write('SoftenerLevelCtrl')),
|
||||
SelectDesc(key='softener_concentration', name='Softener concentration',
|
||||
icon='mdi:flask-plus-outline',
|
||||
translation_key='washer_softener_concentration',
|
||||
entity_category='config',
|
||||
options=_level_options('SoftenerLevel2Ctrl'),
|
||||
exists_fn=lambda rep, resources: bool(
|
||||
_level_options('SoftenerLevel2Ctrl')(resources)),
|
||||
rep_fn=lambda rep: _option_value(
|
||||
rep.get('x.com.samsung.da.options'), 'SoftenerLevel2Ctrl'),
|
||||
write_fn=_level_write('SoftenerLevel2Ctrl')),
|
||||
BinarySensorDesc(key='detergent_low', name='Detergent low',
|
||||
icon='mdi:alert-circle-outline', device_class='problem',
|
||||
exists_fn=_dosing_alarm_exists('DetergentAlarm'),
|
||||
rep_fn=_dosing_low('DetergentAlarm')),
|
||||
BinarySensorDesc(key='softener_low', name='Softener low',
|
||||
icon='mdi:alert-circle-outline', device_class='problem',
|
||||
exists_fn=_dosing_alarm_exists('SoftenerAlarm'),
|
||||
rep_fn=_dosing_low('SoftenerAlarm')),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -248,3 +401,40 @@ REMOTE_CONTROL_VS_FALLBACK = Capability(
|
||||
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
|
||||
state_class: 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'
|
||||
|
||||
|
||||
@@ -72,6 +73,7 @@ class ButtonDesc(SamsungEntityDescription):
|
||||
class NumberDesc(SamsungEntityDescription):
|
||||
device_class: 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_max: Optional[float] = None
|
||||
step: Optional[float] = None
|
||||
|
||||
@@ -43,6 +43,13 @@ class LocalThingsSensor(LocalThingsEntity, SensorEntity):
|
||||
if 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
|
||||
def native_value(self):
|
||||
return (self.coordinator.data or {}).get(self._state_key)
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
},
|
||||
"ice_type": {
|
||||
"state": {
|
||||
"off": "Off",
|
||||
"whiskey_iceball_3": "3 Balls/Day",
|
||||
"whiskey_iceball_6": "6 Balls/Day",
|
||||
"whiskey_iceball_9": "9 Balls/Day"
|
||||
@@ -78,6 +79,28 @@
|
||||
"8f": "Intense Cold",
|
||||
"96": "Less Microfiber"
|
||||
}
|
||||
},
|
||||
"washer_dosing_quantity": {
|
||||
"state": {
|
||||
"00": "None",
|
||||
"01": "Low",
|
||||
"02": "Medium",
|
||||
"03": "High"
|
||||
}
|
||||
},
|
||||
"washer_detergent_water_hardness": {
|
||||
"state": {
|
||||
"01": "Soft",
|
||||
"02": "Medium",
|
||||
"03": "Hard"
|
||||
}
|
||||
},
|
||||
"washer_softener_concentration": {
|
||||
"state": {
|
||||
"01": "1x",
|
||||
"02": "2x",
|
||||
"03": "3x"
|
||||
}
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
@@ -92,6 +115,13 @@
|
||||
"observe": "Push (observe)",
|
||||
"poll": "Polling"
|
||||
}
|
||||
},
|
||||
"machine_state": {
|
||||
"state": {
|
||||
"idle": "Idle",
|
||||
"active": "Active",
|
||||
"pause": "Paused"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
},
|
||||
"ice_type": {
|
||||
"state": {
|
||||
"off": "Off",
|
||||
"whiskey_iceball_3": "3 Balls/Day",
|
||||
"whiskey_iceball_6": "6 Balls/Day",
|
||||
"whiskey_iceball_9": "9 Balls/Day"
|
||||
@@ -78,6 +79,28 @@
|
||||
"8f": "Intense Cold",
|
||||
"96": "Less Microfiber"
|
||||
}
|
||||
},
|
||||
"washer_dosing_quantity": {
|
||||
"state": {
|
||||
"00": "None",
|
||||
"01": "Low",
|
||||
"02": "Medium",
|
||||
"03": "High"
|
||||
}
|
||||
},
|
||||
"washer_detergent_water_hardness": {
|
||||
"state": {
|
||||
"01": "Soft",
|
||||
"02": "Medium",
|
||||
"03": "Hard"
|
||||
}
|
||||
},
|
||||
"washer_softener_concentration": {
|
||||
"state": {
|
||||
"01": "1x",
|
||||
"02": "2x",
|
||||
"03": "3x"
|
||||
}
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
@@ -92,6 +115,13 @@
|
||||
"observe": "Push (observe)",
|
||||
"poll": "Polling"
|
||||
}
|
||||
},
|
||||
"machine_state": {
|
||||
"state": {
|
||||
"idle": "Idle",
|
||||
"active": "Active",
|
||||
"pause": "Paused"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
+2
-2
@@ -10,7 +10,7 @@
|
||||
"cooler_setpoint",
|
||||
"cooler_temperature",
|
||||
"day_brightness",
|
||||
"defrost_blocked",
|
||||
"defrost_active",
|
||||
"defrost_delay",
|
||||
"door_alert",
|
||||
"door_cooler_open",
|
||||
@@ -50,7 +50,7 @@
|
||||
"samsung_refrigerator_cooler_setpoint",
|
||||
"samsung_refrigerator_cooler_temperature",
|
||||
"samsung_refrigerator_day_brightness",
|
||||
"samsung_refrigerator_defrost_blocked",
|
||||
"samsung_refrigerator_defrost_active",
|
||||
"samsung_refrigerator_defrost_delay",
|
||||
"samsung_refrigerator_door_alert",
|
||||
"samsung_refrigerator_door_cooler_open",
|
||||
|
||||
Vendored
+4
-1
@@ -6,18 +6,21 @@
|
||||
"cycle",
|
||||
"cycle_active",
|
||||
"delay_start_hours",
|
||||
"detergent_low",
|
||||
"diagnosis_status",
|
||||
"drum_clean_cycles_remaining",
|
||||
"drum_clean_last_cleaned",
|
||||
"energy_kwh",
|
||||
"finish_time",
|
||||
"firmware_update",
|
||||
"job_beginning_status",
|
||||
"machine_state",
|
||||
"power_switch",
|
||||
"power_watts",
|
||||
"progress",
|
||||
"progress_percentage",
|
||||
"remote_control",
|
||||
"rinse_cycles",
|
||||
"softener_low",
|
||||
"spin_speed",
|
||||
"wash_temperature",
|
||||
"water_liters"
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
"""Tests for the diagnostics platform."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from custom_components.localthings.const import DOMAIN
|
||||
from custom_components.localthings.diagnostics import async_get_config_entry_diagnostics
|
||||
from custom_components.localthings.registry.redact import REDACTED
|
||||
|
||||
_MANIFEST_VERSION = json.loads(
|
||||
(Path(__file__).parents[2] / 'custom_components' / 'localthings' / 'manifest.json').read_text()
|
||||
)['version']
|
||||
|
||||
|
||||
async def test_diagnostics_shape_and_redaction(
|
||||
hass: HomeAssistant, mock_entry, mock_coordinator_session
|
||||
@@ -19,7 +26,7 @@ async def test_diagnostics_shape_and_redaction(
|
||||
assert diagnostics["device_type"] == 'refrigerator'
|
||||
assert diagnostics["one_ui_version"] == '7.0 Refrigerator'
|
||||
assert diagnostics["unbound_hrefs"] == []
|
||||
assert diagnostics["integration_version"] == '0.1.0'
|
||||
assert diagnostics["integration_version"] == _MANIFEST_VERSION
|
||||
assert diagnostics["smartthings_local_version"]
|
||||
|
||||
resources = diagnostics["resources"]
|
||||
|
||||
@@ -154,6 +154,20 @@ class TestForDeviceByModel:
|
||||
assert reg is not None
|
||||
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):
|
||||
from custom_components.localthings.registry.by_type import for_device_by_model
|
||||
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
|
||||
@@ -8,6 +8,22 @@ def test_machine_state_maps_samsung_to_ocf():
|
||||
assert ms.value_fn('Ready') == 'idle'
|
||||
|
||||
|
||||
class TestProgressPercentage:
|
||||
"""issue #9: device firmware leaves progressPercentage stale (e.g. '1')
|
||||
after a cycle ends instead of resetting it, so it must be gated on
|
||||
active state the same way `progress`/`cycle_active`/`finish_time` are."""
|
||||
|
||||
def test_zeroed_when_not_active(self):
|
||||
desc = next(e for e in OPERATIONAL_STATE.entities if e.key == 'progress_percentage')
|
||||
rep = {'x.com.samsung.da.state': 'Ready', 'x.com.samsung.da.progressPercentage': '1'}
|
||||
assert desc.rep_fn(rep) == 0
|
||||
|
||||
def test_passes_through_when_active(self):
|
||||
desc = next(e for e in OPERATIONAL_STATE.entities if e.key == 'progress_percentage')
|
||||
rep = {'x.com.samsung.da.state': 'Run', 'x.com.samsung.da.progressPercentage': '42'}
|
||||
assert desc.rep_fn(rep) == 42
|
||||
|
||||
|
||||
class TestDelayFieldFallback:
|
||||
def test_reads_delay_end_time_when_delay_start_time_absent(self):
|
||||
from custom_components.localthings.registry.capabilities.operational import OPERATIONAL_STATE
|
||||
|
||||
@@ -112,6 +112,132 @@ class TestCycleOptions:
|
||||
assert washer._cycle_options(resources) == []
|
||||
|
||||
|
||||
class TestDrumClean:
|
||||
def test_cycles_remaining(self):
|
||||
"""DrumCleanProposal_40 - WashingTimes_3 == 37, matching a live
|
||||
app screenshot's 'Potreba cistenia po 37 cykloch'."""
|
||||
desc = next(e for e in washer.WASHER_COURSE.entities
|
||||
if e.key == 'drum_clean_cycles_remaining')
|
||||
rep = {'x.com.samsung.da.options': ['WashingTimes_3', 'DrumCleanProposal_40']}
|
||||
assert desc.rep_fn(rep) == 37
|
||||
|
||||
def test_cycles_remaining_never_negative(self):
|
||||
desc = next(e for e in washer.WASHER_COURSE.entities
|
||||
if e.key == 'drum_clean_cycles_remaining')
|
||||
rep = {'x.com.samsung.da.options': ['WashingTimes_50', 'DrumCleanProposal_40']}
|
||||
assert desc.rep_fn(rep) == 0
|
||||
|
||||
def test_cycles_remaining_missing_fields(self):
|
||||
desc = next(e for e in washer.WASHER_COURSE.entities
|
||||
if e.key == 'drum_clean_cycles_remaining')
|
||||
assert desc.rep_fn({'x.com.samsung.da.options': []}) is None
|
||||
|
||||
def test_cycles_remaining_exists_only_when_computable(self):
|
||||
desc = next(e for e in washer.WASHER_COURSE.entities
|
||||
if e.key == 'drum_clean_cycles_remaining')
|
||||
assert desc.exists_fn({'x.com.samsung.da.options': []}, {}) is False
|
||||
rep = {'x.com.samsung.da.options': ['WashingTimes_3', 'DrumCleanProposal_40']}
|
||||
assert desc.exists_fn(rep, {}) is True
|
||||
|
||||
def test_last_cleaned(self):
|
||||
"""DrumCleanLog_2026-07-01T20:18:07 -> a UTC-aware datetime,
|
||||
matching the same screenshot's '10 days ago' (as of 2026-07-11)."""
|
||||
desc = next(e for e in washer.WASHER_COURSE.entities
|
||||
if e.key == 'drum_clean_last_cleaned')
|
||||
rep = {'x.com.samsung.da.options': ['DrumCleanLog_2026-07-01T20:18:07']}
|
||||
from datetime import datetime, timezone
|
||||
assert desc.rep_fn(rep) == datetime(2026, 7, 1, 20, 18, 7, tzinfo=timezone.utc)
|
||||
|
||||
def test_last_cleaned_missing(self):
|
||||
desc = next(e for e in washer.WASHER_COURSE.entities
|
||||
if e.key == 'drum_clean_last_cleaned')
|
||||
assert desc.rep_fn({'x.com.samsung.da.options': []}) is None
|
||||
assert desc.exists_fn({'x.com.samsung.da.options': []}, {}) is False
|
||||
|
||||
|
||||
# Raw options array from issue #9's diagnostic dump (trimmed to the fields
|
||||
# relevant to detergent/softener dosing).
|
||||
_DOSING_OPTIONS = [
|
||||
'Course_1C',
|
||||
'DetergentAlarm_Off',
|
||||
'SoftenerAlarm_Off',
|
||||
'DetergentLevelCtrl_3',
|
||||
'SoftenerLevelCtrl_3',
|
||||
'SupportedDetergentLevelCtrl_00010203',
|
||||
'SupportedSoftenerLevelCtrl_00010203',
|
||||
'DetergentLevel2Ctrl_2',
|
||||
'SoftenerLevel2Ctrl_2',
|
||||
'SupportedDetergentLevel2Ctrl_010203',
|
||||
'SupportedSoftenerLevel2Ctrl_010203',
|
||||
]
|
||||
_DOSING_RESOURCES = {'/course/vs/0': {'x.com.samsung.da.options': _DOSING_OPTIONS}}
|
||||
|
||||
|
||||
class TestDetergentSoftenerDosing:
|
||||
@staticmethod
|
||||
def _desc(key):
|
||||
return next(e for e in washer.WASHER_COURSE.entities if e.key == key)
|
||||
|
||||
def test_quantity_and_hardness_read(self):
|
||||
rep = {'x.com.samsung.da.options': _DOSING_OPTIONS}
|
||||
assert self._desc('detergent_quantity').rep_fn(rep) == '3'
|
||||
assert self._desc('detergent_water_hardness').rep_fn(rep) == '2'
|
||||
assert self._desc('softener_quantity').rep_fn(rep) == '3'
|
||||
assert self._desc('softener_concentration').rep_fn(rep) == '2'
|
||||
|
||||
def test_translation_keys(self):
|
||||
"""detergent_quantity and softener_quantity share one translation_key
|
||||
(same 00-03 -> None/Low/Medium/High vocabulary on both dispensers,
|
||||
same shape as fridge.py's shared 'brightness_level' key); hardness
|
||||
and concentration each have their own since their labels differ."""
|
||||
assert self._desc('detergent_quantity').translation_key == 'washer_dosing_quantity'
|
||||
assert self._desc('softener_quantity').translation_key == 'washer_dosing_quantity'
|
||||
assert self._desc('detergent_water_hardness').translation_key == 'washer_detergent_water_hardness'
|
||||
assert self._desc('softener_concentration').translation_key == 'washer_softener_concentration'
|
||||
|
||||
def test_quantity_and_hardness_options_decode_supported_list(self):
|
||||
assert self._desc('detergent_quantity').options(_DOSING_RESOURCES) == ['00', '01', '02', '03']
|
||||
assert self._desc('softener_quantity').options(_DOSING_RESOURCES) == ['00', '01', '02', '03']
|
||||
assert self._desc('detergent_water_hardness').options(_DOSING_RESOURCES) == ['01', '02', '03']
|
||||
assert self._desc('softener_concentration').options(_DOSING_RESOURCES) == ['01', '02', '03']
|
||||
|
||||
def test_exists_only_when_supported_list_present(self):
|
||||
for key in ('detergent_quantity', 'detergent_water_hardness',
|
||||
'softener_quantity', 'softener_concentration'):
|
||||
desc = self._desc(key)
|
||||
assert desc.exists_fn({}, {}) is False
|
||||
assert desc.exists_fn({}, _DOSING_RESOURCES) is True
|
||||
|
||||
def test_quantity_write(self):
|
||||
rep = {'x.com.samsung.da.options': list(_DOSING_OPTIONS)}
|
||||
path, body = self._desc('detergent_quantity').write_fn('01', rep)
|
||||
assert path == ['course', 'vs', '0']
|
||||
assert 'DetergentLevelCtrl_01' in body['x.com.samsung.da.options']
|
||||
assert 'DetergentLevelCtrl_3' not in body['x.com.samsung.da.options']
|
||||
|
||||
def test_hardness_write(self):
|
||||
rep = {'x.com.samsung.da.options': list(_DOSING_OPTIONS)}
|
||||
path, body = self._desc('softener_concentration').write_fn('03', rep)
|
||||
assert path == ['course', 'vs', '0']
|
||||
assert 'SoftenerLevel2Ctrl_03' in body['x.com.samsung.da.options']
|
||||
|
||||
def test_low_reservoir_off_when_alarm_off(self):
|
||||
rep = {'x.com.samsung.da.options': _DOSING_OPTIONS}
|
||||
assert self._desc('detergent_low').rep_fn(rep) is False
|
||||
assert self._desc('softener_low').rep_fn(rep) is False
|
||||
|
||||
def test_low_reservoir_on_when_alarm_active(self):
|
||||
opts = ['DetergentAlarm_On', 'SoftenerAlarm_On']
|
||||
rep = {'x.com.samsung.da.options': opts}
|
||||
assert self._desc('detergent_low').rep_fn(rep) is True
|
||||
assert self._desc('softener_low').rep_fn(rep) is True
|
||||
|
||||
def test_low_reservoir_exists_only_when_alarm_field_present(self):
|
||||
assert self._desc('detergent_low').exists_fn({'x.com.samsung.da.options': []}, {}) is False
|
||||
rep = {'x.com.samsung.da.options': _DOSING_OPTIONS}
|
||||
assert self._desc('detergent_low').exists_fn(rep, {}) is True
|
||||
|
||||
|
||||
class TestBuzzerSound:
|
||||
def test_href(self):
|
||||
assert washer.BUZZER_SOUND.href == '/buzzersound/vs/0'
|
||||
@@ -195,3 +321,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/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