Compare commits
122
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ea986fe8e5 | ||
|
|
e47a71689d | ||
|
|
9cf1e46bf6 | ||
|
|
7276ff65a8 | ||
|
|
c731ecefe5 | ||
|
|
77cc875608 | ||
|
|
ae711038ca | ||
|
|
3b3dd54372 | ||
|
|
b2c32a90b6 | ||
|
|
b2c0517115 | ||
|
|
39dcf6a7b8 | ||
|
|
ed768d7257 | ||
|
|
efe87381b7 | ||
|
|
8e25e6dc71 | ||
|
|
60f8fdf488 | ||
|
|
dccd6e1209 | ||
|
|
037dc9722f | ||
|
|
dffe1ef455 | ||
|
|
49bd4ef0cb | ||
|
|
f7faf56689 | ||
|
|
85cca70b6b | ||
|
|
020402c82f | ||
|
|
cf18cc3ecc | ||
|
|
61e6035a36 | ||
|
|
7b155aabc2 | ||
|
|
788b8f98a1 | ||
|
|
83bd158049 | ||
|
|
618443db7b | ||
|
|
3ed277a33b | ||
|
|
42a80fffe6 | ||
|
|
b121d24966 | ||
|
|
615c5d5aa9 | ||
|
|
30e814f242 | ||
|
|
13f1214790 | ||
|
|
4d21f3b8f2 | ||
|
|
760d797c12 | ||
|
|
b944549f55 | ||
|
|
94551da4a0 | ||
|
|
f7db568a43 | ||
|
|
62caf769f2 | ||
|
|
e7daa21931 | ||
|
|
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 | ||
|
|
c64f58aaa5 | ||
|
|
bedc662b47 | ||
|
|
296535d0f2 | ||
|
|
d7fe609159 | ||
|
|
15f836caeb | ||
|
|
1576d095c8 | ||
|
|
febc8fa4a0 | ||
|
|
9264b912f9 | ||
|
|
84788174f8 | ||
|
|
00c3ebaf75 | ||
|
|
4cc073c88a | ||
|
|
91013ece60 | ||
|
|
795763f3ca | ||
|
|
a7ed230569 | ||
|
|
9fee5b5ec0 | ||
|
|
f24d6a0ae6 | ||
|
|
29b145ccdd | ||
|
|
5fbf58af30 | ||
|
|
9ae59889d4 | ||
|
|
35f76ce2df | ||
|
|
2a6bbc9dfb | ||
|
|
aee19fd953 | ||
|
|
37bd063c0a | ||
|
|
e875266405 | ||
|
|
9b48ca0e82 | ||
|
|
e66f4fd2ff | ||
|
|
bf3b3a8bbd | ||
|
|
dd541b19a1 |
@@ -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,32 @@
|
||||
|
||||
**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` |
|
||||
| Air purifier | `by_type/air_purifier.py` |
|
||||
| Dryer | `by_type/dryer.py` |
|
||||
| Oven | `by_type/oven.py` |
|
||||
| Cooktop (read-only burner status) | `by_type/cooktop.py` |
|
||||
| Range hood | `by_type/range_hood.py` |
|
||||
| Range | `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 +40,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 +59,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 +75,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 +88,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 +96,33 @@ 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 / fan.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,
|
||||
cooktop, range_hood, 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 +140,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`, add its consumer-model prefix to `_CONSUMER_PREFIX_TO_KEY` so `for_device_by_model()` can route it. If it also omits `/information/vs/0` (as the verified NA9300K cooktop does), add a distinctive, conservative resource-signature rule to `for_device_by_resources()`.
|
||||
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 +162,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,327 @@
|
||||
"""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 and
|
||||
applies the optimistic value/settle guard to that same href -- not the bound
|
||||
`/mode/vs/0` href -- so one descriptor drives writes to, and gets fresh state
|
||||
back for, power, mode, temperature and wind resources alike.
|
||||
"""
|
||||
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)
|
||||
@@ -2,16 +2,24 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import selectors
|
||||
import socket
|
||||
import ssl
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
import voluptuous as vol
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.data_entry_flow import FlowResult
|
||||
from homeassistant.helpers.selector import (
|
||||
ObjectSelector,
|
||||
SelectSelector,
|
||||
SelectSelectorConfig,
|
||||
SelectSelectorMode,
|
||||
TextSelector,
|
||||
TextSelectorConfig,
|
||||
TextSelectorType,
|
||||
@@ -22,7 +30,9 @@ 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,
|
||||
CONF_BYPASS_REMOTE_CONTROL,
|
||||
PROBE_PORT_RANGE, PREFERRED_PROBE_PORTS, LIVENESS_PROBE_TIMEOUT_S,
|
||||
PROBE_GET_TIMEOUT_S,
|
||||
)
|
||||
|
||||
_TEXT = TextSelector(TextSelectorConfig(type=TextSelectorType.TEXT))
|
||||
@@ -120,12 +130,81 @@ 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
|
||||
from smartthings_local.protocol.dtls_session import DtlsCoapSession
|
||||
from .registry.batch import parse_device0_batch
|
||||
from .registry.by_type import for_device, for_device_by_model
|
||||
from .registry.by_type import (
|
||||
for_device, for_device_by_model, for_device_by_resources,
|
||||
)
|
||||
|
||||
_LOGGER.debug("Fetching Samsung cloud UUID from %s", _SAMSUNG_CLOUD_HOST)
|
||||
try:
|
||||
@@ -146,8 +225,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 +249,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)
|
||||
@@ -183,7 +275,7 @@ def _probe_and_validate(host: str, ca_cert_pem: str, ca_key_pem: str) -> dict:
|
||||
) or for_device_by_model(
|
||||
info_resource.get('x.com.samsung.da.modelNum', ''),
|
||||
info_resource.get('x.com.samsung.da.description', ''),
|
||||
)
|
||||
) or for_device_by_resources(resources)
|
||||
return {
|
||||
"port": port,
|
||||
"serial": serial,
|
||||
@@ -215,6 +307,13 @@ class LocalThingsConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
self._ca_key_pem: str = ""
|
||||
self._pending_info: dict | None = None
|
||||
|
||||
@staticmethod
|
||||
@callback
|
||||
def async_get_options_flow(
|
||||
config_entry: config_entries.ConfigEntry,
|
||||
) -> LocalThingsOptionsFlow:
|
||||
return LocalThingsOptionsFlow()
|
||||
|
||||
def _create_entry(self, info: dict) -> FlowResult:
|
||||
return self.async_create_entry(
|
||||
title=f"Samsung Appliance ({self._host})",
|
||||
@@ -299,3 +398,146 @@ class LocalThingsConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
"one_ui_version": self._pending_info["one_ui_version"] or "(none reported)",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class LocalThingsOptionsFlow(config_entries.OptionsFlow):
|
||||
"""Per-device options: the remote-control-off write-block override
|
||||
(issue #54) plus a debug panel for writing an arbitrary body to an
|
||||
arbitrary resource href, so a user can pin down device-specific write
|
||||
behavior without waiting on a new release.
|
||||
|
||||
The remote-control override exists because most devices reject writes
|
||||
outright while remote control is off and a clear error beats a silent
|
||||
device-side rejection -- but not every model actually enforces that,
|
||||
so this lets a user who's confirmed their device accepts writes anyway
|
||||
turn the block off for just that device rather than it being
|
||||
hardcoded on for everyone. The debug panel goes further: it bypasses
|
||||
that block (and every write_fn/validate_fn) entirely, sending exactly
|
||||
the body the user types to whatever href they pick.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._debug_href: str = ""
|
||||
self._debug_result: tuple[int, dict] | None = None
|
||||
|
||||
def _coordinator(self):
|
||||
return self.hass.data.get(DOMAIN, {}).get(self.config_entry.entry_id)
|
||||
|
||||
async def async_step_init(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
return self.async_show_menu(
|
||||
step_id="init",
|
||||
menu_options=["settings", "debug_write"],
|
||||
)
|
||||
|
||||
async def async_step_settings(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
if user_input is not None:
|
||||
return self.async_create_entry(data=user_input)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="settings",
|
||||
data_schema=vol.Schema({
|
||||
vol.Required(
|
||||
CONF_BYPASS_REMOTE_CONTROL,
|
||||
default=self.config_entry.options.get(
|
||||
CONF_BYPASS_REMOTE_CONTROL, False
|
||||
),
|
||||
): bool,
|
||||
}),
|
||||
)
|
||||
|
||||
async def async_step_debug_write(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
coord = self._coordinator()
|
||||
if coord is None:
|
||||
return self.async_abort(reason="not_loaded")
|
||||
|
||||
if user_input is not None:
|
||||
self._debug_href = user_input["href"]
|
||||
return await self.async_step_debug_edit()
|
||||
|
||||
hrefs = sorted(coord.last_resources.keys())
|
||||
return self.async_show_form(
|
||||
step_id="debug_write",
|
||||
data_schema=vol.Schema({
|
||||
vol.Required("href"): SelectSelector(SelectSelectorConfig(
|
||||
options=hrefs,
|
||||
custom_value=True,
|
||||
mode=SelectSelectorMode.DROPDOWN,
|
||||
)),
|
||||
}),
|
||||
)
|
||||
|
||||
def _show_debug_edit_form(
|
||||
self, href: str, current: dict, errors: dict[str, str], payload,
|
||||
) -> FlowResult:
|
||||
return self.async_show_form(
|
||||
step_id="debug_edit",
|
||||
data_schema=vol.Schema({
|
||||
vol.Required(
|
||||
"payload", default=(payload if payload is not None else {}),
|
||||
): ObjectSelector(),
|
||||
}),
|
||||
errors=errors,
|
||||
description_placeholders={
|
||||
"href": href,
|
||||
"current_value": (
|
||||
json.dumps(current, indent=2, ensure_ascii=False)
|
||||
if current else "(no cached value)"
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
async def async_step_debug_edit(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
coord = self._coordinator()
|
||||
if coord is None:
|
||||
return self.async_abort(reason="not_loaded")
|
||||
|
||||
href = self._debug_href
|
||||
current = coord.resource(href)
|
||||
|
||||
if user_input is not None:
|
||||
payload = user_input.get("payload") or {}
|
||||
if not isinstance(payload, dict) or not payload:
|
||||
return self._show_debug_edit_form(
|
||||
href, current, {"payload": "empty_payload"}, payload
|
||||
)
|
||||
try:
|
||||
code, new_rep = await coord.async_raw_write(href, payload)
|
||||
except Exception: # noqa: BLE001 - surfaced to the user below
|
||||
_LOGGER.exception("debug raw write failed for %s", href)
|
||||
return self._show_debug_edit_form(
|
||||
href, current, {"base": "write_failed"}, payload
|
||||
)
|
||||
self._debug_result = (code, new_rep)
|
||||
return await self.async_step_debug_result()
|
||||
|
||||
return self._show_debug_edit_form(href, current, {}, None)
|
||||
|
||||
async def async_step_debug_result(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
code, new_rep = self._debug_result or (0, {})
|
||||
return self.async_show_menu(
|
||||
step_id="debug_result",
|
||||
menu_options=["debug_write", "finish"],
|
||||
description_placeholders={
|
||||
"code": f"{code >> 5}.{code & 0x1f:02d} ({code:#04x})",
|
||||
"new_value": (
|
||||
json.dumps(new_rep, indent=2, ensure_ascii=False)
|
||||
if new_rep else "(no value returned)"
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
async def async_step_finish(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
# Close the flow without altering saved options.
|
||||
return self.async_create_entry(data=dict(self.config_entry.options))
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
DOMAIN = "localthings"
|
||||
|
||||
PLATFORMS = ["sensor", "binary_sensor", "switch", "number", "select", "button", "time"]
|
||||
PLATFORMS = [
|
||||
"sensor", "binary_sensor", "switch", "number", "select", "button",
|
||||
"time", "climate", "fan",
|
||||
]
|
||||
|
||||
CONF_HOST = "host"
|
||||
CONF_PORT = "port"
|
||||
@@ -9,7 +12,35 @@ 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]
|
||||
# Options-flow key (entry.options, not entry.data): lets a user override the
|
||||
# device-wide remote-control-off write block for a specific device (issue
|
||||
# #54). Some devices report remote control off yet still accept certain
|
||||
# writes (e.g. default detergent/softener dosing on a washer, applied even
|
||||
# to the built-in programs) -- the block exists to give a clear error
|
||||
# instead of a silent device-side rejection, but that assumption doesn't
|
||||
# hold for every model. Defaults to False (block stays on) everywhere it's
|
||||
# read, so devices this doesn't apply to see no behavior change.
|
||||
CONF_BYPASS_REMOTE_CONTROL = "bypass_remote_control_lock"
|
||||
|
||||
# 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
|
||||
@@ -20,7 +21,8 @@ from smartthings_local.protocol.dtls_session import DtlsCoapSession
|
||||
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.by_type import for_device, for_device_by_model, for_device_by_resources
|
||||
from .registry.capabilities.common import merge_options_field, remote_control_enabled
|
||||
from .registry.discovery import discover, BoundEntity
|
||||
from .registry import CAPABILITIES
|
||||
from .registry.adapter import flatten
|
||||
@@ -29,13 +31,19 @@ from .observe import ObserveManager, MODE_OBSERVE, MODE_POLL, GRACE_PERIOD_S
|
||||
|
||||
from .const import (
|
||||
DOMAIN, CONF_HOST, CONF_PORT, CONF_LEAF_CERT_PEM, CONF_LEAF_KEY_PEM,
|
||||
DEVICE_SUPPORT_ISSUE_URL, SUMMARY_INTERVAL_S,
|
||||
CONF_BYPASS_REMOTE_CONTROL, DEVICE_SUPPORT_ISSUE_URL, SUMMARY_INTERVAL_S,
|
||||
)
|
||||
|
||||
_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
|
||||
@@ -69,6 +77,14 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
# fine session shouldn't tear down a working OBSERVE subscription.
|
||||
_POLL_TIMEOUT_LIMIT: int = 3
|
||||
|
||||
# Timeouts for the two network round trips a write triggers: the PUT
|
||||
# itself (_do_put), then the confirming full /device/0 summary poll
|
||||
# async_send_command requests right after (_poll_once). Named here
|
||||
# (rather than left as inline literals) so the write-settle window
|
||||
# below can be sized to always outlast both — see async_send_command.
|
||||
_POST_TIMEOUT_S: float = 8.0
|
||||
_POLL_TIMEOUT_S: float = 35.0
|
||||
|
||||
def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||
# Per-device logger (module logger scoped to this device's host) so
|
||||
# every log line — including the base DataUpdateCoordinator's own
|
||||
@@ -115,6 +131,13 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
def last_resources(self) -> dict:
|
||||
return self._cache.snapshot()
|
||||
|
||||
def resource(self, href: str) -> dict:
|
||||
"""A single href's current rep. Cheaper than `last_resources.get(href)`
|
||||
for callers that only need one href — `last_resources` copies every
|
||||
tracked href's rep to build the snapshot dict, while this is a
|
||||
direct O(1) cache lookup."""
|
||||
return self._cache.get(href) or {}
|
||||
|
||||
@property
|
||||
def observe_mode(self) -> str:
|
||||
return self._observe.mode
|
||||
@@ -196,7 +219,7 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
# would have succeeded, not a dead session. 35s gives a slow
|
||||
# blockwise transfer room to actually finish instead of
|
||||
# generating a TimeoutError every cycle.
|
||||
code, payload = sess.get(_SEED_PATH, timeout=35.0)
|
||||
code, payload = sess.get(_SEED_PATH, timeout=self._POLL_TIMEOUT_S)
|
||||
except TimeoutError:
|
||||
raise
|
||||
except Exception as e:
|
||||
@@ -280,6 +303,8 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
info.get('x.com.samsung.da.modelNum', ''),
|
||||
info.get('x.com.samsung.da.description', ''),
|
||||
)
|
||||
if reg is None:
|
||||
reg = for_device_by_resources(resources)
|
||||
unbound: list[str] = []
|
||||
if reg is not None:
|
||||
self._log.debug("device type: %s (oneUiVersion=%r)", reg.name, one_ui)
|
||||
@@ -299,7 +324,7 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
self.device_serial = serial
|
||||
|
||||
ident = self._identity
|
||||
device_type = reg.name.title() if reg else 'Appliance'
|
||||
device_type = reg.name.replace('_', ' ').title() if reg else 'Appliance'
|
||||
model_num = info.get('x.com.samsung.da.modelNum', '')
|
||||
model = model_num.split('|', 1)[0] if model_num else (ident.model if ident else '')
|
||||
name = f"Samsung {device_type} ({model})" if model else f"Samsung {device_type}"
|
||||
@@ -502,30 +527,178 @@ 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 -- unless the user has
|
||||
opted this device out of it via CONF_BYPASS_REMOTE_CONTROL (issue
|
||||
#54: some devices accept certain writes, e.g. a washer's default
|
||||
dosing levels, even while reporting remote control off, so the
|
||||
block's assumption doesn't hold for every model)."""
|
||||
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()
|
||||
bypass_remote_control = self._entry.options.get(CONF_BYPASS_REMOTE_CONTROL, False)
|
||||
if not bypass_remote_control and 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
|
||||
|
||||
# The write's actual target, not necessarily bound_entity.href. Most
|
||||
# descriptors write to the same resource they're bound to, but a
|
||||
# composite entity -- the AC's ClimateDesc, bound to /mode/vs/0 --
|
||||
# drives writes to several sibling resources via path_segs
|
||||
# (/power/0, /temperature/desired/0, /wind/strength/vs/0, ...) that
|
||||
# write_fn picks per payload (see airconditioner._climate_write).
|
||||
# Applying the optimistic value and settle guard below to
|
||||
# bound_entity.href instead of this target protected the wrong
|
||||
# resource: /mode/vs/0 got the (nonsensical, wrong-shaped) optimistic
|
||||
# merge while the resource the climate entity actually displays from
|
||||
# (e.g. /power/0) never got one, so HA kept showing the pre-write
|
||||
# state until the next real read of that resource -- the 20-60s lag
|
||||
# in issues #17/#53, which survived the earlier optimistic-apply fix
|
||||
# (issue #27) because that fix applied to the wrong href too.
|
||||
write_href = '/' + '/'.join(path_segs)
|
||||
|
||||
# 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).
|
||||
#
|
||||
# settle_s must outlast the PUT and the async_request_refresh()
|
||||
# below combined, not just DEFAULT_SETTLE_S's fixed few seconds --
|
||||
# that refresh is a full /device/0 summary poll, which
|
||||
# _POLL_TIMEOUT_S itself admits can legitimately take tens of
|
||||
# seconds on these devices (see _poll_once), and some writes settle
|
||||
# on the device itself well after that: issue #9's washer packs
|
||||
# cycle/detergent/softener selection into the same /course/vs/0
|
||||
# options[] array, and picking a new value there visibly needs a
|
||||
# few seconds of internal validation/dispenser movement before the
|
||||
# device's own state agrees -- while /washer/vs/0's temperature/
|
||||
# spin fields (plain flags, no device-side settling) confirm
|
||||
# instantly on the same device. A short fixed window expired while
|
||||
# the confirm poll was still in flight (or before the device had
|
||||
# caught up internally), so that stale read landed unprotected and
|
||||
# reverted the optimistic value, self-correcting again only once a
|
||||
# later poll finally saw the real change -- read by the user as the
|
||||
# write "reverting, then re-applying itself" a few seconds later.
|
||||
#
|
||||
# An earlier attempt at this also released the guard early, right
|
||||
# after the confirming refresh completed, to avoid shutting out
|
||||
# unrelated real updates (another automation, the physical remote)
|
||||
# for the rest of settle_s. That was reverted: releasing the guard
|
||||
# the moment one round trip finishes doesn't mean the device has
|
||||
# actually caught up (exactly the slow-settling case above), and it
|
||||
# introduced its own races around overlapping writes to the same
|
||||
# href. Simpler and safer to just hold the guard for the full,
|
||||
# generously-sized window and let it expire on its own.
|
||||
# write_fn bodies that touch x.com.samsung.da.options carry only the
|
||||
# changed token(s) now (issue #54: confirmed sufficient on the wire --
|
||||
# the device merges by prefix itself), not the whole packed array.
|
||||
# observe.apply()'s field-level {**cached, **rep} merge doesn't know
|
||||
# that -- handed the bare token list, it would replace the cached
|
||||
# field outright and wipe every sibling option for the rest of the
|
||||
# settle window. Pre-merge it here the same way the device does, so
|
||||
# the optimistic cache entry stays complete; the minimal `body` below
|
||||
# is still exactly what goes out over the wire.
|
||||
optimistic_body = body
|
||||
new_options = body.get('x.com.samsung.da.options')
|
||||
if isinstance(new_options, list):
|
||||
cached_options = (self._cache.get(write_href) or {}).get('x.com.samsung.da.options')
|
||||
optimistic_body = {
|
||||
**body,
|
||||
'x.com.samsung.da.options': merge_options_field(cached_options, new_options),
|
||||
}
|
||||
self._observe.apply(write_href, optimistic_body, source='optimistic')
|
||||
self._observe.mark_write_pending(
|
||||
write_href, settle_s=self._POST_TIMEOUT_S + self._POLL_TIMEOUT_S
|
||||
)
|
||||
|
||||
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)
|
||||
code, _ = sess.post(path_segs, cbor2.dumps(body), timeout=self._POST_TIMEOUT_S)
|
||||
self._log.info("PUT %s → code %#04x", write_href, code)
|
||||
|
||||
try:
|
||||
await self.hass.async_add_executor_job(_do_put)
|
||||
except Exception as e:
|
||||
self._log.error("command failed for %s: %s", href, e)
|
||||
self._log.error("command failed for %s: %s", write_href, e)
|
||||
else:
|
||||
await self.async_request_refresh()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Debug raw write (issue #54): a power-user escape hatch for the
|
||||
# options-flow debug panel, letting a user POST an arbitrary partial
|
||||
# body to an arbitrary href to pin down device-specific write behavior
|
||||
# without waiting on a new release. Deliberately bypasses the
|
||||
# remote-control block and every write_fn/validate_fn above -- that's
|
||||
# the whole point, so use with care.
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _raw_write_blocking(self, path_segs: list[str], body: dict, href: str) -> tuple[int, dict]:
|
||||
"""Debug primitive: POST an arbitrary patch, then read the href
|
||||
back for ground truth. Blocking -- runs in executor."""
|
||||
if self._session is None:
|
||||
self._connect_session()
|
||||
sess = self._session
|
||||
if sess is None:
|
||||
raise RuntimeError("no session")
|
||||
code, _ = sess.post(path_segs, cbor2.dumps(body), timeout=self._POST_TIMEOUT_S)
|
||||
self._log.warning("DEBUG raw write POST %s %r → code %#04x", href, body, code)
|
||||
new_rep: dict = {}
|
||||
try:
|
||||
sess.pace()
|
||||
rcode, payload = sess.get(path_segs, timeout=10.0)
|
||||
if rcode == 0x45 and payload:
|
||||
rep = cbor2.loads(payload)
|
||||
if isinstance(rep, dict):
|
||||
self._observe.apply(href, rep, source='poll')
|
||||
new_rep = rep
|
||||
except Exception as e:
|
||||
self._log.debug("raw write follow-up read failed: %s", e)
|
||||
return code, new_rep
|
||||
|
||||
async def async_raw_write(self, href: str, body: dict) -> tuple[int, dict]:
|
||||
"""Debug-only arbitrary write (issue #54). Bypasses the
|
||||
remote-control block and all write_fn/validate_fn logic; sends
|
||||
`body` verbatim as a partial-rep PATCH to `href`. Returns
|
||||
(coap_code, new_rep) where new_rep is the href's value read back
|
||||
right after the write. Used by the options-flow debug panel to
|
||||
help users pin down device-specific write behavior without a new
|
||||
release."""
|
||||
if not isinstance(body, dict) or not body:
|
||||
raise ServiceValidationError("Debug write payload must be a non-empty object.")
|
||||
path_segs = [s for s in href.strip('/').split('/') if s]
|
||||
if not path_segs:
|
||||
raise ServiceValidationError("A resource href is required.")
|
||||
norm_href = '/' + '/'.join(path_segs)
|
||||
async with self._session_lock:
|
||||
code, new_rep = await self.hass.async_add_executor_job(
|
||||
self._raw_write_blocking, path_segs, body, norm_href
|
||||
)
|
||||
# Hasten a full summary poll so entities on other resources catch
|
||||
# up too -- a debug write can affect siblings, not just its href.
|
||||
await self.async_request_refresh()
|
||||
return code, new_rep
|
||||
|
||||
@@ -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,13 +57,39 @@ 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)
|
||||
self._attr_translation_key = bound.desc.translation_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_icon = bound.desc.icon
|
||||
raw_cat = bound.desc.entity_category
|
||||
self._attr_entity_category = EntityCategory(raw_cat) if raw_cat else None
|
||||
self._attr_entity_registry_enabled_default = bound.desc.enabled_default
|
||||
|
||||
@property
|
||||
def translation_key(self) -> str | None:
|
||||
"""Override Entity.translation_key (a property upstream, not a
|
||||
plain attribute) so a callable descriptor -- e.g.
|
||||
laundry.cycle_select's table-id-gated resolver -- is re-evaluated
|
||||
against live coordinator data on every access, not resolved once
|
||||
at construction time.
|
||||
|
||||
Discovery runs on the first /device/0 poll, which the entity
|
||||
registry already documents can hand a sibling resource an empty
|
||||
stub rep before it's actually been fetched (see _is_included's
|
||||
docstring) -- a static one-time resolution here would risk baking
|
||||
in a permanent None (no translation) for the entity's whole
|
||||
lifetime if that stub hadn't populated yet, even once the real
|
||||
value arrives on a later poll.
|
||||
"""
|
||||
tk = self._bound.desc.translation_key
|
||||
return tk(self.coordinator.last_resources) if callable(tk) else tk
|
||||
|
||||
@property
|
||||
def device_info(self) -> DeviceInfo:
|
||||
return self.coordinator.device_info
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
"""Fan platform for Samsung range hoods."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.components.fan import FanEntity, FanEntityFeature
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.util.percentage import (
|
||||
ordered_list_item_to_percentage,
|
||||
percentage_to_ordered_list_item,
|
||||
)
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import LocalThingsCoordinator
|
||||
from .entity import LocalThingsEntity, _is_included
|
||||
from .registry.entities import FanDesc
|
||||
|
||||
|
||||
POWER_HREF = '/power/0'
|
||||
POWER_VS_HREF = '/power/vs/0'
|
||||
_FAN_SPEED_FIELD = 'x.com.samsung.da.hood.fanSpeed'
|
||||
_SUPPORTED_FAN_SPEED_FIELD = 'x.com.samsung.da.hood.supportedFanSpeed'
|
||||
|
||||
|
||||
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(
|
||||
LocalThingsRangeHoodFan(coordinator, bound)
|
||||
for bound in coordinator.bound
|
||||
if isinstance(bound.desc, FanDesc) and _is_included(bound, coordinator)
|
||||
)
|
||||
|
||||
|
||||
class LocalThingsRangeHoodFan(LocalThingsEntity, FanEntity):
|
||||
"""A hood fan combining sibling power and fan-speed resources."""
|
||||
|
||||
_enable_turn_on_off_backwards_compatibility = False
|
||||
_attr_supported_features = (
|
||||
FanEntityFeature.SET_SPEED
|
||||
| FanEntityFeature.TURN_ON
|
||||
| FanEntityFeature.TURN_OFF
|
||||
)
|
||||
|
||||
def __init__(self, coordinator: LocalThingsCoordinator, bound) -> None:
|
||||
super().__init__(coordinator, bound)
|
||||
self._attr_name = None
|
||||
|
||||
def _rep(self, href: str) -> dict:
|
||||
return self.coordinator.resource(href) or {}
|
||||
|
||||
def _all_speed_codes(self) -> list[str]:
|
||||
rep = self._rep(self._bound.href)
|
||||
return [str(value) for value in rep.get(_SUPPORTED_FAN_SPEED_FIELD, ())]
|
||||
|
||||
def _active_speed_codes(self) -> list[str]:
|
||||
# Power is carried by the separate /power resource. fanSpeed retains
|
||||
# the selected setting while power is off (as the lamp's `current`
|
||||
# field does), so every advertised code is an active ordered speed.
|
||||
return self._all_speed_codes()
|
||||
|
||||
def _power_payload(self, enabled: bool) -> tuple[str, bool, str]:
|
||||
"""Target whichever power resource this hood actually exposes."""
|
||||
resources = self.coordinator.last_resources
|
||||
target = POWER_HREF if POWER_HREF in resources else POWER_VS_HREF
|
||||
return 'power', enabled, target
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
rep = self._rep(POWER_HREF)
|
||||
if 'value' in rep:
|
||||
return bool(rep.get('value'))
|
||||
return str(
|
||||
self._rep(POWER_VS_HREF).get('x.com.samsung.da.power', '')
|
||||
).lower() == 'on'
|
||||
|
||||
@property
|
||||
def speed_count(self) -> int:
|
||||
return len(self._active_speed_codes())
|
||||
|
||||
@property
|
||||
def percentage(self) -> int | None:
|
||||
if not self.is_on:
|
||||
return 0
|
||||
codes = self._active_speed_codes()
|
||||
current = str(self._rep(self._bound.href).get(_FAN_SPEED_FIELD, ''))
|
||||
if not codes or current not in codes:
|
||||
return None
|
||||
return ordered_list_item_to_percentage(codes, current)
|
||||
|
||||
async def async_turn_on(
|
||||
self, percentage: int | None = None, preset_mode: str | None = None,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
await self.coordinator.async_send_command(
|
||||
self._bound, self._power_payload(True),
|
||||
)
|
||||
if percentage is not None:
|
||||
await self.async_set_percentage(percentage)
|
||||
|
||||
async def async_turn_off(self, **kwargs) -> None:
|
||||
await self.coordinator.async_send_command(
|
||||
self._bound, self._power_payload(False),
|
||||
)
|
||||
|
||||
async def async_set_percentage(self, percentage: int) -> None:
|
||||
if percentage <= 0:
|
||||
await self.async_turn_off()
|
||||
return
|
||||
codes = self._active_speed_codes()
|
||||
if not codes:
|
||||
return
|
||||
if not self.is_on:
|
||||
await self.coordinator.async_send_command(
|
||||
self._bound, self._power_payload(True),
|
||||
)
|
||||
code = percentage_to_ordered_list_item(codes, percentage)
|
||||
await self.coordinator.async_send_command(self._bound, ('speed', code))
|
||||
@@ -5,12 +5,12 @@
|
||||
"config_flow": true,
|
||||
"dependencies": [],
|
||||
"documentation": "https://github.com/mbillow/localthings",
|
||||
"iot_class": "local_polling",
|
||||
"iot_class": "local_push",
|
||||
"issue_tracker": "https://github.com/mbillow/localthings/issues",
|
||||
"requirements": [
|
||||
"cbor2>=5.4.6",
|
||||
"pyOpenSSL>=23.0",
|
||||
"smartthings-local>=0.1.0"
|
||||
],
|
||||
"version": "0.1.0"
|
||||
"version": "0.11.1"
|
||||
}
|
||||
|
||||
@@ -42,16 +42,25 @@ class LocalThingsNumber(LocalThingsEntity, NumberEntity):
|
||||
if desc.step is not None:
|
||||
self._attr_native_step = desc.step
|
||||
|
||||
@property
|
||||
def native_unit_of_measurement(self):
|
||||
desc: NumberDesc = self._bound.desc
|
||||
if desc.unit_fn is not None:
|
||||
return desc.unit_fn(self.coordinator.resource(self._bound.href))
|
||||
return self._attr_native_unit_of_measurement
|
||||
|
||||
def _range_from_resource(self) -> list | None:
|
||||
desc: NumberDesc = self._bound.desc
|
||||
if not desc.range_field:
|
||||
return None
|
||||
rep = self.coordinator.last_resources.get(self._bound.href) or {}
|
||||
r = rep.get(desc.range_field)
|
||||
r = self.coordinator.resource(self._bound.href).get(desc.range_field)
|
||||
return r if (isinstance(r, (list, tuple)) and len(r) == 2) else None
|
||||
|
||||
@property
|
||||
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])
|
||||
@@ -61,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])
|
||||
@@ -68,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,49 @@ 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."""
|
||||
if self._is_settling(href):
|
||||
"""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.
|
||||
|
||||
`source == 'optimistic'` always bypasses the settle gate below,
|
||||
even while this href is already settling from an earlier write.
|
||||
Several independent selects can share one href -- issue #9's washer
|
||||
packs cycle, detergent, and softener settings onto the same
|
||||
/course/vs/0 -- so a second, different write to the same href
|
||||
while the first's settle window is still open (up to
|
||||
_POST_TIMEOUT_S + _POLL_TIMEOUT_S, tens of seconds -- see
|
||||
coordinator.async_send_command) is an expected, normal sequence,
|
||||
not a stale echo of the first write. Gating it the same as a
|
||||
poll/sweep/observe update would silently drop the user's own
|
||||
second selection from the cache until the first write's guard
|
||||
happened to expire, i.e. the exact symptom this guard exists to
|
||||
prevent, just relocated to whichever write loses the race.
|
||||
"""
|
||||
if source != 'optimistic' and 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,29 @@
|
||||
from typing import Optional
|
||||
|
||||
from ._base import DeviceRegistry
|
||||
from . import dishwasher, dryer, oven, refrigerator, washer
|
||||
from . import (
|
||||
air_purifier, airconditioner, cooktop, dishwasher, dryer, oven,
|
||||
range as _range, range_hood, refrigerator, washer,
|
||||
)
|
||||
|
||||
__all__ = ['DeviceRegistry', '_type_key', 'for_device', 'for_device_by_model']
|
||||
__all__ = [
|
||||
'DeviceRegistry', '_type_key', 'for_device', 'for_device_by_model',
|
||||
'for_device_by_resources',
|
||||
]
|
||||
|
||||
|
||||
_REGISTRY_BY_KEY: dict[str, DeviceRegistry] = {
|
||||
'air_purifier': air_purifier.REGISTRY,
|
||||
'airpurifier': air_purifier.REGISTRY,
|
||||
'airconditioner': airconditioner.REGISTRY,
|
||||
'air_conditioner': airconditioner.REGISTRY,
|
||||
'cooktop': cooktop.REGISTRY,
|
||||
'dishwasher': dishwasher.REGISTRY,
|
||||
'dryer': dryer.REGISTRY,
|
||||
'oven': oven.REGISTRY,
|
||||
'hood': range_hood.REGISTRY,
|
||||
'range': _range.REGISTRY,
|
||||
'range_hood': range_hood.REGISTRY,
|
||||
'refrigerator': refrigerator.REGISTRY,
|
||||
'washer': washer.REGISTRY,
|
||||
}
|
||||
@@ -66,6 +80,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 +104,70 @@ 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'
|
||||
# System air conditioners (multi-indoor-unit commercial installs, e.g.
|
||||
# A-CAWW-TP2-20-COMMON, issue #52) report no oneUiVersion either and
|
||||
# carry the '-CAWW-' board-family token instead of '_RAC_'/'_PRAC_'.
|
||||
# Same TP1X/TP2X-class resource surface as the room-AC models above
|
||||
# (confirmed by the issue #52 dump binding cleanly against the existing
|
||||
# airconditioner registry once routed here), plus one new SAC-specific
|
||||
# resource (see airconditioner.py's _AC_IGNORED).
|
||||
if key is None and '-CAWW-' in (model_num or '').upper():
|
||||
key = 'airconditioner'
|
||||
# Air purifiers (e.g. ARTIK051_TVTL_18K, issue #56) report no
|
||||
# oneUiVersion either, and carry the '_TVTL_' board-family token.
|
||||
if key is None and '_TVTL_' in (model_num or ''):
|
||||
key = 'air_purifier'
|
||||
model_identity = f'{model_num} {description}'.upper()
|
||||
if key is None and ('_COOKTOP' in model_identity or '_GB_CT_' in model_identity):
|
||||
key = 'cooktop'
|
||||
if key is None and model_identity.startswith('AHD-'):
|
||||
key = 'range_hood'
|
||||
# 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'
|
||||
# Wall ovens (e.g. TP1X_DA-KS-OVEN-0107X, issue #55) -- same board-family
|
||||
# naming as the range combo above, minus the burners; also reports no
|
||||
# oneUiVersion and doesn't match the washer/dryer/dishwasher prefix map.
|
||||
if key is None and '-OVEN-' in (model_num or '').upper():
|
||||
key = 'oven'
|
||||
return _REGISTRY_BY_KEY.get(key) if key else None
|
||||
|
||||
|
||||
def for_device_by_resources(resources: dict[str, dict]) -> Optional[DeviceRegistry]:
|
||||
"""Detect a device family from a distinctive local-resource signature.
|
||||
|
||||
Some newer cooktops omit both ``oneUiVersion`` and
|
||||
``/information/vs/0``. Their mode resource still identifies them: it
|
||||
contains a DeviceType option and multiple per-burner OperationState
|
||||
options. Require both shapes so an oven's unrelated ``/mode/vs/0`` is
|
||||
not misclassified.
|
||||
"""
|
||||
mode = resources.get('/mode/vs/0', {})
|
||||
options = mode.get('x.com.samsung.da.options') or ()
|
||||
has_device_type = any(
|
||||
isinstance(option, str) and option.startswith('DeviceType_')
|
||||
for option in options
|
||||
)
|
||||
operation_states = sum(
|
||||
1 for option in options
|
||||
if isinstance(option, str) and option.startswith('OperationState')
|
||||
)
|
||||
if has_device_type and operation_states >= 2:
|
||||
return _REGISTRY_BY_KEY['cooktop']
|
||||
if (
|
||||
'/hood/fanspeed/vs/0' in resources
|
||||
and '/hood/lamp/vs/0' in resources
|
||||
):
|
||||
return _REGISTRY_BY_KEY['range_hood']
|
||||
return None
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
"""Air-purifier device registry (Samsung ARTIK051_TVTL-class, issue #56).
|
||||
|
||||
Reports no oneUiVersion; resolved via for_device_by_model's '_TVTL_' modelNum
|
||||
token (see registry.py). Reuses dishwasher.DIAGNOSIS for /diagnosis/vs/0
|
||||
(identical field/write contract).
|
||||
"""
|
||||
from ..capabilities import air_purifier, common, dishwasher, ignored
|
||||
from ._base import DeviceRegistry, _build
|
||||
|
||||
REGISTRY = DeviceRegistry(
|
||||
name='air_purifier',
|
||||
capabilities=_build([
|
||||
*ignored.IGNORED,
|
||||
*common.UNIVERSAL,
|
||||
*common.POWER,
|
||||
dishwasher.DIAGNOSIS,
|
||||
air_purifier.AIR_QUALITY,
|
||||
air_purifier.FILTER,
|
||||
air_purifier.DEVICE_ACTIVE,
|
||||
air_purifier.AIRFLOW_GENERIC,
|
||||
air_purifier.AIRFLOW_VS_FALLBACK,
|
||||
air_purifier.MODE,
|
||||
*air_purifier.COVERAGE,
|
||||
]),
|
||||
)
|
||||
@@ -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,
|
||||
]),
|
||||
)
|
||||
@@ -0,0 +1,17 @@
|
||||
"""Cooktop device registry."""
|
||||
|
||||
from ..capabilities import common, cooktop, ignored
|
||||
from ._base import DeviceRegistry, _build
|
||||
|
||||
|
||||
REGISTRY = DeviceRegistry(
|
||||
name='cooktop',
|
||||
capabilities=_build([
|
||||
*ignored.IGNORED,
|
||||
cooktop.COOKTOP_POWER,
|
||||
cooktop.COOKTOP_MODE,
|
||||
cooktop.COOKTOP_CONNECTED,
|
||||
cooktop.PAIRED_HOOD_STATUS,
|
||||
common.FIRMWARE_UPDATE,
|
||||
]),
|
||||
)
|
||||
@@ -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,
|
||||
]),
|
||||
)
|
||||
@@ -0,0 +1,22 @@
|
||||
"""Range-hood device registry."""
|
||||
|
||||
from ..capabilities import common, ignored, range_hood
|
||||
from ._base import DeviceRegistry, _build
|
||||
|
||||
|
||||
REGISTRY = DeviceRegistry(
|
||||
name='range_hood',
|
||||
capabilities=_build([
|
||||
*ignored.IGNORED,
|
||||
range_hood.HOOD_ALARMS,
|
||||
common.ENERGY_METER,
|
||||
common.FIRMWARE_UPDATE,
|
||||
range_hood.HOOD_FAN,
|
||||
range_hood.HOOD_LAMP,
|
||||
range_hood.HOOD_FILTER,
|
||||
range_hood.AIR_QUALITY,
|
||||
range_hood.AIR_LEVEL_CHECK,
|
||||
range_hood.AUTO_VENTILATION,
|
||||
*range_hood.COVERAGE,
|
||||
]),
|
||||
)
|
||||
@@ -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,15 @@ 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=[
|
||||
fridge.TEMP_CURRENT_GENERIC,
|
||||
|
||||
@@ -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,
|
||||
common.ENERGY_METER,
|
||||
laundry.BUZZER_SOUND,
|
||||
laundry.JOB_BEGINNING_STATUS,
|
||||
common.WATER_METER,
|
||||
operational.OPERATIONAL_STATE,
|
||||
dishwasher.DIAGNOSIS,
|
||||
fridge.FIRMWARE_UPDATE,
|
||||
]),
|
||||
)
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
from . import common, dishwasher, fridge, ignored, laundry, operational, oven
|
||||
from . import (
|
||||
common, cooktop, dishwasher, fridge, ignored, laundry, operational, oven,
|
||||
range_hood,
|
||||
)
|
||||
from ..capability import Capability
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
"""Capabilities for the Samsung ARTIK051_TVTL-class air purifier family
|
||||
(model AX60R5080WD/SE, issue #56).
|
||||
|
||||
Power, kids-lock, remote-control, alarms, and the energy meter are the shared
|
||||
common.py capabilities (this family exposes the standard /power/0+/power/vs/0
|
||||
pair and /alarms/vs/0, /energy/consumption/vs/0). /diagnosis/vs/0 reuses
|
||||
dishwasher.DIAGNOSIS -- identical field/write contract
|
||||
(x.com.samsung.da.diagnosisStart, 'Ready' on both dumps).
|
||||
|
||||
/mode/vs/0's x.com.samsung.da.options array packs multiple independent
|
||||
'<Prefix>_<value>' flags into one list -- the same packed-list contract
|
||||
laundry.py's option_value/option_write already model for /course/vs/0's
|
||||
options[] (reused directly below, just against this family's own href). Per
|
||||
issue #56's follow-up (five diagnostics dumps captured with the physical unit
|
||||
set to Auto/Sleep/Low/Medium/High):
|
||||
Light_On / Light_Off -- a plain on/off flag; MODE below models it as a
|
||||
real switch, RMW-replacing just that one entry.
|
||||
Comode_Off -- read 'Off' on *every* one of the five dumps,
|
||||
including High/Low/Medium/Auto -- confirms this
|
||||
is NOT the fan-speed selector (ruling out the
|
||||
original guess); exposed read-only since its
|
||||
actual purpose is still unconfirmed.
|
||||
OptionCode_60282 -- confirmed opaque/not user-facing in the
|
||||
SmartThings app; not modeled (same treatment as
|
||||
range_hood's OptionCode_* token on the same
|
||||
href).
|
||||
Blooming_* -- confirmed to have no corresponding SmartThings
|
||||
app setting; dropped entirely rather than kept
|
||||
as an unexplained diagnostic (it did track 1:1
|
||||
with Sleep mode across the five dumps -- 0 in
|
||||
Sleep, 6 otherwise -- so it's plausibly an
|
||||
automatic side effect of sleep mode, e.g. a
|
||||
display-dimming level, but that's still a guess).
|
||||
|
||||
/airflow/0 and /airflow/vs/0's `speed` still isn't modeled as a real
|
||||
fan-speed control: across the same five dumps it read 0 for both Auto *and*
|
||||
High, and 3 for Low/Medium *and* Sleep -- not a monotonic mapping to any
|
||||
selectable level, and the dumps were all captured within about three minutes
|
||||
of each other (only one poll cycle apart at this integration's 30s summary
|
||||
interval), so the values may not have settled after each change before the
|
||||
diagnostics snapshot was taken. Exposed read-only pending a confirmed,
|
||||
stable capture -- see the issue #56 discussion for what's needed.
|
||||
"""
|
||||
from ..capability import Capability
|
||||
from ..entities import BinarySensorDesc, SensorDesc, SwitchDesc
|
||||
from .common import int_or_none, sensor_item_value
|
||||
from .laundry import bool_option_exists, bool_option_value, option_value, option_write
|
||||
|
||||
_AIR_QUALITY_SENSORS = (
|
||||
('dust', 'Dust', 'mdi:blur', 'Dust'),
|
||||
('fine_dust', 'Fine dust', 'mdi:blur', 'FineDust'),
|
||||
('super_fine_dust', 'Super fine dust', 'mdi:blur', 'SuperFineDust'),
|
||||
('odor', 'Odor', 'mdi:scent', 'Odor'),
|
||||
('clean_level', 'Clean level', 'mdi:air-filter', 'CleanLevel'),
|
||||
)
|
||||
|
||||
AIR_QUALITY = Capability(
|
||||
href='/sensors/vs/0',
|
||||
poll_tier='warm',
|
||||
entities=tuple(
|
||||
SensorDesc(key=key, field='x.com.samsung.da.items', name=name, icon=icon,
|
||||
value_fn=lambda items, t=sensor_type: sensor_item_value(items, t))
|
||||
for key, name, icon, sensor_type in _AIR_QUALITY_SENSORS
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _consumable_state(items, name):
|
||||
"""Read a `/consumable/vs/0`-style items[] entry -- {name, state} pairs,
|
||||
unlike AIR_QUALITY's {type, value} shape above."""
|
||||
for item in items or ():
|
||||
if isinstance(item, dict) and item.get('x.com.samsung.da.name') == name:
|
||||
return item.get('x.com.samsung.da.state')
|
||||
return None
|
||||
|
||||
|
||||
# FilterProgress is a 0-100 percentage counting up as the filter wears --
|
||||
# confirmed via issue #56: the SmartThings app shows "Filter needs changing"
|
||||
# once this reaches 100, so 100 means fully used, not "brand new." Named
|
||||
# after the raw field (matching the AC/range_hood filterUsage convention,
|
||||
# which counts the same direction) rather than "filter life," which would
|
||||
# imply the opposite direction.
|
||||
FILTER = Capability(
|
||||
href='/consumable/vs/0',
|
||||
poll_tier='cold',
|
||||
entities=(
|
||||
SensorDesc(key='filter_progress', field='x.com.samsung.da.items',
|
||||
name='Filter progress', unit='%', state_class='measurement',
|
||||
icon='mdi:air-filter', entity_category='diagnostic',
|
||||
value_fn=lambda items: int_or_none(
|
||||
_consumable_state(items, 'FilterProgress'))),
|
||||
),
|
||||
)
|
||||
|
||||
DEVICE_ACTIVE = Capability(
|
||||
href='/devicespecificinfo/vs/0',
|
||||
poll_tier='cold',
|
||||
entities=(
|
||||
BinarySensorDesc(key='device_active', field='x.com.samsung.da.deviceActive',
|
||||
name='Device active', icon='mdi:check-network-outline',
|
||||
entity_category='diagnostic',
|
||||
value_fn=lambda v: bool(v)),
|
||||
),
|
||||
)
|
||||
|
||||
# OCF-native / vendor pair for fan speed+direction -- see module docstring for
|
||||
# why these are read-only for now.
|
||||
AIRFLOW_GENERIC = Capability(
|
||||
href='/airflow/0',
|
||||
poll_tier='warm',
|
||||
entities=(
|
||||
SensorDesc(key='fan_speed_level', field='speed',
|
||||
name='Fan speed level', icon='mdi:fan',
|
||||
state_class='measurement', entity_category='diagnostic'),
|
||||
SensorDesc(key='fan_direction', field='direction',
|
||||
name='Fan direction', icon='mdi:rotate-3d-variant',
|
||||
entity_category='diagnostic'),
|
||||
),
|
||||
)
|
||||
|
||||
AIRFLOW_VS_FALLBACK = Capability(
|
||||
href='/airflow/vs/0',
|
||||
match_fn=lambda rep, resources: '/airflow/0' not in resources,
|
||||
poll_tier='warm',
|
||||
entities=(
|
||||
SensorDesc(key='fan_speed_level', field='x.com.samsung.da.speedLevel',
|
||||
name='Fan speed level', icon='mdi:fan',
|
||||
state_class='measurement', entity_category='diagnostic',
|
||||
value_fn=int_or_none),
|
||||
SensorDesc(key='fan_direction', field='x.com.samsung.da.direction',
|
||||
name='Fan direction', icon='mdi:rotate-3d-variant',
|
||||
entity_category='diagnostic'),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _light_write(payload, rep, href=None):
|
||||
# option_write's single-token write is confirmed on a washer's
|
||||
# /course/vs/0 (issue #54), NOT independently on this family's
|
||||
# /mode/vs/0 -- extrapolated on the assumption the same vendor field
|
||||
# merges the same way everywhere. If some unit replaces the field
|
||||
# outright instead, this would drop Comode/OptionCode alongside it on
|
||||
# the next light toggle; revisit if a real device report surfaces that.
|
||||
return ['mode', 'vs', '0'], {
|
||||
'x.com.samsung.da.options': option_write('Light', payload),
|
||||
}
|
||||
|
||||
|
||||
MODE = Capability(
|
||||
href='/mode/vs/0',
|
||||
poll_tier='warm',
|
||||
entities=(
|
||||
SwitchDesc(key='display_light', name='Display light', icon='mdi:led-on',
|
||||
entity_category='config',
|
||||
rep_fn=bool_option_value('Light'),
|
||||
exists_fn=bool_option_exists('Light'),
|
||||
write_fn=_light_write),
|
||||
# Read-only -- confirmed NOT the fan-speed selector (see module
|
||||
# docstring), actual purpose still unconfirmed.
|
||||
SensorDesc(key='operating_mode', name='Operating mode', icon='mdi:fan',
|
||||
entity_category='diagnostic',
|
||||
rep_fn=lambda rep: option_value(rep.get('x.com.samsung.da.options'), 'Comode'),
|
||||
exists_fn=bool_option_exists('Comode')),
|
||||
),
|
||||
)
|
||||
|
||||
# /humidity/0 and /humidity/vs/0 are empty {} on both dumps this family has
|
||||
# been verified against -- covered here (not globally, per ignored.py's
|
||||
# module docstring) since those hrefs collide with fridge/AC schemas
|
||||
# elsewhere. Same two hrefs and reasoning as airconditioner.py's _AC_IGNORED.
|
||||
COVERAGE = [
|
||||
Capability(href='/humidity/0'),
|
||||
Capability(href='/humidity/vs/0'),
|
||||
]
|
||||
@@ -0,0 +1,258 @@
|
||||
"""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
|
||||
# System-AC-only (multi-indoor-unit commercial installs, e.g.
|
||||
# A-CAWW-TP2-20-COMMON, issue #52): opaque hex-encoded installation
|
||||
# topology -- indoor/outdoor unit pairing, per-unit serials, MCU info.
|
||||
# Commissioning-time plumbing, not user-actionable appliance state.
|
||||
'/sac/installationinfo/vs/0',
|
||||
]
|
||||
|
||||
# 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):
|
||||
@@ -21,16 +23,36 @@ def _num(v):
|
||||
return None
|
||||
|
||||
|
||||
def _clamp_power(v):
|
||||
def int_or_none(v):
|
||||
try:
|
||||
return int(v)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def clamp_power(v):
|
||||
n = _num(v)
|
||||
return 0.0 if (n is not None and n < 0) else n
|
||||
|
||||
|
||||
def _wh_to_kwh(v):
|
||||
def wh_to_kwh(v):
|
||||
n = _num(v)
|
||||
return round(n / 1000.0, 2) if n is not None else None
|
||||
|
||||
|
||||
def normalize_temp_unit(raw, default='°F'):
|
||||
"""'C'/'Celsius' -> '°C', 'F'/'Fahrenheit' -> '°F'. Falls back to
|
||||
`default` for any other/missing value. Shared by fridge.py and oven.py,
|
||||
both of which read a per-device unit off a `/temperature*` resource
|
||||
instead of assuming one (see fridge.py's module docstring, issue #7)."""
|
||||
raw = (raw or '').strip().upper()
|
||||
if raw.startswith('C'):
|
||||
return '°C'
|
||||
if raw.startswith('F'):
|
||||
return '°F'
|
||||
return default
|
||||
|
||||
|
||||
def _ml_to_l(v):
|
||||
n = _num(v)
|
||||
return round(n / 1000.0, 1) if n is not None else None
|
||||
@@ -43,8 +65,104 @@ def _active_alarm_codes(items):
|
||||
return ', '.join(codes) if codes else 'none'
|
||||
|
||||
|
||||
KIDS_LOCK = Capability(
|
||||
def merge_options_field(cached, new_tokens):
|
||||
"""Merge freshly-written `<Prefix>_<Value>` tokens into a cached
|
||||
x.com.samsung.da.options[]-style array the same way the device itself
|
||||
merges them: match by prefix, replace if present, append if not.
|
||||
|
||||
Confirmed on real hardware (issue #54) that a write only needs to carry
|
||||
the changed token(s), not the whole array -- see laundry.option_write /
|
||||
oven._option_write for the write side. This is the read side of that
|
||||
same fact: coordinator.async_send_command uses it to keep the
|
||||
optimistic cache entry for the written href complete (every sibling
|
||||
option still present) during the write-settle window, since the wire
|
||||
body it applies straight to the cache no longer carries them."""
|
||||
merged = list(cached or [])
|
||||
for token in new_tokens or ():
|
||||
if not isinstance(token, str) or '_' not in token:
|
||||
continue
|
||||
prefix = token.split('_', 1)[0]
|
||||
replaced = False
|
||||
for i, o in enumerate(merged):
|
||||
if isinstance(o, str) and o.startswith(prefix + '_'):
|
||||
merged[i] = token
|
||||
replaced = True
|
||||
if not replaced:
|
||||
merged.append(token)
|
||||
return merged
|
||||
|
||||
|
||||
def sensor_item_value(items, sensor_type, index=0):
|
||||
"""Pull one reading out of a `/sensors/vs/0`-style items[] list -- each
|
||||
item is `{type, value: [...]}`; `index` picks which slot of a possibly
|
||||
multi-value reading to read (index 0 is the raw measurement on every
|
||||
family seen so far). Shared by range_hood.AIR_QUALITY and
|
||||
air_purifier.AIR_QUALITY, which read the same resource shape."""
|
||||
for item in items or ():
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
if item.get('x.com.samsung.da.type') != sensor_type:
|
||||
continue
|
||||
values = item.get('x.com.samsung.da.value') or ()
|
||||
if index < len(values):
|
||||
try:
|
||||
return int(values[index])
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
# 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',
|
||||
@@ -55,8 +173,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',
|
||||
@@ -65,16 +216,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',
|
||||
@@ -85,15 +226,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)),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -118,3 +310,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,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
"""Read-only capabilities for Samsung cooktops.
|
||||
|
||||
The first verified device is an NA9300K-class five-burner gas cooktop. Its
|
||||
local OCF API reports burner state as strings embedded in the
|
||||
``x.com.samsung.da.options`` array on ``/mode/vs/0``. Heat-producing controls
|
||||
are intentionally not exposed: the local write contract is unverified and a
|
||||
cooktop must not be remotely ignited by an automation.
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
from ..capability import Capability
|
||||
from ..entities import BinarySensorDesc, SensorDesc
|
||||
|
||||
|
||||
_INACTIVE_OPERATION_STATES = {'Off', 'Ready'}
|
||||
|
||||
|
||||
def _option_value(options, prefix):
|
||||
"""Return the value from the first ``<prefix>_<value>`` option."""
|
||||
marker = prefix + '_'
|
||||
for option in options or ():
|
||||
if isinstance(option, str) and option.startswith(marker):
|
||||
return option[len(marker):]
|
||||
return None
|
||||
|
||||
|
||||
def _operation_slots(options) -> tuple[int, ...]:
|
||||
"""Return the numeric burner slots advertised in an options array."""
|
||||
slots = set()
|
||||
for option in options or ():
|
||||
if not isinstance(option, str):
|
||||
continue
|
||||
match = re.match(r'^OperationState(\d+)_', option)
|
||||
if match:
|
||||
slots.add(int(match.group(1)))
|
||||
return tuple(sorted(slots))
|
||||
|
||||
|
||||
def _any_burner_active(options):
|
||||
"""True when any advertised burner slot is not idle."""
|
||||
states = [
|
||||
_option_value(options, f'OperationState{slot}')
|
||||
for slot in _operation_slots(options)
|
||||
]
|
||||
states = [state for state in states if state is not None]
|
||||
return any(state not in _INACTIVE_OPERATION_STATES for state in states)
|
||||
|
||||
|
||||
def _int_or_none(value):
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
COOKTOP_POWER = Capability(
|
||||
href='/power/vs/0',
|
||||
poll_tier='hot',
|
||||
entities=(
|
||||
BinarySensorDesc(
|
||||
key='power_state',
|
||||
field='x.com.samsung.da.power',
|
||||
name='Power state',
|
||||
device_class='power',
|
||||
icon='mdi:stove',
|
||||
value_fn=lambda value: str(value).lower() == 'on',
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
# The verified NA9300K exposes physical slots 0, 1, 3, 4, and 5. Declare a
|
||||
# generous static superset so variants with other layouts are not silently
|
||||
# omitted; exists_fn hides every slot the live options array does not report.
|
||||
# Replace this bound with data-driven entity generation when #31 lands.
|
||||
_SUPPORTED_OPERATION_SLOTS = tuple(range(8))
|
||||
|
||||
COOKTOP_MODE = Capability(
|
||||
href='/mode/vs/0',
|
||||
poll_tier='hot',
|
||||
entities=(
|
||||
BinarySensorDesc(
|
||||
key='any_burner_active',
|
||||
field='x.com.samsung.da.options',
|
||||
name='Any burner active',
|
||||
device_class='running',
|
||||
icon='mdi:fire',
|
||||
value_fn=_any_burner_active,
|
||||
),
|
||||
*(
|
||||
SensorDesc(
|
||||
key=f'burner_{slot}_state',
|
||||
field='x.com.samsung.da.options',
|
||||
name=f'Burner {slot} state',
|
||||
icon='mdi:gas-burner',
|
||||
value_fn=lambda options, slot=slot: _option_value(
|
||||
options, f'OperationState{slot}'
|
||||
),
|
||||
exists_fn=lambda rep, resources, slot=slot: (
|
||||
not rep or _option_value(
|
||||
rep.get('x.com.samsung.da.options'),
|
||||
f'OperationState{slot}',
|
||||
) is not None
|
||||
),
|
||||
)
|
||||
for slot in _SUPPORTED_OPERATION_SLOTS
|
||||
),
|
||||
SensorDesc(
|
||||
key='main_timer_state',
|
||||
field='x.com.samsung.da.options',
|
||||
name='Timer state',
|
||||
icon='mdi:timer-outline',
|
||||
value_fn=lambda options: _option_value(options, 'MainTimerState'),
|
||||
),
|
||||
SensorDesc(
|
||||
key='main_timer_current',
|
||||
field='x.com.samsung.da.options',
|
||||
name='Timer current value',
|
||||
icon='mdi:timer-sand',
|
||||
enabled_default=False,
|
||||
value_fn=lambda options: _int_or_none(
|
||||
_option_value(options, 'MainTimerCurrent')
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
COOKTOP_CONNECTED = Capability(
|
||||
href='/connected/vs/0',
|
||||
poll_tier='warm',
|
||||
entities=(
|
||||
BinarySensorDesc(
|
||||
key='cloud_connected',
|
||||
field='x.com.samsung.da.connected',
|
||||
name='Cloud connected',
|
||||
device_class='connectivity',
|
||||
entity_category='diagnostic',
|
||||
value_fn=lambda value: str(value).lower() == 'on',
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
PAIRED_HOOD_STATUS = Capability(
|
||||
href='/bluetooth/hood/status/vs/0',
|
||||
poll_tier='hot',
|
||||
entities=(
|
||||
BinarySensorDesc(
|
||||
key='paired_hood_connected',
|
||||
field='connectionState',
|
||||
name='Paired hood connected',
|
||||
device_class='connectivity',
|
||||
value_fn=lambda value: str(value).lower() == 'connected',
|
||||
),
|
||||
BinarySensorDesc(
|
||||
key='paired_hood_power',
|
||||
field='power',
|
||||
name='Paired hood power',
|
||||
device_class='running',
|
||||
value_fn=lambda value: str(value).lower() == 'on',
|
||||
),
|
||||
SensorDesc(
|
||||
key='paired_hood_fan_speed',
|
||||
field='fanSpeed',
|
||||
name='Paired hood fan speed',
|
||||
icon='mdi:fan',
|
||||
value_fn=_int_or_none,
|
||||
),
|
||||
BinarySensorDesc(
|
||||
key='paired_hood_light',
|
||||
field='lampState',
|
||||
name='Paired hood light',
|
||||
device_class='light',
|
||||
value_fn=lambda value: str(value).lower() == 'on',
|
||||
),
|
||||
SensorDesc(
|
||||
key='paired_hood_model',
|
||||
field='micomModelId',
|
||||
name='Paired hood model',
|
||||
icon='mdi:information-outline',
|
||||
entity_category='diagnostic',
|
||||
enabled_default=False,
|
||||
),
|
||||
SensorDesc(
|
||||
key='paired_hood_firmware',
|
||||
field='firmwareVersion',
|
||||
name='Paired hood firmware',
|
||||
icon='mdi:chip',
|
||||
entity_category='diagnostic',
|
||||
enabled_default=False,
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -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,20 @@ 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',
|
||||
table_href='/st/dryercourse/vs/0'),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -2,9 +2,13 @@
|
||||
|
||||
Resources verified against the dump at local-tools/dumps/10.0.0.254.json.
|
||||
|
||||
Temperature fields in all /temperature/* resources are in Fahrenheit on this
|
||||
model. Setpoints are NumberDesc with direct-write write_fn — generic caps
|
||||
derive the CoAP PUT path from href at write time.
|
||||
Temperature unit is read live from each resource, not assumed: the RF9000B
|
||||
dump reports Fahrenheit ("units": "F" / "x.com.samsung.da.unit": "Fahrenheit"),
|
||||
but a TP1X_REF_21K dump (issue #7) reports the same fields in Celsius for the
|
||||
exact same resources — the device tells you which one it is, it's just never
|
||||
been read before. See `_temp_unit`/`_temp_item_unit` below. Setpoints are
|
||||
NumberDesc with direct-write write_fn — generic caps derive the CoAP PUT path
|
||||
from href at write time.
|
||||
|
||||
Multi-instance note: the two door resources (/door/cooler/0 and
|
||||
/door/freezer/0) and the two ice-maker resources (/icemaker/one/vs/0 and
|
||||
@@ -15,9 +19,10 @@ 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
|
||||
|
||||
# Display names for the beverage zone, flex zone, ice type, and
|
||||
# ice-making-status enums below live in strings.json / translations/en.json,
|
||||
@@ -33,6 +38,12 @@ def _int(v):
|
||||
return None
|
||||
|
||||
|
||||
def _temp_unit(rep):
|
||||
"""'units': 'C'/'F' (or 'Celsius'/'Fahrenheit') -> '°C'/'°F'. Defaults to
|
||||
°F (this module's original assumption) if the device omits the field."""
|
||||
return normalize_temp_unit(rep.get('units'))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Temperature (generic — covers /temperature/current/* and /temperature/desired/*)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -45,7 +56,7 @@ TEMP_CURRENT_GENERIC = Capability(
|
||||
entities=(
|
||||
SensorDesc(key='temperature', field='temperature',
|
||||
name=None, icon='mdi:thermometer',
|
||||
device_class='temperature', unit='°F',
|
||||
device_class='temperature', unit_fn=_temp_unit,
|
||||
state_class='measurement'),
|
||||
),
|
||||
)
|
||||
@@ -57,7 +68,7 @@ TEMP_SETPOINT_GENERIC = Capability(
|
||||
poll_tier='warm',
|
||||
entities=(
|
||||
NumberDesc(key='setpoint', field='temperature',
|
||||
name=None, device_class='temperature', unit='°F',
|
||||
name=None, device_class='temperature', unit_fn=_temp_unit,
|
||||
native_min=-20.0, native_max=50.0,
|
||||
range_field='range', entity_category='config',
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
@@ -81,7 +92,7 @@ ICEMAKER_NIGHTTIME = Capability(
|
||||
value_fn=lambda v: v == 'On',
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['icemaker', 'nighttime', 'vs', '0'],
|
||||
{'ice.night.status': 'On' if p else 'Off'})),
|
||||
{'ice.night.status': 'On' if p == 'On' else 'Off'})),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -89,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):
|
||||
@@ -101,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',
|
||||
@@ -150,7 +168,7 @@ DOOR_ALERT = Capability(
|
||||
def _status_lock_write(field):
|
||||
return lambda p, rep, href=None: (
|
||||
['status', 'lock', 'vs', '0'],
|
||||
{field: 'On' if p else 'Off'}
|
||||
{field: 'On' if p == 'On' else 'Off'}
|
||||
)
|
||||
|
||||
|
||||
@@ -172,12 +190,16 @@ STATUS_LOCK = Capability(
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Defrost delay
|
||||
# Defrost delay / active-defrost status
|
||||
#
|
||||
# /defrost/delay/vs/0 is the writable toggle. /defrost/block/vs/0 just
|
||||
# reports whether the defrost cycle is currently blocked as a result (a
|
||||
# derived status, not an independent control) — exposed as a read-only
|
||||
# diagnostic binary sensor.
|
||||
# /defrost/delay/vs/0 is the writable toggle to postpone a scheduled
|
||||
# defrost. /defrost/block/vs/0 is an unrelated, independently-varying
|
||||
# status: despite its "block" naming (originally assumed to mean "defrost
|
||||
# is being withheld"), live dumps confirm DEFROST_BLOCK_ON means the
|
||||
# defrost cycle is *actively running* right now, seen with defrost_delay
|
||||
# off -- i.e. "block" refers to the evaporator/coil block being defrosted,
|
||||
# not a blocking/prevention state. Exposed as a read-only diagnostic
|
||||
# binary sensor.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
DEFROST_DELAY = Capability(
|
||||
@@ -190,43 +212,29 @@ DEFROST_DELAY = Capability(
|
||||
value_fn=lambda v: v == 'On',
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['defrost', 'delay', 'vs', '0'],
|
||||
{'x.com.samsung.da.delayDefrost': 'On' if p else 'Off'})),
|
||||
{'x.com.samsung.da.delayDefrost': 'On' if p == 'On' else 'Off'})),
|
||||
),
|
||||
)
|
||||
|
||||
# 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',
|
||||
entities=(
|
||||
BinarySensorDesc(key='defrost_blocked', field='x.com.samsung.da.modes',
|
||||
name='Defrost blocked', icon='mdi:snowflake-off',
|
||||
BinarySensorDesc(key='defrost_active', field='x.com.samsung.da.modes',
|
||||
name='Defrost active', icon='mdi:snowflake-melt',
|
||||
entity_category='diagnostic',
|
||||
value_fn=lambda modes: bool(modes) and modes[0] == 'DEFROST_BLOCK_ON'),
|
||||
),
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -292,7 +300,7 @@ WELCOME_LIGHTING = Capability(
|
||||
value_fn=lambda v: v == 'On',
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['proximity', 'vs', '0'],
|
||||
{'status': 'On' if p else 'Off'})),
|
||||
{'status': 'On' if p == 'On' else 'Off'})),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -432,7 +440,7 @@ CABINET_LIGHT = Capability(
|
||||
value_fn=lambda v: v == 'On',
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['cabinet', 'light', 'total', 'vs', '0'],
|
||||
{'light.dimming.status': 'On' if p else 'Off'})),
|
||||
{'light.dimming.status': 'On' if p == 'On' else 'Off'})),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -479,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}
|
||||
|
||||
@@ -499,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)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -590,6 +623,13 @@ def _temp_item_value(items, keyword):
|
||||
return None
|
||||
|
||||
|
||||
def _temp_item_unit(items, keyword):
|
||||
for item in (items or []):
|
||||
if keyword.lower() in (item.get('x.com.samsung.da.description') or '').lower():
|
||||
return normalize_temp_unit(item.get('x.com.samsung.da.unit'))
|
||||
return '°F'
|
||||
|
||||
|
||||
TEMPERATURES_FALLBACK = Capability(
|
||||
href='/temperatures/vs/0',
|
||||
match_fn=lambda rep, resources: not _any_temperature_generic(resources),
|
||||
@@ -597,11 +637,15 @@ TEMPERATURES_FALLBACK = Capability(
|
||||
entities=(
|
||||
SensorDesc(key='freezer_temperature', field='x.com.samsung.da.items',
|
||||
name='Freezer temperature', icon='mdi:thermometer',
|
||||
device_class='temperature', unit='°F', state_class='measurement',
|
||||
device_class='temperature', state_class='measurement',
|
||||
unit_fn=lambda rep: _temp_item_unit(
|
||||
rep.get('x.com.samsung.da.items'), 'Freezer'),
|
||||
value_fn=lambda items: _temp_item_value(items, 'Freezer')),
|
||||
SensorDesc(key='fridge_temperature', field='x.com.samsung.da.items',
|
||||
name='Fridge temperature', icon='mdi:thermometer',
|
||||
device_class='temperature', unit='°F', state_class='measurement',
|
||||
device_class='temperature', state_class='measurement',
|
||||
unit_fn=lambda rep: _temp_item_unit(
|
||||
rep.get('x.com.samsung.da.items'), 'Fridge'),
|
||||
value_fn=lambda items: _temp_item_value(items, 'Fridge')),
|
||||
),
|
||||
)
|
||||
@@ -625,6 +669,45 @@ ICEMAKER_STATUS_FALLBACK = Capability(
|
||||
value_fn=lambda v: v == 'On',
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['icemaker', 'status', 'vs', '0'],
|
||||
{'x.com.samsung.da.iceMaker': 'On' if p else 'Off'})),
|
||||
{'x.com.samsung.da.iceMaker': 'On' if p == 'On' else 'Off'})),
|
||||
),
|
||||
)
|
||||
|
||||
# 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
|
||||
# DEFROST_BLOCK_STATUS's defrost_active), each absent independently, so a
|
||||
# single capability-level match_fn can't express it. Gated per-entity
|
||||
# (exists_fn) instead: rapid_fridge/rapid_freezing back off only when
|
||||
# REFRIGERATION's href is present; defrost_active only when
|
||||
# DEFROST_BLOCK_STATUS's is. No write path confirmed for this href, so
|
||||
# these are read-only, unlike REFRIGERATION's switches.
|
||||
REFRIGERATION_FALLBACK = Capability(
|
||||
href='/refrigeration/0',
|
||||
poll_tier='warm',
|
||||
entities=(
|
||||
BinarySensorDesc(key='defrost_active', field='defrost',
|
||||
name='Defrost active', icon='mdi:snowflake-melt',
|
||||
entity_category='diagnostic',
|
||||
value_fn=lambda v: bool(v),
|
||||
exists_fn=lambda rep, resources: '/defrost/block/vs/0' not in resources),
|
||||
BinarySensorDesc(key='rapid_fridge', field='rapidCool',
|
||||
name='Rapid fridge', icon='mdi:fridge-industrial',
|
||||
entity_category='config',
|
||||
value_fn=lambda v: bool(v),
|
||||
exists_fn=lambda rep, resources: '/refrigeration/vs/0' not in resources),
|
||||
BinarySensorDesc(key='rapid_freezing', field='rapidFreeze',
|
||||
name='Rapid freezing', icon='mdi:snowflake',
|
||||
entity_category='config',
|
||||
value_fn=lambda v: bool(v),
|
||||
exists_fn=lambda rep, resources: '/refrigeration/vs/0' not in resources),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -85,10 +85,10 @@ 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) -- 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.
|
||||
Capability(href='/runningmode/vs/0'),
|
||||
@@ -103,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,288 @@ 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.
|
||||
# Confirmed on real hardware (issue #54): a write only needs to carry the one
|
||||
# changed token -- `{'x.com.samsung.da.options': ['SoftenerLevelCtrl_2']}` --
|
||||
# the device matches by prefix, evicts the stale token, and merges the result
|
||||
# into the array itself. No read-modify-write of the whole array needed (see
|
||||
# option_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.
|
||||
#
|
||||
# Some boards populate /wm/editcourse/vs/0 without ever filling in
|
||||
# editCourseList itself (issue #1) -- cycle_options() falls back to deriving
|
||||
# the same list from /course/vs/0's own supportedOptions in that case; see
|
||||
# _course_codes_from_supported_options for the byte-level evidence.
|
||||
#
|
||||
# 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 {}
|
||||
codes = parse_edit_course_list(rep.get('x.com.samsung.da.editCourseList'))
|
||||
if codes:
|
||||
return codes
|
||||
return _course_codes_from_supported_options(resources.get('/course/vs/0') or {})
|
||||
|
||||
|
||||
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 _course_codes_from_supported_options(course_rep):
|
||||
"""Fallback for an empty/missing editCourseList: derive the selectable
|
||||
course list from /course/vs/0's own x.com.samsung.da.supportedOptions
|
||||
instead (issue #1: some DA_WM_TP1/TP2-class boards populate the
|
||||
/wm/editcourse/vs/0 href but never fill in editCourseList itself).
|
||||
|
||||
supportedOptions is a 1-hex-nibble header followed by one fixed-width
|
||||
record per selectable course, self-indexed rather than positional --
|
||||
the first byte of every record is that course's own hex code, just in
|
||||
the firmware's own internal order, not editCourseList's. Confirmed
|
||||
against six independent real-world washer/dryer/dishwasher dumps: every
|
||||
one divides evenly into `header + N * K bytes` with fully unique first
|
||||
bytes across all N records, at the record's true byte width. (What the
|
||||
rest of each record encodes is still unconfirmed -- this only uses the
|
||||
course-code byte.)
|
||||
|
||||
Two guards, deliberately conservative rather than guessing further: the
|
||||
derived codes must (a) all be distinct -- a real course table, not
|
||||
noise -- and (b) include whatever course is currently selected
|
||||
(x.com.samsung.da.options' Course_<code> token), which must always be a
|
||||
member of its own device's valid list. If no split satisfies both, this
|
||||
returns [] rather than guess.
|
||||
|
||||
Among splits that satisfy both, the *smallest* passing K wins, rather
|
||||
than requiring a single unambiguous one -- more than one K reliably
|
||||
does pass on real data (e.g. the shipped dishwasher fixture: true
|
||||
K=7 passes, but so do 10, 14, and 35, none of which are multiples of
|
||||
7 -- position 0 always lands on the same real course code regardless
|
||||
of K, which is enough on its own to satisfy the current-course guard
|
||||
for several unrelated splits). Smallest-K-wins is a heuristic, not a
|
||||
proof: it matches the confirmed answer on every one of six independent
|
||||
real-world dumps this was checked against, but a coincidentally
|
||||
unique, current-course-inclusive *smaller* K is not mathematically
|
||||
impossible on some future device, and would be picked silently. Not
|
||||
guarded against further here, since course tables are typically large
|
||||
enough (double digits) that colliding by chance on both checks is
|
||||
unlikely, and no device seen so far actually needs it.
|
||||
"""
|
||||
raw = course_rep.get('x.com.samsung.da.supportedOptions')
|
||||
hexstr = raw[0] if isinstance(raw, list) and raw else raw
|
||||
if not isinstance(hexstr, str) or len(hexstr) < 3:
|
||||
return []
|
||||
body = hexstr[1:]
|
||||
if len(body) % 2:
|
||||
return []
|
||||
total_bytes = len(body) // 2
|
||||
current = option_value(course_rep.get('x.com.samsung.da.options'), 'Course')
|
||||
for k in range(1, total_bytes + 1):
|
||||
if total_bytes % k:
|
||||
continue
|
||||
n = total_bytes // k
|
||||
if n < 2:
|
||||
continue
|
||||
firsts = [body[i * k * 2:i * k * 2 + 2] for i in range(n)]
|
||||
if len(set(firsts)) != n:
|
||||
continue
|
||||
if current is not None and current not in firsts:
|
||||
continue
|
||||
return firsts
|
||||
return []
|
||||
|
||||
|
||||
def option_write(prefix, new_value):
|
||||
"""A one-token x.com.samsung.da.options write -- see the module comment
|
||||
above cycle_options for why this doesn't read/rewrite the whole array."""
|
||||
return [f'{prefix}_{new_value}']
|
||||
|
||||
|
||||
def cycle_write(p, rep, href=None):
|
||||
if not rep.get('x.com.samsung.da.options'):
|
||||
return None
|
||||
return ['course', 'vs', '0'], {
|
||||
'x.com.samsung.da.options': option_write('Course', p),
|
||||
}
|
||||
|
||||
|
||||
def _table_id(resources, table_href):
|
||||
rep = resources.get(table_href) or {}
|
||||
return rep.get('x.com.samsung.da.st.courseTable')
|
||||
|
||||
|
||||
def cycle_select(*, translation_key, icon, table_href=None):
|
||||
"""A 'Cycle' select over /course/vs/0, labelled from `translation_key`.
|
||||
|
||||
The option list, current value, and write path are all shared across
|
||||
washer/dryer/dishwasher; only the translation is family- (and, for
|
||||
washer/dryer, board-) specific.
|
||||
|
||||
table_href (washer/dryer only -- see washer.py/dryer.py's call sites)
|
||||
suffixes translation_key with the device's own course-table id, read
|
||||
from /st/washercourse/vs/0 or /st/dryercourse/vs/0's
|
||||
x.com.samsung.da.st.courseTable (e.g. 'washer_cycle' + 'Table_02' ->
|
||||
'washer_cycle_table_02'). No table id available at all -- the href
|
||||
absent or empty -- gets no translation_key, i.e. the raw course code
|
||||
displayed as-is.
|
||||
|
||||
This matters because course codes are NOT guaranteed consistent across
|
||||
board generations sharing the same /course/vs/0 contract: every code in
|
||||
washer_cycle_table_02 was confirmed against Table_02-reporting devices
|
||||
(DA_WM_TP1/TP2 boards); FlexWash's older DA_WM_A51 board reports
|
||||
Table_00 instead, so the same hex code could mean a different course
|
||||
there for all we've verified. Building the key from whatever table the
|
||||
device actually reports, rather than gating a single hardcoded key on
|
||||
an exact match, means a table we haven't built translations for yet
|
||||
(like Table_00) just falls through Home Assistant's own missing-
|
||||
translation handling to the same raw-code display -- exactly what
|
||||
happens today for any individual code within a table's translations
|
||||
that isn't populated yet -- and adding one later needs new strings.json
|
||||
entries, not a code change here.
|
||||
|
||||
Left at its default for dishwasher, which has no equivalent table-id
|
||||
resource in any dump seen and no evidence its course codes vary by
|
||||
table the way washer/dryer's do -- there's nothing to build a
|
||||
table-specific key from.
|
||||
"""
|
||||
key = translation_key
|
||||
if table_href is not None:
|
||||
def key(resources):
|
||||
table = _table_id(resources, table_href)
|
||||
return f'{translation_key}_{table.lower()}' if table else None
|
||||
|
||||
return SelectDesc(
|
||||
key='cycle', name='Cycle', icon=icon, translation_key=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
|
||||
if not rep.get('x.com.samsung.da.options'):
|
||||
return None
|
||||
return ['course', 'vs', '0'], {
|
||||
'x.com.samsung.da.options': option_write(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'),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -70,19 +70,34 @@ def _finish_time(remaining_str):
|
||||
return None
|
||||
|
||||
|
||||
# Shared by dryer/dishwasher/oven/washer -- oven.py imports this directly
|
||||
# rather than keeping its own copy, since both wrote the identical
|
||||
# state='Ready' RMW.
|
||||
STOP_BUTTON = ButtonDesc(key='stop', field='', name='Stop', payload='Ready',
|
||||
icon='mdi:stop',
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['operational', 'state', 'vs', '0'],
|
||||
{'x.com.samsung.da.state': p}))
|
||||
|
||||
OPERATIONAL_STATE = Capability(
|
||||
href='/operational/state/vs/0',
|
||||
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.
|
||||
# Samsung firmware keeps state='Run' after progress reaches 'Finish',
|
||||
# so we also gate on progress to avoid a stuck 'Running' indication.
|
||||
# Named 'Running' rather than 'Cycle active' -- this href (and the
|
||||
# start/pause/stop buttons below) is shared across the dryer/
|
||||
# dishwasher/oven/washer families, and 'cycle' is laundry-specific
|
||||
# vocabulary that doesn't fit an oven's bake/roast/etc.
|
||||
BinarySensorDesc(key='cycle_active', device_class='running',
|
||||
name='Cycle active',
|
||||
name='Running',
|
||||
rep_fn=lambda rep: (
|
||||
_SAMSUNG_STATE_TO_OCF.get(rep.get('x.com.samsung.da.state')) == 'active'
|
||||
and rep.get('x.com.samsung.da.progress') != 'Finish'
|
||||
@@ -93,9 +108,11 @@ OPERATIONAL_STATE = Capability(
|
||||
else _progress(rep.get('x.com.samsung.da.progress'))
|
||||
)),
|
||||
SensorDesc(key='progress_percentage',
|
||||
field='x.com.samsung.da.progressPercentage',
|
||||
name='Progress percent', unit='%', state_class='measurement',
|
||||
value_fn=_int),
|
||||
rep_fn=lambda rep: (
|
||||
0 if _SAMSUNG_STATE_TO_OCF.get(rep.get('x.com.samsung.da.state')) != 'active'
|
||||
else _int(rep.get('x.com.samsung.da.progressPercentage'))
|
||||
)),
|
||||
# Only show finish time when machine is actively running. Samsung
|
||||
# firmware leaves a stale remainingTime after a cycle ends, and
|
||||
# freezes it at '00:01:00' when progress reaches 'Finish'.
|
||||
@@ -115,20 +132,16 @@ OPERATIONAL_STATE = Capability(
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['operational', 'state', 'vs', '0'],
|
||||
{_delay_field(rep): _format_delay(p)})),
|
||||
ButtonDesc(key='start', field='', name='Start cycle', payload='Run',
|
||||
ButtonDesc(key='start', field='', name='Start', payload='Run',
|
||||
icon='mdi:play',
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['operational', 'state', 'vs', '0'],
|
||||
{'x.com.samsung.da.state': p})),
|
||||
ButtonDesc(key='pause', field='', name='Pause cycle', payload='Pause',
|
||||
ButtonDesc(key='pause', field='', name='Pause', payload='Pause',
|
||||
icon='mdi:pause',
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['operational', 'state', 'vs', '0'],
|
||||
{'x.com.samsung.da.state': p})),
|
||||
ButtonDesc(key='stop', field='', name='Stop cycle', payload='Ready',
|
||||
icon='mdi:stop',
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['operational', 'state', 'vs', '0'],
|
||||
{'x.com.samsung.da.state': p})),
|
||||
STOP_BUTTON,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -26,9 +26,10 @@ from datetime import datetime, timezone, timedelta
|
||||
|
||||
from ..capability import Capability
|
||||
from ..entities import (
|
||||
BinarySensorDesc, ButtonDesc, NumberDesc, SelectDesc, SensorDesc,
|
||||
SwitchDesc,
|
||||
BinarySensorDesc, NumberDesc, SelectDesc, SensorDesc, SwitchDesc,
|
||||
)
|
||||
from .common import normalize_temp_unit
|
||||
from .operational import STOP_BUTTON
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constants
|
||||
@@ -38,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.
|
||||
@@ -112,10 +122,17 @@ def _option_value(options, prefix):
|
||||
return None
|
||||
|
||||
|
||||
def _replace_in_options(options, prefix, new_value):
|
||||
"""Return a new options list with the `<prefix>_*` slot replaced."""
|
||||
return [f"{prefix}_{new_value}" if o.startswith(prefix + '_') else o
|
||||
for o in options]
|
||||
def _option_write(prefix, new_value):
|
||||
"""A one-token x.com.samsung.da.options write, mirroring
|
||||
laundry.option_write. NOT independently confirmed on an oven -- issue
|
||||
#54 only confirmed prefix-merge-on-write for a washer's /course/vs/0.
|
||||
This extrapolates that same vendor field/contract to the oven's
|
||||
/mode/vs/0, on the assumption the firmware handles the array the same
|
||||
way there. If that assumption is wrong for some oven, a device that
|
||||
replaces the field outright instead of merging would drop every other
|
||||
option in it (Sound/fastpreheat/etc.) on the next write -- revisit if a
|
||||
real device report surfaces that."""
|
||||
return [f'{prefix}_{new_value}']
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -128,8 +145,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:
|
||||
@@ -155,12 +173,6 @@ def _cook_time_write(p, rep, href=None):
|
||||
}
|
||||
|
||||
|
||||
def _stop_write(p, rep, href=None):
|
||||
return ['operational', 'state', 'vs', '0'], {
|
||||
'x.com.samsung.da.state': 'Ready',
|
||||
}
|
||||
|
||||
|
||||
def _oven_mode_write(p, rep, href=None):
|
||||
if p not in _OVEN_MODES:
|
||||
return None
|
||||
@@ -170,47 +182,41 @@ def _oven_mode_write(p, rep, href=None):
|
||||
def _lamp_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:
|
||||
if not rep.get('x.com.samsung.da.options'):
|
||||
return None
|
||||
return ['mode', 'vs', '0'], {
|
||||
'x.com.samsung.da.options': _replace_in_options(opts, 'UpperLamp', p),
|
||||
'x.com.samsung.da.options': _option_write('UpperLamp', p),
|
||||
}
|
||||
|
||||
|
||||
def _sound_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:
|
||||
if not rep.get('x.com.samsung.da.options'):
|
||||
return None
|
||||
return ['mode', 'vs', '0'], {
|
||||
'x.com.samsung.da.options': _replace_in_options(opts, 'Sound', p),
|
||||
'x.com.samsung.da.options': _option_write('Sound', p),
|
||||
}
|
||||
|
||||
|
||||
def _fastpreheat_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:
|
||||
if not rep.get('x.com.samsung.da.options'):
|
||||
return None
|
||||
return ['mode', 'vs', '0'], {
|
||||
'x.com.samsung.da.options': _replace_in_options(opts, 'fastpreheat', p),
|
||||
'x.com.samsung.da.options': _option_write('fastpreheat', p),
|
||||
}
|
||||
|
||||
|
||||
def _naturalsteam_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:
|
||||
if not rep.get('x.com.samsung.da.options'):
|
||||
return None
|
||||
if not any(o.startswith('NaturalSteam_') for o in opts):
|
||||
opts = opts + [f'NaturalSteam_{p}']
|
||||
else:
|
||||
opts = _replace_in_options(opts, 'NaturalSteam', p)
|
||||
return ['mode', 'vs', '0'], {'x.com.samsung.da.options': opts}
|
||||
return ['mode', 'vs', '0'], {
|
||||
'x.com.samsung.da.options': _option_write('NaturalSteam', p),
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -222,9 +228,11 @@ 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',
|
||||
name='Running', device_class='running',
|
||||
value_fn=lambda v: _SAMSUNG_STATE_TO_OCF.get(v) == 'active'),
|
||||
SensorDesc(key='progress_percentage',
|
||||
field='x.com.samsung.da.progressPercentage',
|
||||
@@ -241,8 +249,7 @@ OVEN_OPERATIONAL_STATE = Capability(
|
||||
name='Cook time', unit='min', native_min=0, native_max=1439,
|
||||
step=1.0, icon='mdi:timer', value_fn=_op_minutes,
|
||||
write_fn=_cook_time_write),
|
||||
ButtonDesc(key='stop', field='', name='Stop cycle', icon='mdi:stop',
|
||||
payload='Stop', write_fn=_stop_write),
|
||||
STOP_BUTTON,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -255,21 +262,47 @@ OVEN_CAVITY = Capability(
|
||||
),
|
||||
)
|
||||
|
||||
def _oven_temp_unit(rep):
|
||||
"""Same shape/risk as fridge.py's TEMPERATURES_FALLBACK: this is the
|
||||
same aggregate `/temperatures/vs/0` items[] resource type, which on
|
||||
fridge hardware carries a per-item `x.com.samsung.da.unit` field
|
||||
('Celsius'/'Fahrenheit') that was previously hardcoded away (issue #7).
|
||||
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',
|
||||
entities=(
|
||||
# NumberDesc first — test_oven_setpoint_write_is_read_modify_write uses entities[0]
|
||||
NumberDesc(key='oven_setpoint', field='x.com.samsung.da.items',
|
||||
name='Setpoint', device_class='temperature', unit='°C',
|
||||
name='Setpoint', device_class='temperature', unit_fn=_oven_temp_unit,
|
||||
native_min=float(SETPOINT_MIN_C), native_max=float(SETPOINT_MAX_C),
|
||||
step=float(SETPOINT_STEP_C), icon='mdi:thermometer-chevron-up',
|
||||
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),
|
||||
SensorDesc(key='current_temp_c', field='x.com.samsung.da.items',
|
||||
name='Temperature', device_class='temperature',
|
||||
state_class='measurement', unit='°C',
|
||||
state_class='measurement', unit_fn=_oven_temp_unit,
|
||||
value_fn=lambda items: _int(
|
||||
(items[0].get('x.com.samsung.da.current') if items else None))),
|
||||
),
|
||||
@@ -298,6 +331,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'),
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,288 @@
|
||||
"""Capabilities for Samsung AHD-WW-TP1-22 range hoods.
|
||||
|
||||
The verified device exposes its fan, two-level work lamp, washable-filter
|
||||
status, and particulate sensors as distinct local OCF resources. Fan power
|
||||
and speed are combined into one HA fan entity by ``fan.py``; lamp power and
|
||||
brightness remain separate controls because the device advertises them as two
|
||||
independent fields.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from ..capability import Capability
|
||||
from ..entities import (
|
||||
BinarySensorDesc,
|
||||
FanDesc,
|
||||
SelectDesc,
|
||||
SensorDesc,
|
||||
SwitchDesc,
|
||||
)
|
||||
from .common import int_or_none, sensor_item_value
|
||||
|
||||
|
||||
def _timestamp(value):
|
||||
try:
|
||||
return datetime.fromtimestamp(float(value), tz=timezone.utc)
|
||||
except (TypeError, ValueError, OSError):
|
||||
return None
|
||||
|
||||
|
||||
def _active_alarm_codes(items):
|
||||
"""Discard the hood firmware's retained/deleted ``ErrorCode_OFF`` row.
|
||||
|
||||
Unlike ``common._alarm_codes``, the hood retains a deleted alarm row in
|
||||
its live representation, so this family-specific helper also checks state.
|
||||
"""
|
||||
codes = []
|
||||
for item in items or ():
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
if str(item.get('x.com.samsung.da.state', '')).lower() == 'deleted':
|
||||
continue
|
||||
code = item.get('x.com.samsung.da.code')
|
||||
if code and str(code).lower() != 'errorcode_off':
|
||||
codes.append(code)
|
||||
return ', '.join(codes) if codes else 'none'
|
||||
|
||||
|
||||
HOOD_ALARMS = Capability(
|
||||
href='/alarms/vs/0',
|
||||
poll_tier='hot',
|
||||
entities=(
|
||||
SensorDesc(
|
||||
key='alarm_code',
|
||||
field='x.com.samsung.da.items',
|
||||
name='Alarm code',
|
||||
icon='mdi:alert',
|
||||
entity_category='diagnostic',
|
||||
value_fn=_active_alarm_codes,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _hood_fan_write(payload, rep, href=None):
|
||||
kind, value, *args = payload
|
||||
if kind == 'power':
|
||||
power_href = args[0] if args else '/power/0'
|
||||
if power_href == '/power/0':
|
||||
return ['power', '0'], {'value': bool(value)}
|
||||
if power_href == '/power/vs/0':
|
||||
return ['power', 'vs', '0'], {
|
||||
'x.com.samsung.da.power': 'On' if value else 'Off',
|
||||
}
|
||||
return None
|
||||
if kind == 'speed':
|
||||
value = str(value)
|
||||
supported = [
|
||||
str(code)
|
||||
for code in rep.get('x.com.samsung.da.hood.supportedFanSpeed', ())
|
||||
]
|
||||
if value not in supported:
|
||||
return None
|
||||
return ['hood', 'fanspeed', 'vs', '0'], {
|
||||
'x.com.samsung.da.hood.fanSpeed': value,
|
||||
}
|
||||
return None
|
||||
|
||||
|
||||
HOOD_FAN = Capability(
|
||||
href='/hood/fanspeed/vs/0',
|
||||
poll_tier='hot',
|
||||
entities=(
|
||||
FanDesc(
|
||||
key='fan',
|
||||
field='x.com.samsung.da.hood.fanSpeed',
|
||||
name=None,
|
||||
write_fn=_hood_fan_write,
|
||||
),
|
||||
BinarySensorDesc(
|
||||
key='automatic_operation',
|
||||
field='x.com.samsung.da.hood.autoOperation',
|
||||
name='Automatic operation',
|
||||
icon='mdi:fan-auto',
|
||||
entity_category='diagnostic',
|
||||
value_fn=lambda value: str(value).lower() == 'on',
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _lamp_level_write(value, rep, href=None):
|
||||
code = str(value)
|
||||
supported = [str(level) for level in rep.get('x.com.samsung.lamp.range', ())]
|
||||
if code not in supported:
|
||||
return None
|
||||
return ['hood', 'lamp', 'vs', '0'], {
|
||||
'x.com.samsung.lamp.current': code,
|
||||
}
|
||||
|
||||
|
||||
HOOD_LAMP = Capability(
|
||||
href='/hood/lamp/vs/0',
|
||||
poll_tier='hot',
|
||||
entities=(
|
||||
SwitchDesc(
|
||||
key='lamp',
|
||||
field='x.com.samsung.lamp.power',
|
||||
name='Lamp',
|
||||
icon='mdi:range-hood',
|
||||
value_fn=lambda value: str(value).lower() == 'on',
|
||||
write_fn=lambda payload, rep, href=None: (
|
||||
['hood', 'lamp', 'vs', '0'],
|
||||
{'x.com.samsung.lamp.power': 'On' if payload == 'On' else 'Off'},
|
||||
),
|
||||
),
|
||||
SelectDesc(
|
||||
key='lamp_brightness',
|
||||
field='x.com.samsung.lamp.current',
|
||||
name='Lamp brightness',
|
||||
icon='mdi:brightness-6',
|
||||
translation_key='range_hood_lamp_brightness',
|
||||
options_field='x.com.samsung.lamp.range',
|
||||
write_fn=_lamp_level_write,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
HOOD_FILTER = Capability(
|
||||
href='/filter/hoodfilter/vs/0',
|
||||
poll_tier='cold',
|
||||
entities=(
|
||||
SensorDesc(
|
||||
key='hood_filter_usage',
|
||||
field='x.com.samsung.da.filterUsage',
|
||||
name='Filter usage',
|
||||
unit='%',
|
||||
state_class='measurement',
|
||||
icon='mdi:air-filter',
|
||||
entity_category='diagnostic',
|
||||
value_fn=int_or_none,
|
||||
),
|
||||
SensorDesc(
|
||||
key='hood_filter_status',
|
||||
field='x.com.samsung.da.filterStatus',
|
||||
name='Filter status',
|
||||
icon='mdi:air-filter',
|
||||
entity_category='diagnostic',
|
||||
),
|
||||
SensorDesc(
|
||||
key='hood_filter_capacity',
|
||||
field='x.com.samsung.da.filterCapacity',
|
||||
name='Filter capacity',
|
||||
unit='h',
|
||||
icon='mdi:timer-outline',
|
||||
entity_category='diagnostic',
|
||||
enabled_default=False,
|
||||
value_fn=int_or_none,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
AIR_QUALITY = Capability(
|
||||
href='/sensors/vs/0',
|
||||
poll_tier='warm',
|
||||
entities=(
|
||||
SensorDesc(
|
||||
key='clean_level',
|
||||
field='x.com.samsung.da.items',
|
||||
name='Clean level',
|
||||
icon='mdi:air-filter',
|
||||
value_fn=lambda items: sensor_item_value(items, 'CleanLevel'),
|
||||
),
|
||||
SensorDesc(
|
||||
key='dust',
|
||||
field='x.com.samsung.da.items',
|
||||
name='Dust',
|
||||
value_fn=lambda items: sensor_item_value(items, 'Dust'),
|
||||
),
|
||||
SensorDesc(
|
||||
key='fine_dust',
|
||||
field='x.com.samsung.da.items',
|
||||
name='Fine dust',
|
||||
value_fn=lambda items: sensor_item_value(items, 'FineDust'),
|
||||
),
|
||||
SensorDesc(
|
||||
key='super_fine_dust',
|
||||
field='x.com.samsung.da.items',
|
||||
name='Super fine dust',
|
||||
value_fn=lambda items: sensor_item_value(items, 'SuperFineDust'),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
AIR_LEVEL_CHECK = Capability(
|
||||
href='/airlevelcheck/vs/0',
|
||||
poll_tier='warm',
|
||||
entities=(
|
||||
BinarySensorDesc(
|
||||
key='periodic_air_sensing',
|
||||
field='x.com.samsung.da.periodicSensingActivationState',
|
||||
name='Periodic air sensing',
|
||||
icon='mdi:radar',
|
||||
entity_category='diagnostic',
|
||||
value_fn=lambda value: str(value).lower() == 'on',
|
||||
),
|
||||
SensorDesc(
|
||||
key='air_sensing_state',
|
||||
field='x.com.samsung.da.sensingState',
|
||||
name='Air sensing state',
|
||||
icon='mdi:radar',
|
||||
entity_category='diagnostic',
|
||||
),
|
||||
SensorDesc(
|
||||
key='last_air_sensing_time',
|
||||
field='x.com.samsung.da.lastSensingTime',
|
||||
name='Last air sensing time',
|
||||
device_class='timestamp',
|
||||
entity_category='diagnostic',
|
||||
value_fn=_timestamp,
|
||||
),
|
||||
SensorDesc(
|
||||
key='last_air_sensing_level',
|
||||
field='x.com.samsung.da.lastSensingLevel',
|
||||
name='Last air sensing level',
|
||||
icon='mdi:air-filter',
|
||||
entity_category='diagnostic',
|
||||
),
|
||||
SensorDesc(
|
||||
key='automatic_ventilation_state',
|
||||
field='x.com.samsung.da.autoExeState',
|
||||
name='Automatic ventilation state',
|
||||
icon='mdi:fan-auto',
|
||||
entity_category='diagnostic',
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
AUTO_VENTILATION = Capability(
|
||||
href='/autoventilation/vs/0',
|
||||
poll_tier='warm',
|
||||
entities=(
|
||||
SensorDesc(
|
||||
key='auto_ventilation_action',
|
||||
field='action',
|
||||
name='Auto ventilation action',
|
||||
icon='mdi:fan-auto',
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
# Resource plumbing and opaque feature-negotiation fields that are specific to
|
||||
# this family. Bare capabilities mark them covered without creating entities.
|
||||
COVERAGE = [
|
||||
Capability(href=href)
|
||||
for href in (
|
||||
'/power/0',
|
||||
'/power/vs/0',
|
||||
'/mode/vs/0',
|
||||
'/personality/presence/vs/0',
|
||||
'/availablecontrolsets/vs/0',
|
||||
'/da/softreset/vs/0',
|
||||
)
|
||||
]
|
||||
@@ -5,12 +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 ..entities import BinarySensorDesc, SelectDesc, SensorDesc
|
||||
from .laundry import (
|
||||
bool_option_exists, bool_option_switch, cycle_options, cycle_select, hex_pairs, option_value,
|
||||
option_write,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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
|
||||
@@ -25,16 +38,23 @@ from ..entities import BinarySensorDesc, SelectDesc, SensorDesc, SwitchDesc
|
||||
# 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
|
||||
@@ -42,19 +62,6 @@ from ..entities import BinarySensorDesc, SelectDesc, SensorDesc, SwitchDesc
|
||||
# so it isn't trustworthy as a list of selectable courses and isn't used.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _parse_edit_course_list(raw):
|
||||
"""'EditCourseList_1C1D211B1E29...' -> ['1C', '1D', '21', '1B', '1E', '29', ...]."""
|
||||
if not isinstance(raw, str) or '_' not in raw:
|
||||
return []
|
||||
codes = raw.split('_', 1)[1]
|
||||
return [codes[i:i + 2] for i in range(0, len(codes) - 1, 2)]
|
||||
|
||||
|
||||
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
|
||||
#
|
||||
@@ -85,166 +92,265 @@ 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:
|
||||
# Drum Clean+ maintenance tracking, from the same options[] array as the
|
||||
# selected course. DrumCleanProposal_<N> is the wash-cycle interval between
|
||||
# recommended cleans; WashingTimes_<N> is the count since the last one --
|
||||
# their difference is exactly the "N cycles until due" figure the Samsung
|
||||
# app shows (verified: DrumCleanProposal_40 - WashingTimes_3 == 37, matching
|
||||
# a live app screenshot's "Potreba cistenia po 37 cykloch"). DrumCleanLog_
|
||||
# is the last-clean timestamp (verified against the same screenshot's "10
|
||||
# days ago"); no explicit timezone field accompanies it on this resource,
|
||||
# so it's treated as UTC, matching this integration's convention for other
|
||||
# bare ISO datetime fields (see fridge.py's night-light schedule comment).
|
||||
def _drum_clean_cycles_remaining(rep):
|
||||
opts = rep.get('x.com.samsung.da.options') or []
|
||||
proposal = option_value(opts, 'DrumCleanProposal')
|
||||
washed = option_value(opts, 'WashingTimes')
|
||||
if proposal is None or washed is None:
|
||||
return None
|
||||
return ['course', 'vs', '0'], {
|
||||
'x.com.samsung.da.options': _replace_in_options(opts, 'Course', p),
|
||||
}
|
||||
try:
|
||||
return max(int(proposal) - int(washed), 0)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _drum_clean_last_cleaned(rep):
|
||||
raw = option_value(rep.get('x.com.samsung.da.options'), 'DrumCleanLog')
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
return datetime.fromisoformat(raw).replace(tzinfo=timezone.utc)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
# Detergent/softener auto-dispense dosing, from the same options[] array
|
||||
# (issue #9). '<Prefix>LevelCtrl_<code>' is the selected dose quantity;
|
||||
# '<Prefix>Level2Ctrl_<code>' is a second dial -- water hardness for
|
||||
# detergent, concentration for softener -- matching the SmartThings app's
|
||||
# two-field dispenser screens ("Distributeur de lessive": Quantité + Dureté
|
||||
# de l'eau; "Distributeur d'adoucissant": Quantité + Concentration, per
|
||||
# issue #9's screenshots). 'Supported<Prefix>Ctrl_<hexpairs>' lists the
|
||||
# valid raw codes for its field, same hex-pair shape as EditCourseList.
|
||||
# '<Prefix>Alarm_<On/Off>' is a low-reservoir warning flag.
|
||||
#
|
||||
# Label mapping (entity.select.washer_dosing_quantity/washer_detergent_
|
||||
# water_hardness/washer_softener_concentration in strings.json) is an
|
||||
# assumed, not cross-device-verified, reading of the single issue #9 dump +
|
||||
# screenshots: LevelCtrl's 4 codes as None/Low/Medium/High (00 has no
|
||||
# on-screen equivalent -- the app's Quantité picker only offers
|
||||
# Faible/Moyen/Élevé, i.e. codes 01-03; 00 is assumed to be what
|
||||
# "Activation" off collapses to) matches DetergentLevelCtrl_3/
|
||||
# SoftenerLevelCtrl_3 = "Élevé" on both dispensers. Level2Ctrl's 3 codes as
|
||||
# Soft/Medium/Hard for detergent (Dureté de l'eau: Douce/Moyenne/Dure)
|
||||
# matches DetergentLevel2Ctrl_2 = "Moyenne". The same 3-code shape as
|
||||
# 1x/2x/3x for softener concentration does *not* cleanly match
|
||||
# SoftenerLevel2Ctrl_2 against the screenshot's "3x" -- assumed to be a
|
||||
# setting the user changed in the app between the dump (issue body) and the
|
||||
# screenshots (a later comment), not a different code scheme, since it's
|
||||
# otherwise identical in shape to the detergent side. Revisit if a second
|
||||
# device's dump contradicts this.
|
||||
def _supported_level_options(resources, prefix):
|
||||
rep = resources.get('/course/vs/0') or {}
|
||||
raw = option_value(rep.get('x.com.samsung.da.options'), f'Supported{prefix}')
|
||||
return hex_pairs(raw) if raw else []
|
||||
|
||||
|
||||
def _level_options(prefix):
|
||||
return lambda resources: _supported_level_options(resources, prefix)
|
||||
|
||||
|
||||
def _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):
|
||||
if not rep.get('x.com.samsung.da.options'):
|
||||
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': option_write(prefix, native),
|
||||
}
|
||||
return write
|
||||
|
||||
|
||||
def _dosing_low(prefix):
|
||||
return lambda rep: option_value(
|
||||
rep.get('x.com.samsung.da.options'), prefix) not in (None, 'Off')
|
||||
|
||||
|
||||
# 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),
|
||||
),
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /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',
|
||||
cycle_select(translation_key='washer_cycle', icon='mdi:washing-machine',
|
||||
table_href='/st/washercourse/vs/0'),
|
||||
SensorDesc(key='drum_clean_cycles_remaining', name='Drum clean due in',
|
||||
icon='mdi:washing-machine-alert', unit='cycles',
|
||||
state_class='measurement',
|
||||
exists_fn=lambda rep, resources: _drum_clean_cycles_remaining(rep) is not None,
|
||||
rep_fn=_drum_clean_cycles_remaining),
|
||||
SensorDesc(key='drum_clean_last_cleaned', name='Drum last cleaned',
|
||||
icon='mdi:calendar-clock', device_class='timestamp',
|
||||
entity_category='diagnostic',
|
||||
exists_fn=lambda rep, resources: _drum_clean_last_cleaned(rep) is not None,
|
||||
rep_fn=_drum_clean_last_cleaned),
|
||||
SelectDesc(key='detergent_quantity', name='Detergent quantity', icon='mdi:cup-water',
|
||||
translation_key='washer_dosing_quantity',
|
||||
entity_category='config',
|
||||
options_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',
|
||||
options=_level_options('DetergentLevelCtrl'),
|
||||
exists_fn=lambda rep, resources: bool(
|
||||
_level_options('DetergentLevelCtrl')(resources)),
|
||||
rep_fn=_dosing_level('DetergentLevelCtrl'),
|
||||
write_fn=_level_write('DetergentLevelCtrl')),
|
||||
SelectDesc(key='detergent_water_hardness', name='Detergent water hardness',
|
||||
icon='mdi:water-opacity',
|
||||
translation_key='washer_detergent_water_hardness',
|
||||
entity_category='config',
|
||||
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'),
|
||||
options=_level_options('DetergentLevel2Ctrl'),
|
||||
exists_fn=lambda rep, resources: bool(
|
||||
_level_options('DetergentLevel2Ctrl')(resources)),
|
||||
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',
|
||||
entity_category='config',
|
||||
options=_level_options('SoftenerLevelCtrl'),
|
||||
exists_fn=lambda rep, resources: bool(
|
||||
_level_options('SoftenerLevelCtrl')(resources)),
|
||||
rep_fn=_dosing_level('SoftenerLevelCtrl'),
|
||||
write_fn=_level_write('SoftenerLevelCtrl')),
|
||||
SelectDesc(key='softener_concentration', name='Softener concentration',
|
||||
icon='mdi:flask-plus-outline',
|
||||
translation_key='washer_softener_concentration',
|
||||
entity_category='config',
|
||||
options=_level_options('SoftenerLevel2Ctrl'),
|
||||
exists_fn=lambda rep, resources: bool(
|
||||
_level_options('SoftenerLevel2Ctrl')(resources)),
|
||||
rep_fn=_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=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=bool_option_exists('SoftenerAlarm'),
|
||||
rep_fn=_dosing_low('SoftenerAlarm')),
|
||||
_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:
|
||||
@@ -21,7 +28,12 @@ class SamsungEntityDescription:
|
||||
key: str
|
||||
field: str = ''
|
||||
name: Optional[str] = None
|
||||
translation_key: Optional[str] = None
|
||||
translation_key: Any = None # str | Callable[[dict[str, dict]], Optional[str]]
|
||||
# callable form receives the coordinator's full href->rep resource
|
||||
# snapshot and returns the key to use (or None for no translation this
|
||||
# device) -- for a descriptor shared across board generations whose
|
||||
# state-code meaning isn't guaranteed consistent between them; see
|
||||
# laundry.cycle_select's table-id-gated resolver.
|
||||
icon: Optional[str] = None
|
||||
entity_category: Optional[str] = None # 'diagnostic' | 'config' | None
|
||||
enabled_default: bool = True
|
||||
@@ -29,7 +41,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
|
||||
|
||||
|
||||
@@ -38,6 +50,7 @@ class SensorDesc(SamsungEntityDescription):
|
||||
device_class: Optional[str] = None
|
||||
state_class: Optional[str] = None
|
||||
unit: Optional[str] = None
|
||||
unit_fn: Optional[Callable[[dict], str]] = None # overrides `unit` from the live rep, when set
|
||||
options: Optional[tuple] = None # required by HA when device_class == 'enum'
|
||||
|
||||
|
||||
@@ -60,6 +73,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)
|
||||
@@ -72,9 +86,17 @@ class ButtonDesc(SamsungEntityDescription):
|
||||
class NumberDesc(SamsungEntityDescription):
|
||||
device_class: Optional[str] = None
|
||||
unit: Optional[str] = None
|
||||
unit_fn: Optional[Callable[[dict], str]] = None # overrides `unit` from the live rep, when set
|
||||
native_min: Optional[float] = None
|
||||
native_max: Optional[float] = None
|
||||
step: Optional[float] = None
|
||||
# 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
|
||||
|
||||
@@ -84,6 +106,23 @@ 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
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class FanDesc(SamsungEntityDescription):
|
||||
# Composite fan entity: reads power from /power/0 and speed/support data
|
||||
# from its bound href. Payloads are (kind, value), like ClimateDesc.
|
||||
write_fn: WriteFn = None
|
||||
|
||||
|
||||
PLATFORM_OF: dict[type, str] = {
|
||||
SensorDesc: 'sensor',
|
||||
BinarySensorDesc: 'binary_sensor',
|
||||
@@ -92,4 +131,6 @@ PLATFORM_OF: dict[type, str] = {
|
||||
ButtonDesc: 'button',
|
||||
NumberDesc: 'number',
|
||||
TimeDesc: 'time',
|
||||
ClimateDesc: 'climate',
|
||||
FanDesc: 'fan',
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Optional
|
||||
|
||||
from homeassistant.components.select import SelectEntity
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
@@ -31,9 +32,14 @@ async def async_setup_entry(
|
||||
_CAMEL_BOUNDARY_RE = re.compile(r'(?<=[a-z0-9])(?=[A-Z])')
|
||||
|
||||
|
||||
def _display(value, desc: SelectDesc):
|
||||
def _display(value, translation_key: Optional[str]):
|
||||
"""Turn a raw device option/state value into what's shown in the UI.
|
||||
|
||||
`translation_key` is the entity's already-resolved key (SelectDesc.
|
||||
translation_key can itself be a callable -- see entities.py -- so
|
||||
callers pass the resolved value, e.g. self.translation_key, not
|
||||
the raw descriptor field).
|
||||
|
||||
An entity with a translation_key looks its state up in strings.json,
|
||||
and hassfest requires those keys to be lowercase -- so those values
|
||||
must be lowercased exactly to match, and the device still expects
|
||||
@@ -50,7 +56,7 @@ def _display(value, desc: SelectDesc):
|
||||
"""
|
||||
if not isinstance(value, str):
|
||||
return value
|
||||
if desc.translation_key:
|
||||
if translation_key:
|
||||
return value.lower()
|
||||
if value.islower():
|
||||
return value.replace('_', ' ').title()
|
||||
@@ -63,7 +69,7 @@ class LocalThingsSelect(LocalThingsEntity, SelectEntity):
|
||||
super().__init__(coordinator, bound)
|
||||
desc: SelectDesc = bound.desc
|
||||
if not desc.options_field and not callable(desc.options):
|
||||
self._attr_options = [_display(o, desc) for o in desc.options]
|
||||
self._attr_options = [_display(o, self.translation_key) for o in desc.options]
|
||||
|
||||
def _raw_options(self) -> list[str]:
|
||||
desc: SelectDesc = self._bound.desc
|
||||
@@ -83,17 +89,17 @@ class LocalThingsSelect(LocalThingsEntity, SelectEntity):
|
||||
def options(self) -> list[str]:
|
||||
desc: SelectDesc = self._bound.desc
|
||||
if desc.options_field or callable(desc.options):
|
||||
return [_display(o, desc) for o in self._raw_options()]
|
||||
return [_display(o, self.translation_key) for o in self._raw_options()]
|
||||
return self._attr_options
|
||||
|
||||
@property
|
||||
def current_option(self):
|
||||
raw = (self.coordinator.data or {}).get(self._state_key)
|
||||
return _display(raw, self._bound.desc)
|
||||
return _display(raw, self.translation_key)
|
||||
|
||||
async def async_select_option(self, option: str) -> None:
|
||||
desc: SelectDesc = self._bound.desc
|
||||
raw = next(
|
||||
(o for o in self._raw_options() if _display(o, desc) == option), option
|
||||
(o for o in self._raw_options() if _display(o, self.translation_key) == option),
|
||||
option,
|
||||
)
|
||||
await self.coordinator.async_send_command(self._bound, raw)
|
||||
|
||||
@@ -43,6 +43,13 @@ class LocalThingsSensor(LocalThingsEntity, SensorEntity):
|
||||
if desc.options:
|
||||
self._attr_options = list(desc.options)
|
||||
|
||||
@property
|
||||
def native_unit_of_measurement(self):
|
||||
desc: SensorDesc = self._bound.desc
|
||||
if desc.unit_fn is not None:
|
||||
return desc.unit_fn(self.coordinator.resource(self._bound.href))
|
||||
return self._attr_native_unit_of_measurement
|
||||
|
||||
@property
|
||||
def native_value(self):
|
||||
return (self.coordinator.data or {}).get(self._state_key)
|
||||
|
||||
@@ -16,8 +16,15 @@
|
||||
"100": "High"
|
||||
}
|
||||
},
|
||||
"range_hood_lamp_brightness": {
|
||||
"state": {
|
||||
"1": "Level 1",
|
||||
"2": "Level 2"
|
||||
}
|
||||
},
|
||||
"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 +36,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": {
|
||||
@@ -52,7 +71,7 @@
|
||||
"8f": "Baby Care"
|
||||
}
|
||||
},
|
||||
"washer_cycle": {
|
||||
"washer_cycle_table_02": {
|
||||
"state": {
|
||||
"1c": "Eco 40-60",
|
||||
"1d": "Super Speed",
|
||||
@@ -76,7 +95,64 @@
|
||||
"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_table_03": {
|
||||
"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": {
|
||||
"state": {
|
||||
"00": "None",
|
||||
"01": "Low",
|
||||
"02": "Medium",
|
||||
"03": "High"
|
||||
}
|
||||
},
|
||||
"washer_detergent_water_hardness": {
|
||||
"state": {
|
||||
"01": "Soft",
|
||||
"02": "Medium",
|
||||
"03": "Hard"
|
||||
}
|
||||
},
|
||||
"washer_softener_concentration": {
|
||||
"state": {
|
||||
"01": "1x",
|
||||
"02": "2x",
|
||||
"03": "3x"
|
||||
}
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -92,6 +168,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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -125,6 +246,53 @@
|
||||
"already_configured": "This device is already configured."
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
"title": "Local Things Options",
|
||||
"menu_options": {
|
||||
"settings": "Remote control write settings",
|
||||
"debug_write": "Debug: write to a resource"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"title": "Remote control write settings",
|
||||
"description": "Some devices accept certain writes (e.g. default detergent/softener dosing on a washer) even while reporting remote control off. By default, LocalThings blocks every write with a clear error whenever a device reports remote control off, rather than letting the device silently reject it. Only enable this if you've confirmed writes actually work on this device with remote control off -- otherwise you'll trade that clear error for a silent failure.",
|
||||
"data": {
|
||||
"bypass_remote_control_lock": "Allow writes even when remote control is reported off"
|
||||
}
|
||||
},
|
||||
"debug_write": {
|
||||
"title": "Debug: write to a resource",
|
||||
"description": "Power-user tool for pinning down device-specific write behavior. Pick the resource (href) you want to write to, or type a custom one that isn't listed. This bypasses the remote-control-off block and sends exactly the fields you provide -- it can misconfigure your appliance, so use it deliberately.",
|
||||
"data": {
|
||||
"href": "Resource href"
|
||||
}
|
||||
},
|
||||
"debug_edit": {
|
||||
"title": "Debug write: {href}",
|
||||
"description": "Current value of `{href}`:\n```\n{current_value}\n```\nEnter ONLY the field(s) you want to change below. What you enter is sent to the device as-is (a partial update); it is NOT merged with the fields shown above, so don't paste the whole value back. Mind the types: a numeric string like \"1\" must stay quoted -- a bare 1 is sent as an integer, which some devices reject.",
|
||||
"data": {
|
||||
"payload": "Payload to write"
|
||||
}
|
||||
},
|
||||
"debug_result": {
|
||||
"title": "Debug write result",
|
||||
"description": "The device returned CoAP code {code}. A 2.xx class means the write was accepted; 4.xx/5.xx means it was rejected. The resource now reads:\n```\n{new_value}\n```\nIf the value is unchanged, the device likely dropped the write. Pick what to do next:",
|
||||
"menu_options": {
|
||||
"debug_write": "Write another resource",
|
||||
"finish": "Finish"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"empty_payload": "Enter at least one field to write.",
|
||||
"write_failed": "The write failed. Check the Home Assistant logs for details."
|
||||
},
|
||||
"abort": {
|
||||
"not_loaded": "This device isn't connected yet. Try again once it has loaded."
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"device_gap": {
|
||||
"title": "Incomplete capability coverage for {device_name}",
|
||||
|
||||
@@ -16,8 +16,15 @@
|
||||
"100": "High"
|
||||
}
|
||||
},
|
||||
"range_hood_lamp_brightness": {
|
||||
"state": {
|
||||
"1": "Level 1",
|
||||
"2": "Level 2"
|
||||
}
|
||||
},
|
||||
"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 +36,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": {
|
||||
@@ -52,7 +71,7 @@
|
||||
"8f": "Baby Care"
|
||||
}
|
||||
},
|
||||
"washer_cycle": {
|
||||
"washer_cycle_table_02": {
|
||||
"state": {
|
||||
"1c": "Eco 40-60",
|
||||
"1d": "Super Speed",
|
||||
@@ -76,7 +95,64 @@
|
||||
"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_table_03": {
|
||||
"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": {
|
||||
"state": {
|
||||
"00": "None",
|
||||
"01": "Low",
|
||||
"02": "Medium",
|
||||
"03": "High"
|
||||
}
|
||||
},
|
||||
"washer_detergent_water_hardness": {
|
||||
"state": {
|
||||
"01": "Soft",
|
||||
"02": "Medium",
|
||||
"03": "Hard"
|
||||
}
|
||||
},
|
||||
"washer_softener_concentration": {
|
||||
"state": {
|
||||
"01": "1x",
|
||||
"02": "2x",
|
||||
"03": "3x"
|
||||
}
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -92,6 +168,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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -125,6 +246,53 @@
|
||||
"already_configured": "This device is already configured."
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
"title": "Local Things Options",
|
||||
"menu_options": {
|
||||
"settings": "Remote control write settings",
|
||||
"debug_write": "Debug: write to a resource"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"title": "Remote control write settings",
|
||||
"description": "Some devices accept certain writes (e.g. default detergent/softener dosing on a washer) even while reporting remote control off. By default, LocalThings blocks every write with a clear error whenever a device reports remote control off, rather than letting the device silently reject it. Only enable this if you've confirmed writes actually work on this device with remote control off -- otherwise you'll trade that clear error for a silent failure.",
|
||||
"data": {
|
||||
"bypass_remote_control_lock": "Allow writes even when remote control is reported off"
|
||||
}
|
||||
},
|
||||
"debug_write": {
|
||||
"title": "Debug: write to a resource",
|
||||
"description": "Power-user tool for pinning down device-specific write behavior. Pick the resource (href) you want to write to, or type a custom one that isn't listed. This bypasses the remote-control-off block and sends exactly the fields you provide -- it can misconfigure your appliance, so use it deliberately.",
|
||||
"data": {
|
||||
"href": "Resource href"
|
||||
}
|
||||
},
|
||||
"debug_edit": {
|
||||
"title": "Debug write: {href}",
|
||||
"description": "Current value of `{href}`:\n```\n{current_value}\n```\nEnter ONLY the field(s) you want to change below. What you enter is sent to the device as-is (a partial update); it is NOT merged with the fields shown above, so don't paste the whole value back. Mind the types: a numeric string like \"1\" must stay quoted -- a bare 1 is sent as an integer, which some devices reject.",
|
||||
"data": {
|
||||
"payload": "Payload to write"
|
||||
}
|
||||
},
|
||||
"debug_result": {
|
||||
"title": "Debug write result",
|
||||
"description": "The device returned CoAP code {code}. A 2.xx class means the write was accepted; 4.xx/5.xx means it was rejected. The resource now reads:\n```\n{new_value}\n```\nIf the value is unchanged, the device likely dropped the write. Pick what to do next:",
|
||||
"menu_options": {
|
||||
"debug_write": "Write another resource",
|
||||
"finish": "Finish"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"empty_payload": "Enter at least one field to write.",
|
||||
"write_failed": "The write failed. Check the Home Assistant logs for details."
|
||||
},
|
||||
"abort": {
|
||||
"not_loaded": "This device isn't connected yet. Try again once it has loaded."
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"device_gap": {
|
||||
"title": "Incomplete capability coverage for {device_name}",
|
||||
|
||||
+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
|
||||
|
||||
+200
@@ -0,0 +1,200 @@
|
||||
{
|
||||
"device0": [
|
||||
{
|
||||
"rt": [
|
||||
"x.com.samsung.devcol"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.ll",
|
||||
"oic.if.b"
|
||||
]
|
||||
},
|
||||
{
|
||||
"href": "/airflow/0",
|
||||
"rep": {
|
||||
"speed": 0,
|
||||
"direction": "Off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/airflow/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.speedLevel": "0",
|
||||
"x.com.samsung.da.direction": "Off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"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-23T15:56:55",
|
||||
"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",
|
||||
"x.com.samsung.da.triggeredTime": "2026-07-23T15:56:55",
|
||||
"x.com.samsung.da.state": "Created"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/configuration/vs/0",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/consumable/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.state": "100",
|
||||
"x.com.samsung.da.name": "FilterProgress"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/devicespecificinfo/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.deviceActive": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/diagnosis/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.diagnosisStart": "Ready"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/energy/consumption/0",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/energy/consumption/vs/0",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/humidity/0",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/humidity/vs/0",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/information/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.modelNum": "ARTIK051_TVTL_18K|10193941|7000023C001111C40100000000000000",
|
||||
"x.com.samsung.da.description": "ARTIK051_TVTL_18K",
|
||||
"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": "02059A230513",
|
||||
"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": "19071703,19071706",
|
||||
"x.com.samsung.da.newVersionAvailable": "0"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/mode/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.options": [
|
||||
"Comode_Off",
|
||||
"Blooming_0",
|
||||
"Light_On",
|
||||
"OptionCode_60282"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"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": "true",
|
||||
"x.com.samsung.da.periodicnotisubscription": "false"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/sensors/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.id": "0",
|
||||
"x.com.samsung.da.description": "Sensor for Dust",
|
||||
"x.com.samsung.da.type": "Dust",
|
||||
"x.com.samsung.da.value": [
|
||||
"11",
|
||||
"0"
|
||||
]
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.id": "1",
|
||||
"x.com.samsung.da.description": "Sensor for FineDust",
|
||||
"x.com.samsung.da.type": "FineDust",
|
||||
"x.com.samsung.da.value": [
|
||||
"9",
|
||||
"0"
|
||||
]
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.id": "2",
|
||||
"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": "3",
|
||||
"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": "4",
|
||||
"x.com.samsung.da.description": "Sensor for SuperFineDust",
|
||||
"x.com.samsung.da.type": "SuperFineDust",
|
||||
"x.com.samsung.da.value": [
|
||||
"5",
|
||||
"0"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
+486
@@ -0,0 +1,486 @@
|
||||
{
|
||||
"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",
|
||||
"x.com.samsung.da.sleepMode": "Off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"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-23T05:13:21",
|
||||
"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-23T05:13:21",
|
||||
"x.com.samsung.da.state": "Deleted"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/availablecontrolsets/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.sets": "000100B4012C0000120000000000",
|
||||
"x.com.samsung.da.id": "CAC",
|
||||
"x.com.samsung.da.version": "1.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/configuration/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.region": "4131000000",
|
||||
"x.com.samsung.da.airconOptionList": [
|
||||
"DR",
|
||||
"SingleCommand_1.0",
|
||||
"AI_2.0"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/diagnosis/vs/0",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/drlc/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.drlcLevel": "0",
|
||||
"x.com.samsung.da.durationminutes": "0",
|
||||
"x.com.samsung.da.start": "1970-01-01T00:00:00Z",
|
||||
"x.com.samsung.da.override": "Off",
|
||||
"x.com.samsung.da.realSaving": "Off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/energy/consumption/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.instantaneousPower": "153.000000",
|
||||
"x.com.samsung.da.cumulativePower": "239209",
|
||||
"x.com.samsung.da.cumulativeSavedPower": "7201",
|
||||
"x.com.samsung.da.cumulativeUnit": "Wh",
|
||||
"x.com.samsung.da.instantaneousPowerUnit": "W",
|
||||
"x.com.samsung.da.cumulativePowerType": "total",
|
||||
"x.com.samsung.da.saveLocation": "/mnt/usage.db"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/file/information/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.timeoffset": "+09:00",
|
||||
"x.com.samsung.supprtedtype": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/filter/airdustfilter/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.filterUsage": "85",
|
||||
"x.com.samsung.da.filterUsageResolution": "1",
|
||||
"x.com.samsung.da.filterStatus": "normal",
|
||||
"x.com.samsung.da.filterCapacity": "1000",
|
||||
"x.com.samsung.da.filterCapacityUnit": "Hour",
|
||||
"x.com.samsung.da.filterResetType": [
|
||||
"replaceable",
|
||||
"washable"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/humidity/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.humidity": "0.000000",
|
||||
"x.com.samsung.da.fivepercentHumidity": "83"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/information/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.modelNum": "A-CAWW-TP2-20-COMMON|10241941|600301170016110B40000F2000F2AD00",
|
||||
"x.com.samsung.da.description": "A-CAWW-TP2-20-COMMON",
|
||||
"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": "023",
|
||||
"x.com.samsung.da.diagMinVersion": "1.0",
|
||||
"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": "02274A260512",
|
||||
"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": "02419A25051901,FFFFFFFFFFFFFF",
|
||||
"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": "FFFFFFFFFFFFFF,FFFFFFFFFFFFFF"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"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",
|
||||
"Nano",
|
||||
"LongWind",
|
||||
"Speed",
|
||||
"Sleep",
|
||||
"NanoSleep"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/mode/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.workingMode": "Auto",
|
||||
"x.com.samsung.da.supportedModes": [
|
||||
"Auto",
|
||||
"Cool",
|
||||
"Dry",
|
||||
"Fan",
|
||||
"AIComfort"
|
||||
],
|
||||
"x.com.samsung.da.modes": [
|
||||
"Auto"
|
||||
],
|
||||
"x.com.samsung.da.options": [
|
||||
"Sleep_0",
|
||||
"ArtificialWorking_Off",
|
||||
"ComfortAICooling_Off",
|
||||
"AiTempChanged_Off",
|
||||
"AiTemp_240",
|
||||
"OutdoorTemp_82",
|
||||
"Light_Off",
|
||||
"Volume_100",
|
||||
"KeyInputPermit_On",
|
||||
"ModePermit_NoLimit",
|
||||
"UpdateAllow_NotAllowed",
|
||||
"DurationOn_419",
|
||||
"WelcomeCoolingState_Off"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/option/autoclean/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.status": "Stop",
|
||||
"x.com.samsung.da.settingStatus": "On",
|
||||
"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": "A-CAWW-TP2-20-COMMON",
|
||||
"versions": [
|
||||
"14260512"
|
||||
],
|
||||
"visVersion": "260512"
|
||||
},
|
||||
{
|
||||
"type": "Micom",
|
||||
"modelId": "040010241941FFFFFFFF",
|
||||
"versions": [
|
||||
"25051901",
|
||||
"FFFFFFFF"
|
||||
],
|
||||
"visVersion": "250519"
|
||||
},
|
||||
{
|
||||
"type": "Micom",
|
||||
"modelId": "0400FFFFFFFFFFFFFFFF",
|
||||
"versions": [
|
||||
"FFFFFFFF",
|
||||
"FFFFFFFF"
|
||||
],
|
||||
"visVersion": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"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": "true",
|
||||
"x.com.samsung.da.periodicnotisubscription": "true"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/reserverulesets/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.sets": "1F121E121E121E121EFFFFFFFF010001000F0001000E0000001C",
|
||||
"x.com.samsung.da.id": "CAC",
|
||||
"x.com.samsung.da.version": "1.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/sac/installationinfo/vs/0",
|
||||
"rep": {
|
||||
"installationOptions": [
|
||||
{
|
||||
"id": "0",
|
||||
"option": "17444980C80171700010"
|
||||
},
|
||||
{
|
||||
"id": "1",
|
||||
"option": "28010000000000000100"
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
"option": "50000000000000000000"
|
||||
},
|
||||
{
|
||||
"id": "3",
|
||||
"option": "30000000000000000000"
|
||||
}
|
||||
],
|
||||
"installationDeviceNum": [
|
||||
{
|
||||
"id": "0",
|
||||
"number": "01"
|
||||
},
|
||||
{
|
||||
"id": "1",
|
||||
"number": "04"
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
"number": "00"
|
||||
}
|
||||
],
|
||||
"outdoorInfo": [
|
||||
{
|
||||
"id": "0",
|
||||
"info": "01900A230418",
|
||||
"serial": "**REDACTED**"
|
||||
},
|
||||
{
|
||||
"id": "1",
|
||||
"info": "000000000000",
|
||||
"serial": "**REDACTED**"
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
"info": "000000000000",
|
||||
"serial": "**REDACTED**"
|
||||
},
|
||||
{
|
||||
"id": "3",
|
||||
"info": "000000000000",
|
||||
"serial": "**REDACTED**"
|
||||
}
|
||||
],
|
||||
"mcuInfo": [
|
||||
{
|
||||
"id": "0",
|
||||
"info": ""
|
||||
},
|
||||
{
|
||||
"id": "1",
|
||||
"info": ""
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
"info": ""
|
||||
},
|
||||
{
|
||||
"id": "3",
|
||||
"info": ""
|
||||
},
|
||||
{
|
||||
"id": "4",
|
||||
"info": ""
|
||||
},
|
||||
{
|
||||
"id": "5",
|
||||
"info": ""
|
||||
},
|
||||
{
|
||||
"id": "6",
|
||||
"info": ""
|
||||
},
|
||||
{
|
||||
"id": "7",
|
||||
"info": ""
|
||||
},
|
||||
{
|
||||
"id": "8",
|
||||
"info": ""
|
||||
},
|
||||
{
|
||||
"id": "9",
|
||||
"info": ""
|
||||
},
|
||||
{
|
||||
"id": "10",
|
||||
"info": ""
|
||||
},
|
||||
{
|
||||
"id": "11",
|
||||
"info": ""
|
||||
},
|
||||
{
|
||||
"id": "12",
|
||||
"info": ""
|
||||
},
|
||||
{
|
||||
"id": "13",
|
||||
"info": ""
|
||||
},
|
||||
{
|
||||
"id": "14",
|
||||
"info": ""
|
||||
},
|
||||
{
|
||||
"id": "15",
|
||||
"info": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/temperature/control/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.increment": "0.5"
|
||||
}
|
||||
},
|
||||
{
|
||||
"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": "27.0",
|
||||
"x.com.samsung.da.current": "29.0",
|
||||
"x.com.samsung.da.maximum": "30",
|
||||
"x.com.samsung.da.minimum": "18",
|
||||
"x.com.samsung.da.increment": "0.5",
|
||||
"x.com.samsung.da.unit": "Celsius"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/timezone/vs/0",
|
||||
"rep": {
|
||||
"timezoneid": "Asia/Seoul",
|
||||
"offset": "+09:00",
|
||||
"DST": "OFF"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/wind/direction/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.modes": "Fix",
|
||||
"x.com.samsung.da.supportedModes": [
|
||||
"Up_And_Low",
|
||||
"Fix",
|
||||
"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"
|
||||
],
|
||||
"x.com.samsung.da.modesName": [
|
||||
"Auto",
|
||||
"Low",
|
||||
"Mid",
|
||||
"High"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
+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
+73
@@ -0,0 +1,73 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/power/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.power": "On"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/mode/vs/0",
|
||||
"rep": {
|
||||
"rt": [
|
||||
"x.com.samsung.da.mode"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.a"
|
||||
],
|
||||
"x.com.samsung.da.options": [
|
||||
"DeviceType_NA9300K-/AA1",
|
||||
"MainTimerCurrent_0",
|
||||
"MainTimerSet_0",
|
||||
"MainTimerState_Ready",
|
||||
"OperationState0_Ready",
|
||||
"OperationState1_Ready",
|
||||
"OperationState3_Ready",
|
||||
"OperationState4_Ready",
|
||||
"OperationState5_Ready"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/connected/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.connected": "On"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/configuration/vs/0",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/bluetooth/hood/status/vs/0",
|
||||
"rep": {
|
||||
"connectionState": "disconnected",
|
||||
"micomModelId": "",
|
||||
"firmwareVersion": "",
|
||||
"power": "off",
|
||||
"fanSpeed": 0,
|
||||
"lampState": "off",
|
||||
"timer": {}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
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": {}
|
||||
}
|
||||
]
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"state_keys": [
|
||||
"alarm_code",
|
||||
"clean_level",
|
||||
"device_active",
|
||||
"diagnosis_status",
|
||||
"display_light",
|
||||
"dust",
|
||||
"energy_kwh",
|
||||
"energy_last_month_kwh",
|
||||
"energy_saved_kwh",
|
||||
"energy_this_month_kwh",
|
||||
"fan_direction",
|
||||
"fan_speed_level",
|
||||
"filter_progress",
|
||||
"fine_dust",
|
||||
"odor",
|
||||
"operating_mode",
|
||||
"power_energy_kwh",
|
||||
"power_switch",
|
||||
"power_watts",
|
||||
"super_fine_dust"
|
||||
]
|
||||
}
|
||||
+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,15 @@
|
||||
{
|
||||
"state_keys": [
|
||||
"air_filter_status",
|
||||
"air_filter_usage",
|
||||
"alarm_code",
|
||||
"auto_clean",
|
||||
"climate",
|
||||
"diagnosis_status",
|
||||
"energy_kwh",
|
||||
"energy_saved_kwh",
|
||||
"firmware_update",
|
||||
"mute_once",
|
||||
"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"
|
||||
]
|
||||
}
|
||||
Vendored
+21
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"state_keys": [
|
||||
"any_burner_active",
|
||||
"burner_0_state",
|
||||
"burner_1_state",
|
||||
"burner_3_state",
|
||||
"burner_4_state",
|
||||
"burner_5_state",
|
||||
"cloud_connected",
|
||||
"firmware_update",
|
||||
"main_timer_current",
|
||||
"main_timer_state",
|
||||
"paired_hood_connected",
|
||||
"paired_hood_fan_speed",
|
||||
"paired_hood_firmware",
|
||||
"paired_hood_light",
|
||||
"paired_hood_model",
|
||||
"paired_hood_power",
|
||||
"power_state"
|
||||
]
|
||||
}
|
||||
+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
+25
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"state_keys": [
|
||||
"alarm_code",
|
||||
"child_lock",
|
||||
"cloud_connected",
|
||||
"cook_time",
|
||||
"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"
|
||||
]
|
||||
}
|
||||
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"
|
||||
]
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"state_keys": [
|
||||
"air_sensing_state",
|
||||
"alarm_code",
|
||||
"auto_ventilation_action",
|
||||
"automatic_operation",
|
||||
"automatic_ventilation_state",
|
||||
"clean_level",
|
||||
"energy_kwh",
|
||||
"energy_saved_kwh",
|
||||
"fan",
|
||||
"firmware_update",
|
||||
"hood_filter_capacity",
|
||||
"hood_filter_status",
|
||||
"hood_filter_usage",
|
||||
"lamp",
|
||||
"lamp_brightness",
|
||||
"last_air_sensing_level",
|
||||
"last_air_sensing_time",
|
||||
"periodic_air_sensing",
|
||||
"dust",
|
||||
"fine_dust",
|
||||
"super_fine_dust"
|
||||
]
|
||||
}
|
||||
+14
-2
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"state_keys": [
|
||||
"ai_energy_level",
|
||||
"alarm_code",
|
||||
"auto_door_opener",
|
||||
"autofill",
|
||||
@@ -10,12 +11,15 @@
|
||||
"cooler_setpoint",
|
||||
"cooler_temperature",
|
||||
"day_brightness",
|
||||
"defrost_blocked",
|
||||
"defrost_active",
|
||||
"defrost_delay",
|
||||
"door_alert",
|
||||
"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",
|
||||
@@ -50,12 +57,15 @@
|
||||
"samsung_refrigerator_cooler_setpoint",
|
||||
"samsung_refrigerator_cooler_temperature",
|
||||
"samsung_refrigerator_day_brightness",
|
||||
"samsung_refrigerator_defrost_blocked",
|
||||
"samsung_refrigerator_defrost_active",
|
||||
"samsung_refrigerator_defrost_delay",
|
||||
"samsung_refrigerator_door_alert",
|
||||
"samsung_refrigerator_door_cooler_open",
|
||||
"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
+9
-1
@@ -1,23 +1,31 @@
|
||||
{
|
||||
"state_keys": [
|
||||
"ai_energy_level",
|
||||
"alarm_code",
|
||||
"bubble_soak",
|
||||
"buzzer_sound",
|
||||
"child_lock",
|
||||
"cycle",
|
||||
"cycle_active",
|
||||
"delay_start_hours",
|
||||
"detergent_low",
|
||||
"diagnosis_status",
|
||||
"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",
|
||||
"power_watts",
|
||||
"pre_wash",
|
||||
"progress",
|
||||
"progress_percentage",
|
||||
"remote_control",
|
||||
"rinse_cycles",
|
||||
"softener_low",
|
||||
"spin_speed",
|
||||
"wash_temperature",
|
||||
"water_liters"
|
||||
|
||||
+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"
|
||||
]
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"state_keys": [
|
||||
"alarm_code",
|
||||
"child_lock",
|
||||
"cycle",
|
||||
"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
+289
@@ -0,0 +1,289 @@
|
||||
{
|
||||
"meta": {
|
||||
"model": "TP1X_DA-KS-OVEN-0107X (NV7000BS/ET5)",
|
||||
"device_type": "oven",
|
||||
"source": "issue #55 diagnostics (scrubbed)",
|
||||
"note": "Wall oven reports no oneUiVersion; resolved via the '-OVEN-' modelNum token fallback in for_device_by_model, mirroring the '-RANGE-' fallback added for issue #44."
|
||||
},
|
||||
"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-22-OVEN-1",
|
||||
"versions": [
|
||||
"40250221"
|
||||
],
|
||||
"visVersion": "250221"
|
||||
},
|
||||
{
|
||||
"type": "Micom",
|
||||
"modelId": "07134046004140472641",
|
||||
"versions": [
|
||||
"23011700",
|
||||
"23122000"
|
||||
],
|
||||
"visVersion": "231220"
|
||||
},
|
||||
{
|
||||
"type": "Micom",
|
||||
"modelId": "071360127441FFFFFFFF",
|
||||
"versions": [
|
||||
"21123000",
|
||||
"FFFFFFFF"
|
||||
],
|
||||
"visVersion": "211230"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/connected/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.connected": "On"
|
||||
}
|
||||
},
|
||||
{
|
||||
"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"
|
||||
}
|
||||
],
|
||||
"rt": [
|
||||
"x.com.samsung.da.doors"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.s"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/information/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.modelNum": "TP1X_DA-KS-OVEN-0107X|40460041|50030018001611020A00000000000000",
|
||||
"x.com.samsung.da.description": "NV7000BS/ET5",
|
||||
"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": "25022100",
|
||||
"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": "04600A23011700, 04726A23122000",
|
||||
"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": "01274A21123000",
|
||||
"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": "KV1",
|
||||
"x.com.samsung.da.diagMinVersion": "3.0",
|
||||
"x.com.samsung.da.diagTsId": "DA01"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/mode/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.supportedModes": [
|
||||
"Autocook",
|
||||
"Convection",
|
||||
"TopHeatPluseConvection",
|
||||
"Conventional",
|
||||
"LargeGrill",
|
||||
"SmallGrill",
|
||||
"BottomHeatPluseConvection",
|
||||
"PlateWarm",
|
||||
"KeepWarm",
|
||||
"Bottom",
|
||||
"EcoConvection",
|
||||
"FanGrill",
|
||||
"Defrost",
|
||||
"SteamClean"
|
||||
],
|
||||
"x.com.samsung.da.modes": [
|
||||
"NoOperation"
|
||||
],
|
||||
"x.com.samsung.da.options": [
|
||||
"DeviceType_NV7000BS/ET5",
|
||||
"UpperTimerCurrent_0",
|
||||
"UpperTimerSet_0",
|
||||
"UpperTimerState_Ready",
|
||||
"UpperLamp_Off",
|
||||
"Sound_On"
|
||||
],
|
||||
"x.com.samsung.da.defaultMode": "Convection",
|
||||
"rt": [
|
||||
"x.com.samsung.da.mode"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.a"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/operational/state/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.state": "Ready",
|
||||
"x.com.samsung.da.operationTime": "00:00:00",
|
||||
"x.com.samsung.da.remainingTime": "00:00:00",
|
||||
"x.com.samsung.da.progressPercentage": "0",
|
||||
"rt": [
|
||||
"x.com.samsung.da.operation"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.a"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"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-23T15:26:07"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/oven/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.state": "Ready",
|
||||
"x.com.samsung.da.recipe": "00000000000000",
|
||||
"rt": [
|
||||
"x.com.samsung.da.oven"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.s"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"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": "0",
|
||||
"x.com.samsung.da.current": "0",
|
||||
"x.com.samsung.da.increment": "5",
|
||||
"x.com.samsung.da.unit": "Celsius"
|
||||
}
|
||||
],
|
||||
"rt": [
|
||||
"x.com.samsung.da.temperatures"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.a"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/timezone/vs/0",
|
||||
"rep": {
|
||||
"timezoneid": "Europe/Rome",
|
||||
"offset": "+02:00",
|
||||
"DST": "ON"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/configuration/vs/0",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"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": "true",
|
||||
"rt": [
|
||||
"x.com.samsung.da.configuration"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.s"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/wirelessinfo/vs/0",
|
||||
"rep": {
|
||||
"macaddressWiFi": "**REDACTED**",
|
||||
"macaddressBLE": "**REDACTED**"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/quickcontrol/info/vs/0",
|
||||
"rep": {
|
||||
"supportedVersion": "1.0"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
+199
@@ -0,0 +1,199 @@
|
||||
{
|
||||
"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": "TEST-DEVICE-ID",
|
||||
"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": "/airlevelcheck/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.periodicSensingActivationState": "Off",
|
||||
"x.com.samsung.da.periodicSensingInterval": "3600",
|
||||
"x.com.samsung.da.startSensingOnce": "Off",
|
||||
"x.com.samsung.da.sensingState": "NonProcessing",
|
||||
"x.com.samsung.da.lastSensingTime": "1784651378",
|
||||
"x.com.samsung.da.lastSensingLevel": "Kr2",
|
||||
"x.com.samsung.da.autoExeState": "Sensing",
|
||||
"x.com.samsung.da.supportedAutoExeState": ["Airpurify", "Alarm"],
|
||||
"x.com.samsung.da.periodicSensingSkipStatus": "Off",
|
||||
"x.com.samsung.da.periodicSensingSkipTime": "00000000"
|
||||
}
|
||||
},
|
||||
{
|
||||
"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-21T20:18:51",
|
||||
"x.com.samsung.da.state": "Deleted"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/energy/consumption/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.cumulativePower": "165620",
|
||||
"x.com.samsung.da.cumulativeSavedPower": "0",
|
||||
"x.com.samsung.da.cumulativeDate": "1784647130",
|
||||
"x.com.samsung.da.cumulativeDateUTC": "1784665130",
|
||||
"x.com.samsung.da.cumulativeUnit": "Wh"
|
||||
}
|
||||
},
|
||||
{"href": "/energy/consumption/0", "rep": {}},
|
||||
{
|
||||
"href": "/mode/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.options": ["OptionCode_19322", "UpdateAllow_NotAllowed"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"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": ["2"]
|
||||
},
|
||||
{
|
||||
"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": ["18", "1"]
|
||||
},
|
||||
{
|
||||
"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": ["14", "1"]
|
||||
},
|
||||
{
|
||||
"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": ["9", "1"]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/information/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.modelNum": "AHD-WW-TP1-22-COMMON|20136141|7800006B001713C44D00090001030000",
|
||||
"x.com.samsung.da.description": "AHD-WW-TP1-22-COMMON",
|
||||
"x.com.samsung.da.serialNum": "TEST-HOOD-SERIAL",
|
||||
"x.com.samsung.da.diagProtocolType": "WIFI_HTTPS",
|
||||
"x.com.samsung.da.diagSetupid": "KH0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/file/information/vs/0",
|
||||
"rep": {"x.com.samsung.timeoffset": "-05:00"}
|
||||
},
|
||||
{
|
||||
"href": "/configuration/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.region": "0000000000",
|
||||
"x.com.samsung.da.airconOptionList": ["DR"],
|
||||
"x.com.samsung.da.countryCode": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/drlc/0",
|
||||
"rep": {
|
||||
"DRLevel": 0,
|
||||
"start": "1970-01-01T00:00:00Z",
|
||||
"durationminutes": 0,
|
||||
"override": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/drlc/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.drlcLevel": "0",
|
||||
"x.com.samsung.da.durationminutes": "0",
|
||||
"x.com.samsung.da.start": "1970-01-01T00:00:00Z",
|
||||
"x.com.samsung.da.override": "Off",
|
||||
"x.com.samsung.da.realSaving": "Off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/availablecontrolsets/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.sets": "0000000000012000000000000000",
|
||||
"x.com.samsung.da.id": "HOOD",
|
||||
"x.com.samsung.da.version": "1.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/da/softreset/vs/0",
|
||||
"rep": {"x.com.samsung.da.softwarereset": "false"}
|
||||
},
|
||||
{
|
||||
"href": "/filter/hoodfilter/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.filterUsage": "100",
|
||||
"x.com.samsung.da.filterUsageResolution": "1",
|
||||
"x.com.samsung.da.filterStatus": "wash",
|
||||
"x.com.samsung.da.filterCapacity": "60",
|
||||
"x.com.samsung.da.filterCapacityUnit": "Hour",
|
||||
"x.com.samsung.da.filterResetType": ["replaceable", "washable"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/hood/fanspeed/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.hood.autoOperation": "Off",
|
||||
"x.com.samsung.da.hood.fanSpeed": "14",
|
||||
"x.com.samsung.da.hood.supportedFanSpeed": ["14", "15", "16", "17"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/hood/lamp/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.lamp.power": "Off",
|
||||
"x.com.samsung.lamp.current": "2",
|
||||
"x.com.samsung.lamp.range": ["1", "2"]
|
||||
}
|
||||
},
|
||||
{"href": "/autoventilation/vs/0", "rep": {"action": "Start"}},
|
||||
{
|
||||
"href": "/otninformation/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.target": "",
|
||||
"x.com.samsung.da.newVersionAvailable": "false"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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):
|
||||
|
||||
@@ -9,7 +9,8 @@ from homeassistant.data_entry_flow import FlowResultType
|
||||
from pytest_homeassistant_custom_component.common import MockConfigEntry
|
||||
|
||||
from custom_components.localthings.const import (
|
||||
CONF_CA_CERT_PEM, CONF_CA_KEY_PEM, CONF_HOST, CONF_PORT, DOMAIN,
|
||||
CONF_BYPASS_REMOTE_CONTROL, CONF_CA_CERT_PEM, CONF_CA_KEY_PEM,
|
||||
CONF_HOST, CONF_PORT, DOMAIN,
|
||||
)
|
||||
|
||||
from .conftest import (
|
||||
@@ -56,6 +57,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
|
||||
@@ -169,3 +277,200 @@ def test_probe_marks_washer_as_recognized(monkeypatch):
|
||||
) is not None
|
||||
)
|
||||
assert recognized is True
|
||||
|
||||
|
||||
async def test_options_flow_init_shows_menu(hass: HomeAssistant) -> None:
|
||||
"""The options flow's entry point is now a menu (issue #54's debug
|
||||
panel lives alongside the remote-control settings), not the settings
|
||||
form directly."""
|
||||
entry = MockConfigEntry(domain=DOMAIN, data=ENTRY_DATA, unique_id=f'localthings_{MOCK_SERIAL}')
|
||||
entry.add_to_hass(hass)
|
||||
|
||||
result = await hass.config_entries.options.async_init(entry.entry_id)
|
||||
|
||||
assert result['type'] == FlowResultType.MENU
|
||||
assert result['step_id'] == 'init'
|
||||
assert set(result['menu_options']) == {'settings', 'debug_write'}
|
||||
|
||||
|
||||
async def test_options_flow_default_is_off(hass: HomeAssistant) -> None:
|
||||
"""The bypass defaults to False, so devices that never touch this
|
||||
option see no change in the remote-control write block."""
|
||||
entry = MockConfigEntry(domain=DOMAIN, data=ENTRY_DATA, unique_id=f'localthings_{MOCK_SERIAL}')
|
||||
entry.add_to_hass(hass)
|
||||
|
||||
result = await hass.config_entries.options.async_init(entry.entry_id)
|
||||
result = await hass.config_entries.options.async_configure(
|
||||
result['flow_id'], user_input={'next_step_id': 'settings'}
|
||||
)
|
||||
|
||||
assert result['type'] == FlowResultType.FORM
|
||||
assert result['step_id'] == 'settings'
|
||||
assert result['data_schema']({})[CONF_BYPASS_REMOTE_CONTROL] is False
|
||||
|
||||
|
||||
async def test_options_flow_can_enable_bypass(hass: HomeAssistant) -> None:
|
||||
"""Submitting the form with the toggle on stores it in entry.options,
|
||||
where coordinator.async_send_command reads it (issue #54)."""
|
||||
entry = MockConfigEntry(domain=DOMAIN, data=ENTRY_DATA, unique_id=f'localthings_{MOCK_SERIAL}')
|
||||
entry.add_to_hass(hass)
|
||||
|
||||
result = await hass.config_entries.options.async_init(entry.entry_id)
|
||||
result = await hass.config_entries.options.async_configure(
|
||||
result['flow_id'], user_input={'next_step_id': 'settings'}
|
||||
)
|
||||
result = await hass.config_entries.options.async_configure(
|
||||
result['flow_id'], user_input={CONF_BYPASS_REMOTE_CONTROL: True}
|
||||
)
|
||||
|
||||
assert result['type'] == FlowResultType.CREATE_ENTRY
|
||||
assert entry.options[CONF_BYPASS_REMOTE_CONTROL] is True
|
||||
|
||||
|
||||
async def test_options_flow_reflects_previously_saved_value(hass: HomeAssistant) -> None:
|
||||
"""Reopening the form shows the currently-saved choice as the default,
|
||||
not always False."""
|
||||
entry = MockConfigEntry(
|
||||
domain=DOMAIN, data=ENTRY_DATA, unique_id=f'localthings_{MOCK_SERIAL}',
|
||||
options={CONF_BYPASS_REMOTE_CONTROL: True},
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
|
||||
result = await hass.config_entries.options.async_init(entry.entry_id)
|
||||
result = await hass.config_entries.options.async_configure(
|
||||
result['flow_id'], user_input={'next_step_id': 'settings'}
|
||||
)
|
||||
|
||||
assert result['data_schema']({})[CONF_BYPASS_REMOTE_CONTROL] is True
|
||||
|
||||
|
||||
async def test_options_flow_debug_write_shows_hrefs_from_coordinator(
|
||||
hass: HomeAssistant, mock_coordinator_session,
|
||||
) -> None:
|
||||
"""The debug panel's href dropdown is populated from the live
|
||||
coordinator's cached resources, not a static list."""
|
||||
entry = MockConfigEntry(domain=DOMAIN, data=ENTRY_DATA, unique_id=f'localthings_{MOCK_SERIAL}')
|
||||
entry.add_to_hass(hass)
|
||||
await hass.config_entries.async_setup(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
result = await hass.config_entries.options.async_init(entry.entry_id)
|
||||
result = await hass.config_entries.options.async_configure(
|
||||
result['flow_id'], user_input={'next_step_id': 'debug_write'}
|
||||
)
|
||||
|
||||
assert result['type'] == FlowResultType.FORM
|
||||
assert result['step_id'] == 'debug_write'
|
||||
|
||||
|
||||
async def test_options_flow_debug_write_aborts_when_device_not_loaded(
|
||||
hass: HomeAssistant,
|
||||
) -> None:
|
||||
"""No coordinator in hass.data (device never finished loading) means
|
||||
the debug panel has nothing to write to or read from."""
|
||||
entry = MockConfigEntry(domain=DOMAIN, data=ENTRY_DATA, unique_id=f'localthings_{MOCK_SERIAL}')
|
||||
entry.add_to_hass(hass)
|
||||
|
||||
result = await hass.config_entries.options.async_init(entry.entry_id)
|
||||
result = await hass.config_entries.options.async_configure(
|
||||
result['flow_id'], user_input={'next_step_id': 'debug_write'}
|
||||
)
|
||||
|
||||
assert result['type'] == FlowResultType.ABORT
|
||||
assert result['reason'] == 'not_loaded'
|
||||
|
||||
|
||||
async def test_options_flow_debug_edit_writes_and_shows_result(
|
||||
hass: HomeAssistant, mock_coordinator_session,
|
||||
) -> None:
|
||||
"""Picking an href, then submitting a payload, drives
|
||||
coordinator.async_raw_write and lands on the result menu with the
|
||||
device's response."""
|
||||
entry = MockConfigEntry(domain=DOMAIN, data=ENTRY_DATA, unique_id=f'localthings_{MOCK_SERIAL}')
|
||||
entry.add_to_hass(hass)
|
||||
await hass.config_entries.async_setup(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
result = await hass.config_entries.options.async_init(entry.entry_id)
|
||||
result = await hass.config_entries.options.async_configure(
|
||||
result['flow_id'], user_input={'next_step_id': 'debug_write'}
|
||||
)
|
||||
result = await hass.config_entries.options.async_configure(
|
||||
result['flow_id'], user_input={'href': '/washer/vs/0'},
|
||||
)
|
||||
assert result['type'] == FlowResultType.FORM
|
||||
assert result['step_id'] == 'debug_edit'
|
||||
|
||||
with patch(
|
||||
'custom_components.localthings.coordinator.LocalThingsCoordinator.async_raw_write',
|
||||
return_value=(0x44, {'a': 1}),
|
||||
):
|
||||
result = await hass.config_entries.options.async_configure(
|
||||
result['flow_id'], user_input={'payload': {'a': 1}},
|
||||
)
|
||||
|
||||
assert result['type'] == FlowResultType.MENU
|
||||
assert result['step_id'] == 'debug_result'
|
||||
assert result['description_placeholders']['code'] == '2.04 (0x44)'
|
||||
|
||||
|
||||
async def test_options_flow_debug_edit_rejects_empty_payload(
|
||||
hass: HomeAssistant, mock_coordinator_session,
|
||||
) -> None:
|
||||
entry = MockConfigEntry(domain=DOMAIN, data=ENTRY_DATA, unique_id=f'localthings_{MOCK_SERIAL}')
|
||||
entry.add_to_hass(hass)
|
||||
await hass.config_entries.async_setup(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
result = await hass.config_entries.options.async_init(entry.entry_id)
|
||||
result = await hass.config_entries.options.async_configure(
|
||||
result['flow_id'], user_input={'next_step_id': 'debug_write'}
|
||||
)
|
||||
result = await hass.config_entries.options.async_configure(
|
||||
result['flow_id'], user_input={'href': '/washer/vs/0'},
|
||||
)
|
||||
result = await hass.config_entries.options.async_configure(
|
||||
result['flow_id'], user_input={'payload': {}},
|
||||
)
|
||||
|
||||
assert result['type'] == FlowResultType.FORM
|
||||
assert result['step_id'] == 'debug_edit'
|
||||
assert result['errors'] == {'payload': 'empty_payload'}
|
||||
|
||||
|
||||
async def test_options_flow_finish_preserves_existing_options(
|
||||
hass: HomeAssistant, mock_coordinator_session,
|
||||
) -> None:
|
||||
"""Finishing from the debug-result menu must not clobber a previously
|
||||
saved remote-control bypass setting."""
|
||||
entry = MockConfigEntry(
|
||||
domain=DOMAIN, data=ENTRY_DATA, unique_id=f'localthings_{MOCK_SERIAL}',
|
||||
options={CONF_BYPASS_REMOTE_CONTROL: True},
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
await hass.config_entries.async_setup(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
result = await hass.config_entries.options.async_init(entry.entry_id)
|
||||
result = await hass.config_entries.options.async_configure(
|
||||
result['flow_id'], user_input={'next_step_id': 'debug_write'}
|
||||
)
|
||||
result = await hass.config_entries.options.async_configure(
|
||||
result['flow_id'], user_input={'href': '/washer/vs/0'},
|
||||
)
|
||||
with patch(
|
||||
'custom_components.localthings.coordinator.LocalThingsCoordinator.async_raw_write',
|
||||
return_value=(0x44, {'a': 1}),
|
||||
):
|
||||
result = await hass.config_entries.options.async_configure(
|
||||
result['flow_id'], user_input={'payload': {'a': 1}},
|
||||
)
|
||||
assert result['type'] == FlowResultType.MENU
|
||||
assert result['step_id'] == 'debug_result'
|
||||
|
||||
result = await hass.config_entries.options.async_configure(
|
||||
result['flow_id'], user_input={'next_step_id': 'finish'}
|
||||
)
|
||||
|
||||
assert result['type'] == FlowResultType.CREATE_ENTRY
|
||||
assert entry.options[CONF_BYPASS_REMOTE_CONTROL] is True
|
||||
|
||||
@@ -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,
|
||||
CONF_BYPASS_REMOTE_CONTROL, 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
|
||||
@@ -134,6 +136,31 @@ def test_run_discovery_detects_washer_via_model_fallback(
|
||||
assert coordinator.device_type_name == 'washer'
|
||||
|
||||
|
||||
def test_run_discovery_detects_cooktop_via_resource_signature(
|
||||
hass: HomeAssistant, mock_entry
|
||||
) -> None:
|
||||
"""NA9300K omits oneUiVersion and information but has burner options."""
|
||||
from tests.conftest import _load_device
|
||||
|
||||
coordinator = LocalThingsCoordinator(hass, mock_entry)
|
||||
coordinator._run_discovery(_load_device('cooktop'))
|
||||
|
||||
assert coordinator.device_type_name == 'cooktop'
|
||||
assert coordinator._unbound_hrefs == []
|
||||
|
||||
|
||||
def test_run_discovery_detects_range_hood_via_model(
|
||||
hass: HomeAssistant, mock_entry
|
||||
) -> None:
|
||||
from tests.conftest import _load_device
|
||||
|
||||
coordinator = LocalThingsCoordinator(hass, mock_entry)
|
||||
coordinator._run_discovery(_load_device('range_hood'))
|
||||
|
||||
assert coordinator.device_type_name == 'range_hood'
|
||||
assert coordinator._unbound_hrefs == []
|
||||
|
||||
|
||||
def test_update_coverage_gap_issue_creates_issue_for_unknown_type(
|
||||
hass: HomeAssistant, mock_entry
|
||||
) -> None:
|
||||
@@ -260,21 +287,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 +324,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 +341,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 +379,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 +418,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 +444,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 +479,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 +525,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 +552,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 +574,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 +614,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
|
||||
|
||||
@@ -676,7 +643,13 @@ async def test_write_marks_href_pending_before_post(
|
||||
hass: HomeAssistant, mock_entry, mock_coordinator_observe_session
|
||||
) -> None:
|
||||
"""async_send_command must call mark_write_pending before POSTing so a
|
||||
slow-to-settle device can't immediately revert the optimistic write."""
|
||||
slow-to-settle device can't immediately revert the optimistic write --
|
||||
and it must guard the write's actual target href (write_fn's own
|
||||
path_segs), not bound_entity.href. Those differ for a composite entity
|
||||
like the AC's ClimateDesc (bound to /mode/vs/0, but writing power/temp/
|
||||
fan/swing/preset to their own sibling hrefs) -- guarding the bound href
|
||||
protected the wrong resource and left the one the entity actually
|
||||
displays from unprotected (issues #17/#53)."""
|
||||
from custom_components.localthings.registry.discovery import BoundEntity
|
||||
from custom_components.localthings.registry.entities import NumberDesc
|
||||
|
||||
@@ -695,4 +668,355 @@ async def test_write_marks_href_pending_before_post(
|
||||
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
|
||||
assert coordinator._observe._settle_until.get('/some/path') is not None
|
||||
assert coordinator._observe._settle_until.get('/test/vs/0') is 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 at the write's actual
|
||||
target href (write_fn's path_segs) -- not bound_entity.href, which
|
||||
differs for a composite entity like the AC's ClimateDesc -- and stay put
|
||||
against a stale echo for the rest of the settle window there.
|
||||
|
||||
Applying the optimistic value to bound_entity.href instead is exactly
|
||||
how issues #17/#53 survived the original optimistic-apply fix (issue
|
||||
#27): the bound href got a harmless (if nonsensical) optimistic merge,
|
||||
while the resource the entity actually reads from for its displayed
|
||||
state never got one and stayed stale until the next real read of it."""
|
||||
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 at the write's real
|
||||
# target, not the bound href.
|
||||
assert coordinator._cache.get('/some/path') == {'value': 5}
|
||||
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('/some/path', {'value': 0}, source='observe')
|
||||
assert applied is False
|
||||
assert coordinator._cache.get('/some/path') == {'value': 5}
|
||||
|
||||
|
||||
async def test_climate_power_write_applies_to_its_own_href_not_bound_href(
|
||||
hass: HomeAssistant, mock_entry, mock_coordinator_observe_session
|
||||
) -> None:
|
||||
"""Regression for issues #17/#53, using the real AC capability. The
|
||||
composite climate entity binds /mode/vs/0 (airconditioner.CLIMATE.href),
|
||||
but a power command's write_fn (airconditioner._climate_write) targets
|
||||
the sibling /power/0 -- the href climate.py's `_is_on()` actually reads.
|
||||
The optimistic value and settle guard must land there, not on the bound
|
||||
/mode/vs/0, or the entity never sees the write and shows stale state
|
||||
until the next unrelated read of /power/0."""
|
||||
from custom_components.localthings.registry.capabilities import airconditioner
|
||||
from custom_components.localthings.registry.discovery import BoundEntity
|
||||
|
||||
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]
|
||||
|
||||
bound = BoundEntity(
|
||||
href=airconditioner.CLIMATE.href,
|
||||
capability=coordinator.bound[0].capability,
|
||||
desc=airconditioner.CLIMATE.entities[0],
|
||||
)
|
||||
|
||||
with patch.object(fake, 'subscribe'):
|
||||
fake.post = lambda *a, **k: (0x44, b'')
|
||||
await coordinator.async_send_command(bound, ('power', True))
|
||||
|
||||
assert coordinator._cache.get('/power/0') == {'value': True}
|
||||
assert coordinator._cache.get(airconditioner.CLIMATE.href) != {'value': True}
|
||||
assert coordinator._observe._settle_until.get('/power/0') is not None
|
||||
|
||||
|
||||
async def test_send_command_survives_stale_confirm_poll(
|
||||
hass: HomeAssistant, mock_entry, mock_coordinator_observe_session, fridge_resources,
|
||||
) -> None:
|
||||
"""The settle window must outlast the confirm poll async_send_command
|
||||
triggers via async_request_refresh(), not just DEFAULT_SETTLE_S's fixed
|
||||
few seconds (issue #9). Some devices settle a write internally slower
|
||||
than that refresh's own round trip -- issue #9's washer packs cycle/
|
||||
detergent/softener selection into /course/vs/0's shared options[]
|
||||
array, which visibly takes a few seconds of validation/dispenser
|
||||
movement to catch up, unlike /washer/vs/0's plain temperature/spin
|
||||
fields on the same device, which confirm instantly. A confirm poll that
|
||||
lands before the device has caught up is stale, and a settle window
|
||||
sized only to the fixed default (rather than the PUT+poll round trip)
|
||||
expires before that poll even returns, so the stale read lands
|
||||
unprotected and reverts the optimistic value -- self-correcting again
|
||||
only once a later poll finally sees the real change. That reads to a
|
||||
user as the write reverting, then reapplying itself, a few seconds
|
||||
later."""
|
||||
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 (['test', 'vs', '0'], {'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)
|
||||
|
||||
def _stale_confirm_poll():
|
||||
# Stand-in for the write's own confirming /device/0 poll racing
|
||||
# against a stale read for the same href -- the device's internal
|
||||
# state hasn't caught up to the write yet, even though it will a
|
||||
# little later.
|
||||
coordinator._observe.apply('/test/vs/0', {'value': 0}, source='poll')
|
||||
return fridge_resources
|
||||
|
||||
with (
|
||||
patch.object(fake, 'subscribe'),
|
||||
patch.object(LocalThingsCoordinator, '_poll_once', side_effect=_stale_confirm_poll),
|
||||
):
|
||||
fake.post = lambda *a, **k: (0x44, b'')
|
||||
await coordinator.async_send_command(bound, 5)
|
||||
|
||||
# The optimistic value is visible right away and survived the stale
|
||||
# confirm-poll race triggered by the refresh above.
|
||||
assert coordinator._cache.get('/test/vs/0') == {'value': 5}
|
||||
|
||||
# The settle window is still open afterward -- sized to outlast the
|
||||
# whole PUT + confirm-poll round trip, not released the moment that
|
||||
# round trip happens to finish.
|
||||
applied = coordinator._observe.apply('/test/vs/0', {'value': 0}, source='observe')
|
||||
assert applied is False
|
||||
assert coordinator._cache.get('/test/vs/0') == {'value': 5}
|
||||
|
||||
|
||||
async def test_second_write_to_same_href_lands_during_first_writes_settle_window(
|
||||
hass: HomeAssistant, mock_entry, mock_coordinator_observe_session,
|
||||
) -> None:
|
||||
"""Regression for issue #9: /course/vs/0 backs several independent
|
||||
washer selects (cycle, detergent quantity, softener quantity, ...)
|
||||
sharing one href. The settle window is now sized to tens of seconds
|
||||
(see test_send_command_survives_stale_confirm_poll), which makes a
|
||||
second, different write to that href landing while the first write's
|
||||
window is still open a routine occurrence -- e.g. a user picking a
|
||||
cycle and then adjusting detergent quantity within the same minute --
|
||||
not a rare edge case. The second write's own optimistic value must
|
||||
still show up immediately; a guard meant to protect optimistic writes
|
||||
from stale device echoes must not itself suppress a later one."""
|
||||
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]
|
||||
|
||||
desc_a = NumberDesc(key='cycle', field='cycle',
|
||||
write_fn=lambda p, rep, href: (['test', 'vs', '0'], {'cycle': p}))
|
||||
desc_b = NumberDesc(key='detergent', field='detergent',
|
||||
write_fn=lambda p, rep, href: (['test', 'vs', '0'], {'detergent': p}))
|
||||
bound_a = BoundEntity(href='/test/vs/0', capability=coordinator.bound[0].capability, desc=desc_a)
|
||||
bound_b = BoundEntity(href='/test/vs/0', capability=coordinator.bound[0].capability, desc=desc_b)
|
||||
|
||||
with patch.object(fake, 'subscribe'):
|
||||
fake.post = lambda *a, **k: (0x44, b'')
|
||||
await coordinator.async_send_command(bound_a, 'Eco')
|
||||
assert coordinator._cache.get('/test/vs/0') == {'cycle': 'Eco'}
|
||||
|
||||
# Still well within the first write's settle window.
|
||||
await coordinator.async_send_command(bound_b, 'High')
|
||||
|
||||
assert coordinator._cache.get('/test/vs/0') == {'cycle': 'Eco', 'detergent': 'High'}
|
||||
|
||||
|
||||
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._cache.get('/some/path') == {'value': 5}
|
||||
|
||||
|
||||
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')
|
||||
|
||||
|
||||
async def test_send_command_bypasses_remote_control_when_option_enabled(
|
||||
hass: HomeAssistant, mock_coordinator_observe_session
|
||||
) -> None:
|
||||
"""Issue #54: some devices accept certain writes even while reporting
|
||||
remote control off, so a user who's confirmed that for their device can
|
||||
opt it out of the block entirely via the options flow
|
||||
(CONF_BYPASS_REMOTE_CONTROL, entry.options -- not entry.data)."""
|
||||
from pytest_homeassistant_custom_component.common import MockConfigEntry
|
||||
|
||||
from custom_components.localthings.registry.discovery import BoundEntity
|
||||
from custom_components.localthings.registry.entities import NumberDesc
|
||||
|
||||
entry = MockConfigEntry(
|
||||
domain=DOMAIN, data=ENTRY_DATA, unique_id=f'localthings_{MOCK_SERIAL}',
|
||||
options={CONF_BYPASS_REMOTE_CONTROL: True},
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
|
||||
fake = mock_coordinator_observe_session
|
||||
await hass.config_entries.async_setup(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
coordinator: LocalThingsCoordinator = hass.data[DOMAIN][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)
|
||||
|
||||
with patch.object(fake, 'subscribe'):
|
||||
fake.post = lambda *a, **k: (0x44, b'')
|
||||
await coordinator.async_send_command(bound, 5)
|
||||
|
||||
assert coordinator._cache.get('/some/path') == {'value': 5}
|
||||
|
||||
@@ -1,12 +1,21 @@
|
||||
"""Tests for the diagnostics platform."""
|
||||
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
|
||||
|
||||
_MANIFEST_VERSION = json.loads(
|
||||
(Path(__file__).parents[2] / 'custom_components' / 'localthings' / 'manifest.json').read_text()
|
||||
)['version']
|
||||
|
||||
|
||||
async def test_diagnostics_shape_and_redaction(
|
||||
hass: HomeAssistant, mock_entry, mock_coordinator_session
|
||||
@@ -19,7 +28,7 @@ async def test_diagnostics_shape_and_redaction(
|
||||
assert diagnostics["device_type"] == 'refrigerator'
|
||||
assert diagnostics["one_ui_version"] == '7.0 Refrigerator'
|
||||
assert diagnostics["unbound_hrefs"] == []
|
||||
assert diagnostics["integration_version"] == '0.1.0'
|
||||
assert diagnostics["integration_version"] == _MANIFEST_VERSION
|
||||
assert diagnostics["smartthings_local_version"]
|
||||
|
||||
resources = diagnostics["resources"]
|
||||
@@ -42,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')
|
||||
@@ -45,6 +67,30 @@ def test_apply_drops_update_during_settle_window():
|
||||
assert mgr.cache.get('/oven/vs/0') == {'a': 1}
|
||||
|
||||
|
||||
def test_apply_optimistic_bypasses_an_in_progress_settle_window():
|
||||
"""Regression for issue #9: /course/vs/0 backs several independent
|
||||
washer selects (cycle, detergent quantity, softener quantity, ...).
|
||||
Picking a second one while the first's settle window is still open
|
||||
(now sized to tens of seconds -- see coordinator._POST_TIMEOUT_S/
|
||||
_POLL_TIMEOUT_S) is a normal sequence, not a stale echo of the first
|
||||
write, and must land in the cache immediately -- not get silently
|
||||
dropped by a guard that exists to protect optimistic writes, not
|
||||
suppress them."""
|
||||
mgr = _manager()
|
||||
mgr.cache.apply_rep('/course/vs/0', {'Course': '1C', 'Detergent': '1'}, source='seed')
|
||||
mgr.mark_write_pending('/course/vs/0', settle_s=30.0)
|
||||
|
||||
result = mgr.apply('/course/vs/0', {'Detergent': '2'}, source='optimistic')
|
||||
|
||||
assert result is True
|
||||
assert mgr.cache.get('/course/vs/0') == {'Course': '1C', 'Detergent': '2'}
|
||||
|
||||
# A poll/sweep/observe update racing in right behind it is still
|
||||
# gated -- the second write's own guard (re-armed by mark_write_pending,
|
||||
# not exercised directly here) is what protects it going forward.
|
||||
assert mgr.apply('/course/vs/0', {'Detergent': '1'}, source='poll') is False
|
||||
|
||||
|
||||
def test_apply_accepts_update_after_settle_window_elapses():
|
||||
mgr = _manager()
|
||||
mgr.mark_write_pending('/oven/vs/0', settle_s=0.05)
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
"""Tests for the ARTIK051_TVTL_18K air-purifier profile (issue #56)."""
|
||||
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 air_purifier
|
||||
from custom_components.localthings.registry.discovery import discover
|
||||
from tests.conftest import _load_device
|
||||
|
||||
|
||||
def _purifier():
|
||||
resources = _load_device('air_purifier')
|
||||
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 = _purifier()
|
||||
bound = discover(resources, reg.capabilities, reg.pattern_capabilities)
|
||||
return flatten(bound, resources)
|
||||
|
||||
|
||||
def test_model_resolves_to_air_purifier_registry():
|
||||
reg, _ = _purifier()
|
||||
assert reg is not None
|
||||
assert reg.name == 'air_purifier'
|
||||
|
||||
|
||||
def test_no_unbound_hrefs():
|
||||
"""Every resource in the issue #56 dump binds or is ignored -- clears the
|
||||
coverage-gap repair."""
|
||||
reg, resources = _purifier()
|
||||
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', 'alarm_code', 'dust', 'fine_dust', 'super_fine_dust',
|
||||
'odor', 'clean_level', 'filter_progress', 'device_active',
|
||||
'diagnosis_status', 'fan_speed_level', 'fan_direction',
|
||||
'display_light', 'operating_mode',
|
||||
):
|
||||
assert key in state, key
|
||||
|
||||
|
||||
def test_air_quality_sensor_values():
|
||||
"""Dust/FineDust/SuperFineDust/Odor/CleanLevel read index 0 of each
|
||||
items[] entry's value list (the raw measurement, per common.sensor_item_value)."""
|
||||
state = _state()
|
||||
assert state['dust'] == 11
|
||||
assert state['fine_dust'] == 9
|
||||
assert state['super_fine_dust'] == 5
|
||||
assert state['odor'] == 0
|
||||
assert state['clean_level'] == 0
|
||||
|
||||
|
||||
def test_filter_progress_reads_named_consumable_item():
|
||||
"""FilterProgress is confirmed (issue #56) to count up as the filter
|
||||
wears -- 100 means fully used and needs replacing, not "brand new"."""
|
||||
assert _state()['filter_progress'] == 100
|
||||
|
||||
|
||||
def test_diagnosis_reuses_dishwasher_capability():
|
||||
"""/diagnosis/vs/0 has the identical field/write contract as
|
||||
dishwasher.DIAGNOSIS, so the by_type registry reuses it directly."""
|
||||
from custom_components.localthings.registry.capabilities import dishwasher
|
||||
reg, _ = _purifier()
|
||||
assert dishwasher.DIAGNOSIS in reg.capabilities['/diagnosis/vs/0']
|
||||
|
||||
|
||||
def test_light_switch_write_contract():
|
||||
"""The display-light switch writes only the changed 'Light_*' token
|
||||
(via laundry.option_write) -- confirmed on real hardware (issue #54)
|
||||
that the device merges by prefix itself, so no read-modify-write of the
|
||||
whole packed /mode/vs/0 options list is needed."""
|
||||
desc = next(e for e in air_purifier.MODE.entities if e.key == 'display_light')
|
||||
rep = {'x.com.samsung.da.options': [
|
||||
'Comode_Off', 'Blooming_0', 'Light_On', 'OptionCode_60282',
|
||||
]}
|
||||
assert desc.rep_fn(rep) is True
|
||||
assert desc.write_fn('Off', rep) == (
|
||||
['mode', 'vs', '0'],
|
||||
{'x.com.samsung.da.options': ['Light_Off']},
|
||||
)
|
||||
|
||||
|
||||
def test_operating_mode_is_a_read_only_diagnostic():
|
||||
"""Comode_* surfaces as a raw diagnostic sensor rather than a select/
|
||||
control -- issue #56's five running-state dumps confirmed it reads 'Off'
|
||||
regardless of the device's actual fan setting, ruling out the original
|
||||
guess that it was the fan-speed selector; its real purpose is still
|
||||
unconfirmed (see the air_purifier.py module docstring)."""
|
||||
operating_mode = next(e for e in air_purifier.MODE.entities if e.key == 'operating_mode')
|
||||
rep = {'x.com.samsung.da.options': ['Comode_Off']}
|
||||
assert operating_mode.rep_fn(rep) == 'Off'
|
||||
assert not hasattr(operating_mode, 'write_fn')
|
||||
|
||||
|
||||
def test_blooming_not_modeled():
|
||||
"""Confirmed (issue #56) to have no corresponding SmartThings app
|
||||
setting -- dropped entirely rather than kept as an unexplained
|
||||
diagnostic."""
|
||||
assert not any(e.key == 'blooming_level' for e in air_purifier.MODE.entities)
|
||||
|
||||
|
||||
def test_airflow_vs_fallback_only_binds_without_generic():
|
||||
"""/airflow/vs/0 is a match_fn fallback -- it must not bind when the
|
||||
OCF-standard /airflow/0 is also present (both are on every dump seen)."""
|
||||
assert air_purifier.AIRFLOW_VS_FALLBACK.match_fn(
|
||||
{}, {'/airflow/0': {'speed': 0, 'direction': 'Off'}},
|
||||
) is False
|
||||
assert air_purifier.AIRFLOW_VS_FALLBACK.match_fn({}, {}) is True
|
||||
@@ -0,0 +1,259 @@
|
||||
"""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_caww_tp2_model_resolves_via_model_fallback():
|
||||
"""A-CAWW-TP2-20-COMMON (issue #52, System AC) reports no oneUiVersion
|
||||
and no '_RAC_'/'_PRAC_' token -- resolved via the '-CAWW-' modelNum
|
||||
fallback added for this device."""
|
||||
reg, _ = _resolve('airconditioner_caww_tp2')
|
||||
assert reg is not None and reg.name == 'airconditioner'
|
||||
|
||||
|
||||
def test_caww_tp2_no_unbound_hrefs():
|
||||
"""Every resource in the issue #52 dump binds or is ignored -- clears
|
||||
the coverage-gap repair. Only new href beyond the existing RAC/PRAC
|
||||
surface is /sac/installationinfo/vs/0 (opaque SAC installation topology,
|
||||
ignored)."""
|
||||
reg, resources = _resolve('airconditioner_caww_tp2')
|
||||
unbound = []
|
||||
discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append)
|
||||
assert unbound == []
|
||||
|
||||
|
||||
def test_caww_tp2_sac_installationinfo_is_ignored():
|
||||
ignored_hrefs = {cap.href for cap in airconditioner.COVERAGE}
|
||||
assert '/sac/installationinfo/vs/0' in ignored_hrefs
|
||||
|
||||
|
||||
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
|
||||
@@ -41,6 +41,16 @@ class TestForDevice:
|
||||
assert isinstance(registry, DeviceRegistry)
|
||||
assert registry.name == 'refrigerator'
|
||||
|
||||
def test_for_device_returns_cooktop_registry(self):
|
||||
registry = for_device('7.0 Cooktop')
|
||||
assert registry is not None
|
||||
assert registry.name == 'cooktop'
|
||||
|
||||
def test_for_device_returns_range_hood_registry(self):
|
||||
registry = for_device('7.0 Range Hood')
|
||||
assert registry is not None
|
||||
assert registry.name == 'range_hood'
|
||||
|
||||
|
||||
class TestDeviceRegistries:
|
||||
"""Tests for device registries themselves."""
|
||||
@@ -154,6 +164,83 @@ class TestForDeviceByModel:
|
||||
assert reg is not None
|
||||
assert reg.name == 'refrigerator'
|
||||
|
||||
def test_refrigerator_rl_series_via_ref_token(self):
|
||||
"""Issue #7: RL38C6B0CWW/EG (a bottom-freezer RL-series fridge, not
|
||||
the RF9000-style french-door this module was originally verified
|
||||
against) reports description/modelNum 'TP1X_REF_21K' -- same
|
||||
internal platform code as any other TP1X-based fridge, so the
|
||||
existing '_REF_' fallback already resolves it correctly."""
|
||||
from custom_components.localthings.registry.by_type import for_device_by_model
|
||||
reg = for_device_by_model(
|
||||
'TP1X_REF_21K|00156941|00050126001611304100000031010000',
|
||||
'TP1X_REF_21K',
|
||||
)
|
||||
assert reg is not None
|
||||
assert reg.name == 'refrigerator'
|
||||
|
||||
def test_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_cooktop_via_legacy_model_description(self):
|
||||
"""Older cooktops identify themselves as ARTIK051_GLOBAL_COOKTOP."""
|
||||
from custom_components.localthings.registry.by_type import for_device_by_model
|
||||
reg = for_device_by_model(
|
||||
'ARTIK051_GB_CT_001|40424141|50000204001211000200000000000000',
|
||||
'ARTIK051_GLOBAL_COOKTOP',
|
||||
)
|
||||
assert reg is not None
|
||||
assert reg.name == 'cooktop'
|
||||
|
||||
def test_range_hood_via_ahd_model(self):
|
||||
from custom_components.localthings.registry.by_type import for_device_by_model
|
||||
reg = for_device_by_model(
|
||||
'AHD-WW-TP1-22-COMMON|20136141|7800006B001713C44D00090001030000',
|
||||
'AHD-WW-TP1-22-COMMON',
|
||||
)
|
||||
assert reg is not None
|
||||
assert reg.name == 'range_hood'
|
||||
|
||||
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_oven_via_oven_token(self):
|
||||
"""Issue #55: a wall oven (NV7000BS/ET5) reports no oneUiVersion and
|
||||
an unrecognized consumer token ('NV'); it falls back to the '-OVEN-'
|
||||
token in modelNum, mirroring the '-RANGE-' fallback for issue #44."""
|
||||
from custom_components.localthings.registry.by_type import for_device_by_model
|
||||
reg = for_device_by_model(
|
||||
'TP1X_DA-KS-OVEN-0107X|40460041|50030018001611020A00000000000000',
|
||||
'NV7000BS/ET5',
|
||||
)
|
||||
assert reg is not None
|
||||
assert reg.name == 'oven'
|
||||
|
||||
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')
|
||||
@@ -162,3 +249,38 @@ class TestForDeviceByModel:
|
||||
def test_empty_inputs_return_none(self):
|
||||
from custom_components.localthings.registry.by_type import for_device_by_model
|
||||
assert for_device_by_model('', '') is None
|
||||
|
||||
|
||||
class TestForDeviceByResources:
|
||||
def test_na9300k_without_one_ui_or_information_is_cooktop(self):
|
||||
from custom_components.localthings.registry.by_type import for_device_by_resources
|
||||
from tests.conftest import _load_device
|
||||
|
||||
reg = for_device_by_resources(_load_device('cooktop'))
|
||||
|
||||
assert reg is not None
|
||||
assert reg.name == 'cooktop'
|
||||
|
||||
def test_unrelated_mode_options_are_not_cooktop(self):
|
||||
from custom_components.localthings.registry.by_type import for_device_by_resources
|
||||
|
||||
resources = {
|
||||
'/mode/vs/0': {
|
||||
'x.com.samsung.da.options': [
|
||||
'DeviceType_SOME_OVEN',
|
||||
'UpperLamp_Off',
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
assert for_device_by_resources(resources) is None
|
||||
|
||||
def test_hood_resource_signature(self):
|
||||
from custom_components.localthings.registry.by_type import for_device_by_resources
|
||||
resources = {
|
||||
'/hood/fanspeed/vs/0': {},
|
||||
'/hood/lamp/vs/0': {},
|
||||
}
|
||||
reg = for_device_by_resources(resources)
|
||||
assert reg is not None
|
||||
assert reg.name == 'range_hood'
|
||||
|
||||
@@ -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,367 @@ 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
|
||||
|
||||
|
||||
class TestMergeOptionsField:
|
||||
"""merge_options_field() is the read side of issue #54's finding: a
|
||||
write only needs to carry the changed token, so the coordinator uses
|
||||
this to keep its optimistic cache entry complete (every sibling option
|
||||
still present) without waiting on a real poll."""
|
||||
|
||||
def test_replaces_matching_prefix(self):
|
||||
cached = ['DeviceType_0167', 'Course_16', 'GMT_04']
|
||||
assert common.merge_options_field(cached, ['Course_1D']) == [
|
||||
'DeviceType_0167', 'Course_1D', 'GMT_04',
|
||||
]
|
||||
|
||||
def test_appends_when_prefix_absent(self):
|
||||
cached = ['DeviceType_0167']
|
||||
assert common.merge_options_field(cached, ['NaturalSteam_On']) == [
|
||||
'DeviceType_0167', 'NaturalSteam_On',
|
||||
]
|
||||
|
||||
def test_merges_multiple_tokens_independently(self):
|
||||
cached = ['DetergentLevelCtrl_1', 'SoftenerLevelCtrl_0', 'GMT_04']
|
||||
merged = common.merge_options_field(cached, ['SoftenerLevelCtrl_2'])
|
||||
assert merged == ['DetergentLevelCtrl_1', 'SoftenerLevelCtrl_2', 'GMT_04']
|
||||
|
||||
def test_handles_missing_cache(self):
|
||||
assert common.merge_options_field(None, ['Course_1D']) == ['Course_1D']
|
||||
|
||||
def test_ignores_malformed_tokens(self):
|
||||
cached = ['Course_16']
|
||||
assert common.merge_options_field(cached, ['nounderscore']) == ['Course_16']
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
"""Tests for the read-only cooktop capability profile."""
|
||||
|
||||
from custom_components.localthings.registry.adapter import flatten
|
||||
from custom_components.localthings.registry.by_type import cooktop
|
||||
from custom_components.localthings.registry.capabilities.cooktop import (
|
||||
COOKTOP_MODE,
|
||||
)
|
||||
from custom_components.localthings.registry.discovery import discover
|
||||
from tests.conftest import _load_device
|
||||
|
||||
|
||||
def _state(resources):
|
||||
bound = discover(
|
||||
resources,
|
||||
cooktop.REGISTRY.capabilities,
|
||||
cooktop.REGISTRY.pattern_capabilities,
|
||||
)
|
||||
return flatten(bound, resources)
|
||||
|
||||
|
||||
def test_real_cooktop_fixture_has_expected_idle_state():
|
||||
state = _state(_load_device('cooktop'))
|
||||
|
||||
assert state['power_state'] is True
|
||||
assert state['any_burner_active'] is False
|
||||
assert state['burner_0_state'] == 'Ready'
|
||||
assert state['burner_5_state'] == 'Ready'
|
||||
assert state['main_timer_state'] == 'Ready'
|
||||
assert state['main_timer_current'] == 0
|
||||
assert state['cloud_connected'] is True
|
||||
assert state['paired_hood_connected'] is False
|
||||
assert state['paired_hood_power'] is False
|
||||
assert state['paired_hood_fan_speed'] == 0
|
||||
assert state['paired_hood_light'] is False
|
||||
|
||||
|
||||
def test_run_operation_marks_any_burner_active():
|
||||
resources = _load_device('cooktop')
|
||||
options = resources['/mode/vs/0']['x.com.samsung.da.options']
|
||||
options[options.index('OperationState3_Ready')] = 'OperationState3_Run'
|
||||
|
||||
state = _state(resources)
|
||||
|
||||
assert state['burner_3_state'] == 'Run'
|
||||
assert state['any_burner_active'] is True
|
||||
|
||||
|
||||
def test_cooktop_profile_has_no_write_functions():
|
||||
"""A local integration must never expose unverified remote ignition."""
|
||||
descriptions = [
|
||||
desc
|
||||
for capabilities in cooktop.REGISTRY.capabilities.values()
|
||||
for capability in capabilities
|
||||
for desc in capability.entities
|
||||
]
|
||||
|
||||
assert descriptions
|
||||
assert all(not hasattr(desc, 'write_fn') or desc.write_fn is None
|
||||
for desc in descriptions)
|
||||
|
||||
|
||||
def test_static_slot_superset_has_headroom_for_other_layouts():
|
||||
keys = {desc.key for desc in COOKTOP_MODE.entities}
|
||||
|
||||
assert {f'burner_{slot}_state' for slot in range(8)} <= keys
|
||||
|
||||
|
||||
def test_variant_slot_outside_na9300k_layout_is_discovered():
|
||||
resources = _load_device('cooktop')
|
||||
resources['/mode/vs/0']['x.com.samsung.da.options'].append(
|
||||
'OperationState7_Ready',
|
||||
)
|
||||
|
||||
state = _state(resources)
|
||||
|
||||
assert state['burner_7_state'] == 'Ready'
|
||||
assert 'burner_2_state' not in state
|
||||
@@ -0,0 +1,137 @@
|
||||
"""Tests for LocalThingsCoordinator.async_raw_write -- the debug-only
|
||||
arbitrary-href write primitive backing the options-flow debug panel
|
||||
(issue #54). It deliberately bypasses the remote-control block and every
|
||||
write_fn/validate_fn, so this only exercises the primitive itself: it
|
||||
POSTs exactly the caller's body, reads the href back, and validates its
|
||||
inputs.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import cbor2
|
||||
import pytest
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ServiceValidationError
|
||||
from pytest_homeassistant_custom_component.common import MockConfigEntry
|
||||
|
||||
from custom_components.localthings.const import (
|
||||
CONF_HOST, CONF_LEAF_CERT_PEM, CONF_LEAF_KEY_PEM, CONF_PORT, DOMAIN,
|
||||
)
|
||||
from custom_components.localthings.coordinator import LocalThingsCoordinator
|
||||
|
||||
ENTRY_DATA = {
|
||||
CONF_HOST: '10.0.0.199',
|
||||
CONF_PORT: 49154,
|
||||
CONF_LEAF_CERT_PEM: '-----BEGIN CERTIFICATE-----\nTEST-LEAF\n-----END CERTIFICATE-----',
|
||||
CONF_LEAF_KEY_PEM: '-----BEGIN PRIVATE KEY-----\nTEST-LEAF-KEY\n-----END PRIVATE KEY-----',
|
||||
}
|
||||
|
||||
|
||||
class _FakeRawWriteSession:
|
||||
"""Stand-in for DtlsCoapSession: records every POST verbatim and
|
||||
answers the follow-up GET with a canned representation -- no real
|
||||
DTLS/network involved."""
|
||||
|
||||
def __init__(self, post_code: int = 0x44, get_rep: dict | None = None):
|
||||
self.post_calls: list[tuple[list[str], bytes]] = []
|
||||
self.get_calls: list[list[str]] = []
|
||||
self._post_code = post_code
|
||||
self._get_rep = {} if get_rep is None else get_rep
|
||||
|
||||
def post(self, path_segs, payload, timeout=None):
|
||||
self.post_calls.append((list(path_segs), payload))
|
||||
return self._post_code, b''
|
||||
|
||||
def get(self, path_segs, timeout=None):
|
||||
self.get_calls.append(list(path_segs))
|
||||
return 0x45, cbor2.dumps(self._get_rep)
|
||||
|
||||
def pace(self):
|
||||
pass
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def coordinator(hass: HomeAssistant) -> LocalThingsCoordinator:
|
||||
entry = MockConfigEntry(
|
||||
domain=DOMAIN, data=ENTRY_DATA, unique_id='localthings_RAWWRITE-TEST',
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
coord = LocalThingsCoordinator(hass, entry)
|
||||
# async_raw_write kicks a full refresh after the write; a real refresh
|
||||
# would try to poll a session that doesn't exist for this unit test, so
|
||||
# replace it with a no-op the same way the reference test setup does.
|
||||
coord.async_request_refresh = AsyncMock()
|
||||
return coord
|
||||
|
||||
|
||||
async def test_raw_write_splits_href_and_posts_exact_body(coordinator) -> None:
|
||||
fake = _FakeRawWriteSession(post_code=0x44, get_rep={'x.field': 'after'})
|
||||
coordinator._session = fake
|
||||
|
||||
body = {'x.com.samsung.da.field': 'value', 'n': 1}
|
||||
code, new_rep = await coordinator.async_raw_write('/course/vs/0', body)
|
||||
|
||||
assert len(fake.post_calls) == 1
|
||||
posted_path, posted_bytes = fake.post_calls[0]
|
||||
assert posted_path == ['course', 'vs', '0']
|
||||
assert cbor2.loads(posted_bytes) == body
|
||||
|
||||
assert code == 0x44
|
||||
assert new_rep == {'x.field': 'after'}
|
||||
coordinator.async_request_refresh.assert_awaited_once()
|
||||
|
||||
|
||||
async def test_raw_write_reads_href_back_for_ground_truth(coordinator) -> None:
|
||||
fake = _FakeRawWriteSession(post_code=0x45, get_rep={'value': 'confirmed'})
|
||||
coordinator._session = fake
|
||||
|
||||
code, new_rep = await coordinator.async_raw_write('/washer/vs/0', {'a': 1})
|
||||
|
||||
assert fake.get_calls == [['washer', 'vs', '0']]
|
||||
assert code == 0x45
|
||||
assert new_rep == {'value': 'confirmed'}
|
||||
|
||||
|
||||
async def test_raw_write_returns_coap_code_from_post_not_get(coordinator) -> None:
|
||||
"""The returned code reflects the write's own response, even though a
|
||||
follow-up GET (which could carry a different code in principle) runs
|
||||
right after it."""
|
||||
fake = _FakeRawWriteSession(post_code=0x80, get_rep={'value': 1})
|
||||
coordinator._session = fake
|
||||
|
||||
code, _ = await coordinator.async_raw_write('/test/vs/0', {'a': 1})
|
||||
|
||||
assert code == 0x80
|
||||
|
||||
|
||||
async def test_raw_write_rejects_empty_dict(coordinator) -> None:
|
||||
with pytest.raises(ServiceValidationError):
|
||||
await coordinator.async_raw_write('/washer/vs/0', {})
|
||||
|
||||
|
||||
async def test_raw_write_rejects_non_dict_payload(coordinator) -> None:
|
||||
with pytest.raises(ServiceValidationError):
|
||||
await coordinator.async_raw_write('/washer/vs/0', ['not', 'a', 'dict']) # type: ignore[arg-type]
|
||||
|
||||
|
||||
async def test_raw_write_rejects_empty_href(coordinator) -> None:
|
||||
with pytest.raises(ServiceValidationError):
|
||||
await coordinator.async_raw_write('', {'a': 1})
|
||||
|
||||
|
||||
async def test_raw_write_rejects_root_href(coordinator) -> None:
|
||||
with pytest.raises(ServiceValidationError):
|
||||
await coordinator.async_raw_write('/', {'a': 1})
|
||||
|
||||
|
||||
async def test_raw_write_validation_errors_do_not_touch_the_session(coordinator) -> None:
|
||||
"""A rejected call must fail before ever reaching the network -- no
|
||||
session needs to be connected at all for validation to run."""
|
||||
assert coordinator._session is None
|
||||
|
||||
with pytest.raises(ServiceValidationError):
|
||||
await coordinator.async_raw_write('/washer/vs/0', {})
|
||||
|
||||
assert coordinator._session is None
|
||||
coordinator.async_request_refresh.assert_not_awaited()
|
||||
@@ -0,0 +1,94 @@
|
||||
"""Tests for LocalThingsCoordinator.async_send_command's optimistic-apply
|
||||
step, specifically for x.com.samsung.da.options[] writes (issue #54).
|
||||
|
||||
The write itself only needs to carry the single changed token -- confirmed
|
||||
on real hardware, the device merges by prefix and evicts the stale token
|
||||
itself. But observe.ObserveManager.apply() does a shallow {**cached, **rep}
|
||||
field merge, so handing it that same minimal single-token body would
|
||||
overwrite the *whole* cached options[] field, wiping every sibling option
|
||||
(other courses/levels/toggles packed into the same array) until the next
|
||||
real poll lands. async_send_command must pre-merge the token into the
|
||||
cached array itself before applying it optimistically, while still POSTing
|
||||
only the minimal body over the wire.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import cbor2
|
||||
import pytest
|
||||
from homeassistant.core import HomeAssistant
|
||||
from pytest_homeassistant_custom_component.common import MockConfigEntry
|
||||
|
||||
from custom_components.localthings.const import (
|
||||
CONF_HOST, CONF_LEAF_CERT_PEM, CONF_LEAF_KEY_PEM, CONF_PORT, DOMAIN,
|
||||
)
|
||||
from custom_components.localthings.coordinator import LocalThingsCoordinator
|
||||
from custom_components.localthings.registry.capabilities import laundry
|
||||
from custom_components.localthings.registry.discovery import BoundEntity
|
||||
|
||||
ENTRY_DATA = {
|
||||
CONF_HOST: '10.0.0.199',
|
||||
CONF_PORT: 49154,
|
||||
CONF_LEAF_CERT_PEM: '-----BEGIN CERTIFICATE-----\nTEST-LEAF\n-----END CERTIFICATE-----',
|
||||
CONF_LEAF_KEY_PEM: '-----BEGIN PRIVATE KEY-----\nTEST-LEAF-KEY\n-----END PRIVATE KEY-----',
|
||||
}
|
||||
|
||||
|
||||
class _FakeSendSession:
|
||||
def __init__(self):
|
||||
self.post_calls: list[tuple[list[str], bytes]] = []
|
||||
|
||||
def post(self, path_segs, payload, timeout=None):
|
||||
self.post_calls.append((list(path_segs), payload))
|
||||
return 0x44, b''
|
||||
|
||||
def pace(self):
|
||||
pass
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def coordinator(hass: HomeAssistant) -> LocalThingsCoordinator:
|
||||
entry = MockConfigEntry(
|
||||
domain=DOMAIN, data=ENTRY_DATA, unique_id='localthings_SENDCMD-TEST',
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
coord = LocalThingsCoordinator(hass, entry)
|
||||
coord.async_request_refresh = AsyncMock()
|
||||
coord._session = _FakeSendSession()
|
||||
return coord
|
||||
|
||||
|
||||
async def test_options_write_posts_only_the_changed_token(coordinator) -> None:
|
||||
href = '/course/vs/0'
|
||||
coordinator._observe.apply(href, {
|
||||
'x.com.samsung.da.options': ['DeviceType_0167', 'Course_16', 'GMT_04'],
|
||||
}, source='poll')
|
||||
|
||||
desc = laundry.cycle_select(translation_key='dryer_cycle', icon='x')
|
||||
bound = BoundEntity(href=href, capability=None, desc=desc)
|
||||
|
||||
await coordinator.async_send_command(bound, '1D')
|
||||
|
||||
posted_path, posted_bytes = coordinator._session.post_calls[0]
|
||||
assert posted_path == ['course', 'vs', '0']
|
||||
assert cbor2.loads(posted_bytes) == {'x.com.samsung.da.options': ['Course_1D']}
|
||||
|
||||
|
||||
async def test_options_write_optimistic_cache_keeps_sibling_tokens(coordinator) -> None:
|
||||
"""The regression this guards: applying the minimal wire body straight
|
||||
to the cache would wipe DeviceType_0167/GMT_04 until the next poll."""
|
||||
href = '/course/vs/0'
|
||||
coordinator._observe.apply(href, {
|
||||
'x.com.samsung.da.options': ['DeviceType_0167', 'Course_16', 'GMT_04'],
|
||||
}, source='poll')
|
||||
|
||||
desc = laundry.cycle_select(translation_key='dryer_cycle', icon='x')
|
||||
bound = BoundEntity(href=href, capability=None, desc=desc)
|
||||
|
||||
await coordinator.async_send_command(bound, '1D')
|
||||
|
||||
cached = coordinator._cache.get(href)
|
||||
assert cached['x.com.samsung.da.options'] == [
|
||||
'DeviceType_0167', 'Course_1D', 'GMT_04',
|
||||
]
|
||||
@@ -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,47 @@
|
||||
"""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'],
|
||||
}
|
||||
assert body == {'x.com.samsung.da.options': ['Course_90']}
|
||||
|
||||
|
||||
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
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user