Compare commits
71
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
760d797c12 | ||
|
|
b944549f55 | ||
|
|
94551da4a0 | ||
|
|
f783aa72b9 | ||
|
|
ed461d82cd | ||
|
|
62d4ed673b | ||
|
|
e74089a545 | ||
|
|
b2f3e501e5 | ||
|
|
8ca2c3cb16 | ||
|
|
aec3c5e460 | ||
|
|
321f186b0e | ||
|
|
2f6b7d54e3 | ||
|
|
6e5fee32b8 | ||
|
|
1e9437ef3c | ||
|
|
36f7aa0b8c | ||
|
|
10ab51c7f3 | ||
|
|
8bf9002eb7 | ||
|
|
fb0f24a175 | ||
|
|
5944d14bca | ||
|
|
1493467c63 | ||
|
|
61ee2972dc | ||
|
|
3d5263d792 | ||
|
|
d6639c99d4 | ||
|
|
5d9b470d7e | ||
|
|
9800cd0aa8 | ||
|
|
3469f57080 | ||
|
|
f54daf3608 | ||
|
|
e49b61e01e | ||
|
|
dbe70ffc5b | ||
|
|
7d011bfe89 | ||
|
|
9199fb7711 | ||
|
|
6611f85958 | ||
|
|
8470853fb6 | ||
|
|
e942c822a6 | ||
|
|
4a376a98aa | ||
|
|
bc9d4f4cf5 | ||
|
|
10f2aac0bd | ||
|
|
0cc9d8e511 | ||
|
|
b586277031 | ||
|
|
2a0a9f32ee | ||
|
|
8dadea6796 | ||
|
|
2cd93619a4 | ||
|
|
5a9a002a7f | ||
|
|
0715eb0829 | ||
|
|
43542b146f | ||
|
|
9a899080fa | ||
|
|
88e630b54f | ||
|
|
70a7f861c3 | ||
|
|
ff034905a2 | ||
|
|
ae8bb0b621 | ||
|
|
3688cf24a0 | ||
|
|
d917d757e9 | ||
|
|
f4b4a04628 | ||
|
|
c0298d57d9 | ||
|
|
0503bda0f3 | ||
|
|
6f53b3043b | ||
|
|
bedc662b47 | ||
|
|
296535d0f2 | ||
|
|
d7fe609159 | ||
|
|
15f836caeb | ||
|
|
1576d095c8 | ||
|
|
febc8fa4a0 | ||
|
|
9264b912f9 | ||
|
|
84788174f8 | ||
|
|
00c3ebaf75 | ||
|
|
4cc073c88a | ||
|
|
91013ece60 | ||
|
|
795763f3ca | ||
|
|
a7ed230569 | ||
|
|
9fee5b5ec0 | ||
|
|
f24d6a0ae6 |
@@ -0,0 +1,161 @@
|
||||
---
|
||||
name: adding-device-support
|
||||
description: >-
|
||||
Add or extend support for a Samsung OCF appliance in localthings from a
|
||||
/device/0 diagnostics dump. Use when a device-support issue lands, a device
|
||||
raises the "incomplete capability coverage" repair, a diagnostics JSON needs
|
||||
triaging, or you're mapping OCF resources to HA entities. Covers reading dumps,
|
||||
OCF-standard vs vendor hrefs, the diagnostic/config/normal entity taxonomy,
|
||||
ensuring every href is bound or ignored, and locking it in with a fixture +
|
||||
golden + test.
|
||||
---
|
||||
|
||||
# Adding device support
|
||||
|
||||
localthings maps a Samsung appliance's OCF resources (`/device/0` dump) to Home
|
||||
Assistant entities. Each resource `href` is handled by a `Capability` that
|
||||
declares the entities it produces. This skill is the workflow for turning a new
|
||||
dump into coverage.
|
||||
|
||||
## 1. Get the dump and see the gaps
|
||||
|
||||
A user's diagnostics download (`config_entry-localthings-*.json`) has, under
|
||||
`data`:
|
||||
- `resources`: `{href: rep}` — the parsed `/device/0` snapshot. **This is the
|
||||
source of truth**, not code comments.
|
||||
- `unbound_hrefs`: resources that bound to no capability. The
|
||||
"incomplete capability coverage" repair fires whenever this is **non-empty or
|
||||
the device type is unrecognized** (`coordinator._update_coverage_gap_issue`).
|
||||
|
||||
Goal: make `unbound_hrefs` empty by **binding** the useful resources and
|
||||
**ignoring** the noise — and surface every genuinely useful sensor/select/switch
|
||||
along the way.
|
||||
|
||||
## 2. Compute coverage without Home Assistant
|
||||
|
||||
The `registry/` package is HA-free, so you can drive discovery directly (HA
|
||||
isn't importable standalone because `localthings/__init__.py` pulls it in — stub
|
||||
the package to skip that):
|
||||
|
||||
```python
|
||||
import sys, types, json, importlib
|
||||
cc = types.ModuleType('custom_components'); cc.__path__=['custom_components']; sys.modules['custom_components']=cc
|
||||
lt = types.ModuleType('custom_components.localthings'); lt.__path__=['custom_components/localthings']; sys.modules['custom_components.localthings']=lt
|
||||
by_type = importlib.import_module('custom_components.localthings.registry.by_type')
|
||||
discovery = importlib.import_module('custom_components.localthings.registry.discovery')
|
||||
adapter = importlib.import_module('custom_components.localthings.registry.adapter')
|
||||
|
||||
resources = json.load(open('dump.json'))['data']['resources']
|
||||
info = resources['/information/vs/0']
|
||||
reg = by_type.for_device_by_model(info['x.com.samsung.da.modelNum'], info['x.com.samsung.da.description'])
|
||||
# or: by_type.for_device(one_ui_version) when /otninformation has swVersionInfo.oneUiVersion
|
||||
unbound = []
|
||||
bound = discovery.discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append)
|
||||
state = adapter.flatten(bound, resources) # {entity_key: value}
|
||||
print('registry:', reg.name, 'unbound:', sorted(unbound))
|
||||
print('state_keys:', sorted(state))
|
||||
```
|
||||
|
||||
`discover()` binds caps (applies `rt_filter`/`match_fn`); `flatten()` applies
|
||||
`exists_fn` and produces the final entity values. Use the same routine to
|
||||
regenerate a golden.
|
||||
|
||||
## 3. OCF-standard vs vendor hrefs (`/x/0` vs `/x/vs/0`)
|
||||
|
||||
Samsung appliances run RT-OCF and often expose the **same state twice**:
|
||||
- `/x/vs/0` — **vendor** resource, `x.com.samsung.da.*` fields.
|
||||
- `/x/0` — **standard OCF** resource type (`oic.r.*`) with OCF's fixed field
|
||||
names. Confirmable against the OCF spec: `/power/0` `{value: bool}` is
|
||||
`oic.r.switch.binary`; `/operational/state/0` is `oic.r.operational.state`.
|
||||
|
||||
Newer firmware advertises both as Samsung migrates onto the OCF standard. **There
|
||||
is no single "always prefer vs / always prefer non-vs" rule** — decide per
|
||||
resource from the populated dump:
|
||||
- **Both populated, same state** (power, kids-lock, remote): prefer the
|
||||
OCF-standard `/x/0`; fall back to `/x/vs/0` when `/x/0` is absent. Encode with
|
||||
a `match_fn` presence check — see `common.POWER_GENERIC` / `POWER_VS_FALLBACK`.
|
||||
- **Only vendor populated** (`/energy/consumption/0` is often empty `{}`): use
|
||||
`/x/vs/0`.
|
||||
- **Vendor is a superset** (`/operational/state/vs/0` adds fields the OCF one
|
||||
lacks): build on the vendor resource, ignore the OCF subset.
|
||||
|
||||
Course/cycle is **not** an OCF question — there's no standard course resource, so
|
||||
`/course/vs/0` (and the `/st/*course/vs/0` re-encoding) are both vendor.
|
||||
|
||||
## 4. Entity taxonomy — the judgement call
|
||||
|
||||
For each field worth exposing, decide the entity kind and category
|
||||
(`entity_category` on the descriptor):
|
||||
- **Normal / primary** (no `entity_category`): the things a user acts on or
|
||||
watches — power switch, machine state, the cycle select, energy sensors.
|
||||
- **`config`**: user-tunable settings — sound mode, door LED, wash temperature,
|
||||
buzzer. Shown under the device's Configuration section.
|
||||
- **`diagnostic`**: read-only status/troubleshooting — alarms, diagnosis, job
|
||||
beginning status, last-operation source.
|
||||
|
||||
Also set `poll_tier` (`hot`/`warm`/`cold`) on the capability for how often it's
|
||||
sub-polled between summary polls. Pick descriptor types from `entities.py`
|
||||
(`SensorDesc`, `SelectDesc`, `SwitchDesc`, `NumberDesc`, `BinarySensorDesc`,
|
||||
`TimeDesc`, `ButtonDesc`) — the class selects the HA platform.
|
||||
|
||||
**Don't guess.** If a field's meaning or write contract is unclear from the dump
|
||||
(opaque encoded blobs, no supported-values list), leave it unbound so it surfaces
|
||||
as a gap for a human, or ignore it with a documented reason — never invent an
|
||||
entity on a hunch (`ignored.py`'s rule).
|
||||
|
||||
## 5. Enum selects need translation support
|
||||
|
||||
Any select whose options are raw device codes (course/cycle, and code-valued
|
||||
settings) must render through translations, not Python:
|
||||
- Set `translation_key='<family>_cycle'` (or similar) on the `SelectDesc`;
|
||||
`options`/`options_field` supply the **raw** codes.
|
||||
- Add the labels to **both** `strings.json` and `translations/en.json` under
|
||||
`entity.select.<translation_key>.state.<code>`, with the code **lowercased**
|
||||
(e.g. `"16": "Cotton"`). Codes with no entry render as the raw code — that's
|
||||
the cue to identify and name them.
|
||||
|
||||
## 6. Coverage discipline: bound or ignored
|
||||
|
||||
Every href in the dump must resolve, or the repair fires. If a resource isn't
|
||||
worth an entity, add it to `capabilities/ignored.py` (a no-entity `Capability`)
|
||||
with a one-line reason. Add there only when it's **irrelevant plumbing**
|
||||
(network/OTA/account housekeeping) or a **duplicate of state exposed via a
|
||||
friendlier href**.
|
||||
|
||||
- **Global vs per-registry ignore:** `ignored.IGNORED` is folded into every
|
||||
registry. A global ignore **collides** (via `_build`) with any real capability
|
||||
that binds the same href in some family — e.g. `/course/vs/0` can't be globally
|
||||
ignored because washers bind it. When only one family should ignore an href
|
||||
that another binds, scope the ignore to that family's registry.
|
||||
|
||||
## 7. Reuse before writing new code
|
||||
|
||||
Check `common.py` (generic OCF: power, energy, alarms, water) and `laundry.py`
|
||||
(shared washer/dryer/dishwasher: buzzer, job status, `cycle_select` + course
|
||||
machinery) before adding a capability. Cross-family reuse is normal — the dryer
|
||||
registry uses `fridge.FIRMWARE_UPDATE`; all three laundry families share
|
||||
`laundry.cycle_select`. If two families hand-roll the same helper, hoist it to a
|
||||
shared module rather than copying.
|
||||
|
||||
## 8. Lock it in
|
||||
|
||||
1. Add a **scrubbed** fixture `tests/fixtures/<type>_device.json`
|
||||
(`{"device0": [ {devcol rep}, {href, rep}, ... ]}`) — replace serials, MACs,
|
||||
and other PII with placeholders.
|
||||
2. Generate `tests/fixtures/golden/<type>.json` (`{"state_keys": [...]}`) with
|
||||
the harness in §2.
|
||||
3. Add the type to `test_golden_regression.py` and write a
|
||||
`test_<type>_capabilities.py` asserting **zero unbound hrefs** and that the
|
||||
expected entities exist (and any misleading ones are gated).
|
||||
4. Run `pytest tests/ -q` — and re-run the golden tests for **other** device
|
||||
types after any change to `common.py`/`laundry.py`, since they share those.
|
||||
|
||||
## Key files
|
||||
- `registry/discovery.py` — `discover()`, unbound reporting, pattern caps.
|
||||
- `registry/capability.py`, `registry/entities.py` — the `Capability` and
|
||||
descriptor shapes (`rt_filter`, `match_fn`, `exists_fn`, `rep_fn`, `write_fn`).
|
||||
- `registry/capabilities/{common,laundry,fridge,...}.py` — capability defs.
|
||||
- `registry/capabilities/ignored.py` — the ignore list + its philosophy.
|
||||
- `registry/by_type/*.py` — per-device-type registries (what to include).
|
||||
- `registry/registry.py` — the global unknown-device fallback + collision check.
|
||||
- `tests/test_golden_regression.py`, `tests/fixtures/` — regression harness.
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Run hassfest validation
|
||||
uses: home-assistant/actions/hassfest@master
|
||||
@@ -29,9 +29,29 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Run HACS validation
|
||||
uses: hacs/action@main
|
||||
with:
|
||||
category: integration
|
||||
|
||||
tests:
|
||||
name: Pytest
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v7
|
||||
with:
|
||||
python-version: "3.14"
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r requirements-dev.txt
|
||||
|
||||
- name: Run tests
|
||||
run: pytest tests/ -q
|
||||
|
||||
+4
-2
@@ -27,8 +27,10 @@ flows/
|
||||
# Local-only research tools (not part of the HA integration)
|
||||
local-tools/
|
||||
|
||||
# Claude Code per-project state (permissions allowlist, etc.)
|
||||
.claude/
|
||||
# Claude Code per-project state (permissions allowlist, etc.) — but keep
|
||||
# shared, committed skills under .claude/skills/.
|
||||
.claude/*
|
||||
!.claude/skills/
|
||||
|
||||
# HA dev instance — persisted config and secrets
|
||||
ha_config/
|
||||
|
||||
@@ -2,31 +2,29 @@
|
||||
|
||||
**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 |
|
||||
|---|---|
|
||||
| Air conditioner | `by_type/airconditioner.py` |
|
||||
| Dryer | `by_type/dryer.py` |
|
||||
| Oven | `by_type/oven.py` |
|
||||
| Range / cooktop-oven combo | `by_type/range.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.
|
||||
|
||||
@@ -39,23 +37,17 @@ Other Tizen RT / DAWIT-family appliances almost certainly speak the same protoco
|
||||
nmap -Pn -sU -p 49152-49160 "$APPLIANCE_IP"
|
||||
```
|
||||
|
||||
- `49154/udp` or `49155/udp` open|filtered with a DTLS handshake responding: newer firmware (Tizen RT 3.x, DAWIT 3.0+). This is what the integration talks to. The config flow probes both ports automatically, so you don't need to know which one your device uses.
|
||||
- Any UDP port in `49152-49160` open|filtered with a DTLS handshake responding: newer firmware (Tizen RT 3.x, DAWIT 3.0+). This is what the integration talks to. Most devices answer on `49154`/`49155`, but some builds bind lower (e.g. `49153`). The config flow sweeps the whole range and auto-detects the live port, so you don't need to know which one your device uses.
|
||||
- Only `8888/tcp` open (token-based HTTPS): older firmware (roughly 2018-2022). **Not supported here.**
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
@@ -64,7 +56,7 @@ This repo doesn't include the needed CA bundle. For an example of how to obtain
|
||||
2. Restart HA.
|
||||
3. **Settings > Devices & Services > Add Integration > LocalThings.**
|
||||
4. First device: paste the appliance's IP, plus the contents of the CA private and public key from Part 2.
|
||||
5. The flow fetches the current UUID from Samsung's cloud gateway, mints a leaf cert signed by your CA, probes ports `49154`/`49155`, and confirms the device answers `/device/0`. On success it creates the config entry and detects the device type automatically.
|
||||
5. The flow fetches the current UUID from Samsung's cloud gateway, mints a leaf cert signed by your CA, sweeps the `49152-49160` range to find the live DTLS port, and confirms the device answers `/device/0`. On success it creates the config entry and detects the device type automatically.
|
||||
6. Every subsequent device only asks for the host IP; the stored CA credentials are reused to mint that device's leaf cert.
|
||||
|
||||
Entities appear under one HA device per appliance, named `Samsung Appliance (<ip>)` initially. Rename freely: the config entry is keyed on the device's serial, not the name.
|
||||
@@ -80,7 +72,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 +85,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 +93,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
|
||||
sensor.py / binary_sensor.py / switch.py / number.py / select.py / button.py / time.py
|
||||
One module per HA platform
|
||||
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 / climate.py
|
||||
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, airconditioner,
|
||||
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 +136,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 +158,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.
|
||||
|
||||
|
||||
@@ -0,0 +1,326 @@
|
||||
"""Climate platform for Local Things.
|
||||
|
||||
The first composite entity in this integration: a single HA climate card that
|
||||
unifies several OCF resources of a Samsung air conditioner. Unlike every other
|
||||
platform here (one descriptor -> one resource field), a climate entity reads
|
||||
power, HVAC mode, current/target temperature, fan (wind) strength, swing (wind
|
||||
direction) and the convenient-mode preset from *different* resources.
|
||||
|
||||
It binds one primary `BoundEntity` (the `/mode/vs/0` capability) so the registry
|
||||
still tracks it, and reads the sibling resources straight from the coordinator
|
||||
snapshot via `coordinator.resource(href)` -- the same cross-resource read that
|
||||
`number.py` (live range/unit) and `select.py` (options callable) already do.
|
||||
|
||||
Writes go through `coordinator.async_send_command(bound, (kind, value))`: the
|
||||
CLIMATE capability's `write_fn` maps each `(kind, value)` payload to the right
|
||||
`(path_segs, body)`, and `async_send_command` POSTs to those path_segs (the
|
||||
bound href is only used for logging), so one descriptor drives writes to power,
|
||||
mode, temperature and wind resources.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.components.climate import (
|
||||
ClimateEntity,
|
||||
ClimateEntityFeature,
|
||||
HVACMode,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import UnitOfTemperature
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from .registry.entities import ClimateDesc
|
||||
# The AC's canonical resource hrefs live in the capability module (the single
|
||||
# source of truth shared with its COVERAGE caps); power prefers the OCF-standard
|
||||
# href, falling back to the vendor one, mirroring common.POWER_GENERIC /
|
||||
# POWER_VS_FALLBACK.
|
||||
from .registry.capabilities.airconditioner import (
|
||||
HREF_MODE as MODE_HREF,
|
||||
HREF_POWER as POWER_HREF,
|
||||
HREF_POWER_VS as POWER_VS_HREF,
|
||||
HREF_TEMP_CURRENT as TEMP_CURRENT_HREF,
|
||||
HREF_TEMP_DESIRED as TEMP_DESIRED_HREF,
|
||||
HREF_TEMP_CONTROL as TEMP_CONTROL_HREF,
|
||||
HREF_TEMPS_VS as TEMPS_VS_HREF,
|
||||
HREF_WIND_STRENGTH as WIND_STRENGTH_HREF,
|
||||
HREF_WIND_DIRECTION as WIND_DIRECTION_HREF,
|
||||
HREF_CONVENIENT as CONVENIENT_HREF,
|
||||
)
|
||||
from .registry.capabilities.common import normalize_temp_unit
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import LocalThingsCoordinator
|
||||
from .entity import LocalThingsEntity, _is_included
|
||||
|
||||
_MODES_FIELD = 'x.com.samsung.da.modes'
|
||||
_SUPPORTED_FIELD = 'x.com.samsung.da.supportedModes'
|
||||
|
||||
# --- device code <-> HA value maps -----------------------------------------
|
||||
# HVAC mode: Samsung /mode/vs/0 modes <-> HA HVACMode (excluding OFF, which is
|
||||
# driven by the power resource).
|
||||
_DEVICE_TO_HVAC: dict[str, HVACMode] = {
|
||||
'Cool': HVACMode.COOL,
|
||||
'Dry': HVACMode.DRY,
|
||||
'Wind': HVACMode.FAN_ONLY,
|
||||
'Auto': HVACMode.HEAT_COOL,
|
||||
'Heat': HVACMode.HEAT,
|
||||
}
|
||||
_HVAC_TO_DEVICE = {v: k for k, v in _DEVICE_TO_HVAC.items()}
|
||||
|
||||
# Fan (wind strength): device codes "0".."4" -> HA standard fan constants where
|
||||
# a clean match exists so they auto-localize; "turbo" is custom (translated).
|
||||
_DEVICE_TO_FAN: dict[str, str] = {
|
||||
'0': 'auto',
|
||||
'1': 'low',
|
||||
'2': 'medium',
|
||||
'3': 'high',
|
||||
'4': 'turbo',
|
||||
}
|
||||
_FAN_TO_DEVICE = {v: k for k, v in _DEVICE_TO_FAN.items()}
|
||||
|
||||
# Swing (wind direction): all map onto HA standard swing constants (auto-localize).
|
||||
_DEVICE_TO_SWING: dict[str, str] = {
|
||||
'Fix': 'off',
|
||||
'All': 'both',
|
||||
'Up_And_Low': 'vertical',
|
||||
}
|
||||
_SWING_TO_DEVICE = {v: k for k, v in _DEVICE_TO_SWING.items()}
|
||||
|
||||
# Preset (convenient mode): Off/Sleep map onto HA standard presets; Quiet/Smart/
|
||||
# Speed are custom (translated).
|
||||
_DEVICE_TO_PRESET: dict[str, str] = {
|
||||
'Off': 'none',
|
||||
'Sleep': 'sleep',
|
||||
'Quiet': 'quiet',
|
||||
'Smart': 'smart',
|
||||
'Speed': 'speed',
|
||||
}
|
||||
_PRESET_TO_DEVICE = {v: k for k, v in _DEVICE_TO_PRESET.items()}
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: ConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
coordinator: LocalThingsCoordinator = hass.data[DOMAIN][entry.entry_id]
|
||||
async_add_entities(
|
||||
LocalThingsClimate(coordinator, b)
|
||||
for b in coordinator.bound
|
||||
if isinstance(b.desc, ClimateDesc) and _is_included(b, coordinator)
|
||||
)
|
||||
|
||||
|
||||
def _first(value):
|
||||
"""Samsung `modes` is a single-element list on some resources, a scalar on
|
||||
others. Return the first element of a list, else the value itself."""
|
||||
if isinstance(value, (list, tuple)):
|
||||
return value[0] if value else None
|
||||
return value
|
||||
|
||||
|
||||
def _num(value):
|
||||
try:
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _temps_vs_item(rep: dict) -> dict:
|
||||
"""First item of the vendor `/temperatures/vs/0` items[] array.
|
||||
|
||||
Newer AC firmware (Tizen Lite, oneUiVersion "7.0 Air conditioner", e.g.
|
||||
model TP1X_DA-AC-RAC-01011) does NOT expose the OCF-standard
|
||||
/temperature/current/0 + /temperature/desired/0 pair; it reports current
|
||||
and target under a single `/temperatures/vs/0` resource whose
|
||||
`x.com.samsung.da.items[0]` carries current/desired/minimum/maximum/
|
||||
increment/unit. Returns {} when absent, so callers fall through cleanly.
|
||||
"""
|
||||
items = rep.get('x.com.samsung.da.items')
|
||||
if isinstance(items, (list, tuple)) and items and isinstance(items[0], dict):
|
||||
return items[0]
|
||||
return {}
|
||||
|
||||
|
||||
class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
|
||||
"""Composite climate entity for a Samsung air conditioner."""
|
||||
|
||||
# translation_key comes from the ClimateDesc (base __init__ sets
|
||||
# _attr_translation_key from bound.desc), resolving the state_attributes
|
||||
# translations under entity.climate.airconditioner.
|
||||
# Modern climate entities opt out of the deprecated auto-added TURN_ON/OFF.
|
||||
_enable_turn_on_off_backwards_compatibility = False
|
||||
|
||||
def __init__(self, coordinator: LocalThingsCoordinator, bound) -> None:
|
||||
super().__init__(coordinator, bound)
|
||||
# Primary/main entity for the device: no name suffix, just the device name.
|
||||
self._attr_name = None
|
||||
self._attr_supported_features = (
|
||||
ClimateEntityFeature.TARGET_TEMPERATURE
|
||||
| ClimateEntityFeature.FAN_MODE
|
||||
| ClimateEntityFeature.SWING_MODE
|
||||
| ClimateEntityFeature.PRESET_MODE
|
||||
| ClimateEntityFeature.TURN_ON
|
||||
| ClimateEntityFeature.TURN_OFF
|
||||
)
|
||||
|
||||
# -- resource helpers ---------------------------------------------------
|
||||
|
||||
def _rep(self, href: str) -> dict:
|
||||
return self.coordinator.resource(href) or {}
|
||||
|
||||
def _is_on(self) -> bool:
|
||||
rep = self._rep(POWER_HREF)
|
||||
if 'value' in rep:
|
||||
return bool(rep.get('value'))
|
||||
vs = self._rep(POWER_VS_HREF)
|
||||
return str(vs.get('x.com.samsung.da.power', '')).lower() == 'on'
|
||||
|
||||
def _supported(self, href: str) -> list[str]:
|
||||
return list(self._rep(href).get(_SUPPORTED_FIELD) or [])
|
||||
|
||||
def _read_mode(self, href: str, mapping: dict):
|
||||
"""Current mode of a wind/convenient resource, mapped to its HA value."""
|
||||
return mapping.get(_first(self._rep(href).get(_MODES_FIELD)))
|
||||
|
||||
def _read_modes(self, href: str, mapping: dict) -> list[str]:
|
||||
"""Supported modes of a resource, mapped to HA values (unknowns dropped)."""
|
||||
return [mapping[c] for c in self._supported(href) if c in mapping]
|
||||
|
||||
# -- temperature --------------------------------------------------------
|
||||
|
||||
def _temps_vs(self) -> dict:
|
||||
"""Vendor `/temperatures/vs/0` items[0] (empty {} when absent)."""
|
||||
return _temps_vs_item(self._rep(TEMPS_VS_HREF))
|
||||
|
||||
@property
|
||||
def temperature_unit(self) -> str:
|
||||
raw = self._rep(TEMP_DESIRED_HREF).get('units')
|
||||
if raw is None:
|
||||
raw = self._temps_vs().get('x.com.samsung.da.unit')
|
||||
return (UnitOfTemperature.FAHRENHEIT
|
||||
if normalize_temp_unit(raw, '°C') == '°F'
|
||||
else UnitOfTemperature.CELSIUS)
|
||||
|
||||
@property
|
||||
def current_temperature(self):
|
||||
v = _num(self._rep(TEMP_CURRENT_HREF).get('temperature'))
|
||||
if v is None:
|
||||
v = _num(self._temps_vs().get('x.com.samsung.da.current'))
|
||||
return v
|
||||
|
||||
@property
|
||||
def target_temperature(self):
|
||||
v = _num(self._rep(TEMP_DESIRED_HREF).get('temperature'))
|
||||
if v is None:
|
||||
v = _num(self._temps_vs().get('x.com.samsung.da.desired'))
|
||||
return v
|
||||
|
||||
def _range(self) -> list | None:
|
||||
r = self._rep(TEMP_DESIRED_HREF).get('range')
|
||||
if isinstance(r, (list, tuple)) and len(r) == 2:
|
||||
return r
|
||||
item = self._temps_vs()
|
||||
lo = _num(item.get('x.com.samsung.da.minimum'))
|
||||
hi = _num(item.get('x.com.samsung.da.maximum'))
|
||||
return [lo, hi] if (lo is not None and hi is not None) else None
|
||||
|
||||
@property
|
||||
def min_temp(self) -> float:
|
||||
r = self._range()
|
||||
return float(r[0]) if r else super().min_temp
|
||||
|
||||
@property
|
||||
def max_temp(self) -> float:
|
||||
r = self._range()
|
||||
return float(r[1]) if r else super().max_temp
|
||||
|
||||
@property
|
||||
def target_temperature_step(self) -> float:
|
||||
return (_num(self._rep(TEMP_CONTROL_HREF).get('increment'))
|
||||
or _num(self._rep(TEMP_CONTROL_HREF).get('x.com.samsung.da.increment'))
|
||||
or _num(self._temps_vs().get('x.com.samsung.da.increment'))
|
||||
or 1.0)
|
||||
|
||||
# -- hvac mode ----------------------------------------------------------
|
||||
|
||||
@property
|
||||
def hvac_mode(self) -> HVACMode:
|
||||
if not self._is_on():
|
||||
return HVACMode.OFF
|
||||
device = _first(self._rep(MODE_HREF).get(_MODES_FIELD))
|
||||
return _DEVICE_TO_HVAC.get(device, HVACMode.AUTO)
|
||||
|
||||
@property
|
||||
def hvac_modes(self) -> list[HVACMode]:
|
||||
modes = [HVACMode.OFF]
|
||||
for m in self._supported(MODE_HREF):
|
||||
mapped = _DEVICE_TO_HVAC.get(m)
|
||||
if mapped is not None and mapped not in modes:
|
||||
modes.append(mapped)
|
||||
return modes
|
||||
|
||||
# -- fan / swing / preset ----------------------------------------------
|
||||
|
||||
@property
|
||||
def fan_mode(self):
|
||||
return self._read_mode(WIND_STRENGTH_HREF, _DEVICE_TO_FAN)
|
||||
|
||||
@property
|
||||
def fan_modes(self) -> list[str]:
|
||||
return self._read_modes(WIND_STRENGTH_HREF, _DEVICE_TO_FAN)
|
||||
|
||||
@property
|
||||
def swing_mode(self):
|
||||
return self._read_mode(WIND_DIRECTION_HREF, _DEVICE_TO_SWING)
|
||||
|
||||
@property
|
||||
def swing_modes(self) -> list[str]:
|
||||
return self._read_modes(WIND_DIRECTION_HREF, _DEVICE_TO_SWING)
|
||||
|
||||
@property
|
||||
def preset_mode(self):
|
||||
return self._read_mode(CONVENIENT_HREF, _DEVICE_TO_PRESET)
|
||||
|
||||
@property
|
||||
def preset_modes(self) -> list[str]:
|
||||
return self._read_modes(CONVENIENT_HREF, _DEVICE_TO_PRESET)
|
||||
|
||||
# -- writes -------------------------------------------------------------
|
||||
|
||||
async def async_set_temperature(self, **kwargs) -> None:
|
||||
temp = kwargs.get('temperature')
|
||||
if temp is not None:
|
||||
await self.coordinator.async_send_command(self._bound, ('temperature', temp))
|
||||
|
||||
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
|
||||
if hvac_mode == HVACMode.OFF:
|
||||
await self.coordinator.async_send_command(self._bound, ('power', False))
|
||||
return
|
||||
device = _HVAC_TO_DEVICE.get(hvac_mode)
|
||||
if device is None:
|
||||
return
|
||||
if not self._is_on():
|
||||
await self.coordinator.async_send_command(self._bound, ('power', True))
|
||||
await self.coordinator.async_send_command(self._bound, ('mode', device))
|
||||
|
||||
async def async_turn_on(self) -> None:
|
||||
await self.coordinator.async_send_command(self._bound, ('power', True))
|
||||
|
||||
async def async_turn_off(self) -> None:
|
||||
await self.coordinator.async_send_command(self._bound, ('power', False))
|
||||
|
||||
async def _set_mapped(self, kind: str, mapping: dict, value: str) -> None:
|
||||
"""Map an HA fan/swing/preset value back to its device code and write it."""
|
||||
device = mapping.get(value)
|
||||
if device is not None:
|
||||
await self.coordinator.async_send_command(self._bound, (kind, device))
|
||||
|
||||
async def async_set_fan_mode(self, fan_mode: str) -> None:
|
||||
await self._set_mapped('fan', _FAN_TO_DEVICE, fan_mode)
|
||||
|
||||
async def async_set_swing_mode(self, swing_mode: str) -> None:
|
||||
await self._set_mapped('swing', _SWING_TO_DEVICE, swing_mode)
|
||||
|
||||
async def async_set_preset_mode(self, preset_mode: str) -> None:
|
||||
await self._set_mapped('preset', _PRESET_TO_DEVICE, preset_mode)
|
||||
@@ -4,8 +4,10 @@ from __future__ import annotations
|
||||
import datetime
|
||||
import logging
|
||||
import re
|
||||
import selectors
|
||||
import socket
|
||||
import ssl
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
import voluptuous as vol
|
||||
@@ -22,7 +24,8 @@ from .const import (
|
||||
CONF_HOST, CONF_PORT,
|
||||
CONF_CA_CERT_PEM, CONF_CA_KEY_PEM,
|
||||
CONF_LEAF_CERT_PEM, CONF_LEAF_KEY_PEM,
|
||||
PROBE_PORTS,
|
||||
PROBE_PORT_RANGE, PREFERRED_PROBE_PORTS, LIVENESS_PROBE_TIMEOUT_S,
|
||||
PROBE_GET_TIMEOUT_S,
|
||||
)
|
||||
|
||||
_TEXT = TextSelector(TextSelectorConfig(type=TextSelectorType.TEXT))
|
||||
@@ -120,6 +123,73 @@ def _mint_leaf_cert(ca_cert_pem: str, ca_key_pem: str, uuid: str) -> tuple[str,
|
||||
return fullchain_pem, leaf_key_pem
|
||||
|
||||
|
||||
def _order_candidates(ports: list[int]) -> list[int]:
|
||||
"""Order live ports so the historically known DTLS ports are tried first."""
|
||||
preferred = [p for p in PREFERRED_PROBE_PORTS if p in ports]
|
||||
rest = sorted(p for p in ports if p not in PREFERRED_PROBE_PORTS)
|
||||
return preferred + rest
|
||||
|
||||
|
||||
def _find_live_ports(host: str, ports: list[int], timeout: float) -> list[int]:
|
||||
"""Fast UDP liveness sweep to narrow the range before the DTLS handshake.
|
||||
|
||||
UDP is connectionless, but a *connected* UDP socket surfaces the ICMP
|
||||
port-unreachable that a closed port returns as ECONNREFUSED on its next
|
||||
recv. So we send one probe datagram per port and watch for that error:
|
||||
|
||||
* ECONNREFUSED -> port is closed (device actively rejected it)
|
||||
* silence / any data -> port may be live (open|filtered); a candidate
|
||||
|
||||
This is the in-process equivalent of ``nmap -sU``: it lets us take a
|
||||
nine-port range down to the one or two ports actually worth a full DTLS
|
||||
handshake + /device/0 GET, and bounds the total wait to ``timeout``
|
||||
instead of stalling on every dead port when a firewall swallows the ICMP
|
||||
replies.
|
||||
"""
|
||||
sockets: dict[int, socket.socket] = {}
|
||||
sel = selectors.DefaultSelector()
|
||||
# A single byte is enough to provoke an ICMP port-unreach from a closed
|
||||
# port; a real DTLS ClientHello is unnecessary just to test for life.
|
||||
probe = b"\x00"
|
||||
try:
|
||||
for port in ports:
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
sock.setblocking(False)
|
||||
try:
|
||||
sock.connect((host, port))
|
||||
sock.send(probe)
|
||||
except OSError:
|
||||
sock.close()
|
||||
continue
|
||||
sockets[port] = sock
|
||||
sel.register(sock, selectors.EVENT_READ, port)
|
||||
|
||||
# Ports drop out of the selector as they refuse; whatever is still
|
||||
# registered when the deadline passes is silent-but-live (a candidate).
|
||||
deadline = time.monotonic() + timeout
|
||||
while sel.get_map():
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
break
|
||||
for key, _ in sel.select(timeout=remaining):
|
||||
try:
|
||||
# Data back means live; ECONNREFUSED (or any other socket
|
||||
# error) means the port is closed/unusable — rule it out.
|
||||
key.fileobj.recv(1)
|
||||
except OSError:
|
||||
sel.unregister(key.fileobj)
|
||||
live = [key.data for key in sel.get_map().values()]
|
||||
finally:
|
||||
sel.close()
|
||||
for sock in sockets.values():
|
||||
try:
|
||||
sock.close()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
return _order_candidates(live)
|
||||
|
||||
|
||||
def _probe_and_validate(host: str, ca_cert_pem: str, ca_key_pem: str) -> dict:
|
||||
"""Fetch UUID, mint leaf cert, probe each port. Returns config entry data dict."""
|
||||
import cbor2
|
||||
@@ -146,8 +216,21 @@ def _probe_and_validate(host: str, ca_cert_pem: str, ca_key_pem: str) -> dict:
|
||||
raise CannotConnect(f"Failed to mint leaf cert: {exc}") from exc
|
||||
_LOGGER.debug("Leaf cert minted successfully")
|
||||
|
||||
candidates = _find_live_ports(
|
||||
host, PROBE_PORT_RANGE, LIVENESS_PROBE_TIMEOUT_S
|
||||
)
|
||||
if not candidates:
|
||||
# Every port in the range actively refused: there is no DTLS/CoAP
|
||||
# listener on this host, so a handshake can't succeed. Fail now with a
|
||||
# clear message instead of retrying doomed ports.
|
||||
raise CannotConnect(
|
||||
f"no live DTLS port found on {host} "
|
||||
f"in {PROBE_PORT_RANGE[0]}-{PROBE_PORT_RANGE[-1]}"
|
||||
)
|
||||
_LOGGER.debug("Live DTLS port candidates on %s: %s", host, candidates)
|
||||
|
||||
last_exc = None
|
||||
for port in PROBE_PORTS:
|
||||
for port in candidates:
|
||||
sess = None
|
||||
try:
|
||||
sess = DtlsCoapSession(
|
||||
@@ -157,7 +240,7 @@ def _probe_and_validate(host: str, ca_cert_pem: str, ca_key_pem: str) -> dict:
|
||||
)
|
||||
sess.connect()
|
||||
sess.start_reader()
|
||||
code, payload = sess.get(['device', '0'], timeout=15.0)
|
||||
code, payload = sess.get(['device', '0'], timeout=PROBE_GET_TIMEOUT_S)
|
||||
if code != 0x45 or not payload:
|
||||
raise CannotConnect(f"port {port}: unexpected code {code:#04x}")
|
||||
body = cbor2.loads(payload)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
DOMAIN = "localthings"
|
||||
|
||||
PLATFORMS = ["sensor", "binary_sensor", "switch", "number", "select", "button", "time"]
|
||||
PLATFORMS = ["sensor", "binary_sensor", "switch", "number", "select", "button", "time", "climate"]
|
||||
|
||||
CONF_HOST = "host"
|
||||
CONF_PORT = "port"
|
||||
@@ -9,7 +9,25 @@ CONF_CA_KEY_PEM = "ca_key_pem"
|
||||
CONF_LEAF_CERT_PEM = "leaf_cert_pem"
|
||||
CONF_LEAF_KEY_PEM = "leaf_key_pem"
|
||||
|
||||
PROBE_PORTS = [49154, 49155]
|
||||
# The DTLS/CoAP local API binds somewhere in this ephemeral range; which port
|
||||
# depends on firmware. Newer builds answer on 49154/49155, but older ones have
|
||||
# been seen as low as 49153, so we sweep the whole range for a live UDP port
|
||||
# before attempting the (expensive) DTLS handshake.
|
||||
PROBE_PORT_RANGE = list(range(49152, 49161))
|
||||
|
||||
# Ports we've historically seen complete a DTLS handshake. When more than one
|
||||
# port in the range looks live, these are tried first.
|
||||
PREFERRED_PROBE_PORTS = [49154, 49155]
|
||||
|
||||
# Per-port timeout for the cheap UDP liveness sweep. Closed ports return an
|
||||
# ICMP port-unreachable almost immediately; a live-but-silent port is only
|
||||
# detected by this timeout elapsing, so keep it short.
|
||||
LIVENESS_PROBE_TIMEOUT_S = 1.5
|
||||
|
||||
# Deadline for the blockwise /device/0 GET during the config-flow probe. The
|
||||
# slowest device observed returns a full dump in ~8s, so 10s leaves headroom
|
||||
# without stalling setup; it matches the per-resource read timeout elsewhere.
|
||||
PROBE_GET_TIMEOUT_S = 10.0
|
||||
|
||||
SUMMARY_INTERVAL_S = 30.0
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import cbor2
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import ServiceValidationError
|
||||
from homeassistant.helpers import issue_registry as ir
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
@@ -21,6 +22,7 @@ from smartthings_local.ocf.state_cache import StateCache
|
||||
|
||||
from .registry.batch import parse_device0_batch
|
||||
from .registry.by_type import for_device, for_device_by_model
|
||||
from .registry.capabilities.common import remote_control_enabled
|
||||
from .registry.discovery import discover, BoundEntity
|
||||
from .registry import CAPABILITIES
|
||||
from .registry.adapter import flatten
|
||||
@@ -36,6 +38,12 @@ _LOGGER = logging.getLogger(__name__)
|
||||
|
||||
_SEED_PATH = ['device', '0']
|
||||
|
||||
_REMOTE_CONTROL_DISABLED_MESSAGE = (
|
||||
"Remote control is turned off on this device. Check your appliance's "
|
||||
"manual for how to enable remote control before Home Assistant can "
|
||||
"control it."
|
||||
)
|
||||
|
||||
|
||||
class _NoOpDescriptor:
|
||||
"""StateCache requires a descriptor with an on_observation hook. This
|
||||
@@ -509,24 +517,49 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
|
||||
async def async_send_command(self, bound_entity: BoundEntity,
|
||||
payload: Any) -> None:
|
||||
"""Write a value to the device. Fire-and-forget style."""
|
||||
"""Write a value to the device. Fire-and-forget style.
|
||||
|
||||
A description-level validate_fn (currently SwitchDesc only) runs
|
||||
here rather than per-platform, so rejecting a write with a
|
||||
user-facing message -- as opposed to write_fn's silent no-op below
|
||||
-- is available to every platform for free. The remote-control
|
||||
check runs first and applies to every platform unconditionally,
|
||||
ahead of any description-specific validate_fn."""
|
||||
desc = bound_entity.desc
|
||||
write_fn = getattr(desc, 'write_fn', None)
|
||||
if write_fn is None:
|
||||
return
|
||||
href = bound_entity.href
|
||||
rep = self._cache.get(href or '') or {}
|
||||
resources = self._cache.snapshot()
|
||||
if not remote_control_enabled(resources):
|
||||
raise ServiceValidationError(_REMOTE_CONTROL_DISABLED_MESSAGE)
|
||||
validate_fn = getattr(desc, 'validate_fn', None)
|
||||
if validate_fn is not None:
|
||||
error = validate_fn(payload, rep, resources)
|
||||
if error:
|
||||
raise ServiceValidationError(error)
|
||||
result = write_fn(payload, rep, href)
|
||||
if result is None:
|
||||
self._log.warning("write_fn rejected payload %r for %s", payload, href)
|
||||
return
|
||||
path_segs, body = result
|
||||
|
||||
# Apply the write optimistically before starting the settle guard,
|
||||
# not after -- mark_write_pending gates every source (poll, sweep,
|
||||
# observe) through the same apply(), itself included, so flipping
|
||||
# this order would have the guard drop the one update it exists to
|
||||
# protect. Without an optimistic value in the cache for it to hold
|
||||
# onto, the settle window was just delaying the real device
|
||||
# confirmation for a few seconds on every write, which read exactly
|
||||
# like the write being silently reverted (issue #27).
|
||||
self._observe.apply(href, body, source='optimistic')
|
||||
self._observe.mark_write_pending(href)
|
||||
|
||||
def _do_put():
|
||||
sess = self._session
|
||||
if sess is None:
|
||||
raise RuntimeError("no session")
|
||||
self._observe.mark_write_pending(href)
|
||||
code, _ = sess.post(path_segs, cbor2.dumps(body), timeout=8.0)
|
||||
self._log.info("PUT %s → code %#04x", href, code)
|
||||
|
||||
|
||||
@@ -26,13 +26,18 @@ async def async_get_config_entry_diagnostics(
|
||||
coordinator: LocalThingsCoordinator = hass.data[DOMAIN][entry.entry_id]
|
||||
integration = await async_get_integration(hass, DOMAIN)
|
||||
|
||||
# importlib.metadata.version() reads the installed package's metadata off
|
||||
# disk (listdir + open + read_text), which trips HA's event-loop blocking
|
||||
# detector when called inline here. Offload it to the executor.
|
||||
stl_version = await hass.async_add_executor_job(pkg_version, "smartthings-local")
|
||||
|
||||
return {
|
||||
"device_type": coordinator.device_type_name or "unknown",
|
||||
"one_ui_version": coordinator.one_ui_version,
|
||||
"unbound_hrefs": sorted(coordinator._unbound_hrefs),
|
||||
"resources": redact_resources(coordinator.last_resources),
|
||||
"integration_version": integration.version,
|
||||
"smartthings_local_version": pkg_version("smartthings-local"),
|
||||
"smartthings_local_version": stl_version,
|
||||
"observe_mode": coordinator.observe_mode,
|
||||
"observe_subscribed_hrefs": sorted(coordinator._observe.subscribed_hrefs),
|
||||
"observe_fallback_hrefs": sorted(coordinator._observe.fallback_hrefs),
|
||||
|
||||
@@ -8,7 +8,7 @@ from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.const import EntityCategory
|
||||
|
||||
from .registry.adapter import _key
|
||||
from .registry.discovery import BoundEntity
|
||||
from .registry.discovery import BoundEntity, _snake_to_title
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import LocalThingsCoordinator
|
||||
@@ -44,7 +44,7 @@ def _derive_name(state_key: str) -> str:
|
||||
instance number with a space: "door_cooler_open1" → "Door Cooler Open 1".
|
||||
"""
|
||||
name = re.sub(r'(\d+)$', lambda m: f' {m.group()}' if int(m.group()) > 0 else '', state_key)
|
||||
return name.replace('_', ' ').title().strip()
|
||||
return _snake_to_title(name).strip()
|
||||
|
||||
|
||||
class LocalThingsEntity(CoordinatorEntity[LocalThingsCoordinator]):
|
||||
@@ -57,7 +57,15 @@ class LocalThingsEntity(CoordinatorEntity[LocalThingsCoordinator]):
|
||||
self._bound = bound
|
||||
self._state_key = _key(bound)
|
||||
self._attr_unique_id = f"{DOMAIN}_{coordinator.device_serial}_{self._state_key}"
|
||||
self._attr_name = bound.desc.name if bound.desc.name is not None else _derive_name(self._state_key)
|
||||
if bound.desc.name is not None:
|
||||
self._attr_name = bound.desc.name
|
||||
elif bound.instance_name:
|
||||
# A device-given instance name (e.g. an ice maker's "Cubed
|
||||
# Ice") takes the place of the href-derived instance label,
|
||||
# keeping the same entity-specific suffix (issue #27).
|
||||
self._attr_name = f"{bound.instance_name} {_derive_name(bound.desc.key)}".strip()
|
||||
else:
|
||||
self._attr_name = _derive_name(self._state_key)
|
||||
self._attr_translation_key = bound.desc.translation_key
|
||||
self._attr_icon = bound.desc.icon
|
||||
raw_cat = bound.desc.entity_category
|
||||
|
||||
@@ -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.4.0"
|
||||
"version": "0.10.1"
|
||||
}
|
||||
|
||||
@@ -58,6 +58,9 @@ class LocalThingsNumber(LocalThingsEntity, NumberEntity):
|
||||
|
||||
@property
|
||||
def native_min_value(self) -> float:
|
||||
desc: NumberDesc = self._bound.desc
|
||||
if desc.native_min_fn is not None:
|
||||
return desc.native_min_fn(self.coordinator.resource(self._bound.href))
|
||||
r = self._range_from_resource()
|
||||
if r is not None:
|
||||
return float(r[0])
|
||||
@@ -67,6 +70,9 @@ class LocalThingsNumber(LocalThingsEntity, NumberEntity):
|
||||
|
||||
@property
|
||||
def native_max_value(self) -> float:
|
||||
desc: NumberDesc = self._bound.desc
|
||||
if desc.native_max_fn is not None:
|
||||
return desc.native_max_fn(self.coordinator.resource(self._bound.href))
|
||||
r = self._range_from_resource()
|
||||
if r is not None:
|
||||
return float(r[1])
|
||||
@@ -74,6 +80,15 @@ class LocalThingsNumber(LocalThingsEntity, NumberEntity):
|
||||
return self._attr_native_max_value
|
||||
return super().native_max_value
|
||||
|
||||
@property
|
||||
def native_step(self) -> float:
|
||||
desc: NumberDesc = self._bound.desc
|
||||
if desc.step_fn is not None:
|
||||
return desc.step_fn(self.coordinator.resource(self._bound.href))
|
||||
if hasattr(self, '_attr_native_step'):
|
||||
return self._attr_native_step
|
||||
return super().native_step
|
||||
|
||||
@property
|
||||
def native_value(self):
|
||||
return (self.coordinator.data or {}).get(self._state_key)
|
||||
|
||||
@@ -69,6 +69,7 @@ class ObserveManager:
|
||||
self.last_mode_change_wall = time.time()
|
||||
self._settle_until: dict[str, float] = {}
|
||||
self._settle_lock = threading.Lock()
|
||||
self._cache_lock = threading.Lock()
|
||||
self.subscribed_hrefs: set[str] = set()
|
||||
self._notified: set[str] = set()
|
||||
self._last_notify_ts: float | None = None
|
||||
@@ -92,11 +93,35 @@ class ObserveManager:
|
||||
return True
|
||||
|
||||
def apply(self, href: str, rep: dict, source: str) -> bool:
|
||||
"""Gate a StateCache.apply_rep call through the write-settle guard."""
|
||||
"""Gate a StateCache.apply_rep call through the write-settle guard.
|
||||
|
||||
Merges the incoming rep onto whatever's already cached for this
|
||||
href rather than handing it to StateCache.apply_rep verbatim --
|
||||
apply_rep does a full replace, and Samsung devices don't always
|
||||
repeat every field on every update (issue #27: a /mode/vs/0
|
||||
OBSERVE notify -- and, on at least one Bespoke fridge, even the
|
||||
/device/0 sweep entry for that href -- can carry just `modes`,
|
||||
omitting `supportedOptions`/`supportedModes` entirely). A full
|
||||
replace would silently wipe fields a select entity's
|
||||
exists_fn/options_field gates on the moment one partial update
|
||||
comes through, even though nothing about the device's actual
|
||||
supported options changed.
|
||||
|
||||
`apply()` is the sole path StateCache mutations flow through in
|
||||
this component (poll, sweep, and OBSERVE notify all funnel here),
|
||||
so `_cache_lock` serializes the read-then-write across those
|
||||
threads (DTLS reader for notifies, executor threads for poll/
|
||||
sweep) -- without it, two concurrent updates for the same href
|
||||
could each read the same prior rep and the second writer would
|
||||
silently lose the first's fields, reintroducing the exact bug
|
||||
this merge fixes.
|
||||
"""
|
||||
if self._is_settling(href):
|
||||
self.log.debug("dropping %s update for %s (settling)", source, href)
|
||||
return False
|
||||
return self.cache.apply_rep(href, rep, source=source)
|
||||
with self._cache_lock:
|
||||
merged = {**(self.cache.get(href) or {}), **rep}
|
||||
return self.cache.apply_rep(href, merged, source=source)
|
||||
|
||||
def on_notification(self, href: str, payload: bytes) -> None:
|
||||
"""Wired as DtlsCoapSession.on_notification. Runs on the DTLS
|
||||
|
||||
@@ -2,15 +2,18 @@
|
||||
from typing import Optional
|
||||
|
||||
from ._base import DeviceRegistry
|
||||
from . import dishwasher, dryer, oven, refrigerator, washer
|
||||
from . import airconditioner, dishwasher, dryer, oven, range as _range, refrigerator, washer
|
||||
|
||||
__all__ = ['DeviceRegistry', '_type_key', 'for_device', 'for_device_by_model']
|
||||
|
||||
|
||||
_REGISTRY_BY_KEY: dict[str, DeviceRegistry] = {
|
||||
'airconditioner': airconditioner.REGISTRY,
|
||||
'air_conditioner': airconditioner.REGISTRY,
|
||||
'dishwasher': dishwasher.REGISTRY,
|
||||
'dryer': dryer.REGISTRY,
|
||||
'oven': oven.REGISTRY,
|
||||
'range': _range.REGISTRY,
|
||||
'refrigerator': refrigerator.REGISTRY,
|
||||
'washer': washer.REGISTRY,
|
||||
}
|
||||
@@ -66,6 +69,8 @@ def for_device(one_ui_version: str) -> Optional[DeviceRegistry]:
|
||||
_CONSUMER_PREFIX_TO_KEY: dict[str, str] = {
|
||||
'WW': 'washer',
|
||||
'WD': 'washer',
|
||||
'WF': 'washer',
|
||||
'WV': 'washer', # FlexWash twin units (e.g. WV55M9600AW) -- issue #19
|
||||
'DV': 'dryer',
|
||||
'DW': 'dishwasher',
|
||||
}
|
||||
@@ -88,4 +93,18 @@ def for_device_by_model(model_num: str, description: str) -> Optional[DeviceRegi
|
||||
key = _CONSUMER_PREFIX_TO_KEY.get(token[:2].upper())
|
||||
if key is None and '_REF_' in (model_num or ''):
|
||||
key = 'refrigerator'
|
||||
# Room air conditioners (e.g. ARTIK051_PRAC_20K) report no oneUiVersion and
|
||||
# a modelNum carrying the '_PRAC_' (Package Room Air Conditioner) token.
|
||||
if key is None and '_PRAC_' in (model_num or ''):
|
||||
key = 'airconditioner'
|
||||
# Older/simpler RAC boards (e.g. TP2X_RAC_20K, issue #37) use the plain
|
||||
# '_RAC_' token instead -- distinct from '_PRAC_' above (no overlap: the
|
||||
# 'P' sits between the underscore and 'RAC' in that token).
|
||||
if key is None and '_RAC_' in (model_num or ''):
|
||||
key = 'airconditioner'
|
||||
# Range/cooktop-oven combos (e.g. TP1X_DA-KS-RANGE-0102X, issue #44) --
|
||||
# like the RAC/PRAC air conditioners above, these report no oneUiVersion
|
||||
# and don't match the washer/dryer/dishwasher consumer-prefix map either.
|
||||
if key is None and '-RANGE-' in (model_num or '').upper():
|
||||
key = 'range'
|
||||
return _REGISTRY_BY_KEY.get(key) if key else None
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
"""Air-conditioner device registry (Samsung ARTIK051_PRAC-class, issue #17).
|
||||
|
||||
The first device whose core controls surface as a single composite HA `climate`
|
||||
entity (see capabilities/airconditioner.py and climate.py). Power/mode/temp/wind
|
||||
are consumed by that entity rather than exposed as separate switches/selects, so
|
||||
this registry includes *common.UNIVERSAL but deliberately NOT common.POWER --
|
||||
on/off is the climate entity's HVACMode.OFF / TURN_ON/OFF. See common.POWER's
|
||||
own comment in capabilities/common.py for why it's excluded.
|
||||
|
||||
Reuses dishwasher.DIAGNOSIS for /diagnosis/vs/0.
|
||||
"""
|
||||
from ..capabilities import airconditioner, common, dishwasher, ignored
|
||||
from ._base import DeviceRegistry, _build
|
||||
|
||||
REGISTRY = DeviceRegistry(
|
||||
name='airconditioner',
|
||||
capabilities=_build([
|
||||
*ignored.IGNORED,
|
||||
*common.UNIVERSAL,
|
||||
dishwasher.DIAGNOSIS,
|
||||
airconditioner.CLIMATE,
|
||||
airconditioner.AIR_PURIFY,
|
||||
airconditioner.AUTO_CLEAN,
|
||||
airconditioner.AIR_FILTER,
|
||||
airconditioner.DISPLAY_LIGHT,
|
||||
airconditioner.MUTE_ONCE,
|
||||
airconditioner.CURRENT_LIMIT,
|
||||
*airconditioner.COVERAGE,
|
||||
]),
|
||||
)
|
||||
@@ -1,16 +1,13 @@
|
||||
"""Dishwasher device registry."""
|
||||
from ..capabilities import common, dishwasher, dryer, fridge, ignored, laundry, operational
|
||||
from ..capabilities import common, dishwasher, ignored, laundry, operational
|
||||
from ._base import DeviceRegistry, _build
|
||||
|
||||
REGISTRY = DeviceRegistry(
|
||||
name='dishwasher',
|
||||
capabilities=_build([
|
||||
*ignored.IGNORED,
|
||||
common.POWER,
|
||||
common.KIDS_LOCK,
|
||||
common.REMOTE_CONTROL,
|
||||
common.ALARMS,
|
||||
common.ENERGY_METER,
|
||||
*common.UNIVERSAL,
|
||||
*common.POWER,
|
||||
common.WATER_METER,
|
||||
common.WATER_FILTER,
|
||||
operational.OPERATIONAL_STATE,
|
||||
@@ -18,10 +15,9 @@ REGISTRY = DeviceRegistry(
|
||||
dishwasher.DISHWASHER_SETTINGS,
|
||||
dishwasher.DIAGNOSIS,
|
||||
dishwasher.OPERATION_ORIGIN,
|
||||
dryer.JOB_BEGINNING_STATUS,
|
||||
laundry.JOB_BEGINNING_STATUS,
|
||||
laundry.DOOR_LED,
|
||||
laundry.SOUND_MODE,
|
||||
laundry.SOUND_VOLUME,
|
||||
fridge.FIRMWARE_UPDATE,
|
||||
]),
|
||||
)
|
||||
|
||||
@@ -2,26 +2,29 @@
|
||||
|
||||
Note: dryer devices use port 49155 (not 49154). The config flow handles
|
||||
this automatically via port probing.
|
||||
|
||||
The power/kids-lock/remote-control, buzzer, energy-meter, job-status, and
|
||||
cycle-select capabilities are the shared laundry ones (laundry.py), the same
|
||||
objects the washer registry uses -- washer and dryer expose the same
|
||||
DA_WM_-family surface, so they stay consistent instead of each carrying a
|
||||
bespoke variant.
|
||||
"""
|
||||
from ..capabilities import common, dryer, fridge, ignored, laundry, operational
|
||||
from ..capabilities import common, dryer, ignored, laundry, operational
|
||||
from ._base import DeviceRegistry, _build
|
||||
|
||||
REGISTRY = DeviceRegistry(
|
||||
name='dryer',
|
||||
capabilities=_build([
|
||||
*ignored.IGNORED,
|
||||
common.POWER,
|
||||
common.KIDS_LOCK,
|
||||
common.REMOTE_CONTROL,
|
||||
common.ALARMS,
|
||||
common.ENERGY_METER,
|
||||
*common.UNIVERSAL,
|
||||
*common.POWER,
|
||||
operational.OPERATIONAL_STATE,
|
||||
laundry.DOOR_LED,
|
||||
laundry.SOUND_MODE,
|
||||
laundry.BUZZER_SOUND,
|
||||
laundry.JOB_BEGINNING_STATUS,
|
||||
dryer.DRYER_SETTINGS,
|
||||
dryer.DRYER_COURSE,
|
||||
dryer.JOB_BEGINNING_STATUS,
|
||||
dryer.DRYER_DIAGNOSIS,
|
||||
fridge.FIRMWARE_UPDATE,
|
||||
]),
|
||||
)
|
||||
|
||||
@@ -1,21 +1,19 @@
|
||||
"""Oven device registry."""
|
||||
from ..capabilities import common, fridge, ignored, oven
|
||||
from ..capabilities import common, ignored, oven
|
||||
from ._base import DeviceRegistry, _build
|
||||
|
||||
REGISTRY = DeviceRegistry(
|
||||
name='oven',
|
||||
capabilities=_build([
|
||||
*ignored.IGNORED,
|
||||
common.POWER,
|
||||
common.KIDS_LOCK,
|
||||
common.REMOTE_CONTROL,
|
||||
common.ALARMS,
|
||||
*common.UNIVERSAL,
|
||||
*common.POWER,
|
||||
oven.OVEN_CAVITY,
|
||||
oven.OVEN_SETPOINT,
|
||||
oven.OVEN_MODE,
|
||||
oven.OVEN_OPERATIONAL_STATE,
|
||||
oven.OVEN_DOOR,
|
||||
oven.OVEN_CONNECTED,
|
||||
fridge.FIRMWARE_UPDATE,
|
||||
oven.OVEN_SPEC,
|
||||
]),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
"""Range (oven + cooktop combo) device registry — issue #44.
|
||||
|
||||
Reuses the oven family's cavity/setpoint/mode/operational-state/door/
|
||||
connected capabilities wholesale (a range's oven half is the same OCF
|
||||
surface as a standalone oven) and adds the cooktop-specific capabilities
|
||||
for the burner half.
|
||||
"""
|
||||
from ..capabilities import common, ignored, oven
|
||||
from ..capabilities import range as range_caps
|
||||
from ._base import DeviceRegistry, _build
|
||||
|
||||
REGISTRY = DeviceRegistry(
|
||||
name='range',
|
||||
capabilities=_build([
|
||||
*ignored.IGNORED,
|
||||
*common.UNIVERSAL,
|
||||
*common.POWER,
|
||||
oven.OVEN_CAVITY,
|
||||
oven.OVEN_SETPOINT,
|
||||
oven.OVEN_MODE,
|
||||
oven.OVEN_OPERATIONAL_STATE,
|
||||
oven.OVEN_DOOR,
|
||||
oven.OVEN_CONNECTED,
|
||||
oven.OVEN_SPEC,
|
||||
range_caps.COOKTOP_STATUS,
|
||||
range_caps.COOKTOP_SPEC,
|
||||
range_caps.COOKTOP_SAFETY,
|
||||
]),
|
||||
)
|
||||
@@ -1,19 +1,17 @@
|
||||
"""Refrigerator device registry."""
|
||||
from ..capabilities import common, fridge, ignored
|
||||
from ..capabilities import common, dishwasher, fridge, ignored
|
||||
from ._base import DeviceRegistry, _build
|
||||
|
||||
REGISTRY = DeviceRegistry(
|
||||
name='refrigerator',
|
||||
capabilities=_build([
|
||||
*ignored.IGNORED,
|
||||
common.POWER,
|
||||
*common.UNIVERSAL,
|
||||
*common.POWER,
|
||||
fridge.STATUS_LOCK,
|
||||
fridge.DOOR_ALERT,
|
||||
common.KIDS_LOCK,
|
||||
common.REMOTE_CONTROL,
|
||||
common.ALARMS,
|
||||
common.ENERGY_METER,
|
||||
common.WATER_FILTER,
|
||||
dishwasher.DIAGNOSIS,
|
||||
fridge.ICEMAKER_NIGHTTIME,
|
||||
fridge.FLEX_ZONE,
|
||||
fridge.REFRIGERATION,
|
||||
@@ -23,13 +21,14 @@ REGISTRY = DeviceRegistry(
|
||||
fridge.CABINET_LIGHT_ENHANCED,
|
||||
fridge.SABBATH,
|
||||
fridge.BEVERAGE_ZONE,
|
||||
fridge.FIRMWARE_UPDATE,
|
||||
fridge.PANTRY_ZONE,
|
||||
fridge.DEFROST_DELAY,
|
||||
fridge.DEFROST_DELAY_NATIVE_DUPLICATE,
|
||||
fridge.DEFROST_BLOCK_STATUS,
|
||||
fridge.SELF_CHECK,
|
||||
fridge.DOORS_FALLBACK,
|
||||
fridge.TEMPERATURES_FALLBACK,
|
||||
fridge.ICEMAKER_STATUS_FALLBACK,
|
||||
fridge.ICEMAKER_STATUS_NATIVE_DUPLICATE,
|
||||
fridge.REFRIGERATION_FALLBACK,
|
||||
]),
|
||||
pattern_capabilities=[
|
||||
|
||||
@@ -1,26 +1,19 @@
|
||||
"""Washer device registry."""
|
||||
from ..capabilities import common, dishwasher, fridge, ignored, operational, washer
|
||||
from ..capabilities import common, dishwasher, ignored, laundry, operational, washer
|
||||
from ._base import DeviceRegistry, _build
|
||||
|
||||
REGISTRY = DeviceRegistry(
|
||||
name='washer',
|
||||
capabilities=_build([
|
||||
*ignored.IGNORED,
|
||||
washer.POWER_GENERIC,
|
||||
washer.POWER_VS_FALLBACK,
|
||||
washer.KIDS_LOCK_GENERIC,
|
||||
washer.KIDS_LOCK_VS_FALLBACK,
|
||||
washer.REMOTE_CONTROL_GENERIC,
|
||||
washer.REMOTE_CONTROL_VS_FALLBACK,
|
||||
*common.UNIVERSAL,
|
||||
*common.POWER,
|
||||
washer.WASHER_SETTINGS,
|
||||
washer.WASHER_COURSE,
|
||||
washer.BUZZER_SOUND,
|
||||
washer.WASHER_JOB_BEGINNING_STATUS,
|
||||
common.ALARMS,
|
||||
washer.WASHER_ENERGY_METER,
|
||||
laundry.BUZZER_SOUND,
|
||||
laundry.JOB_BEGINNING_STATUS,
|
||||
common.WATER_METER,
|
||||
operational.OPERATIONAL_STATE,
|
||||
dishwasher.DIAGNOSIS,
|
||||
fridge.FIRMWARE_UPDATE,
|
||||
]),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
"""Capabilities for the Samsung air-conditioner family (ARTIK051_PRAC-class).
|
||||
|
||||
Resources verified against the issue #17 diagnostics dump (model
|
||||
ARTIK051_PRAC_20K). This is the first family whose core controls surface as a
|
||||
single composite HA `climate` entity rather than a scatter of switches/selects:
|
||||
power (on/off), HVAC mode, current/target temperature, fan (wind) strength,
|
||||
swing (wind direction), and the convenient-mode preset all live on one climate
|
||||
card. The climate platform (climate.py) reads those sibling resources from the
|
||||
coordinator snapshot; here we bind the primary `/mode/vs/0` resource to the
|
||||
`ClimateDesc` and mark the consumed siblings as covered.
|
||||
|
||||
None of these caps may go into the global `ALL`/`CAPABILITIES`: `/mode/vs/0`,
|
||||
`/temperatures/vs/0`, `/humidity/*` collide with fridge/oven hrefs of a
|
||||
different schema (see capabilities/__init__.py). They live only in the AC
|
||||
by_type registry.
|
||||
"""
|
||||
from ..capability import Capability
|
||||
from ..entities import BinarySensorDesc, ClimateDesc, SensorDesc, SwitchDesc
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Canonical AC resource hrefs. The climate entity (climate.py) binds the
|
||||
# primary HREF_MODE via CLIMATE below and reads the CLIMATE_CONSUMED_HREFS
|
||||
# siblings off the coordinator snapshot; those siblings are marked covered
|
||||
# (no-entity caps) so discover() reports no gap. Declared once here and
|
||||
# imported by climate.py, so a new sibling read can't drift out of sync with
|
||||
# its coverage entry.
|
||||
# ---------------------------------------------------------------------------
|
||||
HREF_MODE = '/mode/vs/0' # primary (bound by CLIMATE)
|
||||
HREF_POWER = '/power/0' # on/off -> HVACMode.OFF / TURN_ON/OFF
|
||||
HREF_POWER_VS = '/power/vs/0' # vendor fallback for on/off
|
||||
HREF_TEMP_CURRENT = '/temperature/current/0' # current_temperature
|
||||
HREF_TEMP_DESIRED = '/temperature/desired/0' # target_temperature (write target)
|
||||
HREF_TEMP_CONTROL = '/temperature/control/vs/0' # target_temperature_step
|
||||
HREF_WIND_STRENGTH = '/wind/strength/vs/0' # fan_mode
|
||||
HREF_WIND_DIRECTION = '/wind/direction/vs/0' # swing_mode
|
||||
HREF_CONVENIENT = '/mode/convenient/vs/0' # preset_mode
|
||||
HREF_TEMPS_VS = '/temperatures/vs/0' # vendor temp fallback (items[] array)
|
||||
|
||||
CLIMATE_CONSUMED_HREFS = [
|
||||
HREF_POWER, HREF_POWER_VS, HREF_TEMP_CURRENT, HREF_TEMP_DESIRED,
|
||||
HREF_TEMP_CONTROL, HREF_TEMPS_VS, HREF_WIND_STRENGTH, HREF_WIND_DIRECTION,
|
||||
HREF_CONVENIENT,
|
||||
]
|
||||
|
||||
|
||||
def _num(v):
|
||||
try:
|
||||
return float(v)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _filter_usage_percent(rep):
|
||||
"""Filter usage as a percentage of rated capacity. The device reports
|
||||
`filterUsage` as a raw count in `filterCapacityUnit` (Hours here, e.g.
|
||||
100 of a 500 capacity), so a plain value with a '%' unit would be wrong --
|
||||
normalize to used/capacity. Returns None when capacity is missing/zero."""
|
||||
used = _num(rep.get('x.com.samsung.da.filterUsage'))
|
||||
cap = _num(rep.get('x.com.samsung.da.filterCapacity'))
|
||||
if used is None or not cap:
|
||||
return None
|
||||
return round(used / cap * 100)
|
||||
|
||||
|
||||
def _first_mode(rep):
|
||||
"""Representative scalar for the climate entity in the flattened state
|
||||
(golden/regression). The real entity computes hvac_mode from power + mode."""
|
||||
modes = rep.get('x.com.samsung.da.modes')
|
||||
if isinstance(modes, (list, tuple)):
|
||||
return modes[0] if modes else None
|
||||
return modes
|
||||
|
||||
|
||||
def _climate_write(payload, rep, href=None):
|
||||
"""Map a (kind, value) command from the climate platform to the
|
||||
(path_segs, body) for that one sub-write. `value` is already the raw device
|
||||
code (the platform maps HA<->device). async_send_command POSTs to path_segs,
|
||||
so a single desc drives writes across power/mode/temperature/wind resources.
|
||||
Read-modify-write safe: each write sends only its own field, leaving the
|
||||
resource's other fields (e.g. /mode/vs/0's opaque `options` blob) untouched.
|
||||
"""
|
||||
kind, value = payload
|
||||
if kind == 'power':
|
||||
return (['power', '0'], {'value': bool(value)})
|
||||
if kind == 'mode':
|
||||
return (['mode', 'vs', '0'], {'x.com.samsung.da.modes': [value]})
|
||||
if kind == 'temperature':
|
||||
return (['temperature', 'desired', '0'], {'temperature': int(round(float(value)))})
|
||||
if kind == 'fan':
|
||||
return (['wind', 'strength', 'vs', '0'], {'x.com.samsung.da.modes': value})
|
||||
if kind == 'swing':
|
||||
return (['wind', 'direction', 'vs', '0'], {'x.com.samsung.da.modes': value})
|
||||
if kind == 'preset':
|
||||
return (['mode', 'convenient', 'vs', '0'], {'x.com.samsung.da.modes': value})
|
||||
return None
|
||||
|
||||
|
||||
CLIMATE = Capability(
|
||||
href=HREF_MODE,
|
||||
poll_tier='warm',
|
||||
entities=(
|
||||
ClimateDesc(key='climate', translation_key='airconditioner',
|
||||
rep_fn=_first_mode, write_fn=_climate_write),
|
||||
),
|
||||
)
|
||||
|
||||
AIR_PURIFY = Capability(
|
||||
href='/option/airpurify/vs/0',
|
||||
poll_tier='warm',
|
||||
entities=(
|
||||
SwitchDesc(key='air_purify', field='x.com.samsung.da.modes',
|
||||
name='Air purification', icon='mdi:air-purifier',
|
||||
entity_category='config',
|
||||
value_fn=lambda v: v == 'On',
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['option', 'airpurify', 'vs', '0'],
|
||||
{'x.com.samsung.da.modes': 'On' if p == 'On' else 'Off'})),
|
||||
),
|
||||
)
|
||||
|
||||
AUTO_CLEAN = Capability(
|
||||
href='/option/autoclean/vs/0',
|
||||
poll_tier='cold',
|
||||
entities=(
|
||||
SwitchDesc(key='auto_clean', field='x.com.samsung.da.settingStatus',
|
||||
name='Auto clean', icon='mdi:spray-bottle',
|
||||
entity_category='config',
|
||||
value_fn=lambda v: v == 'On',
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['option', 'autoclean', 'vs', '0'],
|
||||
{'x.com.samsung.da.settingStatus': 'On' if p == 'On' else 'Off'})),
|
||||
),
|
||||
)
|
||||
|
||||
AIR_FILTER = Capability(
|
||||
href='/filter/airdustfilter/vs/0',
|
||||
poll_tier='cold',
|
||||
entities=(
|
||||
SensorDesc(key='air_filter_usage', rep_fn=_filter_usage_percent,
|
||||
name='Filter usage', unit='%', state_class='measurement',
|
||||
icon='mdi:air-filter', entity_category='diagnostic'),
|
||||
SensorDesc(key='air_filter_status', field='x.com.samsung.da.filterStatus',
|
||||
name='Filter status', device_class='enum',
|
||||
options=('normal', 'wash', 'replace'),
|
||||
translation_key='air_filter_status',
|
||||
icon='mdi:air-filter', entity_category='diagnostic',
|
||||
value_fn=lambda v: v.lower() if isinstance(v, str) else v),
|
||||
),
|
||||
)
|
||||
|
||||
DISPLAY_LIGHT = Capability(
|
||||
href='/light/vs/0',
|
||||
poll_tier='cold',
|
||||
entities=(
|
||||
SwitchDesc(key='display_light', field='mode',
|
||||
name='Display light', icon='mdi:led-on',
|
||||
entity_category='config',
|
||||
value_fn=lambda v: v == 'On',
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['light', 'vs', '0'],
|
||||
{'mode': 'On' if p == 'On' else 'Off'})),
|
||||
),
|
||||
)
|
||||
|
||||
# Confirmed against issue #38's dump (TP1X_DA-AC-RAC-01001_0000): a single
|
||||
# boolean field, no vendor prefix, mirroring the On/Off convention used
|
||||
# throughout the rest of this API.
|
||||
MUTE_ONCE = Capability(
|
||||
href='/option/muteonce/vs/0',
|
||||
poll_tier='warm',
|
||||
entities=(
|
||||
SwitchDesc(key='mute_once', field='muteonce',
|
||||
name='Mute once', icon='mdi:volume-mute',
|
||||
entity_category='config',
|
||||
value_fn=lambda v: v == 'On',
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['option', 'muteonce', 'vs', '0'],
|
||||
{'muteonce': 'On' if p == 'On' else 'Off'})),
|
||||
),
|
||||
)
|
||||
|
||||
# Circuit-breaker current-limit setting (issue #38, TP1X board): `operation`
|
||||
# toggles the limiter and `modes` picks a level out of `supportedModes`
|
||||
# (seen as '3'..'9'). No vendor field-name prefix and no unit/label in the
|
||||
# dump to confirm what the levels mean (amps vs. an abstract tier) --
|
||||
# exposed read-only per the 'don't guess' rule rather than risking an
|
||||
# unverified write to live HVAC hardware.
|
||||
CURRENT_LIMIT = Capability(
|
||||
href='/electriccurrent/vs/0',
|
||||
poll_tier='cold',
|
||||
entities=(
|
||||
BinarySensorDesc(key='current_limit_enabled', field='operation',
|
||||
name='Current limit enabled', icon='mdi:current-ac',
|
||||
entity_category='diagnostic',
|
||||
value_fn=lambda v: v == 'On'),
|
||||
SensorDesc(key='current_limit_level', field='modes',
|
||||
name='Current limit level', icon='mdi:current-ac',
|
||||
entity_category='diagnostic'),
|
||||
),
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AC-scoped coverage: the CLIMATE_CONSUMED_HREFS above (read by the climate
|
||||
# entity) plus vendor duplicates / all-zero-ambiguous / plumbing resources.
|
||||
# These are NOT in the global ignored.IGNORED because several of them
|
||||
# (/mode/vs/0 handled above, /temperatures/vs/0, /humidity/*) collide with
|
||||
# other families' schemas. A no-entity Capability still marks the href as
|
||||
# bound so discover() reports no coverage gap.
|
||||
#
|
||||
# CLIMATE_CONSUMED_HREFS carry the climate card's actual displayed state
|
||||
# (power, current/target temp, fan, swing, preset) -- the coordinator only
|
||||
# OBSERVE-subscribes and sub-polls 'hot'/'warm' hrefs (see coordinator.py),
|
||||
# so leaving these at the Capability default of 'cold' meant every state
|
||||
# change was invisible until the next full /device/0 summary sweep
|
||||
# (~30s -- issue #17: instant device response, 20-30s HA lag). Pin them to
|
||||
# 'warm' -- same tier as CLIMATE's own primary href -- so they get push
|
||||
# notifications (or, in poll-only mode, the warm sub-poll cadence) instead
|
||||
# of waiting on the summary sweep.
|
||||
# ---------------------------------------------------------------------------
|
||||
_AC_IGNORED = [
|
||||
# All-zero and ambiguously encoded on this model (2-value arrays); the
|
||||
# 'don't guess' rule -- leave unmodeled rather than invent entities.
|
||||
'/sensors/vs/0',
|
||||
'/humidity/0',
|
||||
'/humidity/vs/0',
|
||||
# Presence-personalization plumbing (empty item list here).
|
||||
'/personality/presence/vs/0',
|
||||
# --- TP1X/TP2X-class housekeeping / opaque blobs. These carry no
|
||||
# user-actionable state or no documented write contract, so per the
|
||||
# 'don't guess' rule they are ignored rather than modeled.
|
||||
# /option/muteonce/vs/0 and /selfcheck/vs/0 are deliberately NOT here --
|
||||
# see MUTE_ONCE above and common.SELF_CHECK (via common.UNIVERSAL) in
|
||||
# the by_type registry, both of which have a confirmed, cleanly
|
||||
# modelable contract.
|
||||
'/airlevelcheck/vs/0', # periodic air-quality sensing scheduler plumbing
|
||||
'/aisleep/vs/0', # AI-sleep feedback state (no actionable control)
|
||||
'/availablecontrolsets/vs/0', # opaque hex-encoded control-set bitmap
|
||||
'/da/softreset/vs/0', # soft-reset trigger plumbing
|
||||
'/keepnormalstate/vs/0', # internal keep-normal flag
|
||||
'/mds/absencemonitoring/vs/0', # motion-detection sensor plumbing (empty here)
|
||||
'/mds/absencestate/vs/0', # motion-detection state (empty here)
|
||||
'/remotedatacontrol/vs/0', # remote data-control session status
|
||||
'/remotetemperature/vs/0', # external temp-sensor feed (unset on this unit)
|
||||
'/reserverulesets/vs/0', # opaque hex-encoded schedule reservation blob
|
||||
'/welcome/temperature/vs/0', # welcome-cooling plumbing
|
||||
]
|
||||
|
||||
# Built as bare no-entity caps; folded into the AC registry (not global).
|
||||
COVERAGE = [
|
||||
Capability(href=h, poll_tier='warm') for h in CLIMATE_CONSUMED_HREFS
|
||||
] + [
|
||||
Capability(href=h) for h in _AC_IGNORED
|
||||
]
|
||||
@@ -11,7 +11,9 @@ against live device dumps:
|
||||
/filter/waterfilter/vs/0 -> x.com.samsung.da.filterUsage / filterStatus
|
||||
"""
|
||||
from ..capability import Capability
|
||||
from ..entities import BinarySensorDesc, SensorDesc, SwitchDesc
|
||||
from ..entities import (
|
||||
BinarySensorDesc, ButtonDesc, SelectDesc, SensorDesc, SwitchDesc,
|
||||
)
|
||||
|
||||
|
||||
def _num(v):
|
||||
@@ -56,8 +58,57 @@ def _active_alarm_codes(items):
|
||||
return ', '.join(codes) if codes else 'none'
|
||||
|
||||
|
||||
KIDS_LOCK = Capability(
|
||||
# OCF-native / vendor '-vs' fallback pairs for power, kids-lock, remote control.
|
||||
#
|
||||
# These three controls exist as both a standard OCF resource (/power/0,
|
||||
# oic.r.switch.binary, plain boolean 'value') and a Samsung vendor resource
|
||||
# (/power/vs/0, x.com.samsung.da.power) -- Samsung advertises both as its
|
||||
# firmware migrates onto the OCF standard model. Prefer the OCF-standard href
|
||||
# when the device exposes it; the '-vs' href (a string-encoded duplicate for
|
||||
# these three) binds only when the generic href is absent, via match_fn. Older
|
||||
# firmware has only the '-vs' resource, so the pair is behaviour-identical to a
|
||||
# lone '-vs' cap there. See the adding-device-support skill's "OCF-standard vs
|
||||
# vendor" section for why this is preferred-non-vs-with-fallback, not a blanket
|
||||
# choice. Every device registry lists both caps of each pair.
|
||||
|
||||
POWER_GENERIC = Capability(
|
||||
href='/power/0',
|
||||
entities=(
|
||||
SwitchDesc(key='power_switch', field='value',
|
||||
name='Power',
|
||||
value_fn=lambda v: bool(v),
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['power', '0'], {'value': p == 'On'})),
|
||||
),
|
||||
)
|
||||
|
||||
POWER_VS_FALLBACK = Capability(
|
||||
href='/power/vs/0',
|
||||
match_fn=lambda rep, resources: '/power/0' not in resources,
|
||||
entities=(
|
||||
SwitchDesc(key='power_switch', field='x.com.samsung.da.power',
|
||||
name='Power',
|
||||
value_fn=lambda v: v == 'On',
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['power', 'vs', '0'],
|
||||
{'x.com.samsung.da.power': 'On' if p == 'On' else 'Off'})),
|
||||
),
|
||||
)
|
||||
|
||||
KIDS_LOCK_GENERIC = Capability(
|
||||
href='/kidslock/0',
|
||||
entities=(
|
||||
SwitchDesc(key='child_lock', field='value',
|
||||
name='Child lock', device_class='lock',
|
||||
value_fn=lambda v: bool(v),
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['kidslock', '0'], {'value': p == 'On'})),
|
||||
),
|
||||
)
|
||||
|
||||
KIDS_LOCK_VS_FALLBACK = Capability(
|
||||
href='/kidslock/vs/0',
|
||||
match_fn=lambda rep, resources: '/kidslock/0' not in resources,
|
||||
entities=(
|
||||
SwitchDesc(key='child_lock', field='x.com.samsung.da.kidsLock',
|
||||
name='Child lock', device_class='lock',
|
||||
@@ -68,8 +119,41 @@ KIDS_LOCK = Capability(
|
||||
),
|
||||
)
|
||||
|
||||
REMOTE_CONTROL = Capability(
|
||||
|
||||
def remote_control_enabled(resources: dict) -> bool:
|
||||
"""Single source of truth for the /remotectrl on/off signal, mirroring
|
||||
REMOTE_CONTROL_GENERIC/_VS_FALLBACK's href/field pair and precedence
|
||||
below. Used both to render the read-only Smart Control binary_sensor
|
||||
(via those two descriptors) and, from coordinator.async_send_command,
|
||||
to block writes outright when remote control is off. Both hrefs are
|
||||
poll_tier='warm' below so that gate reads recent state (subscribed
|
||||
when observe is live, subpolled every ~6s otherwise) rather than a
|
||||
once-per-30s cold summary poll. True (assume enabled) when neither
|
||||
href is present -- most device types don't report this capability
|
||||
at all."""
|
||||
generic = resources.get('/remotectrl/0')
|
||||
if generic is not None:
|
||||
return bool(generic.get('value'))
|
||||
fallback = resources.get('/remotectrl/vs/0')
|
||||
if fallback is not None:
|
||||
return str(fallback.get('x.com.samsung.da.remoteControlEnabled')).lower() == 'true'
|
||||
return True
|
||||
|
||||
|
||||
REMOTE_CONTROL_GENERIC = Capability(
|
||||
href='/remotectrl/0',
|
||||
poll_tier='warm',
|
||||
entities=(
|
||||
BinarySensorDesc(key='remote_control', field='value',
|
||||
name='Smart Control', device_class='connectivity',
|
||||
value_fn=lambda v: bool(v)),
|
||||
),
|
||||
)
|
||||
|
||||
REMOTE_CONTROL_VS_FALLBACK = Capability(
|
||||
href='/remotectrl/vs/0',
|
||||
match_fn=lambda rep, resources: '/remotectrl/0' not in resources,
|
||||
poll_tier='warm',
|
||||
entities=(
|
||||
BinarySensorDesc(key='remote_control',
|
||||
field='x.com.samsung.da.remoteControlEnabled',
|
||||
@@ -78,16 +162,6 @@ REMOTE_CONTROL = Capability(
|
||||
),
|
||||
)
|
||||
|
||||
POWER = Capability(
|
||||
href='/power/vs/0',
|
||||
entities=(
|
||||
SwitchDesc(key='power_switch', field='x.com.samsung.da.power',
|
||||
name='Power',
|
||||
value_fn=lambda v: v == 'On',
|
||||
write_fn=lambda p, rep, href=None: (['power', 'vs', '0'], {'x.com.samsung.da.power': 'On' if p == 'On' else 'Off'})),
|
||||
),
|
||||
)
|
||||
|
||||
ALARMS = Capability(
|
||||
href='/alarms/vs/0',
|
||||
poll_tier='hot',
|
||||
@@ -98,15 +172,66 @@ ALARMS = Capability(
|
||||
),
|
||||
)
|
||||
|
||||
# instantaneousPower is a dead field on DA_WM_-class laundry dumps (washers and
|
||||
# the issue #14 dryer) and on dishwashers too: the literal sentinel '-500',
|
||||
# unchanged across off/idle/running. clamp_power floors it to a misleading
|
||||
# "0 W" that reads as a real idle measurement. Gate power_watts out when the
|
||||
# sentinel is seen -- but only then, so a device reporting a real value (e.g. a
|
||||
# fridge's 93 W) still shows it (issue #6). cumulativePower is absent on at
|
||||
# least one washer model; the exists_fn makes that explicit rather than relying
|
||||
# on the generic field-presence gate.
|
||||
_DEAD_INSTANTANEOUS_POWER = '-500'
|
||||
|
||||
ENERGY_METER = Capability(
|
||||
href='/energy/consumption/vs/0',
|
||||
entities=(
|
||||
# `not rep` keeps the empty-{} stub carve-out (see entity._is_included):
|
||||
# an explicit exists_fn otherwise bypasses it, which would drop the
|
||||
# entity when /device/0 returns a not-yet-fetched stub. On a populated
|
||||
# rep, hide power only for the dead sentinel or an absent field.
|
||||
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,
|
||||
exists_fn=lambda rep, resources: not rep or (
|
||||
rep.get('x.com.samsung.da.instantaneousPower')
|
||||
not in (None, _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),
|
||||
state_class='total_increasing', unit='kWh', value_fn=wh_to_kwh,
|
||||
exists_fn=lambda rep, resources: (
|
||||
not rep or 'x.com.samsung.da.cumulativePower' in rep)),
|
||||
# cumulativeConsumption is a second, independently-varying running
|
||||
# total alongside cumulativePower -- some fridges (issue #26) report
|
||||
# both. Self-gates off where only cumulativePower is present. `not
|
||||
# rep or` keeps the same empty-{} stub carve-out as power_watts/
|
||||
# energy_kwh above -- without it, an exists_fn permanently drops the
|
||||
# entity if setup happens to land on a not-yet-fetched stub.
|
||||
SensorDesc(key='power_energy_kwh', field='x.com.samsung.da.cumulativeConsumption',
|
||||
name='Power energy', device_class='energy',
|
||||
state_class='total_increasing', unit='kWh', value_fn=wh_to_kwh,
|
||||
exists_fn=lambda rep, resources: (
|
||||
not rep or 'x.com.samsung.da.cumulativeConsumption' in rep)),
|
||||
# AI Energy Mode's lifetime savings estimate vs. an unoptimized
|
||||
# baseline -- present on some models (e.g. TP1X_REF_21K, issue #21/
|
||||
# #27) and absent on others (issue #20/#26), unlike cumulativePower.
|
||||
SensorDesc(key='energy_saved_kwh', field='x.com.samsung.da.cumulativeSavedPower',
|
||||
name='Energy saved', device_class='energy',
|
||||
state_class='total_increasing', unit='kWh', value_fn=wh_to_kwh,
|
||||
exists_fn=lambda rep, resources: (
|
||||
not rep or 'x.com.samsung.da.cumulativeSavedPower' in rep)),
|
||||
# Monthly billing-cycle totals -- the completed prior month and the
|
||||
# in-progress current month. Not ever-increasing (each resets at
|
||||
# month boundary), so no state_class.
|
||||
SensorDesc(key='energy_last_month_kwh', field='x.com.samsung.da.monthlyConsumption',
|
||||
name='Energy (last month)', device_class='energy',
|
||||
unit='kWh', value_fn=wh_to_kwh,
|
||||
exists_fn=lambda rep, resources: (
|
||||
not rep or 'x.com.samsung.da.monthlyConsumption' in rep)),
|
||||
SensorDesc(key='energy_this_month_kwh', field='x.com.samsung.da.thismonthlyConsumption',
|
||||
name='Energy (this month)', device_class='energy',
|
||||
unit='kWh', value_fn=wh_to_kwh,
|
||||
exists_fn=lambda rep, resources: (
|
||||
not rep or 'x.com.samsung.da.thismonthlyConsumption' in rep)),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -131,3 +256,162 @@ WATER_FILTER = Capability(
|
||||
name='Filter status', icon='mdi:filter-check'),
|
||||
),
|
||||
)
|
||||
|
||||
# AI energy-saving level -- '0' is off, and supportedAiLevel lists the
|
||||
# additional level(s) the device offers ('1' meaning just "on" on most
|
||||
# hardware, but multi-level boards have been reported). Verified cross-family:
|
||||
# fridge (issue #21) and washer (issue #40) both expose this href.
|
||||
#
|
||||
# supportedAiLevel is a single-entry list on most captured hardware, where a
|
||||
# select would offer only one real choice against an implicit "off" -- shown
|
||||
# as a switch instead. '0' itself is never in supportedAiLevel but has been
|
||||
# observed live as the off value of aiLevel, so the select synthesizes it
|
||||
# back in as an explicit option rather than leaving no way to turn off.
|
||||
#
|
||||
# No translation_key: aiLevel's values are plain digit strings, and
|
||||
# select.py's _display() already renders an untranslated numeric string
|
||||
# as-is -- there's nothing a strings.json entry adds that's worth maintaining
|
||||
# against an unknown, growing number of future levels.
|
||||
|
||||
|
||||
def _ai_energy_supported_levels(rep):
|
||||
"""supportedAiLevel as a list -- a stray scalar (e.g. a string) must not
|
||||
be len()-checked as if it were a list."""
|
||||
sl = rep.get('supportedAiLevel')
|
||||
return list(sl) if isinstance(sl, (list, tuple)) else []
|
||||
|
||||
|
||||
def _ai_energy_level_options(resources):
|
||||
rep = resources.get('/energy/ailevel/vs/0') or {}
|
||||
return ['0', *_ai_energy_supported_levels(rep)]
|
||||
|
||||
|
||||
def _ai_energy_level_write(p, rep, href=None):
|
||||
return ['energy', 'ailevel', 'vs', '0'], {'aiLevel': p}
|
||||
|
||||
|
||||
def _ai_energy_level_switch_write(p, rep, href=None):
|
||||
levels = _ai_energy_supported_levels(rep)
|
||||
on_level = levels[0] if levels else '1'
|
||||
return ['energy', 'ailevel', 'vs', '0'], {'aiLevel': on_level if p == 'On' else '0'}
|
||||
|
||||
|
||||
AI_ENERGY_LEVEL = Capability(
|
||||
href='/energy/ailevel/vs/0',
|
||||
poll_tier='cold',
|
||||
entities=(
|
||||
# No `not rep` stub carve-out on either side, unlike most exists_fn
|
||||
# gates in this file -- entity creation only ever runs once, against
|
||||
# whichever snapshot happens to be current the moment platforms are
|
||||
# set up (see entity._is_included / __init__.py's
|
||||
# async_config_entry_first_refresh-before-forward-entry-setups
|
||||
# ordering), while flatten() re-evaluates exists_fn every poll
|
||||
# against live data. Both descriptors share key='ai_energy_level',
|
||||
# so if a stub carve-out let one of them win at setup time while the
|
||||
# other wins once real data lands, flatten() would feed the
|
||||
# instantiated entity a value shaped for the other platform (e.g. a
|
||||
# bool into a Select). Requiring real, populated data on both sides
|
||||
# keeps the entity-creation decision and the live-value decision in
|
||||
# permanent agreement -- the cost is this entity doesn't appear
|
||||
# until a reload if the device's very first poll stubs this
|
||||
# cold-tier href, the same reload already required to fix which
|
||||
# platform got picked in that case.
|
||||
SwitchDesc(key='ai_energy_level', field='aiLevel',
|
||||
name='AI energy level', icon='mdi:leaf',
|
||||
entity_category='config',
|
||||
value_fn=lambda v: v != '0',
|
||||
exists_fn=lambda rep, resources: (
|
||||
len(_ai_energy_supported_levels(rep)) == 1),
|
||||
write_fn=_ai_energy_level_switch_write),
|
||||
SelectDesc(key='ai_energy_level', field='aiLevel',
|
||||
name='AI energy level', icon='mdi:leaf',
|
||||
entity_category='config',
|
||||
options=_ai_energy_level_options,
|
||||
exists_fn=lambda rep, resources: (
|
||||
len(_ai_energy_supported_levels(rep)) > 1),
|
||||
write_fn=_ai_energy_level_write),
|
||||
),
|
||||
)
|
||||
|
||||
FIRMWARE_UPDATE = Capability(
|
||||
href='/otninformation/vs/0',
|
||||
poll_tier='cold',
|
||||
entities=(
|
||||
BinarySensorDesc(
|
||||
key='firmware_update',
|
||||
field='x.com.samsung.da.newVersionAvailable',
|
||||
name='Firmware update available',
|
||||
device_class='update',
|
||||
entity_category='diagnostic',
|
||||
value_fn=lambda v: str(v).lower() == 'true' if v is not None else None,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
SELF_CHECK = Capability(
|
||||
href='/selfcheck/vs/0',
|
||||
poll_tier='cold',
|
||||
entities=(
|
||||
SensorDesc(key='selfcheck_status', field='x.com.samsung.da.status',
|
||||
name='Self-check status', icon='mdi:stethoscope',
|
||||
entity_category='diagnostic'),
|
||||
SensorDesc(key='selfcheck_result', field='x.com.samsung.da.result',
|
||||
name='Self-check result', icon='mdi:clipboard-check-outline',
|
||||
entity_category='diagnostic'),
|
||||
# List of error codes from the last self-check; joined for display.
|
||||
# Not every fridge reports the field, hence the exists_fn.
|
||||
SensorDesc(key='selfcheck_error', field='x.com.samsung.da.error',
|
||||
name='Self-check error', icon='mdi:alert-circle-outline',
|
||||
entity_category='diagnostic',
|
||||
exists_fn=lambda rep, resources: (
|
||||
not rep or 'x.com.samsung.da.error' in rep),
|
||||
value_fn=lambda v: (', '.join(v) if v else None) if isinstance(v, list) else v),
|
||||
ButtonDesc(key='selfcheck_start', field='', name='Start self-check',
|
||||
payload='Start', icon='mdi:play-circle-outline',
|
||||
entity_category='diagnostic',
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['selfcheck', 'vs', '0'], {'x.com.samsung.da.status': p})),
|
||||
),
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cross-family bundles, unpacked into every by_type registry's _build([...])
|
||||
# call the same way ignored.IGNORED is (*common.UNIVERSAL / *common.POWER).
|
||||
# discover() only binds a capability whose href is actually present in a
|
||||
# given device's resource dump, so listing one here for a family that
|
||||
# doesn't expose the href is a no-op, not a phantom entity -- see the
|
||||
# adding-device-support skill's coverage-discipline section.
|
||||
#
|
||||
# UNIVERSAL holds every capability with no known family that both (a) has
|
||||
# the href and (b) needs to model it some other way -- broadening one of
|
||||
# these to a new family is a safe, harmless guess (issue #40's AI energy
|
||||
# level: 2 of 6 families confirmed, blanket-added everywhere else).
|
||||
#
|
||||
# POWER is kept separate -- airconditioner is the one family that opts out
|
||||
# of it. Canonical reason (see by_type/airconditioner.py and its test for
|
||||
# pointers back here, not restatements): AC's climate entity already owns
|
||||
# /power/0 and /power/vs/0 via bare, no-entity Capability objects
|
||||
# (airconditioner.COVERAGE), and a second, real POWER_GENERIC/
|
||||
# POWER_VS_FALLBACK cap on the same href would make _build() raise (a href
|
||||
# with >1 cap must have every cap discriminated by rt_filter/match_fn, and
|
||||
# the bare COVERAGE cap has neither). Kids-lock/remote-control don't have
|
||||
# this conflict -- no AC dump has ever reported those hrefs -- so they stay
|
||||
# in UNIVERSAL.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
UNIVERSAL = (
|
||||
ALARMS,
|
||||
ENERGY_METER,
|
||||
FIRMWARE_UPDATE,
|
||||
SELF_CHECK,
|
||||
AI_ENERGY_LEVEL,
|
||||
KIDS_LOCK_GENERIC,
|
||||
KIDS_LOCK_VS_FALLBACK,
|
||||
REMOTE_CONTROL_GENERIC,
|
||||
REMOTE_CONTROL_VS_FALLBACK,
|
||||
)
|
||||
|
||||
POWER = (
|
||||
POWER_GENERIC,
|
||||
POWER_VS_FALLBACK,
|
||||
)
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
"""Capabilities specific to dishwasher appliances (DW9000F-class).
|
||||
|
||||
Resources verified against the live device dump at 10.0.0.129.
|
||||
|
||||
The /course/vs/0 cycle select and its options-array machinery are shared with
|
||||
washer and dryer in laundry.py; only the dishwasher-specific options (storm
|
||||
wash, auto release dry) are read locally here.
|
||||
"""
|
||||
from ..capability import Capability
|
||||
from ..entities import ButtonDesc, SelectDesc, SensorDesc, SwitchDesc
|
||||
from .laundry import bool_option_switch, cycle_select
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /dishwasher/vs/0 — cycle wash/dry settings
|
||||
@@ -28,108 +33,22 @@ DISHWASHER_SETTINGS = Capability(
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /course/vs/0 — cycle selection and course options (RMW on options array)
|
||||
# /course/vs/0 — cycle selection (shared laundry.cycle_select) plus the
|
||||
# dishwasher-only StormWashZone / AutoDoorRelease toggles that ride in the
|
||||
# same options array (shared laundry.bool_option_switch, same options[]
|
||||
# boolean-toggle contract washer's bubble-soak/pre-wash/intensive switches
|
||||
# use). Course display names live in translations under
|
||||
# entity.select.dishwasher_cycle (see laundry.cycle_select).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Course IDs are uppercase hex strings matching the Course_XX encoding in
|
||||
# options[]. Display names are not kept here -- they live in
|
||||
# strings.json/translations under entity.select.dishwasher_cycle.state.<id,
|
||||
# lowercased>, same as every other device-enum select in this integration
|
||||
# (see fridge.py's translation_key entities and select.py's _display()), so
|
||||
# they can be localized instead of hardcoded to English.
|
||||
#
|
||||
# No static fallback list is kept here -- a hardcoded course table would
|
||||
# show options a given dishwasher model doesn't actually have (or hide ones
|
||||
# it does). The only trustworthy per-device source is the live
|
||||
# x.com.samsung.da.editCourseList on /wm/editcourse/vs/0. When that's
|
||||
# absent (e.g. never populated until the app's course-edit screen has been
|
||||
# opened at least once), the cycle select isn't created at all -- see
|
||||
# CYCLE_OPTIONS's exists_fn below. (x.com.samsung.da.options' MostUsed_*
|
||||
# entry was considered as a second fallback source, but its bytes beyond
|
||||
# the first don't correspond to any confirmed course code on hardware we
|
||||
# have dumps for, so it isn't trustworthy either -- see washer.py's
|
||||
# _cycle_options docstring for the byte-level evidence.)
|
||||
|
||||
|
||||
def _parse_edit_course_list(raw):
|
||||
"""'EditCourseList_0E07908683848D808E8F' -> ['0E', '07', ...]."""
|
||||
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)]
|
||||
|
||||
|
||||
def _cycle_options(resources):
|
||||
rep = resources.get('/wm/editcourse/vs/0') or {}
|
||||
return _parse_edit_course_list(rep.get('x.com.samsung.da.editCourseList'))
|
||||
|
||||
|
||||
def _option_value(options, prefix):
|
||||
"""Find `<prefix>_<value>` in options array and return <value>."""
|
||||
for o in (options or []):
|
||||
if isinstance(o, str) and o.startswith(prefix + '_'):
|
||||
return o.split('_', 1)[1]
|
||||
return None
|
||||
|
||||
|
||||
def _replace_in_options(options, prefix, new_value):
|
||||
return [f"{prefix}_{new_value}" if isinstance(o, str) and o.startswith(prefix + '_') else o
|
||||
for o in options]
|
||||
|
||||
|
||||
def _cycle_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, 'Course', p),
|
||||
}
|
||||
|
||||
|
||||
def _storm_wash_write(p, rep, href=None):
|
||||
if p not in ('On', 'Off'):
|
||||
return 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, 'StormWashZone', p),
|
||||
}
|
||||
|
||||
|
||||
def _auto_release_write(p, rep, href=None):
|
||||
if p not in ('On', 'Off'):
|
||||
return 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, 'AutoDoorRelease', p),
|
||||
}
|
||||
|
||||
|
||||
CYCLE_OPTIONS = Capability(
|
||||
href='/course/vs/0',
|
||||
entities=(
|
||||
SelectDesc(key='cycle', name='Cycle', icon='mdi:dishwasher',
|
||||
translation_key='dishwasher_cycle',
|
||||
options=_cycle_options,
|
||||
exists_fn=lambda rep, resources: bool(_cycle_options(resources)),
|
||||
rep_fn=lambda rep: _option_value(
|
||||
rep.get('x.com.samsung.da.options'), 'Course'),
|
||||
write_fn=_cycle_write),
|
||||
SwitchDesc(key='storm_wash', name='Storm Wash+', icon='mdi:weather-lightning-rainy',
|
||||
rep_fn=lambda rep: _option_value(
|
||||
rep.get('x.com.samsung.da.options'), 'StormWashZone') == 'On',
|
||||
write_fn=_storm_wash_write),
|
||||
SwitchDesc(key='auto_release_dry', name='Auto release dry', icon='mdi:door-open',
|
||||
exists_fn=lambda rep, resources: any(
|
||||
isinstance(o, str) and o.startswith('AutoDoorRelease_')
|
||||
for o in (rep.get('x.com.samsung.da.options') or [])
|
||||
),
|
||||
rep_fn=lambda rep: _option_value(
|
||||
rep.get('x.com.samsung.da.options'), 'AutoDoorRelease') == 'On',
|
||||
write_fn=_auto_release_write),
|
||||
cycle_select(translation_key='dishwasher_cycle', icon='mdi:dishwasher'),
|
||||
bool_option_switch('storm_wash', 'Storm Wash+', 'mdi:weather-lightning-rainy',
|
||||
'StormWashZone'),
|
||||
bool_option_switch('auto_release_dry', 'Auto release dry', 'mdi:door-open',
|
||||
'AutoDoorRelease', gate_on_presence=True),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -1,37 +1,16 @@
|
||||
"""Capabilities specific to the dryer family (Samsung DV5000T-class).
|
||||
"""Capabilities specific to the dryer family (Samsung DA_WM_TP1/TP2-class).
|
||||
|
||||
Resources derived from the old dryer.py descriptor OBSERVE_PATHS and
|
||||
flatten() implementation:
|
||||
/washer/vs/0 -> DRYER_SETTINGS (dryLevel, dryTime, dryerType, wrinklePrevent)
|
||||
/st/dryercourse/vs/0 -> DRYER_COURSE (dryer_mode SelectDesc)
|
||||
/wm/jobbeginingstatus/vs/0 -> JOB_BEGINNING_STATUS
|
||||
/diagnosis/vs/0 -> DRYER_DIAGNOSIS
|
||||
Dryer-specific controls only. The shared laundry surface -- power/kids-lock/
|
||||
remote-control fallback pairs, buzzer, energy meter, job-beginning-status, and
|
||||
the /course/vs/0 cycle select -- lives in laundry.py.
|
||||
|
||||
Course table captured 2026-05-29 on a DA_WM_TP2_20_COMMON_DV5000T. Other
|
||||
dryer models may use a different course table; options=() means HA renders
|
||||
whatever the device reports, and the write_fn validates against this table.
|
||||
/washer/vs/0 -> DRYER_SETTINGS (dryLevel, dryTime, dryerType, wrinklePrevent)
|
||||
/course/vs/0 -> DRYER_COURSE (shared cycle select; see below)
|
||||
/diagnosis/vs/0 -> DRYER_DIAGNOSIS
|
||||
"""
|
||||
from ..capability import Capability
|
||||
from ..entities import SelectDesc, SensorDesc, SwitchDesc
|
||||
|
||||
# Course table: hex codes -> human names (Table_03, DV5000T-class).
|
||||
_COURSE_NAMES = {
|
||||
0x16: 'Cotton',
|
||||
0x18: 'Synthetics',
|
||||
0x19: 'Delicates',
|
||||
0x1A: 'Wool',
|
||||
0x1B: 'Bedding',
|
||||
0x1C: 'Shirts',
|
||||
0x1D: 'Towels',
|
||||
0x1E: 'Outdoor',
|
||||
0x1F: 'Mixed Load',
|
||||
0x20: 'Iron Dry',
|
||||
0x23: 'Quick Dry 35',
|
||||
0x24: 'Cool Air',
|
||||
0x25: 'Warm Air',
|
||||
0x27: 'Time Dry',
|
||||
}
|
||||
_COURSE_CODE_BY_NAME = {name: code for code, name in _COURSE_NAMES.items()}
|
||||
from ..entities import SensorDesc, SwitchDesc
|
||||
from .laundry import cycle_select
|
||||
|
||||
|
||||
def _wrinkle_write(p, rep, href=None):
|
||||
@@ -40,20 +19,6 @@ def _wrinkle_write(p, rep, href=None):
|
||||
return ['washer', 'vs', '0'], {'x.com.samsung.da.wrinklePrevent': p}
|
||||
|
||||
|
||||
def _course_write(p, rep, href=None):
|
||||
"""Encode a human course name to the Samsung hex-encoded course string."""
|
||||
code = _COURSE_CODE_BY_NAME.get(p)
|
||||
if code is None:
|
||||
return None
|
||||
return ['st', 'dryercourse', 'vs', '0'], {
|
||||
'x.com.samsung.da.st.dryerMode': f'Course_{code:02X}',
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Capabilities
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
DRYER_SETTINGS = Capability(
|
||||
href='/washer/vs/0',
|
||||
poll_tier='warm',
|
||||
@@ -71,28 +36,19 @@ DRYER_SETTINGS = Capability(
|
||||
),
|
||||
)
|
||||
|
||||
# /course/vs/0 -- cycle selection, shared with washer/dishwasher via
|
||||
# laundry.cycle_select (options read live from /wm/editcourse/vs/0, written as
|
||||
# an RMW on the options array). Course display names live in translations
|
||||
# under entity.select.dryer_cycle (Table_03, DV5000-class, captured
|
||||
# 2026-05-29). Codes 0x21 and 0x4C appear in the issue #14 DV90BB5245AES1
|
||||
# editCourseList but aren't identified yet -- they render as the raw code
|
||||
# until named. The /st/dryercourse/vs/0 resource re-encodes the same selected
|
||||
# course and is ignored (ignored.py) -- the mirror of how /st/washercourse/vs/0
|
||||
# is ignored for washers.
|
||||
DRYER_COURSE = Capability(
|
||||
href='/st/dryercourse/vs/0',
|
||||
poll_tier='warm',
|
||||
href='/course/vs/0',
|
||||
entities=(
|
||||
SelectDesc(key='dryer_mode', field='x.com.samsung.da.st.dryerMode',
|
||||
name='Dryer mode', icon='mdi:tumble-dryer',
|
||||
options=tuple(_COURSE_CODE_BY_NAME),
|
||||
value_fn=lambda v: _COURSE_NAMES.get(
|
||||
int(v.split('_')[1], 16) if isinstance(v, str) and '_' in v else -1
|
||||
),
|
||||
write_fn=_course_write),
|
||||
),
|
||||
)
|
||||
|
||||
JOB_BEGINNING_STATUS = Capability(
|
||||
href='/wm/jobbeginingstatus/vs/0',
|
||||
poll_tier='warm',
|
||||
entities=(
|
||||
SensorDesc(key='job_beginning_status',
|
||||
field='x.com.samsung.da.jobBeginingStatus',
|
||||
name='Job beginning status',
|
||||
entity_category='diagnostic'),
|
||||
cycle_select(translation_key='dryer_cycle', icon='mdi:tumble-dryer'),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ import datetime
|
||||
|
||||
from ..capability import Capability
|
||||
from ..entities import (
|
||||
BinarySensorDesc, ButtonDesc, NumberDesc, SelectDesc, SensorDesc,
|
||||
BinarySensorDesc, NumberDesc, SelectDesc, SensorDesc,
|
||||
SwitchDesc, TimeDesc,
|
||||
)
|
||||
from .common import normalize_temp_unit
|
||||
@@ -100,6 +100,12 @@ ICEMAKER_NIGHTTIME = Capability(
|
||||
# Icemaker (generic — covers /icemaker/one/vs/0, /icemaker/two/vs/0)
|
||||
# /icemaker/status/vs/0 is kept as exact-href cap and binds first.
|
||||
# /icemaker/nighttime/vs/0 is excluded by match_fn (lacks iceMaker.state).
|
||||
#
|
||||
# Entity names are derived from x.com.samsung.da.iceMaker.name ("CUBED_ICE",
|
||||
# "ICE_BITES") via name_field, not the href's "one"/"two" segment -- these two
|
||||
# ice makers are independent on/off toggles that can both be enabled at once
|
||||
# (issue #27), so they stay separate entities rather than a single ice-type
|
||||
# select, but users still want them labeled with the device's own names.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _icemaker_write(field):
|
||||
@@ -112,6 +118,7 @@ ICEMAKER_GENERIC = Capability(
|
||||
href=None,
|
||||
href_prefix='/icemaker/',
|
||||
match_fn=lambda rep, resources: 'x.com.samsung.da.iceMaker.state' in rep,
|
||||
name_field='x.com.samsung.da.iceMaker.name',
|
||||
poll_tier='warm',
|
||||
entities=(
|
||||
SensorDesc(key='making_status',
|
||||
@@ -209,6 +216,14 @@ DEFROST_DELAY = Capability(
|
||||
),
|
||||
)
|
||||
|
||||
# OCF-native boolean mirror of DEFROST_DELAY. The captured TP1X_REF_21K
|
||||
# firmware publishes the same state on both hrefs, but only the vendor resource
|
||||
# above has a confirmed write contract. Bind the native mirror without another
|
||||
# entity so discovery records it as an intentional duplicate.
|
||||
DEFROST_DELAY_NATIVE_DUPLICATE = Capability(
|
||||
href='/defrost/delay/0',
|
||||
)
|
||||
|
||||
DEFROST_BLOCK_STATUS = Capability(
|
||||
href='/defrost/block/vs/0',
|
||||
poll_tier='warm',
|
||||
@@ -220,28 +235,6 @@ DEFROST_BLOCK_STATUS = Capability(
|
||||
),
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Self-check diagnostic
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
SELF_CHECK = Capability(
|
||||
href='/selfcheck/vs/0',
|
||||
poll_tier='cold',
|
||||
entities=(
|
||||
SensorDesc(key='selfcheck_status', field='x.com.samsung.da.status',
|
||||
name='Self-check status', icon='mdi:stethoscope',
|
||||
entity_category='diagnostic'),
|
||||
SensorDesc(key='selfcheck_result', field='x.com.samsung.da.result',
|
||||
name='Self-check result', icon='mdi:clipboard-check-outline',
|
||||
entity_category='diagnostic'),
|
||||
ButtonDesc(key='selfcheck_start', field='', name='Start self-check',
|
||||
payload='Start', icon='mdi:play-circle-outline',
|
||||
entity_category='diagnostic',
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['selfcheck', 'vs', '0'], {'x.com.samsung.da.status': p})),
|
||||
),
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Refrigeration modes (rapid cooling)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -494,17 +487,64 @@ BEVERAGE_ZONE = Capability(
|
||||
),
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pantry / Cool Select Zone -- a convertible compartment toggled between
|
||||
# wine/deli/drinks temperature presets (issue #20). Same shape as
|
||||
# BEVERAGE_ZONE (a controllable named sub-zone with a mode + supported-modes
|
||||
# list) but a distinct resource/field set -- x.com.samsung.da.mode /
|
||||
# x.com.samsung.da.supportedOptions on /status/pantry/one/vs/0, rather than
|
||||
# roomDesiredMode/roomSupportedModes on /specialzone/one/vs/0. Only a "one"
|
||||
# instance has been seen; not generalized to a pattern cap until a second
|
||||
# instance turns up.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _pantry_write(p, rep, href=None):
|
||||
return ['status', 'pantry', 'one', 'vs', '0'], {'x.com.samsung.da.mode': p}
|
||||
|
||||
|
||||
PANTRY_ZONE = Capability(
|
||||
href='/status/pantry/one/vs/0',
|
||||
poll_tier='warm',
|
||||
entities=(
|
||||
SelectDesc(key='pantry_zone_mode', field='x.com.samsung.da.mode',
|
||||
name='Pantry zone mode', icon='mdi:glass-wine',
|
||||
translation_key='pantry_zone_mode',
|
||||
entity_category='config',
|
||||
options_field='x.com.samsung.da.supportedOptions',
|
||||
write_fn=_pantry_write),
|
||||
),
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Flex zone (convertible drawer — /mode/vs/0 on RF9000-class fridges)
|
||||
#
|
||||
# x.com.samsung.da.modes holds multiple orthogonal flags in one list.
|
||||
# The flex zone entry is identified by the CV_TTYPE_RF9000A_ prefix.
|
||||
# x.com.samsung.da.modes holds multiple orthogonal flags in one list; the
|
||||
# flex-zone entry is whichever item is also a member of supportedOptions --
|
||||
# the other flags (WATERFILTER_*, DEFROST_BLOCK_*, the CVN_*_ZONE marker)
|
||||
# never appear there. The prefix on that item varies by fridge family
|
||||
# (CV_TTYPE_RF9000A_ on RF9000-class, CV_FDR_ on Bespoke-class -- issue #27 /
|
||||
# #26, where the old CV_TTYPE_RF9000A_-only match left this entity bound but
|
||||
# stuck on None), so match by list membership instead of a hardcoded prefix.
|
||||
# Write replaces only that item; other flags are preserved.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _flex_zone_supported(rep):
|
||||
return set(rep.get('x.com.samsung.da.supportedOptions') or ())
|
||||
|
||||
|
||||
def _flex_zone_current(rep):
|
||||
# Every dump seen has at most one modes/supportedOptions overlap, so
|
||||
# "first match" and "strip all matches" (in the write below) agree. If a
|
||||
# future device ever reports two, this reads the first and the write
|
||||
# would drop both -- revisit if that turns up.
|
||||
modes = rep.get('x.com.samsung.da.modes') or []
|
||||
supported = _flex_zone_supported(rep)
|
||||
return next((m for m in modes if m in supported), None)
|
||||
|
||||
|
||||
def _flex_zone_write(p, rep, href=None):
|
||||
modes = list(rep.get('x.com.samsung.da.modes') or [])
|
||||
modes = [m for m in modes if not m.startswith('CV_TTYPE_RF9000A_')]
|
||||
supported = _flex_zone_supported(rep)
|
||||
modes = [m for m in (rep.get('x.com.samsung.da.modes') or []) if m not in supported]
|
||||
modes.append(p)
|
||||
return ['mode', 'vs', '0'], {'x.com.samsung.da.modes': modes}
|
||||
|
||||
@@ -514,39 +554,17 @@ FLEX_ZONE = Capability(
|
||||
poll_tier='warm',
|
||||
entities=(
|
||||
SelectDesc(key='flex_zone_mode',
|
||||
field='x.com.samsung.da.modes',
|
||||
name='Flex zone mode', icon='mdi:thermostat',
|
||||
translation_key='flex_zone_mode',
|
||||
entity_category='config',
|
||||
options_field='x.com.samsung.da.supportedOptions',
|
||||
exists_fn=lambda rep, resources: bool(
|
||||
rep.get('x.com.samsung.da.supportedOptions')),
|
||||
value_fn=lambda modes: next(
|
||||
(m for m in (modes or [])
|
||||
if m.startswith('CV_TTYPE_RF9000A_')), None),
|
||||
rep_fn=_flex_zone_current,
|
||||
write_fn=_flex_zone_write),
|
||||
),
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Firmware update
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
FIRMWARE_UPDATE = Capability(
|
||||
href='/otninformation/vs/0',
|
||||
poll_tier='cold',
|
||||
entities=(
|
||||
BinarySensorDesc(
|
||||
key='firmware_update',
|
||||
field='x.com.samsung.da.newVersionAvailable',
|
||||
name='Firmware update available',
|
||||
device_class='update',
|
||||
entity_category='diagnostic',
|
||||
value_fn=lambda v: str(v).lower() == 'true' if v is not None else None,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Generic door pattern capability (href=None — use as pattern_cap only)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -655,6 +673,14 @@ ICEMAKER_STATUS_FALLBACK = Capability(
|
||||
),
|
||||
)
|
||||
|
||||
# OCF-native aggregate mirror of ICEMAKER_STATUS_FALLBACK. On the captured
|
||||
# TP1X_REF_21K it duplicates both the vendor aggregate and the richer per-unit
|
||||
# /icemaker/one|two/vs/0 resources. Its write contract is not advertised, so
|
||||
# keep the proven per-unit/vendor controls and bind this as a duplicate only.
|
||||
ICEMAKER_STATUS_NATIVE_DUPLICATE = Capability(
|
||||
href='/icemaker/status/0',
|
||||
)
|
||||
|
||||
# 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
|
||||
|
||||
@@ -85,14 +85,9 @@ IGNORED: list[Capability] = [
|
||||
# Warranty/service-plan enrollment status — every field reads "Unknown"
|
||||
# on hardware not enrolled in a Samsung Care+ style program.
|
||||
Capability(href='/dginformation/vs/0'),
|
||||
# AI energy-saving level; only one value ('1') is ever in
|
||||
# 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.
|
||||
# has ever been seen in `modes` (issue #7's dump) -- no real choice to
|
||||
# expose yet. 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.
|
||||
@@ -108,6 +103,10 @@ IGNORED: list[Capability] = [
|
||||
# washer.WASHER_COURSE at /course/vs/0 (x.com.samsung.da.st.washerMode
|
||||
# is literally "Table_02_Course_<same hex code>").
|
||||
Capability(href='/st/washercourse/vs/0'),
|
||||
# Dryer counterpart of the above: re-encoding of the course already
|
||||
# exposed by dryer.DRYER_COURSE at /course/vs/0
|
||||
# (x.com.samsung.da.st.dryerMode is "Table_03_Course_<same hex code>").
|
||||
Capability(href='/st/dryercourse/vs/0'),
|
||||
# Empty on every washer dump seen so far.
|
||||
Capability(href='/wm/welcomemsg/vs/0'),
|
||||
# User-saved custom course slots (F1-FA). No controllable/observable
|
||||
|
||||
@@ -1,13 +1,28 @@
|
||||
"""Capabilities specific to the laundry-family appliances (dishwasher, dryer).
|
||||
"""Capabilities shared across the laundry family (washer, dryer, dishwasher).
|
||||
|
||||
Resources verified against the DW9000F-class dump at 10.0.0.129.
|
||||
Note: door-LED keys use NO `x.com.samsung.da.` prefix — `setBrightness` and
|
||||
`setNightLight` — preserved exactly as they appear in the OCF resource rep.
|
||||
Anything here is bound by more than one laundry registry, so it lives in one
|
||||
place instead of being copied per family. Device-type-specific controls (wash
|
||||
temperature, dry level, dishwasher storm-wash, etc.) stay in washer.py /
|
||||
dryer.py / dishwasher.py; only the genuinely shared laundry surface is here.
|
||||
|
||||
Generic OCF controls that aren't laundry-specific (power, kids-lock, remote
|
||||
control, energy meter) live in common.py, not here.
|
||||
|
||||
Resource hrefs seen across laundry dumps:
|
||||
/doorled/light/vs/0 -> DOOR_LED (door LED brightness / night light)
|
||||
/settings/sound/*/vs/0-> SOUND_MODE / SOUND_VOLUME
|
||||
/buzzersound/vs/0 -> BUZZER_SOUND (buzzer + optional finish chime)
|
||||
/course/vs/0 -> the cycle select + per-family course options
|
||||
/wm/editcourse/vs/0 -> live editCourseList that drives the cycle options
|
||||
/wm/jobbeginingstatus/vs/0 -> JOB_BEGINNING_STATUS
|
||||
|
||||
Door-LED keys use NO `x.com.samsung.da.` prefix -- `setBrightness` /
|
||||
`setNightLight` -- preserved exactly as they appear in the OCF resource rep.
|
||||
"""
|
||||
from datetime import time as dt_time
|
||||
|
||||
from ..capability import Capability
|
||||
from ..entities import NumberDesc, SelectDesc, SwitchDesc, TimeDesc
|
||||
from ..entities import NumberDesc, SelectDesc, SensorDesc, SwitchDesc, TimeDesc
|
||||
|
||||
_LED_LEVELS = ('Low', 'High')
|
||||
_SOUND_MODES = ('voice', 'tone', 'mute')
|
||||
@@ -100,3 +115,177 @@ SOUND_VOLUME = Capability(
|
||||
{'level': str(int(p))})),
|
||||
),
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /buzzersound/vs/0 -- buzzer volume and (on some units) a separate finish
|
||||
# chime. Fields have no 'x.com.samsung.da.' prefix in this resource. Seen on
|
||||
# washers and DA_WM_TP1 dryers; the dryer dump carries only setBuzzerSound
|
||||
# (no supportedFinishSound), so finish_sound self-gates off there.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
BUZZER_SOUND = Capability(
|
||||
href='/buzzersound/vs/0',
|
||||
entities=(
|
||||
SelectDesc(key='buzzer_sound', field='setBuzzerSound',
|
||||
name='Buzzer sound', icon='mdi:volume-high',
|
||||
entity_category='config',
|
||||
options_field='supportedBuzzerSound',
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['buzzersound', 'vs', '0'], {'setBuzzerSound': p})),
|
||||
SelectDesc(key='finish_sound', field='setFinishSound',
|
||||
name='Finish sound', icon='mdi:bell-ring',
|
||||
entity_category='config',
|
||||
exists_fn=lambda rep, resources: 'supportedFinishSound' in rep,
|
||||
options_field='supportedFinishSound',
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['buzzersound', 'vs', '0'], {'setFinishSound': p})),
|
||||
),
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cycle selection over /course/vs/0.
|
||||
#
|
||||
# The selected course and every other user-tunable option ride in the
|
||||
# x.com.samsung.da.options array on /course/vs/0 as `<Prefix>_<value>` tokens;
|
||||
# a write is a read-modify-write of that whole array (cycle_write). The set of
|
||||
# *selectable* courses is not hardcoded -- it's read live from
|
||||
# x.com.samsung.da.editCourseList on /wm/editcourse/vs/0 (cycle_options), so we
|
||||
# never show a course a given model doesn't have or hide one it does. Course
|
||||
# codes are uppercase hex; display names live in translations under
|
||||
# entity.select.<translation_key>.state.<id lowercased> so they can be
|
||||
# localized -- every device-enum select in this integration works this way.
|
||||
# washer.py's course comment has the byte-level evidence for why the options[]
|
||||
# MostUsed_* entry is *not* a trustworthy second source.
|
||||
#
|
||||
# Shared verbatim by washer, dishwasher, and dryer -- all DA_WM_-family boards
|
||||
# expose the same /course/vs/0 options contract.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def hex_pairs(codes):
|
||||
"""'1C1D21...' -> ['1C', '1D', '21', ...]."""
|
||||
return [codes[i:i + 2] for i in range(0, len(codes) - 1, 2)]
|
||||
|
||||
|
||||
def parse_edit_course_list(raw):
|
||||
"""'EditCourseList_1C1D21...' -> ['1C', '1D', '21', ...]."""
|
||||
if not isinstance(raw, str) or '_' not in raw:
|
||||
return []
|
||||
return hex_pairs(raw.split('_', 1)[1])
|
||||
|
||||
|
||||
def cycle_options(resources):
|
||||
rep = resources.get('/wm/editcourse/vs/0') or {}
|
||||
return parse_edit_course_list(rep.get('x.com.samsung.da.editCourseList'))
|
||||
|
||||
|
||||
def option_value(options, prefix):
|
||||
"""Find `<prefix>_<value>` in the options array and return <value>."""
|
||||
for o in (options or []):
|
||||
if isinstance(o, str) and o.startswith(prefix + '_'):
|
||||
return o.split('_', 1)[1]
|
||||
return None
|
||||
|
||||
|
||||
def replace_in_options(options, prefix, new_value):
|
||||
return [f"{prefix}_{new_value}" if isinstance(o, str) and o.startswith(prefix + '_') else o
|
||||
for o in options]
|
||||
|
||||
|
||||
def cycle_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, 'Course', p),
|
||||
}
|
||||
|
||||
|
||||
def cycle_select(*, translation_key, icon):
|
||||
"""A 'Cycle' select over /course/vs/0, labelled from `translation_key`.
|
||||
|
||||
The caller supplies the family's translation key (washer_cycle /
|
||||
dishwasher_cycle / dryer_cycle) and icon; the option list, current value,
|
||||
and write path are all shared.
|
||||
"""
|
||||
return SelectDesc(
|
||||
key='cycle', name='Cycle', icon=icon, translation_key=translation_key,
|
||||
options=cycle_options,
|
||||
exists_fn=lambda rep, resources: bool(cycle_options(resources)),
|
||||
rep_fn=lambda rep: option_value(rep.get('x.com.samsung.da.options'), 'Course'),
|
||||
write_fn=cycle_write,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Plain boolean toggles over /course/vs/0's options[] array: a
|
||||
# '<prefix>_On'/'<prefix>_Off' token, read-modify-written the same way as
|
||||
# the 'Course' token above. Shared by washer (bubble soak, pre-wash,
|
||||
# intensive -- issue #22) and dishwasher (storm wash, auto release dry) --
|
||||
# both families ride this exact contract, just with different prefixes and
|
||||
# different presence/validation needs on top.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def bool_option_write(prefix):
|
||||
def write(p, rep, href=None):
|
||||
if p not in ('On', 'Off'):
|
||||
return 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 bool_option_value(prefix):
|
||||
return lambda rep: option_value(rep.get('x.com.samsung.da.options'), prefix) == 'On'
|
||||
|
||||
|
||||
def bool_option_exists(prefix):
|
||||
return lambda rep, resources: option_value(
|
||||
rep.get('x.com.samsung.da.options'), prefix) is not None
|
||||
|
||||
|
||||
def bool_option_switch(key, name, icon, prefix, *, entity_category=None,
|
||||
gate_on_presence=False, validate_fn=None):
|
||||
"""A SwitchDesc over a '<prefix>_On'/'<prefix>_Off' options[] token.
|
||||
|
||||
gate_on_presence self-gates the entity off on models that never report
|
||||
the token at all (washer's bubble soak/pre-wash/intensive); leave False
|
||||
for a toggle every device in the family reports (dishwasher's storm
|
||||
wash). validate_fn is passed straight through to SwitchDesc for callers
|
||||
that need to reject a write against live device state (e.g. washer's
|
||||
per-course availability check) -- this factory has no opinion on it and
|
||||
building one, if needed, is the caller's job.
|
||||
"""
|
||||
return SwitchDesc(
|
||||
key=key, name=name, icon=icon, entity_category=entity_category,
|
||||
exists_fn=bool_option_exists(prefix) if gate_on_presence else None,
|
||||
rep_fn=bool_option_value(prefix),
|
||||
write_fn=bool_option_write(prefix),
|
||||
validate_fn=validate_fn,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /wm/jobbeginingstatus/vs/0 -- the "why did the cycle not start" reason
|
||||
# (e.g. door open, no water). The vendor field is x.com.samsung.da.currentStatus
|
||||
# on every laundry dump that populates it (washer + DA_WM_TP1 dryer). An
|
||||
# earlier dryer descriptor read x.com.samsung.da.jobBeginingStatus, but no dump
|
||||
# ever carried that field, so the dryer sensor was always blank -- fixed by
|
||||
# sharing this one reader.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
JOB_BEGINNING_STATUS = Capability(
|
||||
href='/wm/jobbeginingstatus/vs/0',
|
||||
poll_tier='warm',
|
||||
entities=(
|
||||
SensorDesc(key='job_beginning_status',
|
||||
field='x.com.samsung.da.currentStatus',
|
||||
name='Job beginning status',
|
||||
entity_category='diagnostic'),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -39,6 +39,15 @@ SETPOINT_MIN_C = 30
|
||||
SETPOINT_MAX_C = 270
|
||||
SETPOINT_STEP_C = 5
|
||||
|
||||
# Verified against issue #44's range dump (NSI6DG9100SRAA, unit reported as
|
||||
# "Fahrenheit" on /temperatures/vs/0): Bake mode's modeSpec on /mode/vs/0
|
||||
# reports tempMinF/tempMaxF/tempIntervalF = 175/550/5. Kept as a separate
|
||||
# constant set rather than converted from the Celsius bounds above, which
|
||||
# are themselves unverified (no live dump; see module docstring).
|
||||
SETPOINT_MIN_F = 175
|
||||
SETPOINT_MAX_F = 550
|
||||
SETPOINT_STEP_F = 5
|
||||
|
||||
# Mode options seen on NV7000BS-class. No dump exists so this list is inferred
|
||||
# from Samsung documentation and firmware observations. The firmware will
|
||||
# reject unknown modes; missing entries here are a coverage gap, not a bug.
|
||||
@@ -129,8 +138,9 @@ def _oven_setpoint_write(p, rep, href=None):
|
||||
temp = float(p)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
temp_i = int(round(temp / SETPOINT_STEP_C) * SETPOINT_STEP_C)
|
||||
if not (SETPOINT_MIN_C <= temp_i <= SETPOINT_MAX_C):
|
||||
min_v, max_v, step_v = _setpoint_bounds(rep)
|
||||
temp_i = int(round(temp / step_v) * step_v)
|
||||
if not (min_v <= temp_i <= max_v):
|
||||
return None
|
||||
items = rep.get('x.com.samsung.da.items')
|
||||
if not items:
|
||||
@@ -223,7 +233,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'),
|
||||
@@ -261,14 +273,24 @@ def _oven_temp_unit(rep):
|
||||
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."""
|
||||
Keeps the verified '°C' default when the field is absent (the original
|
||||
NV7000BS-class dump this module was written against), but reads it live
|
||||
-- issue #44's range dump is the first to report 'Fahrenheit' here."""
|
||||
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')
|
||||
|
||||
|
||||
def _setpoint_bounds(rep):
|
||||
"""(min, max, step) for the live unit -- see the SETPOINT_*_C/_F
|
||||
constants above for provenance. Bounds must track the unit shown by
|
||||
unit_fn (both read the same live rep), or the HA slider's range would
|
||||
silently mismatch its own displayed unit."""
|
||||
if _oven_temp_unit(rep) == '°F':
|
||||
return SETPOINT_MIN_F, SETPOINT_MAX_F, SETPOINT_STEP_F
|
||||
return SETPOINT_MIN_C, SETPOINT_MAX_C, SETPOINT_STEP_C
|
||||
|
||||
|
||||
OVEN_SETPOINT = Capability(
|
||||
href='/temperatures/vs/0',
|
||||
poll_tier='hot',
|
||||
@@ -278,6 +300,9 @@ OVEN_SETPOINT = Capability(
|
||||
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',
|
||||
native_min_fn=lambda rep: float(_setpoint_bounds(rep)[0]),
|
||||
native_max_fn=lambda rep: float(_setpoint_bounds(rep)[1]),
|
||||
step_fn=lambda rep: float(_setpoint_bounds(rep)[2]),
|
||||
value_fn=lambda items: _int(
|
||||
(items[0].get('x.com.samsung.da.desired') if items else None)),
|
||||
write_fn=_oven_setpoint_write),
|
||||
@@ -312,6 +337,13 @@ OVEN_CONNECTED = Capability(
|
||||
),
|
||||
)
|
||||
|
||||
# Static cavity capability metadata (count/type/supported features) -- no
|
||||
# per-cavity data varies at runtime on any dump seen so far (issue #44's
|
||||
# range: single cavity, no supportedFeatureList entries). Bound with no
|
||||
# entities purely for coverage; revisit if a multi-cavity dump surfaces
|
||||
# fields worth exposing.
|
||||
OVEN_SPEC = Capability(href='/oven/spec/vs/0')
|
||||
|
||||
OVEN_MODE = Capability(
|
||||
href='/mode/vs/0',
|
||||
poll_tier='warm',
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
"""Capabilities for the cooktop half of range/combo appliances (issue #44,
|
||||
model TP1X_DA-KS-RANGE-0102X).
|
||||
|
||||
Not to be confused with PR #23's registry/capabilities/cooktop.py, which
|
||||
covers an unrelated standalone-cooktop product (NA9300K-class) that encodes
|
||||
burner state as strings inside /mode/vs/0's options array instead of the
|
||||
structured /cooktop/status/vs/0 resource this module reads -- two different
|
||||
OCF surfaces that happen to share the English word "cooktop".
|
||||
|
||||
Unlike the rest of the OCF surface, these hrefs use plain camelCase field
|
||||
names (no `x.com.samsung.da.` prefix) -- `/cooktop/status/vs/0` already
|
||||
looks like a vendor resource migrated onto OCF-standard-shaped field naming.
|
||||
|
||||
`/cooktop/status/vs/0` carries every burner's live state in one `burnerList`
|
||||
array (indexed by `burnerNumber`, not by a separate href per burner like
|
||||
fridge ice makers), so per-burner entities are hardcoded up to MAX_BURNERS
|
||||
and gated by exists_fn against whichever indices the device actually
|
||||
reports -- harmless over-declaration, per common.py's UNIVERSAL note, since
|
||||
an index absent from burnerList just never binds.
|
||||
|
||||
Write surfaces here are unproven (no live device to verify against, same
|
||||
caveat as oven.py's RMW writes) -- power level uses the same read-modify-
|
||||
write pattern already proven safe elsewhere in this codebase (oven setpoint,
|
||||
icemaker toggles).
|
||||
"""
|
||||
from ..capability import Capability
|
||||
from ..entities import BinarySensorDesc, SelectDesc, SensorDesc
|
||||
|
||||
# Observed as high as 4 (this issue's dump); user-reported hardware with 5
|
||||
# burners exists. Kept a little above both since exists_fn gates unused
|
||||
# slots out -- see module docstring.
|
||||
MAX_BURNERS = 6
|
||||
|
||||
|
||||
def _burner(burner_list, i):
|
||||
for b in (burner_list or []):
|
||||
if b.get('burnerNumber') == i:
|
||||
return b
|
||||
return None
|
||||
|
||||
|
||||
def _burner_exists(i):
|
||||
return lambda rep, resources: _burner(rep.get('burnerList'), i) is not None
|
||||
|
||||
|
||||
def _burner_field_fn(i, field):
|
||||
return lambda burner_list: (_burner(burner_list, i) or {}).get(field)
|
||||
|
||||
|
||||
def _burner_hot_surface_fn(i):
|
||||
get_state = _burner_field_fn(i, 'hotSurfaceState')
|
||||
return lambda burner_list: get_state(burner_list) not in (None, 'normal')
|
||||
|
||||
|
||||
def _power_level_options(resources):
|
||||
spec = resources.get('/cooktop/spec/vs/0') or {}
|
||||
return list(spec.get('supportedPowerLevelList') or [])
|
||||
|
||||
|
||||
def _burner_power_level_write(i):
|
||||
def write(p, rep, href=None):
|
||||
burner_list = rep.get('burnerList')
|
||||
if not burner_list:
|
||||
return None
|
||||
new_list = []
|
||||
found = False
|
||||
for b in burner_list:
|
||||
if b.get('burnerNumber') == i:
|
||||
b = dict(b)
|
||||
b['powerLevel'] = p
|
||||
found = True
|
||||
new_list.append(b)
|
||||
if not found:
|
||||
return None
|
||||
return ['cooktop', 'status', 'vs', '0'], {'burnerList': new_list}
|
||||
return write
|
||||
|
||||
|
||||
def _burner_entities(i):
|
||||
exists = _burner_exists(i)
|
||||
n = i + 1
|
||||
return (
|
||||
SelectDesc(key=f'burner_{i}_power_level', field='burnerList',
|
||||
name=f'Burner {n} power level', icon='mdi:knob',
|
||||
options=_power_level_options,
|
||||
exists_fn=exists,
|
||||
value_fn=_burner_field_fn(i, 'powerLevel'),
|
||||
write_fn=_burner_power_level_write(i)),
|
||||
SensorDesc(key=f'burner_{i}_state', field='burnerList',
|
||||
name=f'Burner {n} state', icon='mdi:stove',
|
||||
exists_fn=exists,
|
||||
value_fn=_burner_field_fn(i, 'operationState')),
|
||||
BinarySensorDesc(key=f'burner_{i}_hot_surface', field='burnerList',
|
||||
name=f'Burner {n} hot surface', device_class='heat',
|
||||
exists_fn=exists,
|
||||
value_fn=_burner_hot_surface_fn(i)),
|
||||
)
|
||||
|
||||
|
||||
COOKTOP_STATUS = Capability(
|
||||
href='/cooktop/status/vs/0',
|
||||
poll_tier='hot',
|
||||
entities=(
|
||||
SensorDesc(key='cooktop_state', field='operationState',
|
||||
name='Cooktop state', icon='mdi:pot-steam'),
|
||||
*[e for i in range(MAX_BURNERS) for e in _burner_entities(i)],
|
||||
),
|
||||
)
|
||||
|
||||
# Static burner-count/power-level-list metadata, read directly by
|
||||
# COOKTOP_STATUS's power-level select (options=_power_level_options) rather
|
||||
# than exposed through its own entity -- same "informs another capability,
|
||||
# no entity of its own" pattern as /wm/editcourse/vs/0 (ignored.py).
|
||||
COOKTOP_SPEC = Capability(href='/cooktop/spec/vs/0')
|
||||
|
||||
# settingTime (seconds) is the hot-surface auto-shutoff timer's configured
|
||||
# duration (1200s = 20 min in issue #44's dump); state on/off is whether the
|
||||
# feature itself is enabled -- not a live "surface is hot right now" alert
|
||||
# (that's COOKTOP_STATUS's per-burner hot_surface). No write contract
|
||||
# verified, so read-only for now.
|
||||
COOKTOP_SAFETY = Capability(
|
||||
href='/cooktop/settings/status/vs/0',
|
||||
poll_tier='warm',
|
||||
entities=(
|
||||
BinarySensorDesc(key='cooktop_safety_shutoff_enabled', field='safetyAlert',
|
||||
name='Hot surface auto-shutoff enabled',
|
||||
entity_category='config',
|
||||
value_fn=lambda v: (v or {}).get('state') == 'on'),
|
||||
),
|
||||
)
|
||||
@@ -5,16 +5,25 @@ Resources verified against two live WW90DG6U25LEU4 dumps (Table_02 course
|
||||
family). Washers never report `oneUiVersion` -- see
|
||||
`registry/by_type/__init__.py`'s `for_device_by_model()` for the fallback
|
||||
detection this device type requires.
|
||||
|
||||
The shared laundry surface -- power/kids-lock/remote-control OCF+vendor
|
||||
fallback pairs, buzzer, energy meter, job-beginning-status, and the
|
||||
/course/vs/0 cycle-select machinery -- lives in laundry.py. Only washer-
|
||||
specific controls (wash settings, drum-clean tracking, dispenser dosing) are
|
||||
here; they read washer-only fields off the same shared /course/vs/0 options
|
||||
array.
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from ..capability import Capability
|
||||
from ..entities import BinarySensorDesc, SelectDesc, SensorDesc, SwitchDesc
|
||||
|
||||
from .common import clamp_power, wh_to_kwh
|
||||
from ..entities import BinarySensorDesc, SelectDesc, SensorDesc
|
||||
from .laundry import (
|
||||
bool_option_exists, bool_option_switch, cycle_options, cycle_select, hex_pairs, option_value,
|
||||
replace_in_options,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Course_XX hex codes. The 23 codes named in strings.json/translations
|
||||
# Course_XX hex codes. 23 of the codes named in strings.json/translations
|
||||
# under entity.select.washer_cycle.state.<id, lowercased> were captured
|
||||
# from a live WW90DG6U25LEU4's x.com.samsung.da.editCourseList
|
||||
# (EditCourseList_1C1D211B1E29243328262722202325322F2E30662D8F96), matched
|
||||
@@ -29,16 +38,23 @@ from .common import clamp_power, wh_to_kwh
|
||||
# you'd expect to be pinned (default cycle + maintenance cycle),
|
||||
# corroborating the positional match.
|
||||
#
|
||||
# A further 5 codes -- '36' Wash+Dry, '37' Air Wash, '38' Cotton Dry,
|
||||
# '39' Synthetics Dry, and a second, distinct '1F' Intense Cold (not the
|
||||
# same code as '8F' above) -- came from a WD90T654DBN/S1 washer/dryer
|
||||
# combo's editCourseList and were named from that user's app screenshot
|
||||
# (issue #22). Combo units carry their own course set, so these codes
|
||||
# don't imply anything about '1F' on a plain washer.
|
||||
#
|
||||
# No static fallback list of those codes is kept here, deliberately: other
|
||||
# washer models have a different actual course set (a second dump's active
|
||||
# course, '65', isn't even in the list above; models with 'AI Wash'/'Mixed
|
||||
# Load' -- both "applicable models only" per the manual -- would have yet
|
||||
# another set), so hardcoding one device's list would show/hide the wrong
|
||||
# options on a different model. _cycle_options() below reads only the live
|
||||
# options on a different model. laundry.cycle_options() reads only the live
|
||||
# x.com.samsung.da.editCourseList; if a device doesn't populate that
|
||||
# resource, the cycle select isn't created at all (see WASHER_COURSE's
|
||||
# exists_fn). x.com.samsung.da.options' MostUsed_* entry was considered as
|
||||
# a fallback source (its first byte reliably equals the currently-selected
|
||||
# resource, the cycle select isn't created at all (see cycle_select's
|
||||
# exists_fn). x.com.samsung.da.options' MostUsed_* entry was considered as a
|
||||
# fallback source (its first byte reliably equals the currently-selected
|
||||
# Course_XX on both dumps we have), but the bytes after that don't
|
||||
# correspond to any confirmed course code on either device -- e.g. dump 1's
|
||||
# MostUsed_1C8410923FA67F00000000000000 decodes to
|
||||
@@ -46,23 +62,6 @@ from .common import clamp_power, wh_to_kwh
|
||||
# so it isn't trustworthy as a list of selectable courses and isn't used.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
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 []
|
||||
return _hex_pairs(raw.split('_', 1)[1])
|
||||
|
||||
|
||||
def _cycle_options(resources):
|
||||
rep = resources.get('/wm/editcourse/vs/0') or {}
|
||||
return _parse_edit_course_list(rep.get('x.com.samsung.da.editCourseList'))
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /washer/vs/0 -- wash temperature, spin speed, rinse cycle count
|
||||
#
|
||||
@@ -93,34 +92,28 @@ WASHER_SETTINGS = Capability(
|
||||
options_field='x.com.samsung.da.supportedRinseCycles',
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['washer', 'vs', '0'], {'x.com.samsung.da.rinseCycles': p})),
|
||||
# Washer/dryer combo units carry a dryLevel field on the wash
|
||||
# resource itself (no separate dryer device/course) -- see issue
|
||||
# #22. Self-gates off on plain washers, which never report
|
||||
# supportedDryLevel.
|
||||
SelectDesc(key='dry_level', field='x.com.samsung.da.dryLevel',
|
||||
name='Dry level', icon='mdi:tumble-dryer',
|
||||
entity_category='config',
|
||||
translation_key='washer_dry_level',
|
||||
options_field='x.com.samsung.da.supportedDryLevel',
|
||||
exists_fn=lambda rep, resources: bool(
|
||||
rep.get('x.com.samsung.da.supportedDryLevel')),
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['washer', 'vs', '0'], {'x.com.samsung.da.dryLevel': p})),
|
||||
),
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /course/vs/0 -- selected course, read/write (RMW on the options array,
|
||||
# same shape as dishwasher.CYCLE_OPTIONS._cycle_write).
|
||||
# /course/vs/0 -- the cycle select is the shared laundry.cycle_select; the
|
||||
# drum-clean and dispenser-dosing entities below are washer-specific reads off
|
||||
# the same options array.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _option_value(options, prefix):
|
||||
for o in (options or []):
|
||||
if isinstance(o, str) and o.startswith(prefix + '_'):
|
||||
return o.split('_', 1)[1]
|
||||
return None
|
||||
|
||||
|
||||
def _replace_in_options(options, prefix, new_value):
|
||||
return [f"{prefix}_{new_value}" if isinstance(o, str) and o.startswith(prefix + '_') else o
|
||||
for o in options]
|
||||
|
||||
|
||||
def _cycle_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, 'Course', p),
|
||||
}
|
||||
|
||||
|
||||
# Drum Clean+ maintenance tracking, from the same options[] array as the
|
||||
# selected course. DrumCleanProposal_<N> is the wash-cycle interval between
|
||||
@@ -134,8 +127,8 @@ def _cycle_write(p, rep, href=None):
|
||||
# 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')
|
||||
proposal = option_value(opts, 'DrumCleanProposal')
|
||||
washed = option_value(opts, 'WashingTimes')
|
||||
if proposal is None or washed is None:
|
||||
return None
|
||||
try:
|
||||
@@ -145,7 +138,7 @@ def _drum_clean_cycles_remaining(rep):
|
||||
|
||||
|
||||
def _drum_clean_last_cleaned(rep):
|
||||
raw = _option_value(rep.get('x.com.samsung.da.options'), 'DrumCleanLog')
|
||||
raw = option_value(rep.get('x.com.samsung.da.options'), 'DrumCleanLog')
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
@@ -182,45 +175,125 @@ def _drum_clean_last_cleaned(rep):
|
||||
# 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 []
|
||||
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 _dosing_level(prefix):
|
||||
"""Current dose code, normalized to the `Supported<prefix>` code format.
|
||||
|
||||
The device reports the selected level as `<prefix>_<code>` with the code
|
||||
un-padded (e.g. '3'), but the valid codes -- which are also this select's
|
||||
options and its translation keys -- come from `Supported<prefix>_<hexpairs>`
|
||||
as zero-padded hex pairs (e.g. '03'). Left as '3', the current value sits
|
||||
outside the select's own option list, so HA renders it 'unknown' (issue #9).
|
||||
Resolve it to the supported code with the same integer value so
|
||||
current_option matches an option (and its translation)."""
|
||||
def fn(rep):
|
||||
opts = rep.get('x.com.samsung.da.options')
|
||||
raw = option_value(opts, prefix)
|
||||
if raw is None:
|
||||
return None
|
||||
supported_raw = option_value(opts, f'Supported{prefix}')
|
||||
try:
|
||||
target = int(raw, 16)
|
||||
except (TypeError, ValueError):
|
||||
return raw
|
||||
for code in hex_pairs(supported_raw) if supported_raw else []:
|
||||
try:
|
||||
if int(code, 16) == target:
|
||||
return code
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
return raw
|
||||
return fn
|
||||
|
||||
|
||||
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
|
||||
# `p` is the zero-padded supported code the UI selected (e.g. '03');
|
||||
# the device stores the level un-padded (e.g. '3'), matching how it
|
||||
# reports it, so write it back in that native shape.
|
||||
try:
|
||||
native = format(int(p, 16), 'X')
|
||||
except (TypeError, ValueError):
|
||||
native = p
|
||||
return ['course', 'vs', '0'], {
|
||||
'x.com.samsung.da.options': _replace_in_options(opts, prefix, p),
|
||||
'x.com.samsung.da.options': replace_in_options(opts, prefix, native),
|
||||
}
|
||||
return write
|
||||
|
||||
|
||||
def _dosing_low(prefix):
|
||||
return lambda rep: _option_value(
|
||||
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
|
||||
# Bubble soak / pre-wash / intensive-wash toggles, from the same options[]
|
||||
# array (issue #22 follow-up on a WD90T654DBN/S1 combo). Each rides as a
|
||||
# plain '<Prefix>_On'/'<Prefix>_Off' token, confirmed by a dump taken with
|
||||
# Bubble Soak switched on in the app (BubbleSoak_On) -- the same On/Off shape
|
||||
# already used by AiOption and KidsLockBypass in this same array, so
|
||||
# PreWashSetting/IntensiveSetting are assumed to follow suit.
|
||||
#
|
||||
# Each also has a differently-named hex-pair availability field that lines up
|
||||
# positionally with editCourseList: BubbleSoakSet, PreWashAvailableSet,
|
||||
# IntensiveAvailableSet. On the reporter's dump (course '30' at position 1 of
|
||||
# 24), all three read 'F0' at that position and the toggle was writable --
|
||||
# and the same dump's earlier state (course '1C' at position 0, 'BubbleSoak
|
||||
# Off') decodes to '00' for that course, matching the app graying the
|
||||
# control out there. 'F0'/'00' is treated as available/unavailable on that
|
||||
# evidence. exists_fn (device-level presence) still only runs once, against
|
||||
# the setup-time snapshot, so it isn't a fit for this per-course check --
|
||||
# validate_fn runs on every write attempt instead (dispatched from
|
||||
# coordinator.async_send_command, ahead of write_fn), rejecting an on-write
|
||||
# for a course whose byte isn't 'F0' with a user-facing error rather than
|
||||
# silently no-opping against the device. The read/write/presence machinery
|
||||
# itself is laundry.bool_option_switch, shared with dishwasher's storm-wash/
|
||||
# auto-release-dry toggles -- only this per-course gating is washer-only, so
|
||||
# it stays here rather than in laundry.py (see laundry.bool_option_switch's
|
||||
# docstring: it takes a prebuilt validate_fn and has no opinion on it).
|
||||
def _bool_option_switch(key, name, icon, prefix, availability_field):
|
||||
def validate(p, rep, resources):
|
||||
"""Reject turning on when the selected course's byte in
|
||||
`availability_field` isn't 'F0'. Turning off is never blocked. Falls
|
||||
back to allowing the write whenever the availability data can't be
|
||||
resolved (unrecognized course, missing/mismatched-length bitmap)
|
||||
rather than guessing -- a false rejection is worse than an
|
||||
occasional no-op write."""
|
||||
if p != 'On':
|
||||
return None
|
||||
opts = rep.get('x.com.samsung.da.options') or []
|
||||
current = option_value(opts, 'Course')
|
||||
courses = cycle_options(resources)
|
||||
if not current or current not in courses:
|
||||
return None
|
||||
raw = option_value(opts, availability_field)
|
||||
if raw is None:
|
||||
return None
|
||||
pairs = hex_pairs(raw)
|
||||
if len(pairs) != len(courses):
|
||||
return None
|
||||
if pairs[courses.index(current)] != 'F0':
|
||||
return f"{name} isn't available on the selected cycle."
|
||||
return None
|
||||
|
||||
return bool_option_switch(
|
||||
key, name, icon, prefix,
|
||||
entity_category='config', gate_on_presence=True, validate_fn=validate)
|
||||
|
||||
|
||||
WASHER_COURSE = Capability(
|
||||
href='/course/vs/0',
|
||||
entities=(
|
||||
SelectDesc(key='cycle', name='Cycle', icon='mdi:washing-machine',
|
||||
translation_key='washer_cycle',
|
||||
options=_cycle_options,
|
||||
exists_fn=lambda rep, resources: bool(_cycle_options(resources)),
|
||||
rep_fn=lambda rep: _option_value(
|
||||
rep.get('x.com.samsung.da.options'), 'Course'),
|
||||
write_fn=_cycle_write),
|
||||
cycle_select(translation_key='washer_cycle', icon='mdi:washing-machine'),
|
||||
SensorDesc(key='drum_clean_cycles_remaining', name='Drum clean due in',
|
||||
icon='mdi:washing-machine-alert', unit='cycles',
|
||||
state_class='measurement',
|
||||
@@ -237,8 +310,7 @@ WASHER_COURSE = Capability(
|
||||
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'),
|
||||
rep_fn=_dosing_level('DetergentLevelCtrl'),
|
||||
write_fn=_level_write('DetergentLevelCtrl')),
|
||||
SelectDesc(key='detergent_water_hardness', name='Detergent water hardness',
|
||||
icon='mdi:water-opacity',
|
||||
@@ -247,8 +319,7 @@ WASHER_COURSE = Capability(
|
||||
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'),
|
||||
rep_fn=_dosing_level('DetergentLevel2Ctrl'),
|
||||
write_fn=_level_write('DetergentLevel2Ctrl')),
|
||||
SelectDesc(key='softener_quantity', name='Softener quantity', icon='mdi:flask-outline',
|
||||
translation_key='washer_dosing_quantity',
|
||||
@@ -256,8 +327,7 @@ WASHER_COURSE = Capability(
|
||||
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'),
|
||||
rep_fn=_dosing_level('SoftenerLevelCtrl'),
|
||||
write_fn=_level_write('SoftenerLevelCtrl')),
|
||||
SelectDesc(key='softener_concentration', name='Softener concentration',
|
||||
icon='mdi:flask-plus-outline',
|
||||
@@ -266,175 +336,21 @@ WASHER_COURSE = Capability(
|
||||
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'),
|
||||
rep_fn=_dosing_level('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'),
|
||||
exists_fn=bool_option_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'),
|
||||
exists_fn=bool_option_exists('SoftenerAlarm'),
|
||||
rep_fn=_dosing_low('SoftenerAlarm')),
|
||||
),
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /buzzersound/vs/0 -- buzzer volume and (on some units) a separate finish
|
||||
# chime. Fields have no 'x.com.samsung.da.' prefix in this resource, unlike
|
||||
# most other washer hrefs.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
BUZZER_SOUND = Capability(
|
||||
href='/buzzersound/vs/0',
|
||||
entities=(
|
||||
SelectDesc(key='buzzer_sound', field='setBuzzerSound',
|
||||
name='Buzzer sound', icon='mdi:volume-high',
|
||||
entity_category='config',
|
||||
options_field='supportedBuzzerSound',
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['buzzersound', 'vs', '0'], {'setBuzzerSound': p})),
|
||||
SelectDesc(key='finish_sound', field='setFinishSound',
|
||||
name='Finish sound', icon='mdi:bell-ring',
|
||||
entity_category='config',
|
||||
exists_fn=lambda rep, resources: 'supportedFinishSound' in rep,
|
||||
options_field='supportedFinishSound',
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['buzzersound', 'vs', '0'], {'setFinishSound': p})),
|
||||
),
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /wm/jobbeginingstatus/vs/0 -- same href as dryer.JOB_BEGINNING_STATUS, but
|
||||
# a different field name (currentStatus, not jobBeginingStatus).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
WASHER_JOB_BEGINNING_STATUS = Capability(
|
||||
href='/wm/jobbeginingstatus/vs/0',
|
||||
poll_tier='warm',
|
||||
entities=(
|
||||
SensorDesc(key='job_beginning_status',
|
||||
field='x.com.samsung.da.currentStatus',
|
||||
name='Job beginning status',
|
||||
entity_category='diagnostic'),
|
||||
),
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# OCF-native / '-vs' fallback pairs for power, kids-lock, remote control.
|
||||
#
|
||||
# Same shape as fridge.py's "Aggregate-resource fallbacks": the generic OCF
|
||||
# href (/power/0, plain boolean 'value') is preferred when present; the
|
||||
# vendor '-vs' href (richer historically, but for these three controls just
|
||||
# a string-encoded duplicate) only binds when the generic href is absent
|
||||
# from this device's resource set, via match_fn. Scoped to washer.py, not
|
||||
# common.py -- no other device type has been confirmed to expose the
|
||||
# generic hrefs, so this must not change behavior for dishwasher/dryer/
|
||||
# oven/refrigerator.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
POWER_GENERIC = Capability(
|
||||
href='/power/0',
|
||||
entities=(
|
||||
SwitchDesc(key='power_switch', field='value',
|
||||
name='Power',
|
||||
value_fn=lambda v: bool(v),
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['power', '0'], {'value': p == 'On'})),
|
||||
),
|
||||
)
|
||||
|
||||
POWER_VS_FALLBACK = Capability(
|
||||
href='/power/vs/0',
|
||||
match_fn=lambda rep, resources: '/power/0' not in resources,
|
||||
entities=(
|
||||
SwitchDesc(key='power_switch', field='x.com.samsung.da.power',
|
||||
name='Power',
|
||||
value_fn=lambda v: v == 'On',
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['power', 'vs', '0'],
|
||||
{'x.com.samsung.da.power': 'On' if p == 'On' else 'Off'})),
|
||||
),
|
||||
)
|
||||
|
||||
KIDS_LOCK_GENERIC = Capability(
|
||||
href='/kidslock/0',
|
||||
entities=(
|
||||
SwitchDesc(key='child_lock', field='value',
|
||||
name='Child lock', device_class='lock',
|
||||
value_fn=lambda v: bool(v),
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['kidslock', '0'], {'value': p == 'On'})),
|
||||
),
|
||||
)
|
||||
|
||||
KIDS_LOCK_VS_FALLBACK = Capability(
|
||||
href='/kidslock/vs/0',
|
||||
match_fn=lambda rep, resources: '/kidslock/0' not in resources,
|
||||
entities=(
|
||||
SwitchDesc(key='child_lock', field='x.com.samsung.da.kidsLock',
|
||||
name='Child lock', device_class='lock',
|
||||
value_fn=lambda v: v != 'Ready',
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['kidslock', 'vs', '0'],
|
||||
{'x.com.samsung.da.kidsLock': 'Enable' if p == 'On' else 'Ready'})),
|
||||
),
|
||||
)
|
||||
|
||||
REMOTE_CONTROL_GENERIC = Capability(
|
||||
href='/remotectrl/0',
|
||||
entities=(
|
||||
BinarySensorDesc(key='remote_control', field='value',
|
||||
name='Smart Control', device_class='connectivity',
|
||||
value_fn=lambda v: bool(v)),
|
||||
),
|
||||
)
|
||||
|
||||
REMOTE_CONTROL_VS_FALLBACK = Capability(
|
||||
href='/remotectrl/vs/0',
|
||||
match_fn=lambda rep, resources: '/remotectrl/0' not in resources,
|
||||
entities=(
|
||||
BinarySensorDesc(key='remote_control',
|
||||
field='x.com.samsung.da.remoteControlEnabled',
|
||||
name='Smart Control', device_class='connectivity',
|
||||
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),
|
||||
_bool_option_switch('bubble_soak', 'Bubble soak', 'mdi:chart-bubble',
|
||||
'BubbleSoak', 'BubbleSoakSet'),
|
||||
_bool_option_switch('pre_wash', 'Pre wash', 'mdi:washing-machine',
|
||||
'PreWashSetting', 'PreWashAvailableSet'),
|
||||
_bool_option_switch('intensive', 'Intensive', 'mdi:washing-machine',
|
||||
'IntensiveSetting', 'IntensiveAvailableSet'),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -15,6 +15,11 @@ class Capability:
|
||||
rt_filter: Optional[str] = None # bind only if rt_filter in rep.get('rt', ())
|
||||
href_prefix: Optional[str] = None # pattern caps only: bind only if href starts with this
|
||||
strip_prefix_in_key: bool = False # strip href_prefix segs before building key_override
|
||||
# Rep field holding this instance's device-given name (e.g. an ice
|
||||
# maker's "CUBED_ICE"/"ICE_BITES"), normalized and used as the display
|
||||
# name prefix in place of the href-derived instance label. Does not
|
||||
# affect key_override/unique_id -- only what's shown in the UI.
|
||||
name_field: Optional[str] = None
|
||||
match_fn: Optional[Callable[[dict, dict], bool]] = None # match_fn(rep, resources) -> bool
|
||||
# Rare optional hook — only operational-state-style resources use this.
|
||||
on_observation: Optional[Callable[[dict, dict], None]] = None
|
||||
|
||||
@@ -27,6 +27,25 @@ class BoundEntity:
|
||||
desc: SamsungEntityDescription
|
||||
instance: str = ''
|
||||
key_override: Optional[str] = None
|
||||
instance_name: Optional[str] = None
|
||||
|
||||
|
||||
def _snake_to_title(s: str) -> str:
|
||||
"""'CUBED_ICE'/'cubed_ice' -> 'Cubed Ice'. Shared with entity.py's
|
||||
_derive_name, which applies the same transform to a state key."""
|
||||
return s.replace('_', ' ').title()
|
||||
|
||||
|
||||
def _instance_name(cap: Capability, rep: dict) -> Optional[str]:
|
||||
"""Normalize `cap.name_field`'s raw value ("CUBED_ICE" -> "Cubed Ice")
|
||||
for use as a display-name prefix, or None if the cap doesn't declare
|
||||
one or the device didn't report it."""
|
||||
if not cap.name_field:
|
||||
return None
|
||||
raw = rep.get(cap.name_field)
|
||||
if not isinstance(raw, str) or not raw:
|
||||
return None
|
||||
return _snake_to_title(raw)
|
||||
|
||||
|
||||
def instance_suffix(href: str) -> str:
|
||||
@@ -37,6 +56,18 @@ def instance_suffix(href: str) -> str:
|
||||
return ''
|
||||
|
||||
|
||||
def _bind(cap: Capability, href: str, inst: str, inst_name: Optional[str],
|
||||
key_prefix: Optional[str] = None) -> list[BoundEntity]:
|
||||
"""Build one BoundEntity per entity on `cap`, sharing the instance/
|
||||
key-prefix/instance-name computed once by the caller."""
|
||||
return [
|
||||
BoundEntity(href=href, capability=cap, desc=desc, instance=inst,
|
||||
key_override=f'{key_prefix}_{desc.key}' if key_prefix else None,
|
||||
instance_name=inst_name)
|
||||
for desc in cap.entities
|
||||
]
|
||||
|
||||
|
||||
def discover(
|
||||
resources: dict[str, dict],
|
||||
registry: dict[str, list[Capability]],
|
||||
@@ -58,9 +89,7 @@ def discover(
|
||||
if cap.match_fn is not None and not cap.match_fn(rep, resources):
|
||||
continue
|
||||
inst = instance_suffix(href)
|
||||
for desc in cap.entities:
|
||||
out.append(BoundEntity(href=href, capability=cap,
|
||||
desc=desc, instance=inst))
|
||||
out.extend(_bind(cap, href, inst, _instance_name(cap, rep)))
|
||||
matched = True
|
||||
|
||||
if matched:
|
||||
@@ -78,10 +107,7 @@ def discover(
|
||||
# Auto-derive key prefix from href segments (skip digits and 'vs')
|
||||
src = href[len(cap.href_prefix):] if (cap.strip_prefix_in_key and cap.href_prefix) else href
|
||||
segs = [s for s in src.strip('/').split('/') if s and not s.isdigit() and s != 'vs']
|
||||
for desc in cap.entities:
|
||||
key_override = '_'.join(segs) + '_' + desc.key
|
||||
out.append(BoundEntity(href=href, capability=cap, desc=desc,
|
||||
instance=inst, key_override=key_override))
|
||||
out.extend(_bind(cap, href, inst, _instance_name(cap, rep), '_'.join(segs)))
|
||||
matched = True
|
||||
break
|
||||
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
Frozen dataclasses so the future native HA component can consume them as
|
||||
EntityDescription subclasses unchanged. Read transforms live in value_fn;
|
||||
presence gating in exists_fn; write logic in write_fn on command platforms.
|
||||
presence gating in exists_fn; write logic in write_fn on command platforms;
|
||||
pre-write rejection (surfaced to the user, not just logged) in validate_fn
|
||||
where a description declares one.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -10,6 +12,11 @@ from dataclasses import dataclass, field
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
WriteFn = Optional[Callable[[Any, dict], "tuple[list[str], dict] | None"]]
|
||||
# (payload, rep, resources) -> a human-readable rejection message, or None to
|
||||
# allow the write. resources is the coordinator's full href->rep snapshot, for
|
||||
# the same cross-resource lookups exists_fn needs (e.g. reading a sibling
|
||||
# href's live option list).
|
||||
ValidateFn = Optional[Callable[[Any, dict, dict], "str | None"]]
|
||||
|
||||
|
||||
def _identity(v: Any) -> Any:
|
||||
@@ -29,7 +36,7 @@ class SamsungEntityDescription:
|
||||
rep_fn: Optional[Callable[[dict], Any]] = None # replaces field+value_fn; receives full rep
|
||||
# (rep, resources): rep is this entity's own href's representation;
|
||||
# resources is the coordinator's full href->rep snapshot, for gating
|
||||
# presence on a sibling resource (e.g. washer._cycle_options's source).
|
||||
# presence on a sibling resource (e.g. laundry.cycle_options's source).
|
||||
exists_fn: Optional[Callable[[dict, dict], bool]] = None
|
||||
|
||||
|
||||
@@ -61,6 +68,7 @@ class SelectDesc(SamsungEntityDescription):
|
||||
class SwitchDesc(SamsungEntityDescription):
|
||||
device_class: Optional[str] = None
|
||||
write_fn: WriteFn = None
|
||||
validate_fn: ValidateFn = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
@@ -77,6 +85,13 @@ class NumberDesc(SamsungEntityDescription):
|
||||
native_min: Optional[float] = None
|
||||
native_max: Optional[float] = None
|
||||
step: Optional[float] = None
|
||||
# Override native_min/native_max/step from the live rep, when set --
|
||||
# same "static default, live override" shape as unit_fn, for resources
|
||||
# whose sane bounds depend on a per-device value (e.g. a temperature
|
||||
# setpoint reported in Celsius on one device, Fahrenheit on another).
|
||||
native_min_fn: Optional[Callable[[dict], float]] = None
|
||||
native_max_fn: Optional[Callable[[dict], float]] = None
|
||||
step_fn: Optional[Callable[[dict], float]] = None
|
||||
range_field: Optional[str] = None # resource field containing [min, max] list
|
||||
write_fn: WriteFn = None
|
||||
|
||||
@@ -86,6 +101,16 @@ class TimeDesc(SamsungEntityDescription):
|
||||
write_fn: WriteFn = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class ClimateDesc(SamsungEntityDescription):
|
||||
# A composite entity: it binds one *primary* resource (its href) but the
|
||||
# climate platform reads sibling resources (power, temperature, wind) from
|
||||
# the coordinator snapshot and writes to several of them. write_fn takes a
|
||||
# (kind, value) payload from the platform and returns the (path_segs, body)
|
||||
# for that one sub-write, so a single desc drives multi-resource writes.
|
||||
write_fn: WriteFn = None
|
||||
|
||||
|
||||
PLATFORM_OF: dict[type, str] = {
|
||||
SensorDesc: 'sensor',
|
||||
BinarySensorDesc: 'binary_sensor',
|
||||
@@ -94,4 +119,5 @@ PLATFORM_OF: dict[type, str] = {
|
||||
ButtonDesc: 'button',
|
||||
NumberDesc: 'number',
|
||||
TimeDesc: 'time',
|
||||
ClimateDesc: 'climate',
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
@@ -29,13 +30,25 @@
|
||||
"sp_ttype_wine_dessert": "Wine and Dessert"
|
||||
}
|
||||
},
|
||||
"pantry_zone_mode": {
|
||||
"state": {
|
||||
"fdr_wine": "Wine",
|
||||
"fdr_deli": "Deli",
|
||||
"fdr_drinks": "Drinks"
|
||||
}
|
||||
},
|
||||
"flex_zone_mode": {
|
||||
"state": {
|
||||
"cv_ttype_rf9000a_freeze": "Freeze",
|
||||
"cv_ttype_rf9000a_softfreeze": "Soft Freeze",
|
||||
"cv_ttype_rf9000a_meat_fish": "Meat/Fish",
|
||||
"cv_ttype_rf9000a_fruit_veggies": "Fruit & Veggies",
|
||||
"cv_ttype_rf9000a_beverage": "Beverage"
|
||||
"cv_ttype_rf9000a_beverage": "Beverage",
|
||||
"cv_fdr_wine": "Wine",
|
||||
"cv_fdr_deli": "Deli",
|
||||
"cv_fdr_beverage": "Beverage",
|
||||
"cv_fdr_meat": "Meat",
|
||||
"cv_fdr_soft_freezer": "Soft Freezer"
|
||||
}
|
||||
},
|
||||
"dishwasher_cycle": {
|
||||
@@ -76,7 +89,30 @@
|
||||
"66": "Denim",
|
||||
"2d": "Silent Wash",
|
||||
"8f": "Intense Cold",
|
||||
"96": "Less Microfiber"
|
||||
"96": "Less Microfiber",
|
||||
"36": "Wash+Dry",
|
||||
"37": "Air Wash",
|
||||
"38": "Cotton Dry",
|
||||
"39": "Synthetics Dry",
|
||||
"1f": "Intense Cold"
|
||||
}
|
||||
},
|
||||
"dryer_cycle": {
|
||||
"state": {
|
||||
"16": "Cotton",
|
||||
"18": "Synthetics",
|
||||
"19": "Delicates",
|
||||
"1a": "Wool",
|
||||
"1b": "Bedding",
|
||||
"1c": "Shirts",
|
||||
"1d": "Towels",
|
||||
"1e": "Outdoor",
|
||||
"1f": "Mixed Load",
|
||||
"20": "Iron Dry",
|
||||
"23": "Quick Dry 35",
|
||||
"24": "Cool Air",
|
||||
"25": "Warm Air",
|
||||
"27": "Time Dry"
|
||||
}
|
||||
},
|
||||
"washer_dosing_quantity": {
|
||||
@@ -100,6 +136,18 @@
|
||||
"02": "2x",
|
||||
"03": "3x"
|
||||
}
|
||||
},
|
||||
"washer_dry_level": {
|
||||
"state": {
|
||||
"none": "Off",
|
||||
"cupboard": "Cupboard",
|
||||
"30": "30 min",
|
||||
"60": "1 hr",
|
||||
"90": "1 hr 30",
|
||||
"120": "2 hr",
|
||||
"180": "3 hr",
|
||||
"240": "4 hr"
|
||||
}
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
@@ -114,6 +162,51 @@
|
||||
"observe": "Push (observe)",
|
||||
"poll": "Polling"
|
||||
}
|
||||
},
|
||||
"machine_state": {
|
||||
"state": {
|
||||
"idle": "Idle",
|
||||
"active": "Active",
|
||||
"pause": "Paused"
|
||||
}
|
||||
},
|
||||
"air_filter_status": {
|
||||
"state": {
|
||||
"normal": "Normal",
|
||||
"wash": "Wash",
|
||||
"replace": "Replace"
|
||||
}
|
||||
}
|
||||
},
|
||||
"climate": {
|
||||
"airconditioner": {
|
||||
"state_attributes": {
|
||||
"fan_mode": {
|
||||
"state": {
|
||||
"auto": "Auto",
|
||||
"low": "Low",
|
||||
"medium": "Medium",
|
||||
"high": "High",
|
||||
"turbo": "Turbo"
|
||||
}
|
||||
},
|
||||
"swing_mode": {
|
||||
"state": {
|
||||
"off": "Fixed",
|
||||
"both": "All directions",
|
||||
"vertical": "Up and down"
|
||||
}
|
||||
},
|
||||
"preset_mode": {
|
||||
"state": {
|
||||
"none": "Off",
|
||||
"sleep": "Sleep",
|
||||
"quiet": "Quiet",
|
||||
"smart": "Smart",
|
||||
"speed": "Speed"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
@@ -29,13 +30,25 @@
|
||||
"sp_ttype_wine_dessert": "Wine and Dessert"
|
||||
}
|
||||
},
|
||||
"pantry_zone_mode": {
|
||||
"state": {
|
||||
"fdr_wine": "Wine",
|
||||
"fdr_deli": "Deli",
|
||||
"fdr_drinks": "Drinks"
|
||||
}
|
||||
},
|
||||
"flex_zone_mode": {
|
||||
"state": {
|
||||
"cv_ttype_rf9000a_freeze": "Freeze",
|
||||
"cv_ttype_rf9000a_softfreeze": "Soft Freeze",
|
||||
"cv_ttype_rf9000a_meat_fish": "Meat/Fish",
|
||||
"cv_ttype_rf9000a_fruit_veggies": "Fruit & Veggies",
|
||||
"cv_ttype_rf9000a_beverage": "Beverage"
|
||||
"cv_ttype_rf9000a_beverage": "Beverage",
|
||||
"cv_fdr_wine": "Wine",
|
||||
"cv_fdr_deli": "Deli",
|
||||
"cv_fdr_beverage": "Beverage",
|
||||
"cv_fdr_meat": "Meat",
|
||||
"cv_fdr_soft_freezer": "Soft Freezer"
|
||||
}
|
||||
},
|
||||
"dishwasher_cycle": {
|
||||
@@ -76,7 +89,30 @@
|
||||
"66": "Denim",
|
||||
"2d": "Silent Wash",
|
||||
"8f": "Intense Cold",
|
||||
"96": "Less Microfiber"
|
||||
"96": "Less Microfiber",
|
||||
"36": "Wash+Dry",
|
||||
"37": "Air Wash",
|
||||
"38": "Cotton Dry",
|
||||
"39": "Synthetics Dry",
|
||||
"1f": "Intense Cold"
|
||||
}
|
||||
},
|
||||
"dryer_cycle": {
|
||||
"state": {
|
||||
"16": "Cotton",
|
||||
"18": "Synthetics",
|
||||
"19": "Delicates",
|
||||
"1a": "Wool",
|
||||
"1b": "Bedding",
|
||||
"1c": "Shirts",
|
||||
"1d": "Towels",
|
||||
"1e": "Outdoor",
|
||||
"1f": "Mixed Load",
|
||||
"20": "Iron Dry",
|
||||
"23": "Quick Dry 35",
|
||||
"24": "Cool Air",
|
||||
"25": "Warm Air",
|
||||
"27": "Time Dry"
|
||||
}
|
||||
},
|
||||
"washer_dosing_quantity": {
|
||||
@@ -100,6 +136,18 @@
|
||||
"02": "2x",
|
||||
"03": "3x"
|
||||
}
|
||||
},
|
||||
"washer_dry_level": {
|
||||
"state": {
|
||||
"none": "Off",
|
||||
"cupboard": "Cupboard",
|
||||
"30": "30 min",
|
||||
"60": "1 hr",
|
||||
"90": "1 hr 30",
|
||||
"120": "2 hr",
|
||||
"180": "3 hr",
|
||||
"240": "4 hr"
|
||||
}
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
@@ -114,6 +162,51 @@
|
||||
"observe": "Push (observe)",
|
||||
"poll": "Polling"
|
||||
}
|
||||
},
|
||||
"machine_state": {
|
||||
"state": {
|
||||
"idle": "Idle",
|
||||
"active": "Active",
|
||||
"pause": "Paused"
|
||||
}
|
||||
},
|
||||
"air_filter_status": {
|
||||
"state": {
|
||||
"normal": "Normal",
|
||||
"wash": "Wash",
|
||||
"replace": "Replace"
|
||||
}
|
||||
}
|
||||
},
|
||||
"climate": {
|
||||
"airconditioner": {
|
||||
"state_attributes": {
|
||||
"fan_mode": {
|
||||
"state": {
|
||||
"auto": "Auto",
|
||||
"low": "Low",
|
||||
"medium": "Medium",
|
||||
"high": "High",
|
||||
"turbo": "Turbo"
|
||||
}
|
||||
},
|
||||
"swing_mode": {
|
||||
"state": {
|
||||
"off": "Fixed",
|
||||
"both": "All directions",
|
||||
"vertical": "Up and down"
|
||||
}
|
||||
},
|
||||
"preset_mode": {
|
||||
"state": {
|
||||
"none": "Off",
|
||||
"sleep": "Sleep",
|
||||
"quiet": "Quiet",
|
||||
"smart": "Smart",
|
||||
"speed": "Speed"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
+10
-1
@@ -1,2 +1,11 @@
|
||||
pytest>=8.0
|
||||
# Test harness — pulls in home-assistant, pytest, and pytest-socket at the
|
||||
# matching versions. Current Home Assistant requires Python >= 3.14; pip will
|
||||
# resolve the newest home-assistant your interpreter supports.
|
||||
pytest-homeassistant-custom-component>=0.13.316
|
||||
|
||||
# Integration runtime deps, needed to import the component under test
|
||||
# (also declared in custom_components/localthings/manifest.json).
|
||||
smartthings-local>=0.1.0
|
||||
cbor2>=5.4.6
|
||||
pyOpenSSL>=23.0
|
||||
cryptography>=41.0
|
||||
|
||||
+400
@@ -0,0 +1,400 @@
|
||||
{
|
||||
"meta": {
|
||||
"model": "ARTIK051_PRAC_20K",
|
||||
"device_type": "airconditioner",
|
||||
"source": "issue #17 diagnostics (scrubbed)",
|
||||
"note": "First air-conditioner /device/0 dump; first composite HA climate entity."
|
||||
},
|
||||
"device0": [
|
||||
{
|
||||
"rt": [
|
||||
"x.com.samsung.devcol",
|
||||
"oic.wk.col"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.ll",
|
||||
"oic.if.b"
|
||||
]
|
||||
},
|
||||
{
|
||||
"href": "/personality/presence/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.id": "",
|
||||
"x.com.samsung.da.deviceId": "**REDACTED**",
|
||||
"x.com.samsung.da.value": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/realtimenotiforclient/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.timeforshortnoti": "0",
|
||||
"x.com.samsung.da.longnotisubscription": "false",
|
||||
"x.com.samsung.da.periodicnotisubscription": "true"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/filter/airdustfilter/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.filterUsage": "100",
|
||||
"x.com.samsung.da.filterUsageResolution": "1",
|
||||
"x.com.samsung.da.filterDesiredUsage": "500",
|
||||
"x.com.samsung.da.filterStatus": "wash",
|
||||
"x.com.samsung.da.filterCapacity": "500",
|
||||
"x.com.samsung.da.filterCapacityUnit": "Hour",
|
||||
"x.com.samsung.da.filterResetType": [
|
||||
"replaceable",
|
||||
"washable"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/temperature/control/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.increment": "1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/mode/convenient/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.modes": "Off",
|
||||
"x.com.samsung.da.supportedModes": [
|
||||
"Off",
|
||||
"Sleep",
|
||||
"Quiet",
|
||||
"Smart",
|
||||
"Speed"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/option/airpurify/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.modes": "Off",
|
||||
"x.com.samsung.da.supportedModes": [
|
||||
"On",
|
||||
"Off"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/option/autoclean/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.status": "Stop",
|
||||
"x.com.samsung.da.settingStatus": "Off",
|
||||
"x.com.samsung.da.progress": "0",
|
||||
"x.com.samsung.da.supportedStatus": [
|
||||
"Start",
|
||||
"Stop"
|
||||
],
|
||||
"x.com.samsung.da.supportedSettingStatus": [
|
||||
"On",
|
||||
"Off"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/wind/strength/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.modes": "1",
|
||||
"x.com.samsung.da.supportedModes": [
|
||||
"0",
|
||||
"1",
|
||||
"2",
|
||||
"3",
|
||||
"4"
|
||||
],
|
||||
"x.com.samsung.da.modesName": [
|
||||
"Auto",
|
||||
"Low",
|
||||
"Mid",
|
||||
"High",
|
||||
"Turbo"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/wind/direction/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.modes": "Fix",
|
||||
"x.com.samsung.da.supportedModes": [
|
||||
"Fix",
|
||||
"All",
|
||||
"Up_And_Low"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/alarms/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.id": "0",
|
||||
"x.com.samsung.da.description": "Alarm",
|
||||
"x.com.samsung.da.alarmType": "Device",
|
||||
"x.com.samsung.da.code": "ErrorCode_OFF",
|
||||
"x.com.samsung.da.triggeredTime": "2026-07-21T07:20:30"
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.id": "1",
|
||||
"x.com.samsung.da.description": "Alarm",
|
||||
"x.com.samsung.da.alarmType": "Device",
|
||||
"x.com.samsung.da.code": "FilterAlarm",
|
||||
"x.com.samsung.da.triggeredTime": "2026-07-21T07:20:30",
|
||||
"x.com.samsung.da.state": "Created"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/temperatures/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.id": "0",
|
||||
"x.com.samsung.da.description": "Temperature",
|
||||
"x.com.samsung.da.desired": "24.0",
|
||||
"x.com.samsung.da.current": "27.0",
|
||||
"x.com.samsung.da.maximum": "30",
|
||||
"x.com.samsung.da.minimum": "16",
|
||||
"x.com.samsung.da.increment": "1.0",
|
||||
"x.com.samsung.da.unit": "Celsius"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/temperature/current/0",
|
||||
"rep": {
|
||||
"range": [
|
||||
16.0,
|
||||
30.0
|
||||
],
|
||||
"units": "C",
|
||||
"temperature": 27.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/temperature/desired/0",
|
||||
"rep": {
|
||||
"range": [
|
||||
16.0,
|
||||
30.0
|
||||
],
|
||||
"units": "C",
|
||||
"temperature": 24.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/diagnosis/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.diagnosisStart": "Ready"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/energy/consumption/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.instantaneousPower": "0",
|
||||
"x.com.samsung.da.cumulativePower": "1686632",
|
||||
"x.com.samsung.da.cumulativeSavedPower": "0",
|
||||
"x.com.samsung.da.cumulativeUnit": "Wh",
|
||||
"x.com.samsung.da.instantaneousPowerUnit": "W"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/energy/consumption/0",
|
||||
"rep": {
|
||||
"power": 0.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/mode/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.supportedModes": [
|
||||
"Cool",
|
||||
"Dry",
|
||||
"Wind",
|
||||
"Auto",
|
||||
"Heat"
|
||||
],
|
||||
"x.com.samsung.da.modes": [
|
||||
"Cool"
|
||||
],
|
||||
"x.com.samsung.da.options": [
|
||||
"Sleep_0",
|
||||
"ArtificialWorking_Off",
|
||||
"ComfortAICooling_Off",
|
||||
"AiTempChanged_Off",
|
||||
"AiTemp_240",
|
||||
"OutdoorTemp_79",
|
||||
"CoolCapa_50",
|
||||
"WarmCapa_60",
|
||||
"Light_Off",
|
||||
"Volume_100",
|
||||
"OptionCode_53304",
|
||||
"ExtendOptionCode_197256",
|
||||
"RacInfo_None",
|
||||
"UpdateAllow_NotAllowed",
|
||||
"DurationOn_0",
|
||||
"WelcomeCoolingState_Off"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/power/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.power": "Off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/power/0",
|
||||
"rep": {
|
||||
"value": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/sensors/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.id": "0",
|
||||
"x.com.samsung.da.description": "Sensor for CleanLevel",
|
||||
"x.com.samsung.da.type": "CleanLevel",
|
||||
"x.com.samsung.da.value": [
|
||||
"0"
|
||||
]
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.id": "1",
|
||||
"x.com.samsung.da.description": "Sensor for Odor",
|
||||
"x.com.samsung.da.type": "Odor",
|
||||
"x.com.samsung.da.value": [
|
||||
"0"
|
||||
]
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.id": "2",
|
||||
"x.com.samsung.da.description": "Sensor for Dust",
|
||||
"x.com.samsung.da.type": "Dust",
|
||||
"x.com.samsung.da.value": [
|
||||
"0",
|
||||
"0"
|
||||
]
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.id": "3",
|
||||
"x.com.samsung.da.description": "Sensor for FineDust",
|
||||
"x.com.samsung.da.type": "FineDust",
|
||||
"x.com.samsung.da.value": [
|
||||
"0",
|
||||
"0"
|
||||
]
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.id": "4",
|
||||
"x.com.samsung.da.description": "Sensor for SuperFineDust",
|
||||
"x.com.samsung.da.type": "SuperFineDust",
|
||||
"x.com.samsung.da.value": [
|
||||
"0",
|
||||
"0"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/information/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.modelNum": "ARTIK051_PRAC_20K|10217841|60010532001411004200003000000000",
|
||||
"x.com.samsung.da.description": "ARTIK051_PRAC_20K",
|
||||
"x.com.samsung.da.serialNum": "**REDACTED**",
|
||||
"x.com.samsung.da.otnDUID": "**REDACTED**",
|
||||
"x.com.samsung.da.serialNumOption": "**REDACTED**",
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.id": "0",
|
||||
"x.com.samsung.da.description": "Version",
|
||||
"x.com.samsung.da.type": "Software",
|
||||
"x.com.samsung.da.number": "02181A230313",
|
||||
"x.com.samsung.da.newVersionAvailable": "0"
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.id": "1",
|
||||
"x.com.samsung.da.description": "Version",
|
||||
"x.com.samsung.da.type": "Firmware",
|
||||
"x.com.samsung.da.number": "22112400,FFFFFFFF",
|
||||
"x.com.samsung.da.newVersionAvailable": "0"
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.id": "2",
|
||||
"x.com.samsung.da.description": "Version",
|
||||
"x.com.samsung.da.type": "Outdoor",
|
||||
"x.com.samsung.da.number": "0000,0000"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/file/information/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.timeoffset": "+00:00",
|
||||
"x.com.samsung.supprtedtype": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/configuration/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.region": "0000000000",
|
||||
"x.com.samsung.da.airconOptionList": [
|
||||
"SingleCommand_1",
|
||||
"DR",
|
||||
"HOMECARE_WIZARD_V2",
|
||||
"PRODUCT_GLOBAL",
|
||||
"AI_RAC_GLOBAL_HEATPUMP_3.0",
|
||||
"AI_3.0",
|
||||
"Auto_To_AI",
|
||||
"AI_Heat"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/humidity/0",
|
||||
"rep": {
|
||||
"humidity": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/humidity/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.humidity": "0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/drlc/0",
|
||||
"rep": {
|
||||
"DRLevel": 0,
|
||||
"start": "1970-01-01T00:00:00Z",
|
||||
"duration": 0,
|
||||
"override": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/drlc/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.drlcLevel": "0",
|
||||
"x.com.samsung.da.duration": "00:00:00",
|
||||
"x.com.samsung.da.drlcStartTime": "1970-01-01T00:00:00Z",
|
||||
"x.com.samsung.da.override": "Off",
|
||||
"x.com.samsung.da.realSaving": "Off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/timezone/vs/0",
|
||||
"rep": {}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,595 @@
|
||||
{
|
||||
"meta": {
|
||||
"note": "Samsung Room A/C, model TP1X_DA-AC-RAC-01011 (oneUiVersion '7.0 Air conditioner', Tizen Lite). Scrubbed real /device/0 dump. Adds temperature via /temperatures/vs/0 + display light. Issue #17 (newer model class).",
|
||||
"issue": 17,
|
||||
"model": "TP1X_DA-AC-RAC-01011",
|
||||
"oneUiVersion": "7.0 Air conditioner"
|
||||
},
|
||||
"device0": [
|
||||
{
|
||||
"rt": [
|
||||
"x.com.samsung.devcol",
|
||||
"oic.wk.col"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.ll",
|
||||
"oic.if.b"
|
||||
]
|
||||
},
|
||||
{
|
||||
"href": "/airlevelcheck/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.periodicSensingInterval": "1800",
|
||||
"x.com.samsung.da.sensingState": "NonProcessing",
|
||||
"x.com.samsung.da.lastSensingTime": "1784508715",
|
||||
"x.com.samsung.da.lastSensingLevel": "Kr1",
|
||||
"x.com.samsung.da.periodicSensingSkipStatus": "Off",
|
||||
"x.com.samsung.da.periodicSensingSkipTime": "00000000",
|
||||
"x.com.samsung.da.periodicSensingActivationState": "On",
|
||||
"x.com.samsung.da.autoExeState": "Alarm",
|
||||
"x.com.samsung.da.supportedAutoExeState": [
|
||||
"Airpurify",
|
||||
"Alarm",
|
||||
"Sensing"
|
||||
],
|
||||
"x.com.samsung.da.startSensingOnce": "Off",
|
||||
"x.com.samsung.da.autoExeSetting": "Off",
|
||||
"x.com.samsung.da.supportedAutoExeSetting": [
|
||||
"On",
|
||||
"Off"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/aisleep/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.displayNightMode": "Off",
|
||||
"x.com.samsung.da.elapsedTime": "0",
|
||||
"x.com.samsung.da.requestFeedback": "Off",
|
||||
"x.com.samsung.da.resultFeedback": 0,
|
||||
"x.com.samsung.da.statusFeedback": "Idle",
|
||||
"x.com.samsung.da.sleepTime": "14002200"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/alarms/vs/0",
|
||||
"rep": {
|
||||
"rt": [
|
||||
"x.com.samsung.da.alarms"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.a"
|
||||
],
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.id": "0",
|
||||
"x.com.samsung.da.alarmType": "Device",
|
||||
"x.com.samsung.da.code": "ErrorCode_OFF",
|
||||
"x.com.samsung.da.triggeredTime": "2026-07-22T07:13:19",
|
||||
"x.com.samsung.da.state": "Deleted"
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.id": "1",
|
||||
"x.com.samsung.da.alarmType": "Device",
|
||||
"x.com.samsung.da.code": "FilterAlarm_OFF",
|
||||
"x.com.samsung.da.triggeredTime": "2026-07-22T07:13:19",
|
||||
"x.com.samsung.da.state": "Deleted"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/availablecontrolsets/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.sets": "000000A0012C016102490F0F0000",
|
||||
"x.com.samsung.da.id": "RAC",
|
||||
"x.com.samsung.da.version": "1.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/configuration/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.region": "0000000000",
|
||||
"x.com.samsung.da.airconOptionList": [
|
||||
"PRODUCT_GLOBAL",
|
||||
"AI_GLOBAL_HEATPUMP_4.0",
|
||||
"AI_3.0",
|
||||
"Auto_To_AI",
|
||||
"AI_Heat",
|
||||
"ENERGY_2.0",
|
||||
"HOMECARE_WIZARD_V2",
|
||||
"DR",
|
||||
"SingleCommand_1"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/da/softreset/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.softwarereset": "false"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/drlc/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.start": "1970-01-01T00:00:00Z",
|
||||
"x.com.samsung.da.override": "Off",
|
||||
"x.com.samsung.da.durationminutes": "0",
|
||||
"x.com.samsung.da.drlcLevel": "0",
|
||||
"x.com.samsung.da.realSaving": "Off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/energy/consumption/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.cumulativeDate": "1784704371",
|
||||
"x.com.samsung.da.cumulativeSavedPower": "-1",
|
||||
"x.com.samsung.da.cumulativePower": "75025",
|
||||
"x.com.samsung.da.cumulativeUnit": "Wh",
|
||||
"x.com.samsung.da.instantaneousPowerUnit": "W",
|
||||
"x.com.samsung.da.cumulativePowerType": "total"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/file/information/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.timeoffset": "+02:00",
|
||||
"x.com.samsung.supprtedtype": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/filter/airdustfilter/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.filterUsageResolution": "1",
|
||||
"x.com.samsung.da.filterCapacity": "500",
|
||||
"x.com.samsung.da.filterCapacityUnit": "Hour",
|
||||
"x.com.samsung.da.filterResetType": [
|
||||
"replaceable",
|
||||
"washable"
|
||||
],
|
||||
"x.com.samsung.da.filterStatus": "normal",
|
||||
"x.com.samsung.da.filterUsage": "42",
|
||||
"x.com.samsung.da.filterDesiredUsage": "500",
|
||||
"x.com.samsung.da.supportedFilterDesiredUsage": [
|
||||
"180",
|
||||
"300",
|
||||
"500",
|
||||
"700"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/humidity/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.humidity": "0",
|
||||
"x.com.samsung.da.fivepercentHumidity": "53"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/information/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.otnDUID": "**REDACTED**",
|
||||
"x.com.samsung.da.modelNum": "TP1X_DA-AC-RAC-01011_0000|10275643|60010523001911F58E0049320092E900",
|
||||
"x.com.samsung.da.description": "TP1X_DA-AC-RAC-01011_0000",
|
||||
"x.com.samsung.da.serialNum": "**REDACTED**",
|
||||
"x.com.samsung.da.serialNumOption": "**REDACTED**",
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.id": "0",
|
||||
"x.com.samsung.da.description": "Version",
|
||||
"x.com.samsung.da.type": "Software",
|
||||
"x.com.samsung.da.number": "02762A260401",
|
||||
"x.com.samsung.da.newVersionAvailable": "0"
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.id": "1",
|
||||
"x.com.samsung.da.description": "Version",
|
||||
"x.com.samsung.da.type": "Firmware",
|
||||
"x.com.samsung.da.newVersionAvailable": "0",
|
||||
"x.com.samsung.da.number": "02756C25082500,FFFFFFFFFFFFFF"
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.id": "2",
|
||||
"x.com.samsung.da.description": "Version",
|
||||
"x.com.samsung.da.type": "Firmware",
|
||||
"x.com.samsung.da.newVersionAvailable": "0",
|
||||
"x.com.samsung.da.number": "02669A24092600,02636A10001200"
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.id": "3",
|
||||
"x.com.samsung.da.description": "Version",
|
||||
"x.com.samsung.da.type": "Firmware",
|
||||
"x.com.samsung.da.newVersionAvailable": "0",
|
||||
"x.com.samsung.da.number": "02672A10001000,FFFFFFFFFFFFFF"
|
||||
}
|
||||
],
|
||||
"x.com.samsung.da.diagDumpType": "file",
|
||||
"x.com.samsung.da.diagEndPoint": "SSM",
|
||||
"x.com.samsung.da.diagLogType": [
|
||||
"errCode",
|
||||
"dump"
|
||||
],
|
||||
"x.com.samsung.da.diagMnid": "0AJT",
|
||||
"x.com.samsung.da.diagSetupid": "AR4",
|
||||
"x.com.samsung.da.diagProtocolType": "BLE_OCF",
|
||||
"x.com.samsung.da.diagMinVersion": "3.0",
|
||||
"x.com.samsung.da.diagTsId": "DA01"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/keepnormalstate/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.keepnormal": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/light/vs/0",
|
||||
"rep": {
|
||||
"mode": "On",
|
||||
"supportedModes": [
|
||||
"On",
|
||||
"Off"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/mds/absencemonitoring/vs/0",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/mds/absencestate/vs/0",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/mode/convenient/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.modes": "Off",
|
||||
"x.com.samsung.da.supportedModes": [
|
||||
"Off",
|
||||
"Sleep",
|
||||
"Quiet",
|
||||
"Smart",
|
||||
"Speed",
|
||||
"MotionIndirect",
|
||||
"Nano",
|
||||
"NanoSleep",
|
||||
"DryComfort"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/mode/vs/0",
|
||||
"rep": {
|
||||
"rt": [
|
||||
"x.com.samsung.da.mode"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.a"
|
||||
],
|
||||
"x.com.samsung.da.modes": [
|
||||
"Heat"
|
||||
],
|
||||
"workingMode": "Heat",
|
||||
"x.com.samsung.da.supportedModes": [
|
||||
"Auto",
|
||||
"Cool",
|
||||
"Dry",
|
||||
"Heat",
|
||||
"Wind"
|
||||
],
|
||||
"x.com.samsung.da.options": [
|
||||
"OptionCode_56378",
|
||||
"Sleep_6",
|
||||
"ArtificialWorking_Off",
|
||||
"Off",
|
||||
"OutdoorTemp_68",
|
||||
"OutdoorConnection_Connected"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/option/airpurify/vs/0",
|
||||
"rep": {
|
||||
"rt": [
|
||||
"x.com.samsung.da.option.airpurify"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.a"
|
||||
],
|
||||
"x.com.samsung.da.modes": "Off",
|
||||
"x.com.samsung.da.supportedModes": [
|
||||
"On",
|
||||
"Off"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/option/autoclean/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.status": "Stop",
|
||||
"x.com.samsung.da.supportedStatus": [
|
||||
"Start",
|
||||
"Stop"
|
||||
],
|
||||
"x.com.samsung.da.progress": "0",
|
||||
"x.com.samsung.da.settingStatus": "On",
|
||||
"x.com.samsung.da.supportedSettingStatus": [
|
||||
"On",
|
||||
"Off"
|
||||
],
|
||||
"defaultSettingStatus": "On"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/option/muteonce/vs/0",
|
||||
"rep": {
|
||||
"muteonce": "Off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/personality/presence/vs/0",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/power/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.power": "Off",
|
||||
"operationNumber": "39",
|
||||
"displayCondition": "Enable"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/realtimenotiforclient/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.timeforshortnoti": "0",
|
||||
"x.com.samsung.da.longnotisubscription": "true",
|
||||
"x.com.samsung.da.periodicnotisubscription": "true"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/remotedatacontrol/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.status": "Off",
|
||||
"x.com.samsung.da.connectionStatus": "Disconnected"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/remotetemperature/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.temperature": "",
|
||||
"x.com.samsung.da.unit": "",
|
||||
"x.com.samsung.da.error": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/reserverulesets/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.sets": "3EFFFFFFFF101E121EFFFF101EFFFF000001001F0001001F001F0000009CACFFFF0000",
|
||||
"x.com.samsung.da.id": "RAC",
|
||||
"x.com.samsung.da.version": "1.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/selfcheck/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.start": "Cancel",
|
||||
"x.com.samsung.da.status": "Ready",
|
||||
"x.com.samsung.da.progress": "0",
|
||||
"x.com.samsung.da.result": "NA",
|
||||
"x.com.samsung.da.supportedActions": [
|
||||
"Start",
|
||||
"Cancel"
|
||||
],
|
||||
"x.com.samsung.da.error": [
|
||||
"DA_ERROR_NONE"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/sensors/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.cleanLevel": "1",
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.id": "0",
|
||||
"x.com.samsung.da.description": "Sensor for CleanLevel",
|
||||
"x.com.samsung.da.type": "CleanLevel",
|
||||
"x.com.samsung.da.value": [
|
||||
"1"
|
||||
]
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.id": "2",
|
||||
"x.com.samsung.da.description": "Sensor for Dust",
|
||||
"x.com.samsung.da.type": "Dust",
|
||||
"x.com.samsung.da.value": [
|
||||
"0",
|
||||
"0"
|
||||
]
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.id": "3",
|
||||
"x.com.samsung.da.description": "Sensor for FineDust",
|
||||
"x.com.samsung.da.type": "FineDust",
|
||||
"x.com.samsung.da.value": [
|
||||
"0",
|
||||
"0"
|
||||
]
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.id": "4",
|
||||
"x.com.samsung.da.description": "Sensor for SuperFineDust",
|
||||
"x.com.samsung.da.type": "SuperFineDust",
|
||||
"x.com.samsung.da.value": [
|
||||
"0",
|
||||
"0"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/temperature/control/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.increment": "1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/temperatures/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.id": "0",
|
||||
"x.com.samsung.da.description": "Temperature",
|
||||
"x.com.samsung.da.current": "17.0",
|
||||
"x.com.samsung.da.desired": "25.0",
|
||||
"x.com.samsung.da.minimum": "16",
|
||||
"x.com.samsung.da.maximum": "30",
|
||||
"x.com.samsung.da.increment": "1",
|
||||
"x.com.samsung.da.unit": "Celsius"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/welcome/temperature/vs/0",
|
||||
"rep": {
|
||||
"operatingStatus": "None",
|
||||
"requestId": "0000"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/wind/direction/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.modes": "Fix",
|
||||
"x.com.samsung.da.supportedModes": [
|
||||
"Fix",
|
||||
"Up_And_Low",
|
||||
"Left_And_Right",
|
||||
"All"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/wind/strength/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.modes": "0",
|
||||
"x.com.samsung.da.supportedModes": [
|
||||
"0",
|
||||
"1",
|
||||
"2",
|
||||
"3",
|
||||
"4"
|
||||
],
|
||||
"x.com.samsung.da.modesName": [
|
||||
"Auto",
|
||||
"Low",
|
||||
"Mid",
|
||||
"High",
|
||||
"Turbo"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/otninformation/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.target": "",
|
||||
"x.com.samsung.da.newVersionAvailable": "false",
|
||||
"otnStatus": "None",
|
||||
"flashingProgress": "0",
|
||||
"otnCompleteDate": "noHistory",
|
||||
"scheduledTime": "None",
|
||||
"swVersionInfo": {
|
||||
"platform": "Tizen Lite",
|
||||
"oneUiVersion": "7.0 Air conditioner",
|
||||
"osVersion": "4.0"
|
||||
},
|
||||
"otnList": [
|
||||
{
|
||||
"type": "WIFI",
|
||||
"modelId": "ARA-KR-TP1-25-ARXX00",
|
||||
"versions": [
|
||||
"11260401"
|
||||
],
|
||||
"visVersion": "260401"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/timezone/vs/0",
|
||||
"rep": {
|
||||
"timezoneid": "Africa/Johannesburg",
|
||||
"offset": "+02:00",
|
||||
"DST": "OFF"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/connectionconfig/vs/0",
|
||||
"rep": {
|
||||
"autoReconnectionMinVersion": "1.0",
|
||||
"autoReconnection": "true",
|
||||
"autoReconnectionProtocolType": [
|
||||
"helper_hotspot",
|
||||
"ble_ocf"
|
||||
],
|
||||
"supportedWiFiAuthType": [
|
||||
"OPEN",
|
||||
"WEP",
|
||||
"WPA-PSK",
|
||||
"WPA2-PSK",
|
||||
"SAE"
|
||||
],
|
||||
"supportedWiFiCryptoType": [
|
||||
"TKIP",
|
||||
"AES",
|
||||
"WEP-64",
|
||||
"WEP-128"
|
||||
],
|
||||
"supportedWiFiFreq": [
|
||||
"2.4G"
|
||||
],
|
||||
"calmConnectionCare": {
|
||||
"version": "1.0",
|
||||
"role": [
|
||||
"things"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/wirelessinfo/vs/0",
|
||||
"rep": {
|
||||
"macaddressWiFi": "**REDACTED**",
|
||||
"macaddressBLE": "**REDACTED**",
|
||||
"connectedApSsid": "**REDACTED**"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/quickcontrol/info/vs/0",
|
||||
"rep": {
|
||||
"supportedVersion": "1.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/dginformation/vs/0",
|
||||
"rep": {
|
||||
"enrolmentstatus": "Unknown",
|
||||
"devicestate": "Unknown",
|
||||
"lockstatus": "Normal",
|
||||
"nextduedate": "",
|
||||
"workingminutes": 0,
|
||||
"paymentinfo": {
|
||||
"emiplan": "Unknown",
|
||||
"currency": "Unknown",
|
||||
"totalemi": 0,
|
||||
"totalemipaid": 0
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
+591
@@ -0,0 +1,591 @@
|
||||
{
|
||||
"meta": {
|
||||
"model": "TP1X_DA-AC-RAC-01001_0000",
|
||||
"device_type": "airconditioner",
|
||||
"source": "issue #38 diagnostics (scrubbed)",
|
||||
"note": "Newer TP1X RAC board (oneUiVersion '7.0 Air conditioner') with a fuller resource set than the ARTIK051/TP2X boards: display light, self-check, mute-once, and a circuit-breaker current-limit setting."
|
||||
},
|
||||
"device0": [
|
||||
{
|
||||
"rt": [
|
||||
"x.com.samsung.devcol",
|
||||
"oic.wk.col"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.ll",
|
||||
"oic.if.b"
|
||||
]
|
||||
},
|
||||
{
|
||||
"href": "/aisleep/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.displayNightMode": "Off",
|
||||
"x.com.samsung.da.elapsedTime": "0",
|
||||
"x.com.samsung.da.requestFeedback": "Off",
|
||||
"x.com.samsung.da.resultFeedback": 0,
|
||||
"x.com.samsung.da.statusFeedback": "Idle",
|
||||
"x.com.samsung.da.sleepTime": "14002200"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/alarms/vs/0",
|
||||
"rep": {
|
||||
"rt": [
|
||||
"x.com.samsung.da.alarms"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.a"
|
||||
],
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.id": "0",
|
||||
"x.com.samsung.da.alarmType": "Device",
|
||||
"x.com.samsung.da.code": "ErrorCode_OFF",
|
||||
"x.com.samsung.da.triggeredTime": "2026-07-22T12:58:00",
|
||||
"x.com.samsung.da.state": "Deleted"
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.id": "1",
|
||||
"x.com.samsung.da.alarmType": "Device",
|
||||
"x.com.samsung.da.code": "FilterAlarm_OFF",
|
||||
"x.com.samsung.da.triggeredTime": "2026-07-22T12:58:00",
|
||||
"x.com.samsung.da.state": "Deleted"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/availablecontrolsets/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.sets": "AEB700A0012C01610248040F0000",
|
||||
"x.com.samsung.da.id": "RAC",
|
||||
"x.com.samsung.da.version": "1.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/configuration/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.region": "0000000000",
|
||||
"x.com.samsung.da.airconOptionList": [
|
||||
"PRODUCT_GLOBAL",
|
||||
"AI_GLOBAL_HEATPUMP_4.0",
|
||||
"AI_3.0",
|
||||
"Auto_To_AI",
|
||||
"AI_Heat",
|
||||
"ENERGY_2.0",
|
||||
"HOMECARE_WIZARD_V2",
|
||||
"DR",
|
||||
"SingleCommand_1"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/connectionconfig/vs/0",
|
||||
"rep": {
|
||||
"autoReconnectionMinVersion": "1.0",
|
||||
"autoReconnection": "true",
|
||||
"autoReconnectionProtocolType": [
|
||||
"helper_hotspot",
|
||||
"ble_ocf"
|
||||
],
|
||||
"supportedWiFiAuthType": [
|
||||
"OPEN",
|
||||
"WEP",
|
||||
"WPA-PSK",
|
||||
"WPA2-PSK",
|
||||
"SAE"
|
||||
],
|
||||
"supportedWiFiCryptoType": [
|
||||
"TKIP",
|
||||
"AES",
|
||||
"WEP-64",
|
||||
"WEP-128"
|
||||
],
|
||||
"supportedWiFiFreq": [
|
||||
"2.4G"
|
||||
],
|
||||
"calmConnectionCare": {
|
||||
"version": "1.0",
|
||||
"role": [
|
||||
"things"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/da/softreset/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.softwarereset": "false"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/dginformation/vs/0",
|
||||
"rep": {
|
||||
"enrolmentstatus": "Unknown",
|
||||
"devicestate": "Unknown",
|
||||
"lockstatus": "Unknown",
|
||||
"nextduedate": "",
|
||||
"workingminutes": 0,
|
||||
"paymentinfo": {
|
||||
"emiplan": "Unknown",
|
||||
"currency": "Unknown",
|
||||
"totalemi": 0,
|
||||
"totalemipaid": 0
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/drlc/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.start": "2026-07-22T10:59:14Z",
|
||||
"x.com.samsung.da.override": "Off",
|
||||
"x.com.samsung.da.durationminutes": "1148",
|
||||
"x.com.samsung.da.drlcLevel": "1",
|
||||
"x.com.samsung.da.realSaving": "Off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/electriccurrent/vs/0",
|
||||
"rep": {
|
||||
"operation": "Off",
|
||||
"modes": "3",
|
||||
"supportedModes": [
|
||||
"3",
|
||||
"4",
|
||||
"5",
|
||||
"6",
|
||||
"7",
|
||||
"8",
|
||||
"9"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/energy/consumption/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.cumulativeDate": "1784725078",
|
||||
"x.com.samsung.da.cumulativeSavedPower": "-1",
|
||||
"x.com.samsung.da.cumulativePower": "160922",
|
||||
"x.com.samsung.da.cumulativeUnit": "Wh",
|
||||
"x.com.samsung.da.instantaneousPowerUnit": "W",
|
||||
"x.com.samsung.da.cumulativePowerType": "total"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/file/information/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.timeoffset": "+03:00",
|
||||
"x.com.samsung.supprtedtype": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/filter/airdustfilter/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.filterUsageResolution": "1",
|
||||
"x.com.samsung.da.filterCapacity": "500",
|
||||
"x.com.samsung.da.filterCapacityUnit": "Hour",
|
||||
"x.com.samsung.da.filterResetType": [
|
||||
"replaceable",
|
||||
"washable"
|
||||
],
|
||||
"x.com.samsung.da.filterStatus": "normal",
|
||||
"x.com.samsung.da.filterUsage": "30",
|
||||
"x.com.samsung.da.filterDesiredUsage": "500",
|
||||
"x.com.samsung.da.supportedFilterDesiredUsage": [
|
||||
"180",
|
||||
"300",
|
||||
"500",
|
||||
"700"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/information/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.otnDUID": "**REDACTED**",
|
||||
"x.com.samsung.da.modelNum": "TP1X_DA-AC-RAC-01001_0000|10267841|60010532001911000E0041220090A000",
|
||||
"x.com.samsung.da.description": "TP1X_DA-AC-RAC-01001_0000",
|
||||
"x.com.samsung.da.serialNum": "**REDACTED**",
|
||||
"x.com.samsung.da.serialNumOption": "**REDACTED**",
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.id": "0",
|
||||
"x.com.samsung.da.description": "Version",
|
||||
"x.com.samsung.da.type": "Software",
|
||||
"x.com.samsung.da.number": "02762A260401",
|
||||
"x.com.samsung.da.newVersionAvailable": "0"
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.id": "1",
|
||||
"x.com.samsung.da.description": "Version",
|
||||
"x.com.samsung.da.type": "Firmware",
|
||||
"x.com.samsung.da.newVersionAvailable": "0",
|
||||
"x.com.samsung.da.number": "02678A24092500,FFFFFFFFFFFFFF"
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.id": "2",
|
||||
"x.com.samsung.da.description": "Version",
|
||||
"x.com.samsung.da.type": "Firmware",
|
||||
"x.com.samsung.da.newVersionAvailable": "0",
|
||||
"x.com.samsung.da.number": "02669A24092600,02636A10001200"
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.id": "3",
|
||||
"x.com.samsung.da.description": "Version",
|
||||
"x.com.samsung.da.type": "Firmware",
|
||||
"x.com.samsung.da.newVersionAvailable": "0",
|
||||
"x.com.samsung.da.number": "02672A10001000,FFFFFFFFFFFFFF"
|
||||
}
|
||||
],
|
||||
"x.com.samsung.da.diagDumpType": "file",
|
||||
"x.com.samsung.da.diagEndPoint": "SSM",
|
||||
"x.com.samsung.da.diagLogType": [
|
||||
"errCode",
|
||||
"dump"
|
||||
],
|
||||
"x.com.samsung.da.diagMnid": "0AJT",
|
||||
"x.com.samsung.da.diagSetupid": "AR2",
|
||||
"x.com.samsung.da.diagProtocolType": "BLE_OCF",
|
||||
"x.com.samsung.da.diagMinVersion": "3.0",
|
||||
"x.com.samsung.da.diagTsId": "DA01"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/keepnormalstate/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.keepnormal": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/light/vs/0",
|
||||
"rep": {
|
||||
"mode": "On",
|
||||
"supportedModes": [
|
||||
"On",
|
||||
"Off"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/mds/absencemonitoring/vs/0",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/mds/absencestate/vs/0",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/mode/convenient/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.modes": "Off",
|
||||
"x.com.samsung.da.supportedModes": [
|
||||
"Off",
|
||||
"Sleep",
|
||||
"Quiet",
|
||||
"Smart",
|
||||
"Speed"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/mode/vs/0",
|
||||
"rep": {
|
||||
"rt": [
|
||||
"x.com.samsung.da.mode"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.a"
|
||||
],
|
||||
"x.com.samsung.da.modes": [
|
||||
"Cool"
|
||||
],
|
||||
"workingMode": "Cool",
|
||||
"x.com.samsung.da.supportedModes": [
|
||||
"Auto",
|
||||
"Cool",
|
||||
"Dry",
|
||||
"Fan",
|
||||
"Heat"
|
||||
],
|
||||
"x.com.samsung.da.options": [
|
||||
"OptionCode_54328",
|
||||
"Sleep_0",
|
||||
"ArtificialWorking_Off",
|
||||
"ComfortAICooling_Off",
|
||||
"AiTempChanged_Off",
|
||||
"AiTemp_240",
|
||||
"Volume_100",
|
||||
"StopAutoClean_Idle",
|
||||
"DiagnosisAI_Off",
|
||||
"ProgressDiagnosisAI_0",
|
||||
"ResultDiagnosisAI_Normal",
|
||||
"ExtendOptionCode_230024",
|
||||
"UpdateAllow_NotAllowed",
|
||||
"DurationOn_120",
|
||||
"OutdoorTemp_92",
|
||||
"CoolCapa_50",
|
||||
"WarmCapa_60",
|
||||
"Autoclean_Off",
|
||||
"WelcomeCoolingState_Off",
|
||||
"WelcomeHeatingState_Off",
|
||||
"OutdoorConnection_Connected"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/option/airpurify/vs/0",
|
||||
"rep": {
|
||||
"rt": [
|
||||
"x.com.samsung.da.option.airpurify"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.a"
|
||||
],
|
||||
"x.com.samsung.da.modes": "Off",
|
||||
"x.com.samsung.da.supportedModes": [
|
||||
"On",
|
||||
"Off"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/option/autoclean/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.status": "Stop",
|
||||
"x.com.samsung.da.supportedStatus": [
|
||||
"Start",
|
||||
"Stop"
|
||||
],
|
||||
"x.com.samsung.da.progress": "0",
|
||||
"x.com.samsung.da.settingStatus": "Off",
|
||||
"x.com.samsung.da.supportedSettingStatus": [
|
||||
"On",
|
||||
"Off"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/option/muteonce/vs/0",
|
||||
"rep": {
|
||||
"muteonce": "Off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/otninformation/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.target": "",
|
||||
"x.com.samsung.da.newVersionAvailable": "false",
|
||||
"otnStatus": "None",
|
||||
"flashingProgress": "0",
|
||||
"otnCompleteDate": "noHistory",
|
||||
"scheduledTime": "None",
|
||||
"swVersionInfo": {
|
||||
"platform": "Tizen Lite",
|
||||
"oneUiVersion": "7.0 Air conditioner",
|
||||
"osVersion": "4.0"
|
||||
},
|
||||
"otnList": [
|
||||
{
|
||||
"type": "WIFI",
|
||||
"modelId": "ARA-KR-TP1-25-ARXX00",
|
||||
"versions": [
|
||||
"11260401"
|
||||
],
|
||||
"visVersion": "260401"
|
||||
},
|
||||
{
|
||||
"type": "Micom",
|
||||
"modelId": "845210267841FFFFFFFF",
|
||||
"versions": [
|
||||
"24092500",
|
||||
"FFFFFFFF"
|
||||
],
|
||||
"visVersion": "240925"
|
||||
},
|
||||
{
|
||||
"type": "Micom",
|
||||
"modelId": "045210267841FFFFFFFF",
|
||||
"versions": [
|
||||
"24092500",
|
||||
"FFFFFFFF"
|
||||
],
|
||||
"visVersion": "240925"
|
||||
},
|
||||
{
|
||||
"type": "Micom",
|
||||
"modelId": "04521026694110263641",
|
||||
"versions": [
|
||||
"24092600",
|
||||
"10001200"
|
||||
],
|
||||
"visVersion": "240926"
|
||||
},
|
||||
{
|
||||
"type": "Micom",
|
||||
"modelId": "FGCAN3",
|
||||
"versions": [
|
||||
"",
|
||||
""
|
||||
],
|
||||
"visVersion": "999999"
|
||||
},
|
||||
{
|
||||
"type": "Micom",
|
||||
"modelId": "845210266941FFFFFFFF",
|
||||
"versions": [
|
||||
"24092600",
|
||||
"FFFFFFFF"
|
||||
],
|
||||
"visVersion": "240926"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/personality/presence/vs/0",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/power/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.power": "On",
|
||||
"operationNumber": "9"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/quickcontrol/info/vs/0",
|
||||
"rep": {
|
||||
"supportedVersion": "1.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/realtimenotiforclient/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.timeforshortnoti": "0",
|
||||
"x.com.samsung.da.longnotisubscription": "false",
|
||||
"x.com.samsung.da.periodicnotisubscription": "true"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/remotedatacontrol/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.status": "Off",
|
||||
"x.com.samsung.da.connectionStatus": "Disconnected"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/remotetemperature/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.temperature": "",
|
||||
"x.com.samsung.da.unit": "",
|
||||
"x.com.samsung.da.error": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/reserverulesets/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.sets": "AEFFFFFFFF101E121EFFFF101EFFFF000001001F00010000001F0000000000FFFF1E00",
|
||||
"x.com.samsung.da.id": "RAC",
|
||||
"x.com.samsung.da.version": "1.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/selfcheck/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.start": "Cancel",
|
||||
"x.com.samsung.da.status": "Ready",
|
||||
"x.com.samsung.da.progress": "0",
|
||||
"x.com.samsung.da.result": "NA",
|
||||
"x.com.samsung.da.supportedActions": [
|
||||
"Start",
|
||||
"Cancel"
|
||||
],
|
||||
"x.com.samsung.da.error": [
|
||||
"DA_ERROR_NONE"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/temperature/control/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.increment": "1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/temperatures/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.id": "0",
|
||||
"x.com.samsung.da.description": "Temperature",
|
||||
"x.com.samsung.da.current": "26.0",
|
||||
"x.com.samsung.da.desired": "22.0",
|
||||
"x.com.samsung.da.minimum": "16",
|
||||
"x.com.samsung.da.maximum": "30",
|
||||
"x.com.samsung.da.increment": "1",
|
||||
"x.com.samsung.da.unit": "Celsius"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/timezone/vs/0",
|
||||
"rep": {
|
||||
"timezoneid": "Asia/Amman",
|
||||
"offset": "+03:00",
|
||||
"DST": "OFF"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/welcome/temperature/vs/0",
|
||||
"rep": {
|
||||
"operatingStatus": "None",
|
||||
"requestId": "0000"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/wind/direction/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.modes": "All",
|
||||
"x.com.samsung.da.supportedModes": [
|
||||
"Fix",
|
||||
"Up_And_Low",
|
||||
"Left_And_Right",
|
||||
"All"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/wind/strength/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.modes": "1",
|
||||
"x.com.samsung.da.supportedModes": [
|
||||
"0",
|
||||
"1",
|
||||
"2",
|
||||
"3",
|
||||
"4"
|
||||
],
|
||||
"x.com.samsung.da.modesName": [
|
||||
"Auto",
|
||||
"Low",
|
||||
"Mid",
|
||||
"High",
|
||||
"Turbo"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/wirelessinfo/vs/0",
|
||||
"rep": {
|
||||
"macaddressWiFi": "**REDACTED**",
|
||||
"macaddressBLE": "**REDACTED**",
|
||||
"connectedApSsid": "**REDACTED**"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,380 @@
|
||||
{
|
||||
"meta": {
|
||||
"model": "TP2X_RAC_20K",
|
||||
"device_type": "airconditioner",
|
||||
"source": "issue #37 diagnostics (scrubbed)",
|
||||
"note": "Older/simpler RAC board reporting no oneUiVersion -- resolved via the '_RAC_' modelNum token fallback in for_device_by_model (unlike issue #38's TP1X board, which self-reports oneUiVersion)."
|
||||
},
|
||||
"device0": [
|
||||
{
|
||||
"rt": [
|
||||
"x.com.samsung.devcol",
|
||||
"oic.wk.col"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.ll",
|
||||
"oic.if.b"
|
||||
]
|
||||
},
|
||||
{
|
||||
"href": "/alarms/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.id": "0",
|
||||
"x.com.samsung.da.description": "Alarm",
|
||||
"x.com.samsung.da.alarmType": "Device",
|
||||
"x.com.samsung.da.code": "ErrorCode_OFF",
|
||||
"x.com.samsung.da.triggeredTime": "2026-07-22T12:42:46",
|
||||
"x.com.samsung.da.state": "Deleted"
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.id": "1",
|
||||
"x.com.samsung.da.description": "Alarm",
|
||||
"x.com.samsung.da.alarmType": "Device",
|
||||
"x.com.samsung.da.code": "FilterAlarm_OFF",
|
||||
"x.com.samsung.da.triggeredTime": "2026-07-22T12:42:46",
|
||||
"x.com.samsung.da.state": "Deleted"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/availablecontrolsets/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.sets": "000000A0012C01610249040F0000",
|
||||
"x.com.samsung.da.id": "RAC",
|
||||
"x.com.samsung.da.version": "1.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/configuration/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.region": "0000000000",
|
||||
"x.com.samsung.da.airconOptionList": [
|
||||
"SingleCommand_1",
|
||||
"HOMECARE_WIZARD_V2",
|
||||
"PRODUCT_GLOBAL",
|
||||
"AI_RAC_GLOBAL_HEATPUMP_3.0",
|
||||
"AI_3.0",
|
||||
"Auto_To_AI",
|
||||
"AI_Heat"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/energy/consumption/0",
|
||||
"rep": {
|
||||
"power": 0.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/energy/consumption/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.instantaneousPower": "0.000000",
|
||||
"x.com.samsung.da.cumulativePower": "717060",
|
||||
"x.com.samsung.da.cumulativeUnit": "Wh",
|
||||
"x.com.samsung.da.cumulativePowerType": "total",
|
||||
"x.com.samsung.da.instantaneousPowerUnit": "W"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/file/information/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.timeoffset": "+03:00",
|
||||
"x.com.samsung.supprtedtype": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/filter/airdustfilter/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.filterUsage": "39",
|
||||
"x.com.samsung.da.filterUsageResolution": "1",
|
||||
"x.com.samsung.da.filterDesiredUsage": "500",
|
||||
"x.com.samsung.da.filterStatus": "normal",
|
||||
"x.com.samsung.da.filterCapacity": "500",
|
||||
"x.com.samsung.da.filterCapacityUnit": "Hour",
|
||||
"x.com.samsung.da.filterResetType": [
|
||||
"replaceable",
|
||||
"washable"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/humidity/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.humidity": "0",
|
||||
"x.com.samsung.da.fivepercentHumidity": "43"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/information/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.modelNum": "TP2X_RAC_20K|10229641|60010523001511014600003200800000",
|
||||
"x.com.samsung.da.description": "TP2X_RAC_20K",
|
||||
"x.com.samsung.da.serialNum": "**REDACTED**",
|
||||
"x.com.samsung.da.otnDUID": "**REDACTED**",
|
||||
"x.com.samsung.da.diagProtocolType": "WIFI_HTTPS",
|
||||
"x.com.samsung.da.diagLogType": [
|
||||
"errCode",
|
||||
"dump"
|
||||
],
|
||||
"x.com.samsung.da.diagDumpType": "file",
|
||||
"x.com.samsung.da.diagEndPoint": "SSM",
|
||||
"x.com.samsung.da.diagMnid": "0AJT",
|
||||
"x.com.samsung.da.diagSetupid": "010",
|
||||
"x.com.samsung.da.diagMinVersion": "1.0",
|
||||
"x.com.samsung.da.serialNumOption": "**REDACTED**",
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.id": "0",
|
||||
"x.com.samsung.da.description": "Version",
|
||||
"x.com.samsung.da.type": "Software",
|
||||
"x.com.samsung.da.number": "02273A260324",
|
||||
"x.com.samsung.da.newVersionAvailable": "0"
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.id": "1",
|
||||
"x.com.samsung.da.description": "Version",
|
||||
"x.com.samsung.da.type": "Firmware",
|
||||
"x.com.samsung.da.number": "102296A23012700",
|
||||
"x.com.samsung.da.newVersionAvailable": "0"
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.id": "2",
|
||||
"x.com.samsung.da.description": "Version",
|
||||
"x.com.samsung.da.type": "Firmware",
|
||||
"x.com.samsung.da.newVersionAvailable": "0"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/keepnormalstate/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.keepnormal": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/mode/convenient/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.modes": "Off",
|
||||
"x.com.samsung.da.supportedModes": [
|
||||
"Off",
|
||||
"Sleep",
|
||||
"Quiet",
|
||||
"Smart",
|
||||
"Speed",
|
||||
"MotionIndirect",
|
||||
"MotionDirect",
|
||||
"Nano",
|
||||
"NanoSleep"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/mode/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.supportedModes": [
|
||||
"Auto",
|
||||
"Cool",
|
||||
"Dry",
|
||||
"Wind",
|
||||
"Heat",
|
||||
"HOMECARE_WIZARD_V2"
|
||||
],
|
||||
"x.com.samsung.da.modes": [
|
||||
"Cool"
|
||||
],
|
||||
"x.com.samsung.da.options": [
|
||||
"Sleep_16",
|
||||
"ArtificialWorking_Off",
|
||||
"ComfortAICooling_Off",
|
||||
"MotionState_Normal",
|
||||
"AiTempChanged_Off",
|
||||
"AiTemp_240",
|
||||
"OutdoorTemp_85",
|
||||
"CoolCapa_35",
|
||||
"WarmCapa_35",
|
||||
"Light_Off",
|
||||
"Volume_100",
|
||||
"StopAutoClean_Idle",
|
||||
"Autoclean_Off",
|
||||
"DiagnosisAI_Off",
|
||||
"ProgressDiagnosisAI_1",
|
||||
"ResultDiagnosisAI_Normal",
|
||||
"MotionMode_PowerSave",
|
||||
"OptionCode_56376",
|
||||
"ExtendOptionCode_230157",
|
||||
"RacInfo_None",
|
||||
"UpdateAllow_NotAllowed",
|
||||
"DurationOn_50",
|
||||
"WelcomeCoolingState_Off"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/option/autoclean/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.status": "Stop",
|
||||
"x.com.samsung.da.settingStatus": "Off",
|
||||
"x.com.samsung.da.progress": "0",
|
||||
"x.com.samsung.da.supportedStatus": [
|
||||
"Start",
|
||||
"Stop"
|
||||
],
|
||||
"x.com.samsung.da.supportedSettingStatus": [
|
||||
"On",
|
||||
"Off"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/option/muteonce/vs/0",
|
||||
"rep": {
|
||||
"muteonce": "Off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/otninformation/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.target": "",
|
||||
"x.com.samsung.da.newVersionAvailable": "false",
|
||||
"x.com.samsung.da.newVersionNo": "00000000",
|
||||
"x.com.samsung.da.currentVersionInfo": "00000000",
|
||||
"otnStatus": "None",
|
||||
"flashingProgress": "",
|
||||
"otnTarget": "main",
|
||||
"otnCompleteDate": "noHistory",
|
||||
"otnList": [
|
||||
{
|
||||
"type": "WIFI",
|
||||
"modelId": "TP2X_RAC_20K",
|
||||
"versions": [
|
||||
"11260324"
|
||||
],
|
||||
"visVersion": "260324"
|
||||
},
|
||||
{
|
||||
"type": "Micom",
|
||||
"modelId": "045210229641FFFFFFFF",
|
||||
"versions": [
|
||||
"23012700",
|
||||
"FFFFFFFF"
|
||||
],
|
||||
"visVersion": "230127"
|
||||
},
|
||||
{
|
||||
"type": "Micom",
|
||||
"modelId": "04521022954110229941",
|
||||
"versions": [
|
||||
"21092700",
|
||||
"10000300"
|
||||
],
|
||||
"visVersion": "210927"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/personality/presence/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.id": "",
|
||||
"x.com.samsung.da.deviceId": "**REDACTED**",
|
||||
"x.com.samsung.da.value": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/power/0",
|
||||
"rep": {
|
||||
"value": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/power/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.power": "Off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/realtimenotiforclient/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.timeforshortnoti": "0",
|
||||
"x.com.samsung.da.longnotisubscription": "false",
|
||||
"x.com.samsung.da.periodicnotisubscription": "true"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/sensors/vs/0",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/temperature/control/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.increment": "1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/temperatures/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.id": "0",
|
||||
"x.com.samsung.da.description": "Temperature",
|
||||
"x.com.samsung.da.desired": "22.0",
|
||||
"x.com.samsung.da.current": "26.0",
|
||||
"x.com.samsung.da.maximum": "30",
|
||||
"x.com.samsung.da.minimum": "16",
|
||||
"x.com.samsung.da.increment": "1.0",
|
||||
"x.com.samsung.da.unit": "Celsius"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/timezone/vs/0",
|
||||
"rep": {
|
||||
"timezoneid": "Asia/Amman",
|
||||
"offset": "+03:00",
|
||||
"DST": "OFF"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/wind/direction/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.modes": "Left_And_Right",
|
||||
"x.com.samsung.da.supportedModes": [
|
||||
"Fix",
|
||||
"All",
|
||||
"Up_And_Low",
|
||||
"Left_And_Right"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/wind/strength/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.modes": "2",
|
||||
"x.com.samsung.da.supportedModes": [
|
||||
"0",
|
||||
"1",
|
||||
"2",
|
||||
"3",
|
||||
"4"
|
||||
],
|
||||
"x.com.samsung.da.modesName": [
|
||||
"Auto",
|
||||
"Low",
|
||||
"Mid",
|
||||
"High",
|
||||
"Turbo"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
+423
@@ -0,0 +1,423 @@
|
||||
{
|
||||
"meta": {
|
||||
"model": "DV90BB5245AES1",
|
||||
"device_type": "dryer",
|
||||
"source": "issue #14 diagnostics (scrubbed)",
|
||||
"note": "First full dryer /device/0 dump. DA_WM_TP1_21_COMMON board."
|
||||
},
|
||||
"device0": [
|
||||
{
|
||||
"rt": [
|
||||
"x.com.samsung.devcol",
|
||||
"oic.wk.col"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.ll",
|
||||
"oic.if.b"
|
||||
]
|
||||
},
|
||||
{
|
||||
"href": "/alarms/vs/0",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/buzzersound/vs/0",
|
||||
"rep": {
|
||||
"supportedBuzzerSound": [
|
||||
"Off",
|
||||
"On"
|
||||
],
|
||||
"setBuzzerSound": "Off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/configuration/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.region": "0000000000",
|
||||
"x.com.samsung.da.countryCode": "GB"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/connectionconfig/vs/0",
|
||||
"rep": {
|
||||
"autoReconnectionMinVersion": "1.0",
|
||||
"autoReconnection": "true",
|
||||
"autoReconnectionProtocolType": [
|
||||
"helper_hotspot",
|
||||
"ble_ocf"
|
||||
],
|
||||
"supportedWiFiAuthType": [
|
||||
"OPEN",
|
||||
"WEP",
|
||||
"WPA-PSK",
|
||||
"WPA2-PSK",
|
||||
"SAE"
|
||||
],
|
||||
"supportedWiFiCryptoType": [
|
||||
"TKIP",
|
||||
"AES",
|
||||
"WEP-64",
|
||||
"WEP-128"
|
||||
],
|
||||
"supportedWiFiFreq": [
|
||||
"2.4G"
|
||||
],
|
||||
"calmConnectionCare": {
|
||||
"version": "1.0",
|
||||
"role": [
|
||||
"things"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/course/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.supportedModes": [
|
||||
"HOMECARE_WIZARD_V2"
|
||||
],
|
||||
"x.com.samsung.da.options": [
|
||||
"DeviceType_0165",
|
||||
"UpdateAllow_NotAllowed",
|
||||
"Course_16",
|
||||
"AiOption_On",
|
||||
"MixedLoadBell_Disable",
|
||||
"MixedLoadBellNoti_Nothing",
|
||||
"LaundryOutTime_0",
|
||||
"SeamlessControl_Disable",
|
||||
"KidsLockBypass_On",
|
||||
"DetergentOnce_1",
|
||||
"DetergentLeft_0",
|
||||
"DetergentBase_5",
|
||||
"DetergentAlarm_Off",
|
||||
"DetergentType_3",
|
||||
"DetergentTotal_0",
|
||||
"SpecialFunction_20",
|
||||
"AvailableDelayTime_182",
|
||||
"LaundryPlannerUserSetTime_0",
|
||||
"ProgressTimeSet_B2198CB4003C",
|
||||
"WrinklePreventRunning_Off",
|
||||
"SendToDevice_Off",
|
||||
"GMT_02",
|
||||
"DrumLight_Off",
|
||||
"DryTimeSet_FF000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000070007FE020007FE020007FE00000000",
|
||||
"EnergyLevelSet_0505030503020404030502030304020102",
|
||||
"MostUsed_27D000",
|
||||
"WrinklePreventSet_0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F",
|
||||
"MixedLoadBellSet_02FF0202FFFFFFFFFFFFFFFFFFFFFFFF",
|
||||
"EmptyingTheBucket_Nothing",
|
||||
"GeoFenceAlarm",
|
||||
"UsagesDB_ok",
|
||||
"EnergyKW_396",
|
||||
"DrumCleanLog_Empty",
|
||||
"TimeSync_NotSupported"
|
||||
],
|
||||
"x.com.samsung.da.supportedOptions": [
|
||||
"116D20E23D0001FD20E18D20E19D2041DD2041BD2041CD20E21D3081AD1021ED20420D10227D00025D00024D0004CD10E"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/cycleinterface/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.cycleInterfaceEnabled": "Off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/diagnosis/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.diagnosisStart": "Ready"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/energy/consumption/0",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/energy/consumption/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.instantaneousPower": "-500",
|
||||
"x.com.samsung.da.instantaneousPowerUnit": "W",
|
||||
"x.com.samsung.da.cumulativePower": "677300",
|
||||
"x.com.samsung.da.cumulativeUnit": "Wh",
|
||||
"x.com.samsung.da.cumulativeDate": "1784469600",
|
||||
"x.com.samsung.da.cumulativeDateUTC": "1784466000"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/file/information/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.timeoffset": "+01:00"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/information/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.modelNum": "DA_WM_TP1_21_COMMON|20314441|30010102001611000AA3031700000000",
|
||||
"x.com.samsung.da.description": "DA_WM_TP1_21_COMMON_DV5000B/DC92-03266A_0004",
|
||||
"x.com.samsung.da.serialNum": "0AJ8XREDACTED000",
|
||||
"x.com.samsung.da.otnDUID": "REDACTEDDUID0000000000000000000000000000",
|
||||
"x.com.samsung.da.diagProtocolType": "BLE_OCF",
|
||||
"x.com.samsung.da.diagLogType": [
|
||||
"errCode",
|
||||
"dump"
|
||||
],
|
||||
"x.com.samsung.da.diagDumpType": "file",
|
||||
"x.com.samsung.da.diagEndPoint": "SSM",
|
||||
"x.com.samsung.da.diagMnid": "0AJT",
|
||||
"x.com.samsung.da.diagSetupid": "DR1",
|
||||
"x.com.samsung.da.diagMinVersion": "3.0",
|
||||
"x.com.samsung.da.diagTsId": "DA01",
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.id": "0",
|
||||
"x.com.samsung.da.description": "DA_WM_TP1_21_COMMON|20314441|30010102001611000AA3031700000000",
|
||||
"x.com.samsung.da.type": "Software",
|
||||
"x.com.samsung.da.number": "02986A260118(A182)",
|
||||
"x.com.samsung.da.newVersionAvailable": "0"
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.id": "1",
|
||||
"x.com.samsung.da.description": "Firmware_1_DB_20314441220516500FFFFF203266412207065004FFFF(01652031444120326641_30000000)(FileDown:0)(Type:0)",
|
||||
"x.com.samsung.da.type": "Firmware",
|
||||
"x.com.samsung.da.number": "03144A22051650,03266A22070650",
|
||||
"x.com.samsung.da.newVersionAvailable": "0"
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.id": "2",
|
||||
"x.com.samsung.da.description": "Firmware_2_DB_2032244122050903042FFFFFFFFFFFFFFFFFFFFFFFFE(016520322441FFFFFFFF_30000000)(FileDown:0)(Type:0)",
|
||||
"x.com.samsung.da.type": "Firmware",
|
||||
"x.com.samsung.da.number": "03224A22050903,FFFFFFFFFFFFFF"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/kidslock/0",
|
||||
"rep": {
|
||||
"value": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/kidslock/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.kidsLock": "Ready"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/operational/state/0",
|
||||
"rep": {
|
||||
"currentMachineState": "Ready",
|
||||
"machineStates": [
|
||||
"None",
|
||||
"Ready",
|
||||
"Running",
|
||||
"Paused",
|
||||
"Finish"
|
||||
],
|
||||
"jobStates": [
|
||||
"None",
|
||||
"Drying",
|
||||
"Cooling",
|
||||
"Finish"
|
||||
],
|
||||
"currentJobState": "None",
|
||||
"remainingTime": "03:02:00",
|
||||
"progressPercentage": "1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/operational/state/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.state": "Ready",
|
||||
"x.com.samsung.da.remainingTime": "03:02:00",
|
||||
"x.com.samsung.da.progressPercentage": "1",
|
||||
"x.com.samsung.da.progress": "None",
|
||||
"x.com.samsung.da.delayEndTime": "00:00:00",
|
||||
"x.com.samsung.da.supportedProgress": [
|
||||
"None",
|
||||
"Drying",
|
||||
"Cooling",
|
||||
"Finish"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/otninformation/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.target": "",
|
||||
"x.com.samsung.da.newVersionAvailable": "false",
|
||||
"x.com.samsung.da.newVersionNo": "00000000",
|
||||
"x.com.samsung.da.currentVersionInfo": "00000000",
|
||||
"otnStatus": "None",
|
||||
"flashingProgress": "",
|
||||
"otnTarget": "main",
|
||||
"otnCompleteDate": "2026-03-04",
|
||||
"otnList": [
|
||||
{
|
||||
"type": "WIFI",
|
||||
"modelId": "DA_WM_TP1_21_COMMON",
|
||||
"versions": [
|
||||
"30260118"
|
||||
],
|
||||
"visVersion": "260118"
|
||||
},
|
||||
{
|
||||
"type": "Micom",
|
||||
"modelId": "01652031444120326641",
|
||||
"versions": [
|
||||
"22051650",
|
||||
"22070650"
|
||||
],
|
||||
"visVersion": "220706"
|
||||
},
|
||||
{
|
||||
"type": "Micom",
|
||||
"modelId": "016520322441FFFFFFFF",
|
||||
"versions": [
|
||||
"22050903",
|
||||
"FFFFFFFF"
|
||||
],
|
||||
"visVersion": "220509"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/power/0",
|
||||
"rep": {
|
||||
"value": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/power/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.power": "Off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/quickcontrol/info/vs/0",
|
||||
"rep": {
|
||||
"supportedVersion": "1.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/realtimenotiforclient/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.timeforshortnoti": "0",
|
||||
"x.com.samsung.da.periodicnotisubscription": "true"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/remotectrl/0",
|
||||
"rep": {
|
||||
"value": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/remotectrl/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.remoteControlEnabled": "false"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/setting/vs/0",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/st/dryercourse/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.st.dryerMode": "Table_03_Course_16",
|
||||
"x.com.samsung.da.st.courseTable": "Table_03"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/timezone/vs/0",
|
||||
"rep": {
|
||||
"timezoneid": "Europe/London",
|
||||
"offset": "+01:00",
|
||||
"DST": "ON"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/washer/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.wrinklePrevent": "Off",
|
||||
"x.com.samsung.da.dryLevel": "Normal",
|
||||
"x.com.samsung.da.supportedDryLevel": [
|
||||
"None",
|
||||
"Less",
|
||||
"Normal",
|
||||
"More"
|
||||
],
|
||||
"x.com.samsung.da.dryTime": "00:00:00",
|
||||
"x.com.samsung.da.supportedDryTime": [
|
||||
"00:00:00",
|
||||
"00:20:00",
|
||||
"00:30:00",
|
||||
"00:40:00",
|
||||
"00:50:00",
|
||||
"01:00:00",
|
||||
"01:30:00",
|
||||
"02:00:00",
|
||||
"02:30:00",
|
||||
"03:00:00",
|
||||
"04:00:00"
|
||||
],
|
||||
"x.com.samsung.da.dryerType": "Electricity"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/wirelessinfo/vs/0",
|
||||
"rep": {
|
||||
"macaddressWiFi": "00:00:00:00:00:00",
|
||||
"macaddressBLE": "00:00:00:00:00:00"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/wm/editcourse/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.editCourseList": "EditCourseList_16271D231F18191B1C211A1E2025244C",
|
||||
"x.com.samsung.da.fixedCourseList": "FixedCourseList_161820"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/wm/jobbeginingstatus/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.currentStatus": "None"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/wm/personalcourse/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.courses": [
|
||||
"F1_00",
|
||||
"F2_00",
|
||||
"F3_00",
|
||||
"F4_00",
|
||||
"F5_00",
|
||||
"F6_00",
|
||||
"F7_00",
|
||||
"F8_00",
|
||||
"F9_00",
|
||||
"FA_00"
|
||||
],
|
||||
"x.com.samsung.da.maxCourseNum": "10"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/wm/setinfo/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.isModelSettingWithoutSC": "true",
|
||||
"x.com.samsung.da.aiCourse": "false",
|
||||
"x.com.samsung.da.isModelSettingPowerOnOff": "false",
|
||||
"x.com.samsung.da.modelCode": "M(None),W(DV90BB5245AES1)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/wm/welcomemsg/vs/0",
|
||||
"rep": {}
|
||||
}
|
||||
]
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"state_keys": [
|
||||
"air_filter_status",
|
||||
"air_filter_usage",
|
||||
"air_purify",
|
||||
"alarm_code",
|
||||
"auto_clean",
|
||||
"climate",
|
||||
"diagnosis_status",
|
||||
"energy_kwh",
|
||||
"energy_saved_kwh",
|
||||
"power_watts"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"state_keys": [
|
||||
"air_filter_status",
|
||||
"air_filter_usage",
|
||||
"air_purify",
|
||||
"alarm_code",
|
||||
"auto_clean",
|
||||
"climate",
|
||||
"display_light",
|
||||
"energy_kwh",
|
||||
"energy_saved_kwh",
|
||||
"firmware_update",
|
||||
"mute_once",
|
||||
"selfcheck_error",
|
||||
"selfcheck_result",
|
||||
"selfcheck_status"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"state_keys": [
|
||||
"air_filter_status",
|
||||
"air_filter_usage",
|
||||
"air_purify",
|
||||
"alarm_code",
|
||||
"auto_clean",
|
||||
"climate",
|
||||
"current_limit_enabled",
|
||||
"current_limit_level",
|
||||
"display_light",
|
||||
"energy_kwh",
|
||||
"energy_saved_kwh",
|
||||
"firmware_update",
|
||||
"mute_once",
|
||||
"selfcheck_error",
|
||||
"selfcheck_result",
|
||||
"selfcheck_status"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"state_keys": [
|
||||
"air_filter_status",
|
||||
"air_filter_usage",
|
||||
"alarm_code",
|
||||
"auto_clean",
|
||||
"climate",
|
||||
"energy_kwh",
|
||||
"firmware_update",
|
||||
"mute_once",
|
||||
"power_watts"
|
||||
]
|
||||
}
|
||||
+2
-2
@@ -8,6 +8,7 @@
|
||||
"delay_start_hours",
|
||||
"diagnosis_status",
|
||||
"energy_kwh",
|
||||
"energy_saved_kwh",
|
||||
"finish_time",
|
||||
"firmware_update",
|
||||
"heated_dry",
|
||||
@@ -20,7 +21,6 @@
|
||||
"machine_state",
|
||||
"operation_origin",
|
||||
"power_switch",
|
||||
"power_watts",
|
||||
"progress",
|
||||
"progress_percentage",
|
||||
"remote_control",
|
||||
@@ -40,6 +40,7 @@
|
||||
"samsung_dishwasher_diagnosis_start",
|
||||
"samsung_dishwasher_diagnosis_status",
|
||||
"samsung_dishwasher_energy_kwh",
|
||||
"samsung_dishwasher_energy_saved_kwh",
|
||||
"samsung_dishwasher_finish_time",
|
||||
"samsung_dishwasher_firmware_update",
|
||||
"samsung_dishwasher_heated_dry",
|
||||
@@ -53,7 +54,6 @@
|
||||
"samsung_dishwasher_operation_origin",
|
||||
"samsung_dishwasher_pause",
|
||||
"samsung_dishwasher_power_switch",
|
||||
"samsung_dishwasher_power_watts",
|
||||
"samsung_dishwasher_progress",
|
||||
"samsung_dishwasher_progress_percentage",
|
||||
"samsung_dishwasher_remote_control",
|
||||
|
||||
Vendored
+24
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"state_keys": [
|
||||
"alarm_code",
|
||||
"buzzer_sound",
|
||||
"child_lock",
|
||||
"cycle",
|
||||
"cycle_active",
|
||||
"delay_start_hours",
|
||||
"diagnosis",
|
||||
"dry_level",
|
||||
"dry_time",
|
||||
"dryer_type",
|
||||
"energy_kwh",
|
||||
"finish_time",
|
||||
"firmware_update",
|
||||
"job_beginning_status",
|
||||
"machine_state",
|
||||
"power_switch",
|
||||
"progress",
|
||||
"progress_percentage",
|
||||
"remote_control",
|
||||
"wrinkle_prevent"
|
||||
]
|
||||
}
|
||||
Vendored
+39
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"state_keys": [
|
||||
"alarm_code",
|
||||
"burner_0_hot_surface",
|
||||
"burner_0_power_level",
|
||||
"burner_0_state",
|
||||
"burner_1_hot_surface",
|
||||
"burner_1_power_level",
|
||||
"burner_1_state",
|
||||
"burner_2_hot_surface",
|
||||
"burner_2_power_level",
|
||||
"burner_2_state",
|
||||
"burner_3_hot_surface",
|
||||
"burner_3_power_level",
|
||||
"burner_3_state",
|
||||
"child_lock",
|
||||
"cloud_connected",
|
||||
"cook_time",
|
||||
"cooktop_safety_shutoff_enabled",
|
||||
"cooktop_state",
|
||||
"current_temp_c",
|
||||
"cycle_active",
|
||||
"door_open",
|
||||
"fast_preheat",
|
||||
"finish_time",
|
||||
"firmware_update",
|
||||
"lamp",
|
||||
"machine_state",
|
||||
"natural_steam",
|
||||
"operation_time_minutes",
|
||||
"oven_mode",
|
||||
"oven_setpoint",
|
||||
"oven_state",
|
||||
"power_switch",
|
||||
"progress_percentage",
|
||||
"remote_control",
|
||||
"sound"
|
||||
]
|
||||
}
|
||||
+12
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"state_keys": [
|
||||
"ai_energy_level",
|
||||
"alarm_code",
|
||||
"auto_door_opener",
|
||||
"autofill",
|
||||
@@ -16,6 +17,9 @@
|
||||
"door_cooler_open",
|
||||
"door_freezer_open",
|
||||
"energy_kwh",
|
||||
"energy_last_month_kwh",
|
||||
"energy_saved_kwh",
|
||||
"energy_this_month_kwh",
|
||||
"filter_status",
|
||||
"filter_usage",
|
||||
"firmware_update",
|
||||
@@ -31,15 +35,18 @@
|
||||
"icemaker_two_type",
|
||||
"night_end",
|
||||
"night_start",
|
||||
"power_energy_kwh",
|
||||
"power_watts",
|
||||
"rapid_freezing",
|
||||
"rapid_fridge",
|
||||
"sabbath_mode",
|
||||
"selfcheck_error",
|
||||
"selfcheck_result",
|
||||
"selfcheck_status",
|
||||
"welcome_lighting"
|
||||
],
|
||||
"discovery_unique_ids": [
|
||||
"samsung_refrigerator_ai_energy_level",
|
||||
"samsung_refrigerator_alarm_code",
|
||||
"samsung_refrigerator_auto_door_opener",
|
||||
"samsung_refrigerator_autofill",
|
||||
@@ -56,6 +63,9 @@
|
||||
"samsung_refrigerator_door_cooler_open",
|
||||
"samsung_refrigerator_door_freezer_open",
|
||||
"samsung_refrigerator_energy_kwh",
|
||||
"samsung_refrigerator_energy_last_month_kwh",
|
||||
"samsung_refrigerator_energy_saved_kwh",
|
||||
"samsung_refrigerator_energy_this_month_kwh",
|
||||
"samsung_refrigerator_filter_status",
|
||||
"samsung_refrigerator_filter_usage",
|
||||
"samsung_refrigerator_firmware_update",
|
||||
@@ -71,10 +81,12 @@
|
||||
"samsung_refrigerator_icemaker_two_type",
|
||||
"samsung_refrigerator_night_end",
|
||||
"samsung_refrigerator_night_start",
|
||||
"samsung_refrigerator_power_energy_kwh",
|
||||
"samsung_refrigerator_power_watts",
|
||||
"samsung_refrigerator_rapid_freezing",
|
||||
"samsung_refrigerator_rapid_fridge",
|
||||
"samsung_refrigerator_sabbath_mode",
|
||||
"samsung_refrigerator_selfcheck_error",
|
||||
"samsung_refrigerator_selfcheck_result",
|
||||
"samsung_refrigerator_selfcheck_start",
|
||||
"samsung_refrigerator_selfcheck_status",
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"state_keys": [
|
||||
"alarm_code",
|
||||
"autofill",
|
||||
"cooler_setpoint",
|
||||
"cooler_temperature",
|
||||
"defrost_active",
|
||||
"defrost_delay",
|
||||
"diagnosis_status",
|
||||
"door_cooler_open",
|
||||
"door_freezer_open",
|
||||
"energy_last_month_kwh",
|
||||
"energy_this_month_kwh",
|
||||
"filter_status",
|
||||
"filter_usage",
|
||||
"firmware_update",
|
||||
"freezer_setpoint",
|
||||
"freezer_temperature",
|
||||
"icemaker_one_enabled",
|
||||
"icemaker_one_making_status",
|
||||
"pantry_zone_mode",
|
||||
"power_energy_kwh",
|
||||
"power_watts",
|
||||
"rapid_freezing",
|
||||
"rapid_fridge",
|
||||
"sabbath_mode"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"state_keys": [
|
||||
"ai_energy_level",
|
||||
"alarm_code",
|
||||
"autofill",
|
||||
"cooler_setpoint",
|
||||
"cooler_temperature",
|
||||
"defrost_active",
|
||||
"defrost_delay",
|
||||
"door_cooler_open",
|
||||
"door_cvroom_open",
|
||||
"door_freezer_open",
|
||||
"energy_kwh",
|
||||
"energy_last_month_kwh",
|
||||
"energy_saved_kwh",
|
||||
"energy_this_month_kwh",
|
||||
"filter_status",
|
||||
"filter_usage",
|
||||
"firmware_update",
|
||||
"flex_zone_mode",
|
||||
"freezer_setpoint",
|
||||
"freezer_temperature",
|
||||
"icemaker_one_enabled",
|
||||
"icemaker_one_making_status",
|
||||
"icemaker_two_enabled",
|
||||
"icemaker_two_making_status",
|
||||
"power_energy_kwh",
|
||||
"power_watts",
|
||||
"rapid_freezing",
|
||||
"rapid_fridge",
|
||||
"sabbath_mode",
|
||||
"selfcheck_error",
|
||||
"selfcheck_result",
|
||||
"selfcheck_status"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"state_keys": [
|
||||
"alarm_code",
|
||||
"cooler_setpoint",
|
||||
"cooler_temperature",
|
||||
"defrost_active",
|
||||
"defrost_delay",
|
||||
"diagnosis_status",
|
||||
"door_cooler_open",
|
||||
"door_cvroom_open",
|
||||
"door_freezer_open",
|
||||
"energy_kwh",
|
||||
"energy_last_month_kwh",
|
||||
"energy_this_month_kwh",
|
||||
"filter_status",
|
||||
"filter_usage",
|
||||
"firmware_update",
|
||||
"flex_zone_mode",
|
||||
"freezer_setpoint",
|
||||
"freezer_temperature",
|
||||
"icemaker_one_enabled",
|
||||
"icemaker_one_making_status",
|
||||
"power_energy_kwh",
|
||||
"power_watts",
|
||||
"rapid_freezing",
|
||||
"rapid_fridge",
|
||||
"sabbath_mode"
|
||||
]
|
||||
}
|
||||
Vendored
+5
@@ -1,6 +1,8 @@
|
||||
{
|
||||
"state_keys": [
|
||||
"ai_energy_level",
|
||||
"alarm_code",
|
||||
"bubble_soak",
|
||||
"buzzer_sound",
|
||||
"child_lock",
|
||||
"cycle",
|
||||
@@ -11,11 +13,14 @@
|
||||
"drum_clean_cycles_remaining",
|
||||
"drum_clean_last_cleaned",
|
||||
"energy_kwh",
|
||||
"energy_saved_kwh",
|
||||
"finish_time",
|
||||
"firmware_update",
|
||||
"intensive",
|
||||
"job_beginning_status",
|
||||
"machine_state",
|
||||
"power_switch",
|
||||
"pre_wash",
|
||||
"progress",
|
||||
"progress_percentage",
|
||||
"remote_control",
|
||||
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"state_keys": [
|
||||
"alarm_code",
|
||||
"bubble_soak",
|
||||
"child_lock",
|
||||
"cycle",
|
||||
"cycle_active",
|
||||
"delay_start_hours",
|
||||
"detergent_low",
|
||||
"diagnosis_status",
|
||||
"drum_clean_cycles_remaining",
|
||||
"dry_level",
|
||||
"energy_kwh",
|
||||
"finish_time",
|
||||
"firmware_update",
|
||||
"intensive",
|
||||
"job_beginning_status",
|
||||
"machine_state",
|
||||
"power_switch",
|
||||
"pre_wash",
|
||||
"progress",
|
||||
"progress_percentage",
|
||||
"remote_control",
|
||||
"rinse_cycles",
|
||||
"softener_low",
|
||||
"spin_speed",
|
||||
"wash_temperature"
|
||||
]
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"state_keys": [
|
||||
"alarm_code",
|
||||
"child_lock",
|
||||
"cycle_active",
|
||||
"delay_start_hours",
|
||||
"detergent_low",
|
||||
"diagnosis_status",
|
||||
"drum_clean_cycles_remaining",
|
||||
"energy_kwh",
|
||||
"finish_time",
|
||||
"job_beginning_status",
|
||||
"machine_state",
|
||||
"power_switch",
|
||||
"progress",
|
||||
"progress_percentage",
|
||||
"remote_control",
|
||||
"rinse_cycles",
|
||||
"spin_speed",
|
||||
"wash_temperature"
|
||||
]
|
||||
}
|
||||
Vendored
+466
@@ -0,0 +1,466 @@
|
||||
{
|
||||
"meta": {
|
||||
"model": "TP1X_DA-KS-RANGE-0102X (NSI6DG9100SRAA)",
|
||||
"device_type": "range",
|
||||
"source": "issue #44 diagnostics (scrubbed)",
|
||||
"note": "Range/cooktop-oven combo, first dump of this device type. 4 burners reported (numberOfBurners=4)."
|
||||
},
|
||||
"device0": [
|
||||
{
|
||||
"rt": [
|
||||
"x.com.samsung.devcol",
|
||||
"oic.wk.col"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.ll",
|
||||
"oic.if.b"
|
||||
]
|
||||
},
|
||||
{
|
||||
"href": "/otninformation/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.target": "",
|
||||
"x.com.samsung.da.newVersionAvailable": "false",
|
||||
"flashingProgress": "",
|
||||
"otnStatus": "None",
|
||||
"otnList": [
|
||||
{
|
||||
"type": "WIFI",
|
||||
"modelId": "AKS-WW-TP1-23-OVEN-3",
|
||||
"versions": [
|
||||
"40241114"
|
||||
],
|
||||
"visVersion": "241114"
|
||||
},
|
||||
{
|
||||
"type": "Micom",
|
||||
"modelId": "07174047694140477041",
|
||||
"versions": [
|
||||
"24052400",
|
||||
"24012300"
|
||||
],
|
||||
"visVersion": "240524"
|
||||
},
|
||||
{
|
||||
"type": "Micom",
|
||||
"modelId": "071760141141FFFFFFFF",
|
||||
"versions": [
|
||||
"24010500",
|
||||
"FFFFFFFF"
|
||||
],
|
||||
"visVersion": "240105"
|
||||
},
|
||||
{
|
||||
"type": "Micom",
|
||||
"modelId": "071740476841FFFFFFFF",
|
||||
"versions": [
|
||||
"23121600",
|
||||
"FFFFFFFF"
|
||||
],
|
||||
"visVersion": "231216"
|
||||
},
|
||||
{
|
||||
"type": "Micom",
|
||||
"modelId": "07174047484140474941",
|
||||
"versions": [
|
||||
"24020500",
|
||||
"24012600"
|
||||
],
|
||||
"visVersion": "240205"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/connected/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.connected": "On"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/doors/vs/0",
|
||||
"rep": {
|
||||
"rt": [
|
||||
"x.com.samsung.da.doors"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.s"
|
||||
],
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.id": "0",
|
||||
"x.com.samsung.da.description": "Door",
|
||||
"x.com.samsung.da.openState": "Close",
|
||||
"x.com.samsung.da.lock": "Unlock"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/information/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.modelNum": "TP1X_DA-KS-RANGE-0102X|40476941|5001011E041811010A00000000000000",
|
||||
"x.com.samsung.da.description": "NI9100D-/AA0",
|
||||
"x.com.samsung.da.serialNum": "**REDACTED**",
|
||||
"x.com.samsung.da.otnDUID": "**REDACTED**",
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.id": "0",
|
||||
"x.com.samsung.da.description": "Version",
|
||||
"x.com.samsung.da.type": "Software",
|
||||
"x.com.samsung.da.number": "24111400",
|
||||
"x.com.samsung.da.newVersionAvailable": "0"
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.id": "1",
|
||||
"x.com.samsung.da.description": "Version",
|
||||
"x.com.samsung.da.type": "Firmware",
|
||||
"x.com.samsung.da.number": "04769A24052400, 04770A24012300",
|
||||
"x.com.samsung.da.newVersionAvailable": "0"
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.id": "2",
|
||||
"x.com.samsung.da.description": "Version",
|
||||
"x.com.samsung.da.type": "Firmware",
|
||||
"x.com.samsung.da.number": "01411A24010500",
|
||||
"x.com.samsung.da.newVersionAvailable": "0"
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.id": "3",
|
||||
"x.com.samsung.da.description": "Version",
|
||||
"x.com.samsung.da.type": "Firmware",
|
||||
"x.com.samsung.da.number": "04768A23121600",
|
||||
"x.com.samsung.da.newVersionAvailable": "0"
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.id": "4",
|
||||
"x.com.samsung.da.description": "Version",
|
||||
"x.com.samsung.da.type": "Firmware",
|
||||
"x.com.samsung.da.number": "04748A24020500, 04749A24012600",
|
||||
"x.com.samsung.da.newVersionAvailable": "0"
|
||||
}
|
||||
],
|
||||
"x.com.samsung.da.diagProtocolType": "BLE_OCF",
|
||||
"x.com.samsung.da.diagLogType": [
|
||||
"errCode",
|
||||
"dump"
|
||||
],
|
||||
"x.com.samsung.da.diagDumpType": "file",
|
||||
"x.com.samsung.da.diagEndPoint": "SSM",
|
||||
"x.com.samsung.da.diagMnid": "0AJT",
|
||||
"x.com.samsung.da.diagSetupid": "KR3",
|
||||
"x.com.samsung.da.diagMinVersion": "3.0",
|
||||
"x.com.samsung.da.diagTsId": "DA01"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/mode/vs/0",
|
||||
"rep": {
|
||||
"rt": [
|
||||
"x.com.samsung.da.mode"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.a"
|
||||
],
|
||||
"x.com.samsung.da.supportedModes": [
|
||||
"Bake",
|
||||
"Broil",
|
||||
"KeepWarm",
|
||||
"BreadProof",
|
||||
"SelfClean",
|
||||
"SteamClean"
|
||||
],
|
||||
"x.com.samsung.da.modes": [
|
||||
"NoOperation"
|
||||
],
|
||||
"x.com.samsung.da.modeStateList": [
|
||||
{
|
||||
"mode": "Bake",
|
||||
"state": "Fixed"
|
||||
},
|
||||
{
|
||||
"mode": "Broil",
|
||||
"state": "Show"
|
||||
},
|
||||
{
|
||||
"mode": "KeepWarm",
|
||||
"state": "Show"
|
||||
},
|
||||
{
|
||||
"mode": "BreadProof",
|
||||
"state": "Show"
|
||||
},
|
||||
{
|
||||
"mode": "SelfClean",
|
||||
"state": "Fixed"
|
||||
},
|
||||
{
|
||||
"mode": "SteamClean",
|
||||
"state": "Fixed"
|
||||
}
|
||||
],
|
||||
"x.com.samsung.da.options": [
|
||||
"DeviceType_NI9100D-/AA0",
|
||||
"SettingPossible_5",
|
||||
"TimeAutoSync_On",
|
||||
"UpperLamp_On",
|
||||
"TimeSystem_12",
|
||||
"Sound_On",
|
||||
"AdjustingTemp_0",
|
||||
"Sabbath_Off",
|
||||
"EnergySaving_On"
|
||||
],
|
||||
"x.com.samsung.da.defaultMode": "Bake",
|
||||
"x.com.samsung.da.modeSpec": "[{\"mode\":\"Bake\",\"version\":\"0101\",\"default\":\"Default\",\"control\":\"Start&Setting\",\"cavity\":\"Single\",\"tempMinC\":\"80\",\"tempMaxC\":\"285\",\"tempDefaultC\":\"175\",\"tempListLengthC\":\"0\",\"tempMinF\":\"175\",\"tempMaxF\":\"550\",\"tempDefaultF\":\"350\",\"tempListLengthF\":\"0\",\"timeMin\":\"00:01:00\",\"timeMax\":\"09:59:00\",\"timeDefault\":\"01:00:00\",\"probeMinC\":\"40\",\"probeMaxC\":\"90\",\"probeDefaultC\":\"65\",\"probeMinF\":\"100\",\"probeMaxF\":\"200\",\"probeDefaultF\":\"150\",\"powerDefault\":\"NotSupported\",\"powerListLength\":\"0\",\"tempIntervalC\":\"5\",\"tempIntervalF\":\"5\",\"probeIntervalC\":\"1\",\"probeIntervalF\":\"1\"},{\"mode\":\"Broil\",\"version\":\"0101\",\"default\":\"Normal\",\"control\":\"Setting\",\"cavity\":\"Single\",\"tempMinC\":\"61442\",\"tempMaxC\":\"61441\",\"tempDefaultC\":\"61441\",\"tempListLengthC\":\"2\",\"tempListDataC\":[\"61441\",\"61442\"],\"tempMinF\":\"61442\",\"tempMaxF\":\"61441\",\"tempDefaultF\":\"61441\",\"tempListLengthF\":\"2\",\"tempListDataF\":[\"61441\",\"61442\"],\"timeMin\":\"NotSupported\",\"timeMax\":\"NotSupported\",\"timeDefault\":\"NotSupported\",\"probeMinC\":\"NotSupported\",\"probeMaxC\":\"NotSupported\",\"probeDefaultC\":\"NotSupported\",\"probeMinF\":\"NotSupported\",\"probeMaxF\":\"NotSupported\",\"probeDefaultF\":\"NotSupported\",\"powerDefault\":\"NotSupported\",\"powerListLength\":\"0\",\"tempIntervalC\":\"NotSupported\",\"tempIntervalF\":\"NotSupported\",\"probeIntervalC\":\"NotSupported\",\"probeIntervalF\":\"NotSupported\"},{\"mode\":\"KeepWarm\",\"version\":\"0101\",\"default\":\"Normal\",\"control\":\"Setting\",\"cavity\":\"Single\",\"tempMinC\":\"80\",\"tempMaxC\":\"80\",\"tempDefaultC\":\"80\",\"tempListLengthC\":\"1\",\"tempListDataC\":[\"80\"],\"tempMinF\":\"175\",\"tempMaxF\":\"175\",\"tempDefaultF\":\"175\",\"tempListLengthF\":\"1\",\"tempListDataF\":[\"175\"],\"timeMin\":\"NotSupported\",\"timeMax\":\"NotSupported\",\"timeDefault\":\"NotSupported\",\"probeMinC\":\"NotSupported\",\"probeMaxC\":\"NotSupported\",\"probeDefaultC\":\"NotSupported\",\"probeMinF\":\"NotSupported\",\"probeMaxF\":\"NotSupported\",\"probeDefaultF\":\"NotSupported\",\"powerDefault\":\"NotSupported\",\"powerListLength\":\"0\",\"tempIntervalC\":\"NotSupported\",\"tempIntervalF\":\"NotSupported\",\"probeIntervalC\":\"NotSupported\",\"probeIntervalF\":\"NotSupported\"},{\"mode\":\"BreadProof\",\"version\":\"0101\",\"default\":\"Normal\",\"control\":\"Setting\",\"cavity\":\"Single\",\"tempMinC\":\"35\",\"tempMaxC\":\"35\",\"tempDefaultC\":\"35\",\"tempListLengthC\":\"1\",\"tempListDataC\":[\"35\"],\"tempMinF\":\"95\",\"tempMaxF\":\"95\",\"tempDefaultF\":\"95\",\"tempListLengthF\":\"1\",\"tempListDataF\":[\"95\"],\"timeMin\":\"NotSupported\",\"timeMax\":\"NotSupported\",\"timeDefault\":\"NotSupported\",\"probeMinC\":\"NotSupported\",\"probeMaxC\":\"NotSupported\",\"probeDefaultC\":\"NotSupported\",\"probeMinF\":\"NotSupported\",\"probeMaxF\":\"NotSupported\",\"probeDefaultF\":\"NotSupported\",\"powerDefault\":\"NotSupported\",\"powerListLength\":\"0\",\"tempIntervalC\":\"NotSupported\",\"tempIntervalF\":\"NotSupported\",\"probeIntervalC\":\"NotSupported\",\"probeIntervalF\":\"NotSupported\"},{\"mode\":\"SelfClean\",\"version\":\"0101\",\"default\":\"Normal\",\"control\":\"NotSupported\",\"cavity\":\"Single\",\"tempMinC\":\"NotSupported\",\"tempMaxC\":\"NotSupported\",\"tempDefaultC\":\"NotSupported\",\"tempListLengthC\":\"0\",\"tempMinF\":\"NotSupported\",\"tempMaxF\":\"NotSupported\",\"tempDefaultF\":\"NotSupported\",\"tempListLengthF\":\"0\",\"timeMin\":\"NotSupported\",\"timeMax\":\"NotSupported\",\"timeDefault\":\"NotSupported\",\"probeMinC\":\"NotSupported\",\"probeMaxC\":\"NotSupported\",\"probeDefaultC\":\"NotSupported\",\"probeMinF\":\"NotSupported\",\"probeMaxF\":\"NotSupported\",\"probeDefaultF\":\"NotSupported\",\"powerDefault\":\"NotSupported\",\"powerListLength\":\"0\",\"tempIntervalC\":\"NotSupported\",\"tempIntervalF\":\"NotSupported\",\"probeIntervalC\":\"NotSupported\",\"probeIntervalF\":\"NotSupported\"},{\"mode\":\"SteamClean\",\"version\":\"0101\",\"default\":\"Normal\",\"control\":\"NotSupported\",\"cavity\":\"Single\",\"tempMinC\":\"NotSupported\",\"tempMaxC\":\"NotSupported\",\"tempDefaultC\":\"NotSupported\",\"tempListLengthC\":\"0\",\"tempMinF\":\"NotSupported\",\"tempMaxF\":\"NotSupported\",\"tempDefaultF\":\"NotSupported\",\"tempListLengthF\":\"0\",\"timeMin\":\"NotSupported\",\"timeMax\":\"NotSupported\",\"timeDefault\":\"NotSupported\",\"probeMinC\":\"NotSupported\",\"probeMaxC\":\"NotSupported\",\"probeDefaultC\":\"NotSupported\",\"probeMinF\":\"NotSupported\",\"probeMaxF\":\"NotSupported\",\"probeDefaultF\":\"NotSupported\",\"powerDefault\":\"NotSupported\",\"powerListLength\":\"0\",\"tempIntervalC\":\"NotSupported\",\"tempIntervalF\":\"NotSupported\",\"probeIntervalC\":\"NotSupported\",\"probeIntervalF\":\"NotSupported\"}]"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/operational/state/vs/0",
|
||||
"rep": {
|
||||
"rt": [
|
||||
"x.com.samsung.da.operation"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.a"
|
||||
],
|
||||
"x.com.samsung.da.state": "Ready",
|
||||
"causeSource.state": "SETB_Ready",
|
||||
"x.com.samsung.da.operationTime": "00:00:00",
|
||||
"x.com.samsung.da.remainingTime": "00:00:00",
|
||||
"x.com.samsung.da.progressPercentage": "1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/alarms/vs/0",
|
||||
"rep": {
|
||||
"rt": [
|
||||
"x.com.samsung.da.alarms"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.s"
|
||||
],
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.id": "0",
|
||||
"x.com.samsung.da.description": "Alarm",
|
||||
"x.com.samsung.da.alarmType": "Device",
|
||||
"x.com.samsung.da.code": "OV_E_OFF",
|
||||
"x.com.samsung.da.triggeredTime": "2026-07-22T23:55:41"
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.id": "1",
|
||||
"x.com.samsung.da.description": "Alarm",
|
||||
"x.com.samsung.da.alarmType": "Device",
|
||||
"x.com.samsung.da.code": "CT_E_OFF",
|
||||
"x.com.samsung.da.triggeredTime": "2026-07-22T23:55:41"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/oven/vs/0",
|
||||
"rep": {
|
||||
"rt": [
|
||||
"x.com.samsung.da.oven"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.s"
|
||||
],
|
||||
"x.com.samsung.da.state": "Ready"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/temperatures/vs/0",
|
||||
"rep": {
|
||||
"rt": [
|
||||
"x.com.samsung.da.temperatures"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.a"
|
||||
],
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.id": "0",
|
||||
"x.com.samsung.da.description": "Temperature",
|
||||
"x.com.samsung.da.desired": "0",
|
||||
"x.com.samsung.da.current": "175",
|
||||
"x.com.samsung.da.increment": "5",
|
||||
"x.com.samsung.da.unit": "Fahrenheit"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/timezone/vs/0",
|
||||
"rep": {
|
||||
"timezoneid": "America/Chicago",
|
||||
"offset": "-05:00",
|
||||
"DST": "ON"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/oven/spec/vs/0",
|
||||
"rep": {
|
||||
"cavityInfo": {
|
||||
"numberOfCavity": 1,
|
||||
"type": "single",
|
||||
"serviceMainCavity": "single",
|
||||
"cavityList": [
|
||||
{
|
||||
"name": "single",
|
||||
"flexSupport": false,
|
||||
"single": {
|
||||
"resourceId": 0,
|
||||
"supportedFeatureList": [
|
||||
""
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/kidslock/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.kidsLock": "Ready"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/power/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.power": "On"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/remotectrl/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.remoteControlEnabled": "false"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/cooktop/settings/status/vs/0",
|
||||
"rep": {
|
||||
"safetyAlert": {
|
||||
"state": "on",
|
||||
"settingTime": 1200
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/cooktop/spec/vs/0",
|
||||
"rep": {
|
||||
"supportedFeatureList": [
|
||||
""
|
||||
],
|
||||
"numberOfBurners": 4,
|
||||
"supportedBurnerList": [
|
||||
0,
|
||||
1,
|
||||
2,
|
||||
3
|
||||
],
|
||||
"supportedPowerLevelList": [
|
||||
"1",
|
||||
"2",
|
||||
"3",
|
||||
"4",
|
||||
"5",
|
||||
"6",
|
||||
"7",
|
||||
"8",
|
||||
"9",
|
||||
"boost",
|
||||
"0",
|
||||
"simmer"
|
||||
],
|
||||
"supportedRecipeProtocolVersion": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/cooktop/status/vs/0",
|
||||
"rep": {
|
||||
"power": "off",
|
||||
"childLock": "off",
|
||||
"operationState": "ready",
|
||||
"additionalStateList": [
|
||||
""
|
||||
],
|
||||
"burnerList": [
|
||||
{
|
||||
"burnerNumber": 0,
|
||||
"mode": "normal",
|
||||
"operationState": "ready",
|
||||
"powerLevel": "0",
|
||||
"hotSurfaceState": "normal",
|
||||
"panDetection": true,
|
||||
"timer": {
|
||||
"operationState": "ready",
|
||||
"cookingTime": 0,
|
||||
"remainingTime": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"burnerNumber": 1,
|
||||
"mode": "normal",
|
||||
"operationState": "ready",
|
||||
"powerLevel": "0",
|
||||
"hotSurfaceState": "normal",
|
||||
"panDetection": true,
|
||||
"timer": {
|
||||
"operationState": "ready",
|
||||
"cookingTime": 0,
|
||||
"remainingTime": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"burnerNumber": 2,
|
||||
"mode": "normal",
|
||||
"operationState": "ready",
|
||||
"powerLevel": "0",
|
||||
"hotSurfaceState": "normal",
|
||||
"panDetection": true,
|
||||
"timer": {
|
||||
"operationState": "ready",
|
||||
"cookingTime": 0,
|
||||
"remainingTime": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"burnerNumber": 3,
|
||||
"mode": "normal",
|
||||
"operationState": "ready",
|
||||
"powerLevel": "0",
|
||||
"hotSurfaceState": "normal",
|
||||
"panDetection": true,
|
||||
"timer": {
|
||||
"operationState": "ready",
|
||||
"cookingTime": 0,
|
||||
"remainingTime": 0
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/wirelessinfo/vs/0",
|
||||
"rep": {
|
||||
"macaddressWiFi": "**REDACTED**",
|
||||
"macaddressBLE": "**REDACTED**"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/quickcontrol/info/vs/0",
|
||||
"rep": {
|
||||
"supportedVersion": "1.0"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
{
|
||||
"device0": [
|
||||
{
|
||||
"rt": [
|
||||
"x.com.samsung.devcol",
|
||||
"oic.wk.col"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.ll",
|
||||
"oic.if.b"
|
||||
]
|
||||
},
|
||||
{
|
||||
"href": "/alarms/vs/0",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/temperatures/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.id": "0",
|
||||
"x.com.samsung.da.description": "Freezer",
|
||||
"x.com.samsung.da.desired": "-21",
|
||||
"x.com.samsung.da.current": "-21",
|
||||
"x.com.samsung.da.maximum": "-15",
|
||||
"x.com.samsung.da.minimum": "-23",
|
||||
"x.com.samsung.da.unit": "Celsius"
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.id": "1",
|
||||
"x.com.samsung.da.description": "Fridge",
|
||||
"x.com.samsung.da.desired": "2",
|
||||
"x.com.samsung.da.current": "2",
|
||||
"x.com.samsung.da.maximum": "7",
|
||||
"x.com.samsung.da.minimum": "1",
|
||||
"x.com.samsung.da.unit": "Celsius"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/temperature/current/freezer/0",
|
||||
"rep": {
|
||||
"range": [
|
||||
-23.0,
|
||||
-15.0
|
||||
],
|
||||
"units": "C",
|
||||
"temperature": -21.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/temperature/desired/freezer/0",
|
||||
"rep": {
|
||||
"range": [
|
||||
-23.0,
|
||||
-15.0
|
||||
],
|
||||
"units": "C",
|
||||
"temperature": -21.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/temperature/current/cooler/0",
|
||||
"rep": {
|
||||
"range": [
|
||||
1.0,
|
||||
7.0
|
||||
],
|
||||
"units": "C",
|
||||
"temperature": 2.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/temperature/desired/cooler/0",
|
||||
"rep": {
|
||||
"range": [
|
||||
1.0,
|
||||
7.0
|
||||
],
|
||||
"units": "C",
|
||||
"temperature": 2.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/diagnosis/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.diagnosisStart": "Ready"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/energy/consumption/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.cumulativeConsumption": "0",
|
||||
"x.com.samsung.da.cumulativeUnit": "Wh",
|
||||
"x.com.samsung.da.instantaneousPower": "0",
|
||||
"x.com.samsung.da.instantaneousPowerUnit": "W",
|
||||
"x.com.samsung.da.monthlyConsumption": "86000",
|
||||
"x.com.samsung.da.thismonthlyConsumption": "64026"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/mode/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.supportedModes": [
|
||||
"HOMECARE_WIZARD_V2",
|
||||
"ENERGY_REPORT_MODEL",
|
||||
"18K_REF_OUTDOOR_CONTROL_V2",
|
||||
"SUPPORT_SABBATH_CONTROL"
|
||||
],
|
||||
"x.com.samsung.da.modes": [
|
||||
"WATERFILTER_ENABLE"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/mode/0",
|
||||
"rep": {
|
||||
"supportedModes": [
|
||||
"HOMECARE_WIZARD_V2",
|
||||
"ENERGY_REPORT_MODEL",
|
||||
"18K_REF_OUTDOOR_CONTROL_V2",
|
||||
"SUPPORT_SABBATH_CONTROL"
|
||||
],
|
||||
"modes": [
|
||||
"WATERFILTER_ENABLE"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/defrost/block/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.supportedModes": [
|
||||
"DEFROST_BLOCK_ON",
|
||||
"DEFROST_BLOCK_OFF"
|
||||
],
|
||||
"x.com.samsung.da.modes": [
|
||||
"DEFROST_BLOCK_OFF"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/sabbath/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.sabbathMode": "Off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/realtimenotiforclient/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.timeforshortnoti": "0",
|
||||
"x.com.samsung.da.periodicnotisubscription": "true"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/information/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.modelNum": "ARTIK051_REF_17K|00117141|0002074E051311200103000010000000",
|
||||
"x.com.samsung.da.description": "ARTIK051_REF_17K",
|
||||
"x.com.samsung.da.serialNum": "REDACTED",
|
||||
"x.com.samsung.da.otnDUID": "REDACTED",
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.id": "0",
|
||||
"x.com.samsung.da.description": "WiFi Module",
|
||||
"x.com.samsung.da.type": "Software",
|
||||
"x.com.samsung.da.number": "02144A201124",
|
||||
"x.com.samsung.da.newVersionAvailable": "0"
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.id": "1",
|
||||
"x.com.samsung.da.description": "Micom",
|
||||
"x.com.samsung.da.type": "Firmware",
|
||||
"x.com.samsung.da.number": "2003030C,FFFFFFFF",
|
||||
"x.com.samsung.da.newVersionAvailable": "0"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/file/information/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.timeoffset": "-04:00",
|
||||
"x.com.samsung.supprtedtype": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/doors/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.id": "0",
|
||||
"x.com.samsung.da.description": "Door",
|
||||
"x.com.samsung.da.openState": "Close"
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.id": "1",
|
||||
"x.com.samsung.da.description": "Door",
|
||||
"x.com.samsung.da.openState": "Close"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/door/freezer/0",
|
||||
"rep": {
|
||||
"openState": "Close"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/door/cooler/0",
|
||||
"rep": {
|
||||
"openState": "Close"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/configuration/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.countryCode": "US",
|
||||
"x.com.samsung.da.region": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/defrost/delay/0",
|
||||
"rep": {
|
||||
"value": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/defrost/delay/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.delayDefrost": "Off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/defrost/reservation/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.id": "0",
|
||||
"x.com.samsung.da.description": "Summer Season",
|
||||
"x.com.samsung.da.startTime": "0000-05-01T15:00:00",
|
||||
"x.com.samsung.da.period": "04:00:00",
|
||||
"x.com.samsung.da.endTime": "0000-10-31T00:00:00"
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.id": "1",
|
||||
"x.com.samsung.da.description": "Winter Season",
|
||||
"x.com.samsung.da.startTime": "0000-11-01T06:00:00",
|
||||
"x.com.samsung.da.period": "04:00:00",
|
||||
"x.com.samsung.da.endTime": "0000-04-30T00:00:00"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/icemaker/status/0",
|
||||
"rep": {
|
||||
"status": "On"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/icemaker/status/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.iceMaker": "On"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/refrigeration/0",
|
||||
"rep": {
|
||||
"defrost": false,
|
||||
"rapidFreeze": false,
|
||||
"rapidCool": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/refrigeration/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.rapidFridge": "Off",
|
||||
"x.com.samsung.da.rapidFreezing": "Off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/drlc/0",
|
||||
"rep": {
|
||||
"DRLevel": 0,
|
||||
"start": "00:00:00",
|
||||
"duration": 0,
|
||||
"override": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/drlc/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.drlcLevel": "0",
|
||||
"x.com.samsung.da.duration": "00:00:00",
|
||||
"x.com.samsung.da.drlcStartTime": "00:00:00",
|
||||
"x.com.samsung.da.override": "Off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/otninformation/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.target": "Micom",
|
||||
"x.com.samsung.da.newVersionAvailable": "false"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/filter/waterfilter/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.filterUsage": "100",
|
||||
"x.com.samsung.da.filterUsageResolution": "1",
|
||||
"x.com.samsung.da.filterStatus": "replace",
|
||||
"x.com.samsung.da.filterResetType": [
|
||||
"replaceable"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/autofill/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.autofill": "Off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/icemaker/one/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.iceMaker.name": "ICE_MAKER",
|
||||
"x.com.samsung.da.iceMaker.state": "On",
|
||||
"x.com.samsung.da.iceMaker.type": "toggle",
|
||||
"x.com.samsung.da.iceMaker.iceMakingStatus": "ICESTATUS_STOP",
|
||||
"x.com.samsung.da.iceType.desired": "NORMAL"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/status/pantry/one/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.controllable": "true",
|
||||
"x.com.samsung.da.name": "COOL_SELECT_ZONE",
|
||||
"x.com.samsung.da.mode": "FDR_DRINKS",
|
||||
"x.com.samsung.da.supportedOptions": [
|
||||
"FDR_WINE",
|
||||
"FDR_DELI",
|
||||
"FDR_DRINKS"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
+2
-1
@@ -279,7 +279,8 @@
|
||||
"rep": {
|
||||
"aiLevel": "1",
|
||||
"supportedAiLevel": [
|
||||
"1"
|
||||
"1",
|
||||
"2"
|
||||
],
|
||||
"href": "/energy/ailevel/vs/0"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
{
|
||||
"device0": [
|
||||
{
|
||||
"rt": ["x.com.samsung.devcol", "oic.wk.col"],
|
||||
"if": ["oic.if.baseline", "oic.if.ll", "oic.if.b"]
|
||||
},
|
||||
{"href": "/alarms/vs/0", "rep": {}},
|
||||
{
|
||||
"href": "/temperatures/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.id": "0",
|
||||
"x.com.samsung.da.description": "Freezer",
|
||||
"x.com.samsung.da.desired": "0",
|
||||
"x.com.samsung.da.current": "0",
|
||||
"x.com.samsung.da.maximum": "5",
|
||||
"x.com.samsung.da.minimum": "-8",
|
||||
"x.com.samsung.da.unit": "Fahrenheit"
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.id": "1",
|
||||
"x.com.samsung.da.description": "Fridge",
|
||||
"x.com.samsung.da.desired": "39",
|
||||
"x.com.samsung.da.current": "39",
|
||||
"x.com.samsung.da.maximum": "44",
|
||||
"x.com.samsung.da.minimum": "34",
|
||||
"x.com.samsung.da.unit": "Fahrenheit"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{"href": "/temperature/current/freezer/0", "rep": {"range": [-8.0, 5.0], "units": "F", "temperature": 0.0}},
|
||||
{"href": "/temperature/desired/freezer/0", "rep": {"range": [-8.0, 5.0], "units": "F", "temperature": 0.0}},
|
||||
{"href": "/temperature/current/cooler/0", "rep": {"range": [34.0, 44.0], "units": "F", "temperature": 39.0}},
|
||||
{"href": "/temperature/desired/cooler/0", "rep": {"range": [34.0, 44.0], "units": "F", "temperature": 39.0}},
|
||||
{
|
||||
"href": "/selfcheck/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.status": "Ready",
|
||||
"x.com.samsung.da.result": "Success",
|
||||
"x.com.samsung.da.error": ["DA_ERROR_NONE"],
|
||||
"x.com.samsung.da.supportedActions": ["Start"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/energy/consumption/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.cumulativeConsumption": "63",
|
||||
"x.com.samsung.da.cumulativePower": "1831899",
|
||||
"x.com.samsung.da.cumulativeUnit": "Wh",
|
||||
"x.com.samsung.da.instantaneousPower": "3",
|
||||
"x.com.samsung.da.instantaneousPowerUnit": "W",
|
||||
"x.com.samsung.da.monthlyConsumption": "52000",
|
||||
"x.com.samsung.da.thismonthlyConsumption": "38900",
|
||||
"x.com.samsung.da.cumulativeSavedPower": "0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/mode/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.supportedModes": [
|
||||
"HOMECARE_WIZARD_V2",
|
||||
"ENERGY_REPORT_MODEL",
|
||||
"18K_REF_OUTDOOR_CONTROL_V2",
|
||||
"SUPPORT_SABBATH_CONTROL"
|
||||
],
|
||||
"x.com.samsung.da.modes": ["CVN_CONVERTIBLE_ZONE", "CV_FDR_WINE", "WATERFILTER_DISABLE"],
|
||||
"x.com.samsung.da.supportedOptions": [
|
||||
"CV_FDR_WINE",
|
||||
"CV_FDR_DELI",
|
||||
"CV_FDR_BEVERAGE",
|
||||
"CV_FDR_MEAT",
|
||||
"CV_FDR_SOFT_FREEZER"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/mode/0",
|
||||
"rep": {
|
||||
"supportedModes": [
|
||||
"HOMECARE_WIZARD_V2",
|
||||
"ENERGY_REPORT_MODEL",
|
||||
"18K_REF_OUTDOOR_CONTROL_V2",
|
||||
"SUPPORT_SABBATH_CONTROL"
|
||||
],
|
||||
"modes": ["CVN_CONVERTIBLE_ZONE", "CV_FDR_WINE", "WATERFILTER_DISABLE"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/defrost/block/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.supportedModes": ["DEFROST_BLOCK_ON", "DEFROST_BLOCK_OFF"],
|
||||
"x.com.samsung.da.modes": ["DEFROST_BLOCK_OFF"]
|
||||
}
|
||||
},
|
||||
{"href": "/sabbath/vs/0", "rep": {"x.com.samsung.da.sabbathMode": "Off"}},
|
||||
{
|
||||
"href": "/realtimenotiforclient/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.timeforshortnoti": "0",
|
||||
"x.com.samsung.da.periodicnotisubscription": "true"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/information/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.modelNum": "TP1X_REF_21K|00148241|00020850031611204103000021010000",
|
||||
"x.com.samsung.da.description": "TP1X_REF_21K",
|
||||
"x.com.samsung.da.serialNum": "REDACTED",
|
||||
"x.com.samsung.da.otnDUID": "REDACTED",
|
||||
"x.com.samsung.da.diagProtocolType": "WIFI_HTTPS",
|
||||
"x.com.samsung.da.diagLogType": ["errCode", "dump"],
|
||||
"x.com.samsung.da.diagDumpType": "file",
|
||||
"x.com.samsung.da.diagEndPoint": "SSM",
|
||||
"x.com.samsung.da.diagMnid": "0AJT",
|
||||
"x.com.samsung.da.diagSetupid": "512",
|
||||
"x.com.samsung.da.diagMinVersion": "1.0",
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.id": "0",
|
||||
"x.com.samsung.da.description": "WiFi Module",
|
||||
"x.com.samsung.da.type": "Software",
|
||||
"x.com.samsung.da.number": "250420",
|
||||
"x.com.samsung.da.newVersionAvailable": "0"
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.id": "1",
|
||||
"x.com.samsung.da.description": "Micom",
|
||||
"x.com.samsung.da.type": "Firmware",
|
||||
"x.com.samsung.da.number": "23071923, FFFFFFFF",
|
||||
"x.com.samsung.da.newVersionAvailable": "0"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{"href": "/file/information/vs/0", "rep": {"x.com.samsung.timeoffset": "-05:00", "x.com.samsung.supprtedtype": 1}},
|
||||
{
|
||||
"href": "/doors/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.items": [
|
||||
{"x.com.samsung.da.id": "0", "x.com.samsung.da.description": "Door", "x.com.samsung.da.openState": "Close"},
|
||||
{"x.com.samsung.da.id": "1", "x.com.samsung.da.description": "Door", "x.com.samsung.da.openState": "Close"},
|
||||
{"x.com.samsung.da.id": "2", "x.com.samsung.da.description": "Door", "x.com.samsung.da.openState": "Close"}
|
||||
]
|
||||
}
|
||||
},
|
||||
{"href": "/door/freezer/0", "rep": {"openState": "Close"}},
|
||||
{"href": "/door/cooler/0", "rep": {"openState": "Close"}},
|
||||
{"href": "/door/cvroom/0", "rep": {"openState": "Close"}},
|
||||
{"href": "/configuration/vs/0", "rep": {"x.com.samsung.da.countryCode": "", "x.com.samsung.da.region": ""}},
|
||||
{"href": "/defrost/delay/0", "rep": {"value": false}},
|
||||
{"href": "/defrost/delay/vs/0", "rep": {"x.com.samsung.da.delayDefrost": "Off"}},
|
||||
{
|
||||
"href": "/defrost/reservation/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.id": "0",
|
||||
"x.com.samsung.da.description": "Summer Season",
|
||||
"x.com.samsung.da.startTime": "0000-05-01T15:00:00",
|
||||
"x.com.samsung.da.period": "04:00:00",
|
||||
"x.com.samsung.da.endTime": "0000-10-31T00:00:00"
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.id": "1",
|
||||
"x.com.samsung.da.description": "Winter Season",
|
||||
"x.com.samsung.da.startTime": "0000-11-01T06:00:00",
|
||||
"x.com.samsung.da.period": "04:00:00",
|
||||
"x.com.samsung.da.endTime": "0000-04-30T00:00:00"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{"href": "/icemaker/status/0", "rep": {"status": "On"}},
|
||||
{"href": "/icemaker/status/vs/0", "rep": {"x.com.samsung.da.iceMaker": "On"}},
|
||||
{"href": "/refrigeration/0", "rep": {"defrost": false, "rapidFreeze": false, "rapidCool": false}},
|
||||
{
|
||||
"href": "/refrigeration/vs/0",
|
||||
"rep": {"x.com.samsung.da.rapidFridge": "Off", "x.com.samsung.da.rapidFreezing": "Off"}
|
||||
},
|
||||
{"href": "/drlc/0", "rep": {"DRLevel": 0, "override": false}},
|
||||
{
|
||||
"href": "/drlc/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.drlcLevel": "0",
|
||||
"x.com.samsung.da.override": "Off",
|
||||
"x.com.samsung.da.durationminutes": "0",
|
||||
"x.com.samsung.da.realSaving": "Off"
|
||||
}
|
||||
},
|
||||
{"href": "/bespoke/vs/0", "rep": {"x.com.samsung.da.BespokeProduct": "On"}},
|
||||
{
|
||||
"href": "/otninformation/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.target": "",
|
||||
"x.com.samsung.da.newVersionAvailable": "false",
|
||||
"otnStatus": "None",
|
||||
"flashingProgress": "",
|
||||
"otnList": [
|
||||
{"type": "WIFI", "modelId": "A-RFWW-TP1-22-REV1", "versions": ["20250420"], "visVersion": "250420"},
|
||||
{"type": "Micom", "modelId": "024100148241FFFFFFFF", "versions": ["23071923", "FFFFFFFF"], "visVersion": "230719"}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/filter/waterfilter/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.filterUsage": "31",
|
||||
"x.com.samsung.da.filterUsageResolution": "1",
|
||||
"x.com.samsung.da.filterStatus": "normal",
|
||||
"x.com.samsung.da.filterResetType": ["replaceable"]
|
||||
}
|
||||
},
|
||||
{"href": "/autofill/vs/0", "rep": {"x.com.samsung.da.autofill": "Off"}},
|
||||
{
|
||||
"href": "/icemaker/one/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.iceMaker.name": "CUBED_ICE",
|
||||
"x.com.samsung.da.iceMaker.state": "On",
|
||||
"x.com.samsung.da.iceMaker.type": "toggle",
|
||||
"x.com.samsung.da.iceMaker.iceMakingStatus": "ICESTATUS_STOP",
|
||||
"x.com.samsung.da.iceType.desired": "NORMAL"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/icemaker/two/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.iceMaker.name": "ICE_BITES",
|
||||
"x.com.samsung.da.iceMaker.state": "On",
|
||||
"x.com.samsung.da.iceMaker.type": "toggle",
|
||||
"x.com.samsung.da.iceMaker.iceMakingStatus": "ICESTATUS_STOP",
|
||||
"x.com.samsung.da.iceType.desired": "On"
|
||||
}
|
||||
},
|
||||
{"href": "/energy/ailevel/vs/0", "rep": {"aiLevel": "1", "supportedAiLevel": ["1"]}},
|
||||
{
|
||||
"href": "/connectionconfig/vs/0",
|
||||
"rep": {
|
||||
"autoReconnectionMinVersion": "1.0",
|
||||
"autoReconnection": "true",
|
||||
"autoReconnectionProtocolType": ["ble_ocf", null],
|
||||
"supportedWiFiAuthType": ["OPEN", "WEP", "WPA-PSK", "WPA2-PSK", "SAE"],
|
||||
"supportedWiFiCryptoType": ["TKIP", "AES", "WEP-64", "WEP-128"],
|
||||
"supportedWiFiFreq": ["2.4G"],
|
||||
"calmConnectionCare": {"version": "1.0", "role": ["things"]}
|
||||
}
|
||||
},
|
||||
{"href": "/timezone/vs/0", "rep": {"timezoneid": "America/Chicago", "offset": "-05:00", "DST": "ON"}},
|
||||
{"href": "/wirelessinfo/vs/0", "rep": {"macaddressWiFi": "REDACTED", "macaddressBLE": "REDACTED"}},
|
||||
{"href": "/quickcontrol/info/vs/0", "rep": {"supportedVersion": "1.0"}}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
{
|
||||
"device0": [
|
||||
{
|
||||
"rt": [
|
||||
"x.com.samsung.devcol",
|
||||
"oic.wk.col"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.ll",
|
||||
"oic.if.b"
|
||||
]
|
||||
},
|
||||
{
|
||||
"href": "/alarms/vs/0",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/temperatures/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.id": "0",
|
||||
"x.com.samsung.da.description": "Freezer",
|
||||
"x.com.samsung.da.desired": "0",
|
||||
"x.com.samsung.da.current": "0",
|
||||
"x.com.samsung.da.maximum": "5",
|
||||
"x.com.samsung.da.minimum": "-8",
|
||||
"x.com.samsung.da.unit": "Fahrenheit"
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.id": "1",
|
||||
"x.com.samsung.da.description": "Fridge",
|
||||
"x.com.samsung.da.desired": "37",
|
||||
"x.com.samsung.da.current": "37",
|
||||
"x.com.samsung.da.maximum": "44",
|
||||
"x.com.samsung.da.minimum": "34",
|
||||
"x.com.samsung.da.unit": "Fahrenheit"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/temperature/current/freezer/0",
|
||||
"rep": {
|
||||
"range": [
|
||||
-8.0,
|
||||
5.0
|
||||
],
|
||||
"units": "F",
|
||||
"temperature": 0.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/temperature/desired/freezer/0",
|
||||
"rep": {
|
||||
"range": [
|
||||
-8.0,
|
||||
5.0
|
||||
],
|
||||
"units": "F",
|
||||
"temperature": 0.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/temperature/current/cooler/0",
|
||||
"rep": {
|
||||
"range": [
|
||||
34.0,
|
||||
44.0
|
||||
],
|
||||
"units": "F",
|
||||
"temperature": 37.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/temperature/desired/cooler/0",
|
||||
"rep": {
|
||||
"range": [
|
||||
34.0,
|
||||
44.0
|
||||
],
|
||||
"units": "F",
|
||||
"temperature": 37.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/diagnosis/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.diagnosisStart": "Ready"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/energy/consumption/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.cumulativeConsumption": "288048",
|
||||
"x.com.samsung.da.cumulativePower": "1797027",
|
||||
"x.com.samsung.da.cumulativeUnit": "Wh",
|
||||
"x.com.samsung.da.instantaneousPower": "115",
|
||||
"x.com.samsung.da.instantaneousPowerUnit": "W",
|
||||
"x.com.samsung.da.monthlyConsumption": "55400",
|
||||
"x.com.samsung.da.thismonthlyConsumption": "39737"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/mode/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.supportedModes": [
|
||||
"HOMECARE_WIZARD_V2",
|
||||
"ENERGY_REPORT_MODEL",
|
||||
"18K_REF_OUTDOOR_CONTROL_V2",
|
||||
"SUPPORT_SABBATH_CONTROL"
|
||||
],
|
||||
"x.com.samsung.da.modes": [
|
||||
"CVN_CONVERTIBLE_ZONE",
|
||||
"CV_FDR_BEVERAGE",
|
||||
"WATERFILTER_DISABLE"
|
||||
],
|
||||
"x.com.samsung.da.supportedOptions": [
|
||||
"CV_FDR_WINE",
|
||||
"CV_FDR_DELI",
|
||||
"CV_FDR_BEVERAGE",
|
||||
"CV_FDR_MEAT"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/mode/0",
|
||||
"rep": {
|
||||
"supportedModes": [
|
||||
"HOMECARE_WIZARD_V2",
|
||||
"ENERGY_REPORT_MODEL",
|
||||
"18K_REF_OUTDOOR_CONTROL_V2",
|
||||
"SUPPORT_SABBATH_CONTROL"
|
||||
],
|
||||
"modes": [
|
||||
"CVN_CONVERTIBLE_ZONE",
|
||||
"CV_FDR_BEVERAGE",
|
||||
"WATERFILTER_DISABLE"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/defrost/block/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.supportedModes": [
|
||||
"DEFROST_BLOCK_ON",
|
||||
"DEFROST_BLOCK_OFF"
|
||||
],
|
||||
"x.com.samsung.da.modes": [
|
||||
"DEFROST_BLOCK_OFF"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/sabbath/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.sabbathMode": "Off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/realtimenotiforclient/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.timeforshortnoti": "0",
|
||||
"x.com.samsung.da.periodicnotisubscription": "true"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/information/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.modelNum": "TP2X_REF_20K|00113141|0002033F051311200103000010000000",
|
||||
"x.com.samsung.da.description": "TP2X_REF_20K",
|
||||
"x.com.samsung.da.serialNum": "REDACTED",
|
||||
"x.com.samsung.da.otnDUID": "REDACTED",
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.id": "0",
|
||||
"x.com.samsung.da.description": "WiFi Module",
|
||||
"x.com.samsung.da.type": "Software",
|
||||
"x.com.samsung.da.number": "02144A220110",
|
||||
"x.com.samsung.da.newVersionAvailable": "0"
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.id": "1",
|
||||
"x.com.samsung.da.description": "Micom",
|
||||
"x.com.samsung.da.type": "Firmware",
|
||||
"x.com.samsung.da.number": "2011030E,FFFFFFFF",
|
||||
"x.com.samsung.da.newVersionAvailable": "0"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/file/information/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.timeoffset": "+00:00",
|
||||
"x.com.samsung.supprtedtype": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/doors/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.id": "0",
|
||||
"x.com.samsung.da.description": "Door",
|
||||
"x.com.samsung.da.openState": "Close"
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.id": "1",
|
||||
"x.com.samsung.da.description": "Door",
|
||||
"x.com.samsung.da.openState": "Close"
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.id": "2",
|
||||
"x.com.samsung.da.description": "Door",
|
||||
"x.com.samsung.da.openState": "Close"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/door/freezer/0",
|
||||
"rep": {
|
||||
"openState": "Close"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/door/cooler/0",
|
||||
"rep": {
|
||||
"openState": "Close"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/door/cvroom/0",
|
||||
"rep": {
|
||||
"openState": "Close"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/configuration/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.countryCode": "US",
|
||||
"x.com.samsung.da.region": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/defrost/delay/0",
|
||||
"rep": {
|
||||
"value": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/defrost/delay/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.delayDefrost": "Off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/defrost/reservation/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.id": "0",
|
||||
"x.com.samsung.da.description": "Summer Season",
|
||||
"x.com.samsung.da.startTime": "0000-05-01T15:00:00",
|
||||
"x.com.samsung.da.period": "04:00:00",
|
||||
"x.com.samsung.da.endTime": "0000-10-31T00:00:00"
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.id": "1",
|
||||
"x.com.samsung.da.description": "Winter Season",
|
||||
"x.com.samsung.da.startTime": "0000-11-01T06:00:00",
|
||||
"x.com.samsung.da.period": "04:00:00",
|
||||
"x.com.samsung.da.endTime": "0000-04-30T00:00:00"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/icemaker/status/0",
|
||||
"rep": {
|
||||
"status": "On"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/icemaker/status/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.iceMaker": "On"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/refrigeration/0",
|
||||
"rep": {
|
||||
"defrost": false,
|
||||
"rapidFreeze": false,
|
||||
"rapidCool": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/refrigeration/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.rapidFridge": "Off",
|
||||
"x.com.samsung.da.rapidFreezing": "Off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/drlc/0",
|
||||
"rep": {
|
||||
"DRLevel": 0,
|
||||
"override": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/drlc/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.drlcLevel": "0",
|
||||
"x.com.samsung.da.override": "Off",
|
||||
"x.com.samsung.da.durationminutes": "0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/otninformation/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.target": "Micom",
|
||||
"x.com.samsung.da.newVersionAvailable": "false"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/filter/waterfilter/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.filterUsage": "0",
|
||||
"x.com.samsung.da.filterUsageResolution": "1",
|
||||
"x.com.samsung.da.filterStatus": "normal",
|
||||
"x.com.samsung.da.filterResetType": [
|
||||
"replaceable"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/icemaker/one/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.iceMaker.name": "ICE_MAKER",
|
||||
"x.com.samsung.da.iceMaker.state": "On",
|
||||
"x.com.samsung.da.iceMaker.type": "toggle",
|
||||
"x.com.samsung.da.iceMaker.iceMakingStatus": "ICESTATUS_STOP",
|
||||
"x.com.samsung.da.iceType.desired": "NORMAL"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
+15
@@ -44,6 +44,21 @@
|
||||
"href": "/energy/consumption/0",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/energy/ailevel/vs/0",
|
||||
"rep": {
|
||||
"aiLevel": "0",
|
||||
"supportedAiLevel": [
|
||||
"1"
|
||||
],
|
||||
"energySavingInfo": [
|
||||
{
|
||||
"aiLevel": "1",
|
||||
"maxEnergySavingRate": "70"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/course/vs/0",
|
||||
"rep": {
|
||||
|
||||
+325
@@ -0,0 +1,325 @@
|
||||
{
|
||||
"device0": [
|
||||
{
|
||||
"rt": [
|
||||
"x.com.samsung.devcol",
|
||||
"oic.wk.col"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.ll",
|
||||
"oic.if.b"
|
||||
]
|
||||
},
|
||||
{
|
||||
"href": "/realtimenotiforclient/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.timeforshortnoti": "0",
|
||||
"x.com.samsung.da.periodicnotisubscription": "true"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/alarms/vs/0",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/diagnosis/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.diagnosisStart": "Ready"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/energy/consumption/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.instantaneousPower": "-500",
|
||||
"x.com.samsung.da.instantaneousPowerUnit": "W",
|
||||
"x.com.samsung.da.cumulativePower": "1210200",
|
||||
"x.com.samsung.da.cumulativeUnit": "Wh",
|
||||
"x.com.samsung.da.cumulativeDate": "1784671200",
|
||||
"x.com.samsung.da.cumulativeDateUTC": "1784667600"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/energy/consumption/0",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/course/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.supportedModes": [
|
||||
"HOMECARE_WIZARD_V2"
|
||||
],
|
||||
"x.com.samsung.da.options": [
|
||||
"DeviceType_0167",
|
||||
"UpdateAllow_NotAllowed",
|
||||
"Course_1C",
|
||||
"AiOption_On",
|
||||
"LaundryOutTime_0",
|
||||
"AddWashSet_0",
|
||||
"AddWashAvailable_7",
|
||||
"AddWashIndicator_On",
|
||||
"SeamlessControl_Enable",
|
||||
"KidsLockBypass_On",
|
||||
"WashingTimes_25",
|
||||
"DrumCleanProposal_40",
|
||||
"DrumCleanLog_2024-07-10T22:36:33|2024-09-12T21:37:17|2024-12-06T02:13:28|2025-03-04T20:40:47|2025-05-25T11:02:07|2025-08-17T19:48:10|2025-10-30T20:20:13|2026-01-08T21:44:02|2026-04-02T21:17:40|2026-06-08T19:51:07",
|
||||
"DetergentOnce_1",
|
||||
"DetergentLeft_0",
|
||||
"DetergentBase_5",
|
||||
"DetergentAlarm_Off",
|
||||
"DetergentType_2",
|
||||
"DetergentTotal_49",
|
||||
"SoftenerOnce_0",
|
||||
"SoftenerLeft_0",
|
||||
"SoftenerBase_0",
|
||||
"SoftenerAlarm_Off",
|
||||
"SoftenerType_0",
|
||||
"SoftenerTotal_0",
|
||||
"SpecialFunction_4",
|
||||
"AvailableDelayTime_159",
|
||||
"BubbleSoak_Off",
|
||||
"LaundryPlannerUserSetTime_0",
|
||||
"ProgressTimeSet_420BF48204D4A204F5",
|
||||
"SendToDevice_Off",
|
||||
"GMT_02",
|
||||
"LaundryPlannerMicomSetTime_1704",
|
||||
"PreWashSetting_Off",
|
||||
"IntensiveSetting_Off",
|
||||
"BubbleSoakSet_00F000F000F000F0F0F0F00000F000F0F00000F000000000",
|
||||
"EnergyLevelSet_05030401040104020404020503020202030301010505050405",
|
||||
"MostUsed_1C8410923FA67FB01F0000000000",
|
||||
"PreWashAvailableSet_F0F000F0F0F000F0F0F0F00000F0F0F0F00000F000000000",
|
||||
"IntensiveAvailableSet_F0F000F0F0F000F0F0F0F00000F0F0F0F00000F000000000",
|
||||
"GeoFenceAlarm",
|
||||
"UsagesDB_ok",
|
||||
"EnergyKW_396",
|
||||
"DrumCleanLog_2024-07-10T22:36:33|2024-09-12T21:37:17|2024-12-06T02:13:28|2025-03-04T20:40:47|2025-05-25T11:02:07|2025-08-17T19:48:10|2025-10-30T20:20:13|2026-01-08T21:44:02|2026-04-02T21:17:40|2026-06-08T19:51:07",
|
||||
"TimeSync_NotSupported"
|
||||
],
|
||||
"x.com.samsung.da.supportedOptions": [
|
||||
"41C8410923FA67FB01F1B847E923FA67FB01F1E811E933FA53FB01F1D841E923FA67FB01F1F8102923FA57FB01F25843E923FA57FB01F26841E920FA207B01F33857E933FA67FB00024841E930FA30FB00032833E923FA37FB00020857E943FA67FB01F22841E920FA30FB00023831E930FA57FB01F2F831E933FA30FB00021831E943FA57FB0002D841E923FA30FB00030843E923FA67FB000278000913FA67FB01F2880009000A67EB01F368410923FA640B11E3880009000A000B11E3980009000A000B11E3780009000A000B0002985209204A520B000"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/power/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.power": "Off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/power/0",
|
||||
"rep": {
|
||||
"value": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/cycleinterface/vs/0",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/kidslock/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.kidsLock": "Ready"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/kidslock/0",
|
||||
"rep": {
|
||||
"value": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/operational/state/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.state": "Ready",
|
||||
"x.com.samsung.da.remainingTime": "02:39:00",
|
||||
"x.com.samsung.da.progressPercentage": "1",
|
||||
"x.com.samsung.da.progress": "None",
|
||||
"x.com.samsung.da.delayEndTime": "00:00:00",
|
||||
"x.com.samsung.da.supportedProgress": [
|
||||
"None",
|
||||
"Weightsensing",
|
||||
"Wash",
|
||||
"Rinse",
|
||||
"Spin",
|
||||
"Finish"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/operational/state/0",
|
||||
"rep": {
|
||||
"currentMachineState": "**REDACTED**",
|
||||
"machineStates": "**REDACTED**",
|
||||
"jobStates": [
|
||||
"None",
|
||||
"Weightsensing",
|
||||
"Wash",
|
||||
"Rinse",
|
||||
"Spin",
|
||||
"Finish"
|
||||
],
|
||||
"currentJobState": "None",
|
||||
"remainingTime": "02:39:00",
|
||||
"progressPercentage": "1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/information/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.modelNum": "DA_WM_TP2_20_COMMON|20260041|20010102011411B402A3020700000000",
|
||||
"x.com.samsung.da.description": "DA_WM_TP2_20_COMMON_WW6600R/DC92-02617A_5049",
|
||||
"x.com.samsung.da.serialNum": "REDACTED",
|
||||
"x.com.samsung.da.otnDUID": "REDACTED",
|
||||
"x.com.samsung.da.diagProtocolType": "WIFI_HTTPS",
|
||||
"x.com.samsung.da.diagLogType": [
|
||||
"errCode",
|
||||
"dump"
|
||||
],
|
||||
"x.com.samsung.da.diagDumpType": "file",
|
||||
"x.com.samsung.da.diagEndPoint": "SSM",
|
||||
"x.com.samsung.da.diagMnid": "0AJT",
|
||||
"x.com.samsung.da.diagSetupid": "210",
|
||||
"x.com.samsung.da.diagMinVersion": "1.0",
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.id": "0",
|
||||
"x.com.samsung.da.description": "DA_WM_TP2_20_COMMON|20260041|20010102011411B402A3020700000000",
|
||||
"x.com.samsung.da.type": "Software",
|
||||
"x.com.samsung.da.number": "02674A250414(F546)",
|
||||
"x.com.samsung.da.newVersionAvailable": "0"
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.id": "1",
|
||||
"x.com.samsung.da.description": "Firmware_1_DB_20260041200911390FFFFF202617412101203104FFFF(01672026004120261741_30000000)(FileDown:0)(Type:0)",
|
||||
"x.com.samsung.da.type": "Firmware",
|
||||
"x.com.samsung.da.number": "20091139,21012031",
|
||||
"x.com.samsung.da.newVersionAvailable": "0"
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.id": "2",
|
||||
"x.com.samsung.da.description": "Firmware_2_DB_2025984120072205042FFFFFFFFFFFFFFFFFFFFFFFFE(016720259841FFFFFFFF_30000000)(FileDown:0)(Type:0)",
|
||||
"x.com.samsung.da.type": "Firmware",
|
||||
"x.com.samsung.da.number": "20072205,FFFFFFFF"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/file/information/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.timeoffset": "+01:00"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/washer/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.waterTemperature": "40",
|
||||
"x.com.samsung.da.supportedWaterTemperature": [
|
||||
"None",
|
||||
"Cold",
|
||||
"20",
|
||||
"30",
|
||||
"40",
|
||||
"60",
|
||||
"90"
|
||||
],
|
||||
"x.com.samsung.da.spinLevel": "1400",
|
||||
"x.com.samsung.da.supportedSpinLevel": [
|
||||
"RinseHold",
|
||||
"NoSpin",
|
||||
"400",
|
||||
"800",
|
||||
"1000",
|
||||
"1200",
|
||||
"1400"
|
||||
],
|
||||
"x.com.samsung.da.dryLevel": "None",
|
||||
"x.com.samsung.da.supportedDryLevel": [
|
||||
"None",
|
||||
"Cupboard",
|
||||
"30",
|
||||
"60",
|
||||
"90",
|
||||
"120",
|
||||
"180",
|
||||
"240"
|
||||
],
|
||||
"x.com.samsung.da.rinseCycles": "2",
|
||||
"x.com.samsung.da.supportedRinseCycles": [
|
||||
"0",
|
||||
"1",
|
||||
"2",
|
||||
"3",
|
||||
"4",
|
||||
"5"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/st/washercourse/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.st.washerMode": "Table_02_Course_1C",
|
||||
"x.com.samsung.da.st.courseTable": "Table_02"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/setting/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.supportedSetLanguage": [
|
||||
"ko_KR",
|
||||
"en_US"
|
||||
],
|
||||
"x.com.samsung.da.setLanguage": "en_GB"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/wm/editcourse/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.editCourseList": "EditCourseList_1C3026241E293633281D27211B202F2223323825392D1F37",
|
||||
"x.com.samsung.da.fixedCourseList": "FixedCourseList_1C353A29"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/wm/setinfo/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.isModelSettingWithoutSC": "true",
|
||||
"x.com.samsung.da.aiCourse": "true_formobile",
|
||||
"x.com.samsung.da.isModelSettingPowerOnOff": "false"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/wm/jobbeginingstatus/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.currentStatus": "None"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/otninformation/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.target": "Micom",
|
||||
"x.com.samsung.da.newVersionAvailable": "false"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/remotectrl/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.remoteControlEnabled": "false"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/remotectrl/0",
|
||||
"rep": {
|
||||
"value": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/configuration/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.region": "0000000000",
|
||||
"x.com.samsung.da.countryCode": "GB"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
+253
@@ -0,0 +1,253 @@
|
||||
{
|
||||
"device0": [
|
||||
{
|
||||
"rt": [
|
||||
"x.com.samsung.devcol",
|
||||
"oic.wk.col"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.ll",
|
||||
"oic.if.b"
|
||||
]
|
||||
},
|
||||
{
|
||||
"href": "/realtimenotiforclient/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.timeforshortnoti": "0",
|
||||
"x.com.samsung.da.periodicnotisubscription": "true"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/alarms/vs/0",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/diagnosis/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.diagnosisStart": "Ready"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/energy/consumption/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.instantaneousPowerUnit": "W",
|
||||
"x.com.samsung.da.instantaneousPower": "-500",
|
||||
"x.com.samsung.da.cumulativePower": "199200",
|
||||
"x.com.samsung.da.cumulativeUnit": "Wh",
|
||||
"x.com.samsung.da.cumulativeDate": "1784631600",
|
||||
"x.com.samsung.da.cumulativeDateUTC": "1784646000"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/energy/consumption/0",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/course/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.supportedModes": [
|
||||
"HOMECARE_WIZARD_V2"
|
||||
],
|
||||
"x.com.samsung.da.options": [
|
||||
"DeviceType_0146",
|
||||
"UpdateAllow_NotAllowed",
|
||||
"Course_A5",
|
||||
"LaundryOutTime_0",
|
||||
"AddGarmentIndicator_Off",
|
||||
"SteamWash_Enable",
|
||||
"SeamlessControl_Disable",
|
||||
"KidsLockBypass_On",
|
||||
"WashingTimes_14",
|
||||
"DrumCleanProposal_40",
|
||||
"DetergentOnce_0",
|
||||
"DetergentLeft_0",
|
||||
"DetergentBase_0",
|
||||
"DetergentAlarm_Off",
|
||||
"DetergentType_0",
|
||||
"DetergentTotal_0",
|
||||
"SpecialFunction_4",
|
||||
"AvailableDelayTime_124",
|
||||
"LaundryPlannerUserSetTime_0",
|
||||
"ProgressTimeSet_4210E08209F6A2021C",
|
||||
"EnergyLevelSet_0502040502030302030105",
|
||||
"UsagesDB_ok",
|
||||
"EnergyKW_396",
|
||||
"DrumCleanLog_2023-08-11T19:17:11|2023-11-02T03:23:13|2023-12-22T22:35:54|2024-04-08T01:27:32|2024-04-22T01:07:26|2024-10-02T19:16:45|2025-02-03T02:01:33|2025-06-16T17:36:13|2026-01-04T18:34:46|2026-05-26T02:33:16",
|
||||
"TimeSync_NotSupported"
|
||||
],
|
||||
"x.com.samsung.da.supportedOptions": [
|
||||
"301833EA41BC20E9C843EA41FC30EA98520A41FC20EA2810EA207C20EAA831EA41FC20EA5830EA20FC20E9F831EA41FC10E9E830EA21FC20E9D8000A41FC000B18520A410C000"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/power/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.power": "On"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/power/0",
|
||||
"rep": {
|
||||
"value": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/cycleinterface/vs/0",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/kidslock/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.kidsLock": "Ready"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/kidslock/0",
|
||||
"rep": {
|
||||
"value": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/operational/state/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.state": "Ready",
|
||||
"x.com.samsung.da.remainingTime": "02:04:00",
|
||||
"x.com.samsung.da.progressPercentage": "1",
|
||||
"x.com.samsung.da.progress": "None",
|
||||
"x.com.samsung.da.delayEndTime": "00:00:00",
|
||||
"x.com.samsung.da.supportedProgress": [
|
||||
"None",
|
||||
"Wash",
|
||||
"Rinse",
|
||||
"Spin",
|
||||
"Finish"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/operational/state/0",
|
||||
"rep": {
|
||||
"currentMachineState": "**REDACTED**",
|
||||
"machineStates": "**REDACTED**",
|
||||
"jobStates": [
|
||||
"None",
|
||||
"Wash",
|
||||
"Rinse",
|
||||
"Spin",
|
||||
"Finish"
|
||||
],
|
||||
"currentJobState": "None",
|
||||
"remainingTime": "02:04:00",
|
||||
"progressPercentage": "1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/information/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.modelNum": "DA_WM_A51_20_COMMON|20198042|20020001001111400203000000000000",
|
||||
"x.com.samsung.da.description": "DA_WM_A51_20_COMMON_WV9600M/DC92-01980B_0014",
|
||||
"x.com.samsung.da.serialNum": "REDACTED",
|
||||
"x.com.samsung.da.otnDUID": "REDACTED",
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.id": "0",
|
||||
"x.com.samsung.da.description": "DA_WM_A51_20_COMMON|20198042|20020001001111400203000000000000",
|
||||
"x.com.samsung.da.type": "Software",
|
||||
"x.com.samsung.da.number": "02198A230708(E257)",
|
||||
"x.com.samsung.da.newVersionAvailable": "0"
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.id": "1",
|
||||
"x.com.samsung.da.description": "DA_WM_A51_20_COMMON",
|
||||
"x.com.samsung.da.type": "Firmware",
|
||||
"x.com.samsung.da.number": "18030902,18090603",
|
||||
"x.com.samsung.da.newVersionAvailable": "0"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/file/information/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.timeoffset": "-04:00"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/washer/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.waterTemperature": "Warm",
|
||||
"x.com.samsung.da.supportedWaterTemperature": [
|
||||
"None",
|
||||
"Cold",
|
||||
"Cool",
|
||||
"Warm",
|
||||
"Hot",
|
||||
"ExtraHot"
|
||||
],
|
||||
"x.com.samsung.da.spinLevel": "Low",
|
||||
"x.com.samsung.da.supportedSpinLevel": [
|
||||
"RinseHold",
|
||||
"NoSpin",
|
||||
"Low",
|
||||
"Medium",
|
||||
"High"
|
||||
],
|
||||
"x.com.samsung.da.soilLevel": "Heavy",
|
||||
"x.com.samsung.da.supportedSoilLevel": [
|
||||
"None",
|
||||
"Light",
|
||||
"Normal",
|
||||
"Heavy"
|
||||
],
|
||||
"x.com.samsung.da.rinseCycles": "3"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/st/washercourse/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.st.washerMode": "Table_00_Course_A5",
|
||||
"x.com.samsung.da.st.courseTable": "Table_00"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/setting/vs/0",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/wm/editcourse/vs/0",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/wm/setinfo/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.isModelSettingWithoutSC": "false",
|
||||
"x.com.samsung.da.isModelSettingPowerOnOff": "false"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/wm/jobbeginingstatus/vs/0",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/remotectrl/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.remoteControlEnabled": "true"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/remotectrl/0",
|
||||
"rep": {
|
||||
"value": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/configuration/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.region": "0000000000",
|
||||
"x.com.samsung.da.countryCode": "US"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -3,8 +3,10 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from unittest.mock import patch
|
||||
|
||||
import cbor2
|
||||
import pytest
|
||||
from pytest_homeassistant_custom_component.common import MockConfigEntry
|
||||
|
||||
@@ -150,12 +152,26 @@ class FakeObserveSession:
|
||||
self.subscribed: list[str] = []
|
||||
self.fail_hrefs: set[str] = set()
|
||||
self.closed = False
|
||||
# When set to a rep dict, subscribe() immediately delivers that rep
|
||||
# as an OBSERVE notification for the href — what a real device does
|
||||
# when it answers a subscription with the current representation.
|
||||
# `try_enter_observe_mode` clears its notified set before it
|
||||
# subscribes, so this is the only way to deliver a notify that
|
||||
# reliably counts: it lands synchronously via on_notification,
|
||||
# after that clear and before the post-sleep fraction check — a
|
||||
# notify raced in from another thread can be wiped by the clear
|
||||
# (or arrive after the check) depending on scheduling. Set to
|
||||
# None to model a device that answers subscriptions but never
|
||||
# notifies.
|
||||
self.notify_on_subscribe: dict[str, Any] | None = None
|
||||
|
||||
def subscribe(self, path_segs):
|
||||
href = '/' + '/'.join(path_segs)
|
||||
if href in self.fail_hrefs:
|
||||
raise ConnectionError("subscribe failed")
|
||||
self.subscribed.append(href)
|
||||
if self.notify_on_subscribe is not None and self.on_notification is not None:
|
||||
self.on_notification(href, cbor2.dumps(self.notify_on_subscribe))
|
||||
return b'\x01'
|
||||
|
||||
def refresh_observes(self, paths):
|
||||
|
||||
@@ -56,6 +56,113 @@ async def test_successful_setup(hass: HomeAssistant, mock_probe) -> None:
|
||||
assert result['data'][CONF_CA_CERT_PEM] == MOCK_CA_CERT_PEM
|
||||
|
||||
|
||||
def test_order_candidates_prefers_known_ports() -> None:
|
||||
"""Live ports are ordered with the historically known DTLS ports first,
|
||||
then the rest ascending."""
|
||||
from custom_components.localthings.config_flow import _order_candidates
|
||||
|
||||
assert _order_candidates([49160, 49153, 49155, 49154]) == [
|
||||
49154, 49155, 49153, 49160,
|
||||
]
|
||||
assert _order_candidates([49153]) == [49153]
|
||||
|
||||
|
||||
def test_find_live_ports_detects_silent_port(socket_enabled) -> None:
|
||||
"""The UDP liveness sweep flags a bound-but-silent port as live and drops
|
||||
ports that refuse with ICMP port-unreachable.
|
||||
|
||||
A bound, never-recv'd UDP socket stands in for a device that listens but
|
||||
stays silent (open|filtered), like the dishwasher in issue #13 on 49153.
|
||||
Two sibling ports are reserved then closed so loopback refuses datagrams
|
||||
to them, standing in for the closed ports the scan should discard.
|
||||
|
||||
`socket_enabled` lifts pytest-socket's default block (the HA test harness
|
||||
disables real sockets); this test genuinely needs loopback UDP to exercise
|
||||
the ICMP-unreachable path.
|
||||
"""
|
||||
import socket
|
||||
|
||||
from custom_components.localthings.config_flow import _find_live_ports
|
||||
|
||||
reserve = [socket.socket(socket.AF_INET, socket.SOCK_DGRAM) for _ in range(3)]
|
||||
for s in reserve:
|
||||
s.bind(('127.0.0.1', 0))
|
||||
ports = [s.getsockname()[1] for s in reserve]
|
||||
live_sock, live_port = reserve[0], ports[0]
|
||||
reserve[1].close()
|
||||
reserve[2].close()
|
||||
closed_ports = ports[1:]
|
||||
|
||||
try:
|
||||
result = _find_live_ports(
|
||||
'127.0.0.1', [closed_ports[0], live_port, closed_ports[1]], 0.8,
|
||||
)
|
||||
finally:
|
||||
live_sock.close()
|
||||
|
||||
assert result == [live_port]
|
||||
|
||||
|
||||
async def test_probe_uses_discovered_low_port(hass: HomeAssistant, monkeypatch) -> None:
|
||||
"""A device that only answers on 49153 — outside the historical
|
||||
49154/49155 pair — is found by the liveness sweep and its port is stored
|
||||
on the config entry (issue #13)."""
|
||||
import cbor2
|
||||
|
||||
from custom_components.localthings import config_flow
|
||||
|
||||
device0 = [
|
||||
{'rt': ['x.com.samsung.devcol']},
|
||||
{'href': '/information/vs/0', 'rep': {
|
||||
'x.com.samsung.da.modelNum':
|
||||
'DA_WM_TP1_21_COMMON|20375141|20010002001811424AA30217008A0000',
|
||||
'x.com.samsung.da.description':
|
||||
'DA_WM_TP1_21_COMMON_WW5000C/DC92-03495A_B048',
|
||||
'x.com.samsung.da.serialNum': 'DISHWASHER-49153',
|
||||
}},
|
||||
{'href': '/otninformation/vs/0', 'rep': {'otnStatus': 'None'}},
|
||||
]
|
||||
|
||||
class _FakeSession:
|
||||
def __init__(self, host, port, cert_pem=None, key_pem=None):
|
||||
self.host, self.port = host, port
|
||||
|
||||
def connect(self):
|
||||
pass
|
||||
|
||||
def start_reader(self):
|
||||
pass
|
||||
|
||||
def get(self, path, timeout=15.0):
|
||||
return 0x45, cbor2.dumps(device0)
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
monkeypatch.setattr(config_flow, '_fetch_samsung_uuid', lambda: 'test-uuid')
|
||||
monkeypatch.setattr(
|
||||
config_flow, '_mint_leaf_cert',
|
||||
lambda ca_cert, ca_key, uuid: ('FULLCHAIN', 'LEAFKEY'),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
config_flow, '_find_live_ports',
|
||||
lambda host, ports, timeout: [49153],
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
'smartthings_local.protocol.dtls_session.DtlsCoapSession', _FakeSession,
|
||||
)
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={'source': 'user'}
|
||||
)
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result['flow_id'],
|
||||
{CONF_HOST: MOCK_HOST, CONF_CA_CERT_PEM: MOCK_CA_CERT_PEM, CONF_CA_KEY_PEM: MOCK_CA_KEY_PEM},
|
||||
)
|
||||
assert result['type'] == FlowResultType.CREATE_ENTRY
|
||||
assert result['data'][CONF_PORT] == 49153
|
||||
|
||||
|
||||
async def test_cannot_connect(hass: HomeAssistant) -> None:
|
||||
"""Failed probe: form re-shown with cannot_connect error."""
|
||||
from custom_components.localthings.config_flow import CannotConnect
|
||||
|
||||
@@ -1,20 +1,22 @@
|
||||
"""Tests for the LocalThingsCoordinator."""
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import time
|
||||
from datetime import timedelta
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import cbor2
|
||||
import pytest
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ServiceValidationError
|
||||
from homeassistant.helpers import issue_registry as ir
|
||||
|
||||
from custom_components.localthings.const import (
|
||||
CONF_HOST, DOMAIN, SUMMARY_INTERVAL_S,
|
||||
)
|
||||
from custom_components.localthings.coordinator import LocalThingsCoordinator
|
||||
from custom_components.localthings.registry.capabilities.common import (
|
||||
remote_control_enabled,
|
||||
)
|
||||
from custom_components.localthings.observe import MODE_OBSERVE, MODE_POLL, PUSH_HEALTH_WINDOW_S
|
||||
|
||||
from .conftest import ENTRY_DATA, MOCK_SERIAL
|
||||
@@ -260,21 +262,18 @@ async def test_enters_observe_mode_when_hot_warm_hrefs_notify(
|
||||
coordinator: LocalThingsCoordinator = hass.data[DOMAIN][mock_entry.entry_id]
|
||||
hrefs = coordinator._hot_hrefs + coordinator._warm_hrefs
|
||||
|
||||
# try_enter_observe_mode clears prior notifications as soon as it
|
||||
# starts, so notifies must land *during* its grace-period sleep (same
|
||||
# pattern test_observe.py uses), not before the call.
|
||||
def _notify_during_grace_period():
|
||||
time.sleep(0.005)
|
||||
for href in hrefs:
|
||||
fake.on_notification(href, cbor2.dumps({'notified': True}))
|
||||
|
||||
notifier = threading.Thread(target=_notify_during_grace_period, daemon=True)
|
||||
notifier.start()
|
||||
# try_enter_observe_mode clears prior notifications before it
|
||||
# subscribes, then checks the notified fraction after the grace-period
|
||||
# sleep — a notify counts as long as it arrives in that window, not
|
||||
# specifically during the sleep itself. The fake session delivers one
|
||||
# synchronously from subscribe() for every href it subscribes to (see
|
||||
# FakeObserveSession.notify_on_subscribe in conftest.py), which lands
|
||||
# it in that window by construction rather than by timing.
|
||||
fake.notify_on_subscribe = {'notified': True}
|
||||
entered = await hass.async_add_executor_job(
|
||||
coordinator._observe.try_enter_observe_mode,
|
||||
fake, hrefs, 0.02, 0.8,
|
||||
)
|
||||
notifier.join()
|
||||
|
||||
assert entered is True
|
||||
assert coordinator._observe.mode == MODE_OBSERVE
|
||||
@@ -300,18 +299,11 @@ async def test_reconnect_while_observe_mode_downgrades_to_poll(
|
||||
|
||||
# Get the coordinator into observe mode the same way
|
||||
# test_enters_observe_mode_when_hot_warm_hrefs_notify does.
|
||||
def _notify_during_grace_period():
|
||||
time.sleep(0.005)
|
||||
for href in hrefs:
|
||||
fake.on_notification(href, cbor2.dumps({'notified': True}))
|
||||
|
||||
notifier = threading.Thread(target=_notify_during_grace_period, daemon=True)
|
||||
notifier.start()
|
||||
fake.notify_on_subscribe = {'notified': True}
|
||||
entered = await hass.async_add_executor_job(
|
||||
coordinator._observe.try_enter_observe_mode,
|
||||
fake, hrefs, 0.02, 0.8,
|
||||
)
|
||||
notifier.join()
|
||||
assert entered is True
|
||||
assert coordinator.observe_mode == MODE_OBSERVE
|
||||
|
||||
@@ -324,12 +316,12 @@ async def test_reconnect_while_observe_mode_downgrades_to_poll(
|
||||
|
||||
# Simulate the existing "poll failed, reconnecting" branch: _poll_once
|
||||
# fails once (triggering the reconnect/backoff path), then succeeds.
|
||||
# No fresh notifies are supplied for the immediate resubscribe attempt
|
||||
# this now triggers, so its grace period (shortened for tests by the
|
||||
# `_fast_coordinator_timers` autouse fixture — see conftest.py) times
|
||||
# out and mode stays 'poll' — this test only asserts the tear-down half of
|
||||
# the fix; test_reconnect_from_observe_mode_resubscribes_immediately
|
||||
# covers the successful-immediate-resubscribe half.
|
||||
# The device stops notifying on subscribe, so the immediate resubscribe
|
||||
# attempt this now triggers gets nothing back and mode stays 'poll' —
|
||||
# this test only asserts the tear-down half of the fix;
|
||||
# test_reconnect_from_observe_mode_resubscribes_immediately covers the
|
||||
# successful-immediate-resubscribe half.
|
||||
fake.notify_on_subscribe = None
|
||||
with (
|
||||
patch(
|
||||
'custom_components.localthings.coordinator.LocalThingsCoordinator._poll_once',
|
||||
@@ -362,18 +354,11 @@ async def test_poll_timeout_skips_reconnect_when_push_is_healthy(
|
||||
coordinator: LocalThingsCoordinator = hass.data[DOMAIN][mock_entry.entry_id]
|
||||
hrefs = coordinator._hot_hrefs + coordinator._warm_hrefs
|
||||
|
||||
def _notify_during_grace_period():
|
||||
time.sleep(0.005)
|
||||
for href in hrefs:
|
||||
fake.on_notification(href, cbor2.dumps({'notified': True}))
|
||||
|
||||
notifier = threading.Thread(target=_notify_during_grace_period, daemon=True)
|
||||
notifier.start()
|
||||
fake.notify_on_subscribe = {'notified': True}
|
||||
entered = await hass.async_add_executor_job(
|
||||
coordinator._observe.try_enter_observe_mode,
|
||||
fake, hrefs, 0.02, 0.8,
|
||||
)
|
||||
notifier.join()
|
||||
assert entered is True
|
||||
assert coordinator.observe_mode == MODE_OBSERVE
|
||||
|
||||
@@ -408,18 +393,11 @@ async def test_poll_timeout_reconnects_after_consecutive_limit_even_with_push(
|
||||
coordinator: LocalThingsCoordinator = hass.data[DOMAIN][mock_entry.entry_id]
|
||||
hrefs = coordinator._hot_hrefs + coordinator._warm_hrefs
|
||||
|
||||
def _notify_during_grace_period():
|
||||
time.sleep(0.005)
|
||||
for href in hrefs:
|
||||
fake.on_notification(href, cbor2.dumps({'notified': True}))
|
||||
|
||||
notifier = threading.Thread(target=_notify_during_grace_period, daemon=True)
|
||||
notifier.start()
|
||||
fake.notify_on_subscribe = {'notified': True}
|
||||
entered = await hass.async_add_executor_job(
|
||||
coordinator._observe.try_enter_observe_mode,
|
||||
fake, hrefs, 0.02, 0.8,
|
||||
)
|
||||
notifier.join()
|
||||
assert entered is True
|
||||
assert coordinator.observe_mode == MODE_OBSERVE
|
||||
|
||||
@@ -441,7 +419,10 @@ async def test_poll_timeout_reconnects_after_consecutive_limit_even_with_push(
|
||||
assert coordinator.observe_mode == MODE_OBSERVE
|
||||
|
||||
# The final consecutive timeout crosses the limit and triggers the
|
||||
# existing reconnect path, which then succeeds and downgrades.
|
||||
# existing reconnect path, which then succeeds and downgrades. The
|
||||
# device stops notifying on subscribe, so the resubscribe attempt that
|
||||
# follows the reconnect finds nothing and mode stays 'poll'.
|
||||
fake.notify_on_subscribe = None
|
||||
with (
|
||||
patch(
|
||||
'custom_components.localthings.coordinator.LocalThingsCoordinator._poll_once',
|
||||
@@ -473,18 +454,11 @@ async def test_poll_timeout_counter_resets_when_push_is_healthy_again(
|
||||
coordinator: LocalThingsCoordinator = hass.data[DOMAIN][mock_entry.entry_id]
|
||||
hrefs = coordinator._hot_hrefs + coordinator._warm_hrefs
|
||||
|
||||
def _notify_during_grace_period():
|
||||
time.sleep(0.005)
|
||||
for href in hrefs:
|
||||
fake.on_notification(href, cbor2.dumps({'notified': True}))
|
||||
|
||||
notifier = threading.Thread(target=_notify_during_grace_period, daemon=True)
|
||||
notifier.start()
|
||||
fake.notify_on_subscribe = {'notified': True}
|
||||
entered = await hass.async_add_executor_job(
|
||||
coordinator._observe.try_enter_observe_mode,
|
||||
fake, hrefs, 0.02, 0.8,
|
||||
)
|
||||
notifier.join()
|
||||
assert entered is True
|
||||
assert coordinator.observe_mode == MODE_OBSERVE
|
||||
|
||||
@@ -526,21 +500,11 @@ async def test_reconnect_from_observe_mode_resubscribes_immediately(
|
||||
coordinator: LocalThingsCoordinator = hass.data[DOMAIN][mock_entry.entry_id]
|
||||
hrefs = coordinator._hot_hrefs + coordinator._warm_hrefs
|
||||
|
||||
def _notify(delay: float = 0.005) -> threading.Thread:
|
||||
def _run():
|
||||
time.sleep(delay)
|
||||
for href in hrefs:
|
||||
fake.on_notification(href, cbor2.dumps({'notified': True}))
|
||||
t = threading.Thread(target=_run, daemon=True)
|
||||
t.start()
|
||||
return t
|
||||
|
||||
notifier = _notify()
|
||||
fake.notify_on_subscribe = {'notified': True}
|
||||
entered = await hass.async_add_executor_job(
|
||||
coordinator._observe.try_enter_observe_mode,
|
||||
fake, hrefs, 0.02, 0.8,
|
||||
)
|
||||
notifier.join()
|
||||
assert entered is True
|
||||
assert coordinator.observe_mode == MODE_OBSERVE
|
||||
|
||||
@@ -563,10 +527,8 @@ async def test_reconnect_from_observe_mode_resubscribes_immediately(
|
||||
new=AsyncMock(),
|
||||
),
|
||||
):
|
||||
notifier = _notify()
|
||||
await coordinator.async_request_refresh()
|
||||
await hass.async_block_till_done()
|
||||
notifier.join()
|
||||
|
||||
assert coordinator.observe_mode == MODE_OBSERVE
|
||||
|
||||
@@ -587,21 +549,11 @@ async def test_sweep_mismatch_never_downgrades_a_live_observe_session(
|
||||
hrefs = coordinator._hot_hrefs + coordinator._warm_hrefs
|
||||
assert len(hrefs) >= 2
|
||||
|
||||
def _notify(delay: float = 0.005) -> threading.Thread:
|
||||
def _run():
|
||||
time.sleep(delay)
|
||||
for href in hrefs:
|
||||
fake.on_notification(href, cbor2.dumps({'notified': True}))
|
||||
t = threading.Thread(target=_run, daemon=True)
|
||||
t.start()
|
||||
return t
|
||||
|
||||
notifier = _notify()
|
||||
fake.notify_on_subscribe = {'notified': True}
|
||||
entered = await hass.async_add_executor_job(
|
||||
coordinator._observe.try_enter_observe_mode,
|
||||
fake, hrefs, 0.02, 0.8,
|
||||
)
|
||||
notifier.join()
|
||||
assert entered is True
|
||||
assert coordinator.observe_mode == MODE_OBSERVE
|
||||
|
||||
@@ -637,21 +589,11 @@ async def test_sweep_mismatch_forces_subpolls_on_a_live_observe_session(
|
||||
hrefs = coordinator._hot_hrefs + coordinator._warm_hrefs
|
||||
assert len(hrefs) >= 2
|
||||
|
||||
def _notify(delay: float = 0.005) -> threading.Thread:
|
||||
def _run():
|
||||
time.sleep(delay)
|
||||
for href in hrefs:
|
||||
fake.on_notification(href, cbor2.dumps({'notified': True}))
|
||||
t = threading.Thread(target=_run, daemon=True)
|
||||
t.start()
|
||||
return t
|
||||
|
||||
notifier = _notify()
|
||||
fake.notify_on_subscribe = {'notified': True}
|
||||
entered = await hass.async_add_executor_job(
|
||||
coordinator._observe.try_enter_observe_mode,
|
||||
fake, hrefs, 0.02, 0.8,
|
||||
)
|
||||
notifier.join()
|
||||
assert entered is True
|
||||
assert coordinator.observe_mode == MODE_OBSERVE
|
||||
|
||||
@@ -696,3 +638,175 @@ async def test_write_marks_href_pending_before_post(
|
||||
await coordinator.async_send_command(bound, 5)
|
||||
|
||||
assert coordinator._observe._settle_until.get('/test/vs/0') is not None
|
||||
|
||||
|
||||
async def test_send_command_applies_write_optimistically_before_settling(
|
||||
hass: HomeAssistant, mock_entry, mock_coordinator_observe_session
|
||||
) -> None:
|
||||
"""The cache must reflect a write immediately, and stay put against a
|
||||
stale echo for the rest of the settle window -- otherwise
|
||||
mark_write_pending has nothing to protect and just delays the real
|
||||
device confirmation instead, which reads to a user as the write being
|
||||
silently reverted (issue #27)."""
|
||||
from custom_components.localthings.registry.discovery import BoundEntity
|
||||
from custom_components.localthings.registry.entities import NumberDesc
|
||||
|
||||
fake = mock_coordinator_observe_session
|
||||
await hass.config_entries.async_setup(mock_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
coordinator: LocalThingsCoordinator = hass.data[DOMAIN][mock_entry.entry_id]
|
||||
|
||||
def _write_fn(payload, rep, href):
|
||||
return (['some', 'path'], {'value': payload})
|
||||
|
||||
desc = NumberDesc(key='test', field='value', write_fn=_write_fn)
|
||||
bound = BoundEntity(href='/test/vs/0', capability=coordinator.bound[0].capability, desc=desc)
|
||||
|
||||
with patch.object(fake, 'subscribe'):
|
||||
fake.post = lambda *a, **k: (0x44, b'')
|
||||
await coordinator.async_send_command(bound, 5)
|
||||
|
||||
# The optimistic value is visible right away, without waiting for a
|
||||
# device response.
|
||||
assert coordinator._cache.get('/test/vs/0') == {'value': 5}
|
||||
|
||||
# A stale update racing in behind the write (e.g. a poll/notify that
|
||||
# was already in flight before the PUT) must not clobber it while the
|
||||
# settle window is open.
|
||||
applied = coordinator._observe.apply('/test/vs/0', {'value': 0}, source='observe')
|
||||
assert applied is False
|
||||
assert coordinator._cache.get('/test/vs/0') == {'value': 5}
|
||||
|
||||
|
||||
class TestRemoteControlEnabled:
|
||||
"""remote_control_enabled (registry/capabilities/common.py) is the
|
||||
single source of truth for the /remotectrl on/off signal, shared by
|
||||
the Smart Control binary_sensor descriptors and the coordinator's
|
||||
write guard alike."""
|
||||
|
||||
def test_vs_fallback_href_true_is_enabled(self):
|
||||
resources = {'/remotectrl/vs/0': {'x.com.samsung.da.remoteControlEnabled': 'true'}}
|
||||
assert remote_control_enabled(resources) is True
|
||||
|
||||
def test_vs_fallback_href_false_is_disabled(self):
|
||||
resources = {'/remotectrl/vs/0': {'x.com.samsung.da.remoteControlEnabled': 'false'}}
|
||||
assert remote_control_enabled(resources) is False
|
||||
|
||||
def test_generic_href_true_is_enabled(self):
|
||||
resources = {'/remotectrl/0': {'value': True}}
|
||||
assert remote_control_enabled(resources) is True
|
||||
|
||||
def test_generic_href_false_is_disabled(self):
|
||||
resources = {'/remotectrl/0': {'value': False}}
|
||||
assert remote_control_enabled(resources) is False
|
||||
|
||||
def test_generic_href_wins_when_both_present(self):
|
||||
resources = {
|
||||
'/remotectrl/0': {'value': True},
|
||||
'/remotectrl/vs/0': {'x.com.samsung.da.remoteControlEnabled': 'false'},
|
||||
}
|
||||
assert remote_control_enabled(resources) is True
|
||||
|
||||
def test_assumes_enabled_when_capability_absent(self):
|
||||
assert remote_control_enabled({}) is True
|
||||
|
||||
|
||||
async def test_send_command_blocked_when_remote_control_disabled(
|
||||
hass: HomeAssistant, mock_entry, mock_coordinator_observe_session
|
||||
) -> None:
|
||||
"""A device reporting remote control off must reject every write with
|
||||
a user-facing message, ahead of write_fn's silent-no-op path and any
|
||||
description-level validate_fn."""
|
||||
from custom_components.localthings.registry.discovery import BoundEntity
|
||||
from custom_components.localthings.registry.entities import NumberDesc
|
||||
|
||||
fake = mock_coordinator_observe_session
|
||||
await hass.config_entries.async_setup(mock_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
coordinator: LocalThingsCoordinator = hass.data[DOMAIN][mock_entry.entry_id]
|
||||
coordinator._cache.apply_rep(
|
||||
'/remotectrl/vs/0',
|
||||
{'x.com.samsung.da.remoteControlEnabled': 'false'},
|
||||
source='test',
|
||||
)
|
||||
|
||||
def _write_fn(payload, rep, href):
|
||||
return (['some', 'path'], {'value': payload})
|
||||
|
||||
desc = NumberDesc(key='test', field='value', write_fn=_write_fn)
|
||||
bound = BoundEntity(href='/test/vs/0', capability=coordinator.bound[0].capability, desc=desc)
|
||||
|
||||
posted = False
|
||||
|
||||
def _post(*a, **k):
|
||||
nonlocal posted
|
||||
posted = True
|
||||
return (0x44, b'')
|
||||
|
||||
with patch.object(fake, 'subscribe'):
|
||||
fake.post = _post
|
||||
with pytest.raises(ServiceValidationError, match="Remote control is turned off"):
|
||||
await coordinator.async_send_command(bound, 5)
|
||||
|
||||
assert posted is False
|
||||
|
||||
|
||||
async def test_send_command_allowed_when_remote_control_enabled(
|
||||
hass: HomeAssistant, mock_entry, mock_coordinator_observe_session
|
||||
) -> None:
|
||||
"""The same write goes through once remote control reports enabled."""
|
||||
from custom_components.localthings.registry.discovery import BoundEntity
|
||||
from custom_components.localthings.registry.entities import NumberDesc
|
||||
|
||||
fake = mock_coordinator_observe_session
|
||||
await hass.config_entries.async_setup(mock_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
coordinator: LocalThingsCoordinator = hass.data[DOMAIN][mock_entry.entry_id]
|
||||
coordinator._cache.apply_rep(
|
||||
'/remotectrl/vs/0',
|
||||
{'x.com.samsung.da.remoteControlEnabled': 'true'},
|
||||
source='test',
|
||||
)
|
||||
|
||||
def _write_fn(payload, rep, href):
|
||||
return (['some', 'path'], {'value': payload})
|
||||
|
||||
desc = NumberDesc(key='test', field='value', write_fn=_write_fn)
|
||||
bound = BoundEntity(href='/test/vs/0', capability=coordinator.bound[0].capability, desc=desc)
|
||||
|
||||
with patch.object(fake, 'subscribe'):
|
||||
fake.post = lambda *a, **k: (0x44, b'')
|
||||
await coordinator.async_send_command(bound, 5)
|
||||
|
||||
assert coordinator._observe._settle_until.get('/test/vs/0') is not None
|
||||
|
||||
|
||||
async def test_send_command_remote_control_check_precedes_validate_fn(
|
||||
hass: HomeAssistant, mock_entry, mock_coordinator_observe_session
|
||||
) -> None:
|
||||
"""When both a disabled remote-control state and a failing validate_fn
|
||||
apply to the same write, the remote-control message wins -- it's the
|
||||
more actionable of the two and is checked first."""
|
||||
from custom_components.localthings.registry.discovery import BoundEntity
|
||||
from custom_components.localthings.registry.entities import SwitchDesc
|
||||
|
||||
await hass.config_entries.async_setup(mock_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
coordinator: LocalThingsCoordinator = hass.data[DOMAIN][mock_entry.entry_id]
|
||||
coordinator._cache.apply_rep(
|
||||
'/remotectrl/vs/0',
|
||||
{'x.com.samsung.da.remoteControlEnabled': 'false'},
|
||||
source='test',
|
||||
)
|
||||
|
||||
def _write_fn(payload, rep, href=None):
|
||||
return (['some', 'path'], {'value': payload})
|
||||
|
||||
def _validate_fn(payload, rep, resources):
|
||||
return "always rejected"
|
||||
|
||||
desc = SwitchDesc(key='test', field='value', write_fn=_write_fn, validate_fn=_validate_fn)
|
||||
bound = BoundEntity(href='/test/vs/0', capability=coordinator.bound[0].capability, desc=desc)
|
||||
|
||||
with pytest.raises(ServiceValidationError, match="Remote control is turned off"):
|
||||
await coordinator.async_send_command(bound, 'On')
|
||||
|
||||
@@ -2,10 +2,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from custom_components.localthings import diagnostics as diagnostics_mod
|
||||
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
|
||||
@@ -49,3 +51,27 @@ async def test_diagnostics_include_observe_mode_fields(
|
||||
assert diag['observe_fallback_hrefs'] == []
|
||||
assert 'observe_last_mode_change' in diag
|
||||
assert diag['observe_href_freshness_s'] == {}
|
||||
|
||||
|
||||
async def test_dependency_version_read_off_the_event_loop(
|
||||
hass: HomeAssistant, mock_entry, mock_coordinator_session, monkeypatch
|
||||
) -> None:
|
||||
"""importlib.metadata.version() does blocking disk I/O, so it must run in
|
||||
the executor, not on the event loop (issue #9's logs flagged it)."""
|
||||
await hass.config_entries.async_setup(mock_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
loop_thread_id = threading.get_ident() # this coroutine runs on the loop
|
||||
seen: dict[str, int] = {}
|
||||
real_pkg_version = diagnostics_mod.pkg_version
|
||||
|
||||
def _spy(name: str) -> str:
|
||||
seen['thread_id'] = threading.get_ident()
|
||||
return real_pkg_version(name)
|
||||
|
||||
monkeypatch.setattr(diagnostics_mod, 'pkg_version', _spy)
|
||||
|
||||
diag = await async_get_config_entry_diagnostics(hass, mock_entry)
|
||||
|
||||
assert diag['smartthings_local_version']
|
||||
assert seen['thread_id'] != loop_thread_id
|
||||
|
||||
@@ -34,6 +34,28 @@ def test_apply_writes_through_when_not_settling():
|
||||
assert mgr.cache.get('/oven/vs/0') == {'a': 1}
|
||||
|
||||
|
||||
def test_apply_merges_partial_update_onto_prior_rep():
|
||||
"""Regression test for issue #27: a Bespoke fridge's /mode/vs/0 notify
|
||||
(and even a later sweep entry for that href) can carry only `modes`,
|
||||
omitting `supportedOptions` entirely. A full replace would wipe
|
||||
`supportedOptions` from the cache the moment that partial update
|
||||
arrives, even though the device's supported options didn't change --
|
||||
which is exactly what made the flex-zone select disappear."""
|
||||
mgr = _manager()
|
||||
full = {
|
||||
'x.com.samsung.da.modes': ['CVN_CONVERTIBLE_ZONE', 'CV_FDR_MEAT'],
|
||||
'x.com.samsung.da.supportedOptions': ['CV_FDR_WINE', 'CV_FDR_MEAT'],
|
||||
}
|
||||
mgr.apply('/mode/vs/0', full, source='poll')
|
||||
|
||||
partial = {'x.com.samsung.da.modes': ['CVN_CONVERTIBLE_ZONE', 'WATERFILTER_ENABLE']}
|
||||
mgr.apply('/mode/vs/0', partial, source='observe')
|
||||
|
||||
cached = mgr.cache.get('/mode/vs/0')
|
||||
assert cached['x.com.samsung.da.modes'] == ['CVN_CONVERTIBLE_ZONE', 'WATERFILTER_ENABLE']
|
||||
assert cached['x.com.samsung.da.supportedOptions'] == ['CV_FDR_WINE', 'CV_FDR_MEAT']
|
||||
|
||||
|
||||
def test_apply_drops_update_during_settle_window():
|
||||
mgr = _manager()
|
||||
mgr.cache.apply_rep('/oven/vs/0', {'a': 1}, source='seed')
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
"""Tests for Samsung air-conditioner support (issue #17).
|
||||
|
||||
These stay HA-free like the rest of the suite: they exercise the registry,
|
||||
discovery/flatten, and the CLIMATE capability's write contract. The composite
|
||||
climate entity itself lives in climate.py (imports homeassistant) and is not
|
||||
importable here -- consistent with how the other HA platform files are untested.
|
||||
"""
|
||||
from custom_components.localthings.registry.adapter import flatten
|
||||
from custom_components.localthings.registry.by_type import for_device, for_device_by_model
|
||||
from custom_components.localthings.registry.capabilities import airconditioner
|
||||
from custom_components.localthings.registry.discovery import discover
|
||||
from custom_components.localthings.registry.entities import ClimateDesc
|
||||
|
||||
from tests.conftest import _load_device
|
||||
|
||||
|
||||
def _ac():
|
||||
resources = _load_device('airconditioner')
|
||||
info = resources['/information/vs/0']
|
||||
reg = for_device_by_model(
|
||||
info['x.com.samsung.da.modelNum'], info['x.com.samsung.da.description'],
|
||||
)
|
||||
return reg, resources
|
||||
|
||||
|
||||
def _resolve(name):
|
||||
"""Mirror the coordinator's detection order: oneUiVersion first, modelNum
|
||||
fallback second (needed for issue #37's board, which reports neither
|
||||
oneUiVersion nor a '_PRAC_' modelNum token)."""
|
||||
resources = _load_device(name)
|
||||
otn = resources.get('/otninformation/vs/0', {})
|
||||
one_ui = otn.get('swVersionInfo', {}).get('oneUiVersion', '')
|
||||
info = resources['/information/vs/0']
|
||||
reg = for_device(one_ui) if one_ui else None
|
||||
if reg is None:
|
||||
reg = for_device_by_model(
|
||||
info['x.com.samsung.da.modelNum'], info['x.com.samsung.da.description'],
|
||||
)
|
||||
return reg, resources
|
||||
|
||||
|
||||
def _bound():
|
||||
reg, resources = _ac()
|
||||
return discover(resources, reg.capabilities, reg.pattern_capabilities), resources
|
||||
|
||||
|
||||
def _state():
|
||||
bound, resources = _bound()
|
||||
return flatten(bound, resources)
|
||||
|
||||
|
||||
def test_ac_model_resolves_to_airconditioner_registry():
|
||||
reg, _ = _ac()
|
||||
assert reg is not None and reg.name == 'airconditioner'
|
||||
|
||||
|
||||
def test_no_unbound_hrefs():
|
||||
"""Every resource in the issue #17 dump binds or is covered -- clears the
|
||||
coverage-gap repair."""
|
||||
reg, resources = _ac()
|
||||
unbound = []
|
||||
discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append)
|
||||
assert unbound == []
|
||||
|
||||
|
||||
def test_climate_entity_is_bound():
|
||||
"""The composite climate entity binds the primary /mode/vs/0 resource."""
|
||||
bound, _ = _bound()
|
||||
climate = [b for b in bound if isinstance(b.desc, ClimateDesc)]
|
||||
assert len(climate) == 1
|
||||
assert climate[0].href == '/mode/vs/0'
|
||||
|
||||
|
||||
def test_expected_state_keys_present():
|
||||
state = _state()
|
||||
for key in ('climate', 'air_purify', 'auto_clean', 'air_filter_status',
|
||||
'air_filter_usage', 'diagnosis_status', 'alarm_code', 'energy_kwh'):
|
||||
assert key in state, key
|
||||
|
||||
|
||||
def test_power_and_convenient_folded_into_climate():
|
||||
"""On/off is the climate entity's HVACMode.OFF and convenient mode is its
|
||||
preset_mode -- neither surfaces as a standalone switch/select."""
|
||||
state = _state()
|
||||
assert 'power_switch' not in state
|
||||
assert 'convenient_mode' not in state
|
||||
|
||||
|
||||
def test_air_filter_usage_is_percentage_of_capacity():
|
||||
"""filterUsage is a raw count in the capacity unit (100 of 500), surfaced as
|
||||
a percentage rather than the misleading raw value."""
|
||||
assert _state()['air_filter_usage'] == 20
|
||||
|
||||
|
||||
def test_climate_write_targets():
|
||||
"""The CLIMATE write_fn maps each (kind, value) command to the right OCF
|
||||
POST target and body. `value` is already the raw device code."""
|
||||
write = airconditioner.CLIMATE.entities[0].write_fn
|
||||
assert write(('power', True), {}) == (['power', '0'], {'value': True})
|
||||
assert write(('power', False), {}) == (['power', '0'], {'value': False})
|
||||
assert write(('mode', 'Heat'), {}) == (
|
||||
['mode', 'vs', '0'], {'x.com.samsung.da.modes': ['Heat']})
|
||||
assert write(('temperature', 23.6), {}) == (
|
||||
['temperature', 'desired', '0'], {'temperature': 24})
|
||||
assert write(('fan', '2'), {}) == (
|
||||
['wind', 'strength', 'vs', '0'], {'x.com.samsung.da.modes': '2'})
|
||||
assert write(('swing', 'All'), {}) == (
|
||||
['wind', 'direction', 'vs', '0'], {'x.com.samsung.da.modes': 'All'})
|
||||
assert write(('preset', 'Sleep'), {}) == (
|
||||
['mode', 'convenient', 'vs', '0'], {'x.com.samsung.da.modes': 'Sleep'})
|
||||
assert write(('bogus', 1), {}) is None
|
||||
|
||||
|
||||
def test_climate_consumed_hrefs_declared_as_coverage():
|
||||
"""The climate-consumed and ambiguous hrefs are declared in the AC registry
|
||||
(as no-entity coverage caps) so they don't leak as gaps -- but produce no
|
||||
standalone entities."""
|
||||
reg, _ = _ac()
|
||||
for href in ('/power/0', '/power/vs/0', '/temperature/desired/0',
|
||||
'/wind/strength/vs/0', '/mode/convenient/vs/0',
|
||||
'/temperatures/vs/0', '/sensors/vs/0', '/humidity/0'):
|
||||
caps = reg.capabilities.get(href)
|
||||
assert caps, href
|
||||
assert all(c.entities == () for c in caps), href
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TP1X_DA-AC-RAC-01011 (oneUiVersion "7.0 Air conditioner", Tizen Lite) -- a
|
||||
# newer model class than the ARTIK051_PRAC dump above. It has no OCF-standard
|
||||
# /temperature/current+desired pair (temperature lives on the vendor
|
||||
# /temperatures/vs/0 items[] resource), exposes a /light/vs/0 display light, and
|
||||
# carries extra vendor housekeeping hrefs. Issue #17 for this class (PR #36).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _ac_tp1x():
|
||||
resources = _load_device('airconditioner_tp1x_da_ac_rac_01011')
|
||||
one_ui = resources['/otninformation/vs/0']['swVersionInfo']['oneUiVersion']
|
||||
return for_device(one_ui), resources
|
||||
|
||||
|
||||
def test_tp1x_resolves_to_airconditioner_registry():
|
||||
reg, _ = _ac_tp1x()
|
||||
assert reg is not None and reg.name == 'airconditioner'
|
||||
|
||||
|
||||
def test_tp1x_no_unbound_hrefs():
|
||||
"""Every resource in the TP1X dump binds or is covered -- including
|
||||
/temperatures/vs/0, /light/vs/0 and the housekeeping hrefs absent from
|
||||
the ARTIK051 dump. Clears the coverage-gap repair."""
|
||||
reg, resources = _ac_tp1x()
|
||||
unbound = []
|
||||
discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append)
|
||||
assert unbound == []
|
||||
|
||||
|
||||
def test_tp1x_display_light_switch_present():
|
||||
"""/light/vs/0 (mode On/Off) surfaces as the display-light switch."""
|
||||
reg, resources = _ac_tp1x()
|
||||
state = flatten(
|
||||
discover(resources, reg.capabilities, reg.pattern_capabilities), resources)
|
||||
assert state.get('display_light') is True # device reports mode == 'On'
|
||||
|
||||
|
||||
def test_tp1x_vendor_temperature_and_light_covered():
|
||||
"""The vendor temperature resource (read by the climate entity) and the
|
||||
display-light resource both resolve in the registry -- no gap."""
|
||||
reg, _ = _ac_tp1x()
|
||||
assert reg.capabilities.get('/temperatures/vs/0'), '/temperatures/vs/0'
|
||||
assert reg.capabilities.get('/light/vs/0'), '/light/vs/0'
|
||||
|
||||
|
||||
def test_tp1x_climate_entity_is_bound():
|
||||
"""The composite climate entity still binds the primary /mode/vs/0."""
|
||||
reg, resources = _ac_tp1x()
|
||||
bound = discover(resources, reg.capabilities, reg.pattern_capabilities)
|
||||
climate = [b for b in bound if isinstance(b.desc, ClimateDesc)]
|
||||
assert len(climate) == 1 and climate[0].href == '/mode/vs/0'
|
||||
|
||||
|
||||
def test_tp2x_rac_20k_model_resolves_via_model_fallback():
|
||||
"""TP2X_RAC_20K (issue #37) reports no oneUiVersion and no '_PRAC_' token
|
||||
-- resolved via the '_RAC_' modelNum fallback added for this device."""
|
||||
reg, _ = _resolve('airconditioner_tp2x_rac_20k')
|
||||
assert reg is not None and reg.name == 'airconditioner'
|
||||
|
||||
|
||||
def test_tp2x_rac_20k_no_unbound_hrefs():
|
||||
reg, resources = _resolve('airconditioner_tp2x_rac_20k')
|
||||
unbound = []
|
||||
discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append)
|
||||
assert unbound == []
|
||||
|
||||
|
||||
def test_tp1x_rac_model_resolves_via_one_ui_version():
|
||||
"""TP1X_DA-AC-RAC-01001_0000 (issue #38) self-reports oneUiVersion
|
||||
'7.0 Air conditioner' -- resolved via for_device(), not the modelNum
|
||||
fallback."""
|
||||
reg, _ = _resolve('airconditioner_tp1x_rac')
|
||||
assert reg is not None and reg.name == 'airconditioner'
|
||||
|
||||
|
||||
def test_tp1x_rac_no_unbound_hrefs():
|
||||
reg, resources = _resolve('airconditioner_tp1x_rac')
|
||||
unbound = []
|
||||
discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append)
|
||||
assert unbound == []
|
||||
|
||||
|
||||
def test_tp1x_rac_expected_state_keys_present():
|
||||
reg, resources = _resolve('airconditioner_tp1x_rac')
|
||||
bound = discover(resources, reg.capabilities, reg.pattern_capabilities)
|
||||
state = flatten(bound, resources)
|
||||
for key in ('climate', 'display_light', 'mute_once', 'selfcheck_status',
|
||||
'selfcheck_result', 'current_limit_enabled', 'current_limit_level'):
|
||||
assert key in state, key
|
||||
|
||||
|
||||
def test_mute_once_write_target():
|
||||
write = airconditioner.MUTE_ONCE.entities[0].write_fn
|
||||
assert write('On', {}) == (['option', 'muteonce', 'vs', '0'], {'muteonce': 'On'})
|
||||
assert write('Off', {}) == (['option', 'muteonce', 'vs', '0'], {'muteonce': 'Off'})
|
||||
|
||||
|
||||
def test_display_light_write_target():
|
||||
write = airconditioner.DISPLAY_LIGHT.entities[0].write_fn
|
||||
assert write('On', {}) == (['light', 'vs', '0'], {'mode': 'On'})
|
||||
assert write('Off', {}) == (['light', 'vs', '0'], {'mode': 'Off'})
|
||||
|
||||
|
||||
def test_current_limit_is_read_only():
|
||||
"""Meaning/write contract for the current-limit levels isn't confirmed
|
||||
from the dump alone -- exposed as read-only diagnostic sensors rather
|
||||
than a guessed writable control."""
|
||||
for desc in airconditioner.CURRENT_LIMIT.entities:
|
||||
assert getattr(desc, 'write_fn', None) is None
|
||||
@@ -168,6 +168,38 @@ class TestForDeviceByModel:
|
||||
assert reg is not None
|
||||
assert reg.name == 'refrigerator'
|
||||
|
||||
def test_airconditioner_via_prac_token(self):
|
||||
"""Issue #17: a room AC (ARTIK051_PRAC_20K) reports no oneUiVersion and
|
||||
an unrecognized consumer token ('20K'); it falls back to the '_PRAC_'
|
||||
(Package Room Air Conditioner) token in modelNum."""
|
||||
from custom_components.localthings.registry.by_type import for_device_by_model
|
||||
reg = for_device_by_model(
|
||||
'ARTIK051_PRAC_20K|10217841|60010532001411004200003000000000',
|
||||
'ARTIK051_PRAC_20K',
|
||||
)
|
||||
assert reg is not None
|
||||
assert reg.name == 'airconditioner'
|
||||
|
||||
def test_washer_wf_prefix(self):
|
||||
"""US front-load washers use the WF consumer-model prefix."""
|
||||
from custom_components.localthings.registry.by_type import for_device_by_model
|
||||
reg = for_device_by_model(
|
||||
'DA_WM_TP1_21_COMMON|20313741|20010001001611244AA3021700000000',
|
||||
'DA_WM_TP1_21_COMMON_WF8900B/DC92-03129A_A0AE',
|
||||
)
|
||||
assert reg is not None
|
||||
assert reg.name == 'washer'
|
||||
|
||||
def test_washer_wv_prefix(self):
|
||||
"""FlexWash twin washers use the WV consumer-model prefix -- issue #19."""
|
||||
from custom_components.localthings.registry.by_type import for_device_by_model
|
||||
reg = for_device_by_model(
|
||||
'DA_WM_A51_20_COMMON|20198042|20020001001111400203000000000000',
|
||||
'DA_WM_A51_20_COMMON_WV9600M/DC92-01980B_0014',
|
||||
)
|
||||
assert reg is not None
|
||||
assert reg.name == 'washer'
|
||||
|
||||
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,38 @@
|
||||
"""Tests for the /temperatures/vs/0 fallback helper in climate.py (PR #36).
|
||||
|
||||
`_temps_vs_item` is a pure module-level function (plain dict in, plain dict
|
||||
out) with no coordinator/entity dependency, so -- like select.py's `_display`
|
||||
in test_select_display.py -- it's testable directly even though the
|
||||
LocalThingsClimate entity itself needs a coordinator/hass fixture to
|
||||
exercise. This covers the actual fallback-selection logic that the
|
||||
registry-level "no unbound hrefs" tests in test_airconditioner_capabilities.py
|
||||
don't reach: TP1X-class firmware has no OCF-standard
|
||||
/temperature/current+desired pair, so current_temperature/target_temperature
|
||||
would silently be None without this fallback.
|
||||
"""
|
||||
from custom_components.localthings.climate import _temps_vs_item
|
||||
|
||||
|
||||
def test_temps_vs_item_returns_first_item():
|
||||
rep = {
|
||||
'x.com.samsung.da.items': [
|
||||
{'x.com.samsung.da.current': '17.0', 'x.com.samsung.da.desired': '25.0'},
|
||||
],
|
||||
}
|
||||
assert _temps_vs_item(rep) == {
|
||||
'x.com.samsung.da.current': '17.0', 'x.com.samsung.da.desired': '25.0',
|
||||
}
|
||||
|
||||
|
||||
def test_temps_vs_item_empty_when_items_missing():
|
||||
"""OCF-pair devices don't populate /temperatures/vs/0's items at all --
|
||||
callers must fall through to the OCF hrefs cleanly, not raise."""
|
||||
assert _temps_vs_item({}) == {}
|
||||
|
||||
|
||||
def test_temps_vs_item_empty_when_items_is_empty_list():
|
||||
assert _temps_vs_item({'x.com.samsung.da.items': []}) == {}
|
||||
|
||||
|
||||
def test_temps_vs_item_empty_when_items_entry_is_not_a_dict():
|
||||
assert _temps_vs_item({'x.com.samsung.da.items': ['not-a-dict']}) == {}
|
||||
@@ -4,28 +4,336 @@ from custom_components.localthings.registry.discovery import discover
|
||||
|
||||
def _reg():
|
||||
return {c.href: [c] for c in (
|
||||
common.KIDS_LOCK, common.REMOTE_CONTROL, common.POWER,
|
||||
common.KIDS_LOCK_GENERIC, common.KIDS_LOCK_VS_FALLBACK,
|
||||
common.REMOTE_CONTROL_GENERIC, common.REMOTE_CONTROL_VS_FALLBACK,
|
||||
common.POWER_GENERIC, common.POWER_VS_FALLBACK,
|
||||
common.ALARMS, common.ENERGY_METER, common.WATER_METER,
|
||||
common.WATER_FILTER,
|
||||
)}
|
||||
|
||||
|
||||
def test_kids_lock_binary_value_fn():
|
||||
desc = common.KIDS_LOCK.entities[0]
|
||||
def test_kids_lock_vs_value_fn():
|
||||
desc = common.KIDS_LOCK_VS_FALLBACK.entities[0]
|
||||
assert desc.value_fn('Lock') is True
|
||||
assert desc.value_fn('Ready') is False
|
||||
|
||||
|
||||
def test_energy_clamps_negative_power(fridge_resources):
|
||||
# instantaneousPower can read negative at idle; must clamp to 0.
|
||||
pw = next(e for e in common.ENERGY_METER.entities if e.key == 'power_watts')
|
||||
assert pw.value_fn(-500.0) == 0.0
|
||||
assert pw.value_fn(93.0) == 93.0
|
||||
|
||||
|
||||
def test_common_caps_discover_on_dishwasher(dishwasher_resources):
|
||||
bound = discover(dishwasher_resources, _reg())
|
||||
keys = {b.desc.key for b in bound}
|
||||
assert 'child_lock' in keys
|
||||
assert 'remote_control' in keys
|
||||
assert 'power_watts' in keys
|
||||
assert 'power_switch' in keys
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# OCF-native / vendor '-vs' fallback pairs (power, kids-lock, remote control).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestPowerFallback:
|
||||
def test_generic_href_read_write(self):
|
||||
assert common.POWER_GENERIC.href == '/power/0'
|
||||
desc = common.POWER_GENERIC.entities[0]
|
||||
assert desc.value_fn(True) is True
|
||||
assert desc.value_fn(False) is False
|
||||
path, body = desc.write_fn('On', {})
|
||||
assert path == ['power', '0']
|
||||
assert body == {'value': True}
|
||||
assert desc.write_fn('Off', {})[1] == {'value': False}
|
||||
|
||||
def test_vs_fallback_binds_only_when_generic_absent(self):
|
||||
assert common.POWER_VS_FALLBACK.href == '/power/vs/0'
|
||||
assert common.POWER_VS_FALLBACK.match_fn({}, {'/power/vs/0': {}}) is True
|
||||
assert common.POWER_VS_FALLBACK.match_fn(
|
||||
{}, {'/power/0': {}, '/power/vs/0': {}}) is False
|
||||
|
||||
def test_vs_fallback_read_write(self):
|
||||
desc = common.POWER_VS_FALLBACK.entities[0]
|
||||
assert desc.value_fn('On') is True
|
||||
assert desc.value_fn('Off') is False
|
||||
path, body = desc.write_fn('On', {})
|
||||
assert path == ['power', 'vs', '0']
|
||||
assert body == {'x.com.samsung.da.power': 'On'}
|
||||
|
||||
|
||||
class TestKidsLockFallback:
|
||||
def test_generic_read_write(self):
|
||||
assert common.KIDS_LOCK_GENERIC.href == '/kidslock/0'
|
||||
desc = common.KIDS_LOCK_GENERIC.entities[0]
|
||||
assert desc.value_fn(True) is True
|
||||
path, body = desc.write_fn('On', {})
|
||||
assert path == ['kidslock', '0']
|
||||
assert body == {'value': True}
|
||||
|
||||
def test_vs_fallback_gated(self):
|
||||
assert common.KIDS_LOCK_VS_FALLBACK.match_fn({}, {'/kidslock/vs/0': {}}) is True
|
||||
assert common.KIDS_LOCK_VS_FALLBACK.match_fn(
|
||||
{}, {'/kidslock/0': {}, '/kidslock/vs/0': {}}) is False
|
||||
|
||||
|
||||
class TestRemoteControlFallback:
|
||||
def test_generic_read(self):
|
||||
assert common.REMOTE_CONTROL_GENERIC.href == '/remotectrl/0'
|
||||
desc = common.REMOTE_CONTROL_GENERIC.entities[0]
|
||||
assert desc.value_fn(True) is True
|
||||
assert desc.value_fn(False) is False
|
||||
|
||||
def test_vs_fallback_gated(self):
|
||||
assert common.REMOTE_CONTROL_VS_FALLBACK.match_fn({}, {'/remotectrl/vs/0': {}}) is True
|
||||
assert common.REMOTE_CONTROL_VS_FALLBACK.match_fn(
|
||||
{}, {'/remotectrl/0': {}, '/remotectrl/vs/0': {}}) is False
|
||||
|
||||
def test_polled_warm_so_write_gating_stays_fresh(self):
|
||||
"""coordinator.async_send_command blocks writes on this signal, so
|
||||
it can't sit in the default 'cold' tier (refreshed only once per
|
||||
30s summary poll) -- it needs the subscribe/subpoll cadence 'warm'
|
||||
and 'hot' hrefs get instead."""
|
||||
assert common.REMOTE_CONTROL_GENERIC.poll_tier == 'warm'
|
||||
assert common.REMOTE_CONTROL_VS_FALLBACK.poll_tier == 'warm'
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Energy meter. instantaneousPower clamps negatives to 0, but the constant
|
||||
# '-500' sentinel (a dead field on DA_WM_ laundry + dishwasher dumps, issue #6)
|
||||
# gates power_watts out entirely so it doesn't read as a real idle "0 W".
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestEnergyMeter:
|
||||
def test_href(self):
|
||||
assert common.ENERGY_METER.href == '/energy/consumption/vs/0'
|
||||
|
||||
def test_power_clamps_negative(self):
|
||||
pw = next(e for e in common.ENERGY_METER.entities if e.key == 'power_watts')
|
||||
assert pw.value_fn(-500.0) == 0.0
|
||||
assert pw.value_fn(93.0) == 93.0
|
||||
|
||||
def test_power_watts_hidden_for_dead_sentinel(self):
|
||||
pw = next(e for e in common.ENERGY_METER.entities if e.key == 'power_watts')
|
||||
assert pw.exists_fn({'x.com.samsung.da.instantaneousPower': '-500'}, {}) is False
|
||||
|
||||
def test_power_watts_shown_for_real_value(self):
|
||||
pw = next(e for e in common.ENERGY_METER.entities if e.key == 'power_watts')
|
||||
assert pw.exists_fn({'x.com.samsung.da.instantaneousPower': '150'}, {}) is True
|
||||
|
||||
def test_energy_kwh_hidden_when_cumulative_power_absent(self):
|
||||
kwh = next(e for e in common.ENERGY_METER.entities if e.key == 'energy_kwh')
|
||||
assert kwh.exists_fn({'x.com.samsung.da.instantaneousPower': '-500'}, {}) is False
|
||||
|
||||
def test_energy_kwh_shown_when_present(self):
|
||||
kwh = next(e for e in common.ENERGY_METER.entities if e.key == 'energy_kwh')
|
||||
assert kwh.exists_fn({'x.com.samsung.da.cumulativePower': '58900'}, {}) is True
|
||||
|
||||
def test_both_entities_included_on_empty_stub(self):
|
||||
"""An empty {} rep means the resource exists but data isn't fetched yet
|
||||
(see entity._is_included) -- include both so sub-polls populate them."""
|
||||
pw = next(e for e in common.ENERGY_METER.entities if e.key == 'power_watts')
|
||||
kwh = next(e for e in common.ENERGY_METER.entities if e.key == 'energy_kwh')
|
||||
assert pw.exists_fn({}, {}) is True
|
||||
assert kwh.exists_fn({}, {}) is True
|
||||
|
||||
def test_power_watts_hidden_when_field_absent_in_populated_rep(self):
|
||||
"""A populated rep that lacks instantaneousPower must not spawn a
|
||||
phantom power sensor (the exists_fn replaces the field-presence gate)."""
|
||||
pw = next(e for e in common.ENERGY_METER.entities if e.key == 'power_watts')
|
||||
assert pw.exists_fn({'x.com.samsung.da.cumulativePower': '5'}, {}) is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AI energy-saving level. '0' is off; supportedAiLevel lists the additional
|
||||
# level(s) on offer. A single-entry list (issue #21 fridge, issue #40 washer)
|
||||
# is really a binary toggle, so it's exposed as a switch instead of a
|
||||
# one-choice select; multiple entries get a select with '0' synthesized back
|
||||
# in as the explicit off option (supportedAiLevel never lists '0' itself, but
|
||||
# it's a real, observed value of aiLevel).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _ai_energy_level_desc(cls_name):
|
||||
return next(e for e in common.AI_ENERGY_LEVEL.entities
|
||||
if e.__class__.__name__ == cls_name)
|
||||
|
||||
|
||||
class TestAiEnergyLevelSwitch:
|
||||
def _desc(self):
|
||||
return _ai_energy_level_desc('SwitchDesc')
|
||||
|
||||
def test_href(self):
|
||||
assert common.AI_ENERGY_LEVEL.href == '/energy/ailevel/vs/0'
|
||||
|
||||
def test_shown_only_with_single_supported_level(self):
|
||||
desc = self._desc()
|
||||
assert desc.exists_fn({'aiLevel': '1', 'supportedAiLevel': ['1']}, {}) is True
|
||||
assert desc.exists_fn({'aiLevel': '1', 'supportedAiLevel': ['1', '2']}, {}) is False
|
||||
|
||||
def test_hidden_when_supported_level_is_non_list_scalar(self):
|
||||
"""A stray scalar (e.g. a string) must not be len()-checked as if it
|
||||
were a list -- a 2-char string would otherwise wrongly pass `== 1`
|
||||
style checks."""
|
||||
desc = self._desc()
|
||||
assert desc.exists_fn({'aiLevel': '1', 'supportedAiLevel': '1'}, {}) is False
|
||||
|
||||
def test_hidden_when_supported_level_missing(self):
|
||||
desc = self._desc()
|
||||
assert desc.exists_fn({'aiLevel': '1'}, {}) is False
|
||||
|
||||
def test_hidden_when_supported_level_empty_list(self):
|
||||
desc = self._desc()
|
||||
assert desc.exists_fn({'aiLevel': '0', 'supportedAiLevel': []}, {}) is False
|
||||
|
||||
def test_hidden_on_empty_stub_rep(self):
|
||||
desc = self._desc()
|
||||
assert desc.exists_fn({}, {}) is False
|
||||
|
||||
def test_value_fn(self):
|
||||
desc = self._desc()
|
||||
assert desc.value_fn('0') is False
|
||||
assert desc.value_fn('1') is True
|
||||
|
||||
def test_write_on_uses_the_single_supported_level(self):
|
||||
"""The on-value is whatever the device calls its one level, not a
|
||||
hardcoded '1'."""
|
||||
desc = self._desc()
|
||||
path, body = desc.write_fn('On', {'supportedAiLevel': ['2']})
|
||||
assert path == ['energy', 'ailevel', 'vs', '0']
|
||||
assert body == {'aiLevel': '2'}
|
||||
|
||||
def test_write_off(self):
|
||||
desc = self._desc()
|
||||
path, body = desc.write_fn('Off', {'supportedAiLevel': ['1']})
|
||||
assert body == {'aiLevel': '0'}
|
||||
|
||||
|
||||
class TestAiEnergyLevelSelect:
|
||||
def _desc(self):
|
||||
return _ai_energy_level_desc('SelectDesc')
|
||||
|
||||
def test_shown_only_with_multiple_supported_levels(self):
|
||||
desc = self._desc()
|
||||
assert desc.exists_fn({'aiLevel': '1', 'supportedAiLevel': ['1', '2']}, {}) is True
|
||||
assert desc.exists_fn({'aiLevel': '1', 'supportedAiLevel': ['1']}, {}) is False
|
||||
|
||||
def test_hidden_when_supported_level_is_non_list_scalar(self):
|
||||
desc = self._desc()
|
||||
assert desc.exists_fn({'aiLevel': '1', 'supportedAiLevel': '12'}, {}) is False
|
||||
|
||||
def test_hidden_when_supported_level_missing(self):
|
||||
desc = self._desc()
|
||||
assert desc.exists_fn({'aiLevel': '1'}, {}) is False
|
||||
|
||||
def test_hidden_when_supported_level_empty_list(self):
|
||||
desc = self._desc()
|
||||
assert desc.exists_fn({'aiLevel': '0', 'supportedAiLevel': []}, {}) is False
|
||||
|
||||
def test_no_translation_key(self):
|
||||
"""aiLevel's values are plain digits that render fine untranslated
|
||||
(select.py's _display()) -- no strings.json entry to maintain
|
||||
against an unknown number of future levels."""
|
||||
desc = self._desc()
|
||||
assert desc.translation_key is None
|
||||
|
||||
def test_options_synthesize_off(self):
|
||||
"""'0' is never in supportedAiLevel but is a real, observed aiLevel
|
||||
value -- synthesized back in as the explicit off option."""
|
||||
desc = self._desc()
|
||||
resources = {'/energy/ailevel/vs/0': {'supportedAiLevel': ['1', '2']}}
|
||||
assert desc.options(resources) == ['0', '1', '2']
|
||||
|
||||
def test_options_empty_when_resource_missing(self):
|
||||
desc = self._desc()
|
||||
assert desc.options({}) == ['0']
|
||||
|
||||
def test_write(self):
|
||||
desc = self._desc()
|
||||
path, body = desc.write_fn('2', {})
|
||||
assert path == ['energy', 'ailevel', 'vs', '0']
|
||||
assert body == {'aiLevel': '2'}
|
||||
|
||||
|
||||
class TestAiEnergyLevelStubDoesNotDecideThePlatform:
|
||||
"""Issue found in review: entity *creation* runs once, against whatever
|
||||
snapshot is current when platforms are set up (see
|
||||
__init__.py's async_config_entry_first_refresh-before-forward-entry-setups
|
||||
ordering), while flatten() re-evaluates exists_fn every poll against live
|
||||
data. Both descriptors share key='ai_energy_level' (see adapter._key), so
|
||||
if a stub carve-out let one of them win at setup time while the other
|
||||
wins once real data lands, flatten() would feed the already-instantiated
|
||||
entity a value shaped for the other platform (e.g. a bool into a Select
|
||||
expecting a string option). Neither side gets a `not rep` carve-out, so
|
||||
an unfetched stub can never win entity creation for either platform --
|
||||
the entity simply doesn't appear until a reload happens with real data,
|
||||
same as any other exists_fn-gated entity in this codebase that's unlucky
|
||||
on first-poll timing, instead of appearing as the wrong widget type."""
|
||||
|
||||
def test_neither_widget_exists_on_empty_stub_rep(self):
|
||||
switch = _ai_energy_level_desc('SwitchDesc')
|
||||
select = _ai_energy_level_desc('SelectDesc')
|
||||
assert switch.exists_fn({}, {}) is False
|
||||
assert select.exists_fn({}, {}) is False
|
||||
|
||||
|
||||
class TestSelfCheckError:
|
||||
"""Self-check diagnostic error list -- surfaced on hardware that reports
|
||||
x.com.samsung.da.error, joined into a single display string."""
|
||||
|
||||
def _desc(self):
|
||||
return next(e for e in common.SELF_CHECK.entities if e.key == 'selfcheck_error')
|
||||
|
||||
def test_exists_when_field_present(self):
|
||||
desc = self._desc()
|
||||
assert desc.exists_fn({'x.com.samsung.da.error': ['DA_ERROR_NONE']}, {}) is True
|
||||
|
||||
def test_does_not_exist_when_field_absent(self):
|
||||
desc = self._desc()
|
||||
assert desc.exists_fn({'x.com.samsung.da.status': 'Ready'}, {}) is False
|
||||
|
||||
def test_exists_for_empty_stub_rep(self):
|
||||
"""An empty {} rep is /device/0's not-yet-fetched-stub carve-out --
|
||||
must be included-for-now, same as ENERGY_METER's fields."""
|
||||
desc = self._desc()
|
||||
assert desc.exists_fn({}, {}) is True
|
||||
|
||||
def test_value_joins_list(self):
|
||||
desc = self._desc()
|
||||
assert desc.value_fn(['E1', 'E2']) == 'E1, E2'
|
||||
|
||||
def test_value_passes_through_scalar(self):
|
||||
desc = self._desc()
|
||||
assert desc.value_fn('DA_ERROR_NONE') == 'DA_ERROR_NONE'
|
||||
|
||||
def test_value_none_for_empty_list(self):
|
||||
"""An empty error list means no value to show -- None (unknown),
|
||||
not an empty string."""
|
||||
desc = self._desc()
|
||||
assert desc.value_fn([]) is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cross-family bundles (UNIVERSAL / POWER) -- unpacked into every by_type
|
||||
# registry's _build([...]) call in place of the hand-duplicated capability
|
||||
# lists that used to live there.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestUniversalAndPowerBundles:
|
||||
def test_universal_contains_the_no_conflict_capabilities(self):
|
||||
assert set(common.UNIVERSAL) == {
|
||||
common.ALARMS, common.ENERGY_METER, common.FIRMWARE_UPDATE,
|
||||
common.SELF_CHECK, common.AI_ENERGY_LEVEL,
|
||||
common.KIDS_LOCK_GENERIC, common.KIDS_LOCK_VS_FALLBACK,
|
||||
common.REMOTE_CONTROL_GENERIC, common.REMOTE_CONTROL_VS_FALLBACK,
|
||||
}
|
||||
|
||||
def test_power_kept_separate_for_airconditioners_sake(self):
|
||||
"""See common.POWER's own comment for why airconditioner opts out."""
|
||||
assert set(common.POWER) == {common.POWER_GENERIC, common.POWER_VS_FALLBACK}
|
||||
|
||||
def test_no_overlap_between_the_two_bundles(self):
|
||||
assert not (set(common.UNIVERSAL) & set(common.POWER))
|
||||
|
||||
def test_airconditioner_registry_does_not_include_power(self):
|
||||
from custom_components.localthings.registry.by_type import airconditioner
|
||||
bound_caps = {c for caps in airconditioner.REGISTRY.capabilities.values() for c in caps}
|
||||
assert common.POWER_GENERIC not in bound_caps
|
||||
assert common.POWER_VS_FALLBACK not in bound_caps
|
||||
|
||||
@@ -158,6 +158,46 @@ def test_discover_match_fn_decline_is_not_logged_as_gap():
|
||||
assert seen == []
|
||||
|
||||
|
||||
def test_discover_pattern_cap_reads_name_field():
|
||||
"""A pattern cap's name_field normalizes a device-given instance name
|
||||
(e.g. an ice maker's "CUBED_ICE") for use in the entity's display name,
|
||||
independent of the href-derived key (issue #27)."""
|
||||
cap = Capability(
|
||||
href=None,
|
||||
href_prefix='/icemaker/',
|
||||
name_field='x.com.samsung.da.iceMaker.name',
|
||||
entities=(BinarySensorDesc(key='enabled', field='x.com.samsung.da.iceMaker.state'),),
|
||||
)
|
||||
resources = {
|
||||
'/icemaker/one/vs/0': {
|
||||
'x.com.samsung.da.iceMaker.state': 'On',
|
||||
'x.com.samsung.da.iceMaker.name': 'CUBED_ICE',
|
||||
},
|
||||
'/icemaker/two/vs/0': {
|
||||
'x.com.samsung.da.iceMaker.state': 'On',
|
||||
'x.com.samsung.da.iceMaker.name': 'ICE_BITES',
|
||||
},
|
||||
}
|
||||
bound = discover(resources, {}, pattern_caps=[cap])
|
||||
names = {b.href: b.instance_name for b in bound}
|
||||
assert names == {
|
||||
'/icemaker/one/vs/0': 'Cubed Ice',
|
||||
'/icemaker/two/vs/0': 'Ice Bites',
|
||||
}
|
||||
|
||||
|
||||
def test_discover_pattern_cap_name_field_absent_leaves_instance_name_none():
|
||||
cap = Capability(
|
||||
href=None,
|
||||
href_prefix='/icemaker/',
|
||||
name_field='x.com.samsung.da.iceMaker.name',
|
||||
entities=(BinarySensorDesc(key='enabled', field='x.com.samsung.da.iceMaker.state'),),
|
||||
)
|
||||
resources = {'/icemaker/one/vs/0': {'x.com.samsung.da.iceMaker.state': 'On'}}
|
||||
bound = discover(resources, {}, pattern_caps=[cap])
|
||||
assert bound[0].instance_name is None
|
||||
|
||||
|
||||
def test_discover_rt_filter_gates_binding():
|
||||
"""Cap with rt_filter must not bind a rep whose rt list does not match."""
|
||||
oven_mode_cap = Capability(
|
||||
|
||||
@@ -1,51 +1,49 @@
|
||||
"""Tests for dishwasher-specific capabilities."""
|
||||
from custom_components.localthings.registry.capabilities import dishwasher
|
||||
"""Tests for dishwasher-specific capabilities.
|
||||
|
||||
The shared /course/vs/0 cycle-select machinery (parse_edit_course_list,
|
||||
cycle_options, cycle_write) is tested in test_laundry_capabilities.py; here we
|
||||
check the dishwasher wiring and its device-specific options.
|
||||
"""
|
||||
from custom_components.localthings.registry.capabilities import dishwasher, laundry
|
||||
|
||||
|
||||
class TestCycleOptions:
|
||||
def test_parses_edit_course_list(self):
|
||||
raw = 'EditCourseList_0E07908683848D808E8F'
|
||||
assert dishwasher._parse_edit_course_list(raw) == [
|
||||
'0E', '07', '90', '86', '83', '84', '8D', '80', '8E', '8F',
|
||||
]
|
||||
def _cycle(self):
|
||||
return next(e for e in dishwasher.CYCLE_OPTIONS.entities if e.key == 'cycle')
|
||||
|
||||
def test_parse_edit_course_list_handles_missing_or_malformed(self):
|
||||
assert dishwasher._parse_edit_course_list(None) == []
|
||||
assert dishwasher._parse_edit_course_list('') == []
|
||||
assert dishwasher._parse_edit_course_list('no underscore') == []
|
||||
|
||||
def test_cycle_options_reads_live_edit_course_list(self):
|
||||
resources = {
|
||||
'/wm/editcourse/vs/0': {
|
||||
'x.com.samsung.da.editCourseList': 'EditCourseList_0E9086',
|
||||
},
|
||||
}
|
||||
assert dishwasher._cycle_options(resources) == ['0E', '90', '86']
|
||||
|
||||
def test_cycle_options_empty_when_resource_absent(self):
|
||||
assert dishwasher._cycle_options({}) == []
|
||||
|
||||
def test_cycle_options_empty_when_resource_empty(self):
|
||||
resources = {'/wm/editcourse/vs/0': {}}
|
||||
assert dishwasher._cycle_options(resources) == []
|
||||
|
||||
def test_cycle_desc_uses_cycle_options_callable(self):
|
||||
desc = next(e for e in dishwasher.CYCLE_OPTIONS.entities if e.key == 'cycle')
|
||||
assert desc.options is dishwasher._cycle_options
|
||||
def test_cycle_desc_uses_shared_cycle_options(self):
|
||||
desc = self._cycle()
|
||||
assert desc.options is laundry.cycle_options
|
||||
assert desc.translation_key == 'dishwasher_cycle'
|
||||
|
||||
def test_exists_only_when_edit_course_list_is_live(self):
|
||||
desc = next(e for e in dishwasher.CYCLE_OPTIONS.entities if e.key == 'cycle')
|
||||
desc = self._cycle()
|
||||
assert desc.exists_fn({}, {}) is False
|
||||
assert desc.exists_fn({}, {'/wm/editcourse/vs/0': {}}) is False
|
||||
live = {'/wm/editcourse/vs/0': {'x.com.samsung.da.editCourseList': 'EditCourseList_0E'}}
|
||||
assert desc.exists_fn({}, live) is True
|
||||
|
||||
def test_cycle_write_uses_raw_code_directly(self):
|
||||
desc = next(e for e in dishwasher.CYCLE_OPTIONS.entities if e.key == 'cycle')
|
||||
desc = self._cycle()
|
||||
rep = {'x.com.samsung.da.options': ['DeviceType_0001', 'Course_0E', 'GMT_04']}
|
||||
path, body = desc.write_fn('90', rep)
|
||||
assert path == ['course', 'vs', '0']
|
||||
assert body == {
|
||||
'x.com.samsung.da.options': ['DeviceType_0001', 'Course_90', 'GMT_04'],
|
||||
}
|
||||
|
||||
|
||||
class TestDishwasherOptions:
|
||||
def test_storm_wash_read_and_write(self):
|
||||
desc = next(e for e in dishwasher.CYCLE_OPTIONS.entities if e.key == 'storm_wash')
|
||||
assert desc.rep_fn({'x.com.samsung.da.options': ['StormWashZone_On']}) is True
|
||||
assert desc.rep_fn({'x.com.samsung.da.options': ['StormWashZone_Off']}) is False
|
||||
path, body = desc.write_fn('Off', {'x.com.samsung.da.options': ['StormWashZone_On']})
|
||||
assert path == ['course', 'vs', '0']
|
||||
assert 'StormWashZone_Off' in body['x.com.samsung.da.options']
|
||||
|
||||
def test_auto_release_exists_only_when_field_present(self):
|
||||
desc = next(e for e in dishwasher.CYCLE_OPTIONS.entities if e.key == 'auto_release_dry')
|
||||
assert desc.exists_fn({'x.com.samsung.da.options': []}, {}) is False
|
||||
assert desc.exists_fn(
|
||||
{'x.com.samsung.da.options': ['AutoDoorRelease_On']}, {}) is True
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
"""Tests for dryer support and washer/dryer consistency (issue #14)."""
|
||||
from custom_components.localthings.registry.adapter import flatten
|
||||
from custom_components.localthings.registry.by_type import for_device_by_model
|
||||
from custom_components.localthings.registry.capabilities import dryer, ignored, laundry
|
||||
from custom_components.localthings.registry.discovery import discover
|
||||
|
||||
from tests.conftest import _load_device
|
||||
|
||||
|
||||
def _dryer():
|
||||
resources = _load_device('dryer')
|
||||
info = resources['/information/vs/0']
|
||||
reg = for_device_by_model(
|
||||
info['x.com.samsung.da.modelNum'], info['x.com.samsung.da.description'],
|
||||
)
|
||||
return reg, resources
|
||||
|
||||
|
||||
def _state():
|
||||
reg, resources = _dryer()
|
||||
bound = discover(resources, reg.capabilities, reg.pattern_capabilities)
|
||||
return flatten(bound, resources)
|
||||
|
||||
|
||||
def test_dryer_model_resolves_to_dryer_registry():
|
||||
reg, _ = _dryer()
|
||||
assert reg is not None and reg.name == 'dryer'
|
||||
|
||||
|
||||
def test_no_unbound_hrefs():
|
||||
"""Every resource in the issue #14 dump binds or is ignored -- clears the
|
||||
coverage-gap repair."""
|
||||
reg, resources = _dryer()
|
||||
unbound = []
|
||||
discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append)
|
||||
assert unbound == []
|
||||
|
||||
|
||||
def test_expected_entities_present():
|
||||
state = _state()
|
||||
for key in ('buzzer_sound', 'cycle', 'power_switch', 'child_lock',
|
||||
'remote_control', 'dry_level', 'wrinkle_prevent', 'energy_kwh'):
|
||||
assert key in state, key
|
||||
|
||||
|
||||
def test_job_beginning_status_reads_current_status():
|
||||
"""The dump carries x.com.samsung.da.currentStatus (not the old
|
||||
jobBeginingStatus field the dryer descriptor used to read), so the sensor
|
||||
is populated instead of blank."""
|
||||
assert _state()['job_beginning_status'] == 'None' # the dump's currentStatus value
|
||||
|
||||
|
||||
def test_power_watts_gated_for_dead_sentinel():
|
||||
"""instantaneousPower is the dead '-500' sentinel on this dryer, so the
|
||||
misleading 0 W power sensor is suppressed; cumulative energy still shows."""
|
||||
state = _state()
|
||||
assert 'power_watts' not in state
|
||||
assert 'energy_kwh' in state
|
||||
|
||||
|
||||
def test_course_bound_to_shared_course_vs_0():
|
||||
"""Dryer course uses the shared /course/vs/0 cycle select with dryer_cycle
|
||||
translations, consistent with washer/dishwasher."""
|
||||
assert dryer.DRYER_COURSE.href == '/course/vs/0'
|
||||
desc = next(e for e in dryer.DRYER_COURSE.entities if e.key == 'cycle')
|
||||
assert desc.translation_key == 'dryer_cycle'
|
||||
assert desc.options is laundry.cycle_options
|
||||
rep = {'x.com.samsung.da.options': ['Course_16', 'GMT_02']}
|
||||
assert desc.rep_fn(rep) == '16'
|
||||
|
||||
|
||||
def test_st_dryercourse_is_ignored():
|
||||
"""/st/dryercourse/vs/0 re-encodes the course exposed via /course/vs/0 and
|
||||
is globally ignored -- the mirror of /st/washercourse/vs/0."""
|
||||
ignored_hrefs = {c.href for c in ignored.IGNORED}
|
||||
assert '/st/dryercourse/vs/0' in ignored_hrefs
|
||||
assert '/st/washercourse/vs/0' in ignored_hrefs
|
||||
@@ -1,6 +1,6 @@
|
||||
from custom_components.localthings.registry.entities import (
|
||||
SensorDesc, BinarySensorDesc, SelectDesc, SwitchDesc, ButtonDesc,
|
||||
NumberDesc, PLATFORM_OF,
|
||||
NumberDesc, TimeDesc, ClimateDesc, PLATFORM_OF,
|
||||
)
|
||||
|
||||
|
||||
@@ -26,6 +26,8 @@ def test_platform_mapping_covers_all_subclasses():
|
||||
assert PLATFORM_OF[SwitchDesc] == 'switch'
|
||||
assert PLATFORM_OF[ButtonDesc] == 'button'
|
||||
assert PLATFORM_OF[NumberDesc] == 'number'
|
||||
assert PLATFORM_OF[TimeDesc] == 'time'
|
||||
assert PLATFORM_OF[ClimateDesc] == 'climate'
|
||||
|
||||
|
||||
def test_select_carries_options_and_write_fn():
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
"""Tests for LocalThingsEntity's display-name derivation
|
||||
(custom_components/localthings/entity.py) -- the explicit-name,
|
||||
device-given-instance-name, and href-derived fallbacks.
|
||||
"""
|
||||
from custom_components.localthings.entity import LocalThingsEntity
|
||||
from custom_components.localthings.registry.capability import Capability
|
||||
from custom_components.localthings.registry.discovery import BoundEntity
|
||||
from custom_components.localthings.registry.entities import BinarySensorDesc
|
||||
|
||||
|
||||
class _FakeCoordinator:
|
||||
device_serial = 'TEST-SERIAL'
|
||||
|
||||
def __init__(self, last_resources=None):
|
||||
self.last_resources = last_resources or {}
|
||||
|
||||
|
||||
def _make_entity(desc, href='/x/vs/0', key_override=None, instance='', instance_name=None):
|
||||
capability = Capability(href=href, entities=(desc,))
|
||||
bound = BoundEntity(href=href, capability=capability, desc=desc,
|
||||
instance=instance, key_override=key_override,
|
||||
instance_name=instance_name)
|
||||
return LocalThingsEntity(_FakeCoordinator(), bound)
|
||||
|
||||
|
||||
def test_explicit_name_wins_over_everything():
|
||||
desc = BinarySensorDesc(key='enabled', name='Explicit Name')
|
||||
entity = _make_entity(desc, instance_name='Cubed Ice')
|
||||
assert entity._attr_name == 'Explicit Name'
|
||||
|
||||
|
||||
def test_instance_name_prefixes_the_derived_suffix():
|
||||
"""Issue #27: an ice maker's device-given name ("Cubed Ice") replaces
|
||||
the href-derived instance label ("Icemaker One") as the name prefix,
|
||||
keeping the same entity-specific suffix."""
|
||||
desc = BinarySensorDesc(key='enabled')
|
||||
entity = _make_entity(desc, key_override='icemaker_one_enabled',
|
||||
instance_name='Cubed Ice')
|
||||
assert entity._attr_name == 'Cubed Ice Enabled'
|
||||
|
||||
|
||||
def test_no_instance_name_falls_back_to_derived_state_key():
|
||||
desc = BinarySensorDesc(key='enabled')
|
||||
entity = _make_entity(desc, key_override='icemaker_one_enabled')
|
||||
assert entity._attr_name == 'Icemaker One Enabled'
|
||||
@@ -102,3 +102,159 @@ class TestRefrigerationFallback:
|
||||
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
|
||||
|
||||
|
||||
class TestFlexZone:
|
||||
"""Issue #32 (surfaced by #26/#27's TP2X/Bespoke dumps): the flex-zone
|
||||
entry's prefix varies by fridge family (CV_TTYPE_RF9000A_ on RF9000-class,
|
||||
CV_FDR_ on TP1X/Bespoke-class). The old hardcoded-prefix match left
|
||||
CV_FDR_-family fridges with an always-unknown select, and a write would
|
||||
have appended a duplicate flag instead of replacing the existing one."""
|
||||
|
||||
def test_reads_rf9000_prefix(self):
|
||||
rep = {
|
||||
'x.com.samsung.da.modes': ['CV_TTYPE_RF9000A_BEVERAGE', 'WATERFILTER_DISABLE'],
|
||||
'x.com.samsung.da.supportedOptions': [
|
||||
'CV_TTYPE_RF9000A_FREEZE', 'CV_TTYPE_RF9000A_BEVERAGE'],
|
||||
}
|
||||
assert fridge._flex_zone_current(rep) == 'CV_TTYPE_RF9000A_BEVERAGE'
|
||||
|
||||
def test_reads_cv_fdr_prefix(self):
|
||||
rep = {
|
||||
'x.com.samsung.da.modes': ['CVN_CONVERTIBLE_ZONE', 'CV_FDR_MEAT', 'WATERFILTER_ENABLE'],
|
||||
'x.com.samsung.da.supportedOptions': [
|
||||
'CV_FDR_WINE', 'CV_FDR_DELI', 'CV_FDR_BEVERAGE', 'CV_FDR_MEAT'],
|
||||
}
|
||||
assert fridge._flex_zone_current(rep) == 'CV_FDR_MEAT'
|
||||
|
||||
def test_write_replaces_rather_than_duplicates(self):
|
||||
rep = {
|
||||
'x.com.samsung.da.modes': ['CVN_CONVERTIBLE_ZONE', 'CV_FDR_MEAT', 'WATERFILTER_ENABLE'],
|
||||
'x.com.samsung.da.supportedOptions': [
|
||||
'CV_FDR_WINE', 'CV_FDR_DELI', 'CV_FDR_BEVERAGE', 'CV_FDR_MEAT'],
|
||||
}
|
||||
path, payload = fridge._flex_zone_write('CV_FDR_WINE', rep)
|
||||
assert path == ['mode', 'vs', '0']
|
||||
modes = payload['x.com.samsung.da.modes']
|
||||
assert modes.count('CV_FDR_WINE') == 1
|
||||
assert 'CV_FDR_MEAT' not in modes
|
||||
assert 'CVN_CONVERTIBLE_ZONE' in modes and 'WATERFILTER_ENABLE' in modes
|
||||
|
||||
|
||||
class TestTp1xNativeDuplicateResources:
|
||||
"""The US TP1X_REF_21K publishes two native mirrors in addition to the
|
||||
richer vendor resources. They must count as covered without producing
|
||||
duplicate entities or guessing unverified write contracts."""
|
||||
|
||||
def test_duplicate_capabilities_have_no_entities(self):
|
||||
assert fridge.DEFROST_DELAY_NATIVE_DUPLICATE.entities == ()
|
||||
assert fridge.ICEMAKER_STATUS_NATIVE_DUPLICATE.entities == ()
|
||||
|
||||
def test_us_fixture_has_complete_coverage(self):
|
||||
from custom_components.localthings.registry.adapter import flatten
|
||||
from custom_components.localthings.registry.by_type import refrigerator
|
||||
from custom_components.localthings.registry.discovery import discover
|
||||
from tests.conftest import _load_device
|
||||
|
||||
resources = _load_device('refrigerator_tp1x_ref_21k_us')
|
||||
unbound = []
|
||||
bound = discover(
|
||||
resources,
|
||||
refrigerator.REGISTRY.capabilities,
|
||||
refrigerator.REGISTRY.pattern_capabilities,
|
||||
log=unbound.append,
|
||||
)
|
||||
|
||||
assert unbound == []
|
||||
state = flatten(bound, resources)
|
||||
assert state['defrost_delay'] is False
|
||||
assert 'ice_maker_enabled' not in state
|
||||
assert state['icemaker_one_enabled'] is True
|
||||
assert state['icemaker_two_enabled'] is True
|
||||
assert state['selfcheck_error'] == 'DA_ERROR_NONE'
|
||||
|
||||
|
||||
class TestPantryZone:
|
||||
"""Cool Select Zone pantry compartment on ARTIK051_REF_17K -- issue #20."""
|
||||
|
||||
def test_href(self):
|
||||
assert fridge.PANTRY_ZONE.href == '/status/pantry/one/vs/0'
|
||||
|
||||
def test_write(self):
|
||||
desc = fridge.PANTRY_ZONE.entities[0]
|
||||
path, body = desc.write_fn('FDR_WINE', {})
|
||||
assert path == ['status', 'pantry', 'one', 'vs', '0']
|
||||
assert body == {'x.com.samsung.da.mode': 'FDR_WINE'}
|
||||
|
||||
|
||||
class TestArtik051AndTp2xFixturesHaveCompleteCoverage:
|
||||
"""issue #20 (ARTIK051_REF_17K) and #26 (TP2X_REF_20K) both triggered
|
||||
the incomplete-capability-coverage repair; both must resolve to zero
|
||||
unbound hrefs now that /diagnosis/vs/0 (dishwasher.DIAGNOSIS, reused for
|
||||
fridges), /status/pantry/one/vs/0 (PANTRY_ZONE), and the OCF-native
|
||||
defrost/icemaker mirrors are covered."""
|
||||
|
||||
def test_artik051_ref_17k(self):
|
||||
from custom_components.localthings.registry.adapter import flatten
|
||||
from custom_components.localthings.registry.by_type import refrigerator
|
||||
from custom_components.localthings.registry.discovery import discover
|
||||
from tests.conftest import _load_device
|
||||
|
||||
resources = _load_device('refrigerator_artik051_ref_17k')
|
||||
unbound = []
|
||||
bound = discover(
|
||||
resources,
|
||||
refrigerator.REGISTRY.capabilities,
|
||||
refrigerator.REGISTRY.pattern_capabilities,
|
||||
log=unbound.append,
|
||||
)
|
||||
assert unbound == []
|
||||
state = flatten(bound, resources)
|
||||
assert state['pantry_zone_mode'] == 'FDR_DRINKS'
|
||||
|
||||
def test_tp2x_ref_20k(self):
|
||||
from custom_components.localthings.registry.adapter import flatten
|
||||
from custom_components.localthings.registry.by_type import refrigerator
|
||||
from custom_components.localthings.registry.discovery import discover
|
||||
from tests.conftest import _load_device
|
||||
|
||||
resources = _load_device('refrigerator_tp2x_ref_20k')
|
||||
unbound = []
|
||||
bound = discover(
|
||||
resources,
|
||||
refrigerator.REGISTRY.capabilities,
|
||||
refrigerator.REGISTRY.pattern_capabilities,
|
||||
log=unbound.append,
|
||||
)
|
||||
assert unbound == []
|
||||
state = flatten(bound, resources)
|
||||
assert state['flex_zone_mode'] == 'CV_FDR_BEVERAGE'
|
||||
|
||||
|
||||
class TestRefrigeratorAiEnergyLevelFixtureCoverage:
|
||||
"""AI energy-saving level (common.AI_ENERGY_LEVEL, see
|
||||
test_common_capabilities.py) is exercised end-to-end here against the
|
||||
refrigerator fixture/registry.
|
||||
|
||||
refrigerator_device.json's supportedAiLevel was extended to two entries
|
||||
(['1', '2']) specifically to exercise the select branch -- the real
|
||||
captured TP1X_REF_21K_US dump only ever reports one entry (switch
|
||||
branch), so the select is synthetic-fixture-only for now."""
|
||||
|
||||
def test_synthetic_fixture_has_complete_coverage(self):
|
||||
from custom_components.localthings.registry.adapter import flatten
|
||||
from custom_components.localthings.registry.by_type import refrigerator
|
||||
from custom_components.localthings.registry.discovery import discover
|
||||
from tests.conftest import _load_device
|
||||
|
||||
resources = _load_device('refrigerator')
|
||||
unbound = []
|
||||
bound = discover(
|
||||
resources,
|
||||
refrigerator.REGISTRY.capabilities,
|
||||
refrigerator.REGISTRY.pattern_capabilities,
|
||||
log=unbound.append,
|
||||
)
|
||||
assert unbound == []
|
||||
state = flatten(bound, resources)
|
||||
assert state['ai_energy_level'] == '1'
|
||||
|
||||
@@ -57,6 +57,165 @@ def test_registry_reproduces_golden_state_keys_for_washer():
|
||||
)
|
||||
|
||||
|
||||
def test_registry_reproduces_golden_state_keys_for_dryer():
|
||||
from tests.conftest import _load_device
|
||||
resources = _load_device('dryer')
|
||||
golden = json.loads((GOLDEN / 'dryer.json').read_text())
|
||||
state_keys = _new_state_keys('dryer', resources)
|
||||
assert set(state_keys) == set(golden['state_keys']), (
|
||||
f"state_keys mismatch:\n"
|
||||
f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n"
|
||||
f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}"
|
||||
)
|
||||
|
||||
|
||||
def test_registry_reproduces_golden_state_keys_for_airconditioner():
|
||||
from tests.conftest import _load_device
|
||||
resources = _load_device('airconditioner')
|
||||
golden = json.loads((GOLDEN / 'airconditioner.json').read_text())
|
||||
state_keys = _new_state_keys('airconditioner', resources)
|
||||
assert set(state_keys) == set(golden['state_keys']), (
|
||||
f"state_keys mismatch:\n"
|
||||
f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n"
|
||||
f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}"
|
||||
)
|
||||
|
||||
|
||||
def test_registry_reproduces_golden_state_keys_for_tp1x_ref_21k_us():
|
||||
from tests.conftest import _load_device
|
||||
resources = _load_device('refrigerator_tp1x_ref_21k_us')
|
||||
golden = json.loads(
|
||||
(GOLDEN / 'refrigerator_tp1x_ref_21k_us.json').read_text()
|
||||
)
|
||||
state_keys = _new_state_keys('refrigerator_tp1x_ref_21k_us', resources)
|
||||
assert set(state_keys) == set(golden['state_keys']), (
|
||||
f"state_keys mismatch:\n"
|
||||
f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n"
|
||||
f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}"
|
||||
)
|
||||
|
||||
|
||||
def test_registry_reproduces_golden_state_keys_for_washer_flexwash():
|
||||
"""FlexWash twin washers (WV-prefix consumer model, e.g. WV55M9600AW)
|
||||
report no oneUiVersion and previously fell through for_device_by_model's
|
||||
consumer-prefix map entirely -- issue #19."""
|
||||
from tests.conftest import _load_device
|
||||
resources = _load_device('washer_flexwash')
|
||||
golden = json.loads((GOLDEN / 'washer_flexwash.json').read_text())
|
||||
state_keys = _new_state_keys('washer_flexwash', resources)
|
||||
assert set(state_keys) == set(golden['state_keys']), (
|
||||
f"state_keys mismatch:\n"
|
||||
f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n"
|
||||
f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}"
|
||||
)
|
||||
|
||||
|
||||
def test_registry_reproduces_golden_state_keys_for_washer_dryer_combo():
|
||||
"""Washer/dryer combo units carry a writable dryLevel field on
|
||||
/washer/vs/0 itself, with no separate dryer resource -- issue #22."""
|
||||
from tests.conftest import _load_device
|
||||
resources = _load_device('washer_dryer_combo')
|
||||
golden = json.loads((GOLDEN / 'washer_dryer_combo.json').read_text())
|
||||
state_keys = _new_state_keys('washer_dryer_combo', resources)
|
||||
assert set(state_keys) == set(golden['state_keys']), (
|
||||
f"state_keys mismatch:\n"
|
||||
f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n"
|
||||
f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}"
|
||||
)
|
||||
|
||||
|
||||
def test_registry_reproduces_golden_state_keys_for_artik051_ref_17k():
|
||||
"""ARTIK051_REF_17K's Cool Select Zone pantry compartment
|
||||
(/status/pantry/one/vs/0) -- issue #20."""
|
||||
from tests.conftest import _load_device
|
||||
resources = _load_device('refrigerator_artik051_ref_17k')
|
||||
golden = json.loads((GOLDEN / 'refrigerator_artik051_ref_17k.json').read_text())
|
||||
state_keys = _new_state_keys('refrigerator_artik051_ref_17k', resources)
|
||||
assert set(state_keys) == set(golden['state_keys']), (
|
||||
f"state_keys mismatch:\n"
|
||||
f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n"
|
||||
f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}"
|
||||
)
|
||||
|
||||
|
||||
def test_registry_reproduces_golden_state_keys_for_tp2x_ref_20k():
|
||||
"""TP2X_REF_20K -- CV_FDR_-prefixed flex zone (issue #32) plus the extra
|
||||
energy fields (cumulativeConsumption/monthlyConsumption/
|
||||
thismonthlyConsumption) surfaced by issue #26."""
|
||||
from tests.conftest import _load_device
|
||||
resources = _load_device('refrigerator_tp2x_ref_20k')
|
||||
golden = json.loads((GOLDEN / 'refrigerator_tp2x_ref_20k.json').read_text())
|
||||
state_keys = _new_state_keys('refrigerator_tp2x_ref_20k', resources)
|
||||
assert set(state_keys) == set(golden['state_keys']), (
|
||||
f"state_keys mismatch:\n"
|
||||
f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n"
|
||||
f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}"
|
||||
)
|
||||
|
||||
|
||||
def test_registry_reproduces_golden_state_keys_for_ac_tp1x_da_ac_rac_01011():
|
||||
"""Newer AC firmware (Tizen Lite, oneUiVersion "7.0 Air conditioner"; model
|
||||
TP1X_DA-AC-RAC-01011) reports temperature via the vendor /temperatures/vs/0
|
||||
items[] resource and adds a /light/vs/0 display light, with extra vendor
|
||||
housekeeping hrefs -- issue #17 for this model class (PR #36)."""
|
||||
from tests.conftest import _load_device
|
||||
resources = _load_device('airconditioner_tp1x_da_ac_rac_01011')
|
||||
golden = json.loads(
|
||||
(GOLDEN / 'airconditioner_tp1x_da_ac_rac_01011.json').read_text()
|
||||
)
|
||||
state_keys = _new_state_keys('airconditioner_tp1x_da_ac_rac_01011', resources)
|
||||
assert set(state_keys) == set(golden['state_keys']), (
|
||||
f"state_keys mismatch:\n"
|
||||
f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n"
|
||||
f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}"
|
||||
)
|
||||
|
||||
|
||||
def test_registry_reproduces_golden_state_keys_for_tp2x_rac_20k():
|
||||
"""TP2X_RAC_20K (issue #37) -- reports no oneUiVersion; resolved via the
|
||||
'_RAC_' modelNum token fallback in for_device_by_model."""
|
||||
from tests.conftest import _load_device
|
||||
resources = _load_device('airconditioner_tp2x_rac_20k')
|
||||
golden = json.loads((GOLDEN / 'airconditioner_tp2x_rac_20k.json').read_text())
|
||||
state_keys = _new_state_keys('airconditioner_tp2x_rac_20k', resources)
|
||||
assert set(state_keys) == set(golden['state_keys']), (
|
||||
f"state_keys mismatch:\n"
|
||||
f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n"
|
||||
f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}"
|
||||
)
|
||||
|
||||
|
||||
def test_registry_reproduces_golden_state_keys_for_tp1x_rac():
|
||||
"""TP1X_DA-AC-RAC-01001_0000 (issue #38) -- fuller RAC board with display
|
||||
light, self-check, mute-once, and a current-limit setting."""
|
||||
from tests.conftest import _load_device
|
||||
resources = _load_device('airconditioner_tp1x_rac')
|
||||
golden = json.loads((GOLDEN / 'airconditioner_tp1x_rac.json').read_text())
|
||||
state_keys = _new_state_keys('airconditioner_tp1x_rac', resources)
|
||||
assert set(state_keys) == set(golden['state_keys']), (
|
||||
f"state_keys mismatch:\n"
|
||||
f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n"
|
||||
f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}"
|
||||
)
|
||||
|
||||
|
||||
def test_registry_reproduces_golden_state_keys_for_range():
|
||||
"""Range/cooktop-oven combo (model TP1X_DA-KS-RANGE-0102X, issue #44) --
|
||||
reports no oneUiVersion; resolved via the '-RANGE-' modelNum token
|
||||
fallback in for_device_by_model. Reuses the oven family's cavity/
|
||||
setpoint/mode/operational-state capabilities and adds range.py's
|
||||
per-burner capabilities for the 4 burners this dump reports."""
|
||||
from tests.conftest import _load_device
|
||||
resources = _load_device('range')
|
||||
golden = json.loads((GOLDEN / 'range.json').read_text())
|
||||
state_keys = _new_state_keys('range', resources)
|
||||
assert set(state_keys) == set(golden['state_keys']), (
|
||||
f"state_keys mismatch:\n"
|
||||
f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n"
|
||||
f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}"
|
||||
)
|
||||
|
||||
|
||||
def test_resources_from_batch_preferred_over_flat():
|
||||
from tests.conftest import _resources_from_dump
|
||||
dump = {
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
"""Tests for the shared laundry capabilities (washer/dryer/dishwasher)."""
|
||||
from custom_components.localthings.registry.capabilities import laundry
|
||||
|
||||
|
||||
class TestCourseHelpers:
|
||||
def test_parses_edit_course_list(self):
|
||||
raw = 'EditCourseList_1C1D211B1E29243328262722202325322F2E30662D8F96'
|
||||
assert laundry.parse_edit_course_list(raw) == [
|
||||
'1C', '1D', '21', '1B', '1E', '29', '24', '33', '28', '26', '27',
|
||||
'22', '20', '23', '25', '32', '2F', '2E', '30', '66', '2D', '8F', '96',
|
||||
]
|
||||
|
||||
def test_parse_edit_course_list_handles_missing_or_malformed(self):
|
||||
assert laundry.parse_edit_course_list(None) == []
|
||||
assert laundry.parse_edit_course_list('') == []
|
||||
assert laundry.parse_edit_course_list('no underscore') == []
|
||||
|
||||
def test_cycle_options_reads_live_edit_course_list(self):
|
||||
"""The device's own course list -- including a code ('65') never seen
|
||||
on the primary dump -- is used as-is; there is no hardcoded table."""
|
||||
resources = {
|
||||
'/wm/editcourse/vs/0': {
|
||||
'x.com.samsung.da.editCourseList': 'EditCourseList_651C',
|
||||
},
|
||||
}
|
||||
assert laundry.cycle_options(resources) == ['65', '1C']
|
||||
|
||||
def test_cycle_options_empty_when_resource_absent_or_empty(self):
|
||||
assert laundry.cycle_options({}) == []
|
||||
assert laundry.cycle_options({'/wm/editcourse/vs/0': {}}) == []
|
||||
|
||||
def test_option_value(self):
|
||||
opts = ['DeviceType_0167', 'Course_1C', 'GMT_04']
|
||||
assert laundry.option_value(opts, 'Course') == '1C'
|
||||
assert laundry.option_value(opts, 'Missing') is None
|
||||
|
||||
|
||||
class TestCycleSelect:
|
||||
def test_builds_labelled_cycle_select(self):
|
||||
desc = laundry.cycle_select(translation_key='dryer_cycle', icon='mdi:tumble-dryer')
|
||||
assert desc.key == 'cycle'
|
||||
assert desc.translation_key == 'dryer_cycle'
|
||||
assert desc.icon == 'mdi:tumble-dryer'
|
||||
assert desc.options is laundry.cycle_options
|
||||
|
||||
def test_reads_raw_course_code_from_options(self):
|
||||
desc = laundry.cycle_select(translation_key='dryer_cycle', icon='x')
|
||||
rep = {'x.com.samsung.da.options': ['DeviceType_0167', 'Course_16', 'GMT_04']}
|
||||
assert desc.rep_fn(rep) == '16'
|
||||
assert desc.rep_fn({'x.com.samsung.da.options': ['GMT_04']}) is None
|
||||
|
||||
def test_exists_only_when_edit_course_list_is_live(self):
|
||||
desc = laundry.cycle_select(translation_key='dryer_cycle', icon='x')
|
||||
assert desc.exists_fn({}, {}) is False
|
||||
assert desc.exists_fn({}, {'/wm/editcourse/vs/0': {}}) is False
|
||||
live = {'/wm/editcourse/vs/0': {'x.com.samsung.da.editCourseList': 'EditCourseList_16'}}
|
||||
assert desc.exists_fn({}, live) is True
|
||||
|
||||
def test_cycle_write_rmw_on_options(self):
|
||||
desc = laundry.cycle_select(translation_key='dryer_cycle', icon='x')
|
||||
rep = {'x.com.samsung.da.options': ['DeviceType_0167', 'Course_16', 'GMT_04']}
|
||||
path, body = desc.write_fn('1D', rep)
|
||||
assert path == ['course', 'vs', '0']
|
||||
assert body == {
|
||||
'x.com.samsung.da.options': ['DeviceType_0167', 'Course_1D', 'GMT_04'],
|
||||
}
|
||||
|
||||
def test_cycle_write_noop_without_options(self):
|
||||
desc = laundry.cycle_select(translation_key='dryer_cycle', icon='x')
|
||||
assert desc.write_fn('1D', {}) is None
|
||||
|
||||
|
||||
class TestBuzzerSound:
|
||||
def test_href(self):
|
||||
assert laundry.BUZZER_SOUND.href == '/buzzersound/vs/0'
|
||||
|
||||
def test_buzzer_sound_write(self):
|
||||
desc = next(e for e in laundry.BUZZER_SOUND.entities if e.key == 'buzzer_sound')
|
||||
assert desc.options_field == 'supportedBuzzerSound'
|
||||
path, body = desc.write_fn('On', {})
|
||||
assert path == ['buzzersound', 'vs', '0']
|
||||
assert body == {'setBuzzerSound': 'On'}
|
||||
|
||||
def test_finish_sound_exists_only_when_supported(self):
|
||||
desc = next(e for e in laundry.BUZZER_SOUND.entities if e.key == 'finish_sound')
|
||||
assert desc.exists_fn({'setBuzzerSound': 'On'}, {}) is False
|
||||
assert desc.exists_fn({'supportedFinishSound': ['FinishSound_1']}, {}) is True
|
||||
|
||||
|
||||
class TestJobBeginningStatus:
|
||||
def test_href_and_field(self):
|
||||
assert laundry.JOB_BEGINNING_STATUS.href == '/wm/jobbeginingstatus/vs/0'
|
||||
desc = laundry.JOB_BEGINNING_STATUS.entities[0]
|
||||
assert desc.field == 'x.com.samsung.da.currentStatus'
|
||||
assert desc.entity_category == 'diagnostic'
|
||||
@@ -53,6 +53,46 @@ def test_oven_setpoint_rejects_missing_items():
|
||||
assert desc.write_fn(200, {}) is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# OVEN_SETPOINT — Fahrenheit bounds (issue #44 range dump reports
|
||||
# unit="Fahrenheit"; bounds/step must track the live unit, not stay pinned
|
||||
# to the Celsius defaults)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _fahrenheit_rep(desired='0'):
|
||||
return {'x.com.samsung.da.items': [{
|
||||
'x.com.samsung.da.desired': desired,
|
||||
'x.com.samsung.da.unit': 'Fahrenheit',
|
||||
}]}
|
||||
|
||||
|
||||
def test_oven_setpoint_write_uses_fahrenheit_bounds():
|
||||
desc = oven.OVEN_SETPOINT.entities[0]
|
||||
rep = _fahrenheit_rep()
|
||||
# 350 is within F bounds (175-550) but above the C max (270) --
|
||||
# confirms the write path isn't silently still clamping to Celsius.
|
||||
_, body = desc.write_fn(350, rep)
|
||||
assert body['x.com.samsung.da.items'][0]['x.com.samsung.da.desired'] == '350'
|
||||
|
||||
|
||||
def test_oven_setpoint_rejects_out_of_range_fahrenheit():
|
||||
desc = oven.OVEN_SETPOINT.entities[0]
|
||||
rep = _fahrenheit_rep()
|
||||
assert desc.write_fn(100, rep) is None # below F min (175)
|
||||
assert desc.write_fn(600, rep) is None # above F max (550)
|
||||
|
||||
|
||||
def test_oven_setpoint_native_bounds_track_live_unit():
|
||||
desc = oven.OVEN_SETPOINT.entities[0]
|
||||
celsius_rep = {'x.com.samsung.da.items': [{'x.com.samsung.da.unit': 'Celsius'}]}
|
||||
assert desc.native_min_fn(celsius_rep) == 30.0
|
||||
assert desc.native_max_fn(celsius_rep) == 270.0
|
||||
fahrenheit_rep = _fahrenheit_rep()
|
||||
assert desc.native_min_fn(fahrenheit_rep) == 175.0
|
||||
assert desc.native_max_fn(fahrenheit_rep) == 550.0
|
||||
assert desc.step_fn(fahrenheit_rep) == 5.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# OVEN_MODE — SelectDesc with non-empty options
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
"""Tests for range/cooktop-oven combo support (issue #44)."""
|
||||
from custom_components.localthings.registry.adapter import flatten
|
||||
from custom_components.localthings.registry.by_type import for_device_by_model
|
||||
from custom_components.localthings.registry.capabilities import range as range_caps
|
||||
from custom_components.localthings.registry.discovery import discover
|
||||
|
||||
from tests.conftest import _load_device
|
||||
|
||||
|
||||
def _range():
|
||||
resources = _load_device('range')
|
||||
info = resources['/information/vs/0']
|
||||
reg = for_device_by_model(
|
||||
info['x.com.samsung.da.modelNum'], info['x.com.samsung.da.description'],
|
||||
)
|
||||
return reg, resources
|
||||
|
||||
|
||||
def _state():
|
||||
reg, resources = _range()
|
||||
bound = discover(resources, reg.capabilities, reg.pattern_capabilities)
|
||||
return flatten(bound, resources)
|
||||
|
||||
|
||||
def test_range_model_resolves_to_range_registry():
|
||||
reg, _ = _range()
|
||||
assert reg is not None and reg.name == 'range'
|
||||
|
||||
|
||||
def test_no_unbound_hrefs():
|
||||
"""Every resource in the issue #44 dump binds or is ignored -- clears the
|
||||
coverage-gap repair."""
|
||||
reg, resources = _range()
|
||||
unbound = []
|
||||
discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append)
|
||||
assert unbound == []
|
||||
|
||||
|
||||
def test_expected_entities_present():
|
||||
state = _state()
|
||||
for key in (
|
||||
'power_switch', 'oven_setpoint', 'current_temp_c', 'oven_mode',
|
||||
'machine_state', 'door_open', 'cloud_connected', 'cooktop_state',
|
||||
'cooktop_safety_shutoff_enabled',
|
||||
'burner_0_power_level', 'burner_0_state', 'burner_0_hot_surface',
|
||||
'burner_3_power_level', 'burner_3_state', 'burner_3_hot_surface',
|
||||
):
|
||||
assert key in state, key
|
||||
|
||||
|
||||
def test_unreported_burners_gated_out():
|
||||
"""Dump reports numberOfBurners=4 (indices 0-3) -- burner slots 4/5 of
|
||||
MAX_BURNERS must not appear as entities."""
|
||||
state = _state()
|
||||
for key in ('burner_4_power_level', 'burner_5_power_level'):
|
||||
assert key not in state, key
|
||||
|
||||
|
||||
def test_burner_power_level_write_is_read_modify_write():
|
||||
desc = next(e for e in range_caps.COOKTOP_STATUS.entities
|
||||
if e.key == 'burner_1_power_level')
|
||||
rep = {'burnerList': [
|
||||
{'burnerNumber': 0, 'powerLevel': '3'},
|
||||
{'burnerNumber': 1, 'powerLevel': '0'},
|
||||
]}
|
||||
path, body = desc.write_fn('boost', rep)
|
||||
assert path == ['cooktop', 'status', 'vs', '0']
|
||||
burners = {b['burnerNumber']: b['powerLevel'] for b in body['burnerList']}
|
||||
assert burners[1] == 'boost'
|
||||
assert burners[0] == '3' # sibling burner untouched
|
||||
|
||||
|
||||
def test_burner_power_level_write_rejects_missing_burner():
|
||||
desc = next(e for e in range_caps.COOKTOP_STATUS.entities
|
||||
if e.key == 'burner_2_power_level')
|
||||
rep = {'burnerList': [{'burnerNumber': 0, 'powerLevel': '0'}]}
|
||||
assert desc.write_fn('5', rep) is None
|
||||
|
||||
|
||||
def test_burner_hot_surface_true_when_not_normal():
|
||||
desc = next(e for e in range_caps.COOKTOP_STATUS.entities
|
||||
if e.key == 'burner_0_hot_surface')
|
||||
assert desc.value_fn([{'burnerNumber': 0, 'hotSurfaceState': 'hot'}]) is True
|
||||
assert desc.value_fn([{'burnerNumber': 0, 'hotSurfaceState': 'normal'}]) is False
|
||||
@@ -36,7 +36,7 @@ def test_options_field_unaffected():
|
||||
def test_callable_options_receives_full_resource_snapshot():
|
||||
"""A callable options is handed the coordinator's full href->rep
|
||||
snapshot, not just this entity's own href's rep -- needed for course
|
||||
lists decoded from a sibling resource (see washer._cycle_options)."""
|
||||
lists decoded from a sibling resource (see laundry.cycle_options)."""
|
||||
calls = []
|
||||
|
||||
def _options_fn(resources):
|
||||
|
||||
+232
-132
@@ -1,5 +1,10 @@
|
||||
"""Tests for washer-specific capabilities."""
|
||||
from custom_components.localthings.registry.capabilities import washer
|
||||
"""Tests for washer-specific capabilities.
|
||||
|
||||
Shared laundry capabilities (cycle-select machinery, buzzer, job status) are
|
||||
tested in test_laundry_capabilities.py; the OCF power/kids-lock/remote-control
|
||||
fallback pairs and the energy meter in test_common_capabilities.py.
|
||||
"""
|
||||
from custom_components.localthings.registry.capabilities import laundry, washer
|
||||
|
||||
|
||||
def _rep_by_href(cap, href):
|
||||
@@ -35,6 +40,22 @@ class TestWasherSettings:
|
||||
assert body == {'x.com.samsung.da.rinseCycles': '3'}
|
||||
|
||||
|
||||
class TestDryLevel:
|
||||
"""Washer/dryer combo units carry a writable dryLevel field on
|
||||
/washer/vs/0 itself, self-gated off on plain washers -- issue #22."""
|
||||
|
||||
def test_exists_only_when_supported_dry_level_present(self):
|
||||
desc = next(e for e in washer.WASHER_SETTINGS.entities if e.key == 'dry_level')
|
||||
assert desc.exists_fn({'x.com.samsung.da.supportedDryLevel': ['None', '30']}, {}) is True
|
||||
assert desc.exists_fn({}, {}) is False
|
||||
|
||||
def test_write(self):
|
||||
desc = next(e for e in washer.WASHER_SETTINGS.entities if e.key == 'dry_level')
|
||||
path, body = desc.write_fn('Cupboard', {})
|
||||
assert path == ['washer', 'vs', '0']
|
||||
assert body == {'x.com.samsung.da.dryLevel': 'Cupboard'}
|
||||
|
||||
|
||||
class TestWasherCourse:
|
||||
def test_href(self):
|
||||
assert washer.WASHER_COURSE.href == '/course/vs/0'
|
||||
@@ -57,7 +78,7 @@ class TestWasherCourse:
|
||||
|
||||
def test_cycle_desc_uses_cycle_options_callable(self):
|
||||
desc = next(e for e in washer.WASHER_COURSE.entities if e.key == 'cycle')
|
||||
assert desc.options is washer._cycle_options
|
||||
assert desc.options is laundry.cycle_options
|
||||
|
||||
def test_exists_only_when_edit_course_list_is_live(self):
|
||||
"""No hardcoded course table is kept -- the selector only appears
|
||||
@@ -79,39 +100,6 @@ class TestWasherCourse:
|
||||
}
|
||||
|
||||
|
||||
class TestCycleOptions:
|
||||
def test_parses_edit_course_list(self):
|
||||
raw = 'EditCourseList_1C1D211B1E29243328262722202325322F2E30662D8F96'
|
||||
assert washer._parse_edit_course_list(raw) == [
|
||||
'1C', '1D', '21', '1B', '1E', '29', '24', '33', '28', '26', '27',
|
||||
'22', '20', '23', '25', '32', '2F', '2E', '30', '66', '2D', '8F', '96',
|
||||
]
|
||||
|
||||
def test_parse_edit_course_list_handles_missing_or_malformed(self):
|
||||
assert washer._parse_edit_course_list(None) == []
|
||||
assert washer._parse_edit_course_list('') == []
|
||||
assert washer._parse_edit_course_list('no underscore') == []
|
||||
|
||||
def test_cycle_options_reads_live_edit_course_list(self):
|
||||
"""A different washer model's own course list -- including a code
|
||||
('65') never seen on the primary dump -- is used as-is; there is
|
||||
no hardcoded table to fall back to or reconcile against."""
|
||||
resources = {
|
||||
'/wm/editcourse/vs/0': {
|
||||
'x.com.samsung.da.editCourseList': 'EditCourseList_651C',
|
||||
},
|
||||
}
|
||||
assert washer._cycle_options(resources) == ['65', '1C']
|
||||
|
||||
def test_cycle_options_empty_when_resource_absent(self):
|
||||
assert washer._cycle_options({}) == []
|
||||
|
||||
def test_cycle_options_empty_when_resource_empty(self):
|
||||
"""The second known washer dump has /wm/editcourse/vs/0 == {}."""
|
||||
resources = {'/wm/editcourse/vs/0': {}}
|
||||
assert washer._cycle_options(resources) == []
|
||||
|
||||
|
||||
class TestDrumClean:
|
||||
def test_cycles_remaining(self):
|
||||
"""DrumCleanProposal_40 - WashingTimes_3 == 37, matching a live
|
||||
@@ -179,11 +167,32 @@ class TestDetergentSoftenerDosing:
|
||||
return next(e for e in washer.WASHER_COURSE.entities if e.key == key)
|
||||
|
||||
def test_quantity_and_hardness_read(self):
|
||||
"""The device reports the level un-padded ('3'), but the options and
|
||||
translation keys are zero-padded supported codes ('03'); rep_fn
|
||||
normalizes to the supported code so the value is a valid option
|
||||
(issue #9 -- otherwise HA renders the select 'unknown')."""
|
||||
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'
|
||||
assert self._desc('detergent_quantity').rep_fn(rep) == '03'
|
||||
assert self._desc('detergent_water_hardness').rep_fn(rep) == '02'
|
||||
assert self._desc('softener_quantity').rep_fn(rep) == '03'
|
||||
assert self._desc('softener_concentration').rep_fn(rep) == '02'
|
||||
|
||||
def test_current_value_is_a_valid_option_for_every_dosing_select(self):
|
||||
"""The core regression: HA shows a select 'unknown' when current_option
|
||||
is not in options. Each dosing select's value must be one of its own
|
||||
options."""
|
||||
rep = {'x.com.samsung.da.options': _DOSING_OPTIONS}
|
||||
for key in ('detergent_quantity', 'detergent_water_hardness',
|
||||
'softener_quantity', 'softener_concentration'):
|
||||
desc = self._desc(key)
|
||||
assert desc.rep_fn(rep) in desc.options(_DOSING_RESOURCES), key
|
||||
|
||||
def test_read_passes_through_when_no_supported_match(self):
|
||||
"""A value with no matching supported code is returned as-is rather than
|
||||
dropped, so an unexpected device stays visible instead of blank."""
|
||||
rep = {'x.com.samsung.da.options': ['DetergentLevelCtrl_7',
|
||||
'SupportedDetergentLevelCtrl_00010203']}
|
||||
assert self._desc('detergent_quantity').rep_fn(rep) == '7'
|
||||
|
||||
def test_translation_keys(self):
|
||||
"""detergent_quantity and softener_quantity share one translation_key
|
||||
@@ -209,17 +218,21 @@ class TestDetergentSoftenerDosing:
|
||||
assert desc.exists_fn({}, _DOSING_RESOURCES) is True
|
||||
|
||||
def test_quantity_write(self):
|
||||
"""The UI selects a padded supported code ('01'); the write posts the
|
||||
un-padded device code ('1'), mirroring how the device reports it."""
|
||||
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_1' in body['x.com.samsung.da.options']
|
||||
assert 'DetergentLevelCtrl_3' not in body['x.com.samsung.da.options']
|
||||
# untouched siblings survive the read-modify-write
|
||||
assert 'SoftenerLevelCtrl_3' 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']
|
||||
assert 'SoftenerLevel2Ctrl_3' in body['x.com.samsung.da.options']
|
||||
|
||||
def test_low_reservoir_off_when_alarm_off(self):
|
||||
rep = {'x.com.samsung.da.options': _DOSING_OPTIONS}
|
||||
@@ -238,112 +251,199 @@ class TestDetergentSoftenerDosing:
|
||||
assert self._desc('detergent_low').exists_fn(rep, {}) is True
|
||||
|
||||
|
||||
class TestBuzzerSound:
|
||||
def test_href(self):
|
||||
assert washer.BUZZER_SOUND.href == '/buzzersound/vs/0'
|
||||
class TestWashOptionToggles:
|
||||
"""Bubble soak / pre-wash / intensive-wash switches, from the same
|
||||
options[] array as the cycle select (issue #22 follow-up). Confirmed
|
||||
On/Off shape from a dump with Bubble Soak toggled on in the app."""
|
||||
|
||||
def test_buzzer_sound_write(self):
|
||||
desc = next(e for e in washer.BUZZER_SOUND.entities if e.key == 'buzzer_sound')
|
||||
path, body = desc.write_fn('Volume_High', {})
|
||||
assert path == ['buzzersound', 'vs', '0']
|
||||
assert body == {'setBuzzerSound': 'Volume_High'}
|
||||
@staticmethod
|
||||
def _desc(key):
|
||||
return next(e for e in washer.WASHER_COURSE.entities if e.key == key)
|
||||
|
||||
def test_finish_sound_exists_only_when_supported(self):
|
||||
desc = next(e for e in washer.BUZZER_SOUND.entities if e.key == 'finish_sound')
|
||||
assert desc.exists_fn({'setBuzzerSound': 'On'}, {}) is False
|
||||
assert desc.exists_fn({'supportedFinishSound': ['FinishSound_1']}, {}) is True
|
||||
@staticmethod
|
||||
def _keys():
|
||||
return ('bubble_soak', 'pre_wash', 'intensive')
|
||||
|
||||
def test_finish_sound_write(self):
|
||||
desc = next(e for e in washer.BUZZER_SOUND.entities if e.key == 'finish_sound')
|
||||
path, body = desc.write_fn('FinishSound_2', {})
|
||||
assert path == ['buzzersound', 'vs', '0']
|
||||
assert body == {'setFinishSound': 'FinishSound_2'}
|
||||
@staticmethod
|
||||
def _prefix(key):
|
||||
return {'bubble_soak': 'BubbleSoak',
|
||||
'pre_wash': 'PreWashSetting',
|
||||
'intensive': 'IntensiveSetting'}[key]
|
||||
|
||||
def test_exists_only_when_field_present(self):
|
||||
for key in self._keys():
|
||||
desc = self._desc(key)
|
||||
assert desc.exists_fn({'x.com.samsung.da.options': []}, {}) is False
|
||||
prefix = self._prefix(key)
|
||||
rep = {'x.com.samsung.da.options': [f'{prefix}_Off']}
|
||||
assert desc.exists_fn(rep, {}) is True
|
||||
|
||||
def test_reads_off(self):
|
||||
for key in self._keys():
|
||||
prefix = self._prefix(key)
|
||||
rep = {'x.com.samsung.da.options': [f'{prefix}_Off']}
|
||||
assert self._desc(key).rep_fn(rep) is False
|
||||
|
||||
def test_reads_on(self):
|
||||
for key in self._keys():
|
||||
prefix = self._prefix(key)
|
||||
rep = {'x.com.samsung.da.options': [f'{prefix}_On']}
|
||||
assert self._desc(key).rep_fn(rep) is True
|
||||
|
||||
def test_write_on_and_off(self):
|
||||
"""write_fn receives the same 'On'/'Off' string switch.py sends
|
||||
(not a bool) -- covers a bug where an earlier `'On' if p else 'Off'`
|
||||
implementation always wrote 'On', since any non-empty string
|
||||
(including 'Off') is truthy."""
|
||||
for key in self._keys():
|
||||
prefix = self._prefix(key)
|
||||
rep = {'x.com.samsung.da.options': [f'{prefix}_Off', 'GMT_02']}
|
||||
path, body = self._desc(key).write_fn('On', rep)
|
||||
assert path == ['course', 'vs', '0']
|
||||
assert f'{prefix}_On' in body['x.com.samsung.da.options']
|
||||
assert 'GMT_02' in body['x.com.samsung.da.options']
|
||||
|
||||
rep = {'x.com.samsung.da.options': [f'{prefix}_On']}
|
||||
path, body = self._desc(key).write_fn('Off', rep)
|
||||
assert f'{prefix}_Off' in body['x.com.samsung.da.options']
|
||||
assert f'{prefix}_On' not in body['x.com.samsung.da.options']
|
||||
|
||||
def test_write_rejects_non_on_off_payload(self):
|
||||
for key in self._keys():
|
||||
prefix = self._prefix(key)
|
||||
rep = {'x.com.samsung.da.options': [f'{prefix}_Off']}
|
||||
assert self._desc(key).write_fn('bogus', rep) is None
|
||||
|
||||
|
||||
class TestJobBeginningStatus:
|
||||
def test_href_and_field(self):
|
||||
assert washer.WASHER_JOB_BEGINNING_STATUS.href == '/wm/jobbeginingstatus/vs/0'
|
||||
desc = washer.WASHER_JOB_BEGINNING_STATUS.entities[0]
|
||||
assert desc.field == 'x.com.samsung.da.currentStatus'
|
||||
# editCourseList and availability bitmaps from the reporter's issue #22
|
||||
# follow-up dump (WD90T654DBN/S1, course '30' selected, Bubble Soak just
|
||||
# turned on in the app): 24 courses, course '30' at position 1 reads 'F0'
|
||||
# (available) on all three bitmaps; course '1C' at position 0 reads '00' on
|
||||
# BubbleSoakSet (matching the app graying that control out for Eco 40-60).
|
||||
_EDIT_COURSE_RESOURCES = {
|
||||
'/wm/editcourse/vs/0': {
|
||||
'x.com.samsung.da.editCourseList':
|
||||
'EditCourseList_1C301E26361B1D1F253324322022232F212D272838393729',
|
||||
},
|
||||
}
|
||||
_BUBBLE_SOAK_SET = 'BubbleSoakSet_00F000F000F000F0F0F0F00000F000F0F00000F000000000'
|
||||
_PRE_WASH_AVAILABLE_SET = 'PreWashAvailableSet_F0F000F0F0F000F0F0F0F00000F0F0F0F00000F000000000'
|
||||
_INTENSIVE_AVAILABLE_SET = 'IntensiveAvailableSet_F0F000F0F0F000F0F0F0F00000F0F0F0F00000F000000000'
|
||||
|
||||
|
||||
class TestPowerFallback:
|
||||
def test_generic_href(self):
|
||||
assert washer.POWER_GENERIC.href == '/power/0'
|
||||
class TestWashOptionToggleValidation:
|
||||
"""validate_fn rejects turning a toggle on for a course whose byte in
|
||||
its availability bitmap isn't 'F0', with a user-facing message switch.py
|
||||
raises as ServiceValidationError -- distinct from write_fn's silent
|
||||
no-op for a malformed payload."""
|
||||
|
||||
def test_generic_read_write(self):
|
||||
desc = washer.POWER_GENERIC.entities[0]
|
||||
assert desc.value_fn(True) is True
|
||||
assert desc.value_fn(False) is False
|
||||
path, body = desc.write_fn('On', {})
|
||||
assert path == ['power', '0']
|
||||
assert body == {'value': True}
|
||||
path, body = desc.write_fn('Off', {})
|
||||
assert body == {'value': False}
|
||||
@staticmethod
|
||||
def _desc(key):
|
||||
return next(e for e in washer.WASHER_COURSE.entities if e.key == key)
|
||||
|
||||
def test_vs_fallback_binds_only_when_generic_absent(self):
|
||||
assert washer.POWER_VS_FALLBACK.href == '/power/vs/0'
|
||||
assert washer.POWER_VS_FALLBACK.match_fn({}, {'/power/vs/0': {}}) is True
|
||||
assert washer.POWER_VS_FALLBACK.match_fn({}, {'/power/0': {}, '/power/vs/0': {}}) is False
|
||||
def test_allowed_on_a_supported_course(self):
|
||||
rep = {'x.com.samsung.da.options': ['Course_30', _BUBBLE_SOAK_SET]}
|
||||
assert self._desc('bubble_soak').validate_fn(
|
||||
'On', rep, _EDIT_COURSE_RESOURCES) is None
|
||||
|
||||
def test_vs_fallback_read_write(self):
|
||||
desc = washer.POWER_VS_FALLBACK.entities[0]
|
||||
assert desc.value_fn('On') is True
|
||||
assert desc.value_fn('Off') is False
|
||||
path, body = desc.write_fn('On', {})
|
||||
assert path == ['power', 'vs', '0']
|
||||
assert body == {'x.com.samsung.da.power': 'On'}
|
||||
def test_rejected_on_an_unsupported_course(self):
|
||||
rep = {'x.com.samsung.da.options': ['Course_1C', _BUBBLE_SOAK_SET]}
|
||||
msg = self._desc('bubble_soak').validate_fn('On', rep, _EDIT_COURSE_RESOURCES)
|
||||
assert msg == "Bubble soak isn't available on the selected cycle."
|
||||
|
||||
def test_pre_wash_and_intensive_use_their_own_availableset_field(self):
|
||||
rep = {'x.com.samsung.da.options': ['Course_30', _PRE_WASH_AVAILABLE_SET]}
|
||||
assert self._desc('pre_wash').validate_fn(
|
||||
'On', rep, _EDIT_COURSE_RESOURCES) is None
|
||||
rep = {'x.com.samsung.da.options': ['Course_30', _INTENSIVE_AVAILABLE_SET]}
|
||||
assert self._desc('intensive').validate_fn(
|
||||
'On', rep, _EDIT_COURSE_RESOURCES) is None
|
||||
|
||||
def test_turning_off_is_never_blocked(self):
|
||||
rep = {'x.com.samsung.da.options': ['Course_1C', _BUBBLE_SOAK_SET]}
|
||||
assert self._desc('bubble_soak').validate_fn(
|
||||
'Off', rep, _EDIT_COURSE_RESOURCES) is None
|
||||
|
||||
def test_allows_write_when_course_unresolvable(self):
|
||||
"""No editCourseList, no Course_ token, or a bitmap whose length
|
||||
doesn't match editCourseList -- in every case, fail open rather than
|
||||
block a write we can't actually verify."""
|
||||
desc = self._desc('bubble_soak')
|
||||
rep = {'x.com.samsung.da.options': ['Course_1C', _BUBBLE_SOAK_SET]}
|
||||
assert desc.validate_fn('On', rep, {}) is None
|
||||
|
||||
rep = {'x.com.samsung.da.options': [_BUBBLE_SOAK_SET]}
|
||||
assert desc.validate_fn('On', rep, _EDIT_COURSE_RESOURCES) is None
|
||||
|
||||
rep = {'x.com.samsung.da.options': ['Course_1C', 'BubbleSoakSet_00F0']}
|
||||
assert desc.validate_fn('On', rep, _EDIT_COURSE_RESOURCES) is None
|
||||
|
||||
|
||||
class TestKidsLockFallback:
|
||||
def test_generic_read_write(self):
|
||||
assert washer.KIDS_LOCK_GENERIC.href == '/kidslock/0'
|
||||
desc = washer.KIDS_LOCK_GENERIC.entities[0]
|
||||
assert desc.value_fn(True) is True
|
||||
path, body = desc.write_fn('On', {})
|
||||
assert path == ['kidslock', '0']
|
||||
assert body == {'value': True}
|
||||
class TestAiEnergyLevel:
|
||||
"""Issue #40 -- /energy/ailevel/vs/0 was unbound on a plain washer.
|
||||
|
||||
def test_vs_fallback_gated(self):
|
||||
assert washer.KIDS_LOCK_VS_FALLBACK.match_fn({}, {'/kidslock/vs/0': {}}) is True
|
||||
assert washer.KIDS_LOCK_VS_FALLBACK.match_fn(
|
||||
{}, {'/kidslock/0': {}, '/kidslock/vs/0': {}}) is False
|
||||
The capability itself (common.AI_ENERGY_LEVEL) is tested in
|
||||
test_common_capabilities.py; this just confirms it's wired into the
|
||||
washer registry and that the fixture's single-entry supportedAiLevel
|
||||
(['1'], matching the issue's dump) surfaces as a switch, not a select."""
|
||||
|
||||
def test_fixture_has_complete_coverage(self):
|
||||
from custom_components.localthings.registry.adapter import flatten
|
||||
from custom_components.localthings.registry.by_type import washer as washer_registry
|
||||
from custom_components.localthings.registry.discovery import discover
|
||||
from tests.conftest import _load_device
|
||||
|
||||
resources = _load_device('washer')
|
||||
unbound = []
|
||||
bound = discover(
|
||||
resources,
|
||||
washer_registry.REGISTRY.capabilities,
|
||||
washer_registry.REGISTRY.pattern_capabilities,
|
||||
log=unbound.append,
|
||||
)
|
||||
assert unbound == []
|
||||
state = flatten(bound, resources)
|
||||
assert state['ai_energy_level'] is False # aiLevel '0' -- off
|
||||
|
||||
|
||||
class TestRemoteControlFallback:
|
||||
def test_generic_read(self):
|
||||
assert washer.REMOTE_CONTROL_GENERIC.href == '/remotectrl/0'
|
||||
desc = washer.REMOTE_CONTROL_GENERIC.entities[0]
|
||||
assert desc.value_fn(True) is True
|
||||
assert desc.value_fn(False) is False
|
||||
class TestFlexWashAndComboFixturesHaveCompleteCoverage:
|
||||
"""FlexWash (issue #19, previously unrecognized entirely) and
|
||||
washer/dryer combo (issue #22, dry_level) dumps must both resolve to
|
||||
zero unbound hrefs."""
|
||||
|
||||
def test_vs_fallback_gated(self):
|
||||
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
|
||||
def test_flexwash(self):
|
||||
from custom_components.localthings.registry.adapter import flatten
|
||||
from custom_components.localthings.registry.by_type import washer as washer_registry
|
||||
from custom_components.localthings.registry.discovery import discover
|
||||
from tests.conftest import _load_device
|
||||
|
||||
resources = _load_device('washer_flexwash')
|
||||
unbound = []
|
||||
bound = discover(
|
||||
resources,
|
||||
washer_registry.REGISTRY.capabilities,
|
||||
washer_registry.REGISTRY.pattern_capabilities,
|
||||
log=unbound.append,
|
||||
)
|
||||
assert unbound == []
|
||||
state = flatten(bound, resources)
|
||||
assert 'dry_level' not in state # plain washer -- no supportedDryLevel field
|
||||
|
||||
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_dryer_combo(self):
|
||||
from custom_components.localthings.registry.adapter import flatten
|
||||
from custom_components.localthings.registry.by_type import washer as washer_registry
|
||||
from custom_components.localthings.registry.discovery import discover
|
||||
from tests.conftest import _load_device
|
||||
|
||||
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
|
||||
resources = _load_device('washer_dryer_combo')
|
||||
unbound = []
|
||||
bound = discover(
|
||||
resources,
|
||||
washer_registry.REGISTRY.capabilities,
|
||||
washer_registry.REGISTRY.pattern_capabilities,
|
||||
log=unbound.append,
|
||||
)
|
||||
assert unbound == []
|
||||
state = flatten(bound, resources)
|
||||
# the device's own "off" sentinel is the literal string 'None', not absence
|
||||
assert state['dry_level'] == 'None'
|
||||
|
||||
Reference in New Issue
Block a user